Marzo de 2016

Volumen 31, número 3

Ejecución de pruebas: regresión basada en redes neuronales

Por James McCaffrey

James McCaffreyEl objetivo de un problema de regresión es predecir el valor de una variable numérica (normalmente denominada variable dependiente) basándose en los valores de una o varias variables predictoras (las variables independientes), que pueden ser numéricas o categóricas. Por ejemplo, podría querer predecir los ingresos anuales de una persona basándose en su edad, sexo (hombre o mujer) y en los años de educación que ha recibido.

La forma más sencilla de regresión se conoce como regresión lineal (RL). Una ecuación de predicción de RL podría parecerse a lo siguiente: ingresos = 17,53 + (5,11 * edad) + (-2,02 * hombre) + (-1,32 * mujer) + (6,09 * educación). Aunque la RL es útil para algunos problemas, en muchas situaciones no es eficaz. Pero hay otros tipos comunes de regresiones: la regresión polinomial, el modelo de regresión lineal general y la regresión basada en redes neuronales (NNR). Se puede considerar que la regresión basada en redes neuronales es el tipo de regresión más potente.

El tipo más común de red neuronal (NN) es uno que prediga una variable categórica. Por ejemplo, podría querer predecir la tendencia política de una persona (conservador, moderado, liberal) basándose en factores como la edad, los ingresos y el sexo. Un clasificador NN tiene n nodos de salida, donde n es el número de valores que puede tomar la variable dependiente. Los valores de los n nodos de salida suman 1,0 y pueden interpretarse aproximadamente como probabilidades. De forma que, para predecir la tendencia política, un clasificador NN tendría tres nodos de salida. Si los valores de los nodos de salida fueran (0,24; 0,61; 0,15), el clasificador NN estaría prediciendo "moderado", ya que el nodo intermedio tiene la mayor probabilidad.

En la regresión NN, la red neuronal tiene un único nodo de salida que contiene el valor predicho de la variable numérica dependiente. De esta forma, en el ejemplo que predice los ingresos anuales, habría tres nodos de entrada (uno para edad, otro para sexo donde hombre = -1 y mujer = +1, y otro para los años de educación) y otro nodo de entrada (ingresos anuales).

Una buena manera de comprender en qué consiste la regresión NN y ver el camino que va a tomar este artículo es echar un vistazo al programa de demostración de la Figura 1. En lugar de trabajar con un problema realista, para que las ideas detrás de la regresión NN queden lo más claras posibles, el objetivo de la demostración es crear un modelo NN que pueda predecir el valor de la función seno. Por si sus conocimientos sobre trigonometría están algo oxidados, se muestra el gráfico de la función seno en la Figura 2. La función seno acepta un único valor de entero real desde infinito negativo a infinito positivo, y devuelve un valor comprendido entre -1,0 y +1,0. La función seno devuelve 0 cuando x = 0,0, x = pi (~3,14), x = 2 * pi, x= 3 * pi, y así sucesivamente. La función seno es una función sorprendentemente difícil de modelar.

Demostración de regresión basada en redes neuronales
Figura 1. Demostración de regresión basada en redes neuronales

La función Sin(x)
Figura 2. La función Sin(x)

La demostración comienza generando mediante programación 80 elementos de datos que se usarán para entrenar el modelo NN. Los 80 elementos de entrenamiento tienen un valor de entrada aleatorio x entre 0 y 6,4 (un poco más que 2 * pi) y un valor y correspondiente, que se corresponde con sin(x).

La demostración crea una NN 1-12-1, es decir, una red neuronal con un nodo de entrada (para x), 12 nodos de procesamiento ocultos (que definen eficazmente la ecuación de predicción) y un nodo de salida (el seno predicho de x). Cuando se trabaja con redes neuronales, siempre existe experimentación; el número de nodos ocultos se determina mediante prueba y error.

Los clasificadores NN tienen dos funciones de activación, una para los nodos ocultos y otra para los nodos de salida. La función de activación del nodo de salida de un clasificador es casi siempre la función softmax porque genera valores que suman 1,0. La función de activación del nodo oculto de un clasificador suele ser la función sigmoidal logística o la función tangente hiperbólica (abreviada como tanh). Pero en la regresión NN, existe una función de activación del nodo oculto, pero no una función de activación del nodo de salida. La red neuronal de demostración usa la función tanh para la activación del nodo oculto.

