Salta al contenuto principale

52 - Reti residuali, convoluzione 1x1 e blocchi Inception

Perché una rete residuale impara dove una semplice no: derivazione del gradiente, convoluzione 1×1 e blocco Inception, con listati PyTorch eseguiti.

Cosa ne pensi di questo articolo?

52 - Reti residuali, convoluzione 1x1 e blocchi Inception
Machine Learning Engineerfedericocalo.dev

In Breve

Perché una rete residuale impara dove una semplice no: derivazione del gradiente, convoluzione 1×1 e blocco Inception, con listati PyTorch eseguiti.

Immagina di dover correggere un testo di cento pagine. Un metodo è riscriverlo da capo, pagina per pagina: rischi di peggiorarlo, perché ogni riscrittura può introdurre errori nuovi. Un altro metodo è lasciare il testo com'è e scrivere a margine solo le correzioni: se una pagina è già giusta, il margine resta vuoto e la pagina passa invariata. Una rete residuale (ResNet) costruisce i suoi strati con il secondo metodo: ogni blocco non riscrive il segnale, ne calcola una correzione e la somma al segnale in ingresso. Questa idea semplice ha reso possibile l'addestramento di reti con decine, centinaia e persino migliaia di strati, e ha vinto la gara di classificazione di immagini ILSVRC nel 2015.

Prerequisiti: sai come la backpropagation calcola il gradiente, conosci stride, padding e pooling (lezione sul conteggio dei parametri) e hai già addestrato una piccola rete convolutiva (lezione su LeNet con PyTorch). Qui trovi tre strumenti che si completano: il blocco residuale, che permette di impilare molti strati; la convoluzione 1×1, che cambia il numero di canali a basso costo; il blocco Inception, che allarga la rete in parallelo invece di approfondirla. Tutti i listati sono stati eseguiti con PyTorch 2.12.0 e scikit-learn 1.9.0 su CPU, due volte di seguito, con uscita identica; l'intero percorso impiega circa un minuto su due thread (una sola misura, 2026-11-12).

Il problema della degradazione

La lezione sulla backpropagation ti ha mostrato il gradiente che svanisce in una rete profonda con sigmoide, e la lezione sulle reti a strati ha mostrato che una buona inizializzazione e la ReLU lo mitigano. Ci si poteva aspettare che, con queste tecniche, aggiungere strati non facesse mai male almeno sull'errore di training: nel caso peggiore i nuovi strati potrebbero imparare la funzione identità, e la rete profonda farebbe quanto quella superficiale. Nel 2015 K. He e collaboratori osservarono il contrario: su CIFAR-10 una rete «semplice» (senza connessioni residuali) con 56 strati aveva un errore di training più alto di una con 20. Non è overfitting (l'errore sul training sarebbe più basso, non più alto): è un problema di ottimizzazione. Anche in teoria esiste una soluzione profonda altrettanto buona (basterebbe che gli strati aggiunti fossero identità), ma l'algoritmo di discesa del gradiente non riesce a trovarla: imparare una funzione identità con una pila di strati non lineari è sorprendentemente difficile. Il fenomeno si chiama degradazione.

L'idea residuale

La soluzione di He et al. è di cambiare ciò che il blocco deve imparare. Se la mappa desiderata da un blocco è H(x), invece di chiedere ai suoi strati di approssimare direttamente H(x), si chiede loro di approssimare il residuo F(x) = H(x) − x, e l'uscita del blocco è:

y = x + F(x)

Il termine x arriva all'uscita attraverso una connessione di scorciatoia (skip connection) che non attraversa nessun peso né non linearità. Se la trasformazione ottima è vicina all'identità (e per molti strati lo è), al blocco basta portare F(x) vicino a zero, cosa molto più facile che costruire l'identità con strati non lineari: bastano pesi piccoli.

Il blocco residuoIl blocco residuo: l'ingresso x passa attraverso due convoluzioni (il ramo F) e viene anche portato avanti dalla connessione di scorciatoia; le due strade si sommanoIl blocco residuoxconv 3×3ReLUconv 3×3F(x)+scorciatoia (identità)ReLUuscitasomma: x + F(x)
Il blocco residuo: l'ingresso x passa attraverso due convoluzioni (il ramo F) e viene anche portato avanti dalla connessione di scorciatoia; le due strade si sommano

