Compartir a través de


Ejecución de pruebas

Propagación inversa en redes neuronales para programadores

James McCaffrey

Descargar el ejemplo de código

James McCaffreyUna forma de ver las redes neuronales es como metafunciones que aceptan un número fijo de entradas numéricas y producen un número fijo de salidas numéricas. En la mayoría de los casos, las redes neuronales tienen una capa de neuronas ocultas, donde cada una de estas está conectada completamente con las neuronas de entrada y de salida. Asociado con cada neurona oculta y cada neurona de salida individual se encuentra un conjunto de valores de ponderación y un valor único llamado sesgo. Las ponderaciones y sesgos determinan los valores de salida para un conjunto dado de valores de entrada.

Al emplear redes neuronales para modelar un conjunto de datos existente para realizar predicciones sobre datos nuevos, la principal dificultad radica en encontrar el conjunto de valores de ponderación y sesgo que calzan mejor con los datos existentes. La técnica más común para calcular las ponderaciones y sesgos óptimos se llama propagación inversa. Aunque existen muchas referencias excelentes que describen las matemáticas complejas que subyacen a la propagación inversa, hay pocas guías disponibles para los programadores, que describan claramente cómo programar el algoritmo de propagación inversa. Este artículo explica cómo implementar la propagación inversa. El lenguaje de programación en los ejemplos es C#, pero no debería ser difícil refactorizarlos a otros lenguajes.

La mejor forma de ver mi objetivo es mirar la captura de pantalla de un programa de demostración en la Ilustración 1. Este programa crea una red neuronal que tiene tres neuronas de entrada, con una capa oculta de cuatro neuronas y dos neuronas de salida. Las redes neuronales con una sola capa oculta requieren de dos funciones de activación. En muchas situaciones, sin embargo, estas dos funciones son iguales, habitualmente la función sigmoidea. Pero en esta demostración, para ilustrar la relación entre las funciones de activación y la propagación inversa, empleo funciones de activación diferentes: la función sigmoidea para los cálculos de la capa de entrada a la oculta, y la tangente hiperbólica para los cálculos de la capa oculta a la de salida.

Back-Propagation Algorithm in ActionIlustración 1 Algoritmo de propagación inversa en acción

Una red neuronal 3-4-2 completamente conectada requiere de 3*4 + 4*2 = 20 valores de ponderación y 4+2 = 6 valores de sesgo, lo que suma un total de 26 ponderaciones y sesgos. Estas ponderaciones y sesgos se inicializan en valores más o menos arbitrarios. Los valores de entrada genéricos se establecen en 1,0, 2,0 y 3,0. Con los valores iniciales de ponderación, sesgo y entrada, los valores de salida iniciales que calcula la red neuronal son {0,7225; -0,8779}. El programa de demostración supone que los dos valores de salida correctos son {-0,8500; 0,7500}. La finalidad del algoritmo de propagación inversa es encontrar un nuevo conjunto de ponderaciones y sesgos que generen resultados muy próximos a los valores correctos para las entradas {1,0; 2,0; 3,0}.

La propagación inversa requiere de dos parámetros libres. La velocidad de aprendizaje, generalmente designada con la letra griega eta en la literatura sobre propagación inversa, controla la velocidad con la que el algoritmo converge en el cálculo final. El momento, designado generalmente con la letra griega alfa, permite que el algoritmo de propagación inversa evite las situaciones donde el algoritmo oscila sin converger en un cálculo final. El programa de demostración establece la velocidad de aprendizaje en 0,90 y el momento en 0,04. Normalmente estos valores se encuentran por ensayo y error.

El proceso de encontrar el mejor conjunto de ponderaciones y sesgos para una red neuronal a veces se denomina entrenamiento de la red. En el caso de la propagación inversa, el entrenamiento es un proceso iterativo. En cada iteración, la propagación inversa calcula un nuevo conjunto de valores de ponderación y sesgo que, en teoría, generan valores de salida que estarán más cercanos a los valores esperados. Después de la primera iteración de entrenamiento del programa de demostración, el algoritmo de propagación inversa encontró los nuevos valores de ponderación y sesgo, que generaron los resultados nuevos {-0,8932; -0,8006}. El primer valor de salida de -0,8932 está mucho más cercano al primer valor esperado de -0,8500. El segundo valor nuevo de salida de -0,8006 sigue estando muy alejado del valor esperado de 0,7500.

