Immagina di scansionare una pagina con la lente d'ingrandimento. Puoi spostare la lente di un millimetro alla volta, e leggere ogni zona più volte, oppure di un centimetro, e farlo più in fretta ma saltando dei dettagli: questo è lo stride (il passo). Puoi appoggiare la pagina su un foglio più grande, con un margine bianco intorno, in modo che anche le parole sul bordo vengano guardate al centro della lente: questo è il padding (il riempimento). E quando hai finito puoi riassumere ogni zona con il dettaglio più evidente, buttando via il resto, per avere una mappa più piccola e più maneggevole: questo è il pooling. Tre operazioni semplici che, insieme alla convoluzione, compongono l'architettura di quasi ogni rete convolutiva.
Questa lezione presuppone la convoluzione con filtri e kernel e il calcolo del campo ricettivo che vi si trova. Il lavoro quotidiano che qui diventa preciso è prevedere la forma dei tensori in ogni punto di una rete: è dove si sbaglia più spesso, e un errore di forma si scopre soltanto quando la rete gira. Ricaviamo la formula, contiamo a mano i parametri di una rete in stile LeNet-5, il capostipite delle reti convolutive moderne, e vediamo quanto calcolo costano.
Una nota sul codice, perché cambia il modo di leggere i listati. Sulla macchina con cui sono stati eseguiti non c'è PyTorch, quindi niente di ciò che segue lo usa: le forme e i parametri sono verificati con una piccola implementazione in NumPy (convoluzione, ReLU, max pooling, flatten e strato denso, poche righe per classe). Le formule che ricavi valgono identiche in PyTorch, dove la documentazione di Conv2d riporta la stessa espressione per l'uscita; ma i numeri e i messaggi d'errore che leggi sotto sono quelli di NumPy 2.3 con Python 3.14, non di PyTorch.
Lo stride e il padding, con la formula derivata
Nella lezione precedente la finestra scorreva di un pixel alla volta, senza uscire dall'immagine, e l'uscita si restringeva di k − 1 pixel. Due manopole cambiano questo comportamento:
- Stride S: di quanti pixel si sposta la finestra tra una posizione e la successiva. Con S = 1 si guardano tutte le posizioni; con S = 2 si salta una posizione su due e l'uscita ha circa metà delle dimensioni per lato.
- Padding P: quanti pixel di «bordo» si aggiungono su ciascun lato dell'immagine prima di convolvere. Il valore più comune è zero (zero padding), e allarga l'immagine da H a H + 2P.
Le due manopole agiscono su cose diverse: lo stride decide quante posizioni della finestra esistono, il padding decide quanto è grande l'immagine su cui scorre. Lo stride riduce il lavoro e la risoluzione dell'uscita, il padding conserva i bordi e le dimensioni; nessuno dei due cambia il numero di parametri del filtro, che dipende soltanto da kernel e canali.
Ricaviamo la dimensione dell'uscita. Dopo il padding l'immagine ha lato H + 2P. L'angolo in alto a sinistra della finestra può stare nelle posizioni 0, S, 2S, ... finché la finestra resta dentro, cioè finché la sua posizione p soddisfa p + K ≤ H + 2P, quindi p ≤ H + 2P − K. Le posizioni valide sono 0, S, 2S, ..., fino al più grande multiplo di S non superiore a H + 2P − K. Quante sono? Sono ⌊(H + 2P − K)/S⌋ + 1 (il +1 conta la posizione 0):
H_out = ⌊ (H + 2P − K) / S ⌋ + 1
Casi notevoli:
P = 0, S = 1: H_out = H − K + 1 (la convoluzione «valida» della lezione precedente)
K dispari, P = (K−1)/2, S = 1: H_out = H (padding «same»: l'uscita ha la stessa dimensione dell'ingresso)
K = 2, S = 2, P = 0: H_out = ⌊(H − 2)/2⌋ + 1 = ⌊H/2⌋ (dimezza: tipico del pooling)
Il simbolo ⌊·⌋ è la parte intera inferiore: se la divisione non è esatta, i pixel che «avanzano» in fondo vengono semplicemente ignorati. Per non fidarci della derivazione, la mettiamo alla prova con un conteggio indipendente: costruiamo l'immagine con il padding, estraiamo tutte le finestre K×K con sliding_window_view di NumPy, teniamo una finestra ogni S e contiamo quante sono, senza mai usare la formula.
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
def dimensione_uscita(H, K, S=1, P=0):
return (H + 2 * P - K) // S + 1
def posizioni_finestra(H, K, S, P):
"""Conta le posizioni della finestra su un'immagine con zero padding, senza usare la formula."""
img = np.pad(np.zeros((H, H)), P)
return sliding_window_view(img, (K, K))[::S, ::S].shape[0]
controlli, corretti = 0, 0
for H in range(5, 20):
for K in (1, 2, 3, 5):
for S in (1, 2, 3):
for P in (0, 1, 2):
if H + 2 * P < K:
continue # il kernel non entra nemmeno
controlli += 1
corretti += (posizioni_finestra(H, K, S, P) == dimensione_uscita(H, K, S, P))
print("combinazioni verificate: %d formula corretta in %d" % (controlli, corretti))
print("\nH K S P uscita")
for H, K, S, P in ((28, 5, 1, 0), (28, 5, 1, 2), (32, 3, 2, 1), (224, 7, 2, 3), (7, 2, 2, 0)):
print("%3d %2d %2d %2d %d" % (H, K, S, P, dimensione_uscita(H, K, S, P)))
combinazioni verificate: 540 formula corretta in 540
H K S P uscita
28 5 1 0 24
28 5 1 2 28
32 3 2 1 16
224 7 2 3 112
7 2 2 0 3
Cosa fa il codice. posizioni_finestra costruisce un'immagine di zeri con np.pad, ne ricava tutte le finestre sovrapposte con sliding_window_view e con [::S, ::S] tiene quelle a distanza di S; la lunghezza dell'asse delle posizioni è la dimensione dell'uscita. Il quadruplo ciclo percorre 540 combinazioni valide di dimensione, kernel, stride e padding, escludendo quelle in cui il kernel non entra nemmeno nell'immagine, e la formula coincide con il conteggio in tutte e 540. Le righe finali sono esempi che incontrerai: 28 con kernel 5 e senza padding dà 24; con padding 2 resta 28 (padding «same»); 32 con kernel 3, stride 2 e padding 1 dà 16; 224 con kernel 7, stride 2 e padding 3 (il primo strato di molte reti per immagini) dà 112; e una finestra 2×2 con stride 2 su una mappa 7×7 dà 3, scartando l'ultima riga e l'ultima colonna. Il controllo è su una sola dimensione perché l'immagine è quadrata: per un'immagine rettangolare la formula si applica separatamente ad altezza e larghezza.
Perché il padding? Due ragioni. La prima è mantenere le dimensioni: senza padding l'immagine si restringe a ogni strato e dopo molti strati non resta nulla. La seconda è trattare i bordi come il resto: un pixel d'angolo, senza padding, partecipa a una sola finestra, uno centrale a nove (con un kernel 3×3); il padding riequilibra il contributo. Il padding con zeri introduce però bordi artificiali (una cornice nera) che il filtro può scambiare per bordi veri; esistono varianti (riflessione, replicazione) che riducono il problema, a costo di una piccola complessità.
Il pooling: riassumere una finestra
Il pooling riduce le dimensioni di una mappa riassumendo ogni finestra con un solo numero, senza parametri da imparare. Le due varianti:
- Max pooling: il massimo della finestra. Dice «il motivo è presente da qualche parte qui dentro», senza dire dove esattamente.
- Average pooling: la media della finestra. Dice «quanto, in media, è presente il motivo».
I due operatori rispondono a domande diverse: il massimo conserva il segnale più forte e butta la sua posizione esatta dentro la finestra, la media conserva l'intensità complessiva e smorza i picchi. Nessuno dei due ha pesi da imparare, quindi il pooling aggiunge zero parametri e serve solo a restringere la mappa.
La scelta più comune è una finestra 2×2 con stride 2, che dimezza altezza e larghezza (e quindi divide per quattro il numero di valori di ogni mappa).
Nel disegno, ciascuna delle quattro finestre 2×2 colorate viene sostituita dal suo massimo (in rosso): 4, 5, 3, 7. Il pooling lavora su ogni canale in modo indipendente, quindi non cambia il numero di canali.
Che cosa succede ai gradienti
Il max pooling non ha parametri, ma deve far passare la backpropagation. La regola segue dalla definizione: l'uscita è il massimo di quattro valori, quindi dipende (localmente) solo dal valore che è il massimo; spostando di poco gli altri tre l'uscita non cambia. Dunque il gradiente che arriva sull'uscita viene mandato interamente al pixel che ha vinto, e agli altri tre va zero. Nell'average pooling l'uscita dipende da tutti i quattro valori in egual misura (derivata 1/4 ciascuno): il gradiente viene diviso in parti uguali. Senza un framework di differenziazione automatica a disposizione, controlliamo la regola nel modo più diretto: stimiamo la derivata con le differenze finite (si sposta un pixel per volta di ±10-6 e si guarda quanto cambia la somma delle uscite) e la confrontiamo con la regola. Nello stesso listato implementiamo il max pooling a mano e misuriamo un effetto spesso citato, l'invarianza a piccole traslazioni.
import numpy as np
def max_pooling(I, k=2, s=2):
H = (I.shape[0] - k) // s + 1
W = (I.shape[1] - k) // s + 1
return np.array([[I[i * s:i * s + k, j * s:j * s + k].max() for j in range(W)] for i in range(H)])
invariati, cambiati_prima, prove = 0, 0, 0
for r in range(8):
for c in range(7):
a = np.zeros((8, 8)); a[r, c] = 1.0
b = np.zeros((8, 8)); b[r, c + 1] = 1.0
prove += 1
cambiati_prima += not np.array_equal(a, b)
invariati += np.array_equal(max_pooling(a), max_pooling(b))
print("spostando il motivo di 1 pixel: %d prove su %d cambiano l'immagine, solo %d su %d cambiano l'uscita del pooling" % (
cambiati_prima, prove, prove - invariati, prove))
x = np.array([[1., 3, 2, 0], [4, 2, 1, 5], [0, 1, 7, 2], [3, 2, 1, 1]])
def gradiente_max_pooling(x, k=2):
"""Derivata della somma delle uscite rispetto a x: 1 sul pixel che ha vinto, 0 altrove."""
g = np.zeros_like(x)
for i in range(0, x.shape[0], k):
for j in range(0, x.shape[1], k):
blocco = x[i:i + k, j:j + k]
r, c = np.unravel_index(blocco.argmax(), blocco.shape)
g[i + r, j + c] = 1.0
return g
def media_pooling(I, k=2, s=2):
H = (I.shape[0] - k) // s + 1
W = (I.shape[1] - k) // s + 1
return np.array([[I[i * s:i * s + k, j * s:j * s + k].mean() for j in range(W)] for i in range(H)])
def somma_pooling(x, f=max_pooling):
return f(x).sum()
def gradiente_numerico(x, f, eps=1e-6):
g = np.zeros_like(x)
for i in range(x.shape[0]):
for j in range(x.shape[1]):
piu = x.copy(); piu[i, j] += eps
meno = x.copy(); meno[i, j] -= eps
g[i, j] = (somma_pooling(piu, f) - somma_pooling(meno, f)) / (2 * eps)
return g
# controllo con differenze finite: il gradiente analitico deve coincidere
numerico = gradiente_numerico(x, max_pooling)
numerico_media = gradiente_numerico(x, media_pooling)
print("\nuscita del max pooling:\n", max_pooling(x))
print("gradiente rispetto all'ingresso (1 solo dove c'era il massimo):\n", gradiente_max_pooling(x))
print("coincide con le differenze finite:", np.allclose(gradiente_max_pooling(x), numerico, atol=1e-6))
print("gradiente con average pooling (differenze finite, arrotondato a 2 decimali):\n", numerico_media.round(2))
spostando il motivo di 1 pixel: 56 prove su 56 cambiano l'immagine, solo 24 su 56 cambiano l'uscita del pooling
uscita del max pooling:
[[4. 5.]
[3. 7.]]
gradiente rispetto all'ingresso (1 solo dove c'era il massimo):
[[0. 0. 0. 0.]
[1. 0. 0. 1.]
[0. 0. 1. 0.]
[1. 0. 0. 0.]]
coincide con le differenze finite: True
gradiente con average pooling (differenze finite, arrotondato a 2 decimali):
[[0.25 0.25 0.25 0.25]
[0.25 0.25 0.25 0.25]
[0.25 0.25 0.25 0.25]
[0.25 0.25 0.25 0.25]]
Cosa fa il codice. max_pooling scorre finestre 2×2 con stride 2 e prende il massimo di ciascuna. Il primo esperimento mette un singolo pixel acceso in tutte le 56 posizioni possibili e lo sposta di una colonna a destra: l'immagine cambia sempre (56 su 56), ma l'uscita del pooling cambia solo in 24 casi su 56 (i casi in cui lo spostamento attraversa il confine tra due finestre), cioè rimane identica in 32 casi (57%). Il pooling dà quindi un'invarianza parziale alle piccole traslazioni: ciò che è entro la stessa finestra viene «assorbito». Non è un'invarianza completa, e uno spostamento maggiore della finestra cambierà comunque l'uscita. Nella seconda parte il gradiente del max pooling è 1 solo nella posizione dei massimi (4, 5, 3 e 7) e zero ovunque altrove, e coincide con le differenze finite; con l'average pooling le differenze finite danno 0,25 dappertutto.
Una cautela sul gradiente: se in una finestra due valori sono pari al massimo, la funzione non è derivabile in quel punto e i framework scelgono una convenzione (qui argmax prende il primo); nei dati reali succede di rado, ma con la ReLU, che produce molti zeri, in una finestra tutta a zero capita.
Una CNN completa: forme e parametri, strato per strato
Mettiamo insieme gli ingredienti in un'architettura classica in stile LeNet-5 per cifre manoscritte 28×28 in scala di grigi: due blocchi «convoluzione, ReLU, pooling» seguiti da tre strati densi. Il passaggio dal mondo spaziale (immagine) a quello dei vettori si chiama flatten (appiattimento): il tensore (N, C, H, W) diventa una matrice (N, C·H·W).
Il disegno riporta le forme per un solo esempio: 1@28×28 significa 1 canale di 28×28 pixel. Ogni convoluzione cambia il numero di canali (1 → 6 → 16) e ogni pooling dimezza la risoluzione spaziale; alla fine 16 canali di 5×5 diventano un vettore di 400 numeri, che alimenta i tre strati densi. Per verificare ogni forma e ogni conteggio di parametri servono gli strati veri, quindi il listato che segue costruisce la piccola libreria in NumPy che useremo anche nei due esempi successivi (se li esegui, mettili nello stesso file). La convoluzione è un einsum sulle finestre estratte con sliding_window_view; i pesi sono casuali con seme fisso, perché per forme e conteggi i valori non contano; parametri() restituisce il numero di elementi di pesi e bias, che è esattamente ciò che in PyTorch conta numel().
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
rng = np.random.default_rng(0)
class Conv2d:
def __init__(self, c_in, c_out, k, padding=0):
self.W = rng.normal(0, 0.1, (c_out, c_in, k, k)); self.b = np.zeros(c_out); self.p = padding
def __call__(self, x): # x: (N, C, H, W)
x = np.pad(x, ((0, 0), (0, 0), (self.p, self.p), (self.p, self.p)))
k = self.W.shape[-1]
finestre = sliding_window_view(x, (k, k), axis=(2, 3)) # (N, C, H', W', k, k)
return np.einsum("nchwij,ocij->nohw", finestre, self.W) + self.b[None, :, None, None]
def parametri(self): return self.W.size + self.b.size
class ReLU:
def __call__(self, x): return np.maximum(x, 0)
def parametri(self): return 0
class MaxPool2d:
def __init__(self, k): self.k = k
def __call__(self, x):
n, c, h, w = x.shape; k = self.k
return x[:, :, :h // k * k, :w // k * k].reshape(n, c, h // k, k, w // k, k).max(axis=(3, 5))
def parametri(self): return 0
class Flatten:
def __call__(self, x): return x.reshape(x.shape[0], -1)
def parametri(self): return 0
class Linear:
def __init__(self, n_in, n_out):
self.W = rng.normal(0, 0.1, (n_in, n_out)); self.b = np.zeros(n_out)
def __call__(self, x): return x @ self.W + self.b
def parametri(self): return self.W.size + self.b.size
def Sequential(*strati): return list(strati)
rete = Sequential(
Conv2d(1, 6, 5, padding=2), ReLU(), MaxPool2d(2), # 1x28x28 -> 6x28x28 -> 6x14x14
Conv2d(6, 16, 5), ReLU(), MaxPool2d(2), # -> 16x10x10 -> 16x5x5
Flatten(), # -> 400
Linear(400, 120), ReLU(),
Linear(120, 84), ReLU(),
Linear(84, 10))
z = np.zeros((2, 1, 28, 28)) # un batch di 2 immagini
totale = 0
print("strato forma d'uscita (batch di 2) parametri")
for strato in rete:
z = strato(z)
n = strato.parametri()
totale += n
print("%-20s %-28s %9d" % (type(strato).__name__, tuple(z.shape), n))
print("totale parametri: %d" % totale)
fc1 = 400 * 120 + 120
print("quota dei parametri nel primo strato denso: %.1f%% nelle due convoluzioni: %.1f%%" % (100 * fc1 / totale, 100 * (156 + 2416) / totale))
strato forma d'uscita (batch di 2) parametri
Conv2d (2, 6, 28, 28) 156
ReLU (2, 6, 28, 28) 0
MaxPool2d (2, 6, 14, 14) 0
Conv2d (2, 16, 10, 10) 2416
ReLU (2, 16, 10, 10) 0
MaxPool2d (2, 16, 5, 5) 0
Flatten (2, 400) 0
Linear (2, 120) 48120
ReLU (2, 120) 0
Linear (2, 84) 10164
ReLU (2, 84) 0
Linear (2, 10) 850
totale parametri: 61706
quota dei parametri nel primo strato denso: 78.0% nelle due convoluzioni: 4.2%
Cosa fa il codice. Sequential è qui una semplice lista di strati; il ciclo la attraversa strato per strato con un batch di zeri, stampando la forma di ogni uscita e quanti parametri ha lo strato. I conti si possono ricostruire a mano con le formule della lezione precedente: la prima convoluzione ha 6·(1·5·5 + 1) = 156 parametri; la seconda 16·(6·5·5 + 1) = 2 416; il primo strato denso 400·120 + 120 = 48 120; il secondo 120·84 + 84 = 10 164; l'ultimo 84·10 + 10 = 850. Totale: 61 706. Il pooling, la ReLU e il flatten non hanno parametri. La riga finale mette in luce un fatto strutturale: il 78% dei parametri sta nel primo strato denso, e le due convoluzioni insieme ne hanno solo il 4,2%. Gli strati convolutivi sono parsimoniosi grazie alla condivisione dei pesi; è il collegamento denso dopo il flatten, che deve connettere ogni valore a ogni neurone, a pesare. Per questo le architetture moderne evitano gli strati densi voluminosi e usano un global pooling (lo trovi nella lezione su feature map, global pooling e U-Net).
Parametri e calcolo non sono la stessa cosa
Un'altra misura del «costo» di una rete è il numero di moltiplicazioni-addizioni (MAC, multiply-accumulate) per un'immagine. Una convoluzione applica lo stesso kernel in molte posizioni: ha pochi parametri ma molte operazioni. Per uno strato convolutivo, le MAC sono H_out · W_out · C_out · (C_in · k · k); per uno denso, n_in · n_out.
Il listato è puro Python: applica le due formule alle cinque parti con parametri.
mac = {
"conv1 (1 -> 6, 5x5, uscita 28x28)": 28 * 28 * 6 * (1 * 5 * 5),
"conv2 (6 -> 16, 5x5, uscita 10x10)": 10 * 10 * 16 * (6 * 5 * 5),
"denso 400 -> 120": 400 * 120,
"denso 120 -> 84": 120 * 84,
"denso 84 -> 10": 84 * 10,
}
totale_mac = sum(mac.values())
for nome, valore in mac.items():
print("%-38s %9d MAC (%4.1f%% del calcolo)" % (nome, valore, 100 * valore / totale_mac))
print("totale: %d MAC per immagine -> %.1f MAC per parametro" % (totale_mac, totale_mac / 61706))
conv1 (1 -> 6, 5x5, uscita 28x28) 117600 MAC (28.2% del calcolo)
conv2 (6 -> 16, 5x5, uscita 10x10) 240000 MAC (57.6% del calcolo)
denso 400 -> 120 48000 MAC (11.5% del calcolo)
denso 120 -> 84 10080 MAC ( 2.4% del calcolo)
denso 84 -> 10 840 MAC ( 0.2% del calcolo)
totale: 416520 MAC per immagine -> 6.8 MAC per parametro
Cosa fa il codice. Le due convoluzioni contengono il 4% dei parametri ma l'86% del calcolo (circa 358 000 MAC su 417 000), mentre il primo strato denso contiene il 78% dei parametri ma l'11% del calcolo. È una dualità importante nelle reti convolutive: la memoria dei pesi è dominata dagli strati densi, il tempo di calcolo dagli strati convolutivi, e le due ottimizzazioni (comprimere il modello, accelerarlo) agiscono su parti diverse della rete.
La trappola classica: la dimensione dopo il flatten
Il numero 400 in Linear(400, 120) non è arbitrario: è 16·5·5, e dipende da tutta la catena di convoluzioni, padding e pooling che lo precede. Se cambi la risoluzione dell'immagine, il padding o un kernel, quel numero cambia, e la rete smette di funzionare con un errore di forma. Un modo comodo di calcolarlo senza sbagliare è farlo fare alla rete, con un passaggio di prova su un tensore di zeri. Il listato riusa le classi del blocco precedente.
import numpy as np
exec(open("c.py").read().split("rete = Sequential(")[0])
parte_conv = Sequential(Conv2d(1, 6, 5, padding=2), ReLU(), MaxPool2d(2),
Conv2d(6, 16, 5), ReLU(), MaxPool2d(2), Flatten())
def esegui(strati, x):
for s in strati:
x = s(x)
return x
for lato in (28, 32, 40):
n_flatten = esegui(parte_conv, np.zeros((1, 1, lato, lato))).shape[1]
r = ((lato + 4 - 5 + 1) // 2 - 4) // 2
print("immagine %dx%d -> dopo il flatten: %d valori (formula: 16 * %d * %d = %d)" % (lato, lato, n_flatten, r, r, 16 * r ** 2))
try:
esegui(parte_conv + [Linear(400, 10)], np.zeros((1, 1, 32, 32))) # il 400 vale solo per 28x28
except ValueError as e:
print("\nerrore con immagini 32x32 e Linear(400, ...):", e)
immagine 28x28 -> dopo il flatten: 400 valori (formula: 16 * 5 * 5 = 400)
immagine 32x32 -> dopo il flatten: 576 valori (formula: 16 * 6 * 6 = 576)
immagine 40x40 -> dopo il flatten: 1024 valori (formula: 16 * 8 * 8 = 1024)
errore con immagini 32x32 e Linear(400, ...): matmul: Input operand 1 has a mismatch in its core dimension 0, with gufunc signature (n?,k),(k,m?)->(n?,m?) (size 400 is different from 576)
Cosa fa il codice. Il primo ciclo manda immagini di lato 28, 32 e 40 attraverso la parte convolutiva e legge quanti valori escono dal flatten: 400, 576 e 1024. Confrontali con la formula: con il pooling che dimezza due volte e le convoluzioni che tolgono 4 pixel, il lato finale è ((lato + 4 − 5 + 1)/2 − 4)/2. L'ultima prova aggiunge Linear(400, 10) e la applica a immagini 32×32: NumPy si ferma con un errore sulle dimensioni di matmul («size 400 is different from 576»), che dice chiaramente che il flatten produce 576 valori, non 400. Lo stesso sbaglio in PyTorch ha un messaggio formulato diversamente (non l'ho riprodotto, per quanto detto in apertura); il framework offre anche nn.LazyLinear, che deduce la dimensione di ingresso al primo passaggio; in alternativa si calcola con un passaggio di prova come sopra, evitando di fidarsi del calcolo a mente.
Un secondo esempio svolto a mano: immagini a colori 32×32
Esercitiamo il metodo su una rete per immagini a colori di 32×32 pixel e 10 classi, con tre blocchi «convoluzione 3×3 con padding 1, ReLU, pooling 2×2» che raddoppiano i canali (32, 64, 128) mentre dimezzano la risoluzione. Il procedimento è sempre lo stesso: per ogni strato si applica la formula della dimensione e quella dei parametri.
- Ingresso: 3 canali, 32×32.
- Conv 1 (3 → 32, k=3, P=1): lato (32 + 2 − 3)/1 + 1 = 32, uscita 32@32×32. Parametri: 32·(3·9 + 1) = 896. Pool: 32@16×16.
- Conv 2 (32 → 64): lato 16, uscita 64@16×16. Parametri: 64·(32·9 + 1) = 18 496. Pool: 64@8×8.
- Conv 3 (64 → 128): lato 8, uscita 128@8×8. Parametri: 128·(64·9 + 1) = 73 856. Pool: 128@4×4.
- Flatten: 128·4·4 = 2 048 valori. Denso 2 048 → 10: 2 048·10 + 10 = 20 490 parametri.
- Totale: 896 + 18 496 + 73 856 + 20 490 = 113 738 parametri.
Leggi il conto per colonne: la dimensione spaziale non cambia nelle convoluzioni (padding 1 con kernel 3 conserva il lato) e si dimezza solo nei pooling, mentre i parametri crescono con il prodotto fra canali d'ingresso e d'uscita. Il fatto che conta è nell'ultima voce: dopo tre dimezzamenti restano 4×4 posizioni, e il flatten porta 2 048 valori nello strato denso, che con i suoi 20 490 parametri pesa meno dell'ultima convoluzione (73 856).
Verifichiamo il conto con il codice (stesse classi del blocco precedente), e osserviamo la regola pratica che questa architettura incarna: man mano che la risoluzione spaziale si dimezza (riducendo di un fattore 4 il numero di posizioni), si raddoppiano i canali, così che il calcolo per strato resti dello stesso ordine di grandezza e la rete possa rappresentare caratteristiche sempre più numerose e astratte.
import numpy as np
exec(open("c.py").read().split("rete = Sequential(")[0])
cifar_style = Sequential(
Conv2d(3, 32, 3, padding=1), ReLU(), MaxPool2d(2),
Conv2d(32, 64, 3, padding=1), ReLU(), MaxPool2d(2),
Conv2d(64, 128, 3, padding=1), ReLU(), MaxPool2d(2),
Flatten(), Linear(128 * 4 * 4, 10))
z = np.zeros((1, 3, 32, 32))
for strato in cifar_style:
z = strato(z)
if isinstance(strato, (Conv2d, MaxPool2d, Flatten, Linear)):
print("%-10s -> %-18s parametri %7d" % (type(strato).__name__, tuple(z.shape[1:]), strato.parametri()))
print("totale: %d (a mano: 896 + 18496 + 73856 + 20490 = %d)" % (sum(s.parametri() for s in cifar_style), 896 + 18496 + 73856 + 20490))
Conv2d -> (32, 32, 32) parametri 896
MaxPool2d -> (32, 16, 16) parametri 0
Conv2d -> (64, 16, 16) parametri 18496
MaxPool2d -> (64, 8, 8) parametri 0
Conv2d -> (128, 8, 8) parametri 73856
MaxPool2d -> (128, 4, 4) parametri 0
Flatten -> (2048,) parametri 0
Linear -> (10,) parametri 20490
totale: 113738 (a mano: 896 + 18496 + 73856 + 20490 = 113738)
Cosa fa il codice. Attraversa la rete con un'immagine di zeri e stampa, per ogni strato con forma o parametri interessanti, la forma dell'uscita (senza la dimensione del batch) e il numero di parametri. I numeri coincidono con il calcolo a mano riga per riga. Osserva anche che l'ultimo blocco convolutivo (73 856 parametri) è il più «costoso» in parametri tra le convoluzioni perché moltiplica il numero di canali d'ingresso (64) per quello d'uscita (128): il costo di una convoluzione cresce con il prodotto C_in·C_out, ed è il motivo per cui nei livelli profondi con molti canali si cercano strutture più economiche (le convoluzioni 1×1 della lezione su reti residuali e Inception).
Regole pratiche per disegnare un'architettura
- Inizia con kernel 3×3 e padding «same»: sono la scelta più comune e quella che rende più semplice il calcolo delle forme (le dimensioni cambiano solo con i pooling o gli stride).
- Dimezza la risoluzione, raddoppia i canali: il numero di valori per strato (canali × altezza × larghezza) cresce o cala in modo controllato, e la rete costruisce caratteristiche più astratte man mano che il campo ricettivo si allarga.
- Riduci prima di appiattire: porta la mappa a una dimensione piccola (4×4, 7×7) prima del flatten, per non avere uno strato denso con milioni di parametri.
- Calcola il campo ricettivo: nell'ultimo strato convolutivo deve essere paragonabile alla dimensione degli oggetti da riconoscere. Con i tre blocchi dell'esempio il campo ricettivo all'ultimo pooling è di 22 pixel (puoi verificarlo con la funzione
campo_ricettivodella lezione precedente), circa due terzi del lato di un'immagine 32×32: sufficiente per oggetti di media grandezza; per immagini grandi servono più strati o stride maggiori. - Verifica sempre con un passaggio di prova prima di addestrare: forme, numero di parametri, tempo di un passaggio.
Le cinque regole sono euristiche, non teoremi: nascono dall'osservare cosa funziona nelle architetture classiche e hanno controesempi (reti con kernel 5×5 nel primo strato, canali che non raddoppiano a ogni passo). Usale come punto di partenza per il primo tentativo e correggile con una misura: la regola sul campo ricettivo, per esempio, la verifichi con la funzione della lezione precedente (campo_ricettivo([(3, 1), (2, 2)] * 3) dà 22, calcolato per questo articolo), mentre le regole sul flatten e sul passaggio di prova le verifichi con i listati sopra.
Tre esercizi per fissare le idee
Esercizio 1. Un'immagine 64×64 passa per una convoluzione 5×5 con stride 1 e padding 0, poi un max pooling 2×2 con stride 2. Qual è la dimensione dell'uscita? (Convoluzione: 64 − 5 + 1 = 60; pooling: 60/2 = 30.)
Esercizio 2. Quanti parametri ha uno strato Conv2d(64, 128, kernel_size=3)? E uno Conv2d(64, 128, kernel_size=1)? (128·(64·9 + 1) = 73 856 e 128·(64·1 + 1) = 8 320: il kernel 1×1 costa circa un nono, ma non guarda i pixel vicini; è usato per cambiare il numero di canali.)
Esercizio 3. Modifica la rete a colori sopra eliminando l'ultimo pooling: quanti valori escono dal flatten e quanti parametri ha il nuovo strato denso? (128·8·8 = 8 192 valori; 8 192·10 + 10 = 81 930 parametri: quasi quattro volte quelli di prima, solo per un pooling in meno.)
Altri modi di ridurre la risoluzione
- Convoluzione con stride 2: fa la riduzione di dimensione con un filtro appreso invece di una regola fissa come il massimo. Molte architetture moderne rinunciano al pooling in favore di convoluzioni con stride.
- Convoluzione dilatata (dilation): spazia i pesi del kernel (per esempio un 3×3 con un buco tra i pesi) per allargare il campo ricettivo senza più parametri. La dimensione effettiva del kernel diventa K + (K − 1)(d − 1) con dilatazione d, e la formula dell'uscita si aggiorna di conseguenza.
- Global pooling: un pooling con finestra grande quanto tutta la mappa; riduce ogni canale a un solo numero. Lo vedrai nella lezione su feature map e global pooling.
Sono tre modi diversi di rispondere a un problema diverso: lo stride 2 sostituisce il pooling con un filtro che impara come riassumere, la dilatazione allarga il campo ricettivo senza ridurre la risoluzione, il global pooling elimina del tutto la dimensione spaziale. Nessuno è «migliore»; la scelta dipende da cosa deve sopravvivere alla riduzione. Se serve la posizione (segmentazione) si evita di buttarla; se serve solo una decisione per immagine si può buttarla senza rimpianti.
Trade-off e trappole reali
- Pooling e posizione: il pooling scarta informazione sulla posizione precisa. È un vantaggio se interessa «c'è un occhio» e uno svantaggio se interessa «dov'è l'occhio» (segmentazione, localizzazione), dove si usano strategie diverse (lezione su U-Net).
- Stride maggiore del kernel: se S > K alcuni pixel non vengono mai visti da nessuna finestra.
- Dimensioni dispari e floor: con un'immagine 7×7 e pooling 2×2 si perde l'ultima riga e colonna. Una scelta di dimensioni che si dimezzano esattamente (28 → 14 → 7) evita questo effetto, mentre 7 → 3 (e poi 3 → 1) elimina informazione.
- Padding mancante: in PyTorch il default di
Conv2dèpadding=0(letto nel sorgente del modulo, vedi Fonti). Se ti aspetti l'uscita della stessa dimensione devi chiederlo: con kernel dispari e stride 1 bastapadding=(K-1)//2, eConv2daccetta anche la stringa'same'(solo con stride 1). - Strati densi giganti dopo il flatten: dominano i parametri (78% nell'esempio) e favoriscono l'overfitting.
- Confondere parametri con costo di calcolo: le convoluzioni hanno pochi parametri ma molta computazione.
- Calcolare a mente le dimensioni invece di verificarle: un passaggio di prova con tensori di zeri costa un secondo e evita ore di debug.
- Max pooling e ReLU: l'ordine tra le due non cambia il risultato, perché entrambe sono monotone (il massimo di valori con ReLU è la ReLU del massimo); per convenzione si mette prima la ReLU.
Le trappole hanno un'origine comune: la forma di un tensore è una conseguenza di cinque numeri (dimensione, kernel, stride, padding, pooling) e basta cambiarne uno perché tutto ciò che sta a valle cambi. Per questo conviene far calcolare le forme alla rete con un passaggio di prova e non portarsele in testa; quello che resta da giudicare a occhio sono i trade-off, cioè quanta informazione sulla posizione e quanti parametri sei disposto a pagare.
Domande frequenti
Meglio il max pooling o la media? Il max è la scelta più comune negli strati intermedi, perché preserva i segnali forti (la presenza di una caratteristica). La media, applicata a tutta la mappa (global average pooling), è quella usata prima dell'ultimo strato nelle architetture moderne.
Perché la finestra di pooling è quasi sempre 2×2? Perché è il minimo che riduce la risoluzione e conserva abbastanza informazione. Finestre più grandi riducono troppo in fretta.
Quanti strati convolutivi servono? Dipende dalla complessità del compito: LeNet-5 ne ha 2 per le cifre; reti per immagini naturali ne hanno decine (lezioni successive).
Il padding con zeri è sempre una buona idea? È la scelta di default e funziona bene, ma introduce una cornice artificiale. Per applicazioni sensibili ai bordi si valutano la riflessione o la replicazione.
Che cosa sai fare adesso
Sai calcolare la dimensione di uscita di una convoluzione, ⌊(H + 2P − K)/S⌋ + 1, e l'hai vista coincidere con un conteggio diretto delle finestre su 540 combinazioni. Sai che il pooling riassume ogni finestra senza parametri, che nel max pooling il gradiente va solo al massimo (verificato con le differenze finite) e che l'invarianza alle traslazioni che offre è parziale: 24 casi su 56 cambiano l'uscita. Sai contare forme e parametri di una CNN in stile LeNet-5 (61 706, di cui il 78% nel primo strato denso, mentre l'86% delle moltiplicazioni sta nelle convoluzioni) e far calcolare alla rete la dimensione dopo il flatten. Nella lezione successiva, addestrare una CNN in stile LeNet-5 con PyTorch, quella stessa rete impara da immagini vere; se vuoi rivedere come si ottiene il campo ricettivo, torna alla lezione sulla convoluzione.
Fonti
- V. Dumoulin e F. Visin, A guide to convolution arithmetic for deep learning (arXiv:1603.07285) - la relazione fra dimensione d'ingresso, kernel, padding, stride e dimensione d'uscita per convoluzioni e pooling, da cui il controllo della formula; scheda dell'abstract letta, consultata il 2026-11-12
- PyTorch, sorgente di torch/nn/modules/conv.py (classe Conv2d) - la formula dell'uscita con il floor, il default
padding=0estride=1e la stringa'same', letti nel codice, consultato il 2026-11-12 - NumPy: numpy.lib.stride_tricks.sliding_window_view - l'estrazione delle finestre usata sia nel conteggio sia nella convoluzione dei listati, consultata il 2026-11-12
- Y. LeCun, L. Bottou, Y. Bengio, P. Haffner, Gradient-based learning applied to document recognition, Proceedings of the IEEE, 86(11), 1998 - l'architettura LeNet-5 a cui la rete di questo articolo si ispira (la rete qui è una variante semplificata, con ReLU e max pooling; articolo non riletto online per questo lavoro)
Commenti
Caricamento commenti...
Accedi per commentare