Salta al contenuto principale

49 - La convoluzione: filtri, kernel e perché guardare localmente

Perché uno strato denso non basta per le immagini: la convoluzione calcolata a mano, un filtro di Sobel appreso col gradiente e le forme di uno strato.

Cosa ne pensi di questo articolo?

49 - La convoluzione: filtri, kernel e perché guardare localmente
Machine Learning Engineerfedericocalo.dev

In Breve

Perché uno strato denso non basta per le immagini: la convoluzione calcolata a mano, un filtro di Sobel appreso col gradiente e le forme di uno strato.

Per cercare una parola scritta in piccolo su una pagina non leggi tutto il foglio in un colpo: prendi una lente d'ingrandimento e la fai scorrere lungo il testo. In ogni posizione la lente ti mostra una piccola finestra, e tu verifichi se lì dentro c'è quello che cerchi. Una convoluzione fa esattamente questo su un'immagine: una piccola finestra (il filtro o kernel) scorre su tutta l'immagine e in ogni posizione risponde con un numero, alto se in quel punto c'è il motivo che il filtro cerca (un bordo, un angolo, una macchia), basso se non c'è. L'insieme di tutte le risposte è una nuova immagine, la mappa delle caratteristiche.

Le reti convolutive sono la risposta alla domanda: come si fa a far vedere una rete neurale? Nelle lezioni precedenti hai addestrato una rete densa su cifre da 8×8 pixel, e ha funzionato (circa il 96% di accuratezza). Ma con immagini più grandi la strategia densa non scala e, soprattutto, ignora la cosa più importante di un'immagine: che i pixel vicini sono correlati. In questa lezione capisci perché, definisci la convoluzione con precisione, la calcoli a mano e a codice, scopri che un filtro non va progettato ma imparato con lo stesso gradiente che conosci, e trovi tutti i conti (forme, parametri, campo ricettivo) che userai nelle prossime lezioni.

Perché uno strato denso non basta per le immagini

Una fotografia a colori di 224×224 pixel ha 224·224·3 = 150 528 numeri. Se colleghi questi ingressi a uno strato denso di 1000 neuroni, servono 150 528·1000 + 1000 = 150 529 000 parametri, circa 150 milioni, in un solo strato. Il problema non è solo di costo. Ci sono due difetti più profondi.

Il primo è che uno strato denso ignora la vicinanza: per lui i pixel sono una lista di numeri senza ordine, e scambiare le colonne di X in modo coerente tra tutti gli esempi non cambierebbe nulla per l'apprendimento, mentre in un'immagine contano gli schemi locali (un bordo è una differenza tra pixel adiacenti). Il secondo è che non riusa ciò che impara: se la rete impara a riconoscere un occhio in alto a sinistra, non sa riconoscerlo in basso a destra, e dovrebbe impararlo di nuovo, con pesi diversi, per ogni posizione.

Le reti convolutive risolvono entrambi i difetti con tre idee che vale la pena nominare e ricordare. Le tre idee si leggono insieme: la prima restringe lo sguardo di ogni neurone, la seconda lo riusa ovunque, e la terza è la conseguenza che si osserva sull'uscita.

  1. Connettività locale (campo ricettivo locale): ogni neurone guarda soltanto una piccola regione dell'ingresso, per esempio 3×3 pixel, non tutta l'immagine.
  2. Condivisione dei pesi: lo stesso insieme di pesi (il kernel) viene usato in tutte le posizioni. Un solo rilevatore di bordi verticali, applicato dappertutto.
  3. Equivarianza alla traslazione: se sposti il motivo nell'immagine, la risposta si sposta della stessa quantità. Un bordo in un altro punto produce lo stesso segnale in un altro punto.

Le idee non sono nate con il deep learning: la connettività locale e la gerarchia di rilevatori comparivano nel «neocognitron» di K. Fukushima (1980), e l'addestramento con backpropagation di una rete convolutiva su cifre manoscritte è del gruppo di Y. LeCun (1989, poi LeNet-5, 1998), le fonti primarie da cui parte questo modulo.

L'immagine come tensore