Il disegno mostra il blocco: l'ingresso x segue due strade. In alto attraversa il ramo residuale (due convoluzioni 3×3 con una ReLU in mezzo) e diventa F(x); in basso passa invariato dalla scorciatoia. Le due uscite si sommano elemento per elemento, e una ReLU chiude il blocco. Perché le due strade si possano sommare devono avere la stessa forma: lo vedremo tra poco.

Perché il gradiente ora passa: la derivazione

Impiliamo L blocchi residuali, con x_{l+1} = x_l + F(x_l) (semplifichiamo togliendo la ReLU finale, come fa la variante «pre-attivazione» di ResNet). Srotolando la ricorrenza dal blocco l al blocco L si ottiene:

x_L = x_l + Σ_{i=l}^{L−1} F(x_i)

L'uscita di un blocco profondo è l'ingresso di un blocco superficiale più una somma di correzioni. Ora applica la regola della catena alla perdita E, derivando rispetto a x_l:

∂E/∂x_l = (∂E/∂x_L) · (∂x_L/∂x_l) = (∂E/∂x_L) · ( 1 + ∂/∂x_l Σ_{i=l}^{L−1} F(x_i) )

Guarda il fattore tra parentesi: contiene un 1, che viene dalla scorciatoia, più un termine che dipende dai pesi dei rami F. Nella rete «semplice» la stessa derivata è un prodotto di L − l matrici (una per strato), che tende a zero o all'infinito in modo esponenziale; qui il gradiente ∂E/∂x_L arriva a x_l direttamente, sommato (non moltiplicato) al contributo dei rami. Il termine 1 garantisce che il gradiente del livello superiore non svanisca, indipendentemente da quanto sono piccoli o grandi i pesi dei rami. È un'autostrada per l'errore.

L'esperimento: reti profonde con e senza scorciatoie

