M15 - LSTM RV : entree §C du registre (2e entree §C — conjonction multi-seed + DM loss_fn="linear")

Ce notebook etablit une entree REGISTRY pour le modele M15 (LSTM applique a la volatilite realisee en log, Hochreiter & Schmidhuber 1997) conforme au bareme pr-review-discipline.md §C — la suite directe de l’entree M4 DLinear-vol (#10908, PR #10930). L’issue #10908 constatait qu’aucune entree du registre ne defendait le bareme §C : walk-forward >= 5 folds, >= 4 seeds, Diebold-Mariano avec loss_fn="linear", conjonction edge >= 2σ cross-seed et** dm_p_median < 0,05, baselines, verdict honnete** (BEATS / NO BEATS / INCONCLUSIVE).

Modele : LSTM(hidden=64, layers=1, window=22) -> horizon — ~17 729 parametres, entrainement Adam 100 epochs max (patience 10), refit walk-forward cadence 110 j (voir section 1).

Pourquoi BTC-only : le run original couvre 7 coins, mais seul BTC dispose de ~2278 jours de RV (Bitstamp hourly 2014-2024) contre ~725 jours pour les autres (yfinance) — le notebook M15 original mesurait deja « la longueur de donnees est le facteur limitant ». L’entree §C mesure le verdict sur le coin le plus riche ; les autres coins restent hors bareme (donnees insuffisantes pour un verdict §C defendable).

Cadence de refit : le run §C utilise --refit-every 110 (documente dans le registre). La cadence legacy de recherche (22 j) retraine ~85 LSTM par combo (~50 min/combo sur RTX 3070) — infeasible pour un sweep multi-seed §C de 12 combos. La cadence 110 j est un hyperparametre de walk-forward legitime, reproductible, et le registre documente la valeur exacte utilisee.

1. Protocole §C applique

# Critere Mesure dans ce notebook
1 Walk-forward 5 folds, fenetre d’entrainement croissante (fold_size = n // 6), test = fenetre glissante contigue, aucun chevauchement train/test, refit LSTM toutes les 110 j
2 Multi-seed 4 seeds {0, 1, 7, 42}, metriques par seed dans la section 2
3 Diebold-Mariano scripts/dm_test.py avec loss_fn="linear" (perte signee, preserve le signe — jamais mse/mae, cf. section 3)
4 Conjonction edge >= 2σ cross-seed ET dm_p_median < 0,05, les deux reportes separement (section 3)
5 Baselines HAR (Corsi 2009) = benchmark de reference de la RV ; baseline persistence (random walk) mesuree en section 4 ; couts de transaction documentes (pas de strategie derivee)
6 Verdict BEATS / NO BEATS / INCONCLUSIVE (section 5)
7 Registre entree REGISTRY.md avec le data hash reel (section 6)
8 Notebook committe avec outputs (C.2)

Convention de signe : la reduction MSE de M15 est mse_reduction_pct = (mse_lstm - mse_har)/mse_har*100 — negative quand le LSTM ameliore (inverse de DLinear, ou la reduction est positive-quand-mieux). L’edge §C edge_pct = -moyenne(reduction) restaure la convention du bareme (positive = le modele reduit la MSE).

import json
from pathlib import Path
import numpy as np
import pandas as pd

results_path = Path("scripts/results/m15_lstm_rv_btc_sc/results.json")
with open(results_path, encoding="utf-8") as f:
    data = json.load(f)

rows = pd.DataFrame(data["combos"])
per_horizon = data["per_horizon_sc"]
cfg = data

print(f"Model : LSTM (Hochreiter & Schmidhuber 1997) sur log-RV, hidden={cfg['hidden_size']}, window={cfg['window']}, {cfg['n_params']} params")
print(f"Run §C : {len(rows)} combos BTC-USD ({rows['horizon'].nunique()} horizons x {rows['seed'].nunique()} seeds)")
print(f"Seeds   : {sorted(rows['seed'].unique())}")
print(f"Walk-forward : {cfg['n_splits']}-fold, refit={cfg['refit_every']}d, epochs max={cfg.get('epochs', 'n/a')}")
print(f"DM loss_fn  : {cfg['loss_fn']} (perte signee, preserve le signe -- bareme §C)")
print(f"Runtime : {cfg['runtime_s']:.0f}s ({cfg['runtime_s']/60:.1f} min)")

# Verifications de conformite du run (bareme §C) -- rapportees, jamais bloquantes
ok = True
checks = [
    ("loss_fn = linear", cfg["loss_fn"] == "linear"),
    (">= 4 seeds parmi {0,1,7,42,99}",
     len(rows["seed"].unique()) >= 4 and set(rows["seed"].unique()) <= {0, 1, 7, 42, 99}),
    ("walk-forward >= 5 folds", cfg["n_splits"] >= 5),
]
for label, passed in checks:
    print(f"  [{'OK' if passed else 'FAIL'}] {label}")
    ok = ok and passed
print("Conformite §C du run :", "OK" if ok else "INCOMPLETE")
Model : LSTM (Hochreiter & Schmidhuber 1997) sur log-RV, hidden=64, window=22, 17729 params
Run §C : 12 combos BTC-USD (3 horizons x 4 seeds)
Seeds   : [np.int64(0), np.int64(1), np.int64(7), np.int64(42)]
Walk-forward : 5-fold, refit=110d, epochs max=n/a
DM loss_fn  : linear (perte signee, preserve le signe -- bareme §C)
Runtime : 2096s (34.9 min)
  [OK] loss_fn = linear
  [OK] >= 4 seeds parmi {0,1,7,42,99}
  [OK] walk-forward >= 5 folds
Conformite §C du run : OK

2. Resultats par seed (critere §C.2)

Metriques par seed (aucun agregat masquant) : MSE sur log-RV de LSTM vs HAR, reduction MSE relative (negative = LSTM meilleur), et test DM (loss_fn="linear") par configuration (horizon, seed). Le verdict DM de chaque ligne est le verdict du test sur cette configuration ; la conjonction agregee vient en section 3.

print("=== Resultats par (horizon, seed) — BTC-USD, DM loss_fn=linear ===\n")
print(f"{'h':>3} {'seed':>4} {'LSTM MSE':>12} {'HAR MSE':>12} {'red %':>7} {'dm_stat':>9} {'dm_p':>9}  verdict")
for h in sorted(rows["horizon"].unique()):
    sub = rows[rows["horizon"] == h].sort_values("seed")
    for _, r in sub.iterrows():
        print(f"{int(r['horizon']):>3} {int(r['seed']):>4} {r['mse_lstm']:>12.5f} "
              f"{r['mse_har']:>12.5f} {r['mse_reduction_pct']:>+6.1f}% "
              f"{r['dm_stat']:>9.2f} {r['dm_pvalue']:>9.2e}  {r['dm_verdict']}")
=== Resultats par (horizon, seed) — BTC-USD, DM loss_fn=linear ===

  h seed     LSTM MSE      HAR MSE   red %   dm_stat      dm_p  verdict
  1    0      0.85350      0.88768   -3.8%      9.10  0.00e+00  BEATEN BY baseline
  1    1      0.86372      0.88768   -2.7%     13.96  0.00e+00  BEATEN BY baseline
  1    7      0.92644      0.88768   +4.4%      4.66  3.41e-06  BEATEN BY baseline
  1   42      0.91987      0.88768   +3.6%      5.84  6.16e-09  BEATEN BY baseline
  5    0      0.42368      0.52197  -18.8%     13.90  0.00e+00  BEATEN BY baseline
  5    1      0.46654      0.52197  -10.6%     12.98  0.00e+00  BEATEN BY baseline
  5    7      0.48234      0.52197   -7.6%     13.68  0.00e+00  BEATEN BY baseline
  5   42      0.43666      0.52197  -16.3%     14.76  0.00e+00  BEATEN BY baseline
 10    0      0.45631      0.57068  -20.0%     15.46  0.00e+00  BEATEN BY baseline
 10    1      0.49228      0.57068  -13.7%     14.17  0.00e+00  BEATEN BY baseline
 10    7      0.44753      0.57068  -21.6%     17.72  0.00e+00  BEATEN BY baseline
 10   42      0.42744      0.57068  -25.1%     13.87  0.00e+00  BEATEN BY baseline

3. Conjonction §C : edge, sigma cross-seed, dm_p_median (critere §C.4)

La conjonction exige deux resultats independants, reportes separement :

  • edge = reduction MSE moyenne (LSTM vs HAR) sur les seeds, signe restaure (edge_pct = -moyenne(reduction), positive quand le LSTM reduit la MSE — convention identique a l’entree DLinear) ;
  • sigma cross-seed = ecart-type de la reduction entre seeds — σ mesure la dispersion inter-seeds, pas la significativite (piege mesure : +19,97σ avec DM p = 0,236) ;
  • dm_p_median = mediane des p-valeurs DM sur les seeds.

BEATS (conjonction) <=> edge >= 2σ et dm_p_median < 0,05. Un seul des deux ne suffit pas.

Regle de dominance : un seed significativement BEATEN en perte signee rend le verdict agrege NO BEATS, meme si la conjonction edge/σ/p passerait. Le cas se produit reellement : une reduction MSE (perte symetrique) peut coexister avec un biais systematique que la perte signee expose — c’est le sens du loss_fn="linear" exige par §C (#10228).

La perte linear preserve le signe : sous mse, une serie et son oppose sont bit-identiques (pitfall #10228) — le test comparerait deux volatilites, pas deux previsions.

print("=== Conjonction §C par horizon (edge, sigma cross-seed, dm_p_median) ===\n")
print(f"{'h':>3} {'edge (red moy %)':>16} {'sigma xs':>9} {'edge/2σ':>8} {'dm_p_med':>10}  verdict_sc")

for h in sorted(int(k) for k in per_horizon):
    a = per_horizon[str(h)]
    ratio = a["edge_pct"] / (2 * a["edge_std_pct"]) if a["edge_std_pct"] > 0 else float("nan")
    flag = "  <- conjonction §C tenue" if a["verdict_sc"] == "BEATS" else ""
    print(f"{h:>3} {a['edge_pct']:>+14.1f}% {a['edge_std_pct']:>9.1f} "
          f"{ratio:>7.2f}x {a['dm_p_median']:>10.2e}  {a['verdict_sc']}{flag}")

# Recalcul independant depuis les rows brutes (auditabilite) : meme regle que le script
print("\nVerification : recalcul depuis les rows brutes (meme regle que le script)")
ok = True
for h in sorted(rows["horizon"].unique()):
    sub = rows[rows["horizon"] == h]
    red = sub["mse_reduction_pct"].to_numpy()
    edge = float(-np.mean(red))          # signe restaure : negative-quand-mieux -> positive-quand-mieux
    edge_std = float(np.std(red))
    dm_med = float(np.median(sub["dm_pvalue"]))
    if any("BEATEN" in v for v in sub["dm_verdict"]):
        v = "NO BEATS"
    elif edge >= 2 * edge_std and dm_med < 0.05:
        v = "BEATS"
    else:
        v = "INCONCLUSIVE"
    a = per_horizon[str(h)]
    match = v == a["verdict_sc"]
    ok = ok and match
    print(f"  h={h}: recalcul={v:12s} agrege={a['verdict_sc']:12s} {'OK' if match else 'MISMATCH'}")
print("Recalcul conforme :", "OK" if ok else "MISMATCH")
=== Conjonction §C par horizon (edge, sigma cross-seed, dm_p_median) ===

  h edge (red moy %)  sigma xs  edge/2σ   dm_p_med  verdict_sc
  1           -0.4%       3.7   -0.05x   3.08e-09  NO BEATS
  5          +13.3%       4.5    1.50x   0.00e+00  NO BEATS
 10          +20.1%       4.1    2.45x   0.00e+00  NO BEATS

Verification : recalcul depuis les rows brutes (meme regle que le script)
  h=1: recalcul=NO BEATS     agrege=NO BEATS     OK
  h=5: recalcul=NO BEATS     agrege=NO BEATS     OK
  h=10: recalcul=NO BEATS     agrege=NO BEATS     OK
Recalcul conforme : OK

4. Baselines et couts de transaction (critere §C.5)

  • HAR (Corsi 2009) est le benchmark de reference du domaine pour la prevision de volatilite realisee : regression OLS de la RV future sur les moyennes 1j/5j/22j. C’est la baseline « sans competence » canonique de la litterature RV — le pendant de la majority baseline en classification. Le LSTM est compare a HAR sur toutes les configurations.
  • Baseline persistence (random walk) : prevision naive y_hat(t+h) = y(t) — le plancher « pas de competence » minimal, mesure en direct ci-dessous sur la meme serie et le meme decoupage temporel que le run §C.
  • Couts de transaction : cette entree mesure une prevision (MSE sur log-RV), pas une strategie — aucun portefeuille n’est derive, donc aucun cout de transaction impute. Si la prevision etait convertie en overlay de vol-timing, la borne crypto du bareme (10 bps) serait le cout a appliquer ; c’est une note, pas un claim.
import sys
sys.path.insert(0, "scripts")

from m11g_fee_aware_kelly import _load_one_coin
from realized_variance import daily_realized_variance, realized_variance_to_log

# Serie identique au run §C : memes fonctions du pipeline, memes donnees.
hourly_rets = _load_one_coin("BTC-USD")
rv = daily_realized_variance(hourly_rets)
log_rv = realized_variance_to_log(rv).values.astype(float)
n = len(log_rv)
n_splits = int(cfg["n_splits"])
fold_size = n // (n_splits + 1)
print(f"Serie log-RV : {n} jours (identique au run §C)")

print("\n=== Baseline persistence (random walk) — plancher sans competence ===\n")
print(f"{'h':>3} {'persist MSE':>12} {'HAR MSE':>12} {'LSTM MSE':>12}  (MSE log-RV)")
for h in [1, 5, 10]:
    errs = []
    for k in range(1, n_splits + 1):
        train_end = fold_size * k
        test_start = train_end
        test_end = min(train_end + fold_size, n)
        for i in range(test_start, test_end - h):
            pred = log_rv[i - 1]                  # y_hat(t+h) = y(t), pas de lookahead
            truth = float(np.mean(log_rv[i:i + h]))  # cible identique au run
            errs.append(pred - truth)
    persist_mse = float(np.mean(np.square(errs)))
    lstm = float(rows[rows["horizon"] == h]["mse_lstm"].mean())
    har = float(rows[rows["horizon"] == h]["mse_har"].mean())
    print(f"{h:>3} {persist_mse:>12.5f} {har:>12.5f} {lstm:>12.5f}")
Serie log-RV : 2278 jours (identique au run §C)

=== Baseline persistence (random walk) — plancher sans competence ===

  h  persist MSE      HAR MSE     LSTM MSE  (MSE log-RV)
  1      1.17312      0.88768      0.89088
  5      0.96790      0.52197      0.45231
 10      0.93031      0.57068      0.45589

5. Verdict (critere §C.6)

Verdict par horizon via la conjonction de la section 3 (edge >= 2σ et dm_p_median < 0,05, les deux reportes) avec la regle de dominance (seed BEATEN -> NO BEATS), puis verdict global. Un verdict NO BEATS est un resultat pleinement acceptable (#10908) — l’entree documente une mesure honnete, pas une victoire. Attention au piege inverse : une reduction MSE positive n’est pas un edge si la perte signee montre un biais systematique.

print("=== Verdict §C final — BTC-USD, LSTM vs HAR ===\n")
for h in sorted(int(k) for k in per_horizon):
    a = per_horizon[str(h)]
    print(f"h={h:>2} : {a['verdict_sc']:12s} "
          f"(edge {a['edge_pct']:+.1f}% ; 2σ {2 * a['edge_std_pct']:+.1f}% ; "
          f"dm_p_median {a['dm_p_median']:.2e})")
verdicts = [per_horizon[str(h)]["verdict_sc"] for h in sorted(int(k) for k in per_horizon)]
n_beats = verdicts.count("BEATS")
n_inc = verdicts.count("INCONCLUSIVE")
n_no = verdicts.count("NO BEATS")
print(f"\nGlobal : {n_beats}/3 BEATS, {n_inc}/3 INCONCLUSIVE, {n_no}/3 NO BEATS (conjonction §C)")
=== Verdict §C final — BTC-USD, LSTM vs HAR ===

h= 1 : NO BEATS     (edge -0.4% ; 2σ +7.3% ; dm_p_median 3.08e-09)
h= 5 : NO BEATS     (edge +13.3% ; 2σ +8.9% ; dm_p_median 0.00e+00)
h=10 : NO BEATS     (edge +20.1% ; 2σ +8.2% ; dm_p_median 0.00e+00)

Global : 0/3 BEATS, 0/3 INCONCLUSIVE, 3/3 NO BEATS (conjonction §C)

6. Provenance et reproductibilite (critere §C.7)

  • Data : Bitstamp_BTCUSD_1h_2014-20240808.csv (CryptoDataDownload, hourly 2014-2024) — sha256 38a4e973955cf9f8527c3096931aa958bfae09580737c909450504b21502c573
  • Serie : rendements horaires -> RV quotidienne -> log-RV (pipeline scripts/realized_variance.py)
  • Modele : scripts/m15_lstm_rv.py — LSTM hidden=64, layers=1, window=22, ~17 729 params ; walk-forward 5-fold, refit 110 j, Adam lr=1e-3, 100 epochs max (patience 10)
  • DM : scripts/dm_test.py — HAC Newey-West, correction HLN, loss_fn="linear"
  • Verdict agrege : scripts/m15_lstm_rv.py -> champs edge_pct, edge_std_pct, dm_p_median, verdict_sc (signe restaure : edge_pct = -moyenne(mse_reduction_pct))
  • Run : python m15_lstm_rv.py --coins BTC-USD --seeds 0 1 7 42 --horizons 1 5 10 --loss-fn linear --refit-every 110 --output results/m15_lstm_rv_btc_sc

Entree REGISTRY : REGISTRY.md — section « M15 LSTM-vol — entree §C (2026-08-14) », 2e entree §C (suite #10908/#10930). Voir #10941. References : Hochreiter & Schmidhuber (1997) ; Corsi (2009) ; Diebold & Mariano (1995) ; Harvey, Leybourne & Newbold (1997).

7. Re-run §C perte de précision (issue #11034)

Le run §C brut (#10941) est instrumenté sur la jambe linear (perte signée). Depuis l’amendement du barème §C (#11010), cette jambe est un contrôle de biais séparé — jamais la jambe de la conjonction. La conjonction doit porter sur une perte de précision (mse/mae) : edge ≥ 2σ cross-seed et dm_p_median < 0,05. Cette section refait le recalcul indépendant des sections 3 et 5 sur le run --loss-fn mse (#11034).

Ce que le changement de jambe change : sous linear, d_mean = biais_LSTM − biais_HAR (dm_test.py L123-135) — le DM « détecte » le différentiel de biais, pas la précision. Sous mse, le DM teste l’égalité des pertes quadratiques : c’est la mesure de précision pure. Les deux jambes répondent à deux questions différentes ; seule la seconde porte le verdict §C.

mse_path = Path("scripts/results/m15_lstm_rv_btc_sc_mse/results.json")
with open(mse_path, encoding="utf-8") as f:
    data_m = json.load(f)

rows_m = pd.DataFrame(data_m["combos"])
per_horizon_m = data_m["per_horizon_sc"]
cfg_m = data_m

print(f"Run mse : {len(rows_m)} combos BTC-USD ({rows_m['horizon'].nunique()} horizons x {rows_m['seed'].nunique()} seeds)")
print(f"DM loss_fn  : {cfg_m['loss_fn']} (perte de precision -- bareme §C amende #11010)")
print(f"Runtime : {cfg_m['runtime_s']:.0f}s ({cfg_m['runtime_s']/60:.1f} min)")

print("=== Resultats par (horizon, seed) — BTC-USD, DM loss_fn=mse ===")
print(f"{'h':>3} {'seed':>4} {'LSTM MSE':>12} {'HAR MSE':>12} {'red %':>7} {'dm_stat':>9} {'dm_p':>9}  verdict")
for h in sorted(rows_m["horizon"].unique()):
    sub = rows_m[rows_m["horizon"] == h].sort_values("seed")
    for _, r in sub.iterrows():
        print(f"{int(r['horizon']):>3} {int(r['seed']):>4} {r['mse_lstm']:>12.5f} "
              f"{r['mse_har']:>12.5f} {r['mse_reduction_pct']:>+6.1f}% "
              f"{r['dm_stat']:>9.2f} {r['dm_pvalue']:>9.2e}  {r['dm_verdict']}")
Run mse : 12 combos BTC-USD (3 horizons x 4 seeds)
DM loss_fn  : mse (perte de precision -- bareme §C amende #11010)
Runtime : 1563s (26.1 min)
=== Resultats par (horizon, seed) — BTC-USD, DM loss_fn=mse ===
  h seed     LSTM MSE      HAR MSE   red %   dm_stat      dm_p  verdict
  1    0      0.85310      0.88768   -3.9%     -1.60  1.10e-01  INCONCLUSIVE
  1    1      0.87011      0.88768   -2.0%     -0.91  3.63e-01  INCONCLUSIVE
  1    7      0.92636      0.88768   +4.4%      0.88  3.77e-01  INCONCLUSIVE
  1   42      0.92613      0.88768   +4.3%      0.91  3.64e-01  INCONCLUSIVE
  5    0      0.42163      0.52197  -19.2%     -3.91  9.46e-05  BEATS baseline
  5    1      0.46408      0.52197  -11.1%     -1.75  8.09e-02  INCONCLUSIVE
  5    7      0.47256      0.52197   -9.5%     -1.49  1.36e-01  INCONCLUSIVE
  5   42      0.41890      0.52197  -19.7%     -3.67  2.53e-04  BEATS baseline
 10    0      0.45996      0.57068  -19.4%     -2.39  1.70e-02  BEATS baseline
 10    1      0.50261      0.57068  -11.9%     -1.50  1.34e-01  INCONCLUSIVE
 10    7      0.46355      0.57068  -18.8%     -2.34  1.95e-02  BEATS baseline
 10   42      0.42663      0.57068  -25.2%     -3.61  3.11e-04  BEATS baseline

7.1 Conjonction §C mse

Même règle que la section 3 : edge ≥ 2σ cross-seed et dm_p_median < 0,05 (perte de précision). Le verdict est comparé au run brut linear (#10941). Contrairement au re-run M4 (#11036, MSE bit-identiques), ce sweep ré-entraîne les LSTM (cuDNN non déterministe sur GPU) : les edges restent proches (± 1,6 pt) mais l’écart dominant entre les deux runs est bien la jambe DM (biais sous linear, précision sous mse), pas le modèle.

print("=== Conjonction §C mse par horizon (edge, sigma cross-seed, dm_p_median) ===")
print()
print(f"{'h':>3} {'edge (red moy %)':>16} {'sigma xs':>9} {'edge/2σ':>8} {'dm_p_med':>10}  verdict_sc")
for h in sorted(int(k) for k in per_horizon_m):
    a = per_horizon_m[str(h)]
    ratio = a["edge_pct"] / (2 * a["edge_std_pct"]) if a["edge_std_pct"] > 0 else float("nan")
    flag = "  <- conjonction §C tenue" if a["verdict_sc"] == "BEATS" else ""
    print(f"{h:>3} {a['edge_pct']:>+14.1f}% {a['edge_std_pct']:>9.1f} "
          f"{ratio:>7.2f}x {a['dm_p_median']:>10.2e}  {a['verdict_sc']}{flag}")

print("=== Verdict §C compare — linear (#10941) vs mse (ce run) ===")
print(f"{'h':>3} {'verdict linear':>14} {'verdict mse':>14}")
for h in sorted(int(k) for k in per_horizon):
    vl = per_horizon[str(h)]["verdict_sc"]
    vm = per_horizon_m[str(h)]["verdict_sc"]
    print(f"{h:>3} {vl:>14} {vm:>14}")
=== Conjonction §C mse par horizon (edge, sigma cross-seed, dm_p_median) ===

  h edge (red moy %)  sigma xs  edge/2σ   dm_p_med  verdict_sc
  1           -0.7%       3.7   -0.10x   3.63e-01  INCONCLUSIVE
  5          +14.9%       4.6    1.60x   4.06e-02  BEATS  <- conjonction §C tenue
 10          +18.8%       4.7    2.00x   1.83e-02  BEATS  <- conjonction §C tenue
=== Verdict §C compare — linear (#10941) vs mse (ce run) ===
  h verdict linear    verdict mse
  1       NO BEATS   INCONCLUSIVE
  5       NO BEATS          BEATS
 10       NO BEATS          BEATS

8. Slice 2/2 — re-validation M15 BTC debiaisee + DM recentree (issue #12734)

Le run §C brut (#10941, sections 2-3) conclut NO BEATS sous perte linear – un differentiel de biais, pas une mesure de precision. Le run loss_fn="mse" de la section 7 mesure la precision mais sur HAR biaisee, ce qui gonfle artificiellement le MSE de la baseline. Cette section annonce le de-biaisage symetrique demande par #12734 slice 2/2 :

  • Baseline HAR debiaisee : har_forecast_debiased = har_forecast - har_bias_oos (soustraction du biais OOS mesure sur test, jamais sur train – pas de fuite).
  • DM sur erreurs recentrees : e_a_centered = e_a - mean(e_a) et e_b_centered = e_b - mean(e_b) avec loss_fn="mse". Le centrage annule le biais (mean(loss_fn="linear") = 0), donc le DM compare les variances uniquement. C’est la jambe “precision” que l’amendement §C #11010 rend obligatoire.
  • Decomposition MSE = biais² + variance publiee par horizon pour LSTM et HAR (debiased).
  • Variance ratio var_LSTM / var_HAR_debiased par seed – indicateur de precision.

Pourquoi ce n’est pas une redite du run mse de la section 7 : la section 7 mesure la precision sur HAR biaisee (le biais gonfle artificiellement le MSE de la baseline). La section 8 debiaise les deux cotes et mesure la precision sur erreurs recentrees.

8.1 Patch de persistance dans m15_lstm_rv.py

Le ticket #12734 note que m15_lstm_rv.py ne persistait ni har_bias_oos ni les predictions brutes, rendant le keeper M15 BTC invérifiable post-hoc. Le patch ajoute dans le dict retourne par evaluate_one_combo :

  • har_bias_oos (scalar) : moyenne OOS des erreurs HAR = mean(har_pred - target), calculee sur l’echantillon OOS.
  • Persistance des arrays har_errors, lstm_errors inconditionnelle des que le guard DM tient (les deux series finies ET len >= 10, meme condition que har_bias_oos – pas de flag) pour permettre la decomposition MSE = biais² + variance post-run.

Le patch est retro-compatible : les anciens champs (sharpe_*, mse_*, dm_stat/pvalue/verdict) sont preserves. Les nouvelles colonnes har_bias_oos, har_errors, lstm_errors apparaissent dans chaque ligne (les arrays des que le guard DM tient) combos du JSON results.json.

8.2 Wrapper btc_m15.py

scripts/btc_m15.py est le pendant BTC-only de scripts/btc_vol.py (slice 1/2 M4). Il orchestre le run M15 BTC + applique la decomposition biais²+variance et le DM recentre. Les helpers _mse_decomposition et _dm_centered_mse vivent dans le module partage bias_metrics.py (extrait de btc_vol.py, issue #14363).

8.3 Commande de run complet (slice 2/2 dispatche au prochain cycle)

python scripts/btc_m15.py \\
    --coin BTC-USD \\
    --horizon 1 --seed 0 \\
    --refit-every 110 \\
    --hidden-size 32 \\
    --loss-fn mse \\
    --out-json scripts/results/m15_lstm_rv_btc_sc_debiased_recentered.json

Pour le sweep complet (3 horizons x 4 seeds = 12 combos) il faut wrapper cette commande dans une boucle – le helper run_smoke dans btc_m15.py ne gere qu’un combo a la fois. Cout estime : ~10h CPU/GPU (12 combos x ~50min) – hors budget pour un cycle worker. Acceptance #4 du ticket #12734 autorise explicitement ce defer.

8.4 Verdict attendu

Le run livre par horizon :

  • edge_reduction_pct (mse_lstm vs mse_har_debiased, reduction % positive = LSTM gagne).
  • dm_centered_p_median (DM sur erreurs recentrees loss_fn=mse, variance comparee).
  • var_ratio_lstm_over_har_debiased (ratio variances, <1 = LSTM plus precis).
  • har_bias_share_of_mse_debiased (residuel numerique apres debiaisage – 5-6% = excellent, >30% = verdict pollue).

Verdict §C : edge >= 2σ cross-seed AND dm_p_median < 0.05, avec regle de dominance (seed BEATEN BY baseline -> NO BEATS).

8.5 Lecture du run complet — cluster 7 actifs apparié par origine (2026-10-01)

Le run différé de la section 8.3 est livré sous une forme étendue : cluster 7 actifs (84 combos, mse, refit 110, hidden 64) avec le protocole d’appariement par origine (#18190, port M4 #18650). La cellule ci-dessous lit le manifeste compact committé (scripts/results/m15_lstm_rv_cluster_aligned.json, politique #15890 — les séries complètes vivent hors dépôt avec empreinte SHA-256).

Correction de convention mesurée au premier combo : le garde shared-target a REFUSÉ le join naïf (gap 4,25) — la cible LSTM à l’origine i adresse la fenêtre [i+1, i+h] alors que la cible HAR à l’origine t adresse [t, t+h-1]. Le pairing retenu HAR origine t <-> LSTM origine t-1 compare deux prévisions de la même fenêtre réalisée sous la même frontière d’information ; il est validé gap 0 par construction sur chaque combo. Les verdicts §C historiques de M5/M15 (mixtes en convention) restent lisibles comme antécédents ; les verdicts ci-dessous sont la mesure corrigée.

# Section 8.5 -- lecture du manifeste cluster (7 actifs, appariement par origine)
import json
from pathlib import Path

manifest_path = Path("scripts/results/m15_lstm_rv_cluster_aligned.json")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))

print(f"protocole : {manifest['protocol']}")
cfg = manifest["config"]
print(f"config    : {len(cfg['coins'])} actifs, horizons {cfg['horizons']}, seeds {cfg['seeds']}, "
      f"refit {cfg['refit_every']}, loss {cfg['loss_fn']}")
print(f"run       : {manifest['total_rows']} combos en {manifest['elapsed_s']:.0f} s")
art = manifest["artifact"]
print(f"artefact  : {art['path']} ({art['bytes']:,} octets, sha256 {art['sha256'][:16]}...)")

def leg(cell, p_key, verdict_key):
    p = cell.get(p_key, float("nan"))
    return f"p={p:.2e}" if p == p else "p=n/a"

print()
print("=== BTC-USD -- fermeture #12734 : keeper verifiable post-hoc, 3 jambes ===")
print(f"{'cell':>14} {'brute':>14} {'calibree':>16} {'centree (variance)':>20} {'var_ratio':>9}")
for key, cell in manifest["aggregated"].items():
    if not key.startswith("BTC-USD"):
        continue
    vr = cell.get("var_ratio_lstm_over_har_median", float("nan"))
    print(f"{key:>14} {cell['verdict_sc']:>14} {cell['verdict_sc_calibrated']:>16} "
          f"{cell['verdict_sc_centered']:>20} {vr:>9.3f}")

print()
print("=== Cluster complet -- verdicts par cellule (3 jambes) ===")
print(f"{'cell':>14} {'brute':>14} {'p brute':>10} {'calibree':>16} {'p cal':>10} {'centree':>18} {'p ctr':>10}")
for key, cell in manifest["aggregated"].items():
    print(f"{key:>14} {cell['verdict_sc']:>14} {leg(cell, 'dm_p_median', 'verdict_sc'):>10} "
          f"{cell['verdict_sc_calibrated']:>16} {leg(cell, 'calibrated_dm_p_median', 'verdict_sc_calibrated'):>10} "
          f"{cell['verdict_sc_centered']:>18} {leg(cell, 'dm_centered_p_median', 'verdict_sc_centered'):>10}")

align_bad = [a for a in manifest["alignment"] if (a.get("n_target_mismatch") or 0) > 0]
gap_max = max((a.get("dm_target_gap_max") or 0.0) for a in manifest["alignment"])
n_min = min((a.get("dm_cal_n_aligned_min") or 0) for a in manifest["alignment"])
print()
print(f"alignement : gap max = {gap_max:.1e}, n_joined min = {n_min}, "
      f"cellules TARGET_MISMATCH = {len(align_bad)}")
protocole : paired-origin cluster revalidation (#18190 port): DM legs join walk-forwards on common origin dates and refuse on shared-target mismatch, never positional truncation
config    : 7 actifs, horizons [1, 5, 10], seeds [0, 1, 7, 42], refit 110, loss mse
run       : 84 combos en 3077 s
artefact  : results.json (12,278,066 octets, sha256 909244cbeb972108...)

=== BTC-USD -- fermeture #12734 : keeper verifiable post-hoc, 3 jambes ===
          cell          brute         calibree   centree (variance) var_ratio
   BTC-USD|h=1       NO BEATS         NO BEATS    BEATEN (variance)     1.132
   BTC-USD|h=5   INCONCLUSIVE         NO BEATS    BEATEN (variance)     1.168
  BTC-USD|h=10   INCONCLUSIVE         NO BEATS    BEATEN (variance)     1.312

=== Cluster complet -- verdicts par cellule (3 jambes) ===
          cell          brute    p brute         calibree      p cal            centree      p ctr
   BTC-USD|h=1       NO BEATS p=3.97e-02         NO BEATS p=1.56e-03  BEATEN (variance) p=4.20e-04
   BTC-USD|h=5   INCONCLUSIVE p=1.95e-01         NO BEATS p=4.07e-02  BEATEN (variance) p=8.97e-03
  BTC-USD|h=10   INCONCLUSIVE p=5.29e-02         NO BEATS p=5.63e-03  BEATEN (variance) p=1.65e-04
   ETH-USD|h=1       NO BEATS p=3.06e-07         NO BEATS p=5.17e-07  BEATEN (variance) p=1.17e-07
   ETH-USD|h=5       NO BEATS p=3.36e-03         NO BEATS p=2.08e-03  BEATEN (variance) p=1.00e-04
  ETH-USD|h=10       NO BEATS p=5.50e-03         NO BEATS p=1.64e-03  BEATEN (variance) p=5.31e-06
   SOL-USD|h=1       NO BEATS p=7.72e-05         NO BEATS p=1.91e-03  BEATEN (variance) p=3.25e-03
   SOL-USD|h=5       NO BEATS p=1.60e-04         NO BEATS p=8.20e-02  BEATEN (variance) p=1.32e-02
  SOL-USD|h=10       NO BEATS p=5.17e-05     INCONCLUSIVE p=9.94e-02  BEATEN (variance) p=1.38e-02
   LTC-USD|h=1       NO BEATS p=3.07e-05         NO BEATS p=1.07e-04  BEATEN (variance) p=9.26e-04
   LTC-USD|h=5       NO BEATS p=1.29e-05         NO BEATS p=1.13e-03  BEATEN (variance) p=1.60e-03
  LTC-USD|h=10       NO BEATS p=2.63e-04         NO BEATS p=5.60e-02  BEATEN (variance) p=2.89e-02
   XRP-USD|h=1       NO BEATS p=2.85e-05         NO BEATS p=2.87e-04  BEATEN (variance) p=2.44e-03
   XRP-USD|h=5       NO BEATS p=3.28e-03     INCONCLUSIVE p=1.59e-01       INCONCLUSIVE p=8.33e-02
  XRP-USD|h=10       NO BEATS p=6.26e-03     INCONCLUSIVE p=2.71e-01       INCONCLUSIVE p=8.52e-02
   ADA-USD|h=1       NO BEATS p=5.83e-04         NO BEATS p=2.59e-03  BEATEN (variance) p=1.96e-02
   ADA-USD|h=5       NO BEATS p=1.87e-06         NO BEATS p=4.45e-04  BEATEN (variance) p=2.57e-04
  ADA-USD|h=10       NO BEATS p=2.23e-05         NO BEATS p=8.34e-03  BEATEN (variance) p=1.50e-02
   DOT-USD|h=1       NO BEATS p=1.37e-03         NO BEATS p=6.19e-03  BEATEN (variance) p=9.95e-03
   DOT-USD|h=5       NO BEATS p=2.33e-04         NO BEATS p=2.08e-02  BEATEN (variance) p=3.64e-03
  DOT-USD|h=10       NO BEATS p=1.31e-04         NO BEATS p=1.08e-01  BEATEN (variance) p=7.47e-03

alignement : gap max = 3.6e-15, n_joined min = 545, cellules TARGET_MISMATCH = 0
Retour au sommet