Salta al contenuto principale

54 - Leggere una CNN: feature map, global pooling e U-Net

Feature map con gli hook, global pooling, Class Activation Map e U-Net con salti: parametri, accuratezza e IoU misurati con PyTorch.

Cosa ne pensi di questo articolo?

54 - Leggere una CNN: feature map, global pooling e U-Net
Machine Learning Engineerfedericocalo.dev

In Breve

Feature map con gli hook, global pooling, Class Activation Map e U-Net con salti: parametri, accuratezza e IoU misurati con PyTorch.

Una rete neurale ha fama di scatola nera: ci metti dentro un'immagine, esce una classe, e in mezzo ci sono migliaia di numeri incomprensibili. È una fama in parte meritata e in parte no. Le reti convolutive hanno una particolarità: i loro strati intermedi sono immagini (le mappe delle caratteristiche), e le immagini si possono guardare. Quello che c'è tra ingresso e uscita si può quindi ispezionare con gli hook di PyTorch, riassumere con una media per canale (global pooling), usare per ricavare una mappa di ciò che la rete ha guardato (CAM) e, con l'architettura a U, portare dalla classificazione di un'immagine a quella di ogni pixel (segmentazione).

Prerequisiti: conosci stride, padding e pooling (lezione sul conteggio dei parametri), hai addestrato una piccola CNN con early stopping (lezione su LeNet) e conosci le reti residuali (lezione sulle reti residuali). 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 (misura del 2026-11-12). Un risultato ti sorprenderà in modo istruttivo: un modello che non segmenta nulla può avere un'accuratezza per pixel dell'85%.

Il vocabolario

  • Feature map (mappa delle caratteristiche): l'uscita di uno strato convolutivo, un tensore (canali, altezza, larghezza) in cui ciascun canale è una mappa spaziale della presenza di un certo motivo.
  • Hook (gancio): una funzione che PyTorch richiama automaticamente quando un modulo esegue il passaggio in avanti, e che permette di «intercettare» le attivazioni intermedie senza modificare il modello.
  • Global average pooling (GAP): un pooling con finestra grande quanto l'intera mappa, che riduce ciascun canale alla sua media: da (C, H, W) a un vettore di C numeri.
  • Class Activation Map (CAM): una mappa spaziale che indica quali zone dell'immagine hanno contribuito di più alla previsione di una certa classe.
  • Segmentazione semantica: assegnare una classe a ogni pixel (per esempio «oggetto» o «sfondo»), invece di una classe all'intera immagine.
  • IoU (Intersection over Union): la metrica standard per la segmentazione, il rapporto tra l'area in comune di maschera prevista e maschera vera e l'area della loro unione. Vale 1 se coincidono, 0 se non si toccano.
  • Convoluzione trasposta (transposed convolution): l'operazione che aumenta la risoluzione spaziale, imparando i pesi di un «ingrandimento».

Le sette voci seguono i tre argomenti della lezione. Feature map e hook servono a guardare dentro; global average pooling e CAM sono la coppia che riduce i parametri e poi usa quella riduzione per spiegare la previsione; segmentazione, IoU e convoluzione trasposta appartengono alla parte sull'U-Net. L'IoU è la voce da ricordare in ogni caso: più avanti vedrai un modello inutile con il 85% di accuratezza e un IoU diverso da quello di un modello che funziona davvero.

Guardare dentro: estrarre le feature map

Costruiamo una piccola CNN in due versioni (una con la parte densa finale classica, l'altra con il global pooling che spiegheremo tra poco) e la addestriamo sulle cifre. Usiamo gli stessi dati preparati nelle lezioni scorse: divisione 60/20/20 stratificata, normalizzazione con le statistiche del training.

Nota sul codice: i blocchi Python di questo articolo vanno eseguiti in ordine, in un unico script o notebook. Ciascuno riusa gli import e le variabili definiti nei blocchi precedenti.

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()
a_t = lambda a: torch.tensor(((a - media) / dev).reshape(-1, 1, 8, 8), dtype=torch.float32)
T_tr, T_va, T_te = a_t(X_tr), a_t(X_va), a_t(X_te)
L_tr, L_va, L_te = [torch.tensor(a, dtype=torch.long) for a in (y_tr, y_va, y_te)]

class Rete(nn.Module):
    def __init__(self, gap):
        super().__init__()
        self.gap = gap
        self.conv1 = nn.Sequential(nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2))     # (1,8,8)  -> (8,4,4)
        self.conv2 = nn.Sequential(nn.Conv2d(8, 16, 3, padding=1), nn.ReLU())                      # (8,4,4)  -> (16,4,4): l'ultima mappa resta 4x4
        self.testa = nn.Linear(16, 10) if gap else nn.Sequential(nn.Flatten(), nn.Linear(16 * 4 * 4, 10))

    def mappe(self, x):                                       # le mappe dell'ultimo strato convolutivo
        return self.conv2(self.conv1(x))

    def forward(self, x):
        f = self.mappe(x)
        return self.testa(f.mean(dim=(2, 3))) if self.gap else self.testa(f)       # GAP: media su altezza e larghezza