Misuriamo l'effetto con una versione densa e minimale, che isola l'idea: una pila di strati densi di larghezza 64, con ReLU, su Digits, nella variante «semplice» (h = relu(W h)) e in quella residuale (h = h + relu(W h)). Una terza variante residuale scala il ramo di correzione per 1/√L, per un motivo che vedremo nei risultati. Per ciascuna misuriamo la norma del gradiente del primo strato (ci dice se il segnale d'errore ci arriva) e poi addestriamo.

import numpy as np
import torch
import torch.nn as nn
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

X, y = load_digits(return_X_y=True)
X_tmp, X_te, y_tmp, y_te = train_test_split(X, y, test_size=0.2, stratify=y, random_state=0)
X_tr, X_va, y_tr, y_va = train_test_split(X_tmp, y_tmp, test_size=0.25, stratify=y_tmp, random_state=0)
media, dev = X_tr.mean(), X_tr.std()
T_tr, T_va = [torch.tensor((a - media) / dev, dtype=torch.float32) for a in (X_tr, X_va)]
L_tr, L_va = torch.tensor(y_tr), torch.tensor(y_va)

class Semplice(nn.Module):                                    # h <- relu(W h), L volte
    def __init__(self, profondita, larghezza=64):
        super().__init__()
        self.ingresso = nn.Linear(64, larghezza)
        self.nascosti = nn.ModuleList([nn.Linear(larghezza, larghezza) for _ in range(profondita)])
        self.uscita = nn.Linear(larghezza, 10)
    def forward(self, x):
        h = torch.relu(self.ingresso(x))
        for strato in self.nascosti:
            h = self.passo(h, strato)
        return self.uscita(h)
    def passo(self, h, strato):
        return torch.relu(strato(h))

class Residuale(Semplice):                                    # h <- h + relu(W h): la scorciatoia
    def passo(self, h, strato):
        return h + torch.relu(strato(h))

class ResiduoScalato(Semplice):                               # h <- h + (1/sqrt(L)) relu(W h)
    def passo(self, h, strato):
        return h + torch.relu(strato(h)) / len(self.nascosti) ** 0.5

def norma_gradiente_primo_strato(modello):
    modello.zero_grad()
    nn.CrossEntropyLoss()(modello(T_tr), L_tr).backward()
    return modello.ingresso.weight.grad.norm().item()

print("norma del gradiente del primo strato (media sull'intero training set)")
print("profondita   semplice          residuale          residuale scalato")
for d in (5, 20, 50):
    valori = []
    for classe in (Semplice, Residuale, ResiduoScalato):
        torch.manual_seed(0)
        valori.append(norma_gradiente_primo_strato(classe(d)))
    print("%10d   %-16.5f   %-16.5f   %.5f" % ((d,) + tuple(valori)))
norma del gradiente del primo strato (media sull'intero training set)
profondita   semplice          residuale          residuale scalato
         5   0.00601            1.43556            0.69622
        20   0.00000            54.24111           0.65498
        50   0.00000            45191.06641        0.75554

Cosa fa il codice. Le tre classi differiscono per una sola riga, il metodo passo: nella rete semplice è relu(strato(h)); nella residuale h + relu(strato(h)); nella terza il ramo è diviso per √L. norma_gradiente_primo_strato calcola la perdita su tutto il training e legge la norma del gradiente del primo strato (weight.grad.norm()). Leggi i risultati. Nella rete semplice il gradiente del primo strato è 0,006 con 5 strati e praticamente zero (sotto 10⁻⁵) con 20 e 50: il segnale d'errore non arriva. Nella rete residuale, il gradiente arriva, e anche troppo: 1,4 con 5 strati, 54 con 20, oltre 45 000 con 50.

Qui sta la ragione della terza variante: senza normalizzazione né scalatura, sommare molte correzioni fa crescere l'attivazione e il gradiente in modo esplosivo. Le vere ResNet evitano il problema con la batch normalization (lezione successiva) e inizializzazioni attente. Con il ramo scalato per 1/√L il gradiente si stabilizza intorno a 0,7 per tutte le profondità: il segnale arriva e non esplode.

def addestra(modello, epoche=40, lr=1e-3):
    ott = torch.optim.Adam(modello.parameters(), lr=lr)
    perdita_fn = nn.CrossEntropyLoss()
    loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(T_tr, L_tr), batch_size=32, shuffle=True)
    for _ in range(epoche):
        for xb, yb in loader:
            ott.zero_grad(); perdita_fn(modello(xb), yb).backward(); ott.step()
    modello.eval()
    with torch.no_grad():
        return ((modello(T_tr).argmax(1) == L_tr).float().mean().item(),
                (modello(T_va).argmax(1) == L_va).float().mean().item(),
                perdita_fn(modello(T_tr), L_tr).item())

print("profondita  modello            acc. training  acc. validazione  perdita training")
for d in (4, 20, 50):
    for classe in (Semplice, Residuale, ResiduoScalato):
        torch.manual_seed(0)
        a_tr, a_va, perdita = addestra(classe(d))
        print("%10d  %-18s %12.3f  %16.3f  %16.4f" % (d, classe.__name__, a_tr, a_va, perdita))
profondita  modello            acc. training  acc. validazione  perdita training
         4  Semplice                  1.000             0.961            0.0008
         4  Residuale                 1.000             0.981            0.0005
         4  ResiduoScalato            1.000             0.969            0.0012
        20  Semplice                  0.101             0.100            2.3025
        20  Residuale                 1.000             0.975            0.0001
        20  ResiduoScalato            1.000             0.967            0.0003
        50  Semplice                  0.101             0.100            2.3025
        50  Residuale                 0.987             0.922            0.0573
        50  ResiduoScalato            1.000             0.969            0.0002

Cosa fa il codice. Addestra ciascuna rete con Adam per 40 epoche e riporta accuratezza di training e validazione. La rete semplice con 4 strati va bene (validazione circa 0,96). Con 20 e 50 strati non impara: accuratezza 0,10, cioè il caso, e perdita 2,302 = ln 10, il valore di chi dà probabilità 1/10 a ogni classe. È la degradazione portata all'estremo: la rete più profonda non è peggiore per overfitting, non impara affatto. Le versioni residuali imparano a tutte le profondità: la residuale semplice arriva a 0,975 con 20 strati e a 0,922 con 50 (più instabile, per l'esplosione che abbiamo visto), la scalata a 0,967 con 20 strati e 0,969 con 50. Il messaggio, ben più importante dei decimali, è qualitativo: la scorciatoia trasforma un compito impossibile in uno risolvibile.

Un avvertimento onesto: questa è una pila densa su un problema facile e non prova che più strati migliorino i risultati (a 20 e 50 strati la validazione non supera quella della rete a 4 strati); mostra solo che l'ottimizzazione di una rete profonda diventa possibile.

Una proprietà sorprendente: togliere un blocco

