Partial Dependence + ICE
A four-state partial dependence plot with the ICE bundle it is the average of — one faint line per instance under the bold mean, a centring toggle that re-bases every curve on the first grid point, curves that move against the average dashed and counted, and a decile rug showing where the training data actually lives.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-partial-dependence.jsonPrompt
Build a React + TypeScript + Tailwind "ChartPartialDependence" model-explainability
card in plain SVG with zod. Not recharts: the bundle is dozens of anonymous
hairlines that must accumulate per-line alpha to read as density, the bold line is
their own mean rather than another series, and the centring toggle re-bases every
line AND the y axis at once.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
feature: { name: string; unit?: string };
grid: number[]; // ascending, distinct feature values
pdp: number[]; // averaged prediction, aligned with grid
ice: number[][]; // one row per instance, each aligned with grid
centred?: boolean; dataRug?: number[]; predictionLabel?: string }.
- grid / pdp / ice are ONE table read by index. superRefine: ready needs two grid
points; pdp.length must equal grid.length; every ice row must equal grid.length;
grid must be strictly ascending. Guard every access so a ragged payload yields
an issue, not a TypeError out of safeParse.
- pdp is the average over ALL evaluated rows while ice is normally a subsample —
that is why both are sent instead of deriving one from the other.
- dataRug is the raw observed column, one value per training row, unsorted.
- Props = z.infer plus height (default 230, clamped 160..480), onRetry,
onCentredChange, className and the div's native props; forwardRef to the card.
- Export the maths next to the component so a test can print the numbers the
picture is made of: buildPartialDependenceModel(), centre(), quantile(),
supportBin(), supportCount(), rankSample(), niceStep(), digitsFor(), ticksIn().
Behavior
- THE IDENTITY IS THE POINT. Partial dependence IS the pointwise mean of the ICE
curves: PD(v) = (1/n) * sum_i f(v, x_C_i). Recompute the mean of the drawn
bundle, take max |pdp - mean(ice)| over the grid, and say in words how far the
drawn curves sit from the plotted average — a small gap is the subsample, a big
one means the two were computed on different rows. Never silently replace one
with the other. Measure that gap in the frame ON SCREEN: centring takes each
side's own anchor off, so the centred axis shows |gap(v) - gap(v0)| and quoting
the raw maximum there points the reader at a divergence the picture does not
have.
- CENTRED (c-ICE, Goldstein et al.). Toggle: every curve is re-based on ITS OWN
value at the first grid point, h_i(v) - h_i(v0); the average is re-based on its
own anchor for the same reason, which keeps PD = mean(ICE) true in both modes.
All lines then leave zero together, so vertical spread is disagreement about the
EFFECT rather than about the level. Net change is untouched by the shift, so the
"moves the other way" tally is identical in both modes — state that, because a
tally that flipped with the view would be a bug the reader could not see.
Uncontrolled by default (seeded once from the contract's `centred`), controlled
when onCentredChange is passed, so a page of cards can be centred together.
- HETEROGENEITY IS COUNTED, NOT LEFT TO THE EYE. Net change per curve is
h_i(last) - h_i(first); compare its sign with the average's. Curves that
strictly oppose it are drawn last (never buried), dashed as well as recoloured,
and counted in the header, the legend and the caption. A flat average has no
direction, so nothing opposes it — do not let sign(0) crown every curve.
- CLEAN COLUMNS, THEN ROWS. Drop a grid position whose value or average is not
finite, or that repeats a value already placed; sort a copy so the caller's
array is never mutated. Then keep an ICE row only if it covers every surviving
column with finite numbers — a curve with a hole would be drawn as a straight
line across the hole, which is a prediction nobody made. Count both drops in
view.
- SUPPORT. The rug is one tick per training row plus taller ticks at the nine
type-7 deciles: h = (n-1)p, Q = x[floor h] + (h - floor h) * (x[ceil h] -
x[floor h]). Cap the drawn ticks (about 300) by rank-systematic sampling of the
SORTED column — that preserves the empirical quantiles, so thinning never moves
where the data looks dense. Once that cap bites it is no longer one tick per
row, so the caption has to say which of the two it drew instead of asserting a
count the picture does not carry. Rows outside the swept range are counted in
words, never piled onto the edge. Each grid point owns the axis half way to
each neighbour;
count the rows in it as half-open [from, to) except at the last point, so the
bins partition the rug and the printed shares add up.
- SCAN. One tab stop, role="slider", snapping to grid points: pointer move, arrow
keys, PageUp/PageDown, Home/End. It reads the average, the middle 80% of the
bundle (p10..p90 of that column, drawn as an error bar with caps) and the share
of training rows in that grid point's own bin. Keyboard moves speak through an
sr-only role=status line; pointer moves do not (a live region updated on every
pointer sample is a queue nobody can listen through).
- Four first-class branches of one card: loading (a fan of curve silhouettes under
a bolder average plus a rug strip, aria-hidden, sr-only role=status), empty
(worded as "no grid to sweep", so it cannot be mistaken for a failed fetch),
error (Try again only when onRetry was passed), ready. status "ready" with fewer
than two usable grid points renders the empty branch. An empty `ice` is NOT
empty: the average still draws, and the card says plainly that nothing on it can
show whether the instances agreed.
- CLEANUP: one ResizeObserver measuring the plot column so SVG user units are CSS
pixels, disconnected on unmount and whenever the node changes. No timers, no
rAF, no window listeners.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground panel, border gridlines,
muted skeleton, muted-foreground axis text, foreground/45 dashed anchor line,
foreground/35 rug ticks with foreground/80 deciles, stroke-ring focus frame,
bg-primary + text-primary-foreground for the pressed toggle, var(--chart-1) for
the bundle AND its average (shared hue says the bold line is their mean),
var(--chart-5) for the curves that contradict it. Never a hex.
- PER-LINE ALPHA, NEVER GROUP OPACITY: overlapping hairlines have to accumulate or
the bundle stops reading as density. Scale it with the row count and FLOOR it
(about 11/n clamped to 0.22..0.55) — a tenth of a mid-tone token lands within a
few sRGB steps of a dark card and the whole bundle disappears there.
- The average is stroked twice: a card-coloured halo under a 2.5px line, so it
reads over a dense bundle in the same hue. Grid points stay as dots — the
segments between them are straight interpolation and must not pass for a place
the model was re-scored.
- COLOUR IS NEVER THE ONLY CHANNEL: opposing curves carry a dash pattern repeated
in the legend swatch, and their count is printed in three places.
- Y axis in prediction units, or in change-from-anchor when centred, with
signDisplay exceptZero so a centred tick cannot be read as a level. The window
is the data plus 6%, with ticks landing INSIDE it: rounding the window out to
whole steps pads a probability axis with a strip of impossible values. Precision
follows the scale — one digit finer than the gridline step, clamped 2..4 — and a
rounded-away negative loses its sign so no label reads "-0".
- X ticks at ~one per 78px plus the domain ends labelled unless a round tick
already sits within 26px, anchored start/end at the edges so nothing clips.
- ACCESSIBILITY: role="group" on the svg (never role="img" — it is
children-presentational and would silence the slider), aria-labelledby the
heading, aria-describedby the summary. The toggle is a real button with
aria-pressed and a focus-visible ring. An sr-only WRAPPER DIV (never sr-only on
the table itself: CSS width is only a lower bound for a table box) holds up to
14 rank-spaced grid points with the average, the bundle's p10/median/p90 and the
rows supporting each one.
- Motion: the only animation is the skeleton pulse, and it carries
motion-reduce:animate-none.
Customization levers
- Bundle size: the payload decides. A few dozen curves read as a bundle, a few
thousand read as a smear — subsample on the evaluation side, and prefer a
rank-systematic sample by net effect so the drawn spread matches the full one.
- Anchor: centring re-bases on the FIRST grid point. Swap it for the median grid
point, or for each instance's own observed value, by changing centre() alone —
the axis title, the tick signs and the readout all follow.
- The bundle's ink: one chart token at floored alpha. Colour the curves by a
second feature instead (contract type, cohort, decile of another column) to turn
the plot into the interaction detector Goldstein proposed — keep the alpha floor
and add a dash ladder so it still survives greyscale.
- Support: drop dataRug for a lean card, or swap the decile rug for a histogram
strip in the same lane; the lane is a fixed 13px band under the plot.
- Spread: the scan draws p10..p90. Raise it to the full min..max for a small
bundle, or drop the error bar and keep the readout for a dashboard tile.
- Density: height is the plot box; the caption, the legend and the readout are
independent rows and can be removed top-down without touching the geometry.
- Two features at once: this card sweeps ONE feature. A 2-D partial dependence is
a heatmap, not a second bundle — send it to a heatmap component rather than
stacking a second grid here.Concepts
- The average is the bundle — a partial dependence plot is nothing but the pointwise mean of the individual curves, so drawing the mean without them is quoting a statistic without its sample. The card recomputes
mean(ice)in whichever frame is on screen and prints how far it sits from the average it was handed: a small gap is the subsample doing its job, a large one means the two were computed on different rows and the picture is lying about its own arithmetic. - Centring turns level into effect — each curve starts somewhere different because each instance is different, and that vertical scatter drowns out the thing being asked about. Re-basing every curve on its own value at the anchor collapses the levels and leaves only disagreement about what the feature does. The net change of a curve is untouched by the shift, so the count of curves moving the other way is the same number in both views — a tally that flipped with the toggle would be a bug the reader could never catch.
- A minority that moves the other way is the finding — an average can fall while a whole subgroup climbs, and the flat summary of that is a lie by omission. Curves whose net change opposes the average are drawn last so they are never buried, dashed as well as recoloured so the call-out survives greyscale, and counted in the header rather than left for the eye to spot in a bundle of dozens.
- The rug is the honesty channel — partial dependence is a confident line at every grid value, including feature values almost nobody in the training set had. One tick per row — thinned evenly by rank once the column outgrows the tick budget, which leaves the density exactly where it was, and the caption then says so rather than claiming a tick per row — with taller ticks at the deciles says which stretches were measured and which were extrapolated: where the ticks crowd, a tenth of the data sits between two tall ones; where they thin out, the curve is the model guessing. Rows outside the swept range are counted in words rather than piled onto the edge, where they would fake support that is not there.
- Support travels with the reading — every grid point owns the axis half way to each neighbour, and the scan prints how many training rows fall in that stretch. Half-open bins make the shares a partition of the rug, so they add up instead of double-counting every boundary row.
- Density needs per-line alpha with a floor — a bundle reads as density only when overlapping hairlines accumulate, which group opacity flattens away. The alpha falls with the row count so a large bundle does not go solid, and stops falling at a floor, because a tenth of a mid-tone token is within a few sRGB steps of a dark card and the entire sample would vanish on one theme while looking correct on the other.
Sprint Force-Velocity Profile
A four-state sprint force-velocity-power profile derived from split times alone — a mono-exponential velocity fit over the measured segments, the linear force-velocity line it implies with its F0 and V0 intercepts, and the power parabola peaking at the optimal velocity on its own axis.
Learning Curve
A four-state learning curve: training and validation score against training-set size, each inside a ±1 SD band computed from the per-fold scores, with the gap at the largest size bracketed and read as high variance, still converging, still learning, high bias or converged.