Lesson 5.7.1 · 75 min

PyTorch: tensors, autograd and the training loop

Keras hides the training loop behind fit; PyTorch leaves it in plain sight. This lesson writes it piece by piece on a problem with a known answer, tomorrow's realized volatility: tensors, gradients checked with finite differences, data split by date, a loop with early stopping and the same network in Lightning, with bit-for-bit identical results.

Fig. — Validation mean squared error (2016-2017) per epoch of the linear model and the MLP, trained with the same PyTorch loop on the HAR features of six assets, against the least-squares solution
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

From Keras's fit to a loop in plain sight

In module 5.6, Keras trains with one call: model.fit(x, y, epochs=…). Inside there is a loop that requests batches, computes the loss, differentiates, updates the weights and evaluates. PyTorch does not hide that loop: you write it. It is more code, and it is the reason almost all deep learning research is published in PyTorch: when something fails, the failure is in lines you can read.

PieceKeras 3 (5.6)PyTorchLightning
Dataarrays or PyDatasetDataset + DataLoaderthe same DataLoaders
Modelkeras.Modelnn.ModuleLightningModule (wraps one)
Gradientshidden in fitloss.backward()hidden in the Trainer
LoopfithandwrittenTrainer.fit
Devicebackend and configurationexplicit .to(device)accelerator=

The problem of the lesson is the level 5 volatility one: predict the logarithm of tomorrow's realized variance for six fictitious assets (vol5_diario.parquet) with the features of Corsi's (2009) HAR model: today's RV, its 5-session mean and its 22-session mean, plus the negative part of today's return. The data are synthetic and the truth is known: the generator is a HAR on the logarithm of integrated variance, so a linear HAR is almost optimal and serves as the yardstick. A network that beat it by a wide margin would be measuring a mistake.

IN [01]python
import copy
import numpy as np
import pandas as pd
import torch
from torch import nn
from torch.utils.data import DataLoader, Dataset

torch.manual_seed(0)
torch.use_deterministic_algorithms(True)

v = pd.read_parquet("vol5_diario.parquet")
v["x"] = np.log(v["rv_5min"])

def har(g):
    """HAR features of one asset and target: tomorrow's log RV."""
    x = g["x"]
    return pd.DataFrame({
        "fecha": g["fecha"], "activo": g["activo"],
        "d": x, "w": x.rolling(5).mean(), "m": x.rolling(22).mean(),
        "neg": np.minimum(g["rendimiento"], 0),
        "y": x.shift(-1), "fecha_y": g["fecha"].shift(-1)})

h = pd.concat([har(g) for _, g in v.groupby("activo")]).dropna()
h["tramo"] = np.select(
    [h["fecha_y"] < "2016-01-01",
     (h["fecha"] >= "2016-01-01") & (h["fecha_y"] < "2018-01-01"),
     h["fecha"] >= "2018-01-01"],
    ["ent", "val", "prueba"], "purga")

ent = h["tramo"] == "ent"
centro = h[ent].groupby("activo")["d"].mean()
escala = h[ent].groupby("activo")["neg"].std()
for c in ["d", "w", "m", "y"]:
    h[c] = h[c] - h["activo"].map(centro)
h["neg"] = h["neg"] / h["activo"].map(escala)
print(h["tramo"].value_counts().to_string())
print(h.loc[h["tramo"] == "purga", ["fecha", "fecha_y"]]
      .drop_duplicates().to_string(index=False))
OUT [01]HAR data for six assets and splits by label date
tramo
prueba    12060
ent       11958
val        3012
purga        12
     fecha    fecha_y
2015-12-31 2016-01-04
2017-12-29 2018-01-02

Three decisions that recur throughout the module (in the code, ent, val and prueba are the training, validation and test segments, and purga the purged rows):

  • The split is made by the label date. Training: targets before 2016; validation: 2016 and 2017; test: 2018 onward. Rows whose target crosses a boundary (the one of December 31, 2015 points to the RV of January 4, 2016) are purged, as in lesson 5.4.2 with longer labels.
  • The statistics come from training. The center of each asset (the mean of its log RV) and the scale of negative returns are computed with 2008-2015 and applied unchanged to validation and test. Computing them with the whole sample puts the future into the past (lesson 5.2.4).
  • One global model. The six assets go together, each centered on its own level: 12,000 training rows instead of 2,000. The level shifts in the sample (the bond in 2022, crypto in 2019) are outside the center computed with 2008-2015: the model has to follow them through the persistence of the features themselves.

Tensors

A tensor is an n-dimensional array with three attributes that matter: dtype, shape and device. And, on request, a history of operations for differentiation. Almost the whole API imitates NumPy; the differences are in the details.

