Monte Carlo coverage studies for bootstrap_ci and simulate_hhi_ci - #14
Merged
Conversation
`bootstrap_ci` and `simulate_hhi_ci` are the only places in the package that attach an uncertainty statement to a number that reaches the paper, and neither had been checked against a known truth. The three tests that appeared to cover them cannot fail: one bootstraps a constant vector, where every resample is that constant and both endpoints are 0.4 by construction; one calls `simulate_hhi_ci(recall=1.0, precision=1.0)`, the branch where no simulation runs at all; one asserts `lo <= median <= hi`, true of any three sorted quantiles of any array. Adds tests/test_metrics_coverage.py: Monte Carlo coverage studies gated with simcheck, so every tolerance is derived from the replicate count. Renames the three tests above to say what they really check and adds two real assertions alongside them. Three findings, each pinned by a test that must fail: - The percentile bootstrap covers 0.819 of a nominal 0.95 for the mean of a lognormal(0, 1.25) sample at n=25. Textbook -- it is why BCa exists -- but per-source quote counts are exactly that shape. - `simulate_hhi_ci`'s point correction is arithmetically a no-op. It multiplies every source's count by the same factor precision/recall in expectation, and HHI is scale-invariant, so its median reproduces hhi(observed) to five decimals at any recall and precision. The interval is real; the correction is not. - Consequently a *tail-biased* recall -- the case the module docstring is written to motivate, where one-off voices are missed more often than headline sources -- is not corrected at all: coverage 0.057, with a bias almost as wide as the whole interval. Where the interval does hold: 0.945 on well-covered sources over 1000 replicates, and 0.946/0.961 for bootstrapped validation rates. simcheck joins the `test` dependency group as a git reference, following geoinference; PyPI rejects direct URL references in published metadata, so it must stay out of a published extra. Relocking also picked up pandera 0.32.1 and typer 0.27.1, which pyproject.toml already required but uv.lock had not been refreshed for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Member
Author
|
Note on the one red check: `ci / dependency-review` fails with
That is a repository setting (Dependency graph is off for `notnews/covered`), not anything this branch introduced — PRs #11, #12 and #13 all fail the same check in 4-5 seconds with the same error. Every other check passes: lint, pyright, the built-wheel smoke test, workflow-security, docs, and the test matrix on Python 3.11/3.12/3.13. |
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.
src/covered/metrics.pyships two confidence intervals. Neither had ever beenchecked against a known truth. This adds
tests/test_metrics_coverage.py: realMonte Carlo coverage studies, every gate taken from
simcheck so its tolerance is derived
from the replicate count rather than chosen by hand.
The cautionary example: three tests that cannot fail
These are the tests that made the intervals look covered.
tests/test_metrics.py:24test_bootstrap_ci_brackets_meanEvery resample of a constant vector is that constant, so both endpoints are 0.4
by construction. A wrong quantile, a wrong
alpha, an off-by-one resample, andreturn (lo, lo)all pass it. It is a test ofnp.mean.tests/test_metrics.py:30test_simulate_hhi_no_error_is_deterministicrecall=1.0, precision=1.0is the one branch in whichkept = obsandadd_factor = 0, so no simulation runs at all. The three asserted quantilesare equal because all fifty draws are the same number. It is a test of
hhi.tests/test_metrics.py:38test_simulate_hhi_with_error_brackets_and_ordersTrue of any three sorted quantiles of any array whatsoever, including an all-zero
one.
I renamed all three to say what they actually check, added a docstring to each
explaining why the original name over-claimed, and added two real assertions:
test_bootstrap_ci_widens_with_the_confidence_level(same sample, sameresampling seed, so the 95% and 80% intervals must nest strictly — the constant
sample cannot see
alphaat all), and a strictlo < median < hiin place ofthe non-strict one. Nothing was deleted.
Results
Replicate counts below are from the development runs quoted in the docstrings;
the gates themselves run at simcheck's tier (100 fast / 400 deep), or at a fixed
600 where a pinned failure needs the power. Both tiers were run and both pass.
bootstrap_cialpha=0.20, Bernoulli n=100alpha=0.20, Bernoulli n=200The heavy-tailed failure is the headline. The percentile bootstrap is not
second-order accurate for the mean of a skewed distribution — the interval is
centred on the sample mean and inherits its skew instead of correcting for it,
which is the standard motivation for BCa and the bootstrap-t. It is a known
property rather than a defect introduced here, but per-source quote counts in
the CNN corpus are exactly that shape, and a 95% label on an interval that
covers 82% of the time is a claim the paper should not make silently. The deficit
closes only slowly with sample size:
It is pinned the way geoinference pins its known-bad cases:
pytest.raises(AssertionError)around
assert_coverage, plus a directional check againstbinomial_band(0.95, reps)[0].Also recorded but not gated: the Normal row's uniform 1.7-point deficit, which is
the percentile bootstrap applying no t correction.
simulate_hhi_ciFinding 1: the point correction is arithmetically a no-op.
simulate_hhi_cikeeps each observed event with probability
precisionand then adds back(1 - recall) / recalltimes what it kept, so in expectation it multiplies everysource's count by the same factor
precision / recall. HHI is a function ofshares and invariant to a common scale. The corrected median is therefore
hhi(observed), whatever recall and precision are set to — measured on a fixedfive-source market with raw HHI 0.603340:
The module docstring promises "a corrected series with credible intervals next to
the raw one". The interval is real; the correction is not. This is pinned by
test_the_point_correction_is_a_no_op, which anything that genuinely moved thepoint estimate would break — and that is the intended way for it to break.
Finding 2: the case the docstring actually claims is the case it cannot
handle.
covered/metrics.pyopens by saying missed sources "drop mostlytail/one-off voices and bias HHI upward". That is a statement about recall
being lower for the tail than for the headline sources — the realistic case, an
extractor that reliably attributes a quote to the President but misses a one-time
caller.
simulate_hhi_citakes a single scalar recall and applies it to everysource alike, and by Finding 1 that rescaling cancels out of HHI. The
correction is structurally incapable of removing the bias its own module
docstring is written to motivate. With headline recall 0.9 and one-off recall
0.6, coverage is 0.057 over 600 replicates, with a mean signed error of +0.0148
against a mean interval width of 0.0156 — the bias is almost the whole width of
the interval. Forty of the hundred one-off sources vanish outright. Pinned.
Finding 3: the weakness the docstring admits is real, monotone, and much
smaller than the docstring implies. "Missed events are conservatively attributed
to already-observed sources, so the correction is a lower bound on tail
diversity." A source missed entirely is not in the input, so nothing in the
simulation can restore it; the imputed market has fewer sources than the real one
and reads more concentrated. Coverage falls monotonically with the number of
sources lost — 0.921 / 0.892 / 0.873 as recall drops through 0.9 / 0.7 / 0.5 and
10 / 30 / 50 of a hundred one-off sources vanish — with the signed error always
positive, exactly as predicted.
But the effect is small, and that is the part worth recording, because it is
not what the module docstring implies. Under a uniform recall the tail is thinned
in the same proportion as the head, so the surviving shares stay nearly unbiased
even though whole sources are gone; HHI reads shares, is dominated by the head,
and a one-off source contributes
(1/N)**2to it. Losing half the one-offsources moves the point estimate by 0.0004 on an HHI of 0.20. The admitted
weakness bites hard on the diversity measures in
covered.hhi(n_distinct,normalized_entropy), which have no interval at all, and only barely on HHI.Only the recall-0.5 rung is far enough below nominal for a gate this file can
afford to resolve — 0.873 against a 600-replicate floor of 0.923 is 3.7 standard
errors, while pinning 0.921 would need about 2400 replicates and eighteen minutes
— so that is the rung gated, with the other two recorded in the docstring.
One methodological trap worth knowing about. Coverage of this interval depends
on
n_draws. It is a pair of empirical quantiles ofn_drawssimulated HHIs, andnp.nanquantile's linear interpolation places the nominal 2.5% endpoint at aboutthe
0.025 + 0.975/n_drawsquantile, so the interval spans about0.95 - 1.95/n_drawsof the simulated distribution — 0.948 at the defaultn_draws=1000, 0.940 at 199. Verified directly against a standard normal over40000 trials: 0.9490 and 0.9413. My first pass ran the studies at 199 draws to
save time and measured a systematic ~2-point deficit across fifteen
(recall, precision) settings; at the library default it disappears. The studies
now run at the default, and the module docstring says so, so that nobody reruns
them cheaply and concludes the estimator is broken.
Negative controls
Every claim has a case that must fail.
test_a_shrunk_interval_is_caught— the 95% interval shrunk byz(0.90)/z(0.975) = 0.654, the derived factor that turns a Gaussian 95%interval into an 80% one. Measured 0.790 over 400 replicates. It must fail a
95% gate and pass an 80% one, and both halves are asserted. Proves the
coverage gate rejects a too-narrow interval, and — the half that matters — that
it is not a blanket rejector.
test_the_two_nominal_levels_are_distinguishable— thealpha=0.20studyjudged at 95% must raise, and the
alpha=0.05study judged at 80% must raise.Measured 0.810 and 0.946 over 400 replicates each. Proves the two
alphatests are not both passing on a band wide enough to admit either answer, so an
implementation that ignored
alphaentirely cannot satisfy both.test_assuming_perfect_extraction_is_caught—simulate_hhi_ci(recall=1.0, precision=1.0), the exact call the vacuous existing test makes, pointed atgenuinely corrupted counts. It returns a degenerate interval at
hhi(observed).Measured 0.000. Proves
assert_coverageis measuring coverage in the HHIstudies; if it ever stops failing, every other HHI gate is certifying nothing.
test_tail_biased_recall_is_caught_under_coveringandtest_a_vanishing_tail_under_covers— Findings 2 and 3 above, also servingas controls. Prove the HHI fixture family is capable of producing a biased
interval at all, so
test_well_covered_sources_coverholding is not anartefact of a fixture too easy to fail.
test_a_skewed_mean_at_small_n_under_covers— the bootstrap failure,likewise. Proves the bootstrap fixture family can produce under-coverage.
Verification
The whole suite was 121 passing before this branch; it is 137 now — 15 from the
new file and 1 new assertion in
tests/test_metrics.py. Nothing skips.pyproject.tomlgains simcheck in thetestdependency group as a git reference,matching geoinference's pattern — PyPI rejects direct URL references in published
metadata, so it must not go in a published extra.
uv.lockis committed;regenerating it also picked up
pandera 0.31.1 -> 0.32.1andtyper 0.26.7 -> 0.27.1, whichpyproject.tomlonmainalready required (pandera>=0.32.1,typer>=0.27.0) but the lock had not been refreshed for — souv sync --frozen,which CI runs, was already inconsistent on
main.pydoclintis not run: it fails to import in this venv (ImportError: cannot import name 'DocstringYields' from 'docstring_parser.common'), the pre-existingdocstring_parserconflict that.github/workflows/ci.ymldocuments and disableswith
run-pydoclint: false.🤖 Generated with Claude Code