ML Arc Capstone — the full toolkit on one problem, done right¶

Forecasting S&P realized volatility with every model in the arc, and the applied workflow that surrounds them¶

This capstone brings the whole Machine-Learning arc together on a single, real problem — forecasting S&P 500 realized volatility — and does it the way a quant desk actually would. It combines, in one notebook, the threads of the entire arc:

  • the model roster (subsections 1–3): regularized linear models (Ridge/Lasso/Elastic Net), kernel methods (SVR, Gaussian process), tree ensembles (random forest, XGBoost), and neural networks (a feature-fed MLP, plus the raw-sequence LSTM/Transformer from the deep-learning notebooks) — against the classical volatility benchmark, HAR-RV;
  • feature engineering (subsections 4, 7): the known structure of volatility encoded as features — including a fractionally-differenced price (López de Prado), the memory-preserving stationary feature from the Financial-ML subsection;
  • honest, leakage-free validation (subsections 4, 7): a live demonstration of how ordinary cross-validation lies on time series, escalating through temporal, walk-forward, and finally purged & embargoed cross-validation;
  • calibrated uncertainty (subsections 3, 5, 6): a Gaussian process error bar checked for coverage, then a split-conformal interval with a distribution-free coverage guarantee — the BART/GP theme carried through to conformal prediction;
  • interpretability (subsection 6): SHAP to see which engineered features actually drive the forecast.

The point is not to crown a winner but to show the judgement the arc has been building: feature engineering usually beats model choice, a strong classical baseline is hard to beat, validation done wrong will fool you, and a forecast without a guaranteed error bar or an explanation is only half a model. Data: S&P realized variance + returns, 2000–2013 (spx_rv_ret.csv), the series shared with the volatility arc. Python-only. ROC-AUC / RMSE conventions as in the earlier notebooks.

1. The problem and feature engineering — including a fractionally-differenced price¶

The target is next-day log realized volatility, $\log(\sqrt{\text{RV}_{t+1}}\times100)$ — realized volatility is the model-free measure of daily market movement (sum of squared intraday returns), and it is persistent enough to forecast (unlike returns). Rather than hand the models a raw price series, we do what applied forecasting always does first: engineer features that encode the known structure of volatility —

  • HAR components — today's log-RV, and its trailing weekly (5-day) and monthly (22-day) averages: Corsi's (2009) three-term approximation to volatility's long memory, and the classical benchmark's entire input;

  • short lags of log-RV (1, 2, 5 days) for finer dynamics;

  • return-based features — absolute return, squared return, and a 5-day average absolute return (the leverage/news channel);

  • a 10-day rolling dispersion of log-RV;

  • a fractionally-differenced log-price at $d=0.50$ — from the Financial-ML fractional differentiation notebook. Integer differencing (returns, $d=1$) is stationary but memoryless; the log-price ($d=0$) is full-memory but non-stationary. The operator $(1-B)^d$ keeps the level information ML needs in a stationary form, injecting a price-level signal the pure-volatility features lack.

    The order matters and is worth stating precisely, because the obvious rule picks the wrong one. Selecting the smallest $d$ that clears an ADF test gives about 0.35 on this series — but that result still measures $I(0.63)$, above the 0.5 boundary at which a fractionally integrated series becomes stationary at all. The order that genuinely crosses it is $d\approx0.50$, and that is also the order which maximises out-of-sample $R^2$ on this exact target. Two independent criteria, one answer.

Every tabular model sees this same engineered matrix, so the comparison is about models, not who got better features.

In [1]:
import os
os.environ["KMP_DUPLICATE_LIB_OK"]="TRUE"
import numpy as np, pandas as pd, matplotlib.pyplot as plt, warnings, time
warnings.filterwarnings("ignore")
BLUE="#2b6cb0"; RED="#c53030"; GREEN="#2f855a"; ORANGE="#dd6b20"; GREY="#a0aec0"; PURP="#6b46c1"
d=pd.read_csv("spx_rv_ret.csv"); dates=pd.to_datetime(d["date"]); rv=d["rv"].values; ret=d["ret"].values; n=len(rv)
lvol=np.log(np.sqrt(rv)*100.0); S=pd.Series(lvol)

# --- fractional differentiation (López de Prado, Financial-ML subsec.): weights of (1-B)^d ---
def ffd_weights(dd, thresh=1e-4, maxk=1000):
    w=[1.0]; k=1
    while k<maxk:
        wk=-w[-1]*(dd-k+1)/k
        if abs(wk)<thresh: break
        w.append(wk); k+=1
    return np.array(w)
def frac_diff(x, dd, thresh=1e-4):
    w=ffd_weights(dd,thresh)[::-1]; width=len(w); out=np.full(len(x),np.nan)
    for i in range(width-1,len(x)): out[i]=np.dot(w, x[i-width+1:i+1])
    return out