La salida de una NN la determinan sus valores de entrada y un conjunto de constantes denominadas ponderaciones y sesgos. Como los sesgos en realidad solo son tipos especiales de ponderaciones, en ocasiones se usa el término "ponderaciones" para referirse a ambos. Una red neuronal con "i" nodos de entrada, "j" nodos ocultos y "k" nodos de salida tiene un total de (i * j) + j + (j * k) + k ponderaciones y sesgos. Por lo tanto, la NN 1-12-1 de demostración tiene (1 * 12) + 12 + (12 * 1) + 1 = 37 ponderaciones y sesgos.

El proceso de determinación de los valores de las ponderaciones y los sesgos se denomina entrenamiento del modelo. La idea consiste en probar valores distintos de las ponderaciones y los sesgos para determinar cuándo coinciden lo máximo posible los valores de salida calculados de la NN con los valores de salida correctos conocidos de los datos de entrenamiento.

Existen varios algoritmos distintos que se pueden usar para entrenar una NN. Con diferencia, el enfoque más habitual es usar el algoritmo de propagación hacia atrás. La propagación hacia atrás es un proceso iterativo en el que los valores de las ponderaciones y los sesgos cambian lentamente, de forma que la NN normalmente calcula valores de salida más precisos.

La propagación hacia atrás utiliza dos parámetros obligatorios (número máximo de iteraciones y velocidad de aprendizaje) y un parámetro opcional (la velocidad momentum). El parámetro maxEpochs establece un límite en el número de iteraciones del algoritmo. El parámetro learnRate controla cuánto pueden cambiar las ponderaciones y los sesgos en cada iteración. El parámetro momentum acelera el entrenamiento y también ayuda a evitar que el algoritmo de propagación hacia atrás se bloquee en una solución poco óptima. La demostración establece el valor de maxEpochs en 10 000, el valor de learnRate en 0,005 y el valor de momentum en 0,001. Estos valores se determinan mediante prueba y error.

Cuando se utiliza el algoritmo de propagación hacia atrás para el entrenamiento de una NN, existen tres variantes que se pueden usar. En la propagación hacia atrás por lotes, primero se examinan todos los elementos de entrenamiento y luego se ajustan todos los valores de las ponderaciones y los sesgos. En la propagación hacia atrás estocástica (también conocida como propagación hacia atrás en línea), después de que se examine cada uno de los elementos de entrenamiento, se ajustan todos los valores de las ponderaciones y los sesgos. En la propagación hacia atrás por minilotes, todos los valores de las ponderaciones y los sesgos se ajustan después de examinar una parte especificada de los elementos de entrenamiento. El programa de demostración usa la variante más habitual: la propagación hacia atrás estocástica.

El programa de demostración muestra una medida de error cada 1000 épocas de entrenamiento. Observe que los valores de error varían ligeramente. Cuando se completó el entrenamiento, la demostración mostró los valores de los 37 sesgos y ponderaciones que definen el modelo NN. Los valores de las ponderaciones y los sesgos de la NN no tienen ninguna interpretación evidente, pero es importante examinar sus valores por si hubiera resultados incorrectos, como, por ejemplo, cuando una ponderación tiene un valor extraordinariamente grande y todas las demás tienen valores próximos a cero.

El programa de demostración finaliza con la evaluación del modelo NN. Los valores predichos mediante la NN de sin(x) para x = pi, pi / 2, y 3 * pi / 2 se encuentran todos dentro de una diferencia de 0,02 con respecto a los valores correctos. El valor predicho para sin(6 * pi) está muy alejado del valor correcto. Pero se trata de un resultado esperado, ya que la NN solo se entrenó para predecir los valores de sin(x) para valores de x entre 0 y 2 * pi.

En este artículo se supone que tiene como mínimo conocimientos intermedios sobre programación, pero no se supone que sepa gran cosa sobre la regresión basada en redes neuronales. Este programa de demostración está codificado con C#, pero no debería tener demasiados problemas para refactorizar el código a otro lenguaje, como Visual Basic o Perl. El programa de demostración es demasiado largo para presentarlo entero en este artículo, pero el código fuente completo está disponible en la descarga que acompaña este artículo. Todas las comprobaciones de errores se han quitado para mantener el tamaño del código reducido y las ideas principales tan claras como sea posible.