def addestra(modello, seme, epoche=200, pazienza=10):
    torch.manual_seed(seme)
    ott = torch.optim.Adam(modello.parameters(), lr=3e-3)
    perdita_fn = nn.CrossEntropyLoss()
    loader = torch.utils.data.DataLoader(torch.utils.data.TensorDataset(T_tr, L_tr), batch_size=32, shuffle=True)
    migliore = (np.inf, None, 0)
    for epoca in range(epoche):
        modello.train()
        for xb, yb in loader:
            ott.zero_grad(); perdita_fn(modello(xb), yb).backward(); ott.step()
        modello.eval()
        with torch.no_grad():
            pv = perdita_fn(modello(T_va), L_va).item()
        if pv < migliore[0] - 1e-4:
            migliore = (pv, {k: v.clone() for k, v in modello.state_dict().items()}, epoca)
        elif epoca - migliore[2] >= pazienza:
            break
    modello.load_state_dict(migliore[1]); modello.eval()
    with torch.no_grad():
        return (modello(T_te).argmax(1) == L_te).float().mean().item()

torch.manual_seed(0)
rete = Rete(gap=True)
print("accuratezza sul test della rete con GAP (seme 0): %.3f" % addestra(rete, 0))

# un "gancio" intercetta l'uscita di conv1 senza modificare il modello
catturato = {}
gancio = rete.conv1[0].register_forward_hook(lambda modulo, ingresso, uscita: catturato.update(mappe=uscita.detach()))
cifra = T_te[:1]                                              # la prima immagine del test
previsione = rete(cifra).argmax(1).item()
gancio.remove()
print("etichetta vera %d   previsione %d   forma delle mappe catturate: %s" % (L_te[0].item(), previsione, tuple(catturato["mappe"].shape)))

def ascii_mappa(m, soglie=" .:-=+*#%@"):
    m = (m - m.min()) / (m.max() - m.min() + 1e-9)
    return ["".join(soglie[int(v * (len(soglie) - 1))] * 2 for v in riga) for riga in m]

print("\ncifra in ingresso (8x8):")
print("\n".join(ascii_mappa(cifra[0, 0].numpy())))
forza = catturato["mappe"][0].abs().mean(dim=(1, 2))
migliori = forza.argsort(descending=True)[:2].tolist()
for k in migliori:
    print("\ncanale %d di conv1 (attivazione media %.2f):" % (k, forza[k]))
    print("\n".join(ascii_mappa(catturato["mappe"][0, k].numpy())))
    print("kernel 3x3 del canale %d:\n%s" % (k, np.round(rete.conv1[0].weight[k, 0].detach().numpy(), 2)))
accuratezza sul test della rete con GAP (seme 0): 0.961
etichetta vera 7   previsione 7   forma delle mappe catturate: (1, 8, 8, 8)

cifra in ingresso (8x8):
    ..##@@++    
    ****--@@..  
    ##..  @@::  
      ::++@@--  
      ##@@@@++  
      ::##      
      **++      
      @@::      

canale 3 di conv1 (attivazione media 3.56):
****==::..--==**
##**==..  --==**
##**++--  ::--**
####++--    --++
##@@**--....++**
##@@++::..--****
##%%++----**%%##
**##======**##**
kernel 3x3 del canale 3:
[[-0.82 -0.84 -1.12]
 [-0.77 -0.54 -1.02]
 [-1.36 -0.91 -1.15]]

canale 1 di conv1 (attivazione media 3.36):
++##**====++++**
####==....==++**
**==--====++==**
****####++====**
**%%**==    ::++
**%%**==....++**
**%%##++==++####
##%%++====##@@##
kernel 3x3 del canale 1:
[[-1.49 -1.21 -1.68]
 [-1.45 -1.31 -1.56]
 [-0.03  1.08  1.36]]

