Optimizer Racetrack

Three (or more) optimizers racing on the same 2D loss landscape, each leaving a coloured trace. Pick a surface (Rosenbrock banana, saddle, ill-conditioned bowl), nudge the learning rate, and watch SGD zig-zag while Adam glides — exactly the diagnostic a real training run buys you, on a single page.

Iteration 0 of 100. SGD loss 17.500. Adam loss 17.500. Momentum loss 17.500.
Rosenbrockstep 000 / 100
  • SGDloss 17.500
  • Adamloss 17.500
  • Momentumloss 17.500
Customize
Landscape
rosenbrock
Race
0.010
100
Playback

Installation

npx shadcn@latest add https://craftbits.dev/r/optimizer-racetrack.json

Usage

import { OptimizerRacetrack } from "@craft-bits/core";
 
<OptimizerRacetrack
  optimizers={["sgd", "adam", "momentum"]}
  loss="rosenbrock"
  learningRate={0.01}
  iterations={100}
/>

Drive playback from outside the component, e.g. synced to a scrollytelling scrubber:

const [iter, setIter] = useState(0);
 
<OptimizerRacetrack
  iteration={iter}
  onIterationChange={setIter}
  loss="saddle"
/>

Add a custom optimizer alongside the built-ins:

<OptimizerRacetrack
  optimizers={["sgd", "adam"]}
  customOptimizers={[
    {
      name: "Nesterov",
      tone: "default",
      step: ({ pos, grad, lr, state }) => {
        const beta = 0.9;
        const vx = (state.vx ?? 0) * beta + grad[0];
        const vy = (state.vy ?? 0) * beta + grad[1];
        state.vx = vx;
        state.vy = vy;
        return [
          pos[0] - lr * (beta * vx + grad[0]),
          pos[1] - lr * (beta * vy + grad[1]),
        ];
      },
    },
  ]}
/>

Understanding the component

  1. One surface, many descents. Every optimizer starts from the same point on the same loss landscape with the same learning rate — the only thing that changes is the update rule, so the visible divergence between traces is the divergence between algorithms, not hyperparameters.
  2. Three landscapes, each with a teaching point. rosenbrock is the narrow curved valley — SGD zig-zags, Adam tracks. saddle (x² − y²) has zero gradient at the saddle — SGD stalls, Momentum escapes. quadratic (x² + 10y²) is the ill-conditioned bowl — Adam's per-axis scaling shines.
  3. Analytic gradients, not finite differences. Each landscape ships an explicit ∇f — what the optimizer sees on screen matches what it would compute in a real training loop.
  4. Marching-squares contours. Iso-lines are built on the fly via a 64×64 marching-squares pass on the loss function. Change the surface and the contour plot follows; no precomputed assets.
  5. RAF-driven race. Autoplay advances the shared iteration counter at ~6 steps/second via requestAnimationFrame. The traces always rebuild from precomputed trajectories, so scrubbing is instant.
  6. SPRINGS.snap on the head. Each optimizer's current-position dot springs to its new location between iterations — racing-feel, not lazy-spring.
  7. Reduced-motion fallback. With prefers-reduced-motion: reduce, the race jumps to the final iteration on mount and disables autoplay — every trace renders statically.

Props

PropTypeDefaultDescription
optimizersreadonly Optimizer[]['sgd','adam','momentum']Built-in optimizers to race.
customOptimizersreadonly OptimizerConfig[]Pluggable optimizers with a step function.
loss'rosenbrock' | 'saddle' | 'quadratic''rosenbrock'Loss landscape.
learningRatenumber0.01Shared learning rate.
iterationsnumber100Steps simulated per optimizer.
playingbooleanControlled play state. Pair with onPlayingChange.
defaultPlayingbooleantrueUncontrolled initial play state.
onPlayingChange(playing: boolean) => voidFires when play / pause flips.
iterationnumberControlled iteration index. Pair with onIterationChange.
defaultIterationnumber0Uncontrolled initial iteration.
onIterationChange(iteration: number) => voidFires on tick and on scrub.
sizenumber360Pixel size of the SVG (square).
transitionTransitionSPRINGS.snapSpring for the current-position dot.
classNamestringMerged onto the root <div> via cn().

Accessibility

  • The figure is role="figure" with aria-label="Optimizer comparison on <loss> landscape".
  • An aria-live="polite" summary announces iteration + per-optimizer loss whenever the race advances.
  • The play / pause button is aria-pressed; the label flips between "Play race" and "Pause race".
  • The scrubber is a native <input type="range"> with an explicit aria-label, so keyboard arrows nudge the iteration and screen readers narrate the value.
  • Color is never the only signal — every trace gets a labelled legend entry, and diverged runs read "diverged" textually.
  • prefers-reduced-motion: reduce snaps to the final iteration and disables autoplay; no per-iteration motion is shown.

Credits

  • Extracted from: craftingattention (app/src/lessons/primitives/viz/OptimizerRacetrack.tsx). Stripped the lesson-specific mode strip, predict-quiz, narration heuristics, and the companion LossLandscapeExplorer. Generalized to a single comparative primitive with controlled / uncontrolled state pairs and a pluggable customOptimizers API.