Estructura del programa de demostración

Para crear el programa de demostración, inicié Visual Studio y seleccioné la plantilla de aplicación de consola de C# mediante la acción de menú Archivo | Nuevo | Proyecto. Utilicé Visual Studio 2015, pero la demostración no tiene dependencias significativas de .NET, por lo que cualquier versión de Visual Studio funcionará. Asigné como nombre del proyecto NeuralRegression.

Cuando se cargó el código de plantilla en el editor, en la ventana Explorador de soluciones, seleccioné el archivo Program.cs, hice clic con el botón derecho en él y cambié su nombre por NeuralRegressionProgram.cs, que es más descriptivo. Permití que Visual Studio cambiase automáticamente el nombre de la clase Program. En la parte superior del código del Editor, eliminé todas las referencias a los espacios de nombres sin utilizar y dejé solo la referencia al espacio de nombres System de nivel superior.

La estructura general del programa de demostración, con algunos cambios menores para ahorrar espacio, se muestra en la Figura 3. Todas las instrucciones de control están en el método Main. Toda la funcionalidad de la regresión basada en redes neuronales está contenida en una clase definida por el programa denominada NeuralNetwork.

Figura 3. Estructura del programa de regresión basada en redes neuronales

using System;
namespace NeuralRegression
{
  class NeuralRegressionProgram
  {
    static void Main(string[] args)
    {
      Console.WriteLine("Begin NN regression demo");
      Console.WriteLine("Goal is to predict sin(x)");
      // Create training data
      // Create neural network
      // Train neural network
      // Evaluate neural network
      Console.WriteLine("End demo");
      Console.ReadLine();
    }
    public static void ShowVector(double[] vector,
      int decimals, int lineLen, bool newLine) { . . }
    public static void ShowMatrix(double[][] matrix,
      int numRows, int decimals, bool indices) { . . }
  }
  public class NeuralNetwork
  {
    private int numInput; // Number input nodes
    private int numHidden;
    private int numOutput;
    private double[] inputs; // Input nodes
    private double[] hiddens;
    private double[] outputs;
    private double[][] ihWeights; // Input-hidden
    private double[] hBiases;
    private double[][] hoWeights; // Hidden-output
    private double[] oBiases;
    private Random rnd;
    public NeuralNetwork(int numInput, int numHidden,
      int numOutput, int seed) { . . }
    // Misc. private helper methods
    public void SetWeights(double[] weights) { . . }
    public double[] GetWeights() { . . }
    public double[] ComputeOutputs(double[] xValues) { . . }
    public double[] Train(double[][] trainData,
      int maxEpochs, double learnRate,
      double momentum) { . . }
  } // class NeuralNetwork
} // ns

En el método Main, los datos de entrenamiento se crean mediante estas instrucciones:

int numItems = 80;
double[][] trainData = new double[numItems][];
Random rnd = new Random(1);
for (int i = 0; i < numItems; ++i) {
  double x = 6.4 * rnd.NextDouble();
  double sx = Math.Sin(x);
  trainData[i] = new double[] { x, sx };
}

Como regla general cuando se trabaja con redes neuronales, es mejor tener el máximo de datos de entrenamiento posible. Para modelar la función seno para los valores de x comprendidos entre 0 y 2 * pi, necesité un mínimo de 80 elementos para obtener buenos resultados. La elección de un valor de inicialización de 1 para el objeto de número aleatorio es arbitraria. Los datos de entrenamiento se almacenan en una matriz de tipo matriz de matrices. En escenarios realistas, probablemente leerá los datos de entrenamiento de un archivo de texto.

La red neuronal se crea mediante estas instrucciones:

int numInput = 1;
int numHidden = 12;
int numOutput = 1;
int rndSeed = 0;
NeuralNetwork nn = new NeuralNetwork(numInput,
  numHidden, numOutput, rndSeed);

Únicamente existe un nodo de entrada porque la función seno objeto solo acepta un valor único. En la mayoría de problemas de regresión basada en redes neuronales, dispondrá de varios nodos de entrada, uno para cada una de las variables independientes/predictoras. En la mayoría de problemas de regresión basada en redes neuronales solo hay un único nodo de salida, pero es posible predecir dos o más valores numéricos.

