Paithon Book Paithon Book
Esegui il codice

Il training loop: addestrare un modello#

Chi programma in PyTorch riconosce a colpo d’occhio cinque righe che tornano, identiche, in ogni progetto: dal tutorial per principianti al codice che addestra i grandi modelli linguistici. Sono il training loop, e il fatto che si scrivano a mano non è una dimenticanza della libreria: è una presa di posizione. Dove altri framework nascondono l’addestramento dietro un unico comando, PyTorch preferisce che ogni passo (previsione, errore, gradiente, correzione) resti visibile e modificabile. È più codice, ma è tuo: quando vorrai cambiare qualcosa nel modo di apprendere, saprai esattamente dove mettere le mani.

Il rito: i cinque passi#

Eccole, le cinque righe. Tutto il resto della sezione non fa che spiegarle e metterle al lavoro su un problema vero. Prima, però, due parole che nel codice compaiono senza presentazioni.

L’ottimizzatore è il pezzo che a ogni giro corregge i pesi del modello. Il learning rate (in italiano si dice anche, più brevemente, il passo: le due parole in questo libro vogliono dire la stessa cosa) è la sua manopola principale, e decide quanto è grande ogni correzione: un passo corto impara piano ma non sbaglia strada, un passo lungo va veloce ma rischia di scavalcare il punto buono.

Di ottimizzatori ce ne sono parecchi, e per adesso ne bastano due. Il più semplice si chiama SGD, dall’inglese stochastic gradient descent, discesa del gradiente a caso; «a caso» perché a ogni giro guarda un pacchetto di esempi sorteggiati invece di tutti quanti, ed è il modo in cui gli esempi arrivano a una rete, come vedremo fra poco. SGD usa lo stesso passo per tutti i pesi. Adam invece dà a ciascun peso il passo suo, ed è la ragione per cui si prova per primo in quasi ogni progetto (Fig. 6.6). Il nome non è di persona: sta per adaptive moment estimation.

Confronto fra due ottimizzatori sugli stessi pesi. Con SGD tutti i parametri si muovono con lo stesso passo, grande o piccolo che sia stata finora la loro correzione. Con Adam ogni peso ha il passo suo, calcolato da quanto quel peso si è mosso nelle correzioni precedenti: chi si è mosso molto rallenta, chi si è mosso poco accelera.

Fig. 6.6 Un learning rate per ciascuno. Adam non sceglie una velocità migliore: ne sceglie una diversa per ogni parametro, in base a quanto quel parametro si è mosso finora.#

La conseguenza pratica: con SGD il learning rate va tarato bene, perché è uno solo per tutti; con Adam il valore di partenza è meno critico, perché ciascun peso lo riscala per conto proprio.

Nel codice le cose hanno il nome inglese: criterion è la funzione di perdita (sì, la stessa che il capitolo chiama loss e che qui a volte si chiama «criterio»: sono tre nomi per un oggetto solo), dataloader è un cameriere che porta i pacchetti di esempi e che costruiremo nel prossimo paragrafo, optimizer è l’ottimizzatore appena presentato.

for X_batch, y_batch in dataloader:
    y_pred = model(X_batch)             # 1. forward: la previsione
    loss = criterion(y_pred, y_batch)   # 2. loss: quanto abbiamo sbagliato
    optimizer.zero_grad()               # 3. via i gradienti del giro prima
    loss.backward()                     # 4. backward: calcola i gradienti
    optimizer.step()                    # 5. aggiorna i pesi

Il terzo passo è quello che a prima vista sembra fuori posto: si butta via una cosa prima di averla calcolata. La ragione è che PyTorch, quando calcola i gradienti, non li scrive sopra ai vecchi: li somma a quelli che trova. Disastroso qui, perché al secondo giro il modello si correggerebbe usando anche l’errore del primo; comodo in un caso solo, che è quello di chi ha una macchina piccola e vuole far finta di avere pacchetti grandi, e che la sezione su replicare un paper riprende. Fuori da quel caso, si fa piazza pulita prima, ed è per questo che il gesto sta prima del calcolo e non dopo l’uso.