El proceso de entrenamiento se puede terminar de diferentes formas. El programa de demostración itera el entrenamiento hasta que la suma de las diferencias absolutas entre los valores de salida y los esperados sea menor que <= 0,01 o el entrenamiento llegue a 1.000 iteraciones. En la demostración, después de seis iteraciones de entrenamiento, la propagación inversa encontró un conjunto de valores de ponderación y sesgo que generaron salidas de {-0,8423; 0,7481}; muy próximas a los valores esperados de {-0,8500; 0,7500}.

Este artículo supone que usted cuenta con conocimientos de programación de experto y que tiene un conocimiento muy básico de las redes neuronales. (Para obtener información básica sobre las redes neuronales, consulte mi artículo de mayo de 2012, “Profundización en las redes neuronales”, en msdn.microsoft.com/magazine/hh975375.) El código del programa que aparece en la Ilustración 1 es demasiado extenso para presentarlo en este artículo, de modo que me concentraré en explicar las partes claves del algoritmo. El código fuente completo para el programa de demostración está disponible en archive.msdn.microsoft.com/mag201210TestRun.

Definición de una clase de una red neuronal

La codificación de una red neuronal que emplee la propagación inversa se presta a la perfección para un enfoque orientado a objetos. En la Ilustración 2 se muestra la definición de clase que se empleó en el programa de demostración.

Ilustración 2 Clase de red neuronal

class NeuralNetwork
{
  private int numInput;
  private int numHidden;
  private int numOutput;
  // 15 input, output, weight, bias, and other arrays here
  public NeuralNetwork(int numInput, 
    int numHidden, int numOutput) {...}
  public void UpdateWeights(double[] tValues, 
    double eta, double alpha) {...}
  public void SetWeights(double[] weights) {...}
  public double[] GetWeights() {...}
  public double[] ComputeOutputs(double[] xValues) {...}
  private static double SigmoidFunction(double x)
  {
    if (x < -45.0) return 0.0;
    else if (x > 45.0) return 1.0;
    else return 1.0 / (1.0 + Math.Exp(-x));
  }
  private static double HyperTanFunction(double x)
  {
    if (x < -10.0) return -1.0;
    else if (x > 10.0) return 1.0;
    else return Math.Tanh(x);
  }
}

Los campos de los miembros numInput, numHidden y numOutput son características que definen la arquitectura de la red neuronal. Aparte de un constructor sencillo, la clase cuenta con cuatro métodos de acceso y dos métodos auxiliares. El método UpdateWeights contiene toda la lógica del algoritmo de propagación inversa. El método SetWeights acepta una matriz de ponderaciones y sesgos y copia estos valores en forma secuencial en las matrices miembro. El método GetWeights realiza la operación inversa, al copiar las ponderaciones y sesgos en una sola matriz y devolverla. El método ComputeOutputs determina los valores de salida de la red neuronal con los valores de entrada, ponderación y sesgo actuales.

