Skip to content

Commit 31bd498

Browse files
feat(eval): bootstrap DSR confidence intervals + paired-seed significance on the leaderboard
Answers 'is A > B beyond seed noise' (Ch19 A/B) that deflated-Sharpe + pass^k don't: seed-paired percentile-bootstrap CI + paired-difference test (self-contained, deterministic). Surfaced in the Python leaderboard + EVALUATION.md. From the ADP audit.
2 parents 3f2c294 + dc9e605 commit 31bd498

9 files changed

Lines changed: 1091 additions & 25 deletions

File tree

EVALUATION.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ A leaderboard entry is incomplete unless it states all of:
5959
reporting `train` vs `test` deflated Sharpe and `gap_deflated_sharpe`. A large
6060
positive gap is overfit; near zero generalizes. An entry with a strong train number
6161
and no reported test number is presumed overfit.
62+
6. **Confidence interval on the deflated Sharpe** and, when comparing entries, the
63+
**paired-difference verdict** (see the next section). A ranked number with no interval,
64+
or an "A beats B" claim a paired test calls tied, is a dashboard, not a result.
6265

6366
## Cross-regime transfer (a stronger robustness signal)
6467

@@ -78,6 +81,47 @@ within-tier gap cannot see. The Rust core exposes the protocol primitive
7881
sibling of `train_test_split`). Reporting a `calm -> hard` and a `calm -> extreme` transfer
7982
gap alongside the within-tier generalization gap is strictly stronger evidence of robustness.
8083

84+
## Statistical confidence (is A > B beyond seed noise?)
85+
86+
Deflation handles overfit-luck and pass^k handles per-run reliability, but a leaderboard has
87+
one more thing to defend when two entries are close: is A's deflated Sharpe really higher
88+
than B's, or did A just draw a kinder held-out band? That is a Ch. 19 A/B-testing question
89+
(Advances in Financial Machine Learning) that neither the deflated Sharpe nor pass^k answers.
90+
Two self-contained, deterministic tools close it, both keyed on a fixed resample seed, so a
91+
confidence report replays bit-for-bit.
92+
93+
- **Seed-paired bootstrap CI on the deflated Sharpe.** The held-out seeds are the independent
94+
sampling units. `deflated_sharpe_ci(per_seed_returns, n_trials)` resamples them with
95+
replacement, recomputes the deflated Sharpe on each resample, and returns the percentile
96+
interval `{point, lo, hi, width}`. The `point` is exactly the number the leaderboard ranks
97+
on (the deflation footprint is matched), so the CI brackets it; the interval widens for a
98+
noisier or shorter track, where fewer seeds carry the headline. `run_baselines` attaches
99+
this to every row as `deflated_sharpe_ci`, and `leaderboard_markdown(rows, show_ci=True)`
100+
prints it as a column.
101+
102+
- **Paired-difference significance test.** `pairwise_significance(rows)` runs `paired_dsr_diff`
103+
down the ranked board: each bootstrap draw feeds the **same** resampled seeds to both
104+
neighbours, so the price-path luck common to both cancels and the difference isolates skill.
105+
When the difference CI straddles zero the two entries are **statistically tied**; otherwise
106+
the higher-ranked one wins **beyond seed noise**. `significance_markdown` renders one verdict
107+
per adjacent pair.
108+
109+
Reproduce over the baselines with:
110+
111+
```bash
112+
cd crates/openoutcry-py
113+
python -c "from openoutcry.baselines import run_baselines, leaderboard_markdown; \
114+
from openoutcry.confidence import pairwise_significance, significance_markdown; \
115+
rows = run_baselines(n_symbols=4, n_days=120, seeds=range(16), distribution_mode='calm'); \
116+
print(leaderboard_markdown(rows, show_ci=True)); print(); \
117+
print(significance_markdown(pairwise_significance(rows)))"
118+
```
119+
120+
The Rust core (`openoutcry::leaderboard_ci`) exposes the same primitives,
121+
`bootstrap_dsr_ci` and `paired_dsr_diff`, over per-seed return series, with the deflated
122+
Sharpe math ported self-contained (Bailey & López de Prado) so no extra dependency is pulled
123+
in to draw the interval.
124+
81125
## Adaptive curriculum (training side)
82126

