Septiembre de 2017
Volumen 32, número 9
Serie de pruebas: aprendizaje de la red neuronal profunda
Por James McCaffrey
Una red neuronal con realimentación (FNN) normal tiene un conjunto de nodos de entrada, un conjunto de nodos de procesamiento ocultos y un conjunto de nodos de salida. Por ejemplo, si quisiera predecir la inclinación política de una persona (conservadora, moderada, liberal) según su edad y sus ingresos, podría crear una FNN con dos nodos de entrada, ocho nodos ocultos y tres nodos de salida. El número de nodos de entrada y salida viene determinado por la estructura de los datos, pero el número de nodos ocultos es un parámetro libre que debe determinarse mediante prueba y error.
Una red neuronal profunda (DNN) tiene dos o más capas ocultas. En este artículo, explicaré cómo entrenar una DNN mediante el algoritmo de retropropagación y describiré el problema del "degradado evanescente" asociado. Después de leer este artículo, obtendrá código con el que experimentar y comprenderá mejor qué sucede entre bambalinas cuando usa una biblioteca de redes neuronales, como Microsoft Cognitive Toolkit (CNTK) o Google TensorFlow.
Observe la red DNN que se muestra en la Figura 1. La DNN tiene tres capas ocultas que contienen cuatro, dos y dos nodos. Los valores de los nodos de entrada son 3,80 y 5,00, que puede imaginar que hacen referencia a la edad normalizada (38 años) y los ingresos (50 000 al año) de una persona. Cada uno de los ocho nodos tiene un valor de entre -1,0 y +1,0, porque la red DNN usa la función de activación tanh.
Figura 1 Ejemplo de red neuronal profunda 2-(4,2,2)-3
Los valores de los nodos de salida preliminares son (-0,109, 3,015, 1,202). Estos valores se convierten en los valores de salida finales (0,036, 0,829, 0,135) mediante la función softmax. La finalidad de softmax es forzar los valores de los nodos de salida para que sumen 1.0, de modo que puedan interpretarse como probabilidades. Suponiendo que los nodos de salida representan las probabilidades de "conservador", "moderado" y "liberal", respectivamente, la persona de este ejemplo se prevé moderada políticamente.
En la Figura 1, cada una de las flechas que conectan nodos representa una constante numérica que se conoce como una ponderación. Los valores de ponderación suelen estar comprendidos entre -10,0 y +10,0, pero pueden ser cualquier valor en principio. Cada una de las pequeñas flechas que apuntan a los nodos ocultos y a los nodos de salida es una constante numérica que se conoce como un sesgo. Los valores de las ponderaciones y los sesgos determinan los valores de los nodos de salida.
El proceso de búsqueda de los valores de las ponderaciones y los sesgos se denomina entrenamiento de la red. La idea es usar un gran conjunto de datos de aprendizaje que incluya valores de entrada conocidos y valores de salida correctos conocidos y, luego, usar un algoritmo de optimización para encontrar los valores de las ponderaciones y los sesgos, de modo que la diferencia de los valores de salida calculados y los valores de salida correctos conocidos sea mínima. Existen varios algoritmos que se pueden usar para entrenar una DNN, pero el más habitual es, con diferencia, el algoritmo de retropropagación.
En este artículo se supone que cuenta con nociones básicas del mecanismo de entrada-salida de la red neuronal y, como mínimo, con conocimientos de programación de nivel intermedio. El programa de demostración se codifica con C#, pero no debería resultar demasiado difícil refactorizar la demo a otro lenguaje, como Python o Java, sí así lo desea. El programa de demostración es demasiado largo para presentarlo entero en este artículo, pero el programa completo está disponible en la descarga que acompaña este artículo.
El programa de demostración
La demostración comienza con la generación de 2000 elementos de datos de entrenamiento sintéticos. Cada elemento tiene cuatro valores de entrada comprendidos entre -4,0 y +4,0, seguidos de tres valores de salida, que pueden ser (1, 0, 0) o (0, 1, 0) o (0, 0, 1) y que representan tres valores categóricos posibles. Los elementos de aprendizaje primero y último son:
[ 0] 2.24 1.91 2.52 2.41 0.00 1.00 0.00
...
[1999] 1.30 -2.41 -3.18 0.11 1.00 0.00 0.00
Entre bastidores, se crea una DNN 4-(10,10,10)-3 con valores de ponderaciones y sesgos aleatorios y, luego se introducen los valores de entrada aleatorios en la red para generar los datos ficticios. Una vez generados los datos de aprendizaje, la demo crea una nueva DNN 4-(10,10,10)-3 y la entrena con el algoritmo de retropropagación. Durante el aprendizaje, el error cuadrático medio actual y la precisión de la clasificación se muestran cada 200 iteraciones.
El error disminuye lentamente y la precisión aumenta lentamente, como es de esperar. Cuando se completa el aprendizaje, la precisión final del modelo de DNN es del 93,45 %, lo que significa que 0,9345 x 2000 = 1869 elementos se clasificaron correctamente y, por tanto, que 131 elementos se clasificaron de manera incorrecta. El código de demostración que genera la salida empieza por:
using System;
namespace DeepNetTrain
{
class DeepNetTrainProgram {
static void Main(string[] args) {
Console.WriteLine("Begin deep net demo");
int numInput = 4;
int[] numHidden = new int[] { 10, 10, 10 };
int numOutput = 3;
...
El programa de demostración usa solo C# simple, sin espacios de nombres, excepto System. Primero, se prepara la DNN para generar los datos de aprendizaje simulados. El número de capas ocultas, 3, se pasa implícitamente como el número de elementos en la matriz numHidden. Un diseño alternativo es pasar el número de capas ocultas de manera explícita. A continuación, los datos de aprendizaje se generan con el método auxiliar MakeData:
int numDataItems = 2000;
Console.WriteLine("Generating " + numDataItems +
" artificial training data items ");
double[][] trainData = MakeData(numDataItems,
numInput, numHidden, numOutput, 5);
Console.WriteLine("Done. Training data is: ");
ShowMatrix(trainData, 3, 2, true);
El 5 pasado a MakeData es un valor de inicialización para un objeto aleatorio para que las ejecuciones de demostración sean reproducibles. El valor de 5 se usó simplemente porque proporcionaba una buena demostración. La llamada a la función auxiliar ShowMatrix muestra las 3 primeras filas y la última fila de los datos generados, con 2 decimales, que muestran índices (true). A continuación, se crea la DNN y se prepara el aprendizaje:
Console.WriteLine("Creating a 4-(10,10,10)-3 DNN");
DeepNet dn = new DeepNet(numInput, numHidden, numOutput);
int maxEpochs = 2000;
double learnRate = 0.001;
double momentum = 0.01;
La demo usa una clase DeepNet definida por el programa. El algoritmo de retropropagación es iterativo, de modo que se debe especificar un número máximo de iteraciones, que en este caso es de 2000. El parámetro de velocidad de aprendizaje controla la cantidad de ajustes de las ponderaciones y los sesgos cada vez que se procesa un elemento de aprendizaje. Una velocidad de aprendizaje reducida puede dar lugar a un aprendizaje demasiado lento (horas, días o más tiempo), pero una velocidad de aprendizaje alta puede generar resultados extremadamente oscilantes que nunca lleguen a estabilizarse. Elegir una velocidad de aprendizaje óptima es una cuestión de prueba y error, y un importante desafío a la hora de trabajar con redes DNN. El factor de momento es algo similar a una velocidad de aprendizaje auxiliar, que suele acelerar el aprendizaje cuando se usa una velocidad de aprendizaje reducida.
El código de llamada del programa de demostración termina de la siguiente manera:
...
double[] wts = dn.Train(trainData, maxEpochs,
learnRate, momentum, 10);
Console.WriteLine("Training complete");
double trainError = dn.Error(trainData, false);
double trainAcc = dn.Accuracy(trainData, false);
Console.WriteLine("Final model MS error = " +
trainError.ToString("F4"));
Console.WriteLine("Final model accuracy = " +
trainAcc.ToString("F4"));
Console.WriteLine("End demo ");
}
El método Train usa el algoritmo de retropropagación para encontrar valores para las ponderaciones y los sesgos, de modo que la diferencia entre los valores de salida calculados y los valores de salida correctos sea mínima. El método Train devuelve los valores de las ponderaciones y los sesgos. El argumento de 10 pasado a Train indica que se mostrarán mensajes de progreso cada 2000/10 = 200 iteraciones. Es importante supervisar el progreso, ya que pueden surgir, y a menudo surgen, errores al entrenar una red neuronal.
Al finalizar el aprendizaje, el error y la precisión finales del modelo se calculan y se muestran con los valores de las ponderaciones y los sesgos finales, que permanecen dentro de la DNN. Las ponderaciones y los sesgos se podrían haber vuelto a cargar explícitamente ejecutando la instrucción dnn.SetWeights(wts), pero no es necesario en este caso. Los argumentos "false" pasados a los métodos Error y Accuracy indican que no se mostrarán mensajes de diagnóstico.
Degradados y ponderaciones de las redes neuronales
Cada ponderación y sesgo de una DNN tiene un valor de degradado asociado. Un degradado es un cálculo derivado de la función de error y es simplemente un valor, como -1,53, donde el signo del degradado indica si la ponderación o el sesgo asociados se deben aumentar o disminuir para reducir el error. La magnitud del degradado es proporcional al cambio que se debe aplicar a la ponderación o al sesgo. Por ejemplo, supongamos que una de las ponderaciones, w, de una DNN tiene un valor de +4,36 y que, después del procesamiento de un elemento de aprendizaje, el degradado de la ponderación, g, se calcula en +2,50. Si la velocidad de aprendizaje, lr, está establecida en 0,10, el nuevo valor de ponderación es:
w = w + (lr * g)= 4.36 + (0.10 * 2.50)= 4.36 + 0.25= 4.61
Por tanto, el aprendizaje de una DNN se reduce en realidad a la búsqueda de degradados para cada valor de ponderación y sesgo. Resulta que calcular los degradados de las ponderaciones que conectan los últimos nodos de la capa oculta a los nodos de la capa de salida y los degradados de los sesgos de los nodos de salida es relativamente fácil, aunque la matemática subyacente sea extraordinariamente profunda. Expresado en código, el primer paso es calcular lo que denominamos "señales del nodo de salida" para cada nodo de salida:
for (int k = 0; k < nOutput; ++k) {
errorSignal = tValues[k] - oNodes[k];
derivative = (1 - oNodes[k]) * oNodes[k];
oSignals[k] = errorSignal * derivative;
}
La variable local errorSignal es la diferencia entre el valor de destino (el valor de nodo correcto de los datos de aprendizaje) y el valor del nodo de salida calculado. Los detalles pueden ser muy complicados. Por ejemplo, el código de demostración usa (destino - salida), pero algunas referencias usan (salida - destino), lo que influye en si se debe agregar o restar la instrucción de actualización de la ponderación asociada al modificar ponderaciones.
La derivada de la variable local es una derivada de cálculo (no es lo mismo que el degradado, que también es una derivada) de la función de activación de salida, que, en este caso, es la función softmax. En otras palabras, si usa algo distinto de softmax, deberá modificar el cálculo de la variable local derivada.
Una vez calculadas las señales de los nodos de salida, estas se pueden usar para calcular los degradados de las ponderaciones de oculto a salida:
for (int j = 0; j < nHidden[lastLayer]; ++j) {
for (int k = 0; k < nOutput; ++k) {
hoGrads[j][k] = hNodes[lastLayer][j] * oSignals[k];
}
}
En resumen, el degradado de ponderación que conecta un nodo oculto a un nodo de salida es el valor del nodo oculto por la señal de salida del nodo de salida. Una vez calculado el gradiente asociado a una ponderación de oculto a salida, la ponderación se puede actualizar:
for (int j = 0; j < nHidden[lastLayer]; ++j) {
for (int k = 0; k < nOutput; ++k) {
double delta = hoGrads[j][k] * learnRate;
hoWeights[j][k] += delta;
hoWeights[j][k] += hoPrevWeightsDelta[j][k] * momentum;
hoPrevWeightsDelta[j][k] = delta;
}
}
En primer lugar, la ponderación se incrementa por la diferencia, que es el valor del degradado por la velocidad de aprendizaje. A continuación, la ponderación se incrementa por una cantidad adicional (el producto de la diferencia anterior por el factor de momento). Tenga en cuenta que el uso del momento es opcional, aunque casi siempre se realiza para aumentar la velocidad de aprendizaje.
En resumen, para actualizar la ponderación de oculto a oculto, se calcula la señal de un nodo de salida, que depende de la diferencia entre el valor de destino y el valor calculado, y la derivada de la función de activación de los nodos de salida (por lo general, softmax). A continuación, se usa la señal del nodo de salida y el valor del nodo oculto para calcular el degradado. Luego, se usa el degradado y la velocidad de aprendizaje para calcular una diferencia de la ponderación, y se actualiza la ponderación con la diferencia.
Lamentablemente, el cálculo de los degradados de las ponderaciones de entrada a oculto y las ponderaciones de oculto a oculto es mucho más complejo. Una explicación rigurosa requeriría páginas y páginas, pero puede hacerse una idea del proceso si examina una parte del código:
int lastLayer = nLayers - 1;
for (int j = 0; j < nHidden[lastLayer]; ++j) {
derivative = (1 + hNodes[lastLayer][j]) *
(1 - hNodes[lastLayer][j]); // For tanh
double sum = 0.0;
for (int k = 0; k < nOutput; ++k) {
sum += oSignals[k] * hoWeights[j][k];
}
hSignals[lastLayer][j] = derivative * sum;
}
Este código calcula las señales de los últimos nodos de la capa oculta (los que preceden directamente a los nodos de salida). La derivada de la variable local es la derivada de cálculo de la función de activación de la capa oculta, que en este caso es tanh. No obstante, las señales ocultas dependen de una suma de productos que implica las señales de los nodos de salida. Esto da lugar al problema del "degradado evanescente".
Problema del degradado evanescente
Cuando se usa el algoritmo de retropropagación para entrenar una DNN, durante el aprendizaje, los valores de degradado asociados a ponderaciones de oculto a oculto bajan considerablemente o se convierten en cero muy rápido. Si un valor de degradado es cero, el degradado por la velocidad de aprendizaje será cero, la diferencia de ponderación será cero y la ponderación no variará. Si un gradiente no llega a cero, pero alcanza un valor muy bajo, la diferencia será insignificante y la velocidad de aprendizaje será mínima.
El motivo por el que los degradados se acercan a cero con rapidez debería quedar claro si se examina con detenimiento el código de la demostración. Dado que los valores de los nodos de salida se convierten en probabilidades, están comprendidos entre 0 y 1. Esto conduce a señales de nodos de salida de 0 a 1. La parte multiplicadora del cálculo de las señales de nodos ocultos implica, por tanto, la multiplicación reiterada de valores entre 0 y 1, lo que puede producir degradados cada vez menores. Por ejemplo, 0,5 x 0,5 x 0,5 x 0,5 = 0,0625. Además, la función tanh de activación de capas ocultas introduce otro término fracción-por-fracción.
El programa de demostración ilustra el problema del degradado evanescente mediante la observación del degradado asociado a la ponderación del nodo 0 de la capa de entrada en el nodo 0 de la primera capa oculta. El degradado de esta ponderación disminuye rápidamente:
epoch = 200 gradient = -0.002536
epoch = 400 gradient = -0.000551
epoch = 600 gradient = -0.000141
epoch = 800 gradient = -0.159148
epoch = 1000 gradient = -0.000009
...
El degradado cambia temporalmente a epoch 800 porque la demo actualiza las ponderaciones y los sesgos después de procesar cada elemento de aprendizaje (esto se denomina aprendizaje "estocástico" o "en línea", en oposición al aprendizaje por "lotes" o "minilotes") y, por pura casualidad, el elemento de aprendizaje procesado en epoch 800 dio lugar a un degradado mayor que lo normal.
En los comienzos de las DNN, hace unos 25 o 30 años, el problema del degradado evanescente era un obstáculo. A medida que crecía el rendimiento informático, la importancia del problema del degradado evanescente disminuía, ya que el aprendizaje se podía ralentizar un poco. No obstante, con la aparición de redes muy profundas con cientos e incluso miles de capas ocultas, el problema surgió de nuevo.
Se han desarrollado numerosas técnicas para abordar el problema del degradado evanescente. Un enfoque consiste en usar la función de unidad lineal rectificada (ReLU) en lugar de la función tanh para la activación de las capas ocultas. Otro enfoque consiste en usar distintas velocidades de aprendizaje para distintas capas (velocidades mayores para las capas más próximas a la capa de entrada). La norma actual consiste en usar las GPU para el aprendizaje en profundidad. Un enfoque radical es el de evitar completamente la retropropagación y, en su lugar, usar un algoritmo de optimización que no requiere degradados, como la optimización de conjuntos de partículas.
Resumen
El término "red neuronal profunda" se refiere habitualmente al tipo de red que se describe en este artículo (una red completamente conectada con varias capas ocultas). No obstante, existen otros muchos tipos de redes neuronales profundas. Las redes neuronales convolucionales son muy eficaces en la clasificación de imágenes. Las redes de memoria a corto plazo extensas son extremadamente eficaces en el procesamiento de lenguaje natural. La mayoría de las variaciones de las redes neuronales profundas usan algún tipo de retropropagación y están sujetas al problema del degradado evanescente.
El Dr. James McCaffreytrabaja para Microsoft Research en Redmond, Washington. Ha colaborado en el desarrollo de varios productos de Microsoft como, por ejemplo, Internet Explorer y Bing. Puede ponerse en contacto con el Dr. McCaffrey en jamccaff@microsoft.com.
Gracias a los siguientes expertos técnicos de Microsoft por revisar este artículo: Chris Lee y Adith Swaminathan