La formula x_L = x_l + Σ F(x_i) ha una conseguenza che si può verificare. Se esplicitiamo il prodotto di L blocchi, ciascuno dei quali è «scorciatoia più ramo», e lo espandiamo, ogni blocco contribuisce o con il termine identità o con il termine F: in totale si ottengono 2^L cammini diversi dall'ingresso all'uscita, alcuni cortissimi (tutte scorciatoie) e altri lunghi (molti rami). Una ResNet con 20 blocchi si comporta come un insieme di oltre un milione di reti di profondità diversa che condividono i pesi: è la lettura proposta da A. Veit, M. Wilber e S. Belongie (2016). Se è così, togliere un singolo blocco dovrebbe eliminare solo una frazione dei cammini e degradare poco la rete, mentre in una rete «semplice» togliere uno strato spezza l'unica catena. Proviamo:

def accuratezza_senza_blocco(modello, k, T, L):
    modello.eval()
    with torch.no_grad():
        h = torch.relu(modello.ingresso(T))
        for i, strato in enumerate(modello.nascosti):
            if i == k:
                continue                                           # il blocco k viene saltato
            h = modello.passo(h, strato)
        return (modello.uscita(h).argmax(1) == L).float().mean().item()

torch.manual_seed(0)
residuale20 = ResiduoScalato(20); addestra(residuale20)
torch.manual_seed(0)
semplice4 = Semplice(4); addestra(semplice4)

print("residuale a 20 blocchi:  completo %.3f   senza il blocco 0, 5, 10, 15, 19: %s" % (
    accuratezza_senza_blocco(residuale20, -1, T_va, L_va), [round(accuratezza_senza_blocco(residuale20, k, T_va, L_va), 3) for k in (0, 5, 10, 15, 19)]))
print("semplice a 4 strati:     completo %.3f   senza lo strato 0, 1, 2, 3:     %s" % (
    accuratezza_senza_blocco(semplice4, -1, T_va, L_va), [round(accuratezza_senza_blocco(semplice4, k, T_va, L_va), 3) for k in range(4)]))
print("cammini distinti in una pila di 20 blocchi residuali: 2^20 = %d" % 2 ** 20)
residuale a 20 blocchi:  completo 0.967   senza il blocco 0, 5, 10, 15, 19: [0.964, 0.969, 0.967, 0.967, 0.967]
semplice a 4 strati:     completo 0.961   senza lo strato 0, 1, 2, 3:     [0.117, 0.025, 0.203, 0.225]
cammini distinti in una pila di 20 blocchi residuali: 2^20 = 1048576

Cosa fa il codice. Addestra una rete residuale a 20 blocchi e una semplice a 4 strati, poi valuta la validazione saltando un blocco alla volta (continue nel ciclo). Il risultato è netto: la rete residuale passa da 0,967 a valori compresi tra 0,964 e 0,969 qualunque blocco venga tolto, cioè nessuna differenza apprezzabile, mentre la rete semplice crolla da 0,961 a valori tra 0,03 e 0,23 togliendo uno qualunque dei suoi strati. Non è un'altra misura di accuratezza: dice che nella rete residuale nessun singolo blocco è indispensabile, perché il segnale ha molte strade alternative. Un'interpretazione cauta: in questo esempio i blocchi residuali imparano correzioni piccole (il ramo è scalato per 1/√L), quindi ciascuno conta poco. Nelle ResNet reali l'effetto esiste ma è meno estremo. Resta comunque una prova numerica che la scorciatoia cambia la natura della rete, da una catena a un insieme di cammini.

Il blocco residuale convolutivo

Nelle ResNet per immagini il ramo F è una piccola pila di convoluzioni 3×3 (con batch normalization, che vedremo nella lezione successiva). La somma richiede che x e F(x) abbiano la stessa forma (N, C, H, W). Se il blocco cambia il numero di canali o dimezza la risoluzione (stride 2), la scorciatoia non può più essere l'identità: si usa una proiezione, una convoluzione 1×1 con lo stesso stride che porta x alla forma di F(x).

class BloccoResiduale(nn.Module):
    def __init__(self, c_in, c_out, stride=1):
        super().__init__()
        self.ramo = nn.Sequential(nn.Conv2d(c_in, c_out, 3, stride, 1), nn.ReLU(), nn.Conv2d(c_out, c_out, 3, 1, 1))
        # scorciatoia: identità se le forme coincidono, altrimenti proiezione 1x1
        self.scorciatoia = None if (c_in == c_out and stride == 1) else nn.Conv2d(c_in, c_out, 1, stride)

    def forward(self, x):
        s = x if self.scorciatoia is None else self.scorciatoia(x)
        return torch.relu(s + self.ramo(x))

