In a nutshell
The code below will loop through each of your deck options groups, find the success rate for post-lapse reviews, and attempt to adjust the Lapse New Interval percentage to hit an 85% average success rate.
Background
Previously, I wrote about using efficient settings for the new interval after a lapse.
See: New Interval After a Lapse in Anki
In deck settings set the New Interval under the Lapses tab to make the new interval of forgotten cards a percentage of their previous interval. Ideally, play with this percentage until you have an 80-90% success rate on re-learned cards.
I mentioned that I had some code to automate this, but I’d have to separate it from a bunch of other code in a big messy addon I made.
Well, I finally got around to doing that.
Details
- Adjustments only happen on profile load (startup).
- You are prompted to accept changes or not.
- Deck options groups with a Lapse New Interval setting of 0 (the default) will remain unchanged.
- Currently, all decks get the 85% target; you can’t set individual targets for different options groups, but that would be a pretty easy change to make (I may do it later).
- No changes are made unless a minimum of new reviews are found (set to 100; configurable).
- We keep track of the last time we changed a setting for each options group and then only make changes when we have 100 more reviews to analyse.
- On first run, we analyze all post-lapse reviews; after that we look just at the last 100.
Adjustment Algorithm
If your current success rate is 20% higher, than the target rate (85%), we make the Lapse New Interval 20% longer; if our success rate is 20% lower, we make the Lapse New Interval 20% shorter… simple, but should work OK.
The Code
N.B. I won’t be posting this code to ankiweb any time soon. This is very bare-bones and not very user-friendly and I don’t feel like putting in the work to make it idiot-proof… I mean user-friendly enough for noobs.
Save this code as autoLapseNewInterval.py
or something like that in your Anki addons directory.
UPDATE 2018-12-29: I’ve had a report of this working in Anki 2.1. You’ll need to save the file as __init__.py
in a directory called autoLapseNewInterval in your Anki 2.1 addons directory and possibly comment out one of the lines in the code (see comments within code below).
# Auto-Lapse-New-Interval
# Anki 2 plugin
# Author: EJS
# Version 0.1
# License: GNU GPL v3 <www.gnu.org/licenses/gpl.html>
from __future__ import division
import datetime, time, math, json, os
from anki.hooks import wrap, addHook
from aqt import *
from aqt.main import AnkiQt
from anki.utils import intTime
# card_sample_size
# Number of cards needed for adequate sample size
# we won't update settings
# unless we have at least this many cards
# to go off of. This prevents us from changing initial
# settings based on a small set of data.
card_sample_size = 100
defaultTSR = 85 #Default target success rate (as a percentage)
# ------------Nothing to edit below--------------------------------#
rev_lapses = {}
# CONFIG file to store last time options settings were adjusted
previous = {} # Record of previous adjustment dates
LapseConffile = os.path.join(os.path.dirname(os.path.realpath(__file__)), "autoLapseNewInterval.data")
# NB The line below may not work with Anki 2.1
# Try commenting it out if you get errors.
LapseConffile = LapseConffile.decode(sys.getfilesystemencoding())
if os.path.exists(LapseConffile):
previous = json.load(open(LapseConffile, 'r'))
def save_lapseStats():
json.dump(previous, open(LapseConffile, 'w'))
# Find settings group ID
def find_settings_group_id(name):
dconf = mw.col.decks.dconf
for k in dconf:
if dconf[k]['name'] == name:
return k
return False
# Find decks in settings group
def find_decks_in_settings_group(group_id):
members = []
decks = mw.col.decks.decks
for d in decks:
if 'conf' in decks[d] and int(decks[d]['conf']) == int(group_id):
members.append(d)
return members
# NOTE:
# from anki.utils import intTime
# intTime function returns seconds since epoch UTC multipiled by an optional scaling parameter; defaults to 1.
# Main Function
def adjLapse_all(silent=True):
eval_lapsed_newIvl(silent)
# Startup Function
def adjLapse_startup():
global previous
profile = aqt.mw.pm.name
if profile not in previous:
previous[profile] = {}
adjLapse_all(True) # Includes functions below
#Run when profile is loaded, save stats when unloaded
addHook("profileLoaded", adjLapse_startup)
addHook("unloadProfile", save_lapseStats)
#Find the success rate for a deck
def deck_lapsed_success_rate(deck_ids, lapsed_rev_records):
lapsed_query = """select count() from
(select * from
(select min(b.id) as bid, a.id as aid, a.ivl, a.lastIvl as aLastIvl, b.ivl as bIvl, b.lastIvl as bLastIvl, b.ease
from revlog as a, revlog as b, cards as c
where
a.type = 1
and a.ease = 1
and a.cid = b.cid
and a.cid = c.id
and ("""
i = 0
for d in deck_ids:
if i == 0:
lapsed_query += "c.did = %s" % d
i = 1
else:
lapsed_query += " or c.did = %s" % d
lapsed_query += """) and b.type = 1
and b.id > a.id
group by a.id
order by a.id) as s
where bLastIvl < aLastIvl
limit %s) as o
where ease > 1""" % lapsed_rev_records
lapsed_successes = mw.col.db.scalar(lapsed_query)
lapsed_success_rate = int(100 * lapsed_successes / lapsed_rev_records)
return lapsed_success_rate
#Find number of lapsed review records in deck
def lapsed_records_in_deck(deck_id, from_date):
lapsed_query = """select count() from
(select min(b.id) as bid, a.id as aid, a.ivl, a.lastIvl as aLastIvl, b.ivl as bIvl, b.lastIvl as bLastIvl, b.ease
from revlog as a, revlog as b, cards as c
where
a.id > %s
and a.type = 1
and a.ease = 1
and a.cid = b.cid
and a.cid = c.id
and c.did = %s
and b.type = 1
and b.id > a.id
group by a.id
order by a.id) as s
where bLastIvl < aLastIvl""" % (from_date, deck_id)
lapsed_records = mw.col.db.scalar(lapsed_query)
if lapsed_records:
return lapsed_records
else:
return 0
# Calculate the Lapse success rate of an options group
def og_lapsed_success_rate(name, min_look_back):
profile = aqt.mw.pm.name
creation_date = (int(mw.col.crt) * 1000)
deck_ids = []
lapsed_rev_records = 0
group_id = find_settings_group_id(name)
#add profile to previous dates dictionary
if 'lapsed' not in previous[profile][name]:
previous[profile][name]['lapsed'] = creation_date
from_date = previous[profile][name]['lapsed']
#exit if it has been less than 24 hours since last adjustment
cur_date = intTime(1000)
if (cur_date - from_date) < (24 * 60 * 60 * 1000):
#utils.showInfo("Waiting 24 hours to adjust Lapse Next Interval for %s." % name)
return False, False
if group_id:
# Find decks and cycle through
decks = find_decks_in_settings_group(group_id)
for d in decks:
deck_ids.append(d)
lapsed_rev_records += lapsed_records_in_deck(d, from_date)
# make sure we have enough records in review to
if lapsed_rev_records >= min_look_back:
lapsed_success_rate = deck_lapsed_success_rate(deck_ids, lapsed_rev_records) #look back over all records since last adjustment
else:
lapsed_success_rate = False
return lapsed_success_rate, lapsed_rev_records
else:
return False, False
#Adjust the lapsed new interval setting for an options group
def adj_lapsed_newIvl(group_id, silent=True):
global previous
profile = aqt.mw.pm.name
tsr = defaultTSR
# Return if target success rate is false or 0.
if not tsr: return
#find name of group
dconf = mw.col.decks.dconf
name = dconf[group_id]['name']
if name not in previous[profile]:
previous[profile][name] = {}
cur_LNIvl = float(mw.col.decks.dconf[group_id]['lapse']['mult'])
lapsed_success_rate, lapsed_rev_records = og_lapsed_success_rate(name, card_sample_size)
# Returns False, False if we don't have enough review records since last time.
if lapsed_success_rate and lapsed_rev_records and lapsed_success_rate != tsr:
target_rate = float(tsr) / 100
cur_rate = float(lapsed_success_rate) / 100
#Simplistic ratio adjustment
new_LNIvl = round(cur_LNIvl * cur_rate/target_rate, 2)
if new_LNIvl > 1:
new_LNIvl = 1
if utils.askUser("%s"
"\n\nLapsed Card Success Rate: %s"
"\nTarget Success Rate: %s"
"\nCurrent lapsed new interval: %s"
"\nSuggested interval: %s"
"\n\nAccept new Lapsed new interval?" % (
name, cur_rate, target_rate, cur_LNIvl, new_LNIvl)):
# make changes
mw.col.decks.dconf[group_id]['lapse']['mult'] = new_LNIvl
mw.col.decks.save(mw.col.decks.dconf[group_id])
previous[profile][name]['lapsed'] = intTime(1000)
#utils.showInfo("Updating lapsed new interval currently disabled")
else:
if not silent:
utils.showInfo("Lapsed New Interval\n\nNot enough records for options group %s" % name)
def eval_lapsed_newIvl(silent=False):
#find all deck options groups
dconf = mw.col.decks.dconf
for og in dconf:
adj_lapsed_newIvl(og, silent)