Calibration Curve
A four-state reliability diagram — binned predicted probability against observed frequency, read off the diagonal, with Wilson intervals, a shared-axis count strip and every bin reachable from the keyboard.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-calibration.jsonPrompt
Build a React + TypeScript + Tailwind "ChartCalibration" reliability diagram on
the shadcn chart primitives (ChartContainer / ChartTooltip / ChartTooltipContent
over recharts ComposedChart) with zod.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
modelName?: string; positiveLabel?: string;
items: { id, label?, from, to, count, positives, meanPredicted? }[] }.
One item is one predicted-probability bin: its edges on the 0–1 axis, how many
samples fell inside it, how many of those turned out positive, and optionally
the mean prediction inside it. Refine to > from and positives <= count, and
require at least one bin when ready.
- The component never bins raw observations. Choosing the bins changes every
reading on the chart, so that decision belongs to the scoring job (or to a
histogram), not hidden inside a card.
- Unlike a gains table, the edges carry the order — so sort by `from` and never
make the caller's array order load-bearing.
- Props = z.infer of the schema plus view? / defaultView? ("reliability" | "gap",
controlled-optional), onViewChange?, showCounts? (default true), showInterval?
(default true), onSelectBin?, onRetry?, className, and the native div props
(forwardRef, rest spread onto the root). No parallel hand-written interface.
- Export the pure maths beside the component so a test can print the same numbers
the picture is made of: buildCalibrationModel(items) for the model,
calibrationStripBars(bins) for the count strip, wilsonInterval(positives,
count) for the whiskers.
Behavior
- Per bin: observed = positives / count; predicted = the reported mean when it is
finite and inside its own edges, else the midpoint; gap = (observed −
predicted) in percentage points. A bin holding no samples has no observed
frequency at all — null, never 0 — and is kept in the strip and left off the
curve.
- Three figures summarise the card, each weighted by bin mass: ECE (Σ weight ·
|gap|), the worst single bin (max |gap|, named), and the overall bias
(observed rate − mean predicted), which is what says "over-confident" or
"under-confident" in words. Weighted, never a plain mean over bins: a 90-sample
tail bin must not outvote a 5,000-sample one.
- Every point carries a 95% Wilson interval computed from its own count. Use
Wilson, not the normal approximation: the textbook interval collapses to zero
width exactly where a bin is most suspicious (0% or 100% observed) and runs off
the [0, 1] axis for small counts. It is what separates "this bin is
miscalibrated" from "this bin holds nine samples".
- Two views over one dataset, switched by a segmented control (role="group",
aria-pressed): reliability plots observed against predicted with the diagonal;
gap plots observed − predicted against a zero line, where a 3 pp
miscalibration the diagonal hides is obvious. Everything that names a unit
switches together — y ticks and title, tooltip, the sentence and the aria-label
— and only the active view's series is configured and mounted, so the tooltip
cannot structurally carry a reading from the other view.
- Bins are the keyboard path into the chart, not decoration. The count strip is a
role="group" of one <button> per bin with a roving tabindex: one delegated
keydown handler, ArrowLeft / ArrowRight step, Home / End jump, Escape clears
the reading, Enter / Space fire onSelectBin with the caller's own item object.
Focus and hover both set the reading; pointer-leave hands it back to whatever
the keyboard still owns rather than blanking a line a focused bin is still
announcing; leaving the group entirely clears it. Move by calling .focus() and
let the focus event move the tab stop, so a move never waits for a render.
- Degenerate inputs are named, never rendered as NaN or as an empty plot:
· no samples at all (or every bin dropped) -> the empty branch;
· one populated bin -> a point, not a curve, said in words;
· bins that hold no samples -> a dotted stub on the strip plus a note;
· bins covering only part of the 0–1 axis -> print the covered share, so the
diagonal never implies the model was checked where it never spoke;
· negative / NaN counts, inverted ranges and edges outside 0–1 are dropped and
counted; positives above their own count are capped and counted; a mean
prediction outside its own bin falls back to the midpoint and is counted.
Nothing is ever repaired silently.
- Four first-class branches in one card: loading (plot box, diagonal, sagging
curve silhouette and a count strip, so nothing shifts when data lands), empty
(outlined axes + dashed diagonal), error (message, plus a "Try again" button
only when onRetry exists — and move focus to the card root afterwards, or the
focus ring lands on <body> when the button unmounts), ready.
Rendering & styling
- Fixed scales, never fitted: x is [0, 1] for both panels; the reliability y axis
is [0, 1] with ticks every 25%. The gap axis is symmetric around zero — two
equal steps each side off a 1/2/2.5/5/10/20/25/50 pp ladder — and always spans
the intervals as well as the estimates, so hiding the whiskers cannot rescale
the chart under the reader. An asymmetric gap axis would make "above" look
bigger than "below".
- Two panels, one axis. Hide the recharts XAxis and draw the shared ticks under
the count strip. The strip is a plain positioned layer inset by exactly the
y-tick lane (46px) on the left and the plot's own right margin (12px), so a bar
sits under the x it describes. Put that padding on a wrapper, never on the
positioned box: an absolutely positioned child resolves its offsets against the
padding box and every bar would shift by 46px. Bars are placed by their real
edges (left/width as a percentage of the axis), so unequal bins stay honest, a
gap in coverage stays a visible gap, and a populated bin never falls below a
floor height — "few" must not render as "none".
- Perfect calibration is reference geometry, not a series: a ReferenceLine
segment from (0,0) to (1,1) in reliability space, the constant 0 in gap space.
Keeping it out of the data array keeps it out of the tooltip, where
"perfect = 60%" is noise, and keeps every plotted dot a measured bin.
- Colour is never the only channel: the curve is a solid var(--chart-1) line with
dots, the reference a dotted var(--foreground) at reduced opacity, the interval
an I-beam in the key, and the bin being read gets a caret above its bar as well
as a fill. The sign of a gap is carried by position (above or below the
reference) and repeated in words in the readout. Chart tokens are used for
strokes, fills and swatches only, never for text.
- Accessibility: ChartContainer is role="img" with a summarising aria-label
(bins, samples, promised vs observed, direction, ECE, worst bin) and
accessibilityLayer={false} — recharts otherwise puts tabindex="0" on the <svg>,
which inside a children-presentational role="img" is a tab stop with no
accessible name. Each bin button carries the full reading of that bin as its
accessible name; a visible role="status" readout under the chart is the polite
live region the keyboard path announces through, and doubles as the direct
label for the pointer path. Exact values live in a sr-only <table>: put the
sr-only class on a wrapping <div>, never on the <table>, because CSS width is
only a minimum for a table box and the content-wide table would push the page
into horizontal scroll.
- Recharts animates in JS, so motion-reduce: classes cannot reach it: read
prefers-reduced-motion with useSyncExternalStore (server fallback false) and
pass isAnimationActive={!reduced} to the Line and the ErrorBar. Everything else
that transitions is motion-reduce:transition-none. The chart is complete with
animation off.
- Numbers use explicit "en-US" Intl.NumberFormat (one decimal on percentages and
percentage points, explicit sign on gaps, grouped integers on counts). Spell
"percentage points" rather than "pp" in anything spoken. cn() merges className;
only semantic tokens, never a hex.
Customization levers
- Bins: the contract is any ordered partition, and the resolution is the
editorial choice — ten equal deciles for a first look, unequal bins (fine where
the predictions pile up, coarse where they do not) for a recalibrated model.
Send meanPredicted with wide bins; the midpoint flatters a model whose
predictions sit against one edge.
- Views: defaultView="gap" when the audience already trusts the model and is
hunting a few points of drift, or drive view / onViewChange from a page-level
control to keep several model cards in step. Drop the toggle and hardcode one
view for an embedded card.
- Density: h-[220px] plot + 56px strip + px-6 suits a dashboard grid.
showCounts={false} collapses the strip to an 18px selector rail (the bins stay
keyboard-reachable — that is not the part to remove), showInterval={false}
gives bare points, and the three stat tiles are an auto-fit
minmax(9rem, 1fr) grid that can be dropped whole for a compact card.
- Confidence: wilsonInterval takes the z it uses, so 90% (1.645) or 99% (2.576)
is a one-argument change; the axis follows because it is computed from the
interval extents.
- Palette: swap var(--chart-1) for the host palette and keep the dash patterns —
they are what survives a colour-blind reader and a greyscale print.
- Wiring: onSelectBin hands back the caller's own item object, so a page can open
the drill-down for that bin; onRetry is the only thing that draws a retry
button, so omit it where retrying is not the visitor's job.Concepts
- The diagonal is the model's own promise — a reliability diagram is the only chart where the reference line is written by the thing being measured: it says "of everything you called 70%, 70% should happen". Points under it are over-confidence, points over it under-confidence, and the direction is repeated in words because the sign of a gap is not something to leave to a reader's eye.
- Ranking skill is not calibration — a model can order every case perfectly and still be wrong about every probability; multiply its scores by 1.4 and the ROC curve does not move at all while this chart collapses. Anything that spends the number — pricing, expected loss, a threshold on cost — is asking this question, not the ranking one.
- Bin count is the whole argument — an observed frequency is a proportion, so its uncertainty is set by how many samples fell in the bin. The Wilson interval carries that on every point (it stays inside 0–1 and does not collapse at the extremes, where the normal interval does), and the strip underneath shows where the samples actually are, so a dramatic tail bin holding ninety samples reads as thin rather than as damning.
- Reliability view versus gap view — the same accumulation, plotted twice: against the diagonal, where the shape is legible, and as observed minus predicted against zero, where a three-point drift that the diagonal swallows is obvious. Only the active view's series is mounted, so a stale reading from the other view cannot survive the toggle.
- Mean prediction, not the midpoint — with wide bins the x of a point is a choice. The midpoint assumes the predictions sit in the middle of the band; the reported mean says where they actually sat. Feeding a mean that falls outside its own bin is a data bug, so it falls back to the midpoint and is counted in a note rather than moving a point somewhere it was never measured.
- Where the model never spoke — bins that arrived empty, and a bin set covering only part of the 0–1 axis, are stated on the card. A curve that stops at 40% says nothing about what the model does at 90%, and a diagonal drawn across the whole axis quietly implies it was checked there.
Confusion Matrix
A predicted × actual grid that reads as raw counts or row/column normalized shares, with per-class recall and precision margins, a hatched off-diagonal, and undefined rates kept apart from zero.
UpSet Plot
A four-state UpSet plot for set overlaps a Venn cannot draw — a dots-and-lines membership matrix under sorted size bars, per-set totals on the left, and disjoint columns that add up instead of double-counting.