Skip to content

Test that the calibrators calibrate - #1

Merged
soodoku merged 1 commit into
mainfrom
tests/calibration-monte-carlo
Aug 10, 2026
Merged

Test that the calibrators calibrate#1
soodoku merged 1 commit into
mainfrom
tests/calibration-monte-carlo

Conversation

@soodoku

@soodoku soodoku commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

The gap

StreamingIsotonicCalibrator, NearlyIsotonicCalibrator, IsotonicCalibrator, PlattScaling and TemperatureScaling exist to return calibrated probabilities. Nothing tested that they do.

tests/test_calibrators.py:38-96 asserts monotonicity, range in [0,1], shape and rank preservation. The identity function satisfies all four on already-monotone input, so a calibrator that returned p_raw unchanged passed the entire suite. The only statistical assertions in the repo were assert ece < 0.05 and assert ece > 0.5 in test_metrics.py — hard-coded thresholds on a binned estimator, the first computed from a single np.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 distortion q = g(p), and outcomes are Bernoulli(p). Because g is a bijection, E[y | q] = g^-1(q) exactly, so the calibration error of any map is available in closed form:

CE(f) = sqrt( E_q [ (f(q) - g^-1(q))^2 ] )

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_error is deliberately not the yardstick: the plugin estimator charges a perfectly calibrated forecaster for its own binning noise, and test_binned_ece_charges_a_perfect_forecaster_for_noise measures 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.

distortion raw CE Streaming Nearly Isotonic Platt Temperature
slope 0.1067 0.0293 0.0286 0.0284 0.0081 0.0049
power 0.1430 0.0294 0.0268 0.0284 0.0121 0.1381
already calibrated 0.0000 0.0300 0.0267 0.0284 0.0080 0.0079
  • Error reduction: every calibrator beats the input it was handed in every replicate, both distortions.
  • Already-calibrated input is not destroyed: the damage is 0.0079–0.0300 (worst replicate 0.0460), against sd(p) = 0.2597 for a forecaster that has collapsed and the 0.1067 the distortion costs. Every calibrator clears both yardsticks in every replicate. Both yardsticks come from the DGP, not from a number picked by hand.
  • The detector is honest: max-|Z| size on the oracle map 0.0485 vs nominal 0.05; on undistorted input, identical; on either distortion, rejection rate 1.000.

One measured limitation, reported rather than smoothed over

TemperatureScaling removes 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 and p^gamma is not in its family. test_temperature_scaling_barely_helps_outside_its_family pins 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:

  • _ConstantCalibrator returns 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 on E[p] - E[y] passes it. CE 0.2598.
  • _ShufflingCalibrator fits 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_proportion on the reduction gate and on the resolution gate, via pytest.raises. test_the_constant_calibrator_passes_a_calibration_in_the_large_check shows 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 < x anywhere in the new file. simcheck enters as a git reference in a test dependency-group, not a published extra — PyPI rejects direct URL references in project metadata.

CI was installing nothing

uv sync --dev resolves the dev group; this project only had a dev extra. Nothing was installed, so ruff, mypy and pytest were never present and all three jobs have been failing on main. They now sync --extra dev --group test. Turning them back on surfaced one pre-existing formatting drift in calibrators.py and two pre-existing no-any-return errors on np.interp; both fixed here. Actions bumped to checkout@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

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>
@soodoku
soodoku merged commit 65ae64b into main Aug 10, 2026
4 checks passed
@soodoku
soodoku deleted the tests/calibration-monte-carlo branch August 10, 2026 01:00
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