-
-
Notifications
You must be signed in to change notification settings - Fork 20
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Make simulate iterate by card instead of by day. #235
Make simulate iterate by card instead of by day. #235
Conversation
It's a good idea to make simulate iterate by card. However, it also makes it impossible to introduce any review sort order into simulation, doesn't it? |
Good point. Hopefully by using a priority queue and iterating through the cards in the order that they are reviewed, changing the review priorities will be possible. |
It's impossible that the number of reviews in the first day is larger than zero without any existing cards.
|
src/optimal_retention.rs
Outdated
let offset = first_rating_offsets[rating - 1]; | ||
let stability = | ||
stability_short_term(w, w[rating - 1], offset, first_session_lens[rating - 1]); | ||
let day = i / learn_limit; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
learn_limit
is usize::MAX
when max_cost_perday
is enable. The code doesn't take it into account.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this was fixed by this now that learn cards are reviewed in the main loop.
fsrs-rs/src/optimal_retention.rs
Lines 228 to 235 in 59798eb
if (review_cnt_per_day[day_index] + 1 > review_limit) | |
|| (!not_learn && learn_cnt_per_day[day_index] + 1 > learn_limit) | |
|| (cost_per_day[day_index] + fail_cost > max_cost_perday) | |
{ | |
card.due += 1.; | |
card_priorities.change_priority(&card_index, card_priority(card, !not_learn)); | |
continue; | |
} |
Cards will all still be on day 0 in the queue but will be pushed until days which low cost enough.
src/optimal_retention.rs
Outdated
for (i, card) in cards.iter().enumerate() { | ||
card_priorities.push(i, card_priority(card)); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If the card hasn't been learned, it shouldn't be pushed here, right?
src/optimal_retention.rs
Outdated
let retrievability = power_forgetting_curve(delta_t, card.stability); | ||
|
||
// Create 'forget' mask | ||
let forget = Zip::from(&rand_slice) | ||
.and(&retrievability) | ||
.map_collect(|&rand_val, &retriev_val| rand_val > retriev_val); | ||
let forget = !rng.gen_bool(retrievability as f64); | ||
|
||
// Sample 'rating' for 'need_review' entries | ||
let mut ratings = Array1::zeros(deck_size); | ||
izip!(&mut ratings, &need_review, &forget) | ||
.filter(|(_, &condition, _)| condition) | ||
.for_each(|(rating, _, forget)| { | ||
*rating = if *forget { | ||
1 | ||
} else { | ||
review_rating_choices[review_rating_dist.sample(&mut rng)] | ||
}; | ||
}); | ||
|
||
// Update 'cost' column based on 'need_review', 'forget' and 'ratings' | ||
izip!(&mut cost, &need_review, &forget, &ratings) | ||
.filter(|(_, &need_review_flag, _, _)| need_review_flag) | ||
.for_each(|(cost, _, &forget_flag, &rating)| { | ||
*cost = if forget_flag { | ||
review_costs[0] * loss_aversion | ||
} else { | ||
review_costs[rating - 1] | ||
} | ||
}); | ||
let rating = if forget { | ||
1 | ||
} else { | ||
review_rating_choices[review_rating_dist.sample(&mut rng)] | ||
}; | ||
|
||
//dbg!(&card, &rating); | ||
|
||
// Calculate cumulative sum of 'cost' | ||
let mut cum_sum = Array1::<f32>::zeros(deck_size); | ||
cum_sum[0] = cost[0]; | ||
for i in 1..deck_size { | ||
cum_sum[i] = cum_sum[i - 1] + cost[i]; | ||
// Update 'cost' based on 'forget' and 'rating' | ||
let cost = if not_learn { | ||
if forget { | ||
review_costs[0] * loss_aversion | ||
} else { | ||
review_costs[rating - 1] | ||
} | ||
} else { | ||
learn_costs[rating - 1] | ||
}; | ||
|
||
// Wait until a day which is available | ||
while day_index < learn_span && cost_per_day[day_index] + cost > max_cost_perday { | ||
day_index += 1; | ||
} | ||
if day_index >= learn_span { | ||
card_priorities.pop(); | ||
continue; | ||
} | ||
|
||
// Create 'true_review' mask based on 'need_review' and 'cum_sum' and 'review_limit' | ||
let mut review_count = 0; | ||
let true_review = | ||
Zip::from(&need_review) | ||
.and(&cum_sum) | ||
.map_collect(|&need_review_flag, &cum_cost| { | ||
if need_review_flag { | ||
review_count += 1; | ||
} | ||
need_review_flag | ||
&& (cum_cost <= max_cost_perday) | ||
&& (review_count <= review_limit) | ||
}); | ||
|
||
let need_learn = old_due.mapv(|x| x == learn_span as f32); | ||
// Update 'cost' column based on 'need_learn' | ||
izip!(&mut cost, &need_learn, &init_ratings) | ||
.filter(|(_, &need_learn_flag, _)| need_learn_flag) | ||
.for_each(|(cost, _, &rating)| { | ||
*cost = learn_costs[rating - 1]; | ||
}); | ||
|
||
cum_sum[0] = cost[0]; | ||
for i in 1..deck_size { | ||
cum_sum[i] = cum_sum[i - 1] + cost[i]; | ||
let retrievability = | ||
power_forgetting_curve(day_index as f32 - card.last_date, card.stability); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The rating is sampled from the first retrievability. However, the second retrievability is the real one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I ran into problems with this because
- The rating depends on the retrievability
- The cost depends on the rating
- The cost determines if the card has a cost too high to fit in the day (it might change the day)
- The day determines the retrievability
Would it be ok to just assume the card is forgotten when checking whether the cost is too much for that day or not? Something like:
while day_index < learn_span && cost_per_day[day_index] +
(review_costs[0] * loss_aversion) // Card hasn't been reviewed yet, we are assuming they forgot.
> max_cost_perday {
day_index += 1;
}
To avoid rating re-roll shenanigans.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It isn't OK to assume that. For example, assuming the original interval is 365 days, a delay of one day doesn't have an significant impact. But if the original interval is 1 day, the delay will induce a large impact.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oops. I'm sorry I misunderstood. It's OK to use the maximum cost when check whether the cost is too much for that day.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think fixing it will solve this test:
thread 'optimal_retention::tests::simulator' panicked at src/optimal_retention.rs:898:9:
assertion left == right
failed
left: 8334.49
right: 6521.068
In the implementation of current PR, the memorization is significantly higher than the previous on. Because it's too optimistic to sample the rating from the first retrievability.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the change:
left: 5832.3096
right: 6521.068
I'm assuming the memorisation has probably gone under because we're assuming the card failed which the current version doesn't do?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
At least the value is closer to the expected value than the previous one.
I guess it's because sometime the forget_cost
is so large that the review is delayed too much, which decreases the memorization.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
src/optimal_retention.rs
Outdated
let upper = min(day_index + ivl as usize, learn_span); | ||
for i in day_index..upper { | ||
memorized_cnt_per_day[i] += retrievability; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I figure it out. The retrievability
should decay over time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
left: 6475.4844
right: 6521.068
Oh yeah that's a lot better.
---- optimal_retention::tests::simulate_with_learn_review_limit stdout ---- This test still has a large difference. I will dive into it later. |
I think that's better? I think it was treating new cards as review cards at certain points.
|
Could you benchmark it? |
Well on my computer at least: Current:
This pr:
|
Dang, that's big |
This difference is still larger than my expectation. I plan to do a T-test tomorrow to check whether the difference is statistically significant. If not, I will merge it. |
After increasing the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
|
Simulating the reviews iterating card by card instead of day by day speeds up the calculation (especially with a long learn_span) and simplifies the code.
Also fixes the fact that there's a drop-off near the start.
I hope that this functions in a way pretty much exactly like the day by day version, I'll leave the tests failing so you can see exactly how the results changed.
(/\ I've probably screwed up somewhere)
Please note that my results are left shifted one value compared to before because "today" will have a very small cost if the user has already done their reviews. (I think).
And here's how the graphs look with my decks:
Large deck (Done reviews)
This pr
Before
(Starting drop-off lasts more than one day so not just the fact reviews are done, if I had to guess the first review of a card isn't registered for some reason)
Smaller deck (Undone reviews)
This pr
Before
Smaller deck (Done reviews)
This pr
Before