Learning Curve
A four-state learning curve: training and validation score against training-set size, each inside a ±1 SD band computed from the per-fold scores, with the gap at the largest size bracketed and read as high variance, still converging, still learning, high bias or converged.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-learning-curve.jsonPrompt
Build a React + TypeScript + Tailwind "ChartLearningCurve" model-diagnostics card
in plain SVG with zod. Not recharts: the bands are computed from raw per-fold
scores rather than sent as bounds, the gap has to be measured fold-wise against
its own standard error, and the annotation at the converged end is a bracket
between two curves — none of which is an annotation API.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
metric: string;
points: { trainSize: number > 0;
trainScores: number[]; // one per CV fold
valScores: number[] }[]; // same folds, aligned by index
target?: { value: number; label?: string } }.
- trainScores[j] and valScores[j] are THE SAME fold j — one model, scored on the
split it was fitted on and on the part held out from it. State it in the
schema doc and refine that the two arrays have equal length: the pairing is
what makes SD(gap) computable rather than guessed.
- Nothing assumes 0..1 and nothing is clamped: a negative R² on a tiny training
set is a real reading, and validation above training is what heavy
regularisation looks like.
- superRefine: ready needs >= 2 points ("one size is a score, not a curve");
each point needs >= 1 fold on both sides when ready; trainSize unique (two
fold sets at one size are a contradiction, and their mean is a number nobody
measured). Guard every access so a ragged payload yields an issue, not a
TypeError.
- Props = z.infer plus height (default 250, clamped 160..480), onRetry,
className and the div's native props; forwardRef to the card.
- Export the maths next to the component so a test can print the numbers the
picture is made of: mean(), sampleSd(), summariseStop(),
buildLearningCurveModel(), readLearningCurve(), axisStep(), nearestStop().
Maths (state the formulas in comments; they are the component, not decoration)
- Per size, per side: mean = Σx / K, and the band half-height is the SAMPLE
standard deviation SD = sqrt( Σ(xᵢ − x̄)² / (K − 1) ). K − 1 because the folds
are a sample of the splits that could have been drawn. K = 1 gives SD 0 and
the card names the fold count, so a zero-width band is never read as
certainty. (numpy's std is ddof=0; × sqrt((K−1)/K) reproduces it.)
- Standard error of a mean: SE = SD / sqrt(K).
- Gap, FOLD-WISE where the arrays line up: gapⱼ = trainⱼ − valⱼ, gap = mean(gapⱼ),
SD(gap) = sampleSd(gapⱼ). Its mean equals meanTrain − meanVal exactly, but its
SD does not: an easy split flatters both sides at once, and pairing keeps that
correlation instead of inflating the spread with it. Ragged arrays fall back
to Var(a − b) = Var(a) + Var(b) — conservative — and the card says so.
- Non-finite folds: drop fold j from BOTH sides or neither, or fold 3's train
score silently re-pairs with fold 4's validation score.
- THE READ, taken over the TAIL — every size from half the largest size onward,
never fewer than two stops (the left end of any learning curve is steep and
separated; a verdict taken there calls every model high-variance). Three
measurements, one rule: a difference counts only when it clears 2 × the
standard error OF THAT DIFFERENCE (SE of a difference = hypot(SE₁, SE₂)).
gapSignificant = gap(last) > 2·SE(gap,last)
gapClosing = gap(tailStart) − gap(last) > 2·hypot(SE,SE)
valStillRising = val(last) − val(tailStart) > 2·hypot(SE,SE)
belowTarget = val(last) + 2·SE(val,last) < target
Verdict — a TREE, not a list, because every verdict under the first branch
claims the curves have MET. While the gap is real the only open question is
whether it is still narrowing, and neither answer may fall through to a
reading that says they met:
gapSignificant && !gapClosing → high variance the gap is not closing
gapSignificant && gapClosing → still converging real gap, still narrowing
else valStillRising → still learning the sweep stopped early
else belowTarget → high bias met, flat, short of target
else → converged
Print the measurements next to the verdict; the headline is a summary, not
the evidence — and every clause in the read box has to be one the branch
actually tested. "The flat validation tail says more rows are not buying
much" belongs behind !valStillRising, or better, prints valGain against its
own 2 × SE and lets the reader draw it.
Behavior
- Four first-class branches of one card: loading (skeleton with a flat top
line, a rising lower one and a narrowing band, aria-hidden, plus an sr-only
role=status line), empty (worded so it cannot be mistaken for a failed fetch),
error (Try again only when onRetry was passed), ready. status "ready" with
fewer than two usable sizes renders the empty branch.
- CLEAN, THEN DRAW: sort a copy by ascending trainSize, drop unplottable points
and repeats of a size already placed, count both and disclose them under the
chart. Count the sizes whose folds could not be paired separately, and name
the assumption their gap SD then rests on.
- ANNOTATE THE CONVERGED END: a bracket at the largest size spanning the two
means, with the signed gap beside it. Draw it toward the inside of the plot
(right-anchored text) so it can never clip the frame, and step it away from
the target line when the two would collide instead of trusting the halo.
- TARGET: a dashed rule with its label at the LEFT edge (the gap bracket owns
the right). It joins the y window only when it is within one data span of the
data; a target further out is left off the axis and marked with a caret at the
edge it left by, because chasing it would squash every curve into a ribbon.
- SCAN: one tab stop, role="slider", snapping to measured sizes — pointer move,
arrows, PageUp/PageDown, Home/End — reading train mean ± SD, validation
mean ± SD and the gap at that size. It rests at the largest size, where the
read is taken. Keyboard moves speak through an sr-only role=status line;
pointer moves do not (a live region updated on every pointer sample is a queue
nobody can listen through).
- CLEANUP: one ResizeObserver measuring the plot column so SVG user units are
CSS pixels, disconnected on unmount and whenever the node changes. No timers,
no rAF, no window listeners.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel, border gridlines
and the read box, muted skeleton, muted-foreground axis text, foreground/45
for the target rule, foreground for the gap bracket, destructive (border /10
fill, dark:/20) only for the high-variance chip, stroke-ring for the focus
frame, var(--chart-1) for training and var(--chart-2) for validation. Never a
hex, never a chart token as body text colour.
- Bands are filled AND outlined (fill ~0.22, stroke ~0.5 of the same ink):
a fill this light survives on a light card but nearly vanishes on a dark one,
so the ribbon's edge carries it there. Stroking the closed band path outlines
the whole ribbon, end caps included, for one attribute.
- COLOUR IS NEVER THE ONLY CHANNEL: the training curve is dashed in the plot, in
the legend swatch and in the skeleton; the target has its own dash pattern.
- Straight segments between measured sizes — the sweep is sampled, and a spline
would invent a bend nobody measured — with a dot at every measured size.
- Axes: y from the BAND extremes (a band cut off at the frame understates
exactly the uncertainty it exists to show) padded 8%; x always labels both
domain ends and drops an interior tick that would sit within ~30px of an end
label, so "3,000" never collides with "3,200".
- PRECISION FOLLOWS THE SCALE: tick digits come from the chosen y step, readouts
two digits finer (clamped 2..4). An accuracy sweep lands on a 0.1 step and a
negative-RMSE sweep on a step of 5; three fixed decimals would print noise on
one and six gridlines all reading "0" on the other.
- ACCESSIBILITY: role="group" on the svg (never role="img" — it is
children-presentational and would silence the slider), aria-labelledby the
heading and aria-describedby the summary line. An sr-only WRAPPER DIV (never
sr-only on the table itself: CSS width is only a lower bound for a table box)
holds up to 16 rank-spaced sizes with train, validation, gap and fold count.
- Motion: the only animation is the skeleton pulse, and it carries
motion-reduce:animate-none.
Customization levers
- The bar for a trend: SIGMA (default 2) drives all three tests at once. Raise
it to 3 for a report that only calls out what nobody can argue with; drop it
toward 1 for an exploratory sweep with few folds.
- Statistical size vs practical size: the read asks whether a gap is REAL, not
whether it is BIG — folds that agree closely can make a 0.003 gap significant.
That is deliberate (a floor in score units would have to be re-tuned for every
metric) and the gap is printed beside the verdict, but if your audience needs
one, add `&& gap > minGap` to gapSignificant with minGap supplied by the
caller in the metric's own units.
- Where the read is taken: the tail starts at half the largest training size.
Move it to the last three stops for a dense sweep, or to the whole range when
you only ran four sizes.
- Verdict vocabulary: VERDICT_LABEL feeds the chip, VERDICT_HEADLINE the read
box — retitle them for your audience ("needs regularisation" / "needs data")
without touching the classifier.
- Density: height is the plot box; drop the caption paragraph, the read box or
the legend for a dashboard tile, or raise LABELS_AT to keep the in-plot labels
on narrower cards.
- Bands: swap ±1 SD for ±1 SE by dividing by sqrt(K) in one place — say which in
the legend, since the two look identical and differ by a factor of sqrt(K).
- Palette: re-point TRAIN_INK / VAL_INK at the host palette, but keep the dash
ladder — it is the channel that survives a greyscale print.
- Axis: for a sweep spanning decades (100 → 1,000,000 rows), map trainSize
through log10 in toX and label the ticks in the same units; the bands, the
bracket and the read are all in score units and are unaffected.
- Interaction: for a static report card drop the slider group and keep the
bracket; for a shared cursor across a page of charts lift scanIndex into a
controlled prop and emit onSizeChange.Concepts
- The x axis is data, not time — a learning curve re-fits the model at growing training-set sizes, so a point is "how good would this model be with n rows". That is what makes the question it answers should we buy more data, and it is why a per-epoch loss curve, which never changes n, cannot be read the same way.
- The band comes from the folds, not from the payload — the component is handed raw per-fold scores and computes the mean and the Bessel-corrected sample SD itself. A wide band means the folds disagreed at that size, which is a different fact from the score moving, and it cannot be smuggled in pre-summarised.
- Pairing is what makes the gap measurable — fold j's training and validation scores come from one split, so
gapⱼ = trainⱼ − valⱼhas its own distribution. Its mean is exactlymeanTrain − meanVal, but its SD keeps the correlation an easy split induces in both sides; treating the two curves as independent inflates the spread and quietly makes every gap look uncertain. - One rule decides every trend — a difference is only called a trend when it clears twice the standard error of that difference. The same test asks "is there a gap", "is it closing" and "is validation still climbing", so nothing on the card rests on a threshold in score units that would need re-tuning for R² versus RMSE.
- The read is taken at the tail, never at the left end — every learning curve starts steep and separated. Judging there would label every model high-variance, so the verdict looks only at sizes past half the largest one, and the card says which size that is.
- The verdict is a headline over printed evidence — the chip says "high variance", and next to it sit the gap, how much it moved and the noise it moved inside. A reader who disagrees with the classifier can still see the three numbers it was made from, which is the difference between a diagnosis and a decoration. That is also why the verdicts form a tree rather than a list: "converged" is only reachable once the gap is inside the fold noise, so the headline can never claim the curves met while the bracket printed beside it says they are 0.2 apart.
Partial Dependence + ICE
A four-state partial dependence plot with the ICE bundle it is the average of — one faint line per instance under the bold mean, a centring toggle that re-bases every curve on the first grid point, curves that move against the average dashed and counted, and a decile rug showing where the training data actually lives.
Body Load Map
Front and back human silhouettes in hand-rolled SVG whose muscle areas shade by reported load or soreness — one chart token at stepped opacity, hatching for areas nobody reported, leader-lined labels on the worst areas and a ranked list underneath.