Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartLearningCurveData,
  ChartLearningCurvePoint,
  ChartLearningCurveTarget,
} from "./chart-learning-curve.contract"

export interface ChartLearningCurveProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    ChartLearningCurveData {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-learning-curve.json

Prompt

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 exactly meanTrain − 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.

On This Page