Charts

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.

Preview in your theme

Loading preview…

"use client"

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

import {

Installation

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

Prompt

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.

On This Page