IN [02]python
a = h[["d", "w"]].to_numpy()[:3].copy()
t = torch.from_numpy(a)          # shares memory with `a`
print(t.dtype, tuple(t.shape))
t[0, 0] = 9.0
print("a[0, 0] after changing t:", a[0, 0])
c = torch.tensor(a)              # copy
c[0, 0] = -1.0
print("a[0, 0] after changing c:", a[0, 0])
print("from Python floats:", torch.tensor([0.5, 1.0]).dtype)

x = torch.arange(6, dtype=torch.float32).reshape(3, 2)
print(x - x.mean(dim=0))         # (3, 2) - (2,)
print(x.T.is_contiguous(), x.device)
OUT [02]Shared memory, dtype and broadcasting
torch.float64 (3, 2)
a[0, 0] after changing t: 9.0
a[0, 0] after changing c: 9.0
from Python floats: torch.float32
tensor([[-2., -2.],
        [ 0.,  0.],
        [ 2.,  2.]])
False cpu
  • torch.from_numpy does not copy: changing the tensor changes the array. torch.tensor does copy.
  • A NumPy array is float64; a tensor created from Python floats is float32, PyTorch's default dtype and that of the layers. Mixing them is not converted silently:
IN [03]python
capa = nn.Linear(2, 1)           # float32 weights
capa(torch.from_numpy(a))        # float64 input
OUT [03]A float32 layer with a float64 input
RuntimeError: mat1 and mat2 must have the same dtype, but got Double and Float
  • Broadcasting follows NumPy's rules: (3, 2) - (2,) subtracts the mean of each column.
  • x.T is a view with different memory strides (not contiguous): it copies nothing. Some operations (view) reject it; reshape or .contiguous() copy when needed.
  • x.device says where the tensor lives. Two tensors on different devices cannot be operated on together.

Autograd and how to check it

When a tensor has requires_grad=True, PyTorch records each operation that uses it in a directed graph. When backward() is called on a scalar, it walks that graph backward with the chain rule and leaves in each leaf its derivative in .grad. It is reverse-mode automatic differentiation: the cost is of the order of one more evaluation of the function, whether it has one parameter or a million.

To see it with one of the losses used in volatility: a linear model of the logarithm of variance, f=Xwf = X w, evaluated with the QLIKE (the loss of the forecast table in the data README), which up to constants is

L(w)=1n∑i(RVi e−fi+fi),∇L=1nX⊤(1−RV⊙e−f).L(w) = \frac{1}{n}\sum_i \left( RV_i\, e^{-f_i} + f_i \right), \qquad \nabla L = \frac{1}{n} X^\top \left( 1 - RV \odot e^{-f} \right).
backwardw leaff = X wX dataRV e^-f + fmean L
Mermaid source
flowchart LR
  W[w leaf] --> F[f = X w]
  X[X data] --> F
  F --> E[RV e^-f + f]
  E --> L[mean L]
  L -. backward .-> W
IN [04]python
cols = ["d", "w", "m", "neg"]
Xq = torch.tensor(h.loc[ent, cols].to_numpy())
Xq = torch.cat([torch.ones(len(Xq), 1, dtype=Xq.dtype), Xq], dim=1)
rv = torch.exp(torch.tensor(h.loc[ent, "y"].to_numpy()))

def qlike(w):
    f = Xq @ w
    return (rv * torch.exp(-f) + f).mean()

w = torch.tensor([0.0, 0.4, 0.3, 0.2, -0.1], dtype=torch.float64,
                 requires_grad=True)
perdida = qlike(w)
perdida.backward()
with torch.no_grad():
    f = Xq @ w
    analitico = Xq.T @ (1 - rv * torch.exp(-f)) / len(Xq)
print("autograd  :", w.grad.numpy().round(6))
print("analytical:", analitico.numpy().round(6))
print("max. difference:", f"{(w.grad - analitico).abs().max():.1e}")
OUT [04]Autograd versus the analytical derivative
autograd  : [-0.127462  0.087152  0.045058  0.02778  -0.022228]
analytical: [-0.127462  0.087152  0.045058  0.02778  -0.022228]
max. difference: 1.6e-15

They agree to the last decimal of float64. When there is no analytical derivative (a network, an unusual loss), the check uses central finite differences: ∂L/∂wj≈(L(w+hej)−L(w−hej))/2h\partial L/\partial w_j \approx (L(w + h e_j) - L(w - h e_j)) / 2h. Its error has two parts pulling in opposite directions: truncation, proportional to h2h^2, and the rounding from subtracting two nearly equal numbers, proportional to ε/h\varepsilon/h, with ε\varepsilon the machine precision. The best step is where they cross, around ε1/3\varepsilon^{1/3}.

