深入了解神经网络
人工神经网络(通常简称为神经网络)指的是松散模拟生物神经元和神经突触的一种抽象方式。 虽然神经网络已经研究了数十年,但在我看来,Internet 上的很多神经网络代码实现方案至今没有提供很好的说明。 在本月的专栏中,我将解释什么是神经网络并提供实现神经网络的 C# 代码。
要想了解我在此讲述的内容,最好是看一下图 1 和图 2。 思考神经网络的一种方式是将其视为数字输入/输出机制。 图 1 所示的神经网络有三个输入,分别标记为 x0、x1 和 x2,并且分别具有值 1.0、2.0 和 3.0。 该神经网络有两个输出,分别标记为 y0 何 y1,并且分别具有值 0.72 和 -0.88。 图 1 所示的神经网络有一个所谓的隐藏神经元层,并且可被描述成一个包含三个输入、两个输出和四个隐藏神经元的完全连接的三层前馈网络。 可惜的是,神经网络术语改变了不少。 在本文中,我使用的术语一般和 bit.ly/wfikTI 上“优秀的神经网络常见问题解答”中所述的术语保持一致,但也有例外。
图 1 神经网络结构
图 2 神经网络演示程序
图 2 显示的是本文中介绍的演示程序产生的输出。 神经网络采用 S 形激活函数和双曲正切激活函数。 在图 1 中,这两个函数分别表示为一个含有希腊字母 phi 的方程式。 神经网络产生的输出取决于一组数字权重和偏移的值。 在本例中,共有 26 个权重和偏移,值分别为 0.10、0.20 ... -5.00. 当权重和偏移值加载到神经网络中后,演示程序会加载这三个输入值(1.0、2.0 和 3.0),然后执行一系列计算,如消息提示的“输入到隐藏”运算和“隐藏到输出”运算。 演示程序最后会显示两个输出值(0.72 和 -0.88)。
我将向您详细介绍产生图 2 所示输出的程序。 本专栏假定您具备中级编程技能,但不了解神经网络。 该演示程序使用 C# 语言进行编码,但您应该能够轻松使用其他语言重构演示代码,如 Visual Basic .NET 或 Python。 本文中提供的程序本质上属于教程和实验平台;并不能直接解决任何实际问题,因此我还将介绍如何扩展该代码以解决具有实际意义的问题。 我相信,您会发现本文非常有趣,而且部分编程技巧对您的编码技能不无裨益。
为神经网络建模
从概念上说,人工神经网络模拟了真正的生物神经网络的行为。 在图 1 中,圆圈表示执行处理的神经元,箭头则表示信息流以及称为权重的数值。 在很多情况下,输入值不含任何权重,而是直接复制到输入神经元中,并且不进行任何处理便直接发出,因此,最早的实际操作发生在隐藏层神经元中。 假设输入值 1.0、2.0 和 3.0 已从输入神经元发出。 如果您仔细查看图 1,您会看到一个箭头,表示三个输入神经元与四个隐藏神经元之间一一对应的权重值。 假设显示的三个指向顶部隐藏神经元的权重箭头分别命名为 w00、w10 和 w20。 在这种记法中,第一个索引表示源输入神经元的索引,第二个索引则表示目标隐藏神经元的索引。 神经元处理共有三个步骤。 第一步,计算出加权和。 假设 w00 = 0.1、w10 = 0.5 且 w20 = 0.9。 则顶部隐藏神经元的加权和则为 (1.0)(0.1) + (2.0)(0.5) + (3.0)(0.9) = 3.8。 第二步是添加偏移值。 假设偏移值为 -2.0;则调整后的加权和变为 3.8 + (-2.0) = 1.8。 第三步是向调整后的加权和应用一个激活函数。 假设激活函数是由 1.0 / (1.0 * Exp(-x)) 定义的 Sigmoid 函数,其中 Exp 表示指数函数。 隐藏神经元的输出则变为 1.0 / (1.0 * Exp(-1.8)) = 0.86。 此输出将成为输入到每个输出层神经元中的加权和的一部分。 在图 1 中,这三个步骤通过含有希腊字母 phi 的方程式表示: 计算出加权和 (xw)、添加偏移 (b) 并应用激活函数 (phi)。
当所有隐藏神经元的值都计算出后,将以同样的方式计算输出层神经元的值。 用来计算输出神经元值的激活函数可以跟用来计算隐藏神经元值的函数完全相同,也可以与其不同。 图 2 中运行的演示程序使用双曲正切函数作为“隐藏到输出”激活函数。 当计算出所有的输出层神经元值后,在大多数情况下,不会对这些值进行加权或处理,而只是简单地作为神经网络的最终输出值发出。
内部结构
理解本文介绍的神经网络实现方案的关键是仔细研究图 3,乍一看,您可能会觉得非常复杂。 但是请先忍一下,这个图形其实没那么复杂,产生这种感觉的原因只是因为它才第一次出现。 图 3 展示了全部八个数组和两个矩阵。 第一个数组标记为 this.inputs。 此数组存放了神经网络输入值,在本示例中,这些值为 1.0、2.0 和 3.0。 接下来是用于在所谓的“隐藏层”计算值的一组权重值。 这些权重存储在 3 x 4 的矩阵中,并标记为 i-h weights,其中 i-h 表示“输入到隐藏”。 请注意,图 1 中的演示神经网络有四个隐藏神经元。 i-h weights 矩阵中的行数与输入的数量相同,列数则与隐藏神经元的数量相同。
标记为 i-h sums 的数组是一个用于计算的临时数组。 请注意,i-h sums 数组的长度总是与隐藏神经元的数量保持一致(在本例中为四个)。 接下来是标记为 i-h biases 的数组。 神经网络偏移是用于计算隐藏和输出层神经元的额外权重。 i-h biases 数组的长度与 i-h sums 数组的长度相同,因此也和隐藏神经元的数量相同。
标记为 i-h outputs 的数组是一个中间结果,此数组中的值将用作下一层的输入。 i-h sums 数组的长度与隐藏神经元的数量相同。
接下来是标记为 h-o weights 的矩阵,其中 h-o 表示“隐藏到输出”。 在这里,h-o weights 矩阵的大小为 4 x 2,因为一共有四个隐藏神经元和两个输出。 h-o sums 数组、h-o biases 数组和 this.outputs 数组的长度均与输出的数量相同(在本例中为两个)。
图 3 底部标记为 weights 的数组存放了“输入到隐藏”和“隐藏到输出”的所有权重和偏移。 在本例中,weights 数组的长度为 (3 * 4) + 4 + (4 * 2) + 2 = 26。一般来说,如果 Ni 表示输入值的数量,Nh 表示隐藏神经元的数量,No 表示输出值的数量,则 weights 数组的长度为 Nw = (Ni * Nh) + Nh + (Nh * No) + No。
计算输出
当创建好上述八个数组和两个矩阵后,神经网络即可根据输入、权重和偏移计算输出。 第一步是将输入值复制到 this.inputs 数组中。 接下来是为 weights 数组赋值。 在本演示中,您可以使用任何权重值。 然后,weights 数组中的值将会复制到 i-h weights 矩阵、i-h biases 数组、h-o weights 矩阵和 h-o biases 数组。 图 3 清晰显示了这一关系。
计算 i-h sums 数组中的值包括两个步骤。 第一步,将 inputs 数组中的值乘以 i-h weights 矩阵中适当列的值,计算出加权和。 例如,隐藏神经元的加权和 [3](这里我使用的是零基索引)就是将每个输入值乘以 i-h weights 矩阵中第 [3] 列的值所得的结果: (1.0)(0.4) + (2.0)(0.8) + (3.0)(1.2) = 5.6. 计算 i-h sum 值的第二步是将每个偏移值添加到当前的 i-h sum 值。 例如,由于 i-h biases [3] 的一个值为 -7.0,则 i-h sums [3] 的值就变成 5.6 + (-7.0) = -1.4。
当 i-h sums 数组中的所有值均计算出后,再将“输入到隐藏”激活函数应用到这些和,产出“输入到隐藏”的输出值。 在这里,您可以使用多种不同的激活函数。 其中最简单的激活函数是阶跃函数,该函数只是简单地对所有大于零的输入值返回 1.0,而对小于或等于零的输入值返回 0.0。 另一种常见的并且也是本文中所使用的激活函数是 Sigmoid 函数,定义为 f(x) = 1.0 / (1.0 * Exp(-x))。 Sigmoid 函数的图形如图 4 所示。
图 4 Sigmoid 函数
请注意,Sigmoid 函数返回的值严格限制在 0 到 1 之间(不含 0 和 1)。 在本例中,如果 i-h sums [3] 的值在添加偏移值后变成 -1.4,则 i-h outputs [3] 的值将变为 1.0 / (1.0 * Exp(-(-1.4))) = 0.20。
当所有“输入到隐藏”输出神经元值均计算出后,这些值将用作“隐藏到输出”层神经元计算的输入。 这些计算过程与“输入到隐藏”计算过程相同: 先计算出加权和,然后添加偏移,最后应用某个激活函数。 在本例中,我使用双曲正切函数(简称为 tanh)作为“隐藏到输出”激活函数。 tanh 函数与 Sigmoid 函数的关系十分密切。 tanh 函数的图形是一个与 Sigmoid 函数颇为相似的 S 形曲线,但 tanh 函数的返回值范围是 (-1,1),而不是 (0,1)。
组合权重和偏移
我在 Internet 上看到的所有神经网络实现方案都没有维护单独的权重和偏移数组,而是将权重和偏移组合到权重矩阵中。 这是如何实现的? 回想一下“输入到隐藏”神经元 [3] 的值的计算过程:(i0 * w03) + (i1 * w13) + (i2 * w23) + b3,其中 i0 是输入值 [0],w03 是输入 [0] 和神经元 [3] 的权重,而 b3 是隐藏神经元 [3] 的偏移值。 如果您创建另一个假的输入 [4] 并且包含一个虚拟值 1.0,以及另一个包含偏移值的权重行,则刚刚讲到的计算就变为: (i0 * w03) + (i1 * w13) + (i2 * w23) + (i3 * w33),其中 i3 是虚拟的 1.0 输入值,w33 是偏移。 有人认为,这种方法简化了神经网络模型。 我不同意这种说法。 在我看来,组合权重和偏移使得神经网络模型更加难以理解,并且在实现过程中更容易出错。 但是,很显然,只有我是这么想的,因此您应该自行做出设计决策。
实现
我使用 Visual Studio 2010 实现了图 1、图 2 和图 3 所示的神经网络。我还创建了一个名为 NeuralNetworks 的 C# 控制台应用程序。 在解决方案资源管理器窗口中,我右键单击 Program.cs 文件并将其重命名为 NeuralNetworksProgram.cs,这也将模板生成的类名改成了 NeuralNetworksProgram。 删除了大部分 WriteLine 语句的程序整体结构如图 5 所示。
图 5 神经网络程序结构
using System;
namespace NeuralNetworks
{
class NeuralNetworksProgram
{
static void Main(string[] args)
{
try
{
Console.WriteLine("\nBegin Neural Network demo\n");
NeuralNetwork nn = new NeuralNetwork(3, 4, 2);
double[] weights = new double[] {
0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 1.1, 1.2,
-2.0, -6.0, -1.0, -7.0,
1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2.0,
-2.5, -5.0 };
nn.SetWeights(weights);
double[] xValues = new double[] { 1.0, 2.0, 3.0 };
double[] yValues = nn.ComputeOutputs(xValues);
Helpers.ShowVector(yValues);
Console.WriteLine("End Neural Network demo\n");
}
catch (Exception ex)
{
Console.WriteLine("Fatal: " + ex.Message);
}
}
}
class NeuralNetwork
{
// Class members here
public NeuralNetwork(int numInput, int numHidden, int numOutput) { ... }
public void SetWeights(double[] weights) { ... }
public double[] ComputeOutputs(double[] xValues) { ... }
private static double SigmoidFunction(double x) { ... }
private static double HyperTanFunction(double x) { ... }
}
public class Helpers
{
public static double[][] MakeMatrix(int rows, int cols) { ... }
public static void ShowVector(double[] vector) { ... }
public static void ShowMatrix(double[][] matrix, int numRows) { ... }
}
} // ns
我删除了所有模板生成的 using 语句,除了引用 System 命名空间的一条语句。 在 Main 函数中,显示了开始消息后,我使用三个输入、四个隐藏神经元和两个输出实例化了一个名为 nn 的 NeuralNetwork 对象。 接着,我向一个名为 weights 的数组分配了 26 个任意权重和偏移。 然后,我使用 SetWeights 方法将这些权重加载到神经网络对象中。 我将值 1.0、2.0 和 3.0 分配到 xValues 数组中。 我使用 ComputeOutputs 方法将输入值加载到神经网络中,并确定所产生的输出,然后将这些输出提取到 yValues 数组中。 演示程序最后会显示输出值。
NeuralNetwork 类
NeuralNetwork 类定义开始为:
class NeuralNetwork
{
private int numInput;
private int numHidden;
private int numOutput;
...
如同前面几部分所介绍的那样,神经网络的结构取决于输入值、隐藏层神经元和输出值的数量。 接着,类定义如下:
private double[] inputs;
private double[][] ihWeights; // input-to-hidden
private double[] ihSums;
private double[] ihBiases;
private double[] ihOutputs;
private double[][] hoWeights; // hidden-to-output
private double[] hoSums;
private double[] hoBiases;
private double[] outputs;
...
这七个数组和两个矩阵对应到图 3 所示的数组和矩阵。 我为“输入到隐藏”数据增加了一个 ih 前缀,为“隐藏到输出”数据增加了一个 ho 前缀。 回想一下 ihOutputs 数组中用作输出层计算的输入的值,所以为此数组命名有点麻烦。
图 6 显示了如何定义 NeuralNetwork 类的构造函数。
图 6 NeuralNetwork 类的构造函数
public NeuralNetwork(int numInput, int numHidden, int numOutput)
{
this.numInput = numInput;
this.numHidden = numHidden;
this.numOutput = numOutput;
inputs = new double[numInput];
ihWeights = Helpers.MakeMatrix(numInput, numHidden);
ihSums = new double[numHidden];
ihBiases = new double[numHidden];
ihOutputs = new double[numHidden];
hoWeights = Helpers.MakeMatrix(numHidden, numOutput);
hoSums = new double[numOutput];
hoBiases = new double[numOutput];
outputs = new double[numOutput];
}
将输入参数值 numInput、numHidden 和 numOutput 复制到它们各自对应的类字段中后,全部九个成员数组和矩阵都将分配到我前面所说的大小。 我将矩阵实现为包含数组的数组,而没有使用 C# 的多维数组类型,这样您就更容易将我的代码重构为不支持多维数组类型的语言。 由于我的矩阵的每一行都必须分配,因此使用帮助器方法会比较方便,如 MakeMatrix。
SetWeights 方法接受一组权重和偏移值,并且填充 ihWeights、ihBiases、hoWeights 和 hoBiases。 该方法的开头如下所示:
public void SetWeights(double[] weights)
{
int numWeights = (numInput * numHidden) +
(numHidden * numOutput) + numHidden + numOutput;
if (weights.Length != numWeights)
throw new Exception("xxxxxx");
int k = 0;
...
如前所述,在完全连接的前馈神经网络中,权重和偏移的总数 Nw 为 (Ni * Nh) + (Nh * No) + Nh + No。我可以简单检查一下,看看 weights 数组参数的长度是否正确。 此处,“xxxxxx”表示描述性错误消息。 接着,我将索引变量 k 初始化为 weights 数组参数的开头。 SetWeights 方法最后为:
for (int i = 0; i < numInput; ++i)
for (int j = 0; j < numHidden; ++j)
ihWeights[i][j] = weights[k++];
for (int i = 0; i < numHidden; ++i)
ihBiases[i] = weights[k++];
for (int i = 0; i < numHidden; ++i)
for (int j = 0; j < numOutput; ++j)
hoWeights[i][j] = weights[k++];
for (int i = 0; i < numOutput; ++i)
hoBiases[i] = weights[k++]
}
weights 数组参数中的每个值将依次复制到 ihWeights、ihBiases、hoWeights 和 hoBiases 中。 请注意,不会有任何值复制到 ihSums 或 hoSums 中,因为这两个从头开始的数组将用于计算。
计算输出
NeuralNetwork 类的核心是 ComputeOutputs 方法。 此方法异常简短,开始为:
public double[] ComputeOutputs(double[] xValues)
{
if (xValues.Length != numInput)
throw new Exception("xxxxxx");
for (int i = 0; i < numHidden; ++i)
ihSums[i] = 0.0;
for (int i = 0; i < numOutput; ++i)
hoSums[i] = 0.0;
...
首先,我将检查一下输入 xValues 数组的长度是否适合 NeuralNetwork 对象。 然后将 ihSums 和 hoSums 数组清零。 如果仅调用一次 ComputeOutputs,则不需要进行此显式初始化;但如果多次调用 ComputeOutputs(因为 ihSums 和 hoSums 为计算值),则必须进行显式初始化。 您也可以使用替代设计方法,不声明 ihSums 和 hoSums 并且不将它们分配为类成员,而是将它们包含在 ComputeOutputs 方法内部。 接着,ComputeOutputs 方法如下:
for (int i = 0; i < xValues.Length; ++i)
this.inputs[i] = xValues[i];
for (int j = 0; j < numHidden; ++j)
for (int i = 0; i < numInput; ++i)
ihSums[j] += this.inputs[i] * ihWeights[i][j];
...
xValues 数组参数中的值将复制到类输入数组成员中。 在某些神经网络方案中,输入参数值进行了标准化,例如,通过执行线性转换,使所有输入都在 -1.0 到 +1.0 之间,但是本例中没有进行标准化。 接着,嵌套的循环计算出加权和,如图 1 和图 3 所示。 请注意,为了以标准格式为 ihWeights 编制索引(其中 i 是行索引,j 是列索引),必须将 j 放在外层循环。 接着,ComputeOutputs 方法如下:
for (int i = 0; i < numHidden; ++i)
ihSums[i] += ihBiases[i];
for (int i = 0; i < numHidden; ++i)
ihOutputs[i] = SigmoidFunction(ihSums[i]);
...
每个加权和都会通过添加适当的偏移值进行修改。 此时,为产生图 2 所示的输出,我使用 Helpers.ShowVector 方法来显示 ihSums 数组中的当前值。 接着,我将 Sigmoid 函数应用到 ihSums 中的每个值,并将结果分配到数组 ihOutputs 中。 稍后我将简单介绍一下 SigmoidFunction 方法的代码。 接着,ComputeOutputs 方法如下:
for (int j = 0; j < numOutput; ++j)
for (int i = 0; i < numHidden; ++i)
hoSums[j] += ihOutputs[i] * hoWeights[i][j];
for (int i = 0; i < numOutput; ++i)
hoSums[i] += hoBiases[i];
...
我使用 ihOutputs 中刚刚计算出的值和 hoWeights 中的权重来计算 hoSums 中的值,然后加上合适的“隐藏到输出”偏移值。 同样,为了产生图 2 所示的输出,我调用了 Helpers.ShowVector。 ComputeOutputs 方法最后如下:
for (int i = 0; i < numOutput; ++i)
this.outputs[i] = HyperTanFunction(hoSums[i]);
double[] result = new double[numOutput];
this.outputs.CopyTo(result, 0);
return result;
}
我将 HyperTanFunction 方法应用到 hoSums,以将最终输出生成到类数组私有成员输出中。 我将这些输出复制到本地结果数组中,并将该数组用作返回值。 您也可以选择不实现不含返回值的 ComputeOutputs,而实现公共方法 GetOutputs,这样便可以检索神经网络对象的输出。
激活函数和帮助器方法
下面是用来计算“输入到隐藏”输出的 Sigmoid 函数的代码:
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));
}
由于 Math.Exp 函数的部分实现可能会产生算术溢出,因此通常需要检查输入参数的值。 用来计算“隐藏到输出”结果的 tanh 函数的代码如下:
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);
}
双曲正切函数的返回值在 -1 到 +1 之间,因此不会出现算术溢出的问题。 此处,检查输入值仅仅是为了提高性能。
Helpers 类中的静态实用工具方法只是为了编码方便。 NeuralNetwork 构造函数中用来分配矩阵的 MakeMatrix 方法将矩阵的每一行都实现为包含数组的数组:
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;
}
ShowVector 和 ShowMatrix 方法将数组或矩阵中的值显示到控制台上。 您可以在随本文提供的代码下载中查看这两个方法的代码(请访问 archive.msdn.microsoft.com/mag201205TestRun)。
后续步骤
此处介绍的代码为您理解和实验神经网络奠定了坚实的基础。 您可能希望深入了解使用不同的激活函数以及不同数量的输入、输出和隐藏层神经元的效果。 您可以修改神经网络使其部分连接,这样,其中的部分神经元在逻辑上就不会连接到下一层。 本文介绍的神经网络具有一个隐藏层。 更为复杂的神经网络可能包含两个甚至更多的隐藏层,而您可能还想扩展此处介绍的代码以实现此类神经网络。
神经网络可用于解决各种实际问题,包括分类问题。 要解决这些问题,还存在一些挑战。 例如,您必须懂得如何编码非数字数据,以及如何训练神经网络使其找到一组最优的权重和偏移。 在以后的文章中,我将介绍使用神经网络进行分类的方法。
James McCaffrey 博士供职于 Volt Information Sciences, Inc.,负责管理对华盛顿州雷蒙德市 Microsoft 总部园区内的软件工程师进行的技术培训。 他参与过多项 Microsoft 产品的研发工作,其中包括 Internet Explorer 和 MSN Search。 他是《.NET Test Automation Recipes》(Apress, 2006) 的作者,您可以通过以下电子邮箱地址与他联系:jammc@microsoft.com。
衷心感谢以下 Microsoft 技术专家对本文的审阅: Dan Liebling 和 Anne Loomis Thompson