Spaced Repetition SM2
Spaced Repetition SM2
Este é o código-fonte em javascript do algoritmo spaced repetition. Segundo o Wikipédia. Futuramente irei refatorá-lo para as linguagens JAVA e C++.
Repetição espaçada é uma técnica de aprendizado baseada em evidências usualmente utilizada com flashcards (cartões de memorização). Flashcards recém-introduzidos e mais difíceis são mostrados com maior frequência, enquanto os mais antigos e menos difíceis são mostrados com menor frequência para explorar o efeito de espaçamento. Foi comprovado que o uso de repetição espaçada aumenta a taxa de aprendizado
Fonte: https://pt.wikipedia.org/wiki/Repetição_espaçada
Este código foi obtido a partir de um plugin do obsidian no github e serve de ponto de partida para compreender o funcionamento deste algoritmo. Veja mais a frente os casos de teste.
Fonte: https://www.stephenmwangi.com/obsidian-spaced-repetition/flashcards/#single-line-basic-remnote-style
Algoritmo: https://www.stephenmwangi.com/obsidian-spaced-repetition/algorithms/
(It's the same as that used for flashcards - apart from the PageRanks)
- The algorithm is a variant of Anki's algorithm which is based on the SM-2 algorithm.
- It supports ternary reviews i.e. a concept is either hard, good, or easy at the time of review.
- initial ease is weighted (using max_link_factor) depending on the average ease of linked notes, note importance, and the base ease.
if link_count > 0: initial_ease = (1 - link_contribution) * base_ease + link_contribution * average_easelink_contribution = max_link_factor * min(1.0, log(link_count + 0.5) / log(64))(cater for uncertainty)
- The importance of the different concepts/notes is determined using the PageRank algorithm (not all notes are created equal xD)
- On most occasions, the most fundamental concepts/notes have higher importance
- If the user reviews a concept/note as:
- easy, the ease increases by
20and the interval changes toold_interval * new_ease / 100 * 1.3(the 1.3 is the easy bonus) - good, the ease remains unchanged and the interval changes to
old_interval * old_ease / 100 - hard, the ease decreases by
20and the interval changes toold_interval * 0.5- The
0.5can be modified in settings minimum ease = 130
- The
- For
8or more days:interval += random_choice({-fuzz, 0, +fuzz})- where
fuzz = ceil(0.05 * interval) - Anki docs: > "[...] Anki also applies a small amount of random “fuzz” to prevent cards that were introduced at the same time and given the same ratings from sticking together and always coming up for review on the same day."
- where
- easy, the ease increases by
- The scheduling information is stored in the YAML front matter
export enum ReviewResponse {
Easy,
Good,
Hard,
Reset,
}
export interface SRSettings {
// algorithm
baseEase: number;
lapsesIntervalChange: number;
easyBonus: number;
maximumInterval: number;
maxLinkFactor: number;
}
export const DEFAULT_SETTINGS: SRSettings = {
// algorithm
baseEase: 250,
lapsesIntervalChange: 0.5,
easyBonus: 1.3,
maximumInterval: 36525,
maxLinkFactor: 1.0,
};
export function schedule(
response: ReviewResponse,
interval: number,
ease: number,
delayBeforeReview: number,
settingsObj: SRSettings,
dueDates?: Record<number, number>,
): Record<string, number> {
delayBeforeReview = Math.max(0, Math.floor(delayBeforeReview / (24 * 3600 * 1000))); // 1 dia
if (response === ReviewResponse.Easy) {
ease += 20;
interval = ((interval + delayBeforeReview) * ease) / 100;
interval *= settingsObj.easyBonus;
} else if (response === ReviewResponse.Good) {
interval = ((interval + delayBeforeReview / 2) * ease) / 100;
} else if (response === ReviewResponse.Hard) {
ease = Math.max(130, ease - 20);
interval = Math.max(
1,
* (interval + delayBeforeReview / 4) * settingsObj.lapsesIntervalChange,
);
}
// replaces random fuzz with load balancing over the fuzz interval
if (dueDates !== undefined) {
interval = Math.round(interval);
if (!Object.prototype.hasOwnProperty.call(dueDates, interval)) {
dueDates[interval] = 0;
} else {
// disable fuzzing for small intervals
if (interval > 4) {
let fuzz = 0;
if (interval < 7) fuzz = 1;
else if (interval < 30) fuzz = Math.max(2, Math.floor(interval * 0.15));
else fuzz = Math.max(4, Math.floor(interval * 0.05));
const originalInterval = interval;
outer: for (let i = 1; i <= fuzz; i++) {
for (const ivl of [originalInterval - i, originalInterval + i]) {
if (!Object.prototype.hasOwnProperty.call(dueDates, ivl)) {
dueDates[ivl] = 0;
interval = ivl;
break outer;
}
if (dueDates[ivl] < dueDates[interval]) interval = ivl;
}
}
}
}
dueDates[interval]++;
}
interval = Math.min(interval, settingsObj.maximumInterval);
return { interval: Math.round(interval * 10) / 10, ease };
}
Quando nos deparamos com um código que pouco entendemos, o melhor lugar para iniciar a análise são os casos de testes unitário. Veja abaixo alguns deles.
test("Test reviewing with default settings", () => {
expect(
schedule(ReviewResponse.Easy, 1, DEFAULT_SETTINGS.baseEase, 0, DEFAULT_SETTINGS, {}),
).toEqual({
ease: DEFAULT_SETTINGS.baseEase + 20,
interval: 4,
});
expect(
schedule(ReviewResponse.Good, 1, DEFAULT_SETTINGS.baseEase, 0, DEFAULT_SETTINGS, {}),
).toEqual({
ease: DEFAULT_SETTINGS.baseEase,
interval: 3,
});
expect(
schedule(ReviewResponse.Hard, 1, DEFAULT_SETTINGS.baseEase, 0, DEFAULT_SETTINGS, {}),
).toEqual({
ease: DEFAULT_SETTINGS.baseEase - 20,
interval: 1,
});
});
test("Test reviewing with default settings & delay", () => {
const delay = 2 * 24 * 3600 * 1000; // two day delay
expect(
schedule(ReviewResponse.Easy, 10, DEFAULT_SETTINGS.baseEase, delay, DEFAULT_SETTINGS, {}),
).toEqual({
ease: DEFAULT_SETTINGS.baseEase + 20,
interval: 42,
});
expect(
schedule(ReviewResponse.Good, 10, DEFAULT_SETTINGS.baseEase, delay, DEFAULT_SETTINGS, {}),
).toEqual({
ease: DEFAULT_SETTINGS.baseEase,
interval: 28,
});
expect(
schedule(ReviewResponse.Hard, 10, DEFAULT_SETTINGS.baseEase, delay, DEFAULT_SETTINGS, {}),
).toEqual({
ease: DEFAULT_SETTINGS.baseEase - 20,
interval: 5,
});
});
test("Test load balancing, small interval (load balancing disabled)", () => {
const dueDates = {
0: 1,
1: 1,
2: 1,
3: 4,
};
expect(
schedule(ReviewResponse.Good, 1, DEFAULT_SETTINGS.baseEase, 0, DEFAULT_SETTINGS, dueDates),
).toEqual({
ease: DEFAULT_SETTINGS.baseEase,
interval: 3,
});
expect(dueDates).toEqual({
0: 1,
1: 1,
2: 1,
3: 5,
});
});
test("Test load balancing", () => {
// interval < 7
let dueDates: Record<number, number> = {
5: 2,
};
expect(
schedule(ReviewResponse.Good, 2, DEFAULT_SETTINGS.baseEase, 0, DEFAULT_SETTINGS, dueDates),
).toEqual({
ease: DEFAULT_SETTINGS.baseEase,
interval: 4,
});
expect(dueDates).toEqual({
4: 1,
5: 2,
});
// 7 <= interval < 30
dueDates = {
25: 2,
};
expect(
schedule(ReviewResponse.Good, 10, DEFAULT_SETTINGS.baseEase, 0, DEFAULT_SETTINGS, dueDates),
).toEqual({
ease: DEFAULT_SETTINGS.baseEase,
interval: 24,
});
expect(dueDates).toEqual({
24: 1,
25: 2,
});
// interval >= 30
dueDates = {
2: 5,
59: 8,
60: 9,
61: 3,
62: 5,
63: 4,
64: 4,
65: 8,
66: 2,
67: 10,
};
expect(
schedule(ReviewResponse.Good, 25, DEFAULT_SETTINGS.baseEase, 0, DEFAULT_SETTINGS, dueDates),
).toEqual({
ease: DEFAULT_SETTINGS.baseEase,
interval: 66,
});
expect(dueDates).toEqual({
2: 5,
59: 8,
60: 9,
61: 3,
62: 5,
63: 4,
64: 4,
65: 8,
66: 3,
67: 10,
});
});
test("Test textInterval - desktop", () => {
expect(textInterval(1, false)).toEqual("1 day(s)");
expect(textInterval(41, false)).toEqual("1.3 month(s)");
expect(textInterval(366, false)).toEqual("1 year(s)");
expect(textInterval(1000, false)).toEqual("2.7 year(s)");
});
test("Test textInterval - mobile", () => {
expect(textInterval(1, true)).toEqual("1d");
expect(textInterval(41, true)).toEqual("1.3m");
expect(textInterval(366, true)).toEqual("1y");
expect(textInterval(1000, true)).toEqual("2.7y");
});
test("Test new cards", () => {
expect(textInterval(undefined, false)).toEqual("New");
});