83127
For *training* (this is a training aid, not a leaderboard rule), an adaptive curriculum
@@ -148,5 +192,7 @@ print(leaderboard_markdown(run_baselines(n_symbols=4, n_days=120, seeds=range(16
148192
## The social contract
149193

150194
Report the canonical env-ID, the tier, the cost and leakage model, and the
151-
generalization gap alongside your deflated Sharpe and pass^k rate. Rank on the deflated,
152-
process-checked number. A score with no held-out gap is a dashboard, not a result.
195+
generalization gap alongside your deflated Sharpe (with its bootstrap CI) and pass^k rate.
196+
Rank on the deflated, process-checked number, and when you claim one entry beats another,
197+
back it with the paired-difference verdict. A score with no held-out gap is a dashboard, not
198+
a result; an "A > B" with no significance test is a coin flip dressed as one.

crates/openoutcry-py/python/openoutcry/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@
5555
MaxSharpePolicy,
5656
KellyVolTargetPolicy,
5757
)
58+
from .confidence import (
59+
deflated_sharpe_ci,
60+
paired_dsr_diff,
61+
pairwise_significance,
62+
significance_markdown,
63+
)
5864
from .rewards import (
5965
REWARD_SCHEMES,
6066
list_reward_schemes,
@@ -197,6 +203,10 @@
197203
"mandate_reward",
198204
"run_baselines",
199205
"leaderboard_markdown",
206+
"deflated_sharpe_ci",
207+
"paired_dsr_diff",
208+
"pairwise_significance",
209+
"significance_markdown",
200210
"to_minari",
201211
"to_minari_train_test",
202212
"MultiAgentOpenOutcryEnv",

crates/openoutcry-py/python/openoutcry/baselines.py

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@
2323

2424
import numpy as np
2525

26+
from .confidence import (
27+
DEFAULT_ALPHA,
28+
DEFAULT_N_BOOT,
29+
DEFAULT_RESAMPLE_SEED,
30+
deflated_sharpe_ci,
31+
)
2632
from .gym import OpenOutcryEnv
2733
from .openoutcry_py import score_run
2834

@@ -313,6 +319,10 @@ def run_baselines(
313319
distribution_mode: str = "calm",
314320
max_steps: int = 512,
315321
n_trials: Optional[int] = None,
322+
confidence: bool = True,
323+
n_boot: int = DEFAULT_N_BOOT,
324+
resample_seed: int = DEFAULT_RESAMPLE_SEED,
325+
alpha: float = DEFAULT_ALPHA,
316326
) -> list[dict]:
317327
"""Roll every reference policy over ``seeds`` and score it with SharpeBench.
318328
@@ -322,56 +332,88 @@ def run_baselines(
322332
``n_trials`` defaults to the number of baseline policies — the honest declared
323333
in-sample search breadth, which deflates the Sharpe for multiple-comparison luck.
324334
325-
Returns one row per policy: ``{policy, deflated_sharpe, passed_k_rate, mean_return}``.
335+
When ``confidence`` is set (default), each row also carries a seed-paired bootstrap CI
336+
on the deflated Sharpe (``deflated_sharpe_ci``) and the per-seed return series
337+
(``per_seed_returns``) that a paired significance test consumes — see
338+
:func:`~openoutcry.confidence.pairwise_significance`. The CI's ``point`` equals the row's
339+
``deflated_sharpe`` because the deflation footprint is matched. ``n_boot`` /
340+
``resample_seed`` / ``alpha`` tune the bootstrap and are deterministic in the seed.
341+
342+
Returns one row per policy: ``{policy, deflated_sharpe, passed_k_rate, mean_return}``
343+
plus, when ``confidence`` is set, ``{deflated_sharpe_ci, per_seed_returns}``.
326344
"""
327345
seeds = list(seeds)
328346
trials = len(BASELINE_POLICIES) if n_trials is None else int(n_trials)
329347
rows: list[dict] = []
330348
for name, factory in BASELINE_POLICIES:
331349
pooled: list[float] = []
332350
passed: list[float] = []
351+
per_seed: list[list[float]] = []
333352
for s in seeds:
334353
policy = factory()
335354
env = _make_env(n_symbols, n_days, s, distribution_mode)
336355
returns = _rollout_returns(env, policy, max_steps)
356+
per_seed.append(returns)
337357
pooled.extend(returns)
338358
if len(returns) >= 2:
339359
comp = json.loads(score_run(returns, trials))
340360
passed.append(1.0 if comp.get("passed_k", False) else 0.0)
341361
composite = json.loads(score_run(pooled, trials)) if len(pooled) >= 2 else {}
342-
rows.append(
343-
{
344-
"policy": name,
345-
"deflated_sharpe": float(composite.get("deflated_sharpe", 0.0)),
346-
"passed_k_rate": float(np.mean(passed)) if passed else 0.0,
347-
"mean_return": float(np.mean(pooled)) if pooled else 0.0,
348-
}
349-
)
362+
row = {
363+
"policy": name,
364+
"deflated_sharpe": float(composite.get("deflated_sharpe", 0.0)),
365+
"passed_k_rate": float(np.mean(passed)) if passed else 0.0,
366+
"mean_return": float(np.mean(pooled)) if pooled else 0.0,
367+
}
368+
if confidence:
369+
row["deflated_sharpe_ci"] = deflated_sharpe_ci(
370+
per_seed,
371+
trials,
372+
n_boot=n_boot,
373+
resample_seed=resample_seed,
374+
alpha=alpha,
375+
)
376+
row["per_seed_returns"] = per_seed
377+
rows.append(row)
350378
return rows
351379

352380

353-
def leaderboard_markdown(rows: Sequence[dict]) -> str:
381+
def leaderboard_markdown(rows: Sequence[dict], *, show_ci: bool = False) -> str:
354382
"""Render baseline ``rows`` as a markdown table sorted by deflated Sharpe (desc).
355383
356384
The sort key is deflated Sharpe and *only* deflated Sharpe — the social contract
357385
is that entrants are ranked on the deflated, process-checked number, never raw
358386
return. Mean return is shown for context, not for ranking.
387+
388+
With ``show_ci`` set, an extra column reports the seed-paired bootstrap 95% CI on the
389+
deflated Sharpe (from each row's ``deflated_sharpe_ci``), so the table shows not just the
390+
ranked number but how firmly the seeds support it. Default off, so the canonical baseline
391+
tables reproduce byte-identically.
359392
"""
360393
ordered = sorted(rows, key=lambda r: r.get("deflated_sharpe", 0.0), reverse=True)
361-
lines = [
362-
"| Rank | Policy | Deflated Sharpe | pass^k rate | Mean return |",
363-
"|---|---|---|---|---|",
364-
]
394+
if show_ci:
395+
header = "| Rank | Policy | Deflated Sharpe | 95% CI | pass^k rate | Mean return |"
396+
sep = "|---|---|---|---|---|---|"
397+
else:
398+
header = "| Rank | Policy | Deflated Sharpe | pass^k rate | Mean return |"
399+
sep = "|---|---|---|---|---|"
400+
lines = [header, sep]
365401
for i, r in enumerate(ordered, start=1):
366-
lines.append(
367-
"| {rank} | {policy} | {ds:.4f} | {pk:.2f} | {mr:.6f} |".format(
368-
rank=i,
369-
policy=r.get("policy", "?"),
370-
ds=float(r.get("deflated_sharpe", 0.0)),
371-
pk=float(r.get("passed_k_rate", 0.0)),
372-
mr=float(r.get("mean_return", 0.0)),
402+
cells = [
403+
str(i),
404+
str(r.get("policy", "?")),
405+
"{:.4f}".format(float(r.get("deflated_sharpe", 0.0))),
406+
]
407+
if show_ci:
408+
ci = r.get("deflated_sharpe_ci") or {}
409+
cells.append(
410+
"[{lo:.4f}, {hi:.4f}]".format(
411+
lo=float(ci.get("lo", 0.0)), hi=float(ci.get("hi", 0.0))
412+
)
373413
)
374-
)
414+
cells.append("{:.2f}".format(float(r.get("passed_k_rate", 0.0))))
415+
cells.append("{:.6f}".format(float(r.get("mean_return", 0.0))))
416+
lines.append("| " + " | ".join(cells) + " |")
375417
return "\n".join(lines)
376418

377419

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
"""Statistical confidence for the leaderboard ranking (bootstrap CI + paired A/B test).
2+
3+
The benchmark ranks on the **deflated Sharpe** (which discounts overfit-luck) plus the
4+
**pass^k** rate (per-run reliability). Neither answers the question a leaderboard has to
5+
defend when two entries are close: *is A's number better than B's beyond seed noise, or did
6+
A just draw a kinder held-out band?* This module closes that leg (Advances in Financial
7+
Machine Learning, Ch. 19, A/B testing under sampling uncertainty):
8+
9+
* :func:`deflated_sharpe_ci` puts a **seed-paired bootstrap CI** around an entry's deflated
10+
Sharpe by resampling its held-out seeds (the independent sampling units) with replacement.
11+
A wide interval means the headline number rests on a few lucky seeds.
12+
* :func:`paired_dsr_diff` runs a **paired-difference significance test** across the *shared*
13+
held-out seed band: each bootstrap draw feeds the same resampled seeds to both entries, so
14+
the common price-path luck cancels and the difference isolates skill. A difference CI that
15+
straddles zero means the two entries are statistically tied.
16+
* :func:`pairwise_significance` applies the paired test down a ranked leaderboard, so each
17+
neighbouring pair is labelled ``a_better`` / ``tied`` and the ranking states which gaps are
18+
real and which are within seed noise.
19+
20+
The heavy lifting is the self-contained Rust core (no ``sharpebench-stats`` dependency); the
21+
deflation footprint is folded on the Rust side so a CI here brackets the same point deflated
22+
Sharpe :func:`~openoutcry.score_run` reports. Everything is deterministic in
23+
``resample_seed``, so a confidence report replays bit-for-bit.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import json
29+
from typing import Sequence
30+
31+
from .openoutcry_py import bootstrap_dsr_ci as _bootstrap_dsr_ci
32+
from .openoutcry_py import paired_dsr_diff as _paired_dsr_diff
33+
34+
# The scoring kernel's own bootstrap seed (``ScoreConfig::default().bootstrap_seed``), reused
35+
# so the confidence layer's resampling shares the benchmark's canonical seed by default.
36+
DEFAULT_RESAMPLE_SEED = 0x5BA7_2026
37+
DEFAULT_N_BOOT = 2000
38+
DEFAULT_ALPHA = 0.05
39+
40+
PerSeedReturns = Sequence[Sequence[float]]
41+
42+
43+
def deflated_sharpe_ci(
44+
per_seed_returns: PerSeedReturns,
45+
n_trials: int = 0,
46+
*,
47+
n_boot: int = DEFAULT_N_BOOT,
48+
resample_seed: int = DEFAULT_RESAMPLE_SEED,
49+
alpha: float = DEFAULT_ALPHA,
50+
) -> dict:
51+
"""Seed-paired percentile bootstrap CI on an entry's deflated Sharpe.
52+
53+
``per_seed_returns`` is one per-bar return series per held-out seed. ``n_trials`` is the
54+
entry's *declared* in-sample search budget (folded onto the kernel's baseline footprint
55+
Rust-side, so the CI brackets the ``score_run`` point). Returns
56+
``{point, lo, hi, width, confidence, n_boot}``.
57+
"""
58+
rows = [list(map(float, r)) for r in per_seed_returns]
59+
return json.loads(
60+
_bootstrap_dsr_ci(rows, int(n_trials), int(n_boot), int(resample_seed), float(alpha))
61+
)
62+
63+
64+
def paired_dsr_diff(
65+
a_per_seed_returns: PerSeedReturns,
66+
b_per_seed_returns: PerSeedReturns,
67+
n_trials: int = 0,
68+
*,
69+
n_boot: int = DEFAULT_N_BOOT,
70+
resample_seed: int = DEFAULT_RESAMPLE_SEED,
71+
alpha: float = DEFAULT_ALPHA,
72+
) -> dict:
73+
"""Paired-difference significance test between two entries on the **same** seed band.
74+
75+
``a_per_seed_returns[i]`` and ``b_per_seed_returns[i]`` must be the two entries' return
76+
series on the *same* seed ``i`` (the pairing is what cancels the shared price-path luck).
77+
Returns ``{point_diff, lo, hi, p_value, confidence, significant, verdict, n_boot}`` with
78+
``verdict`` one of ``"a_better"`` / ``"b_better"`` / ``"tied"``.
79+
"""
80+
a = [list(map(float, r)) for r in a_per_seed_returns]
81+
b = [list(map(float, r)) for r in b_per_seed_returns]
82+
return json.loads(
83+
_paired_dsr_diff(
84+
a, b, int(n_trials), int(n_boot), int(resample_seed), float(alpha)
85+
)
86+
)
87+
88+
89+
def pairwise_significance(
90+
rows: Sequence[dict],
91+
n_trials: int = 0,
92+
*,
93+
n_boot: int = DEFAULT_N_BOOT,
94+
resample_seed: int = DEFAULT_RESAMPLE_SEED,
95+
alpha: float = DEFAULT_ALPHA,
96+
) -> list[dict]:
97+
"""Paired significance verdict for each adjacent pair down a ranked leaderboard.
98+
99+
``rows`` are leaderboard entries (each carrying ``"policy"`` and ``"per_seed_returns"``,
100+
as produced by :func:`~openoutcry.baselines.run_baselines`). They are ranked by deflated
101+
Sharpe (desc) and each neighbouring pair ``(A, B)`` is tested; ``A`` is the higher-ranked
102+
entry, so ``verdict == "a_better"`` means the rank gap is real and ``"tied"`` means the
103+
two are within seed noise. Rows without ``"per_seed_returns"`` are skipped.
104+
"""
105+
usable = [r for r in rows if r.get("per_seed_returns")]
106+
ordered = sorted(usable, key=lambda r: r.get("deflated_sharpe", 0.0), reverse=True)
107+
out: list[dict] = []
108+
for higher, lower in zip(ordered, ordered[1:]):
109+
diff = paired_dsr_diff(
110+
higher["per_seed_returns"],
111+
lower["per_seed_returns"],
112+
n_trials,
113+
n_boot=n_boot,
114+
resample_seed=resample_seed,
115+
alpha=alpha,
116+
)
117+
out.append(
118+
{
119+
"a": higher.get("policy", "?"),
120+
"b": lower.get("policy", "?"),
121+
**diff,
122+
}
123+
)
124+
return out
125+
126+
127+
def significance_markdown(comparisons: Sequence[dict]) -> str:
128+
"""Render :func:`pairwise_significance` output as a markdown table.
129+
130+
One row per adjacent leaderboard pair: the ranked-above entry ``A``, the ranked-below
131+
entry ``B``, the deflated-Sharpe difference with its bootstrap CI, the two-sided p-value,
132+
and a plain-English verdict (``A > B beyond seed noise`` vs ``statistically tied``).
133+
"""
134+
lines = [
135+
"| A (ranked above) | B (ranked below) | Deflated Sharpe diff | 95% CI | p-value | Verdict |",
136+
"|---|---|---|---|---|---|",
137+
]
138+
for c in comparisons:
139+
verdict = (
140+
f"{c['a']} > {c['b']} beyond seed noise"
141+
if c.get("significant")
142+
else "statistically tied"
143+
)
144+
lines.append(
145+
"| {a} | {b} | {diff:+.4f} | [{lo:+.4f}, {hi:+.4f}] | {p:.3f} | {verdict} |".format(
146+
a=c.get("a", "?"),
147+
b=c.get("b", "?"),
148+
diff=float(c.get("point_diff", 0.0)),
149+
lo=float(c.get("lo", 0.0)),
150+
hi=float(c.get("hi", 0.0)),
151+
p=float(c.get("p_value", 1.0)),
152+
verdict=verdict,
153+
)
154+
)
155+
return "\n".join(lines)
156+
157+
158+
__all__ = [
159+
"deflated_sharpe_ci",
160+
"paired_dsr_diff",
161+
"pairwise_significance",
162+
"significance_markdown",
163+
"DEFAULT_RESAMPLE_SEED",
164+
"DEFAULT_N_BOOT",
165+
"DEFAULT_ALPHA",
166+
]

0 commit comments

Comments
 (0)