Fix the multivariate battery's false-alarm rate (1.7% -> 0.1%) - #10
Merged
Conversation
Measured on 60 replicates of *correct* integer data (iid discrete Gaussian,
like real signatures), the extended multivariate battery rejected 1.7% of the
time against a nominal alpha of 0.1% -- a correct sampler flagged ~17x too
often. Three distinct causes, plus the meta-test that would have caught them.
1. squared_norm compared ||x||^2/sigma^2 to a *continuous* chi2(dim), but for
integer data ||x||^2 lives on a lattice of step 1, which a KS test resolves
at small sigma (20% of p-values < 0.05 on correct data). The moments are not
the issue -- the discrete and continuous ones match to ~1e-21 here -- the
lattice is. Replaced with a binned chi-square against the exact pmf of
||x||^2 (the dim-fold convolution of X^2's pmf, via FFT). That null is
analytic, so it stays calibrated *and* keeps power (a MC calibration would
floor at 1/(B+1) > alpha and silently stop rejecting).
2. henze_zirkler was doubly broken and had never worked:
- term1 (a double sum) was divided by n instead of n^2, leaving it ~n times
larger than the other terms; the statistic became a monotone function of
the mean pairwise distance and *decreased* for heavy-tailed data. Before
the fix a t(2) sample scored p = 1.00 and a correct Gaussian p = 0.38.
- the smoothing parameter used 1/(2p) instead of the standard 1/sqrt(2),
shrinking the bandwidth like 1/p (0.048 instead of 1.08 at p=16), a kernel
too flat to see the distribution's shape.
After both fixes it separates cleanly: correct Gaussian/discrete ~0.6,
uniform/t(2)/bimodal at the MC floor.
3. henze_zirkler's MC p-value used the naive mean(), which can be exactly 0
(below any alpha) ~1/(mc_B+1) of the time. Switched to (1+#)/(B+1) like the
rest of the suite. Its floor is then 1/(mc_B+1) >> alpha, so it can no longer
single-handedly reject; it is excluded from the Bonferroni gate and
contributes through the Fisher pool instead. (Giving it real rejection power
would need mc_B ~ 10/alpha replicates, each O(n^2) -- out of scope.)
Added multivariate_pvalue_uniformity() in calibration.py: the multivariate
counterpart of pvalue_uniformity(), measuring exactly this false-alarm rate. It
did not exist, which is why the above went unnoticed. Plus four tests locking
squared_norm's calibration/power/routing and HZ's p-value floor.
False-alarm rate 1.7% -> 0.0% on the same seeds; power preserved (correlated
pair, FFT-tree zeroing, and a 5% sigma error are all still caught). Suite: 157
passed.
Co-Authored-By: Claude Opus 4.8 <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.
Found by asking "does the multivariate battery behave well?" and measuring it — the same method that surfaced #7, #8 and #9.
The symptom
On 60 replicates of correct integer data (iid discrete Gaussian, like real Falcon signatures), the extended battery rejected 1.7 % of the time against a nominal α = 0.1 % — a correct sampler flagged ~17× too often. Three distinct causes.
1.
squared_norm— continuous χ² on lattice dataIt compared ‖x‖²/σ² to a continuous χ²(dim). For integer data ‖x‖² lives on a lattice of step 1, which a KS test resolves at small σ (20 % of p-values < 0.05 on correct data). The moments are not the issue — the discrete and continuous ones match to ~1e-21 here — the lattice is.
Fix: a binned χ² against the exact pmf of ‖x‖² (the dim-fold convolution of X²'s pmf, via FFT). That null is analytic, so it stays calibrated and keeps power — a Monte-Carlo calibration would floor at 1/(B+1) > α and silently stop rejecting (I tried it; it did exactly that).
2.
henze_zirkler— never actually workedTwo independent bugs:
term1(a double sum) divided byninstead ofn²β = 1/(2p)instead of the standard1/√2Before the fix, a t(2) sample scored p = 1.00 and a correct Gaussian p = 0.38 — the test was inverted and powerless. After: correct Gaussian/discrete ~0.6; uniform / t(2) / bimodal all at the MC floor.
3.
henze_zirkler— naive MC p-valuenp.mean(null >= obs)can be exactly 0 (below any α) ~1/(mc_B+1) of the time. Switched to(1+#)/(B+1)like the rest of the suite. Its floor is then 1/(mc_B+1) ≫ α, so it can no longer single-handedly reject — it is excluded from the Bonferroni gate and contributes through the Fisher pool instead. (Real rejection power would need mc_B ~ 10/α replicates, each O(n²) — out of scope, deliberately.)The meta-test that was missing
Added
multivariate_pvalue_uniformity()incalibration.py— the multivariate counterpart ofpvalue_uniformity(), measuring exactly this false-alarm rate. It did not exist, which is why all of the above went unnoticed.Results
Four tests added, locking squared_norm's calibration + power + null routing and HZ's p-value floor. Suite: 157 passed, 1 skipped.
Behaviour changes for reviewers
squared_norm_testnow returns a"null"key (exact-discrete-chi2orchi2-analytic).henze_zirkler_testcan no longer returnp = 0.🤖 Generated with Claude Code