Una NN necesita un objeto aleatorio para inicializar los valores de las ponderaciones y para revolver el orden en que se procesan los elementos de entrenamiento. El constructor del elemento NeuralNetwork de la demostración acepta un valor de inicialización para el objeto aleatorio interno. El valor utilizado, 0, es arbitrario.

La red neuronal se entrena mediante estas instrucciones:

int maxEpochs = 10000;
double learnRate = 0.005;
double momentum  = 0.001;
double[] weights = nn.Train(trainData, maxEpochs,
  learnRate, momentum);
ShowVector(weights, 4, 8, true);

Una NN es altamente sensible a los valores de los parámetros de entrenamiento. Hasta un cambio muy pequeño puede provocar un resultado completamente distinto.

El programa de demostración evalúa la calidad del modelo NN resultante mediante la predicción de sin(x) para tres valores estándares. La instrucciones, con algunas pequeñas modificaciones, son:

double[] y = nn.ComputeOutputs(new double[] { Math.PI });
Console.WriteLine("Predicted =  " + y[0]);
y = nn.ComputeOutputs(new double[] { Math.PI / 2 });
Console.WriteLine("Predicted =  " + y[0]);
y = nn.ComputeOutputs(new double[] { 3 * Math.PI / 2.0 });
Console.WriteLine("Predicted = " + y[0]);

Observe que la NN de la demostración almacena sus resultados en una matriz de nodos de salida, aunque solo hay un único valor de salida para este ejemplo. La devolución de una matriz permite predecir varios valores sin cambiar el código fuente.

La demostración finaliza con la predicción de sin(x) para un valor x que está claramente fuera del intervalo de los datos de entrenamiento:

y = nn.ComputeOutputs(new double[] { 6 * Math.PI });
Console.WriteLine("Predicted =  " + y[0]);
Console.WriteLine("End demo");

En la mayoría de escenarios de clasificador NN, se llama a un método que calcula la precisión de la clasificación, es decir, el número de predicciones correctas divididas por el número total de predicciones. Esto es posible porque un valor de salida categórico es o bien correcto o incorrecto. Pero cuando se trabaja con la regresión basada en NN, no hay una forma estándar de definir la precisión. Si quiere calcular alguna medida de precisión, será dependiente del problema. Por ejemplo, para predecir sin(x), podría definir arbitrariamente una predicción correcta como una que esté dentro de una diferencia de 0,01 con respecto al valor correcto.

Cálculo de valores de salida

La mayoría de las diferencias principales entre una NN diseñada para clasificación y otra diseñada para regresión se encuentran en los métodos que calculan la salida y entrenan el modelo. La definición del método ComputeOutputs de la clase NeuralNetwork comienza así:

public double[] ComputeOutputs(double[] xValues)
{
  double[] hSums = new double[numHidden];
  double[] oSums = new double[numOutput];
...

El método acepta una matriz que contenga los valores de las variables independientes/predictoras. Las variables locales hSums y oSums son matrices de almacenamiento temporal que contienen valores preliminares (anteriores a la activación) de los nodos ocultos y de salida. A continuación, los valores de las variables independientes se copian en los nodos de entrada de la red neuronal:

for (int i = 0; i < numInput; ++i)
  this.inputs[i] = xValues[i];

Después, los valores preliminares de los nodos ocultos se calculan mediante la multiplicación de cada uno de los valores de entrada por su correspondiente ponderación entrada-oculto, y acumulando:

for (int j = 0; j < numHidden; ++j)
  for (int i = 0; i < numInput; ++i)
    hSums[j] += this.inputs[i] * this.ihWeights[i][j];

A continuación, se agregan los valores de sesgo de los nodos ocultos:

for (int j = 0; j < numHidden; ++j)
  hSums[j] += this.hBiases[j];

Los valores de los nodos ocultos se determinan mediante la aplicación de la función de activación de nodo oculto a cada suma preliminar:

for (int j = 0; j < numHidden; ++j)
  this.hiddens[j] = HyperTan(hSums[j]);

Después, los valores preliminares de los nodos de salida se calculan mediante la multiplicación de cada uno de los valores de los nodos ocultos por su correspondiente ponderación oculto-entrada, y acumulando:

for (int k = 0; k < numOutput; ++k)
  for (int j = 0; j < numHidden; ++j)
    oSums[k] += hiddens[j] * hoWeights[j][k];

Luego, se agregan los valores de sesgo de los nodos ocultos:

for (int k = 0; k < numOutput; ++k)
  oSums[k] += oBiases[k];

Hasta este punto, el cálculo de los valores de los nodos de salida para una red de regresión es exactamente igual que el cálculo de los valores de los nodos de salida de una red de clasificador. Pero en un clasificador, los valores de los nodos de salida finales se calcularían mediante la aplicación de la función de activación softmax a cada suma acumulada. Para una red de regresión, no se aplica ninguna función de activación. Por lo tanto, el método ComputeOutputs finaliza simplemente con la copia de los valores de la matriz de almacenamiento temporal oSums directamente en los nodos de salida:

...
  Array.Copy(oSums, this.outputs, outputs.Length);
  double[] retResult = new double[numOutput]; // Could define a GetOutputs
  Array.Copy(this.outputs, retResult, retResult.Length);
  return retResult;
}

