Skip to content

Add a real-data example notebook and run it in CI - #11

Open
hamed wants to merge 4 commits into
mainfrom
examples/income-notebook
Open

hamed wants to merge 4 commits into
mainfrom
examples/income-notebook

Conversation

@hamed

@hamed hamed commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Adds examples/census_income.ipynb — self-contained, committed with its outputs, and executed in CI like the quickstart.

What it shows

The existing examples measure hand-written toy rows and synthetic normal draws. They demonstrate the API, but not the thing the library exists for: deciding whether the gap between two models survives resampling.

This one downloads the Census Income ("Adult") set from OpenML, trains a gradient-boosted tree and a deliberately handicapped logistic regression, and runs the full pipeline over both.

average precision (5–95) recall @ precision ≥ 0.80 (5–95) AUC (5–95)
gradient boosting [0.816, 0.835] [0.593, 0.639] [0.925, 0.931]
logistic regression [0.634, 0.660] [0.227, 0.309] [0.825, 0.836]

No pair overlaps, so the notebook ends on a decision instead of two bare numbers. 48,842 rows, 23.9% positive, 14,653 held out, 100 replicas.

Why this dataset

Measured the alternatives against the same pipeline before choosing:

dataset AP band width verdict
iris (setosa vs rest) 0.000 perfectly separable — invisible band
iris (versicolor vs virginica) 0.000 same
breast_cancer 0.015 AUC 0.994, band hugs the ceiling
census income 0.017 / 0.025, on metrics at 0.83 and 0.65 genuinely hard

Iris and breast_cancer are bundled and need no network, which is why they were the obvious candidates — but logistic regression is near-perfect on both, the band collapses, and the resulting figure argues against the library. Census Income is hard enough that the intervals carry information.

CI

The notebook job gains a step and now guards both notebooks in the working tree. The checker compares text/plain payloads, so every printed number is pinned — a change that moves a metric fails the build, which is the regression signal you asked for.

Verified reproducible: executed, then re-executed through scripts/check_example_notebook.py, matching. Both models are bitwise-deterministic across runs on fixed seeds, and the house float_format = "{:.3f}" absorbs floating-point jitter across machines.

This costs the notebook job a network dependency on OpenML. A fetch failure there is a flake, not a package defect; the comment in test.yml says so. Runtime is about 10 seconds — the whole replicas pipeline over 2.96M bootstrapped rows takes ~4.5s.

Portability details

  • The AUC cell selects its columns before groupby.apply rather than passing include_groups=False, which needs pandas 2.2 — the notebook's own install line asks only for replicas[pandas,plot].
  • The notebook extra declares scikit-learn>=1.4, the floor for HistGradientBoostingClassifier(categorical_features="from_dtype").
  • Ruff flagged an unused pr_band import in the first draft; the notebook is linted like any other file now that Move the reference design notebook into docs/ #10 split the per-file ignores.

Note on the dataset

Census Income has documented baggage as a fairness benchmark (Ding et al., Retiring Adult, 2021). The notebook says so in prose, uses it only as a hard imbalanced classification problem, and does not facet any result by the race or sex columns.

Verification

ruff check, ruff format --check, 135 tests, and the notebook checker all pass.

🤖 Generated with Claude Code

hamed and others added 4 commits August 25, 2026 12:22
The existing examples measure hand-written toy rows and synthetic normal
draws. Both are fine for showing the API, but neither shows the library
doing the thing it exists for: deciding whether the gap between two models
survives resampling.

examples/census_income.ipynb downloads the Census Income set from OpenML,
trains a gradient-boosted tree and a deliberately handicapped logistic
regression, and runs the full pipeline over both. Average precision comes
out at [0.816, 0.835] against [0.634, 0.660], and at a precision floor of
0.80 the recall intervals are [0.593, 0.639] against [0.227, 0.309].
Neither pair overlaps, so the notebook ends on a decision rather than on
two bare numbers.