El método SigmoidFunction se emplea como función de activación entre las capas de entrada y oculta. Acepta un valor real (tipo double en C#) y devuelve un valor entre 0,0 y 1,0. El método HyperTanFunction también acepta un valor real, pero devuelve un valor entre -1,0 y +1,0. El lenguaje C# tiene una función tangente hiperbólica integrada, Math.Tanh, pero si usted usa un lenguaje sin una función tanh nativa, tendrá que escribirla de cero.

Configuración de las matrices

Una de las claves para programar correctamente un algoritmo de propagación inversa para redes neuronales es entender plenamente las matrices que se emplean para almacenar los valores de ponderación y sesgo, almacenar diferentes tipos de valores de entrada y salida, almacenar valores de una iteración anterior del algoritmo y almacenar resultados temporales. El diagrama grande que aparece en la Ilustración 3 contiene toda la información que necesita para entender cómo se programa la propagación inversa. Es probable que su primera reacción frente a la Ilustración 3 sea algo así como “Olvídalo, esto es demasiado enredado”. Pero espere. La propagación inversa no es trivial, pero una vez que entienda el diagrama, podrá implementarla en cualquier lenguaje de programación.

The Back-Propagation Algorithm
Ilustración 3 Algoritmo de propagación inversa

En la Ilustración 3 aparecen las entradas y salidas primarias en los bordes, pero también aparecen varios valores de entrada y salida locales que ocurren en el interior del diagrama. No debe subestimar la dificultad de programar una red neuronal y lo importante es usar nombres y significados claros para todas estas entradas y salidas. Según mi experiencia, los diagramas como el que aparece en la Ilustración 3 son absolutamente imprescindibles.

 Las primeras cinco matrices de las quince que se emplean en la definición de la red neuronal y aparecen en la Ilustración 2 tratan de las capas de entrada a oculta y son:

public class NeuralNetwork
{
  // Declare numInput, numHidden, numOutput
  private double[] inputs;
  private double[][] ihWeights;
  private double[] ihSums;
  private double[] ihBiases;
  private double[] ihOutputs;
...

La primera matriz, llamada inputs, contiene los valores numéricos de entrada. Por lo general, estos valores provienen directamente de algún origen de datos normalizado, como por ejemplo un archivo de texto. El constructor de NeuralNetwork crea las instancias de las entradas del siguiente modo:

this.inputs = new double[numInput];

La matriz ihWeights (ponderaciones de entrada a oculta) es una matriz virtual bidimensional implementada como matriz de matrices. El primer índice indica la neurona de entrada y el segundo la neurona oculta. El constructor crea la instancia de esta matriz del siguiente modo:

this.ihWeights = Helpers.MakeMatrix(numInput, numHidden);

Aquí, Helpers es una clase auxiliar con métodos estáticos, que permite simplificar la clase de la red neuronal:

public static double[][] MakeMatrix(int rows, int cols)
{
  double[][] result = new double[rows][];
  for (int i = 0; i < rows; ++i)
    result[i] = new double[cols];
  return result;
}

La matriz ihSums es una matriz temporal que se emplea para contener un cálculo intermedio en el método ComputeOutputs. La matriz contiene los valores que se convertirán en las entradas locales de las neuronas ocultas y las instancias se crean del siguiente modo:

this.ihSums = new double[numHidden];

La matriz ihBiases contiene los valores de los sesgos para las neuronas ocultas. Los valores de sesgo en las redes neuronales son constantes; para aplicarlos, se multiplican con un valor de entrada local. Los valores de sesgo se agregan a una suma intermedia para producir un valor de salida local, que se convierte en la entrada local para la siguiente capa. La instancia de la matriz ihBiases se crea así:

this.ihBiases = new double[numHidden];

La matriz ihOutputs contiene los valores que se emiten de las neuronas de la capa oculta (que se convierten en las entradas de la capa de salida).

Las siguientes cuatro matrices de la clase NeuralNetwork contienen los valores relacionados con la capa oculta a salida:

private double[][] hoWeights;
private double[] hoSums;
private double[] hoBiases;
private double[] outputs;

Las instancias de estas cuatro matrices se crean en el constructor del siguiente modo:

this.hoWeights = Helpers.MakeMatrix(numHidden, numOutput);
this.hoSums = new double[numOutput];
this.hoBiases = new double[numOutput];
this.outputs = new double[numOutput];

La clase de la red neuronal tiene seis matrices que se relacionan directamente con el algoritmo de propagación inversa. Las primeras dos matrices contienen los valores llamados gradientes para las neuronas de las capas de salida y oculta. Un gradiente es un valor que describe en forma indirecta qué tan alejadas están y en qué dirección (positiva o negativa) se encuentran las salidas locales en comparación con las salidas esperadas. Los valores de gradiente se emplean para calcular los valores delta, que se suman a los valores de ponderación y sesgo actuales para generar ponderaciones y sesgos nuevos y mejores. Hay un valor de gradiente para cada neurona de las capas oculta y de salida. Las matrices se declaran del siguiente modo:

private double[] oGrads; // Output gradients
private double[] hGrads; // Hidden gradients

Las instancias de las matrices se crean en el constructor del siguiente modo:

this.oGrads = new double[numOutput];
this.hGrads = new double[numHidden];

Las últimas cuatro matrices de la clase NeuralNetwork contienen los deltas (no los gradientes) de la última iteración del bucle de entrenamiento. Estos deltas anteriores son necesarios si se emplea el mecanismo de momento para prevenir la no-convergencia de la propagación inversa. A mi juicio, el momento es imprescindible, pero si usted decide que no quiere implementarlo, puede omitir estas matrices. Se declaran como:

private double[][] ihPrevWeightsDelta;  // For momentum
private double[] ihPrevBiasesDelta;
private double[][] hoPrevWeightsDelta;
private double[] hoPrevBiasesDelta;

Y las instancias se crean del siguiente modo:

ihPrevWeightsDelta = Helpers.MakeMatrix(numInput, numHidden);
ihPrevBiasesDelta = new double[numHidden];
hoPrevWeightsDelta = Helpers.MakeMatrix(numHidden, numOutput);
hoPrevBiasesDelta = new double[numOutput];

Cálculo de las salidas

Cada iteración de entrenamiento que aparece en la Ilustración 1 tiene dos partes. En la primera parte, las salidas se calculan con las entradas, ponderaciones y sesgos primarios actuales. En la segunda parte, se emplea la propagación inversa para modificar las ponderaciones y sesgos. El diagrama de la Ilustración 3 muestra ambas partes del proceso de entrenamiento.

Si procedemos de izquierda a derecha, a las entradas x0, x1 y x2 se asignan los valores 1,0, 2,0 y 3,0. Estos valores de entrada primarios pasan a las neuronas de la capa de entrada y se emiten sin modificación. Aunque las neuronas de la capa de entrada pueden modificar las entradas, por ejemplo para normalizar los valores para que se ubiquen dentro de un intervalo determinado, en la mayoría de los casos este tipo de procesos se realiza en forma externa. Por esta razón, en los diagramas de las redes neuronales, las neuronas de entrada frecuentemente se representan con rectángulos o cuadrados, para indicar que estas neuronas no procesan los datos del modo que lo hacen las neuronas de las capas ocultas y de salida. Esto también afecta la terminología que se emplea. En algunos casos, la red neuronal que aparece en la Ilustración3 se llamaría una red de tres capas, pero como la capa de entrada no realiza ningún procesamiento, esta red neuronal a veces se llama una red de dos capas.

Luego, cada una de las neuronas de la capa oculta calcula una entrada y una salida local. Por ejemplo, la neurona oculta del extremo inferior con el índice [3] calcula la suma temporal como (1,0)(0,4)+(2,0)(0,8)+(3,0)(1,2) = 5,6. La suma temporal es el producto de la suma de las tres entradas, multiplicado por la ponderación de la capa de entrada a la oculta asociada. Los valores sobre cada flecha corresponden a las ponderaciones. Luego, se agrega el valor del sesgo (-7,0) a la suma temporal para obtener un valor de entrada local de 5,6 + (-7,0) = -1,40. Luego se aplica la función de activación de la capa de entrada a oculta a este valor de entrada intermedio para obtener el valor de salida local de la neurona. En este caso, la función de activación es la función sigmoidea, así que la salida local es 1 / (1 + exp(-(-1,40))) = 0,20.

Las neuronas de la capa de salida calculan sus entradas y salidas en forma análoga. Por ejemplo, en la Ilustración 3, la neurona del extremo inferior con el índice [1] calcula la suma temporal como (0,86)(1,4)+(0,17)(1,6)+(0,98)(1,8)+(0,20)(2,0) = 3,73. Se suma el sesgo asociado para obtener la entrada local: 3,73 + (-5,0) = -1,37. Y se aplica la función de activación para obtener la salida primaria: tanh(-1,37) = -0,88. Si examina el código de ComputeOutputs, verá que el método calcula las salidas tal como lo acabamos de ver.

Propagación inversa

Aunque la matemática detrás de la propagación es bastante complicada, una vez que conoce estos resultados matemáticos, la implementación de la programación no es tan difícil. La programación inversa comienza a procesar de derecha a izquierda en el diagrama que aparece en la Ilustración 3. El primer paso consiste en calcular los valores de gradiente para cada neurona de la capa de salida. Recuerde que el gradiente es un valor que contiene información sobre la magnitud y la dirección de un error. El cálculo de los gradientes para las neuronas de la capa de salida es diferente de los gradientes para las neuronas de la capa oculta.

El gradiente de una neurona de la capa de salida es igual al valor esperado (deseado) menos el valor de salida calculado, multiplicado por la derivada de la función de activación de la capa de salida, evaluada en el valor de salida calculado. Por ejemplo, el valor del gradiente de la neurona de la capa de salida del extremo inferior en la Ilustración 3, con el índice [1], se calcula como:

(0,75 – (-0,88)) * (1 – (-0,88)) * (1 + (-0,88)) = 0,37   

El número 0,75 es el valor deseado. El número -0,88 corresponde al valor de salida, calculado a partir del cálculo del paso previo. Recuerde que en este ejemplo la función de activación de la capa de salida es la función tangente hiperbólica. La derivada de tanh(x) es (1 - tanh(x)) * (1 + tanh(x)). El análisis matemático es un poco complicado pero, en última instancia, el cálculo del gradiente de una neurona de la capa de salida viene dado por la fórmula descrita.

El gradiente de una neurona de la capa oculta es igual a la derivada de la función de activación de la capa oculta, evaluada en la salida local de la neurona, multiplicada por la suma del producto de las salidas primarias, multiplicado por las ponderaciones de la capa oculta a la de salida asociadas. Por ejemplo, en la Ilustración 3, el gradiente de la neurona de la capa oculta en el extremo inferior con el índice [3] es:

(0,20)(1 – 0,20) * [ (-0,76)(1,9) + (0,37)(2,0) ] = -0,03

Si designamos la función sigmoidea por g(x), entonces la derivada de la función sigmoidea es g(x) * (1 - g(x)). Recuerde que este ejemplo usa la función sigmoidea para la función de activación de la capa de entrada a la oculta. Aquí, el número 0,20 es la salida local de la neurona. Los números -0,76 y 0,37 son los gradientes de las neuronas de la capa de salida y 1,9 y 2,0 son las ponderaciones de la capa oculta a la de salida, asociadas con los dos gradientes de la capa de salida.

Cálculo de los deltas de ponderación y sesgo

Después de calcular todos los gradientes de las capas de salida y oculta, el siguiente paso del algoritmo de propagación inversa es usar los valores de los gradientes para calcular los valores delta para cada valor de ponderación y sesgo. A diferencia de los gradientes, que se deben calcular de derecha a izquierda, los valores delta se pueden calcular en cualquier orden. El valor delta de cualquier ponderación es igual a eta, multiplicado por el gradiente asociado con la ponderación o sesgo, multiplicado por el valor de entrada asociado con la ponderación o sesgo. Por ejemplo, el valor delta para la ponderación de la capa de entrada a la oculta de la neurona de entrada [2] a la neurona oculta [3] es:

    delta ponderación e-o[2][3] = eta * gradiente oculta[3] * entrada[2]
    = 0,90 * (-0,11) * 3,0
    = -0,297

El número 0,90 es eta, que controla la velocidad de aprendizaje de la propagación inversa. A mayor valor de eta, se producen cambios mayores en delta, con el riesgo de pasar de largo de una respuesta correcta. El valor -0,11 es el gradiente de la neurona oculta [3]. El número 3,0 es el valor de entrada de la neurona de entrada [2]. En términos del diagrama de la Ilustración 3, si una ponderación se representa como flecha de una neurona a otra, para calcular el delta para una ponderación determinada, se usa el valor del gradiente de la neurona donde termina la flecha, a la derecha, y el valor de entrada de la neurona donde parte la flecha, a la izquierda.

Al calcular los deltas de los valores de sesgo, observe que como los valores de sesgo simplemente se suman a una suma intermedia, no tienen asociado ningún valor de entrada. Por lo tanto, para calcular el delta de un valor de sesgo, podemos omitir completamente el término del valor de entrada o usar un valor artificial de 1,0 a modo de documentación. Por ejemplo, en la Ilustración 3, el sesgo de la capa oculta del extremo inferior tiene el valor -7,0. El delta de este valor de sesgo es:

    0,90 * gradiente de la neurona donde termina la flecha * 1,0
= 0,90 * (-0,11) * 1,0
    = 0,099

Adición de un término de momento

Después de calcular todos los valores de delta de ponderación y sesgo, se puede actualizar cada ponderación y sesgo, al sumar simplemente el valor de delta asociado. Pero la experiencia ha mostrado que, con ciertos conjuntos de datos, el algoritmo de propagación inversa puede oscilar y pasar de largo y quedar corto repetidas veces del valor esperado, sin converger jamás en un conjunto final de valores de ponderación y sesgo. Una técnica para reducir esta tendencia consiste en agregar a cada ponderación y sesgo nuevo un término adicional llamado momento. El momento de una ponderación (o sesgo) es simplemente un valor pequeño (como 0,4 en el programa de demostración), multiplicado por el valor del último delta de la ponderación. El momento introduce un poco de complejidad en el algoritmo de propagación inversa, ya que se deben almacenar los valores de los deltas anteriores. La matemática que explica por qué esta técnica previene la oscilación es sutil, pero el resultado es sencillo.

En resumen, para actualizar una ponderación (o sesgo) mediante la propagación inversa, el primer paso consiste en calcular los gradientes de todas las neuronas de la capa de salida. El segundo paso consiste en calcular los gradientes de todas las neuronas de la capa oculta. El tercer paso consiste en calcular los deltas de todas las ponderaciones con el uso de eta, la velocidad de aprendizaje. El cuarto paso consiste en agregar los deltas a cada ponderación. El quinto paso consiste en agregar el término de momento a cada ponderación.  

Codificación con Visual Studio 2012

La explicación de la propagación inversa que se presentó en este artículo, junto con el código de ejemplo debería entregarle suficiente información para que pueda entender e implementar el algoritmo de propagación inversa. La propagación inversa es solamente una de varias técnicas que se pueden emplear para calcular los valores óptimos de ponderación y sesgo para un conjunto de datos. Comparada con las alternativas, como la optimización de enjambre de partículas y optimización evolutiva, la propagación inversa suele ser más rápida. Pero la propagación inversa también tiene desventajas. No se puede usar con redes neuronales que usen funciones de activación que no se puedan diferenciar. La determinación de valores buenos para la velocidad de aprendizaje y momento es más un arte que una ciencia y puede consumir bastante tiempo.

Existen varios temas que no se abordan en este artículo; en concreto, qué hacer en el caso de varios elementos de datos esperados. Explicaré este concepto, aparte de otras técnicas de redes neuronales, en otros artículos.

Cuando codifiqué el programa de demostración para este artículo, usé la versión beta de Visual Studio 2012. Aunque muchas de las características nuevas de Visual Studio 2012 tienen relación con las aplicaciones para Windows 8, quería ver cómo Visual Studio 2012 funcionaba con las buenas viejas aplicaciones de consola. Me sorprendió gratamente que no recibí sorpresas ingratas de ninguna de las nuevas características de Visual Studio 2012. La transición a Visual Studio 2012 no me generó ningún problema. Aunque no usé la nueva característica Async de Visual Studio 2012, me podría haber servido para calcular todos los valores delta de cada ponderación y sesgo. Probé la nueva característica de Jerarquía de llamadas y esta me pareció útil e intuitiva. Mi impresión inicial de Visual Studio 2012 fue favorable y pretendo realizar la transición en cuanto pueda.

El **Dr. James McCaffrey **trabaja en Volt Information Sciences Inc., donde está a cargo del entrenamiento técnico de los ingenieros informáticos que trabajan en el campus de Microsoft en Redmond, Washington. Ha colaborado en el desarrollo de varios productos de Microsoft como, por ejemplo, Internet Explorer y MSN Search. Es el autor de “.NET Test Automation Recipes” (Apress, 2006) y se puede ubicar en la dirección de correo electrónico jammc@microsoft.com.

Gracias al siguiente experto técnico por su ayuda en la revisión de este artículo: Dan Liebling