2017 年 10 月
第 32 卷,第 10 期
测试运行 - 使用 C# 神经网络的时序回归
时序回归问题旨在根据历史时间数据进行预测。例如,如果有一年或两年内的月度销售数据,建议预测下个月的销售情况。时序回归通常很难运行,可以使用多种不同的技术。
在本文中,我将展示如何结合使用滚动窗口数据和神经网络,从而执行时序回归分析。最好通过举例介绍这个想法。请查看图 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