lp=np.cumsum(ret/100.0)                       # log price
# Order chosen by the INTEGRATION ORDER of the result, not by an ADF test. The Financial-ML fractional-
# differentiation notebook shows an ADF-based minimum-d rule selects ~0.35 here, but that series still measures
# I(0.63) -- above the 0.5 stationarity boundary. The order that actually crosses into stationarity is ~0.50,
# and it is also the order that maximises out-of-sample R-squared on this very target.
DFRAC=0.50
fd_price=frac_diff(lp, DFRAC)

feat=pd.DataFrame({"RV_d":lvol,"RV_w":S.rolling(5).mean(),"RV_m":S.rolling(22).mean(),
                   "lag1":S.shift(1),"lag2":S.shift(2),"lag5":S.shift(5),
                   "absret":np.abs(ret),"ret2":ret**2,"absret_w":pd.Series(np.abs(ret)).rolling(5).mean(),
                   "disp10":S.rolling(10).std(),"fd_price":fd_price})
names=list(feat.columns); y=np.r_[lvol[1:],np.nan]
df=feat.copy(); df["y"]=y; df=df.dropna(); X=df[names].values; Y=df["y"].values
keep_dates=dates.values[feat.notna().all(1).values & ~np.isnan(y)]
print(f"{n} trading days {d['date'].iloc[0]} to {d['date'].iloc[-1]}; engineered {X.shape[1]} features, {len(Y)} usable rows")
print(f"fractionally-differenced price at d={DFRAC}: corr with log-price {np.corrcoef(fd_price[~np.isnan(fd_price)],lp[~np.isnan(fd_price)])[0,1]:.2f} "
      f"(memory kept) vs returns corr {np.corrcoef(np.diff(lp),lp[1:])[0,1]:.2f} (memory gone)")
fig,ax=plt.subplots(1,2,figsize=(13.5,4))
ax[0].plot(dates,np.sqrt(rv)*100*np.sqrt(252),color=BLUE,lw=.6); ax[0].set_title("S&P realized volatility (annualized %) — the target's history"); ax[0].set_ylabel("annualized vol %")
im=ax[1].imshow(np.corrcoef(X.T),cmap="RdBu_r",vmin=-1,vmax=1); ax[1].set_xticks(range(len(names))); ax[1].set_xticklabels(names,rotation=90,fontsize=7); ax[1].set_yticks(range(len(names))); ax[1].set_yticklabels(names,fontsize=7)
ax[1].set_title("Engineered feature correlations (fd_price is the near-orthogonal one)"); plt.colorbar(im,ax=ax[1],fraction=0.046)
plt.tight_layout(); plt.show()
print("The volatility features are highly collinear (all measure recent vol) -- why regularization matters; the fractionally-")
print("differenced price stands apart, contributing price-level memory the vol features do not carry.")
3459 trading days 2000-01-03 to 2013-11-12; engineered 11 features, 3259 usable rows
fractionally-differenced price at d=0.5: corr with log-price 0.58 (memory kept) vs returns corr 0.05 (memory gone)
No description has been provided for this image
The volatility features are highly collinear (all measure recent vol) -- why regularization matters; the fractionally-
differenced price stands apart, contributing price-level memory the vol features do not carry.

2. The full model roster — one honest temporal split¶

Every model forecasts next-day log-RV out of sample on the last 20% of the timeline (a strict temporal split — train on the past, test on the future, never the reverse). The roster spans the whole arc: the classical HAR-RV, the regularized linear trio, kernel methods (SVR, Gaussian process), tree ensembles (random forest, XGBoost), and a feature-fed MLP. Metric: out-of-sample RMSE on log realized volatility.

The LSTM and Transformer are reported alongside but deliberately outside the ranking. They are trained on the raw 22-day sequence in the deep-learning notebooks, on those notebooks' own splits, so placing them in the same bar chart would imply a like-for-like comparison that was never run. HAR is fitted in both setups and acts as the bridge between them.

A ranking by RMSE also says nothing about whether the gaps are real, so each is tested against the leader with a Diebold-Mariano test on squared-error loss. That turns "the roster converges" from an impression into a claim that can be checked — and it does not entirely survive.

