Loss Curve Compare

A side-by-side chart for comparing pre-computed loss curves. Hand it N training-loss arrays and it plots them as polylines on a shared step axis with a configurable log or linear y, optional trailing-average smoothing, and an optional shared cursor. Use it to compare model A vs model B, different loss functions on the same task (MSE vs cross-entropy), or one hyperparameter swept across runs.

Loss curve comparison: Model A, Model B, Model C.Step 60 of 119. Model A: 0.104. Model B: 0.270. Model C: 0.668.
Loss curvesy log smooth 5 step 60/119
0.0010.010.1110020406080100119steploss (log)
  • Model A0.104
  • Model B0.270
  • Model C0.668
Customize
Axis
Smoothing
5
Cursor
60

Installation

npx shadcn@latest add https://craftbits.dev/r/loss-curve-compare.json

Usage

import { LossCurveCompare } from "@craft-bits/core";
 
<LossCurveCompare
  curves={[
    { id: "a", label: "Model A", points: lossA, tone: "accent" },
    { id: "b", label: "Model B", points: lossB, tone: "success" },
  ]}
/>

Tame noisy minibatch losses with a trailing running average — the convergence trend reads through the wobble without distorting it:

<LossCurveCompare curves={runs} smoothingWindow={10} />

Highlight a teaching beat with a shared cursor (controlled — wire scrubbing externally if you need it):

<LossCurveCompare curves={runs} currentStep={42} />

Switch to a linear axis for converged-tail comparisons:

<LossCurveCompare curves={runs} yScale="linear" />

Understanding the component

  1. Pure plotting primitive — no math beyond smoothing and the axis transform. You hand it points: number[] per curve and it renders. Compute the curves however you like — analytic, recorded from a real training run, or synthesised for teaching.
  2. Log y-axis by default. Losses span orders of magnitude (cross-entropy from ~1.0 down to ~1e-4), so log is the default — the spread reads cleanly across decades. Flip to linear for converged-tail comparisons where the absolute gap matters more than the order.
  3. smoothingWindow is a trailing running average. 1 (the default) is a no-op — the raw curve. >1 averages the last w samples at each step. The window is causal — the smoothed value at step t only depends on data ≤ t, so the smoothed curve never "knows the future" of the training run.
  4. The longest curve sets the x-axis. Shorter curves stop drawing at their last point; the cursor still spans the longest. Mix-and-match runs of different lengths without padding.
  5. Cursor is opt-in. Pass currentStep to draw a shared vertical guide with a head dot on every curve. Omit it and the chart just shows the curves and the legend. No autoplay, no transport — wire scrubbing from outside if you need it.
  6. Three tones, by intent. accent is the protagonist (Model A, the new loss). success is the converged baseline. warning is the struggler (the curve that plateaus or diverges).
  7. Two springs, two roles. The curves animate between data changes (e.g. when you bump smoothingWindow) with SPRINGS.smooth. The cursor and head dots use SPRINGS.snap. prefers-reduced-motion: reduce snaps both instantly.

Props

PropTypeDefaultDescription
curvesreadonly LossCurve[]Required. Pre-computed loss curves with id, label, points, optional tone.
yScale'linear' | 'log''log'Y-axis transform. log for orders-of-magnitude spread, linear for converged tails.
currentStepnumberShared cursor step. Omit for no cursor. Out-of-range values clamp into [0, maxStep].
smoothingWindownumber1Trailing running-average window. 1 is a no-op.
transitionTransitionSPRINGS.snapSpring for the cursor and head dots.
classNamestringMerged onto the root <div> via cn().

The LossCurve shape:

FieldTypeDescription
idstringUnique key — React key + aria summary token.
labelstringLegend label.
pointsreadonly number[]One loss value per step. Non-finite entries are skipped.
tone'accent' | 'success' | 'warning'Curve tone. Defaults to 'accent'.

Accessibility

  • The figure is role="figure" with a labelled title that names every curve in the comparison.
  • An aria-live="polite" summary above the chart announces the curve count, axis transform, and per-curve loss at the cursor (when one is set) — so the comparison is readable without color.
  • Each curve pairs a tone swatch with a textual label (and a numerical loss when the cursor is on) in the legend, so color is never the only signal.
  • prefers-reduced-motion: reduce snaps both the curves and the cursor with no spring.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/viz/LossCurveCompare.tsx). The source was a bespoke MSE-vs-cross-entropy lesson primitive (Explore / Predict mode strip, drag-the-p slider, six-question quiz with usePredictRounds, gradient-arrow inset, narration heuristics, SvgLabel / TogglePill / ChallengeBtn chrome). Reframed here into a general multi-line loss-curve viewer: stripped the modes, prediction quiz, narration, drag interaction, and gradient-arrow inset; replaced the hard-coded MSE / CE pair with arbitrary curves: { id, label, points, tone? }[]; added a log / linear axis toggle and a trailing-average smoothingWindow; kept the shared-cursor head-dot pattern but switched it to controlled-only (no internal drag state — pair it with your own scrubber if you need one).