Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  CartesianGrid,
  ComposedChart,
  ErrorBar,
  Line,
  ReferenceDot,
  ReferenceLine,
  XAxis,
  YAxis,
} from "recharts"

Installation

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

Prompt

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.

On This Page