In [2]:
from sklearn.linear_model import LinearRegression, RidgeCV, LassoCV, ElasticNetCV
from sklearn.svm import SVR
from sklearn.ensemble import RandomForestRegressor
from sklearn.neural_network import MLPRegressor
from sklearn.gaussian_process import GaussianProcessRegressor
from sklearn.gaussian_process.kernels import RBF, WhiteKernel, ConstantKernel as C
from sklearn.preprocessing import StandardScaler
import xgboost as xgb
def rmse(a,b): return np.sqrt(np.mean((a-b)**2))
sp=int(0.8*len(Y)); Xtr,Xte,Ytr,Yte=X[:sp],X[sp:],Y[:sp],Y[sp:]
sc=StandardScaler().fit(Xtr); Ztr=sc.transform(Xtr); Zte=sc.transform(Xte)
R={}; PRED={}
PRED["HAR-RV (classical)"]=LinearRegression().fit(Xtr[:,:3],Ytr).predict(Xte[:,:3]); R["HAR-RV (classical)"]=rmse(PRED["HAR-RV (classical)"],Yte)
PRED["Ridge"]=RidgeCV(alphas=np.logspace(-3,3,40)).fit(Ztr,Ytr).predict(Zte); R["Ridge"]=rmse(PRED["Ridge"],Yte)
PRED["Lasso"]=LassoCV(n_alphas=50,cv=5,max_iter=20000).fit(Ztr,Ytr).predict(Zte); R["Lasso"]=rmse(PRED["Lasso"],Yte)
PRED["Elastic Net"]=ElasticNetCV(l1_ratio=0.5,n_alphas=50,cv=5,max_iter=20000).fit(Ztr,Ytr).predict(Zte); R["Elastic Net"]=rmse(PRED["Elastic Net"],Yte)
PRED["SVR (RBF kernel)"]=SVR(C=1,gamma="scale").fit(Ztr,Ytr).predict(Zte); R["SVR (RBF kernel)"]=rmse(PRED["SVR (RBF kernel)"],Yte)
sub=np.random.default_rng(0).choice(len(Ztr),min(1200,len(Ztr)),replace=False)
gp=GaussianProcessRegressor(kernel=C(1.0)*RBF(np.ones(X.shape[1]))+WhiteKernel(0.1),normalize_y=True).fit(Ztr[sub],Ytr[sub])
gmu,gsd=gp.predict(Zte,return_std=True); PRED["Gaussian process"]=gmu; R["Gaussian process"]=rmse(gmu,Yte)
PRED["Random forest"]=RandomForestRegressor(n_estimators=300,min_samples_leaf=5,random_state=0,n_jobs=-1).fit(Xtr,Ytr).predict(Xte); R["Random forest"]=rmse(PRED["Random forest"],Yte)
xgm=xgb.XGBRegressor(n_estimators=300,learning_rate=0.05,max_depth=3,verbosity=0); xgm.fit(Xtr,Ytr); PRED["XGBoost"]=xgm.predict(Xte); R["XGBoost"]=rmse(PRED["XGBoost"],Yte)
PRED["MLP (feature-fed)"]=MLPRegressor((64,32),alpha=1e-3,max_iter=500,random_state=0).fit(Ztr,Ytr).predict(Zte); R["MLP (feature-fed)"]=rmse(PRED["MLP (feature-fed)"],Yte)
# The LSTM and Transformer are NOT refitted here. They are trained on the raw 22-day sequence in the
# deep-learning notebooks, on those notebooks' own splits, so their numbers are not produced by this cell and
# do not belong inside a ranking of models scored on this one. They are reported separately below.
LSTM_SRC, TRF_SRC, HAR_SRC = 0.3465, 0.3531, 0.3473          # as published in ex13 (LSTM) and ex14 (Transformer)
tab=pd.Series(R).sort_values()
print("OOS RMSE on log realized volatility (lower = better):"); print(tab.round(4).to_string())
fig,ax=plt.subplots(figsize=(8.5,5))
def col(k): return GREEN if "HAR" in k else (PURP if ("LSTM" in k or "Transformer" in k or "MLP" in k) else (ORANGE if ("SVR" in k or "process" in k) else BLUE))
ax.barh(tab.index,tab.values,color=[col(k) for k in tab.index]); ax.invert_yaxis(); ax.set_xlim(min(tab.values)-0.005,max(tab.values)+0.005); ax.set_xlabel("OOS RMSE")
ax.set_title("The full roster on realized-vol forecasting")
for i,v in enumerate(tab.values): ax.text(v+0.0005,i,f"{v:.3f}",va="center",fontsize=8)
plt.tight_layout(); plt.show()
# Ranking by RMSE says nothing about whether the gaps are real. Test them.
from scipy.stats import norm as _norm
def dm_stat(e1,e2,h=1):
    """Diebold-Mariano on squared-error loss, Newey-West corrected."""
    dd_=e1-e2; Tn=len(dd_); s=np.var(dd_,ddof=0)
    for l in range(1,h+1):
        s+=2*(1-l/(h+1))*np.cov(dd_[l:],dd_[:-l],ddof=0)[0,1]
    return dd_.mean()/np.sqrt(s/Tn)
bench=tab.index[0]; eb=(Yte-PRED[bench])**2
print(f"\nDiebold-Mariano against the leader ({bench}, RMSE {tab.iloc[0]:.4f}) -- is each gap real?")
print(f"  {'model':22s} {'RMSE':>8} {'gap':>9} {'DM t':>7} {'p':>7}  verdict")
worse=[]
for k in tab.index[1:]:
    t_=dm_stat((Yte-PRED[k])**2,eb); p_=2*(1-_norm.cdf(abs(t_)))
    if p_<0.05: worse.append(k)
    print(f"  {k:22s} {tab[k]:>8.4f} {tab[k]-tab.iloc[0]:>+9.4f} {t_:>7.2f} {p_:>7.3f}  {'significantly worse' if p_<0.05 else 'not distinguishable'}")