Quelle cinque righe dicono l’ordine, non il movimento: Fig. 6.7 le fa girare tre volte, cioè su tre pacchetti di esempi in fila (il nome tecnico è mini-batch, e il prossimo paragrafo racconta perché gli esempi arrivino a pacchetti), e mostra a ogni passo che cosa cambia dentro il modello.

I cinque passi del ciclo di addestramento in fila, con una freccia che dal quinto torna al primo. Sotto, lo stato del modello: le barre dei gradienti di sei pesi e la posizione di quei sei pesi rispetto al valore di partenza. Il quarto passo, backward, riempie le barre dei gradienti; il quinto, step, sposta i pesi; le barre restano piene fino allo zero_grad del giro successivo, che le riporta a zero. A destra la loss dei tre giri, che scende: 2,35 poi 2,29 poi 2,25.

Fig. 6.7 Tre giri dello stesso ciclo, su una rete piccola presa a esempio. I gradienti compaiono quando backward() li calcola, restano finché lo zero_grad() del giro dopo non li toglie di mezzo, e intanto step() sposta i pesi. A destra la loss dei tre giri: parte da \(2{,}35\), poco sopra il \(2{,}30\) di chi tira a indovinare fra dieci cifre, e a ogni giro scende un poco.#

È il metodo con cui si impara con le flashcard, le carte per memorizzare. Guardi la domanda e provi a rispondere (passo 1, la previsione). Giri la carta e confronti con la risposta giusta: quanto eri lontano? (passo 2, l’errore). Butti via gli appunti del giro precedente (passo 3), capisci in che direzione hai sbagliato, troppo alto? troppo basso? (passo 4), e aggiusti di conseguenza il tuo modo di rispondere, un poco alla volta (passo 5). Poi passi al mazzetto successivo, e quando hai ripassato l’intero mazzo una volta, hai completato quella che si chiama un’epoca. Ripetuto per migliaia di carte ed epoche, questo giro è tutto ciò che serve a una rete per imparare.

Il loop realizza un passo di discesa del gradiente su mini-batch. Con \(\mathcal{L}\) la loss media sul batch e \(\theta\) i parametri:

\[ \theta \leftarrow \theta - \eta \, \nabla_{\theta} \mathcal{L}, \]

dove \(\eta\) è il learning rate. loss.backward() calcola \(\nabla_{\theta}\mathcal{L}\) via autograd e lo deposita in p.grad per ogni parametro; optimizer.step() applica l’aggiornamento, la formula esatta dipende dall’ottimizzatore: la discesa semplice per optim.SGD, stime adattive dei momenti per optim.Adam [KB15], il default robusto di quasi ogni progetto. zero_grad() è necessario perché autograd accumula i gradienti a ogni backward(): senza, ogni passo userebbe la somma di tutti i gradienti precedenti.

Il nome però dice meno di quello che il metodo fa. Da PyTorch 2.0 il default è set_to_none=True, quindi p.grad non diventa un tensore di zeri: diventa None. È un risparmio (nessuna memoria tenuta occupata da gradienti che non ci sono, e un’operazione in meno per parametro) ed è una trappola per chi va a controllare i gradienti nel posto sbagliato: un assert p.grad is not None scritto dopo l’azzeramento fallisce su tutti i parametri, e quel controllo va messo subito dopo il backward().

L’ordine dei passi 3–5 è l’unica liturgia da rispettare; tutto il resto è normale Python, e infatti qui si innestano senza attrito gradient clipping, scheduler del learning rate, mixed precision. L’eccezione alla liturgia è una sola, ed è l’accumulo dei gradienti, il modo di simulare un batch grande su una macchina piccola che vedremo in replicare un paper: lì si eseguono \(k\) backward() e un solo step(), quindi l’azzeramento esce dal giro e si fa una volta ogni \(k\) micro-batch. Rispettare la liturgia lì è l’errore: il codice gira identico, e la matematica no. Misurato, l’accumulo fatto bene coincide con il batch grande vero a meno di \(1{,}5 \cdot 10^{-8}\), quello con lo zero_grad() a ogni micro-batch sbaglia di \(8{,}4 \cdot 10^{-2}\) senza una riga di errore.

