Skip to content

Instantly share code, notes, and snippets.

@riceissa
Last active December 15, 2023 09:36
Show Gist options
  • Save riceissa/1ead1b9881ffbb48793565ce69d7dbdd to your computer and use it in GitHub Desktop.
Save riceissa/1ead1b9881ffbb48793565ce69d7dbdd to your computer and use it in GitHub Desktop.
my current understanding of Anki's spacing algorithm
"""
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)
@grandinquisitor
Copy link

there's a spelling inconsistency: LAPSE_STEPS vs LAPSES_STEPS

@riceissa
Copy link
Author

@grandinquisitor Thanks, fixed!

@L-M-Sherlock
Copy link

I developed a new spacing algorithm for Anki. Maybe you will be interested in it: https://github.com/open-spaced-repetition/fsrs4anki

@riceissa
Copy link
Author

@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