X
    Categories: blog

Il transfer learning

Nell’articolo precedente abbiamo visto come sfruttare la libreria Keras per creare un modello convoluzionale con il quale riuscire a classificare immagini.

Per raggiungere lo scopo abbiamo dovuto inventarci un modello e successivamente addestrarlo passandogli un dataset molto corposo di immagini (60.000 immagini di training e 10.000 di test).

Nel nostro caso il tutto è stato eseguito in maniera abbastanza veloce per i seguenti motivi:

  • il modello neurale non era particolarmente complesso
  • nonostante l’elevato numero di esempi, le immagini erano di dimensioni molto ridotte (28 x 28 pixel) e a livelli di grigi (1 solo canale colore)

In condizioni normali invece ci si troverà di fronte a immagini ben più grandi (tipiche dimensioni sono 1024×768 con 3 canali RGB), modelli convoluzionali molto più complessi (milioni o decine di milioni di parametri) necessari a gestire la maggiore complessità delle scene riprese nelle immagini, dataset di addestramento in generale più ridotto (centinaia o migliaia di immagini).
Tutto ciò rende molto difficile addestrare il modello da zero, richiedendo ore o giorni al suo completamento, ottenendo in generale un livello di accuracy non particolarmente brillante.

Negli articoli passati abbiamo già accennato al fatto di poter utilizzare l’augmentation per sopperire parzialmente ai pochi esempi in addestramento, ma anche utilizzando questo strumento, resta comunque lo scoglio del tempo e della potenza di elaborazione richiesti dall’addestramento di un modello con milioni di parametri.

In casi come questo ci viene in aiuto lo strumento del transfer learning.

Il transfer learning

La pratica del transfer learning consente di riutilizzare gran parte dei parametri (pesi) di una rete neurale già addestrata in precedenza su un problema simile a quello che dobbiamo risolvere, soffermandoci sull’addestramento solo degli ultimi layer che sono solitamente quelli dedicati alla classificazione e/o alla regressione delle feature ottenute con i layer precedenti.

Questo consente di ottenere due risultati:

  • riutilizzo del comportamento di una rete già addestrata ad estrarre efficacemente feature dai dati di input
  • limitare l’elaborazione ad un numero sensibilmente minore di parametri (corrispondenti agli ultimi layer)

Ad esempio, se dovessimo classificare le varietà di mele da una immagine, potremmo partire utilizzando una rete neurale già addestrata a classificare immagini di aerei, automobili, cani, gatti, uova, ecc… Questo perchè la maggiore varietà del dataset di addestramento garantisce una migliore capacità di estrazione feature di vario genere dalle immagini.

Come indicato nella figura sopra, della rete neurale pre-addestrata manterremmo solo gli strati iniziali, andando a ridefinire solo gli ultimi strati di classificazione.

Gli strati riutilizzati verrebbero etichettati come “read-only“, così da calcolare solo i parametri corrispondenti agli ultimi layer, velocizzando di molto i tempi di addestramento, diminuendo sensibilmente la potenza di elaborazione richiesta ed in generale migliorando l’accuracy.

In Internet è possibile trovare diversi modelli già pre-addestrati su dataset standardizzati. I dataset più famosi sono ImageNet o COCO, ma ne esistono molti altri, a seconda dello scopo per cui quella rete neurale è stata progettata.

In generale, l’insieme di strati che vengono riutilizzati da una rete pre-addestrata viene chiamato backbone o feature-extractor.

Codice Python

NOTA: il codice di questo esempio lo trovi qui

Utilizzando Keras è facile creare un nuovo classificatore di immagini sfruttando reti pre-addestrate. In questo esempio andremo a creare un classificatore di immagini che distingua tra cinciarelle e corvi.

Per ottenere un dataset di addestramento minimale, utilizzeremo la libreria google_images_download che, come suggerisce il nome, consente di automatizzare il download di immagini utilizzando il motore di ricerca di Google.

# uso la libreria per scaricare automaticamente un certo numero di immagini di cinciarelle e corvi
# NOTA: il dataset scaricato può contenere immagini non adatte all'addestramento, quindi va verificato
from google_images_download import google_images_download
 
