2017 年 8 月

第 32 卷,第 8 期

测试运行 - 使用 C# 的深度神经网络 IO

作者 James McCaffrey

James McCaffrey机器学习的许多最新进展(如使用数据进行预测)已通过深度神经网络得到实现。例如,Microsoft Cortana 和 Apple Siri 中的语音识别,以及有助于实现无人驾驶汽车的图像识别。

深度神经网络 (DNN) 为常规术语,还有多个变体,包括递归神经网络 (RNN) 和卷积神经网络 (CNN)。我在本文中介绍的 DNN 最基本形式没有特殊名称。因此,我将它称为 DNN。

本文将介绍 DNN,因此最好有可以试验的具体演示程序,这将有助于了解有关 DNN 的介绍。我不会提供可直接用于生产系统的代码,但将提供可扩展以便创建此类系统的代码(如后所述)。即使从未打算实现 DNN,或许也会对 DNN 的工作原理介绍本身感兴趣。

直观解释 DNN 是最佳做法。请看一下图 1。深度网络在左侧有两个输入节点,值分别为 1.0、2.0。在右侧有三个输出节点,值分别为 0.3269、0.3333、0.3398。可以将 DNN 看作是复杂的数学函数:通常接受两个或多个数字输入值,并返回一个或多个数字输出值。

基本深度神经网络
图 1:基本深度神经网络

所示 DNN 对应于解决一个问题,旨在根据年龄和收入预测人员的政党派别(“Democrat”、“Republican”或“Other”)。其中,输入值以某种方式进行了缩放。如果“Democrat”编码为 (1,0,0),“Republican”编码为 (0,1,0),而“Other”编码为 (0,0,1),那么图 1 中的 DNN 预测年龄值为 1.0 且收入值为 2.0 的人员的政党派别为“Other”,因为最后一个输出值 (0.3398) 最大。

常规神经网络有一个隐藏的节点处理层。DNN 有两个或多个隐藏层,可以处理非常困难的预测问题。特殊化类型的 DNN(如 RNN 和 CNN)也有多个节点处理层,不同之处在于还有更复杂的连接体系结构。

图 1 中的 DNN 有三个隐藏的节点处理层。第一个隐藏层有四个节点,第二个和第三个隐藏层有两个节点。每个从左指向右的长箭头都表示一个数字常量(称为“权重”)。如果节点从图 1 顶部开始以零为基数(即为 [0])开始编制索引,那么将 input[0] 连接到 hidden[0][0](层 0 的节点 0)的权重值为 0.01,将 input[1] 连接到 hidden[0][3](层 0 的节点 3)的权重值为 0.08,依此类推。有 26 个节点到节点的权重值。

八个隐藏节点和三个输出节点都有一个表示数字常量(称为“偏差”)的小箭头。例如,hidden[2][0] 的偏差值为0.33,output[1] 的偏差值为 0.36。图中并未标注出所有的权重和偏差值,但由于值是 0.01 到 0.37 之间的顺序数,因此可以很容易地就确定未标注的权重或偏差值。

在下面各部分中,我将介绍 DNN 输入输出机制的工作原理,以及如何实现此机制。虽然演示程序是使用 C# 进行编码,但如果愿意,可以将此代码重构为其他语言(如 Python 或 JavaScript),应该不会遇到太多麻烦。演示程序因太长而无法在本文中全部展示,但可以在随附的代码下载内容中获取整个程序。

演示程序

了解本文所述观点的一个好方法是,研究图 2 中的演示程序屏幕截图。演示程序对应于图 1 中展示的 DNN,并通过显示网络中 13 个节点的值展示了输入输出机制。图 3 展示了生成输出的演示代码的开头。

基本深度神经网络演示运行
图 2:基本深度神经网络演示运行

图 3:输出生成代码的开头