IN [05]python
def dif_finitas(w0, paso, dtype):
    X_, rv_ = Xq.to(dtype), rv.to(dtype)
    w0 = w0.detach().to(dtype)
    g = torch.zeros_like(w0)
    for j in range(len(w0)):
        e = torch.zeros_like(w0)
        e[j] = paso
        Lmas = (rv_ * torch.exp(-X_ @ (w0 + e)) + X_ @ (w0 + e)).mean()
        Lmen = (rv_ * torch.exp(-X_ @ (w0 - e)) + X_ @ (w0 - e)).mean()
        g[j] = (Lmas - Lmen) / (2 * paso)
    return g

errores = {}
for dt in (torch.float64, torch.float32):
    for k in range(1, 10):
        g = dif_finitas(w, 10.0 ** -k, dt).double()
        errores[(str(dt)[6:], f"1e-{k}")] = (g - w.grad).abs().max()
err = pd.Series(errores).unstack(0)
print(err.map(lambda e: f"{e:.1e}").to_string())
print("gradcheck:", torch.autograd.gradcheck(qlike, (w,)))
OUT [05]Finite differences: the step and the precision
      float32  float64
1e-1  5.9e-03  5.9e-03
1e-2  5.5e-05  5.8e-05
1e-3  9.2e-05  5.8e-07
1e-4  9.5e-04  5.8e-09
1e-5  9.7e-03  6.5e-11
1e-6  8.7e-02  1.1e-10
1e-7  1.3e-01  1.2e-09
1e-8  1.3e-01  8.9e-09
1e-9  1.3e-01  1.1e-07
gradcheck: True
Fig. — Maximum error of the QLIKE gradient computed with central finite differences against autograd, by step size and precision

The figure puts the table on a log scale. In float64 the error falls with h2h^2 (each decade of step, two decades of error) down to h=10−5h = 10^{-5}, where it reaches 6.5·10⁻¹¹; with smaller steps, rounding pushes it back up. In float32 the minimum comes much earlier, at h=10−2h = 10^{-2} with an error of 5.5·10⁻⁵, and at h=10−6h = 10^{-6} the error (0.087) is already the size of the gradient itself: the estimate is garbage. Hence the rule of thumb: gradient checks are done in float64, which is what torch.autograd.gradcheck does (and why it requires double-precision inputs).

Dataset and DataLoader with time splits

A Dataset answers two questions: how many examples there are (__len__) and which one is the i-th (__getitem__). A DataLoader groups them into batches, shuffles them if asked and can load them in parallel processes. The normalization of the features (mean and standard deviation of each column) comes, once again, from training.

IN [06]python
FEATS = ["d", "w", "m", "neg"]
media = h.loc[ent, FEATS].mean()
desv = h.loc[ent, FEATS].std()

class SerieVol(Dataset):
    """Features standardized with training and tomorrow's log RV."""
    def __init__(self, df):
        z = (df[FEATS] - media) / desv
        self.X = torch.tensor(z.to_numpy(), dtype=torch.float32)
        self.y = torch.tensor(df["y"].to_numpy(), dtype=torch.float32)
        self.fecha_y = df["fecha_y"].to_numpy()

    def __len__(self):
        return len(self.y)

    def __getitem__(self, i):
        return self.X[i], self.y[i]

ds = {k: SerieVol(h[h["tramo"] == k]) for k in ("ent", "val", "prueba")}
print({k: len(d) for k, d in ds.items()})
print("last target in ent:",
      ds["ent"].fecha_y.max().astype("datetime64[D]"))
print("first target in val:",
      ds["val"].fecha_y.min().astype("datetime64[D]"))

def cargador(semilla=0):
    return DataLoader(ds["ent"], batch_size=256, shuffle=True,
                      generator=torch.Generator().manual_seed(semilla))

xb, yb = next(iter(cargador()))
print(tuple(xb.shape), tuple(yb.shape), xb.dtype)
OUT [06]One Dataset per segment and a DataLoader with its own generator
{'ent': 11958, 'val': 3012, 'prueba': 12060}
last target in ent: 2015-12-31
first target in val: 2016-01-05
(256, 4) (256,) torch.float32

Shuffling within training is correct: the rows are already split by date and each one carries only information from its past, so the batch order leaks nothing; shuffling helps stochastic gradient descent. What is wrong is shuffling before splitting (random_split over the whole sample): it is the random k-fold of lesson 5.4.1, with almost identical neighbors on both sides of the split.

The dedicated generator is not a detail: it makes the batch order depend only on its seed, not on how many random numbers the rest of the program has consumed before. The Lightning section shows why that matters.

nn.Module and a clean training loop

A model is a subclass of nn.Module: the layers are declared in __init__ (their parameters are registered) and forward says how they combine. The loop is always the same:

Batch x, yzero_gradforwardlossbackwardstepval with no_gradbest state_dict
Mermaid source
flowchart TB
  A[Batch x, y] --> B[zero_grad]
  B --> C[forward]
  C --> D[loss]
  D --> E[backward]
  E --> F[step]
  F --> A
  F --> G[val with no_grad]
  G --> H[best state_dict]