Dataset e DataLoader: la catena di rifornimento#

Le reti non mangiano il dataset intero in un boccone, né un esempio alla volta: mangiano mini-batch, pacchetti di qualche decina di esempi. A prepararli ci pensano due classi di torch.utils.data.

from torch.utils.data import DataLoader
from torchvision import datasets, transforms

# MNIST scaricato e trasformato in tensori con valori in [0, 1]
train_data = datasets.MNIST(root="data", train=True, download=True,
                            transform=transforms.ToTensor())
test_data = datasets.MNIST(root="data", train=False, download=True,
                           transform=transforms.ToTensor())

train_loader = DataLoader(train_data, batch_size=64, shuffle=True)
test_loader = DataLoader(test_data, batch_size=256)   # niente shuffle, pacchetti piu' grandi

Le due righe finali non sono uguali per caso, e la ragione è la stessa per tutte e due: in esame il modello non impara. Il mescolamento serve a non fargli imparare a memoria l’ordine delle carte, quindi in esame non serve. E la dimensione del pacchetto, in addestramento, decide anche ogni quanti esempi il modello si corregge: 64 vuol dire una correzione ogni 64 immagini. In esame non si corregge niente, quindi si può prendere il pacchetto più capiente che la memoria regge, e si guadagna solo velocità.

Il Dataset è la dispensa: sa quanti esempi ci sono e sa consegnarti l’esempio numero \(i\) quando glielo chiedi. Il DataLoader è il cameriere che apparecchia: pesca dalla dispensa, mescola l’ordine a ogni giro (così la rete non impara la sequenza a memoria, come uno studente che ripassa sempre le carte nello stesso ordine) e porta in tavola vassoi da 64 esempi alla volta. Perché proprio a vassoi? Un esempio alla volta è uno spreco, la GPU resta ferma ad aspettare; tutti insieme non entrano in memoria. Il mini-batch è la via di mezzo che tiene la cucina sempre piena.

Dataset (variante map-style) è un protocollo minimo: __len__ e __getitem__. Qualunque classe che li implementi (un file CSV, una cartella di immagini, un database) diventa una sorgente per il DataLoader, che aggiunge campionamento (shuffle=True rimescola gli indici a ogni epoca), batching (impila gli esempi lungo il primo asse: qui tensori \((64, 1, 28, 28)\)), e caricamento parallelo (num_workers) con memoria page-locked (pin_memory=True), che è la premessa del trasferimento asincrono verso la GPU, non l’asincronia: quella richiede anche non_blocking=True nel .to(), come si vedrà in prestazioni. La transform ToTensor() converte le immagini PIL in tensori float32 con valori in \([0, 1]\) e layout channels-first \((C, H, W)\); per MNIST si può aggiungere transforms.Normalize((0.1307,), (0.3081,)) (media e deviazione standard del dataset) per centrare gli input, come visto nel capitolo sulle reti neurali. Statisticamente, il gradiente su un mini-batch è una stima non distorta ma rumorosa del gradiente vero: il rumore è il prezzo (e in parte il segreto) della discesa stocastica.

MNIST da cima a fondo#

Mettiamo insieme tutto quello che il capitolo ha costruito: tensori, modello, loss, dati. Questo è un programma completo che scarica MNIST, addestra il percettrone multistrato della sezione precedente e lo valuta su immagini mai viste. Ci sono dentro tre chiamate che non abbiamo ancora presentato (model.train(), model.eval() e il blocco torch.no_grad()): per ora si possono leggere come «adesso studia» e «adesso rispondi e basta», e la sezione subito dopo il codice se ne occupa per esteso.