Un'immagine in scala di grigi è una matrice di forma (H, W): H righe di pixel, W colonne, ciascun valore l'intensità (in genere un intero da 0 a 255 se è memorizzata con 8 bit). Un'immagine a colori ha tre canali (rosso, verde, blu): è un tensore con tre matrici sovrapposte. Per un batch di N immagini si aggiunge una dimensione davanti. Esistono due convenzioni sull'ordine, e conoscerle evita errori ricorrenti.

  • Canali per primi (la convenzione che uso in questa lezione, e quella di PyTorch secondo la sua documentazione): (N, C, H, W), cioè batch, canali, altezza, larghezza.
  • Canali per ultimi (TensorFlow/Keras per default, e la maggior parte delle librerie di immagini come PIL e matplotlib): (N, H, W, C).

Un'altra abitudine: i pixel sono interi da 0 a 255, ma la rete vuole numeri in virgola mobile di scala moderata. Si dividono per 255 (valori in [0, 1]) e spesso si standardizzano. Più avanti vedrai una trappola concreta di questo tipo.

Che cos'è una convoluzione

Sia I un'immagine (H × W) e K un kernel (k × k, con k dispari, per esempio 3). L'uscita nella posizione (i, j) è la somma dei prodotti tra il kernel e la finestra k × k dell'immagine che parte in (i, j):

O[i, j] = Σ_{u=0..k−1} Σ_{v=0..k−1}  K[u, v] · I[i + u, j + v]

Una precisazione di nomenclatura che spesso confonde. Questa operazione, in matematica, si chiama correlazione incrociata (cross-correlation); la convoluzione matematica rigorosa ribalta il kernel (usa I[i − u, j − v]). Le librerie di deep learning implementano la prima e la chiamano «convoluzione». La differenza non conta: il kernel è un insieme di numeri che la rete impara, e se la matematica richiedesse un ribaltamento la rete imparerebbe il kernel ribaltato. Seguiamo la convenzione delle librerie.

La convoluzione: una finestra che scorreUna immagine 5×5 con una finestra 3×3 evidenziata, il kernel 3×3 e la cella corrispondente dell'uscita 3×3, calcolata come somma dei prodotti elemento per elementoLa convoluzione: una finestra che scorre1201301310210211023001102immagine (5×5)finestra corrente*10-110-110-1kernel (3×3)=00-1-2-440-30uscita (3×3)somma dei prodotti = 1·1 + 2·0 + 0·(−1) + 0·1 + 1·0 + 3·(−1) + 2·1 + 1·0 + 0·(−1) = 0
Una immagine 5×5 con una finestra 3×3 evidenziata, il kernel 3×3 e la cella corrispondente dell'uscita 3×3, calcolata come somma dei prodotti elemento per elemento

Il disegno mostra il calcolo: la finestra 3×3 gialla in alto a sinistra dell'immagine 5×5 viene moltiplicata, elemento per elemento, per il kernel (che qui cerca un bordo verticale: pesi +1 a sinistra, 0 al centro, −1 a destra) e i nove prodotti si sommano. Il risultato 0 è la prima cella dell'uscita; poi la finestra scorre di una posizione a destra, e così via. Con un'immagine 5×5 e un kernel 3×3 ci sono 3×3 = 9 posizioni possibili: l'uscita è 3×3. In generale, senza riempimento ai bordi, l'uscita ha dimensioni (H − k + 1) × (W − k + 1): l'immagine «si restringe» di k − 1 pixel per lato. Nella prossima lezione vedrai come controllarlo con il padding e lo stride.

Implementazione a mano: un rilevatore di bordi verticali

Costruiamo un'immagine sintetica 8×8 con un quadrato luminoso 4×4 al centro su fondo nero, e applichiamo il filtro di Sobel verticale, un classico rilevatore di bordi che confronta la parte destra con la sinistra di una finestra, dando più peso alla riga centrale. Poi verifichiamo il risultato con SciPy (correlate2d) e, subito dopo, con PyTorch: due implementazioni indipendenti.

import numpy as np
from scipy.signal import correlate2d

img = np.zeros((8, 8))
img[2:6, 2:6] = 1.0                                          # quadrato luminoso al centro

sobel_v = np.array([[-1, 0, 1],
                    [-2, 0, 2],
                    [-1, 0, 1]], dtype=float)                # destra meno sinistra, riga centrale pesata doppio

