En este notebook se muestra cómo crear un modelo de clasificación de imágenes utilizando las técnicas vistas en clase.

Para crear nuestro clasificador de imágenes vamos a utilizar la librería fastAI. Este notebook está inspirado en el curso asociado a dicha librería.

En esta práctica vamos a hacer un uso intensivo de la GPU, así que es importante activar su uso desde la opción Configuración del cuaderno del menú Editar (esta opción debería estar habilitada por defecto, pero es recomendable que lo compruebes).

Librerías

Comenzamos descargando la última versión de la librería FastAI. Al finalizar la instalación se reiniciará el kernel de manera automática.

!pip install fastai -Uq
import IPython
IPython.Application.instance().kernel.do_shutdown(True)

A continuación, cargamos aquellas librerías que son necesarias.

from fastai.vision.all import *

Dataset

Para esta práctica vamos a usar como ejemplo de dataset el Intel Image Classification dataset. Este dataset consta de imágenes de tamaño 150x150 distribuidas en 6 categorías (buildings, forest, glacier, mountain, sea, street). Los siguientes comandos descargan y descomprimen dicho dataset.

!wget https://unirioja-my.sharepoint.com/:u:/g/personal/joheras_unirioja_es/EbMVHqKMSnNHh6I0-4-QWdQBlVDKz2Uz5Ky73zc5tHGofg?download=1 -O IntelImageClassification.zip
!unzip IntelImageClassification.zip

Vamos a explorar el contenido de este dataset. Para ello vamos a crear un objeto Path que apunta al directorio que acabamos de crear.

path = Path('IntelImageClassification/')

Con el objeto path podemos utilizar funciones como ls().

path.ls()

Vemos que nuestro dataset consta de dos carpetas llamadas train y test. Recordar que es importante hacer la partición del dataset en dos conjuntos distintos, para luego poder evaluarlo correctamente. Podemos ahora crear objetos path que apunten respectivamente a nuestro conjunto de entrenamiento y a nuestro conjunto de test.

trainPath = path/'train'
testPath = path/'test'

Veamos el contenido de cada uno de estos directorios.

trainPath.ls()
testPath.ls()

Podemos ver que tanto la carpeta train como la carpeta test contienen 6 subcarpetas, una por cada clase del dataset.

Cargando el dataset

A continuación vamos a mostrar cómo se carga el dataset para poder posteriormente crear nuestro modelo. Este proceso se hace en dos pasos. Primero se construye un objeto DataBlock y a continuación se construye un objeto DataLoader a partir del DataBlock. Tienes más información sobre estos objetos en la documentación de FastAI.

Datablock

Comenzamos construyendo el objeto DataBlock. A continuación explicaremos cada una de sus componentes.

db = DataBlock(blocks = (ImageBlock, CategoryBlock),
                 get_items=get_image_files, 
                 splitter=RandomSplitter(valid_pct=0.2,seed=42),
                 get_y=parent_label,
                 item_tfms = Resize(256),
                 batch_tfms=aug_transforms(size=128,min_scale=0.75))

Vamos a ver las distintas componentes del DataBlock.

  • El atributo blocks sirve para indicar el tipo de nuestros datos. Como estamos en un problema de clasificación de imágenes, tenemos que la entrada de nuestro modelo será una imagen, es decir un ImageBlock, y la salida será una categoría, es decir un CategoryBlock. Por lo tanto indicamos que blocks = (ImageBlock, CategoryBlock).
  • El atributo get_items debe proporcionar una función para leer los datos. En nuestro caso queremos leer una serie de imágenes que estarán almacenadas en un path. Para ello usamos la función get_image_files. Puedes ver qué hace exactamente esta función ejecutando el comando ??get_image_files.
  • El atributo splitter nos indica cómo partir el dataset. Daros cuenta que tenemos un conjunto de entrenamiento y uno de test, pero para entrenar nuestro modelo y probar distintas alternativas nos interesa usar un conjunto de validación, que lo vamos a tomar de forma aleatorea a partir de nuestro conjunto de entrenamiento usando un 20% del mismo. Para ello usaremos el objeto RandomSplitter(valid_pct=0.2,seed=42).
  • El atributo get_y sirve para indicar cómo extraemos la clase a partir de nuestros datos. La función get_image_files nos proporciona una lista con los paths a las imágenes de nuestro dataset. Si nos fijamos en dichos paths, la clase de cada imagen viene dada por la carpeta en la que está contenida, por lo que podemos usar el método parent_label para obtener la clase de la misma.

Por último, los atributos item_tfms y batch_tfms sirven para aplicar una técnica conocida como preescalado (o presizing).

Preescalado (presizing)

El preescalado es una técnica de aumento de datos diseñada para minimizar la destrucción de datos. Para poder sacar el máximo partido a las GPUs, es necesario que todas las imágenes tengan el mismo tamaño, por lo que es común reescalar todas las imágenes al mismo tamaño.

Sin embargo, hay varias técnicas de aumento que si se aplican después de reescalar pueden introducir zonas vacías o degradar los datos. Por ejemplo, si rotamos una imagen 45 grados, los bordes de la imagen quedan vacíos, lo que no le sirve para nada al modelo. Para solucionar este problema se utiliza la técnica del preescalado que consta de dos pasos.

  1. Las imágenes se reescalan a una dimensión mayor que la que se usará realmente para entrenar.
  2. Se aplican las distintas técnicas de aumento, y finalmente se reescala al tamaño deseado.

El punto clave es el primer paso que sirve para crear imágenes con el suficiente espacio para luego poder aplicar los distintos aumentos. Tienes más información sobre esta técnica en el libro de FastAI.

En nuestro caso estamos haciendo un escalado inicial a tamaño 256 para luego aplicar un escalado a tamaño 128. Notar que no sólo estamos aplicando un escalado como técnica de aumento de datos, sino que también gracias a la función aug_transforms se aplican otros aumentos.

Data augmentation

Como hemos visto en clase, la técnica de aumento de datos (o data augmentation) nos proporciona un método para aumentar el tamaño de nuestro dataset. FastAI ofrece una serie de aumentos por defecto que se pueden configurar mediante el método aug_transforms. Veamos a continuación que aumentos ofrece dicha función.

??aug_transforms

Como vemos la función anterior puede ser utilizada para fijar distintos aumentos y la probabilidad con la que queremos que se apliquen. En caso de querer otro tipo de transformaciones que no estén incluidas por defecto en dicha función podemos usar la librería Albumentations como se explica en la documentación de FastAI.

Dataloader

Pasamos ahora a construir nuestro DataLoader que se construye a partir del DataBlock construido anteriormente indicándole el path donde se encuentran nuestras imágenes. Además podemos configurar el DataLoader indicándole el tamaño del batch que queremos utilizar. Al trabajar con GPUs es importante que usemos batches de tamaño 2^n para optimizar el uso de la GPU.

dls = db.dataloaders(trainPath,bs=128)

A continuación mostramos un batch de nuestro DataLoader. Es conveniente comprobar que realmente se han cargado las imágenes y sus anotaciones de manera correcta.

dls.show_batch()

Entrenando el modelo

Pasamos ahora a construir y entrenar nuestro modelo. Pero antes vamos a definir una serie de callbacks.

Callbacks

En ocasiones nos interesa cambiar el comportamiento por defecto que tiene el proceso de entrenamiento, por ejemplo para guardar los mejores pesos que se han producido hasta ese momento. El procedimiento usado por FastAI para incluir dicha funcionalidad son los callbacks que sirven para modificar el proceso de entrenamiento. La lista completa de callbacks incluida en FastAI, está disponible en su documentación. En nuesto caso sólo vamos a utilizar 3 callbacks:

  • ShowGraphCallback: este callback sirve para mostrar las curvas de entrenamiento y validación.
  • EarlyStoppingCallback: este callback nos permite aplicar la técnica de early stopping. Para ello debemos indicarle la métrica que queremos monitorizar para saber cuándo parar, y la paciencia (es decir cuántas épocas dejamos que el modelo continúe entrenando si no ha habido mejora).
  • SaveModelCallback: este callback guarda el mejor modelo encontrado durante el proceso de entrenamiento y lo carga al final del mismo. Como vamos a crear un modelo usando la arquitectura resnet18 conviene que indiquemos esto en el nombre del modelo. También sería conveniente indicar el nombre del dataset para no confundirlos.
callbacks = [
    ShowGraphCallback(),
    EarlyStoppingCallback(patience=3),
    SaveModelCallback(fname='modelResnet18')  
]

Además de estos tres callbacks utilizaremos otro que nos servirá para acelerar el entrenamiento de nuestros modelos usando mixed precision.

Construyendo el modelo

A continuación construimos nuestro modelo, un objeto de la clase Learner, utilizando el método cnn_learner que toma como parámetros el DataLoader, la arquitectura que queremos entrenar (en nuestro caso un resnet18), la métrica que usaremos para evaluar nuestro modelo (esta evaluación se hace sobre el conjunto de validación, y en nuestro caso será la accuracy), y los callbacks. Notar que en la instrucción anterior incluimos la transformación del modelo a mixed precision.

