Lesson 5.6.3 · 70 min

1D CNN, LSTM and GRU: windows, scaling and leaks

A recurrent network reads a window of days and decides what to remember; a convolutional one looks for local patterns in it. Both promise to learn from history what a linear model does not see. This lesson builds the windows and the scaling without looking at the future, trains a 1D CNN, an LSTM and a GRU to forecast the volatility of six assets and compares them with the HAR, which in these data is almost the true model.

Fig. — Test mean squared error (2018-2025) of the one-day forecast of log realised variance for six synthetic assets: recurrent and convolutional networks against the HAR and the oracle
03/04
Module
5.6 · Deep learning with Keras 3
Duration
70 min
Updated
Level 5 lessons
  1. 5.1 · ML fundamentals applied to finance

    1. 5.1.1 Why financial machine learning is different
    2. 5.1.2 Supervised, unsupervised and reinforcement learning; scikit-learn pipelines
    3. 5.1.3 Economic metrics for trading models: beyond accuracy
  2. 5.2 · Feature engineering

    1. 5.2.1 Price, volume and microstructure features
    2. 5.2.2 Point-in-time fundamentals, macro and alternative data
    3. 5.2.3 Fractional differentiation for features
    4. 5.2.4 Leakage-free normalization and cross-sectional rank
    5. 5.2.5 Feature importance: MDI, MDA, SFI and SHAP
  3. 5.3 · Labelling and sampling

    1. 5.3.1 Fixed-horizon labeling and its problems
    2. 5.3.2 Triple-barrier method: labels that mimic a trade
    3. 5.3.3 Meta-labeling: one model for the side and another for the size
    4. 5.3.4 Label uniqueness, overlap weights and the sequential bootstrap
    5. 5.3.5 Event-based sampling: the symmetric CUSUM filter
  4. 5.4 · Validation for financial series

    1. 5.4.1 Why random k-fold fails on financial data
    2. 5.4.2 Purged k-fold with embargo and CPCV for ML models
    3. 5.4.3 Walk-forward with retraining: windows and frequency
    4. 5.4.4 Backtesting ML signals with costs and Deflated Sharpe
  5. 5.5 · Classical models

    1. 5.5.1 Regularized regressions: Ridge, Lasso, Elastic Net and logistic
    2. 5.5.2 Decision trees, Random Forest and bagging
    3. 5.5.3 Gradient boosting: XGBoost, LightGBM and CatBoost
    4. 5.5.4 Optuna and the risk of overfitting the validation
    5. 5.5.5 Unsupervised learning: PCA, clustering and autoencoders
    6. 5.5.6 Learning to rank for stock selection
  6. 5.6 · Deep learning with Keras 3

    1. 5.6.1 Keras 3: one model, several compute engines
    2. 5.6.2 An MLP on a stock panel: regularize and compare
    3. 5.6.3 1D CNN, LSTM and GRU: windows, scaling and leaks
    4. 5.6.4 Callbacks, a Sharpe loss and the model in production
  7. 5.7 · Deep learning with PyTorch

    1. 5.7.1 PyTorch: tensors, autograd and the training loop
    2. 5.7.2 Time series architectures: TCN, N-BEATS, PatchTST and iTransformer
    3. 5.7.3 Probabilistic models: quantile loss, DeepAR and intervals
    4. 5.7.4 Graph neural networks for asset relationships
    5. 5.7.5 End-to-end portfolio optimization: differentiable layers and Sharpe loss
  8. 5.8 · Time series foundation models

    1. 5.8.1 Time series foundation models: pretrain once, forecast without training
    2. 5.8.2 Foundation model families: architecture, data and license
    3. 5.8.3 In practice: context, quantiles, covariates and fine-tuning
    4. 5.8.4 Foundation models in finance: what the evidence says
    5. 5.8.5 Lab: foundation models vs GARCH, HAR and LightGBM
  9. 5.9 · Reinforcement learning

    1. 5.9.1 MDP, Q-learning, PPO and Gymnasium environments
    2. 5.9.2 RL for execution, market making and dynamic hedging
    3. 5.9.3 Why reinforcement learning overfits in directional trading
  10. 5.10 · Interpretability, robustness and model risk

    1. 5.10.1 SHAP, partial dependence and the stability of feature importances
    2. 5.10.2 Data drift and concept drift: detecting them and what to do
    3. 5.10.3 Ensembles, signal combination and synthetic data