# proprietà chiave: con il ramo "spento" (ultimo strato a zero) il blocco è l'identità
blocco = BloccoResiduale(16, 16)
for p in blocco.ramo[2].parameters():
    nn.init.zeros_(p)
x = torch.relu(torch.randn(2, 16, 8, 8))
print("ramo azzerato -> uscita uguale all'ingresso:", torch.allclose(blocco(x), x))

senza_proiezione = BloccoResiduale(16, 16)
con_proiezione = BloccoResiduale(16, 32, stride=2)
print("blocco 16->16:  forma %s  parametri %d" % (tuple(senza_proiezione(x).shape), sum(p.numel() for p in senza_proiezione.parameters())))
print("blocco 16->32 stride 2:  forma %s  parametri %d  (con la proiezione 1x1)" % (tuple(con_proiezione(x).shape), sum(p.numel() for p in con_proiezione.parameters())))
ramo azzerato -> uscita uguale all'ingresso: True
blocco 16->16:  forma (2, 16, 8, 8)  parametri 4640
blocco 16->32 stride 2:  forma (2, 32, 4, 4)  parametri 14432  (con la proiezione 1x1)

Cosa fa il codice. BloccoResiduale contiene il ramo (due convoluzioni) e una scorciatoia che è None (identità) quando le forme coincidono, altrimenti una convoluzione 1×1 con lo stesso stride. In forward, la somma s + self.ramo(x) è la riga che realizza y = x + F(x), seguita dalla ReLU. La prima prova dimostra la proprietà che rende il blocco facile da addestrare: se azzeri i pesi dell'ultimo strato del ramo, F(x) = 0 e il blocco restituisce esattamente x (per un ingresso non negativo, come l'uscita di una ReLU). All'inizializzazione un blocco con pesi piccoli è quindi «quasi identità», e la rete profonda parte da una funzione ragionevole invece che da un rumore che si amplifica. La seconda parte mostra le forme: il blocco 16→16 mantiene (2, 16, 8, 8) con 4 640 parametri; quello 16→32 con stride 2 produce (2, 32, 4, 4) e ne ha 14 432, di cui 544 della proiezione 1×1 (32·16 + 32).

La convoluzione 1×1

La proiezione appena usata è una convoluzione 1×1: un kernel di una sola posizione, che non guarda i vicini ma combina i canali nello stesso pixel. Per ogni posizione (i, j), calcola una combinazione lineare dei C_in canali per produrre C_out canali. È uno strato denso applicato indipendentemente a ogni pixel, con gli stessi pesi dappertutto. Lo verifichiamo con una moltiplicazione tra matrici (einsum è una notazione compatta per somme di prodotti su indici):

conv1x1 = nn.Conv2d(8, 5, kernel_size=1)
x = torch.randn(2, 8, 4, 4)
y = conv1x1(x)
y_denso = torch.einsum("oc,nchw->nohw", conv1x1.weight[:, :, 0, 0], x) + conv1x1.bias[None, :, None, None]
print("conv 1x1 = prodotto matrice-canali in ogni pixel:", torch.allclose(y, y_denso, atol=1e-6))
print("forma dei pesi:", tuple(conv1x1.weight.shape), "-> una matrice 5x8 sui canali, copiata in tutte le 16 posizioni")
conv 1x1 = prodotto matrice-canali in ogni pixel: True
forma dei pesi: (5, 8, 1, 1) -> una matrice 5x8 sui canali, copiata in tutte le 16 posizioni

Cosa fa il codice. einsum("oc,nchw->nohw") dice: per ciascun esempio n e posizione (h, w), moltiplica la matrice dei pesi (o, c) per il vettore dei canali c e produce o. Il risultato, più il bias, coincide con la convoluzione 1×1. A cosa serve? A cambiare il numero di canali in modo economico: ridurlo prima di una convoluzione costosa (riduzione, bottleneck) o aumentarlo dopo. Il risparmio è notevole: confrontiamo due convoluzioni 3×3 su 256 canali con la struttura «bottleneck» usata nelle ResNet profonde (1×1 per scendere a 64 canali, 3×3 su 64, 1×1 per tornare a 256), che ha lo stesso ingresso e la stessa uscita:

due_3x3 = 2 * (256 * 256 * 3 * 3)                                 # due convoluzioni 3x3 256 -> 256 (senza bias)
bottleneck = 256 * 64 * 1 * 1 + 64 * 64 * 3 * 3 + 64 * 256 * 1 * 1
print("due convoluzioni 3x3 su 256 canali: %9d parametri" % due_3x3)
print("blocco bottleneck 1x1 -> 3x3 -> 1x1: %9d parametri   (%.1f volte meno)" % (bottleneck, due_3x3 / bottleneck))
due convoluzioni 3x3 su 256 canali:   1179648 parametri
blocco bottleneck 1x1 -> 3x3 -> 1x1:     69632 parametri   (16.9 volte meno)

Il calcolo mostra che il bottleneck ha 69 632 parametri contro 1 179 648: circa 17 volte meno. L'idea è che la convoluzione 3×3, quella costosa, lavora su 64 canali anziché su 256, e le due convoluzioni 1×1 si occupano di comprimere e ripristinare. È il motivo per cui le ResNet da 50 strati in su usano blocchi bottleneck.

Il blocco Inception: allargare invece di approfondire

GoogLeNet (C. Szegedy et al., 2014, vincitore di ILSVRC 2014) affronta un altro problema: quale dimensione di kernel scegliere? 1×1 per le caratteristiche puntuali, 3×3 per quelle medie, 5×5 per quelle ampie? Invece di scegliere, il blocco Inception le usa tutte in parallelo sullo stesso ingresso e concatena le uscite lungo l'asse dei canali; la rete decide quale ramo usare di più attraverso i pesi appresi. Per rendere sostenibile il costo, davanti alle convoluzioni 3×3 e 5×5 inserisce convoluzioni 1×1 di riduzione dei canali.

Un blocco Inception con riduzioni 1×1Quattro rami paralleli sullo stesso ingresso: convoluzione 1×1; 1×1 poi 3×3; 1×1 poi 5×5; max pooling poi 1×1; le uscite vengono concatenate lungo i canaliUn blocco Inception con riduzioni 1×1ingresso1×11×1 (riduzione)3×31×1 (riduzione)5×5pool 3×31×1concatconcatenazione sui canali
Quattro rami paralleli sullo stesso ingresso: convoluzione 1×1; 1×1 poi 3×3; 1×1 poi 5×5; max pooling poi 1×1; le uscite vengono concatenate lungo i canali

Il disegno mostra i quattro rami: 1×1; 1×1 seguita da 3×3; 1×1 seguita da 5×5; max pooling 3×3 seguito da 1×1. Le uscite hanno la stessa dimensione spaziale (grazie al padding) e quindi si concatenano sui canali. Costruiamo il blocco con i valori del modulo «3a» di GoogLeNet (ingresso 192 canali; rami da 64, 96→128, 16→32 e 32 canali) e mettiamo a confronto la versione con riduzioni e quella «ingenua» che applica 3×3 e 5×5 direttamente ai 192 canali:

class Inception(nn.Module):
    def __init__(self, c_in, c1, c3_rid, c3, c5_rid, c5, c_pool):
        super().__init__()
        self.r1 = nn.Conv2d(c_in, c1, 1)
        self.r3 = nn.Sequential(nn.Conv2d(c_in, c3_rid, 1), nn.ReLU(), nn.Conv2d(c3_rid, c3, 3, padding=1))
        self.r5 = nn.Sequential(nn.Conv2d(c_in, c5_rid, 1), nn.ReLU(), nn.Conv2d(c5_rid, c5, 5, padding=2))
        self.rp = nn.Sequential(nn.MaxPool2d(3, stride=1, padding=1), nn.Conv2d(c_in, c_pool, 1))

    def forward(self, x):
        return torch.relu(torch.cat([self.r1(x), self.r3(x), self.r5(x), self.rp(x)], dim=1))     # concatenazione sui canali

modulo = Inception(192, 64, 96, 128, 16, 32, 32)
x = torch.randn(1, 192, 28, 28)
print("forma d'uscita:", tuple(modulo(x).shape), " (64 + 128 + 32 + 32 = 256 canali)")

def parametri_conv(c_in, c_out, k):
    return c_in * c_out * k * k + c_out

