Nella lezione precedente hai costruito la cella ConvLSTM e ne hai misurato le proprietà. Adesso la mettiamo al lavoro su un compito per cui è stata inventata: prevedere il frame successivo di un video. Per tenere tutto riproducibile su un portatile usiamo una versione semplificata di un banco di prova classico, il «Moving MNIST»: una cifra scritta a mano rimbalza dentro una tela e la rete, dopo aver visto i frame fino al tempo t, deve disegnare il frame t + 1. Capiremo che cosa significa «imparare» in questo contesto confrontando con una baseline più difficile da battere di quanto sembri (copiare l'ultimo frame), misureremo che cosa succede con velocità mai viste e quando la rete deve prevedere più passi in avanti nutrendosi delle proprie previsioni, e vedremo perché le immagini che produce sono sfocate.
Ogni cifra di questa lezione viene da un'esecuzione del codice che trovi nel testo, ripetuta due volte con uscita identica (semi di torch e numpy fissati, 2 thread su CPU, senza GPU). Le cifre non sono quelle di MNIST: scaricarlo richiede torchvision e una connessione, quindi usiamo le 1797 cifre 8×8 di sklearn.datasets.load_digits, che sono già dentro scikit-learn. Sono piccole e rumorose, ma bastano a mostrare tutti i fenomeni, e i numeri non sono confrontabili con quelli pubblicati su MNIST. Con un altro seme i valori cambieranno di qualche punto percentuale.
Le parole di questa lezione
La previsione del frame successivo (next-frame prediction) è il compito: dato un film fino all'istante t, produrre l'immagine dell'istante t + 1. È apprendimento auto-supervisionato, perché l'etichetta è il frame successivo, che nel video c'è già e non richiede nessuna annotazione umana. Il teacher forcing consiste nel dare alla rete, in addestramento, il frame vero al tempo t e non la sua previsione precedente: rende l'addestramento stabile e parallelizzabile sulle sequenze. Il rollout (o previsione autoregressiva) è l'uso opposto: per prevedere più passi in avanti si dà alla rete la propria previsione come ingresso del passo successivo, cosa che in addestramento non ha mai visto, e lo scarto fra i due regimi si chiama exposure bias, perché gli errori si accumulano senza che la rete abbia imparato a correggerli.
La baseline «copia l'ultimo frame» (persistence) prevede che il frame t + 1 sia uguale al frame t: come la persistenza delle serie temporali, è semplice e spesso difficile da battere se il movimento è lento. L'MSE (errore quadratico medio) sui pixel è la media dei quadrati delle differenze fra immagine prevista e vera, ed è la perdita che useremo. Moving MNIST è il nome di un benchmark introdotto da Srivastava e colleghi (2015) per l'apprendimento non supervisionato di rappresentazioni video con LSTM, in cui cifre MNIST si muovono su una tela; la versione qui è una variante nostra e molto più piccola (una sola cifra 8×8 su una tela 16×16), non il dataset originale.
L'analogia: indovinare dove sarà la palla
Guardi una partita di tennis e qualcuno ti copre gli occhi un istante: dove sarà la palla? Se non sai nulla, rispondi «dov'era un attimo fa»: è la baseline «copia l'ultimo frame», e se la palla si muove piano, sbagli di poco. Se invece hai guardato abbastanza fotogrammi da capire direzione e velocità, puoi dire «un po' più a destra e in basso». Quel «un po' più» è ciò che la rete deve imparare: la differenza fra «copia» e «prevedi» sta tutta nel movimento. E c'è una seconda lezione: se non sei sicuro se la palla rimbalzerà a sinistra o a destra, la risposta che minimizza l'errore medio è una palla «mezza sinistra e mezza destra», cioè sfocata. Lo vedremo accadere.
Codice: il generatore di dati
Non scarichiamo un dataset di video già fatto: lo generiamo. Questo ha un vantaggio importante per l'apprendimento: controlliamo la velocità, la posizione e l'insieme di cifre usate, e possiamo creare un numero illimitato di esempi nuovi (niente rischio di memorizzazione). Le 1797 cifre di load_digits hanno valori da 0 a 16, che dividiamo per 16 per portarli in [0, 1], e le separiamo in 1257 cifre per l'addestramento e 540 per il test (train_test_split stratificato, seme 0): le cifre di test non compaiono mai nei video di addestramento, così la rete non può memorizzarne le forme.
La cifra scorre con velocità costante e rimbalza sui bordi. Il rimbalzo si calcola con una formula chiusa: la posizione «libera» è p₀ + v·t; la si prende modulo il periodo 2·L (andata e ritorno sul tratto di lunghezza L) e, se supera L, la si riflette (2L − p). Un esempio con numeri: la cifra è 8×8 su una tela 16×16, quindi L = 16 − 8 = 8 pixel di corsa; partenza 3, velocità 3. Le posizioni nei primi sette istanti sono 3, 6, 7, 4, 1, 2, 5. Al terzo istante la posizione libera sarebbe 9, oltre il bordo, e viene riflessa in 16 − 9 = 7; da lì la cifra torna indietro.
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
torch.manual_seed(0)
torch.set_num_threads(2)
CANVAS, DIGIT = 16, 8 # 16x16 frames, digits stay 8x8
X, y = load_digits(return_X_y=True) # 1797 digits, 8x8, values 0..16
X = (X / 16.0).reshape(-1, DIGIT, DIGIT).astype(np.float32)
train_digits, test_digits = train_test_split(X, test_size=0.3, stratify=y, random_state=0)
print("digits:", train_digits.shape[0], "train /", test_digits.shape[0], "test")
def bounce(p0, v, t, limit):
"""Position of a point moving with speed v from p0, reflected inside [0, limit]."""
p = p0 + v * t
period = 2 * limit
p = np.mod(p, period)
return np.where(p > limit, period - p, p)
def make_batch(digits, batch, T, max_speed=2, rng=None, speeds=None):
rng = rng or np.random.default_rng()
idx = rng.integers(0, len(digits), batch)
limit = CANVAS - DIGIT
x0 = rng.integers(0, limit + 1, (batch, 2)).astype(float)
if speeds is None:
v = rng.integers(-max_speed, max_speed + 1, (batch, 2)).astype(float)
else: # force the speed components to come from `speeds`
v = rng.choice(speeds, (batch, 2)).astype(float)
v[(v == 0).all(axis=1)] = 1.0 # never a still digit
frames = np.zeros((batch, T, 1, CANVAS, CANVAS), dtype=np.float32)
for t in range(T):
pos = bounce(x0, v, t, limit).round().astype(int) # (batch, 2) -> (row, col)
for b in range(batch):
r, c = pos[b]
frames[b, t, 0, r:r + DIGIT, c:c + DIGIT] = digits[idx[b]]
return torch.from_numpy(frames)
clip = make_batch(train_digits, 4, 10, rng=np.random.default_rng(0))
print(clip.shape)
print("bounce start 3, speed 3, limit 8:", bounce(3, 3, np.arange(7), 8).tolist())
print("bounce start 5, speed 4, limit 10:", bounce(5, 4, np.arange(4), 10).tolist())
# digits: 1257 train / 540 test
# torch.Size([4, 10, 1, 16, 16])
# bounce start 3, speed 3, limit 8: [3, 6, 7, 4, 1, 2, 5]
# bounce start 5, speed 4, limit 10: [5, 9, 7, 3]
Spiegazione. load_digits restituisce le cifre come vettori di 64 valori; il reshape le riporta a 8×8. bounce è la formula di riflessione, vettorizzata con NumPy (np.mod, np.where). make_batch sceglie a caso batch cifre, una posizione iniziale e una velocità per ogni componente (riga e colonna) da −max_speed a +max_speed; se per caso la velocità è (0, 0) la cambia in (1, 1) per evitare cifre ferme. Poi, per ogni istante, calcola la posizione con bounce e «incolla» la cifra sulla tela. L'argomento speeds serve più avanti per forzare le velocità a un insieme scelto. L'uscita conferma la forma, quattro clip da dieci frame in scala di grigi 16×16, e i due esempi del rimbalzo, quello del testo e quello della verifica in fondo alla lezione. Il seme del generatore è esplicito (default_rng(0)): senza, due esecuzioni darebbero video diversi e cifre diverse.
Codice: il modello
Il modello impila due celle ConvLSTM (quella della lezione precedente, riscritta qui in forma compatta) e aggiunge una convoluzione 1×1 che trasforma lo stato dell'ultimo strato in una singola immagine, con una sigmoide per mantenere i pixel in [0, 1]. Su una tela così piccola bastano 16 canali per strato, dove con immagini 32×32 ne avremmo scelti di più. Il bias del forget gate parte da 1, come nelle lezioni sull'LSTM, perché la memoria non venga azzerata all'inizio.
class ConvLSTMCell(nn.Module):
def __init__(self, in_ch, hidden_ch, kernel=3):
super().__init__()
self.hidden_ch = hidden_ch
self.conv = nn.Conv2d(in_ch + hidden_ch, 4 * hidden_ch, kernel, padding=kernel // 2)
with torch.no_grad():
self.conv.bias[hidden_ch:2 * hidden_ch] = 1.0
def forward(self, x, state):
h, c = state
i, f, g, o = torch.chunk(self.conv(torch.cat([x, h], dim=1)), 4, dim=1)
c = torch.sigmoid(f) * c + torch.sigmoid(i) * torch.tanh(g)
h = torch.sigmoid(o) * torch.tanh(c)
return h, c
class NextFrameNet(nn.Module):
"""Two stacked ConvLSTM layers + a 1x1 convolution that turns the state into a predicted frame."""
def __init__(self, hidden=16, kernel=3):
super().__init__()
self.cells = nn.ModuleList([ConvLSTMCell(1, hidden, kernel), ConvLSTMCell(hidden, hidden, kernel)])
self.head = nn.Conv2d(hidden, 1, 1)
self.hidden = hidden
def init_state(self, B, H, W):
z = lambda: torch.zeros(B, self.hidden, H, W)
return [(z(), z()) for _ in self.cells]
def step(self, frame, states):
x = frame
new = []
for cell, st in zip(self.cells, states):
h, c = cell(x, st)
new.append((h, c)); x = h
return torch.sigmoid(self.head(x)), new # predicted NEXT frame, pixels in [0, 1]
def forward(self, clip): # clip: (B, T, 1, H, W) -> predictions for frames 1..T-1
B, T, _, H, W = clip.shape
states = self.init_state(B, H, W)
preds = []
for t in range(T - 1):
p, states = self.step(clip[:, t], states) # sees frames 0..t (the TRUE ones), predicts t+1
preds.append(p)
return torch.stack(preds, dim=1) # (B, T-1, 1, H, W)
Come leggerlo. ConvLSTMCell è la stessa cella di prima. NextFrameNet ne tiene due in una ModuleList: la prima riceve il frame (1 canale), la seconda riceve gli stati della prima (16 canali). init_state crea gli stati iniziali a zero con la forma giusta (B, 16, H, W) per ogni strato. step è il cuore: fa passare un frame attraverso i due strati, aggiornando gli stati, e restituisce la previsione del frame successivo e i nuovi stati. forward è l'addestramento con teacher forcing: per t da 0 a T − 2 passa il frame vero clip[:, t] a step e raccoglie la previsione del frame t + 1. I parametri si contano a mano: il primo strato ha una convoluzione 3×3 da 17 canali (1 + 16) a 64 (4 × 16), cioè 17·64·9 + 64 = 9856; il secondo da 32 a 64, cioè 32·64·9 + 64 = 18.496; la testa 1×1 ha 16 + 1 = 17. Totale 28.369, pochi: la ConvLSTM condivide i pesi sullo spazio.
Addestramento e baseline
Prima di allenare scriviamo i numeri da battere su un set di validazione fisso: l'MSE della baseline «copia l'ultimo frame», F.mse_loss(val[:, :-1], val[:, 1:]), cioè l'errore che si ha prevedendo ogni frame uguale al precedente, e quello di una seconda baseline ancora più ingenua, «tutto nero». Il set di validazione usa 64 clip fatte con le cifre di test e un generatore con seme 123; il batch di addestramento è fresco a ogni passo, con il proprio generatore a seme 0.
model = NextFrameNet()
opt = torch.optim.Adam(model.parameters(), lr=3e-3)
print("parameters:", sum(p.numel() for p in model.parameters()))
rng = np.random.default_rng(0)
with torch.no_grad():
val = make_batch(test_digits, 64, 10, rng=np.random.default_rng(123))
copy_mse = F.mse_loss(val[:, :-1], val[:, 1:]).item()
zero_mse = F.mse_loss(torch.zeros_like(val[:, 1:]), val[:, 1:]).item()
print(f"copy-last-frame MSE: {copy_mse:.5f} all-black MSE: {zero_mse:.5f}")
STEPS = 1200
for step in range(1, STEPS + 1):
clip = make_batch(train_digits, 16, 10, rng=rng)
pred = model(clip)
loss = F.mse_loss(pred, clip[:, 1:])
opt.zero_grad(); loss.backward()
nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
if step % 200 == 0:
with torch.no_grad():
v = F.mse_loss(model(val), val[:, 1:]).item()
print(f"step {step:4d} train {loss.item():.5f} val {v:.5f} ratio {v / copy_mse:.2f}")
# parameters: 28369
# copy-last-frame MSE: 0.06409 all-black MSE: 0.05928
# step 200 train 0.05087 val 0.05462 ratio 0.85
# step 400 train 0.05618 val 0.05268 ratio 0.82
# step 600 train 0.03449 val 0.03602 ratio 0.56
# step 800 train 0.02932 val 0.03171 ratio 0.49
# step 1000 train 0.02845 val 0.03061 ratio 0.48
# step 1200 train 0.02782 val 0.02880 ratio 0.45
Lettura del ciclo: a ogni passo si genera un batch fresco di 16 clip da 10 frame, la rete prevede i frame 1…9 dai frame veri 0…8, si calcola l'MSE rispetto ai frame veri, si fa backward, clipping del gradiente (lezione sul BPTT) e step. Con 1200 passi l'esperimento termina in pochi minuti su CPU; con una macchina libera e più passi la curva scenderebbe ancora, quindi il valore finale non è un limite del modello. La colonna train è la perdita del singolo batch di quel passo, rumorosa; la colonna val è sul set fisso, ed è quella da leggere.
Cosa dicono i numeri. Il dato che sorprende viene prima dell'addestramento: la baseline «copia» vale 0.06409 ed è peggiore di prevedere un'immagine tutta nera (0.05928). La ragione: una cifra su sfondo nero che si sposta lascia due errori, i pixel che prevedevi accesi e non lo sono più e quelli che prevedevi spenti e si sono accesi, mentre la tela nera sbaglia solo i secondi. Con cifre piccole su sfondo vuoto, dunque, non basta battere la copia: conviene confrontarsi anche con il nero. Fra i passi 200 e 400 la rete sta a 0.0546 e 0.0527, appena sotto il nero: ha imparato dove è, in media, la luminosità, non il movimento. Poi, fra i passi 400 e 600, l'errore scende a 0.0360: è il momento in cui ha «agganciato» la dinamica. Da lì scende lentamente fino a 0.02880 a 1200 passi, cioè il 45% della baseline «copia» e il 49% del nero. Ricorda il plateau della lezione sull'LSTM: il modello passa un lungo tratto in cui la sola strategia disponibile è dipingere una macchia grigia, e poi scopre la struttura.
Con quali velocità funziona? Cosa succede a quelle mai viste
In addestramento le velocità sono componenti in {−2, …, +2}. Valutiamo il modello separatamente su tre insiemi: velocità piccole (viste), velocità fino a 2 (viste), e velocità ±3 mai viste. Con una corsa di soli 8 pixel, ±3 fa rimbalzare la cifra già al terzo istante, un caso difficile in più.
@torch.no_grad()
def one_step_mse(model, digits, speeds, n=64, seed=7):
clip = make_batch(digits, n, 10, rng=np.random.default_rng(seed), speeds=speeds)
return F.mse_loss(model(clip), clip[:, 1:]).item(), F.mse_loss(clip[:, :-1], clip[:, 1:]).item()
for label, speeds in [("speeds 0,+-1 (seen)", [-1, 0, 1]), ("speeds 0,+-2 (seen)", [-2, 0, 2]), ("speeds +-3 (NEVER seen)", [-3, 3])]:
m, c = one_step_mse(model, test_digits, speeds)
print(f"{label:26s} model {m:.5f} copy-last {c:.5f} ratio {m / c:.2f}")
# speeds 0,+-1 (seen) model 0.02276 copy-last 0.04590 ratio 0.50
# speeds 0,+-2 (seen) model 0.03164 copy-last 0.06805 ratio 0.46
# speeds +-3 (NEVER seen) model 0.06369 copy-last 0.09437 ratio 0.67
Spiegazione del codice: one_step_mse genera clip con velocità forzate dall'insieme speeds e restituisce l'MSE del modello e quello della baseline sullo stesso batch (seme fisso, quindi confrontabili). Il rapporto model/copy è la misura utile: più è basso, meglio la rete batte la copia. Con velocità viste il rapporto è 0.50 e 0.46: il modello più che dimezza l'errore (le due cifre sono troppo vicine, con un solo seme, per dire quale velocità sia più facile). Con velocità ±3, mai viste, sale a 0.67: il modello batte ancora la copia, ma molto meno. Notiamo che la baseline stessa peggiora (0.0944) perché un movimento più rapido rende la copia più sbagliata.
Due cause sono confuse in questo risultato: la rete non ha mai visto velocità 3 in addestramento, e il campo recettivo è limitato (lezione precedente: due strati con kernel 3×3, raggio dell'ordine di 2 pixel per passo, più la memoria). Per separarle bisognerebbe riaddestrare includendo la velocità 3 e vedere se il rapporto torna verso 0.46; non è stato fatto, quindi il degrado non va attribuito a una sola causa. La regola pratica è comunque chiara: valuta il modello sulle velocità che incontrerà in produzione, e verifica che siano nell'intervallo di addestramento.
Prevedere più passi: il rollout
Finora la rete ha sempre ricevuto frame veri. In uso reale, per prevedere cinque passi in avanti, le diamo cinque frame veri e poi la lasciamo proseguire da sola, dandole in ingresso la propria previsione a ogni passo. La funzione rollout fa esattamente questo, e la confrontiamo con la baseline «ripeti l'ultimo frame reale» (il frame 4) tenuta ferma per tutto l'orizzonte.
@torch.no_grad()
def rollout(model, clip, n_context=5, n_future=5):
"""Read n_context TRUE frames, then keep predicting by feeding the model its own predictions."""
B, T, _, H, W = clip.shape
states = model.init_state(B, H, W)
for t in range(n_context): # warm-up on real frames
pred, states = model.step(clip[:, t], states)
outs = [pred] # prediction of frame n_context
for _ in range(n_future - 1):
pred, states = model.step(pred, states) # feed the prediction back in
outs.append(pred)
return torch.stack(outs, dim=1)
clip = make_batch(test_digits, 64, 10, rng=np.random.default_rng(7))
pred = rollout(model, clip)
for k in range(5):
target = clip[:, 5 + k]
print(f"horizon {k + 1}: model {F.mse_loss(pred[:, k], target).item():.5f} copy-last-real-frame {F.mse_loss(clip[:, 4], target).item():.5f}")
# horizon 1: model 0.02894 copy-last-real-frame 0.05987
# horizon 2: model 0.04102 copy-last-real-frame 0.08599
# horizon 3: model 0.04465 copy-last-real-frame 0.09307
# horizon 4: model 0.04578 copy-last-real-frame 0.09980
# horizon 5: model 0.04837 copy-last-real-frame 0.09889
La funzione prima fa passare n_context = 5 frame veri a step per «scaldare» gli stati; poi, per ogni passo futuro, richiama step con la previsione precedente (pred) al posto di un frame vero. Risultato: l'errore del modello cresce da 0.0289 all'orizzonte 1 a 0.0484 all'orizzonte 5 (circa 1,7 volte), ma cresce anche quello della copia, da 0.0599 a 0.0989, perché il frame 4 si allontana nel tempo. Il rapporto fra i due resta intorno a 0.5 (0.48 all'orizzonte 1, 0.49 all'orizzonte 5): in questa configurazione l'errore non esplode e il vantaggio sulla copia non si consuma. La previsione del primo passo (0.0289) coincide con l'MSE a un passo della validazione (0.0288), come ci si aspetta.
Questo smentisce una lettura facile del fenomeno, quella per cui l'exposure bias si vedrebbe sempre come un crollo dell'accuratezza. Qui la rete, addestrata solo su ingressi veri, regge cinque passi di rollout senza degradare rispetto alla baseline, probabilmente perché i video sono semplici (una cifra, velocità costante) e perché da una previsione già sfocata ricava ancora abbastanza informazione sul moto. Il segno del problema compare altrove, nelle immagini, come vedremo nella prossima sezione. Il nostro esperimento non separa l'exposure bias dal semplice fatto che prevedere lontano è più difficile; per isolarlo bisognerebbe confrontare, sullo stesso frame bersaglio, la previsione con ingressi veri e quella con ingressi generati. Le contromisure note, che non applichiamo qui, sono addestrare anche con ingressi generati dal modello a tratti (scheduled sampling), o usare una perdita su più passi in avanti.
Perché le immagini escono sfocate
Misuriamo la nitidezza: la differenza quadratica media fra pixel vicini (un valore basso significa immagine «morbida», uno alto significa bordi netti). Confrontiamo i frame veri con le previsioni.
def sharpness(img):
"""Mean squared difference between neighbouring pixels: low value = blurry picture."""
dx = (img[..., :, 1:] - img[..., :, :-1]).pow(2).mean()
dy = (img[..., 1:, :] - img[..., :-1, :]).pow(2).mean()
return (dx + dy).item()
with torch.no_grad():
one_step = model(clip) # (B, 9, 1, H, W): predictions of frames 1..9
print("sharpness of real frames :", round(sharpness(clip[:, 1:]), 4))
print("sharpness of 1-step predictions:", round(sharpness(one_step), 4))
print("sharpness at rollout horizon 1 :", round(sharpness(pred[:, 0]), 4))
print("sharpness at rollout horizon 5 :", round(sharpness(pred[:, 4]), 4))
print("max pixel value: real", round(clip[:, 5:].max().item(), 2), " rollout h5", round(pred[:, 4].max().item(), 2))
# sharpness of real frames : 0.0777
# sharpness of 1-step predictions: 0.012
# sharpness at rollout horizon 1 : 0.013
# sharpness at rollout horizon 5 : 0.006
# max pixel value: real 1.0 rollout h5 0.58
La funzione sharpness somma la differenza al quadrato fra pixel orizzontalmente e verticalmente adiacenti. I frame veri hanno nitidezza 0.0777, le previsioni a un passo 0.012: circa sei volte più sfocate. Qui l'accumulo di errori è visibile: all'orizzonte 5 la nitidezza scende a 0.006, la metà di quella dell'orizzonte 1, e il pixel più luminoso arriva solo a 0.58 contro 1.0 reale. Rispetto alla copia la rete non perde terreno, ma disegna una macchia sempre più pallida.
La spiegazione è matematica e merita un esempio. Supponi che, per un certo pixel, la rete sappia solo che nel 50% dei casi la cifra sarà lì (pixel = 1) e nel 50% no (pixel = 0). Qual è il valore che minimizza l'errore quadratico medio atteso? Chiamiamo v la previsione: l'errore atteso è 0.5·(1 − v)² + 0.5·(0 − v)², che ha minimo in v = 0.5. La rete, cioè, non sceglie fra i due futuri possibili: disegna la loro media, un pixel grigio. Ripetuto su tutti i pixel incerti, il risultato è un'immagine morbida. Non è un errore del codice, è una proprietà della perdita MSE quando il futuro è ambiguo; qui l'ambiguità c'è davvero, perché la posizione di partenza e la velocità non sono note con certezza dai primi frame. Rimedi che esistono (non provati qui): perdite più «percettive», perdite avversarie (GAN), o modelli probabilistici che campionano un futuro specifico; ne vedremo uno, il VAE, nelle lezioni successive.
Trade-off
- Una baseline forte è un bene, due sono meglio. Il rapporto 0.45 con la copia è un risultato interpretabile; un MSE «basso» senza baseline non lo sarebbe. Con cifre piccole su una tela nera l'immagine tutta nera ha già un MSE di 0.059, inferiore a quello della copia.
- Capacità contro memoria. Più canali (16 qui) migliorano la qualità ma moltiplicano memoria e tempo; abbiamo scelto piccolo per poter addestrare su CPU.
- Teacher forcing contro rollout. Il primo stabilizza e velocizza l'addestramento; il secondo è ciò che serve in uso. La distanza fra i due (exposure bias) è un costo reale, che qui si è visto nella nitidezza più che nell'MSE.
- MSE contro nitidezza. Ottimizzare l'errore pixel per pixel favorisce immagini medie e sfocate.
Le quattro voci hanno una radice comune: la scelta più comoda per l'addestramento (una perdita semplice, frame veri in ingresso, un modello piccolo) è quella che scarica il costo sull'uso reale, dove servono orizzonti lunghi e immagini nitide. Il rapporto con la baseline è lo strumento che impedisce di non accorgersene, a patto che la baseline sia quella giusta.
Trappole reali
La prima trappola è non avere la baseline «copia» e fidarsi di una sola: con movimenti lenti e sfondo vuoto l'MSE di una rete mediocre sembra ottimo, e qui la copia perde persino contro il nero, quindi scrivi sempre entrambi i numeri prima di allenare. La seconda è valutare solo con teacher forcing: l'MSE a un passo non dice che aspetto abbiano le immagini a cinque passi (qui l'errore sale di 1,7 volte e la nitidezza si dimezza), e se il tuo uso è prevedere cinque passi devi misurare il rollout a cinque passi. La terza è testare solo sulla distribuzione di velocità dell'addestramento: una modifica di velocità in produzione degrada il modello (da rapporto 0.46-0.50 a 0.67 con velocità ±3), quindi costruisci test di stress con velocità, posizioni e rumore diversi.
La quarta è giudicare dai grafici: le immagini sfocate possono «sembrare» plausibili, e conviene misurare numericamente (qui la nitidezza). La quinta è scambiare il plateau per un fallimento: fino al passo 400 il modello sembrava non imparare (rapporto 0.82-0.85), e l'addestramento va guardato su una scala adeguata, confrontando con le baseline. La sesta è la fuga di informazione fra train e test: le cifre di test devono essere diverse da quelle di addestramento (qui il 30% di load_digits è tenuto fuori prima di generare i video), altrimenti il modello può memorizzare le forme. L'ultima è affidarsi a un solo seed: differenze piccole, come 0.46 contro 0.50, non sono significative.
Domande per verificarti (con risposta)
Perché l'addestramento usa frame veri come ingresso a ogni passo? È il teacher forcing: stabilizza e permette di calcolare tutte le previsioni insieme; il prezzo è l'exposure bias in uso.
Se un pixel è 1 nel 30% dei futuri possibili e 0 nel 70%, qual è la previsione che minimizza l'MSE? La media, 0.3: l'errore atteso 0.3(1 − v)² + 0.7v² ha minimo in v = 0.3.
Il modello ha MSE 0.0288, la copia 0.0641. Quanto vale il rapporto e cosa significa? 0.0288 / 0.0641 ≈ 0.45: il modello commette il 45% dell'errore della copia.
Con la formula di rimbalzo, partendo da 5 con velocità 4 e limite 10, dove sta la cifra ai tempi 0, 1, 2, 3? Posizioni libere 5, 9, 13, 17; periodo 20; riflesse: 5, 9, 20−13 = 7, 20−17 = 3 (è la seconda riga dell'uscita del generatore).
Un esempio svolto a mano: la copia, il modello e la sfocatura su quattro pixel
Facciamo i conti su un «frame» di soli quattro pixel. Al tempo t la cifra occupa i pixel [0, 1, 1, 0]; al tempo t + 1 si è spostata di un pixel a destra: [0, 0, 1, 1].
Baseline «copia». Prevedere [0, 1, 1, 0] per il frame successivo dà differenze [0, 1, 0, 1], quindi MSE (0 + 1 + 0 + 1) / 4 = 0.5. Il frame tutto nero [0, 0, 0, 0] sbaglia solo i due pixel accesi: MSE (0 + 0 + 1 + 1) / 4 = 0.5, pari alla copia. Se invece lo spostamento è di due pixel e la cifra non si sovrappone alla vecchia posizione ([1, 1, 0, 0] contro [0, 0, 1, 1]), la copia sbaglia tutti e quattro i pixel (MSE 1) e il nero solo due (MSE 0.5): è il motivo per cui, nell'esperimento, la copia perde contro il nero. Un modello che ha capito il movimento (un pixel a destra) prevede [0, 0, 1, 1] e ottiene MSE 0. La nostra rete ha ridotto l'MSE medio da 0.0641 a 0.0288 (il 45%): non è arrivata a zero perché le cifre reali sono più complesse e i movimenti variano.
La sfocatura. Supponi che il modello non sappia se la cifra andrà a destra ([0, 0, 1, 1]) o a sinistra ([1, 1, 0, 0]), con uguale probabilità. Cosa conviene prevedere, pixel per pixel? Per il primo pixel il valore vero è 0 oppure 1 con probabilità ½: prevedere p dà un errore atteso ½(1 − p)² + ½p². Per p = 0.5 vale 0.25 in qualunque caso; per p = 1 vale 0.5 in media (0 se è giusto, 1 se è sbagliato); per p = 0 idem. La previsione che minimizza l'errore atteso è dunque la media, [0.5, 0.5, 0.5, 0.5]: un frame grigio uniforme, che non assomiglia a nessuno dei due futuri possibili. Con l'MSE il modello «copre le scommesse», e il risultato è un'immagine sfocata. Per ottenere immagini nitide bisogna cambiare la domanda: non «qual è la media dei futuri?» ma «campiona un futuro plausibile» (modelli probabilistici, VAE, perdite avversarie).
Domande frequenti
Perché valutare con una baseline «copia» se la rete è ovviamente migliore? Perché «ovviamente» non è una misura. Con sfondo nero e cifre piccole una rete che predice quasi tutto nero ha già un MSE basso (0.0593 qui, meno della copia); solo il rapporto con le baseline dice quanto la rete capisce del movimento.
Quanti passi servono? Qui il modello è uscito dal plateau fra 400 e 600 passi e ha continuato a migliorare fino a 1200. Con più dati o cifre più grandi serve di più; si guarda la curva di validazione, non un numero fisso.
Perché la rete funziona peggio con velocità 3? Due cause confuse: non l'ha mai vista in addestramento e il campo recettivo è limitato (lezione precedente). Per distinguere basterebbe riaddestrare includendo la velocità 3 e confrontare.
Come migliorare la previsione a più passi? Addestrare anche sul proprio output (scheduled sampling), usare una perdita su più passi, o un modello che prevede esplicitamente l'incertezza. Non li abbiamo provati.
Il Moving MNIST originale è lo stesso? No: quello di Srivastava e colleghi usa due cifre di 28×28 su una tela 64×64 e un insieme di sequenze generato in modo specifico. La nostra variante (una cifra 8×8 su 16×16, da load_digits) è più semplice e i numeri non sono confrontabili con i valori pubblicati.
Una ConvLSTM serve davvero per un compito così? Per mostrare le idee sì, ma un compito con una sola cifra e velocità costante si potrebbe risolvere anche con un modello più semplice. Il valore dell'esempio sta nel mostrare le tre difficoltà (generalizzazione, accumulo di errori, ambiguità del futuro) che restano anche in problemi più grandi.
Cosa portarti via
Una ConvLSTM a due strati e 28 mila parametri impara in pochi minuti di CPU a prevedere il frame successivo di cifre in movimento con il 45% dell'errore della baseline «copia», dopo un plateau iniziale in cui fa poco meglio di un'immagine nera. Il risultato ha tre limiti misurati: con velocità mai viste il vantaggio si riduce (rapporto 0.67), prevedendo cinque passi di seguito le immagini perdono metà della nitidezza e il pixel più luminoso scende a 0.58 pur con un rapporto di errore sulla copia stabile, e già a un passo le immagini sono sei volte più sfocate delle vere, perché la perdita MSE premia la media dei futuri possibili. Questi tre limiti (generalizzazione, accumulo di errori, ambiguità del futuro) sono i problemi aperti di ogni modello di previsione video. Nella prossima lezione cambiamo tema: come far passare il segnale in reti molto profonde, con le Highway Network.
Fonti
- N. Srivastava, E. Mansimov, R. Salakhutdinov, Unsupervised Learning of Video Representations using LSTMs (arXiv:1502.04681) - l'origine del benchmark Moving MNIST (due cifre su una tela 64×64), scheda raggiunta il 2026-11-12
- X. Shi et al., Convolutional LSTM Network (arXiv:1506.04214) - la ConvLSTM per la previsione di sequenze di mappe, scheda raggiunta il 2026-11-12
- S. Bengio et al., Scheduled Sampling for Sequence Prediction with Recurrent Neural Networks (arXiv:1506.03099) - la contromisura all'exposure bias citata nel testo, scheda raggiunta il 2026-11-12
- scikit-learn: sklearn.datasets.load_digits - le 1797 cifre 8×8 usate al posto di MNIST, senza download, consultata il 2026-11-12
Commenti
Caricamento commenti...
Accedi per commentare