Test that the calibrators calibrate - #1
Merged
Conversation
The package's headline claim had no test. Every assertion in
tests/test_calibrators.py is about monotonicity, range, shape or rank, and
the identity function satisfies all four on already-monotone input, so a
calibrator that returned p_raw unchanged passed the whole suite. The only
statistical assertions in the repo were two hard-coded ECE thresholds, one
of them computed from a single seed.
tests/test_calibration_studies.py generates a known, controlled
miscalibration -- q = g(p) for a strictly monotone g, y ~ Bernoulli(p) --
so the true calibration map is g^-1 in closed form and the calibration
error of any map can be computed exactly, with no binning and no threshold
to choose. Twenty-eight tests over two distortions:
* every calibrator reduces the exact L2 calibration error, in every
replicate: 0.1067 -> 0.0049-0.0300 (slope), 0.1430 -> 0.0121-0.0294
(power);
* on already-calibrated input none of them destroys the signal -- the
damage is 0.0079-0.0300, against sd(p) = 0.2597 for a collapsed
forecaster and the 0.1067 the distortion costs;
* a constant calibrator and a shuffling calibrator are run through the
same gates and required to be rejected. Both pass every assertion the
suite already had.
The one measured limitation is written down rather than smoothed over:
TemperatureScaling removes 3.4% of a power miscalibration where the others
remove 79-92%, because it has one parameter and the distortion is not in
its family.
Gates come from simcheck, so their tolerance is derived from the replicate
count. simcheck is a git reference in a `test` dependency-group, not a
published extra, because PyPI rejects direct URLs in project metadata.
CI was installing nothing: `uv sync --dev` resolves the `dev` *group*, and
this project only had a `dev` *extra*, so ruff, mypy and pytest were never
present and all three jobs failed on main. They now sync `--extra dev
--group test`, which surfaced one formatting drift and two mypy
no-any-return errors on np.interp; both fixed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
StreamingIsotonicCalibrator,NearlyIsotonicCalibrator,IsotonicCalibrator,PlattScalingandTemperatureScalingexist to return calibrated probabilities. Nothing tested that they do.tests/test_calibrators.py:38-96asserts monotonicity, range in [0,1], shape and rank preservation. The identity function satisfies all four on already-monotone input, so a calibrator that returnedp_rawunchanged passed the entire suite. The only statistical assertions in the repo wereassert ece < 0.05andassert ece > 0.5intest_metrics.py— hard-coded thresholds on a binned estimator, the first computed from a singlenp.random.seed(42)draw.The design
The truth is set by construction. True probabilities
p ~ U(0.05, 0.95)are drawn, the reported probabilities are a deterministic strictly monotone distortionq = g(p), and outcomes areBernoulli(p). Becausegis a bijection,E[y | q] = g^-1(q)exactly, so the calibration error of any map is available in closed form:No binning, no estimator bias, no threshold to choose. Two distortions: a pure temperature shift (
logit q = 0.5 logit p, every method correctly specified) and a power law (q = p^0.6, temperature scaling misspecified — a study where every method is well specified cannot tell a method that works from one the fixture is flattering).Both distortions are strictly monotone, which is precisely why the existing monotonicity and rank tests cannot see them.
expected_calibration_erroris deliberately not the yardstick: the plugin estimator charges a perfectly calibrated forecaster for its own binning noise, andtest_binned_ece_charges_a_perfect_forecaster_for_noisemeasures it — 0.0104 at 5 bins, 0.0144 at 20, 0.0481 at 200, on a forecaster whose true calibration error is exactly zero. The one binned statistic used here is a max-|Z| test whose null distribution is known; its size is measured at 0.0485 against a nominal 0.05 over 2000 replicates before anything is concluded from it.What the studies measured
All claims HOLD.
One measured limitation, reported rather than smoothed over
TemperatureScalingremoves 3.4% of a power miscalibration (0.1430 → 0.1381) where Platt removes 91.5% and the isotonic methods 79–80%. It technically reduces the error in every replicate, so the reduction gate passes — but "it reduces calibration error" would badly misdescribe it. It has one parameter andp^gammais not in its family.test_temperature_scaling_barely_helps_outside_its_familypins the gap relative to the weakest other calibrator so it cannot drift into a claim.Negative controls
Every gate has a case that makes it fail. Two broken calibrators, both built to survive the assertions the suite already had:
_ConstantCalibratorreturns the base rate. It is monotone (weakly), in range, right-shaped, rank preserving, and calibrated in the large — its mean output matches the mean outcome to within 0.01, so any check onE[p] - E[y]passes it. CE 0.2598._ShufflingCalibratorfits correctly then permutes the answers. Its output has the exact marginal distribution of a correct calibrator — same values, same histogram, same distinct levels — and none attached to the right input. CE 0.3684.Both are required to be rejected by
assert_proportionon the reduction gate and on the resolution gate, viapytest.raises.test_the_constant_calibrator_passes_a_calibration_in_the_large_checkshows in one measurement why the conditional test is the one being run.Tolerances
Every gate is a simcheck gate, so its tolerance comes from the replicate count. There is no
assert ece < xanywhere in the new file. simcheck enters as a git reference in atestdependency-group, not a published extra — PyPI rejects direct URL references in project metadata.CI was installing nothing
uv sync --devresolves thedevgroup; this project only had adevextra. Nothing was installed, soruff,mypyandpytestwere never present and all three jobs have been failing onmain. They now sync--extra dev --group test. Turning them back on surfaced one pre-existing formatting drift incalibrators.pyand two pre-existingno-any-returnerrors onnp.interp; both fixed here. Actions bumped tocheckout@v5/setup-uv@v7.Counts
uv run pytest tests/ -v: 58 passed (30 pre-existing, 28 new), 0 failed, 0 skipped.uv run ruff check src tests: clean.uv run ruff format --check src tests: clean.uv run mypy src: clean.🤖 Generated with Claude Code