Blog
Wed Mar 11MLCNNLSTMComputer Vision

CNN + LSTM per Image Captioning: senza Transformer, senza magie, solo matematica

Also availableEnglish →

Lo so, nel 2026 tutti usano ViT + GPT-4o per generare descrizioni di immagini in due righe di Python. Il punto è questo: se non capisci le pipeline pre-transformer, non capisci davvero cosa succede dentro quelle moderne. Il meccanismo di attenzione è solo una versione migliore di quello che stiamo per descrivere.

Quindi abbiamo costruito un sistema di image captioning da zero. ResNet50 come encoder, LSTM come decoder, addestrato su Flickr8k. Nessun modello linguistico pre-addestrato. Nessun embedding CLIP. Solo convoluzioni, moltiplicazioni di matrici e celle di memoria con gate. Andiamo.


L'architettura in una frase

Un encoder CNN (ResNet50) estrae feature spaziali da un'immagine e le comprime in un vettore a 256 dimensioni. Un decoder LSTM prende quel vettore e genera una didascalia, parola per parola.

L'output dell'encoder viene concatenato con le caption embeddate e dato in pasto a un LSTM per generare la frase corrispondente. Fine. Tutto il resto sono dettagli implementativi. Vediamo i layer uno ad uno.


Parte 1: La convoluzione

Prendi un'immagine composta da pixel in bianco e nero. Puoi scrivere l'intensità come numeri. Ogni cubo qui sotto è un numero da 0 a 255.

Immagine in bianco e nero come griglia 3D di valori pixel, con un kernel sovrapposto in alto a destra

Definisci un kernel come una piccola matrice di pesi apprendibili usata per estrarre feature dall'immagine.

Una convoluzione è una combinazione lineare tra i pixel dell'immagine e uno o più filtri:

sono le coordinate di output nella matrice convolta. Il risultato è una cella di una nuova matrice più piccola: la feature map. Il kernel estrae pattern locali: bordi, gradienti, angoli.

Convoluzione: il kernel scorre sulla griglia di pixel producendo una feature map

Stride e Padding

  • Stride : di quanti pixel si sposta il kernel ad ogni passo
  • Padding : aggiunge zeri ai bordi per controllare le dimensioni dell'output

Data un'immagine e un kernel , la dimensione dell'output è:

Visualizzazione di stride e padding su una griglia di convoluzione

Immagini a colori: 3 canali

Per le immagini RGB si applica il kernel su ciascun canale (R, G, B) separatamente, poi si sommano i risultati:

Convoluzione 3D su immagine a colori — stesso filtro applicato a ciascun canale RGB, risultati sommati

Aggiungendo più filtri si ottengono più canali (feature) per descrivere l'immagine.

Più filtri producono più canali di feature in output

L'intera idea di una CNN è trasformare un'immagine in un vettore di feature.

Layer convoluzionale

Prendi la matrice di feature dalla convoluzione (operazione lineare), aggiungi un bias, applica ReLU (non lineare). È come una rete neurale dove i nodi di input sono le intensità dei canali sotto il kernel e i pesi sono i valori del kernel. Il bias evita che la funzione sia forzata a passare per l'origine, permettendo alla rete di rappresentare pattern con un offset.

Layer convoluzionale: output convoluzione + bias → attivazione ReLU → feature map

Pooling Layer

Il max pooling prende il massimo in ogni finestra. L'average pooling fa la media. Entrambi riducono le dimensioni spaziali, aggregano le feature e contrastano direttamente l'overfitting.

Max pooling vs average pooling su una griglia di feature map


Parte 2: ResNet50, l'encoder

ResNet50 è la nostra backbone per l'estrazione di feature. Trasforma le immagini in input in rappresentazioni significative attraverso uno stage iniziale e quattro stage di blocchi residui. Nello stage di flattening finale, rimuoviamo l'head di classificazione per preservare le feature map invece di produrre predizioni di classe.

Perché "Res"?

"ResNet" sta per Residual Network. L'idea centrale: invece di imparare una mappatura completa, i layer imparano il residuo: la differenza tra input e output desiderato. Una skip connection bypassa uno o più layer e aggiunge direttamente l'input originale all'output.

Diagramma blocco residuo: output = x + F(x), skip connection che bypassa due layer

I gradienti scorrono direttamente attraverso la skip connection durante la backprop. Questo risolve il problema del vanishing gradient e rende pratico addestrare reti con 50+ layer.