import torch
from torch import nn, optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms

device = "cuda" if torch.cuda.is_available() else "cpu"

# --- dati ---
train_data = datasets.MNIST(root="data", train=True, download=True,
                            transform=transforms.ToTensor())
test_data = datasets.MNIST(root="data", train=False, download=True,
                           transform=transforms.ToTensor())
train_loader = DataLoader(train_data, batch_size=64, shuffle=True)
test_loader = DataLoader(test_data, batch_size=256)

# --- modello, loss, ottimizzatore ---
model = nn.Sequential(
    nn.Flatten(),
    nn.Linear(28 * 28, 128),
    nn.ReLU(),
    nn.Linear(128, 10),
).to(device)

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)   # 1e-3 e' 0,001

# --- addestramento ---
for epoca in range(5):
    model.train()                        # modalità addestramento
    for X, y in train_loader:
        X, y = X.to(device), y.to(device)
        y_pred = model(X)
        loss = criterion(y_pred, y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

    # --- valutazione a fine epoca ---
    model.eval()                         # modalità valutazione
    corretti = 0
    with torch.no_grad():                # niente gradienti: solo lettura
        for X, y in test_loader:
            X, y = X.to(device), y.to(device)
            y_pred = model(X)
            # per ogni immagine prendi il punteggio piu' alto -> la cifra scelta;
            # confrontala con quella vera; conta i sì
            corretti += (y_pred.argmax(dim=1) == y).sum().item()

    print(f"epoca {epoca + 1}: accuratezza sul test {corretti / len(test_data):.3f}")

La riga che conta le risposte giuste merita di essere sciolta, perché è quella che produce il numero di cui il modello si vanta, e sta tutta in una riga sola. y_pred è una tabella con una riga per immagine e dieci punteggi per riga; argmax(dim=1) scorre ciascuna riga e restituisce la posizione del punteggio più alto, cioè la cifra che il modello ha scelto (dim sta per dimension, cioè quale asse percorrere: dim=1 è il secondo, quello dei dieci punteggi, perché il primo, dim=0, è quello delle immagini). Il confronto == y mette a fianco la risposta vera e produce una colonna di sì e no; .sum() conta i sì (che valgono uno) e .item() estrae quel conteggio come numero Python normale, da poter sommare al totale. Quattro gesti, quattro parole, e sono gli stessi quattro che torneranno in ogni programma del capitolo. Il rapporto fra i sì e il totale è l’accuratezza: la quota di risposte giuste, e basta.

Cinque epoche, e l’accuratezza sul test arriva attorno al 97–98%: novantasette cifre su cento lette correttamente da \(101\,770\) numeri che prima di partire erano casuali. Quanto ci vuole dipende molto dalla macchina, e vale la pena dirlo per non lasciare aspettative sbagliate: su una GPU sono decine di secondi, su una CPU normale si va sui minuti (su questa macchina, quattro core e nient’altro di speciale, sette minuti, con un \(97{,}5\%\) alla quinta epoca).

Studiare e dare l’esame: train() ed eval()#

Nel programma compaiono due chiamate su cui vale la pena fermarsi: model.train() e model.eval(), con il blocco torch.no_grad().

La rete ha due modalità, come uno studente. Quando studia (model.train()) può usare trucchi che servono solo a imparare meglio, per esempio coprirsi a caso qualche appunto per non adagiarsi (il dropout, che vedremo nel capitolo sul deep learning). Quando dà l’esame (model.eval()) i trucchi si spengono: risponde e basta, al meglio di quel che sa. E torch.no_grad() dice al registratore dei gradienti di spegnersi: durante l’esame non si prende appunti per migliorare, si risponde soltanto, e senza il registratore acceso tutto è più veloce e leggero.

train()/eval() commutano un flag che cambia il comportamento dei moduli «a doppia personalità»: nn.Dropout (attivo solo in training) e le nn.BatchNorm1d/2d/3d (statistiche del batch in training, medie mobili in valutazione) sono i due casi principali. Di che cosa facciano davvero, e perché aiutino, si occupa il capitolo sul deep learning in ottimizzazione e regolarizzazione; qui serve solo sapere che hanno due comportamenti e che l’interruttore è questo. Attenzione a come si dice, perché la formulazione sbrigativa («eval() spegne dropout e batch norm») è falsa per la seconda: in eval() il dropout diventa davvero l’identità, la batch norm invece continua a normalizzare, solo che usa le medie mobili accumulate invece delle statistiche del batch corrente. Misurato su torch 2.13: un nn.BatchNorm1d(3) allenato su dati centrati attorno a \(10\) e poi messo in eval() restituisce uscite di media \(\approx 0\) a fronte di ingressi di media \(\approx 10\). La differenza non è terminologica: chi crede che eval() disattivi la batch norm non capisce perché un modello valutato con un batch da un solo esempio funzioni benissimo in eval() (le medie mobili non dipendono dal batch) e in train() invece non parta affatto. Non con un nan, come si legge spesso: con un’eccezione esplicita, ValueError: Expected more than 1 value per channel when training, che una nn.BatchNorm1d(3) solleva su un ingresso di forma \((1, 3)\). Il messaggio è più utile della leggenda, perché chi lo incontra riconosce il caso senza doverlo dedurre: con un esempio solo la varianza per canale non è stimabile, e la libreria preferisce fermarsi piuttosto che normalizzare per qualcosa che non ha calcolato. Dove invece i valori per canale sono più di uno il conto si fa e nan non ne esce: un ingresso \((1, 3, 5)\), o l’immagine \((1, 3, 4, 4)\) di una nn.BatchNorm2d, passano senza storie, e su un ingresso costante l’uscita non è nan: è zero, o un residuo minuscolo di arrotondamento, perché l’\(\varepsilon\) che si somma al denominatore impedisce che si divida zero per zero. torch.no_grad() è un context manager che sospende la costruzione del grafo autograd: non vengono salvati i valori intermedi per un backward() che non arriverà mai, con un risparmio di memoria che cresce con la profondità della rete, e l’inferenza accelera. Sono due meccanismi indipendenti e servono entrambi: eval() senza no_grad() dà predizioni corrette ma spreca memoria; no_grad() senza eval() lascia il dropout acceso e falsa le predizioni. Il nostro MLP non ha né dropout né batch norm, quindi qui eval() è tecnicamente superfluo, ma scriverlo sempre è un’abitudine che evita bug sottili appena il modello cresce. Quando si fa soltanto inferenza esiste una forma più stretta di no_grad(), torch.inference_mode(), che oltre a non registrare rinuncia anche al version counter e al tracciamento delle viste: è leggermente più veloce, al prezzo che i tensori che produce non possono poi rientrare in un grafo autograd.

Quando fermarsi: la validazione#

Il numero stampato a fine epoca merita rispetto: è la differenza tra imparare e imparare a memoria.

Prima però va sistemata una parola, perché da qui in avanti ne compaiono due dove finora ce n’era una. Nel programma qui sopra i mucchi di dati sono due, addestramento e test, e a fine epoca abbiamo guardato il test. Facendo così, però, il test smette di essere quello che deve essere: se lo guardo a ogni epoca e in base a quel numero decido quando fermarmi o che cosa cambiare, allora quelle immagini hanno partecipato alle mie decisioni, e il voto che mi danno non è più il voto di uno che non le aveva mai viste.

Per questo i mucchi in un progetto serio sono tre. L’addestramento è quello su cui il modello impara. La validazione è quello che si guarda spesso, a ogni epoca, per decidere: è la simulazione d’esame, e la si può consumare senza danno perché serve proprio a quello. Il test è quello che si tocca una volta sola, alla fine, e che dà il voto vero. Nel programma qui sopra ne abbiamo usati due per non appesantire il codice, ed è una scorciatoia comune negli esempi: fuori dagli esempi, il terzo mucchio si ritaglia.

Due curve di perdita in funzione delle epoche. La curva di addestramento scende con continuità; quella di validazione scende, tocca un minimo e poi risale. Una linea tratteggiata verticale segna il punto di arresto anticipato in corrispondenza del minimo della validazione.

Fig. 6.8 La perdita di addestramento scende sempre; quella di validazione tocca un minimo e poi risale. Da lì in poi il modello memorizza il rumore: la linea dell’arresto anticipato marca il momento giusto per fermarsi.#

Guarda le due curve in Fig. 6.8. Attenzione al verso: qui in verticale c’è l’errore, quindi scendere è migliorare. La curva dell’addestramento è come i compiti fatti a casa: l’errore cala sempre, perché il modello rivede gli stessi esercizi. Quella della validazione è la simulazione d’esame con domande nuove. All’inizio scendono insieme, ed è buon segno. Poi quella della validazione tocca il fondo e ricomincia a salire, mentre quella dell’addestramento continua a scendere: da lì in avanti il modello non sta più imparando, sta imparando a memoria, ed è l’overfitting incontrato nel capitolo sul machine learning. La mossa giusta è fermarsi nel punto più basso della validazione, e tenere da parte la copia del modello salvata in quel momento.

Nel programma di poco fa niente di tutto questo c’è: cinque epoche e via, perché su MNIST cinque epoche non bastano a mandare a memoria sessantamila immagini. Aggiungerlo però costa poco, ed è un if: a ogni epoca si guarda il numero della validazione, se è il migliore finora si salva una copia del modello, e se non migliora per un po’ di epoche di fila si esce dal ciclo. Come si salva una copia è l’argomento delle prossime righe.

Nel loop esplicito la diagnosi si scrive da sé: si ritaglia un set di validazione (ad esempio con torch.utils.data.random_split(train_data, [55000, 5000])), a fine epoca si misura \(\mathcal{L}_{\text{val}}\), e l’early stopping è un if: se la validazione non migliora da \(k\) epoche (la patience), si esce dal ciclo e si ricaricano i pesi dell’epoca migliore, salvati via via con torch.save. Ciò che Keras offriva come callback preconfezionate, in PyTorch sono sei righe di controllo di flusso, in cambio, nessun limite: fermarsi su una metrica composta o salvare solo a condizioni particolari sono varianti banali dello stesso if. Riprendere da checkpoint no, ed è la trappola della sezione seguente: vuole anche lo stato dell’ottimizzatore. Il divario \(\mathcal{L}_{\text{val}} - \mathcal{L}_{\text{train}}\) resta la bussola: se si allarga, servono i freni (regolarizzazione L2 via weight_decay dell’ottimizzatore, nn.Dropout) che approfondiremo nel capitolo sul deep learning.

Salvare il lavoro: lo state_dict#

Un modello addestrato va messo al sicuro. In PyTorch non si salva l’oggetto modello: si salva il suo state_dict, cioè l’elenco di tutti i suoi numeri con accanto il nome del pezzo a cui appartengono (in Python un elenco fatto così, dove a ogni nome corrisponde una cosa, si chiama dizionario). Dentro non ci sono solo i pesi imparati: ci sono anche i buffer, cioè i numeri che il modello si tiene da parte senza impararli, perché li ha misurati sui dati invece di ricavarli dall’errore. Ne incontreremo un caso nel capitolo sul deep learning; qui basta sapere che nel file finiscono anche quelli.

Il motivo per cui non si salva l’oggetto intero è pratico. Salvare l’oggetto significherebbe mettere nel file anche la classe Python che lo descrive, e quella classe sta nel tuo codice, che intanto cambia: fra sei mesi il file proverebbe a ricostruire una classe che non esiste più con quel nome, o che esiste con un forward diverso. Salvando solo i numeri, il file resta leggibile finché sai ricostruire l’architettura, e l’architettura è scritta nel codice, dove si può leggere e correggere.

torch.save(model.state_dict(), "mnist_mlp.pt")     # salva i numeri

model2 = nn.Sequential(                            # stessa architettura...
    nn.Flatten(), nn.Linear(28 * 28, 128), nn.ReLU(), nn.Linear(128, 10)
)
model2.load_state_dict(torch.load("mnist_mlp.pt")) # ...numeri ricaricati
model2.eval()                                      # pronto per l'uso

Il codice che definisce l’architettura resta la fonte di verità; il file .pt contiene solo i numeri. È una divisione dei compiti coerente con tutto il capitolo (il modello è codice, i pesi sono dati) ed è il formato in cui circolano i modelli pre-addestrati che riutilizzeremo nel capitolo sulla visione artificiale, quando un modello nato per un compito verrà rifinito (fine-tuning) su un altro.

C’è però una distinzione da fare subito, perché costa una riga farla e giorni scoprirla dopo: salvare per usare e salvare per riprendere non sono la stessa cosa.

Il file con i soli pesi serve a usare il modello: lo ricarichi, gli dai un’immagine, ti risponde. Non serve a riprendere l’addestramento dal punto in cui l’avevi interrotto.

La ragione è che l’ottimizzatore, mentre corregge i pesi, si costruisce una memoria di come si sono mossi finora: è proprio quella memoria che gli permette di dare a ciascun peso il passo giusto, e in particolare di accorciarlo man mano che ci si avvicina. Ricaricare i pesi e ripartire con un ottimizzatore appena creato è come rimettere qualcuno alla guida nel punto esatto in cui lo avevi lasciato, ma senza dirgli che sta arrivando in curva: la posizione è giusta, la velocità no, ed è troppa.

Misurato, e la direzione è quella che sorprende: dopo una ripresa fatta così il primo passo non è timido, è il più lungo che quella manopola consenta, perché un ottimizzatore appena nato non ha ancora nessun motivo per moderarsi. La corsa non interrotta, alla stessa altezza, ne avrebbe fatto uno molto più corto. Di quanto più corto dipende dal problema; che sia più corto, sempre.

Il rimedio costa una riga: nel file si mette anche lo stato dell’ottimizzatore, e al ritorno lo si ricarica.

Con SGD nudo la questione è marginale; con optim.Adam, che è il default raccomandato all’inizio della sezione, i momenti \(m\) e \(v\) sono stato, e ripartire senza di essi non riprende la stessa traiettoria. La parte strutturale, quella che vale su qualunque problema, è questa: la correzione del bias riparte da \(t = 1\), e a \(t = 1\) il rapporto \(\hat{m}/(\sqrt{\hat{v}} + \varepsilon)\) vale \(\pm 1\) per costruzione, quindi il primo aggiornamento è \(\eta\) pieno, il passo più lungo che quella manopola consenta. Qui \(\hat{m}\) e \(\hat{v}\) sono i due momenti corretti per il bias (\(m\) e \(v\) divisi per \(1-\beta_1^t\) e \(1-\beta_2^t\)) ed \(\varepsilon\) è il termine minuscolo che evita la divisione per zero: a \(t = 1\) quelle correzioni danno \(\hat{m} = g\) e \(\hat{v} = g^2\), con \(g\) il gradiente, da cui il rapporto \(\pm 1\). Ricaricando lo stato, invece, il passo coincide esattamente con quello della traiettoria mai interrotta.

Di quanto sia più lungo dipende dal problema, e quindi va detto su quale è misurato e come. Il problema: una quadratica \(\mathcal{L}(\theta) = \frac{1}{2}\|\theta\|^2\) con cento parametri inizializzati da una normale standard, venti passi di Adam con \(\eta = 0{,}1\), poi la ripresa. La misura: la media quadratica dello spostamento sui cento parametri, al primo passo dopo la ripresa. Viene \(0{,}100\) senza lo stato dell’ottimizzatore (cioè esattamente \(\eta\), come previsto) contro \(0{,}0294\) ricaricandolo (torch 2.13). Un fattore \(3{,}4\) qui, un fattore \(2{,}4\) sulla stessa quadratica con un parametro solo, e nessun messaggio d’errore in nessuno dei due casi.

Lo stesso vale per tutto ciò che ha uno state_dict e che il ciclo tocca: lo scheduler del learning rate, il GradScaler della precisione mista, il sampler distribuito. Un checkpoint completo è un dizionario, non un tensore.

Ecco la forma minima, quella che si scrive una volta e si copia in ogni progetto:

from torch import optim

optimizer = optim.Adam(model.parameters(), lr=1e-3)

# checkpoint per RIPRENDERE: i pesi da soli non bastano
torch.save({"epoca": 5,
            "modello": model.state_dict(),
            "ottimizzatore": optimizer.state_dict()}, "checkpoint.pt")

stato = torch.load("checkpoint.pt")
model.load_state_dict(stato["modello"])
optimizer.load_state_dict(stato["ottimizzatore"])   # la riga che si dimentica
print(f"ripresa dall'epoca {stato['epoca']}; lo stato dell'ottimizzatore "
      f"ha le chiavi {list(stato['ottimizzatore'])}")
# ripresa dall'epoca 5; lo stato dell'ottimizzatore ha le chiavi ['state', 'param_groups']

Le due chiavi stampate dicono che cosa c’era da salvare: in state c’è la memoria accumulata su ciascun peso, in param_groups ci sono le impostazioni dell’ottimizzatore, il learning rate per primo. Senza state, la ripresa riparte senza memoria.

La sezione dal notebook agli script riprende questo file e ci aggiunge le altre due cose che servono a rileggerlo fra sei mesi: i nomi delle classi e la configurazione dell’esperimento.

Da ricordare

  • Il giro di addestramento ha cinque passi fissi, sempre nello stesso ordine: prevedi, misura l’errore, butta gli appunti del giro prima, capisci in che direzione hai sbagliato, correggi. Il terzo passo serve perché altrimenti gli appunti si sommano.

  • Gli esempi arrivano in mucchietti (i mini-batch): la dispensa (Dataset) sa consegnarli uno per uno, il cameriere (DataLoader) li mescola e li porta in tavola a vassoi.

  • Quando il modello dà l’esame si aziona model.eval(), e si aggiunge torch.no_grad() per non prendere appunti inutili. Sono due interruttori diversi e servono tutti e due.

  • Le due curve, quella dell’addestramento e quella della simulazione d’esame, dicono quando è ora di fermarsi: quando la seconda smette di migliorare.

  • Del modello si salvano i numeri, non l’oggetto: l’architettura sta nel codice. E per riprendere l’addestramento dove si era interrotto serve anche la memoria dell’ottimizzatore, non solo i pesi.

Da ricordare

  • Il training loop ha cinque passi fissi: forward → loss → zero_grad()backward()step(). Il terzo serve perché i gradienti si accumulano, e non li azzera: li toglie (p.grad torna a None). Nell’accumulo dei gradienti si fa una volta ogni \(k\) micro-batch, ed è l’unica eccezione all’ordine.

  • Dataset consegna gli esempi, DataLoader li rimescola e li impila in mini-batch: il gradiente sul batch è una stima rumorosa ma economica di quello vero.

  • In valutazione: model.eval() spegne il dropout e passa la batch norm alle medie mobili (non la spegne: continua a normalizzare); torch.no_grad() sospende autograd. Servono entrambi.

  • Le curve di training e validazione diagnosticano l’overfitting; l’early stopping in PyTorch è un semplice if nel loop.

  • Si salva lo state_dict (torch.save/load_state_dict), non l’oggetto: contiene parametri e buffer. Per riprendere servono anche ottimizzatore.state_dict() e l’epoca; senza, con Adam il primo passo dopo la ripresa è quello di un ottimizzatore appena nato.