Dati su misura: Dataset, DataLoader e trasformazioni#
Nei manuali il dataset arriva sempre pronto: una riga di codice e MNIST, la
raccolta di cifre scritte a mano su cui il capitolo ha addestrato la sua prima
rete, si
scarica da solo, con le immagini già quadrate, già etichettate, già divise in
addestramento e test. Nella vita reale il primo giorno di un progetto
assomiglia piuttosto a questo: una cartella con quattromila fotografie, i nomi
dei file scritti da tre persone diverse, due immagini corrotte, una classe con
dodici esempi e un’altra con duemila. Prima di poter scrivere un nn.Module
bisogna costruire il tubo che porta quei file dentro la rete, e in PyTorch
quel tubo si costruisce con due pezzi soltanto, sempre gli stessi.
Il capitolo li ha già incontrati di sfuggita nella sezione
sull’addestramento: Dataset sa consegnare l’esempio
numero \(i\), DataLoader li impila in mini-batch. Qui li costruiamo noi, sui
nostri dati.
La convenzione delle cartelle#
Prima di scrivere codice, conviene sapere che per le immagini esiste una convenzione che risparmia il lavoro: una cartella per classe, e il nome della cartella è l’etichetta.
dati/
├── addestramento/
│ ├── pizza/ img_001.jpg img_002.jpg ...
│ ├── bistecca/ img_331.jpg ...
│ └── sushi/ img_780.jpg ...
└── test/
├── pizza/ ...
├── bistecca/ ...
└── sushi/ ...
Con questa disposizione, torchvision fa tutto da sé:
from torchvision import datasets, transforms
preparazione = transforms.Compose([
transforms.Resize((224, 224)), # tutte le immagini della stessa misura
transforms.ToTensor(), # da immagine a tensore (un canale per
# colore, poi altezza e larghezza) con i
# valori portati fra 0 e 1
])
dati_train = datasets.ImageFolder(root="dati/addestramento", transform=preparazione)
dati_test = datasets.ImageFolder(root="dati/test", transform=preparazione)
print(dati_train.classes) # ['bistecca', 'pizza', 'sushi'] (ordine alfabetico)
print(dati_train.class_to_idx) # {'bistecca': 0, 'pizza': 1, 'sushi': 2}
print(len(dati_train)) # quante immagini in tutto
immagine, etichetta = dati_train[0]
print(immagine.shape, etichetta) # torch.Size([3, 224, 224]) 0
Un dettaglio che sembra burocratico e non lo è: l’associazione classe → numero
segue l’ordine alfabetico delle cartelle, non quello in cui le abbiamo in
testa. Alfabetico per il calcolatore, cioè per codice del carattere, che non è
del tutto quello del vocabolario: le maiuscole vengono prima di tutte le
minuscole e le accentate vanno in fondo. Quando poi si legge una predizione,
dati_train.classes[indice] è il modo giusto di tradurla in una parola,
perché è l’unico che non riscrive quell’ordine. Scrivere a mano una lista di
nomi in un altro ordine è un classico modo di ottenere un modello che sembra
sbagliare tutto mentre invece funziona benissimo.
Scrivere un Dataset a mano#
ImageFolder copre il caso fortunato. Appena i dati stanno in un CSV, in un
database, in file audio con le etichette in un foglio a parte (o appena
servono più informazioni della sola classe), si scrive la propria classe. È
meno lavoro di quanto sembri: si scrivono tre metodi, e due soli di quelli
sono il contratto vero, cioè le domande che PyTorch verrà davvero a farci; il
terzo è il costruttore, che serve a noi per prepararci. (Nella classe base è
obbligatorio il solo __getitem__; __len__ è dichiarato facoltativo, ma lo
pretendono il DataLoader con le sue impostazioni normali e quasi tutti i modi
di pescare, quindi in pratica il contratto è a due.)
Fig. 6.11 Uno stampo di partenza, tre versioni specializzate. Chi consuma i dati non sa da dove vengano: chiede sempre le stesse due cose, e ognuna delle tre versioni risponde a modo suo, leggendo immagini, un foglio di calcolo o dei file audio.#
La Fig. 6.11 mostra il meccanismo che permette al
DataLoader di funzionare con qualunque Dataset senza saperne nulla, ed è
l’ereditarietà incontrata nella sezione sui moduli, usata qui per
un altro scopo. In cima c’è Dataset, lo stampo di PyTorch, che non contiene
quasi niente: dice soltanto quali due domande gli si possono fare. Sotto ci
sono le classi che scriviamo noi, una per tipo di dato, e ciascuna risponde a
quelle due domande a modo proprio. Il guadagno è che il DataLoader non deve conoscerle:
gli basta sapere che qualunque cosa erediti da Dataset sa rispondere. Finché
la nostra classe rispetta il contratto, per il resto di PyTorch è
indistinguibile da ImageFolder, anche se legge un tipo di dato che chi ha
scritto la libreria non aveva previsto.
import pathlib
import torch
from torch.utils.data import Dataset
from PIL import Image
class DatasetImmagini(Dataset):
"""Legge le immagini da cartelle-classe, come ImageFolder, ma è nostro.
La `transform` non è davvero facoltativa: senza, `__getitem__` restituisce
una PIL.Image, e il collate di default non sa impilarla in un batch.
"""
def __init__(self, radice: str, transform=None):
# solo .jpg: ImageFolder invece accetta tutte le estensioni note
self.percorsi = sorted(pathlib.Path(radice).glob("*/*.jpg"))
self.classi = sorted({p.parent.name for p in self.percorsi})
self.classe_a_indice = {c: i for i, c in enumerate(self.classi)}
self.transform = transform
def __len__(self) -> int:
return len(self.percorsi)
def __getitem__(self, indice: int):
percorso = self.percorsi[indice]
immagine = Image.open(percorso).convert("RGB") # 3 canali sempre, anche
# da un bianco e nero
etichetta = self.classe_a_indice[percorso.parent.name]
if self.transform is not None:
immagine = self.transform(immagine)
return immagine, etichetta
Sono tre metodi, ma le domande sono due, ed è la distinzione che la
Fig. 6.11 disegna. Le due domande che il DataLoader
farà per tutto l’addestramento sono quanti esempi hai? (__len__) e dammi
il numero 137 (__getitem__: qui si fa il lavoro veloce, ed è la parte che
verrà eseguita milioni di volte). Il terzo metodo, __init__, nessuno ce lo
chiede: è la nostra preparazione, quella che avviene una volta sola prima di
cominciare, dove si elencano i file o si legge il foglio con le etichette.
La seconda domanda chiede un numero preciso, e questo apre una possibilità. Un magazzino con gli scaffali numerati consegna il 137 senza toccare i centotrentasei che vengono prima, così i pezzi si possono chiedere nell’ordine che si vuole, per esempio in un ordine sorteggiato daccapo a ogni giro: è così che i dati vengono mescolati.
Certi dati però non stanno su uno scaffale, arrivano come un nastro che scorre, e da un nastro si prende quello che passa: chiedere il 137 non significa niente, perché per arrivarci bisogna aver lasciato passare tutti quelli davanti. Chi lavora così mescola come può, tenendo da parte un cesto di qualche centinaio di pezzi e pescando lì dentro, e almeno dentro il cesto l’ordine si mescola davvero.
La regola pratica sta tutta in questa divisione del lavoro: in __init__ le
cose pesanti, in __getitem__ le cose leggere. Se in __init__ carichi in
memoria tutte le immagini, un dataset da 200 GB non parte nemmeno; se in
__getitem__ riapri un file CSV di 300 MB per leggere una riga,
l’addestramento diventa lentissimo, e la GPU, che aspetta i dati, resterà
ferma a girarsi i pollici.
Una cosa in __init__ non ci va comunque: un archivio già aperto, o un
collegamento a una banca dati già stabilito. La preparazione la fa una persona
sola, e le richieste vengono poi smistate a degli aiutanti, cioè a più processi
che leggono i dati in parallelo (li accende il DataLoader, e la sezione che
segue dice quanti). Quello che ciascuno si ritrova in mano non è una chiave, che
copiata funzionerebbe: è il filo di una telefonata cominciata da qualcun altro,
e parlare in quattro sulla stessa linea non va. Il lavoro muore con un errore
che sembra venire da tutt’altra parte. Il collegamento si stabilisce alla prima
richiesta, e lo stabilisce chi quella richiesta la sta servendo.
È il protocollo map-style: una mappa da indice a esempio, che consente
campionamento casuale e quindi shuffle. L’alternativa è IterableDataset
(__iter__), pensata per gli stream (file compressi letti in sequenza, code
di messaggi, dataset che non stanno su disco), dove il campionamento casuale
non è possibile e lo shuffling si approssima con un buffer.
Due cose vanno sapute su __getitem__. La prima: con num_workers > 0 viene
eseguito nei processi worker, non in quello principale (col default,
num_workers=0, tutto resta nel processo principale). Ai worker viene passato
l’oggetto Dataset stesso, che sulle piattaforme ad avvio spawn deve quindi
essere serializzabile (pickle); e un handle già aperto in __init__ e usato
nel __getitem__ (un connettore a database, un file HDF5) è la causa classica
dei crash con num_workers > 0, qualunque sia il modo di avvio. Si apre
pigramente, al primo accesso, dentro il worker. La seconda: deve restituire
tensori (o tipi che il collate di default sa impilare); il default gestisce
tensori, numeri, stringhe, dizionari e tuple annidate, ma pretende che tutti
gli elementi del batch abbiano la stessa forma.
Le trasformazioni: preparare, e moltiplicare#
Una transform è una funzione che riceve un esempio e ne restituisce una
versione modificata. Serve a due scopi diversi, che è bene non confondere:
preparare (portare tutto alla stessa misura, allo stesso intervallo di
valori) e moltiplicare (generare varianti plausibili per rendere il
modello più robusto; la data augmentation, trattata in profondità nel
capitolo sulla visione).
from torchvision import transforms
# ADDESTRAMENTO: prepara e moltiplica
train_tf = transforms.Compose([
transforms.Resize((256, 256)),
transforms.RandomCrop(224), # ritaglio casuale
transforms.RandomHorizontalFlip(p=0.5), # specchiatura casuale
transforms.ColorJitter(brightness=0.2, contrast=0.2),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], # statistiche di ImageNet
std=[0.229, 0.224, 0.225]),
])
# VALUTAZIONE: solo prepara. Nessuna casualità.
test_tf = transforms.Compose([
transforms.Resize(256), # il lato corto a 256, senza
# schiacciare le proporzioni
transforms.CenterCrop(224), # ritaglio deterministico
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225]),
])
All’esame le domande sono uguali per tutti. Specchiare, ritagliare e schiarire le fotografie serve mentre si studia, e insegna al modello che un gatto capovolto è ancora un gatto. Farlo durante la prova vorrebbe dire sorteggiare domande diverse per ogni studente, e un voto così non si confronta con niente, né con quello di ieri né con quello di un altro.
Un professore che racconta com’è andata la verifica non elenca ventidue voti,
dice «due sopra la media» e «uno sotto». I numeri diventano piccoli, e la
differenza fra un compagno e l’altro si vede a colpo d’occhio invece di restare
nascosta dentro cifre che si somigliano tutte. Normalize fa lo stesso ai
colori, e le reti da numeri raccolti attorno allo zero imparano meglio, perché
una correzione della stessa misura conta allora uguale su tutti e tre, invece
di essere enorme per uno e trascurabile per un altro.
La media da sola non basta, e si vede con due materie. In italiano i voti stanno quasi tutti fra 5 e 7, in matematica vanno dal 2 al 10, e un 8 nella prima non è la stessa impresa di un 8 nella seconda. Allora lo scarto dalla media si divide per quanto quei voti si sparpagliano di solito, e lo sparpagliamento si chiama deviazione standard. Con media 6 e sparpagliamento 1 quell’8 diventa 2, con media 6 e sparpagliamento 4 diventa 0,5.
Le materie di Normalize sono i colori. Per il rosso, il verde e il blu tiene
una media e uno sparpagliamento a testa, ed ecco perché i numeri della riga sono
sei, due per colore. Al rosso più acceso, che dopo la conversione vale 1, toglie
0,485 e divide per 0,229: viene circa 2,2. Al nero, che vale 0, viene circa 2,1
sotto zero. Il rosso prima andava da 0 a 1, adesso si distende da 2,1 sotto zero
a 2,2 sopra.
La media di classe si fa sui voti, non sui compiti. Finché sono fogli si possono ricopiare, accorciare, riscrivere, ma nessuno ne fa la media. Ruotare, ritagliare e schiarire si possono fare tanto alla fotografia quanto ai numeri che ne escono; togliere una media e dividere per un numero, invece, si fanno soltanto ai numeri. Ecco perché l’ordine è quello: prima la foto, poi la conversione in numeri, e solo dopo la sottrazione. Scambiando le ultime due il programma si ferma.
I sei numeri vengono da ImageNet, la grande raccolta pubblica di fotografie etichettate su cui, dal 2012 in poi, si è misurata la visione artificiale. Perché la scala di ImageNet su delle foto di pizza? Perché quasi nessuno parte da zero: si prende un modello che ha già studiato là, e lui quella scala se l’aspetta, come uno studente che ha imparato in decimi e a cui conviene continuare a dare i voti in decimi, che in giudizi a lettere non saprebbe più dire quanto è andato bene. Chi parte davvero da zero i sei numeri se li calcola sulle proprie foto, la prima volta che le scorre tutte.
ToTensor() converte una PIL.Image in un tensore float32 con layout
\((C, H, W)\) e valori riscalati in \([0,1]\); Normalize, date media \(\mu\) e
deviazione standard \(\sigma\), applica \(x' = (x - \mu)/\sigma\) canale per
canale. L’ordine conta: la
normalizzazione lavora su tensori, quindi va dopo ToTensor(), mentre le
trasformazioni geometriche e fotometriche lavorano tradizionalmente su PIL e
vanno prima.
Le statistiche giuste sono quelle del dataset su cui il modello è stato
addestrato: se si fa transfer learning da pesi ImageNet si usano quelle di
ImageNet, e la scorciatoia più sicura è chiederle direttamente ai pesi;
torchvision.models.EfficientNet_B0_Weights.DEFAULT.transforms() restituisce
la pipeline esatta con cui quei pesi sono stati prodotti. Da torchvision
0.15 esiste torchvision.transforms.v2, che accetta anche box, maschere e
video insieme all’immagine (necessario per detection e segmentazione, dove la
trasformazione geometrica va applicata coerentemente a immagine ed
etichetta) ed è più veloce sui batch; l’API è retrocompatibile.
Il DataLoader sul serio#
Con un Dataset in mano, il DataLoader aggiunge il resto: batch,
mescolamento, parallelismo.
import os
from torch.utils.data import DataLoader
train_loader = DataLoader(
dati_train,
batch_size=32,
shuffle=True, # rimescola a ogni epoca: solo in addestramento
num_workers=os.cpu_count(), # processi che preparano i batch in parallelo:
# è un punto di partenza, poi si misura
pin_memory=True, # memoria "bloccata": trasferimento più rapido alla GPU
drop_last=True, # scarta l'ultimo batch se incompleto
persistent_workers=True, # non li ricrea a ogni epoca
)
test_loader = DataLoader(dati_test, batch_size=64, shuffle=False,
num_workers=os.cpu_count(), pin_memory=True)
Conviene capire ognuno di questi argomenti, perché sono la differenza tra un addestramento che dura un’ora e uno che ne dura sei.
num_workers è il numero di aiutanti che preparano i vassoi mentre la cucina
cucina. Con zero aiutanti, il processo principale alterna: prepara un vassoio,
lo dà alla GPU, aspetta, prepara il prossimo, e la GPU, che è la parte cara
della macchina, resta ferma metà del tempo. Con quattro o otto aiutanti i
vassoi successivi sono già pronti quando servono.
Quanti ne tengono pronti ciascuno? Due, se non si dice altro, e a dirlo è
prefetch_factor. Con otto
aiutanti sono sedici vassoi apparecchiati in giro per la cucina, più quello in
uso: se i vassoi sono grandi, il piano di lavoro si riempie prima che la
cucina abbia fame, e la macchina resta senza memoria per una ragione che con
la scheda grafica non c’entra niente.
pin_memory è il piano d’appoggio accanto al passavivande: i dati vengono
messi in una zona di memoria da cui la GPU può prenderli senza passaggi
intermedi. È la premessa perché la cucina possa lavorare al resto mentre la
scheda ritira, e non basta da sola: la sovrapposizione va chiesta, con una
seconda manopola, quando i dati si passano alla scheda grafica.
drop_last butta via l’ultimo vassoio se è rimasto mezzo vuoto: con duemila
esempi e vassoi da 32, l’ultimo ne ha 16, e ci sono tipi di strato che dai
numeri del vassoio ricavano delle medie (li nomina la sezione sull’ottimizzazione e la regolarizzazione). Su metà vassoio quelle medie
restano giuste e diventano soltanto più ballerine; con 2049 esempi, però,
l’ultimo vassoio ne avrebbe uno solo, e da un numero solo la media si fa ma la
misura di quanto i numeri si sparpagliano no. Quando quegli strati dal vassoio
ricevono un numero per esempio, un vassoio da uno li ferma con un errore; su
una fotografia no, perché lì i numeri su cui fanno il conto sono i pixel, e
restano tanti anche con una foto sola.
persistent_workers dice di non licenziare gli aiutanti alla fine di ogni
giro per riassumerli subito dopo: se prepararsi costa loro qualche secondo,
quei secondi si pagano una volta invece che a ogni epoca. E shuffle=True
mescola il mazzo prima di ogni giro, così la rete non impara l’ordine. Chi
preferisce pescare a modo suo (dando più probabilità agli esempi rari, per
esempio) passa il proprio modo di pescare e toglie shuffle: o l’uno o
l’altro, e chiedendoli tutti e due si ottiene un errore subito.
Un avvertimento sul numero: alzare num_workers non paga oltre un certo
punto. Ogni aiutante è un processo vero, con la sua memoria; oltre il numero
di core della macchina si litiga soltanto. Il modo di scegliere è misurare,
non indovinare.
E un tranello che colpisce su Windows e macOS, dove ogni aiutante che si
presenta al lavoro rilegge da capo il foglio delle istruzioni. Se sul foglio,
in mezzo alle altre righe, c’è scritto «assumi otto aiutanti», ognuno
proverebbe ad assumerne altri otto, e la catena non finirebbe più: Python se
ne accorge sulla porta e ferma tutto con un errore. Il rimedio è mettere le
righe che avviano il lavoro sotto
if __name__ == "__main__":, che è il modo di dire «questo pezzo lo esegue
soltanto chi ha lanciato il programma, non chi arriva dopo».
num_workers=k avvia \(k\) processi (non thread: il GIL, spiegato nel capitolo
su Python, serializzerebbe proprio il codice Python puro del preprocessing,
che è il lavoro da parallelizzare qui) che eseguono
__getitem__ e il collate in parallelo, riempiendo una coda da cui il
processo principale preleva. prefetch_factor (default 2) regola quanti batch
ogni worker tiene pronti in anticipo: la memoria occupata cresce come
\(k \times \text{prefetch\_factor} \times \text{dimensione batch}\), e su
macchine con poca RAM è la prima causa di out of memory che non riguarda la
GPU. persistent_workers=True evita il costo di riavviarli a ogni epoca. Quel
costo non è __init__, che gira una volta sola nel processo principale e ai
worker arriva già fatto: è l’avvio dei processi e, dove nascono per spawn,
il re-import del modulo e la deserializzazione dell’oggetto Dataset, cioè la
sua dimensione.
pin_memory=True alloca i batch in memoria page-locked, che consente il
trasferimento DMA asincrono verso la GPU; combinato con
tensore.to(device, non_blocking=True) permette di sovrapporre copia e
calcolo. Su Windows e macOS, dove i worker nascono per spawn e non per
fork, il codice che li avvia deve stare sotto
if __name__ == "__main__":: senza, ogni worker rilegge il modulo e prova a
far ripartire il programma, e Python lo ferma sul nascere con un
RuntimeError.
drop_last=True scarta l’ultimo batch quando non è pieno. Le statistiche per
batch della BatchNorm su un batch corto restano non distorte e diventano solo
più disperse; il caso netto è il batch da un elemento, su cui la varianza
campionaria non esiste e nn.BatchNorm1d in train() alza ValueError: Expected more than 1 value per channel. Con \(n\) esempi e batch \(b\) succede
quando \(n \bmod b = 1\), che capita più spesso di quanto sembri.
Infine shuffle=True e l’argomento sampler sono mutuamente esclusivi:
shuffle è di fatto una scorciatoia per RandomSampler. Chi passa un sampler
personalizzato deve togliere shuffle.
Quando gli esempi non hanno la stessa forma: collate_fn#
Il pezzo che impila gli esempi in un batch pretende che abbiano tutti la stessa
forma. Si chiama collate, che in inglese vuol dire proprio «mettere in ordine
dei fogli sciolti», e nel codice compare come collate_fn. Con le immagini
ridimensionate la stessa forma è vera per costruzione; con il testo, l’audio o
le serie temporali non lo è quasi mai, perché una frase è lunga sette parole e
la successiva quarantatré. La soluzione è sostituire quel meccanismo con il
proprio.
import torch
from torch.utils.data import Dataset
from torch.nn.utils.rnn import pad_sequence
class DatasetSequenze(Dataset):
"""Frasi gia' tradotte in numeri, di lunghezza diversa fra loro."""
def __init__(self, n=100):
lunghezze = torch.randint(5, 40, (n,))
self.esempi = [(torch.randint(1, 50, (int(l),)), int(l) % 2)
for l in lunghezze]
def __len__(self):
return len(self.esempi)
def __getitem__(self, indice):
return self.esempi[indice]
def raggruppa(batch):
"""Riceve una lista di (sequenza, etichetta); restituisce un batch imbottito."""
sequenze, etichette = zip(*batch)
lunghezze = torch.tensor([len(s) for s in sequenze]) # (B,)
imbottite = pad_sequence(sequenze, batch_first=True, # (B, L_max)
padding_value=0)
return imbottite, lunghezze, torch.tensor(etichette)
torch.manual_seed(0) # frasi e mescolamento sorteggiati sempre uguali
dati = DatasetSequenze()
loader = DataLoader(dati, batch_size=32, shuffle=True, collate_fn=raggruppa)
imbottite, lunghezze, etichette = next(iter(loader))
print(imbottite.shape, lunghezze.shape, etichette.shape)
# le lunghezze vere sono una per frase, la larghezza del batch e' la massima:
print(lunghezze[:8].tolist(), "-> larghezza", imbottite.shape[1])
I numeri cambiano da un vassoio all’altro, perché le frasi sono sorteggiate, e cambia con loro la larghezza del vassoio, che è la lunghezza della frase più lunga capitata dentro. Quello che non cambia sono le tre forme: trentadue righe, trentadue lunghezze, trentadue etichette.
torch.Size([32, 37]) torch.Size([32]) torch.Size([32])
[37, 10, 7, 14, 8, 30, 5, 31] -> larghezza 37
Trentadue frasi portate tutte alla larghezza della più lunga del vassoio, qui trentasette; e accanto le trentadue lunghezze vere, una per frase. La frase che ne aveva cinque è arrivata a trentasette con trentadue zeri in coda.
Le lunghezze vanno restituite insieme ai dati e non sono un dettaglio. Dopo l’imbottitura tutte le frasi del vassoio hanno la stessa larghezza, e gli zeri aggiunti in coda sono indistinguibili da parole vere: senza sapere dove finisce la frase, il modello imparerebbe che lo zero è una parola come le altre, e passerebbe metà del suo tempo a studiare l’imbottitura. Le lunghezze sono l’informazione che permette di dire «da qui in poi non guardare». Il gesto ha due seguiti. La sezione sull’etichettare le sequenze marca le caselle di imbottitura perché non entrino nel conto dell’errore, che è lo stesso lavoro fatto sulle etichette invece che sui dati. Nei Transformer quella stessa riga di «fin qui sì, da qui no» diventa un ingrediente dell’architettura e prende un nome, si chiama maschera, e serve anche a un secondo mestiere: vietare a una parola di guardare quelle che vengono dopo. Per ora basta avere in mano le lunghezze.
Classi sbilanciate: pescare con criterio#
Se una classe ha duemila esempi e un’altra dodici, il mescolamento uniforme mostrerà la classe rara una volta ogni tanto e il modello imparerà, molto razionalmente, a non nominarla mai. Un rimedio è cambiare il modo di pescare.
from torch.utils.data import WeightedRandomSampler
# Le etichette si leggono dall'indice, senza aprire una sola immagine:
# ImageFolder le tiene in .targets. Iterare il dataset le otterrebbe
# ugualmente, ma caricando tutti i file da disco, inutilmente.
etichette = torch.tensor(dati_train.targets) # (N,)
conteggi = torch.bincount(etichette) # esempi per classe
peso_per_classe = 1.0 / conteggi.float() # la classe rara pesa di più
# indicizzare con un elenco: per ogni etichetta va a prendere il peso della sua
# classe, quindi da 3 pesi (uno per classe) si ottengono N pesi (uno per esempio)
pesi = peso_per_classe[etichette] # un peso per esempio
campionatore = WeightedRandomSampler(weights=pesi,
num_samples=len(pesi),
replacement=True)
# Attenzione: con un sampler NON si passa shuffle.
loader = DataLoader(dati_train, batch_size=32, sampler=campionatore)
Il campionamento pesato tocca i dati, che è l’ultima delle quattro leve contro
lo sbilanciamento: prima vengono la metrica, la soglia e il peso delle classi
(weight in CrossEntropyLoss), e la sezione sulle classi sbilanciate le ordina dalla più economica alla
più invasiva.
Quella stessa sezione spiega perché, quando le classi sono sbilanciate così, l’accuratezza smette di dire la verità. Basta un conto: se su duemila foto millenovecento sono pizza, un modello che risponde «pizza» a occhi chiusi, sempre, prende novantacinque su cento e non ha imparato niente. Servono misure che guardino anche le classi rare, e sono precisione, richiamo e F1.
Dividere i dati senza barare#
Il gesto più innocuo del progetto (dividere in addestramento e test) è anche quello dove si commettono i danni più difficili da scoprire.
import torch
from torch.utils.data import random_split
n_val = int(0.1 * len(dati_train))
n_train = len(dati_train) - n_val
generatore = torch.Generator().manual_seed(42) # divisione riproducibile
sotto_train, sotto_val = random_split(dati_train, [n_train, n_val],
generator=generatore)
La divisione a caso funziona solo se gli esempi sono davvero indipendenti. Non lo sono, per esempio, se il dataset contiene dieci fotografie dello stesso paziente, o dieci fotogrammi consecutivi dello stesso video: dividendo a caso, alcune finiscono nell’addestramento e altre nel test, il modello riconosce il paziente invece della malattia, e il voto d’esame risulta splendido (fino al giorno in cui arriva un paziente nuovo).
La regola è: si divide per gruppo, non per esempio. Tutti i dati di un paziente stanno o di qua o di là. E se i dati hanno una data, si divide per data: si addestra sul passato e si valuta sul futuro, perché è così che funzionerà davvero.
C’è un secondo modo di sbirciare, più difficile da vedere perché non sposta nemmeno una fotografia. Prima di dare i numeri alla rete si guarda com’è fatta la collezione (quanto è chiara in media, quanto variano i colori) per rimettere tutto sulla stessa scala. Se per calcolare quelle misure si guarda anche il mucchio d’esame, un pezzetto delle foto d’esame è già entrato nelle decisioni prese prima dell’esame. Le misure si prendono sul mucchio d’addestramento e si applicano tali e quali all’altro. Il regalo che ci si fa è piccolo, e basta a far sembrare vincente un metodo che non lo è.
È la data leakage da correlazione di gruppo: la divisione casuale assume
esempi i.i.d., ipotesi violata da qualunque struttura gerarchica (paziente,
sessione, utente, documento). La contromisura è una divisione per gruppi:
l’equivalente PyTorch di GroupShuffleSplit di scikit-learn si scrive
raccogliendo gli indici per gruppo e passandoli a torch.utils.data.Subset.
Per dati temporali vale l’analogo temporale, la validazione a
origine mobile (che si trova
anche come walk-forward e come forward chaining).
Un secondo tranello, più sottile: le statistiche di normalizzazione e ogni altro parametro di preprocessing vanno calcolati solo sul training set e poi applicati agli altri. Calcolare media e deviazione standard su tutto il dataset prima di dividere lascia filtrare informazione dal test: un errore che gonfia i risultati di poco, ma abbastanza da falsare un confronto.
Il collo di bottiglia è quasi sempre il disco#
Un’ultima cosa, la meno intuitiva di tutte. Quando un addestramento è lento, l’istinto dice che la colpa è del modello. Nella maggior parte dei progetti che non riguardano i modelli giganti, la colpa è invece del caricamento dei dati: la cucina finisce il vassoio e resta ferma ad aspettare il successivo. Conviene tenerlo a mente, perché è la diagnosi che quasi nessuno prova per prima e quasi sempre è quella giusta.
Come ci si accorge, se una scheda grafica c’è: si guarda quanto è occupata
mentre l’addestramento gira, con il comando nvidia-smi scritto in un’altra
finestra del terminale. Se sta al cento per cento in modo stabile, il collo di
bottiglia è il calcolo; se invece salta dal cento a zero e ritorno, la scheda
sta aspettando i dati, e ogni ottimizzazione del modello sarà tempo perso. Chi
lavora sulla sola CPU non ha quel termometro, e allora si cronometra a mano:
un’epoca intera, poi un’epoca in cui il modello non fa niente e si scorrono
soltanto i dati. Se i due tempi si somigliano, il modello non c’entra.
I rimedi, dal più efficace al meno:
Più aiutanti. Alzare num_workers: se il problema è che nessuno prepara i
vassoi mentre la cucina cucina, è la prima cosa da provare.
Ritagliare le foto una volta sola. Se ogni epoca ridimensiona quattromila fotografie da dodici megapixel a 224 pixel per lato, quel lavoro lo si sta rifacendo identico decine di volte. Farlo una volta e salvare le immagini già piccole su disco è un pomeriggio che si ripaga in un’ora.
Meno file, più grandi. Questo è il rimedio che stupisce, perché la ragione non è quella che si immagina: il costo grosso sta nell’aprirle, più che nel leggerle. Aprire un file è come chiedere al bibliotecario di andare a prendere un volume: il tempo lo fa il tragitto, non la lettura, e per un milione di volumi si fa un milione di tragitti. Impacchettare le immagini in pochi archivi grandi, letti di seguito, è chiedere al bibliotecario uno scaffale intero in una volta. La differenza è enorme, e diventa drammatica quando i file non stanno sul computer ma su un disco raggiunto attraverso la rete.
Spostare le trasformazioni pesanti sulla scheda grafica, che le fa più in fretta della CPU.
La diagnosi si fa con nvidia-smi a occhio o, meglio, con il profiler
torch.profiler, che separa il tempo speso in DataLoader da quello speso nei
kernel.
I rimedi, in ordine di efficacia: alzare num_workers; ridimensionare le
immagini una volta su disco invece che a ogni epoca; usare formati che si
leggono in blocco (.npy, WebDataset, LMDB) invece di milioni di piccoli file;
spostare le trasformazioni pesanti sulla GPU (torchvision.transforms.v2
lavora su batch di tensori, quindi anche su device).
Il rimedio dei file impacchettati guadagna per una ragione precisa. Su una
collezione grande il costo
dominante sta nell’aprirli, più che nel decodificarli: ogni open() è una
chiamata di sistema e un accesso ai metadati del filesystem, e un milione di
file piccoli produce un milione di accessi minuscoli e sparsi, che è lo schema
peggiore per qualunque disco e disastroso su uno storage di rete, dove ogni
accesso paga anche la latenza. Impacchettarli in pochi archivi letti in
sequenza sposta il lavoro dove l’hardware è veloce.
C’è poi un secondo motivo per impacchettare i file, che si paga una volta e
serve per sempre. Mentre si scorre tutta la collezione per riscriverla, la si
sta già leggendo: costa zero calcolare intanto media e deviazione standard di
ogni colore, cioè i sei numeri che servono a Normalize e che erano stati
presi in prestito da ImageNet. Sui propri dati si calcolano, e vengono meglio.
Due avvertenze, e sono le stesse di sempre. La prima: quei sei numeri si calcolano solo sulle foto di addestramento, mai su tutte. Calcolarli su tutte vuol dire far entrare nelle mie decisioni anche le foto d’esame, e il voto smette di essere onesto, per la stessa perdita di informazione della divisione fatta a caso su esempi che si assomigliano, e la sezione su overfitting e validazione la tratta per esteso. La seconda: se si parte da un modello già addestrato da altri, i sei numeri non si calcolano affatto, si prendono quelli con cui è stato addestrato lui. Le librerie li tengono insieme ai pesi proprio per questo, e un modello a cui si danno immagini centrate diversamente da come le ricorda risponde peggio senza dire niente.
Il tubo che porta i file dentro la rete è fatto: da qui in avanti si può tornare a occuparsi del modello, sapendo che i dati arrivano. La sezione sulle prestazioni riprenderà il discorso dall’altro lato, quello del calcolo.
Da ricordare
Un
Datasetsi scrive con tre metodi: la preparazione, che avviene una volta sola e dove va messo il lavoro lento; e le due domande che ilDataLoadergli farà davvero, quanti esempi hai e dammi il numero 137 (la seconda gli verrà chiesta milioni di volte, quindi dev’essere veloce).Se le foto stanno in una cartella per classe,
ImageFolderfa tutto da sé. I nomi delle classi li assegna in ordine alfabetico: vanno riletti da lui, mai riscritti a mano in un altro ordine.Le trasformazioni servono a due cose: preparare (stessa misura, stessa scala di numeri) e moltiplicare (girare, specchiare, schiarire). Si moltiplica solo in addestramento, mai durante l’esame.
Il
DataLoadersi regola con pochi argomenti, e il primo è il numero di aiutanti che preparano i vassoi in parallelo; e una regola: o si mescola a caso, o si passa un modo di pescare proprio, non tutti e due.I sei numeri di
Normalizesi calcolano sulle sole foto di addestramento, mai su tutte; e se si parte da un modello già addestrato da altri non si calcolano affatto, si prendono i suoi.Se gli esempi hanno lunghezze diverse (frasi, suoni) si allungano tutti alla stessa misura con degli zeri, e si restituiscono anche le lunghezze vere, altrimenti il modello studia l’imbottitura.
Si divide per gruppo (tutte le foto dello stesso paziente di qua o di là), o per data, mai a caso su esempi che si assomigliano: è il modo più comune di darsi un bel voto senza meritarlo.
Se l’addestramento è lento, sospetta i dati prima del modello.
Da ricordare
Un
Datasetsono tre metodi e un contratto a due:__init__è per noi (lavoro pesante, una volta), mentre__len__e__getitem__sono le domande delDataLoader(lavoro leggero, milioni di volte).ImageFoldercopre il caso «una cartella per classe»; l’indice delle classi segue l’ordine alfabetico, e va riletto da.classes, mai riscritto a mano.Le trasformazioni preparano (resize,
ToTensor,Normalize) e moltiplicano (augmentation): moltiplicare solo in addestramento, mai in valutazione.Nel
DataLoadercontanonum_workers,pin_memory,drop_last,persistent_workers;shuffleesamplersi escludono a vicenda.Con esempi di lunghezza diversa serve un
collate_fnche imbottisce e restituisce le lunghezze vere.Si divide per gruppo (paziente, video, utente) o per data, mai a caso su esempi correlati: è la forma più comune di data leakage.
Se l’addestramento è lento, sospetta il caricamento dei dati prima del modello.