def correlazione(I, K):
    k = K.shape[0]
    out = np.zeros((I.shape[0] - k + 1, I.shape[1] - k + 1))
    for i in range(out.shape[0]):
        for j in range(out.shape[1]):
            out[i, j] = (I[i:i + k, j:j + k] * K).sum()      # somma dei prodotti nella finestra
    return out

uscita = correlazione(img, sobel_v)
print("forma ingresso", img.shape, " forma uscita", uscita.shape, " (8 - 3 + 1 = 6)")
print(uscita)
print("coincide con scipy.signal.correlate2d:", np.allclose(uscita, correlate2d(img, sobel_v, mode="valid")))
forma ingresso (8, 8)  forma uscita (6, 6)  (8 - 3 + 1 = 6)
[[ 1.  1.  0.  0. -1. -1.]
 [ 3.  3.  0.  0. -3. -3.]
 [ 4.  4.  0.  0. -4. -4.]
 [ 4.  4.  0.  0. -4. -4.]
 [ 3.  3.  0.  0. -3. -3.]
 [ 1.  1.  0.  0. -1. -1.]]
coincide con scipy.signal.correlate2d: True

Cosa fa il codice. correlazione scorre le posizioni (i, j) e per ciascuna estrae la finestra I[i:i+k, j:j+k], la moltiplica per il kernel elemento per elemento e somma: è la formula, trascritta. L'uscita ha forma 6×6, come previsto da 8 − 3 + 1. Leggi i numeri: le due colonne a sinistra hanno valori positivi (1, 3, 4, 4, 3, 1) nelle righe in cui il quadrato inizia, dove la finestra vede nero a sinistra e bianco a destra (destra meno sinistra è positiva); la parte centrale è zero, dove la finestra vede luminosità uniforme; le colonne a destra sono negative (−1, −3, −4, ...), dove la finestra vede bianco a sinistra e nero a destra.

Il filtro risponde fortemente solo in corrispondenza dei bordi verticali del quadrato e segnala la polarità del bordo con il segno. I valori cambiano in intensità lungo le righe (1, 3, 4, 4, 3, 1) perché vicino agli angoli del quadrato la finestra vede solo in parte il bordo. La verifica con SciPy conferma che la nostra funzione fa quello che fa correlate2d in modalità valid (l'uscita contiene solo gli elementi che non dipendono dal riempimento con zeri, documentazione SciPy nelle fonti). Il listato seguente fa la stessa verifica con PyTorch.

Il blocco seguente riusa img, sobel_v e uscita del listato precedente. È stato eseguito con PyTorch 2.12.0 su CPU (CUDA_VISIBLE_DEVICES="", 2 thread), due volte, con uscita identica. Non c'è niente di casuale, quindi non serve alcun seme.

import torch
t = torch.nn.functional.conv2d(torch.tensor(img)[None, None], torch.tensor(sobel_v)[None, None])     # (N=1, C=1, H, W)
print("coincide con torch conv2d:", np.allclose(t[0, 0].numpy(), uscita))
coincide con torch conv2d: True

La funzione conv2d di PyTorch è dunque esattamente questa correlazione, con le dimensioni nell'ordine (N, C, H, W): per questo l'immagine e il kernel sono stati avvolti in due dimensioni di lunghezza 1 (batch e canale).

Cambiando il kernel cambia la caratteristica che l'uscita misura. Il filtro di sfocatura (tutti i pesi 1/9) calcola la media della finestra, e smussa i dettagli. Il filtro di Sobel trasposto (la stessa matrice ruotata) rileva i bordi orizzontali. Un filtro «di nitidezza» enfatizza la differenza tra il pixel centrale e i vicini. Per decenni questi filtri sono stati progettati a mano dagli ingegneri della visione artificiale; la svolta delle reti convolutive è che non è più necessario farlo.

La convoluzione è uno strato denso con molti vincoli

Come si collega la convoluzione agli strati densi che conosci? In un modo molto istruttivo: una convoluzione è uno strato denso, con una matrice dei pesi che ha una struttura particolare. Verifichiamolo su un'immagine piccola. Costruiamo una matrice M tale che M · vec(I) (cioè l'immagine «srotolata» in un vettore) dia la stessa uscita della convoluzione, e contiamo quanti elementi di M sono diversi da zero e quanti valori distinti ci sono.

rng = np.random.default_rng(0)
H = W = 5
k = 3
I = rng.normal(size=(H, W))
K = rng.normal(size=(k, k))
Ho = H - k + 1                                              # lato dell'uscita