Cosa fa il codice. La classe Rete ha due varianti: con gap=True la parte finale calcola la media di ciascuna delle 16 mappe e la manda in uno strato lineare; con gap=False appiattisce le mappe 16×4×4 in 256 valori e usa uno strato lineare più largo. addestra è il ciclo con early stopping già visto. Il blocco centrale usa un hook: register_forward_hook registra su un modulo una funzione che riceve modulo, ingresso e uscita ogni volta che il modulo viene eseguito; qui salva l'uscita di conv1[0] in un dizionario, poi il gancio viene rimosso (remove()), perché altrimenti rimarrebbe attivo per sempre. È la tecnica standard per ispezionare un modello senza riscriverlo. La funzione ascii_mappa normalizza una mappa su [0, 1] e la disegna con caratteri sempre più «densi» (spazio, punto, due punti, ... fino alla chiocciola): un modo per «vedere» le attivazioni senza librerie grafiche.

Come si legge il risultato. L'immagine 8×8 in ingresso è la cifra del test; le due mappe stampate sono i due canali di conv1 con la risposta media più alta, e sotto ciascuna c'è il kernel 3×3 che quel canale ha imparato. Confronta ciascuna mappa con la cifra. Il canale 3 ha un kernel con tutti i pesi negativi: dopo la normalizzazione lo sfondo vale circa −0,8 e il tratto è positivo, quindi quel filtro risponde dove l'immagine è scura, ed è come un «negativo» della cifra (nella mappa le zone dense sono lo sfondo). Il canale 1 ha pesi negativi nella riga alta del kernel e positivi in quella bassa: risponde a variazioni verticali, cioè a bordi orizzontali.

Sono due comportamenti riconoscibili, ma non aspettarti kernel «puliti» come il Sobel della lezione sulla convoluzione: con 8 filtri e 1078 esempi (60% di 1797), i filtri sono rumorosi e spesso difficili da interpretare a occhio. Il punto non è che ogni filtro abbia un significato leggibile: è che puoi guardare cosa succede, e che per immagini naturali e reti addestrate su grandi insiemi di dati questa ispezione ha rivelato, nei primi strati, filtri sensibili a bordi e colori e, più in profondità, a trame e parti di oggetti (M. Zeiler e R. Fergus, 2014; vedi Fonti).

Il global average pooling

Nella lezione su stride, padding e pooling hai visto che in una CNN classica il collegamento denso dopo il flatten contiene la maggior parte dei parametri (il 78% in LeNet-5). Il global average pooling (M. Lin e coll., 2014) elimina questo problema: ogni mappa (H × W) viene ridotta alla sua media, e il vettore di C medie va direttamente in un unico strato lineare verso le classi. Per C canali e K classi i parametri della testa sono C·K + K, indipendenti da H e W.

ingresso della testa: (N, C, H, W)  →  media su (H, W)  →  (N, C)  →  Linear(C, K)  →  (N, K)

Tre vantaggi: meno parametri (e quindi meno rischio di overfitting); indipendenza dalla risoluzione (poiché la media funziona con qualunque H e W, la rete accetta immagini di dimensioni diverse da quelle di addestramento); e interpretabilità (ogni canale è associato in modo diretto a un peso per classe: lo useremo tra poco). Il prezzo è che la media butta via l'informazione sulla posizione: la rete sa «quanto» di ciascun motivo c'è, non «dove». Misuriamo parametri e accuratezza su cinque semi, e verifichiamo l'indipendenza dalla risoluzione:

print("testa            parametri   accuratezza sul test (5 semi)")
for gap in (False, True):
    risultati = []
    for seme in range(5):
        torch.manual_seed(seme)
        m = Rete(gap)
        risultati.append(addestra(m, seme))
    print("%-15s  %9d   %.3f +- %.3f" % ("GAP" if gap else "flatten+denso", sum(p.numel() for p in m.parameters()), np.mean(risultati), np.std(risultati)))

gap_net, flat_net = Rete(True), Rete(False)
print("\nimmagine di 16x16 con la rete GAP:     uscita", tuple(gap_net(torch.zeros(1, 1, 16, 16)).shape))
try:
    flat_net(torch.zeros(1, 1, 16, 16))
except RuntimeError as e:
    print("immagine di 16x16 con flatten+denso:   errore -", str(e)[:75])
testa            parametri   accuratezza sul test (5 semi)
flatten+denso         3818   0.971 +- 0.002
GAP                   1418   0.958 +- 0.006

immagine di 16x16 con la rete GAP:     uscita (1, 10)
immagine di 16x16 con flatten+denso:   errore - mat1 and mat2 shapes cannot be multiplied (1x1024 and 256x10)

