Skip to content

Compute tail p-values with sf, not 1 - cdf - #28

Closed
stefan-jansen wants to merge 3 commits into
mainfrom
fix/pvalue-survival-function
Closed

Compute tail p-values with sf, not 1 - cdf#28
stefan-jansen wants to merge 3 commits into
mainfrom
fix/pvalue-survival-function

Conversation

@stefan-jansen

@stefan-jansen stefan-jansen commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The defect

A tail probability written as 1 - dist.cdf(x) returns exactly 0.0 once x passes
roughly 8.35, and is already wrong by 60% at 8.3: cdf rounds to 1.0 long before the
tail mass underflows, so the subtraction cancels every remaining significant digit.
dist.sf evaluates the tail directly.

Measured on stats.t with df=4231:

|t| 2 * (1 - cdf) 2 * sf
6.00 2.138e-09 2.138e-09
8.30 2.220e-16 1.385e-16
8.94 0 5.702e-19
9.50 0 3.409e-21

compute_ic_hac_stats is the one that surfaced it: a downstream notebook rendered
HAC t(3 lags): -8.94 (p=0) to a reader. The true value is 5.7e-19.

It appears in two spellings. The inline one is grep-visible. The other is not:

probability = float(norm.cdf(z_score))
p_value = float(1 - probability)

That cancels exactly as hard and reads as arithmetic rather than as a tail, which is why
the first pass over this package missed it.

What changed

18 sites.

Inline form (14):

  • metrics/ic_inference.py - compute_ic_summary_stats, compute_ic_hac_stats
  • metrics/conditional.py - conditional-IC t-test
  • evaluation/binary_metrics.py - the two proportion z-tests (one-sided greater and
    two-sided branches)
  • evaluation/event_analysis.py - the three event-study tests
  • evaluation/factor/validation.py - the Ljung-Box residual test
  • evaluation/factor/regularized.py - alpha and beta p-values
  • visualization/signal/event_plots.py - the event-plot annotation
  • visualization/backtest/tearsheet.py - the tearsheet z-test

Two-line form (4):

  • evaluation/stats/deflated_sharpe_ratio.py - the DSR p-value in both entry points.
    probability is untouched; it is the DSR itself and is meant to saturate. Only
    p_value changes, to norm.sf(z_score).
  • evaluation/trade_dashboard/tabs/stat_validation.py - the PSR p-value, which now takes
    the left tail of the z-score probabilistic_sharpe_ratio(..., return_components=True)
    already returns.

evaluation/factor/validation.py and the four two-line sites were not in the original
report; they are the same defect and are fixed here.

No decision changes: any statistic in the underflow zone is significant under any
threshold, so no verdict, winner or selection moves. What changes is that a reader no
longer sees a p-value of exactly zero, and stored p_value columns no longer carry 0.0.

Test

tests/test_evaluation/test_pvalue_tail_precision.py covers compute_ic_summary_stats,
compute_ic_hac_stats, Ljung-Box and the DSR numerically - each with a fixture whose
statistic lands past the cancellation point and short of where the true tail mass
underflows - and guards the rest with a static scan of src/.

The scan handles both spellings: it collects the names each file binds to a CDF value,
then flags 1 - <that name> wherever it appears. It reads code only, so a comment
explaining why a nearby line uses sf is prose, not the pattern.

Against main's source the numeric tests and the scan fail. On this branch all 6 pass,
and tests/test_evaluation tests/metrics tests/test_visualization tests/test_binary_metrics.py
is 3256 passed, 4 skipped.

Every two-tailed p-value in the package was written as
2 * (1 - dist.cdf(abs(stat))). That expression returns exactly 0.0 once
abs(stat) passes roughly 8.35 and is already wrong by 60% at 8.3, because
cdf rounds to 1.0 long before the tail mass underflows and the subtraction
cancels the remaining significant digits. dist.sf evaluates the tail
directly.

15 sites: compute_ic_summary_stats and compute_ic_hac_stats, the
conditional-IC test, the two binary-metric z-tests, the three event-study
tests, the Ljung-Box test in factor validation, the regularized-factor
alpha and beta p-values, the event-study plot annotation, and the
tearsheet z-test.

No decision changes: any statistic in the underflow zone is significant
under any threshold. What changes is that a reader no longer sees a
p-value of exactly zero, and stored p_value columns no longer carry 0.0.