IN [07]python
class Lineal(nn.Module):
    def __init__(self, n):
        super().__init__()
        self.capa = nn.Linear(n, 1)

    def forward(self, x):
        return self.capa(x).squeeze(-1)

class MLP(nn.Module):
    def __init__(self, n, oculta=32, p=0.1):
        super().__init__()
        self.red = nn.Sequential(
            nn.Linear(n, oculta), nn.ReLU(), nn.Dropout(p),
            nn.Linear(oculta, oculta), nn.ReLU(), nn.Dropout(p),
            nn.Linear(oculta, 1))

    def forward(self, x):
        return self.red(x).squeeze(-1)

def evaluar(modelo, d):
    modelo.eval()                     # dropout off
    with torch.no_grad():             # no graph
        return nn.functional.mse_loss(modelo(d.X), d.y).item()

def entrenar(modelo, epocas=40, lr=1e-3, wd=0.0, paciencia=8,
             semilla=0, restaurar=True):
    opt = torch.optim.Adam(modelo.parameters(), lr=lr,
                           weight_decay=wd)
    dl = cargador(semilla)
    hist, mejor, sin_mejora = [], (np.inf, None, -1), 0
    for epoca in range(epocas):
        modelo.train()                # dropout on
        suma = 0.0
        for xb, yb in dl:
            opt.zero_grad()
            perdida = nn.functional.mse_loss(modelo(xb), yb)
            perdida.backward()
            opt.step()
            suma += perdida.item() * len(yb)
        val = evaluar(modelo, ds["val"])
        hist.append((epoca, suma / len(ds["ent"]), val))
        if val < mejor[0]:
            mejor = (val, copy.deepcopy(modelo.state_dict()), epoca)
            sin_mejora = 0
        else:
            sin_mejora += 1
            if sin_mejora >= paciencia:
                break
    if restaurar:
        modelo.load_state_dict(mejor[1])
    return pd.DataFrame(hist, columns=["epoca", "ent", "val"]), mejor[2]

torch.manual_seed(0)
lineal = Lineal(4)
hist_lin, ep_lin = entrenar(lineal, epocas=30, lr=1e-2, paciencia=30)
torch.manual_seed(0)
mlp = MLP(4)
hist_mlp, ep_mlp = entrenar(mlp, lr=1e-3, wd=1e-4)
n_par = sum(p.numel() for p in mlp.parameters())
print(f"linear: best epoch {ep_lin}; MLP ({n_par} parameters): "
      f"best epoch {ep_mlp} of {len(hist_mlp)}")
OUT [07]Two models and a loop with early stopping
linear: best epoch 5; MLP (1249 parameters): best epoch 8 of 17

The details that separate a clean loop from one that “almost works”:

  • modelo.train() and modelo.eval() switch dropout on and off (and, if there were any, batch normalization). Evaluating in training mode gives a noisy validation loss that is worse than the real one.
  • opt.zero_grad() before each backward(), because of gradient accumulation.
  • Early stopping keeps a copy of the best state_dict (copy.deepcopy: without it, the dictionary points to the same tensors, which keep being updated) and restores it at the end.
  • The stopping epoch is chosen with validation, so validation is no longer out of sample for that model: the result is measured on the test set.

A linear model with squared loss has a closed-form solution (least squares). If the loop is right, Adam has to get close to it. It is a cheap check of everything above: data, standardization, loop and optimizer.

IN [08]python
Xe = np.c_[np.ones(len(ds["ent"])), ds["ent"].X.double().numpy()]
b_mco = np.linalg.lstsq(Xe, ds["ent"].y.double().numpy(),
                        rcond=None)[0]
b_torch = np.r_[lineal.capa.bias.item(),
                lineal.capa.weight.detach().double().numpy()[0]]
print(pd.DataFrame({"OLS": b_mco, "Adam": b_torch},
                   index=["const"] + FEATS).round(3).T.to_string())
# in HAR units: slope per unit of log RV
print("HAR (OLS):", (b_mco[1:4] / desv[["d", "w", "m"]]).round(3)
      .to_dict())
OUT [08]The trained linear model versus least squares
      const      d      w      m    neg
OLS   0.000  0.120  0.159  0.138 -0.035
Adam -0.005  0.124  0.175  0.137 -0.028
HAR (OLS): {'d': 0.2, 'w': 0.342, 'm': 0.342}

Adam ends up close to the closed-form solution, not on top of it: the slopes differ in the second digit (0.175 versus 0.159 on the 5-day mean), because of the noise of 256-row batches and because early stopping keeps epoch 5. In the test error the difference does not show (next section). The last line translates the least squares coefficients into HAR units: 0.20 for today's RV and 0.34 for each mean. The generator uses 0.36, 0.34 and 0.22 on the logarithm of integrated variance; one day's RV measures it with error, and that error attenuates the daily coefficient and shifts weight to the means, which average it out. It is what is expected, not a bug in the loop.

