Redes multicapa y ajuste de hiperparámetros¶
En este documento profundizaremos más en el desarrollo de redes profundas, entendiendo este concepto como redes con varias capas (ocultas). El hecho de añadir capas ocultas, como veremos, permitirá definir más especificidad y jerarquización en la toma de decisiones. También aprovecharemos este documento para profundizar más en algunas cosas que dejamos en el tintero en documentos anteriores, como el ajuste de parámetros relevantes en las redes, y la visualización gráfica del proceso de aprendizaje.
1. Caracteristicas de los MLPs¶
La idea de perceptrón multicapa (MLP, Multi-Layer Perceptron) fue concebida por Rumelhart, Hinton y Williams en 1986. En aquel entonces la capacidad computacional de los ordenadores era muy reducida, por lo que las posibilidades prácticas de construir redes complejas que entrenaran y resolvieran problemas en un tiempo razonable era muy limitada. Varias décadas después, con el avance de los dispositivos hardware (GPUs, TPUs, procesamiento paralelo, capacidad de almacenamiento...) sí estamos en disposición de desarrollar este tipo de redes con garantías.
Como su nombre indica, un perceptrón multicapa es una red neuronal compuesta de varias capas. En ejemplos anteriores hemos llegado a construir redes con una capa de entrada, una capa oculta y una de salida. Esto ya puede considerarse un MLP, pero en realidad podemos extender las capas ocultas todo lo que queramos y necesitemos. Veremos a continuación qué implica tener más capas ocultas, cómo determinar la cantidad de capas ocultas necesarias y qué otros factores podemos y debemos tener en cuenta al aumentar la complejidad de nuestras redes.
1.1. ¿Qué son los tensores?¶
En algunos apartados de apuntes previos se ha mencionado el concepto de tensor, sin hacer demasiado hincapié en qué significa exactamente. De hecho, la propia librería que usamos se llama TensorFlow, y algunos componentes software como las TPU (Tensor Processing Unit) hacen referencia a este concepto. Así que conviene saber a qué nos estamos refiriendo exactamente.
Un tensor es un elemento matemático que almacena un conjunto de valores numéricos. Puede ser unidimensional (vector), bidimensional (matriz) o de más dimensiones. Estos tensores en Python se almacenan en forma de arrays NumPy, normalmente.
Así, por ejemplo, si queremos analizar un conjunto de tuits de 1.000 usuarios, cada uno con hasta 280 caracteres, podemos usar un tensor 2D de tamaño (1000, 280). Si queremos procesar una imagen RGB de 640 x 480 píxeles, necesitaremos un tensor 3D (640, 480, 3), siendo 3 el número de colores diferentes en cada píxel. Para una secuencia de 1.000 imágenes de este tipo necesitaremos un tensor 4D (1.000, 640, 480, 3).
Así, la idea tras TensorFlow es hacer pasar estos tensores por la red y hacer cálculos matemáticos con los datos que almacenan, para obtener un resultado (multiplicando los valores de entrada con los pesos de las conexiones de cada capa). Y la funcionalidad principal de las TPUs es aumentar la eficiencia en el procesamiento matemático de este conjunto de datos, aplicado fundamentalmente al terreno del machine/deep learning.
2. Definiendo un MLP. Opciones relevantes¶
Vamos a definir un perceptrón multicapa para el siguiente problema: disponemos de este archivo CSV sobre datos de distintos pacientes (aquí podemos consultar el original en Kaggle). En base a distintos parámetros (número de embarazos, nivel de glucosa en sangre, presión sanguínea, etc) se intenta predecir si esa persona tiene o no diabetes (última columna Outcome). Vamos a definir una red neuronal multicapa para calcular esa predicción.
Comenzamos leyendo los datos y procesándolos: en este caso los valores de X corresponden a todas las columnas menos la última, y la columna y es esa última columna de Outcome. Además, escalamos los valores de entrada usando el StandardScaler
de SKLearn que ya hemos utilizado en otros ejemplos previos:
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from keras.models import Sequential
from keras.layers import Dense
# Preparación de datos
datos = pd.read_csv('diabetes.csv')
scaler_X = StandardScaler()
X = datos.iloc[:, :-1]
X = scaler_X.fit_transform(X)
y = datos.iloc[:, -1]
En este caso, construiremos la red de una forma diferente, aunque equivalente a documentos anteriores. Usaremos el constructor de la clase Sequential
para pasarle como parámetro la lista de capas que queremos añadir. Construiremos un MLP con dos capas ocultas de muchas neuronas, y una capa de salida de 1 neurona con la predicción (activación sigmoidea):
modelo = Sequential([
Input(shape=(X.shape[1],)),
Dense(800, activation='relu'),
Dense(300, activation='relu'),
Dense(1, activation='sigmoid')
])
Si echamos un vistazo al resumen del modelo con su método summary
, obtendremos algo así:
________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
dense (Dense) (None, 800) 7200
dense_1 (Dense) (None, 300) 240300
dense_2 (Dense) (None, 1) 301
=================================================================
Total params: 247,801
Trainable params: 247,801
Non-trainable params: 0
_________________________________________________________________
Nos podemos hacer una idea de la cantidad de parámetros (pesos y biases) que se pueden configurar en una red de este tamaño.
Finalmente, vamos a compilar y entrenar nuestra red. Usaremos como función de pérdida la entropía cruzada binaria, como hemos hecho en otros problemas previos de clasificación binaria, y optimizador sgd.
modelo.compile(loss='binary_crossentropy', optimizer='sgd', metrics=['accuracy'])
datos_entrenamiento = modelo.fit(X, y, validation_split=0.2, epochs=200)
En base a los resultados que obtendremos, vamos a analizar a continuación algunos aspectos relevantes y proponer mejoras.
2.1. Reproducibilidad de los resultados¶
Cuando estamos desarrollando un modelo estocástico como es una red neuronal puede resultar interesante poder reproducir siempre los mismos resultados, para ver cómo afectan las mejoras que añadimos a dicho modelo. Esto puede conseguirse estableciendo de entrada una semilla fija (seed) para generar los números aleatorios. Lo haremos utilizando la función set_random_seed
de keras.utils
:
from keras.utils import set_random_seed
...
# Esto debe ir al principio del código
set_random_seed(0)
El número que pongamos como semilla es irrelevante, puede ser cualquiera. Adicionalmente (dependiendo de la versión de Keras/TensorFlow que estemos utilizando) puede ser necesario compaginar esta instrucción con la instrucción seed
de NumPy, que se utiliza internamente para generar vectores con datos aleatorios:
from numpy.random import seed
from keras.utils import set_random_seed
...
seed(0)
set_random_seed(0)
Nuevamente, los números elegidos como semilla pueden ser otros, y también distintos entre sí. En principio, únicamente con set_random_seed
nos puede ser suficiente para reproducir resultados en versiones recientes de la librería, pero conviene tener presente esta última alternativa por si nos es necesaria.
2.2. Gráficas de seguimiento¶
Una de las opciones que hemos comentado de pasada en documentos anteriores, y que hasta ahora no hemos utilizado, es la posibilidad de usar los datos que genera la función de entrenamiento fit
. En concreto, proporciona una secuencia de valores de los parámetros de entrenamiento que hemos definido, tales como la pérdida loss o la precisión accuracy. De este modo, podemos mostrar en un gráfico la evolución de estos datos.
Además, en el caso anterior, hemos empleado la propia función fit
pasándole todo el conjunto X de datos, y la propia función se ha encargado de separar en entrenamiento y test, usando el parámetro validation_split
para determinar el porcentaje que va a cada parte (20% para validación, en el ejemplo anterior). Esto es particularmente útil para gestionar estos gráficos, ya que en los datos del entrenamiento tendremos la evolución del coste y la precisión tanto para el entrenamiento como para el test.
datos_entrenamiento = modelo.fit(X, y, validation_split=0.2, epochs=200)
Así, al finalizar el proceso de entrenamiento podemos mostrar una gráfica combinada de cómo ha evolucionado la función de pérdida en ambos conjuntos (entrenamiento y test), y también podemos hacer lo mismo con la precisión. De hecho, podemos mostrar todos los parámetros (keys) que se almacenan en los datos_entrenamiento
, para saber cuáles podemos visualizar:
print(datos_entrenamiento.history.keys())
# Gráfica combinada de pérdida en entrenamiento y test (val)
plt.plot(datos_entrenamiento.history['loss'])
plt.plot(datos_entrenamiento.history['val_loss'])
plt.title('Evolución del coste')
plt.ylabel('Coste')
plt.xlabel('Epoch')
plt.legend(['Entrenamiento', 'Test'], loc = 'upper left')
plt.show()
2.3. El overfitting¶
El overfitting es un fenómeno que puede darse en cualquier sistema de machine learning, aunque es más habitual en redes profundas, y consiste en un sobre-entrenamiento, en el cual la red se ha dedicado a memorizar el conjunto de datos de entrenamiento, pero no es capaz de inferir nuevos valores fuera de ese conjunto adecuadamente.
Es lo que ocurre con la red del ejemplo anterior. Como puede verse en la gráfica del coste o loss, hay un punto donde el coste en el conjunto de test deja de converger y decrecer, y se dispara. En ese punto es donde la red ha dejado de aprender cosas nuevas, y se ha centrado en memorizar el conjunto de entrenamiento.
¿Qué alternativas hay para solucionar este problema? Existen varias, vamos a mencionar algunas de ellas, teniendo en cuenta que no tenemos por qué limitarnos a una, y podemos escoger una combinación de varias.
Simplificar el modelo de entrada
Reducir el número de neuronas en las capas ocultas puede favorecer que la red no tenga nodos suficientes para memorizar todas las posibles combinaciones de valores de entrada y se "vea obligada" a generalizar resultados. En el ejemplo anterior, si limitamos el tamaño de las capas ocultas a 8 neuronas, por ejemplo, obtendremos unos resultados mejores para el conjunto de validación:
modelo = Sequential([
Input(shape=(X.shape[1],)),
Dense(8, activation='relu'),
Dense(8, activation='relu'),
Dense(1, activation='sigmoid')
])
Early stopping
La técnica del early stopping consiste en detener el entrenamiento en cuanto se detecte el overfitting. En la gráfica anterior podemos ver que eso ocurre en torno a la epoch 40-50. Vamos a volver al modelo original de la red, con muchas neuronas, y usaremos la clase EarlyStopping
de Keras para definir cuándo queremos parar:
from keras.callbacks import EarlyStopping
...
early_stop = EarlyStopping(monitor='val_loss', patience=5, \
restore_best_weights = True)
datos_entrenamiento = modelo.fit(X, y, validation_split=0.2, epochs=200, \
callbacks=[early_stop])
En los parámetros del constructor indicamos:
- Qué variable queremos monitorizar, en el parámetro
monitor
. En este caso queremos monitorizar el coste en el conjunto de validación (val_loss) - El parámetro
patience
indica cuántas iteraciones vamos a admitir sin que el valor decrezca. En nuestro caso, si después de 5 iteraciones el valor sigue subiendo, detendremos el entrenamiento - El parámetro
restore_best_weights
indica que, al detener el entrenamiento, recuperaremos los pesos de la mejor solución obtenida hasta ese momento (hace 5 iteraciones, en nuestro caso). - Finalmente, observa que usamos este elemento (variable
early_stop
) como callback de la función de entrenamiento (parámetrocallbacks
de la funciónfit
).
Al ejecutar el modelo original con esta nueva opción, vemos que el resultado se detiene en la iteración 52 aproximadamente, después de 5 iteraciones sin que la curva de test decrezca:
Capas dropout
Las técnica de dropout permite separar o desconectar neuronas arbitrariamente en cada etapa del entrenamiento, evitando así que se memoricen pesos y rutas predeterminadas para unos datos de entrada concretos. Lo analizaremos con más detalle a continuación
2.4. Las capas de Dropout¶
La técnica de Dropout fue propuesta por primera vez en 2012 por G. Hinton, uno de los padres del perceptrón multicapa. Esta técnica permite desconectar aleatoriamente neuronas de una capa en cada etapa del entrenamiento, evitando así que los pesos se acomoden a memorizar un conjunto fijado de datos.
TensorFlow y Keras permiten utilizar un tipo especial de capa llamada Dropout
, dentro del paquete keras.layers
. Esta capa permite especificar un porcentaje de desconexiones con respecto a la capa previa. ¿Qué significa esto exactamente? Como ya sabemos, cada vez que añadimos una capa Dense
en una red neuronal, ésta conecta automáticamente todas sus neuronas con todas las neuronas de la capa anterior. Si añadimos en medio una capa Dropout
con una tasa de desconexión de 0.2, por ejemplo, esto significa que el 20% de las neuronas de la capa previa se van a desconectar en cada epoch, de forma aleatoria. Esto facilitará que la red no se "acostumbre" a usar unos pesos fijos para memorizar los datos de entrenamiento, y se vea obligada a buscar otras alternativas de combinación de pesos para obtener buenos resultados.
Aquí vemos un ejemplo sencillo, en el que añadimos una capa Dropout
que desconecta el 20% de las neuronas de la capa de entrada en sus conexiones con la primera capa oculta:
from keras.models import Sequential
from keras.layers import Dense, Dropout
modelo = Sequential()
# Suponemos que hay 100 datos de entrada
modelo.add(Dropout(0.2, input_dim=100))
model.add(Dense(60, activation='relu'))
Veamos qué utilidad tiene esto sobre el ejemplo anterior, donde había overtfitting. Aplicaremos una capa de dropout entre la primera y la segunda capa oculta, y otra entre la capa oculta y la de salida. En cada una haremos que se desconecten el 20% de las neuronas.
from keras.layers import Dense, Dropout, Input
...
modelo = Sequential([
Input(shape=(X.shape[1],)),
Dense(8, activation='relu'),
Dropout(0.2),
Dense(8, activation='relu'),
Dropout(0.2),
Dense(1, activation='sigmoid')
])
Con esto se mejoran los resultados de la función de coste para el conjunto de validación, ajustándose mejor a los esperados por el entrenamiento. A cambio, vemos que el coste en el entrenamiento da unos pequeños saltos en su evolución, fruto de las desconexiones que se sufren en cada etapa.
Hay que tener en cuenta que las desconexiones sólo se producen durante la etapa de entrenamiento, no en la validación y posterior inferencia.
Dónde y cómo aplicar dropout
¿Dónde se puede aplicar una capa de dropout? Existen varias teorías al respecto, y todo dependerá de la red en sí. A veces es conveniente aplicarla a la entrada, como en el ejemplo que hemos visto antes, y en otras ocasiones se aconseja dejarlo para los últimos niveles de capas ocultas.
La segunda pregunta que podemos hacernos es... ¿qué porcentaje de dropout es recomendable?. Normalmente suele ser un porcentaje bajo, entorno al 10%-30%. Hay que tener en cuenta que:
- Un porcentaje excesivamente alto puede provocar que la red no llegue a aprender, porque estaremos desconectando constantemente muchas neuronas en cada capa
- Un porcentaje demasiado bajo puede hacer que no se note el efecto, y no se reduzca significativamente el overfitting, al haber demasiadas conexiones permanentes.
Aquí puedes descargar el código fuente de las distintas versiones que hemos probado del ejemplo sobre la diabetes:
- v1: modelo simple con dos capas densas numerosas, para ver el overfitting en la gráfica
- v2: modelo con capas densas más simplificadas para reducir el overfitting
- v3: modelo basado en v1, donde se aplica early stopping cuando se detecta overfitting
- v4: modelo basado en v2 añadiendo capas Dropout para reducir el overfitting.
2.5. Ejemplo¶
Vamos a plantear un ejemplo diferente al que hemos visto anteriormente. Utilizaremos este dataset sobre datos de venta de coches. En el CSV existen diferentes columnas, que pasamos a explicar a continuación:
- price es el precio de venta del coche, catalogado en 4 categorías desde vhigh (muy alto) hasta low (bajo)
- maint es el precio de mantenimiento del coche, con las mismas 4 categorías anteriores
- doors es el número de puertas, categorizado en 2, 3, 4 o 5more (5 o más)
- capacity es la capacidad de personas que tiene el vehículo, categorizado como 2, 4 o más (more)
- luggage es el tamaño del maletero, categorizado como small, med o big
- safety es el nivel de seguridad del vehículo, categorizado como low, med o high
- result es nuestra columna objetivo, que indica el grado de aceptación del vehículo con los datos anteriores. Puede tomar los valores vgood (muy buena), good (buena), acc (aceptable) o unacc (inaceptable).
Como vemos, son todo datos categóricos, así que los vamos a reemplazar por códigos numéricos alternativos. En todos los casos nos interesa que haya un orden en esos valores, ya que, por ejemplo, un precio de mantenimiento high debe ser mayor que uno low. Aplicaremos, por tanto, las siguientes transformaciones:
datos = pd.read_csv('datos_coches.csv')
datos['price'] = datos['price'].map({'vhigh': 3, 'high': 2, 'med': 1, 'low': 0})
datos['maint'] = datos['maint'].map({'vhigh': 3, 'high': 2, 'med': 1, 'low': 0})
datos['doors'] = datos['doors'].map({'2': 2, '3': 3, '4': 4, '5more': 5})
datos['capacity'] = datos['capacity'].map({'2': 2, '4': 4, 'more': 5})
datos['luggage'] = datos['luggage'].map({'big': 2, 'med': 1, 'small': 0})
datos['safety'] = datos['safety'].map({'high': 2, 'med': 1, 'low': 0})
Vamos ahora a separar las columnas independientes X y la dependiente y. En esta última, aplicaremos una codificación one hot para categorizar en 4 columnas diferentes las 4 valoraciones distintas que se hacen:
X = datos.iloc[:, :-1]
y = datos.iloc[:, -1]
num_clases = len(y.unique())
y = pd.get_dummies(datos['result'], columns=['result'])
Ejercicio 1
A partir del pre-procesamiento de datos anterior, crea una red multicapa con las siguientes características:
- Primera capa oculta de 30 neuronas, activación relu y tamaño de entrada (input_dim) igual al número de entradas
- Segunda capa oculta de 30 neuronas, activación relu
- Capa de salida de tantas neuronas como posibles resultados haya, y activación softmax
Compila la red con función de pérdida categorical_crossentropy, optimizador adam y añadiendo la métrica de la precisión (accuracy). Entrénala durante 100 epochs con una porcentaje de validación del 20%. Finalmente, muestra el gráfico de evolución de la función de coste para entrenamiento y test (loss y val_loss)
Ejercicio 2
Verás que el modelo anterior tiene overfitting. Aplica las medidas que consideres oportunas para lograr un mejor ajuste del coste y la precisión en el conjunto de test. Por ejemplo, puedes añadir capas de Dropout con una tasa de 20% o 30%, o intentar re-ajustar el diseño de la red.
2.6. Guardando nuestro modelo¶
Ya hemos podido comprobar que entrenar un modelo, aunque las máquinas actuales sean más potentes que las de hace unas décadas, puede llevar un tiempo considerable. Si tenemos que pasar por el mismo proceso cada vez que queramos probar nuestra red con nuevos datos de entrada, estaremos perdiendo un tiempo precioso.
Afortunadamente, TensorFlow/Keras permite guardar un modelo entrenado para poderlo utilizar cuando necesitemos. Esto puede hacerse de formas diferentes:
- Guardando el modelo completo, a través del método
save
del modelo en cuestión. El formato de salida es un formato propio de Keras llamado HDF5 (extensión de archivo .h5), aunque también podemos especificar el formato .keras, más reciente. Luego podemos recuperar el modelo de nuevo conload_model
del paquetekeras.models
:
modelo.save('mi_modelo.h5');
# También sirve modelo.save('mi_modelo.keras')
...
otro_modelo = keras.models.load_model('mi_modelo.h5')
# Podemos directamente evaluar o hacer predicciones
y_pred = otro_modelo.predict(X_test)
- También podemos guardar los pesos de las conexiones. Esto puede resultar interesante si hemos encontrado una configuración que da resultados aceptables, pero queremos explorar otras posibilidades. De este modo, guardamos los pesos del modelo, y podemos recuperarlos y restablecerlos después. Usaremos el método
save_weights
del modelo para guardar (en formato HDF5 o en el propio formato de TensorFlow, extensión .tf). Luego usamosload_weights
para cargarlos. En el caso de no quererlos guardar a archivo, porque sea algo temporal durante la ejecución, podemos usar los métodosget_weights
yset_weights
:
modelo.save_weights('pesos_mi_modelo.h5')
...
modelo.load_weights('pesos_mi_modelo.h5')
# Alternativamente...
pesos = modelo.get_weights()
...
modelo.set_weights(pesos)
Ejercicio 3
Guarda el modelo que mejor hayas ajustado del ejercicio anterior en un archivo llamado estimacion_coches.h5. Cárgalo en un programa que le pedirá al usuario que introduzca los datos de entrada de un vehículo:
- Precio de venta: 3 (vhigh), 2 (high), 1 (med), 0 (low)
- Precio de mantenimiento (mismos valores que el caso anterior)
- Número de puertas: de 2 a 5
- Capacidad: 2, 4 o 5
- Tamaño del maletero: 2 (big), 1 (med), 0 (small)
- Seguridad del vehículo: 2 (high), 1 (med), 0 (low)
El programa debe emitir una salida con su veredicto, indicando cómo de probable es cada una de las 4 posibles categorías (vgood, good, acc, unacc).
3. Ajuste de hiperparámetros¶
En un modelo de machine o deep learning existen una serie de parámetros (normalmente los datos de entrada que proporcionamos al modelo, y que no son configurables) y de hiperparámetros. Estos últimos son elementos inherentes al modelo que sí se pueden configurar. En el caso de las redes neuronales, podríamos hablar de la configuración de la red (número de capas), optimizadores, funciones de activación, etc.
Ahora que ya tenemos unas nociones un poco más extensas de lo que supone trabajar con una red neuronal, hagamos un resumen de los hiperparámetros que tenemos que considerar a la hora de ajustar lo mejor posible el funcionamiento de dicha red:
- El número de capas ocultas necesarias, y el tamaño (número de neuronas) de las mismas
- Función de activación más adecuada en cada capa
- Función de coste (loss) elegida al compilar el modelo
- Optimizador elegido (también al compilar), junto con su learning rate
- Tamaños de los conjuntos de entrenamiento y test
- Número de iteraciones (epochs) recomendables, y tamaño del batch
- Necesidad de capas de dropout, y dónde ubicarlas
3.1. Capas ocultas y funciones de activación¶
¿Qué beneficios aporta añadir más capas ocultas a una red neuronal? ¿Y cuántas neuronas debería tener cada capa? Son preguntas sin una respuesta fija o concreta, pero hay algunas cuestiones que debemos tener en cuenta para ayudarnos a tomar una decisión.
Respecto al número de capas ocultas recomendable, debemos tener en cuenta que, en problemas que sean linealmente separables, no es necesaria ninguna capa oculta. Podemos conectar todas las entradas a la(s) neurona(s) de salida para obtener un resultado, que será una combinación lineal de las entradas multiplicadas por los pesos.
Sin embargo, en la gran mayoría de los casos, los datos de entrada son interdependientes, y afectan a la salida de una forma más compleja. Añadir capas ocultas nos permite detectar estas interdependencias entre parámetros de entrada. Así, cada neurona de una capa oculta puede aprender una característica diferente sobre los datos de entrada y, al añadir más capas, permitimos definir distintos niveles de abstracción sobre los datos de entrada, capturando primero rasgos más generales para luego, en otras capas, detectar elementos más específicos.
En cuanto al número de neuronas en cada capa, también es un parámetro que dependerá del problema en cuestión. Una capa con pocas neuronas puede caer en el underfitting, es decir, no llegar nunca a realizar estimaciones acertadas, ni en el conjunto de entrenamiento ni en el de test. Por otra parte, un número excesivo de neuronas puede llevar al caso opuesto, como hemos visto: el overfitting. Tener tanta cantidad de conexiones disponibles puede permitir a la red memorizar un camino distinto para cada posible valor de entrada de los datos de entrenamiento, y así memorizarlos todos. Por lo tanto, es conveniente definir un número adecuado. Como hemos comentado con anterioridad, existen varias teorías al respecto:
- Un tamaño entre el tamaño de entrada y el de salida
- La media de las dos capas (entrada y salida)
- 2/3 de la capa de entrada más la de salida
- Un tamaño inferior al doble de la capa de entrada
- ...
Se pueden probar varias de estas posibilidades y quedarnos con la que mejor resultado ofrezca.
Además de establecer el número adecuado de capas ocultas y tamaño de las mismas, es conveniente elegir una función de activación apropiada en cada capa. Si utilizamos funciones de activación no lineales en diferentes capas, aumentamos la flexibilidad de las mismas. En general, podemos afirmar que cualquier red neuronal de 2 capas ocultas con una función de activación no lineal (incluyendo ReLU), y con suficientes neuronas en las capas ocultas, es capaz de expresar cualquier mapeo de entrada a salida. En este sentido, recuerda que:
- La función de activación ReLU nos puede servir para provocar un efecto de "desactivación" en las neuronas. Aquellas que no lleguen a un valor apropiado no emitirán una salida a la capa siguiente. Esto puede ser útil para que, dependiendo de los valores de entrada, se transmitan unos datos u otros a las capas siguientes. Es la función de activación más utilizada en las capas ocultas.
- Las funciones de activación sigmoide y tangente hiperbólica nos servirán para acotar los valores de salida entre 0 y 1 o entre -1 y 1, respectivamente. No son habituales en las capas ocultas, y suelen emplearse más en las capas de salida.
- La función de activación softmax define una distribución probabilística, es decir, la probabilidad de que el elemento de la entrada pertenezca a una de las categorías definidas en la salida. También suele emplearse en la capa de salida principalmente.
- La función lineal se emplea en operaciones de regresión, para dar un valor de salida de entre un conjunto de datos continuos.
3.2. Eligiendo la función de coste¶
La función de coste o pérdida ayudará a determinar lo bien o mal que la red está realizando sus cálculos, comparándolos con los resultados reales que se deberían obtener (aprendizaje supervisado). En este sentido, podemos distinguir dos grandes grupos de funciones de coste:
- Para tareas de regresión (es decir, predecir un valor de salida en base a unos datos de entrada), usaremos funciones de coste que calculen la diferencia entre los valores reales y los valores predichos. Aquí podemos utilizar funciones como el error absoluto medio (MAE), o el error cuadrático medio (MSE), que hemos explicado en documentos anteriores. Sin embargo, cuando los valores son elevados o las diferencias pueden ser grandes, estas funciones pueden dar un valor muy grande. En estos casos podemos emplear el coste logarítmico (MSLE), que reduce estas diferencias. También podemos optar por las raíces cuadradas de los errores anteriores (RMSE, RMSLE), aunque estas últimas no están disponibles directamente en Keras/Tensorflow, y tenemos que implementarlas de forma manual.
- Para tareas de clasificación (definir a qué categoría pertenece el elemento de la entrada en base a sus valores), podemos distinguir entre clasificaciones binarias (dividir el conjunto de entrada en dos categorías) o clasificadores múltiples. En el primer caso, bastará con una única neurona de salida, y es conveniente utilizar una función de entropía cruzada binaria (binary_crossentropy), junto con una función de activación sigmoide en la capa de salida. En el caso de clasificaciones múltiples, utilizaremos una neurona de salida por cada categoría, función de coste de entropía cruzada categórica (categorial_crossentropy) y una función de activación softmax en cada neurona de la capa de salida.
3.3. Eligiendo el optimizador y el learning rate¶
Como hemos visto, los optimizadores ayudan a disminuir más rápidamente la función de coste, buscando el mínimo de la función. Keras/Tensorflow incorpora algunos optimizadores predefinidos. En todos ellos es crucial ajustar bien la tasa de aprendizaje o learning rate. Este hiperparámetro define cómo de grandes o pequeños son los "saltos" que damos en la gráfica en busca del mínimo. Como ya hemos visto anteriormente, un valor muy pequeño puede hacer que el modelo tarde demasiado en encontrar el mínimo, o que se quede estancado en un mínimo local y no sea capaz de llegar a otras partes de la gráfica. Por contra, un valor muy grande puede hacer que los saltos que se dan en la gráfica del coste sean demasiado largos, y no se llegue nunca a "afinar" para encontrar ese mínimo.
Algunos de los optimizadores más populares actualmente son:
- SGD (descenso de gradiente estocástico)
- RMSprop (Root Mean Squared Propagation, propagación de la raíz media de los cuadrados)
- Adam
- Adagrad
- Adadelta
- ...
¿Cuál de todos estos optimizadores utilizar? Nuevamente, dependerá del problema en cuestión, aunque la mayoría de ellos se ajustan bien con alguno de la terna SGD / Adam / RMSProp. En general se diferencian en la forma de definir la dirección de optimización. Algunos, como SGD, son más rígidos, y no funcionan tan bien cuando la diferencia de gradiente es mayor en una dimensión que en otra. Otros, como Adam, son más flexibles o adaptativos, y suelen dar mejores resultados con conjuntos grandes de datos, o ruido que hace que sea difícil encontrar el camino al mínimo.
En el caso de Keras, disponemos de estos optimizadores y otros más en el paquete keras.optimizers
(más información aquí). Podemos utilizarlos poniendo directamente su nombre en el parámetro optimizer
del método compile
, o bien instanciando la clase correspondiente. En este último caso, podemos ajustar manualmente el learning rate. De lo contrario, se asume un valor por defecto de 0.001, normalmente.
# Primera alternativa
modelo.compile(loss='mse', optimizer='adam')
# Segunda alternativa
opt = keras.optimizers.Adam(learning_rate=0.01)
modelo.compile(loss='mse', optimizer=opt)
3.4. Otros parámetros¶
Además de todos los elementos anteriores, también tenemos que considerar el ajuste de otros parámetros de la red, como son:
-
El tamaño de los conjuntos de entrenamiento y test. En este caso, la horquilla suele moverse entre un 15% y un 30% para test, y el resto para entrenamiento. Todo dependerá, principalmente, de la cantidad de datos que tengamos disponibles. Si son muchos, podemos permitirnos destinar un 30% a test. Pero si son pocos, conviene acortar el conjunto de test para permitir que la red entrene mejor con más datos disponibles
-
El número de iteraciones (epochs). Esto también va a depender de cada proceso, y podemos analizarlo con la curva del coste (loss) y/o la precisión (accuracy). En cuanto veamos que la pendiente se aplana y estabiliza, es que la red ya tiene poco más que aprender, y no son necesarias muchas más iteraciones. También es cierto que algunos de los parámetros anteriores, como el número de capas ocultas o la cantidad de neuronas, influyen en que esta convergencia sea más o menos rápida.
-
El tamaño del batch. No suele ser un parámetro excesivamente relevante, e incluso el valor por defecto que asigna Keras (32) es aceptable. Si el conjunto de datos es reducido, podemos también acortar el tamaño del batch a 24 o 16, por ejemplo, para facilitar más iteraciones por epoch y acelerar el aprendizaje. Aunque, como hemos explicado con anterioridad, un tamaño de batch excesivamente pequeño puede provocar que los pesos no se ajusten adecuadamente en esa sub-iteración, y el aprendizaje sea entonces más lento o menos exitoso.
-
Las capas de Dropout. Este parámetro dependerá del overfitting que tenga la red. Si detectamos ese overfitting, podemos optar por ir añadiendo capas de Dropout entre capas ocultas de nuestra red para desconectar un porcentaje de neuronas (normalmente entre el 10% y el 30% aproximadamente) y estudiar el efecto que produce. Recuerda que, además de esto, también podemos optar por reducir el tamaño de la red, o aplicar una estrategia de early stopping para hacer que se detenga en cuanto empiece el overfitting de forma automática.
3.5. Conclusiones¶
Como podemos concluir en base a lo que hemos visto hasta ahora, no existe una ciencia exacta, ni unos pasos fijos recomendables a seguir para ajustar los hiperparámetros de nuestra red neuronal. Hay que conocer bien el problema a resolver para poder establecer adecuadamente algunos de ellos (como las funciones de activación y coste), y luego conviene probar varias combinaciones de otros (número de capas ocultas, optimizador, learning rate...) hasta dar con la que obtenga mejores resultados.
Ejercicio 4
Vamos a subir el dataset sobre estimaciones de coches que hemos empleado en ejemplos anteriores a Google Colab. Y completaremos este documento para probar distintas configuraciones de redes neuronales. Sigue los pasos indicados en el documento Colab y determina cuál de todas las opciones es la que ofrece mejores resultados. También puedes probar tus propios experimentos.