M = np.zeros((Ho * Ho, H * W))                              # una riga per cella d'uscita, una colonna per pixel d'ingresso
for i in range(Ho):
    for j in range(Ho):
        for u in range(k):
            for v in range(k):
                M[i * Ho + j, (i + u) * W + (j + v)] = K[u, v]

print("M @ vec(I) coincide con la convoluzione:", np.allclose(M @ I.ravel(), correlazione(I, K).ravel()))
print("forma di M:", M.shape, "  elementi totali:", M.size, "  diversi da zero:", int((M != 0).sum()), "  valori distinti:", len(np.unique(M[M != 0])))
print("parametri di uno strato denso equivalente: %d   parametri del kernel: %d" % (M.size, K.size))
M @ vec(I) coincide con la convoluzione: True
forma di M: (9, 25)   elementi totali: 225   diversi da zero: 81   valori distinti: 9
parametri di uno strato denso equivalente: 225   parametri del kernel: 9

Cosa fa il codice. Per ogni cella (i, j) dell'uscita scrive nella riga corrispondente di M i nove valori del kernel nelle nove colonne che corrispondono ai pixel della finestra, e lascia zero tutto il resto. Il prodotto matrice-vettore riproduce la convoluzione. La matrice è 9×25 = 225 elementi, di cui solo 81 diversi da zero (9 righe × 9 valori), e quegli 81 elementi assumono soltanto 9 valori distinti: quelli del kernel, ripetuti in tutte le posizioni.

Ecco la condivisione dei pesi vista in forma matriciale: uno strato denso con la stessa funzione avrebbe 225 parametri liberi; la convoluzione ne ha 9, perché il 64% della matrice è zero per la connettività locale e i valori non nulli sono vincolati a essere uguali. Questo spiega due fatti: perché le convoluzioni sono molto più economiche di uno strato denso, e perché sono un modello più restrittivo, con un'assunzione forte (la struttura locale e invariante per traslazione) che per le immagini è vera e quindi aiuta a generalizzare.

I filtri si imparano: la backpropagation attraverso una convoluzione

Il kernel non è altro che un insieme di pesi, e i pesi si imparano con la discesa del gradiente. Per verificarlo facciamo un esperimento controllato: generiamo 20 immagini casuali di 12×12 e calcoliamo le uscite del filtro di Sobel vero. Poi partiamo da un kernel casuale e lo facciamo imparare a produrre quelle uscite, minimizzando l'errore quadratico medio. Se tutto funziona, il kernel appreso dev'essere il Sobel.

Serve il gradiente rispetto al kernel. Per un'uscita O[i, j] = Σ K[u, v]·I[i+u, j+v] e un errore δ[i, j] = ∂J/∂O[i, j] (la derivata della perdita rispetto all'uscita nella posizione (i, j)), la regola della catena dà, sommando su tutte le posizioni in cui il peso K[u, v] è stato usato (la condivisione dei pesi significa che ogni peso influisce sulla perdita attraverso molte uscite):

∂J/∂K[u, v] = Σ_{i, j}  δ[i, j] · I[i + u, j + v]

Il gradiente del kernel è quindi la correlazione tra l'immagine d'ingresso e la mappa degli errori δ. Il segno e la forma sono quelli che conosci: «errore dell'uscita» per «ingresso che ha contribuito», sommati su tutte le posizioni.

Il codice che segue usa immagini casuali (valori uniformi in [0, 1)) perché un bersaglio ottenuto con il Sobel vero rende il risultato verificabile: se il kernel appreso coincide con il Sobel, il gradiente è giusto. Si usa la discesa del gradiente a batch completo e non a mini-batch per eliminare il rumore dell'estrazione, e un learning rate di 0,3, scelto per far convergere il problema in poche centinaia di passi; in una prova a parte, con learning rate 1,0 e lo stesso problema, la perdita è esplosa invece di scendere (non è nel listato).

rng = np.random.default_rng(0)
immagini = [rng.random((12, 12)) for _ in range(20)]
bersagli = [correlazione(I, sobel_v) for I in immagini]             # le uscite che vogliamo ottenere