Real data was the point. Iris and breast_cancer are both bundled and
offline, but logistic regression is near-perfect on either -- measured AP
band widths of 0.000 and 0.015 -- so the band collapses and the figure
argues against the library. Census Income is genuinely hard, and the
bands have something to say.

The notebook is committed with its outputs and executed in CI next to the
quickstart, so a change that moves any printed number fails the build.
That costs the notebook job a network dependency on OpenML; a fetch
failure there is a flake rather than a defect, and the comment in
test.yml says so.

Two portability details: the AUC cell selects its columns before
`groupby.apply` rather than passing `include_groups=False`, which would
have required pandas 2.2 while the install line in the notebook asks only
for `replicas[pandas,plot]`; and the `notebook` extra declares
scikit-learn>=1.4, the floor for
HistGradientBoostingClassifier(categorical_features="from_dtype").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four models now instead of two: gradient boosting, a random forest, a
linear SVM, and the numeric-only logistic regression. LinearSVC has no
predict_proba, so it scores through decision_function on a -3 to 12 scale,
which makes the point that a prediction column only has to be monotone --
replicas ranks by it and never reads it as a probability.

All four average-precision intervals came out disjoint: 5th percentile of
each model clears the 95th of the next. That includes the three-point gap
between the forest and the SVM that the plotted curves cannot separate by
eye. An earlier draft of the prose guessed the opposite and had to be
corrected against the executed output.

The new sections carry race and sex through the prediction frame and
re-run the same pipeline grouped by them, first col="sex" and then
row="sex", col="race". The grid is the argument: White men hold 8,582 test
rows and 2,717 positives, while women recorded as Other hold 46 rows and 3
positives, so the bands go from tight to panel-wide and every model
becomes indistinguishable from every other.

Those columns stay out of bootstrap's `by`. Stratifying on them would fix
each subgroup's size across replicas and understate exactly the
uncertainty these sections are about.

The operating-point table needed its commentary rewritten too. Resolution
is not monotone in subgroup size -- with three positives a single true
positive at the top of the ranking gives precision 1.0 and clears the
target trivially, while the group with twenty positives resolves least
often of all. The notebook now explains that inversion rather than
implying smaller is simply worse.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drops race from the disaggregated analysis and keeps sex, as asked. The
row/column grid stays: it is now row="sex", col="age band", with the band
cut at 25/35/45/55.

Age turns out to make the point better than race did. The under-25 cells
are not small -- 1,165 women and 1,351 men -- but almost nobody in them
earns above $50K, so they hold 6 and 14 positives. A precision-recall
curve is built out of positives, so those panels are a mess of overlapping
bands while the 45-54 cells, with hundreds of positives, separate the
models in the same order the aggregate did. The lesson lands on the right
variable: it is the positive count, not the row count, that decides
whether a subgroup can be measured at all.

The sizes table now sorts by positives rather than rows, so the cells the
prose points at are the ones at the top, with the row count sitting beside
them for contrast.

The resolution commentary is rewritten against the new output. The
inversion still holds and still needs explaining: the under-25 women, with
six positives, resolve 92 times in 100 because one true positive at the
top of the ranking gives precision 1.0, while the under-25 men, with
fourteen, resolve only 70 times.

race remains a column in the dataset and therefore in the raw preview and
the models' feature set; it is only gone from the analysis.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Transposes the grid to row="age band", col="sex", so the two sexes sit
side by side within each age row and the figure reads down the age axis.
Shape goes from 2x5 to 5x2; height and aspect adjusted to suit.

Fixes a real ordering bug while here. The age band labels are strings, and
"under 25" sorts after "55+", so that panel was rendering last -- while
the prose told the reader to start from it. Renaming the band to "17-24"
(17 is the minimum age in the data) makes the labels sort chronologically
on their own, in the plot and in the two tables. Verified the grid comes
out as rows 17-24, 25-34, 35-44, 45-54, 55+ against columns Female, Male.

Prose follows the new orientation: read top to bottom, the 17-24 row
rather than the under-25 column.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant