Ir al contenido principal

Visión artificial con redes convolucionales (CNN)

Las redes neuronales convolucionales o ConvNets son útiles en variadas aplicaciones dentro del machine learning, sin embargo, donde ha conseguido revolucionar el estado del arte es en la visión artificial y el procesado de imágenes. Lo que caracteriza a este tipo de redes es que es capaz de aprender en las primeras capas una serie de características básicas de la imagen, como son líneas o formas más o menos simples. En posteriores fases aprende a discriminar entre elementos más complejos, como ojos u orejas. Finalmente, en capas más profundas son capaces de diferenciar objetos complejos, como personas, perros, gatos o coches. En la siguiente imagen se plasma visualmente lo que quiero decir.


Antes de hablar de las redes convolucionales convendrá explicar qué es eso de la convolución y para qué sirve. En el ámbito del tratamiento de imágenes, la convolución es una operación que involucra dos matrices. Por un lado la propia imagen a la que queremos aplicar la convolución (en forma de matriz, claro), y otra matriz más pequeña llamada kernel. El resultado de la convolución es otra matriz de las mismas dimensiones que la imagen original. Para calcular la matriz de convolución vamos desplazando el kernel por cada uno de los elementos de la matriz principal y hacemos la suma de los productos de cada uno de los elementos de ambas matrices. En realidad suena peor de los que es. Visualmente podemos imaginarlo así.


El centro del kernel, pues pasaría por cada uno de los elementos de la matriz para ir generando la matriz de convolución. Mejor verlo con un ejemplo.

$$
\begin{vmatrix}
P_{1} & P_{2} & P_{3} & P_{4} & P_{5} \\
P_{6} & P_{7} & P_{8} & P_{9} & P_{10} \\
P_{11} & P_{12} & P_{13} & P_{14} & P_{15} \\
P_{16} & P_{17} & P_{18} & P_{19} & P_{20} \\
P_{21} & P_{22} & P_{23} & P_{24} & P_{25}
\end{vmatrix}
\circledast
\begin{vmatrix}
A & B & C \\
D & E & F \\
G & H & I
\end{vmatrix}
=
\begin{vmatrix}
C_{1} & C_{2} & C_{3} & C_{4} & C_{5} \\
C_{6} & C_{7} & C_{8} & C_{9} & C_{10} \\
C_{11} & C_{12} & C_{13} & C_{14} & C_{15} \\
C_{16} & C_{17} & C_{18} & C_{19} & C_{20} \\
C_{21} & C_{22} & C_{23} & C_{24} & C_{25}
\end{vmatrix}
$$

Para calcular, por ejemplo, el elemento \(C_{13}\) de la matriz de convolución haríamos el siguiente cálculo (y así con todos los elementos).

$$C_{13}=A \cdot P_{7} + B \cdot P_{8} + C \cdot P_{9} + D \cdot P_{12} + E \cdot P_{13} + F \cdot C_{14} + G \cdot P_{17} + H \cdot P_{18} + I \cdot P_{19}$$

Un caso especial es cuando aplicamos la convolución sobre un elemento que está en el borde de la matriz. En ese caso, lo más común, es imaginarnos que la matriz está rodeada de ceros.
¿Y para qué sirve esto? Podemos imaginar un kernel como una especie de filtro que nos permite hacer cosas como detectar en las imágenes bordes, líneas verticales y horizontales, así como desenfocar y enfocar la imagen. Algunos kernels interesantes son los siguientes.

Identidad
$$
\begin{bmatrix}
0 & 0 & 0 \\
0 & 1 & 0 \\
0 & 0 & 0
\end{bmatrix}
$$


Detección de bordes


$$
\begin{bmatrix}
\ \ 1 & 0 & -1 \\
\ \ 0 & 0 & \ \ 0 \\
-1 & 0 & \ \ 1
\end{bmatrix}
$$



$$
\begin{bmatrix}
0 & \ \ 1 & 0 \\
1 & -4 & 1 \\
0 & \ \ 1 & 0
\end{bmatrix}
$$



$$
\begin{bmatrix}
-1 & -1 & -1 \\
-1 & \ \ 8 & -1 \\
-1 & -1 & -1
\end{bmatrix}
$$



Afilado
$$
\begin{bmatrix}
\ \ 0 & -1 & \ \ 0 \\
-1 & \ \ 5 & -1 \\
\ \ 0 & -1 & \ \ 0
\end{bmatrix}
$$


Difuminado
$$
\frac{1}{9}
\begin{bmatrix}
1 & 1 & 1 \\
1 & 1 & 1 \\
1 & 1 & 1
\end{bmatrix}
$$


Desenfoque gausiano 3x3
$$
\frac{1}{16}
\begin{bmatrix}
1 & 2 & 1 \\
2 & 4 & 2 \\
1 & 2 & 1
\end{bmatrix}
$$


Desenfoque gausiano 5x5
$$
\frac{1}{256}
\begin{bmatrix}
1 & 4 & 6 & 4 & 1 \\
4 & 16 & 24 & 16 & 4 \\
6 & 24 & 36 & 24 & 6 \\
4 & 16 & 24 & 16 & 4 \\
1 & 4 & 6 & 4 & 1
\end{bmatrix}
$$


Enfoque
$$
\frac{-1}{256}
\begin{bmatrix}
1 & 4 & \ \ 6 & 4 & 1 \\
4 & 16 & \ \ 24 & 16 & 4 \\
6 & 24 & -476 & 24 & 6 \\
4 & 16 & \ \ 24 & 16 & 4 \\
1 & 4 & \ \ 6 & 4 & 1
\end{bmatrix}
$$



En python podemos calcular la matriz de convolución a mano (no es complicado), pero la librería SciPy ya lo hace por nosotros.

from scipy import signal

A=[[255, 7, 3],
   [212, 240, 4],
   [218, 216, 230]]

B= [[1, -1],
    [-1,1]]

conv = signal.convolve(A, B)
print(conv)

[[ 255 -248   -4   -3]
 [ -43  276 -232   -1]
 [   6  -30  250 -226]
 [-218    2  -14  230]]

Con la librería PIL podemos aplicar la operación de convolución a una imagen. Primero la cargamos.

from PIL import Image, ImageFilter
import numpy as np

imgpath = '/home/alberto/Imágenes/imagen.png'
img = Image.open(imgpath)
img = img.convert('L')
display(img)


Hemos convertido la imagen a escala de grises, por lo que nuestra imagen se puede operar como si fuera una sola matriz. Si fuera una imagen en color tendríamos tres matrices, una por cada canal de color RGB.

imgmatrix = np.asarray(img, dtype=np.uint8)
print (imgmatrix)

[[156 164 156 ... 164 156 132]
 [164 164 164 ... 172 156 124]
 [164 156 164 ... 164 156 132]
 ...
 [ 44  44  52 ... 108 100 100]
 [ 44  44  60 ... 100 108 108]
 [ 44  44  52 ... 108 100 108]]

Así pues, como tenemos una matriz entre manos, podemos aplicar la convolución tal que así.

kernelValues = [-2,-1,0,-1,1,1,0,1,2] 
kernel = ImageFilter.Kernel((3,3), kernelValues)
 
im2 = img.filter(kernel)

display(im2)


Una vez tenemos claro qué hace la operación de convolución sobre una imagen, ya podemos hablar de las redes neuronales convolucionales o CNN (Convolutional Neural Networs).

Redes neuronales convolucionales (CNN).

La arquitectura típica de una CNN dedicada al reconocimiento y clasificación de imágenes, está dividida en dos partes: La etapa convolucional y la etapa de clasificación (las CNN pueden usarse en otras aplicaciones, pero aquí os mostraré su uso en la clasificación de imágenes). La fase convolucional utiliza dos tipos de capas: de convolución y de max pooling. Las capas de convolución que en Keras se crean con la función Conv2D() del paquete layers de Keras. Veamos un ejemplo.

Conv2D(64, (3, 3), activation='relu')

El primer parámetro es el número de filtros (kernels) que se va a aplicar a la imagen, y el segundo parámetro es el tamaño de los kernels. Como hemos visto, cada kernel genera un conjunto de características en la imagen inicial. Por ejemplo, algunos kernels detectarán las líneas verticales, otros las horizontales, otros las esquinas, etc. Como resultado vamos a obtener una serie de imágenes (tantas como kernels apliquemos) en las que la red será capaz de reconocer formas simples como líneas y otros tipos de características. Si vamos repitiendo el proceso, y pasamos el resultado por otra capa convolucional, esta será capar de ir reconociendo elementos más complejos, como una oreja o un ojo, y así hasta ser capaz de reconocer un gato o un perro. Evidentemente esto es una simplificación, pero es una idea bastante intuitiva de cómo funciona una red convolucional. Es habitual usar la función de activación relu en este tipo de redes.
El otro tipo de capa que usa una red convolucional para reconocimiento de imágenes es MaxPooling2D().

MaxPooling2D(pool_size=(2, 2))

Esta capa aplica un filtro que reduce la dimensionalidad de la imagen, esto es, la hace más pequeña. El objetivo es, por un lado, reducir el coste computacional. Además se minimiza la posibilidad de overfitting y se consigue aumentar la abstracción sobre los datos de entrada. Lo más fácil será verlo con un ejemplo.


La imagen muestra el resultado de aplicar una operación de max pooling de 2x2. Esto es, se van cogiendo grupos de 2x2 y nos quedamos con el máximo valor del grupo de cuatro píxeles. De esta forma reducimos la imagen que originariamente era de 4x4 a una imagen de 2x2.
Una vez aclarados estos conceptos, vamos a ponernos manos a la obra.

Clasificar las imágenes del dataset CIFAR10

Vamos a usar el dataset CIFAR10. Este dataset contiene 60000 imágenes de tamaño 32x32 dividido en 10 clases diferentes (6000 imágenes por clase). El dataset se divide en 50000 imágenes para entrenamiento y 10000 para test.
Las clases son:
0 - airplane
1 - automobile
2 - bird
3 - cat
4 - deer
5 - dog
6 - frog
7 - horse
8 - ship
9 - truck

Obtendremos el dataset importándolo desde Keras, que ya lo incorpora, por lo que no necesitamos descargarlo desde la web.


from keras.datasets import cifar10

(x_train, y_train), (x_test, y_test) = cifar10.load_data()

Examinemos algunas imágenes para ver qué pinta tienen.

import matplotlib.pyplot as plt

for i in range(9):
    plt.subplot(330 + 1 + i)
    plt.imshow(x_train[i])
    
plt.show()


Vamos a transformar las etiquetas de números enteros a un vector binario, que es más conveniente a la hora de entrenar la red. Esto lo hacemos con la función to_categorical de keras. Esta forma de codificar las etiquetas se denomina one-hot.

from keras.utils import to_categorical

y_train = to_categorical(y_train)
y_test = to_categorical(y_test)

Creamos un conjunto de validación que nos va a servir para ir controlando la calidad del entrenamiento para evitar el overfitting.

import numpy as np

np.random.seed(42)

# barajamos el dataset
permutation = np.random.permutation(x_train.shape[0])
x_train = x_train[permutation]
y_train = y_train[permutation]

# obtenemos el conjunto de validación
num_val = 10000
x_val = x_train[:num_val]
x_train = x_train[num_val:]
y_val = y_train[:num_val]
y_train = y_train[num_val:]

Finalmente definimos el modelo de nuestra red neuronal.

from keras import layers
from keras import models

model = models.Sequential()
model.add(layers.Conv2D(32, (3, 3), activation='relu', padding='same', input_shape=(32, 32, 3)))
model.add(layers.MaxPooling2D(pool_size=(2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu', padding='same'))
model.add(layers.MaxPooling2D(pool_size=(2, 2)))
model.add(layers.Flatten())
model.add(layers.Dense(512, activation='relu'))
model.add(layers.Dense(10, activation='softmax'))

Esquemáticamente tenemos lo siguiente.


Detengámonos un poco en el modelo que acabamos de definir. A diferencia de la red que vimos en el anterior artículo sobre clasificación con deep learning, la entrada de la red convolucional es una imagen, en este caso de 32x32, y con tres capas, una por cada canal de color. Hay varias cosas que señalar. La primera es que el tamaño de los kernels suele ser de tamaño impar (3x3, 5x5, etc). Por otro lado, es habitual que el número de kernels que se aplica en cada capa convolucional vaya en aumento y tome valores (normalmente potencias de 2) cada vez más grandes según se profundiza en la red. En nuestro ejemplo hay pocas capas para lo que suele ser habitual (para permitir que este código se ejecute en equipo relativamente modestos). En una red más compleja se suele llegar a usar hasta 512, 1024, 2048 y hasta más número de filtros. También es habitual incluir una capa de max pooling cada pocas capas de convolución (en nuestro ejemplo, entre cada capa).
La última fase de la red es la de clasificación. Se utiliza una capa densamente conectada. Previamente se aplanan con flatten() las salidas de la fase convolucional, que recordemos que es una imagen en dos dimensiones, para convertirlo en un vector. En la última fase se usa la función de activación softmax de diez salidas (una por cada tipo de imagen a clasificar) que nos devuelve una distribución de probabilidad indicando cuál es la probabilidad de que la imagen pertenezca a cada una de las clases.
Ahora podemos compilar y entrenar el modelo.

from keras import optimizers

model.compile(loss='categorical_crossentropy', 
              optimizer=optimizers.RMSprop(lr=1e-4),
              metrics=['acc'])

train_log = model.fit(x_train, y_train, 
                      epochs=100, batch_size=128,
                     validation_data=(x_val, y_val))

Es buena práctica crear un par de gráficas para ver cómo se comporta el modelo durante el entrenamiento. Veamos la gráfica de ajuste con la función de coste y la de exactitud, tanto para el grupo de datos de entrenamiento como los de validación.

import matplotlib.pyplot as plt

acc = train_log.history['acc']
val_acc = train_log.history['val_acc']
loss = train_log.history['loss']
val_loss = train_log.history['val_loss']
epochs = range(1, len(acc) + 1)
plt.plot(epochs, acc, 'r', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.legend()
plt.figure()
plt.plot(epochs, loss, 'r', label='Training loss')
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()
plt.show()


test_loss, test_accuracy = model.evaluate(x_test, y_test)
print(test_accuracy)

10000/10000 [==============================] - 0s 44us/step
0.6504999995231628

En la segunda gráfica observamos que hay un problema. La red neuronal comienza a tener problemas con el conjunto de validación y vemos como la función de coste crece en vez de minimizarse. Es un claro problema de overfitting. Vamos a recurrir a una técnica habitual en estos casos, que consiste en eliminar complejidad de la red. Al disminuir esta, conseguimos que se simplifique el modelo y que logre generalizar mejor. Una forma de reducir esta complejidad es eliminar algunas conexiones de forma aleatoria entre dos capas. Esto podemos hacerlo con una capa Dropout(). Como parámetro pasamos el porcentaje de conexiones que queremos anular (por ejemplo un valor de 0.25 eliminará el 25% de las conexiones).

model = models.Sequential()
model.add(layers.Conv2D(32, (3, 3), activation='relu', padding='same', input_shape=(32, 32, 3)))
model.add(layers.MaxPooling2D(pool_size=(2, 2)))
model.add(layers.Dropout(0.25))
model.add(layers.Conv2D(64, (3, 3), activation='relu', padding='same'))
model.add(layers.MaxPooling2D(pool_size=(2, 2)))
model.add(layers.Dropout(0.25))
model.add(layers.Flatten())
model.add(layers.Dense(512, activation='relu'))
model.add(layers.Dropout(0.5))
model.add(layers.Dense(10, activation='softmax'))

model.compile(loss='categorical_crossentropy', 
              optimizer=optimizers.RMSprop(lr=1e-4),
              metrics=['acc'])

train_log = model.fit(x_train, y_train, 
                      epochs=100, batch_size=128,
                     validation_data=(x_val, y_val))

Como vemos, ahora la cosa está mucho mejor. Y además llegamos al 71% de exactitud con el conjunto de test frente al 65% que teníamos antes.


Aun así, un 71% no es muy espectacular. ¿Se puede mejorar? Rotundamente sí. El modelo que hemos usado es demasiado sencillo para este tipo de tareas. Una red de este tipo tiene muchas más capas, pero claro, también necesitaremos una máquina y una GPU más potente. Por ejemplo, con el siguiente modelo, que sigue siendo relativamente sencillo, llegamos al 77% (eso sí, no lo intentéis en casa si no vais armados con una buena GPU). Con una red aún más compleja he llegado a superar una exactitud del 95%, pero usando técnicas que ya os contaré otro día.

model = models.Sequential()
model.add(layers.Conv2D(32, (3, 3), activation='relu', padding='same', input_shape=(32, 32, 3)))
model.add(layers.Conv2D(32, (3, 3)), activation='relu')
model.add(layers.MaxPooling2D(pool_size=(2, 2)))
model.add(layers.Dropout(0.25))
model.add(layers.Conv2D(64, (3, 3), activation='relu', padding='same')
model.add(layers.Conv2D(64, (3, 3), activation='relu')
model.add(layers.MaxPooling2D(pool_size=(2, 2)))
model.add(layers.Dropout(0.25))
model.add(layers.Flatten())
model.add(layers.Dense(512), activation='relu')
model.add(layers.Dropout(0.5))
model.add(layers.Dense(10), activation='softmax')

model.compile(loss='categorical_crossentropy', 
              optimizer=optimizers.RMSprop(lr=1e-4),
              metrics=['acc'])

train_log = model.fit(x_train, y_train, 
                      epochs=100, batch_size=128,
                     validation_data=(x_val, y_val))

En la práctica se utilizan modelos más complejos creados por investigadores y a las que se le asigna un nombre para diferenciarlos de otras arquitecturas. Algunos ejemplos son VGG16, AlexNet, ResNet o DenseNet, por nombrar algunas de las arquitecturas más usadas. Como ejemplo os muestro la arquitectura de RasNet, para que os hagáis una idea de la complejidad de los modelos con los que se trabaja habitualmente.


Os invito a experimentar añadiendo y quitando capas, modificando sus parámetros, añadiendo o quitando capas dropout, etc. Es un ejercicio interesante para ir viendo cómo mejora o empeora el resultado.

Comentarios

Entradas populares de este blog

Criptografía en Python con PyCrypto

A la hora de cifrar información con Python, tenemos algunas opciones, pero una de las más fiables es la librería criptográfica PyCrypto, que soporta funciones para cifrado por bloques, cifrado por flujo y cálculo de hash. Además incorpora sus propios generadores de números aleatorios. Seguidamente os presento algunas de sus características y también como se usa.


Regresión lineal y descenso de gradiente con Python

En machine learning, el objetivo principal es encontrar un modelo que explique el comportamiento de un sistema (en el amplio sentido de la palabra). A partir de unos datos de entrenamiento, un sistema de aprendizaje automático ha de ser capaz de inferir un modelo capaz de explicar, al menos en su mayoría, los efectos observados. Pero también aplicar ese aprendizaje. Por ejemplo, un sistema de machine learning muy lucrativo para las empresas anunciantes es aquél que dado un perfil de usuario (datos de entrada A), sea capaz de predecir si pinchará o no (salida B) sobre un anuncio publicitario de, por ejemplo, comida para gatos. No es sencillo crear un modelo capaz de predecir el comportamiento del usuario (o sí), pero en todo caso, existen diferentes técnicas que nos permiten abordar el problema. En el caso del ejemplo que acabamos de ver, el modelo debería ser capaz de clasificar a los usuarios en dos clases diferentes, los que pulsarán y los que no pulsarán el anuncio de comida de ga…

Desbordamiento de enteros (Integer Overflow)

Ya os he hablado en este blog de posibles problemas potenciales que se pueden dar en los programas y que son susceptibles de ser explotados para hacer que dichos programas se comporten de forma diferente a la que deberían. Uno de estos problemas es el del desbordamiento de la pila. Sin embargo, hay otros posibles errores de programación que, aunque menos obvios, son igual de peligrosos. Uno de ellos es el desbordamiento de enteros o integer overflow. Para entender cómo funciona os presento un ejemplo muy sencillo pero didáctico.