kernel = rng.normal(size=(3, 3)) * 0.1                              # inizializzazione casuale piccola
for passo in range(401):
    grad, perdita = np.zeros((3, 3)), 0.0
    for I, bersaglio in zip(immagini, bersagli):
        uscita = correlazione(I, kernel)
        delta = uscita - bersaglio                                   # errore in ogni posizione
        perdita += 0.5 * (delta ** 2).mean()
        for u in range(3):
            for v in range(3):
                grad[u, v] += (delta * I[u:u + delta.shape[0], v:v + delta.shape[1]]).mean()   # correlazione di delta con l'ingresso
    kernel -= 0.3 * grad / len(immagini)                             # discesa del gradiente
    if passo in (0, 10, 50, 100, 400):
        print("passo %3d   perdita %.2e" % (passo, perdita / len(immagini)))

print("kernel appreso:\n", np.round(kernel, 3))
print("kernel di Sobel vero:\n", sobel_v)
passo   0   perdita 4.77e-01
passo  10   perdita 2.89e-01
passo  50   perdita 3.93e-02
passo 100   perdita 3.29e-03
passo 400   perdita 1.39e-09
kernel appreso:
 [[-1. -0.  1.]
 [-2. -0.  2.]
 [-1.  0.  1.]]
kernel di Sobel vero:
 [[-1.  0.  1.]
 [-2.  0.  2.]
 [-1.  0.  1.]]

Cosa fa il codice. Il ciclo esterno è la discesa del gradiente a batch completo (le 20 immagini). Per ogni immagine calcola l'uscita con il kernel corrente, l'errore δ rispetto al bersaglio, e accumula il gradiente con la formula sopra: per ciascuna coppia (u, v) moltiplica δ per la finestra dell'immagine traslata di (u, v) (I[u:u+h, v:v+w]) e ne fa la media. Poi sposta il kernel di 0,3 volte il gradiente medio.

La perdita scende da circa 0,48 a circa 1,4·10⁻⁹ in 400 passi, e il kernel appreso coincide con il Sobel: [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]] all'arrotondamento. Nessuno ha detto al modello che cosa cercare: ha scoperto da solo il rilevatore di bordi guardando soltanto esempi di ingresso e uscita. Questo, in scala molto più grande (milioni di immagini, decine di strati, centinaia di filtri per strato), è ciò che fa una rete convolutiva addestrata: scopre i filtri utili per il compito.

Più filtri e più canali: le forme di un vero strato convolutivo

Un solo filtro cerca un solo motivo. Uno strato convolutivo ne ha molti in parallelo: ciascun filtro produce una mappa delle caratteristiche (un «canale» di uscita), e le mappe vengono impilate. Se l'ingresso ha più canali (per esempio 3, i colori), ciascun filtro ha un kernel per ogni canale in ingresso, e la risposta si ottiene sommando le correlazioni su tutti i canali. Le forme, che devi imparare a memoria, sono:

ingresso:            (N, C_in, H, W)
pesi dello strato:   (C_out, C_in, k, k)      un kernel k×k per ogni coppia (canale d'ingresso, filtro)
bias:                (C_out,)                  uno per filtro
uscita:              (N, C_out, H − k + 1, W − k + 1)

O[n, o, i, j] = b[o] + Σ_c Σ_u Σ_v  K[o, c, u, v] · I[n, c, i + u, j + v]

parametri = C_out · (C_in · k · k + 1)

Il numero di parametri non dipende dalla dimensione dell'immagine (H, W): è questa la ragione della loro economia. Per un ingresso RGB e 16 filtri 3×3 sono 16·(3·9 + 1) = 448 parametri, contro i 150 milioni di prima. Verifichiamo la formula, e ricostruiamo a mano la convoluzione multicanale per capire come si combinano i canali:

def conv_multicanale(x, pesi, bias):
    N, C_in, H, W = x.shape
    C_out, _, k, _ = pesi.shape
    out = np.zeros((N, C_out, H - k + 1, W - k + 1))
    for n in range(N):
        for o in range(C_out):
            out[n, o] = bias[o] + sum(correlazione(x[n, c], pesi[o, c]) for c in range(C_in))   # somma sui canali d'ingresso
    return out

rng = np.random.default_rng(0)
pesi = rng.normal(size=(16, 3, 3, 3)) * 0.1
bias = rng.normal(size=16) * 0.1
print("forma dei pesi:", pesi.shape, "(C_out, C_in, k, k)    forma del bias:", bias.shape)
print("parametri:", pesi.size + bias.size, "  formula 16 * (3*3*3 + 1) =", 16 * (3 * 3 * 3 + 1))

x = rng.normal(size=(2, 3, 10, 10))                                   # un batch di 2 immagini RGB 10x10
y = conv_multicanale(x, pesi, bias)
print("forma ingresso", x.shape, " -> forma uscita", y.shape, " (10 - 3 + 1 = 8)")

# una cella d'uscita ricostruita a mano: somma su canali e finestra, più il bias
n, o, i, j = 1, 5, 2, 3
a_mano = bias[o] + sum((pesi[o, c] * x[n, c, i:i + 3, j:j + 3]).sum() for c in range(3))
con_scipy = bias[o] + sum(correlate2d(x[n, c], pesi[o, c], mode="valid") for c in range(3))
print("cella (n=1, filtro=5, i=2, j=3): a mano %.6f   funzione %.6f   scipy %.6f" % (a_mano, y[n, o, i, j], con_scipy[i, j]))
forma dei pesi: (16, 3, 3, 3) (C_out, C_in, k, k)    forma del bias: (16,)
parametri: 448   formula 16 * (3*3*3 + 1) = 448
forma ingresso (2, 3, 10, 10)  -> forma uscita (2, 16, 8, 8)  (10 - 3 + 1 = 8)
cella (n=1, filtro=5, i=2, j=3): a mano 1.253561   funzione 1.253561   scipy 1.253561

Cosa fa il codice. conv_multicanale è la formula con le quattro sommatorie: per ogni immagine del batch e per ogni filtro somma le correlazioni dei singoli canali e aggiunge il bias. I pesi hanno forma (16, 3, 3, 3), cioè 16 filtri, ciascuno con un kernel 3×3 per ognuno dei 3 canali, e i parametri sono 16·27 + 16 = 448, come dalla formula. L'uscita per un batch di 2 immagini 10×10 è (2, 16, 8, 8): 16 mappe delle caratteristiche di lato 8.

L'ultima parte calcola una singola cella in due modi indipendenti (con le somme esplicite e con correlate2d di SciPy) e i valori coincidono con quelli della funzione. I pesi sono casuali e piccoli: la lezione riguarda le forme e i conti, non i valori. Lo stesso strato in PyTorch è Conv2d. Il listato seguente ne verifica la forma dei pesi e il numero di parametri, poi copia dentro i pesi e il bias della nostra funzione (in float64, perché i nostri array lo sono) e confronta le due uscite. Riusa torch importato nel blocco precedente, pesi, bias, x, y e gli indici n, o, i, j del listato precedente; è stato eseguito con PyTorch 2.12.0 su CPU, due volte, con uscita identica.

torch.manual_seed(0)
conv = torch.nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3)
print("forma dei pesi:", tuple(conv.weight.shape), "(C_out, C_in, k, k)    forma del bias:", tuple(conv.bias.shape))
print("parametri:", sum(p.numel() for p in conv.parameters()), "  formula 16 * (3*3*3 + 1) =", 16 * (3 * 3 * 3 + 1))

with torch.no_grad():                                                 # stessi pesi della versione NumPy, in float64
    conv.double()
    conv.weight.copy_(torch.tensor(pesi)); conv.bias.copy_(torch.tensor(bias))
    yt = conv(torch.tensor(x))
print("forma uscita", tuple(yt.shape), "  massima differenza con conv_multicanale: %.2e" % np.abs(yt.numpy() - y).max())
print("cella (n=1, filtro=5, i=2, j=3): conv2d %.6f   funzione %.6f" % (yt[n, o, i, j].item(), y[n, o, i, j]))
forma dei pesi: (16, 3, 3, 3) (C_out, C_in, k, k)    forma del bias: (16,)
parametri: 448   formula 16 * (3*3*3 + 1) = 448
forma uscita (2, 16, 8, 8)   massima differenza con conv_multicanale: 6.66e-16
cella (n=1, filtro=5, i=2, j=3): conv2d 1.253561   funzione 1.253561