response = google_images_download.googleimagesdownload()
arguments = {"keywords": "cinciarella", "limit": 100, "print_urls": False, "format": "jpg", "size": ">400*300"}
response.download(arguments)
arguments = {"keywords": "corvo", "limit": 100, "print_urls": False, "format": "jpg", "size": ">400*300"}
response.download(arguments)
Item no.: 1 --> Item name = cinciarella
Evaluating...
Starting Download...
Completed Image ====> 1.12-6%20Cinciarella%20sulla%20neve%20-%20Giuseppe%20Pinin%20Becchio.jpg
Completed Image ====> 2.Cinciarella_scheda_specie_grande.jpg
Completed Image ====> 3.cinciarella_01.jpg
.....
.....

Come si vede dal log, il comando esegue una ricerca sul motore di Google e poi inizia a scaricare le immagini trovate, inserendole nella cartella corrispondente alla keyword di ricerca utilizzata (partendo dalla cartella downloads).


Può capitare che non tutte le immagini vengano scaricate, per i motivi più disparati, ad esempio perchè il sito non è più accessibile o necessita di autenticazione.

Inoltre è possibile che alcune immagini non saranno inerenti al tema dell’addestramento: ad esempio ricercando corvo è probabile che venga scaricata anche qualche immagine dell’omonimo film.

Quindi è bene fare una verifica delle immagini scaricate, tanto per evitare di mandare in confusione la rete neurale.

Nel mio caso, a seguito di una verifica manuale delle immagini scaricate, ho ottenuto un totale di 146 immagini suddivise in 2 classi (69 immagini di cinciarelle e 77 immagini di corvi). Un numero di esempi veramente irrisorio per pensare di poter addestrare da zero una rete neurale convoluzionale.

Ora possiamo procedere a definire il nostro modello: partiremo da un backbone pre-addestrato per verificare la bontà della pratica del transfer learning.

Il backbone sfrutterà un modello MobileNet che è un modello neurale leggero ma accurato. Tramite il codice indicheremo che vogliamo venga caricato il modello pre-addestrato sul dataset standard ImageNet e che di questo modello scarteremo i top-layers, che andremo a sostituire con nostri personalizzati e che saranno quindi i layer che andremo ad addestrare per creare il nostro classificatore definitivo.

# Definisco un modello 'backbone' utilizzando il classificatore 'MobileNet'
# pre-addestrato con dataset 'ImageNet'
# Indico che non voglio i top layer del classificatore, perchè userò i miei
# Indico anche che l'ultimo layer sarà un GlobalAveragePool, che consente di
# limitare ulteriormente i parametri da gestire
from keras.applications import MobileNetV2, mobilenet_v2
from keras.layers import Dense
from keras.models import Model
from keras.preprocessing.image import ImageDataGenerator
from keras.optimizers import Adam
 
backbone = MobileNetV2(weights='imagenet', include_top=False, pooling="avg")
 
# indico i layer del backbone come "non addestrabili", in modo da non modificarli
for l in backbone.layers:
    l.trainable = False
 
# definisco gli ultimi layer di classificazione, usando come ingresso le uscite del backbone
x = backbone.output
x = Dense(1024, activation='relu')(x)
x = Dense(1024, activation='relu')(x)
x = Dense(512, activation='relu')(x)
preds = Dense(2, activation='softmax')(x)
 
# il mio modello complessivo avrà gli stessi ingressi del backbone e l'uscita che ho definito
# nei mei top-layers
model = Model(inputs=backbone.input, outputs=preds)
 
# visualizzo un sommario del modello complessivo
model.summary()

Da notare che il ciclo for va ad impostare a False la proprietà trainable di ogni layer del backbone.
Questo rende read-only i parametri di questi layer, che quindi verranno presi come sono e non verranno modificati durante l’addestramento (viene evitata la backpropagation per questi layer). In questa maniera l’elaborazione risulterà essere più leggera.

Successivamente andiamo a definire i nostri layer di classificazione, dove i primi 3 servono a gestire la complessità delle feature che derivano dal backbone, mentre l’ultimo è il vero e proprio classificatore a 2 classi (essendo due il numero di volatili che dovrà gestire). Da notare che gli ingressi di questi layer corrispondono all’uscita del backbone.

Infine genero il modello complessivo, con input pari agli ingressi del backbone ed output pari alle uscite dei nostri top-layer.

Stampando un sommario del nostro modello otterremo questo risultato:

__________________________________________________________________________________________________
Layer (type)                    Output Shape         Param #     Connected to                     
==================================================================================================
input_1 (InputLayer)            (None, None, None, 3 0                                            
__________________________________________________________________________________________________
Conv1_pad (ZeroPadding2D)       (None, None, None, 3 0           input_1[0][0]                    
__________________________________________________________________________________________________
Conv1 (Conv2D)                  (None, None, None, 3 864         Conv1_pad[0][0] 
..............
..............
..............
global_average_pooling2d_1 (Glo (None, 1280)         0           out_relu[0][0]                   
__________________________________________________________________________________________________
dense_1 (Dense)                 (None, 1024)         1311744     global_average_pooling2d_1[0][0] 
__________________________________________________________________________________________________
dense_2 (Dense)                 (None, 1024)         1049600     dense_1[0][0]                    
__________________________________________________________________________________________________
dense_3 (Dense)                 (None, 512)          524800      dense_2[0][0]                    
__________________________________________________________________________________________________
dense_4 (Dense)                 (None, 2)            1026        dense_3[0][0]                    
==================================================================================================
Total params: 5,145,154
Trainable params: 2,887,170
Non-trainable params: 2,257,984

dove i layer tra input_1 e global_average_pooling2d_1 fanno parte del nostro backbone, mentre gli ultimi 4 layer (dense_1 … dense_4) sono i nostri top-layer.

Come si vede sotto, abbiamo un totale di 5.145.154 parametri, di cui 2.887.170 addestrabili (corrisponde alla somma dei parametri dei nostri top-layer) e 2.257.984 non addestrabili (corrisponde alla somma dei parametri dei layer del backbone che abbiamo bloccato).

Addestrando questo modello, quindi, risparmiamo circa il 50% dei parametri (tra l’altro sono per lo più layer convoluzionali e di pooling e quindi tendenzialmente più lenti da elaborare dei layer densi).

Ora non ci resta che compilare il modello indicando la loss function e l’ottimizzatore da utilizzare. Poi gli passeremo i dati utilizzando un generatore che prende i dati direttamente dalle directory create durante la fase di download.

Per l’addestramento utilizzeremo inoltre 10 epoche e come vedremo otteremo una accuracy perfetta.

# compilo il modello con una loss di classificazione, l'ottimizzatore Adam ed aggiungendo l'accuracy come metrica
model.compile(loss="categorical_crossentropy", optimizer=Adam(lr=0.0001), metrics=["accuracy"])
 
# creo un generatore di immagini che utilizzi la funzione di preprocessing necessaria al modello MobileNetV2
train_datagen = ImageDataGenerator(preprocessing_function=mobilenet_v2.preprocess_input)
 
# indico al generatore di immagini dove si trovano le immagini, le dimensioni da usare, il formato colore da usare,
# il batch_size con cui costruire i vari batch, il tipo di classificazione, e se deve mischiare il dataset
train_generator = train_datagen.flow_from_directory('./downloads',
                                                    target_size=(224, 224), 
                                                    color_mode='rgb',
                                                    batch_size=32,
                                                    class_mode='categorical',
                                                    shuffle=True)
 
# addestro il modello usando il generatore di immagini definito in precedenza, indicando
# quanti cicli eseguire per ogni epoca (lo calcolo dividendo l'ampiezza del dataset per il batch_size)
# ed utilizzando 10 epoche in tutto
model.fit_generator(generator=train_generator,
                    steps_per_epoch=ceil(train_generator.n / train_generator.batch_size),
                    epochs=10,
                    verbose=1)
....
....
Epoch 10/10

6/6 [==============================] - 6s 1s/step - loss: 0.0125 - acc: 1.0000

Un risultato sorprendente, visto che abbiamo utilizzato una manciata di immagini e pochissime epoche di addestramento. Sorprendente al punto tale che può nascere il sospetto che la rete sia in completo overfitting.

Verifichiamo usando due immagini di cinciarella e di corvo che non sono presenti nel dataset di addestramento.

Scaricheremo due immagini direttamente da web, in modo da assicurarci di ottenere immagini mai elaborate in precedenza dal modello.

Per fare questo definiamo due funzioni: la prima che scarica una immagine da una URL ad un file e la seconda che legge il file dell’immagine e prepara i dati per passarli al modello neurale.

import urllib
import numpy as np


def download_image(url, filename):
    # eseguo una GET sulla url passata come parametro
    with urllib.request.urlopen(url) as url_get:
        # apro il file su cui scriverò l'immagine
        with open(filename, "wb") as f:
            # leggo dalla GET e scrivo sul file
            f.write(url_get.read())


def load_image(img_path):
    # carico l'immagine dal file
    img = image.load_img(img_path, target_size=(224, 224))
    # trasformo l'immagine in un array Numpy
    # lo shape dell'array sono (altezza, larghezza, canali colore)
    # quindi in questo caso (224, 224, 3)
    img_array = image.img_to_array(img)
    # aggiungo una dimensione all'inizio
    # lo shape diventa (1, 224, 224, 3)
    # dove "1" indica quante immagini sono presenti nel batch
    img_array_batch = np.expand_dims(img_array, axis=0)
    # normalizzo i valori da 0..255 a 0..1
    img_array_batch /= 255.

    return img_array_batch

La funzione download_image vuole in ingresso due parametri: la url dell’immagine da scaricare ed un nome file su cui appoggiare i dati. Sfrutta la libreria urllib per eseguire la richiesta online.

La funzione load_image legge l’immagine da un file, la ridimensiona alla grandezza che vogliamo (224 x 224 pixel, le stesse dimensioni utilizzate per le immagini di addestramento), la aggiunge ad un batch costituito da una unica immagine e normalizza i valori dal range 0..255 al range 0..1 come desiderato dal modello neurale (questa era una preparazione che eseguiva direttamente il generatore di immagini utilizzato in fase di addestramento).

Ora individuiamo le url di due immagini che sicuramente non rientrano nel dataset di addestramento ed utilizziamole per il test predittivo.

# verifico la predizione su un disegno di ciciarella (immagine non utilizzato in training)
download_image("http://www.connemara.it/natura/fauna/uccelli/cinciarella%20foto/cinciarella%20disegno.jpg",
               "tempcinciarella.jpg")

img_di_test = load_image("tempcinciarella.jpg")
predizione = model.predict(img_di_test)
print("Predizione cinciarella:", predizione)

# analogamente per una immagine di corvo
download_image("http://3.bp.blogspot.com/-FV6e0kERgFE/VQMHK0m0E3I/AAAAAAAAFpA/7BeFPWSq8Tk/s1600/Nevermore.jpg",
               "tempcorvo.jpg")

img_di_test = load_image("tempcorvo.jpg")
predizione = model.predict(img_di_test)
print("Predizione corvo:", predizione)

Nel mio caso ho utilizzato due disegni, quindi immagini molto diverse dalle fotografie utilizzate in addestramento. Vediamo come si comporta la rete.

Predizione cinciarella: [[0.9982591  0.00174088]]
Predizione corvo: [[0.02381906 0.976181  ]]

Come si vede le accuracy sono molto alte: 99,82% per la cinciarella (classe 0) e 97,61% per il corvo (classe 1), nonostante un dataset di addestramento estremamente ridotto e l’utilizzo di disegni invece di immagini per fare il test.

Come abbiamo detto, nel nostro caso abbiamo utilizzato un modello MobileNet, che nonostante la sua leggerezza si è dimostrato valido. Nella libreria keras.applications sono presenti diversi altri modelli che è possibile utilizzare in altre situazioni (immagini più complesse, dettagli più piccoli, eccetera).
Si veda la documentazione ufficiale di Keras.

Utilizzando ad esempio il modello Resnet50 vedremo un netto salto nel numero di parametri.

Total params: 27,238,402
Trainable params: 3,673,602
Non-trainable params: 23,564,800

Questo modello ha una accuratezza maggiore del MobileNet ma ovviamente un peso maggiore per l’elaborazione. Viene comunque utilizzato spesso per immagini molto complesse o anche nei modelli di object detection, perchè riesce ad individuare correttamente oggetti di dimensioni ridotte.

In effetti, provando ad utilizzarlo nel nostro caso otterremo un valore di loss sensibilmente minore, anche se come vediamo il tempo di addestramento si raddoppierà.

6/6 [==============================] - 12s 2s/step - loss: 0.0025 - acc: 1.0000

Come andrà il test in questo caso? Scopritelo voi, come esercizio.

See you soon!

Potete verificare il funzionamento degli altri modelli pronti in Keras, sbizzarrendovi sul numero di classi da gestire.

Noi ci rivediamo alla prossima.

Bye!

Cristiano Casadei: Lavoro in Maggioli dal ’96 e ho contribuito a diversi prodotti dell’Azienda: da Concilia a TradeWin, ai prodotti per i Demografici. Dal 2016 entro a far parte a tempo pieno del team dei Progetti Speciali, ora R&D. In questo team ho contribuito allo sviluppo di Revisal, Scacco2 e ora mi occupo di studiare e sperimentare soluzioni che fanno uso di Intelligenza Artificiale. Come si può intuire dalla foto, amo la montagna.
Related Post