Add multi-backend bootstrap and metrics - #2
Conversation
The notebook in examples/ was a lint-normalized copy of the original Colab notebook with every output stripped. Replace it with the original as executed: 13 cells with outputs, including the six figures that carry the result — the bootstrap explainer plots, the toy box plot and PR band, and the two fraud plots showing whether the class-balanced model separates from the plain one. The normalized copy also carried a regression. `ruff check --fix` removed `import numpy as np` and `from matplotlib import pyplot as plt` from the setup cell as duplicates of the cell above it, but that cell opens with "skip this cell, only for demonstration" — so setup has to stand on its own, and `plot_pr` calls `plt.fill_between`. A reader who skipped as instructed got a NameError. Ruff lints the notebook as one module, which misreads three properties of a narrative notebook. E402 was already ignored; I001 and F811 join it. Cell-local import order is authored rather than sorted, and sorting it would produce a diff on a file whose committed outputs no longer match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The notebook is the specification and the package is a port of it, but nothing in the repo said so, and the details that make it correct read as mistakes. The setup cell's re-imports were deleted once already by a lint pass on exactly that reading. docs/reference-design.md states the relationship and pins the parts that must survive future edits: why the notebook stays self-contained, the eleven load-bearing details that look redundant or wrong (the invisible alpha=0.01 lineplots that give the FacetGrid its autoscale and legend, the totals joined onto every row because calculate_pr filters dTP > 0 afterwards, the ties in the toy data, the precision=0.81 target that sits in the gap where precision is non-monotonic in threshold, and the rest), the four places the library deliberately hardens the notebook's behavior, and what the notebook designs that the library has not yet ported. Also records the derived work the notebook implies, in order: golden-value tests from its committed outputs, the Spark-native AUC that cell 47 asks for, and a single-pass bootstrap that keeps the same output contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reference-design doc listed a Spark-native auc() as work the notebook
implies. That inverts the notebook's argument. Cells 45-47 exist to show that
the bootstrap output is generic — any metric computed groupBy('replica') becomes
a distribution, with no support from this library — and the demonstration only
holds if the AUC is computed with something outside the package. Shipping
replicas.auc() would claim the opposite, that metrics must be blessed here
first.
Cell 47's aside about a trapezoidal AUC over the confusion table is advice to a
reader with large data, not a task. Recorded as a standing answer to any future
"add metric X" proposal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Full review of PR #2: two confirmed correctness bugs (calculate_pr group-column overwrite, nullable-int strata parity break), one provably inert mechanism (Spark null-flag grouping), plus behavior-preserving simplifications, API naming issues, and CI floor-coverage gaps. Each finding carries a file:line anchor and a checkbox to act on. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
hamed
left a comment
There was a problem hiding this comment.
Code review: PR #2 — Add multi-backend bootstrap and metrics
- Reviewer: Claude (Fable 5), requested by Hamed
- Date: 2026-08-04
- Scope: all 4 commits, full diff vs
main(23 files, +4874/−1367) - Verdict: approve after the two confirmed bugs are addressed. Nothing blocking
the architecture; the core design (seed derivation, checkpoint invariant, test
shape) is sound.
Validation performed locally: full suite 66 passed (matches the PR claim), CI
green (6 checks), notebook JSON validated (49 cells, 13 with outputs — matches
docs/reference-design.md), plus targeted repro scripts for every finding
marked reproduced.
1. Correctness — confirmed bugs (both reproduced)
1.1 calculate_pr silently destroys a group column named after an output metric
replicas/metrics.py:128 checks group_by conflicts only against
_CONFUSION_COLUMNS, not against the three columns the function adds
(precision, recall, average_precision).
Repro:
calculate_pr(ct, group_by=["precision"]) # no errorThe grouping column is overwritten with computed precision before the
grouped cumulative sum runs, so the running average partitions by the
overwritten float values — silently wrong numbers, no exception.
Fix (one line): extend the conflict set with
("precision", "recall", "average_precision"). The same family applies to
at() when the metric kwarg names a group column.
- fix conflict check
- add regression test
1.2 Cross-backend parity silently breaks for integer strata containing a null
pd.DataFrame({"stratum": [1, 1, 2, None]}) coerces to float64, so
_canonical_value (replicas/_sampling.py:121-123) encodes the keys on the
float path (b"f"), while Polars keeps Int64 and encodes ints (b"i").
Same seed + unique order_by → different multiplicities, no warning.
Reproduced pandas vs Polars; the same break applies pandas vs Spark long
columns.
This contradicts the README's headline guarantee ("identical source-row
multiplicities across backends") in the most common way a user writes nullable
integer strata.
Fix options:
- (preferred) encode integral floats as integers:
value.is_integer()→
b"i"path. Within one typed column, int 1 and float 1.0 cannot coexist, so
no new collision. - document the dtype-equivalence requirement explicitly.
- pick option, implement
- add cross-backend parity test with nullable-int strata
2. Provably inert mechanism — delete
Spark null-flag grouping machinery (replicas/_backends/spark.py:58-72).
Every grouping key is doubled with an isNull flag so _group_key can
distinguish Spark null from real NaN. But:
_canonical_valuemaps both tob"n"(_is_null(NaN)is true), so the
reconstructedNoneand a raw NaN produce identical seeds;isNull(col)is functionally dependent oncol, so the flags cannot change
group boundaries either (Spark already separates null from NaN).
Every path with and without the flags yields byte-identical draws. Cost: wider
shuffle keys, the _group_key slicing arithmetic, the keys[-1] convention.
Removing it collapses _grouping_columns to [df[c] for c in by]. Existing
parity tests prove behavior unchanged.
- delete flags, simplify
_group_key
3. Simplifications (behavior-preserving)
_is_null→ IEEE definition (replicas/_sampling.py:93-105): replace
pandas module-name sniffing withvalue is None or value != valueguarded
byexcept TypeError: return True(coverspd.NA, whose bool coercion
raises;NaT != NaTis already true). Shorter and strictly more general.- Ungrouped pandas totals (
replicas/_metrics_backends/pandas_backend.py:58-62):
pandas broadcasts scalars —result[target] = result[source].sum()replaces
the hand-built repeated-sum DataFrame. - Polars indexing (
replicas/_backends/polars.py:46):
source[indices.tolist()]→source.gather(indices). Verified:gather
accepts the numpy int64 array directly..tolist()materializes one Python
int per drawn row — the hot loop of the local backend. run_seeddual return: the(seed, seeded)tuple carries a fact the
caller already knows (seed is not None). Return just the seed.- Duplicated constants/dispatch:
_CONFUSION_COLUMNSis defined 4 times
(metrics.py + three backends); the mro-root backend dispatch is written
twice (bootstrap.py:21-34,metrics.py:52-60). Unify; the shared
dispatch is also the right place to reject pandasSeries/ polars
LazyFramewith a cleanTypeErrorinstead of a downstream
AttributeError. - Speculative type support in
_canonical_value
(replicas/_sampling.py:124-135):Decimal,time,bytesstrata are
YAGNI; unknown types already raise a cleanTypeError. - Duplicate test:
tests/test_import_isolation.py:28-29parametrizes the
same import with the names swapped; order cannot matter. - Benchmark scope (
benchmarks/): 267 lines whose main job is comparing
against_legacy_bootstrap— a reimplementation of code this PR deletes —
with no committed numbers. Either commit one result table in
benchmarks/README.mdor drop the legacy arm. Related: the Arrow engine is
the single biggest complexity driver inspark.pyand is currently
justified only by this unrun benchmark; keep it, but put one measured
number in the tree.
4. API intuitiveness
group_byrejects the plain stringbyaccepts —
sample(df, by="stratum")works,confusion_table(df, group_by="name")
raises (metrics.py:66-67). First thing a user hits between step 1 and
step 2 of the quick start. Accept a single string. Non-breaking.byvsgroup_by— two names for one concept across a 5-function API.
Pick one (pandas precedent:by), alias the other. Cheapest now, at 0.1.order_byreads as output ordering — SQL instinct; actually it defines
row identity for reproducible draws, and output order is explicitly
unspecified. Rename (row_key/id_by) or make the docstring lead with
"does not sort the output".sampledefaults surprise pandas users — pandasdf.sample()is
without replacement;replicas.sample(df)is always with replacement and
returns a full-size resample with duplicates. Considerresample, or state
"with replacement, same size by default" in the first docstring line.checkpoint_diris positional but Spark-only —
bootstrap(df, ["a"], 100, "/tmp/ckpt")is legal and raises on local
backends. Move behind the*.- Magic
-1— exportORIGINAL = -1so call sites read
replica == replicas.ORIGINAL.
Deliberately unchanged: at(kpi, precision=0.95) kwargs form (best call in
the API), the three-step metrics chain, the invisible same-type-in/out
dispatch.
5. Hygiene / CI
- Declared floors are never tested. CI resolves pyspark 4.0.x/4.2.x; the
pyspark>=3.3floor never runs, and the code sits exactly on it
(withColumnswas added in 3.3). Same for pandas 1.3 / polars 1.0 /
numpy 1.21. Add one matrix leg with lowest-bound pins
(e.g.uv pip install --resolution lowest-direct). - Version string duplicated —
replicas/__init__.py:10and
pyproject.tomlboth hardcode0.1.0; will drift on the first bump. Use
importlib.metadataor setuptoolsdynamic = ["version"]. - Spark Connect unsupported —
_checkpoint
(replicas/_backends/spark.py:236) touchessparkSession.sparkContext
unconditionally, which raises on Connect sessions even when
checkpoint_diris passed. Fine for a classic-3.3 floor; worth one
documented limitation line. - Head commit
73b0632has no message body — thin for a 4.9k-line
change, given how carefully the three docs commits are written. - Edge notes, doc-line severity: NaN and null strata share one PCG stream
(both encodeb"n"), and pandas merges None+NaN into one group where
Polars keeps two; floatorder_bycolumns containing NaN sort differently
across backends (pandas: missing-first; polars/arrow: value ordering);
order_byuniqueness is a documented but unverified contract — violation
on Spark is silent nondeterminism; localreplicadtype is int64 vs
Spark's int32.
6. Strengths (keep doing this)
- The
checkpoint()invariant survives the rewrite and its original
regression tests pass untouched. - Test design: golden notebook values across all three backends, pandas/Arrow
engine equivalence, batch-size invariance, constant logical-plan depth,
subprocess-based import isolation. Right coverage shape for the parity
claims made. - Extras split + backend-free base import is verified by tests, not just
claimed. docs/reference-design.mdrecording load-bearing "wrong-looking" details is
unusually good practice; its factual claims about the notebook check out
against the committed file.
f36e399 was adversarially verified: 91 tests pass, both confirmed bugs reproduce as fixed (and still reproduce on the parent commit), null and NaN strata derive distinct verified streams on every backend and both Spark engines. Amend the follow-up note to record what actually shipped: item 4.1 (string group_by) was implemented but never ticked, and three fixes appeared nowhere in the doc (casefold helper-column checks, the at() helper rename, _weighted_precision preservation). Sections 2, 3.1, and 5.5 described pre-fix behavior; each now carries a resolution note — notably the null flags the review called inert became load-bearing under the b"n"/b"N" stream split, so that recommendation is resolved as retained, and the _is_null IEEE suggestion is withdrawn for the same reason. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Verification of f36e399 (review follow-up)Adversarially verified the fix commit against the review in
🤖 Generated with Claude Code |
Summary
sample,bootstrap,confusion_table,calculate_pr, andatnative on pandas, Polars, and SparkWhy
The reference implementation was Spark-only and built one grouped pandas UDF plus union branch per replica. That made the plan grow with the replica count, materialized each Spark stratum through pandas, and offered no reproducible contract shared with local dataframe backends.
This keeps the notebook's statistical behavior while allowing the same understandable workflow to start locally and move to Spark without conversion.
Review follow-up
calculate_prcan overwrite themgroup_byUser impact
Users install only the backend they need, pass a pandas, Polars, or Spark DataFrame to the same API, and receive the same native frame type. With equivalent native strata and ordering semantics, a supplied seed plus a unique
order_byproduces identical source-row multiplicities across backends. Existing Spark imports and replica semantics remain intact.Plotting remains pandas/Spark-specific, and the executed reference notebook is unchanged.
Validation
91 passedruff check .ruff format --check .