Cosa fa il codice. Per ciascuna testa addestra cinque modelli con semi diversi e riporta numero di parametri e accuratezza media sul test. La rete con flatten e strato denso ha 3 818 parametri e fa 0,971 ± 0,002; quella con GAP ne ha 1 418 (il 63% in meno) e fa 0,958 ± 0,006. Con questi numeri la scelta è un vero compromesso: il global pooling costa poco più di un punto percentuale su Digits, dove le immagini sono minuscole e la posizione del tratto conta, ma toglie quasi due terzi dei parametri. Sul tema delle dimensioni: la rete con GAP accetta senza problemi un ingresso 16×16 (la media funziona con qualunque risoluzione), mentre quella con flatten genera un errore perché il numero di valori dopo il flatten non è più quello previsto dallo strato denso (qui 1024 invece di 256). Su immagini naturali di grandi dimensioni, dove i parametri dello strato denso sarebbero milioni, il GAP è lo standard delle architetture moderne.

Class Activation Map: dove ha guardato la rete?

Con il GAP c'è un'osservazione elegante. La logit della classe c è un'unica somma pesata: logit_c = Σ_k w_{c,k} · media_{i,j}(f_k(i,j)) + b_c, dove f_k è la mappa del canale k e w_{c,k} il peso che la testa associa al canale k per la classe c. Poiché la media è lineare, si può portare dentro la somma:

logit_c = media_{i,j} [ Σ_k w_{c,k} · f_k(i,j) ] + b_c  =  media_{i,j} CAM_c(i,j) + b_c

CAM_c(i,j) = Σ_k w_{c,k} · f_k(i,j)

La mappa CAM_c (Class Activation Map, B. Zhou e coll., 2016) è una mappa spaziale in cui ogni posizione contribuisce alla logit della classe c con la sua parte: dove è alta, la rete ha trovato evidenza per quella classe. E per costruzione la media della CAM più il bias è esattamente la logit: è un'identità, non un'approssimazione. La verifichiamo:

modello = Rete(True)
addestra(modello, 0)
x = T_te[:1]
f = modello.mappe(x)[0]                                      # (16, 4, 4): le mappe dell'ultimo strato convolutivo
w, b = modello.testa.weight, modello.testa.bias
logit = modello(x)[0]
c = logit.argmax().item()
cam = (w[c][:, None, None] * f).sum(dim=0)                   # CAM_c(i,j) = somma_k w_{c,k} f_k(i,j)

print("classe prevista: %d   (vera: %d)" % (c, L_te[0].item()))
print("logit della classe prevista: %.5f" % logit[c].item())
print("media della CAM + bias:      %.5f   -> identità verificata: %s" % ((cam.mean() + b[c]).item(), bool(torch.allclose(logit[c], cam.mean() + b[c], atol=1e-4))))
print("\nCAM della classe prevista (4x4), valori alti = evidenza a favore:")
print(np.round(cam.detach().numpy(), 1))
print("\ncome mappa di caratteri (a destra la cifra originale 8x8):")
cam_alta = torch.nn.functional.interpolate(cam[None, None], size=(8, 8), mode="bilinear", align_corners=False)[0, 0]
for r1, r2 in zip(ascii_mappa(cam_alta.detach().numpy()), ascii_mappa(x[0, 0].numpy())):
    print(r1, "   ", r2)
classe prevista: 7   (vera: 7)
logit della classe prevista: 6.30565
media della CAM + bias:      6.30565   -> identità verificata: True

CAM della classe prevista (4x4), valori alti = evidenza a favore:
[[ -6.   -4.4 -55.9 -19.8]
 [ 18.2   9.3  17.6  11.9]
 [ 26.7  23.1  20.6  11.9]
 [ 12.7  13.3  22.3  -2.4]]

come mappa di caratteri (a destra la cifra originale 8x8):
++++++--    ::--         ..##@@++    
++++++==::..--==         ****--@@..  
####******++****         ##..  @@::  
%%%%####%%%%####           ::++@@--  
@@%%%%%%%%%%####           ##@@@@++  
%%%%%%%%%%%%##**           ::##      
######%%%%%%****           **++      
########%%##**++           @@::      

Cosa fa il codice. Prende l'ultima mappa (16 canali di 4×4), i pesi della testa per la classe prevista e calcola la somma pesata sui canali: è la CAM, una griglia 4×4. L'identità è verificata a cinque decimali: media della CAM più bias coincide con la logit. Poi la CAM viene ingrandita a 8×8 con interpolazione bilineare (interpolate) per sovrapporla all'immagine originale, e stampata accanto alla cifra con la stessa funzione di caratteri. Le zone più «dense» della CAM sono quelle in cui l'evidenza per la classe è maggiore. Qui la mappa è molto grossolana perché l'ultimo strato è 4×4, e non sempre intuitiva: la riga alta porta evidenza negativa e quelle centrali quasi tutta l'evidenza positiva, senza che questo si traduca in una lettura «umana» della cifra 7.