using System;
namespace DeepNetInputOutput
{
  class DeepInputOutputProgram
  {
    static void Main(string[] args)
    {
      Console.WriteLine("Begin deep net IO demo");
      Console.WriteLine("Creating a 2-(4-2-2)-3 deep network");
      int numInput = 2;
      int[] numHidden = new int[] { 4, 2, 2 };
      int numOutput = 3;
      DeepNet dn = new DeepNet(numInput, numHidden, numOutput);

请注意,演示程序仅使用不含命名空间(System 除外)的纯 C#。DNN 的创建方式为,将每层中的节点数量传递给 DeepNet 程序定义类构造函数。隐藏层数量 3 作为 numHidden 数组中的项数进行隐式传递。备用设计是显式传递隐藏层数量。

26 个权重值和 11 个偏差值的设置如下:

int nw = DeepNet.NumWeights(numInput, numHidden, numOutput);
Console.WriteLine("Setting weights and biases to 0.01 to " +
  (nw/100.0).ToString("F2") );
double[] wts = new double[nw];
for (int i = 0; i < wts.Length; ++i)
  wts[i] = (i + 1) * 0.01; 
dn.SetWeights(wts);

权重和偏差的总数是使用静态类方法 NumWeights 进行计算。如果回头看看图 1,可以发现,由于每个节点都连接到右侧层中的所有节点,因此权重数量的计算公式为 (2*4) + (4*2) + (2*2) + (2*3) = 8 + 8 + 4 + 6 = 26。由于每个隐藏节点和输出节点都有一个偏差值,因此偏差总数的计算公式为 4 + 2 + 2 + 3 = 11。

名为 wts 的数组实例化为 37 个单元,再将值设置为介于 0.01 到 0.37 之间。这些值使用 SetWeights 方法插入 DeepNet 对象。在非演示的实际 DNN 中,权重和偏差值是根据一组包含已知输入值和已知正确输出值的数据进行确定。此过程称为“网络定型”。最常见的定型算法称为“反向传播”。

演示程序的 Main 方法代码结尾如下:

...
    Console.WriteLine("Computing output for [1.0, 2.0] ");
    double[] xValues = new double[] { 1.0, 2.0 };
    dn.ComputeOutputs(xValues);
    dn.Dump(false); 
    Console.WriteLine("End demo");
    Console.ReadLine();
  } // Main
} // Class Program

ComputeOutputs 方法接受一组输入值,再使用输入输出机制(我很快就会介绍)计算和存储输出节点值。Dump 帮助程序方法显示 13 个节点值,“false”参数表示不显示 37 个权重和偏差值。

输入输出机制

通过具体示例解释 DNN 的输入输出机制是最佳做法。第一步是使用输入节点值计算第一个隐藏层中的节点值。第一个隐藏层最顶部的隐藏节点值为:
tanh( (1.0)(0.01) + (2.0)(0.05) + 0.27 ) =
tanh(0.38) = 0.3627

用语言描述就是:“对每个输入节点与其相关权重的乘积进行求和,加上偏差值,再求总和的双曲正切值”。 双曲正切函数(缩写为 tanh)称为“激活函数”。双曲正切函数接受从负无穷大到正无穷大的任意值,返回介于 -1.0 到 +1.0 之间的值。重要的备用激活函数包括逻辑 Sigmoid 函数和线性整流 (ReLU) 函数,这两个函数都不在本文的介绍范围以内。

剩余隐藏层中节点值的计算方式完全相同。例如,hidden[1][0] 的节点值计算公式为:
tanh( (0.3627)(0.09) + (0.3969)(0.11) + (0.4301)(0.13) + (0.4621)(0.15) + 0.31 ) =
tanh(0.5115) = 0.4711
hidden[2][0] 的节点值计算公式为:
tanh( (0.4711)(0.17) + (0.4915)(0.19) + 0.33 ) =
tanh(0.5035) = 0.4649

输出节点值是使用不同的激活函数(即 softmax)进行计算。初步的预激活步骤都是一样的,即用乘积总和加上偏差值:
预激活 output[0] =
(.4649)(0.21) + (0.4801)(0.24) + 0.35 =
0.5628
预激活 output[1] =
(.4649)(0.22) + (0.4801)(0.25) + 0.36 =
0.5823
预激活 output[2] =
(.4649)(0.23) + (0.4801)(0.26) + 0.37 =
0.6017

对三个任意值 x、y、z 使用 softmax:
softmax(x) = e^x / (e^x + e^y + e^z)
softmax(y) = e^y / (e^x + e^y + e^z)
softmax(z) = e^z / (e^x + e^y + e^z)

其中,e 是欧拉数,约为 2.718282。因此,图 1**** 中 DNN 的最终输出值为:

output[0] = e^0.5628 / (e^0.5628 + e^0.5823 + e^0.6017) = 0.3269
output[1] = e^0.5823 / (e^0.5628 + e^0.5823 + e^0.6017) = 0.3333
output[2] = e^0.6017 / (e^0.5628 + e^0.5823 + e^0.6017) = 0.3398

softmax 激活函数旨在将输出值总和强制限制为 1.0,这样就可以将它们解读为概率并映射到分类值。在此例中,因为第三个输出值最大,所以编码为 (0,0,1) 的分类值就是 inputs = (1.0, 2.0) 的预测类别。

实现 DeepNet 类

为了创建演示程序,我启动了 Visual Studio,并选择了 C# 控制台应用程序模板,同时将它命名为 DeepNetInputOutput。我使用的是 Visual Studio 2015,但由于演示程序并不严重依赖 .NET,因此可以使用任意一版 Visual Studio。

在模板代码加载后,我在“解决方案资源管理器”窗口中右键单击了文件 Program.cs,并将它重命名为更具描述性的名称(即 DeepNetInputOutputProgram.cs),同时允许 Visual Studio 自动为我重命名类 Program。在编辑器窗口顶部,我删除了所有不必要的 using 语句,仅留下引用 System 命名空间的语句。

我将演示 DNN 作为 DeepNet 类进行实现。类定义代码的开头为:

public class DeepNet
{
  public static Random rnd; 
  public int nInput; 
  public int[] nHidden; 
  public int nOutput; 
  public int nLayers; 
...

为了简化实现,所有类成员都是通过公共作用域进行声明。DeepNet 类使用名为 rnd 的静态随机对象成员,将权重和偏差初始化为随机小值(再用介于 0.01 到 0.37 之间的值覆盖)。成员 nInput 和 nOuput 包含输入和输出节点的数量。数组成员 hHidden 包含每个隐藏层中的节点数量,因此隐藏层数量是由数组的 Length 属性提供(为方便起见,此数量存储在成员 nLayers 中)。类定义代码接下来为:

public double[] iNodes;
public double [][] hNodes;
public double[] oNodes;

深度神经网络实现有多个设计选项。与预期一样,数组成员 iNodes 和 oNodes 包含输入和输出值。二维数组成员 hNodes 包含隐藏节点值。备用设计是,将所有节点存储在一个二维数组结构 nnNodes 中。在演示网络中,nnNodes[0] 是输入节点值数组,nnNodes[4] 是输出节点值数组。

节点到节点权重使用以下数据结构进行存储:

public double[][] ihWeights; 
public double[][][] hhWeights;
public double[][] hoWeights;

成员 ihWeights 是一个二维数组式矩阵,用于包含输入节点到第一层隐藏节点的权重。成员 hoWeights 是一个二维数组式矩阵,用于包含最后一层隐藏节点到输出节点的权重。成员 hhWeights 是一个数组,其中每个单元都指向一个包含隐藏层节点到隐藏层节点权重的二维数组式矩阵。例如,hhWeights[0][3][1] 保留隐藏层 [0] 中隐藏节点 [3] 到隐藏层 [0+1] 中隐藏节点 [1] 的权重。这些数据结构是 DNN 输入输出机制的核心,同时也是学习难点。图 4 就是这些成员的概念图。

权重和偏差数据结构
图 4:权重和偏差数据结构

最后两个类成员包含隐藏节点偏差和输出节点偏差:

public double[][] hBiases;
public double[] oBiases;

与我使用过的其他任何软件系统一样,DNN 有许多备用数据结构设计。编写输入输出代码时,请务必绘制这些数据结构的草图。

计算权重和偏差的数量

若要设置权重和偏差值,必须先知道有多少个权重和偏差。演示程序实现静态方法 NumWeights,以计算并返回此数量。回顾一下,2-(4-2-2)-3 演示网络的权重数量为 (2*4) + (4*2) + (2*2) + (2*3) = 26,偏差数量为 4 + 2 + 2 + 3 = 11。方法 NumWeights 中的关键代码如下,可计算输入节点到隐藏层节点的权重数量、隐藏层节点到隐藏层节点的权重数量,以及隐藏层节点到输出节点的权重数量:

int ihWts = numInput * numHidden[0];
int hhWts = 0;
for (int j = 0; j < numHidden.Length - 1; ++j) {
  int rows = numHidden[j];
  int cols = numHidden[j + 1];
  hhWts += rows * cols;
}
int hoWts = numHidden[numHidden.Length - 1] * numOutput;

建议在一个两单元式整数数组中单独返回权重和偏差的数量,而不是像 NumWeights 方法一样返回权重和偏差的总数。

设置权重和偏差

非演示 DNN 通常将所有权重和偏差初始化为随机小值。演示程序使用类方法 SetWeights,将 26 个权重值设置为介于 0.01 至 0.26 之间,将偏差值设置为介于 0.27 至 0.37 之间。定义代码的开头如下:

public void SetWeights(double[] wts)
{
  int nw = NumWeights(this.nInput, this.nHidden, this.nOutput);
  if (wts.Length != nw)
    throw new Exception("Bad wts[] length in SetWeights()");
  int ptr = 0;
...

输入参数 wts 包含权重和偏差值,并假定具有正确的 Length。变量 ptr 指向 wts 数组。演示程序几乎不进行错误检查,以尽可能确保主题明确。输入节点到第一层隐藏节点的权重设置如下:

for (int i = 0; i < nInput; ++i) 
  for (int j = 0; j < hNodes[0].Length; ++j) 
    ihWeights[i][j] = wts[ptr++];

接下来,隐藏层节点到隐藏层节点的权重设置如下:

for (int h = 0; h < nLayers - 1; ++h) 
  for (int j = 0; j < nHidden[h]; ++j)  // From 
    for (int jj = 0; jj < nHidden[h+1]; ++jj)  // To 
      hhWeights[h][j][jj] = wts[ptr++];

如果不习惯使用多维数组,可能会觉得索引非常棘手。请务必绘制权重和偏差数据结构图(我也仍需要这样做)。最后一层隐藏节点到输出节点的权重设置如下:

int hi = this.nLayers - 1;
for (int j = 0; j < this.nHidden[hi]; ++j)
  for (int k = 0; k < this.nOutput; ++k)
    hoWeights[j][k] = wts[ptr++];

此代码的依据为,如果有 nLayers 个隐藏层(演示网络中为 3 个),那么最后一个隐藏层的索引为 nLayers-1。方法 SetWeights 最后设置隐藏节点偏差和输出节点偏差:

... 
  for (int h = 0; h < nLayers; ++h) 
    for (int j = 0; j < this.nHidden[h]; ++j)
      hBiases[h][j] = wts[ptr++];

  for (int k = 0; k < nOutput; ++k)
    oBiases[k] = wts[ptr++];
}

计算输出值

类方法 ComputeOutputs 定义代码的开头如下:

public double[] ComputeOutputs(double[] xValues)
{
  for (int i = 0; i < nInput; ++i) 
    iNodes[i] = xValues[i];
...

数组参数 xValues 包含输入值。类成员 nInput 包含输入节点的数量,此成员在类构造函数中进行设置。由于将 xValues 中的前 nInput 个值复制到输入节点中,因此假定 xValues 在首批单元中至少有 nInput 个值。接下来,将隐藏节点和输出节点中的当前值清零:

for (int h = 0; h < nLayers; ++h)
  for (int j = 0; j < nHidden[h]; ++j)
    hNodes[h][j] = 0.0;
 
for (int k = 0; k < nOutput; ++k)
  oNodes[k] = 0.0;

这样做是为了将乘积项的总和直接汇总到隐藏节点和输出节点,所以对于每个方法调用,都必须将这些节点显式重置为 0.0。备用方法是声明和使用包含 hSums[][] 和 oSums[] 等名称的本地数组。接下来,计算第一个隐藏层中的节点值:

for (int j = 0; j < nHidden[0]; ++j) {
  for (int i = 0; i < nInput; ++i)
    hNodes[0][j] += ihWeights[i][j] * iNodes[i];
  hNodes[0][j] += hBiases[0][j];  // Add the bias
  hNodes[0][j] = Math.Tanh(hNodes[0][j]);  // Activation
}

此代码几乎就是前述机制的一一映射。内置的 Math.Tanh 用于激活隐藏节点。正如之前所提到的,重要的备用函数包括逻辑 Sigmoid 函数和线性整流 (ReLU) 函数,我将在以后的文章中介绍这两个函数。接下来,计算剩余的隐藏层节点:

for (int h = 1; h < nLayers; ++h) {
  for (int j = 0; j < nHidden[h]; ++j) {
    for (int jj = 0; jj < nHidden[h-1]; ++jj) 
      hNodes[h][j] += hhWeights[h-1][jj][j] * hNodes[h-1][jj];
    hNodes[h][j] += hBiases[h][j];
    hNodes[h][j] = Math.Tanh(hNodes[h][j]);
  }
}

这是演示程序最为棘手的部分,主要是由于需要多个数组索引。接下来,计算输出节点的预激活乘积总和:

for (int k = 0; k < nOutput; ++k) {
  for (int j = 0; j < nHidden[nLayers - 1]; ++j)
    oNodes[k] += hoWeights[j][k] * hNodes[nLayers - 1][j];
   oNodes[k] += oBiases[k];  // Add bias 
}

方法 ComputeOutputs 最后应用 softmax 激活函数,在单独的数组中返回计算得出的输出值:

...     
  double[] retResult = Softmax(oNodes); 
  for (int k = 0; k < nOutput; ++k)
    oNodes[k] = retResult[k];
  return retResult; 
}

Softmax 方法是静态帮助程序。有关详情,请参阅随附的代码下载内容。请注意,由于 softmax 激活需要将进行激活的所有值(在分母项中),因此一次性计算所有 softmax 值更有效,而不要单独进行计算。最终的输出值存储到输出节点中,并且也会单独返回,以便进行调用。

总结

在过去几年里,开展了大量与深度神经网络相关的研究活动,并取得了许多突破。卷积神经网络、递归神经网络、LSTM 神经网络和残差神经网络等特殊化 DNN 的功能非常强大,但也十分复杂。我认为,了解基本 DNN 的工作原理是掌握更复杂变体的关键所在。

在以后的文章中,我将详细介绍如何使用反向传播算法(可以说是机器学习中最著名也是最重要的算法)定型基本 DNN。反向传播或此算法的至少一些形式也用于定型大多数的 DNN 变体。在介绍过程中,我将引入梯度消失概念,这反过来又可以解释现在用于非常复杂预测系统的许多 DNN 的设计和动机。


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

衷心感谢以下 Microsoft 技术专家对本文的审阅:**Li Deng、Pingjun Hu、Po-Sen Huang、Kirk Li、Alan Liu、Ricky Loynd、Baochen Sun、Henrik Turbell。


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