Lift Curve
A four-state gain and lift curve for model evaluation — a cumulative capture curve read against the random diagonal and the perfect-model bound, with key quantiles marked and degenerate inputs named instead of drawn.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-lift-curve.jsonPrompt
Build a React + TypeScript + Tailwind "ChartLiftCurve" model-evaluation card 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?, count, positives }[] }.
`items` is a gains table: one row per slice of the scored population, ordered
by DESCENDING model score, each carrying how many samples it holds and how
many of them are positives. Refine positives <= count, and require at least
one row when ready.
- Order is data, not presentation: the component never re-sorts, because a gains
table carries no score to sort by. Say so in the schema — and handle the
consequence (see "below the baseline" in Behavior) instead of hiding it.
- Component props = z.infer of the schema plus view? / defaultView?
("gain" | "lift", controlled-optional), onViewChange?, markers? (population
quantiles to call out, default [10, 20]), showPerfect? (default true),
onRetry? and className. No parallel hand-written interface.
- Export the model builder (buildLiftCurve) next to the component: the curve,
the quantile readings and the degenerate cases are the product, and they must
be unit-testable without a DOM.
Behavior
- Accumulate once, left to right, over the given order: cumulative population %
on x, cumulative share of all positives ("gain") on y. Lift at any point is
gain / population — the multiple of what a random list that size returns. The
last vertex is the definition of "all of it": write (100%, 100%) rather than
leaving it to a float sum.
- Vertices are the origin plus one per segment, and nothing else. Never insert
an interpolated vertex into the plotted array to make some other line bend
correctly — every dot on a gain curve is read as a measured segment end.
- Quantile markers are read off the curve by linear interpolation between
segment ends, which is exactly the straight line the chart draws, so a marker
can never sit off its own curve. Clean the list to finite values in (0, 100],
sort, dedupe and cap it (4 is plenty); each marker gets a dashed vertical
rule, a dot on the curve and a stat tile.
- Degenerate inputs are named, never rendered as NaN or as an empty plot:
· no samples at all (or every row dropped) -> the empty branch;
· no positives -> gain is 0/0 and lift has no denominator, so the card says
that in words and plots nothing; a flat line along zero would read as a
model that simply found nothing;
· every sample positive -> base rate 100%, so model, random and perfect are
all the same diagonal and lift is 1.00 everywhere; plot it and say why;
· negative / NaN / Infinity counts are dropped and disclosed, positives above
their own count are capped and disclosed — never coerced silently.
- Below the baseline: compare the trapezoid area under the gain curve with 50
(the diagonal). When it is lower, say the curve is under random and name the
likely cause — a gains table fed lowest-score-first draws exactly this. Guard
the check with "there are positives at all", or a zero-positive feed accuses
itself of an inverted ranking.
- Two views over one dataset, switched by a segmented control (role="group",
aria-pressed): gain plots the capture share, lift plots gain / population.
Everything that names a unit switches together — y axis ticks and title, the
stat tiles, the tooltip, the sentence under the chart and the aria-label.
Configure and mount ONLY the active view's series: then the tooltip cannot
structurally carry a reading from the other view.
- Four first-class branches in one card: loading (a plot-sized skeleton with a
concave curve silhouette over its diagonal), empty (outlined axes + dashed
diagonal), error (message + a "Try again" button only when onRetry exists),
ready.
Rendering & styling
- Fixed scales, never fitted to the data: x is [0, 100] with ticks every 20; the
gain axis is [0, 100] with ticks every 25. The lift axis is four equal steps of
a round number (pick the step off a 1/1.2/1.5/2/2.5/3/4/5/6/8/10 ladder) and
must always contain y = 1, or a model that never beats random gets an axis with
no baseline on it — and a max lift of 0 collapses the domain to [0, 0].
- The two reference curves are geometry, not series: the random baseline is a
ReferenceLine segment from (0,0) to (100,100) in gain space and the constant
y = 1 in lift space; the perfect bound is two ReferenceLine segments meeting at
the base rate. Keeping them out of the data array keeps them out of the tooltip
(where "random = 20%" is noise) and keeps every plotted dot a measured point.
- The perfect bound is drawn in the GAIN view only. Gain space is bounded by
100%, so the bound is a shape there; lift space is not — perfect lift is
1 / base rate, which for a 0.8% positive class is 125x and would flatten the
curve being read. In the lift view the same bound is quoted as a sentence
("would hold 11.26x until 8.9% of the population, then decay").
- Colour is never the only channel: the model is a solid var(--chart-1) line, the
perfect bound a dashed var(--chart-3), the random baseline a dotted
var(--foreground) at reduced opacity, quantiles thin dashed rules. Adjacent
chart tokens are only about 1.3:1 apart, so the dash pattern is what separates
them. Chart tokens are used for strokes and swatches only, never for text.
- Accessibility: ChartContainer is role="img" with a summarising aria-label that
follows the active view (segments, samples, base rate, what each axis means,
every marker reading, where the perfect bound lands), and the exact numbers
live in a sr-only <table> beside it — 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.
Pass 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. Spell multiples as "times" in anything spoken.
- 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.
- Numbers use explicit "en-US" Intl.NumberFormat (one decimal on percentages, two
on multiples, grouped integers on counts). cn() merges className; only semantic
tokens, never a hex.
Customization levers
- Markers: `markers` is the conclusion of the chart — [10, 20] suits a decile
gains table, [1, 5] a rare-event fraud list, [5, 10, 25, 50] a campaign list
being sized. Each one adds a rule, a dot and a tile; pass [] for a bare curve.
- Views: `defaultView="lift"` when the audience thinks in multiples, or drive
`view` / `onViewChange` from a page-level control to keep several cards in
step. Drop the toggle and hardcode one view for an embedded card.
- Perfect bound: `showPerfect={false}` for audiences that read it as a target
rather than a ceiling; it also removes the sentence from the lift view.
- Resolution: deciles are the convention, but the contract is any ordered
partition — 20 vingtiles for a sharper head, or unequal buckets (top 1%, next
4%, next 5%, the rest), which is where the interpolated markers earn their keep.
- Density: h-[260px] + px-6 suits a dashboard grid; drop the stat tiles or the
legend row for a compact card. The tile grid is auto-fit minmax(8rem, 1fr), so
four markers sit two-up at 375px and four-up on a wide card.
- Palette: swap var(--chart-1) / var(--chart-3) for the host palette, but keep the
dash patterns — they are what survives a colour-blind reader and a greyscale
print.Concepts
- Gain vs lift — two views of one accumulation: gain is how much of the target you caught, lift is how many times better than picking at random. They answer different questions, so the y axis, the tooltip, the stat tiles and the sentence all switch together; only the active view's series is mounted, so a stale reading from the other view cannot survive the toggle.
- Random baseline and perfect bound — a gain curve means nothing on its own. The diagonal is what a coin flip captures, and the perfect ranking (every positive first) is the ceiling that turns its corner at the base rate. Both are reference geometry rather than data series, which keeps them out of the tooltip and keeps every plotted dot a measured segment end.
- Bounded in gain space, unbounded in lift space — perfect lift is 1 / base rate: 11× at an 8.9% base rate, 125× at 0.8%. Drawing that in the lift view would flatten the curve you came to read, so the bound is a shape in the gain view and a number in the lift view.
- Quantile as the deliverable — "the top 10% holds 38.2% of churners, 3.82× random" is the sentence this chart exists to produce, so it is printed as a tile and as prose rather than left to be eyeballed off a gridline. Marker values are interpolated along the drawn line, so they stay true for quantiles that fall between segment ends.
- Degenerate inputs are named, not drawn — no positives means gain is 0 of 0 and lift has no denominator; every sample positive means the best possible curve is the diagonal. Both are legal data, and both get an explanation instead of a blank plot or a NaN.
- Order is data — the component cannot re-sort a gains table, because the score is already gone. Instead it measures the area under the curve and, when that falls below the diagonal, says the ranking looks inverted rather than presenting a worst-first feed as a finding about the model.
Connected Scatter
A four-state connected scatter: both axes carry a metric, the stops are joined in time order, and direction is encoded four ways — a width ramp, pixel-spaced arrowheads, a start ring against a terminal arrowhead, and named endpoint labels.
Likert Diverging
A four-state diverging Likert chart — one row per question with the negative options stacked leftward and the positive rightward around a shared centre, a neutral band that either straddles the axis or steps out of the bar, a switchable percentage base and net-score sorting.