fix(audit): lookahead housekeeping — purge gap + per-fold NormStats
Closes recs 2 + 3 in lookahead-bias-audit-2026-04-28.md.
Fix 2A (rec 2): add walk_forward::PURGE_BARS = 5 constant and insert
val_start = train_end + PURGE_BARS in all 4 fold-construction sites:
generate_walk_forward_indices, _from_timestamps, _windows (date-based,
drops first PURGE_BARS bars of val), and gpu_walk_forward::generate_folds
(both stratified variants preserve the gap on boundary shift). 5-bar
width matches max per-bar feature lookback (autocorr lags 1/5/10);
compile-time per feedback_isv_for_adaptive_bounds since the
feature-pipeline lookback is itself compile-time.
Fix 2B (rec 3): per-fold NormStats fit from train-only data.
- walk_forward.rs: add from_features_slice + denormalize/denormalize_batch
helpers (inverse of normalize_batch for clamp-bounded round-trip).
- train_baseline_rl.rs fold loop: load fxcache sidecar norm_stats.json,
denormalise back to RAW, refit per-fold via from_features_slice,
renormalise full dataset, re-upload via init_from_fxcache, save
per-fold norm_stats_fold{N}.json. DBN-fallback path keeps legacy
behaviour (no sidecar to denormalise from) with a warn! log.
Ensemble trainer block is documented follow-up (separate code path).
Behavioral tests:
- test_norm_stats_per_fold_fit_train_only: synthetic train-mean=1.0 /
val-mean=9.0 dataset; from_features_slice recovers train-only mean to
ε=1e-5 while from_features returns global 5.0
- test_norm_stats_denormalize_roundtrip: non-clamped features round-trip
bit-identically
- test_walk_forward_purge_gap_indices_from_timestamps: every fold has
val_start - train_end == PURGE_BARS
- test_fold_generation_basic (gpu_walk_forward): updated to expect the
purge gap
Validation: cargo check --workspace clean (12.30s); 20/20 walk_forward
+ gpu_walk_forward tests pass; audit_sp18_consumers.sh --check exit 0.
Pre-existing test failures on local RTX 3050 Ti are unrelated SIGSEGVs
(VRAM exhaustion in chunked Thompson tests, pre-existing on baseline).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,105 @@
|
||||
|
||||
**Status:** Populated during Plan 1 Task 6 (A.5 orphan audit). Updated on every commit per Invariant 7.
|
||||
|
||||
## 2026-05-09 — Lookahead audit housekeeping: purge gap + per-fold NormStats
|
||||
|
||||
Closes recs 2 + 3 in `docs/lookahead-bias-audit-2026-04-28.md`. Two
|
||||
correctness fixes, one atomic commit per `feedback_no_partial_refactor`:
|
||||
|
||||
**Fix 2A — train→val purge gap (rec 2)**
|
||||
|
||||
Adds `walk_forward::PURGE_BARS = 5` constant and inserts it as
|
||||
`val_start = train_end + PURGE_BARS` in all 4 fold-construction
|
||||
sites:
|
||||
- `walk_forward::generate_walk_forward_indices` (bars-based)
|
||||
- `walk_forward::generate_walk_forward_indices_from_timestamps`
|
||||
(fxcache-timestamps-based, the production training path)
|
||||
- `walk_forward::generate_walk_forward_windows` (date-based, drops
|
||||
first PURGE_BARS bars of val window since the partition is by
|
||||
calendar boundary not bar index)
|
||||
- `gpu_walk_forward::GpuWalkForwardConfig::generate_folds` and both
|
||||
stratified variants (CPU `generate_folds_stratified` + GPU
|
||||
`generate_folds_stratified_gpu`) — boundary-shift logic preserves
|
||||
the gap when shifting (`new_train_end = new_val_start.saturating_sub(PURGE_BARS)`)
|
||||
|
||||
5-bar width chosen as the maximum per-bar feature lookback (autocorr
|
||||
lags 1/5/10 at `extraction.rs:484-491`, price-acceleration window 3
|
||||
at `extraction.rs:797-810`). Compile-time per
|
||||
`feedback_isv_for_adaptive_bounds` because the feature-pipeline
|
||||
lookback is itself compile-time — this is structural correctness,
|
||||
not training-dynamics adaptive.
|
||||
|
||||
**Fix 2B — per-fold NormStats fit (rec 3)**
|
||||
|
||||
Adds `NormStats::from_features_slice(features, train_start, train_end)`
|
||||
helper + `denormalize` / `denormalize_batch` inverses to
|
||||
`walk_forward.rs`. Modifies `train_baseline_rl.rs` fold loop to:
|
||||
1. Load fxcache sidecar `norm_stats.json` (precompute-time global
|
||||
stats), denormalise `fxcache.features` back to RAW via
|
||||
`denormalize_batch`. Caveat: `|z|=20` clamp boundary is lossy
|
||||
on round-trip; non-clamped bars round-trip exact.
|
||||
2. Per fold: fit `NormStats::from_features_slice(raw, train_start,
|
||||
train_end)` — train-only data, no val leakage.
|
||||
3. Renormalise full dataset with fold-specific stats, re-upload to
|
||||
GPU via `init_from_fxcache` (replaces `gpu_data` field per
|
||||
`dqn::trainer::mod.rs::init_from_fxcache:2064`; replay buffer
|
||||
reset by `reset_for_fold` so prior-fold features can't carry
|
||||
over).
|
||||
4. Save per-fold `norm_stats_fold{N}.json` reflecting the fit
|
||||
(replaces the legacy "copy the shared file" approach).
|
||||
|
||||
**Known follow-ups** (not in this commit's scope):
|
||||
- DBN-fallback path: when no sidecar JSON exists, `raw_features` is
|
||||
`None` and the legacy global-fit path is preserved with a `warn!`
|
||||
log line. Workflows that hit DBN-fallback (148 GB MBP-10 parsing
|
||||
cold start) should regenerate the fxcache to gain rec 3.
|
||||
- Ensemble trainer constructor block (`train_baseline_rl.rs:843-857`)
|
||||
uploads global-fit features once per ensemble member before the
|
||||
fold loop; the rec-3 fix re-uploads only the primary trainer per
|
||||
fold. Hyperopt-ensembling is a separate code path from the
|
||||
baseline val-window investigation that motivated this audit, so
|
||||
the ensemble path keeps legacy behaviour.
|
||||
|
||||
**Behavioral tests**:
|
||||
- `test_norm_stats_per_fold_fit_train_only` — synthetic dataset
|
||||
with train mean=1.0 / val mean=9.0; `from_features_slice` returns
|
||||
train-only mean to ε=1e-5, `from_features` returns global mean
|
||||
5.0; explicit assertion `mean_diff > 1.0` proves the two paths
|
||||
diverge as expected.
|
||||
- `test_norm_stats_denormalize_roundtrip` — non-clamped features
|
||||
round-trip bit-identically through denormalize→normalize.
|
||||
- `test_walk_forward_purge_gap_indices_from_timestamps` — every
|
||||
fold satisfies `val_start - train_end == PURGE_BARS`.
|
||||
- `test_fold_generation_basic` (gpu_walk_forward) — updated to
|
||||
expect the purge gap in fold-0 / fold-1 boundary indices.
|
||||
|
||||
Atomic commit per `feedback_no_partial_refactor`:
|
||||
- `crates/ml/src/walk_forward.rs` — `PURGE_BARS` constant +
|
||||
`from_features_slice` + `denormalize`/`denormalize_batch` helpers
|
||||
+ 3 fold-construction sites updated + 3 behavioral tests
|
||||
- `crates/ml/src/cuda_pipeline/gpu_walk_forward.rs` — purge gap
|
||||
in `generate_folds` + boundary-shift logic in both stratified
|
||||
variants + `test_fold_generation_basic` updated
|
||||
- `crates/ml/examples/train_baseline_rl.rs` — fold loop
|
||||
per-fold renormalisation + re-upload + per-fold JSON save;
|
||||
fallback path retained with warn log when sidecar JSON
|
||||
unavailable (DBN-fallback or zero cache_key)
|
||||
- `docs/lookahead-bias-audit-2026-04-28.md` — recs 2/3 marked
|
||||
RESOLVED with implementation + caveats
|
||||
|
||||
Pearls applied: `feedback_no_partial_refactor` (4 fold-construction
|
||||
sites updated atomically), `feedback_isv_for_adaptive_bounds`
|
||||
(PURGE_BARS documented as structural-not-tuned), `feedback_wire_everything_up`
|
||||
(helpers + production wiring + tests in same commit),
|
||||
`feedback_no_legacy_aliases` (the rec-3 wiring REPLACES the legacy
|
||||
`shared_norm_stats_src` copy — no half-migrated state).
|
||||
|
||||
Validation: `cargo check --workspace` clean (12.30s); 20/20
|
||||
walk_forward + gpu_walk_forward tests pass; `bash
|
||||
scripts/audit_sp18_consumers.sh --check` exit 0; pre-existing
|
||||
test failures unrelated to this commit (all SIGSEGV in tests
|
||||
that exhaust local RTX 3050 Ti VRAM, pre-existing on baseline).
|
||||
|
||||
## 2026-05-09 — Per-regime val WR instrumentation (audit follow-up)
|
||||
|
||||
Adds 6 output slots [13..19) to `compute_backtest_metrics_kernel` —
|
||||
|
||||
@@ -72,12 +72,37 @@ The validation backtest and the training experience-replay BOTH gather features
|
||||
|
||||
## 4. Walk-forward fold split (crates/ml/src/walk_forward.rs)
|
||||
|
||||
- **CONFIDENCE**: suspicious
|
||||
- **CONFIDENCE**: suspicious — **RESOLVED 2026-05-09**
|
||||
- `generate_walk_forward_indices` at `walk_forward.rs:454`: train `[0..train_end_idx)`, val `[val_start..val_end_bound)` with `val_start = train_end_idx` (`walk_forward.rs:495`). Same in `generate_walk_forward_indices_from_timestamps` (`walk_forward.rs:584`).
|
||||
- **No purge/embargo gap** between train and val. For per-bar labels with `target = next_close` this leaks at most one bar (the last training bar's `next_close` target = first val bar's `close`). On 4M-bar datasets that single-bar overlap is negligible for Sharpe inflation, but worth flagging.
|
||||
- **RESOLVED 2026-05-09**: `walk_forward.rs::PURGE_BARS = 5` constant inserted between `train_end` and `val_start` in all four fold-construction sites:
|
||||
`generate_walk_forward_indices`, `generate_walk_forward_indices_from_timestamps`,
|
||||
`generate_walk_forward_windows` (date-based, drops first 5 bars of val window),
|
||||
and `gpu_walk_forward.rs::GpuWalkForwardConfig::generate_folds` (+ both stratified variants).
|
||||
5 bars chosen as the maximum lookback of any per-bar feature (autocorr lags 1/5/10 at
|
||||
`extraction.rs:484-491`, price-acceleration window 3 at `extraction.rs:797-810`).
|
||||
Behavioral test: `test_walk_forward_purge_gap_indices_from_timestamps` confirms
|
||||
`val_start - train_end == PURGE_BARS` for all generated folds.
|
||||
- More serious: **`NormStats::from_features` is computed on the FULL dataset (train + val combined) at `precompute_features.rs:647`** before the walk-forward split happens. The fold loop then reads pre-normalised features from the fxcache. The mean/std used to z-score training data already incorporate validation-period statistics. Defence-in-depth normalization in the DBN fallback (`train_baseline_rl.rs:631`) repeats the same pattern — `from_features(&all_features_raw)` over the entire span.
|
||||
- This is a textbook normalisation leak. The contract documented at `walk_forward.rs:25` ("Compute stats from training data only") is violated by the actual call site. `train_baseline_supervised.rs:466-468` does it correctly (per-fold `NormStats::from_features(&train_features)`); RL training does not.
|
||||
- Magnitude of impact is bounded (mean/std drift across a 4M-bar futures series is small, sub-percent for stationary features), but for non-stationary slots like price-derived ratios it can shift the input distribution by ~0.1σ. Not the primary explanation for Sharpe=196 by itself, but compounds with §3.
|
||||
- **RESOLVED 2026-05-09**: `train_baseline_rl.rs` fold loop now refits `NormStats`
|
||||
from train-only RAW features per fold via `NormStats::from_features_slice`,
|
||||
renormalises the entire dataset with the fold-specific stats, and re-uploads
|
||||
to GPU via `init_from_fxcache` at fold boundary. RAW features are recovered
|
||||
by reading the fxcache sidecar `norm_stats.json` (saved at precompute time)
|
||||
and applying `denormalize_batch`. Per-fold `norm_stats_fold{N}.json` is
|
||||
saved to the output directory (replacing the legacy single-stats copy)
|
||||
so `evaluate_baseline` finds train-only-fit stats per fold.
|
||||
Behavioral test: `test_norm_stats_per_fold_fit_train_only` constructs a
|
||||
synthetic dataset where the train slice has mean 1.0 and val slice has
|
||||
mean 9.0; `from_features_slice` returns the train-only mean to ε=1e-5
|
||||
while `from_features` returns the global mean of 5.0. **Caveats**: (1)
|
||||
fxcache features at the clamp boundary `|z|=20` (rare, corruption only)
|
||||
round-trip lossy through the `denormalize` path; (2) the DBN-fallback
|
||||
path and ensemble-trainer constructor block still use global-fit features —
|
||||
flagged as a known follow-up. The canonical primary training path is
|
||||
fixed.
|
||||
|
||||
## 5. Validation backtest (gpu_backtest_evaluator + backtest_env_kernel)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user