Sequences, not rows

In the previous lessons each row was independent of the others: a feature vector with its label. A time series can be treated that way if the features already summarize the history (the HAR summarizes 22 days in three means). Sequence networks skip that step: they receive the window of the last days as is, a matrix of LL steps by kk variables, and learn their own summary.

  • 1D CNN: filters a few steps long that slide along the window and detect local patterns (a jump, a change of slope), the same ones at any position.
  • LSTM (Hochreiter and Schmidhuber, 1997) and GRU (Cho et al., 2014): they read the window step by step and keep an internal state; learned gates decide what to forget and what to add at each step. The GRU has two gates and the LSTM three, plus a separate cell state: the GRU has fewer parameters with the same state size.

The test bench is the level 5 volatility one: six synthetic assets (index, stock, bond, gold, currency and crypto) from 2008 to 2025 with their daily realized variance. The true process is a HAR in the logarithm of the integrated variance, with a leverage effect (the negative part of yesterday's return raises volatility), jumps in the stock and the crypto, and level shifts on known dates (data README). A well-estimated HAR is almost optimal: the question is whether a network gets there without being told, and whether it goes beyond it.

The split is chronological: training from 2008 to 2015, validation (for early stopping) 2016-2017 and test 2018-2025, the same as the forecast table of the README. First, the mean level of the log RV in each segment:

IN [01]python
import os
os.environ["KERAS_BACKEND"] = "jax"      # before the first import of keras
import numpy as np
import pandas as pd
import statsmodels.api as sm
import keras
from keras import layers

v = pd.read_parquet("vol5_diario.parquet")
verdad = pd.read_parquet("vol5_verdad.parquet")
v = v.merge(verdad[["fecha", "activo", "iv_prevista"]],
            on=["fecha", "activo"]).sort_values(["activo", "fecha"])
v["lrv"] = np.log(v["rv_5min"])
F_VAL = np.datetime64("2016-01-01")
F_PRUEBA = np.datetime64("2018-01-01")
tramo = np.select([v["fecha"] < F_VAL, v["fecha"] < F_PRUEBA],
                  ["train", "validation"], "test")
niveles = v.groupby(["activo", tramo])["lrv"].mean().unstack()
niveles = niveles[["train", "validation", "test"]]
niveles["test - train"] = niveles["test"] - niveles["train"]
niveles.round(2)
OUT [01]Six volatility series and their level shifts
        train  validation   test  test - train
activo
accion  -8.99       -9.31  -9.24         -0.25
bono   -11.51      -11.61 -11.34          0.17
cripto  -6.95       -7.22  -7.32         -0.37
divisa -10.60      -10.13 -10.41          0.19
indice  -9.85       -9.54  -9.72          0.13
oro     -9.56       -9.92  -9.75         -0.18

The stock and the crypto drop in level between training and test (−0.25 and −0.37 in logs; in the stock the 2015 shift is already inside the training period, which mixes the two regimes); the bond rises (+0.17, because of the 2022 shift) and so does the currency (+0.19: its level shift of January 2015 leaves almost all of the training period in the previous regime). A series that is non-stationary in level is the norm in finance, and it conditions how you scale.

Windows that do not look at the future

The function that cuts the windows is the piece where it is easiest to be off by one step. The rule: the window that ends on day tt contains days t−L+1t-L+1 to tt, and its target is day t+1t+1. With NumPy's sliding_window_view each window is a view, without copying data. Before using it with real data, it is tested with a series whose values are their own position:

IN [02]python
def ventanas(z, L):
    """Windows of L rows ending at t (t = L-1 … n-2) and the
    value of column 0 at t + 1."""
    w = np.lib.stride_tricks.sliding_window_view(
        z, (L, z.shape[1]))[:, 0]
    return w[:-1], z[L:, 0]

juguete = np.arange(8, dtype="float32")[:, None]
Xj, yj = ventanas(juguete, 3)
for xv, yv in zip(Xj[:3], yj[:3]):
    print(xv.ravel().tolist(), "->", float(yv))
print("windows:", len(Xj), "| last target:", float(yj[-1]))
assert all(xv[-1, 0] + 1 == yv for xv, yv in zip(Xj, yj))
OUT [02]Windows: the input ends at t, the target is t + 1
[0.0, 1.0, 2.0] -> 3.0
[1.0, 2.0, 3.0] -> 4.0
[2.0, 3.0, 4.0] -> 5.0
windows: 5 | last target: 7.0

With 8 values and L=3L = 3 you get 5 windows: the first, [0, 1, 2], predicts the 3, and the last predicts the 7, the last value of the series. The assertion checks in all of them that the target is the day after the last day of the window. If someone changes the function, the assertion fails before anything is trained.

Scaling can also look at the future

Networks train better with inputs on a similar scale, so each series is standardized: log RV minus its mean, divided by its standard deviation. The question is which mean. The full-sample one includes 2018-2025, and if the series shifts level, that mean tells the model where it is going to be. To measure how much that information is worth without training any network, take the simplest possible forecast: predict that the log RV of each test day will be the mean of the asset. With the training mean it is a legitimate forecast; with the full-sample one, it is not:

IN [03]python
def stats(s, hasta):
    """Mean and standard deviation of log RV and standard deviation
    of the return, with the rows before `hasta`."""
    m = s["fecha"] < hasta
    return (s.loc[m, "lrv"].mean(), s.loc[m, "lrv"].std(),
            s.loc[m, "rendimiento"].std())

filas = []
for a, s in v.groupby("activo"):
    prueba = s["fecha"] >= F_PRUEBA
    real = s.loc[prueba, "lrv"]
    m_ent = stats(s, F_VAL)[0]
    m_todo = s["lrv"].mean()                 # uses 2018-2025
    filas.append({"activo": a,
                  "training mean": np.mean((real - m_ent) ** 2),
                  "full-sample mean":
                      np.mean((real - m_todo) ** 2)})
fuga = pd.DataFrame(filas).set_index("activo")
fuga.loc["mean"] = fuga.mean()
fuga.round(3)
OUT [03]Scaling can also look at the future
        training mean  full-sample mean
activo
accion          0.544             0.491
bono            0.292             0.275
cripto          0.795             0.689
divisa          0.254             0.221
indice          0.418             0.402
oro             0.371             0.341
mean            0.446             0.403
Fig. — How much the simplest forecast (each asset's mean) appears to improve over 2018-2025 when the mean uses the full sample instead of the training data only

Computing the mean with the full sample lowers the test MSE of that forecast from 0.446 to 0.403 on average, 9.6% less, without any model: more than 13% for the crypto and the currency. A network that receives inputs and targets standardized this way starts with that advantage handed to it, because its initial bias (predicting 0) is already the mean of the future. It is the same leak as the StandardScaler fitted before splitting the data (lesson 5.2.4), and in series with level shifts it weighs more than in a cross-sectional panel.

The rule: mean and standard deviation of each asset from its training segment, and those same figures for validation and test. Each window carries two variables per day: the standardized log RV and the return divided by its training standard deviation. The standard deviation of each asset is also kept, to bring the errors back to log RV units, along with the oracle (iv_prevista, the exact expectation of the integrated variance with the information up to the previous day, which only exists because the data are synthetic):

IN [04]python
L = 22
ACTIVOS = sorted(v["activo"].unique())
partes = {k: [] for k in ("X", "y", "f", "a", "sd", "orac")}
for i, a in enumerate(ACTIVOS):
    s = v[v["activo"] == a]
    m, sd, sr = stats(s, F_VAL)              # training only
    z = np.c_[(s["lrv"] - m) / sd,
              s["rendimiento"] / sr].astype("float32")
    Xa, ya = ventanas(z, L)
    partes["X"].append(Xa)
    partes["y"].append(ya)
    partes["f"].append(s["fecha"].to_numpy()[L:])
    partes["a"].append(np.full(len(ya), i))
    partes["sd"].append(np.full(len(ya), sd))
    lor = (np.log(s["iv_prevista"].to_numpy()) - m) / sd
    partes["orac"].append(lor[L:])
X, y, fch, act, SD, orac = (np.concatenate(partes[k]) for k in
                            ("X", "y", "f", "a", "sd", "orac"))
ent = fch < F_VAL
val = (fch >= F_VAL) & (fch < F_PRUEBA)
te = fch >= F_PRUEBA
print("X:", X.shape, "| train, validation, test:",
      int(ent.sum()), int(val.sum()), int(te.sum()))

def mse(p, m):
    """MSE in log RV units (undoes each asset's scaling)."""
    return float(np.mean(((p[m] - y[m]) * SD[m]) ** 2))
OUT [04]The set of windows of the six assets
X: (27042, 22, 2) | train, validation, test: 11958 3018 12066

There are 27,042 windows of 22 days by 2 variables: 11,958 for training, 3,018 for validation and 12,066 for test. The windows at the end of training and those at the start of validation do not overlap in the target (each one predicts a single day), but they do share input days; with a single boundary and early stopping as the only use of validation, no further purging is needed.

The baselines

Four forecasts without a network, all per asset and estimated on the training data:

  • Naive: tomorrow's log RV is today's.
  • HAR: OLS on the 1-, 5- and 22-day means of the log RV, computed with the window itself.
  • HAR with leverage: adds ℓt=min⁡(rt,0)\ell_t = \min(r_t, 0), the negative part of today's return (the γℓ\gamma \ell term of the true process).
  • Calibrated oracle: a straight line on the logarithm of iv_prevista. The line corrects the bias between the integrated variance and the measured realized variance (the RV does not see the overnight part and carries measurement error and jumps). It is the floor: no legitimate forecast can go below it except by luck.
IN [05]python
def har(X):
    l = X[:, :, 0]
    return np.c_[l[:, -1], l[:, -5:].mean(1), l.mean(1)]

def por_activo(Z):
    """OLS for each asset on its training data; predicts everything."""
    p = np.zeros_like(y)
    for i in range(len(ACTIVOS)):
        mi = act == i
        A_ = np.c_[np.ones(mi.sum()), Z[mi]]
        b = np.linalg.lstsq(A_[ent[mi]], y[mi & ent], rcond=None)[0]
        p[mi] = A_ @ b
    return p

H = har(X)
HL = np.c_[H, np.minimum(X[:, -1, 1], 0)]   # negative part of r_t
pred = {"naive (today's RV)": X[:, -1, 0],
        "HAR": por_activo(H),
        "HAR + leverage": por_activo(HL),
        "calibrated oracle": por_activo(orac[:, None])}
resumen = pd.Series({k: mse(p, te) for k, p in pred.items()},
                    name="test MSE")
resumen.round(4)
OUT [05]The baselines: naive, HAR, HAR with leverage and oracle
naive (today's RV)    0.3184
HAR                   0.2137
HAR + leverage        0.2114
calibrated oracle     0.1962
Name: test MSE, dtype: float64

The naive forecast has an MSE of 0.318; the HAR goes down to 0.2137, leverage to 0.2114 and the oracle marks 0.1962. Between the HAR with leverage and the oracle there is a 7% gap: that is the most a network can gain here. Any test result below 0.1962 is, almost certainly, a leak.

A common way to commit one: smoothing with a centered moving average, which in pandas is an argument (center=True) and in many indicator libraries is the default option of some filter. The cell replaces the HAR's weekly mean with the one centered at tt, which uses t−2t-2 to t+2t+2:

IN [06]python
c5 = []
for a in ACTIVOS:
    s = v[v["activo"] == a]
    m, sd, _ = stats(s, F_VAL)
    zc = ((s["lrv"] - m) / sd).rolling(5, center=True).mean()
    c5.append(zc.to_numpy()[L - 1:-1])    # aligned with the end t
c5 = np.nan_to_num(np.concatenate(c5))
H_mal = np.c_[H[:, 0], c5, H[:, 2]]   # t-2 … t+2 instead of t-4 … t
print(f"HAR with the centered weekly mean: MSE "
      f"{mse(por_activo(H_mal), te):.4f}")
OUT [06]A centered moving average
HAR with the centered weekly mean: MSE 0.1328

An MSE of 0.1328, 32% below the oracle. The model has learned nothing about volatility: the centered mean contains the day to be forecast (t+1t+1) and the next one. With a network the error is just as easy to make and harder to see, because there are no coefficients to look at: a Bidirectional layer on a sequence that reaches the target, a filter applied to the whole series before cutting windows or symmetric padding in a convolution that predicts at every step. The comparison with the oracle, when there is one, and with an honest baseline, when there is not, is the defense.

Three architectures in Keras

All three receive the same input, (22, 2), and end in a Dense(1). With the JAX backend, as in 5.6.2:

Window 22 x 2Causal Conv1DLSTM 16GRU 16FlattenDense 1
Mermaid source
flowchart LR
  V[Window 22 x 2] --> C[Causal Conv1D]
  V --> LS[LSTM 16]
  V --> G[GRU 16]
  C --> F[Flatten]
  F --> D[Dense 1]
  LS --> D
  G --> D
  • The 1D CNN applies 16 filters of 5 days with causal padding: the output at position tt only depends on t−4t-4 to tt. Then it flattens the 22 positions and combines them in the dense layer; that way it keeps where each pattern is (a GlobalAveragePooling1D would lose it, and in volatility what is recent matters more).
  • The LSTM and the GRU read the 22 days in order and pass their final state, of 16 numbers, to the dense layer.
IN [07]python
def red(tipo, n=16):
    x_in = keras.Input((L, 2))
    if tipo == "CNN 1D":
        h = layers.Conv1D(n, 5, activation="relu",
                          padding="causal")(x_in)
        h = layers.Flatten()(h)
    elif tipo == "LSTM":
        h = layers.LSTM(n)(x_in)
    else:
        h = layers.GRU(n)(x_in)
    m = keras.Model(x_in, layers.Dense(1)(h),
                    name=tipo.replace(" ", ""))
    m.compile(keras.optimizers.Adam(1e-3), "mse")
    return m

for tipo in ("CNN 1D", "LSTM", "GRU"):
    print(f"{tipo:<7}{red(tipo).count_params():>5} parameters")
OUT [07]Three architectures for the same window
CNN 1D   529 parameters
LSTM    1233 parameters
GRU      977 parameters

The three have between 500 and 1,250 parameters, against the 5 per asset of the HAR with leverage (30 in total). The LSTM has more than the GRU with the same state: 4×(16×(2+16)+16)=1,2164 \times (16 \times (2 + 16) + 16) = 1{,}216 in the layer against 960.

Training uses Adam (0.001), batches of 512 and at most 30 epochs, with early stopping after 3 epochs without improvement on validation and the weights of the best epoch. The six assets are trained together (a panel model with the standardized series): networks need more data than a HAR, and standardizing by asset makes the series comparable.

IN [08]python
historia = {}
for tipo in ("CNN 1D", "LSTM", "GRU"):
    keras.utils.set_random_seed(0)
    m = red(tipo)
    parada = keras.callbacks.EarlyStopping(
        patience=3, restore_best_weights=True)
    h = m.fit(X[ent], y[ent], epochs=30, batch_size=512, verbose=0,
              validation_data=(X[val], y[val]), callbacks=[parada])
    historia[tipo] = h.history["val_loss"]
    pred[tipo] = m.predict(X, batch_size=4096, verbose=0).ravel()
    mejor = int(np.argmin(h.history["val_loss"])) + 1
    print(f"{tipo:<7} epochs {len(h.history['loss']):>2}, best "
          f"validation in {mejor}")
tabla = pd.Series({k: mse(p, te) for k, p in pred.items()},
                  name="test MSE").sort_values()
tabla.round(4)
OUT [08]Training with early stopping on validation
CNN 1D  epochs 27, best validation in 24
LSTM    epochs 19, best validation in 16
GRU     epochs 19, best validation in 16
calibrated oracle     0.1962
GRU                   0.2106
LSTM                  0.2107
HAR + leverage        0.2114
HAR                   0.2137
CNN 1D                0.2137
naive (today's RV)    0.3184
Name: test MSE, dtype: float64

Early stopping kicked in for all three: the LSTM and the GRU stopped at epoch 19 with their best validation at 16, the CNN at 27. In the test (the figure at the top puts the seven forecasts on one scale):

  • The two recurrent networks end up almost tied with the HAR with leverage: 0.2106 the GRU, 0.2107 the LSTM, 0.2114 the HAR with leverage.
  • The 1D CNN, at 0.2137, matches the HAR without leverage: it does not exploit the information in the returns as well.
  • All of them stay far from the oracle, 0.1962. The distance is the same for the network and for the linear model.

Is the difference larger than the noise?

A difference of 0.0008 in the MSE says nothing without its standard error. The Diebold and Mariano (1995) test compares two forecasts with the series of differences of their losses: if the mean of dt=ea,t2−eb,t2d_t = e_{a,t}^2 - e_{b,t}^2 cannot be told apart from zero, the two forecasts are equally good. The daily losses are autocorrelated (volatility has memory and so do the errors), so the standard error of the mean is computed with Newey-West. Here, dtd_t is the mean over the six assets of each day:

IN [09]python
def diebold_mariano(a, b, lags=10):
    """Mean of the daily loss difference (a - b), averaged across
    assets, with a Newey-West standard error."""
    e = pd.DataFrame({"f": fch[te],
                      "d": ((pred[a][te] - y[te]) * SD[te]) ** 2
                      - ((pred[b][te] - y[te]) * SD[te]) ** 2})
    d = e.groupby("f")["d"].mean().to_numpy()
    r = sm.OLS(d, np.ones_like(d)).fit(
        cov_type="HAC", cov_kwds={"maxlags": lags})
    return r.params[0], r.bse[0], r.tvalues[0]

for a, b in [("GRU", "HAR + leverage"),
             ("LSTM", "HAR + leverage"),
             ("HAR + leverage", "HAR"),
             ("calibrated oracle", "HAR + leverage")]:
    dif, et, t = diebold_mariano(a, b)
    print(f"{a:<21}- {b:<21}{dif:+.4f} ± {et:.4f}  t = {t:+.1f}")
OUT [09]Is the difference larger than the noise?
GRU                  - HAR + leverage       -0.0008 ± 0.0010  t = -0.8
LSTM                 - HAR + leverage       -0.0007 ± 0.0009  t = -0.8
HAR + leverage       - HAR                  -0.0023 ± 0.0005  t = -4.6
calibrated oracle    - HAR + leverage       -0.0152 ± 0.0013  t = -11.6

The GRU and the LSTM improve on the HAR with leverage by 0.0008 and 0.0007, with a standard error of 0.0010: t=−0.8t = -0.8. They cannot be told apart. The test has more than enough power to see real differences: the leverage term improves on the HAR with t=−4.6t = -4.6, and the oracle on the HAR with leverage with t=−11.6t = -11.6. What happens is that the network and the linear model learn the same thing. In these data it was predictable: the true process is a HAR with leverage, and a network can only win on what the linear model does not capture (the jumps, the nonlinearity of leverage, the interactions between assets).

By asset, the average hides opposite behaviors:

IN [10]python
por = pd.DataFrame({k: [mse(pred[k], te & (act == i))
                        for i in range(len(ACTIVOS))]
                    for k in ("HAR", "HAR + leverage", "GRU",
                              "calibrated oracle")},
                   index=ACTIVOS)
por["GRU / HAR+lev."] = por["GRU"] / por["HAR + leverage"]
por.round(3)
OUT [10]Asset by asset
          HAR  HAR + leverage    GRU  calibrated oracle  GRU / HAR+lev.
accion  0.309           0.308  0.296              0.283           0.962
bono    0.134           0.133  0.136              0.130           1.018
cripto  0.398           0.397  0.385              0.354           0.969
divisa  0.121           0.121  0.121              0.115           1.000
indice  0.174           0.162  0.171              0.157           1.056
oro     0.147           0.147  0.155              0.139           1.052
Fig. — Ratio of the GRU test MSE (2018-2025) to the HAR-with-leverage MSE, asset by asset: below 1 the network wins

The GRU wins on the stock (ratio 0.962) and on the crypto (0.969), the two assets with jumps in the true process: after a jump, the day's RV spikes without the background volatility changing as much, and a network that sees the shape of the window can discount it better than a linear HAR, which treats a jump as persistent volatility. It loses on the index (1.056) and gold (1.052): in the index leverage is the strongest of the six (γ=0.18\gamma = 0.18), and a panel model with a single network for the six assets captures it worse than a HAR with its own coefficient.

This suggests the next reasonable step, which is not a bigger network: a HAR per asset with a term for the jumps (the bipower variation, bv_5min, separates the continuous part from the jump part) or a small network that corrects the HAR's residuals. And remember the rule of the module: the network is compared with the best linear baseline that can be built, not with the weakest.

Sequences beyond volatility

The same pieces work for other sequences in the course, with the same traps:

ApplicationWindowTypical trap
Daily volatility (this lesson)22 days of log RV and returnscaling with the full sample; centered means
1-minute bars (hf5)30-120 minutes of return, volume and imbalancewindows that cross the open; normalizing with the whole day
Returns of a panel52 weeks of returns per stockwindows of stocks that did not exist yet (survivorship)
Order book100 updates of the levelsevent timestamps versus receipt timestamps

And for returns, not for volatility, remember the README's control: the conditional mean of rendimiento is constant in these data, so no network can predict it. An LSTM that “gets right” tomorrow's direction in vol5_diario is measuring noise or has a leak.

Exercises

  1. Train the GRU with only the first column of the window (the log RV, without the returns). How much worse is it in the test? Which part of the true model explains the difference?
Solution
IN [11]python
keras.utils.set_random_seed(0)
x_in = keras.Input((L, 1))
m1 = keras.Model(x_in, layers.Dense(1)(layers.GRU(16)(x_in)))
m1.compile(keras.optimizers.Adam(1e-3), "mse")
m1.fit(X[ent][:, :, :1], y[ent], epochs=30, batch_size=512, verbose=0,
       validation_data=(X[val][:, :, :1], y[val]),
       callbacks=[keras.callbacks.EarlyStopping(
           patience=3, restore_best_weights=True)])
pred["GRU without returns"] = m1.predict(
    X[:, :, :1], batch_size=4096, verbose=0).ravel()
for k in ("HAR", "GRU without returns", "GRU"):
    print(f"{k:<21}{mse(pred[k], te):.4f}")
OUT [11]GRU without returns
HAR                  0.2137
GRU without returns  0.2117
GRU                  0.2106

Without the returns, the GRU ends up at 0.2117: better than the HAR (0.2137) and worse than the full GRU (0.2106). The difference from the full GRU, 0.0011, is of the order of the one between the HAR and the HAR with leverage (0.0023): the negative part of the return carries information that the log RV does not have. What the network without returns gains over the HAR comes from handling the jumps better (stock and crypto).

  1. Compute the R² of the regression of the test log RV on the forecast (the square of the correlation) for the HAR with leverage, the GRU and the oracle, asset by asset, and compare it with the table in the data README.
Solution
IN [12]python
def r2(k, i):
    m = te & (act == i)
    return np.corrcoef(pred[k][m], y[m])[0, 1] ** 2

pd.DataFrame({k: [r2(k, i) for i in range(len(ACTIVOS))]
              for k in ("HAR + leverage", "GRU",
                        "calibrated oracle")},
             index=ACTIVOS).round(2)
OUT [12]R² by asset
        HAR + leverage   GRU  calibrated oracle
accion            0.36  0.39               0.41
bono              0.50  0.49               0.51
cripto            0.40  0.42               0.46
divisa            0.45  0.45               0.48
indice            0.60  0.57               0.61
oro               0.57  0.56               0.60

The R² of the HAR with leverage and of the calibrated oracle are very close to those of the README (0.57 and 0.61 for the index with the HAR re-estimated every year and the exact oracle; here 0.60 and 0.61, with a HAR estimated once and by OLS on the window). The GRU has a higher R² than the HAR on the stock (0.39 against 0.36) and on the crypto (0.42 against 0.40) and a lower one on the index (0.57 against 0.60): the same story as the MSE by asset.

  1. A colleague improves the GRU by changing layers.GRU(16) to layers.Bidirectional(layers.GRU(16)) and presents a drop in the test MSE. With the windows of this lesson, can it be a leak? And if the model predicted the volatility of each day of the window (sequence to sequence)?
Solution

With the windows of this lesson, no: each window ends at tt and the target is t+1t+1, outside the window; reading it backward as well as forward adds no future information, only another way of summarizing the past (and twice the parameters). The improvement would have to be tested as always: Diebold-Mariano against the HAR with leverage and several seeds. In a sequence-to-sequence model, on the other hand, the output of step ss is compared with the value of s+1s+1, and the backward pass of the bidirectional layer sees steps s+1,s+2,…s+1, s+2, \ldots of the same window: the target is inside the input. It is the same leak as the centered moving average. Bidirectional layers are useful when the whole sequence is known at the time of deciding (classifying a day that has already closed, for example), never for predicting within it.

Each answer is folded under its question

Self-assessment

  1. Each asset's log RV is standardized with its 2008-2025 mean and standard deviation before splitting into train, validation and test. What happens?

    • AA leak: the mean includes the 2018-2025 level, and in these data that alone makes the simplest forecast appear to improve by 9.6% on average in the test
    • BNothing: standardizing is a linear transformation and does not change the information
    • CThe model trains more slowly
    • DThe windows stop overlapping
    Show answer

    Answer A. A leak: the mean includes the 2018-2025 level, and in these data that alone makes the simplest forecast appear to improve by 9.6% on average in the test

    With level shifts (the bond rises in 2022, crypto falls in 2019), the full-sample mean tells the model where the series will be. The lesson measures it with the forecast “mean of each asset”: the test MSE drops from 0.446 to 0.403 just by computing the mean with everything.

  2. A HAR with the weekly mean computed with a centered window (t−2 to t+2) gives a test MSE of 0.1328, better than the oracle (0.1962). What do you conclude?

    • AThat the centered mean is a better feature
    • BThat the oracle is badly calibrated
    • CThat the HAR must be retrained
    • DThat there is a leak: no model can go below the oracle, which uses the exact expectation with the available information; the centered window includes the value to be forecast and the next one
    Show answer

    Answer D. That there is a leak: no model can go below the oracle, which uses the exact expectation with the available information; the centered window includes the value to be forecast and the next one

    A result below the theoretical floor is the clearest sign of future information. Centered moving averages, filters that smooth in both directions and bidirectional layers have the same problem.

  3. The GRU has a test MSE of 0.2106 and the HAR with leverage 0.2114. The mean daily difference is −0.0008 ± 0.0010 (Newey-West). What does it say?

    • AThat the GRU is better
    • BThat they cannot be told apart: t = −0.8; the GRU matches, but does not beat, a linear model with five parameters per asset
    • CThat the HAR is overfitted
    • DThat more epochs are needed
    Show answer

    Answer B. That they cannot be told apart: t = −0.8; the GRU matches, but does not beat, a linear model with five parameters per asset

    The same test does tell the HAR with leverage apart from the plain HAR (t = −4.6) and the oracle apart from the HAR with leverage (t = −11.6). The power is there; the difference between the network and the linear model is simply noise.

  4. Why does the 1D CNN use padding="causal"?

    • ABecause it is faster
    • BBecause Keras does not support any other padding in Conv1D
    • CSo that the output has the same length and each position only sees earlier or equal positions; with same, the convolution at position t would use t+1 and t+2 of the window
    • DTo reduce the number of parameters
    Show answer

    Answer C. So that the output has the same length and each position only sees earlier or equal positions; with same, the convolution at position t would use t+1 and t+2 of the window

    Inside a window that ends at t there is no future data, so here same would not leak anything after t. But the habit matters: in models that predict at every step (sequence to sequence), symmetric padding brings in the future of the sequence itself.

  5. Without the returns in the window, the GRU goes from 0.2106 to 0.2117. Which part of the true process explains the difference?

    • AThe leverage effect: tomorrow's volatility depends on the negative part of today's return, which the log RV alone does not contain
    • BThe jumps
    • CThe level shifts
    • DNone: it is seed noise
    Show answer

    Answer A. The leverage effect: tomorrow's volatility depends on the negative part of today's return, which the log RV alone does not contain

    The generator includes a leverage term (the negative part of yesterday's shock). The HAR improves with the same term (from 0.2137 to 0.2114). The network without returns sits between the two.

Key points

  • A window is built with the input up to t and the target at t+1; it is checked with a toy example and an assertion, not by eye.
  • The scaling is fitted on each series' training segment. With level shifts, centering with the full sample makes the simplest forecast appear 9.6% better on average; a centered moving average takes the HAR below the oracle.
  • A causal 1D CNN (529 parameters), an LSTM (1,233) and a GRU (977) learn from 22-day windows of log RV and return, with early stopping on a chronological validation.
  • The recurrent networks match the HAR with leverage (0.2106 and 0.2107 against 0.2114; t = −0.8) and stay far from the oracle (0.1962): in these data the true process is almost linear and the HAR already captures it.
  • The network wins on the assets with jumps (stock, crypto) and loses where leverage is strong (index): the average hides opposite behaviors.

Resources