Stage iniziale

  1. Conv 7×7, stride 2 → dimezza la risoluzione spaziale, elimina il rumore dei pixel
  2. BatchNorm → media 0, varianza 1, stabilizza i gradienti
  3. ReLU → non-linearità, azzera i negativi
  4. MaxPool 3×3, stride 2 → ulteriore downsampling 2×, aggiunge invarianza alla traslazione (gli spostamenti piccoli non cambiano il massimo, le feature restano robuste)

Stage iniziale di ResNet50: riduzione spaziale aggressiva dall'immagine grezza a feature map a 64 canali

Blocchi residui Bottleneck

Ogni blocco residuo ha 3 convoluzioni, il design bottleneck:

Passo Operazione Scopo
1 Conv 1×1 Riduce i canali (comprime il calcolo)
2 Conv 3×3 Estrae contesto spaziale
3 Conv 1×1 Ripristina/espande le dimensioni

La skip connection aggiunge l'input originale all'output. Quando le dimensioni non corrispondono (es. 64 → 256), una conv 1×1 sul percorso skip gestisce la proiezione.

Primo blocco bottleneck: conv 1×1 → 3×3 → 1×1 con skip connection, ripetuto 3 volte

Dopo il primo stage, ogni nuovo stage aumenta la complessità semantica scambiando risoluzione spaziale per profondità di canale. La conv 3×3 usa stride=2 per dimezzare le dimensioni spaziali estraendo feature spaziali:

Stage bottleneck successivi: stride=2 sulla conv 3×3, canali crescono 256 → 512 → 1024 → 2048

Ripetizioni per stage, trovate ottimali sperimentalmente: 3 → 4 → 6 → 3 blocchi. Output finale: 2048 canali a 7×7.

Stage di flattening

Dopo 2048 canali a 7×7, ResNet fa una compressione finale:

  1. Adaptive Average Pooling: collassa tutti i 49 pixel spaziali (7×7) in un singolo valore per canale tramite media. Mantiene solo l'essenza semantica.
  2. Proiezione lineare: converte lo spazio di feature da 2048 a 256:

  1. BatchNorm: normalizza l'embedding per metriche di distanza stabili.

Risultato: un compatto fingerprint a 256 dimensioni dell'immagine.

Stage di flattening: adaptive avg pooling → proiezione lineare 2048→256 → BatchNorm → vettore 256-d

Questo embedding finale alimenta il decoder LSTM.


Parte 3: Decoder

Vocabolario

Le parole con frequenza < 5 vengono scartate. Token speciali aggiunti: <PAD> (padding per uniformare la lunghezza delle sequenze), <START>, <END>, <UNK> (sconosciuto per le parole scartate). Vocabolario finale: 5.507 parole.

Embedding Layer

Una lookup table apprendibile: matrice di dimensione .

Ogni parola mappa a un indice intero → una riga di . L'embedding è addestrato end-to-end, quindi parole semanticamente simili ("cane", "gatto") finiscono con vettori vicini nello spazio 256-d.

Lookup table dell'embedding: indice parola → riga della matrice 5507×256 → vettore parola 256-d

Poiché le parole sono one-hot encoded internamente, la moltiplicazione matriciale collassa in una semplice selezione di riga: viene selezionata esattamente la riga corrispondente della matrice:

Selezione embedding: la parola one-hot encoded seleziona la riga esatta dalla matrice di embedding

Concatenazione

Il vettore immagine funge da primo token a . Gli embedding delle parole seguono in sequenza:

sequenza = [vettore_immagine | parola_0 | parola_1 | ... | parola_T]

Le feature dell'immagine fungono da input iniziale, seguite dagli embedding delle parole. Questo è ciò che alimenta l'LSTM.


Parte 4: LSTM

Usiamo la Long Short-Term Memory invece di una RNN classica per due ragioni:

  1. Il modello deve ricordare l'immagine (vista a $t=0$) fino alla fine della frase. Serve memoria a lungo termine.
  2. Le LSTM mitigano il vanishing gradient su sequenze lunghe tramite la struttura a gate.