print(f"\nThe roster does NOT simply converge. {len(tab)-1-len(worse)} of the {len(tab)-1} challengers are statistically indistinguishable from")
print(f"{bench} -- " + ", ".join(str(k) for k in tab.index if k != bench and k not in worse)
      + f" -- while {len(worse)} are significantly worse.")
print(f"\nThe one worth naming is HAR-RV. It loses to {bench} by {tab['HAR-RV (classical)']-tab.iloc[0]:.4f} RMSE at p={2*(1-_norm.cdf(abs(dm_stat((Yte-PRED['HAR-RV (classical)'])**2,eb)))):.3f} -- not 'matched but barely")
print("beaten', but beaten decisively. What beats it is a RIDGE REGRESSION on the engineered features, which is the")
print("real lesson: the gain came from the features, and the simplest possible model was enough to collect it.")
print(f"XGBoost, the SVR and the MLP all lose to that ridge significantly. Complexity bought nothing here; features did.")

print(f"\nThe two sequence models are shown separately and deliberately. Trained on the RAW 22-day sequence in the")
print(f"deep-learning notebooks, they score LSTM {LSTM_SRC:.4f} and Transformer {TRF_SRC:.4f} -- but on those notebooks' own")
print(f"splits, not this one. The bridge between the two setups is HAR, fitted in both: {HAR_SRC:.4f} there against")
print(f"{tab['HAR-RV (classical)']:.4f} here, a difference of {abs(HAR_SRC-tab['HAR-RV (classical)']):.4f}, so the setups are close but not identical.")
print(f"Read with that caveat, the LSTM lands about level with HAR and the Transformer somewhat behind it; both are")
print(f"beaten by a ridge regression on engineered features. Putting them in the same bar chart as models scored on")
print("this split would have implied a like-for-like comparison that was never run.")
OOS RMSE on log realized volatility (lower = better):
Ridge                 0.3299
Elastic Net           0.3306
Lasso                 0.3307
Gaussian process      0.3327
Random forest         0.3362
XGBoost               0.3374
MLP (feature-fed)     0.3381
SVR (RBF kernel)      0.3448
HAR-RV (classical)    0.3462
No description has been provided for this image
Diebold-Mariano against the leader (Ridge, RMSE 0.3299) -- is each gap real?
  model                      RMSE       gap    DM t       p  verdict
  Elastic Net              0.3306   +0.0007    1.33   0.184  not distinguishable
  Lasso                    0.3307   +0.0008    1.46   0.144  not distinguishable
  Gaussian process         0.3327   +0.0029    1.45   0.148  not distinguishable
  Random forest            0.3362   +0.0063    1.86   0.063  not distinguishable
  XGBoost                  0.3374   +0.0075    1.99   0.046  significantly worse
  MLP (feature-fed)        0.3381   +0.0083    2.17   0.030  significantly worse
  SVR (RBF kernel)         0.3448   +0.0149    3.58   0.000  significantly worse
  HAR-RV (classical)       0.3462   +0.0163    4.69   0.000  significantly worse

The roster does NOT simply converge. 4 of the 8 challengers are statistically indistinguishable from
Ridge -- Elastic Net, Lasso, Gaussian process, Random forest -- while 4 are significantly worse.

The one worth naming is HAR-RV. It loses to Ridge by 0.0163 RMSE at p=0.000 -- not 'matched but barely
beaten', but beaten decisively. What beats it is a RIDGE REGRESSION on the engineered features, which is the
real lesson: the gain came from the features, and the simplest possible model was enough to collect it.
XGBoost, the SVR and the MLP all lose to that ridge significantly. Complexity bought nothing here; features did.

The two sequence models are shown separately and deliberately. Trained on the RAW 22-day sequence in the
deep-learning notebooks, they score LSTM 0.3465 and Transformer 0.3531 -- but on those notebooks' own
splits, not this one. The bridge between the two setups is HAR, fitted in both: 0.3473 there against
0.3462 here, a difference of 0.0011, so the setups are close but not identical.
Read with that caveat, the LSTM lands about level with HAR and the Transformer somewhat behind it; both are
beaten by a ridge regression on engineered features. Putting them in the same bar chart as models scored on
this split would have implied a like-for-like comparison that was never run.

3. The validation trap — escalating to purged, embargoed cross-validation¶