Por conveniencia, los valores de los nodos de salida también se copian en una matriz de devolución local, de forma que se pueda acceder a ellos fácilmente sin llamar a un método GetOutputs de algún tipo.

Cuando se entrena un clasificador NN mediante el algoritmo de propagación hacia atrás, se usan las derivadas de cálculo de las dos funciones de activación. En los nodos ocultos, el código es así:

for (int j = 0; j < numHidden; ++j) {
  double sum = 0.0; // sums of output signals
  for (int k = 0; k < numOutput; ++k)
    sum += oSignals[k] * hoWeights[j][k];
  double derivative = (1 + hiddens[j]) * (1 - hiddens[j]);
  hSignals[j] = sum * derivative;
}

El valor de la variable local llamada derivative es la derivada de cálculo de la función tanh y procede de una teoría bastante compleja. En un clasificador NN, el cálculo que usa la derivada de la función de activación del nodo de salida es:

for (int k = 0; k < numOutput; ++k) {
  double derivative = (1 - outputs[k]) * outputs[k];
  oSignals[k] = (tValues[k] - outputs[k]) * derivative;
}

Aquí, el valor de la derivada de la variable local es la derivada de cálculo de la función softmax. No obstante, como la regresión NN no usa una función de activación para los nodos de salida, el código es:

for (int k = 0; k < numOutput; ++k) {
  double derivative = 1.0;
  oSignals[k] = (tValues[k] - outputs[k]) * derivative;
}

Por supuesto, la multiplicación por 1,0 no tiene ningún efecto, por lo que simplemente puede eliminar el término derivative. Otra forma de verlo es que, en una regresión NN, la función de activación del nodo de salida es la función de identidad f(x) = x. La derivada de cálculo de la función de identidad es la constante 1,0.

Resumen

El código de demostración y la explicación de este artículo deberían ser suficiente para que pueda comenzar a trabajar si quiere explorar las regresiones basadas en redes neuronales con una o varias variables predictoras numéricas. Si tiene una variable predictora que es categórica, deberá codificar la variable. Para una variable predictora que pueda tomar uno o dos posibles valores, como por ejemplo sexo (hombre, mujer), codificaría un valor como -1 y otro como +1.

Para una variable predictora que pueda tomar tres o más posibles valores, usaría lo que se conoce como codificación 1-de-(N-1). Por ejemplo, si una variable predictora es un color que puede tomar un valor de cuatro posibles (rojo, azul, verde, amarillo), rojo se codificaría como (1, 0, 0), azul como (0, 1, 0), verde como (0, 0, 1) y amarillo como (-1, -1, -1)


Dr. James McCaffrey* trabaja para Microsoft Research en Redmond, Washington. Ha colaborado en el desarrollo de varios productos de Microsoft como, por ejemplo, Internet Explorer y Bing. Dr. Puede ponerse en contacto James en jammc@microsoft.com.*

Gracias a los siguientes expertos técnicos de Microsoft por revisar este artículo: Gaz Iqbal y Umesh Madan