Con immagini grandi, e mappe finali più ampie (7×7, 14×14), il risultato è una mappa di calore leggibile che mostra su quale parte dell'oggetto la rete ha basato la decisione. È uno strumento prezioso per verificare che il modello guardi dove dovrebbe: una CNN che classifica radiografie guardando le etichette di testo ai bordi dell'immagine anziché il polmone avrebbe ottime metriche e nessuna utilità clinica; la CAM la smaschererebbe.

Dalla classificazione alla segmentazione: l'U-Net

Finora la rete restituiva una classe per immagine. Per la segmentazione serve una classe per ogni pixel: l'uscita è una mappa con la stessa risoluzione dell'ingresso. Una rete che riduce la risoluzione con pooling e poi la ripristina non ha, però, abbastanza informazione per ricostruire i contorni precisi: nello strato più profondo (per esempio 4×4) i dettagli fini sono andati persi. L'U-Net (O. Ronneberger, P. Fischer, T. Brox, 2015, nata per la segmentazione di immagini biomediche) risolve il problema con una struttura a U:

  • Percorso discendente (encoder): blocchi di convoluzioni seguiti da pooling; la risoluzione si dimezza e i canali raddoppiano. Cattura il «cosa» (contesto, forme grandi).
  • Percorso ascendente (decoder): riporta la risoluzione all'originale con convoluzioni trasposte (o interpolazione), dimezzando i canali.
  • Connessioni di salto: a ogni livello, le mappe dell'encoder vengono concatenate a quelle del decoder alla stessa risoluzione. Portano il «dove» (dettagli fini e contorni) che l'encoder aveva prima di ridurli.
  • Uscita: una convoluzione 1×1 che produce un logit per pixel (o un logit per classe e pixel).

Le quattro voci vanno lette dall'alto in basso come un viaggio dell'informazione. L'encoder spende risoluzione per guadagnare contesto, il decoder la restituisce ma senza i dettagli perduti, le connessioni di salto riportano quei dettagli a metà strada, e l'uscita 1×1 trasforma i canali in un logit per pixel. Se togli la terza voce hai un semplice encoder-decoder: l'esperimento che segue misura quanto vale quella sola voce.

Architettura U-Net a due livelliIl percorso discendente (encoder) riduce la risoluzione e aumenta i canali; il percorso ascendente (decoder) la ripristina; le connessioni di salto concatenano le mappe dell'encoder a quelle del decoder alla stessa risoluzioneArchitettura U-Net a due livelli16×16 · 8 canali8×8 · 16 canali4×4 · 32 canaliup 8×8up 16×16uscita 1×16×16poolpoolupupconcatenazione (skip)
Il percorso discendente (encoder) riduce la risoluzione e aumenta i canali; il percorso ascendente (decoder) la ripristina; le connessioni di salto concatenano le mappe dell'encoder a quelle del decoder alla stessa risoluzione

Il disegno mostra la forma. Le frecce arancioni sono le connessioni di salto: portano le mappe dell'encoder (a 16×16 e a 8×8) direttamente al decoder. Prima dell'implementazione serve capire come cresce una mappa. Una convoluzione trasposta con kernel K, stride S e padding P porta un lato H a (H − 1)·S − 2P + K: con K = 2, S = 2, P = 0 raddoppia esattamente (H → 2H), ed è la scelta tipica per il decoder. La verifichiamo:

print("lato in ingresso H, kernel K, stride S, padding P  ->  lato in uscita (formula e PyTorch)")
for H, K, S, P in ((4, 2, 2, 0), (4, 3, 2, 1), (8, 4, 2, 1)):
    reale = nn.ConvTranspose2d(1, 1, K, S, P)(torch.zeros(1, 1, H, H)).shape[-1]
    print("H=%d K=%d S=%d P=%d   ->   (H-1)*S - 2P + K = %d   PyTorch: %d" % (H, K, S, P, (H - 1) * S - 2 * P + K, reale))
lato in ingresso H, kernel K, stride S, padding P  ->  lato in uscita (formula e PyTorch)
H=4 K=2 S=2 P=0   ->   (H-1)*S - 2P + K = 8   PyTorch: 8
H=4 K=3 S=2 P=1   ->   (H-1)*S - 2P + K = 7   PyTorch: 7
H=8 K=4 S=2 P=1   ->   (H-1)*S - 2P + K = 16   PyTorch: 16