ingenuo = parametri_conv(192, 64, 1) + parametri_conv(192, 128, 3) + parametri_conv(192, 32, 5) + parametri_conv(192, 32, 1)
con_riduzioni = sum(p.numel() for p in modulo.parameters())
print("parametri senza riduzioni 1x1: %d   con riduzioni: %d   (%.1f volte meno)" % (ingenuo, con_riduzioni, ingenuo / con_riduzioni))
print("moltiplicazioni per immagine sulla mappa 28x28: %.0f M contro %.0f M" % (28 * 28 * ingenuo / 1e6, 28 * 28 * con_riduzioni / 1e6))
forma d'uscita: (1, 256, 28, 28)  (64 + 128 + 32 + 32 = 256 canali)
parametri senza riduzioni 1x1: 393472   con riduzioni: 163696   (2.4 volte meno)
moltiplicazioni per immagine sulla mappa 28x28: 308 M contro 128 M

Cosa fa il codice. Inception costruisce i quattro rami, e in forward li applica allo stesso x e li concatena con torch.cat(..., dim=1) (la dimensione 1 è quella dei canali): il risultato ha 64 + 128 + 32 + 32 = 256 canali con la stessa risoluzione 28×28. Il confronto dei parametri: la versione ingenua, con 3×3 e 5×5 direttamente su 192 canali, ne ha 393 472; quella con riduzioni 1×1, 163 696: circa 2,4 volte meno. In termini di moltiplicazioni per immagine, 308 milioni contro 128 milioni. Le riduzioni permettono di avere ampiezza (più kernel di dimensioni diverse) a un costo contenuto.

Un controllo sulla fonte: la tabella 1 di Szegedy et al. riporta per il modulo 3a circa 159 mila parametri e 128 milioni di operazioni. Le operazioni coincidono con il nostro conteggio; sui parametri otteniamo 163 328 senza bias e 163 696 con i bias, circa il 3% sopra la cifra arrotondata della tabella, e non so spiegare la differenza. Per il confronto che interessa qui, con e senza riduzioni, conta il rapporto tra i due conteggi fatti con lo stesso metodo, non la cifra assoluta.

Confrontare le due idee

  • ResNet: va in profondità; la scorciatoia somma l'ingresso all'uscita; la forma deve coincidere; i blocchi sono omogenei e semplici da replicare.
  • Inception: va in larghezza; i rami paralleli vengono concatenati; la rete sceglie la scala di ogni caratteristica; i blocchi sono più elaborati e ricchi di iperparametri (i canali di ciascun ramo).
  • In comune: la convoluzione 1×1 come strumento per gestire il numero di canali e il costo, e l'idea di dare alla rete più strade tra l'ingresso e l'uscita.

Il criterio che tiene insieme le voci è la stessa domanda: come dare al segnale più strade tra ingresso e uscita senza far esplodere il costo. ResNet risponde in profondità e con una somma, e per questo impone forme identiche; Inception risponde in larghezza e con una concatenazione, e per questo fa crescere i canali e ti obbliga a scegliere la larghezza di ogni ramo. Il conto degli iperparametri pesa nella scelta: il blocco residuale ne aggiunge uno solo (la proiezione, quando le forme cambiano), mentre il modulo Inception del listato ne ha sei, uno per ogni gruppo di canali.

Trade-off e trappole reali

  • Forme che non coincidono nella somma: se il ramo cambia i canali o la risoluzione e la scorciatoia no, la somma dà un errore di forma. Serve la proiezione 1×1 con lo stesso stride.
  • Sommare o concatenare: ResNet somma (le forme devono essere identiche); Inception e DenseNet concatenano (cresce il numero di canali). Confonderle produce errori o modelli diversi da quelli voluti.
  • Residuale senza normalizzazione: come visto, il segnale può esplodere nelle reti profonde; le ResNet usano batch normalization e inizializzazione attenta.
  • Pensare che più strati siano sempre meglio: la scorciatoia rende profonde reti addestrabili, non necessariamente migliori. Su dati piccoli una rete profonda fa overfitting come le altre.
  • Operazioni sul posto (in-place): x += F(x) invece di x = x + F(x) può rompere la backpropagation, perché sovrascrive un valore che il passaggio all'indietro deve rileggere.
  • Dimenticare che la scorciatoia ha parametri quando è una proiezione: nei conteggi vanno inclusi.
  • Riduzione esagerata nel bottleneck: ridurre i canali troppo toglie capacità; il rapporto 4:1 (256 → 64) è un valore di riferimento, non una legge.
  • Concatenare rami con risoluzioni diverse: i rami Inception devono mantenere la stessa risoluzione spaziale (padding «same») prima della concatenazione.

