Modèles d’espace d’état sélectifs pour prévoir les rendements des ETF
Résumé
Ce notebook explique un modèle pédagogique d’espace d’état sélectif fondé sur Mamba et l’évalue pour la prévision des rendements de ETF. Contrairement à un modèle d’espace d’état à paramètres fixes, il déduit le terme entrée-vers-état, le terme état-vers-sortie et le pas temporel à partir de l’entrée courante. Le pas temporel contrôle la vitesse de décroissance de l’état précédent, tandis qu’une porte apprise distincte module la sortie de l’espace d’état. Le notebook compare la récurrence en temps linéaire du modèle au mélange quadratique des séquences et à l’attention, et explique comment des mises à jour affines peuvent permettre un calcul parallèle par balayage associatif.
L’implémentation est volontairement simplifiée : elle utilise une boucle Python, un pas temporel scalaire partagé, une discrétisation approximative de l’entrée et aucune convolution causale préalable. Elle compare les résultats à une régression ridge sur des caractéristiques fixes de momentum de ETF, selon un seul découpage chronologique et une étiquette de rendement futur. Le calcul séquentiel lent impose des échantillons plafonnés couvrant des dates complètes et un entraînement limité, sans recherche d’hyperparamètres. Le document considère donc les scores comme des éléments propres à cette exécution, et non comme un classement général des architectures ; il relève aussi les limites liées à un seul jeu de données, découpage, horizon d’étiquette et graine aléatoire.
Idées clés
- Les termes d’état et de sortie ainsi que les pas temporels dépendant de l’entrée permettent au modèle sélectif d’adapter ce qu’il conserve à chaque pas de temps.
- Un état de taille fixe et un calcul borné par pas donnent une complexité totale linéaire en longueur de séquence, tandis que les mises à jour affines permettent des balayages parallèles.
- Le pas temporel agit sur la décroissance de l’état, tandis qu’une porte distincte module de façon multiplicative la sortie de l’espace d’état.
- Cette implémentation pédagogique diffère du modèle de référence par sa boucle séquentielle, son pas temporel partagé, sa discrétisation approximative et l’absence de convolution.
- La comparaison sur les ETF utilise des échantillons plafonnés couvrant des dates complètes et un seul découpage ; elle n’établit donc pas un classement général des performances.
Étiquettes
Texte intégral
# A selective state space model, written out step by step
# A selective state space model, written out step by step
**Docker image**: `ml4t-gpu`
`06_tsmixer` related the days in a window with one dense $T \times T$ map, and
`04_transformers` related every day to every other through attention. Both cost
$T^2$, and both gave up the idea that a sequence can be walked once while carrying a
state - the idea `01_core_architectures` measured the price of, because a state
updated one day at a time cannot be computed in parallel across days.
A **state space model** takes the recurrence back and changes what is carried. Its
state has a fixed size and each step does a bounded amount of work, so one sweep is
$O(T)$ - which an LSTM also manages. What the *linear* update adds is that the steps
compose: the recurrence is affine, and each step's coefficients can be computed
without knowing the previous state, so the sweep is an associative scan that runs in
parallel across the sequence instead of a loop that waits for its own output. **Mamba** (Gu and Dao, 2023) adds what makes that
competitive: the matrices reading the input into the state and the state into the
output, and the step size itself, are computed *from the input at that step*. The
model decides what to keep as it goes, which a fixed-parameter SSM cannot.
**Learning objectives**:
- Read the discrete recurrence off the code and say which of its terms are constant
across the sequence and which are functions of the current input.
- Say what "selective" buys over a fixed-parameter SSM, in terms of what the state
can be made to forget.
- Name the simplifications this implementation makes against the paper, and find
each one in the code rather than taking the list on trust.
- Score the result against a penalised linear map on the same window, and read the
comparison for what a capped training budget allows it to say.
**Book Reference**: Chapter 13, Section 13.6 (Alternative architectures and foundation models)
**What this implementation is.** A pedagogical selective SSM in pure PyTorch,
written so the recurrence is readable rather than fast. It carries Mamba's defining
mechanism - $B_t$, $C_t$ and $\Delta_t$ computed from the input at step $t$ - and
departs from the reference `mamba_ssm` package in four ways you can find in the code
below:
- The scan is a Python `for` loop over timesteps, not a parallel associative scan in
a CUDA kernel. The $O(T)$ work is the same; the constant is roughly a hundred times
worse, which is why the sample is capped further down.
- $\Delta_t$ is one scalar per timestep, shared across all channels: `x_proj` emits
`d_state * 2 + 1` values and the last one is the step size. Mamba gives each channel
its own.
- The input discretization is approximated as $\bar B_t \approx \Delta_t B_t$ rather
than the full zero-order-hold expression.
- There is no depthwise causal convolution before the SSM branch, which the paper's
block includes.
Read it as a sketch of the selective-state-space idea, not as a drop-in replacement.
**Prerequisites**: ETF features (`case_studies/etfs/`)
```python
"""Simplified Selective State Space Model - pedagogical Mamba implementation for return prediction."""
import os
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
import numpy as np
import plotly.graph_objects as go
import polars as pl
import torch
import torch.nn as nn
import torch.nn.functional as F
from dl_sequences import create_sequences_multi_asset, load_dl_dataset, train_model
from ml4t.diagnostic.metrics import cross_sectional_ic_series
from plotly.subplots import make_subplots
from sklearn.linear_model import Ridge
from sklearn.preprocessing import StandardScaler
from utils.reproducibility import set_global_seeds
from utils.style import ( # COLORS activates the ml4t Plotly template on import
COLORS,
show_plotly_with_alt,
)
```
```python
SEED = 42
LOOKBACK = 60
D_MODEL = 32
D_STATE = 16
N_LAYERS = 2
DROPOUT = 0.1
EPOCHS = 10
BATCH_SIZE = 128
LR = 1e-3
MAX_TRAIN_SAMPLES = 50_000
MAX_VAL_SAMPLES = 15_000
MAX_TEST_SAMPLES = 15_000
INFER_BATCH_SIZE = 1_024
LABEL_HORIZON = 21
```
```python
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Device: {DEVICE}")
set_global_seeds(SEED)
torch.use_deterministic_algorithms(True)
torch.backends.cudnn.benchmark = False
torch.backends.cudnn.deterministic = True
```
## Data Loading
We use eight fixed momentum horizons from the ETF case-study pipeline.
```python
mds = load_dl_dataset("etfs")
FEATURE_COLS = [
"ret_5d",
"ret_10d",
"ret_21d",
"ret_42d",
"ret_63d",
"ret_126d",
"ret_189d",
"ret_252d",
]
TARGET_COL = mds.label_col
missing_features = sorted(set(FEATURE_COLS) - set(mds.feature_names))
if missing_features:
raise ValueError(f"Missing required ETF momentum features: {missing_features}")
print(f"Features ({len(FEATURE_COLS)}): {FEATURE_COLS}")
print(f"Target: {TARGET_COL}")
```
## Sequence Creation and Temporal Split
```python
df = mds.dataset.drop_nulls(subset=FEATURE_COLS + [TARGET_COL])
print(f"Rows after dropping nulls: {len(df):,}")
per_date = df.group_by(mds.date_col).len().sort(mds.date_col)
print(
f"{df[mds.date_col].min()} to {df[mds.date_col].max()}, "
f"{df[mds.entity_cols[0]].n_unique()} funds; funds per date "
f"{per_date['len'].min()} to {per_date['len'].max()}, median {per_date['len'].median():.0f}"
)
print(
f"Label {TARGET_COL}: mean {df[TARGET_COL].mean():+.5f}, "
f"standard deviation {df[TARGET_COL].std():.5f}"
)
X, y, timestamps, symbols = create_sequences_multi_asset(
df,
FEATURE_COLS,
TARGET_COL,
LOOKBACK,
timestamp_col=mds.date_col,
symbol_col=mds.entity_cols[0],
)
print(f"Sequences: {X.shape[0]:,}, shape: {X.shape}")
sequence_order = np.lexsort((symbols.astype(str), timestamps))
X = np.nan_to_num(X[sequence_order], nan=0.0, posinf=0.0, neginf=0.0).astype(np.float32)
y = np.nan_to_num(y[sequence_order], nan=0.0).astype(np.float32)
timestamps = timestamps[sequence_order]
symbols = symbols[sequence_order]
```
### Splitting by date, with a gap for the label horizon
The split is by date, at fixed fractions of the trading days, and an example belongs
to the partition the date it carries falls in. The label is a `LABEL_HORIZON`-day
forward return, so an example dated within that many days of a boundary has an
outcome resolved by days on the far side; those examples are dropped. Input windows
may still reach back over a boundary, which is right - at decision time the model has
every past observation available.
```python
unique_dates = np.sort(np.unique(timestamps))
train_boundary_idx = int(len(unique_dates) * 0.6)
val_boundary_idx = int(len(unique_dates) * 0.8)
train_end_date = unique_dates[train_boundary_idx]
val_end_date = unique_dates[val_boundary_idx]
train_label_cutoff = unique_dates[train_boundary_idx - LABEL_HORIZON]
val_label_cutoff = unique_dates[val_boundary_idx - LABEL_HORIZON]
train_mask = timestamps < train_label_cutoff
val_mask = (timestamps >= train_end_date) & (timestamps < val_label_cutoff)
test_mask = timestamps >= val_end_date
X_train, y_train = X[train_mask], y[train_mask]
X_val, y_val = X[val_mask], y[val_mask]
X_test, y_test = X[test_mask], y[test_mask]
test_dates, test_symbols = timestamps[test_mask], symbols[test_mask]
```
### Pedagogical subsampling
The pure-Python selective scan is roughly 100× slower than the production
CUDA kernel, so we cap each split at a few thousand sequences for tractable
wall-clock. Critically we subsample by **complete dates**, not row-count: a
raw `[-MAX_SAMPLES:]` slice would start mid-date and leave a partial
cross-section, which biases the per-date Spearman IC. We instead keep the
most recent dates whose total row count fits under the cap.
```python
def _trim_by_complete_dates(X_arr, y_arr, ts_arr, sym_arr, max_samples):
if len(X_arr) <= max_samples:
return X_arr, y_arr, ts_arr, sym_arr
unique_ts = np.sort(np.unique(ts_arr))[::-1]
cumulative = 0
keep_dates: list = []
for ts in unique_ts:
n = int((ts_arr == ts).sum())
if cumulative + n > max_samples and keep_dates:
break
cumulative += n
keep_dates.append(ts)
keep_mask = np.isin(ts_arr, np.array(keep_dates))
return X_arr[keep_mask], y_arr[keep_mask], ts_arr[keep_mask], sym_arr[keep_mask]
X_train, y_train, _train_ts, _train_sym = _trim_by_complete_dates(
X_train, y_train, timestamps[train_mask], symbols[train_mask], MAX_TRAIN_SAMPLES
)
X_val, y_val, _val_ts, _val_sym = _trim_by_complete_dates(
X_val, y_val, timestamps[val_mask], symbols[val_mask], MAX_VAL_SAMPLES
)
X_test, y_test, test_dates, test_symbols = _trim_by_complete_dates(
X_test, y_test, test_dates, test_symbols, MAX_TEST_SAMPLES
)
```
### Cross-sectional IC helper
The same per-date rank correlation used across this section, so the architectures
are compared on one number.
A date's IC is undefined when a model predicts the same value for every fund on it:
the predicted ranks are all tied and there is nothing to correlate. The library
returns `NaN` for such a date, and polars treats `NaN` and null as different values,
so `drop_nulls` alone leaves it in place and one of them makes the whole mean `NaN`.
Both are filtered here, and the count of dates the mean was taken over is printed
beside it - which matters more here than elsewhere in the chapter, because the
subsampling below leaves far fewer dates to average over.
```python
def cross_sectional_ic_mean(y_true, y_pred, dates, syms):
"""Mean cross-sectional Spearman IC over the dates where it is defined.
Returns the mean and the defined/total date counts. Filters both null and NaN,
since polars `drop_nulls` leaves NaN in place.
"""
pred_df = pl.DataFrame({"timestamp": dates, "symbol": syms, "prediction": y_pred})
ret_df = pl.DataFrame({"timestamp": dates, "symbol": syms, "forward_return": y_true})
ic_per_date = cross_sectional_ic_series(
pred_df,
ret_df,
pred_col="prediction",
ret_col="forward_return",
date_col="timestamp",
entity_col="symbol",
)
defined = ic_per_date.filter(pl.col("ic").is_not_null() & pl.col("ic").is_not_nan())
mean_ic = float(defined["ic"].mean()) if defined.height else float("nan")
return {"ic": mean_ic, "n_defined": defined.height, "n_total": ic_per_date.height}
print(f"Train: {len(X_train):,}, Val: {len(X_val):,}, Test: {len(X_test):,}")
print(
f"Purged {LABEL_HORIZON} target dates before each boundary before complete-date subsampling: "
f"validation starts {train_end_date}, test starts {val_end_date}"
)
```
## Selective state space - what the code computes
At each timestep $t$ we discretize a continuous-time SSM via zero-order
hold and run an input-dependent recurrence on a hidden state $h_t$. In
Mamba the matrices $B_t$, $C_t$ and step size $\Delta_t$ are functions of
the input $u_t$ (the **selective** ingredient), while $A$ is a learned
diagonal that stays time-invariant. The state update and output are:
$$h_t = \exp(\Delta_t A)\, h_{t-1} + (\Delta_t B_t)\, u_t$$
$$y_t = C_t^\top h_t + D \cdot u_t$$
The full continuous-time formulation and the derivation of ZOH live in
Section 13.6; this notebook focuses on the discrete recurrence as
implemented in `selective_scan`.
## Selective SSM Block
The block is read in three pieces - the recurrence itself, the shapes its
constructor sets up, and the gated forward pass - so each stands on its own.
> **Runtime warning**: `selective_scan` uses a Python `for` loop over
> `seq_len`, making it ~100× slower than the production Mamba CUDA kernels.
> Expect several minutes on the full ETF dataset. This is intentional: the
> loop exposes the recurrence mechanics that hardware-efficient kernels hide.
### Selective scan recurrence
Pure-Python implementation of the discrete selective scan: at each step we
discretize `A` via zero-order hold using the per-step size `dt_t`, then
update the hidden state and read the output through the input-dependent
`C_t`. Following the Mamba paper we approximate the input discretization
as $\bar B_t \approx \Delta_t \cdot B_t$ rather than the full ZOH form.
```python
def selective_scan(
log_A: torch.Tensor,
D: torch.Tensor,
x_branch: torch.Tensor,
B: torch.Tensor,
C: torch.Tensor,
dt: torch.Tensor,
) -> torch.Tensor:
"""Run the input-dependent SSM recurrence in pure PyTorch."""
A = -torch.exp(log_A)
batch, seq_len, _ = x_branch.shape
d_inner, _ = log_A.shape
state = torch.zeros(batch, d_inner, log_A.shape[1], device=x_branch.device)
outputs = []
for t in range(seq_len):
u_t = x_branch[:, t, :]
B_t = B[:, t, :]
C_t = C[:, t, :]
dt_t = dt[:, t].unsqueeze(-1)
A_bar = torch.exp(dt_t.unsqueeze(-1) * A.unsqueeze(0))
input_term = dt_t.unsqueeze(-1) * B_t.unsqueeze(1) * u_t.unsqueeze(-1)
state = state * A_bar + input_term
outputs.append(torch.einsum("bds,bs->bd", state, C_t) + u_t * D)
return torch.stack(outputs, dim=1)
```
### Block parameter geometry
`in_proj` doubles the channel count to carry both the SSM branch and the
gate branch. `x_proj` produces the time-varying `B_t`, `C_t`, and
`Δ_t`-raw from the SSM branch itself. `log_A` is a learnable diagonal we
negate before exponentiating so the recurrence is contractive. `D` is the
direct skip from input to output.
```python
class SelectiveSSMBlock(nn.Module):
"""Mamba-style block: input-dependent B/C/Δ feeding `selective_scan`."""
def __init__(self, d_model: int, d_state: int = 16, expand: int = 2, dropout: float = 0.1):
super().__init__()
d_inner = d_model * expand
self.in_proj = nn.Linear(d_model, d_inner * 2)
self.x_proj = nn.Linear(d_inner, d_state * 2 + 1)
self.log_A = nn.Parameter(torch.randn(d_inner, d_state) * 0.5)
self.D = nn.Parameter(torch.ones(d_inner))
self.out_proj = nn.Linear(d_inner, d_model)
self.norm = nn.LayerNorm(d_model)
self.dropout = nn.Dropout(dropout)
def forward(self, x):
residual = x
x_branch, z = self.in_proj(x).chunk(2, dim=-1)
x_ssm = self.x_proj(x_branch)
d_state = self.log_A.shape[1]
B, C, dt_raw = x_ssm.split([d_state, d_state, 1], dim=-1)
dt = F.softplus(dt_raw.squeeze(-1) + 1.0)
y = selective_scan(self.log_A, self.D, x_branch, B, C, dt)
y = self.out_proj(y * F.silu(z))
return self.dropout(self.norm(y + residual))
```
## Mamba Regressor
Stacks multiple `SelectiveSSMBlock` layers with an input projection and
a linear head that reads from the last timestep. This mirrors how an
LSTM uses its final hidden state for prediction.
```python
class MambaRegressor(nn.Module):
"""Mamba/SSM for regression: input projection, SSM layers, linear head."""
def __init__(
self,
n_features: int,
d_model: int = 32,
d_state: int = 16,
n_layers: int = 2,
expand: int = 2,
dropout: float = 0.1,
):
super().__init__()
self.input_proj = nn.Linear(n_features, d_model)
self.layers = nn.ModuleList(
[SelectiveSSMBlock(d_model, d_state, expand, dropout) for _ in range(n_layers)]
)
self.fc = nn.Linear(d_model, 1)
def forward(self, x):
# x: (batch, seq_len, features)
x = self.input_proj(x)
for layer in self.layers:
x = layer(x)
# Use last timestep
x = x[:, -1, :]
return self.fc(x).squeeze(-1)
```
```python
set_global_seeds(SEED)
model = MambaRegressor(
n_features=len(FEATURE_COLS),
d_model=D_MODEL,
d_state=D_STATE,
n_layers=N_LAYERS,
dropout=DROPOUT,
).to(DEVICE)
n_params = sum(p.numel() for p in model.parameters())
print(f"MambaRegressor parameters: {n_params:,}")
print(f"Architecture: {N_LAYERS} SSM layers, d_model={D_MODEL}, d_state={D_STATE}")
print(f"Input: ({LOOKBACK} timesteps, {len(FEATURE_COLS)} features)")
```
## Train Mamba
```python
print("Training MambaRegressor...")
history = train_model(
model,
X_train,
y_train,
X_val,
y_val,
EPOCHS,
LR,
BATCH_SIZE,
DEVICE,
weight_decay=0.01,
)
```
### Training convergence
The two curves are what the patience rule reads. Training error alone cannot
separate a model that is learning from one that is memorising, because both look
like progress; the validation curve turning up while the training curve keeps falling
is the signal that stops the run. Whether it fires before the epoch cap is something
to read off the figure, not to assume.
```python
fig = go.Figure()
for label, key, color in [
("Train", "train_loss", COLORS["blue"]),
("Validation", "val_loss", COLORS["amber"]),
]:
fig.add_trace(
go.Scatter(
x=list(range(1, len(history[key]) + 1)),
y=history[key],
mode="lines+markers",
name=label,
line={"color": color},
)
)
fig.update_layout(
title="Training and validation error per epoch",
xaxis_title="Epoch",
yaxis_title="Mean squared error",
)
show_plotly_with_alt(
fig,
"A line chart of mean squared error against epoch, with one line for the training "
"set and one for the validation set. Training stops when the validation line has "
"gone the required number of epochs without a new minimum.",
)
```
## Evaluate on Test Set
```python
model.eval()
with torch.no_grad():
y_pred_batches = []
for i in range(0, len(X_test), INFER_BATCH_SIZE):
X_test_t = torch.FloatTensor(X_test[i : i + INFER_BATCH_SIZE]).to(DEVICE)
y_pred_batches.append(model(X_test_t).cpu().numpy())
y_pred = np.concatenate(y_pred_batches)
test_mse = np.mean((y_pred - y_test) ** 2)
mamba_ic = cross_sectional_ic_mean(y_test, y_pred, test_dates, test_symbols)
test_ic = mamba_ic["ic"]
print("\nMamba Test Results:")
print(f" MSE: {test_mse:.6f}")
print(f" Spearman IC: {test_ic:.4f}", end="")
print(f" (defined on {mamba_ic['n_defined']} of {mamba_ic['n_total']} test dates)")
```
## Ridge Baseline Comparison
Flattening the window into one vector and fitting a penalised linear map gives the
baseline the selective scan has to beat.
The flattened design is `LOOKBACK` x `len(FEATURE_COLS)` columns of overlapping
trailing returns, which are strongly collinear: on this capped sample the Gram
matrix has a condition number around $8 \times 10^6$. scikit-learn's default solver
forms and factorises that matrix and warns that it is ill-conditioned, so `svd` is
requested instead. It computes the same ridge estimator without forming the normal
equations - the two agree to four significant figures on test MSE - and the warning
has no cause rather than being filtered away.
Note also that `alpha` is fixed at 1, not tuned. That keeps the baseline identical
across this section's notebooks, and it means the bars below compare an untuned
linear map with an under-trained network. Neither is at its best.
```python
X_train_flat = X_train.reshape(len(X_train), -1)
X_test_flat = X_test.reshape(len(X_test), -1)
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train_flat)
X_test_scaled = scaler.transform(X_test_flat)
ridge = Ridge(alpha=1.0, solver="svd")
ridge.fit(X_train_scaled, y_train)
y_ridge_pred = ridge.predict(X_test_scaled)
ridge_mse = np.mean((y_ridge_pred - y_test) ** 2)
ridge_ic_result = cross_sectional_ic_mean(y_test, y_ridge_pred, test_dates, test_symbols)
ridge_ic = ridge_ic_result["ic"]
zero_mse = float(np.mean(y_test**2))
print("\nRidge Baseline Results:")
print(f" MSE: {ridge_mse:.6f}")
print(f" Spearman IC: {ridge_ic:.4f}", end="")
print(f" (defined on {ridge_ic_result['n_defined']} of {ridge_ic_result['n_total']} test dates)")
```
## The selective SSM against the linear baseline
Two questions, two panels. The left asks whether the model ordered the funds usefully
on each date; the right asks whether its predicted return levels were closer than
predicting zero. A model can do better on one and worse on the other, and both are
reported because acting on a forecast uses the ordering while fitting one minimises
the squared error.
The ridge regression sees the same window flattened into one vector and fits a
penalised linear map straight to the label - no state, no selection, no notion that
the columns are ordered in time. Whatever the recurrence is worth has to appear as a
difference from that, within the budget the next paragraph describes.
```python
model_names = ["Mamba SSM", "Ridge"]
ic_values = [test_ic, ridge_ic]
mse_ratios = [test_mse / zero_mse, ridge_mse / zero_mse]
bar_palette = {"Mamba SSM": COLORS["blue"], "Ridge": COLORS["slate"]}
fig = make_subplots(
rows=1,
cols=2,
subplot_titles=("Mean cross-sectional Spearman IC", "MSE relative to zero-return forecast"),
)
for model_name, ic_value, mse_ratio in zip(model_names, ic_values, mse_ratios, strict=True):
fig.add_trace(
go.Bar(
x=[model_name],
y=[ic_value],
name=model_name,
marker_color=bar_palette[model_name],
text=[f"{ic_value:.3f}"],
textposition="outside",
showlegend=False,
),
row=1,
col=1,
)
fig.add_trace(
go.Bar(
x=[model_name],
y=[mse_ratio],
name=model_name,
marker_color=bar_palette[model_name],
text=[f"{mse_ratio:.2f}x"],
textposition="outside",
showlegend=False,
),
row=1,
col=2,
)
fig.add_hline(y=0, line_color=COLORS["neutral"], row=1, col=1)
fig.add_hline(y=1, line_dash="dot", line_color=COLORS["neutral"], row=1, col=2)
fig.update_layout(
title="The selective SSM and ridge on the same test split, ranked and levelled",
height=480,
)
fig.update_yaxes(title_text="Spearman IC", row=1, col=1)
fig.update_yaxes(title_text="MSE / zero-return MSE", row=1, col=2)
show_plotly_with_alt(
fig,
"Two bar panels, one bar per model. The left panel gives each model's mean "
"cross-sectional Spearman IC against a line at zero; the right gives its test MSE "
"as a multiple of the zero forecast's, against a dotted line at one.",
)
```
## What the budget lets this comparison say
Both models see the same data: the subsampling above happens before either is fitted,
so the ridge is trained on exactly the sequences the SSM is. What differs is the
fitting. The ridge solution is closed-form and fully converged at its penalty; the
SSM gets at most `EPOCHS` epochs of gradient descent with early stopping, and the
sample is capped at `MAX_TRAIN_SAMPLES` in the first place because the pure-Python
scan runs orders of magnitude slower than the production kernel. Neither model got a
hyperparameter search.
So the figure is a record of what these two models did under this budget, and the
subsampling is the constraint that matters most: both scores are computed over the
most recent complete dates that fit the cap, not over the full test stretch. The
coverage counts printed beside each IC say how many dates each average was taken
over, and they are a small fraction of the dates the other notebooks in this section
score on. Read them before reading the bars.
## Key takeaways
1. **Separate the two claims about cost.** A fixed-size state and bounded work per
step give $O(T)$ total work; a recurrent network already has that, and attention
gives it up by making each step look at every other. Linearity is a different
property, and what it buys is parallelism: because the update is affine and each
step's coefficients depend only on that step's input, the sweep composes into an
associative scan instead of waiting for its own previous output.
2. **"Selective" means three quantities move with the input.** $B_t$, $C_t$ and
$\Delta_t$ all come out of `x_proj` applied to the current input, while $A$ is a
learned diagonal fixed for the whole sequence. A fixed-parameter SSM such as S4
has all four constant, so its state decays on a schedule set at training time
rather than one the input can change.
3. **$\Delta_t$ is the forget control.** It enters as $\exp(\Delta_t A)$ with $A$
negative, so a large step shrinks the previous state hard and a small one
preserves it. Reading that one line is how you see what the model can be trained
to forget.
4. **The gate is a second, multiplicative path.** `in_proj` splits into an SSM branch
and a gate branch, and the SSM output is multiplied by `silu(z)` before the output
projection - the same shape of interaction an LSTM's gates provide, without a
recurrence in the gate itself.
5. **The comparison here is bounded by the training budget, not just the
architecture.** The loop-based scan forces a capped sample and a handful of
epochs, so the figure says what this model does under this budget. Reading it as
a general result about selective SSMs for return prediction would be reading past
what was run.
**Known limitations.** One chronological split of one ETF panel, one label horizon,
one seed, and a sample capped by the pedagogical scan's speed. The comparison is
against one baseline, and a single split cannot rank architectures;
`12_case_study_insights` is where these families are compared across case studies
under walk-forward validation. Deterministic PyTorch algorithms and a fixed cuBLAS
workspace make repeated execution reproduce on the same software and GPU; another
environment will differ in the final decimals.
**Next**: `08_cnn_image_encoding` gives up on the sequence entirely, turning each
window into a picture and handing it to an image classifier.

Reproduit dans son intégralité avec attribution, conformément à la licence de la source. Licence: MIT
Ce résumé a été rédigé par l’agent de recherche de Stratmill à partir de la source originale ; il n’en est pas une copie.