测试运行

程序员的神经网络反向传播

James McCaffrey

下载代码示例

James McCaffrey可以认为人造神经网络神经网络就是一个元功能,它能接收固定数目的数字输入并且生成固定数目的数字输出。在大部分情况下,神经网络有一个隐藏神经元层,它里面的隐藏神经元和输入神经元以及输出神经元完全连接。和每个单个隐层神经元以及每个单个输出神经元相关的是一组加权值和一个单个的偏置值。权重和偏置决定了一组既定输入值的输出值。

在使用神经网络来对现有数据进行建模以对新数据进行预测时,主要的挑战在于如何找到可以生成和现有数值最为匹配的输出值的一组权重和偏置值。判断最佳神经网络的权重和偏置的最常见技术称作反向传播。虽然很多优秀的参考资料描述了支撑反向传播的复杂数学应用,但是程序员可以使用的能清楚解释如何编程反向传播算法的参考指南却寥寥无几。本文解释了如何实现反向传播。我使用了C# 语言,但您应该能够轻松使用其他语言重构本文中的代码。

要想了解我所讲述的内容,最好是看一下图 1 所示演示程序的屏幕快照。该演示程序将创建一个神经网络,它具有三个输入神经元、一个有四个神经元的隐藏层以及两个输出神经元。带一个隐藏层的神经网络需要两个激活函数。然而,在很多情况下这两个激活函数是相同的,通常为sigmoid函数。但是在该演示程序中,为了说明激活函数和反向传播的关系,我将使用不同的激活函数: “输入到隐藏”计算的sigmoid函数,和“隐藏到输出”计算的tanh(双曲正切函数)函数。

Back-Propagation Algorithm in Action
图1 反向传播算法正在运行

一个完全连接的3-4-2神经网络要求 3*4 + 4*2 = 20个权重和4+2 = 6个偏置值,总计为26个权重和偏置。这些权重和偏置被初始化为随机数值。这三个虚拟输入值设定为1.2,2.0和3.0。根据该初始权重、偏置和输入值,神经网络计算出的初始输出值为{0.7225 和 -0.8779}。演示程序会随机将两个正确输出值假定为{-0.8500 和 0.7500}。反向传播算法的目的是找到一组新的权重和偏置,它们生成的输出值和输入值{1.0, 2.0, 3.0}的正确值要非常接近。

反向传播需要以下两个自由参数。学习率(在反向传播文献资料中通常以希腊字母“η”表示)控制算法收敛至最终估算值的速度。动量(通常以希腊字母“α”表示)帮助反向传播算法避免算法振荡并永远不会收敛至最终估算的情况出现。演示程序设置学习率为0.90,动量为0.04。这些数值通常通过反复试验得出。

找到神经网络的最佳权重和偏置的过程有时候被称作训练网络。带反向传播的训练是一个迭代的过程。在每次迭代中,反向传播算法都算出一组新的权重和偏置,它们在理论上可以生成和目标值更接近的输出值。在演示程序的第一次训练迭代后,反向传播算法已经找到了一组新的权重和偏置,它们生成的新的输出值为{-0.8932, -0.8006}。新的第一个输出值-0.8932和第一个目标输出值-0.8500更加接近。第二个新的输出值-0.8006和它的目标输出值0.7500相去依然甚远。

可以有多种方式来终止训练进程。演示程序进行迭代训练直到输出值和目标值的绝对差总和<= 0.01,或者训练达到1000次迭代。在演示中,经过六次迭代训练,反向传播算法找到了一组神经网络权重和偏置,它们生成的输出值{-0.8423, 0.7481}和希望的目标值非常接近。

本文假定您具有专家级的编程技能,并您对于神经网络已经有了基础的理解。神经网络相关基础信息,请参阅我于2012 年5月的文章《了解神经网络》,网址为 msdn.microsoft.com/magazine/hh975375。 演示程序中图 1 所示的代码有点过长不便在本文中出现,因此,我将着重解释算法中的关键部分。可以从 code.msdn.microsoft.com/mag0411BeeColony 获取演示程序的完整源代码。

定义神经网络类

为使用反向传播的神经网络编写代码将会使该网络有效适用面向对象方法。演示程序中使用的类定义显示在图 2

图 2 神经网络类

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

