Il modo più rapido per capire cosa fa e cosa non fa una RNN è metterla davanti a una serie temporale e confrontarla con le alternative più banali. In questa lezione costruisci un dataset a finestre scorrevoli, lo dividi nel modo corretto (nel tempo, non a caso), alleni un nn.RNN in PyTorch e verifichi se batte una baseline di persistenza («domani sarà uguale a oggi»). Poi chiedi al modello di prevedere più passi in avanti nutrendosi delle proprie previsioni e vedi l'errore crescere. Tutti i numeri qui sotto vengono da esecuzioni del codice mostrato, due volte con uscita identica (PyTorch 2.12.0 su CPU, un solo seed).
Le parole di questo articolo
Una serie temporale è una sequenza di misure ordinate nel tempo (temperatura oraria, vendite giornaliere, battito cardiaco), e il compito classico è la previsione (forecasting): dato il passato, stimare il valore successivo; l'orizzonte è quanti passi in avanti vuoi prevedere. Per trasformarla in esempi di addestramento si usa una finestra scorrevole (sliding window): si taglia una finestra di k valori consecutivi come ingresso e si usa il valore subito dopo come bersaglio, poi si sposta la finestra di un passo per ottenere l'esempio successivo.
I dati si dividono in training (su cui la rete impara), validation (con cui scegli gli iperparametri) e test (che non tocchi finché non vuoi un voto onesto); qui usiamo solo training e test per semplicità. Il leakage è la fuga di informazione: quando nel training entra, per errore, ciò che nel mondo reale non avresti, e il punteggio sul test sembra ottimo ma è falso. Come metrica usiamo il MAE (mean absolute error), la media dei valori assoluti degli errori, che si legge nelle stesse unità dei dati; l'MSE è la media dei quadrati, penalizza di più gli errori grandi ed è comodo per l'addestramento. La baseline è il modello più banale che puoi immaginare: se il tuo modello complesso non la batte, non serve. Un'epoca è un passaggio completo sui dati di addestramento, un mini-batch il sottoinsieme usato per ogni aggiornamento dei pesi, e Adam è l'ottimizzatore, cioè la regola che trasforma il gradiente in un aggiornamento, una discesa del gradiente che regola automaticamente il passo per ogni peso.
L'analogia: prevedere il meteo guardando le ultime 30 ore
Se ti chiedo la temperatura fra un'ora e ti lascio guardare le ultime 30 misure, hai un'idea ragionevole del trend e dell'oscillazione giornaliera. Se mi chiedi la temperatura fra 50 ore e puoi ragionare solo sulle tue previsioni successive, ogni piccolo errore si somma al successivo. Il modello fa la stessa cosa. E come il meteorologo onesto, devi sempre chiederti: «faccio meglio di dire che resterà come adesso?».
Il disegno mostra perché dal solo vettore di una serie lunga 2000 si ottengono 1970 esempi (con finestra da 30): ogni spostamento di un passo dà un esempio nuovo, ma gli esempi vicini si sovrappongono quasi del tutto. È proprio questa sovrapposizione a rendere pericoloso mescolare i dati prima di dividerli: una finestra nel test e una nel training che differiscono di un solo passo sono quasi lo stesso esempio.
Passo 1: preparare i dati (a mano, in NumPy)
Contano più dell'architettura tre decisioni. La prima è dividere nel tempo prima di tutto: il test set è la parte finale della serie, perché se mescoli le finestre prima di dividere, finestre quasi identiche (spostate di un solo passo) finiscono sia nel training sia nel test e il modello «ricorda» il test. La seconda è normalizzare con le statistiche del solo training: media e deviazione standard del test non esistono ancora quando fai la previsione vera, e usarle è un altro leakage, più sottile. La terza è calcolare le baseline subito: prima di allenare qualunque cosa scrivi quanto sbaglia la previsione banale, perché senza questo numero un MAE di 0.15 non significa nulla.
import numpy as np
rng = np.random.default_rng(0)
t = np.arange(2000)
series = np.sin(0.1 * t) + 0.5 * np.sin(0.31 * t) + rng.normal(0, 0.1, t.size)
# 1) split IN TIME first: the test set is the final part, never shuffled into the training set
split = int(0.8 * len(series))
train_raw, test_raw = series[:split], series[split:]
# 2) normalise with statistics computed on the TRAIN part only
mu, sigma = train_raw.mean(), train_raw.std()
train, test = (train_raw - mu) / sigma, (test_raw - mu) / sigma
def windows(x, size):
"""X[i] = x[i : i+size], y[i] = x[i+size] (predict the next value)."""
X = np.stack([x[i:i + size] for i in range(len(x) - size)])
return X[..., None], x[size:] # X shape: (N, size, 1)
SIZE = 30
X_tr, y_tr = windows(train, SIZE)
X_te, y_te = windows(test, SIZE)
# Baselines the model must beat
persistence = np.abs(X_te[:, -1, 0] - y_te).mean() # "tomorrow = today"
mean_pred = np.abs(0.0 - y_te).mean() # always the training mean (0 after normalising)
print(f"MAE persistence {persistence:.3f} MAE mean {mean_pred:.3f}")
Il codice, riga per riga nei punti che contano. series è la serie sintetica: due sinusoidi di frequenze diverse sommate più rumore. split = int(0.8 * len(series)) fissa il punto di taglio nel tempo. mu, sigma sono calcolati solo sulla parte di training e applicati a entrambe: (x − mu) / sigma porta i valori attorno a zero con deviazione 1, il che aiuta l'addestramento perché la tanh lavora bene vicino a zero. La funzione windows costruisce, per ogni indice i, l'ingresso x[i : i+size] e il bersaglio x[i+size]; X[..., None] aggiunge l'asse delle feature (qui una sola, il valore), perché nn.RNN si aspetta forma (esempi, passi, feature). Le due baseline si calcolano in una riga ciascuna: la persistenza confronta l'ultimo valore della finestra (X_te[:, -1, 0]) con il bersaglio.
La serie è sintetica: due sinusoidi sovrapposte più rumore gaussiano di deviazione 0.1. Su questa serie l'MAE della persistenza è 0.200 (nella scala normalizzata) e quello di «predico sempre la media» è 0.886. Il rumore irriducibile mette un pavimento: con rumore 0.1 e deviazione standard della serie intorno a 0.8, nessun modello può scendere sotto un MAE di circa 0.1 in scala normalizzata (l'MAE di una gaussiana è circa 0.8 volte la sua deviazione standard).
Passo 2: il modello con nn.RNN
Il modello è quello della lezione precedente: una RNN che legge la finestra e una testa lineare applicata solo all'ultimo stato. Si usa anche il clipping del gradiente, per le ragioni viste prima.
import torch
import torch.nn as nn
torch.manual_seed(0)
class NextValueRNN(nn.Module):
def __init__(self, hidden=32):
super().__init__()
self.rnn = nn.RNN(1, hidden, batch_first=True)
self.head = nn.Linear(hidden, 1)
def forward(self, x): # x: (batch, time, 1)
out, _ = self.rnn(x)
return self.head(out[:, -1, :]).squeeze(-1) # use the last state only
model = NextValueRNN()
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
Xt, yt = torch.tensor(X_tr, dtype=torch.float32), torch.tensor(y_tr, dtype=torch.float32)
Xv, yv = torch.tensor(X_te, dtype=torch.float32), torch.tensor(y_te, dtype=torch.float32)
for epoch in range(15):
model.train()
perm = torch.randperm(len(Xt)) # shuffling WINDOWS is fine once the split is done
for i in range(0, len(Xt), 64):
idx = perm[i:i + 64]
loss = nn.functional.mse_loss(model(Xt[idx]), yt[idx])
opt.zero_grad(); loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
if epoch % 5 == 4:
model.eval()
with torch.no_grad():
mae = (model(Xv) - yv).abs().mean().item()
print(f"epoch {epoch+1:2d} test MAE {mae:.3f}")
La classe NextValueRNN ha due componenti: nn.RNN(1, hidden) legge la finestra e nn.Linear(hidden, 1) trasforma l'ultimo stato in un numero (la previsione). Nel forward, out[:, -1, :] sceglie l'ultimo passo di ogni esempio e .squeeze(-1) toglie l'asse inutile. Il ciclo di addestramento ha sempre la stessa forma: model(Xt[idx]) calcola la previsione, mse_loss misura l'errore, opt.zero_grad() azzera i gradienti vecchi (PyTorch li accumula!), loss.backward() calcola i nuovi con la BPTT della lezione precedente, clip_grad_norm_ li limita e opt.step() aggiorna i pesi. Ogni cinque epoche si passa in modalità eval() e torch.no_grad() per misurare l'MAE sul test senza calcolare gradienti.
Output ottenuto:
epoch 5 test MAE 0.178
epoch 10 test MAE 0.171
epoch 15 test MAE 0.146
Il modello batte la persistenza (0.200) già dopo 5 epoche e continua a migliorare, ma resta sopra il pavimento di circa 0.1: c'è ancora margine. Il punto importante è metodologico. Il risultato «0.146» è interessante solo perché abbiamo scritto prima «0.200». Per una serie con forte autocorrelazione, la persistenza è spesso difficile da battere e molti modelli complessi falliscono proprio lì.
Passo 3: prevedere più passi in avanti
Finora il modello prevede un passo. Per prevedere h passi, l'approccio autoregressivo riusa la propria previsione come ultimo elemento della finestra e rifà il giro. Il rovescio della medaglia è che gli errori si accumulano: ogni previsione sbagliata diventa un ingresso sbagliato per la successiva.
@torch.no_grad()
def forecast(model, seed_window, steps):
"""Feed the model its own predictions: errors accumulate step after step."""
model.eval()
window = seed_window.clone() # (1, SIZE, 1)
preds = []
for _ in range(steps):
nxt = model(window) # (1,)
preds.append(nxt.item())
window = torch.cat([window[:, 1:, :], nxt.view(1, 1, 1)], dim=1) # slide the window
return np.array(preds)
seed = Xv[:1]
for horizon in (1, 10, 50):
p = forecast(model, seed, horizon)
err = np.abs(p - y_te[:horizon]).mean()
print(f"horizon {horizon:2d}: MAE {err:.3f}")
La funzione parte da una finestra vera (seed_window), chiede la previsione al modello e poi fa scorrere la finestra: scarta il valore più vecchio (window[:, 1:, :]) e aggiunge in fondo la previsione appena fatta (torch.cat). Dal secondo passo in poi il modello legge, in parte o del tutto, i propri stessi output invece dei dati reali: è per questo che l'errore si accumula. Il decoratore @torch.no_grad() evita di costruire il grafo dei gradienti, inutile in fase di uso. Il confronto finale usa y_te[:horizon]: i primi horizon valori veri successivi alla finestra di partenza.
horizon 1: MAE 0.177
horizon 10: MAE 0.178
horizon 50: MAE 0.452
A 1 e 10 passi l'errore è praticamente uguale; a 50 passi è più che raddoppiato (0.452). Un solo punto di partenza e un solo seed: il valore preciso varia, la tendenza (l'errore cresce con l'orizzonte) no. Questo è un comportamento atteso e non un difetto del codice.
Cosa cambia se giri una manopola alla volta
Un buon modo di imparare è cambiare una cosa e guardare l'effetto. La funzione riallena da capo una RNN con la dimensione di finestra e quella dello stato che scegli, e restituisce l'MAE sul test (stesse 15 epoche, stesso seed).
def fit_and_score(size, hidden, epochs=15, seed=0):
"""Rebuild windows of the given size, train a fresh RNN, return the test MAE."""
torch.manual_seed(seed)
Xa, ya = windows(train, size)
Xb, yb = windows(test, size)
Xa, ya = torch.tensor(Xa, dtype=torch.float32), torch.tensor(ya, dtype=torch.float32)
Xb, yb = torch.tensor(Xb, dtype=torch.float32), torch.tensor(yb, dtype=torch.float32)
net = NextValueRNN(hidden)
opt = torch.optim.Adam(net.parameters(), lr=3e-3)
for _ in range(epochs):
perm = torch.randperm(len(Xa))
for i in range(0, len(Xa), 64):
idx = perm[i:i + 64]
loss = nn.functional.mse_loss(net(Xa[idx]), ya[idx])
opt.zero_grad(); loss.backward()
nn.utils.clip_grad_norm_(net.parameters(), 1.0)
opt.step()
net.eval()
with torch.no_grad():
return (net(Xb) - yb).abs().mean().item()
print("window size (hidden=32):", {s: round(fit_and_score(s, 32), 3) for s in (2, 5, 30, 60)})
print("hidden size (window=30):", {h: round(fit_and_score(30, h), 3) for h in (2, 8, 32, 128)})
finestra (stato=32): 2 → 0.201 5 → 0.174 30 → 0.146 60 → 0.160
stato (finestra=30): 2 → 0.215 8 → 0.171 32 → 0.146 128 → 0.160
Come leggerlo, con cautela (un solo seed, differenze di pochi centesimi possono essere rumore). Con una finestra di 2 valori il modello vede troppo poco del ciclo della sinusoide e fa solo come la persistenza (0.201 contro 0.200). Con 5 migliora, con 30 raggiunge il meglio osservato. Passare a 60 non aiuta e anzi peggiora leggermente: più passi significano una BPTT più lunga e un compito di ottimizzazione più difficile, senza informazione utile in più, dato che il segnale ha periodi molto più brevi. Lo stesso per lo stato: 2 numeri sono troppo pochi per rappresentare due oscillazioni con fasi diverse, 8 vanno meglio, 32 sono il punto migliore, 128 non portano vantaggio su questo problema semplice. Regola pratica: la capacità giusta è la più piccola che risolve il problema, e si trova scalando in una direzione finché la validazione smette di migliorare.
Misurare il danno dello split sbagliato (e un risultato che non ti aspetti)
Mescolare prima di dividere è un errore. Misuriamo quanto danneggia, confrontando i due modi di dividere con lo stesso modello e lo stesso budget. La funzione train_model riallena da capo; mae misura l'errore. Lo split «sbagliato» assegna a caso l'80% delle finestre al training e il 20% al test.
def train_model(Xa, ya, epochs=15, seed=0, hidden=32):
torch.manual_seed(seed)
net = NextValueRNN(hidden)
opt = torch.optim.Adam(net.parameters(), lr=3e-3)
Xa = torch.tensor(Xa, dtype=torch.float32); ya = torch.tensor(ya, dtype=torch.float32)
for _ in range(epochs):
perm = torch.randperm(len(Xa))
for i in range(0, len(Xa), 64):
idx = perm[i:i + 64]
loss = nn.functional.mse_loss(net(Xa[idx]), ya[idx])
opt.zero_grad(); loss.backward()
nn.utils.clip_grad_norm_(net.parameters(), 1.0)
opt.step()
return net.eval()
def mae(net, X, y):
with torch.no_grad():
return (net(torch.tensor(X, dtype=torch.float32)) - torch.tensor(y, dtype=torch.float32)).abs().mean().item()
# --- Experiment: what does the WRONG split promise us? ---
series_n = (series - series.mean()) / series.std() # (normalised on everything: second small leak)
X_all, y_all = windows(series_n, SIZE)
rng2 = np.random.default_rng(0)
idx = rng2.permutation(len(X_all))
cut = int(0.8 * len(idx))
tr_idx, te_idx = idx[:cut], idx[cut:] # RANDOM split: neighbours end up on both sides
wrong = train_model(X_all[tr_idx], y_all[tr_idx])
print("random split test MAE:", round(mae(wrong, X_all[te_idx], y_all[te_idx]), 3))
right = train_model(X_tr, y_tr)
print("temporal split test MAE:", round(mae(right, X_te, y_te), 3))
random split test MAE: 0.145
temporal split test MAE: 0.146
Risultato: nessuna differenza visibile (0.145 contro 0.146). Perché? Questa serie è una somma di due sinusoidi più rumore bianco. La parte prevedibile è periodica e la rete la impara da qualunque tratto; il rumore non si può memorizzare con una rete così piccola e 15 epoche. Quindi su questa serie lo split sbagliato non ha dato un punteggio gonfiato. Non concludere che sia innocuo: il lavoro serio sta nella lezione opposta. Il danno dipende dal fatto che la serie abbia strutture irregolari che finestre vicine si «rivelano» a vicenda e dal fatto che il modello abbia capacità di memorizzarle; non puoi saperlo senza avere già fatto la divisione giusta. La divisione temporale costa zero e risponde alla domanda che ti interessa davvero («cosa succede quando arriva il futuro?»): usala sempre, senza aspettare di vedere il danno.
Serie con trend: perché lavorare sulle differenze
Una RNN con tanh produce stati in (−1, 1), e la sua uscita lineare ha ingressi dentro un intervallo imparato in training. Una serie con un trend crescente esce da quell'intervallo nel test. Proviamo con una sinusoide più una deriva lineare, confrontando il modello sui livelli grezzi e sulle differenze prime (x_t − x_{t−1}), cioè la serie delle variazioni.
# --- Experiment: a series with a trend leaves the range seen in training ---
t2 = np.arange(2000)
trend = np.sin(0.1 * t2) + 0.004 * t2 + rng.normal(0, 0.05, t2.size) # sine + upward drift
split2 = int(0.8 * len(trend))
def run_on(series_in, label):
tr, te = series_in[:split2], series_in[split2:]
m, s = tr.mean(), tr.std()
tr, te = (tr - m) / s, (te - m) / s
Xa, ya = windows(tr, SIZE); Xb, yb = windows(te, SIZE)
net = train_model(Xa, ya)
return mae(net, Xb, yb), np.abs(Xb[:, -1, 0] - yb).mean(), ya.std()
raw_mae, raw_persist, _ = run_on(trend, "raw")
diff_mae, diff_persist, _ = run_on(np.diff(trend), "diff")
print(f"raw levels : RNN {raw_mae:.3f} persistence {raw_persist:.3f}")
print(f"differences: RNN {diff_mae:.3f} persistence (of the differences) {diff_persist:.3f}")
raw levels : RNN 0.062 persistence 0.041
differences: RNN 0.515 persistence (of the differences) 0.966
Dentro ogni riga, il confronto è equo (stessi dati, stessa scala), fra righe no (scale diverse). Sui livelli grezzi la RNN (0.062) è battuta dalla persistenza (0.041): la rete non è riuscita a seguire la deriva nel test, perché i valori del test escono dall'intervallo visto in training e la persistenza, che si limita a copiare l'ultimo valore, non ne risente. Sulle differenze il quadro si ribalta: la serie delle variazioni non ha trend, e la RNN (0.515) batte la persistenza (0.966) quasi di un fattore due. Per tornare ai livelli si ricostruisce con una somma cumulativa (np.cumsum) a partire dall'ultimo valore noto. Questa è la ragione per cui in finanza e in meteorologia si lavora su rendimenti o differenze, non su prezzi o temperature assolute.
Verificati con quattro conti
Con 5000 punti e finestra di 50 ottieni 5000 − 50 = 4950 esempi, perché ogni finestra consuma 50 valori più il bersaglio. Se la persistenza ha MAE 0.2 e il modello 0.19 probabilmente non hai un successo: la differenza è piccola e con un solo seme può essere rumore, quindi servono più semi e una stima dell'incertezza. Normalizzi con media e deviazione del solo training perché in produzione non conoscerai la media del futuro, e usarla nel test farebbe filtrare informazione che non avresti. Se prevedi 24 ore avanti con un modello ricorsivo a 1 passo, misuri l'MAE a orizzonte 24 e non a 1, perché gli errori si compongono e il valore a 1 passo è troppo ottimista.
Valutazione walk-forward: un solo test è poco
Finora abbiamo un solo punto di taglio e quindi un solo numero. Ma un modello di previsione va usato nel tempo: oggi con i dati fino a oggi, fra un mese con i dati fino a quel giorno. La valutazione walk-forward (o rolling origin) simula questo: si allena sul passato fino a un'origine, si testa sul blocco successivo, poi si sposta l'origine in avanti, si allena di nuovo e si testa ancora. Si ottengono più misure, e la loro variabilità dice quanto puoi fidarti.
# Walk-forward (rolling-origin) evaluation: train on the past, test on the next block, move on.
def walk_forward(series_in, size=30, first_train=0.5, n_folds=4, epochs=15):
n = len(series_in)
block = int((1 - first_train) * n / n_folds)
results = []
for k in range(n_folds):
end_train = int(first_train * n) + k * block
tr = series_in[:end_train]
te = series_in[end_train - size:end_train + block] # overlap `size` values so the first window is complete
m, s = tr.mean(), tr.std()
Xa, ya = windows((tr - m) / s, size)
Xb, yb = windows((te - m) / s, size)
net = train_model(Xa, ya, epochs=epochs)
persistence = np.abs(Xb[:, -1, 0] - yb).mean()
results.append((end_train, mae(net, Xb, yb), persistence))
return results
for end_train, model_mae, persist in walk_forward(series):
print(f"train up to t={end_train:4d} model MAE {model_mae:.3f} persistence {persist:.3f}")
train up to t=1000 model MAE 0.139 persistence 0.193
train up to t=1250 model MAE 0.163 persistence 0.202
train up to t=1500 model MAE 0.152 persistence 0.197
train up to t=1750 model MAE 0.144 persistence 0.204
Spiegazione del codice: walk_forward divide la parte finale della serie in n_folds blocchi. Per ogni blocco allena su tutto ciò che viene prima (series_in[:end_train]), normalizza con statistiche di quel training, e testa sul blocco; il test parte size valori prima per avere subito una finestra completa (quei valori sono già noti, non c'è leakage perché sono input e non bersagli). Il risultato: il modello batte la persistenza in tutti e quattro i blocchi (da 0.139 a 0.163 contro circa 0.2), e il suo MAE varia di circa ±0.012 fra blocchi (a un seed). Questo è un risultato molto più solido di un singolo numero. Il costo è chiaro: si riallena quattro volte.
Strategia diretta contro strategia ricorsiva, misurate sulle stesse finestre
Esistono due modi di prevedere H passi: ricorsivo (un modello a un passo, riutilizzato sulle proprie previsioni) e diretto (un modello con H uscite che prevede tutto in un colpo solo). La domanda interessante è quale vada meglio. L'esperimento precedente aveva un limite: un solo punto di partenza. Qui confrontiamo i due approcci con H = 10, valutando il diretto su tutte le finestre di test e il ricorsivo su 200 punti di partenza.
H = 10 # forecast horizon (steps ahead)
def windows_multi(x, size, horizon):
X = np.stack([x[i:i + size] for i in range(len(x) - size - horizon + 1)])
Y = np.stack([x[i + size:i + size + horizon] for i in range(len(x) - size - horizon + 1)])
return torch.tensor(X[..., None], dtype=torch.float32), torch.tensor(Y, dtype=torch.float32)
class DirectRNN(nn.Module):
"""One forward pass produces ALL H future values (direct strategy)."""
def __init__(self, hidden=32, horizon=H):
super().__init__()
self.rnn = nn.RNN(1, hidden, batch_first=True)
self.head = nn.Linear(hidden, horizon)
def forward(self, x):
out, _ = self.rnn(x)
return self.head(out[:, -1, :]) # (batch, H)
Xd, Yd = windows_multi(train, SIZE, H)
Xv2, Yv2 = windows_multi(test, SIZE, H)
torch.manual_seed(0)
direct = DirectRNN()
opt = torch.optim.Adam(direct.parameters(), lr=3e-3)
for epoch in range(15):
perm = torch.randperm(len(Xd))
for i in range(0, len(Xd), 64):
idx = perm[i:i + 64]
loss = nn.functional.mse_loss(direct(Xd[idx]), Yd[idx])
opt.zero_grad(); loss.backward()
nn.utils.clip_grad_norm_(direct.parameters(), 1.0)
opt.step()
with torch.no_grad():
direct_err = (direct(Xv2) - Yv2).abs().mean(dim=0) # MAE for each of the H steps, over ALL test windows
# Recursive strategy with the 1-step model from before, on 200 starting points
@torch.no_grad()
def recursive_errors(model1, X, Y, n_start=200):
errs = torch.zeros(H)
for j in range(n_start):
window = X[j:j + 1].clone()
for h in range(H):
nxt = model1(window)
errs[h] += (nxt - Y[j, h]).abs().item()
window = torch.cat([window[:, 1:, :], nxt.view(1, 1, 1)], dim=1)
return errs / n_start
rec_err = recursive_errors(model, Xv2, Yv2)
for h in (0, 4, 9):
print(f"step {h + 1:2d}: direct {direct_err[h]:.3f} recursive {rec_err[h]:.3f}")
step 1: direct 0.179 recursive 0.148
step 5: direct 0.231 recursive 0.229
step 10: direct 0.416 recursive 0.247
Lettura. windows_multi costruisce, per ogni finestra, un bersaglio di H valori consecutivi; DirectRNN ha una testa lineare con H uscite. La funzione recursive_errors accumula l'errore assoluto a ciascun passo h lungo la traiettoria ricorsiva. In questo esperimento (un seed, 15 epoche) il ricorsivo vince, e il vantaggio cresce con l'orizzonte: allo step 10 l'errore diretto è 0.416 contro 0.247. È un risultato che va contro l'intuizione «il ricorsivo accumula errori, quindi è peggio». Spiegazioni plausibili, non verificate qui: il modello diretto ha 10 compiti da imparare con la stessa capacità e lo stesso budget di addestramento, mentre il ricorsivo ne impara uno solo, più semplice, e la serie è molto regolare (le sue previsioni a un passo sono buone, quindi l'accumulo è modesto). Con serie più rumorose o con orizzonti più lunghi il verdetto può invertirsi, ed è per questo che nella pratica si prova entrambe e si misura. La regola da portarsi via è metodologica: non fidarti dell'intuizione su quale strategia sia migliore, misura sull'orizzonte che ti interessa.
Trade-off e alternative
- Previsione ricorsiva (come sopra): un solo modello, semplice, ma errori composti.
- Previsione diretta: la testa produce tutti gli
hpassi insieme (nn.Linear(hidden, h)). Niente accumulo di errore ricorsivo, ma l'orizzonte è fisso e il modello è più grande. - Finestra più lunga aiuta solo se c'è informazione utile nel passato lontano, e per una RNN semplice una finestra lunga è anche più difficile da addestrare (lezione precedente). Qui 30 passi coprono tre periodi della sinusoide più veloce.
Le tre strade rispondono a una sola domanda, quanto vuoi fidarti delle tue stesse previsioni: la ricorsiva le riusa, la diretta no, e la finestra più lunga cambia soltanto quanto passato il modello vede. Non c'è un ordine di merito fisso: l'esperimento del paragrafo precedente ha dato un vincitore su questa serie, e su un'altra potrebbe dare l'opposto, quindi la scelta si fa misurando sull'orizzonte che ti serve.
Trappole reali
Il bug più comune è mescolare prima di dividere: dà risultati bellissimi in laboratorio e pessimi in produzione, e il test deve essere sempre «dopo» il training nel tempo. Segue il normalizzare su tutta la serie: usa solo media e deviazione del training e riusale identiche in produzione, salvandole insieme al modello. Poi c'è il non avere una baseline: una rete che «impara» ma non batte la persistenza non serve a niente. Anche misurare solo a un passo e dichiarare vittoria inganna: se il caso d'uso è prevedere 24 ore avanti, valuta a 24 ore. Infine le serie con trend: una serie che sale per sempre esce dal range visto in training e una RNN con tanh non estrapola, quindi lavora sulle differenze (x_t − x_{t−1}) o sui rendimenti.
Un esempio svolto a mano, con otto numeri
Per essere sicuri di aver capito la costruzione dei dati, facciamola con una serie così piccola da stare in una riga: 2, 4, 6, 8, 10, 12, 14, 16, con finestra da 3.
Passo 1, finestre. La prima finestra è [2, 4, 6] e il bersaglio è il valore subito dopo, 8. Spostando di un passo: [4, 6, 8] → 10, [6, 8, 10] → 12, [8, 10, 12] → 14, [10, 12, 14] → 16. Da 8 valori e finestra 3 si ottengono 8 − 3 = 5 esempi (la regola N − k già vista).
Passo 2, divisione nel tempo. Se la parte di addestramento sono i primi 6 valori (2 … 12) e il test gli ultimi 2 (14, 16), nessuna finestra di addestramento può contenere 14 o 16: gli esempi validi per l'addestramento sono [2,4,6]→8, [4,6,8]→10 e [6,8,10]→12.
Passo 3, normalizzazione con le sole statistiche del training. La media dei sei valori di addestramento è 7 e la deviazione standard (della popolazione) √((25 + 9 + 1 + 1 + 9 + 25) / 6) = √11.67 = 3.416. Un valore x diventa (x − 7) / 3.416. Il 12 diventa 1.46; ma il 14 diventa 2.05 e il 16 diventa 2.63: valori fuori dall'intervallo visto in addestramento (che arriva a 1.46). Questo piccolo esempio contiene in miniatura il problema del trend: una serie che sale esce dall'intervallo e una rete con tanh non ha mai visto valori così grandi.
Passo 4, le metriche. Se un modello sbaglia di +0.2, −0.5, +0.1 su tre previsioni, l'MAE è (0.2 + 0.5 + 0.1) / 3 = 0.267 e l'MSE è (0.04 + 0.25 + 0.01) / 3 = 0.100. L'errore grande (−0.5) pesa il 83% dell'MSE ma solo il 62% dell'MAE: ecco cosa significa «l'MSE penalizza di più gli errori grandi».
Passo 5, tornare alle unità vere. Un MAE di 0.146 è «normalizzato». Moltiplicando per la deviazione standard del training (0.795 in questa serie) si ottengono 0.116 nelle unità originali; la persistenza (0.200 normalizzato) corrisponde a 0.159. Se la serie fosse una temperatura in gradi, la rete sbaglierebbe in media di circa 0.12 gradi contro 0.16 del metodo banale. Riporta sempre l'errore nelle unità del problema: un numero adimensionale non dice a un collega se va bene o male.
Domande frequenti
Perché non allenare su tutta la serie e valutare «a occhio»? Perché senza un test non c'è modo di sapere se la rete ha imparato una regola o ha memorizzato gli esempi. Il test è la tua unica stima onesta di come andrà davvero.
Mescolare le finestre di addestramento non è leakage? No, finché la divisione fra addestramento e test è già avvenuta nel tempo. Mescolare serve soltanto a rendere indipendenti i mini-batch durante l'addestramento; il test non viene toccato.
Perché usare solo l'ultimo stato per la previsione? Perché l'ultimo stato ha letto tutta la finestra: è il riassunto che la rete ha costruito. Usare gli stati intermedi sarebbe utile per predire ogni passo (many-to-many), non solo il successivo.
La finestra da 30 è «giusta»? No, è un iperparametro. Nel nostro esperimento 30 ha funzionato meglio di 2, 5 e 60 (0.146 contro 0.201, 0.174, 0.160). La regola utile è che la finestra deve coprire almeno il periodo più lungo che ti interessa (qui la sinusoide lenta ha periodo 63 passi, quella veloce 20) senza essere molto più lunga.
E se ho più serie contemporanee (temperatura e umidità)? Si mette una feature per serie: nn.RNN(input_size=2, …) e l'ingresso ha forma (esempi, passi, 2). La RNN non cambia; cambia solo la terza dimensione dei dati.
Il modello può prevedere qualcosa di mai accaduto? No: apprende regolarità presenti nei dati. Un evento straordinario (un picco mai visto) resta imprevedibile; per questo la valutazione walk-forward su periodi diversi è importante.
Che cosa sai fare adesso
Sai costruire il dataset a finestre di una serie temporale dividendolo nel tempo e normalizzandolo con le sole statistiche del training, calcolare le baseline prima di allenare, e allenare una RNN in PyTorch giudicandola per quanto batte la persistenza e non per il numero in sé. Sai prevedere più passi con la strategia ricorsiva e con quella diretta, misurando l'errore all'orizzonte che ti interessa, e sai scegliere fra livelli e differenze quando c'è un trend. La RNN semplice qui funziona perché la dipendenza è corta e liscia; quando la memoria necessaria si allunga serve la cella con porte di LSTM. Per le basi, riparti da Reti neurali ricorrenti e da BPTT e gradient clipping.
Fonti
- R. J. Hyndman, G. Athanasopoulos, Forecasting: Principles and Practice, sezione 5.10 Time series cross-validation - la valutazione su origini mobili, con un training che contiene solo osservazioni precedenti al test, letta il 2026-11-12
- R. J. Hyndman, G. Athanasopoulos, Forecasting: Principles and Practice, capitolo Stationarity and differencing - il ricorso alle differenze per le serie con trend, pagina raggiunta il 2026-11-12
- PyTorch: sorgente di torch/nn/modules/rnn.py - il layout
(batch, tempo, feature)conbatch_first=Truee le due usciteouteh_n, letto il 2026-11-12 - PyTorch: sorgente di torch/nn/utils/clip_grad.py -
clip_grad_norm_scala i gradienti sulla norma totale, consultata il 2026-11-12
Commenti
Caricamento commenti...
Accedi per commentare