Overfitting Gap Viz

A teaching visualisation for why we early-stop, regularise, and prefer simpler models. Two loss curves — training and validation — are drawn over epochs. Early on, both fall together. Past the knee, training loss keeps dropping while validation loss plateaus and rises again. The area between them is the overfitting gap, shaded in warning tint. A dashed vertical guide marks the validation-loss argmin — the epoch where early stopping would have caught the best generaliser.

Overfitting gap visualisation.Epoch 0. Train 3.68, val 3.35, gap 0.330.
Overfitting gapepoch 00/50 gap 0.330
train 3.68 · val 3.35
Customize
Schedule
50
120ms
Display

Installation

npx shadcn@latest add https://craftbits.dev/r/overfitting-gap-viz.json

Usage

import { OverfittingGapViz } from "@craft-bits/core";
 
<OverfittingGapViz epochs={50} defaultPlaying />

Override the curves with real training logs:

<OverfittingGapViz
  epochs={trainLogs.length - 1}
  trainCurve={trainLogs.map((r) => r.trainLoss)}
  valCurve={trainLogs.map((r) => r.valLoss)}
  bestEpoch={trainLogs.findIndex((r) => r.bestSoFar)}
/>

Drive the cursor externally — pin a specific epoch with no autoplay:

<OverfittingGapViz epochs={50} currentEpoch={28} playing={false} />

Anatomy

  1. Two curves, one chart. Training loss is drawn solid in cb-accent; validation loss is drawn dashed in cb-warning. The dashing doubles as a colour-blind-safe shape signal — validation is distinguishable from training without relying on hue.
  2. The gap is the lesson. A cb-warning-tinted polygon hugs the area between the two curves. When training and validation track together, the band is invisible; once they diverge, the band widens — the literal area being memorised rather than learned.
  3. Best-val epoch as an early-stop guide. A dashed vertical line at the argmin of the validation curve (or a caller-supplied bestEpoch) marks the point where early stopping would have captured the best generaliser. The early-stop label makes the recipe explicit.
  4. Autoplay sweeps the cursor. When playing, an interval advances currentEpoch by 1 every playSpeed ms and wraps at the end of the run. The cursor is a vertical dashed line plus a train-coloured dot on the training curve and a val-coloured dot on the validation curve — both spring smoothly with SPRINGS.snap.
  5. Synthetic defaults that read like a real run. When neither trainCurve nor valCurve is supplied, the component generates a monotonically-decreasing training curve and a U-shaped validation curve whose knee scales with epochs — so the silhouette reads at any horizon, from 20 epochs to 500.
  6. Controlled or uncontrolled everywhere. currentEpoch and playing each have controlled and uncontrolled forms (the Radix pattern). Pass real training logs through trainCurve / valCurve and the same component works as a scrubbable post-mortem rather than a synthetic toy.
  7. Reduced motion. prefers-reduced-motion: reduce collapses every spring to an instant swap and disables autoplay; manual scrubbing still works.

Props

PropTypeDefaultDescription
epochsnumber50Total epochs plotted along the x-axis. Clamped to at least 2.
trainCurvereadonly number[]Override training-loss samples. Resampled to epochs + 1 points.
valCurvereadonly number[]Override validation-loss samples. Resampled to epochs + 1 points.
bestEpochnumberargmin of valEpoch highlighted by the early-stop guide.
showGapbooleantrueFill the area between the curves with a cb-warning tint.
currentEpochnumberControlled cursor epoch. Pair with onCurrentEpochChange.
defaultCurrentEpochnumber0Uncontrolled initial cursor.
onCurrentEpochChange(epoch) => voidFires on autoplay tick and scrub.
playingbooleanControlled play state. Pair with onPlayingChange.
defaultPlayingbooleantrueUncontrolled initial play state.
onPlayingChange(playing) => voidFires when play / pause flips.
playSpeednumber120Milliseconds per autoplay tick.
transitionTransitionSPRINGS.snapSpring for cursor dots. Curves always use SPRINGS.smooth.
classNamestringMerged onto the root via cn().

Accessibility

  • The figure is role="figure" with an aria-label describing the current epoch, both losses, and the resolved best epoch.
  • An aria-live="polite" summary announces the epoch and losses whenever the cursor moves.
  • The play / pause button uses aria-pressed; its label flips between "Play training playback" and "Pause training playback".
  • The validation curve is dashed and the training curve is solid — divergence is signalled by shape and stroke style, never by colour alone.
  • The scrubber is a native range input with an explicit aria-label; arrow keys nudge the cursor by one epoch and screen readers narrate the value.
  • prefers-reduced-motion: reduce collapses every spring to an instant swap and disables autoplay; manual scrubbing still works.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/nn/OverfittingGapViz.tsx). Replaced the lesson-specific drag-thumb slider, SvgLabel chrome, phase-machine narration, raw colour vars, and inline animate() loop with the standard motion.path plus spring transition pattern. Generalised the synthetic curves so the U-shape knee scales with epochs, added trainCurve / valCurve overrides for real training logs, surfaced an argmin-of-val best-epoch guide, and shipped autoplay plus scrubber with controlled and uncontrolled state pairs (Radix pattern).