The result, against the benchmark, and determinism

The figure at the top shows the validation curves of the two models by epoch. The linear model settles in five epochs on the least squares value, 0.197, and oscillates around it because of batch noise; the MLP goes down to 0.191 at its best epoch, the 8th, and early stopping cuts at the 16th, after eight epochs without improvement. What matters is still missing: the test set, 2018-2025, which has not been touched to choose anything.

IN [09]python
def predecir(modelo, d):
    modelo.eval()
    with torch.no_grad():
        return modelo(d.X).double().numpy()

dp = ds["prueba"]
y = dp.y.double().numpy()
Xp = np.c_[np.ones(len(dp)), dp.X.double().numpy()]
pred = {"naive (today's RV)": (dp.X[:, 0].double().numpy()
                               * desv["d"] + media["d"]),
        "HAR by OLS": Xp @ b_mco,
        "linear in PyTorch": predecir(lineal, dp),
        "MLP": predecir(mlp, dp)}
ecm = {k: np.mean((y - p) ** 2) for k, p in pred.items()}
print(pd.Series(ecm).round(3).to_string())

# difference of squared errors, mean per week
dif = (y - pred["HAR by OLS"]) ** 2 - (y - pred["MLP"]) ** 2
semana = pd.to_datetime(h.loc[h["tramo"] == "prueba", "fecha"]
                        ).dt.to_period("W").to_numpy()
s = pd.Series(dif).groupby(semana).mean()
print(f"MLP versus HAR: {dif.mean() / ecm['HAR by OLS']:.1%} "
      f"lower MSE; t = {s.mean() / s.std() * np.sqrt(len(s)):.1f} "
      f"({len(s)} weeks)")
