mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
Calculate parameters automatically
Logic derived from d8e2f6a0ff
Closes #2661
This commit is contained in:
parent
e7bf248a62
commit
6074865763
3 changed files with 185 additions and 162 deletions
|
@ -340,7 +340,11 @@ message ComputeFsrsWeightsResponse {
|
||||||
|
|
||||||
message ComputeOptimalRetentionRequest {
|
message ComputeOptimalRetentionRequest {
|
||||||
repeated float weights = 1;
|
repeated float weights = 1;
|
||||||
OptimalRetentionParameters params = 2;
|
uint32 deck_size = 2;
|
||||||
|
uint32 days_to_simulate = 3;
|
||||||
|
uint32 max_seconds_of_study_per_day = 4;
|
||||||
|
uint32 max_interval = 5;
|
||||||
|
string search = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ComputeOptimalRetentionResponse {
|
message ComputeOptimalRetentionResponse {
|
||||||
|
@ -348,15 +352,11 @@ message ComputeOptimalRetentionResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
message OptimalRetentionParameters {
|
message OptimalRetentionParameters {
|
||||||
uint32 deck_size = 2;
|
|
||||||
uint32 days_to_simulate = 3;
|
|
||||||
uint32 max_seconds_of_study_per_day = 4;
|
|
||||||
uint32 max_interval = 5;
|
|
||||||
double recall_secs_hard = 6;
|
double recall_secs_hard = 6;
|
||||||
double recall_secs_good = 7;
|
double recall_secs_good = 7;
|
||||||
double recall_secs_easy = 8;
|
double recall_secs_easy = 8;
|
||||||
uint32 forget_secs = 9;
|
double forget_secs = 9;
|
||||||
uint32 learn_secs = 10;
|
double learn_secs = 10;
|
||||||
double first_rating_probability_again = 11;
|
double first_rating_probability_again = 11;
|
||||||
double first_rating_probability_hard = 12;
|
double first_rating_probability_hard = 12;
|
||||||
double first_rating_probability_good = 13;
|
double first_rating_probability_good = 13;
|
||||||
|
|
|
@ -5,8 +5,10 @@ use anki_proto::scheduler::ComputeOptimalRetentionRequest;
|
||||||
use anki_proto::scheduler::OptimalRetentionParameters;
|
use anki_proto::scheduler::OptimalRetentionParameters;
|
||||||
use fsrs::SimulatorConfig;
|
use fsrs::SimulatorConfig;
|
||||||
use fsrs::FSRS;
|
use fsrs::FSRS;
|
||||||
|
use itertools::Itertools;
|
||||||
|
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
use crate::revlog::RevlogReviewKind;
|
||||||
use crate::search::SortMode;
|
use crate::search::SortMode;
|
||||||
|
|
||||||
#[derive(Default, Clone, Copy, Debug)]
|
#[derive(Default, Clone, Copy, Debug)]
|
||||||
|
@ -22,74 +24,177 @@ impl Collection {
|
||||||
) -> Result<f32> {
|
) -> Result<f32> {
|
||||||
let mut anki_progress = self.new_progress_handler::<ComputeRetentionProgress>();
|
let mut anki_progress = self.new_progress_handler::<ComputeRetentionProgress>();
|
||||||
let fsrs = FSRS::new(None)?;
|
let fsrs = FSRS::new(None)?;
|
||||||
let p = req.params.as_ref().or_invalid("missing params")?;
|
if req.days_to_simulate == 0 {
|
||||||
Ok(fsrs.optimal_retention(
|
invalid_input!("no days to simulate")
|
||||||
&SimulatorConfig {
|
}
|
||||||
deck_size: p.deck_size as usize,
|
let p = self.get_optimal_retention_parameters(&req.search)?;
|
||||||
learn_span: p.days_to_simulate as usize,
|
Ok(fsrs
|
||||||
max_cost_perday: p.max_seconds_of_study_per_day as f64,
|
.optimal_retention(
|
||||||
max_ivl: p.max_interval as f64,
|
&SimulatorConfig {
|
||||||
recall_costs: [p.recall_secs_hard, p.recall_secs_good, p.recall_secs_easy],
|
deck_size: req.deck_size as usize,
|
||||||
forget_cost: p.forget_secs as f64,
|
learn_span: req.days_to_simulate as usize,
|
||||||
learn_cost: p.learn_secs as f64,
|
max_cost_perday: req.max_seconds_of_study_per_day as f64,
|
||||||
first_rating_prob: [
|
max_ivl: req.max_interval as f64,
|
||||||
p.first_rating_probability_again,
|
recall_costs: [p.recall_secs_hard, p.recall_secs_good, p.recall_secs_easy],
|
||||||
p.first_rating_probability_hard,
|
forget_cost: p.forget_secs,
|
||||||
p.first_rating_probability_good,
|
learn_cost: p.learn_secs,
|
||||||
p.first_rating_probability_easy,
|
first_rating_prob: [
|
||||||
],
|
p.first_rating_probability_again,
|
||||||
review_rating_prob: [
|
p.first_rating_probability_hard,
|
||||||
p.review_rating_probability_hard,
|
p.first_rating_probability_good,
|
||||||
p.review_rating_probability_good,
|
p.first_rating_probability_easy,
|
||||||
p.review_rating_probability_easy,
|
],
|
||||||
],
|
review_rating_prob: [
|
||||||
},
|
p.review_rating_probability_hard,
|
||||||
&req.weights,
|
p.review_rating_probability_good,
|
||||||
|ip| {
|
p.review_rating_probability_easy,
|
||||||
anki_progress
|
],
|
||||||
.update(false, |p| {
|
},
|
||||||
p.total = ip.total as u32;
|
&req.weights,
|
||||||
p.current = ip.current as u32;
|
|ip| {
|
||||||
})
|
anki_progress
|
||||||
.is_ok()
|
.update(false, |p| {
|
||||||
},
|
p.total = ip.total as u32;
|
||||||
)? as f32)
|
p.current = ip.current as u32;
|
||||||
|
})
|
||||||
|
.is_ok()
|
||||||
|
},
|
||||||
|
)?
|
||||||
|
.max(0.8)
|
||||||
|
.min(0.97) as f32)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_optimal_retention_parameters(
|
pub fn get_optimal_retention_parameters(
|
||||||
&mut self,
|
&mut self,
|
||||||
search: &str,
|
search: &str,
|
||||||
) -> Result<OptimalRetentionParameters> {
|
) -> Result<OptimalRetentionParameters> {
|
||||||
let guard = self.search_cards_into_table(search, SortMode::NoOrder)?;
|
let revlogs = self
|
||||||
let deck_size = guard.cards as u32;
|
.search_cards_into_table(search, SortMode::NoOrder)?
|
||||||
|
|
||||||
// if you need access to cards too:
|
|
||||||
// let cards = self.storage.all_searched_cards()?;
|
|
||||||
|
|
||||||
let _revlogs = guard
|
|
||||||
.col
|
.col
|
||||||
.storage
|
.storage
|
||||||
.get_revlog_entries_for_searched_cards_in_order()?;
|
.get_revlog_entries_for_searched_cards_in_order()?;
|
||||||
|
|
||||||
// todo: compute values from revlogs
|
let first_rating_count = revlogs
|
||||||
|
.iter()
|
||||||
|
.filter(|r| {
|
||||||
|
r.review_kind == RevlogReviewKind::Learning
|
||||||
|
&& r.last_interval == 0
|
||||||
|
&& r.button_chosen >= 1
|
||||||
|
})
|
||||||
|
.counts_by(|r| r.button_chosen);
|
||||||
|
let total_first = first_rating_count.values().sum::<usize>() as f64;
|
||||||
|
let first_rating_prob = if total_first > 0.0 {
|
||||||
|
let mut arr = [0.0; 4];
|
||||||
|
first_rating_count
|
||||||
|
.iter()
|
||||||
|
.for_each(|(button_chosen, count)| {
|
||||||
|
arr[*button_chosen as usize - 1] = *count as f64 / total_first
|
||||||
|
});
|
||||||
|
arr
|
||||||
|
} else {
|
||||||
|
return Err(AnkiError::FsrsInsufficientData);
|
||||||
|
};
|
||||||
|
|
||||||
|
let review_rating_count = revlogs
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.review_kind == RevlogReviewKind::Review && r.button_chosen != 1)
|
||||||
|
.counts_by(|r| r.button_chosen);
|
||||||
|
let total_reviews = review_rating_count.values().sum::<usize>() as f64;
|
||||||
|
let review_rating_prob = if total_reviews > 0.0 {
|
||||||
|
let mut arr = [0.0; 3];
|
||||||
|
review_rating_count
|
||||||
|
.iter()
|
||||||
|
.filter(|(&button_chosen, ..)| button_chosen >= 2)
|
||||||
|
.for_each(|(button_chosen, count)| {
|
||||||
|
arr[*button_chosen as usize - 2] = *count as f64 / total_reviews;
|
||||||
|
});
|
||||||
|
arr
|
||||||
|
} else {
|
||||||
|
return Err(AnkiError::FsrsInsufficientData);
|
||||||
|
};
|
||||||
|
|
||||||
|
let recall_costs = {
|
||||||
|
let default = [14.0, 14.0, 10.0, 6.0];
|
||||||
|
let mut arr = default;
|
||||||
|
revlogs
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.review_kind == RevlogReviewKind::Review && r.button_chosen > 0)
|
||||||
|
.sorted_by(|a, b| a.button_chosen.cmp(&b.button_chosen))
|
||||||
|
.group_by(|r| r.button_chosen)
|
||||||
|
.into_iter()
|
||||||
|
.for_each(|(button_chosen, group)| {
|
||||||
|
let group_vec = group.into_iter().map(|r| r.taken_millis).collect_vec();
|
||||||
|
let average_secs =
|
||||||
|
group_vec.iter().sum::<u32>() as f64 / group_vec.len() as f64 / 1000.0;
|
||||||
|
arr[button_chosen as usize - 1] = average_secs
|
||||||
|
});
|
||||||
|
if arr == default {
|
||||||
|
return Err(AnkiError::FsrsInsufficientData);
|
||||||
|
}
|
||||||
|
arr
|
||||||
|
};
|
||||||
|
let learn_cost = {
|
||||||
|
let revlogs_filter = revlogs
|
||||||
|
.iter()
|
||||||
|
.filter(|r| r.review_kind == RevlogReviewKind::Learning && r.last_interval == 0)
|
||||||
|
.map(|r| r.taken_millis);
|
||||||
|
let count = revlogs_filter.clone().count() as f64;
|
||||||
|
if count > 0.0 {
|
||||||
|
revlogs_filter.sum::<u32>() as f64 / count / 1000.0
|
||||||
|
} else {
|
||||||
|
return Err(AnkiError::FsrsInsufficientData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let forget_cost = {
|
||||||
|
let review_kind_to_total_millis = revlogs
|
||||||
|
.iter()
|
||||||
|
.sorted_by(|a, b| a.cid.cmp(&b.cid).then(a.id.cmp(&b.id)))
|
||||||
|
.group_by(|r| r.review_kind)
|
||||||
|
/*
|
||||||
|
for example:
|
||||||
|
o x x o o x x x o o x x o x
|
||||||
|
|<->| |<--->| |<->| |<>|
|
||||||
|
x means forgotten, there are 4 consecutive sets of internal relearning in this card.
|
||||||
|
So each group is counted separately, and each group is summed up internally.(following code)
|
||||||
|
Finally averaging all groups, so sort by cid and id.
|
||||||
|
*/
|
||||||
|
.into_iter()
|
||||||
|
.map(|(review_kind, group)| {
|
||||||
|
let total_millis: u32 = group.into_iter().map(|r| r.taken_millis).sum();
|
||||||
|
(review_kind, total_millis)
|
||||||
|
})
|
||||||
|
.collect_vec();
|
||||||
|
let mut group_sec_by_review_kind: [Vec<_>; 5] = Default::default();
|
||||||
|
for (review_kind, sec) in review_kind_to_total_millis.into_iter() {
|
||||||
|
group_sec_by_review_kind[review_kind as usize].push(sec)
|
||||||
|
}
|
||||||
|
let mut arr = [0.0; 5];
|
||||||
|
for (review_kind, group) in group_sec_by_review_kind.iter().enumerate() {
|
||||||
|
if group.is_empty() && review_kind == RevlogReviewKind::Relearning as usize {
|
||||||
|
return Err(AnkiError::FsrsInsufficientData);
|
||||||
|
}
|
||||||
|
let average_secs = group.iter().sum::<u32>() as f64 / group.len() as f64 / 1000.0;
|
||||||
|
arr[review_kind] = average_secs
|
||||||
|
}
|
||||||
|
arr
|
||||||
|
};
|
||||||
|
|
||||||
|
let forget_cost = forget_cost[RevlogReviewKind::Relearning as usize] + recall_costs[0];
|
||||||
|
|
||||||
let params = OptimalRetentionParameters {
|
let params = OptimalRetentionParameters {
|
||||||
deck_size,
|
recall_secs_hard: recall_costs[1],
|
||||||
days_to_simulate: 365,
|
recall_secs_good: recall_costs[2],
|
||||||
max_seconds_of_study_per_day: 1800,
|
recall_secs_easy: recall_costs[3],
|
||||||
// this should be filled in by the frontend based on their configured value
|
forget_secs: forget_cost,
|
||||||
max_interval: 0,
|
learn_secs: learn_cost,
|
||||||
recall_secs_hard: 14.0,
|
first_rating_probability_again: first_rating_prob[0],
|
||||||
recall_secs_good: 10.0,
|
first_rating_probability_hard: first_rating_prob[1],
|
||||||
recall_secs_easy: 6.0,
|
first_rating_probability_good: first_rating_prob[2],
|
||||||
forget_secs: 50,
|
first_rating_probability_easy: first_rating_prob[3],
|
||||||
learn_secs: 20,
|
review_rating_probability_hard: review_rating_prob[0],
|
||||||
first_rating_probability_again: 0.15,
|
review_rating_probability_good: review_rating_prob[1],
|
||||||
first_rating_probability_hard: 0.2,
|
review_rating_probability_easy: review_rating_prob[2],
|
||||||
first_rating_probability_good: 0.6,
|
|
||||||
first_rating_probability_easy: 0.05,
|
|
||||||
review_rating_probability_hard: 0.3,
|
|
||||||
review_rating_probability_good: 0.6,
|
|
||||||
review_rating_probability_easy: 0.1,
|
|
||||||
};
|
};
|
||||||
Ok(params)
|
Ok(params)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,12 +7,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
ComputeRetentionProgress,
|
ComputeRetentionProgress,
|
||||||
type ComputeWeightsProgress,
|
type ComputeWeightsProgress,
|
||||||
} from "@tslib/anki/collection_pb";
|
} from "@tslib/anki/collection_pb";
|
||||||
import { OptimalRetentionParameters } from "@tslib/anki/scheduler_pb";
|
import { ComputeOptimalRetentionRequest } from "@tslib/anki/scheduler_pb";
|
||||||
import {
|
import {
|
||||||
computeFsrsWeights,
|
computeFsrsWeights,
|
||||||
computeOptimalRetention,
|
computeOptimalRetention,
|
||||||
evaluateWeights,
|
evaluateWeights,
|
||||||
getOptimalRetentionParameters,
|
|
||||||
setWantsAbort,
|
setWantsAbort,
|
||||||
} from "@tslib/backend";
|
} from "@tslib/backend";
|
||||||
import * as tr from "@tslib/ftl";
|
import * as tr from "@tslib/ftl";
|
||||||
|
@ -39,7 +38,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
| ComputeRetentionProgress
|
| ComputeRetentionProgress
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
let optimalParams = new OptimalRetentionParameters({});
|
const optimalRetentionRequest = new ComputeOptimalRetentionRequest({
|
||||||
|
deckSize: 10000,
|
||||||
|
daysToSimulate: 365,
|
||||||
|
maxSecondsOfStudyPerDay: 1800,
|
||||||
|
});
|
||||||
async function computeWeights(): Promise<void> {
|
async function computeWeights(): Promise<void> {
|
||||||
if (computing) {
|
if (computing) {
|
||||||
await setWantsAbort({});
|
await setWantsAbort({});
|
||||||
|
@ -125,10 +128,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
try {
|
try {
|
||||||
await runWithBackendProgress(
|
await runWithBackendProgress(
|
||||||
async () => {
|
async () => {
|
||||||
const resp = await computeOptimalRetention({
|
optimalRetentionRequest.maxInterval = $config.maximumReviewInterval;
|
||||||
params: optimalParams,
|
optimalRetentionRequest.weights = $config.fsrsWeights;
|
||||||
weights: $config.fsrsWeights,
|
optimalRetentionRequest.search = `preset:"${state.getCurrentName()}"`;
|
||||||
});
|
const resp = await computeOptimalRetention(optimalRetentionRequest);
|
||||||
$config.desiredRetention = resp.optimalRetention;
|
$config.desiredRetention = resp.optimalRetention;
|
||||||
if (computeRetentionProgress) {
|
if (computeRetentionProgress) {
|
||||||
computeRetentionProgress.current =
|
computeRetentionProgress.current =
|
||||||
|
@ -146,23 +149,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getRetentionParams(): Promise<void> {
|
|
||||||
if (computing) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
computing = true;
|
|
||||||
try {
|
|
||||||
// await
|
|
||||||
const resp = await getOptimalRetentionParameters({
|
|
||||||
search: `preset:"${state.getCurrentName()}"`,
|
|
||||||
});
|
|
||||||
optimalParams = resp.params!;
|
|
||||||
optimalParams.maxInterval = $config.maximumReviewInterval;
|
|
||||||
} finally {
|
|
||||||
computing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$: computeWeightsProgressString = renderWeightProgress(computeWeightsProgress);
|
$: computeWeightsProgressString = renderWeightProgress(computeWeightsProgress);
|
||||||
$: computeRetentionProgressString = renderRetentionProgress(
|
$: computeRetentionProgressString = renderRetentionProgress(
|
||||||
computeRetentionProgress,
|
computeRetentionProgress,
|
||||||
|
@ -248,90 +234,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
Deck size:
|
Deck size:
|
||||||
<br />
|
<br />
|
||||||
<input type="number" bind:value={optimalParams.deckSize} />
|
<input type="number" bind:value={optimalRetentionRequest.deckSize} />
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
Days to simulate
|
Days to simulate
|
||||||
<br />
|
<br />
|
||||||
<input type="number" bind:value={optimalParams.daysToSimulate} />
|
<input type="number" bind:value={optimalRetentionRequest.daysToSimulate} />
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
Max seconds of study per day:
|
Max seconds of study per day:
|
||||||
<br />
|
<br />
|
||||||
<input type="number" bind:value={optimalParams.maxSecondsOfStudyPerDay} />
|
<input
|
||||||
|
type="number"
|
||||||
|
bind:value={optimalRetentionRequest.maxSecondsOfStudyPerDay}
|
||||||
|
/>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
Seconds to forget a card (again):
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.forgetSecs} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
Seconds to recall a card (hard):
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.recallSecsHard} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
Seconds to recall a card (good):
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.recallSecsGood} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
Seconds to recall a card (easy):
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.recallSecsEasy} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
Seconds to learn a card:
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.learnSecs} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
First rating probability (again):
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.firstRatingProbabilityAgain} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
First rating probability (hard):
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.firstRatingProbabilityHard} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
First rating probability (good):
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.firstRatingProbabilityGood} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
First rating probability (easy):
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.firstRatingProbabilityEasy} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
Review rating probability (hard):
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.reviewRatingProbabilityHard} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
Review rating probability (good):
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.reviewRatingProbabilityGood} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
Review rating probability (easy):
|
|
||||||
<br />
|
|
||||||
<input type="number" bind:value={optimalParams.reviewRatingProbabilityEasy} />
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
|
||||||
on:click={() => getRetentionParams()}
|
|
||||||
>
|
|
||||||
{#if computing}
|
|
||||||
{tr.actionsCancel()}
|
|
||||||
{:else}
|
|
||||||
{tr.deckConfigGetParams()}
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
class="btn {computing ? 'btn-warning' : 'btn-primary'}"
|
||||||
on:click={() => computeRetention()}
|
on:click={() => computeRetention()}
|
||||||
|
|
Loading…
Reference in a new issue