learn = cnn_learner(dls,resnet18,metrics=accuracy,cbs=callbacks).to_fp16()

Notar que internamente la función cnn_learner hace varias cosas con la arquitectura que le pasamos como parámetro (en este caso resnet18). Dicha arquitectura fue entrenada inicialmente para el problema de ImageNet, por lo que ante una nueva imagen, su salida sería la predicción en una de las 1000 clases de ImageNet. Sin embargo, internamente la función cnn_learner elimina las últimas capas de dicha arquitectura, y las reemplaza con una adecuada para nuestro problema concreto.

Antes de entrenar nuestro modelo debemos encontrar un learning rate adecuado.

Learning rate finder

Como hemos visto en teoría, el trabajo de Leslie Smith proporciona un método para encontrar un learning rate adecuado para entrenar nuestro modelo. Dicho learning rate lo puedes encontrar con la función lr_find().

learn.lr_find()

La función anterior no solo nos devuelve un gráfico sino que nos sugiere dos valores lr_min y lr_steep. La recomendación es utilizar el valor de lr_steep, para entrenar el modelo.

Fine-tuning

A continuación vamos a aplicar la técnica de fine tuning. En FastAI esto es tan sencillo como llamar al método fine_tune del objeto Learner. Este método recibe dos parámetros principalmente, el número de épocas (10 en nuestro caso) y el learning rate. El proceso que sigue para entrenar consiste en:

  1. Congelar todas las capas salvo la última, y entrenar esa parte del modelo durante una época.
  2. Descongelar la red, y entrenar el modelo por el número de épocas indicado.

Al ejecutar la siguiente instrucción aparecerá una tabla donde podrás ver la pérdida para el conjunto de entrenamiento, la pérdida para el conjunto de validación, y la accuracy para el conjunto de validación.

learn.fine_tune(10,base_lr=1e-3)

Al final del entrenamiento se ha guardado un modelo en la carpeta models que contiene el mejor modelo construido.

Path('models').ls()

Para su uso posterior, es conveniente exportar el modelo. Para ello es necesario en primer lugar convertir el modelo a fp32.

learn.to_fp32()
learn.export()

Podemos ver que dicho modelo se ha guardado en el mismo directorio donde nos encontramos.

Path().ls(file_exts='.pkl')

Evaluando el modelo

Una vez tenemos entrenado nuestro modelo nos interesa saber:

  1. ¿Qué tal funciona en el conjunto de test?
  2. ¿Qué errores comete?
  3. ¿Cómo se utiliza para predecir la clase ante nuevas imágenes?

Evaluando el modelo en el conjunto de test

Para poder evaluar nuestro modelo en el conjunto de test debemos crear un nuevo DataBlock y un nuevo DataLoader. La única diferencia con el DataBlock utilizado previamente es que para hacer la partición del dataset usamos un objeto de la clase GrandparentSplitter indicando que el conjunto de validación es nuestro conjunto de test. En el caso del DataLoader, la diferencia con el definido anteriormente es que cambiamos la ruta al path.

dbTest = DataBlock(blocks = (ImageBlock, CategoryBlock),
                 get_items=get_image_files, 
                 splitter=GrandparentSplitter(valid_name='test'),
                 get_y=parent_label,
                 item_tfms = Resize(256),
                 batch_tfms=aug_transforms(size=128,min_scale=0.75))
dlsTest = dbTest.dataloaders(path,bs=128)

Para trabajar con este dataloader debemos modificar nuestro objeto Learner. En concreto su atributo dls.

learn.dls = dlsTest

Ahora podemos evaluar nuestro modelo usando el método validate.

learn.validate()

El método validate nos devuelve dos valores: el valor de la función de pérdida, y el valor de nuestra métrica (la accuracy en este caso). Por lo que podemos ver que el modelo tiene una accuracy en el conjunto de test de aproximadamente un 82% (esto puede variar dependiendo de la ejecución).

Interpretación del modelo

Hemos visto que nuestro modelo obtiene una accuracy aproximada (puede variar debido a la aleatoriedad del proceso de entrenamiento) de un 92% en el conjunto de validación. Pero nos interesa conocer los errores que se cometen y si estos son razonables. Para ello podemos construir un objeto ClassificationInterpretation a partir de nuestro Learner y mostrar la matriz de confusión asociada. Recordar que hemos cambiado el DataLoader en el paso anterior, porque es conveniente volver al dataloader usado inicialmente.