Cosa fa il codice. Conv2d(3, 16, 3) crea i 16 filtri con forma dei pesi (16, 3, 3, 3), come nella nostra funzione, e 448 parametri. Dopo la copia dei pesi, la sua uscita differisce dalla nostra al massimo di 6,66·10⁻¹⁶, cioè di un arrotondamento: la nostra conv_multicanale calcola la stessa cosa dello strato della libreria. Quella differenza dipende dall'ordine delle somme, e può cambiare con la macchina e con la versione.

Più strati: il campo ricettivo e la gerarchia

Un singolo neurone di uno strato 3×3 vede 9 pixel. Ma se impili un secondo strato 3×3, ogni suo neurone vede una finestra 3×3 di neuroni del primo strato, e ciascuno di questi vede 3×3 pixel: il neurone del secondo strato dipende da una regione 5×5 dell'immagine originale. Questa regione si chiama campo ricettivo (receptive field). Con kernel k e stride 1 ogni nuovo strato lo allarga di k − 1 pixel: dopo L strati 3×3 il campo ricettivo ha lato 1 + 2L. Con tre strati 3×3 si vedono 7×7 pixel, con una frazione dei parametri di un unico filtro 7×7 (3 strati: 3·9 = 27 pesi per canale contro 49), e con due non linearità in più, che aumentano l'espressività.

Questo è il principio gerarchico che rende potenti le reti convolutive: gli strati iniziali, con un campo ricettivo piccolo, rilevano caratteristiche semplici (bordi, angoli, macchie di colore); gli strati intermedi le combinano in strutture più complesse (trame, parti di oggetti); gli strati finali, con un campo ricettivo ampio, riconoscono oggetti interi. Nessuno programma questa gerarchia: emerge dall'addestramento, e la visualizzazione dei filtri appresi nei primi strati di reti addestrate su immagini naturali mostra spesso rilevatori di bordi orientati e di colore, molto simili al Sobel che hai appena fatto imparare a mano.

def campo_ricettivo(strati):                         # strati: lista di (kernel, stride); formula ricorsiva
    rf, salto = 1, 1                                 # 'salto' = distanza in pixel tra due neuroni adiacenti dello strato corrente
    for k, stride in strati:
        rf += (k - 1) * salto
        salto *= stride
    return rf

print("campo ricettivo, 1 strato 3x3:  ", campo_ricettivo([(3, 1)]))
print("campo ricettivo, 2 strati 3x3:  ", campo_ricettivo([(3, 1)] * 2))
print("campo ricettivo, 3 strati 3x3:  ", campo_ricettivo([(3, 1)] * 3), "(= 1 + 2*3)")
print("pesi per canale: 3 strati 3x3 = %d  contro un solo filtro 7x7 = %d" % (3 * 9, 49))
print("con stride 2 nel primo strato: ", campo_ricettivo([(3, 2), (3, 1), (3, 1)]), "(lo stride allarga il campo più in fretta)")
campo ricettivo, 1 strato 3x3:   3
campo ricettivo, 2 strati 3x3:   5
campo ricettivo, 3 strati 3x3:   7 (= 1 + 2*3)
pesi per canale: 3 strati 3x3 = 27  contro un solo filtro 7x7 = 49
con stride 2 nel primo strato:  11 (lo stride allarga il campo più in fretta)

Cosa fa il codice. La funzione applica una formula che tiene traccia del campo ricettivo (rf) e della distanza in pixel d'ingresso tra due neuroni adiacenti dello strato corrente (salto): ogni strato aggiunge (k − 1)·salto al campo ricettivo, e uno stride maggiore di 1 moltiplica il salto per lo stride. Con stride 1 si ritrova il risultato 1 + 2L. Con stride 2 nel primo strato, i tre strati vedono 11 pixel invece di 7: lo stride allarga il campo più in fretta, a spese della risoluzione delle mappe (la lezione successiva).