Tre trappole (la forma nella somma, le risoluzioni diverse nella concatenazione, le operazioni in place) bloccano il programma con un messaggio d'errore e le scopri alla prima esecuzione. Le altre cinque possono passare senza nessun errore e danno solo un modello peggiore del previsto. La più insidiosa è l'esplosione del gradiente: nell'esperimento di questa lezione la norma ha superato 45 000 con 50 strati senza alcun avviso, quindi misura la norma del gradiente del primo strato prima di lanciare un addestramento lungo.

Domande frequenti

La scorciatoia non rende il blocco inutile, se è l'identità? No: il ramo F può imparare qualunque correzione. La scorciatoia non toglie capacità; garantisce solo che, se la correzione migliore è piccola, la rete non deve faticare per ottenerla, e che il gradiente ha un percorso diretto.

Perché una convoluzione 1×1 è utile se non guarda i vicini? Perché mescola i canali, cioè le caratteristiche rilevate in ciascun punto, ed è l'unico strumento a basso costo per cambiare il numero di canali. Aggiunge anche una non linearità se è seguita da una ReLU.

ResNet o Inception: quale uso? Per un nuovo problema si parte quasi sempre da una ResNet preaddestrata (lezione sul transfer learning): è semplice, robusta e disponibile in tutte le librerie. Inception è storicamente importante e utile da conoscere per le sue idee, ma le varianti recenti sono per lo più basate su blocchi residuali.

Come leggere un confronto fra architetture

I confronti fra architetture sono un terreno fertile per conclusioni sbagliate, perché i modelli differiscono quasi sempre in più di una cosa alla volta. Prima di dire «la rete residuale è migliore», chiediti: hanno lo stesso numero di parametri? Se una ha il doppio dei pesi, la differenza può dipendere dalla capacità e non dalla struttura. Hanno lo stesso budget di addestramento (epoche, ottimizzatore, learning rate, regolarizzazione)? Un iperparametro scelto bene per una e male per l'altra è un confronto truccato senza volerlo. Sono state ripetute con più semi, riportando la variabilità? Differenze più piccole della deviazione tra semi non vanno interpretate.

È stata misurata la cosa giusta? In questa lezione la domanda era «la scorciatoia rende più facile addestrare una rete profonda?», e la misura giusta è la perdita di addestramento e il comportamento dei gradienti, non solo l'accuratezza sul test; un risultato in cui la scorciatoia non aiuta, o aiuta poco, va riportato com'è.

Infine, ricorda la scala del problema: i vantaggi dichiarati nella letteratura per reti con decine o centinaia di strati e milioni di immagini non si trasferiscono automaticamente a problemi piccoli come le cifre 8×8, dove una rete poco profonda può bastare. L'obiettivo di questi esperimenti non è confermare una tesi, ma mostrare il meccanismo (il cammino diretto dei gradienti, la riduzione dei canali con la 1×1) e darti il metodo per verificarlo sui tuoi dati.

Che cosa sai fare adesso

Sai spiegare perché una rete semplice con 20 strati non impara (accuratezza 0,10, perdita 2,3025 = ln 10) mentre la residuale sì, e sai scrivere la derivazione che lo motiva: il fattore 1 in ∂E/∂x_l porta il gradiente dall'uscita ai primi strati sommato e non moltiplicato. Sai anche il limite dell'esperimento: senza normalizzazione la norma del gradiente del primo strato è arrivata a oltre 45 000 con 50 strati, e scalare il ramo per 1/√L l'ha riportata a circa 0,7. Sai costruire un blocco con proiezione 1×1, contare i parametri di un bottleneck (69 632 contro 1 179 648, 16,9 volte meno) e di un modulo Inception con riduzioni (163 696 contro 393 472). Nella lezione successiva, dropout e batch normalization, vedi le due tecniche che stabilizzano l'addestramento di queste reti; per riusare una rete residuale già addestrata, vai al transfer learning.

Nella stessa serie rimandano a questa lezione anche: Leggere una CNN: feature map, global pooling e U-Net.

Fonti

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti