Octubre de 2017

Volumen 32, número 10

Serie de pruebas: regresión de serie temporal mediante una red neuronal de C#

Por James McCaffrey

 

James McCaffreyEl objetivo de un problema de regresión de serie temporal es hacer predicciones basadas en datos de momentos históricos. Por ejemplo, si tiene datos de ventas mensuales (de uno o dos años), puede que quiera predecir las ventas del mes siguiente. La regresión de serie temporal suele ser muy difícil y hay muchas técnicas distintas que puede usar.

En este artículo, demostraré cómo realizar un análisis de regresión de serie temporal mediante datos de ventana con desplazamiento combinados con una red neuronal. Se explica mejor esta idea a través de un ejemplo. Eche un vistazo al programa de demostración de la Figura 1. El programa de demostración analiza la cantidad de pasajeros de aerolíneas que viajaron cada mes entre enero de 1949 y diciembre de 1960.

Demostración de regresión de serie temporal de ventana con desplazamiento

Figura 1 Demostración de regresión de serie temporal de ventana con desplazamiento

Los datos de la demostración proceden de un conocido conjunto de datos de referencia que puede encontrar en diversos sitios de Internet y que se incluye con la descarga que acompaña a este artículo. Los datos sin procesar tienen el aspecto siguiente:

"1949-01";112
"1949-02";118
"1949-03";132
"1949-04";129
"1949-05";121
"1949-06";135
"1949-07";148
"1949-08";148
...
"1960-11";390
"1960-12";432

Hay 144 elementos de datos sin procesar. El primer campo corresponde al año y el mes. El segundo campo es la cantidad total de pasajeros de aerolíneas internacionales del mes, en miles. Esta demostración crea datos de aprendizaje mediante una ventana con desplazamiento de tamaño 4 para producir 140 elementos de aprendizaje. Para normalizar los datos de aprendizaje, cada recuento de pasajeros se divide por 100:

[  0]   1.12   1.18   1.32   1.29   1.21
[  1]   1.18   1.32   1.29   1.21   1.35
[  2]   1.32   1.29   1.21   1.35   1.48
[  3]   1.29   1.21   1.35   1.48   1.48
...
[139]   6.06   5.08   4.61   3.90   4.32

Tenga en cuenta que los valores de tiempo explícitos de los datos se han quitado. La primera ventana contiene los cuatro primeros recuentos de pasajeros (1,12; 1,18; 1,32; 1,29), que se usan como valores indicadores, seguidos del quinto recuento (1,21), que es un valor que se debe predecir. La siguiente ventana contiene los recuentos del segundo al quinto (1,18; 1,32; 1,29, 1,21), que representan el siguiente conjunto de valores indicadores, seguidos del sexto recuento (1,35), el valor que se debe predecir. En resumen, cada conjunto de cuatro recuentos de pasajeros consecutivos se usa para predecir el siguiente recuento.

La demostración crea una red neuronal con cuatro nodos de entrada, 12 nodos de procesamiento ocultos y un único nodo de salida. La cantidad de nodos de entrada se corresponde con la cantidad de indicadores de la ventana con desplazamiento. El tamaño de la ventana se debe determinar mediante prueba y error, lo que representa el mayor inconveniente de esta técnica. La cantidad de nodos ocultos de la red neuronal también se debe determinar mediante prueba y error y, en el caso de las redes neuronales, siempre es true. Solo hay un nodo de salida porque la regresión de serie temporal predice la siguiente unidad temporal.

La red neuronal tiene pesos de nodo a nodo de (4 * 12) + (12 * 1) = 60 y (12 + 1) = 13 sesgos, lo que, básicamente, define el modelo de red neuronal. Con los datos de la ventana con desplazamiento, el programa de demostración enseña a la red con el algoritmo de retropropagación estocástico básico con un índice de aprendizaje definido en 0,01 y una cantidad fija de iteraciones definida en 10 000.

Durante el aprendizaje, la demostración muestra el error cuadrático medio entre los valores de salida predichos y los valores de salida correctos cada 2000 iteraciones. Un error de aprendizaje es difícil de interpretar y se supervisa, principalmente, para observar si pasa algo realmente extraño (lo que es bastante común). En este caso, el error parece estabilizarse después de 4000 iteraciones.

Después del aprendizaje, el código de demostración muestra los 73 valores de peso y sesgos, de nuevo como comprobación de estado, principalmente. En el caso de problemas de regresión de series temporales, normalmente debe usar una métrica de precisión personalizada. Aquí, una predicción correcta sería una en que el recuento de pasajeros predicho sin normalizar es 30 unidades mayor o menor que el recuento real. Con esa definición, el programa de demostración consiguió una precisión del 91,43 por ciento, lo que supone 128 unidades correctas y 12 incorrectas del recuento de 140 pasajeros predichos.

La demostración concluye con el uso de la red neuronal entrenada para predecir el recuento de pasajeros para enero de 1961, el primer período de tiempo después del intervalo de los datos de aprendizaje. Esto se llama extrapolación. La predicción es de 433 pasajeros. Dicho valor se podría usar como variable de indicador para predecir el valor para febrero de 1961, etc.

En este artículo, se asume que tiene una habilidad de programación intermedia o superior y un conocimiento básico de las redes neuronales, pero no se asume ningún conocimiento sobre la regresión de serie temporal. El programa de demostración se programa con C#, pero no debería resultar demasiado difícil refactorizarlo a otro lenguaje, como Java o Python. El programa de demostración es demasiado largo para presentarlo en su totalidad, pero el código fuente completo está disponible en la descarga de archivos que acompaña a este artículo.

Regresión de serie temporal

A menudo, los problemas de regresión de serie temporal se representan con un gráfico de líneas como el de la Figura 2. La línea azul indica los 144 recuentos de pasajeros reales y sin normalizar, en miles, desde enero de 1949 hasta diciembre de 1960. La línea de color rojo claro indica los recuentos de pasajeros predichos que generó el modelo de serie temporal de la red neuronal. Tenga en cuenta que, dado que el modelo usa una ventana con desplazamiento con cuatro valores indicadores, el primer recuento de pasajeros predicho no tiene lugar hasta el mes = 5. Además, he hecho predicciones para nueve meses más allá del rango de los datos de aprendizaje. Se indican con la línea roja de puntos.

Gráfico de líneas de regresión de serie temporal

Figura 2 Gráfico de líneas de regresión de serie temporal

Además de hacer predicciones para momentos posteriores al rango de datos de aprendizaje, los análisis de regresión de serie temporal se pueden usar para identificar puntos de datos anómalos. Esto no sucede con los datos de recuento de pasajeros de demostración: puede ver que los recuentos predichos coinciden bastante con los recuentos reales. Por ejemplo, el recuento de pasajeros real para el mes t = 67 es 302 (el punto azul cerca del centro de la Figura 2) y el recuento predicho, 272. Pero supongamos que el recuento real del mes t = 67 fuera 400. Habría una clara indicación visual que revelaría que el recuento real del mes 67 era un valor atípico.

También puede usar un enfoque de programación para detectar datos anómalos con regresión de serie temporal. Por ejemplo, podría marcar cualquier valor temporal en que el valor real y el predicho difieran más allá de un umbral fijo; por ejemplo, cuatro veces la desviación estándar del valor predicho respecto a los valores de datos reales.

El programa de demostración

Para programar el programa de demostración, inicié Visual Studio, creé una nueva aplicación de consola de C# y la llamé Neural-TimeSeries. Usé Visual Studio 2015, pero el programa de demostración no tiene dependencias significativas de .NET Framework, por lo que cualquier versión reciente funcionará.

Después de cargar el código de plantilla en la ventana del editor, hice clic con el botón derecho en Program.cs, en la ventana del Explorador de soluciones, y cambié el nombre de archivo a NeuralTimeSeriesProgram.cs. Luego, permití a Visual Studio cambiar el nombre de la clase Program por mí. En la parte superior del código generado por la plantilla, eliminé todas las instrucciones using innecesarias y dejé solo la que hace referencia al espacio de nombres System de nivel superior.

La estructura del programa general, con algunos cambios menores para ahorrar espacio, se presenta en la Figura 3.

Figura 3 Estructura del programa NeuralTimeSeries

using System;
namespace NeuralTimeSeries
{
  class NeuralTimeSeriesProgram
  {
    static void Main(string[] args)
    {
      Console.WriteLine("Begin times series demo");
      Console.WriteLine("Predict airline passengers ");
      Console.WriteLine("January 1949 to December 1960 ");

      double[][] trainData = GetAirlineData();
      trainData = Normalize(trainData);
      Console.WriteLine("Normalized training data:");
      ShowMatrix(trainData, 5, 2, true);  // first 5 rows

      int numInput = 4; // Number predictors
      int numHidden = 12;
      int numOutput = 1; // Regression

      Console.WriteLine("Creating a " + numInput + "-" + numHidden +
        "-" + numOutput + " neural network");
      NeuralNetwork nn = new NeuralNetwork(numInput, numHidden,
        numOutput);

      int maxEpochs = 10000;
      double learnRate = 0.01;
      double[] weights = nn.Train(trainData, maxEpochs, learnRate);
      
      Console.WriteLine("Model weights and biases: ");
      ShowVector(weights, 2, 10, true);

      double trainAcc = nn.Accuracy(trainData, 0.30); 
      Console.WriteLine("\nModel accuracy (+/- 30) on training " +
        "data = " + trainAcc.ToString("F4"));

      double[] future = new double[] { 5.08, 4.61, 3.90, 4.32 };
      double[] predicted = nn.ComputeOutputs(future); 
      Console.WriteLine("January 1961 (t=145): ");
      Console.WriteLine((predicted[0] * 100).ToString("F0"));

      Console.WriteLine("End time series demo ");
      Console.ReadLine();
    } // Main

    static double[][] Normalize(double[][] data) { . . }

    static double[][] GetAirlineData() {. . }

    static void ShowMatrix(double[][] matrix, int numRows,
      int decimals, bool indices) { . . }

    static void ShowVector(double[] vector, int decimals,
      int lineLen, bool newLine) { . . }

  public class NeuralNetwork { . . }
} // ns

Esta demostración usa una red neuronal con un único nivel oculto, implementada desde cero. De forma alternativa, puede usar las técnicas que se describen en este artículo, junto con una biblioteca de red neuronal, como Microsoft Cognitive Toolkit (CNTK).

Para empezar, en la demostración se configuran los datos de aprendizaje, como se muestra en la Figura 4.

Figura 4 Configuración de los datos de aprendizaje

double[][] trainData = GetAirlineData();
trainData = Normalize(trainData);
Console.WriteLine("Normalized training data:");
ShowMatrix(trainData, 5, 2, true);

Method GetAirlineData is defined as:

static double[][] GetAirlineData()
{
  double[][] airData = new double[140][];
  airData[0] = new double[] { 112, 118, 132, 129, 121 };
  airData[1] = new double[] { 118, 132, 129, 121, 135 };
...
  airData[139] = new double[] { 606, 508, 461, 390, 432 };
  return airData;
}

Aquí, los datos de la ventana con desplazamiento se han codificado con un tamaño de ventana de 4. Antes de escribir el programa de serie temporal, escribí una pequeña utilidad para generar los datos de la ventana con desplazamiento a partir de los datos sin procesar. En la mayoría de escenarios que no son de demostración, se leen datos sin procesar desde un archivo de texto y, a continuación, se generan datos de ventana con desplazamiento mediante programación y el tamaño de ventana se parametriza para que pueda experimentar con distintos tamaños.

El método Normalize divide todos los valores de datos por una constante de 100. Lo hice así por motivos puramente prácticos. Mis primeros intentos con datos sin normalizar devolvieron resultados muy pobres, pero, después de la normalización, mis resultados eran mucho mejores. En teoría, al trabajar con redes neuronales, no es necesario normalizar los datos, pero, en la práctica, la normalización suele marcar una gran diferencia.

La red neuronal se crea así:

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

La cantidad de nodos de entrada se define en cuatro porque cada ventana con desplazamiento tiene cuatro valores indicadores. La cantidad de nodos de salida se define como uno porque cada conjunto de valores de ventana se usa para hacer una predicción para el mes siguiente. La cantidad de nodos ocultos se define en 12 y se determinó mediante prueba y error.

La red neuronal se entrena y evalúa mediante estas instrucciones:

int maxEpochs = 10000;
double learnRate = 0.01;
double[] weights = nn.Train(trainData, maxEpochs, learnRate);
ShowVector(weights, 2, 10, true);

El método Train usa una propagación inversa básica. Hay muchas variaciones, como, por ejemplo, usar Momentum o índices de aprendizaje adaptativo para aumentar la velocidad del aprendizaje y usar el abandono o regularización L1 o L2 para evitar el sobreajuste del modelo. El método auxiliar ShowVector muestra un vector con valores reales y un formato con 2 decimales y 10 valores por línea.

Una vez creado el modelo de serie temporal de red neuronal, se evalúa su precisión de predicción:

double trainAcc = nn.Accuracy(trainData, 0.30);
Console.WriteLine("\nModel accuracy (+/- 30) on " + 
  " training data = " + trainAcc.ToString("F4"));

En el caso de la regresión de serie temporal, decidir si un valor predicho es correcto o no depende del problema que se investiga. Para los datos de pasajeros de aerolínea, la precisión del método marca un recuento de pasajeros predicho como correcto si el recuento predicho sin normalizar es 30 unidades mayor o menor que el recuento sin procesar real. Para los datos de demostración, las cinco primeras predicciones, de t = 5 a t = 9 son correctas, pero la predicción para t = 10 no lo es:

t  actual  predicted
= = = = = = = = = = =
 5   121     129
 6   135     128
 7   148     137
 8   148     153
 9   136     140
10   119     141

Para acabar, el programa de demostración usa los cuatro últimos recuentos de pasajeros (de t = 141 a 144) para predecir el recuento de pasajeros para el primer período de tiempo después del rango de datos de aprendizaje (t = 145 = enero de 1961):

double[] predictors = new double[] { 5.08, 4.61, 3.90, 4.32 };
double[] forecast = nn.ComputeOutputs(predictors); 
Console.WriteLine("Predicted for January 1961 (t=145): ");
Console.WriteLine((forecast[0] * 100).ToString("F0"));
Console.WriteLine("End time series demo");

Tenga en cuenta que, dado que el modelo de serie temporal se entrena con datos normalizados (divididos por 100), las predicciones también estarán normalizadas, así que la demostración muestra los valores predichos por 100.

Redes neuronales para análisis de series temporales

Al definir una red neuronal, debe especificar las funciones de activación que usan los nodos de nivel oculto y los nodos de nivel de salida. En resumen, recomiendo usar la función de tangente hiperbólica (tanh) para la activación oculta y la función de identidad para la activación de salida.

Si usa un sistema o biblioteca de red neuronal, como Microsoft CNTK o Azure Machine Learning, debe especificar explícitamente las funciones de activación. El programa de demostración codifica estas funciones de activación. El código clave se encuentra en el método ComputeOutputs. Los valores de los nodos ocultos se calculan como se indica a continuación:

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

for (int i = 0; i < numHidden; ++i)  // Add biases
  hSums[i] += this.hBiases[i];

for (int i = 0; i < numHidden; ++i)   // Apply activation
  this.hNodes[i] = HyperTan(hSums[i]); // Hardcoded

Aquí, la función HyperTan se define mediante un programa para evitar valores extremos:

private static double HyperTan(double x) {
  if (x < -20.0) return -1.0; // Correct to 30 decimals
  else if (x > 20.0) return 1.0;
  else return Math.Tanh(x);
}

Una alternativa razonable y común al uso de tanh para la activación de nodos ocultos consiste en usar la función sigmoid de logística relacionada. Por ejemplo:

private static double LogSig(double x) {
  if (x < -20.0) return 0.0; // Close approximation
  else if (x > 20.0) return 1.0;
  else return 1.0 / (1.0 + Math.Exp(x));
}

Dado que la función de identidad es simplemente f(x) = x, usarla para la activación de nodos de salida es una manera sofisticada de decir que no usa ninguna activación explícita. El código de demostración del método ComputeOutputs es:

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

for (int i = 0; i < numOutput; ++i)  // Add biases
  oSums[i] += oBiases[i];

Array.Copy(oSums, this.oNodes, oSums.Length);

La suma de productos de un nodo de salida se copia directamente en el nodo de salida sin aplicar ninguna activación explícita. Tenga en cuenta que el miembro oNodes de la clase NeuralNetwork es una matriz con una celda, en lugar de una sola variable.

La selección de funciones de activación afecta al código en el algoritmo de retropropagación implementado en el método Train. El método Train usa las derivadas de cálculo de cada función de activación. La derivada de y = tanh(x) es (1 + y) * (1 - y). En el código de demostración:

// Hidden node signals
for (int j = 0; j < numHidden; ++j) {
  derivative = (1 + hNodes[j]) * (1 - hNodes[j]); // tanh
  double sum = 0.0; 
  for (int k = 0; k < numOutput; ++k)
    sum += oSignals[k] * hoWeights[j][k]; 
  hSignals[j] = derivative * sum;
}

Si usa la activación sigmoid de logística, la derivada de y = logsig(x) es y * (1 - y). Para la activación de salida, la derivada de cálculo de y = x es solo la constante 1. El código relevante del método Train es:

for (int k = 0; k < numOutput; ++k) {
  errorSignal = tValues[k] - oNodes[k];
  derivative = 1.0;  // For Identity activation
  oSignals[k] = errorSignal * derivative;
}

Obviamente, multiplicar por 1 no tiene ningún efecto. He usado esta programación para que sirva a modo de documentación.

Resumen

Para realizar análisis de regresión de serie temporal, existen muchas técnicas que puede usar. El artículo de Wikipedia sobre el tema enumera decenas de técnicas, clasificadas de muchas formas, como paramétricas y no paramétricas, o lineales y no lineales. En mi opinión, la ventaja principal de usar un enfoque de red neuronal con datos de ventana con desplazamiento es que el modelo resultante suele ser (aunque no siempre) más preciso que los modelos no neuronales. El inconveniente principal del enfoque de red neuronal es que debe experimentar con el índice de aprendizaje para obtener buenos resultados.

La mayoría de técnicas de análisis de regresión de serie temporal usan datos de ventana con desplazamiento o un esquema similar. Sin embargo, existen técnicas avanzadas que pueden usar datos sin procesar sin necesidad de ventanas. Un enfoque concreto relativamente nuevo usa lo que se denomina red neuronal de memoria a largo y corto plazo. Este enfoque suele producir modelos predictivos muy precisos.


El 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. 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: John Krumm, Chris Lee y Adith Swaminathan


Discuta sobre este artículo en el foro de MSDN Magazine