OUT [09]Test error and paired weekly difference
naive (today's RV)    0.319
HAR by OLS            0.213
linear in PyTorch     0.213
MLP                   0.208
MLP versus HAR: 2.5% lower MSE; t = 6.3 (418 weeks)
Fig. — Test mean squared error (2018-2025) of the forecast of tomorrow's log RV across the six assets: naive, HAR by least squares, the same linear model trained in PyTorch and the MLP

What the table says, in order:

  • The naive forecast loses by far. Today's RV alone has a squared error 50% larger than the HAR: the mean over several days removes a good part of the measurement error of a single day.
  • The linear model trained with Adam and the least squares one give the same on the test set. The loop does what it should.
  • The MLP beats the HAR by 2.5%, with a weekly difference of t = 6.3 over 418 weeks. It is a real nonlinearity in these data: the measurement error of the log RV is asymmetric (a χ² with 78 degrees of freedom in logarithms) and the effect of negative returns is not linear in the return. But it is a small improvement over a four-coefficient model; with real data it would have to be checked over several periods (lesson 5.4.3), discounting how many architectures were tried (lesson 5.4.4), before believing it.

A reproducible result does not come for free. With what was fixed at the beginning of the lesson (seed, deterministic algorithms, threads fixed by the environment) and the DataLoader's dedicated generator, two training runs with the same seed give the same weights, bit for bit; with another seed, different weights.

IN [10]python
def pesos(m):
    return torch.cat([p.detach().flatten() for p in m.parameters()])

copias = []
for semilla in (0, 0, 1):
    torch.manual_seed(semilla)
    m = MLP(4)
    entrenar(m, epocas=5, semilla=semilla)
    copias.append(pesos(m))
print("seed 0 twice:", torch.equal(copias[0], copias[1]))
print("seed 0 versus 1:", torch.equal(copias[0], copias[2]))
OUT [10]Same training, same weights
seed 0 twice: True
seed 0 versus 1: False
What is fixedHowWhat it prevents
Initialization and dropouttorch.manual_seed(…) before creating the modeldifferent initial weights and masks
Batch orderDataLoader(…, generator=torch.Generator().manual_seed(…))depending on the state of the global generator
Algorithmstorch.use_deterministic_algorithms(True)operations whose results change between runs (above all on GPU)
Threadsfixed OMP_NUM_THREADS (the course runner sets 4)floating-point sums split differently
Outputsround to what the conclusion needsfar decimals that change between CPUs

GPU, MPS and torch.compile

On CPU, a 1,249-parameter network with 12,000 rows trains in seconds. Large networks need an accelerator. PyTorch treats them as devices: the model and each batch are moved with .to(device) and everything else is the same.

IN [11]python
if torch.cuda.is_available():
    dispositivo = torch.device("cuda")
elif torch.backends.mps.is_available():
    dispositivo = torch.device("mps")
else:
    dispositivo = torch.device("cpu")
print(dispositivo, torch.__version__)
OUT [11]Choosing a device
cpu 2.14.1+cpu

On a machine with an NVIDIA GPU, the training loop changes in three lines, and mixed precision is usually added (bfloat16 on recent GPUs). The code is not run in the course, which runs on CPU only:

IN [12]python
modelo = MLP(4).to(dispositivo)
opt = torch.optim.Adam(modelo.parameters(), lr=1e-3)
dl = DataLoader(ds["ent"], batch_size=4096, shuffle=True,
                pin_memory=True,
                generator=torch.Generator().manual_seed(0))
for xb, yb in dl:
    xb = xb.to(dispositivo, non_blocking=True)
    yb = yb.to(dispositivo, non_blocking=True)
    opt.zero_grad()
    with torch.autocast(device_type="cuda", dtype=torch.bfloat16):
        perdida = nn.functional.mse_loss(modelo(xb), yb)
    perdida.backward()
    opt.step()
  • CUDA (NVIDIA): the reference accelerator, and where determinism costs the most: many GPU operations sum in an order that changes between runs. With use_deterministic_algorithms(True), those without a deterministic version raise a RuntimeError (PyTorch's reproducibility page gives the example of index_add_): better an error than a silently different result.
  • MPS (the GPU of Macs with Apple Silicon): same code with "mps". Not all operations or types are implemented: the missing ones fail or, with PYTORCH_ENABLE_MPS_FALLBACK=1, run on CPU. The float64 checks in this course are best done on CPU.
  • When it pays off: with large models and batches. With a network like the one in this lesson, copying the batches to the GPU costs more than the computation.

torch.compile (PyTorch 2) traces the model and generates code optimized for the device; on CPU, C++ code that is built with the system compiler. The first call is slow (it compiles); the following ones, faster if the model is large. With Python 3.14 and PyTorch 2.14 it works on CPU:

IN [13]python
import os
import tempfile

# the compilation cache, in a temporary folder
os.environ["TORCHINDUCTOR_CACHE_DIR"] = tempfile.mkdtemp()
compilado = torch.compile(mlp)
mlp.eval()
with torch.no_grad():
    a_eager = mlp(dp.X)
    a_comp = compilado(dp.X)
print("same predictions:", torch.allclose(a_eager, a_comp,
                                          atol=1e-5))
OUT [13]torch.compile gives the same predictions
same predictions: True

Same predictions (to float32 tolerance: the generated code may sum in a different order). Compilation took between 20 and 25 seconds on the machine the lesson was written on, for a network that predicts the 12,000 test days in milliseconds: with small models it does not pay off. It makes sense with transformers and large networks, on GPU, and when the same model is going to be run many times.

The same network with Lightning

Lightning reorganizes PyTorch code: the model, the loss of each step and the optimizer go into a LightningModule, and the loop is supplied by the Trainer, which adds what a large project ends up writing anyway: checkpoints, metric logging, multiple GPUs, mixed precision, gradient accumulation. It is the base of neuralforecast, which is used in lessons 5.7.2 and 5.7.3.

IN [14]python
import logging
import warnings
import lightning as L

for n in ("pytorch_lightning", "lightning.pytorch",
          "lightning_fabric", "lightning"):
    logging.getLogger(n).setLevel(logging.ERROR)
warnings.filterwarnings("ignore", module="pytorch_lightning")
warnings.filterwarnings("ignore", module="lightning")

class ModuloVol(L.LightningModule):
    def __init__(self, red, lr=1e-3, wd=1e-4):
        super().__init__()
        self.red, self.lr, self.wd = red, lr, wd

    def training_step(self, lote, i):
        x, y = lote
        return nn.functional.mse_loss(self.red(x), y)

    def validation_step(self, lote, i):
        x, y = lote
        self.log("val", nn.functional.mse_loss(self.red(x), y))

    def configure_optimizers(self):
        return torch.optim.Adam(self.parameters(), lr=self.lr,
                                weight_decay=self.wd)

def con_lightning(epocas, gen_val):
    torch.manual_seed(0)
    red = MLP(4)
    val = DataLoader(ds["val"], batch_size=len(ds["val"]),
                     generator=gen_val)
    L.Trainer(max_epochs=epocas, accelerator="cpu",
              deterministic=True, logger=False,
              enable_progress_bar=False, enable_model_summary=False,
              enable_checkpointing=False, num_sanity_val_steps=0,
              ).fit(ModuloVol(red), cargador(0), val)
    return red

torch.manual_seed(0)
a_mano = MLP(4)
entrenar(a_mano, epocas=5, wd=1e-4, paciencia=99, restaurar=False)
for nombre, gen in [("without generator", None),
                    ("with generator", torch.Generator())]:
    red = con_lightning(5, gen)
    print(f"Lightning, val {nombre}: weights identical to the "
          f"handwritten loop: {torch.equal(pesos(red), pesos(a_mano))}")
OUT [14]LightningModule and Trainer
Lightning, val without generator: weights identical to the handwritten loop: False
Lightning, val with generator: weights identical to the handwritten loop: True

With the validation DataLoader without its own generator, the Lightning weights and those of the handwritten loop agree in the first epoch and diverge afterward. The cause is not Lightning: each time a DataLoader is iterated, its iterator draws a base seed from the loader's generator or, if it has none, from torch's global generator. The handwritten loop evaluates without a DataLoader; Lightning iterates the validation one at the end of each epoch, consumes a number from the global generator and changes the dropout masks of the next epoch. With a dedicated generator in the validation loader, the weights are identical bit for bit. The lesson applies to any project: everything random with its own generator, and nothing that depends on the order in which the rest of the program consumes random numbers.

What Lightning does not do for you: split the data by date, purge, normalize with training statistics or choose the metric. That stays in the DataLoaders and in your code, as in this lesson.

Exercises

  1. Check with finite differences the gradient of the MLP's validation loss with respect to three weights of its first layer. Work in float64 (copy.deepcopy(mlp).double()) and in eval mode (dropout would make the loss random).
Solution
IN [15]python
m64 = copy.deepcopy(mlp).double().eval()
Xv, yv = ds["val"].X.double(), ds["val"].y.double()
m64.zero_grad()
nn.functional.mse_loss(m64(Xv), yv).backward()
W = m64.red[0].weight
paso = 1e-5
for (i, j) in [(0, 0), (5, 2), (17, 3)]:
    with torch.no_grad():
        W[i, j] += paso
        Lmas = nn.functional.mse_loss(m64(Xv), yv).item()
        W[i, j] -= 2 * paso
        Lmen = nn.functional.mse_loss(m64(Xv), yv).item()
        W[i, j] += paso
    fd = (Lmas - Lmen) / (2 * paso)
    print(f"w[{i},{j}]: autograd {W.grad[i, j].item():+.8f}  "
          f"finite diff. {fd:+.8f}")
OUT [15]MLP gradient versus finite differences
w[0,0]: autograd +0.00071116  finite diff. +0.00071116
w[5,2]: autograd -0.00029703  finite diff. -0.00029703
w[17,3]: autograd +0.00020451  finite diff. +0.00020451

They agree in all eight printed digits. Two details: the weights are modified inside torch.no_grad() (otherwise, autograd would record the modification in the graph), and they are restored to their value at the end. Functions with “corners”, such as ReLU, give bad finite differences if the step crosses the zero of some neuron; with a step of 10⁻⁵ that is rare.

  1. Remove opt.zero_grad() from the loop and train the linear model for 10 epochs with the same lr. What happens to the validation loss and why?
Solution
IN [16]python
def entrenar_sin_cero(modelo, epocas, lr):
    opt = torch.optim.Adam(modelo.parameters(), lr=lr)
    vals = []
    for epoca in range(epocas):
        modelo.train()
        for xb, yb in cargador(0):
            nn.functional.mse_loss(modelo(xb), yb).backward()
            opt.step()
        vals.append(round(evaluar(modelo, ds["val"]), 3))
    return vals

torch.manual_seed(0)
print("without zero_grad:", entrenar_sin_cero(Lineal(4), 10, 1e-2))
print("with zero_grad:   ", hist_lin["val"].round(3).tolist()[:10])
OUT [16]Without zero_grad
without zero_grad: [0.432, 0.836, 2.191, 1.423, 2.192, 4.034, 4.052, 2.941, 9.944, 2.517]
with zero_grad:    [0.221, 0.206, 0.2, 0.198, 0.198, 0.197, 0.199, 0.197, 0.198, 0.198]

Without zero_grad, the gradient of each step is the sum of all the previous ones in the epoch: a kind of undamped momentum that pushes the weights away from the minimum, and the validation loss stays far above that of the correct loop. The model raises no error: it just trains badly. It is the most common mistake when writing the loop by hand, and a reason to encapsulate it (a function of your own, like entrenar, or Lightning).

  1. A colleague builds the Dataset with all the rows, does random_split(ds, [0.7, 0.15, 0.15]) and normalizes the features with the mean of the whole sample. Their MLP gives a somewhat lower test error than the one in this lesson. What would you tell them?
Solution

That their test does not measure what they think. With random_split, each test day has its immediate neighbors in training, with almost identical features (the 22-day mean changes little from one day to the next) and with targets that are features of the adjacent row: one row's tomorrow RV is the next row's today RV. It is the leak of the random k-fold of lesson 5.4.1. And normalizing with the whole sample uses the mean and standard deviation of 2018-2025 to standardize 2008-2015: information from the future, small but systematic (lesson 5.2.4). Moreover, without date splits there is no way to know how the model would have behaved through the level shifts of 2019 and 2022. The fix is the one in this lesson: splits by label date, purging at the boundaries and training statistics; and, to compare with theirs, the same procedure with both architectures.

Each answer is folded under its question

Self-assessment

  1. You create t = torch.from_numpy(a) and then do t[0, 0] = 9. What happens to a?

    • AIt changes too: from_numpy shares memory with the array; torch.tensor(a) would make a copy
    • BNothing: from_numpy copies the data
    • CIt raises an error because tensors are immutable
    • DIt changes only if a is float32
    Show answer

    Answer A. It changes too: from_numpy shares memory with the array; torch.tensor(a) would make a copy

    torch.from_numpy and Tensor.numpy() share memory (on CPU). It is useful to avoid duplicating large data and dangerous if you modify one thinking it is a copy.

  2. You compare autograd with central finite differences in float32, and the error is smallest with a step of 0.01 and grows with smaller steps. Why?

    • ABecause autograd is inexact in float32
    • BBecause central differences only work with large steps
    • CBecause with small steps the subtraction of two nearly equal losses loses significant digits (rounding error ≈ ε/h), and with large steps truncation error dominates (≈ h²): the optimum lies between the two
    • DBecause the function is not differentiable
    Show answer

    Answer C. Because with small steps the subtraction of two nearly equal losses loses significant digits (rounding error ≈ ε/h), and with large steps truncation error dominates (≈ h²): the optimum lies between the two

    With ε ≈ 1.2·10⁻⁷ in float32, the best step for central differences is around ε^(1/3) ≈ 0.005; in float64 (ε ≈ 2.2·10⁻¹⁶), around 10⁻⁵. That is why gradient checks are done in float64.

  3. The last training row is dated 2015-12-31 and its target is the RV of January 4, 2016, which is already in the validation set. What do you do?

    • ANothing: it is a single row per asset
    • BMove it to validation
    • CShuffle it with the rest
    • DPurge it: no training row may have a target that falls inside the validation period, just as in the purged k-fold of 5.4.2
    Show answer

    Answer D. Purge it: no training row may have a target that falls inside the validation period, just as in the purged k-fold of 5.4.2

    With a one-day horizon the leak is small, but the rule is the same as with 21-session labels: the split is defined by the date of the label, not by that of the features.

  4. You train the same network twice with the same seed, once with your loop and once with Lightning, and the weights differ from the second epoch on. The batch order is the same. Most likely cause?

    • ALightning uses another optimizer
    • BThe validation DataLoader without its own generator draws its seed from torch's global generator when iterated, and that changes the dropout masks of the next epoch
    • CLightning is not deterministic
    • DThe CPU threads
    Show answer

    Answer B. The validation DataLoader without its own generator draws its seed from torch's global generator when iterated, and that changes the dropout masks of the next epoch

    Each time a DataLoader is iterated, its iterator takes a base seed from loader.generator or, if it has none, from the global generator. With its own torch.Generator() in the validation loader, the two training runs give identical weights.

  5. The MLP lowers the test squared error of the linear HAR by 2.5%, with a weekly difference of t ≈ 6. Which conclusion is correct?

    • AThat networks are always better than linear volatility models
    • BThat there is a leak in the test
    • CThat there is a small, measurable nonlinearity (the measurement error of log RV is not normal and the effect of negative returns is not linear), but the bulk of the forecast comes from the HAR structure, and the network is validated against it
    • DThat the HAR is badly estimated
    Show answer

    Answer C. That there is a small, measurable nonlinearity (the measurement error of log RV is not normal and the effect of negative returns is not linear), but the bulk of the forecast comes from the HAR structure, and the network is validated against it

    The truth in the data is a HAR on the logarithm of integrated variance; RV adds an asymmetric measurement error. A network can scrape something there, never turn it into a different forecast. The linear benchmark is what sets the scale.

Key points

  • A tensor is an array with a dtype, a device and, if needed, a history for autograd. from_numpy shares memory; mixing NumPy's float64 with float32 layers raises an error.
  • Autograd gets checked: against the analytical derivative when there is one and against central finite differences in float64 (step ≈ 10⁻⁵). In float32 the best step is around 10⁻² and the error does not go below ≈ 5·10⁻⁵.
  • Splits are made by the label date, with purging at the boundaries and normalization with training statistics; shuffling within the training set is correct.
  • The clean loop: train()/eval(), zero_grad, backward, step, evaluation under no_grad and early stopping that keeps the best state_dict. A linear model trained this way reaches the least squares solution.
  • Determinism: seed, use_deterministic_algorithms(True), fixed threads and a dedicated torch.Generator in each DataLoader. With that, the handwritten loop and Lightning give identical weights.
  • torch.compile works on CPU with Python 3.14, but compiling costs seconds that a small network never recovers; the GPU (CUDA or MPS) pays off with large models and batches.

Resources