Skip to content

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

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

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.
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.

2 participants