Trade-off e trappole reali

  • Convoluzione o correlazione incrociata: le librerie non ribaltano il kernel. Se confronti con una convoluzione «da manuale» (per esempio scipy.signal.convolve2d), il risultato è ribaltato; per questo sopra abbiamo usato correlate2d.
  • Ordine delle dimensioni: (N, C, H, W) in PyTorch, (N, H, W, C) in Keras e nella maggior parte delle librerie di immagini. Passare un tensore con l'ordine sbagliato non sempre dà errore: la rete tratta l'altezza come se fosse un canale.
  • Pixel interi che traboccano: un'immagine caricata come uint8 (0-255) non sopporta le operazioni aritmetiche tra interi a 8 bit; l'esempio eseguito è qui sotto la lista. Si converte in float e si normalizza prima di calcolare qualsiasi cosa.
  • Pixel non normalizzati: valori da 0 a 255 in ingresso a una rete producono preattivazioni enormi, gradienti sproporzionati e addestramento instabile. Dividi per 255 e standardizza con le statistiche del training.
  • Restringimento ai bordi: ogni convoluzione «valid» toglie k − 1 pixel. Dopo molti strati l'immagine potrebbe sparire, e i pixel di bordo contribuiscono a meno uscite di quelli centrali. Si usa il padding (prossima lezione).
  • Dimenticare la non linearità tra le convoluzioni: due convoluzioni consecutive senza attivazione sono equivalenti a un'unica convoluzione con un kernel più grande, per lo stesso motivo per cui due strati lineari collassano in uno.
  • Pensare che la convoluzione sia invariante alla traslazione: è equivariante (la risposta si sposta con il motivo); l'invarianza (la risposta finale non cambia) viene dal pooling e dagli strati finali, di cui parliamo nella prossima lezione.
  • Troppi filtri o canali: i parametri scalano con C_in·C_out·k². Una rete con 512 canali in ingresso e uscita con kernel 3×3 ha 512·512·9 ≈ 2,4 milioni di parametri in un solo strato.

Le otto voci non hanno lo stesso peso. Le prime due e quella sui pixel interi riguardano la preparazione dei dati e delle forme: sono quelle che producono risultati sbagliati senza alcun errore. Le voci su restringimento, non linearità e invarianza sono errori concettuali, che si evitano capendo che cosa la convoluzione fa e non fa; l'ultima ricorda che il numero di parametri cresce con il prodotto dei canali, e che quindi si paga soprattutto negli strati profondi. Il caso dei pixel interi si verifica in due righe:

a, b = np.array([200], dtype=np.uint8), np.array([100], dtype=np.uint8)
print("media in uint8:", (a + b) // 2, "  media in float:", (a.astype(float) + b) / 2)
media in uint8: [22]   media in float: [150.]

La somma 200 + 100 = 300 non entra in 8 bit e «va a capo» a 300 − 256 = 44, quindi la media risulta 22 invece di 150. Nell'esecuzione NumPy non ha emesso nessun avviso: l'assenza di errore non significa che il calcolo sia giusto, e l'unica protezione è convertire prima a float.

Domande frequenti

Il kernel deve essere 3×3? No, ma 3×3 è la scelta più comune: impilando più strati 3×3 si ottengono campi ricettivi ampi con meno parametri, e più non linearità. Kernel più grandi (5×5, 7×7) compaiono soprattutto nel primo strato di alcune reti, e kernel 1×1 hanno un ruolo specifico (lezione sui blocchi residui e Inception).

Quanti filtri servono? È un iperparametro: tipicamente poche decine nei primi strati e centinaia negli ultimi, con più canali man mano che la risoluzione spaziale diminuisce. Si sceglie guardando la validazione.

Una convoluzione funziona solo sulle immagini? No. Funziona su qualsiasi dato con struttura locale e regolare: serie temporali (convoluzione 1D), audio, testo (convoluzioni 1D sui vettori delle parole), volumi medici (convoluzioni 3D). La domanda da farsi è se ha senso che gli schemi locali valgano ovunque.

Perché «feature map»? Perché ogni canale di uscita è una mappa spaziale della presenza di una certa caratteristica: i valori alti indicano dove, nell'immagine, il filtro ha «trovato» il suo motivo.

Che cosa sai fare adesso

Sai definire la convoluzione (in pratica una correlazione incrociata) e calcolarla a mano e a codice, mostrare che è uno strato denso con una matrice molto strutturata (9 parametri invece di 225 nell'esempio) e far imparare a un kernel casuale un rilevatore di bordi con la discesa del gradiente. Conosci le forme di uno strato convolutivo, la formula dei parametri C_out·(C_in·k² + 1) e il campo ricettivo di strati impilati. La lezione riprende la rete addestrata a mano sulle cifre e la backpropagation; nella prossima, stride, padding e pooling, completi il vocabolario e il conteggio di forme e parametri di una rete.

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