Moduli: costruire il modello#
Verso la metà degli anni Novanta, Yann LeCun e i suoi colleghi ai Bell Labs presero decine di migliaia di cifre scritte a mano (raccolte su formulari cartacei da impiegati dell’ufficio del censimento statunitense e da studenti delle scuole superiori) le normalizzarono e le centrarono in quadratini di \(28\times 28\) pixel. Ne nacque MNIST (Modified NIST): 70.000 immagini in scala di grigi, ciascuna una cifra da \(0\) a \(9\), divise in 60.000 esempi di addestramento e 10.000 di test [LBBH98]. Da allora MNIST è il «Hello, world!» del deep learning: piccolo abbastanza da addestrarsi in pochi secondi, ricco abbastanza da mostrare tutto il ciclo di vita di un modello. Qui costruiamo il modello che leggerà quelle cifre; addestrarlo viene subito dopo.
nn.Module: il mattone di ogni rete#
In PyTorch qualunque pezzo di rete (un singolo strato, un blocco, il modello
intero) è un modulo, cioè una classe che eredita da nn.Module. (Il
nn che si incontrerà in ogni riga di questo capitolo sta per neural
networks: è la parte di PyTorch che contiene i pezzi con cui si montano le
reti.) È la scelta di design più caratteristica della libreria: il modello non
si descrive in un elenco a parte da consegnare alla libreria, si scrive come
una normale classe Python.
Le classi le abbiamo viste nella sezione sulle basi di Python con l’immagine dello stampo per biscotti: una classe è
lo stampo, l’oggetto è il biscotto. Ereditare vuol dire partire da uno
stampo che esiste già e aggiungergli qualcosa invece
di intagliarne uno da zero: il nuovo stampo sa fare tutto quello che sapeva
fare il vecchio, più ciò che gli abbiamo aggiunto. Nel codice l’eredità si
scrive mettendo il nome dello stampo di partenza fra parentesi,
class MLP(nn.Module):. E la prima riga di __init__ (che è il metodo
eseguito quando l’oggetto viene creato, quello che lo mette insieme: si chiama
costruttore) è super().__init__(), la chiamata con cui lo stampo vecchio
si prepara prima che noi ci aggiungiamo il nostro. Va scritta sempre, ed è la
ragione per cui la
si ritroverà, identica, in ogni modello del capitolo. Ciò che si eredita da
nn.Module è molto: tenere il conto di tutti i pesi sparsi nella rete,
spostarli tutti insieme sulla scheda grafica, salvarli su un file,
e accendere su ciascuno il registratore di autograd. Sono tutte cose che
nessuno ha voglia di riscrivere ogni volta.
Ecco il modello per MNIST, intero; le righe che contano sono i due metodi, e li smontiamo subito sotto.
import torch
from torch import nn
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.flatten = nn.Flatten() # da griglia 28x28 a vettore 784
self.hidden = nn.Linear(28 * 28, 128) # ogni ingresso collegato a ogni neurone
self.out = nn.Linear(128, 10) # 10 uscite: una per cifra 0-9
def forward(self, x):
x = self.flatten(x)
x = torch.relu(self.hidden(x)) # ReLU: i numeri negativi diventano zero
return self.out(x) # punteggi grezzi, non probabilita'
model = MLP()
print(model) # elenca i pezzi che compongono il modello
# MLP(
# (flatten): Flatten(start_dim=1, end_dim=-1)
# (hidden): Linear(in_features=784, out_features=128, bias=True)
# (out): Linear(in_features=128, out_features=10, bias=True)
# )
Due metodi, due domande. In __init__ si dice di quali pezzi è fatta la rete:
qui uno strato che srotola l’immagine, uno nascosto da 128 neuroni e uno
d’uscita da 10. I 784 in ingresso sono obbligati (\(28 \times 28\), i pixel
dell’immagine) e i 10 in uscita pure (le cifre da 0 a 9); il 128 nel mezzo no,
l’abbiamo scelto noi. Un valore più grande dà una rete più capiente e più
lenta, uno più piccolo il contrario: si prova, e come si sceglie è l’argomento
della sezione sul flusso di lavoro.
Scrivere un pezzo come self.qualcosa lo fa entrare da solo nell’inventario
della rete. È quell’inventario che l’addestramento andrà a regolare, e basta
una riga per salvarlo tutto su un file o per spostarlo tutto insieme sulla
scheda grafica. Un pezzo tenuto da parte in una lista normale invece
funziona benissimo quando i dati ci passano attraverso, ma nell’inventario non
compare: nessuno lo addestra, nessuno lo salva, e resta com’era appena creato.
E il giorno in cui la rete trasloca sulla scheda grafica quel pezzo resta
indietro, perché a spostare l’inventario ci pensa una riga sola e lui
nell’inventario non c’è: lì il guasto smette di essere silenzioso e il
programma si ferma.
In forward si dice che strada fanno i dati: entra l’immagine, viene
srotolata, passa per lo strato nascosto e poi per la ReLU (che è un filtro
semplicissimo: lascia passare i numeri positivi e schiaccia a zero i
negativi), ed esce come 10 punteggi, uno per cifra. Dentro lo strato non
succede niente di misterioso: ogni neurone guarda tutti i numeri che gli
arrivano, li somma dopo aver moltiplicato ciascuno per un numero suo, e al
totale ne aggiunge un altro. Il filtro non sta dentro lo strato: lo si applica
a parte, ai numeri che dallo strato escono. Chi arriva da altre librerie se lo
aspetta appiccicato allo strato, e qui deve mettercelo lui.
Tutto il resto (tenere il conto dei pesi, calcolare i gradienti) lo fa
nn.Module. E siccome forward è normale Python, ci si può mettere un
print per sbirciare o un if per cambiare strada: il modello è codice che
gira, non una descrizione da consegnare a qualcun altro. forward però non lo
chiama mai nessuno per nome: scrivere model(x), con le parentesi attaccate
all’oggetto come si farebbe con una funzione, è il modo di farlo partire, e
non model.forward(x). Le due righe
sembrano la stessa cosa e danno lo stesso risultato, ma la seconda salta i
controlli che la libreria aggancia intorno al passaggio; non arriva nessun
errore, e la differenza si scopre più tardi, quando uno strumento che si
appoggiava a quei controlli resta muto.
nn.Module fornisce la contabilità dei parametri: ogni attributo che sia
a sua volta un modulo (o un nn.Parameter) viene registrato automaticamente,
e model.parameters() restituisce l’iteratore su tutti i parametri
registrati, anche su quelli con requires_grad=False (è quello che passeremo
all’ottimizzatore). Ciò che invece finisce in una lista Python ordinaria non
viene registrato: il forward lo usa lo stesso, ma resta fuori da
parameters() e dallo state_dict(). nn.Linear(d, u) realizza la
trasformazione affine
dove \(\mathbf{x}\) sono i \(d\) numeri che entrano e \(\mathbf{h}\) i \(u\) che
escono, scritti come colonne, con \(\mathbf{W} \in \mathbb{R}^{u \times d}\) e
\(\mathbf{b} \in \mathbb{R}^{u}\)
creati con requires_grad=True: autograd li traccia senza che si debba fare
nulla. Su un batch la libreria lavora nell’altra forma, quella con una riga
per esempio: dato \(\mathbf{X} \in \mathbb{R}^{N \times d}\) calcola
\(\mathbf{X}\mathbf{W}^\top + \mathbf{b}\), che è la stessa trasformazione
trasposta, e .weight conserva la forma \(u \times d\).
Si noti che l’attivazione non è «dentro» lo strato, come accade in altre
librerie: è una funzione (torch.relu) o un modulo (nn.ReLU) applicato
esplicitamente in forward; coerente con la filosofia «il modello è il
codice». La chiamata model(x) invoca forward attraverso __call__, che
aggiunge gli hook di libreria: per questo non si chiama mai
model.forward(x) direttamente.
La scorciatoia: nn.Sequential#
Quando la rete è una semplice catena (l’uscita di uno strato entra nel successivo, senza rami), la classe si può evitare del tutto:
model = nn.Sequential(
nn.Flatten(),
nn.Linear(28 * 28, 128),
nn.ReLU(),
nn.Linear(128, 10),
)
Un autolavaggio a tunnel: l’auto entra da un capo, incontra il prelavaggio, le
spazzole, il risciacquo, la cera e l’asciugatura, sempre in quest’ordine, e ne
esce dall’altro capo. nn.Sequential descrive la rete esattamente così:
elenchi i passaggi nell’ordine in cui il dato li attraversa, e i collegamenti
si fanno da soli. Nota che qui anche la ReLU è un passaggio del tunnel
(nn.ReLU()): l’asciugatura non lava niente, ma sta in fila come tutti gli
altri passaggi, e senza di lei quello che esce non è la stessa cosa. È la
stessa ReLU di prima in un’altra veste: torch.relu è il verbo, da scrivere
dentro forward, nn.ReLU() è il pezzo, da mettere in fila.
Quale delle due scritture usare? Questa, finché la rete è una fila. Si torna
alla classe il giorno in cui la fila non basta più, cioè quando il dato deve
prendere due strade e ricongiungersi dopo, o saltare un passaggio: un tunnel a
corsia unica non lo sa fare, e un elenco non lo sa scrivere; in forward sì,
perché lì sono normali variabili Python.
nn.Sequential modella una funzione composta
\(f = f_L \circ \dots \circ f_2 \circ f_1\), dove la lista ne fissa l’ordine di
composizione. È adatta a topologie lineari (un ingresso, un’uscita, nessuna
ramificazione); per più input, skip connection o rami paralleli (come le
ResNet che incontreremo nel capitolo sul deep learning) si torna a nn.Module
con un forward esplicito, dove le ramificazioni sono semplici variabili
Python. È la differenza chiave rispetto alle API dichiarative: non serve
un’»API funzionale» separata, perché la composizione arbitraria è già Python.
Per MNIST la pila lineare basta e avanza.
Quanti parametri ha questa rete?#
I parametri sono i numeri che il modello impara, quelli che l’addestramento regolerà: pesi e bias tutti insieme, cioè le manopole di cui si è parlato nella sezione sui tensori. Non tutti i pezzi ne hanno: quello che srotola l’immagine sposta i numeri e non li cambia, quindi di manopole non ne porta nessuna. Contarli è il primo controllo da fare su qualunque modello, prima ancora di addestrarlo: se il numero non è quello che ci si aspetta, la rete montata non è quella che si aveva in mente.
Ogni collegamento tra un ingresso e un neurone ha il suo peso, più un piccolo termine di aggiustamento (il bias) per neurone. Lo strato nascosto collega 784 ingressi a 128 neuroni: \(784 \times 128 + 128 = 100\,480\) numeri da imparare. Lo strato d’uscita: \(128 \times 10 + 10 = 1\,290\). In tutto \(101\,770\) manopole che l’addestramento dovrà regolare, tante, ma una rete moderna ne ha miliardi: MNIST è davvero una palestra in miniatura.
Per nn.Linear(d, u) i parametri sono \(u \cdot d + u\). Verifichiamolo:
n_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(n_params) # 101770
p.numel() conta gli elementi di ciascun tensore; il filtro su
requires_grad esclude eventuali parti congelate (tornerà utile nel
transfer learning). La pila per MNIST è riassunta in
Fig. 6.5: \(784 \cdot 128 + 128 = 100\,480\) per lo strato
nascosto, \(128 \cdot 10 + 10 = 1\,290\) per l’uscita.
Fig. 6.5 Il percettrone multistrato per MNIST: l’immagine viene srotolata in 784 numeri, compressa a 128, infine proiettata su 10 punteggi grezzi, uno per cifra. La trasformazione di quei punteggi in probabilità (si chiama softmax) non sta nel modello: ce l’ha dentro la funzione di perdita, che li riceve grezzi.#
Misurare l’errore: le funzioni di perdita#
Il modello ora esiste, ma è ignorante: i pesi sono numeri casuali. Per
addestrarlo serve prima di tutto un modo di misurare quanto sbaglia: la
funzione di perdita, o loss. Anche queste torch.nn le offre come moduli
pronti, e la parola vale per loro come per gli strati: non perché misurare
l’errore sia un pezzo di rete, ma perché si costruiscono, si spostano e si
chiamano allo stesso modo. Le due che useremo più spesso coprono i due grandi
casi: quando la risposta giusta è un numero, e quando è una scelta fra
categorie.
loss_regressione = nn.MSELoss() # per predire numeri continui
loss_classi = nn.CrossEntropyLoss() # per scegliere tra classi
# esempio: 2 immagini finte date in pasto al modello ancora ignorante.
# (2, 1, 28, 28) = 2 immagini, 1 canale (MNIST e' in scala di grigi), 28x28 pixel
logits = model(torch.randn(2, 1, 28, 28)) # shape (2, 10): 10 punteggi per immagine
target = torch.tensor([3, 7]) # le cifre vere sono un 3 e un 7
errore = loss_classi(logits, target) # un numero solo: la loss media
print(errore.item()) # circa 2,3 (con due sole immagini balla)
Quel \(2{,}3\) non è un numero qualunque, ed è il metro con cui leggeremo tutte le loss di questo capitolo. È il logaritmo naturale di dieci, \(\ln 10 = 2{,}3026\), cioè quanto vale la cross-entropy per chi dà a ciascuna delle dieci cifre la stessa probabilità, una su dieci. Un modello appena creato sta un pelo più su, perché i pesi sorteggiati gli fanno già preferire qualcuna delle cifre, e da dove parte davvero lo misura la sezione sull’addestramento. Un addestramento che funziona scende da lì; uno che resta lassù non ha imparato niente. (Su due sole immagini il numero balla parecchio, fra \(1{,}8\) e \(2{,}9\), perché a sorteggio non ci sono soltanto i pesi: ci sono anche le due immagini.)
Un perito passa la mattina in due appartamenti e su ogni scheda scrive tre numeri, il prezzo, le spese annue e i giorni che ci vorranno a vendere. Mesi dopo si sa com’è andata, e l’agenzia gli manda il conto: ogni numero mancato si paga al quadrato. Sbagliare di \(2\) costa \(4\), sbagliare di \(10\) costa \(100\). Un errore cinque volte più grande non ne vale cinque, ne vale venticinque. È la MSE (errore quadratico medio), la misura per quando la risposta è un numero, un prezzo o la temperatura di domani.
Sulle due schede ci sono sei numeri, quindi sei multe. Sbagliati tutti di \(2\), sono sei multe da \(4\) e la media è \(4\), perché l’agenzia divide per le multe uscite e non per gli appartamenti visitati. Un ufficio che invece somma le tre multe di ogni scheda, dodici per scheda, e divide per le due schede, arriva a ventiquattro diviso due, cioè a \(12\): tre volte tanto, tante volte quanti sono i numeri chiesti per appartamento, sugli stessi identici errori. Con un numero solo per scheda i due conti coincidono e la differenza non si vede. Il perito più bravo resta il più bravo in tutti e due i casi; cambia quanto pesa la multa, cioè quanto lo spinge a correggere il tiro. Chi aveva tarato la lunghezza del passo dell’addestramento su un conto e passa all’altro se la ritrova tre volte più lunga, o tre volte più corta, secondo il verso in cui ha cambiato.
Un piano più sotto un’impiegata legge le cifre scritte a mano sui formulari. Invece di scommettere tutto su una cifra sola, distribuisce la fiducia su tutte e dieci, e paga secondo quanta ne aveva data a quella vera. Alla cifra vera il 90%, e la multa è \(0{,}11\); il 10%, cioè fiducia in parti uguali su tutte e dieci, tirando a indovinare, e la multa è \(2{,}3\); l’1%, e la multa è \(4{,}6\). Guarda gli ultimi due: dal 10% all’1% la fiducia si divide per dieci e la multa raddoppia esatta, e ogni ulteriore divisione per dieci aggiunge sempre quegli stessi \(2{,}3\). A contare è il rapporto fra le fiducie e non il loro divario, e verso il basso la multa non trova fondo: escludere ancora un po” la risposta vera costa altri \(2{,}3\), e nessuno dice basta. È la cross-entropy, la misura per quando la risposta è una scelta fra categorie, quale cifra o quale animale. Di multa ce n’è una per formulario, e con dieci formulari in una volta esce la media di quelle dieci.
Sul foglio l’impiegata scrive punteggi grezzi, un \(3\) marcato e un \(8\) debole.
A farne percentuali ci pensa la cassa, nello stesso momento in cui calcola la
multa, e i due conti insieme escono più precisi che uno dopo l’altro, perché
con percentuali piccolissime il secondo passaggio perde cifre per strada. Anche
nn.CrossEntropyLoss vuole i punteggi grezzi (i logit, il nome tecnico di
quei numeri prima che diventino probabilità) e la trasformazione la fa lei, al
suo interno. Nel modello la softmax non ci va, e metterla è uno degli errori
silenziosi più comuni. Se allo sportello serve dire quanto l’impiegata è
sicura, le percentuali si ricavano dai punteggi in un passaggio a parte, che
serve a leggere il risultato e non ad addestrare.
Per la regressione, nn.MSELoss calcola
dove \(i\) scorre gli \(N\) esempi del batch e \(k\) le \(D\) uscite di ciascun
esempio: la media è su tutti gli elementi del tensore, non sugli esempi.
Quando l’uscita è una sola le due letture coincidono e la distinzione non si
vede; in regressione multi-uscita no. Chi somma i quadrati di un esempio e poi
media sugli esempi ottiene un numero \(D\) volte più grande di quello che
restituisce il modulo. Il punto di minimo è lo stesso, la scala del gradiente
no, e con essa il learning rate che serviva. Per la classificazione a \(K\)
classi, nn.CrossEntropyLoss combina in un solo modulo LogSoftmax e
NLLLoss: dati i logit \(z_1, \dots, z_K\) e la classe vera \(c\) di un singolo
esempio,
dove qui \(k\) e \(j\) scorrono le \(K\) classi, non gli esempi, e \(\ell\) è il costo
di una predizione, quello che sta dentro la somma. Sul batch il modulo
restituisce la \(\mathcal{L}\), cioè la media di questi termini sugli \(N\)
esempi (reduction='mean', il default): è il «numero solo» del codice, e qui
la media è davvero per esempio, perché di termini ce n’è uno per esempio.
Applicarla ai logit, e non a probabilità già normalizzate, non è un
capriccio: il calcolo congiunto del logaritmo e della softmax è numericamente
più stabile (evita underflow con il log-sum-exp trick), e per questo
l’ultimo strato del modello non deve avere la softmax. Se servono le
probabilità (per leggere l’output, non per addestrare), si applica
torch.softmax(logits, dim=1) a valle. Con etichette intere il target ha
shape \((N,)\) e dtype int64, non serve il one-hot.
Le due penalità che abbiamo appena visto in cifre hanno anche una forma, e metterle una accanto all’altra dice in un colpo d’occhio quello che i numeri dicono uno alla volta (Fig. 6.6). Attenzione a come si legge: sono due disegni distinti, con due cose diverse sull’asse orizzontale, e non due curve sovrapposte. A sinistra scorre l’errore, cioè di quanto la predizione ha mancato il valore vero; a destra scorre la fiducia che il modello ha dato alla risposta giusta, da zero (l’ha esclusa) a uno (ne era certo). In verticale, in tutti e due, la penalità.
Fig. 6.6 Due disegni, due assi orizzontali diversi, due caratteri. La parabola perdona gli errori piccoli; la cross-entropy non perdona la sicurezza sbagliata, e cresce senza limite man mano che il modello esclude la risposta giusta.#
È il comportamento agli estremi, e non altro, la ragione per cui in classificazione si sceglie la seconda. Lì ciò che deve fare male è essere convinti del contrario, più che sbagliare di poco: la cross-entropy è costruita esattamente per questo.
Il modello esiste e sa dire quanto sbaglia. Manca chi usa quel numero per correggerlo, ed è l’argomento della sezione sull’addestramento.
Da ricordare
Ogni pezzo di rete è un modulo: in
__init__si elencano i componenti, inforwardsi dice che strada fanno i dati. È normale codice Python, quindi ci si può mettere unprintper sbirciare.nn.Sequentialè la scorciatoia quando la rete è una catena di montaggio; se ci sono rami o scorciatoie, si torna a scrivereforwarda mano.Uno strato che collega ogni ingresso a ogni neurone ha un peso per collegamento più un aggiustamento per neurone: ingressi per neuroni, più i neuroni. Contarli è il primo controllo da fare su qualunque modello, e costa una moltiplicazione per strato.
La funzione di perdita misura quanto il modello sbaglia:
nn.MSELossquando la risposta è un numero,nn.CrossEntropyLossquando è una scelta fra categorie. A quest’ultima si danno i punteggi grezzi, non le probabilità: la trasformazione la fa lei.
Da ricordare
Ogni pezzo di rete è un
nn.Module: in__init__i componenti, inforwardla strada dei dati (normale Python, ispezionabile riga per riga).nn.Sequentialè la scorciatoia per le catene semplici; per topologie con rami si scrive ilforwarda mano.nn.Linear(d, u)calcola \(\mathbf{W}\mathbf{x}+\mathbf{b}\) e ha \(u \cdot d + u\) parametri;model.parameters()li consegna all’ottimizzatore, il componente che nella prossima sezione applicherà le correzioni.Le loss sono moduli:
nn.MSELossper la regressione,nn.CrossEntropyLossper la classificazione; quest’ultima vuole i logit, la softmax ce l’ha dentro.