Last active
March 24, 2025 01:14
-
-
Save riceissa/1ead1b9881ffbb48793565ce69d7dbdd to your computer and use it in GitHub Desktop.
my current understanding of Anki's spacing algorithm
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
""" | |
This is my understanding of the Anki scheduling algorithm, which I mostly | |
got from watching https://www.youtube.com/watch?v=lz60qTP2Gx0 | |
and https://www.youtube.com/watch?v=1XaJjbCSXT0 | |
and from reading | |
https://faqs.ankiweb.net/what-spaced-repetition-algorithm.html | |
There is also https://github.com/dae/anki/blob/master/anki/sched.py but I find | |
it really hard to understand. | |
Things I don't bother to implement here: the random fudge factor (that Anki | |
uses to decorrelate cards that were added on the same day and have the same | |
responses throughout their history), leech tracking, checking if a card from | |
the same notes has been reviewed already that day, delay in response (i.e. I | |
assume all cards are reviewed exactly on the day they are due). | |
Update (2023-12-15): Please note that the Anki review algorithm has possibly | |
changed in many ways since the time when I wrote this program (although I | |
believe that Anki still uses SM2 by default, so the basic concepts should | |
still be the same as what is shown below). I have sadly not had the time | |
or energy to keep up with the latest changes. In particular, Anki now | |
supports FSRS instead of the SM2 algorithm (which is the algorithm | |
below); FSRS is not covered at all below. | |
""" | |
# "New Cards" tab | |
NEW_STEPS = [1, 10] # in minutes | |
GRADUATING_INTERVAL = 1 # in days | |
EASY_INTERVAL = 4 # in days | |
STARTING_EASE = 250 # in percent | |
# "Reviews" tab | |
EASY_BONUS = 130 # in percent | |
INTERVAL_MODIFIER = 100 # in percent | |
MAXIMUM_INTERVAL = 36500 # in days | |
# "Lapses" tab | |
LAPSES_STEPS = [10] # in minutes | |
NEW_INTERVAL = 70 # in percent | |
MINIMUM_INTERVAL = 1 # in days | |
class Card: | |
def __init__(self): | |
self.status = 'learning' # can be 'learning', 'learned', or 'relearning' | |
self.steps_index = 0 | |
self.ease_factor = STARTING_EASE | |
self.interval = None | |
def __repr__(self): | |
return "Card[%s; steps_idx=%s; ease=%s; interval=%s]" % (self.status, | |
self.steps_index, | |
self.ease_factor, | |
str(self.interval)) | |
def schedule(card, response): | |
'''response is one of "again", "hard", "good", or "easy" | |
returns a result in days''' | |
if card.status == 'learning': | |
# for learning cards, there is no "hard" response possible | |
if response == "again": | |
card.steps_index = 0 | |
return minutes_to_days(NEW_STEPS[card.steps_index]) | |
elif response == "good": | |
card.steps_index += 1 | |
if card.steps_index < len(NEW_STEPS): | |
return minutes_to_days(NEW_STEPS[card.steps_index]) | |
else: | |
# we have graduated! | |
card.status = 'learned' | |
card.interval = GRADUATING_INTERVAL | |
return card.interval | |
elif response == "easy": | |
card.status = 'learned' | |
card.interval = EASY_INTERVAL | |
return EASY_INTERVAL | |
else: | |
raise ValueError("you can't press this button / we don't know how to deal with this case") | |
elif card.status == 'learned': | |
if response == "again": | |
card.status = 'relearning' | |
card.steps_index = 0 | |
card.ease_factor = max(130, card.ease_factor - 20) | |
card.interval = max(MINIMUM_INTERVAL, card.interval * NEW_INTERVAL/100) | |
return minutes_to_days(LAPSES_STEPS[0]) | |
elif response == "hard": | |
card.ease_factor = max(130, card.ease_factor - 15) | |
card.interval = card.interval * 1.2 * INTERVAL_MODIFIER/100 | |
return min(MAXIMUM_INTERVAL, card.interval) | |
elif response == "good": | |
card.interval = (card.interval * card.ease_factor/100 | |
* INTERVAL_MODIFIER/100) | |
return min(MAXIMUM_INTERVAL, card.interval) | |
elif response == "easy": | |
card.ease_factor += 15 | |
card.interval = (card.interval * card.ease_factor/100 | |
* INTERVAL_MODIFIER/100 * EASY_BONUS/100) | |
return min(MAXIMUM_INTERVAL, card.interval) | |
else: | |
raise ValueError("you can't press this button / we don't know how to deal with this case") | |
elif card.status == 'relearning': | |
if response == "again": | |
card.steps_index = 0 | |
return minutes_to_days(LAPSES_STEPS[0]) | |
elif response == "good": | |
card.steps_index += 1 | |
if card.steps_index < len(LAPSES_STEPS): | |
return minutes_to_days(LAPSES_STEPS[card.steps_index]) | |
else: | |
# we have re-graduated! | |
card.status = 'learned' | |
# we don't modify the interval here because that was already done when | |
# going from 'learned' to 'relearning' | |
return card.interval | |
else: | |
raise ValueError("you can't press this button / we don't know how to deal with this case") | |
def minutes_to_days(minutes): | |
return minutes / (60 * 24) | |
def human_friendly_time(days): | |
if not days: | |
return days | |
if days < 1: | |
return str(round(days * 24 * 60, 2)) + " minutes" | |
elif days < 30: | |
return str(round(days, 2)) + " days" | |
elif days < 365: | |
return str(round(days / (365.25 / 12), 2)) + " months" | |
else: | |
return str(round(days / 365.25, 2)) + " years" | |
card1 = Card() | |
# responses = ["good", "good", "good", "again", "good", "good", "good"] | |
responses = ["good"] * 10 | |
for r in responses: | |
print(str(card1) + " [%s]" % r, end="→ ") | |
t = schedule(card1, r) | |
print(human_friendly_time(t), card1) |
I developed a new spacing algorithm for Anki. Maybe you will be interested in it: https://github.com/open-spaced-repetition/fsrs4anki
@L-M-Sherlock I saw that Anki now supports FSRS by default. I've sadly not had any time to look into FSRS or to use it. I've added a note at the top of the script mentioning this.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@grandinquisitor Thanks, fixed!