Due stati scorrono nel tempo:

  • Hidden state (256-d): memoria a breve termine, output visibile, usato per predire la prossima parola
  • Cell state (256-d): highway della memoria a lungo termine, trasporta info cruciali (es. il soggetto dell'immagine) senza degradarsi

A , gli stati sono inizializzati a zero e il vettore immagine è il primo input.

Architettura generale LSTM: cell state e hidden state che scorrono nel tempo, con operazioni di gate ad ogni step

Forget Gate

Decide quali informazioni dalla memoria passata ($C_{t-1}$) non servono più.

Guarda l'input corrente e il precedente hidden state , moltiplica ciascuno per i propri pesi, somma, aggiunge bias, passa tutto a una sigmoid:

Output: valori 0 (dimentica) → 1 (mantieni) per ogni elemento del cell state.

Dopo aver generato "uomo", il gate potrebbe dimenticare la feature generica "soggetto presente" per liberare memoria per il verbo.

Forget gate: l'output sigmoid maschera il cell state precedente elemento per elemento

Input Gate

In parallelo, decide quali nuove informazioni scrivere nella memoria a lungo termine.

  • Rete tanh → valori candidati (range −1 a +1, es. intensità della feature singolare/plurale)
  • Rete sigmoid → quanto è importante ogni candidato (0 a 1)

Moltiplica entrambi, aggiungi alla memoria precedente gated:

La vecchia memoria viene aggiornata: dimentichiamo il vecchio, sommiamo il nuovo pesato per importanza.

Input gate: candidati tanh × filtro sigmoid aggiunti al cell state precedente gated

Output Gate

Decide quale deve essere il prossimo hidden state (memoria a breve termine).

sigmoid decide quali parti del cell state mandare in output. Poi passa per tanh (spinge i valori tra −1 e +1) e viene moltiplicato per l'output della sigmoid:

La memoria sa che il soggetto è "gatto singolare", ma se dobbiamo predire un verbo, l'output gate filtra solo l'informazione "singolare" per coniugare correttamente.

Output gate: filtro sigmoid × tanh(cell state) = nuovo hidden state

Layer Fully Connected finale

L'output LSTM (256-d) è un concetto astratto. Dobbiamo tradurlo in una parola del vocabolario.

Linear layer: la matrice pesi proietta l'hidden state su un vettore delle dimensioni del vocabolario.

  • Training: Cross Entropy Loss confrontando i logits con la parola reale successiva (teacher forcing: si usa la parola reale al tempo come input per lo step $t+1$)
  • Inferenza: si passano i logits per softmax, si sceglie la parola con probabilità più alta, la si ri-usa come input successivo

FC layer: h_t (256-d) → linear 256×5507 → softmax → distribuzione di probabilità sul vocabolario


Training

Dataset: Flickr8k: 8.000 immagini, 5 caption ciascuna = 40.000 coppie.

Strategia: Teacher forcing. Si usa la parola reale della caption al tempo come input per lo step . Forza il modello ad imparare predizioni corrette invece di accumulare i propri errori durante il training.

Data augmentation per epoch per forzare l'apprendimento di concetti visivi robusti:

  • Crop e resize casuali
  • Flip orizzontale
  • Color jitter (luminosità/contrasto)
  • Leggere rotazioni

Risultati

La training loss è diminuita costantemente. La validation loss si è stabilizzata intorno all'epoch ~150, poi è leggermente aumentata. Overfitting moderato.

Training vs validation loss su 200 epoch — la validation si stabilizza intorno all'epoch 150

Esempio di output 1

Esempio di output 2

Esempio di output 3

Soggetti, azioni e contesti sono identificati correttamente. Sintatticamente un po' grezzo in certi punti, ma semanticamente sensato. Non male senza nessun modello linguistico pre-addestrato.


Il collo di bottiglia architetturale

Ecco il difetto: tutta l'informazione visiva deve passare attraverso un singolo vettore a 256 dimensioni. Alla quinta parola, il modello sta generando "bicicletta" ma l'encoding dell'immagine è diluito su tutto quello che ResNet ha mai visto.

I meccanismi di attenzione risolvono questo: invece di un singolo vettore riassuntivo, il decoder può interrogare le feature map spaziali di ResNet (7×7 × 2048) ad ogni step, focalizzandosi sulla regione dell'immagine rilevante. Il modello chiede "quale parte dell'immagine è rilevante adesso?" Questo è il passo successivo.


TL;DR

  1. ResNet50: immagine → vettore 256-d (convoluzioni + blocchi residui + adaptive pooling + proiezione lineare)
  2. Embedding layer: parole → vettori 256-d (addestrati end-to-end, semanticamente significativi)
  3. LSTM: [immagine, parola_0, parola_1...] → distribuzione sulla prossima parola (3 operazioni di memoria con gate ad ogni step)
  4. Cross entropy loss + teacher forcing durante il training
  5. Lieve overfitting dopo epoch 150, output coerenti
  6. Il collo di bottiglia del singolo vettore è il difetto architetturale → l'attenzione è la soluzione