Cosa fa il codice. Applica la formula e la confronta con la dimensione reale prodotta da ConvTranspose2d; in tutti e tre i casi coincidono (8, 7 e 16). La convoluzione trasposta si può vedere come l'inverso «di forma» della convoluzione: dove la convoluzione con stride 2 dimezza, la trasposta con stride 2 raddoppia, e imparando i pesi sceglie come «riempire» i pixel nuovi.

Un esperimento di segmentazione: U-Net contro encoder-decoder senza salti

Per misurare il contributo delle connessioni di salto costruiamo un compito sintetico controllato: immagini 16×16 con rumore gaussiano, da 1 a 3 quadrati luminosi di lato 3-6 (l'oggetto da segmentare) e, nel 70% dei casi, una riga orizzontale luminosa che è un distrattore (non fa parte della maschera). Le maschere vere sono note, quindi possiamo misurare IoU e accuratezza per pixel.

def genera(n, seme):
    r = np.random.default_rng(seme)
    X = np.zeros((n, 1, 16, 16), np.float32); Y = np.zeros((n, 1, 16, 16), np.float32)
    for i in range(n):
        img = r.normal(0, 0.35, (16, 16)).astype(np.float32)               # rumore di fondo
        maschera = np.zeros((16, 16), np.float32)
        for _ in range(r.integers(1, 4)):                                   # da 1 a 3 quadrati
            lato = r.integers(3, 7)
            a, b = r.integers(0, 16 - lato + 1, 2)
            maschera[a:a + lato, b:b + lato] = 1
        if r.random() < 0.7:
            img[r.integers(0, 16), :] += 0.6                                # riga luminosa: distrattore
        X[i, 0] = img + 0.9 * maschera; Y[i, 0] = maschera
    return torch.tensor(X), torch.tensor(Y)

X_seg_tr, Y_seg_tr = genera(600, 0)
X_seg_te, Y_seg_te = genera(200, 1)

def blocco(c_in, c_out):
    return nn.Sequential(nn.Conv2d(c_in, c_out, 3, padding=1), nn.ReLU(), nn.Conv2d(c_out, c_out, 3, padding=1), nn.ReLU())

class UNet(nn.Module):
    def __init__(self, salti=True):
        super().__init__()
        self.salti = salti
        self.e1, self.e2, self.fondo = blocco(1, 8), blocco(8, 16), blocco(16, 32)
        self.su2 = nn.ConvTranspose2d(32, 16, 2, 2); self.d2 = blocco(32 if salti else 16, 16)
        self.su1 = nn.ConvTranspose2d(16, 8, 2, 2);  self.d1 = blocco(16 if salti else 8, 8)
        self.uscita = nn.Conv2d(8, 1, 1)                                    # 1x1: un logit per pixel
        self.pool = nn.MaxPool2d(2)

    def forward(self, x):
        e1 = self.e1(x)                                                     # 16x16, 8 canali
        e2 = self.e2(self.pool(e1))                                         # 8x8, 16 canali
        fondo = self.fondo(self.pool(e2))                                   # 4x4, 32 canali
        d2 = self.su2(fondo)                                                # 8x8, 16 canali
        d2 = self.d2(torch.cat([d2, e2], dim=1) if self.salti else d2)      # concatenazione = connessione di salto
        d1 = self.su1(d2)                                                   # 16x16, 8 canali
        d1 = self.d1(torch.cat([d1, e1], dim=1) if self.salti else d1)
        return self.uscita(d1)

def valuta_segmentazione(modello, X, Y):
    modello.eval()
    with torch.no_grad():
        p = (modello(X) > 0).float()                                       # logit > 0  <=>  probabilità > 0.5
    inter, unione = (p * Y).sum(), ((p + Y) > 0).float().sum()
    return (inter / unione).item(), (p == Y).float().mean().item()

print("accuratezza del modello che dice sempre 'sfondo': %.3f" % (1 - Y_seg_te.mean().item()))
print("modello                    parametri   IoU               accuratezza per pixel   (3 semi)")
for salti in (True, False):
    righe = []
    for seme in range(3):
        torch.manual_seed(seme)
        m = UNet(salti)
        ott = torch.optim.Adam(m.parameters(), lr=3e-3)
        perdita_fn = nn.BCEWithLogitsLoss()
        for _ in range(40):
            m.train()
            ordine = torch.randperm(len(X_seg_tr))
            for i in range(0, len(ordine), 20):
                idx = ordine[i:i + 20]
                ott.zero_grad(); perdita_fn(m(X_seg_tr[idx]), Y_seg_tr[idx]).backward(); ott.step()
        righe.append(valuta_segmentazione(m, X_seg_te, Y_seg_te))
    r = np.array(righe)
    print("%-25s  %9d   %.3f +- %.3f      %.3f +- %.3f" % ("U-Net con salti" if salti else "senza salti (enc-dec)", sum(p.numel() for p in m.parameters()), r[:, 0].mean(), r[:, 0].std(), r[:, 1].mean(), r[:, 1].std()))
accuratezza del modello che dice sempre 'sfondo': 0.845
modello                    parametri   IoU               accuratezza per pixel   (3 semi)
U-Net con salti                29321   0.948 +- 0.004      0.992 +- 0.001
senza salti (enc-dec)          26441   0.880 +- 0.004      0.980 +- 0.001

Cosa fa il codice. genera costruisce le immagini e le maschere vere. La classe UNet ha un percorso discendente (e1 a 16×16, e2 a 8×8, fondo a 4×4) e uno ascendente con convoluzioni trasposte (su2, su1); dove salti=True concatena le mappe dell'encoder (torch.cat(..., dim=1), lungo i canali) prima dei blocchi del decoder, ed è per questo che quei blocchi hanno il doppio dei canali in ingresso (32 invece di 16 e 16 invece di 8). L'uscita è una convoluzione 1×1 che produce un logit per pixel, allenato con la BCEWithLogitsLoss (cross-entropia binaria con sigmoide integrata). valuta_segmentazione calcola IoU e accuratezza per pixel dopo aver soglia a 0 le logit (equivale a probabilità 0,5).

Leggi la tabella con attenzione. Il primo numero stampato è una lezione sulle metriche: un modello che dice sempre «sfondo», senza guardare l'immagine, ha un'accuratezza per pixel di 0,845, perché l'oggetto occupa una minoranza dei pixel. Un'accuratezza di 0,98 non è quindi «98% di bravura»: è la stessa trappola delle classi sbilanciate della prima lezione del modulo. L'IoU non si lascia ingannare (il modello «sempre sfondo» ha IoU 0) ed è la metrica da usare. Con le connessioni di salto l'U-Net raggiunge IoU 0,948 ± 0,004 (accuratezza 0,992); senza di esse, a parità di struttura (l'unica differenza è la concatenazione, con meno parametri: 26 441 contro 29 321), IoU 0,880 ± 0,004 (accuratezza 0,980). Quasi sette punti di IoU in più per 2 880 parametri, perché l'informazione sui contorni che l'encoder ha a 16×16 non deve essere ricostruita da un collo di bottiglia 4×4. Le differenze sono molte volte più grandi delle deviazioni tra semi: nessun dubbio che i salti servano.

Trade-off e trappole reali

  • Accuratezza per pixel nella segmentazione: ingannevole con oggetti piccoli (0,845 per il modello che non fa nulla). Usa IoU, il coefficiente di Dice o metriche per classe.
  • Hook dimenticati: un hook registrato e non rimosso continua a eseguirsi (e a occupare memoria) a ogni passaggio. Chiama remove().
  • Interpretare troppo le feature map: i singoli filtri di reti piccole non hanno quasi mai un significato nitido. Le visualizzazioni sono uno strumento di ispezione e di debug, non una dimostrazione di comprensione del modello.
  • CAM a bassa risoluzione: la mappa ha la risoluzione dell'ultimo strato convolutivo (4×4 qui). Va ingrandita per sovrapporla, e dà indicazioni grossolane, non contorni precisi.
  • GAP e informazione di posizione: la media la elimina. Per compiti in cui la posizione conta (lettere nei testi, ordine delle parti) può danneggiare.
  • Forme nelle concatenazioni: la concatenazione dell'U-Net richiede la stessa risoluzione spaziale in encoder e decoder. Con immagini di lato non divisibile per 2 a ogni pooling (per esempio 15) le dimensioni non coincidono. Si scelgono dimensioni multiple di 2^(numero di pooling) o si fa padding.
  • Convoluzione trasposta e «scacchiera»: con kernel non multipli dello stride può produrre artefatti a scacchiera; l'alternativa è l'interpolazione (nearest o bilineare) seguita da una convoluzione normale.
  • Sbilanciamento tra pixel di oggetto e di sfondo: oltre alla scelta della metrica, si pesa la perdita o si usano perdite basate sull'IoU/Dice (per esempio la perdita Dice).
  • Valutare la segmentazione solo su immagini pulite: come nella lezione su CNN e augmentation, un test di robustezza (rumore, contrasto) mostra più di una cifra sul test originale.

Le nove trappole si dividono in tre gruppi. Quattro riguardano la lettura dei risultati (accuratezza per pixel, filtri interpretati troppo, CAM grossolana, test solo su immagini pulite): il programma gira, ma la conclusione è sbagliata. Tre riguardano la forma dei dati (hook non rimosso, GAP che cancella la posizione, concatenazione con risoluzioni incompatibili) e due l'addestramento della segmentazione (artefatti a scacchiera, sbilanciamento tra oggetto e sfondo). La prima è la più cara: nel listato un modello che non fa nulla ha 0,845 di accuratezza per pixel, e solo l'IoU lo smaschera.

Domande frequenti

Perché la U-Net concatena e la ResNet somma? Sono due scelte con scopi diversi. La somma della ResNet aiuta il gradiente (lezione sui blocchi residuali) e richiede forme identiche. La concatenazione dell'U-Net preserva intera l'informazione dei due rami e lascia che il blocco successivo decida come combinarla, al costo di più canali.

Il GAP sostituisce sempre lo strato denso? Nelle architetture moderne per immagini grandi quasi sempre. Per immagini piccole e compiti in cui la posizione è informativa, come in Digits, può costare qualche punto.

La CAM spiega la rete? Mostra dove la rete ha trovato evidenza per una classe, non perché quel pattern la porti alla decisione, né se il modello ragioni nel modo giusto. È uno strumento di verifica (guarda dove deve?), non una spiegazione completa.

Si può fare segmentazione con una ResNet? Sì: molte architetture di segmentazione moderne usano un encoder ResNet (preaddestrato) con un decoder in stile U-Net.

Tre esercizi

Esercizio 1. Stampa le mappe di conv2 (16 canali 4×4) per due cifre diverse, ad esempio un 0 e un 1. Quali canali si accendono in modo diverso? Calcola la correlazione tra i vettori dei canali (dopo il GAP) delle due cifre.

Esercizio 2. Verifica l'identità della CAM per tutte le 360 immagini del test: la differenza massima tra logit e media della CAM più bias dev'essere dell'ordine di 10⁻⁵.

Esercizio 3. Nell'esperimento di segmentazione, aumenta il rumore (deviazione 0,6) e confronta di nuovo U-Net con e senza salti. Il vantaggio dei salti cresce o cala?

Misurare la segmentazione senza farsi ingannare

La metrica ovvia per la segmentazione, la frazione di pixel classificati correttamente, è anche la più ingannevole. Se l'oggetto occupa una piccola parte dell'immagine, un modello che etichetta tutto come sfondo ottiene un'accuratezza di pixel altissima senza aver segmentato niente: è la stessa trappola delle classi sbilanciate vista nel primo modulo, solo che qui lo sbilanciamento è dentro ogni singola immagine. Per questo si usa l'IoU (intersezione su unione) per classe: l'area in cui previsione e verità coincidono divisa per l'area coperta da almeno una delle due. L'IoU ignora i pixel che entrambe dichiarano sfondo, quindi premia solo chi trova l'oggetto e lo delimita bene, ed è 0 per un modello che dice «niente» quando c'è qualcosa.

Altre due cautele. Primo, riporta l'IoU per classe e non solo la media: una media può nascondere una classe rara completamente sbagliata. Secondo, controlla le previsioni a occhio su alcuni esempi con la maschera sovrapposta all'immagine, perché le metriche non distinguono un contorno sfocato da uno spostato, e la causa (e il rimedio) sono diversi.

Quanto all'architettura, la ragione delle connessioni di salto dell'U-Net è proprio la precisione dei contorni: il percorso di discesa riduce la risoluzione per capire che cosa c'è, i salti riportano il dettaglio spaziale per capire dove.

Che cosa sai fare adesso

Sai intercettare le feature map di una CNN con un hook (e rimuoverlo), sai che i singoli filtri di una rete piccola non hanno quasi mai un significato nitido, e sai sostituire flatten e strato denso con il global average pooling: sulle cifre i parametri passano da 3 818 a 1 418 con un costo di circa un punto di accuratezza (0,971 contro 0,958). Sai verificare che la logit è la media di una CAM più il bias e leggere la CAM di una classe senza fidarti troppo di una mappa 4×4. Sai costruire una U-Net con convoluzioni trasposte e connessioni di salto, e sai giudicarla con l'IoU: nell'esperimento i salti hanno portato l'IoU da 0,880 a 0,948, mentre il modello che dice sempre «sfondo» aveva 0,845 di accuratezza per pixel. Nella lezione successiva, transfer learning, riusi una CNN già addestrata per un nuovo compito.

Lezione precedente del percorso: Dropout e Batch Normalization: regolarizzare e stabilizzare una CNN.

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