成员区域、数字输入、隐藏数字和数字输出都定义了神经网络构造的特点。 除了一个简单的构造器外,类还有四个可以访问的方法和两个助手方法。 方法更新权重包含反向传播算法的所有逻辑。 方法SetWeights接受一组权重和偏移值列阵,并且将这些数据按顺序复制到成员列阵中。 方法Getweight通过将复制权重和偏置复制到一个单一列阵中并恢复该列阵的方式进行反向操作。 方法ComputeOutput使用当前输入、权重和偏置值决定神经网络的输出值。

“输入到隐藏”激活函数中使用的是方法SigmoidFunction。 它接受真实数值(在C#中输入两位)并在0.0和1.0之间给出返回值。 方法HyperTanFunction也接受真实数值(在C#中输入两位),但是它在-1.0和+1.0之间给出返回值。 C#语言有内置的双曲正切函数,Math.Tanh,但是如果您使用的语言没有自带的tanh函数,那您就不得不从零开始写一个。

建立列阵

成功编写神经网络反向传播算法的一个重要条件是要深刻理解列阵,它们被用来储存权重和偏置值,不同的输入和输出值,上一次迭代算法数值和scratch运算。 图3 中的大图包含了编写反向传播程序所需要了解的所有信息。 您对 图3 的最初反应很可能是“算了吧,这太复杂了“。请坚持,不要放弃。 反向传播是不容易,但是一旦您理解了这个图表,您就可以使用任何编程语言实现反向传播了。

The Back-Propagation Algorithm
图3 反向传播算法

图3 在图表边缘有主要输入和输出,但是同时在图表内部也有本地输入和输出值。 您不应该低估编写神经网络代码和保持所有输入和输出的名称和意思清楚统一的难度。 根据我的经验,像图3那样的图表绝对是很有必要的。

 图2 中列出的神经网络定义中使用的15个列阵中前五个涉及”输入到隐藏“层,它们是:

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

第一个列阵叫”输入“,包含数字输入值。 这些数值一般都是从规范化的数据源(例如文本文件)中直接得来。 NeuralNetwork 的构造函数将输入举例说明为:

this.inputs = new double[numInput];

列阵ihWeights(”输入到隐藏“权重)是一个虚拟的二维数组,它被用作数组的数组。 第一个索引表示输入神经元,第二个索引表示隐藏神经元。 构造函数将列阵举例说明为:

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

在这里Helpers是一种帮助简化神经网络类静态法的应用类:

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

列阵ihSums是被用来储存ComputeOutputs方法中的中间计算的一种scratch列阵。 列阵储存数据,这些数据将变成隐藏神经元的本地输入并被举例说明为:

this.ihSums = new double[numHidden];

列阵ihBiases储存隐藏神经元的偏置值。 神经网络权重值是常数,用它们和本地输入值相乘。 添加偏置值至中间总和以生成一个本地输出值,该本地输出值将变成下一层的本地输入。 列阵ihBiases被举例说明为:

this.ihBiases = new double[numHidden];

列阵ihOutputs储存从隐藏层神经元(它们将变成输出层的输入)释放的数值。

NeuralNetwork以下四个列阵储存和”隐藏到输出“层的数值:

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

这四个列阵被在构造器中举例说明为:

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

神经网络类有六个和反向传播算法直接连接的列阵。 前两个列阵储存叫做”输出和隐藏层神经元梯度“的数据。 梯度是间接描述本地输出相对目标输出的距离和方向(正向或者负向)的一个数值。 通常使用梯度来计算变量值,变量值被添加到当前权重和偏置值以生成新的、更好的权重和偏置。 每个隐藏层神经元和输出层神经元都有一个梯度值。 列阵被声明为:

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

这些列阵被在构造器中举例说明为:

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

类NeuralNetwork的最后四个列阵储存训练循环的上一次迭代的变量(而不是梯度)。 如果您使用动量原理来方式反向传播的不收敛性,则需要这些以前的变量。 我认为动量是有必要的,但是如果您决定不实施动量,那么您可以忽略这些列阵。 它们被声明为:

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

这些列阵被举例说明为:

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

计算输出

图1 中显示的训练循环的每个迭代都有两部分。 在第一部分中,使用当前的初级输入、权重和偏置计算输出。 在第二部分中,使用反向传播来更改权重和偏置。 图 3 说明了训练进程的这两部分。

从左向右工作,将数值1.0,2.0和3.0分配给输入值 x0,x1和x2。 这些初级输入值进入输入层神经元并不加修改地被释放出来。 虽然输入层神经元可以修改它们的输入(例如:将数值规范至一定的范围内),但是这类处理还是通过外部来完成的。 正因如此,神经网络图表通常使用矩形或者正方形方框表示输入神经元来说明它们没有在像隐藏层和输出层神经元那样处理神经元。 另外,这也影响使用的术语。 在有的情况下,图3 中所示的神经网络被称作”三层网络“,但是因为输入层并不执行处理操作,因此所示的神经网络有时也被称作”两层网络“。

下一点,每个隐藏层神经元都可以计算本地输入和本地输出。 例如:最底下索引编号为[3]的隐藏神经元计算它的scratch总和为 (1.0)(0.4)+(2.0)(0.8)+(3.0)(1.2) = 5.6。 Scratch总和是通过三个输入乘以相关的”输入到隐藏“权重的结果。 每个箭头上方的数值即为权重。 下一点,偏置值-7.0被添加到scratch总和以生成一个本地输入值5.6 + (-7.0) = -1.40。 然后将”输入到隐藏“激活函数应用至这个中间输入值上以生成神经元的本地输出值。 在这种情况下,该激活函数即为sigmoid函数,因此本地输出为 1 / (1 + exp(-(-1.40))) = 0.20。

输出层神经元也通过类似方法计算它们的输入和输出。 例如:在图3中,最下面索引编号为[1]的输出层神经元计算它的scratch总和为 (0.86)(1.4)+(0.17)(1.6)+(0.98)(1.8)+(0.20)(2.0) = 3.73。 添加相关偏置以生成本地输入: 3.73 + (-5.0) = -1.37. 应用激活函数来生成初级输出: tanh(-1.37) = -0.88。 如果您检查ComputeOutputs编码,您就会发现这一方法计算输出的方法和我刚描述的一模一样。

反向传播

虽然反向传播理论背后的数学函数是比较负责的,但是一旦你知道这些数学函数的结果后,应用反向传播并不是特别困难。 反向传播工作是在图3中从左向右进行。 第一步是计算每个输出层神经元的梯度值。 温习一下,梯度是有关某个错误的大小和方向信息的一个值。 输出层神经元梯度的计算方法和隐藏层神经元梯度的计算方法是不一样的。

输出层神经元梯度等于目标(期望)值减去计算所得输出值,乘以在计算输出值时预计的输出层激活函数的微积分倒数。 例如:在图3中,最下面索引编号为[1]的输出层神经元的阶梯至计算为:

(0.75 – (-0.88)) * (1 – (-0.88)) * (1 + (-0.88)) = 0.37   

0.75是期望值。 -0.88是从由前推移计算得出的输出值。 回忆一下,在这个例子输出层激活函数为tanh函数。 tanh(x)的微积分倒数为(1 - tanh(x)) * (1 + tanh(x))。 数学函数分析是有点绕,但是,最终,计算一个输出层神经元是通过这里给出的方程式完成的。

隐藏层神经元梯度等于在本地输出值预计隐藏层激活函数的微积分倒数乘以初级输出值总数,再乘以相关的”隐藏到输出“权重。 例如:在图3中,最下面索引编号为[3]的输出层神经元的阶梯至计算为:

(0.20)(1 – 0.20) * [ (-0.76)(1.9) + (0.37)(2.0) ] = -0.03

如果我们将sigmoid函数叫做g(x),那么sigmoid函数的微积分倒数就是g(x) * (1 - g(x))。 注意在这个例子中,”输入到隐藏“激活函数使用的是sigmoid函数。 这里0.20是神经中的元本地输出。 -0.76和0.37是输出层神经元的梯度,1.9和2.0是和两个输出层梯度相关的”隐藏到输出“权重的梯度。

正在计算权重和偏置变量

在计算完所有的输出层梯度和隐藏层梯度后,反向传播的下一步就是使用这些梯度值来计算每个权重和偏置值的变量。 梯度必须从右向左进行计算,和它不同的是变量值可以以任何顺序进行计算。 任何权重和偏置变量值都等于η乘以和权重和偏置,再乘以和权重或者偏置相关的输入值。 例如,从输入神经元到隐藏神经元“输入到隐藏”变量值是:

delta i-h weight[2][3] = eta * hidden gradient[3] * input[2]
= 0.90 * (-0.11) * 3.0
= -0.297

0.90是η,它控制了反向传播学习速度。 η值过大就会造成变量变化值过大,这就会有错过最佳答案的风险。 -0.11值是隐藏神经元[3]的梯度。 3.0值是输入神经元[2]的输入值。 就 图3中的图表而言,如果从一个神经元到另一个神经元之间的箭头代表权重的话,如果要计算特定权重的变量,您要使用右边被指向神经元的梯度值和左边被指向的神经元的输入值。

在计算偏置值的变量的时候,请注意因为只是简单地将偏置值添加到中间总和中,所以它们没有相关的输入值。 因此,要计算一个偏置值的变量,您可以直接忽略输入值项,或者使用一个虚拟的1.0值作为文档记录之用。 例如:在图3中,最下面的隐藏层偏置值为-7.0。 该偏置值的变量为:

0.90*被指向的神经元梯度*1.0
= 0.90 * (-0.11) * 1.0
= 0.099

添加一个动量项

在计算出所有权重和偏置值以后,就可以通过简单地添加相关变量值的方法来更新每个权重和偏置了。 但是神经网络相关经验表明,对于有的数据组,反向传播有可能出现震荡并且不断地出现超调,然后错过目标值并永远都不能收敛到最终的权重和偏置预计值。 一种减少这种趋势的技术就是给每个新权重和偏置添加一个叫做动量的新项目。 权重(或者偏置)的动量只是一个很小的数值(像演示程序里的0.4)乘以权重的上一个变量值。 使用动量给反向传播算法增加了一点儿复杂度,因为必须要储存上一个变量值。 这一技术为什么可以阻止振荡背后的数学函数是非常微妙的,但是结果却很简单。

总而言之,使用反向算法更新权重(或者偏置)的第一步就是计算出所有输出层神经元的梯度。 第二部是计算出所有隐藏层神经元的梯度。 第三部是使用η(学习率)计算出所有权重变量。 第四步是将变量添加给每个权重。 第五步是给每个权重添加一个动量。

使用 Visual Studio 2012 编写代码

本文中展示的反向传播解释以及范例代码应该已经给您提供了足够的信息来理解和编写反向传播算法的代码了。 法相传播只是几种可以被用来估算数据组的最佳权重和偏置值的几种技术中的一种。 和其他方法(例如:粒子群优化法和渐进优化算法)相比,反向传播一般更快。 但是,反向传播也是有不足之处的。 它不能用作使用了非可微激活函数的神经网络。 决定学习率和动量参数最佳值与其说是科学还不如说是一门艺术,它需要消耗很多时间。

有几个重要的话题本文并没有提及,比如如何处理多目标数据项目。 我会在以后的文章中来解释这个概念以及其他的神经网络技术。

我在编写本文中演示程序的时候使用了beta版的Visual Studio 2012。 虽然Visual Studio 2012的很多新功能是和Windows 8的应用程序相关联的,但是我还是想看看Visual Studio 2012处理一些比较好的控制台应用程序的能力怎么样。 我感到很愉快的是Visual Studio 2012的新功能并没有给我带来不愉快的体验。 我毫不费力地就转换到了Visual Studio 2010。 虽然我没有使用Visual Studio 2012新的Async功能,但是它在计算每个权重和偏置时本来可以很有帮助的。 我试用了新的Call Hierarchy功能,觉得它很有帮助,而且很直观。 我对Visual Studio 2012的最初印象还是比较好的,我也计划尽可能快的转换到Visual Studio 2012。

James McCaffrey博士 供职于 Volt Information Sciences, Inc.,在该公司他负责管理对华盛顿州雷蒙德市沃什湾 Microsoft 总部园区的软件工程师进行的技术培训。 他参与过多项 Microsoft 产品的研发工作,其中包括 Internet Explorer 和 MSN Search。 他是《.NET Test Automation Recipes》(Apress, 2006) 的作者,您可以通过以下电子邮箱地址与他联系:jammc@microsoft.com

衷心感谢以下技术专家对本文的审阅: Dan Liebling