learn.dls=dls
interp = ClassificationInterpretation.from_learner(learn)
interp.plot_confusion_matrix(figsize=(12,12),dpi=60)

Si nos fijamos, la mayoría de errores se producen porque el modelo confunde imágenes de la clase street con imágenes de la clase buildings; e imágenes de la clase mountain con la clase glacier. Podemos ver aquellas en las que se produce un mayor error del siguiente modo. Normalmente utilizaríamos el comando interp.plot_top_losses(10,nrows=2), pero como se muestra en el foro de FastAI, esto produce un comportamiento anómalo que será corregido en próximas versiones, por lo que tenemos que usar la solución que proporcionan en dicho hilo que consiste en definir la siguiente función.

def plot_top_losses_fix(interp, k, largest=True, **kwargs):
        losses,idx = interp.top_losses(k, largest)
        if not isinstance(interp.inputs, tuple): interp.inputs = (interp.inputs,)
        if isinstance(interp.inputs[0], Tensor): inps = tuple(o[idx] for o in interp.inputs)
        else: inps = interp.dl.create_batch(interp.dl.before_batch([tuple(o[i] for o in interp.inputs) for i in idx]))
        b = inps + tuple(o[idx] for o in (interp.targs if is_listy(interp.targs) else (interp.targs,)))
        x,y,its = interp.dl._pre_show_batch(b, max_n=k)
        b_out = inps + tuple(o[idx] for o in (interp.decoded if is_listy(interp.decoded) else (interp.decoded,)))
        x1,y1,outs = interp.dl._pre_show_batch(b_out, max_n=k)
        if its is not None:
            #plot_top_losses(x, y, its, outs.itemgot(slice(len(inps), None)), L(self.preds).itemgot(idx), losses,  **kwargs)
            plot_top_losses(x, y, its, outs.itemgot(slice(len(inps), None)), interp.preds[idx], losses,  **kwargs)
        #TODO: figure out if this is needed
        #its None means that a batch knows how to show itself as a whole, so we pass x, x1
        #else: show_results(x, x1, its, ctxs=ctxs, max_n=max_n, **kwargs)

Una vez definida dicha función ya podemos ver los mayores errores.

plot_top_losses_fix(interp,10,nrows=2)

A partir de la ejecución anterior, podemos ver que hay algunas imágenes que están mal anotadas, y otras en las que es comprensible por qué se está produciendo el error.

Usando el modelo

Vamos a ver cómo usar el modelo ante una nueva imagen. Para ello lo primero que vamos a hacer es cargar dicho modelo.

learn_inf = load_learner('export.pkl')

Ahora podemos usar dicho modelo para hacer inferencia con una nueva imagen mediante el método predict. En nuestro caso vamos a usar una imagen del conjunto de test.

learn_inf.predict('IntelImageClassification/test/buildings/20057.jpg')

La función anterior devuelve tres valores:

  • La clase (buildings en este caso).
  • El índice asociado a dicha clase.
  • Las probabilidades para cada una de las categorías.

Creando una aplicación para nuestro modelo

Es fundamental que los modelos sean usables, por lo que es conveniente proporcionar una interfaz secilla que permita usar nuestros modelos. Para ello, vamos a usar dos herramientas: Gradio y los espacios de HuggingFace. En concreto vamos a ver cómo construir la siguiente aplicación siguiendo los pasos del blog de Tanishq Abraham.

Para crear nuestra aplicación vamos a seguir los siguientes pasos:

  1. Descarga el fichero export.pkl creado anteriormente.
  2. Descarga dos de las imágenes del dataset.
  3. Crea una cuenta en HuggingFace. Este paso sólo hay que realizarlo una vez.
  4. Crea un espacio en HuggingFace. Al crear dicho espacio usamos como nombre Practica1 y seleccionamos Gradio como SDK.
  5. Una vez creado el espacio vamos a la pestaña Files and versions. En dicha pestaña debemos:
    • Subir el fichero export.pkl mediante el botón Add file -> Upload file.
    • Sube las dos imágenes que hayas descargado mediante el botón Add file -> Upload file.
    • Crear un fichero requirements.txt mediante el botón Add file -> Create a new file. Este fichero contendrá las librerías que son necesarias instalar para ejecutar nuestra aplicación, en este caso fastai.
    • Crear un fichero app.py mediante el botón Add file -> Create a new file. Este fichero contendrá el código de la aplicación.
  6. Siguiendo estos pasos tendrás una aplicación que podrás ver desde la pestaña App (la construcción de la aplicación puede llevar unos segundos, el proceso de construcción lo podrás ver pulsando en el botón See logs.