M5 - HMM Regime-Switching HAR : quand la sophistication structurelle nuit

L’extension la plus faible du pipeline vol – apres M12 (OLS deploye), M4 (DLinear 5/21 BEATS), M15 (LSTM keeper), M9 (TFT 0/6 BEATS, surparametrage DL), ce notebook documente un echec d’une autre nature : la sophistication structurelle (pas neuronale).

L’idee est seduisante : la volatilite crypto alterne regimes calmes et turbulents. Un modele qui adapte ses coefficients HAR au regime courant (detecte par une chaine de Markov cachee, HMM K=2 decalee par Viterbi sur le log-RV) pourrait capturer cette asymetrie. C’est l’HAR conditionnel au regime : 8 coefficients OLS (4 base + 4 termes d’interaction regime) au lieu des 3 du HAR classique.

Verdict (jambe brute, machine 4 etats unifiee #14388) : 1/6 BEATS, 1/6 NO BEATS, 4/6 INCONCLUSIVE. ETH h=1 beneficie du regime-switching (+9.6% MSE, 4/4 seeds p<0.005, un seul BEATS sur la jambe brute). ETH h=10 est NO BEATS : 4/4 seeds BEATEN, dm_p_median < 0.05 – le regime-switching y est significativement nuisible (-53% de MSE en plus vs HAR classique). Les 4 configs restantes sont INCONCLUSIVE : 3/4 BEATEN + 1 graine non-significative (BTC h=5, BTC h=10, ETH h=5) ou 3/4 BEATS + 1 graine non-significative (BTC h=1). Pas d’unanimite stricte – la convention choisie par #14388 refuse de trancher.

La jambe hors biais (artefact dedie, doc M5 section “Re-validation hors biais”) sort elle 1/6 BEATS / 4/6 NO BEATS / 1/6 INCONCLUSIVE : sur la jambe centree, les 4 configs en 0/4 BEATS sont unanimes et basculent en NO BEATS. Les deux jambes partagent la meme machine mais tranchent sur des agregats differents.

Machine d’etats commune : _aggregate_state dans scripts/hmm_regime_vol.py gere les deux jambes (brute et hors biais) avec les memes 4 etats. Avant #14388 la jambe brute n’avait que 2 etats et la lecture DEGRADE etait derivee a la main dans ce notebook.

Ce grain complete la courbe d’expressivite sous un angle orthogonal au DL : ce n’est pas seulement le reseau de neurones qui surcompile (M9), l’augmentation structurelle d’un OLS simple nuit aussi.

Fiche technique complete : docs/M5_HMM_REGIME.md. Script : scripts/hmm_regime_vol.py. Resultats : Cycle 25 Wave 3 + mise a jour #14388, 6 configs x 4 seeds, ~650 s CPU (re-gen post-unification).

1. Chargement des résultats

Résultats pre-calcules (6 configs : BTC/ETH x h=1/5/10, 4 seeds pour l’init HMM, walk-forward 5-fold expanding, refit tous les 22 jours). Le fichier contient deux types de lignes : 24 per-seed (un verdict DM par graine) et 6 aggregates (verdict final par config, BEATS exige 4/4 seeds). La metrique est le MSE sur log-RV compare via test de Diebold-Mariano (HAC) au HAR classique.

import json
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

results_path = Path('scripts/results/m5_hmm_regime.json')
if not results_path.exists():
    # RECOVERABLE-MACHINE (cf. issues #11417/#11574) : artefact produit par
    # `python scripts/hmm_regime_vol.py` (gitignore, politique scripts/results/ de la serie, non commite
    # sur main ; il se regenere a chaque run). Precedent : PR #11420 (m15).
    # Sur un clone frais, ce notebook s'execute sans exception ici, mais les
    # cellules aval n'ont rien a traiter tant que l'artefact n'est pas produit.
    print('[INFO] Artefact manquant : ' + str(results_path))
    print('       Produit par : python scripts/hmm_regime_vol.py')
    print("       Runtime au run d'origine : ~576s (CPU, 2 coins x 3 horizons x 4 seeds HMM, WF 5-fold).")
    print('       RECOVERABLE-MACHINE (lane training, env conda coursia-ml-training).')
    print('       Note de portee : issue #11574 (verdict ecrit).')
    data = None
    all_rows = None
    per_seed = None
    agg = None
else:
    with open(results_path) as f:
        data = json.load(f)

    all_rows = pd.DataFrame(data['results'])
    per_seed = all_rows[all_rows['seed'] != 'aggregate'].copy()
    agg = all_rows[all_rows['seed'] == 'aggregate'].copy()
    per_seed['seed'] = per_seed['seed'].astype(int)

    print(f'Model: HMM Regime-Switching HAR (Hamilton 1989 + Corsi 2009)')
    print(f'Approche: {data["approach"]}')
    print(f'HMM: K={data["n_hmm_states"]} etats Gaussiens, decodage Viterbi sur log-RV')
    print(f'Coefficients: 8 (4 base HAR + 4 termes interaction regime) vs 3 pour le HAR classique')
    print(f'Seeds HMM: {data["seeds"]} (init) | Horizons: {data["horizons"]} | WF {data["n_splits"]}-fold, refit {data["refit_every"]}j')
    print(f'Lignes par graine: {len(per_seed)} (2 coins x 3 horizons x {len(per_seed)//6} par config) | Aggregates: {len(agg)}')
    print(f'Runtime original: {data["elapsed_seconds"]:.0f}s')
    print(f'Metrique: MSE sur log-RV + test Diebold-Mariano (HAC) vs HAR classique (baseline M3/M12)')
Model: HMM Regime-Switching HAR (Hamilton 1989 + Corsi 2009)
Approche: regime-switching: separate HAR per Viterbi-decoded regime
HMM: K=2 etats Gaussiens, decodage Viterbi sur log-RV
Coefficients: 8 (4 base HAR + 4 termes interaction regime) vs 3 pour le HAR classique
Seeds HMM: [0, 7, 42, 99] (init) | Horizons: [1, 5, 10] | WF 5-fold, refit 22j
Lignes par graine: 24 (2 coins x 3 horizons x 4 par config) | Aggregates: 6
Runtime original: 654s
Metrique: MSE sur log-RV + test Diebold-Mariano (HAC) vs HAR classique (baseline M3/M12)

2. Verdict global : 1/6 BEATS, 1/6 NO BEATS, 4/6 INCONCLUSIVE

La machine d’etats commune (_aggregate_state, voir scripts/hmm_regime_vol.py) partage quatre verdicts entre la jambe brute et la jambe hors biais (#14388) :

Etat Condition Sens
BEATS 4/4 seeds BEATS et dm_p_median < 0.05 le modele gagne, significativement
NO BEATS 4/4 seeds BEATEN et dm_p_median < 0.05 le modele perd, significativement
refuted-de-biased 4/4 BEATS sur la jambe parente uniquement l’edge n’existait que contre une baseline mal calibree
INCONCLUSIVE tout le reste (dont 3/4 d’un cote ou l’autre, ou 4/4 non-significatif) pas d’unanimite stricte

Avant #14388 la jambe brute n’avait que deux etats (BEATS ou INCONCLUSIVE) : les 4 configs a 0/4 seeds BEATS etaient codees INCONCLUSIVE et la lecture DEGRADE etait derivee a la main. La machine unifiee fait sortir NO BEATS directement, mais la convention unanimite stricte reste : une graine INCONCLUSIVE (DM p >= 0.05 pour cette graine-la) compte comme pas-BEATEN. Trois configs sont en realite 3/4 BEATEN + 1 INCONCLUSIVE (BTC h=5, BTC h=10, ETH h=5) : une graine n’est pas significative et la machine les maintient en INCONCLUSIVE. Seul ETH h=10 a ses 4 graines unanimes BEATEN et bascule en NO BEATS. La convention de lecture se relit dans le doc M5 #14388.

n_beats = int((agg['aggregate_verdict'] == 'BEATS').sum())
n_no_beats = int((agg['aggregate_verdict'] == 'NO BEATS').sum())
n_inc = int((agg['aggregate_verdict'] == 'INCONCLUSIVE').sum())
n_refuted = int((agg['aggregate_verdict'] == 'refuted-de-biased').sum())
n_3_4_beaten_1_inc = int(((agg['n_beats_seeds'].str[:1].astype(int) == 0) & (agg['aggregate_verdict'] == 'INCONCLUSIVE')).sum())
n_3_4_beats_1_inc = int(((agg['n_beats_seeds'].str[:1].astype(int) == 3) & (agg['aggregate_verdict'] == 'INCONCLUSIVE')).sum())
print('=== Verdict global HMM-Regime vs HAR classique (previsions MSE, jambe brute, machine 4 etats #14388) ===')
print()
print(f'BEATS (4/4 seeds BEATS + DM p<0.05)         : {n_beats}/6')
print(f'NO BEATS (4/4 seeds BEATEN + p<0.05)        : {n_no_beats}/6')
print(f"INCONCLUSIVE (pas d unanimite)              : {n_inc}/6")
print(f'  dont 3/4 BEATEN + 1 INCONCLUSIVE           : {n_3_4_beaten_1_inc}/6')
print(f'  dont 3/4 BEATS + 1 INCONCLUSIVE (BTC h=1)  : {n_3_4_beats_1_inc}/6')
print(f'refuted-de-biased (brut BEATS + rec. pas)   : {n_refuted}/6 (0 attendu sur jambe brute)')
print()
print('Table per-config (verdict aggregate brut, machine commune _aggregate_state) :')
print(f'{"config":<10} {"regime_MSE":<11} {"classic_MSE":<12} {"reduction":<10} {"seeds":<8} {"verdict brut"}')
print('-' * 72)
for _, r in agg.sort_values(["coin","horizon"]).iterrows():
    coin = r["coin"].replace("-USD","")
    print(f'{coin+" h="+str(int(r["horizon"])):<10} {r["mean_regime_mse"]:.3f}      {r["mean_classic_mse"]:.3f}       {r["mean_reduction_pct"]:+6.1f}%   {r["n_beats_seeds"]:<8} {r["aggregate_verdict"]}')
print()
print('Note honnete : la jambe brute partage la machine 4 etats avec la jambe hors biais (#14388).')
print('Auparavant, les configs en 0/4 BEATS etaient codees INCONCLUSIVE : le notebook en derivait')
print('DEGRADE a la main, et la jambe brute ne pouvait pas reproduire son propre verdict publie.')
print('Avec la machine unifiee, NO BEATS sort directement du verdict brut (cf. doc M5 #14388).')
print('Distinction importante : 3/4 BEATEN + 1 INCONCLUSIVE (une graine non-significative) reste')
print("INCONCLUSIVE -- l'unanimite stricte exige 4/4 d'un seul cote, pas 3/4 avec un neutre.")
=== Verdict global HMM-Regime vs HAR classique (previsions MSE, jambe brute, machine 4 etats #14388) ===

BEATS (4/4 seeds BEATS + DM p<0.05)         : 1/6
NO BEATS (4/4 seeds BEATEN + p<0.05)        : 1/6
INCONCLUSIVE (pas d unanimite)              : 4/6
  dont 3/4 BEATEN + 1 INCONCLUSIVE           : 3/6
  dont 3/4 BEATS + 1 INCONCLUSIVE (BTC h=1)  : 1/6
refuted-de-biased (brut BEATS + rec. pas)   : 0/6 (0 attendu sur jambe brute)

Table per-config (verdict aggregate brut, machine commune _aggregate_state) :
config     regime_MSE  classic_MSE  reduction  seeds    verdict brut
------------------------------------------------------------------------
BTC h=1    0.825      0.888         +7.0%   3/4      INCONCLUSIVE
BTC h=5    0.580      0.522        -11.1%   0/4      INCONCLUSIVE
BTC h=10   0.732      0.571        -28.3%   0/4      INCONCLUSIVE
ETH h=1    0.619      0.684         +9.6%   4/4      BEATS
ETH h=5    0.441      0.374        -17.9%   0/4      INCONCLUSIVE
ETH h=10   0.573      0.375        -53.0%   0/4      NO BEATS

Note honnete : la jambe brute partage la machine 4 etats avec la jambe hors biais (#14388).
Auparavant, les configs en 0/4 BEATS etaient codees INCONCLUSIVE : le notebook en derivait
DEGRADE a la main, et la jambe brute ne pouvait pas reproduire son propre verdict publie.
Avec la machine unifiee, NO BEATS sort directement du verdict brut (cf. doc M5 #14388).
Distinction importante : 3/4 BEATEN + 1 INCONCLUSIVE (une graine non-significative) reste
INCONCLUSIVE -- l'unanimite stricte exige 4/4 d'un seul cote, pas 3/4 avec un neutre.

3. La degradation croissante avec l’horizon (finding cle)

C’est le résultat le plus important du notebook. La reduction MSE vs HAR classique est positive a h=1 (le regime aide, surtout ETH) mais devient fortement negative aux horizons longs (jusqu’a -53% a ETH h=10 = le modèle ajoute plus de la moitie d’erreur en plus). La raison technique (cf doc finding #5) : la prevision h-step iterer utilise un unique indicateur de regime R pour TOUTES les étapes de la fenêtre de prevision – mais le regime peut switcher en cours de prevision. Le modèle ne peut pas s’adapter mid-forecast, et les termes d’interaction accumulent l’erreur.

print('=== Reduction MSE vs HAR classique, par horizon (moyenne 2 coins) ===')
print()
print(f'{"horizon":<10} {"BTC":<14} {"ETH":<14} {"moyenne"}')
for h in [1, 5, 10]:
    b = agg[(agg['coin']=='BTC-USD') & (agg['horizon']==h)]['mean_reduction_pct'].iloc[0]
    e = agg[(agg['coin']=='ETH-USD') & (agg['horizon']==h)]['mean_reduction_pct'].iloc[0]
    print(f'h={h:<8} {b:+7.1f}%       {e:+7.1f}%       {(b+e)/2:+6.1f}%')

fig, ax = plt.subplots(figsize=(7.5, 4.2))
horizons = [1, 5, 10]
btc = [agg[(agg['coin']=='BTC-USD') & (agg['horizon']==h)]['mean_reduction_pct'].iloc[0] for h in horizons]
eth = [agg[(agg['coin']=='ETH-USD') & (agg['horizon']==h)]['mean_reduction_pct'].iloc[0] for h in horizons]
x = np.arange(len(horizons))
w = 0.36
bars_b = ax.bar(x - w/2, btc, w, label='BTC-USD', color='#f39c12', edgecolor='k', linewidth=0.5)
bars_e = ax.bar(x + w/2, eth, w, label='ETH-USD', color='#9b59b6', edgecolor='k', linewidth=0.5)
ax.axhline(0, color='green', linewidth=1.2, linestyle='--', label='HAR classique (baseline, 0%)')
ax.set_xticks(x); ax.set_xticklabels([f'h={h}' for h in horizons])
ax.set_ylabel('Reduction MSE regime-switching vs HAR classique (%)')
ax.set_title('La sophistication regime nuit aux horizons longs : +9.6% (ETH h=1) -> -53% (ETH h=10)')
for bars in (bars_b, bars_e):
    for b in bars:
        ax.text(b.get_x()+b.get_width()/2, b.get_height() + (1 if b.get_height()>=0 else -2.5),
                f'{b.get_height():+.0f}%', ha='center', fontsize=8, fontweight='bold')
ax.legend(loc='lower left', fontsize=9)
plt.tight_layout()
plt.show()
print('Vert = le regime bat HAR. h=1 legerement positif (ETH +9.6%) ; h=5/h=10 fortement negatif (degrade).')
=== Reduction MSE vs HAR classique, par horizon (moyenne 2 coins) ===

horizon    BTC            ETH            moyenne
h=1           +7.0%          +9.6%         +8.3%
h=5          -11.1%         -17.9%        -14.5%
h=10         -28.3%         -53.0%        -40.7%

Vert = le regime bat HAR. h=1 legerement positif (ETH +9.6%) ; h=5/h=10 fortement negatif (degrade).

4. La sensibilite a l’initialisation HMM (finding #4)

Pourquoi le verdict est-il si instable ? Le decodage Viterbi de l’HMM depend fortement de l’initialisation (les seeds). Le doc finding #4 note : « la graine 0 et 99 peuvent classer 60%/40% bas/haut, tandis que la graine 7 produit 80%/20% ». Cette variance se traduit directement en instabilite de prevision. Le graphique ci-dessous montre la dispersion de la reduction MSE par graine : même sur ETH h=1 (le seul BEATS), une graine produit un edge quasi-nul.

print('=== Dispersion de la reduction MSE par graine (24 per-seed rows) ===')
print()
print(f'{"config":<10} {"seeds BEATS":<12} {"reduction par graine (%)":<40}')
for (coin, h), grp in per_seed.groupby(['coin','horizon']):
    reds = grp.sort_values('seed')['mse_reduction_pct'].values
    seeds_str = ' '.join(f'{r:+5.1f}' for r in reds)
    n_b = int((grp['dm_verdict']=='BEATS baseline').sum())
    lbl = coin[-3:].strip()[-3:] + f' h={h}'
    print(f'{lbl:<10} {n_b}/4         [{seeds_str}]')

fig, ax = plt.subplots(figsize=(8, 4.2))
labels = []
positions = []
data_box = []
i = 0
for (coin, h), grp in per_seed.groupby(['coin','horizon']):
    reds = grp['mse_reduction_pct'].values
    data_box.append(reds)
    positions.append(i)
    labels.append(f'{coin[-3:].strip()}\nh={h}')
    i += 1
bp = ax.boxplot(data_box, positions=positions, widths=0.6, patch_artist=True,
                boxprops=dict(facecolor='#dfe6e9', edgecolor='k', linewidth=0.6),
                medianprops=dict(color='#e74c3c', linewidth=1.6),
                flierprops=dict(marker='o', markerfacecolor='#e74c3c', markersize=4))
ax.axhline(0, color='green', linewidth=1.0, linestyle='--', alpha=0.7)
ax.set_xticks(positions); ax.set_xticklabels(labels, fontsize=8)
ax.set_ylabel('Reduction MSE par graine (%)')
ax.set_title('Dispersion par graine : meme ETH h=1 (BEATS) a une graine quasi-nulle (instabilite HMM)')
plt.tight_layout()
plt.show()
print('Boite = dispersion inter-graines (4 points). Barre rouge = mediane. La boite etendue = sensibilite HMM.')
=== Dispersion de la reduction MSE par graine (24 per-seed rows) ===

config     seeds BEATS  reduction par graine (%)                
USD h=1    3/4         [+10.0  +0.1  +8.3  +9.6]
USD h=5    0/4         [-15.3  -0.8  -4.1 -24.4]
USD h=10   0/4         [-40.5  -1.4 -15.4 -55.8]
USD h=1    4/4         [ +9.5 +11.0  +6.2 +11.7]
USD h=5    0/4         [-20.3 -20.5 -11.3 -19.7]
USD h=10   0/4         [-53.6 -74.4 -32.9 -51.1]

Boite = dispersion inter-graines (4 points). Barre rouge = mediane. La boite etendue = sensibilite HMM.

Synthese : deux axes d’echec, une même lecon

Le pipeline vol echoue a battre le HAR simple par deux axes distincts de sophistication, et la conclusion est identique : ajouter de la complexite n’aide pas sur ces données.

Axe 1 : expressivite neuronale (DL)

Modèle Params Angle Verdict Notebook
M12 HAR-RV-J (OLS) 7 stratégie BEATS, deploye m12
M4 DLinear ~22 prevision (MSE) 5/21 BEATS (BTC) m4
M15 Log-LSTM h=32 4 769 stratégie NO BEATS, retire (#11395) m15
M9 TFT ~110K prevision (DirAcc) 0/6 BEATS (overfit DL) m9

Axe 2 : sophistication structurelle (OLS augmente)

Modèle Params Verdict Notebook
M3b HAR asymetrique (semivariance) 5 3/21 BEATS (BTC) (doc)
M5 HAR-HMM regime 8 + HMM 1/6 BEATS, nuisible h>=5 ce notebook

M5 est le maillon structurel de l’echec : même sans reseau de neurones, decomposer le HAR par regime ajoute du bruit (termes d’interaction) qui compunde sur les horizons longs. Le DL (M9) surcompile par capacite ; le HAR-HMM (M5) surcompile par structure. Les deux concluent que le HAR a 3 coefficients reste le meilleur compromis sur ce benchmark crypto, et que le modèle le plus simple (M12 OLS, 7 paramètres) est le seul a atteindre la production.

fig, ax = plt.subplots(figsize=(8, 3.8))
models = ['M12 OLS', 'M3b asym', 'M4 DLinear', 'M15 LSTM', 'M5 HMM-reg', 'M9 TFT']
params = [7, 5, 22, 4769, 8 + 30, 110801]  # M5 = 8 coeff + HMM params approx
beats_count = [1.0, 3/21, 5/21, 1.0, 1/6, 0/6]  # deployed/keeper normalized a 1.0
beats_lbl = ['DEPLOYE', '3/21', '5/21', 'KEEPER', '1/6', '0/6']
colors = ['#2ecc71', '#1abc9c', '#3498db', '#f39c12', '#9b59b6', '#e74c3c']
ax.scatter(params, beats_count, s=[200,140,160,180,160,240], c=colors, edgecolor='k', linewidth=0.8, zorder=3)
for i, (m, p, b, l) in enumerate(zip(models, params, beats_count, beats_lbl)):
    yoff = 0.05 if b < 0.6 else -0.08
    ax.annotate(f'{m}\n({l})', (p, b), xytext=(8, 0), textcoords='offset points', fontsize=8, va='center')
ax.set_xscale('log')
ax.set_ylim(-0.08, 1.18)
ax.set_xlabel('Complexite (nombre de parametres, echelle log)')
ax.set_ylabel('Taux de succes (BEATS)')
ax.set_title('Deux axes d echec convergent : expressivite neuronale (M9) ET sophistication structurelle (M5)')
ax.axhline(0, color='gray', linewidth=0.6)
plt.tight_layout()
plt.show()
print('Vert = deploye (M12). Rouge = 0/6 (M9). Violet = nuisible h>=5 (M5). La complexite ne paie pas.')

Vert = deploye (M12). Rouge = 0/6 (M9). Violet = nuisible h>=5 (M5). La complexite ne paie pas.

6. Lecon : ne pas confondre richer features et meilleur modèle

L’echec du HAR-HMM regime est pedagogiquement subtil. Le modèle part d’une intuition economique solide (les regimes de volatilite existent, BTC alterne calmes et tempetes), l’implemente correctement (HMM standard, Viterbi, OLS avec interactions), et echoue quand même. Pourquoi ?

  1. Le HAR capte déjà les regimes implicitement. Ses moyennes mobiles 1j/5j/22j reagissent aux changements de volatilite – la decomposition regime est en partie redondante avec la structure temporelle que le HAR encode déjà.
  2. Les termes d’interaction ajoutent du bruit sur des données limitees. Estimer 8 coefficients sur 2 ans de données ETH revient a diviser le signal par regime – chaque sous-modèle a encore moins de données. C’est la même loi qu’au fold 1 du TFT (M9) : moins de données par paramètre = plus d’overfitting, même pour un OLS.
  3. La prevision multi-step casse l’hypothese de regime stationnaire. Le defaut fondamental (doc finding #5) : un seul R pour toute la fenêtre h=10, alors que le regime peut switcher.

Le fil rouge du pipeline vol

Sur ce benchmark crypto a données limitees, chaque source de complexite ajoutee au HAR simple a 3 coefficients est un echec – qu’elle vienne du DL (M9 TFT, M15 LSTM), de l’asymetrie (M3b), ou du regime-switching (M5). Le modèle OLS a 7 paramètres (M12) reste le seul deploye. Cette convergence de M5, M9 et M15 vers le même constat est la lecon la plus robuste de la serie vol : sur des données bruitees et limitees, la parcimonie bat l’expressivite comme la sophistication.

References

  • Hamilton, J.D. (1989). A New Approach to the Economic Analysis of Nonstationary Time Series and the Business Cycle. Econometrica 57(2). (Le papier fondateur des HMM de regime).
  • Corsi, F. (2009). A Simple Approximate Long-Memory Model of Realized Volatility. (HAR baseline).
  • Diebold, F.X. & Mariano, R.S. (1995). Comparing Predictive Accuracy. (test DM).
  • Pour l’echec parallele du DL : M9 TFT. Pour le deployed OLS : M12 HAR-RV-J. Fiche technique : docs/M5_HMM_REGIME.md.
Retour au sommet