tests/test_evaluation/test_pvalue_tail_precision.py covers the two IC
functions and Ljung-Box numerically, and guards the remaining sites with a
static scan for the 1 - <dist>.cdf( pattern.
Copilot AI review requested due to automatic review settings July 31, 2026 00:20

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

`probability = norm.cdf(z)` followed by `p_value = 1 - probability`
cancels exactly as hard as `1 - norm.cdf(z)`: it reads as arithmetic
rather than as a tail, which is why the first pass missed it and why the
regex missed it too.

Three more sites: the DSR p-value in both deflated_sharpe_ratio entry
points, and the PSR p-value in the trade dashboard's statistical
validation tab, which now takes the left tail of the z-score the same
call already returns.

The scan collects names bound to a CDF value per file and flags
`1 - <name>` wherever it appears, so both spellings are covered. It reads
code only: a comment explaining why a nearby line uses sf is prose, not
the pattern.

test_deflated_sharpe_pvalue_survives_extreme_z pins the numeric case. A
Sharpe of 0.5 over ten years puts z above 25, so `probability` is 1.0 to
the last bit while the true p-value is 2.5e-139.
The line-based version only saw an assignment whose name and .cdf( call
were on the same physical line, so a normally formatted

    probability = float(
        stats.norm.cdf(z_score)
    )

was not a CDF binding and the `1 - probability` after it went unflagged.
The regex also needed comments stripped by hand, and that hack was
load-bearing.

ast removes both. An assignment is one node however it is wrapped, and
comments and docstrings are not in the tree at all, so prose naming the
pattern is not the pattern.

Eight cases pin the detector itself: four layouts it must catch, four
kinds of correct code it must not. The one shape it still misses - a CDF
reached through a subscript rather than a bare name - is stated in the
docstring rather than left to be discovered.
@stefan-jansen stefan-jansen added compatibility: none No known compatibility impact priority: high Material user impact requiring prompt remediation status: pending-review Acknowledged and waiting for the stated review point type: bug Confirmed or reported incorrect behavior labels Aug 11, 2026
@stefan-jansen

Copy link
Copy Markdown
Contributor Author

Maintainer review is pending until 2026-08-12. The source fix is already on main and PR #36 isolates the missing regression guard; this PR will then be closed or reduced to any remaining non-duplicated coverage.

@stefan-jansen

Copy link
Copy Markdown
Contributor Author

Next review: 2026-08-12.

stefan-jansen added a commit that referenced this pull request Aug 12, 2026
* test: guard the p-value tail against 1 - cdf coming back

`2 * (1 - dist.cdf(abs(stat)))` returns exactly 0.0 once the statistic passes
about 8.35, because cdf rounds to 1.0 well before the tail mass underflows. At
|t| = 8.94 with df 4231 the true two-tailed value is 5.7e-19 and the expression
gives 0. It reached a reader: crypto_perps_funding/02_labels rendered
"HAC t(3 lags): -8.94 (p=0)".

The sources were fixed on main by 9f7a72a, and this detector is what stops them
regressing. It pins compute_ic_summary_stats, compute_ic_hac_stats, Ljung-Box
and the DSR numerically, and scans the rest statically. The scan parses rather
than greps, because the expression has a second spelling that a `1 - <dist>.cdf(`
pattern does not see:

    probability = float(norm.cdf(z_score))
    p_value = float(1 - probability)

It collects the names each file binds to a CDF value and flags `1 - <that name>`
wherever it appears, so both spellings are covered.

Verified: 14 passed against main unmodified, so main is clean. Reintroducing the
expression at metrics/ic_inference.py:57 fails three of them - the two numeric
pins and the static scan - so the guard is not vacuous.

Taken from origin/fix/pvalue-survival-function, whose 18 source fixes main has
since landed by another route; that branch is now conflicting and this file is
the only part of it main does not have. PR #28 should close
unmerged in favour of this.

* test: read source as UTF-8 in tail detector
@stefan-jansen

Copy link
Copy Markdown
Contributor Author

Superseded by #36, merged as 1554783131273d6b25f3d517dfe2c30f94405d74.

Current main already computes upper-tail p-values with the survival function. PR #36 adds the missing public-behavior regression coverage for the SciPy path, asymptotic fallback, multiple-testing correction, and one-sided alternatives without carrying this branch's stale merge conflicts. All 36 required checks passed at the reviewed #36 head before merge.

@stefan-jansen
stefan-jansen deleted the fix/pvalue-survival-function branch August 12, 2026 01:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

compatibility: none No known compatibility impact priority: high Material user impact requiring prompt remediation status: pending-review Acknowledged and waiting for the stated review point type: bug Confirmed or reported incorrect behavior

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants