polish graphs of simulator, true retention table and forgetting curve (#3448)

* polish graphs of simulator and forgetting curve

* True Retention: decrease precision of percentages

* apply uniform sampling rate to forgetting curve

* don't display time, only date when maxDays >= 365

* don't floor the totalDaysSinceLastReview

* correct cramming condition

* improve code-style

* polish ticks & tooltip of simulator

* remove unused import

* fix minor error of daysSinceFirstLearn

* filter out revlog entries from before the reset

https://forums.ankiweb.net/t/anki-24-10-beta/49989/63?u=l.m.sherlock

* use Math.ceil for windowSize

* fill currentColor for legend text

* remove mix-blend-mode: multiply

* tune the position of legend
This commit is contained in:
Jarrett Ye 2024-09-30 22:22:30 +08:00 committed by GitHub
parent fdc69505e9
commit 59969f62f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 165 additions and 121 deletions

View file

@ -50,51 +50,32 @@ impl GraphsContext {
.map(|(name, _, _)| (*name, TrueRetention::default()))
.collect();
for review in &self.revlog {
for (period_name, start, end) in &periods {
if review.id.as_secs() >= *start && review.id.as_secs() < *end {
let period_stat = period_stats.get_mut(period_name).unwrap();
const MATURE_IVL: i32 = 21; // mature interval is 21 days
match review.review_kind {
RevlogReviewKind::Learning
| RevlogReviewKind::Review
| RevlogReviewKind::Relearning => {
if review.last_interval < MATURE_IVL
&& review.button_chosen == 1
&& (review.review_kind == RevlogReviewKind::Review
|| review.last_interval <= -86400
|| review.last_interval >= 1)
{
period_stat.young_failed += 1;
} else if review.last_interval < MATURE_IVL
&& review.button_chosen > 1
&& (review.review_kind == RevlogReviewKind::Review
|| review.last_interval <= -86400
|| review.last_interval >= 1)
{
period_stat.young_passed += 1;
} else if review.last_interval >= MATURE_IVL
&& review.button_chosen == 1
&& (review.review_kind == RevlogReviewKind::Review
|| review.last_interval <= -86400
|| review.last_interval >= 1)
{
period_stat.mature_failed += 1;
} else if review.last_interval >= MATURE_IVL
&& review.button_chosen > 1
&& (review.review_kind == RevlogReviewKind::Review
|| review.last_interval <= -86400
|| review.last_interval >= 1)
{
period_stat.mature_passed += 1;
}
self.revlog
.iter()
.filter(|review| {
// not manually rescheduled
review.button_chosen > 0
// not cramming
&& (review.review_kind != RevlogReviewKind::Filtered || review.ease_factor != 0)
// cards with an interval ≥ 1 day
&& (review.review_kind == RevlogReviewKind::Review
|| review.last_interval <= -86400
|| review.last_interval >= 1)
})
.for_each(|review| {
for (period_name, start, end) in &periods {
if review.id.as_secs() >= *start && review.id.as_secs() < *end {
let period_stat = period_stats.get_mut(period_name).unwrap();
const MATURE_IVL: i32 = 21; // mature interval is 21 days
match (review.last_interval < MATURE_IVL, review.button_chosen) {
(true, 1) => period_stat.young_failed += 1,
(true, _) => period_stat.young_passed += 1,
(false, 1) => period_stat.mature_failed += 1,
(false, _) => period_stat.mature_passed += 1,
}
RevlogReviewKind::Filtered | RevlogReviewKind::Manual => {}
}
}
}
}
});
stats.today = Some(period_stats["today"].clone());
stats.yesterday = Some(period_stats["yesterday"].clone());

View file

@ -10,32 +10,58 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import AxisTicks from "../graphs/AxisTicks.svelte";
import { writable } from "svelte/store";
import InputBox from "../graphs/InputBox.svelte";
import { prepareData, renderForgettingCurve, TimeRange } from "./forgetting-curve";
import {
renderForgettingCurve,
TimeRange,
calculateMaxDays,
filterRevlog,
} from "./forgetting-curve";
import { defaultGraphBounds } from "../graphs/graph-helpers";
import HoverColumns from "../graphs/HoverColumns.svelte";
export let revlog: RevlogEntry[];
let svg = null as HTMLElement | SVGElement | null;
const bounds = defaultGraphBounds();
const timeRange = writable(TimeRange.AllTime);
const title = tr.cardStatsFsrsForgettingCurveTitle();
const data = prepareData(revlog, TimeRange.AllTime);
const filteredRevlog = filterRevlog(revlog);
const maxDays = calculateMaxDays(filteredRevlog, TimeRange.AllTime);
let defaultTimeRange = TimeRange.Week;
if (maxDays > 365) {
defaultTimeRange = TimeRange.AllTime;
} else if (maxDays > 30) {
defaultTimeRange = TimeRange.Year;
} else if (maxDays > 7) {
defaultTimeRange = TimeRange.Month;
}
const timeRange = writable(defaultTimeRange);
$: renderForgettingCurve(revlog, $timeRange, svg as SVGElement, bounds);
$: renderForgettingCurve(filteredRevlog, $timeRange, svg as SVGElement, bounds);
</script>
<div class="forgetting-curve">
<InputBox>
<div class="time-range-selector">
<label>
<input type="radio" bind:group={$timeRange} value={TimeRange.Week} />
{tr.cardStatsFsrsForgettingCurveFirstWeek()}
</label>
<label>
<input type="radio" bind:group={$timeRange} value={TimeRange.Month} />
{tr.cardStatsFsrsForgettingCurveFirstMonth()}
</label>
{#if data.length > 0 && data.some((point) => point.daysSinceFirstLearn > 365)}
{#if maxDays > 0}
<label>
<input
type="radio"
bind:group={$timeRange}
value={TimeRange.Week}
/>
{tr.cardStatsFsrsForgettingCurveFirstWeek()}
</label>
{/if}
{#if maxDays > 7}
<label>
<input
type="radio"
bind:group={$timeRange}
value={TimeRange.Month}
/>
{tr.cardStatsFsrsForgettingCurveFirstMonth()}
</label>
{/if}
{#if maxDays > 30}
<label>
<input
type="radio"
@ -45,10 +71,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{tr.cardStatsFsrsForgettingCurveFirstYear()}
</label>
{/if}
<label>
<input type="radio" bind:group={$timeRange} value={TimeRange.AllTime} />
{tr.cardStatsFsrsForgettingCurveAllTime()}
</label>
{#if maxDays > 365}
<label>
<input
type="radio"
bind:group={$timeRange}
value={TimeRange.AllTime}
/>
{tr.cardStatsFsrsForgettingCurveAllTime()}
</label>
{/if}
</div>
</InputBox>
<Graph {title}>

View file

@ -7,7 +7,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { RevlogEntry_ReviewKind as ReviewKind } from "@generated/anki/stats_pb";
import * as tr2 from "@generated/ftl";
import { timeSpan, Timestamp } from "@tslib/time";
import { filterRevlogByReviewKind } from "./forgetting-curve";
import { filterRevlogEntryByReviewKind } from "./forgetting-curve";
export let revlog: RevlogEntry[];
export let fsrsEnabled: boolean = false;
@ -84,7 +84,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let prevValidEntry: RevlogEntry | undefined;
let i = index + 1;
while (i < revlog.length) {
if (filterRevlogByReviewKind(revlog[i])) {
if (filterRevlogEntryByReviewKind(revlog[i])) {
prevValidEntry = revlog[i];
break;
}
@ -92,7 +92,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
let elapsedTime = "N/A";
if (filterRevlogByReviewKind(entry)) {
if (filterRevlogEntryByReviewKind(entry)) {
elapsedTime = prevValidEntry
? timeSpan(Number(entry.time) - Number(prevValidEntry.time))
: "0";

View file

@ -13,7 +13,7 @@ import { hideTooltip, showTooltip } from "../graphs/tooltip-utils.svelte";
const FACTOR = 19 / 81;
const DECAY = -0.5;
const MIN_POINTS = 100;
const MIN_POINTS = 1000;
function forgettingCurve(stability: number, daysElapsed: number): number {
return Math.pow((daysElapsed / stability) * FACTOR + 1.0, DECAY);
@ -34,31 +34,44 @@ export enum TimeRange {
AllTime,
}
function filterDataByTimeRange(data: DataPoint[], range: TimeRange): DataPoint[] {
const maxDays = {
[TimeRange.Week]: 7,
[TimeRange.Month]: 30,
[TimeRange.Year]: 365,
[TimeRange.AllTime]: Infinity,
}[range];
const MAX_DAYS = {
[TimeRange.Week]: 7,
[TimeRange.Month]: 30,
[TimeRange.Year]: 365,
[TimeRange.AllTime]: Infinity,
};
function filterDataByTimeRange(data: DataPoint[], maxDays: number): DataPoint[] {
return data.filter((point) => point.daysSinceFirstLearn <= maxDays);
}
export function filterRevlogByReviewKind(entry: RevlogEntry): boolean {
export function filterRevlogEntryByReviewKind(entry: RevlogEntry): boolean {
return (
entry.reviewKind !== RevlogEntry_ReviewKind.MANUAL
&& (entry.reviewKind !== RevlogEntry_ReviewKind.FILTERED || entry.ease !== 0)
);
}
export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
export function filterRevlog(revlog: RevlogEntry[]): RevlogEntry[] {
const result: RevlogEntry[] = [];
for (const entry of revlog) {
if (entry.reviewKind === RevlogEntry_ReviewKind.MANUAL && entry.ease === 0) {
break;
}
result.push(entry);
}
return result.filter((entry) => filterRevlogEntryByReviewKind(entry));
}
export function prepareData(revlog: RevlogEntry[], maxDays: number) {
const data: DataPoint[] = [];
let lastReviewTime = 0;
let lastStability = 0;
const step = Math.min(maxDays / MIN_POINTS, 1);
let daysSinceFirstLearn = 0;
revlog
.filter((entry) => filterRevlogByReviewKind(entry))
.toReversed()
.forEach((entry, index) => {
const reviewTime = Number(entry.time);
@ -76,9 +89,9 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
}
const totalDaysElapsed = (reviewTime - lastReviewTime) / (24 * 60 * 60);
const step = Math.min(1, totalDaysElapsed / MIN_POINTS);
for (let i = 0; i < Math.max(MIN_POINTS, totalDaysElapsed); i++) {
const elapsedDays = (i + 1) * step;
let elapsedDays = 0;
while (elapsedDays < totalDaysElapsed - step) {
elapsedDays += step;
const retrievability = forgettingCurve(lastStability, elapsedDays);
data.push({
date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),
@ -88,10 +101,10 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
stability: lastStability,
});
}
daysSinceFirstLearn += totalDaysElapsed;
data.push({
date: new Date((lastReviewTime + totalDaysElapsed * 86400) * 1000),
daysSinceFirstLearn: data[data.length - 1].daysSinceFirstLearn,
daysSinceFirstLearn: daysSinceFirstLearn,
retrievability: 100,
elapsedDaysSinceLastReview: 0,
stability: lastStability,
@ -106,10 +119,10 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
}
const now = Date.now() / 1000;
const totalDaysSinceLastReview = Math.floor((now - lastReviewTime) / (24 * 60 * 60));
const step = Math.min(1, totalDaysSinceLastReview / MIN_POINTS);
for (let i = 0; i < Math.max(MIN_POINTS, totalDaysSinceLastReview); i++) {
const elapsedDays = (i + 1) * step;
const totalDaysSinceLastReview = (now - lastReviewTime) / (24 * 60 * 60);
let elapsedDays = 0;
while (elapsedDays < totalDaysSinceLastReview - step) {
elapsedDays += step;
const retrievability = forgettingCurve(lastStability, elapsedDays);
data.push({
date: new Date((lastReviewTime + elapsedDays * 86400) * 1000),
@ -119,19 +132,44 @@ export function prepareData(revlog: RevlogEntry[], timeRange: TimeRange) {
stability: lastStability,
});
}
const filteredData = filterDataByTimeRange(data, timeRange);
daysSinceFirstLearn += totalDaysSinceLastReview;
const retrievability = forgettingCurve(lastStability, totalDaysSinceLastReview);
data.push({
date: new Date(now * 1000),
daysSinceFirstLearn: daysSinceFirstLearn,
elapsedDaysSinceLastReview: totalDaysSinceLastReview,
retrievability: retrievability * 100,
stability: lastStability,
});
const filteredData = filterDataByTimeRange(data, maxDays);
return filteredData;
}
export function calculateMaxDays(filteredRevlog: RevlogEntry[], timeRange: TimeRange): number {
if (filteredRevlog.length === 0) {
return 0;
}
const daysSinceFirstLearn = (Date.now() / 1000 - Number(filteredRevlog[filteredRevlog.length - 1].time))
/ (24 * 60 * 60);
return Math.min(daysSinceFirstLearn, MAX_DAYS[timeRange]);
}
export function renderForgettingCurve(
revlog: RevlogEntry[],
filteredRevlog: RevlogEntry[],
timeRange: TimeRange,
svgElem: SVGElement,
bounds: GraphBounds,
) {
const data = prepareData(revlog, timeRange);
const svg = select(svgElem);
const trans = svg.transition().duration(600) as any;
if (filteredRevlog.length === 0) {
setDataAvailable(svg, false);
return;
}
const maxDays = calculateMaxDays(filteredRevlog, timeRange);
const data = prepareData(filteredRevlog, maxDays);
if (data.length === 0) {
setDataAvailable(svg, false);
@ -186,7 +224,9 @@ export function renderForgettingCurve(
.style("opacity", 0);
function tooltipText(d: DataPoint): string {
return `Date: ${d.date.toLocaleString()}<br>
return `${maxDays >= 365 ? "Date" : "Date Time"}: ${
maxDays >= 365 ? d.date.toLocaleDateString() : d.date.toLocaleString()
}<br>
${tr.cardStatsReviewLogElapsedTime()}: ${
timeSpan(d.elapsedDaysSinceLastReview * 86400)
}<br>${tr.cardStatsFsrsRetrievability()}: ${d.retrievability.toFixed(2)}%<br>${tr.cardStatsFsrsStability()}: ${

View file

@ -324,7 +324,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
if (resp) {
const dailyTimeCost = movingAverage(
resp.dailyTimeCost,
Math.round(simulateFsrsRequest.daysToSimulate / 50),
Math.ceil(simulateFsrsRequest.daysToSimulate / 50),
);
points = points.concat(
dailyTimeCost.map((v, i) => ({

View file

@ -36,6 +36,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
overflow-x: auto;
margin-top: 1rem;
display: flex;
justify-content: center;
}
</style>

View file

@ -1,7 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { localizedNumber } from "@tslib/i18n";
import { localizedDate } from "@tslib/i18n";
import {
axisBottom,
axisLeft,
@ -14,9 +14,9 @@ import {
scaleTime,
schemeCategory10,
select,
timeFormat,
} from "d3";
import { timeSpan } from "@tslib/time";
import type { GraphBounds, TableDatum } from "./graph-helpers";
import { setDataAvailable } from "./graph-helpers";
import { hideTooltip, showTooltip } from "./tooltip-utils.svelte";
@ -48,7 +48,6 @@ export function renderSimulationChart(
const convertedData = data.map(d => ({
...d,
date: new Date(today.getTime() + d.x * 24 * 60 * 60 * 1000),
yMinutes: d.y / 60,
}));
const xMin = today;
const xMax = max(convertedData, d => d.date);
@ -56,29 +55,17 @@ export function renderSimulationChart(
const x = scaleTime()
.domain([xMin, xMax!])
.range([bounds.marginLeft, bounds.width - bounds.marginRight]);
const formatDate = timeFormat("%Y-%m-%d");
svg.select<SVGGElement>(".x-ticks")
.call((selection) =>
selection.transition(trans).call(
axisBottom(x)
.ticks(7)
.tickFormat((d: any) => formatDate(d))
.tickSizeOuter(0),
)
)
.call((selection) => selection.transition(trans).call(axisBottom(x).ticks(7).tickSizeOuter(0)))
.attr("direction", "ltr");
// y scale
const yTickFormat = (n: number): string => {
if (Math.round(n) != n) {
return "";
} else {
return localizedNumber(n);
}
return timeSpan(n, true);
};
const yMax = max(convertedData, d => d.yMinutes)!;
const yMax = max(convertedData, d => d.y)!;
const y = scaleLinear()
.range([bounds.height - bounds.marginBottom, bounds.marginTop])
.domain([0, yMax])
@ -103,10 +90,10 @@ export function renderSimulationChart(
.attr("dy", "1em")
.attr("fill", "currentColor")
.style("text-anchor", "middle")
.text("Review Time per day (minutes)");
.text("Review Time per day");
// x lines
const points = convertedData.map((d) => [x(d.date), y(d.yMinutes), d.label]);
const points = convertedData.map((d) => [x(d.date), y(d.y), d.label]);
const groups = rollup(points, v => Object.assign(v, { z: v[0][2] }), d => d[2]);
const color = schemeCategory10;
@ -120,7 +107,6 @@ export function renderSimulationChart(
.selectAll("path")
.data(Array.from(groups.entries()))
.join("path")
.style("mix-blend-mode", "multiply")
.attr("stroke", (d, i) => color[i % color.length])
.attr("d", d => line()(d[1].map(p => [p[0], p[1]])))
.attr("data-group", d => d[0]);
@ -148,7 +134,10 @@ export function renderSimulationChart(
.attr("height", bounds.height - bounds.marginTop - bounds.marginBottom)
.attr("fill", "transparent")
.on("mousemove", mousemove)
.on("mouseout", hideTooltip);
.on("mouseout", () => {
focusLine.style("opacity", 0);
hideTooltip();
});
function mousemove(event: MouseEvent, d: any): void {
pointer(event, document.body);
@ -168,9 +157,10 @@ export function renderSimulationChart(
focusLine.attr("x1", d[0]).attr("x2", d[0]).style("opacity", 1);
let tooltipContent = `Date: ${timeFormat("%Y-%m-%d")(date)}<br>`;
const days = +((date.getTime() - Date.now()) / (60 * 60 * 24 * 1000)).toFixed();
let tooltipContent = `Date: ${localizedDate(date)}<br>In ${days} Days<br>`;
for (const [key, value] of Object.entries(groupData)) {
tooltipContent += `Simulation ${key}: ${value.toFixed(2)} minutes<br>`;
tooltipContent += `#${key}: ${timeSpan(value)}<br>`;
}
showTooltip(tooltipContent, event.pageX, event.pageY);
@ -189,16 +179,17 @@ export function renderSimulationChart(
.on("click", (event, d) => toggleGroup(event, d));
legend.append("rect")
.attr("x", bounds.width - bounds.marginRight + 10)
.attr("width", 19)
.attr("height", 19)
.attr("x", bounds.width - bounds.marginRight + 36)
.attr("width", 12)
.attr("height", 12)
.attr("fill", (d, i) => color[i % color.length]);
legend.append("text")
.attr("x", bounds.width - bounds.marginRight + 34)
.attr("y", 9.5)
.attr("dy", "0.32em")
.text(d => `Simulation ${d}`);
.attr("x", bounds.width - bounds.marginRight + 52)
.attr("y", 7)
.attr("dy", "0.3em")
.attr("fill", "currentColor")
.text(d => `#${d}`);
const toggleGroup = (event: MouseEvent, d: number) => {
const group = d;

View file

@ -17,7 +17,7 @@ function calculateRetention(passed: number, failed: number): string {
if (total === 0) {
return "0%";
}
return localizedNumber((passed / total) * 100) + "%";
return localizedNumber((passed / total) * 100, 1) + "%";
}
function createStatsRow(name: string, data: TrueRetentionData): string {
@ -50,8 +50,9 @@ export function renderTrueRetention(data: GraphsResponse, revlogRange: RevlogRan
td.trl { border: 1px solid; text-align: left; padding: 5px; }
td.trr { border: 1px solid; text-align: right; padding: 5px; }
td.trc { border: 1px solid; text-align: center; padding: 5px; }
table { width: 100%; margin: 0px 25px 20px 25px; }
</style>
<table style="border-collapse: collapse;" cellspacing="0" cellpadding="2">
<table cellspacing="0" cellpadding="2">
<tr>
<td class="trl" rowspan=3><b>${tr.statisticsTrueRetentionRange()}</b></td>
<td class="trc" colspan=9><b>${tr.statisticsReviewsTitle()}</b></td>