Ejecución eager con Keras y TensorFlow

16/03/2020


TensorFlow es una de las librerías más populares de deep learning. Con la popularización de la inteligencia artificial y las redes convolucionales en 2012, el equipo de Google Brain decidió crear una librería de código abierto para el desarrollo de sistemas basados en machine learning. Desde su liberación open source en 2015, TensorFlow ha iterando por diversas formas de operar con redes convolucionales. En una primera versión, esta librería requería la creación manual de variables, y el uso de sesiones para ejecutar grafos de computación. Afortunadamente, esta situación ha ido cambiando hasta facilitar la creación de modelos utilizando Keras como API de alto nivel, y la ejecución eager de operaciones.

Grafo computacional

En una primera versión, la manera de definir y ejecutar redes en TensorFlow era poco intuitiva. Primero debes crear un grafo computacional, después una sesión de ejecución, y finalmente pasarle el grafo a la sesión para que lo ejecute por ti. Esto supone una serie de desventajas, como por ejemplo la complejidad de saber, en tiempo real, que está ocurriendo en cada punto de la red. Otro problema importante de este enfoque es la dificultad de introducir operaciones de control de flujo. Debido a que la sesión ejecuta un grafo computacional, y no código escrito directamente por el programador, es imposible utilizar sentencias como if...else. Este tipo de estructuras deben ser sustituidas por operaciones dentro del grafo, como por ejemplo tf.cond. Todo esto hacía que el desarrollo de redes convolucionales fuese menos intuitivo; cuando el programador escribía conv2D, no estaba haciendo que el programa ejecutase una convolución, sino que estaba haciendo que se añadiese una operación de convolución a un grafo de computación. Además, ese grafo de computación se ejecutaría más tarde, en diferido, sin que el programador pueda usar puntos de interrupción para depurar problemas.

Veamos un ejemplo de cómo funciona TensorFlow en este caso. El primer paso es crear un nodo del grafo computacional por el que se introducirán los datos a la red. En este caso utilizaremos una imagen de 512 filas, 512 columnas y 3 canales (eg: RGB). Para ello, TensorFlow utiliza la notación (Filas, Columnas, Canales), es decir, una matriz que represente una imagen RGB, de 512 filas y 512 columnas, tendrá la forma (1,512,512,3):

import tensorflow as tf
 
entradas = tf.placeholder(tf.float32, (None, 512, 512, 3), name='entradas')

Esto permite comunicarnos con el grafo computacional que crearemos a continuación, estableciendo los datos sobre los que actuará. Una vez establecido el nodo de entrada al grafo de computación, el siguiente paso es añadir más nodos. En este ejemplo ejecutaremos una convolución utilizando 32 filtros de 3x3:

with tf.variable_scope('conv'):
   salida = tf.layers.conv2d(entradas, 32, [3, 3], name='conv')

De forma automática, la función conv2d crea las variables necesarias para guardar los filtros de convolución, y añade una operación de convolución sobre entradas, devolviendo una referencia, salida, al nodo que acabamos de introducir en el grafo.

Además variable_scope crea una caja llamada conv, donde se meterán todas las variables de la operación que acabamos de crear. Esto permite crear nuevas operaciones que reutilicen variables ya creadas:

entradas_1 = tf.placeholder(tf.float32, (None, 512, 512, 3), name='entradas_1')
with tf.variable_scope('conv', reuse=True):
   salida_1 = tf.layers.conv2d(entradas_1, 32, [3, 3], name='conv')

De esta manera, salida_1 ejecutará la misma operación que salida, pero sobre una entrada diferente, pudiendo cambiar así ciertos parámetros para procesar los datos de entrenamiento, validación, y test.

Después, creamos los datos sobre los que queremos aplicar la convolución:

import numpy as np
 
datos = np.ones((1,512,512,3))

Y creamos la sesión sobre la que ejecutaremos el grafo computacional. En primer lugar se crea la sesión, y después se inicializan las variables que utilizaremos.

sesion = tf.Session()
iniciar = tf.global_variables_initializer()
sesion.run(iniciar)

Finalmente ya podemos ejecutar el grafo computacional sobre los datos de entrada:

resultado = sesion.run(salida, feed_dict={entradas: datos})

La función sesion.run ejecuta el grafo hasta el nodo que pasamos como argumento salida, y utilizando las entradas que establecemos en feed_dict. Estas entradas se pasan como un diccionario en el que cada clave es un placeholder definido anteriormente, y su respectivo valor es una matriz NumPy sobre la que operar.

Si este proceso parece tedioso, las versiones más antiguas de TensorFlow lo eran más todavía. Esto se debe a que requerían la creación manual de variables, lo que convertía la instrucción conv2d, en una secuencia de instrucciones que incluyen el manejo explícito de los operandos.

PyTorch

A medida que TensorFlow se fue popularizando, otras empresas como Facebook desarrollaron su propia solución de código abierto que competiría con TensorFlow. Una de las librerías más populares, superando a TensorFlow en número de publicaciones científicas, es PyTorch.

Artículos que usan TensorFlow y PyTorch Artículos que usan TensorFlow y PyTorch. Fuente: https://chillee.github.io/pytorch-vs-tensorflow/

Uno de los motivos principales para la adopción de PyTorch es la simplicidad a la hora de desarrollar código. En PyTorch no existen sesiones, ni hay un grafo que se ejecuta en diferido. La manera de programar en PyTorch se asemeja más a la programación normal de Python, tal y como veremos en el siguiente ejemplo.

Primero importamos las librerías y creamos los datos. Hay que tener en cuenta que PyTorch usa la notación (Canales, Filas, Columnas), así que debemos utilizar la tupla (1, 3, 512, 512) para representar una imagen, de tres canales, con 512 filas, y 512 columnas:

import numpy as np
import torch
import torch.nn as nn
imagen = np.random.rand(1,3,512,512).astype(np.float32)

A continuación convertimos la imagen a un tensor de PyTorch:

imagen = torch.from_numpy(imagen)

Después creamos la capa convolucional:

capa_convolucional = nn.Conv2d(3,16,3)

Y finalmente ejecutamos la convolución:

resultado = capa_convolucional(imagen)

Se puede ver a simple vista que el código es mucho más fácil de entender, no debemos utilizar sesiones, ni placeholders, ni ejecutar en diferido. A pesar de ello, PyTorch sufre una serie de claras desventajas con respecto a TensorFlow.

Desde el nacimiento de TensorFlow, siempre se le ha dado mucha importancia al uso de modelos en producción. Esto ha hecho que se posibilite el uso de TensorFlow en diferentes lenguajes de programación, se puedan ejecutar modelos en diferentes arquitecturas hardware, y se puedan desplegar nuevas versiones de nuestros modelos de forma transparente. Esto ha llevado a muchas empresas a elegir TensorFlow, por delante de PyTorch, como su framework de deep learning.

Las diferentes APIs de TensorFlow

Volviendo ahora a TensorFlow, otra molestia para los desarrolladores ha sido la introducción de varias formas de interactuar con la librería, desde el uso de tf.layers como en el ejemplo anterior, hasta la incorporación de Keras como una parte dentro de TensorFlow. Veamos algunos ejemplos.

Slim

TensorFlow Slim fue una de las primeras simplificaciones que se introdujeron en TensorFlow. En un primer momento, TensorFlow requería que el usuario crease, de manera manual, tanto las variables del modelo, como las operaciones. Además, era necesario definir, también manualmente, muchas operaciones repetitivas como por ejemplo las funciones de activación. TensorFlow Slim automatiza este proceso, ya que la API de creación de operaciones se encarga automáticamente de crear las variables necesarias, y todas las operaciones repetitivas, dentro de un variable_scope controlado.

Así, mientras que con la API tradicional de Tensorflow deberíamos realizar convoluciones de esta manera:

import tensorflow as tf
import numpy as np
datos = np.ones((1, 512, 512, 3))
entrada = tf.placeholder(tf.float32, (None, 512, 512, 3))
filtro = tf.Variable(tf.random_normal([3, 3, 3, 16]))
sesgo = tf.Variable(tf.constant(0.0, shape=[16], dtype=tf.float32))
entrada_filtrada = tf.nn.conv2d(entrada, filtro, strides=[1,1,1,1], padding='SAME')
entrada_filtrada = tf.nn.bias_add(entrada_filtrada, sesgo)
resultado = tf.nn.relu(entrada_filtrada)
sess = tf.Session()
init = tf.global_variables_initializer()
sess.run(init)
salida = sess.run(resultado, feed_dict={entrada: datos})

Con TensorFlow Slim se simplificaría:

import tensorflow as tf
import tensorflow.contrib.slim as slim
import numpy as np
datos = np.ones((1, 512, 512, 3))
entrada = tf.placeholder(tf.float32, (None, 512, 512, 3))
# La siguiente linea incluye el bias y la creacion de variables 
resultado = slim.conv2d(entrada, 16, [3, 3])
sess = tf.Session()
init = tf.global_variables_initializer()
sess.run(init)
salida = sess.run(resultado, feed_dict={entrada: datos})

Esta forma de trabajar con TensorFlow llegó a tener cierto éxito y algunos modelos populares como DeepLab siguen utilizándola. Sin embargo, su uso se vio limitado ya que nunca llegó a formar parte del núcleo de TensorFlow, viviendo siempre en el apartado tf.contrib, y eliminándose con la llegada de TensorFlow 2.0.

Layers

Prácticamente en paralelo al desarrollo de TensorFlow Slim, se creó TensorFlow Layers. Este módulo intenta facilitar el desarrollo de deep learning tal y como lo hace Slim, empaquetando funcionalidad redundante como la creación de ciertas variables, y operaciones. Además, este módulo tuvo mejor suerte que Slim, ya que desde el principio se consideró como un módulo de primer nivel. Esto también supuso una mejor documentación y un mantenimiento de mayor calidad.

Siguiendo con el ejemplo anterior, esta sería la manera de implementar una red con una única convolucion utilizando TensorFlow Layers:

import tensorflow as tf
import numpy as np
datos = np.ones((1, 512, 512, 3))
entrada = tf.placeholder(tf.float32, (None, 512, 512, 3))
resultado = tf.layers.conv2d(entrada, 16, [3, 3], activation=tf.nn.relu)
sess = tf.Session()
init = tf.global_variables_initializer()
sess.run(init)
salida = sess.run(resultado, feed_dict={entrada: datos})

Podemos comprobar que la API de Layers se parece bastante a la de Slim. En concreto, la única diferencia en el ejemplo utilizado es que Slim utiliza la función de activación ReLU por defecto, mientras que Layers requiere que se le pase como parámetro.

Keras

Además de APIs para la creación de modelos, también se han diseñado interfaces como TensorFlow Estimator, o TensorFlow Keras para simplificar tareas comunes como entrenamiento y validación de modelos, predicción de datos, o distribución de arquitecturas. En concreto, Keras comenzó siendo una librería independiente que encapsulaba a TensorFlow, Theano, o CNTK. Sin embargo, debido en parte a su gran popularidad, terminó integrándose dentro de TensorFlow. Una vez integrada, Keras conservaría prácticamente la misma funcionalidad, pero facilitando más la interacción con el resto de funcionalidad de TensorFlow.

Debido a que Keras es una API de más alto nivel que Slim o Layers, también encapsula el manejo de la sesión, el grafo computacional, y la entrada y salida de datos. De esta manera, una red se crearía de la siguiente manera:

from tensorflow.keras import Sequential
from tensorflow.keras.layers import Conv2D
 
modelo = Sequential()
modelo.add(Conv2D(16, 3, input_shape=(512,512,3)))
modelo.compile(optimizer='Adam', loss='categorical_crossentropy')

Y una vez tengamos la red entrenada, podemos analizar una imagen de la siguiente manera:

import numpy as np
 
datos = np.ones((1, 512, 512, 3))
salida = modelo.predict(datos)

Además, Keras también permite el uso de una API funcional, en la que se define de manera explícita la entrada de cada operación, permitiendo arquitecturas más complejas:

from tensorflow.keras import Model, Input
from tensorflow.keras.layers import Conv2D
 
capa_entrada = Input(shape=(512,512,3))
capa_salida = Conv2D(16, 3)(capa_entrada)
modelo = Model(inputs=capa_entrada, outputs=capa_salida)
modelo.compile(optimizer='Adam', loss='categorical_crossentropy')

Sin embargo, pese a que el uso de Keras facilita el desarrollo de deep learning, esta API sigue teniendo ciertos inconvenientes. Pese a que la definición de la red se vuelve más sencilla, el modelo se sigue ejecutando en diferido, lo que nos dificulta el diseño de modelos dinámicos, o la interrupción de la ejecución e inspección de las variables. Estos problemas se resolverán en TensorFlow 2.0 con la ejecución eager.

Ejecución eager en TensorFlow

Una de las novedades más importantes de TensorFlow 2.0 es el cambio en el paradigma de ejecución de modelos. Si bien hasta ahora los modelos de ejecutaban en diferido, cediendo el control del modelo a la sesión de TensorFlow, ahora pasarán a funcionar de una manera más imperativa. Esto supone una serie de ventajas importantes como por ejemplo poder interrumpir la ejecución de un modelo, y la posibilidad de introducir estructuras de control de flujo como if...else.

De esta manera, también podemos crear un modelo con la API funcional de Keras para definir operaciones. Sin embargo, a continuación podremos aplicar esas operaciones directamente, sin utilizar el método predict de Keras.

Primero creamos una capa convolucional:

from tensorflow.keras.layers import Conv2D
 
capa_convolucional = Conv2D(16, 3)

Y a continuación podemos aplicarla directamente sobre matrices de NumPy:

import numpy as np
 
datos = np.ones((1, 512, 512, 3))
salida = capa_convolucional(datos)

Si tenemos una red con varias capas, basta con definirlas de antemano y crear una función para aplicar la red:

from tensorflow.keras.layers import Conv2D
 
capa_convolucional_0 = Conv2D(16, 3)
capa_convolucional_1 = Conv2D(16, 3)
 
def red(entrada):
   salida = capa_convolucional_0(entrada)
   salida = capa_convolucional_1(salida)
   return salida

Una vez definida la arquitectura, podemos utilizarla como una función normal:

import numpy as np
 
datos = np.ones((1, 512, 512, 3))
salida = red(datos)

Ya no hace falta compilar un modelo, ni utilizar el método predict para aplicar la red. Además, esto posibilita comprobar los valores internos de la red para depurar nuestro código o para crear modelos dinámicos.

Aquí vemos un ejemplo de una red en el que la segunda capa solo se ejecuta si la norma l2 del valor intermedio es inferior a 1000:

from tensorflow.keras.layers import Conv2D
 
capa_convolucional_0 = Conv2D(16, 3)
capa_convolucional_1 = Conv2D(16, 3)
 
def red(entrada):
   salida = capa_convolucional_0(entrada)
   norma_l2 = np.sqrt(np.sum(np.square(salida)))
   if norma_l2 < 1000:
       salida = capa_convolucional_1(salida)
   return salida

El código resulta mucho más sencillo, y se asemeja más a la forma tradicional de programar en Python.

Conclusión

En este post hemos visto las diferentes maneras de definir y ejecutar redes en TensorFlow. Si bien el desarrollo de deep learning comenzó siendo tedioso, actualmente TensorFlow incorpora módulos como Keras, y técnicas como la ejecución eager para facilitar la vida de los desarrolladores. La elección de librerías para el desarrollo de redes convolucionales es cada vez tarea más compleja, los principales competidores como TensorFlow y PyTorch continúan incorporando características atractivas para los investigadores y la industria 4.0.