PRIMEROS PASOS CON TENSORFLOW 2.0

¿Cómo se programa la inteligencia artificial?

El pasado mes de Marzo, en el TensorFlow Dev Summit, se anunció la versión alfa de TensorFlow 2.0 completamente funcional. TensorFlow 2.0 se ha centrado en la simplicidad y la facilidad de uso, con actualizaciones importantes en el modelo de ejecución  (evitando al programador tener que pensar en el grafo de flujo de datos de TensorFlow 1.*)  y priorizando para programar el uso de la API de alto nivel basada en Keras. En resumen, ¡que lo han puesto fácil!

Por ello creo que es un buen momento para juntar en un solo libro y actualizar los contenidos de mis libros “Hello World en TensorFlow, para iniciarse en la programación del Deep Learning” publicado en el 2016 y el libro “Deep Learning, introducción práctica con Keras” publicado en el 2018.  

Y este es el propósito de esta página web, compartir con todos y todas lo que vaya generando de contenidos para explicar conceptos básicos de la  programación de la inteligencia artificial teniendo en mente a exalumnos y exalumnas que hayan pasado demasiado pronto por las aulas para conocer esta estimulante ola de innovación alrededor de la inteligencia artificial.  Pero nunca es tarde para adentrarse en este apasionante mundo de la programación de algoritmos para inteligencia artificial en general y Deep Learning en particular.

Siguiendo la misma fórmula de los anteriores libros, en paralelo a la introducción de conceptos teóricos básicos, se invita al lector a usar el teclado del ordenador. Le propongo que se reserve 6 días y ¡a disfrutar!

Primeros pasos con TensorFlow para programar la inteligencia artificial

Día 1:  Familiarizarse con el entorno de trabajo

TensorFlow

TensorFlow es la libreria para Machine Learning de código abierto más popular en el mundo en estos momentos. Desde su lanzamiento inicial en 2015 por el equipo de Google Brain, el paquete cuenta con más de 41 millones de descargas con más de 1.800 contribuidores (datos del TensorFlow Dev Summit en Marzo de 2019).
 

TensorFlow con François Chollet en CVPR 2017 Congreso de Computer VisionLa evolución de TensorFlow ha sido muy rápida desde sus inicios para apoyar el trabajo de los desarrolladores e investigadores de inteligencia artificial en ese momento. En el TensorFlow Dev Summit  se ha presentado una versió alfa de TensorFlow 2.0 que se centra en la simplicidad y facilidad de uso, y por ello ha adoptado la API de nivel superior Keras que fué desarrollada y mantenida previamente por François Chollet (a la derecha en la foto tomada en el congreso CVPR del año 2017). Esta es la API central de alto nivel en la que se basa TensorFlow a partir de ahora y sobre la que centraremos el contenido de este libro.

La mejor manera de comenzar a utilizar TensorFlow 2.0 Alpha es dirigirse a la web de TensorFlow.org y entrar en TensorFlow.org/alpha donde se pueden encontrar tutoriales y guías para iniciarse en la nueva versión alfa. De momento solo en inglés, por tanto si la lengua es un problema,  espero que este libro sea una ayuda para iniciarse  con TensorFlow 2.0.

TensorFlow es el framework open source más popular en el campo del Machine Learning y con la nueva versión 2.0 más “amigable” supongo que continuará manteniendo su posición de liderazo. Por ello es procedente elegir este framework para aprender los conceptos básicos de programación del Deep Learning.

 

Entorno de desarrollo

Pero antes de continuar, les recomiendo que tengan el entorno de desarrollo instalado en su ordenador para ir probando lo que se va explicando en cada apartado.

Debido a que TensorFlow es básicamente una librería de Python, requerimos hacer un uso completo del intérprete de Python. Nuestra propuesta es usar los notebook. Un notebook es un fichero generado por Jupyter Notebook que se puede editar desde un navegador web, permitiendo mezclar la ejecución de código Python con anotaciones y que proporciona una gran versatilidad para compartir parte de códigos con anotaciones a través de la comodidad e independencia de plataforma que ofrece un navegador web.

Propongo al lector que use el Colaboratory environment especialmente si no dispone de GPUs en su ordenador.  Se trata de un proyecto de investigación de Google creado para ayudar a diseminar la educación e investigación del Machine Learning. Es un entorno de notebook Jupyter que no requiere configuración y se ejecuta completamente en Cloud permitiendo usar Keras, TensorFlow o PyTorch. Los notebooks se almacenan en Google Drive y se pueden compartir tal como lo haría con Google Docs. Este entorno es de uso gratuito que solo requiere tener una cuenta en Google y además permite el uso de una GPU de forma gratuita.

Una vez has accedido al Google Colaboratory, debes conectar el entorno seleccionando “CONNECT” en la parte superior derecha del menú (que después de unos segundo deberías ver que indica con una marca verde que todo está correctamente inicializado). A continuación abre un notebook a través de la pestaña “File” en la parte superior izquierda del menú, y  selecciona la opción de “New Python 3.0 notebook”.

El primer paso es instalar tensorflow. Para ello tecleamos:

!pip install tensorflow-gpu==2.0.0-alpha

y a continuación clicamos en el símbolo de “play”. Recordemos que pip es una línea de comandos (no python) y por ello debemos usar como prefijo el signo de exclamación.

Una vez instalada la librería TensorFlow, pasamos a importar la librería en nuestro entorno Python. Para ello creamons una nueva celda para código con “+CODE” en el submenú superior izquierda con el siguiente código:

import tensorflow as tf

y nuevamente clicamos en el símbolo de “play”. Ya estás en disposición de escribir tu primer programa en TensorFlow. Pero antes vamos a introducir unos cuantos conceptos.

Terminología básica de Machine Learning

En este punto vamos a avanzar terminología básica de Machine Learning que nos permitirá mantener un guion de presentación de los conceptos de Deep Learning de manera más cómoda y gradual a lo largo del libro.

En general nos decantamos por usar el término en inglés, aunque en los casos en que la traducción facilite la comprensión del texto usaremos las diversas opciones que se indicaran a continuación.

En Machine Learning nos referimos a label (que también traduciremos por “etiqueta”) a lo que estamos intentando predecir con un modelo.  En cambio, a una variable de entrada la llamaremos feature (lo traduciremos como “característica” o “variable” de un ejemplo o dato de entrada).

Un modelo (model en inglés) define la relación entre features y labels y tiene dos fases claramente diferenciadas para el tema que nos ocupa:

  • Fase de training (que traduciremos también por “entrenamiento”o “aprendizaje”), que es cuando se crea o se “aprende” el modelo, mostrándole los ejemplos de entrada que se tienen etiquetados; de esta manera se consigue que el modelo aprenda iterativamente las relaciones entre las features y labels de los ejemplos.
  • Fase de inference (que traduciremos por “inferencia” o “predicción”), que se refiere al proceso de hacer predicciones mediante la aplicación del modelo ya entrenado a ejemplos no etiquetados.

Consideremos un ejemplo simple de modelo que expresa una relación lineal entre features y labels. El modelo podría expresarse de la siguiente forma:

Donde:

  • y es la label o etiqueta de un ejemplo de entrada.
  • x la feature de ese ejemplo de entrada.
  • w es la pendiente de la recta y que en general le llamaremos “peso” (o weight en inglés) y es uno de los dos parámetros que se tienen que aprender el modelo durante el proceso de entrenamiento para poder usarlo luego para inferencia.
  • b es el punto de intersección de la recta en el eje y que llamamos “sesgo” (o bias en inglés). Este es el otro de los parámetros que deben ser aprendidos por el modelo.

Aunque en este modelo simple que hemos representado solo tenemos una feature de entrada, en el caso de Deep Learning veremos que tenemos muchas variables de entrada, cada una con su peso wi. Por ejemplo, un modelo basado en tres features (x1, x2, x3) puede expresarse de la siguiente manera:

O, de manera más general, se puede expresar como:

que expresa el sumatorio del producto escalar entre los dos vectores (X y W) y luego suma el sesgo.

El parámetro sesgo b, para facilitar la formulación, a veces se expresa como el parámetro w0  (asumiendo una entrada adicional fija de x0=1).

En la fase de entrenamiento de un modelo se aprenden los valores ideales para los parámetros del modelo (los pesos wi y el sesgo b). En el aprendizaje supervisado, la manera de conseguirlo es aplicar un algoritmo de aprendizaje automático que obtenga el valor de estos parámetros examinando muchos ejemplos etiquetados e intentar determinar unos valores para estos parámetros del modelo que minimicen lo que llamamos loss (hay traducciones como “error” en castellano).

Como veremos a lo largo del libro, la loss es un concepto central en Deep Learning que representa la penalización de una mala predicción. Es decir, la loss es un número que indica cuan mala ha sido una predicción en un ejemplo concreto (si la predicción del modelo es perfecta, la loss es cero). Para determinar este valor, como veremos más adelante, en el proceso de entrenamiento aparecerá el concepto de función de loss, y que de momento podemos ver como la función matemática que agrega las loss individuales obtenidas de los ejemplos de entrada al modelo.

En este contexto, por ahora podemos considerar que la fase de entrenamiento de un modelo consiste básicamente en ajustar los parámetros (los pesos wi y el sesgo b) de tal manera que el resultado de la función de loss retorna el valor mínimo posible.

Finalmente, nos queda avanzar el concepto de overfitting (al que también referenciaremos por su traducción de “sobreajuste”) de un modelo, que se produce cuando el modelo obtenido se ajusta tanto a los ejemplos etiquetados de entrada que no puede realizar las predicciones correctas como en ejemplos de datos nuevos que nunca ha visto antes.

Día 2:  Redes neuronales densamente conectadas

De la misma manera que cuándo uno empieza a programar en un lenguaje nuevo existe la tradición de hacerlo con un print Hello World, en Deep Learning se empieza por crear un modelo de reconocimiento de números escritos a mano. Mediante este ejemplo, en este capítulo se presentarán algunos conceptos básicos de las redes neuronales, reduciendo todo lo posible conceptos teóricos, con el objetivo de ofrecer al lector una visión global de un caso concreto para facilitar la lectura de los capítulos posteriores donde se entrará en más detalle de diferentes temas del área. En este capítulo también se mostrará cómo se codifica este ejemplo con Keras para ofrecer al lector un primer contacto con esta librería y entender la estructura que tiene la implementación de este ejemplo con Keras.

Caso de estudio: reconocimiento de dígitos

En este apartado presentamos los datos que usaremos para nuestro primer ejemplo de redes neuronales: el conjunto de datos MNIST, que contiene imágenes de dígitos escritos a mano.

El conjunto de datos MNIST, que se pueden descargar de la página The MNIST database[1], está formado por imágenes de dígitos hechos a mano. Este conjunto de datos contiene 60 000 elementos para entrenar el modelo y        10 000 adicionales para testearlo, y es ideal para adentrarse por primera vez en técnicas de reconocimiento de patrones sin tener que dedicar mucho tiempo al preproceso y formateado de datos, ambos pasos muy importantes y costosos en el análisis de datos[2] y de especial complejidad cuando se está trabajando con imágenes; este conjunto de datos solo requieren pequeñas transformaciones que comentaremos a continuación.

Este conjunto de imágenes originales en blanco y negro han sido normalizadas a 20×20 píxeles conservando su relación de aspecto. En este caso, es importante notar que las imágenes contienen niveles de grises como resultado de la técnica de anti-aliasing[3], usada en el algoritmo de normalización (reducir la resolución de todas las imágenes a la de resolución más baja). Posteriormente, las imágenes se han centrado en una de 28×28 píxeles, calculando el centro de masa de estos y trasladando la imagen con el fin de posicionar este punto en el centro del campo de 28×28. Las imágenes son del siguiente estilo:

Además, el conjunto de datos dispone de una etiqueta (label) por cada una de las imágenes que indica qué dígito representa, tratándose pues de un aprendizaje supervisado el que trataremos en este capítulo.

Esta imagen de entrada se representa en una matriz con las intensidades de cada uno de los 28×28 píxeles con valores entre [0, 255]. Por ejemplo esta imagen (la octava del conjunto de entrenamiento):

Se representa con esta matriz de puntos de 28×28(el lector puede comprobarlo en el notebook de este capítulo):

Por otro lado, recordemos que tenemos las etiquetas, que en nuestro caso son números entre 0 y 9 que indican qué dígito representa la imagen, es decir, a qué clase se asocia. En este ejemplo, vamos a representar esta etiqueta con un vector de 10 posiciones, donde la posición correspondiente al dígito que representa la imagen contiene un 1 y el resto son 0. Este proceso de transformar las etiquetas en un vector de tantos ceros como el número de etiquetas distintas, y poner un 1 en el índice que le corresponde la etiqueta, se conoce como one-hot encoding, y lo presentaremos más adelante en la segunda parte del libro.

Perceptron

Antes de avanzar, una breve explicación intuitiva de cómo funciona una sola neurona para cumplir con su cometido de aprender del conjunto de datos de entrenamiento puede ser de ayuda para el lector. Veamos un ejemplo muy simple para ilustrar como puede aprender una neurona artificial.

Algoritmos de regresión

Recordando lo que se ha explicado en el capítulo anterior, déjennos hacer un breve recordatorio sobre algoritmos de regresión y clasificación de Machine Learning clásico, ya que son el punto de partida de nuestras explicaciones de Deep Learning.

Podríamos decir que los algoritmos de regresión modelan la relación entre distintas variables de entrada (features) utilizando una medida de error, la loss, que se intentará minimizar en un proceso iterativo para poder realizar predicciones “lo más acertadas posibles”. Hablaremos de dos tipos: regresión logística y regresión lineal.

La diferencia principal entre regresión logística y lineal es en el tipo de salida de los modelos; cuando nuestra salida sea discreta, hablamos de regresión logística, y cuando la salida sea continua hablamos de regresión lineal.

Siguiendo las definiciones del primer capítulo, la regresión logística es un algoritmo con aprendizaje supervisado y se utiliza para clasificar. El ejemplo que usaremos a continuación, que consiste en identificar a qué clase pertenece cada ejemplo de entrada asignándole un valor discreto de tipo 0 o 1, se trata de una clasificación binaria.

Una neurona artificial simple

Para mostrar cómo es una neurona básica, supongamos un ejemplo simple, donde tenemos un conjunto de puntos en un plano de dos dimensiones, y cada punto ya se encuentra etiquetado como “cuadrado” o “redonda”:

Dado un nuevo punto “X”, queremos saber qué etiqueta le corresponde:

Una aproximación habitual es dibujar una línea que separe los dos grupos y usarla como clasificador:

En este caso, los datos de entrada serán representados por vectores de la forma (x1, x2) que indican sus coordenadas en este espacio de dos dimensiones, y nuestra función retornará ‘0’ o ‘1’ (encima o debajo de la línea) para saber si se debe clasificar como “cuadrado” o “círculo”. Como hemos visto, se trata de un caso de regresión lineal, donde “la línea” (el clasificador) puede ser definida por la recta

Siguiendo la notación presentada en el capítulo 1. De manera más generalizada, podemos expresar la recta como:

Para clasificar elementos de entrada X, en nuestro caso de dos dimensiones, debemos aprender un vector de peso W de la misma dimensión que los vectores de entrada, es decir, el vector (w1, w2) y un sesgo b.

Con estos valores calculados, ahora ya podemos construir una neurona artificial para clasificar un nuevo elemento X. Básicamente la neurona aplica este vector W de pesos calculado, de manera ponderada sobre los valores en cada dimensión del elemento X de entrada, le sumará el sesgo b, y el resultado lo pasará a través de una función de “activación” no lineal para producir un resultado de ‘0’ o ‘1’. La función de esta neurona artificial que acabamos de definir puede expresarse de una manera más formal, cómo:

Una vez especificada la función que ejecuta la neurona artificial, pasemos a ocuparnos de ayudar al lector a intuir cómo esta neurona puede aprender los parámetros W y b a partir de los datos de que ya disponemos etiquetados cómo “cuadrados” o “círculos”, y por otro lado ver que función nos permite convertir en ‘0’ o ‘1’ el resultado almacenado en z.

En lo que respecta a aprender los parámetros W y b a partir de los datos de que ya disponemos etiquetados cómo “cuadrados” o “círculos”, en el siguiente capítulo presentaremos cómo se realiza este proceso de manera más formal. Por el momento, empezamos a verlo más intuitivamente; se trata de un proceso iterativo para todos los ejemplos etiquetados conocidos, comparando el valor de su label obtenida a través del modelo, con el valor esperado de la label de cada elemento. Después de cada iteración, se ajustan los pesos de los parámetros W y b de tal manera que se minimice la función de loss definida anteriormente.

Una vez tenemos los parámetros W y b podemos calcular el valor de z. Entonces, necesitaremos una función que aplique una transformación a esta variable para qué se convierta en ‘0’ o ‘1’. Aunque hay varias funciones (que llamaremos “funciones de activación” como veremos en el siguiente capítulo), para este ejemplo usaremos una conocida como función sigmoid[4] que retorna un valor real de salida entre 0 y 1 para cualquier valor de entrada:

Si se piensa un poco en la fórmula, veremos que tiende siempre a dar valores próximos al 0 o al 1. Si la entrada z es razonablemente grande y positiva, “e” a la menos z es cero y, por tanto, la y toma el valor de 1. Si z tiene un valor grande y negativo, resulta que para “e” elevado a un número positivo grande, el denominador resultará ser un número grande y por lo tanto el valor de y será próximo a 0. Gráficamente, la función sigmoid presenta esta forma:

Hasta aquí hemos presentado cómo se puede definir una neurona artificial, la arquitectura más simple que puede tener una red neuronal. En concreto esta arquitectura es referenciada en la literatura del tema y se conoce como Perceptron[5] (llamado también linear threshold unit (LTU)), inventada en 1957 por Frank Rosenblatt, y que visualmente se resume de manera general con el siguiente esquema:

El Perceptron es la versión más simple de red neuronal porque consta de una sola capa que contiene una sola neurona. Pero como veremos a lo largo del libro, lo normal hoy en día es que nos encontremos con redes neuronales compuestas de numerosas capas y que cada una de ellas contenga muchas neuronas que se comunican con las de la capa anterior para recibir información, y estas a su vez comunican su información a las neuronas de la capa siguiente.

Como veremos en el siguiente capítulo, hay varias funciones de activación además de la sigmoid, cada una con propiedades diferentes. Pero para el propósito de clasificar números escritos a mano, en este capítulo también les avanzaré otra función de activación llamada softmax[6], que nos será útil para presentar un ejemplo de red neuronal mínima para clasificar en más de dos clases. Por el momento podemos considerar a la función softmax como una generalización de la función sigmoid que permite clasificar más de dos clases.

Multi-Layer Perceptron

Pero antes de avanzar con el ejemplo, introduciremos brevemente la forma que toman habitualmente las redes neuronales construidas a partir de perceptrones como el que acabamos de presentar.

En la literatura del área nos referimos a un Multi-Layer Perceptron (MLP) cuando nos encontramos con redes neuronales que tienen una capa de entrada (input layer), una o más capas compuestas por perceptrones, llamadas capas ocultas (hidden layers) y una capa final con varios perceptrones llamada la capa de salida (output layer). En general nos referimos a Deep Learning cuando el modelo basado en redes neuronales está compuesto por múltiples capas ocultas. Visualmente se puede presentar con el siguiente esquema:

Los MLP son a menudo usados para clasificación, y en concreto cuando las clases son exclusivas, como es el caso de la clasificación de imágenes de dígitos que nos ocupa (en clases de 0 hasta 9), la capa de salida es una función softmax en la que la salida de cada neurona corresponde a la probabilidad estimada de la clase correspondiente. Visualmente lo podríamos representar de la siguiente forma:

Función de activación softmax

Vamos a resolver el problema de manera que, dada una imagen de entrada, obtendremos las probabilidades de que sea cada uno de los 10 posibles dígitos. De esta manera tendremos un modelo que, por ejemplo, podría predecir en una imagen un nueve, pero solo estar seguro en un 80% de que sea un nueve,  ya que debido al dudoso bucle inferior piensa que podría llegar a ser un ocho en un 5% de posibilidades e incluso podría dar una cierta probabilidad a cualquier otro número. Aunque en este caso concreto consideraremos que la predicción de nuestro modelo es un 9, pues es el que tiene mayor probabilidad, esta aproximación de usar una distribución de probabilidades nos puede dar una mejor idea de cuán confiados estamos de nuestra predicción. Esto es bueno en este caso, donde los números son hechos a mano, y seguramente en muchos otros no podemos reconocer los dígitos con un 100% de seguridad.

Por tanto, para este ejemplo de clasificación de MNIST, para cada ejemplo de entrada obtendremos como vector de salida de la red neuronal una distribución de probabilidad sobre un conjunto de etiquetas mutuamente excluyentes, es decir, un vector de 10 probabilidades cada una correspondiente a un dígito y que todas estas 10 probabilidades sumen 1 (las probabilidades se expresarán entre 0 y 1).

Como ya hemos avanzado, esto se logra mediante el uso de una capa de salida en nuestra red neuronal con la función de activación softmax, en la que cada neurona en esta capa softmax depende de las salidas de todas las otras neuronas de la capa, puesto que la suma de la salida de todas ellas debe ser 1.

Pero ¿cómo funciona la función de activación softmax? La función softmax se basa en calcular “las evidencias” de que una determinada imagen pertenece a una clase en particular y luego se convierten estas evidencias en probabilidades de que pertenezca a cada una de las posibles clases.

Para medir la evidencia de que una determinada imagen pertenece a una clase en particular, una aproximación consiste en realizar una suma ponderada de la evidencia de pertenencia de cada uno de sus píxeles a esa clase. Para explicar la idea usaré un ejemplo visual.

Supongamos que disponemos ya del modelo aprendido para el número cero (más adelante ya veremos cómo se aprenden estos modelos). Por el momento, podemos considerar un modelo como “algo” que contiene información para saber si un número es de una determinada clase. En este caso, para el número cero, supongamos que tenemos un modelo como el que presentamos a continuación:

Con una matriz de 28×28 píxeles, donde los píxeles en rojo (en la edición en blanco/negro del libro es el gris más claro) representa pesos negativos (es decir, reducir la evidencia de que pertenece), mientras que los píxeles en azul (en la edición en blanco/negro del libro es el gris más oscuro) representan pesos positivos (aumenta la evidencia de que pertenece). El color negro representa el valor neutro.

Imaginemos una hoja en blanco encima sobre la que trazamos un cero. En general, el trazo de nuestro cero caería sobre la zona azul (recordemos que estamos hablando de imágenes que han sido normalizadas a 20×20 píxeles y posteriormente centradas a una imagen de 28×28). Resulta bastante evidente que si nuestro trazo pasa por encima de la zona roja lo más probable es que no estemos escribiendo un cero; por tanto, usar una métrica basada en sumar si pasamos por zona azul y restar si pasamos por zona roja, parece razonable.

Para confirmar que es una buena métrica imaginemos ahora que trazamos un tres; está claro que la zona roja del centro del anterior modelo que usábamos para el cero va a penalizar la métrica antes mencionada puesto que como podemos ver en la parte izquierda de esta figura al escribir un tres pasamos por encima:

Pero en cambio, si el modelo de referencia es el correspondiente al 3 como el mostrado en la parte derecha de la anterior figura, podemos observar que, en general, los diferentes posibles trazos que representan un tres se mantienen mayormente en la zona azul.

Espero que el lector, viendo este ejemplo visual, ya intuya como la aproximación de los pesos indicados anteriormente nos permite hacer una estimación de qué número se trata.

En la siguiente figura se muestra los pesos de un ejemplo concreto de modelo aprendido para cada una de estas diez clases del MNIST (figura obtenida del ejemplo del tutorial de Tensorflow[7]):

Recordemos que hemos escogido el rojo (gris más claro en edición de libro blanco y negro) en esta representación visual para los pesos negativos, mientras que usaremos el azul (gris más oscuro en edición de libro en blanco y negro) para representar los positivos[8].

Una vez se ha calculado la evidencia de pertenencia a cada una de las 10 clases, estas se deben convertir en probabilidades cuya suma de todos sus componentes sume 1. Para ello softmax usa el valor exponencial de las evidencias calculadas y luego las normaliza de modo que sumen uno, formando una distribución de probabilidad. La probabilidad de pertenencia a la clase i es:

Intuitivamente, el efecto que se consigue con el uso de exponenciales es que una unidad más de evidencia tiene un efecto multiplicador y una unidad menos tiene el efecto inverso. Lo interesante de esta función es que una buena predicción tendrá una sola entrada en el vector con valor cercano a 1, mientras que las entradas restantes estarán cerca de 0. En una predicción débil tendrán varias etiquetas posibles, que tendrán más o menos la misma probabilidad.

Datos para alimentar una red neuronal

A continuación pasemos a un nivel más práctico en el ejemplo de reconocimiento de dígitos MNIST, pero antes aprovechemos para explicar algunos detalles interesantes sobre los datos disponibles.

Conjunto de datos para entrenamiento, validación y prueba

Antes de presentar la implementación en Keras del ejemplo anterior, recordemos cómo debemos repartir los datos disponibles para poder configurar y evaluar el modelo correctamente.

Para la configuración y evaluación de un modelo en Machine Learning, y por ende Deep Learning, habitualmente se dividen los datos disponibles en tres conjuntos: datos de entrenamiento (training), datos de validación (validation) y datos de prueba (test). Los datos de entrenamiento son los que se usan para que el algoritmo de aprendizaje obtenga los parámetros del modelo. Si el modelo no acaba de adaptarse a los datos de entrada (por ejemplo, si presentara overfitting), en este caso modificaríamos el valor de ciertos hiperparámetros y después de entrenarlo nuevamente con los datos de entrenamiento volveríamos a evaluarlo con los de validación. Podemos ir haciendo estos ajustes de los hiperparámetros guiados por los datos de validación hasta que obtenemos unos resultados de validación que consideremos correctos (en la segunda parte del libro entraremos en detalle en este tema de validación).  Si hemos seguido este procedimiento, debemos ser conscientes de que, en realidad, los datos de validación han influido en el modelo para que se ajustara también a los datos de validación. Por este motivo reservamos siempre un conjunto de datos de prueba para evaluación final del modelo que solo se usarán al final de todo el proceso, cuando consideremos que el modelo está acabado de afinar y ya no modificaremos más ninguno de sus hiperparámetros. Veremos en más detalle este funcionamiento en futuros ejemplos de la segunda parte del libro.

Precarga de los datos en Keras

En Keras el conjunto de datos MNIST se encuentra precargado en forma de cuatro arrays Numpy y se pueden obtener con el siguiente código:

import keras

from keras.datasets import mnist
(x_train, y_train), (x_test, y_test) = mnist.load_data()

x_trainy_train conforman el conjunto de entrenamiento, mientras que x_test y y_test contienen los datos para el test. Las imágenes se encuentran codificadas como arrays Numpy y sus correspondientes etiquetas (labels) que van desde 0 hasta 9.  Siguiendo la estrategia dellibro de ir introduciendo gradualmente los conceptos del tema, dejamos para más adelante cómo separar una parte de los datos de entrenamiento para guardarlos como los datos validación, y de momento solo tendremos en cuenta los datos de entrenamiento y de prueba.

Si queremos comprobar qué valores hemos cargado, podemos elegir cualquiera de las imágenes del conjunto MNIST, por ejemplo la imagen 8,  y usando el siguiente código Python:

import matplotlib.pyplot as plt
plt.imshow(x_train[8], cmap=plt.cm.binary)

Obtenemos la siguiente imagen:

Y si queremos ver su correspondiente etiqueta (label) podemos hacerlo mediante:

print(y_train[8])
1

Que como vemos nos devuelve el valor de “1”,  como era de esperar.

Representación de los datos en Keras

Keras, que como hemos visto usa un array multidimensional de Numpy como estructura básica de datos, le llama a esta estructura de datos tensor. De manera resumida podríamos decir que un tensor tiene tres atributos principales:

  • Número de ejes (Rank o ndim): un tensor que contiene un solo número lo llamaremos scalar (o un tensor 0-dimensional, o tensor 0D). Un array de números lo llamamos vector, o tensor 1D. Un array de vectores será una matriz (matrix), o tensor 2D. Si empaquetamos esta matriz en un nuevo array, obtenemos un tensor 3D, que podemos interpretarlo visualmente como un cubo de números. Empaquetando un tensor 3D en un array, podemos crear un tensor 4D, y así sucesivamente. En la librería Numpy de Python esto se llama ndim del tensor.
  • Forma (shape): se trata de una tupla de enteros que describen cuántas dimensiones tiene el tensor en cada eje. Un vector tiene un shape con un único elemento, por ejemplo “(5,)”, mientras que un escalar tiene un shape vacío “( )”. En la librería Numpy este atributo se llama shape.
  • Tipo de datos (data type): este atributo indica el tipo de datos que contiene el tensor, que pueden ser por ejemplo uint8, float32, float64, etc. En raras ocasiones tenemos, en nuestro contexto, tensores de tipo char (nunca string). En la librería Numpy este atributo se llama dtype.

Les propongo que obtengamos el número de ejes y dimensiones del tensor train_images de nuestro ejemplo anterior:

print(x_train.ndim)
3
print(x_train.shape)
(60000, 28, 28)

Y si queremos saber qué tipo de datos contiene:

print(x_train.dtype)
uint8

Normalización de los datos de entrada

Estas imágenes de MNIST de 28×28 píxeles se representan como una matriz de números cuyos valores van entre [0, 255] de tipo uint8. Perocomo veremos en posteriores capítulos, es habitual escalar los valores de entrada de las redes neuronales a unos rangos determinados. En el ejemplo de este capítulo los valores de entrada conviene escalarlos a valores de tipo float32 dentro del intervalo [0, 1]:

x_train = x_train.astype('float32')
x_test = x_test.astype('float32')

x_train /= 255
x_test /= 255

Por otro lado, para facilitar la entrada de datos a nuestra red neuronal (veremos que en convolucionales no hace falta) debemos hacer una transformación del tensor (imagen) de 2 dimensiones (2D) a un vector de una dimensión (1D). Es decir, la matriz de 28×28 números se puede representar con un vector (array) de 784 números (concatenando fila a fila), que es el formato que acepta como entrada una red neuronal densamente conectada como la que veremos en este capítulo.

En Python, convertir cada imagen del conjunto de datos MNIST a un vector con 784 componentes se puede hacer de la siguiente manera:

x_train = x_train.reshape(60000, 784)
x_test = x_test.reshape(10000, 784)

Después de ejecutar estas instrucciones Python, podemos comprobar que x_train.shape toma la forma de (60000, 784) y x_test.shape toma la forma de (10000, 784), donde la primera dimensión indexa la imagen y la segunda indexa el píxel en cada imagen (ahora la intensidad del píxel es un valor entre 0 y 1):

print(x_train.shape)
print(x_test.shape)
(60000, 784)
(10000, 784)

Además tenemos las etiquetas (labels) para cada dato de entrada, (recordemos que en nuestro caso son números entre 0 y 9 que indican qué dígito representa la imagen, es decir, a que clase se asocia). En este ejemplo, y como ya hemos avanzado, vamos a representar esta etiqueta con un vector de 10 posiciones, donde la posición correspondiente al dígito que representa la imagen contiene un 1 y el resto de posiciones del vector contienen el valor 0.

En este ejemplo usaremos lo que se conoce como one-hot encoding, que ya hemos indicado  que explicaremos más adelante en la segunda parte del libro: en resumen, consiste en transformar las etiquetas en un vector de tantos ceros como el número de etiquetas distinta, y que contiene el valor de 1 en el índice que le corresponde al valor de la etiqueta. Keras ofrece muchas funciones de soporte, y entre ellas to_categorical para realizar esta transformación, que la podemos importar de keras.utils:

from keras.utils import to_categorical

Para ver el efecto de la transformación podemos ver los valores antes y después de aplicar to_categorical :

print(y_test[0])
7
print(y_train[0])
5
print(y_train.shape)
(60000,)
print(x_test.shape)
(10000, 784)
y_train = to_categorical(y_train, num_classes=10)
y_test = to_categorical(y_test, num_classes=10)
print(y_test[0])
[0. 0. 0. 0. 0. 0. 0. 1. 0. 0.]
print(y_train[0])
[0. 0. 0. 0. 0. 1. 0. 0. 0. 0.]
print(y_train.shape)
(60000, 10)
print(y_test.shape)
(10000, 10)

Ahora ya tenemos los datos preparados para ser usados en nuestro ejemplo de modelo simple que vamos a programar en Keras en la próxima sección.

Redes densamente conectadas en Keras

En este apartado vamos a presentar cómo se especifica en Keras el modelo que hemos definido en los apartados anteriores.

Clase Sequential en Keras

La estructura de datos principal en Keras es la clase Sequential, que permite la creación de una red neuronal básica. Keras ofrece también una API[9] que permite implementar modelos más complejos en forma de grafo que pueden tener múltiples entradas, múltiples salidas, con conexiones arbitrarias en medio, pero no lo presentaremos hasta el capítulo octavo del libro.

La clase Sequential[10]  de la librería de Keras es una envoltura para el modelo de red neuronal secuencial que ofrece Keras y se puede crear  de la siguiente manera:

from keras.models import Sequential
model = Sequential()

En este caso, el modelo en Keras se considera como una secuencia de capas que cada una de ellas va “destilando” gradualmente los datos de entrada para obtener la salida deseada. En Keras podemos encontrar todos los tipos de capas requeridas y se pueden agregar fácilmente al modelo mediante el método add( ).

Definición del modelo

La construcción en Keras de nuestro modelo para reconocer las imágenes de dígitos podría ser el siguiente:

from keras.models import Sequential
from keras.layers.core import Dense, Activation

model = Sequential()
model.add(Dense(10, activation='sigmoid', input_shape=(784,)))
model.add(Dense(10, activation='softmax'))

Aquí, la red neuronal se ha definido como una secuencia de dos capas que están densamente conectadas, es decir, que todas las neuronas de cada capa están conectadas con todas las neuronas de la capa siguiente. Visualmente podríamos representarlo de la siguiente manera:

En este código expresamos explícitamente en el argumento input_shape  de la primera capa cómo son los datos de entrada: un tensor que indica que tenemos 784 features del modelo (en realidad el tensor que se está definiendo es de (None, 784,) como veremos más adelante).

Una característica muy interesante de la librería de Keras es que esta deducirá automáticamente la forma de los tensores entre capas después de la primera. Esto significa que el programador solo tiene que establecer esta información para la primera de ellas. Además, para cada capa indicamos el número de nodos que tiene y la función de activación que aplicaremos en ella (en este caso, sigmoid).

La segunda capa es una capa softmax de 10 neuronas, lo que significa que devolverá una matriz de 10 valores de probabilidad que representan a los 10 dígitos posibles (en general, la capa de salida de una red de clasificación tendrá tantas neuronas como clases, menos en una clasificación binaria, en donde solo necesita una neurona). Cada valor será la probabilidad de que la imagen del dígito actual pertenezca a cada una de ellas.

Un método muy útil que proporciona Keras para comprobar la arquitectura de nuestra modelo es summary():

model.summary()
_________________________________________________________________
Layer (type)                 Output Shape             Param #
=================================================================
dense_1 (Dense)             (None, 10)                7850
_________________________________________________________________
dense_2 (Dense)             (None, 10)                110
=================================================================
Total params: 7,960
Trainable params: 7,960
Non-trainable params: 0

Más adelante entraremos en más detalle con la información que nos retorna el método summary(), pues resulta muy valioso este cálculo de parámetros y tamaños de los datos que tiene la red neuronal cuando empezamos a construir modelos de redes muy grandes.  Para nuestro ejemplo simple, vemos que indica que se requieren 7 960 parámetros (columna Param #), que corresponden a los 7 850 parámetros para la primera capa y 110 para la segunda.

En la primera capa por cada neurona i (entre 0 y 9) requerimos 784 parámetros para los pesos wij y por tanto 10 x 784 parámetros para almacenar los pesos de las 10 neuronas. Además de los 10 parámetros adicionales para los 10 sesgos bj correspondientes a cada una de ellas. En la segunda capa, al ser una función softmax, se requiere conectar todos sus 10 nodos con los 10 nodos de la capa anterior, y por tanto se requieren 10×10 parámetros wi además de los correspondientes 10 sesgos bj correspondientes a cada nodo.

En el manual de Keras se puede encontrar los detalles de los argumentos que podemos indicar para la capa Dense[11]. En nuestro ejemplo aparecen los más relevantes, donde el primer argumento indica el número de neuronas de la capa; el siguiente es la función de activación que usaremos en ella. En el siguiente capítulo hablaremos en más detalle de otras posibles funciones de activación más allá de las dos presentadas aquí: sigmoid y softmax.

También a menudo se indica la inicialización de los pesos como argumento de las capas Dense. Los valores iniciales deben ser adecuados para que el problema de optimización converja tan rápido como sea posible. En el manual de Keras se puede encontrar las diversas opciones de inicialización[12].

Pasos para implementar una red neuronal en Keras

A continuación vamos a presentar una breve descripción de los pasos que debemos realizar para implementar una red neuronal básica, y en los siguientes capítulos iremos introduciendo gradualmente más detalles de cada uno de estos pasos.

Configuración del proceso de aprendizaje

A partir del modelo Sequential, podemos  definir las capas de manera sencilla con el método add(), tal como hemos avanzado en el apartado anterior. Una vez que tengamos nuestro modelo definido, podemos configurar cómo será su proceso de aprendizaje con el método compile( ), con el que podemos especificar algunas propiedades a través de argumentos del método.

El primero de estos argumentos es la función de loss que usaremos para evaluar el grado de error entre salidas calculadas y las salidas deseadas de los datos de entrenamiento.  Por otro lado, se especifica un optimizador que, como veremos, es la manera que tenemos de especificar el algoritmo de optimitzaación que permite a la red neuronal calcular los pesos de los parámetros a partir de los datos de entrada y de la función de loss definida. Más detalle del  propósito exacto de la función de loss y el optimizador se presentarán en el siguiente capítulo.

Y finalmente debemos indicar la métrica que usaremos para monitorizar el proceso de aprendizaje (y prueba) de nuestra red neuronal. En este primer ejemplo solo tendremos en cuenta la accuracy (fracción de imágenes que son correctamente clasificadas). Por ejemplo, en nuestro caso podemos especificar los siguientes argumentos en método compile( ) para probarlo en nuestro ordenador:

model.compile(loss="categorical_crossentropy",
optimizer="sgd",
metrics = ['accuracy'])

Donde especificamos que la función de loss es categorical_crossentropy, el optimizador usado es el stocastic gradient descent (sgd) y la métrica es accuracy, con la que evaluaremos el porcentaje de aciertos averiguando dónde el modelo predice la etiqueta correcta.

Entrenamiento del modelo

Una vez definido nuestro modelo y configurado su método de aprendizaje, este ya está listo para ser entrenado. Para ello podemos entrenar o “ajustar” el modelo a los datos de entrenamiento de que disponemos invocando al método fit( ) del modelo:

model.fit(x_train, y_train, batch_size=100, epochs=5)

Donde en los dos primeros argumentos hemos indicado los datos con los que entrenaremos el modelo en forma de arrays Numpy. Con el argumento batch_size se indica el número de datos que usaremos para cada actualización de los parámetros del modelo y con epochs estamos indicando el número de veces que usaremos todos los datos en el proceso de aprendizaje.  Estos dos últimos argumentos se explicarán con mucho más detalle en el próximo capítulo.

Este método encuentra el valor de los parámetros de la red mediante el algoritmo iterativo de entrenamiento que presentaremos con un poco más de detalle en el siguiente capítulo. A grandes rasgos, en cada iteración de este algoritmo, este coge datos de entrenamiento de x_train, los pasa a través de la red neuronal (con los valores que en aquel momento tengan sus parámetros), compara el resultado obtenido con el esperado (indicado en y_train) y calcula la loss para guiar el proceso de ajuste de los parámetros del modelo, que intuitivamente consiste en aplicar el optimizador especificado anteriormente en el método compile() para calcular un nuevo valor de cada uno de los parámetros (pesos y sesgos) del modelo en cada iteración de tal forma de que se reduzca el de la loss.

Este es el método que, como veremos, puede llegar a tardar más tiempo y Keras nos permite ver su avance usando el argumento verbose (por defecto, igual a 1), además de indicar una estimación de cuánto tarda cada epoch:

Epoch 1/5
60000/60000 [==================] - 1s 15us/step - loss: 2.1822 - acc: 0.2916
Epoch 2/5
60000/60000 [==================] - 1s 12us/step - loss: 1.9180 - acc: 0.5283
Epoch 3/5
60000/60000 [==================] - 1s 13us/step - loss: 1.6978 - acc: 0.5937
Epoch 4/5
60000/60000 [==================] - 1s 14us/step - loss: 1.5102 - acc: 0.6537
Epoch 5/5
60000/60000 [==================] - 1s 13us/step - loss: 1.3526 - acc: 0.7034
10000/10000 [==================] - 0s 22us/step

Este es un ejemplo simple para que el lector al acabar el capítulo haya podido programar ya su primera red neuronal, pero como veremos  el método fit( ) permite muchos más argumentos que tienen un impacto muy importante en el resultado del aprendizaje. Además, este método retorna un objeto History que hemos omitido en este ejemplo. Su atributo History.history es el registro de los valores de loss para los datos de entrenamiento y resto de métricas en sucesivas epochs, así como otras métricas para los datos de validación si se han especificado. Más adelante, en el capítulo 5 de la segunda parte del libro, veremos lo valioso de esta información para evitar por ejemplo el sobreajuste del modelo.

Evaluación del modelo

En este punto ya se ha entrenado la red neuronal y ahora se puede evaluar cómo se comporta con datos nuevos de prueba (test) con el método evaluate().  Este devuelve dos valores:

test_loss, test_acc = model.evaluate(x_test, y_test)

Que indican cómo de bien o mal se comporta nuestro modelo con datos nuevos que nunca ha visto (que hemos almacenado en x_test y y_test cuando hemos realizado el mnist.load_data( ) ).  De momento fijémonos solo en uno de ellos, la accuracy:

print('Test accuracy:', test_acc)
Test accuracy: 0.9018

Que nos está indicando que el modelo que hemos creado en este capítulo aplicado sobre datos que nunca ha visto anteriormente, clasifica el 90% de ellos correctamente.

Debe el lector fijarse que, en este ejemplo, para evaluar este  modelo solo nos hemos centrado en su accuracy, es decir la proporción entre las predicciones correctas que ha hecho el modelo y el total de predicciones. Sin embargo, aunque en ocasiones resulta suficiente, otras veces es necesario profundizar un poco más y tener en cuenta los tipos de predicciones correctas e incorrectas que realiza el modelo en cada una de sus categorías.

En el mundo de Machine Learning una herramienta para evaluar modelos es la matriz de confusión (confusion matrix),  una tabla con filas y columnas que contabilizan las predicciones en comparación con los valores reales. Usamos esta tabla para entender mejor cómo el modelo se comporta de bien  y es muy útil para mostrar de forma explícita cuando una clase es confundida con otra. Una matriz de confusión para un clasificador binario como el explicado al principio del capítulo tiene esta estructura

En la que:

  • VP es la cantidad de positivos que fueron clasificados correctamente como positivos por el modelo.
  • VN es la cantidad de negativos que fueron clasificados correctamente como negativos por el modelo.
  • FN es la cantidad de positivos que fueron clasificados incorrectamente como negativos.
  • FP es la cantidad de negativos que fueron clasificados incorrectamente como positivos.

Con esta matriz de confusión, la accuracy se puede calcular sumando los valores de la diagonal y divido por el total: 

Accuracy = (VP + VN) / (VP + FP + VN + FN)

Ahora bien, la accuracy puede ser engañosa en la calidad del modelo porque al medirla para el modelo concreto no distinguimos entre los errores de tipo falso positivo y falso negativo, como si ambos tuvieran la misma importancia. Por ejemplo, piensen en un modelo que predice si una seta es venenosa. En este caso, el coste de un falso negativo, es decir, una seta venenosa dada por comestible podría ser dramático. En cambio al revés, un falso positivo, tiene un coste muy diferente.

Por ello tenemos otra métrica llamada Sensitivity (o recall) que nos indica cómo de bien el modelo evita los falsos negativos:

Sensitivity = VP / (VP + FN)

Es decir, del total de observaciones positivas (setas venenosas), cuantas detecta el modelo.

A partir de la matriz de confusión se pueden obtener diversas métricas para focalizar otros casos como se muestra en este enlace[13], pero queda fuera del alcance de este libro entrar más detalladamente en este tema. La conveniencia de usar una métrica u otra dependerá de cada caso en particular y, en concreto, del “coste” asociado a cada error de clasificación del modelo.

Pero el lector se preguntará cómo es esta matriz de confusión en nuestro clasificador, donde tenemos 10 posibles valores. En este caso les propongo usar el paquete  Scikit-learn[14] (que ya hemos mencionado anteriormente) para evaluar la calidad del modelo calculando la matriz de confusión[15], presentada de la figura siguiente:

En este caso los elementos de la diagonal representan el número de puntos en que la etiqueta que predice el modelo coincide con el valor real de la etiqueta, mientras que los otros valores nos indican los casos en que el modelo ha clasificado incorrectamente. Por tanto, cuanto más altos son los valores de la diagonal mejor será la predicción. En este ejemplo, si el lector calcula la suma de los valores de la diagonal dividido por el total de valores de la matriz, observará que coincide con la accuracy que nos ha retornado el método evaluate().

En el GitHub del libro pueden encontrar el código usado para calcular esta matriz de confusión.

Generación de predicciones

Finalmente, nos queda el paso de usar el modelo creado en los anteriores apartados para realizar predicciones sobre qué dígito representan nuevas imáges. Para ello Keras ofrece el método predict() de un modelo que ya ha sido previamente entrenado.

Para probar este método podemos elegir un elemento cualquiera, por ejemplo uno del conjunto de datos de test x_test. Por ejemplo elijamos el  elemento 11 de este conjunto de datos x_test y veamos a que clase corresponde según el modelo entrenado de que disponemos.

Antes vamos a ver la imagen para poder comprobar nosotros mismos  si el modelo está haciendo una predicción correcta (antes de hacer el reshape anterior):

plt.imshow(x_test[11], cmap=plt.cm.binary)

Creo que el lector estará de acuerdo que en este caso se trata del número 6.

Ahora comprobemos que el método predict() del modelo, ejecutando el siguiente código, nos predice correctamente el valor que acabamos de estimar nosotros que debería predecir.

Para ello ejecutamos la siguiente línea de código:

predictions = model.predict(x_test)

Una vez calculado el vector resultado de la predicción para este conjunto de datos podemos saber a qué clase le da más probabilidad de pertenencia  mediante la función argmax de Numpy, que retorna el índice de la posición que contiene el valor más alto del vector. En concreto, para el elemento 11:

np.argmax(predictions[11])
6

Podemos comprobar imprimiendo el array:

print(predictions[11])
[0.06 0.01 0.17 0.01 0.05 0.04 0.54 0.   0.11 0.02]

Vemos que nos ha devuelto el índice 6, correspondiente a la clase “6”, la que habíamos estimado nosotros.

También podemos comprobarlo que el resultado de la predicción es un vector cuya suma de todos sus componentes es igual a 1, como era de esperar. Para ello podemos usar:

np.sum(predictions[11])
1.0

Hasta aquí el lector ha podido crear su primer modelo en Keras que clasifica correctamente los dígitos MNIST el 90% de las veces. En el siguiente capítulo vamos a presentar cómo funciona el proceso de aprendizaje  y varios de los hiperparámetros que podemos usar en una red neuronal para mejorar estos resultados. En el capítulo 4 veremos cómo podemos mejorar estos resultados de clasificación usando redes neuronales convolucionales para el mismo ejemplo.


Referencias:

[1] The MNIST database of handwritten digits. [en línea]. Disponible en:  http://yann.lecun.com/exdb/mnist [Consulta: 24/02/2017].

[2] Remarcar que sin duda este paso de “limpiar” y “preparar” los datos es la fase más costosa en tiempo y desagradecida con datos reales.

[3] Wikipedia, (2016). Antialiasing [en línea]. Disponible en:  https://es.wikipedia.org/wiki/Antialiasing [Consulta: 9/01/2016].

[4] Wikipedia, (2018). Sigmoid function [en línea]. Disponible en:  https://en.wikipedia.org/wiki/Sigmoid_function [Consulta: 2/03/2018].

[5] Wikipedia (2018). Perceptron [en línea]. Disponible en https://en.wikipedia.org/wiki/Perceptron [Consulta 22/12/2018]

[6] Wikipedia, (2018). Softmax function [en línea]. Disponible en: https://en.wikipedia.org/wiki/Softmax_function [Consulta: 22/02/2018].

[7] TensorFlow, (2016) Tutorial MNIST beginners. [en línea]. Disponible en: https://www.tensorflow.org/versions/r0.12/tutorials/mnist/beginners  [Consulta: 16/02/2018].

[8] En caso que el lector esté leyendo de un libro en papel  en blanco y negro, y quiera ver las imágenes en color, recuerde que el libro se encuentra disponible en www.JordiTorres.Barcelona/DeepLearning  donde pueden encontrar las figuras en color.

[9] Véase https://keras.io/getting-started/functional-api-guide/

[10] Véase https://keras.io/models/sequential/

[11] https://keras.io/layers/core/#dense

[12] https://keras.io/initializers/#usage-of-initializers

[13] Confusion Matrix. Wikipedia. [online]. Disponible en: https://en.wikipedia.org/wiki/Confusion_matrix [Accedido: 30/04/2018]

[14] http://scikit-learn.org/stable/

[15] http://scikit-learn.org/stable/modules/generated/sklearn.metrics.confusion_matrix.html

Día 3:  Esquema general de resolución de un problema de machine learning

En esta sección se presenta, a partir de un caso de estudio sencillo, un esquema general de todo el proceso de resolución de un problema de machine learning. 

(pendiente)

Día 4:  Cómo se entrena una red neuronal

En este capítulo vamos a presentar una visión intuitiva de los  componentes principales del proceso de aprendizaje de una red neuronal, pieza clave en muchos de los avances recientes en inteligencia artificial. Además veremos algunos de los parámetros e hiperparámetros más relevantes en Deep Learning. En la segunda parte del capítulo proponemos poner a prueba lo aprendido con una herramienta interactiva, y ver el comportamiento de una red neuronal cuando se le cambian los valores de los parámetros e hiperparámetros.

Proceso de aprendizaje de una red neuronal

Recordemos del capítulo anterior que una red neuronal está formada de neuronas conectadas entre ellas; a su vez, cada conexión de nuestra red neuronal está asociada a un peso que dictamina la importancia que tendrá esa relación en la neurona al multiplicarse por el valor de entrada.

Cada neurona tiene una función de activación que define la salida de la neurona (recordemos la función sigmoid del capítulo anterior). La función de activación se usa para introducir la no linealidad en las capacidades de modelado de la red (disponemos de varias opciones de funciones de activación que presentaremos en la siguiente sección).

Entrenar nuestra red neuronal, es decir, aprender los valores de nuestros parámetros (pesos wij y sesgos bj) es la parte más genuina de Deep Learning  y podemos ver este proceso de aprendizaje en una red neuronal como un proceso iterativo de “ir y venir” por las capas de neuronas. El “ir” propagando hacia delante lo llamaremos  forwardpropagation y el “venir” retropropagando información en la red lo llamamos  backpropagation.

La primera fase forwardpropagation se da cuando se expone la red a los datos de entrenamiento y estos cruzan toda la red neuronal para ser calculadas sus predicciones (labels). Es decir, pasar los datos de entrada a través de la red de tal manera que todas las neuronas apliquen su transformación a la información que reciben de las neuronas de la capa anterior y la envíen a las neuronas de la capa siguiente. Cuando los datos hayan cruzado todas las capas, y todas sus neuronas han realizado sus cálculos, se llegará a la capa final con un resultado de predicción de la label para aquellos ejemplos de entrada.

A continuación usaremos una función de loss  para estimar la loss (o error) y para comparar y medir cuán bueno/malo fue nuestro resultado de la predicción en relación con el resultado correcto (recordemos que estamos en un entorno de aprendizaje supervisado y disponemos de la etiqueta que nos indica el valor esperado). Idealmente, queremos que nuestro coste sea cero, es decir, sin divergencia entre valor estimado y el esperado. Por eso a medida que se entrena el modelo se irán ajustando los pesos de las interconexiones de las neuronas de manera automática hasta obtener buenas predicciones.

Una vez se tiene calculado la loss, se propaga hacia atrás esta información. De ahí  su nombre, retropropagación, en inglés backpropagation. Partiendo de la capa de salida, esa información de loss se propaga hacia todas las neuronas de la capa oculta que contribuyen directamente a la salida. Sin embargo las neuronas de la capa oculta solo reciben una fracción de la señal total de la loss, basándose aproximadamente en la contribución relativa que haya aportado cada neurona a la salida original. Este proceso se repite, capa por capa, hasta que todas las neuronas de la red hayan recibido una señal de loss que describa su contribución relativa a la loss total.

Visualmente, podemos resumir lo que hemos contado con este esquema visual de las etapas:

Ahora que ya hemos propagado hacia atrás esta información, podemos ajustar los pesos de las conexiones entre neuronas. Lo  que estamos haciendo es que la loss se aproxime lo más posible a cero la próxima vez que volvamos usar la red para una predicción. Para ello usaremos una técnica llamada gradient descent.  Esta técnica va cambiando los pesos en pequeños incrementos con la ayuda del cálculo de la derivada (o gradiente) de la función de loss, cosa que nos permite ver en qué dirección “descender” hacia el mínimo global; esto lo va haciendo en general en lotes de datos (batches) en las sucesivas iteraciones (epochs) del conjunto de todos los datos que le pasamos a la red en cada iteración.

Recapitulando, el algoritmo de aprendizaje consiste en:

  • Empezar con unos valores (a menudo aleatorios –random en inglés–) para los parámetros de la red (pesos wij y sesgos bj)
  • Coger un conjunto de ejemplos de datos de entrada y pasarlos por la red para obtener su predicción.
  • Comparar estas predicciones obtenidas con los valores de etiquetas esperadas y con ellas calcular la loss.
  • Realizar el backpropagation para propagar esta loss a todos y cada uno de los parámetros que conforman el modelo de la red neuronal.
  • Usar esta información propagada para actualizar con el gradient descent los parámetros de la red neuronal de manera que reduzca la loss total y obtener un mejor modelo.
  • Continuar iterando en los anteriores pasos hasta que consideremos que tenemos un buen modelo (más adelante veremos cuando debemos parar).

A continuación presentamos en más detalle de cada uno de los elementos que hemos destacado en esta sección.

Funciones de activación

Recordemos que usamos las funciones de activación para propagar hacia adelante la salida de una neurona. Esta salida la reciben las neuronas de la siguiente capa a las que está conectada esta neurona (hasta la capa de salida incluida). Como hemos comentado, la función de activación sirve para introducir la no linealidad en las capacidades de modelado de la red. A continuación vamos a enumerar las más usadas en la actualidad; todas ellas pueden usarse en una capa de Keras (podemos encontrar más información en  su página web[1]).

Linear

La función de activación linear es básicamente la función identidad en la que, en términos prácticos, significa que la señal no cambia.

Sigmoid

La función sigmoid fue introducida en el capítulo anterior y permite reducir valores extremos o atípicos en datos válidos sin eliminarlos. Una función sigmoidea convierte variables independientes de rango casi infinito en probabilidades simples entre 0 y 1. La mayor parte de su salida estará muy cerca de los extremos de 0 o 1 (resulta importante que los valores estén en este rango en algunos tipos de redes neuronales).

Tanh

Del mismo modo que la tangente representa una relación entre el lado opuesto y el adyacente de un triángulo rectángulo, tanh representa la relación entre el seno hiperbólico y el coseno hiperbólico: tanh(x) = sinh(x)/cosh(x). A diferencia de la función sigmoid, el rango normalizado de tanh está entre  -1 y 1, que es la entrada que le va bien a algunas redes neuronales. La ventaja de tanh es que puede tratar más fácilmente con números negativos.

Softmax

La función de activación softmax fue presentada en el capítulo anterior para generalizar la regresión logística de manera que, en lugar de clasificar en binario, pueda contener múltiples límites de decisión.  Como ya hemos visto, la función de activación de softmax devuelve la distribución de probabilidad sobre clases de salida mutuamente excluyentes. Softmax se encontrará a menudo en la capa de salida de un clasificador, tal como ocurre en el primero que el lector ha visto en el capítulo anterior.

ReLU

La función de activación rectified linear unit (ReLU) es una transformación muy interesante que activa un solo nodo si la entrada está por encima de cierto umbral. El comportamiento por defecto y más habitual es que mientras la entrada tenga un valor por debajo de cero, la salida será cero, pero cuando la entrada se eleva por encima, la salida es una relación lineal con la variable de entrada de la forma f (x) = x. La función de activación ReLU ha demostrado funcionar en muchas situaciones diferentes, y actualmente es muy usada.

Elementos del backpropagation

En resumen, podemos ver el backpropagation como un método para alterar los parámetros (pesos y sesgos) de la red neuronal en la dirección correcta. Empieza primero por calcular el término de loss, y luego los parámetros de la red neuronal son ajustados en orden reverso con un algoritmo de optimización teniendo en cuenta esta loss calculada.

Recordemos que en Keras  se dispone del método compile() para definir cómo queremos que sean los componentes que intervienen en el proceso de aprendizaje:

model.compile(loss='categorical_crossentropy',
              optimizer='sgd',
              metrics=['accuracy'])

En concreto, en este ejemplo se le pasan tres argumentos: un optimizador, una función de loss y una lista de métricas. En los problemas de clasificación como nuestro ejemplo se usa la accuracy como métrica. Entremos un poco más en detalle de estos argumentos.

Función de loss

Una función de loss es uno de los parámetros requeridos para cuantificar lo cercano que está una determinada red neuronal de su ideal mientras está en el proceso de entrenamiento.

En la página del manual de Keras[2] podemos encontrar todos los tipos de funciones de loss disponibles en ella.  Algunos tienen sus hiperparámetros concretos que deben ser indicados; en el ejemplo del capítulo anterior, cuando usamos categorical_crossentropy como función de loss, nuestra salida debe ser en formato categórico, es decir, que la variable de salida debe tomar un valor entre los 10 posibles. La elección de la mejor función de loss reside en entender qué tipo de error es o no es aceptable para el problema en concreto.

Optimizadores

El optimizador es otro de los argumentos que se requieren en el método de compile(). Keras dispone en estos momentos de diferentes optimizadores que pueden usarse: SGD, RMSprop, Adagrad, Adadelta, Adam, Adamax, Nadam.  Se puede encontrar más detalle de cada uno de ellos en la documentación de Keras[3].

De forma general, podemos ver el proceso de aprendizaje como un problema de optimización global donde los parámetros (los pesos y los sesgos) se deben ajustar de tal manera que la función de loss presentada anteriormente se minimice.  En la mayoría de los casos, estos parámetros no se pueden resolver analíticamente, pero en general se pueden aproximar bien con algoritmos de optimización iterativos u optimizadores, como los mencionados anteriormente.

Gradient descent

Vamos aquí a explicar uno de los optimizadores para que veamos su funcionamiento en general. Concretamente el gradient descent, la base de muchos y uno de los algoritmos de optimización más comunes en Machine Learning y Deep Learning.

Gradient descent usa la primera derivada (gradiente) de la función de loss cuando realiza la actualización en los parámetros.  Recordemos que el gradiente nos da la pendiente de una función en ese punto. Sin poder entrar en detalle, el proceso consiste en encadenar las derivadas de la loss de cada capa oculta a partir de las derivadas de la loss de su capa superior, incorporando su función de activación en el cálculo (por eso las funciones de activación deben ser derivables).  En cada iteración, una vez todas las neuronas disponen del valor del gradiente de la función loss que les corresponde, se actualizan los valores de los parámetros en el sentido contrario a la que indica el gradiente. El gradiente, en realidad, siempre apunta en el sentido en la que se incrementa el valor de la función de loss. Por tanto, si se usa el negativo del gradiente podemos conseguir el sentido en que tendemos a reducir  la función de loss.

Veamos el proceso de manera visual asumiendo solo una feature: supongamos que esta línea representa los valores que toma la función de loss para cada posible parámetro y en el punto inicial indicado el negativo del gradiente se indica por la flecha:

Para determinar el siguiente valor para el parámetro (para simplificar la explicación, consideremos solo el peso/weight), el algoritmo de gradient descent  modifica el valor del peso inicial para ir en sentido contrario al del gradiente (ya que este apunta en el sentido en que crece la loss y queremos reducirla), añadiendo una cantidad proporcional a este. La magnitud de este cambio está determinado por el valor del gradiente y por un hiperparámetro learning rate (que presentaremos en breve) que podemos especificar. Por lo tanto, conceptualmente es como si siguiéramos la dirección de la pendiente cuesta abajo hasta que alcanzamos un mínimo local:

El algoritmo gradient descent repite este proceso acercándose cada vez más al mínimo hasta que el valor del parámetro llega a un punto más allá del cual no puede disminuir la función de loss:

Más adelante presentaremos maneras de parar este proceso iterativo.

Stochastic Gradient Descent (SGD)

De momento no hemos hablado sobre con qué frecuencia se ajustan los valores de los parámetros:

  • ¿Después de cada ejemplo de entrada?
  • ¿Después de cada recorrido sobre todo el conjunto de ejemplos de entrenamiento (epoch)?
  • ¿Después de una muestra de ejemplos del conjunto de entrenamiento?

En el primer caso hablamos de online learning,cuando se estima el gradiente a partir de la loss observada para cada ejemplo del entrenamiento; también es cuando originalmente hablábamos de Stochastic Gradient Descent (SGD). Mientras que el segundo se conoce por batch learning y se llama Batch Gradient Descent. La literatura indica que se suelen obtener mejores resultados con el online learning, pero existen motivos que justifican el batch learning porque muchas técnicas de optimización solo funcionan con él.

Pero si los datos están bien distribuidos, un pequeño subconjunto de ellos nos debería dar una idea bastante buena del gradiente. Quizás no se obtenga su mejor estimación, pero es más rápido, y debido a que estamos iterando, esta aproximación nos sirve; por esto se usa a menudo la tercera opción antes mencionada conocida como mini-batch. Esta opción suele ser mejor que la online y se requieren menos cálculos para actualizar los parámetros de la red neuronal. Además, el cálculo simultáneo del gradiente para muchos ejemplos de entrada puede realizarse utilizando operaciones con matrices que se implementan de forma muy eficiente con GPU, como hemos visto en el capítulo 1.

Por eso, en realidad muchas aplicaciones usan el stochastic gradient descent (SGD) con un mini-bach de varios ejemplos. Para usar todos los datos lo que se hace es particionar los datos en varios lotes (que llamaremos batches). Entonces cogemos el primer batch, se pasa por la red, se calcula el gradiente de su loss y se actualizan los parámetros de la red neuronal; esto seguiría sucesivamente hasta el último batch.  Ahora, en una sola pasada por todos los datos de entrada, se han realizado solo un número de pasos igual que el número de batches.

SGD es muy fácil de implementar en Keras.  En el método compile() se indica que el optimizador es SGD (valor sgd en el argumento), y entonces todo lo que se debe hacer es especificar el tamaño de batch en el proceso de entrenamiento con el método fit() de la siguiente manera:

model.fit(X_train, y_train, epochs=5, batch_size=100)

Aquí, estamos dividiendo nuestros datos en batches de 100 con el argumento de batch_size.  Con el número de epochs estamos indicando cuantas veces realizamos este proceso sobre todos los datos. Más adelante en este capítulo, cuando ya hayamos presentado los parámetros habituales de los optimizadores, volveremos a hablar de estos dos argumentos.

Parametrización de los modelos

Si el lector al acabar el anterior capítulo ha creado un modelo con los hiperparámetros que allí usamos, supongo que la accuracy del modelo (es decir, el número de veces que acertamos respecto el total) le habrá salido sobre el 90%. ¿Son buenos estos resultados? Yo creo que son fantásticos, porque significa que el lector ya ha programado y ejecutado su primera red neuronal con Keras. Congratulations!

Otra cosa es que haya modelos que permiten mejorar la accuracy. Y esto depende de tener mucho conocimiento y práctica para manejarse bien con los muchos hiperparámetros que podemos cambiar: por ejemplo, con un simple cambio de la función de activación de la primera capa, pasando de una sigmoid a una relu:

model.add(Dense(10, activation='relu', input_shape=(784,)))

Podemos obtener un 2% más de accuracy con un tiempo de cálculo aproximadamente el mismo.

También es posible aumentar el número de epochs, agregar más neuronas en una capa o agregar  más capas. Sin embargo, en estos casos las ganancias en accuracy tienen el efecto lateral de que aumenta el tiempo de ejecución del proceso de aprendizaje. Por ejemplo, si a la capa intermedia en vez de 10  nodos le ponemos 512 nodos:

model.add(Dense(512, activation='relu', input_shape=(784,)))

Podemos comprobar con el método summary() que aumenta el número de parámetros (recordemos que es una fully connected) y el tiempo de ejecución es notablemente superior, incluso reduciendo el número de epochs.  Con este modelo la accuracy,  llega a 94%. Y si aumentamos a 20 epochs ya se consigue una accuracy de 96%.

En definitiva, todo un mundo de posibilidades que veremos con más detalle en los siguientes capítulos, pero que el lector ya puede irse dando cuenta de que encontrar la mejor arquitectura con los mejores parámetros e hiperparámetros de las funciones de activación requiere cierta pericia y experiencia dadas las múltiples posibilidades que veremos que tenemos.

Parámetros e hiperparámetros

Hasta ahora, por simplicidad, no hemos puesto atención explícita a diferenciar entre parámetros e hiperparámetros, pero creo que ha llegado el momento.  En general, consideramos un parámetro del modelo como una variable de configuración que es interna al modelo y cuyo valor puede ser estimado a partir de los datos. En cambio, por hiperparámetro nos referimos a variables de configuración que son externas al modelo en sí mismo y cuyo valor en general no puede ser estimado a partir de los datos, y son especificados por el programador para ajustar los algoritmos de aprendizaje.

Cuando digo que  Deep Learning es más un arte que una ciencia me refiero a que se requiere mucha experiencia e intuición para encontrar los valores óptimos de estos hiperparámetros, que se deben especificar antes de iniciar el proceso de entrenamiento para que los modelos entrenen mejor y más rápidamente.

Dado el carácter introductorio del libro no entraremos en detalle sobre todos ellos, pero tenemos hiperparámetros que vale la pena comentar brevemente, tanto a nivel de estructura y topología de la red  neuronal (número de capas, número de neuronas, sus funciones de activación, etc) como hiperparámetros a nivel de algoritmo de aprendizaje  (learning rate, momentum, epochs, batch size, etc).

A continuación, vamos a introducir alguno de ellos y el resto irán apareciendo en los siguientes capítulos a medida que vayamos entrando en redes neuronales convolucionales y redes neuronales recurrentes.

Número de epochs

Como ya hemos avanzado, epochs nos indica el número de veces en las que todos los datos de entrenamiento han pasado por la red neuronal en el proceso de entrenamiento. Como presentaremos más adelante, una buena pista es incrementar el número de epochs hasta que la métrica accuracy con los datos de validación empieza a decrecer, incluso cuando la accuracy de los datos de entrenamiento continua incrementándose (es cuando detectamos un potencial sobreajuste u overfitting).

Batch size

Tal como hemos comentado antes, podemos particionar los datos de entrenamiento en mini lotes  para pasarlos por la red.  En Keras, como hemos visto, el  batch_size es el argumento que indica el tamaño que se usará de estos lotes en el método fit() en una iteración del entrenamiento para actualizar el gradiente.  El tamaño óptimo dependerá de muchos factores, entre ellos de la capacidad de memoria del computador que usemos para hacer los cálculos.

Learning rate

El vector de gradiente tiene una dirección y una magnitud. Los algoritmos de gradient descent multiplican la magnitud del gradiente por un escalar conocido como learning rate (también denominado a veces step size) para determinar el siguiente punto; por ejemplo, si la magnitud del gradiente es 1,5 y el learning rate es 0,01, entonces el algoritmo de gradient descent seleccionará el siguiente punto a 0,015 del punto anterior.

El valor adecuado de este hiperparámetro es muy dependiente del problema en cuestión, pero en general, si este es demasiado grande, se están dando pasos enormes, lo que podría ser bueno para ir rápido en el proceso de aprendizaje, pero es posible que se salte el mínimo y dificultar así que el proceso de aprendizaje se detenga  porque al buscar el siguiente punto perpetuamente rebota al azar en el fondo del “pozo”. Visualmente se aprecia en esta figura el efecto que puede producirse, en que nunca se llega al valor mínimo (indicado con una pequeña flecha en el dibujo):

Contrariamente, si el learning rate es pequeño, se harán avances constantes y pequeños, teniéndose una mejor oportunidad de llegar a un mínimo local, pero esto puede provocar que el proceso de aprendizaje sea muy lento. En general, una buena regla es que si nuestro modelo de aprendizaje no funciona, disminuyamos la learning rate. Si sabemos que el gradiente de la función de loss es pequeño, entonces es seguro probar con learning rate que compensen el gradiente.

Learning rate decay

Ahora bien, el mejor learning rate  en general es aquel que disminuye a medida que el modelo se acerca a una solución. Para conseguir este efecto, disponemos de otro hiperparámetro, el learning rate decay, que se usa para disminuir el learning rate a medida que van pasando epochs para permitir que el aprendizaje avance más rápido al principio con learning rates más grandes. A medida que se avanza, se van haciendo ajustes cada vez más pequeños para facilitar que converja el proceso de entrenamiento al mínimo de la función loss.

Momentum

En el ejemplo visual con el que hemos explicado el algoritmo de gradient descent, para minimizar la función de loss se tiene la garantía de encontrar el mínimo global porque no hay un mínimo local en el que su optimización se pueda atascar.  Sin embargo, en realidad, los casos reales son más complejos y visualmente es como si nos pudiéramos encontrar con varios mínimos locales y la función loss tuviera una forma como la de la siguiente figura:

En este caso, el optimizador puede quedarse atascado fácilmente en un mínimo local y el algoritmo puede pensar que se ha alcanzado el mínimo global, lo que lleva a resultados subóptimos. El motivo es que en el momento en que nos atascamos, el gradiente es cero, y ya no podemos salir del mínimo local siguiendo estrictamente lo que hemos contado de guiarnos por el gradiente.

Una manera de solventar esta situación podría ser reiniciar el proceso desde diferentes posiciones aleatorias y, de esta manera, incrementar la probabilidad de llegar al mínimo global.

Para evitar esta situación, otra solución que generalmente se utiliza involucra el hiperparámetro momentum. De manera intuitiva, podemos verlo como si un avance tomara el promedio ponderado de los pasos anteriores para obtener así un poco de ímpetu y superar los “baches” como una forma de no atascarse en los mínimos locales. Si consideramos que el promedio de los anteriores eran mejores, quizás nos permita hacer el salto.

Pero usar la media ha demostrado ser una solución muy drástica, porque quizás en gradientes anteriores es mucho menos relevante que justo en el gradiente anterior. Por eso se ha optado por ponderar los anteriores gradientes, y el momentum es una constante entre 0 y 1 que se usa para esta ponderación. Se ha demostrado que los algoritmos que usan momentum funcionan mejor en la práctica.

Una variante es el Nesterov momentum,  que es una versión ligeramente diferente de la actualización del momentum que muy recientemente ha ganado popularidad y que básicamente ralentiza el gradiente cuando está ya cerca de la solución.

Pero dado el carácter introductorio del libro, descarto entrar en más detalle en explicar este hiperparámetro.

Inicialización de los pesos de los parámetros

La inicialización del peso de los parámetros no es exactamente un hiperparámetro, pero es tan importante como cualquiera de ellos y por eso hacemos un breve inciso en esta sección. Es recomendable inicializar los pesos con unos valores aleatorios y pequeños para romper la simetría entre diferentes neuronas, puesto que si dos neuronas tienen exactamente los mismos pesos siempre tendrán el mismo gradiente; eso supone que ambas tengan los mismos valores en las subsecuentes iteraciones, por lo que no serán capaces de aprender características diferentes.

El inicializar los parámetros de forma aleatoria siguiendo una distribución normal estándar es correcto, pero nos puede provocar posibles problemas de vanishing gradients o exploding gradients, que trataremos en el capítulo 7. En general se pueden usar heurísticas teniendo en cuenta el tipo de funciones de activación que tiene nuestra red. Queda fuera del nivel introductorio de este libro entrar en estos detalles, pero si el lector quiere profundizar un poco más le propongo que visite la página web del curso CS231n[4] de Andrej Karpathy en Stanford, donde encontrará conocimientos muy valiosos en esta área expuestos de manera muy didáctica (en inglés).

Hiperparámetros y optimizadores en Keras

¿Cómo podemos especificar estos hiperparámetros? Recordemos que el optimizador es uno de los argumentos que se requieren en el método compile() del modelo; hasta ahora los hemos llamado por su nombre (con un simple strings que los identifica). Pero Keras permite también pasar como argumento una instancia de la clase optimizer con la especificación de alguno hiperparámetros.

Por ejemplo el optimizador stochastic gradient descent permite usar los hiperparámetros momentum, learning rate decay y Nesterov momentum.  En concreto:

keras.optimizers.SGD(lr=0.01, momentum=0.0, decay=0.0, nesterov=False)

Los valores indicados en los argumentos del anterior método son los que toma por defecto y cuyo rango pueden ser:

  • lr: float >= 0. (learning rate)
  • momentum: float >= 0
  • decay: float >= 0 (learning rate decay).
  • nesterov: boolean (que indica si usar o no Nesterov momentum).

Como hemos dicho,  hay varios optimizadores en Keras que el lector puede explorar en su página de documentación[5].

Practicando con una clasificación binaria

Dado el carácter introductorio de este libro, una herramienta muy interesante que puede ser de ayuda al lector para ver cómo se comporta una red neuronal y poner en práctica alguno de los conceptos aquí presentados sin tener que entrar en las matemáticas que hay detrás es TensorFlow Playground[6].

TensorFlow Playground

TensorFlow Playground es una aplicación web de visualización interactiva escrita en JavaScript que nos permite simular redes neuronales simples que se ejecutan en nuestro navegador, y ver los resultados en tiempo real:

Con esta herramienta podremos experimentar con diferentes hiperparámetros y ver su comportamiento.  Precisamente, la flexibilidad de las redes neuronales es una de sus virtudes y a la vez uno de sus inconvenientes para los que se inician en el tema: ¡hay muchos  hiperparámetros para ajustar!

Para empezar, elegimos el conjunto de datos indicado en el apartado “DATA” que se muestra en la figura anterior, y a continuación pulsamos el botón de “Play”. Ahora podemos ver como TensorFlow Playgrond resuelve este problema particular. La línea entre la zona de color azul y naranja empieza a moverse lentamente.  Puede pulsar el botón de “Reset” y volver a probarlo varias veces para ver como la línea se mueve con diferentes valores iniciales a medida que vemos como el contador epoch va aumentando.

Para empezar a entender cómo funciona la herramienta podemos usar el primer ejemplo de perceptron presentado en este libro en el capítulo anterior, un simple problema de clasificación.

En este caso la aplicación trata de encontrar los mejores valores de los parámetros que permitan clasificar correctamente estos puntos. Si pone el cursor encima de los arcos, el lector verá que aparece el valor que se ha asignado a cada parámetro (e incluso permite editarlo y probarlo para ver su efecto):

Recordemos que este peso dictamina la importancia que tendrá esa relación en la neurona al multiplicarse por el valor de entrada.

Después de esta primera toma de contacto vamos a presentar un poco la herramienta que nos permitirá entender cómo se comporta una red neuronal. En la parte superior del menú nos encontramos básicamente hiperparámetros, alguno de los cuales ya comentados en el capítulo anterior: Epoch, Learning rate, Activation, Regularization rate (lo veremos en el capítulo 6),  y Problem type. Todos ellos son menús desplegables en los que podemos elegir el valor de estos hiperparámetros.

En la pestaña Problem type la plataforma permite especificar dos tipos de problema: Regresión (problema continuo) y Clasificación. En total, hay cuatro tipos de datos que podemos elegir para clasificación  y dos tipos para regresión:

Los puntos azules y naranjas forman el conjunto de datos (dataset). Los puntos naranjas tienen el valor -1 y los puntos azules el de +1. En la parte lateral izquierda, debajo del tipo de datos, se encuentran diferentes parámetros que podemos modificar para tunear nuestros datos de entrada.

Usando la pestaña “Ratio of training to test data” podemos controlar el porcentaje de datos que se asigna al conjunto de entrenamiento (si lo modificamos vemos como interactivamente se cambian los puntos que aparecen en el “OUTPUT” de la derecha de la pantalla). El nivel de ruido de los datos también puede definirse y controlarse por el campo “Noise”; el patrón de datos se vuelve más irregular a medida que aumenta el ruido. Como podemos experimentar, cuando el ruido es cero, los datos del problema se distinguen claramente en sus regiones. Sin embargo, al llegar a más de 50, puede verse que los puntos azules y los puntos anaranjados se mezclan, por lo que es muy difícil clasificarlos.

Con “Batch size”, como su nombre indica,  podemos determinar la cantidad de datos que se usará para cada batch de entrenamiento.

A continuación, en la siguiente columna podemos realizar la selección de características. Propongo que usemos  “X1” y “X2” entre los muchos de que disponemos: “X1” es un valor en el eje horizontal, y “X2” es el valor en el eje vertical.

La topología de la red se puede definir en la siguiente columna. Podemos tener hasta seis capas ocultas (agregando capas ocultas, haciendo clic en el signo “+”). Y podemos tener hasta ocho neuronas por capa oculta (haciendo clic en el signo “+” de la capa correspondiente):

Finalmente, recordar que al entrenar la red neuronal queremos minimizar la “Training loss” y luego ver que con los datos de test también se minimiza la “Test loss”.  Los cambios de ambas métricas en cada epoch se muestran interactivamente en la parte superior derecha de la pantalla, en una pequeña gráfica, donde si la loss es reducida la curva va hacia abajo. La test loss se pinta en color negro, y la training loss se pinta en gris.

Clasificación con una sola neurona

Ahora que sabemos un poco más sobre esta herramienta volvamos al primer ejemplo de clasificación que separa en dos grupos (clusters) los datos.

Les propongo que modifiquemos algunos parámetros para coger soltura con la herramienta antes de avanzar. Por ejemplo, podemos modificar algunos parámetros con un learning rate de 0.03 y una función de activación ReLU  (la regularización no vamos a usarla puesto que aún no la hemos presentado hasta el capítulo 6).

Mantenemos el problema como de clasificación, y propongo que pongamos el “ratio of training-to-test” al 50% de los datos y que mantengamos también el parámetro “noise” a cero para facilitar la visualización de la solución (aunque les propongo que más tarde jueguen con él por su cuenta). El  “batch size” lo podemos dejar a 10.

Y, como antes,  usaremos “X1” y “X2” para la entrada. Sugiero comenzar con una sola capa oculta de una sola neurona. Podemos conseguir esto usando los botones de “-“ o “+”:

En la parte superior derecha vemos que los valores iniciales de la “Test loss” y “Training loss“ son altos (al lector le pueden salir valores diferentes dado que los valores iniciales son generados de manera aleatoria).  Pero después de pulsar el botón “play” puede verse que tanto la “Training loss” como la “Test loss” convergen a unos ratios muy bajos  y se mantienen.  Además, en este caso, ambas líneas, negra y gris, se superponen perfectamente.

 Clasificación con más de una neurona

Escojamos otro conjunto de datos de partida como el de la figura adjunta:

Queremos ahora separar los dos conjuntos de datos: los de color naranja se deben clasificar en un grupo y los azules en otro. Pero el problema es que, en este caso, tendrán una forma circular donde los puntos naranja estarán en el círculo exterior y los puntos azules estarán dentro; ahora no se pueden separar estos puntos con una sola línea como antes. Si entrenamos con una capa oculta que tiene una sola neurona, la clasificación anterior aquí fallará.

Les propongo que probemos con múltiples neuronas en la capa oculta. Por ejemplo, prueben con dos neuronas: verán que aún no afina suficiente. Les propongo que después prueben con tres. Verán que al final pueden conseguir una loss tanto de training como de test mucho mejores:

Vayamos a por otro de los ejemplos de la izquierda, aquel donde los datos están divididos en cuatro zonas cuadradas diferentes. Ahora, este problema no se puede solucionar con la anterior red, pero propongo que lo prueben:

Como ven, no somos capaces de conseguir una buena clasificación (aunque en algún caso podría suceder que con solo 3 neuronas funciona dado que la inicialización es aleatoria, pero si hacen varias pruebas verán que no se consigue en general). Pero si tenemos 5 neuronas  como en la figura siguiente, el lector puede ver cómo esta red neuronal consigue una buena clasificación para este caso:

Clasificación con varias capas

Ahora trataremos de clasificar el conjunto de datos con el patrón más complejo que tenemos en esta herramienta. La estructura de remolino de los puntos de datos naranja y azul es un problema desafiante. Si nos basamos en la red anterior, vemos que ni llegando a tener 8 neuronas, el máximo que nos deja la herramienta, conseguimos un buen resultado de clasificación:

Si lo ha probado el lector, en este caso supongo que obtendrá unos valores poco buenos para la loss  de test. Ha llegado el momento de poner más capas; les aseguro que si usan todas las que permite la herramienta lo conseguirán:

Pero verán que como es obvio, el proceso de aprendizaje de los parámetros tarda mucho.

En realidad, con menos capas o neuronas se pueden conseguir unos buenos resultados; les reto a que jueguen un poco por su cuenta, también cambiando por ejemplo las funciones de activación para conseguir un modelo más simple. También pueden considerar probar cualquier otro parámetro.

Esta herramienta solo considera redes neuronales densas; después veremos que las redes neuronales convolucionales y las redes neuronales recurrentes presentan dilemas adicionales, y más complejos. Pero ya solo con estas redes densas podemos ver que uno de los hiperparámetros más difíciles de ajustar es el de decidir cuántas capas tiene el modelo y cuántas neuronas tiene cada una de estas capas.

Como veremos en siguientes capítulos, usar muy pocas neuronas en las capas ocultas dará lugar a lo que es llamado subreajuste o underfitting, una falta de ajuste del modelo debido a que  hay muy pocas neuronas en las capas ocultas para detectar adecuadamente las señales en un conjunto de datos complicado.

Por otro lado, usar demasiadas neuronas en las capas ocultas puede causar varios problemas. Primero, puede producir overfitting,  que ocurre cuando la red neuronal tiene tanta capacidad de procesamiento de información que la cantidad limitada de información contenida en el conjunto de entrenamiento no es suficiente para entrenar a todas las neuronas en las capas ocultas.  Pero por otro lado, una cantidad grande de neuronas en las capas ocultas puede aumentar el tiempo necesario para entrenar la red hasta el punto de que es imposible entrenar adecuadamente la red neuronal en el tiempo necesario.

Obviamente, se debe llegar a un compromiso entre demasiadas y muy pocas neuronas en las capas ocultas y por eso ya he comentado anteriormente que nos encontramos ante un reto que requiere más arte que ciencia.


Referencias:

[1] Véase https://keras.io/activations/

[2] Véase https://keras.io/losses/

[3] Véase https://keras.io/optimizers/

[4] CS231n Convolutional Neural Networks for Visual Recognition. [online] Disponible en http://cs231n.github.io/neural-networks-2/#init [Accedido 20/04/2018]

[5] Véase http://keras.io/optimizers

[6] Véase http://playground.tensorflow.org

Día 5:  Redes neuronales convolucionales

Llegados a este punto, ya estamos preparados para tratar con otro tipo de redes neuronales, las llamadas redes neuronales convolucionales, muy usadas en tareas de visión por computador y las que han permitido grandes avances en el campo de la inteligencia artificial. Estas redes están compuestas por una capa de input, una de output y varias capas hidden, siendo algunas de ellas convolucionales, de aquí su nombre.

En este capítulo presentaremos un caso específico que seguiremos paso a paso para entender los conceptos básicos de este tipo de redes. En concreto, junto con el lector, programaremos una red neuronal convolucional para resolver  el mismo problema de reconocimiento de dígitos del MNIST visto anteriormente.

Introducción a las redes neuronales convolucionales

Una red neuronal convolucional (Convolutional Neural Networks en inglés, con los acrónimos CNNs o ConvNets) es un caso concreto de redes neuronales Deep Learning, que fueron ya usadas a finales de los 90 pero que en estos últimos años se han popularizado enormemente al conseguir resultados muy impresionantes en el reconocimiento de imagen, impactando profundamente en el área de visión por computador.

Las redes neuronales convolucionales son muy similares a las redes neuronales del capítulo anterior: están formadas por neuronas que tienen parámetros en forma de pesos y sesgos que se pueden aprender. Pero un  rasgo diferencial de las CNN es que hacen la suposición explícita de que las entradas son imágenes, cosa que nos permite codificar ciertas propiedades en la arquitectura para reconocer elementos concretos en las imágenes.

Para hacernos una idea intuitiva de cómo funcionan estas redes neuronales, pensemos en cómo nosotros reconocemos las cosas. Por ejemplo, si vemos una cara, la reconocemos porque tiene orejas, ojos, una nariz, cabello, etc. Entonces, para decidir si algo es una cara, lo hacemos como si tuviéramos unas casillas mentales de verificación de las características que vamos marcando. Algunas veces una cara puede no tener una oreja por estar tapada por el pelo, pero igualmente lo clasificamos con una cierta probabilidad como cara porque vemos los ojos, la nariz y la boca. En realidad, podemos verlo como un clasificador equivalente al presentado en el capítulo 2, que predice una probabilidad de que la imagen de entrada sea cara o no cara.

Pero en realidad, antes debemos saber cómo es una oreja o una nariz para saber si están en una imagen; es decir, previamente debemos identificar líneas, bordes, texturas o formas que sean similares a las que contiene las orejas o narices que hemos visto antes. Y esto es lo que las capas de una red neuronal convolucional tienen encomendado hacer.

Pero identificar estos elementos no es suficiente para poder decir que algo es una cara.  Además debemos poder identificar cómo las partes de una cara se encuentran entre sí, tamaños relativos, etc., de lo contrario, la cara no se parecería a lo que estamos acostumbrados. Visualmente, una idea intuitiva de lo que aprenden las capas se presenta a menudo con este ejemplo extraído de este artículo de referencia del grupo de Andrew Ng[1].

La idea que se quiere dar con este ejemplo visual es que, en realidad, en una red neuronal convolucional cada capa va aprendiendo diferentes niveles de abstracción.  El lector puede imaginarse que con redes con muchas capas se pueden conseguir identificar estructuras más complejas en los datos de entrada.

Componentes básicos de una red neuronal convolucional

Ahora que tenemos una visión intuitiva de cómo clasifican las redes neuronales convolucionales una imagen, vamos a presentar un ejemplo de reconocimiento de dígitos MNIST y a partir de él introduciremos las dos capas que definen a las redes convolucionales que pueden expresarse como grupos de neuronas especializadas en dos operaciones: convolución y pooling.

Operación de convolución

La diferencia fundamental entre una capa densamente conectada y una capa especializada en la operación de convolución, que llamaremos capa convolucional, es que la capa densa aprende patrones globales en su espacio global de entrada, mientras que las capas convolucionales aprenden patrones locales en pequeñas ventanas de dos dimensiones.

De manera intuitiva, podríamos decir que el propósito principal de una capa convolucional es detectar características o rasgos visuales en las imágenes como aristas, líneas, gotas de color, etc.  Esta es una propiedad muy interesante, porque una vez aprendida una característica en un punto concreto de la imagen la puede reconocer  después en cualquier parte de la misma. En cambio, en una red neuronal densamente conectada tiene que aprender el patrón nuevamente si este aparece en una nueva localización de la imagen.

Otra característica importante es que las capas convolucionales pueden aprender jerarquías espaciales de patrones preservando relaciones espaciales. Por ejemplo, una primera capa convolucional puede aprender elementos básicos como aristas, y una segunda capa convolucional puede aprender patrones compuestos de elementos básicos aprendidos en la capa anterior. Y así sucesivamente hasta ir aprendiendo patrones muy complejos. Esto permite que las redes neuronales convolucionales aprendan eficientemente conceptos visuales cada vez más complejos y abstractos.

En general las capas convoluciones operan sobre tensores de 3D, llamados feature maps, con dos ejes espaciales de altura y anchura (height y width), además de un eje de canal (channels) también llamado profundidad (depth). Para una imagen de color RGB, la dimensión del eje depth es 3, pues la imagen tiene tres canales: rojo, verde y azul (red, green y blue). Para una imagen en blanco y negro, como los dígitos MNIST, la dimensión del eje depth es 1 (nivel de gris).

En el caso de MNIST, como entrada en nuestra red neuronal podemos pensar en un espacio de neuronas de dos dimensiones 28×28 (height=28, width=28, depth=1). Una primera capa de neuronas ocultas  conectadas a las neuronas de la capa de entrada que hemos comentado realizarán las operaciones convolucionales que acabamos de describir. Pero como hemos explicado, no se conectan todas las neuronas de entrada con todas las neuronas de este primer nivel de neuronas ocultas, como en el caso de las redes neuronales densamente conectas; solo se hace por pequeñas zonas localizadas del espacio de las neuronas de entrada que almacenan los píxeles de la imagen.

Lo explicado, visualmente, se podría representar como:

En el caso de nuestro anterior ejemplo, cada neurona de nuestra capa oculta será conectada a una pequeña región de 5×5 neuronas (es decir 25 neuronas) de la capa de entrada (de 28×28). Intuitivamente, se puede pensar en una ventana del tamaño de 5×5 que va recorriendo toda la capa de 28×28 de entrada que contiene la imagen. Esta ventana va deslizándose a lo largo de toda la capa de neuronas. Por cada posición de la ventana hay una neurona en la capa oculta que procesa esta información.

Visualmente, empezamos con la ventana en la esquina arriba-izquierda de la imagen, y esto le da la información necesaria a la primera neurona de la capa oculta. Después, deslizamos la ventana una posición hacia la derecha para “conectar” las 5×5 neuronas de la capa de entrada incluidas en esta ventana con la segunda neurona de la capa oculta. Y así, sucesivamente, vamos recorriendo todo el espacio de la capa de entrada, de izquierda a derecha y de arriba abajo.

Analizando un poco el caso concreto que hemos propuesto, observemos que si tenemos una entrada de 28×28 píxeles y una ventana de 5×5 esto nos define un espacio de 24×24 neuronas en la primera capa del oculta, debido a que solo podemos mover la ventana 23 neuronas hacia la derecha y 23 hacia abajo antes de chocar con el lado derecho (o inferior) de la imagen de entrada.

Quisiéramos hacer notar al lector que el supuesto que hemos hecho es que la ventana hace movimientos de avance de 1 píxel de distancia, tanto en horizontal como en vertical cuando empieza una nueva fila. Por ello, en cada paso la nueva ventana se solapa con la anterior excepto en esta línea de píxeles que hemos avanzado.  Pero, como veremos en la siguiente sección, en redes neuronales convolucionales se pueden usar diferentes longitudes de pasos de avance (el parámetro llamado stride). En las redes neuronales convolucionales también se puede aplicar una técnica de relleno de ceros alrededor del margen de la imagen para mejorar el barrido que se realiza con la ventana que se va deslizando. El parámetro para definir este relleno recibe el nombre de padding,  el cual también presentaremos con más detalle en la siguiente sección, con el que se puede especificar el tamaño de este relleno.

En nuestro caso de estudio, y siguiendo el formalismo ya presentado previamente, para “conectar” cada neurona de la capa oculta con las 25 neuronas que le corresponden de la capa de entrada usaremos un valor de sesgo b y una matriz de pesos W de tamaño 5×5 que llamaremos filtro (o kernel y filter en inglés).  El  valor de cada punto de la capa oculta corresponde al producto escalar entre el filtro y el puñado de 25 neuronas (5×5) de la capa de entrada.

Ahora bien, lo particular y muy importante de las redes convolucionales es que se usa el mismo filtro (la misma matriz W de pesos y el mismo sesgo b) para todas las neuronas de la capa oculta: en nuestro caso para las 24×24 neuronas (576 neuronas en total) de la primera capa. El lector puede ver en este caso concreto que esta compartición reduce de manera drástica el número de parámetros que tendría una red neuronal si no la hiciéramos: pasa de 14.400 parámetros que tendrían que ser ajustados (5×5×24×24) a 25 (5×5) parámetros más los sesgos b.

Esta matriz W compartida junto con el sesgo b, el cual ya hemos dicho que llamamos filtro en este contexto de redes convolucionales, es similar a  los filtros que usamos para retocar imágenes, que en nuestro caso sirven para buscar características locales en pequeños grupos de entradas. Les recomiendo ver los ejemplos que se encuentran en el manual del editor de imágenes GIMP[2] para hacerse una idea visual y muy intuitiva de cómo funciona un proceso de convolución.

Pero un filtro definido por una matriz W y un sesgo b solo permiten detectar una característica concreta en una imagen; por tanto, para poder realizar el reconocimiento de imágenes se propone usar varios filtro a la vez, uno para cada característica que queramos detectar. Por eso una capa convolucional completa en una red neuronal convolucional incluye varios filtros.

Una manera habitual de representar visualmente esta capa convolucional es la que mostramos en la siguiente figura, donde el nivel de capas ocultas está compuesta por varios filtros. En nuestro ejemplo proponemos 32 filtros, donde cada filtro recordemos que se define con una matriz W de pesos compartida de 5×5 y un sesgo b.

En este ejemplo, la primera capa convolucional  recibe un tensor de entrada de tamaño (28, 28, 1) y genera una salida de tamaño (24, 24, 32), un tensor 3D que contiene las 32 salidas de 24×24 píxel resultado de computar los 32 filtros sobre la entrada.

Operación de pooling

Además de las capas convolucionales que acabamos de describir, las redes neuronales convolucionales acompañan a la capa de convolución con unas capas de pooling, que suelen ser aplicadas inmediatamente después de las capas convolucionales. Una primera aproximación para entender para qué sirven estas capas es ver que las capas de pooling hacen una simplificación de la información recogida por la capa convolucional y crean una versión condensada de la información contenida en estas.

En nuestro ejemplo de MNIST, vamos a escoger una ventana de 2×2 de la capa convolucional y vamos a sintetizar la información en un punto en la capa de pooling. Visualmente, se puede expresar de la siguiente manera:

Hay varias maneras de condensar la información, pero una habitual, y que usaremos en nuestro ejemplo, es la conocida como max-pooling, que como valor se queda con el valor máximo de los que había en la ventana de entrada de 2×2 en nuestro caso.  En este caso dividimos por 4 el tamaño de la salida de la capa de pooling, quedando una imagen de 12×12.

También se puede utilizar average-pooling en lugar max-pooling, donde cada grupo de puntos de entrada se transforma en el valor promedio del grupo de puntos en vez de su valor máximo.  Pero en general el max-pooling tiende a funcionar mejor que las soluciones alternativas.

Es interesante remarcar que con la transformación de pooling mantenemos la relación espacial. Para verlo visualmente, cojamos el siguiente ejemplo de una matriz de 12×12 donde tenemos representado un “7” (Imaginemos que los píxeles donde pasamos por encima contienen un 1 y el resto 0; no lo hemos añadido al dibujo para simplificarlo). Si aplicamos una operación de max-pooling con una ventada de 2×2 (que lo representamos en la matriz central que divide el espacio en un mosaico con regiones del tamaño de la ventana), obtenemos una matriz de 6×6 donde se mantiene una representación equivalente del 7 (en la figura de la derecha donde aquí hemos marcado en blanco los ceros y en negro los puntos con valor 1):

Tal como hemos mencionado anteriormente, la capa convolucional alberga más de un filtro, y por tanto, como aplicamos el max-pooling a cada uno de ellos separadamente, la capa de pooling contendrá tantos filtros de pooling como de filtros convolucionales:

El resultado es, dado que teníamos un espacio de 24×24 neuronas en cada filtro convolucional, después de hacer el pooling tenemos 12×12 neuronas que corresponde a las 12×12 regiones de tamaño 2×2 que aparecen al dividir el espacio de neuronas del espacio del filtro de la capa convolucional.

Implementación de un modelo básico en Keras

Veamos cómo se puede programar este ejemplo de red neuronal convolucional en Keras. Como hemos comentado, hay varios valores a concretar para parametrizar las etapas de convolución y pooling. En nuestro caso, usaremos un modelo simplificado con un stride de 1 en cada dimensión (tamaño del paso con el que desliza la ventana) y un padding de 0 (relleno de ceros alrededor de la imagen). Ambos hiperparámetros los presentaremos a continuación. El pooling será un max-pooling como el descrito anteriormente con una ventana de 2×2.

Arquitectura básica de una red neuronal convolucional

Pasemos a implementar nuestra primera red neuronal convolucional, que consistirá en una convolución seguida de un max-pooling. En nuestro caso, tendremos 32 filtros usando una ventana de 5×5 para la capa convolucional y una ventana de 2×2 para la capa de pooling.  Usaremos la función de activación ReLU. En este caso, estamos configurando una red neuronal convolucional para procesar un tensor de entrada de tamaño (28, 28, 1), que es el tamaño de las imágenes MNIST  (el tercer parámetro es el canal de color que en nuestro caso es 1), y lo especificamos mediante el valor del argumento input_shape=(28, 28,1)  en nuestra primera capa:

from keras import layers
from keras import models

model = models.Sequential()
model.add(layers.Conv2D(32,(5,5),activation='relu',input_shape=(28, 28,1)))
model.add(layers.MaxPooling2D((2, 2)))

model.summary()

_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
conv2d_1 (Conv2D)            (None, 24, 24, 32)        832
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 12, 12, 32)        0
=================================================================
Total params: 832
Trainable params: 832
Non-trainable params: 0

El número de parámetros de la capa conv2D corresponde a la matriz de pesos W de 5×5 y un sesgo b para cada uno de los filtros es 832 parámetros (32 × (25+1)).  El max-pooling no requiere parámetros puesto que es una operación matemática de encontrar el máximo.

Un modelo simple

Y con el fin de construir una red neuronal “deep”, podemos apilar varias capas como la construida en la anterior sección. Para mostrar al lector cómo hacerlo en nuestro ejemplo, crearemos un segundo grupo de capas que tendrá 64 filtros con una ventana de 5×5 en la capa convolucional y una de 2×2 en la capa de pooling. En este caso, el número de canales de entrada tomará el valor de las 32 características que hemos obtenido de la capa anterior, aunque como ya hemos visto anteriormente, no hace falta especificarlo porque Keras lo deduce:

model = models.Sequential()
model.add(layers.Conv2D(32,(5,5),activation='relu',input_shape=(28,28,1)))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (5, 5), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))

Si se muestra la arquitectura del modelo con:

model.summary()

Se puede ver:

_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
conv2d_1 (Conv2D)            (None, 24, 24, 32)        832
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 12, 12, 32)        0
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 8, 8, 64)          51264
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 4, 4, 64)          0
=================================================================
Total params: 52,096
Trainable params: 52,096
Non-trainable params: 0
_________________________________________________________________

En este caso el tamaño de la segunda capa de convolución resultante es de 8×8 dado que ahora partimos de un espacio de entrada de 12×12×32 y una ventana deslizante de 5×5, teniendo en cuenta que tiene un stride de 1. El número de parámetros 51 264 corresponde a que  la segunda capa tendrá 64 filtros,  como hemos especificado en el argumento, con 801 parámetros cada uno (1 corresponde al sesgo, y luego tenemos la matriz W de 5×5 para cada una de las 32 entradas (((5×5×32)+1)×64=51264).

El lector puede ver que la salida de las capas Conv2DMaxPooling2D es un tensor 3D de forma (height, width, channels). Las dimensiones width y height tienden a reducirse a medida que nos adentramos en las capas ocultas de la red neuronal. El número de kernels/filtros es controlado a través del primer argumento pasado a la capa Conv2D  (habitualmente de tamaño 32 o 64).

El siguiente paso, ahora que tenemos 64 filtros de 4×4, consiste en añadir una capa densamente conectada (densely connected layer), que servirá para alimentar una capa final de softmax como la introducida en el capítulo 3 para hacer la clasificación:

model.add(layers.Dense(10, activation='softmax'))

En este ejemplo que nos ocupa,  recordemos que antes tenemos que ajustar los tensores a la entrada de la capa densa como la softmax, que es un tensor de 1D, mientras que la salida de la anterior es un tensor de 3D; por eso se tiene primero que aplanar el tensor de 3D a uno de 1D. Nuestra salida (4,4,64) se debe aplanar a un vector de (1024) antes de aplicar el Softmax.

En este caso, el número de parámetros de la capa softmax es 10 × 1024 +10, con una salida de un vector de 10 como en el ejemplo del capítulo 2:

model = models.Sequential()

model.add(layers.Conv2D(32,(5,5),activation='relu', input_shape=(28,28,1)))
model.add(layers.MaxPooling2D((2, 2)))

model.add(layers.Conv2D(64, (5, 5), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))

model.add(layers.Flatten())
model.add(layers.Dense(10, activation='softmax'))

Con el método summary() podemos ver esta información sobre los parámetros de cada capa y formato de los tensores de salida de cada capa:

_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
conv2d_1 (Conv2D)            (None, 24, 24, 32)        832
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 12, 12, 32)        0
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 8, 8, 64)          51264
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 4, 4, 64)          0
_________________________________________________________________
flatten_1 (Flatten)          (None, 1024)              0
_________________________________________________________________
dense_1 (Dense)              (None, 10)                10250
=================================================================
Total params: 62,346
Trainable params: 62,346
Non-trainable params: 0
_________________________________________________________________

Observando este resumen, se aprecia fácilmente que en las capas convolucionales es donde se requiere más memoria, y por ende computación para almacenar los datos. En cambio, en la capa densamente conectada de softmax se necesita poco espacio de memoria, pero en comparación se necesitan muchos parámetros para el modelo, que deberán ser aprendidos. Es importante ser consciente de los tamaños de los datos y de los parámetros porque cuando tenemos modelos basados en redes neuronales convolucionales, estos tienen muchas capas, como veremos más adelante, y estos valores pueden dispararse.

Una representación más visual de la anterior información se muestra en la siguiente figura, donde vemos una representación gráfica de la forma de los tensores que se pasan entre capas y sus conexiones:

Entrenamiento y evaluación del modelo

Una vez definido el modelo de la red neuronal estamos ya en disposición de pasar a entrenar el modelo, es decir, ajustar los parámetros de todas las capas convolucionales. A partir de aquí, para saber cuán bien lo hace nuestro modelo, debemos hacer lo mismo que ya hicimos en el ejemplo del capítulo 2. Por este motivo, y para no repetirnos, vamos a reutilizar el código ya presentado anteriormente:

from keras.datasets import mnist
from keras.utils import to_categorical

(train_images, train_labels), (test_images, test_labels) = mnist.load_data()

train_images = train_images.reshape((60000, 28, 28, 1))
train_images = train_images.astype('float32') / 255

test_images = test_images.reshape((10000, 28, 28, 1))
test_images = test_images.astype('float32') / 255

train_labels = to_categorical(train_labels)
test_labels = to_categorical(test_labels)


model.compile(loss='categorical_crossentropy',
              optimizer='sgd',
              metrics=['accuracy'])

model.fit(train_images, train_labels,
          batch_size=100,
          epochs=5,
          verbose=1)

test_loss, test_acc = model.evaluate(test_images, test_labels)

print('Test accuracy:', test_acc)

Test accuracy: 0.9704

Como en los casos anteriores, el código se puede encontrar en el GitHub del libro y puede comprobarse que este código ofrece una accuracy de aproximadamente 97%.

El lector, si ha ejecutado el código en un ordenador con solo CPU, habrá notado que esta vez el entrenamiento de la red ha tardado bastante más que el anterior ejemplo, incluso con solo 5 epochs. ¿Se imaginan lo que podría llegar a tardar una red de muchas más capas, epochs o imágenes? A partir de aquí, como comentábamos en la introducción del libro, nos hace falta poder entrenar con más recursos de computación como pueden ser las GPU. En la segunda parte del libro hablaremos más sobre este tema.

Argumentos del método fit

El lector se habrá percatado que en este ejemplo no hemos separado una parte de los datos para la validación del modelo como indicábamos en la sección 2.4, que serían los que se pasarían en el argumento validation_data del método fit().  ¿Cómo es posible?

Como ya hemos visto en otros casos, Keras coge muchos valores por defecto, y uno de ellos es este.  En realidad, si no se especifica el argumento validation_data, Keras usa el argumento validation_split, que es un entero entre 0 y 1 que especifica la fracción de los datos de entrenamiento que se deben considerar como datos de validación (argumento que tampoco hemos indicado en este ejemplo).

Por ejemplo, un valor del 0.2 implica que se separara el 20% de los datos que se han indicado en los arrays Numpy en los dos primeros argumentos del método fit() y no serán incluidos en el entrenamiento, siendo usados solo para evaluar la loss o cualquier otra métrica al final de cada epoch. Su valor por defecto es 0.0.  Es decir, si no se especifica ninguno de estos argumentos no se realiza el proceso de validación al final de cada epoch.

En realidad, no tiene mucho sentido no hacerlo, porque como veremos en la segunda parte del libro los hiperparámetros que pasamos como argumentos a los métodos son muy importantes y precisamente el uso de los datos de validación es crucial para encontrar su mejor valor. Pero de momento, en este capítulo más centrado en la arquitectura de una red neuronal convolucional no hemos creído conveniente entrar en estos detalles y he aquí la simplificación.

También con este ejemplo aprovechamos para remarcar que Keras tiene la mayoría de hiperparámetros inicializados por defecto, de tal manera que nos facilita iniciarnos en la implementación de una red neuronal.

Hiperparámetros de la capa convolucional

Los principales hiperparámetros de las redes neuronales convolucionales son el tamaño de la ventana del filtro, el número de filtros, el paddings y tride.

Tamaño y número de filtros

El tamaño de la ventana (window_height × window_width) que mantiene información de píxeles cercanos espacialmente es usualmente de 3×3 o 5×5. El número de filtros que nos indica el número de características que queremos manejar (output_depth) acostumbran a ser de 32 o 64. En las capas Conv2D de Keras estos hiperparámetros son los que pasamos como argumentos en este orden:

Conv2D(output_depth, (window_height, window_width))

Padding

Para explicar el concepto de padding usemos un ejemplo. Supongamos  una imagen con 5×5 píxeles. Si elegimos una ventana de 3×3 para realizar la convolución vemos que el tensor resultante de la operación es de tamaño 3×3.  Es decir, se encoge un poco: exactamente dos píxeles por cada dimensión, en este caso.  En la siguiente figura se muestra visualmente. Supongamos que la figura de la izquierda es la imagen de 5×5. En ella hemos numerado los píxeles para facilitar ver como se desliza la ventrada de 3×3 para para calcular los elementos del filtro. En el centro se representa como la ventana de 3×3 se ha desplazado por la imagen dos posiciones hacia la derecha y dos posiciones hacia abajo. El resultado de aplicar la operación de convolución nos devuelve el filtro que hemos representado a la izquierda. Cada elemento de este filtro está etiquetado con una letra que lo asocia al contenido de la ventana deslizante con el que se calcula su valor.

Este mismo efecto se puede observar en el ejemplo de red neuronal convolucional que estamos creando en este capítulo. Comenzamos con una imagen de entrada de 28× 28 píxeles y los filtros resultantes son de 24 × 24 después de la primera capa de convolución. Y en la segunda capa de convolución, pasamos de un tensor de 12×12 a uno de 8×8.

Pero a veces queremos obtener una imagen de salida de las mismas dimensiones que la entrada  y podemos usar para ello el hiperparámetro padding en las capas convolucionales. Con padding podemos agregar ceros alrededor de las imágenes de entrada antes de hacer deslizar la ventana por ella. En nuestro caso de la figura anterior, para que el filtro de salida tenga el mismo tamaño que la imagen de entrada, podemos añadir a la imagen de entrada una columna a la derecha, una columna a la izquierda, una fila arriba y una fila debajo de ceros.  Visualmente se puede ver en la siguiente figura:

Si ahora deslizamos la ventana de  3×3, vemos que puede desplazarse 4 posiciones a la derecha y 4 posiciones hacia abajo, generando las 25 ventanas que generan el filtro de tamaño  5×5.

En Keras, el padding en la capa Conv2D se configura con el argumento padding, que puede tener dos valores: “same”, que indica que se añadan tantas filas y columnas de ceros como sea necesario para que la salida tenga la misma dimensión que la entrada; y “valid”, que indica no hacer padding (que es el valor por defecto de este argumento en Keras).

Stride

Otro hiperparámetro que podemos especificar en una capa convolucional es el stride,  que nos indica el número de pasos en que se mueve la ventada de los filtros (en el anterior ejemplo el stride era de uno).

Valores de stride grandes hacen decrecer el tamaño de la información que se pasará la siguiente capa. En la siguiente figura podemos ver el mismo ejemplo anterior pero ahora con un valor de stride de 2:

Como vemos, la imagen de 5×5 se ha convertido en un filtro de tamaño más reducido de 2×2. Pero en realidad los strides en convolucionales para reducir los tamaños son raramente utilizados en la práctica; para ello se usan las operaciones de pooling que hemos presentado antes. En Keras, el stride en la capa Conv2D se configura con el argumento stride  que tiene por defecto el valor  strides=(1, 1) que indica por separado el avance en las dos dimensiones.

Redes  neuronales convolucionales con nombre propio

Hay varias arquitecturas de redes neuronales convolucionales populares dentro de la comunidad de Deep Learning, a las cuales se las denomina mediante un nombre propio. Una de ellas, equivalente a la que hemos presentado en este capítulo, es LeNet[3], nacida en los años noventa cuando Yann LeCun consiguió el primer uso de redes neuronales convolucionales con éxito en la lectura de códigos postales y dígitos. Los datos de entrada a la red son imágenes de 32×32 píxeles, seguida de dos etapas de convolución-pooling, una capa densamente conectada  y una capa softmax final que nos permite reconocer los números.

Pero hay muchas más: por ejemplo, la red neuronal convolucional AlexNet[4] de Alex Krizhevsky, ya mencionada, que ganó la competición de ImageNet del año 2012. Famosas son también GoogleLeNet[5] de Google, que con su módulo de inception reduce drásticamente los parámetros de la red (15 veces menos que AlexNet) y de ella han derivado varias versiones como  la Inception-v4[6]. Otras como la VGGnet[7] contribuyeron a demostrar que la profundidad de la red es una componente crítica para unos buenos resultados.  Si el lector quiere conocer más sobre las diversas redes convolucionales populares en la comunidad puede leer el artículo Recent Advances in Convoluitonal Neural Networks[8].

Lo interesante de muchas de estas redes es que podemos encontrarlas ya construidas en la mayoría de frameworks que introducíamos en la sección 1.3 del capítulo 1. Por ejemplo, en Keras, si quisiéramos programar una red neuronal VGGnet podríamos hacerlo escribiendo el siguiente código:

from keras.models import Sequential
from keras.layers import Dense, Activation, Dropout, Flatten
from keras.layers import Conv2D
from keras.layers import MaxPooling2D

input_shape = (224, 224, 3)

model = Sequential()
model.add(Conv2D(64, (3, 3), input_shape=input_shape, padding='same',activation='relu'))
model.add(Conv2D(64, (3, 3), activation='relu', padding='same'))
model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))

model.add(Conv2D(128, (3, 3), activation='relu', padding='same'))
model.add(Conv2D(128, (3, 3), activation='relu', padding='same',))
model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))

model.add(Conv2D(256, (3, 3), activation='relu', padding='same',))
model.add(Conv2D(256, (3, 3), activation='relu', padding='same',))
model.add(Conv2D(256, (3, 3), activation='relu', padding='same',))
model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))

model.add(Conv2D(512, (3, 3), activation='relu', padding='same',))
model.add(Conv2D(512, (3, 3), activation='relu', padding='same',))
model.add(Conv2D(512, (3, 3), activation='relu', padding='same',))
model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))

model.add(Conv2D(512, (3, 3), activation='relu', padding='same',))
model.add(Conv2D(512, (3, 3), activation='relu', padding='same',))
model.add(Conv2D(512, (3, 3), activation='relu', padding='same',))
model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))

model.add(Flatten())
model.add(Dense(4096, activation='relu'))
model.add(Dense(4096, activation='relu'))
model.add(Dense(1000, activation='softmax'))

Invocando al método summary():

model.summary()

Podemos obtener el detalle del formato de los tensores entre capas, así como los parámetros en cada capa. Pongan atención al número total de parámetros que se requieren.

_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
conv2d_1 (Conv2D)            (None, 224, 224, 64)      1792
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 224, 224, 64)      36928
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 112, 112, 64)      0
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 112, 112, 128)     73856
_________________________________________________________________
conv2d_4 (Conv2D)            (None, 112, 112, 128)     147584
_________________________________________________________________
max_pooling2d_2 (MaxPooling2 (None, 56, 56, 128)       0
_________________________________________________________________
conv2d_5 (Conv2D)            (None, 56, 56, 256)       295168
_________________________________________________________________
conv2d_6 (Conv2D)            (None, 56, 56, 256)       590080
_________________________________________________________________
conv2d_7 (Conv2D)            (None, 56, 56, 256)       590080
_________________________________________________________________
max_pooling2d_3 (MaxPooling2 (None, 28, 28, 256)       0
_________________________________________________________________
conv2d_8 (Conv2D)            (None, 28, 28, 512)       1180160
_________________________________________________________________
conv2d_9 (Conv2D)            (None, 28, 28, 512)       2359808
_________________________________________________________________
conv2d_10 (Conv2D)           (None, 28, 28, 512)       2359808
_________________________________________________________________
max_pooling2d_4 (MaxPooling2 (None, 14, 14, 512)       0
_________________________________________________________________
conv2d_11 (Conv2D)           (None, 14, 14, 512)       2359808
_________________________________________________________________
conv2d_12 (Conv2D)           (None, 14, 14, 512)       2359808
_________________________________________________________________
conv2d_13 (Conv2D)           (None, 14, 14, 512)       2359808
_________________________________________________________________
max_pooling2d_5 (MaxPooling2 (None, 7, 7, 512)         0
_________________________________________________________________
flatten_1 (Flatten)          (None, 25088)             0
_________________________________________________________________
dense_1 (Dense)              (None, 4096)              102764544
_________________________________________________________________
dense_2 (Dense)              (None, 4096)              16781312
_________________________________________________________________
dense_3 (Dense)              (None, 1000)              4097000
=================================================================
Total params: 138,357,544
Trainable params: 138,357,544
Non-trainable params: 0
_________________________________________________________________

Pero en Keras solo nos hace falta especificar las siguientes dos líneas para crearla, además de poderla tener ya iniciada con los parámetros de una red ya entrenada (con Imagenet):

from keras.applications import VGG16

model = VGG16(weights='imagenet')

Nuevamente, si usamos el método summary() para obtener detalles de esta red, vemos que es idéntica en arquitectura, tensores y parámetros que la que hemos programado nosotros a mano.

model.summary()

_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
input_1 (InputLayer)         (None, 224, 224, 3)       0
_________________________________________________________________
block1_conv1 (Conv2D)        (None, 224, 224, 64)      1792
_________________________________________________________________
block1_conv2 (Conv2D)        (None, 224, 224, 64)      36928
_________________________________________________________________
block1_pool (MaxPooling2D)   (None, 112, 112, 64)      0
_________________________________________________________________
block2_conv1 (Conv2D)        (None, 112, 112, 128)     73856
_________________________________________________________________
block2_conv2 (Conv2D)        (None, 112, 112, 128)     147584
_________________________________________________________________
block2_pool (MaxPooling2D)   (None, 56, 56, 128)       0
_________________________________________________________________
block3_conv1 (Conv2D)        (None, 56, 56, 256)       295168
_________________________________________________________________
block3_conv2 (Conv2D)        (None, 56, 56, 256)       590080
_________________________________________________________________
block3_conv3 (Conv2D)        (None, 56, 56, 256)       590080
_________________________________________________________________
block3_pool (MaxPooling2D)   (None, 28, 28, 256)       0
_________________________________________________________________
block4_conv1 (Conv2D)        (None, 28, 28, 512)       1180160
_________________________________________________________________
block4_conv2 (Conv2D)        (None, 28, 28, 512)       2359808
_________________________________________________________________
block4_conv3 (Conv2D)        (None, 28, 28, 512)       2359808
_________________________________________________________________
block4_pool (MaxPooling2D)   (None, 14, 14, 512)       0
_________________________________________________________________
block5_conv1 (Conv2D)        (None, 14, 14, 512)       2359808
_________________________________________________________________
block5_conv2 (Conv2D)        (None, 14, 14, 512)       2359808
_________________________________________________________________
block5_conv3 (Conv2D)        (None, 14, 14, 512)       2359808
_________________________________________________________________
block5_pool (MaxPooling2D)   (None, 7, 7, 512)         0
_________________________________________________________________
flatten (Flatten)            (None, 25088)             0
_________________________________________________________________
fc1 (Dense)                  (None, 4096)              102764544
_________________________________________________________________
fc2 (Dense)                  (None, 4096)              16781312
_________________________________________________________________
predictions (Dense)          (None, 1000)              4097000
=================================================================
Total params: 138,357,544
Trainable params: 138,357,544
Non-trainable params: 0
_________________________________________________________________

En realidad, Keras ofrece varios modelos preentrenados[9]  que como veremos en la segunda parte del libro son muy útiles para lo que se conoce transfer learning: Xception, VGG16, VGG19, ResNet50, InceptionV3, InceptionResNetV2, MobileNet, DenseNet y NASNet.

Como veremos más adelante, el hecho que sean preentrenados es muy valioso en casos donde no disponemos de suficientes datos para entrenarlas, pero también desde un punto de vista computacional. Por ejemplo, en la VGG16 tenemos más de 138 millones de parámetros[10], y si sumamos la memoria requerida para almacenar los datos intermedios vemos que requerimos más de 24 millones de puntos por imagen; si cada uno ocupa 4 bytes de memoria estamos hablando de casi 100 millones de Bytes por imagen solo para la fase de forward.

Recordando lo que hemos comentado antes, las capas convolucionales son las que más memoria requieren: fíjense en las primeras capas convolucionales de este ejemplo, que necesitan almacenar más de 3 millones de puntos (224x224x64) y que las capas fully connected, por ejemplo la fc1, requiere más de un millón de parámetros.


Referencias:

[1] Unsupervised learning of hierarchical representations with convolutional deep belief networks. H Lee, R. Grosse, R Ranganath and A. Y Ng.. Communications ACM, Vol 54 Issue 10, October 2011.

[2] GIMPS – programa de manipulación de imágenes de GNU Apartado 8.2. Matriz de convolución [en línea]. Disponible en: https://docs.gimp.org/es/plug-in-convmatrix.html [Consulta: 5/1/2016].

[3]  Y. LeCun, L. Bottou, Y. Bengio, and P. Haffner. Gradient-based learning applied to document recognition. Proceedings of the IEEE, november 1998. [en línea] http://yann.lecun.com/exdb/publis/pdf/lecun-98.pdf [consulta 15/05/2018]

[4] A. Krizhevsky, I. Sutskever and G.E. Hinton. ImageNet Classification with Deep Convolutional Neural Networks. Neural Information Processing Systems, Lake Tahoe, Nevada. NIPS 2012.  [en línea]  http://papers.nips.cc/paper/4824-imagenet-classification-with-deep-convolutional-neural-networks.pdf  [consulta 15/05/2018]

[5] https://arxiv.org/abs/1409.4842 [en línea]  [consulta 15/05/2018]

[6] https://arxiv.org/abs/1602.07261[en línea]  [consulta 15/05/2018]

[7] http://www.robots.ox.ac.uk/~vgg/research/very_deep/ [en línea] [consulta 15/05/2018]

[8] J Gu, Z. Wang, J. Kuen, L. Ma, A. Shahroudy, B. Shuai, T. Liu, X. Wang and G. Wang. Recent Advances in Convolutional Neural Networks. Journal Pattern Recognition Volumen 77, Issue C, May 2018. Pages 354-377. Elsevier Science  [en línea] preprint versión https://arxiv.org/pdf/1512.07108v5.pdf [consulta 15/05/2018]

[9] Véase https://keras.io/applications/

[10] El lector habrá visto que con el método summary() obtenemos el “Non-training param”, es decir el número de parámetros que no deben ser entrenados. Dejamos este parámetro para explicarlo en la segunda parte del libro.

Día 6:  Tranfer Learning usando redes preentrenadas

 

(pendiente)

Agradecimientos

Agradecimientos a la edición actual

Hasta el momento, en esta edición del libro han participado como revisores Mauricio Echeverría Arístegui y Bernat Torres, compañeros de IThinkUPC.
 

Agradecimientos a la edición de Keras

Escribir un libro requiere motivación pero también mucho tiempo, y por ello quiero empezar agradeciendo a mi familia el apoyo y la comprensión que ha mostrado ante el hecho de que un portátil compartiera con nosotros muchos fines de semana y muchas noches.

A Ferran Julià, un gran amigo que es licenciado en físicas e ingeniero en informática, le agradezco que me acompañara en la escritura de este libro para mejorar su organización y lectura.

En esta línea agradecer también a Andrés Gómez de La Fundación Pública Galega Centro Tecnolóxico de Supercomputación de Galicia(CESGA) por su aportación al hacer una revisión a fondo de los textos de este libro.

A Juan Luís Domínguez, que vino de Jerez para poder leer juntos a François Chollet, le agradezco la ayuda en preparar muchos de los códigos que dan soporte a este libro. Sin Juan Luís ni Maurici Yagües, con quien debatimos semanalmente sobre estos temas, este libro nunca habría empezado a escribirse.

Especial agradecimiento se merece un colega de la UPC y gran amigo, Xavier Giró-i-Nieto. Xavier es una inagotable fuente de inspiración y conocimiento, con quien estoy codirigiendo las tesis doctorales de Míriam Bellver y Victor Campos, a quien les debo también gran parte de conocimiento en estos temas y contenidos de este libro. Y no quiero olvidarme de otros estudiantes como Xisco Sastre, cuyo trabajo final de máster trató también sobre estos conceptos, siendo un gran estímulo para mi aprendizaje y de cuyos resultados salieron un par de artículos de investigación y de los que he usado algunas gráficas en este libro.

Han sido muchos los expertos en este tema que no conozco personalmente los que también me han ayudado a la hora de escribir, permitiéndome que compartiera sus ideas; por ello, menciono en detalle las fuentes en los apartados correspondientes, más como muestra de agradecimiento que no para que el lector lo tenga que consultar (el lector habrá visto que hay más de un centenar de notas de pie de página).

Pero de todos los expertos en que me he inspirado, debo hacer una especial mención a François Chollet, investigador de Google y creador de Keras, a quien tengo la suerte de conocer personalmente. Este libro está escrito después que François publicara su libro Deep Learning with Python, el cual me ha sido de gran ayuda e inspiración y del que he considerado usar algunos de sus ejemplos de código compartido en su GitHub.

Mi más sincero agradecimiento a todos aquellos que han leído parcial o totalmente esta obra antes de ver la luz como Bernat Torres (SOMmobilitat), Fernando García Sedano (Grupsa), Jordi Morera (UPCnet), Guifré Ballester (UPCnet), Sergi Sales (UPCnet)  quienes me han reportado interesantes comentarios para incluir en la versión que tienen en sus manos.

Mi mayor agradecimiento a mi universidad, la Universitat Politècnica de Catalunya– UPC Barcelona Tech, y en especial a Agustín Fernández Jiménez, vicerrector de Transformación Digital de la UPC por haber escrito el prólogo de esta obra y haber hecho una lectura detallada del borrador final. La UPC ha sido el entorno de trabajo que me ha permitido realizar mi investigación sobre estos temas y acumular los conocimientos que aquí quiero compartir. Universidad que además me ofrece dar clases en la Facultat d’Informàtica de Barcelonaa unos alumnos brillantes, quienes me animan a escribir obras como esta.

Como mencionaba al principio del libro, a investigadores como Ricard Gavaldà o Jordi Nin les debo que despertaran en mi el interés por estos temas años atrás. Otros, como Rubèn Tous o Joan Capdevila que me acompañaran en los primeros pasos.

En la misma línea, quiero agradecer al centro de investigación Barcelona Supercomputing Center – Centro Nacional de Computación (BSC) y en especial a sus directores Mateo Valero y Josep M. Martorell, y los directores del departamento de Computer Science Jesús Labarta y Eduard Ayguadé, quienes me han permitido y apoyado siempre esta dèriaque tengo de tener que estar parant l’orellaa les tecnologías que vendrán.

Gracias también a Laura Juan por su exquisita revisión del texto antes de que este viera la luz; sin su ayuda esta obra no tendría la calidad que tiene.

También a Katy Wallace, por poner nombre a esta colección en la que se edita esta obra y a Enric Aromí por responder rápido a una dudas de wordpress en pasar este libro a formato HTML.

En la promoción de este libro agradezco el apoyo de FIB Alumni, Col·legi Oficial d’Enginyeria en Informàtica de Catalunya (COEINF) y el portal de información tecnológico TECNONEWS.

Finalmente, mi agradecimiento a mis alumnos y alumnas que han compartido conmigo la primera edición de este libro, los “cap problema”: Martín Acosta, Alessio Addimando, John Jairo Ballestas, Andrés Bermudez, Gabriel Cantos, Robert Carausu, Víctor Chamizo, Juan de los Reyes, Francesc, de Puig, Pau Figueras, Rafa Genés,  Beñat Jimenez, Miquel Lara, Gil Laroussi, Tito Leiva, Irene Lopez, Eduardo Rodríguez, Isabel Samaniego, Marc Tula, Javier Vasquez, Marc Vila y  Jonathan Zarama. ¡Gracias a todos y a todas!

Agradecimientos a la edición de TensorFlow

Escribir un libro requiere motivación pero también mucho tiempo, y por ello quiero empezar agradeciendo a mi familia el apoyo y la comprensión que ha mostrado ante el hecho de que un portátil compartiera con nosotros muchos fines de semana y parte de las vacaciones de Navidad desde que Google anunciara que liberaba TensorFlow el pasado noviembre.

A Oriol Vinyals le quiero agradecer muy sinceramente su disponibilidad y entusiasmo por escribir el prólogo de este libro, que ha sido para mí el primer gran reconocimiento al esfuerzo realizado. A Oriol lo conocí hace un par de años después de intercanviar unos cuantos correos electrónicos y en persona el año pasado. Realmente, un crack del tema de quien nuestro país se debería sentir muy orgulloso e intentar seducirlo para que algún día deje Silicon Valley y venga a Barcelona a fundar aquí nuestro propio Silicon Valley mediterráneo.

Como avanzaba en el prefacio del libro, un antiguo alumno licenciado en físicas e ingeniero en informática, además de haber sido uno de los mejores becarios de investigación que he tenido en el BSC, ha jugado un papel muy importante en esta obra. Se trata de Ferran Julià, que junto con Oriol Núñez han fundado una startup, con sede en mi comarca, en la que se preparan para analizar imágenes con redes neuronales convolucionales, entre otras muchísimas cosas que ofrece UNDERTILE. Este hecho ha permitido que Ferran Julià haya hecho a la perfección el rol de editor en este libro, incidiendo en forma y contenidos, de la misma manera que lo hizo mi editor Llorenç Rubió cuando publiqué con la editorial Libros de Cabecera mi ópera prima.

Ahora bien, a Oriol Núñez le agradezco profundamente la idea que compartió conmigo de ampliar las posibilidades de este libro y hacer llegar sus beneficios a muchas más personas de las que yo tenía en mente originalmente a través de su proyecto conjunto con la Fundació El Maresme para la integración social y la mejora de la calidad de vida de las personas con discapacidad intelectual de mi comarca.

Mi más sincero agradecimiento a todos aquellos que han leído parcial o totalmente esta obra antes de ver la luz. En especial, a un importante data scientist como es Aleix Ruiz de Villa, quién me ha reportado interesantes comentarios para incluir en la versión que tienen en sus manos. Pero también a Mauro Gómez, Oriol Núñez, Bernat García y Manuel Carbonell por sus importantes aportaciones con sus lecturas previas.

Han sido muchos expertos en este tema que no conozco personalmente los que también me han ayudado en este libro, permitiéndome que compartiera sus ideas e incluso sus códigos, y por ello menciono en detalle las fuentes en los apartados correspondientes en este libro, más como muestra de agradecimiento que no para que el lector lo tenga que consultar.

Mi mayor agradecimiento a mi universidad, la Universitat Politècnica de Catalunya – UPC Barcelona Tech, que ha sido el entorno de trabajo que me ha permitido realizar mi investigación sobre estos temas y acumular los conocimientos que aquí quiero compartir. Universidad que además me ofrece dar clases en la Facultat d’Informàtica de Barcelona, a unos alumnos brillantes, quienes me animan a escribir obras como esta.

En la misma línea, quiero agradecer al centro de investigación Barcelona Supercomputing Center – Centro Nacional de Computación (BSC) y en especial a su director Mateo Valero, y los directores de Computer Science Jesús Labarta y Eduard Ayguadé, quienes me han permitido y apoyado siempre esta “dèria” que tengo de tener que estar “parant l’orella” a les tecnologías que vendrán.

Especialmente me gustaría mencionar a dos de mis colegas de la UPC, con quien estoy codo a codo iniciando esta rama de investigación más de “analítica”: Rubèn Tous y Joan Capdevila han mostrado fe ciega en mis “dèrias” de exploración de nuevos temas para conseguir que nuestros conocimientos puedan aportar a esta nueva área llamada High-Performance Big-Data Analytics. Hacía tiempo que no disfrutaba tanto haciendo investigación.

Relacionado con ellos, agradecer a otro gran data scientist, Jesús Cerquides, del Artificial Intelligence Research Institute del CSIC , de quien a través de la codirección de una tesis doctoral estoy descubriendo una nueva y apasionante galaxia en el universo del Machine Learning.

Y no puedo olvidarme de quien aprendo muchísimo, estudiantes que su trabajo final de máster trata estos temas: Sana Imtiaz o Andrea Ferri.

Hablando de GPUs, gracias a Nacho Navarro, responsable del BSC/UPC NVIDIA GPU Center of Excellence, por facilitarme desde el primer momento el uso de sus recursos para “entrenar” a mis redes neuronales.

Mi agradecimiento al catedrático de la UPC Ricard Gavaldà, uno de los mejores data scientist con los que cuenta el país, que con mucha paciencia me llevo de la mano en mis inicios en este inhóspito para mi, pero apasionante, mundo del Machine Learning a mediados del 2006 creando junto con Toni Moreno-García, Josep Ll Berra y Nico Poggi el primer team híbrido de Data Scientist con Computer Engineers, ¡momentos inolvidables!

Gracias a esa experiencia nuestro grupo de investigación incorporó el Machine Learning con resultados tan brillantes como las tesis de Josep Ll. Berral, Javier Alonso o Nico Poggi, donde usábamos el Machine Learning en la gestión de recursos de los complejos sistemas de computación actuales. Desde entonces que me he quedado prendado del Machine Learning.

Pero no fue hasta unos años más tarde, en 2014, cuando con la incorporación al grupo de Jordi Nin y posteriormente de Jose A. Cordero, que no hice el paso adelante en “Deep Learning”. Sin su estímulo por este tema hoy este libro no existiría. Gracias, Jordi y José, y espero que vuestros nuevos retos profesionales os reporten grandes éxitos.

Y no quiero olvidar a Màrius Mollà, quién me empujó a publicar mi primer libro Empresas en la nube. Desde entonces no para de insistir para cuándo la siguiente obra… ¡Pues aquí la tienes! Y a Mauro Cavaller, un gran colaborador de Màrius, cuya contribución fue clave en mi opera prima, me ha aportado esta vez una última revisión formal.

Gracias también a Laura Juan por su exquisita revisión del texto antes de que este viera la luz; sin su ayuda esta obra no tendría la calidad que tiene. A Katy Wallace, por poner nombre a esta colección en la que se edita esta obra. Y a Bernat Torres, por haber creado la fantástica página web de este libro.

A Oriol Pedrera, un genio que domina todas las artes plásticas, que me ha acompañado con mucha paciencia en las diversas técnicas que he ido usando para realizar las ilustraciones del libro, que junto con Júlia Torres y Roser Bellido hemos ido concretando una y otra vez hasta encontrar la versión que encuentran en este libro. ¡Ha sido genial! En la parte artística no quisiera olvidarme del gran ebanista Rafa Jiménez quien acepto, sin rechistar, construirme a medida mi mesa de dibujo.

Agradecer al meetup grup d’estudis de machine learning de Barcelona por acoger la presentación oficial del libro, y a Oriol Pujol per aceptar impartir la conferencia que ha acompañado esta presentación del libro en el meetup.

Y también , muchas gracias a las entidades que me han ayudado a hacer difusión de la existencia de esta obra: la Facultad de Informática de Barcelona (FIB), la aceleradora de proyectos tecnológicos ITNIG, el Col·legi Oficial d’Enginyers Informàtics (COEINF), la Associació d’Antics Alumnes de la FIB (FIBAlumni), el portal de tecnología TECNONEWS, el portal iDigital y el Centre d’Excel·lència en Big Data a Barcelona (Big Data CoE de Barcelona).

Para acabar, una mención especial a la “penya cap als 50”, la “colla dels informàtics” que después de 30 años todavía hacemos encuentros que dejan a uno cargadísimo de energía. El caso es que aquel fin de semana de noviembre que a la gente de Google desde Silicon Valley se le ocurrió sacar a la luz TensorFlow, yo lo pase con esta peña. Si yo no me hubiera cargado las pilas con ellos durante ese fin de semana, les aseguro que al día siguiente, cuando me planteé enfrascarme en escribir este libro no habría tenido la energía necesaria. Gracias Xavier, Lourdes, Carme, Joan, Humbert, Maica, Jordi, Eva, Paqui, Jordi, Lluís, Roser, Ricard y Anna. Ahora ya he agotado la energía, ¿cuándo volvemos a quedar?