Skip to content

Fix the multivariate battery's false-alarm rate (1.7% -> 0.1%) - #10

Merged
tprest merged 1 commit into
masterfrom
saga-fix-multivariate-calibration
Jul 10, 2026
Merged

Fix the multivariate battery's false-alarm rate (1.7% -> 0.1%)#10
tprest merged 1 commit into
masterfrom
saga-fix-multivariate-calibration

Conversation

@tprest

@tprest tprest commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

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 data

It 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 worked

Two independent bugs:

Bug Effect
term1 (a double sum) divided by n instead of Statistic ~n× too large, became a monotone function of mean pairwise distance → decreased for heavy tails
smoothing β = 1/(2p) instead of the standard 1/√2 bandwidth ~1/p (0.048 vs 1.08 at p=16) → kernel too flat to see shape

Before 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-value

np.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() in calibration.py — the multivariate counterpart of pvalue_uniformity(), measuring exactly this false-alarm rate. It did not exist, which is why all of the above went unnoticed.

Results

before after
false-alarm rate (60 correct datasets, same seeds) 1.7 % 0.0 %
squared_norm mean p / frac<0.05 0.27 / 0.13 0.51 / 0.08
HZ on t(2) heavy tails p = 1.00 p = 0.0099
correlated pair / FFT-tree zeroing / σ+5% caught still caught

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_test now returns a "null" key (exact-discrete-chi2 or chi2-analytic).
  • The Bonferroni gate divides by 5 components instead of 6 (HZ excluded), a slightly more permissive per-component threshold.
  • henze_zirkler_test can no longer return p = 0.

🤖 Generated with Claude Code

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>
@tprest
tprest merged commit 1893b6e into master Jul 10, 2026
4 checks passed
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