This is the single most important, most-violated rule in applied ML forecasting. Ordinary $k$-fold cross-validation shuffles the data, so the model is trained on days after the ones it is tested on — look-ahead leakage. On an autocorrelated series that makes the score look far better than anything achievable live. The honest alternatives respect time, in increasing strictness:

  1. Temporal split — train past → test future (§2's number);
  2. Walk-forward / expanding window — refit repeatedly as time advances, how a strategy would actually have run;
  3. Purged & embargoed $k$-fold (López de Prado, from the Financial-ML subsection) — even walk-forward leaks when a label is built from a forward window: a training observation whose label window overlaps the test window shares information with it. Purging drops those overlapping training rows; an embargo additionally drops a small block right after each test fold to kill leakage through serial correlation. This is the strictest honest estimate.

Below: the same XGBoost scored four ways — leaky shuffled CV, honest temporal split, walk-forward, and the from-scratch purged+embargoed CV.

The result is a useful corrective to the slogan. On this problem the leakage penalty is real but small, because a one-day-ahead label barely overlaps its neighbours and there is almost nothing for purging to remove. What turns out to dominate is something the slogan does not mention at all: the three honest schemes disagree with each other by several times more than leakage moves the number, because they score different stretches of history and volatility is much harder to forecast in some years than others. Comparing RMSEs across validation schemes is meaningless unless they test the same period — a caveat worth more here than the leakage warning itself.

In [3]:
from sklearn.model_selection import KFold, cross_val_score, TimeSeriesSplit
mx=xgb.XGBRegressor(n_estimators=200,learning_rate=0.05,max_depth=3,verbosity=0)
leaky=-cross_val_score(mx,X,Y,cv=KFold(5,shuffle=True,random_state=0),scoring="neg_root_mean_squared_error").mean()
honest=-cross_val_score(mx,X,Y,cv=TimeSeriesSplit(5),scoring="neg_root_mean_squared_error").mean()
# walk-forward expanding window: RMSE per fold
tss=TimeSeriesSplit(6); wf=[]
for tr_i,te_i in tss.split(X):
    m=xgb.XGBRegressor(n_estimators=200,learning_rate=0.05,max_depth=3,verbosity=0).fit(X[tr_i],Y[tr_i])
    wf.append(rmse(m.predict(X[te_i]),Y[te_i]))
# --- purged & embargoed k-fold (from-scratch, Lopez de Prado ch.7) ---
def purged_kfold(t1, n_splits=5, embargo_pct=0.01):
    t1=np.asarray(t1); nn=len(t1); idx=np.arange(nn); embargo=int(nn*embargo_pct)
    for test_idx in np.array_split(idx,n_splits):
        t_start=test_idx[0]; t_end=test_idx[-1]; t_max=int(t1[test_idx].max())
        keep=~((idx<=t_max)&(t1>=t_start))                      # purge overlapping label windows
        if embargo>0: keep[t_end+1:t_end+1+embargo]=False       # embargo the block after the fold
        yield idx[keep], test_idx
t1=np.arange(len(Y))+1                                          # 1-day-ahead label ends at i+1
purged=[]
for tr_i,te_i in purged_kfold(t1,n_splits=6,embargo_pct=0.02):
    m=xgb.XGBRegressor(n_estimators=200,learning_rate=0.05,max_depth=3,verbosity=0).fit(X[tr_i],Y[tr_i])
    purged.append(rmse(m.predict(X[te_i]),Y[te_i]))
methods=["shuffled k-fold\n(LEAKY)","temporal split\n(honest)","walk-forward\n(honest)","purged+embargo\n(strictest)"]
vals=[leaky,R["XGBoost"],np.mean(wf),np.mean(purged)]
fig,ax=plt.subplots(1,2,figsize=(13,4.2))
ax[0].bar(methods,vals,color=[RED,GREEN,BLUE,PURP])
for i,v in enumerate(vals): ax[0].text(i,v+0.003,f"{v:.3f}",ha="center",fontsize=9)
ax[0].set_ylabel("XGBoost RMSE"); ax[0].set_title("Shuffled CV flatters by ~%.0f%% (look-ahead leakage)"%(100*(np.mean(purged)-leaky)/np.mean(purged)))
plt.setp(ax[0].get_xticklabels(),fontsize=8)
ax[1].plot(range(1,len(wf)+1),wf,"o-",color=BLUE,lw=2,label="walk-forward"); ax[1].plot(range(1,len(purged)+1),purged,"s-",color=PURP,lw=2,label="purged+embargo")
ax[1].set_xlabel("fold"); ax[1].set_ylabel("OOS RMSE"); ax[1].set_title("Honest folds agree; shuffled CV is the outlier"); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.show()
hon=[R["XGBoost"],np.mean(wf),np.mean(purged)]
print(f"  shuffled k-fold (leaky) {leaky:.4f}")
print(f"  temporal split          {R['XGBoost']:.4f}")
print(f"  walk-forward            {np.mean(wf):.4f}")
print(f"  purged + embargo        {np.mean(purged):.4f}")
print(f"\nThe expected story is that the leaky number stands apart and the honest ones agree. That is NOT what happens")
print(f"here. The three honest estimates span {max(hon)-min(hon):.4f} RMSE between themselves, while the gap from shuffled CV to the")
print(f"strictest honest estimate is only {abs(np.mean(purged)-leaky):.4f} -- the honest schemes disagree with each other about {(max(hon)-min(hon))/abs(np.mean(purged)-leaky):.0f} times more")
print("than leakage moves the number at all.")
print("\nThe reason is that they are not scoring the same thing. Each scheme tests on a different stretch of history,")
print("and volatility is far harder to forecast in some years than others. The temporal split tests only on the last")
print("20% of the timeline; walk-forward averages six expanding folds; purged k-fold tests on every period including")
print("the calm early years. On this problem the choice of TEST PERIOD dominates the choice of validation scheme.")
print(f"\nLeakage is also small here for a specific reason: the label is ONE day ahead, so label windows barely overlap")
print(f"and purging removes almost nothing. Shuffled CV still flatters -- {abs(np.mean(purged)-leaky):.4f} RMSE, about {100*abs(np.mean(purged)-leaky)/np.mean(purged):.0f}% -- and it")
print("would flatter enormously with multi-day labels, which is exactly what the Financial-ML notebook demonstrates")
print("in a setting built so that the truth is known. The honest summary for THIS problem is narrower than the usual")
print("slogan: shuffled CV is measurably optimistic, and comparing RMSEs across validation schemes is meaningless")
print("unless they score the same period.")
No description has been provided for this image
  shuffled k-fold (leaky) 0.2782
  temporal split          0.3374
  walk-forward            0.3015
  purged + embargo        0.2867

The expected story is that the leaky number stands apart and the honest ones agree. That is NOT what happens
here. The three honest estimates span 0.0507 RMSE between themselves, while the gap from shuffled CV to the
strictest honest estimate is only 0.0085 -- the honest schemes disagree with each other about 6 times more
than leakage moves the number at all.

The reason is that they are not scoring the same thing. Each scheme tests on a different stretch of history,
and volatility is far harder to forecast in some years than others. The temporal split tests only on the last
20% of the timeline; walk-forward averages six expanding folds; purged k-fold tests on every period including
the calm early years. On this problem the choice of TEST PERIOD dominates the choice of validation scheme.

Leakage is also small here for a specific reason: the label is ONE day ahead, so label windows barely overlap
and purging removes almost nothing. Shuffled CV still flatters -- 0.0085 RMSE, about 3% -- and it
would flatter enormously with multi-day labels, which is exactly what the Financial-ML notebook demonstrates
in a setting built so that the truth is known. The honest summary for THIS problem is narrower than the usual
slogan: shuffled CV is measurably optimistic, and comparing RMSEs across validation schemes is meaningless
unless they score the same period.

4. Calibrated uncertainty — a Bayesian error bar, a conformal guarantee, and its one assumption¶

A volatility number drives position sizing and risk limits, so its uncertainty is itself a decision input. Three lessons from the arc converge here:

  • the Gaussian process returns a full predictive distribution — a mean and a standard deviation — for free (subsections 3, 5). But a Bayesian interval only has the right coverage if the model is right, and a plain GP with a fixed noise term tends to be overconfident; we check its coverage and find it below nominal.
  • split-conformal prediction (Model-Evaluation subsection) removes the model-correctness assumption: hold out a calibration slice, take the $\lceil(n_{\text{cal}}+1)(1-\alpha)\rceil/n_{\text{cal}}$ empirical quantile of the absolute calibration residuals as the half-width, and the interval has a distribution-free, finite-sample coverage guarantee — provided calibration and test data are exchangeable.
  • that one assumption is exactly what a financial regime shift breaks. Calibrating on a calm window and testing on the turbulent 2011–13 crisis violates exchangeability, so even conformal under-covers on the honest temporal split. To prove the guarantee itself is sound we also compute conformal coverage on an exchangeable (randomly split) sample, where it lands within sampling error of the 90% target rather than well below it.

The honest capstone lesson: conformal is stronger than a Bayesian interval (no model-correctness assumption), but on non-stationary markets it still needs a recent/rolling calibration set (or adaptive conformal) — the arrow of time bites here too. We overlay the GP and temporal-conformal bands on the test period and compare all coverages against the 90% target.

In [4]:
td=keep_dates[sp:]
def conformal_halfwidth(res, al=0.10):
    m=len(res); return np.quantile(res, np.ceil((m+1)*(1-al))/m, method="higher")
# --- GP native (Bayesian) 90% interval + coverage ---
gp_cov=np.mean((Yte>=gmu-1.645*gsd)&(Yte<=gmu+1.645*gsd))
# --- split-conformal around Ridge, HONEST temporal proper-train / calibration split ---
ncal=int(0.2*len(Xtr)); Xpt,Xcal=Xtr[:-ncal],Xtr[-ncal:]; Ypt,Ycal=Ytr[:-ncal],Ytr[-ncal:]
scp=StandardScaler().fit(Xpt); base=RidgeCV(alphas=np.logspace(-3,3,40)).fit(scp.transform(Xpt),Ypt)
qhat=conformal_halfwidth(np.abs(Ycal-base.predict(scp.transform(Xcal))))
cmu=base.predict(scp.transform(Xte)); clo,chi=cmu-qhat,cmu+qhat
conf_cov=np.mean((Yte>=clo)&(Yte<=chi))
# --- split-conformal on an EXCHANGEABLE random split (to show the guarantee is sound) ---
rng=np.random.default_rng(0); pm=rng.permutation(len(Y)); a,b=int(.6*len(Y)),int(.8*len(Y))
itr,ical,ite=pm[:a],pm[a:b],pm[b:]
sci=StandardScaler().fit(X[itr]); bi=RidgeCV(alphas=np.logspace(-3,3,40)).fit(sci.transform(X[itr]),Y[itr])
qi=conformal_halfwidth(np.abs(Y[ical]-bi.predict(sci.transform(X[ical]))))
pi=bi.predict(sci.transform(X[ite])); conf_cov_iid=np.mean((Y[ite]>=pi-qi)&(Y[ite]<=pi+qi))
fig,ax=plt.subplots(1,2,figsize=(13.5,4.4))
ax[0].fill_between(td,np.exp(clo),np.exp(chi),color=GREEN,alpha=.18,label=f"conformal 90% ({conf_cov:.2f})")
ax[0].fill_between(td,np.exp(gmu-1.645*gsd),np.exp(gmu+1.645*gsd),color=BLUE,alpha=.22,label=f"GP 90% ({gp_cov:.2f})")
ax[0].plot(td,np.exp(Yte),color="black",lw=.9,label="realized vol (actual)")
ax[0].set_ylabel("realized volatility (%, daily)"); ax[0].set_title("Two 90% intervals over the turbulent test period"); ax[0].legend(fontsize=8)
labs=["GP\n(Bayesian)","conformal\n(temporal)","conformal\n(exchangeable)"]; cov=[gp_cov,conf_cov,conf_cov_iid]
ax[1].bar(labs,cov,color=[BLUE,GREEN,PURP])
ax[1].axhline(0.90,color=RED,ls="--",label="90% target"); ax[1].set_ylim(0.7,1.0); ax[1].set_ylabel("empirical coverage")
for i,v in enumerate(cov): ax[1].text(i,v+0.005,f"{v:.2f}",ha="center")
ax[1].set_title("The conformal guarantee holds under exchangeability, degrades under regime shift"); ax[1].legend(fontsize=8)
plt.tight_layout(); plt.show()
print(f"GP native interval covers {gp_cov:.2f} at 90% nominal -- overconfident (fixed-noise GP).")
print(f"Conformal on the honest temporal split covers {conf_cov:.2f}: better than the GP, but a volatility REGIME SHIFT between")
print(f"the calm calibration window and the crisis test period breaks exchangeability. On an EXCHANGEABLE split it hits {conf_cov_iid:.2f}")
_se=np.sqrt(0.90*0.10/len(ite))
print(f"   (that is on {len(ite)} test points, where the standard error of an empirical coverage is {_se:.3f}, so {conf_cov_iid:.2f}")
print(f"   sits {(0.90-conf_cov_iid)/_se:.1f} standard errors below nominal -- inside the noise, not comfortably on target.)")
print("-- the guarantee is sound; the assumption is what markets violate. On non-stationary data, calibrate on RECENT data.")
No description has been provided for this image
GP native interval covers 0.81 at 90% nominal -- overconfident (fixed-noise GP).
Conformal on the honest temporal split covers 0.84: better than the GP, but a volatility REGIME SHIFT between
the calm calibration window and the crisis test period breaks exchangeability. On an EXCHANGEABLE split it hits 0.88
   (that is on 652 test points, where the standard error of an empirical coverage is 0.012, so 0.88
   sits 1.9 standard errors below nominal -- inside the noise, not comfortably on target.)
-- the guarantee is sound; the assumption is what markets violate. On non-stationary data, calibrate on RECENT data.

5. Interpretability — what actually drives the forecast¶

Finally, we open the box with SHAP (TreeSHAP on the XGBoost model): signed, per-prediction feature attributions that sum to the prediction. It answers which of the engineered features the model relies on, and the answer is only half the expected one. The HAR components do carry most of the attribution, confirming that the forecastable signal in daily volatility lives largely in a few persistence terms. But the single largest non-HAR contributor is the fractionally-differenced price, and it is not a minor one — it ranks second overall, ahead of two of the three HAR terms. That is the clearest evidence in this notebook for the fractional-differencing idea, and it explains why the engineered-feature models beat HAR significantly in section 2: HAR has no access to a price-level feature, and volatility history does not substitute for it.

In [5]:
import shap
expl=shap.TreeExplainer(xgm); Xs=pd.DataFrame(Xte,columns=names); sv=expl.shap_values(Xs)
shap.summary_plot(sv,Xs,show=False,plot_size=(9,5)); plt.title("SHAP: which engineered features drive the volatility forecast"); plt.tight_layout(); plt.show()
imp=pd.Series(np.abs(sv).mean(0),index=names).sort_values(ascending=False)
print("Mean |SHAP| by feature:"); print(imp.round(3).to_string())
_har=["RV_d","RV_w","RV_m"]
print(f"\nThe three HAR terms together carry {imp[_har].sum()/imp.sum():.0%} of the total attribution, so the model does substantially")
print("rediscover what the classical HAR regression hard-codes.")
print(f"\nBut the single largest non-HAR contributor is the fractionally-differenced price, at rank {list(imp.index).index('fd_price')+1} of {len(imp)} with")
print(f"{imp['fd_price']/imp.sum():.0%} of the attribution -- more than {imp['fd_price']/imp['RV_d']:.1f} times RV_d and {imp['fd_price']/imp['RV_m']:.1f} times RV_m, the other two HAR components.")
print("That is not a modest contribution, and it is the clearest evidence in this notebook for the fractional-")
print("differencing idea: a price-level feature, made stationary without destroying its memory, carries information")
print(f"the volatility history does not. It is also why the engineered-feature models beat HAR-RV significantly in")
print("section 2 -- HAR does not have access to this feature, and no amount of volatility history substitutes for it.")
No description has been provided for this image
Mean |SHAP| by feature:
RV_w        0.174
fd_price    0.105
absret_w    0.061
RV_d        0.051
RV_m        0.049
lag1        0.036
absret      0.018
lag2        0.018
disp10      0.015
lag5        0.012
ret2        0.000

The three HAR terms together carry 51% of the total attribution, so the model does substantially
rediscover what the classical HAR regression hard-codes.

But the single largest non-HAR contributor is the fractionally-differenced price, at rank 2 of 11 with
20% of the attribution -- more than 2.1 times RV_d and 2.1 times RV_m, the other two HAR components.
That is not a modest contribution, and it is the clearest evidence in this notebook for the fractional-
differencing idea: a price-level feature, made stationary without destroying its memory, carries information
the volatility history does not. It is also why the engineered-feature models beat HAR-RV significantly in
section 2 -- HAR does not have access to this feature, and no amount of volatility history substitutes for it.

6. Synthesis — what the whole arc adds up to¶

Running the entire toolkit on one problem, the right way, produces a set of conclusions that are the real payoff of the Machine-Learning arc:

  1. Feature engineering beats model choice, and the evidence is sharper than "everything converges." Tested rather than eyeballed, four of the eight challengers are statistically indistinguishable from a plain ridge regression — the other regularized linear models and the random forest — while XGBoost, the SVR, the MLP and HAR-RV are all significantly worse. Complexity bought nothing; the features did the work, and the simplest model in the roster was enough to collect it.
  2. The classical baseline lost, and by more than the usual telling admits. HAR-RV is not "matched but barely beaten" here: it trails ridge by 0.0163 RMSE at $p<0.001$. What beats it is not a neural network but three lines of regularized regression on better inputs — chiefly a fractionally-differenced price, which SHAP ranks second of eleven features, ahead of two of the three HAR components. HAR has no access to a price-level feature, and volatility history does not substitute for it. Earning the right to a complex model by first beating the simple one remains the right habit; here the simple model was the winner.
  3. The validation slogan needs narrowing. Shuffled cross-validation is measurably optimistic — about 0.0085 RMSE, some 3% — but on this problem that is the small effect. The three honest schemes disagree with each other by roughly six times as much, because each tests a different stretch of history and volatility is far harder to forecast in some years than others. With a one-day-ahead label there is almost nothing for purging to remove; purging earns its keep on multi-day labels, which is what the Financial-ML notebook demonstrates in a setting where the truth is known. Comparing RMSEs across validation schemes is meaningless unless they score the same period — a caveat worth more here than the leakage warning itself.
  4. A forecast needs an error bar you can trust — and even the guarantee has an assumption. The GP's Bayesian interval covered 0.81 at 90% nominal, overconfident as a fixed-noise GP tends to be. Split-conformal dropped the model-correctness assumption and did better at 0.84, but a volatility regime shift between the calm calibration window and the crisis test period still pushed it below nominal — exchangeability is exactly what a non-stationary market breaks. On a genuinely exchangeable split it recovers to within sampling error of the target. The guarantee is sound; the assumption is what markets violate. Calibrate on recent data, or go adaptive.
  5. A model should be explainable, and the explanation should be read honestly. SHAP confirms the HAR persistence terms carry about half the total attribution — the model does rediscover what the classical model hard-codes. But it also shows the fractionally-differenced price doing far more work than a passing mention would suggest, which is the single clearest piece of evidence in this notebook for the López de Prado feature transform, and the reason section 2's ranking came out the way it did.

This capstone is the combination the arc was built for — the model roster of subsections 1–3, the feature engineering and time-series validation of subsection 4, the uncertainty of subsections 3/5, the evaluation, calibration and conformal guarantees of subsection 6, and the fractional differentiation, purging and embargo of the López de Prado Financial-ML subsection 7 — all on one finance problem, cross-linked to the volatility (GARCH/SV/realized-vol) arcs it forecasts. The judgement on display is the one the whole arc was teaching: match the model to the data, respect the arrow of time, quantify what you don't know and verify the quantification, and explain what you do.