2017 年 10 月

第 32 卷,第 10 期

测试运行 - 使用 C# 神经网络的时序回归

作者 James McCaffrey

James McCaffrey时序回归问题旨在根据历史时间数据进行预测。例如,如果有一年或两年内的月度销售数据,建议预测下个月的销售情况。时序回归通常很难运行,可以使用多种不同的技术。

在本文中,我将展示如何结合使用滚动窗口数据和神经网络,从而执行时序回归分析。最好通过举例介绍这个想法。请查看图 1 中的演示程序。演示程序分析了从 1949 年 1 月到 1960 年 12 月的每月航空旅客数。

滚动窗口时序回归演示

图 1:滚动窗口时序回归演示

演示数据来自 Internet 上许多地方都有的知名基准数据集,可以从本文随附的下载内容中获取。原始数据如下:

"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

共有 144 个原始数据项。第一个字段是年份和月份。第二个字段是每月国际航空旅客总人数,以千人为单位。演示程序创建定型数据的方法为,使用大小为 4 的滚动窗口生成 140 个定型项。定型数据的规范化方式为,用每个旅客计数除以 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

请注意,数据中删除了明确的时间值。第一个窗口由前四个用作预测指标值的旅客计数(1.12、1.18、1.32、1.29)组成,后跟第五个计数 (1.21),即要预测的值。下一个窗口由第二个到第五个计数(即下一组预测指标值 1.18、1.32、1.29、1.21)组成,后跟第六个计数 (1.35),即要预测的值。简单来说,就是使用每组连续四个旅客计数来预测下一个计数。

演示程序使用的神经网络包含 4 个输入节点、12 个隐藏的处理节点和 1 个输出节点。输入节点数量对应于滚动窗口中的预测指标数量。必须通过反复试验来确定窗口大小,这是此项技术的最大缺点。神经网络的隐藏节点数量也必须通过反复试验进行确定,神经网络一向如此。只有一个输出节点,因为时序回归预测只提前一个时间单位。

神经网络有 (4 * 12) + (12 * 1) = 60 个节点间权重,以及 (12 + 1) = 13 个偏差,这其实就定义了神经网络模型。借助滚动窗口数据,演示程序可以使用基本的随机反向传播算法对网络进行定型,其中学习率设置为 0.01,固定迭代次数设置为 10,000。

在定型过程中,演示程序每 2,000 次迭代就会显示一次预测输出值与正确输出值的均方误差。定型误差很难解释,之所以监视此误差主要是为了确定是否有非常奇怪的情况发生(这种情况相当常见)。在此示例中,定型误差似乎在大约 4,000 次迭代后开始趋于稳定。

完成定型后,演示代码显示 73 个权重和偏差值。再强调一遍,大部分情况下,这被用作健全性检查。对于时序回归问题,通常必须使用自定义准确度指标。在这种情况下,正确的预测结果为,未规范化的预测旅客计数与实际计数的差值不超过 30。根据这一定义,演示程序的准确度为 91.43%。也就是说,在 140 个预测旅客计数中,128 个正确,12 个不正确。

演示程序最后使用定型的神经网络,预测 1961 年 1 月(定型数据范围过后的第一时间段)的旅客计数。这就是所谓的“外推法”。预测的旅客计数为 433。此值可用作 1961 年 2 月旅客计数的预测指标变量,以此类推。

为了能够更好地理解本文,需要拥有中等或更高水平编程技能,并掌握神经网络方面的基本知识,但不必对时序回归了解得面面俱到。虽然演示程序是使用 C# 进行编码,但也可以将代码重构为其他语言(如 Java 或 Python),应该不会遇到太多麻烦。演示程序因太长而无法在本文中全部展示,但可以在本文随附的文件下载中获取整个源代码。

时序回归

时序回归问题通常用折线图表示,如图 2 所示。蓝线表示从 1949 年 1 月一直到 1960 年 12 月 144 个未规范化的实际旅客计数(以千人为单位)。浅红色的线表示神经网络时序模型生成的预测旅客计数。请注意,因为此模型使用的滚动窗口包含四个预测指标值,所以第一个预测旅客计数直到 5 月才出现。此外,我还在定型数据范围外,多预测了九个月的旅客计数。这些预测旅客计数用红色虚线表示。

时序回归折线图

图 2:时序回归折线图

除了能够在定型数据范围外进行预测外,时序回归分析还可用于发现异常的数据点。演示程序的旅客计数数据不存在这种问题。可以看到,预测计数与实际计数非常相近。例如,月份 t = 67 的实际旅客计数为 302(图 2 中心附近的蓝点),预测计数为 272。不过,如果假设月份 t = 67 的实际旅客计数为 400,则可以明显看出,月份 67 对应的实际旅客计数为离群值。

也可以使用编程方法,通过时序回归发现异常数据。例如,可以标记任何时间值,其中实际数据值与预测值的差值超过某固定阈值,如预测数据值与实际数据值的标准偏差的四倍。

演示程序

为了对演示程序进行编码,我启动了 Visual Studio,并新建了名为 Neural-TimeSeries 的 C# 控制台应用程序。虽然我使用的是 Visual Studio 2015,但演示程序并不非常依赖 .NET Framework,因此任何新发布的版本都可以正常运行。

在编辑器窗口中加载模板代码后,我右键单击了“解决方案资源管理器”窗口中的 Program.cs 文件,并将此文件重命名为“NeuralTimeSeriesProgram.cs”,然后就是允许 Visual Studio 为我自动重命名类 Program。在模板生成的代码顶部,我删除了所有不必要的 using 语句,仅留下引用顶级 System 命名空间的语句。

图 3**** 展示了整体程序结构(为节省空间,进行了少量小幅改动)。

图 3: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

演示程序使用从头开始实现的单一隐藏层简单神经网络。此外,也可以结合使用本文介绍的技术和神经网络库(如 Microsoft 认知工具包 (CNTK))。

演示程序从设置定型数据入手,如图 4 所示。

图 4:设置定型数据

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;
}

随后,对滚动窗口数据进行硬编码,窗口大小为 4。在编写时序程序之前,我编写了一个简短的实用工具程序,以根据原始数据生成滚动窗口数据。在大多数的非演示情况下,请从文本文件中读取原始数据,再以编程方式生成滚动窗口数据,其中窗口大小已经过参数化,可以试验不同的大小。

Normalize 方法直接用所有数据值除以常数 100。我这样做纯粹是出于实际原因。第一次尝试时,我使用的是非规范化数据,结果非常糟糕,但在执行规范化后,结果得到了很大的改善。从理论上讲,使用神经网络时,数据无需进行规范化,但在实践中,规范化往往会产生巨大影响。

下面创建了神经网络:

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

输入节点数量被设置为 4 个,因为每个滚动窗口都有四个预测指标值。输出节点数量被设置为 1 个,因为每组窗口值都用于预测下个月的情况。隐藏节点数量被设置为 12 个,这是通过反复试验确定的。

神经网络通过以下语句进行定型和评估:

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

Train 方法使用基本的反向传播算法。变体有许多,包括使用动量或自适应学习率提高定型速度,以及使用 L1/L2 正则化或 Dropout 防止模型过度拟合。帮助程序方法 ShowVector 显示矢量,其中实际值的格式为 2 位小数,每行 10 个值。

创建神经网络时序模型后,评估它的预测准确度:

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

对于时序回归,确定预测值的正确与否取决于所要调查的问题。对于航空旅客数据,如果未规范化的预测计数与实际原始计数的差值不超过 30,Accuracy 方法就会将预测旅客计数标记为正确。在演示数据中,t = 5 至 t = 9 的前五个预测计数都正确,但 t = 10 的预测计数不正确:

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

演示程序最后使用末尾四个旅客计数(t = 141 至 t = 144),预测定型数据范围外首个时间段(t = 145,即 1961 年 1 月)的旅客计数:

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");

请注意,由于时序模型是使用规范化数据(除以 100)进行定型,因此预测时也进行了规范化,所以演示程序在显示时用预测值乘以 100。

用于时序分析的神经网络

定义神经网络时,必须指定隐藏层节点和输出层节点使用的激活函数。简要地说,我建议使用双曲正切 (tanh) 函数激活隐藏层,并使用恒等函数激活输出层。

使用神经网络库或系统(如 Microsoft CNTK 或 Azure 机器学习)时,必须明确指定激活函数。演示程序会对这些激活函数进行硬编码。关键代码出现在 ComputeOutputs 方法中。下面计算了隐藏节点值:

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

随后,程序定义了 HyperTan 函数,以免出现极值:

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);
}

使用 tanh 激活隐藏节点 的常见合理替代做法是,使用密切相关的 Sigmoid 逻辑函数。例如,

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));
}

由于恒等函数仅为 f(x) = x,因此用它来激活输出节点,更花俏的说法其实就是不使用任何显式激活函数。ComputeOutputs 方法中的演示代码为:

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);

输出节点的项积之和被直接复制到输出节点中,而未应用任何显式激活函数。请注意,NeuralNetwork 类的 oNodes 成员是包含一个单元的数组,而不是一个变量。

选择的激活函数不同,方法 Train 中实现的反向传播算法代码也会不同。方法 Train 使用每个激活函数的微积分导数。y = tanh(x) 的导数为 (1 + y) * (1 - y)。演示代码如下:

// 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;
}

如果使用 Sigmoid 逻辑激活函数,y = logsig(x) 的导数为 y * (1 - y)。若要激活输出节点,y = x 的微积分导数直接就是常数 1。方法 Train 中的相关代码如下:

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

显然,乘以 1 没有任何影响。我之所以这样编码是出于文档展示目的。

总结

可以使用多种不同的技术,执行时序回归分析。关于此主题的维基百科文章列出了数十种技术,按多种方式进行分类,如参数化与非参数化以及线性与非线性。我认为,结合使用神经网络方法与滚动窗口数据的主要优势是,生成的模型通常(但不一定)比非神经模型更准确。神经网络方法的主要缺点是,必须反复试验学习率,才能获得理想的结果。

大多数时序回归分析技术都使用滚动窗口数据或类似方案。不过,也有可以使用原始数据(而无需窗口化)的高级技术。特别是,一种较新的方法使用所谓的长短期记忆神经网络。此方法生成的预测模型通常非常准确。


Dr.James McCaffrey 供职于华盛顿地区雷蒙德市沃什湾的 Microsoft Research。他参与过多个 Microsoft 产品的工作,包括 Internet Explorer 和必应。Scripto可通过 jamccaff@microsoft.com 与 McCaffrey 取得联系。

衷心感谢以下 Microsoft 技术专家对本文的审阅:John Krumm、Chris Lee 和 Adith Swaminathan


在 MSDN 杂志论坛讨论这篇文章