2018 年 4 月

第 33 卷,第 4 期

测试运行 - 使用 C# 了解 LSTM 单元

作者 James McCaffrey

James McCaffrey长短期记忆 (LSTM) 单元是小型软件组件,可用于创建循环神经网络,以执行与数据序列相关的预测。LSTM 网络负责在多个机器学习领域实现重大突破。本文介绍了如何使用 C# 实现 LSTM 单元。尽管不太可能需要从头开始创建循环神经网络,但确切了解 LSTM 单元的工作原理有助于确定自己是否需要使用代码库(如 Microsoft CNTK 或 Google TensorFlow)创建 LSTM 网络。

请参阅图 1,了解本文所要努力的方向。此演示程序创建接受大小 n = 2 的输入向量的 LSTM 单元,并生成大小 m = 3 的显式输出向量和大小 m = 3 的单元状态向量。LSTM 单元的关键特征是,状态保持不变。

LSTM 单元输入-输出演示 
图 1:LSTM 单元输入-输出演示

LSTM 单元包含 (4 * n * m) + (4 * m * m) 个权重和 (4 * m) 个偏差。权重和偏差都只是值为 0.1234 等的常数,用于定义 LSTM 单元的行为。此演示程序有 60 个权重和 12 个偏差,全都设置为任意值。

此演示程序向 LSTM 单元发送输入 (1.0, 2.0)。计算输出为 (0.0629, 0.0878, 0.1143),且新单元状态为 (0.1143, 0.1554, 0.1973)。我很快就会说明这些数值的含义,而当前讨论的问题是,LSTM 单元接受输入、生成输出并更新单元状态。权重值和偏差值保持不变。 

接下来,此演示程序向 LSTM 单元馈送 (3.0, 4.0)。新计算输出为 (0.1282, 0.2066, 0.2883)。新单元状态为 (0.2278, 0.3523, 0.4789)。新单元状态值包含之前所有输入值和输出值的相关信息,尽管并不明显。这就是长短期记忆中“长”一词的含义。

若要更好地理解本文,至少必须拥有中等水平的编程技能,但无需对 LSTM 单元有任何了解。此演示程序使用 C# 进行编码。不过,应该能够根据需要将代码重构为其他语言(如 Python 或 Visual Basic)。此演示程序稍微有点长,无法全部展示,但随附的文件下载内容中包含完整代码。

了解 LSTM 单元

介绍 LSTM 单元的方式有以下三种:使用体系结构关系图(如图 2 所示)、使用数学等式(如图 3**** 所示)、使用本文中的代码。大家对图 2 中体系结构关系图的初步印象可能是:“这究竟是什么?” 关键点是,LSTM 单元接受输入向量 x(t)(其中 t 表示时间)。显式输出为 h(t)。使用 h(而不是 o)表示输出并不常见,但这是有历史依据的,实际上经常将神经系统描述为函数 g 和 h。

LSTM 单元还使用 h(t-1) 和 c(t-1) 作为输入。其中,t-1 表示上一次步长的时间。c 表示单元状态,因此 h(t-1) 和 c(t-1) 分别表示上一输出值和上一状态值。LSTM 单元体系结构的内部看起来很复杂。确实如此,但并非大家想象得那么复杂。

图 3**** 中的数学等式定义了此演示程序 LSTM 单元的行为。如果不经常接触数学定义,看到图 3 后的反应可能还是“这究竟是什么?” 等式 (1)、(2) 和 (3) 定义了三个门:遗忘门、输入门和输出门。每个门都是介于 0.0 和 1.0 之间的值向量,用于确定每个输入-输出循环中要遗忘(或反之,要记住)的信息量。等式 (4) 计算新单元状态,等式 (5) 计算新输出。

LSTM 单元体系结构
图 2:LSTM 单元体系结构

LSTM 单元数学等式
图 3:LSTM 单元数学等式

这些等式比看起来更简单。例如,以下演示程序代码实现等式 (1):

float[][] ft = MatSig(MatSum(MatProd(Wf, xt),
                 MatProd(Uf, h_prev), bf));

其中,MatSig 是程序定义的函数,用于向矩阵中的所有值应用逻辑 sigmoid 函数。MatSum 对三个矩阵进行求和。MatProd 对两个矩阵执行乘法运算。理解基本矩阵和向量运算后,LSTM 单元实现起来就相当容易了。

演示程序整体结构

图 4 展示了此演示程序的结构(为节省空间,进行了少量小幅改动)。为了尽可能明确主要用途,此演示程序使用的是静态方法,而不是 OOP 方法。

图 4:演示程序结构

using System;
namespace LSTM_IO
{
  class LSTM_IO_Program
  {
    static void Main(string[] args)
    {
      Console.WriteLine("Begin LSTM IO demo");
      // Set up inputs
      // Set up weights and biases
      float[][] ht, ct;  // Outputs, new state
      float[][][] result;
      result = ComputeOutputs(xt, h_prev, c_prev,
        Wf, Wi, Wo, Wc, Uf, Ui, Uo, Uc, bf, bi, bo, bc);
      ht = result[0];  // Outputs
      ct = result[1];  // New state
      Console.WriteLine("Output is:");
      MatPrint(ht, 4, true);
      Console.WriteLine("New cell state is:");
      MatPrint(ct, 4, true);
      // Set up new inputs
      // Call ComputeOutputs again
      Console.WriteLine("End LSTM demo");
    }
    static float[][][] ComputeOutputs(float[][] xt,
      float[][] h_prev, float[][] c_prev,
      float[][] Wf, float[][] Wi, float[][] Wo, float[][] Wc,
      float[][] Uf, float[][] Ui, float[][] Uo, float[][] Uc,
      float[][] bf, float[][] bi, float[][] bo, float[][] bc)
    { . . }
    // Helper matrix functions defined here
  }
}

为了创建此演示程序,我启动了 Visual Studio,并新建了名为“LSTM_IO”的 C# 控制台应用程序。虽然我使用的是 Visual Studio 2015,但此演示程序并不非常依赖 .NET Framework,所以任何版本的 Visual Studio 都可以正常运行。

加载模板代码后,我在“解决方案资源管理器”窗口中将文件 Program.cs 重命名为 LSTM_IO_Program.cs,并允许 Visual Studio 自动重命名 Program 类。在编辑器窗口的顶部,我删除了所有对命名空间的不必要引用,仅留下一个对顶级 System 命名空间的引用。所有操作都由 ComputeOutputs 函数执行。

使用 C# 的矩阵

必须非常了解如何使用 C# 矩阵,才能实现 LSTM 单元。在 C# 中,矩阵是包含数组的数组。在机器学习领域,通常使用 32 位 float 类型,而不是 64 位 double 类型。

此演示程序定义了创建矩阵的帮助程序,如下所示:

static float[][] MatCreate(int rows, int cols)
{
  float[][] result = new float[rows][];
  for (int i = 0; i < rows; ++i)
    result[i] = new float[cols];
  return result;
}

第一个语句创建采用指定行数的数组,每一行都包含一系列 float 类型。for 循环语句将每行分配为包含指定列数的数组。请注意,与大部分编程语言不同,C# 支持真正的矩阵类型,而更为常见的做法是使用包含数组的数组。

在机器学习领域,“列向量”一词(或简称为“向量”)是指包含一列的矩阵。大部分机器学习代码使用的是向量,而不是一维数组。此演示程序定义了通过一维数组生成矩阵/向量的函数:

static float[][] MatFromArray(float[] arr, int rows, int cols)
{
  float[][] result = MatCreate(rows, cols);
  int k = 0;
  for (int i = 0; i < rows; ++i)
    for (int j = 0; j < cols; ++j)
      result[i][j] = arr[k++];
  return result;
}

调用此函数即可创建 3x1(3 行 1 列)向量,如下所示:

float[][] v = MatFromArray(new float[] {1.0f, 9.0f, 5.0f}, 3,1);

此演示程序定义了可复制矩阵的 MatCopy 函数。可按如下调用 MatCopy:

float[][] B = MatCopy(A);

其中,B 是新建的独立矩阵,包含与 A 相同的值。请注意以下代码:

float[][] B = A;

上面的代码将 B 创建为对 A 的引用。因此,如果更改任一矩阵,都会影响另一个矩阵。这可能是也可能不是所需的行为。

矩阵元素方面的运算

实现的 LSTM 单元对矩阵使用元素方面的多个函数,用于使用或修改矩阵中的每个值。例如,下面定义了 MatTanh 函数:

static float[][] MatTanh(float[][] m)
{
  int rows = m.Length; int cols = m[0].Length;
  float[][] result = MatCreate(rows, cols);
  for (int i = 0; i < rows; ++i) // Each row
    for (int j = 0; j < cols; ++j) // Each col
      result[i][j] = Tanh(m[i][j]);
  return result;
}

此函数遍历输入矩阵 m,并向每个值应用双曲正切函数 (tanh)。下面定义了帮助程序函数 Tanh:

static float Tanh(float x)
{
  if (x < -10.0) return -1.0f;
  else if (x > 10.0) return 1.0f;
  return (float)(Math.Tanh(x));
}

此演示程序还定义了 MatSigmoid 函数。此函数与 MatTanh 完全类似,不同之处在于向每个值应用的是逻辑 sigmoid 函数。逻辑 sigmoid 函数与 tanh 紧密相关,返回的值介于 0.0 和 1.0 之间,而不是 -1.0 和 +1.0 之间。

此演示程序定义了 MatSum 函数,用于对形状相同的两个矩阵中的值进行求和。如果观察图 3**** 中的数学等式 (1),就会发现 LSTM 对三个矩阵进行求和。此演示程序重载 MatSum,以处理两个或三个矩阵。

MatHada 函数对形状相同的两个矩阵中的相应值执行乘法运算:

static float[][] MatHada(float[][] a, float[][] b)
{
  int rows = a.Length; int cols = a[0].Length;
  float[][] result = MatCreate(rows, cols);
  for (int i = 0; i < rows; ++i)
    for (int j = 0; j < cols; ++j)
      result[i][j] = a[i][j] * b[i][j];
  return result;
}

元素方面的相乘有时亦称为“Hadamard 函数”。在图 3 的数学等式 (4) 和 (5) 中,Hadamard 函数由圆圈符号表示。请勿混淆元素方面的 Hadamard 函数和矩阵乘法,这是两个截然不同的函数。

矩阵乘法

如果之前没有接触过矩阵乘法,根本就发现不了这种运算。假设 A 是一个 3x2 矩阵:

1.0, 2.0
3.0, 4.0
5.0, 6.0

假设 B 是一个 2x4 矩阵:

10.0, 11.0, 12.0, 13.0
14.0, 15.0, 16.0, 17.0

C = AB(A 和 B 相乘)生成一个 3x4 矩阵:

38.0  41.0  44.0  47.0
 86.0  93.0 100.0 107.0
134.0 145.0 156.0 167.0

此演示程序将矩阵相乘实现为 MatProd 函数。请注意,使用 C# 时,如果矩阵非常大,可以使用任务并行库中的 Parallel.For 语句。

简而言之,使用 C# 实现 LSTM 单元需要有多个帮助程序函数,用于创建矩阵(包含数组的数组)和向量(包含一的矩阵),并对它们执行运算。此演示程序代码定义以下函数:MatCreate、MatFromArray、MatCopy(m)、MatSig(m)、MatTanh(m)、MatHada(a, b)、MatSum(a, b)、MatSum(a, b, c) 和 MatProd(a, b)。尽管这些函数不是创建 LSTM 单元所必需,但使用函数来显示 C# 矩阵还是很实用的。此演示程序定义了 MatPrint 函数。

实现和调用 LSTM 输入-输出

图 5**** 展示了 ComputeOutputs 函数代码。此函数需要使用 15 个参数,但其中有 12 个参数基本相同。

图 5:ComputeOutputs 函数

static float[][][] ComputeOutputs(float[][] xt,
  float[][] h_prev, float[][] c_prev,
  float[][] Wf, float[][] Wi, float[][] Wo, float[][] Wc,
  float[][] Uf, float[][] Ui, float[][] Uo, float[][] Uc,
  float[][] bf, float[][] bi, float[][] bo, float[][] bc)
{
  float[][] ft = MatSig(MatSum(MatProd(Wf, xt),
    MatProd(Uf, h_prev), bf));
  float[][] it = MatSig(MatSum(MatProd(Wi, xt),
    MatProd(Ui, h_prev), bi));
  float[][] ot = MatSig(MatSum(MatProd(Wo, xt),
    MatProd(Uo, h_prev), bo));
  float[][] ct = MatSum(MatHada(ft, c_prev),
    MatHada(it, MatTanh(MatSum(MatProd(Wc, xt),
      MatProd(Uc, h_prev), bc))));
  float[][] ht = MatHada(ot, MatTanh(ct));
  float[][][] result = new float[2][][];
  result[0] = MatCopy(ht);
  result[1] = MatCopy(ct);
  return result;
}

向量 xt 是输入,如 (1.0, 2.0)。Vectors h_prev 和 c_prev 分别是上一输出向量和上一单元状态。四个 W 矩阵是与输入值关联的门权重,其中 f 是遗忘门,i 是输入门,o 是输出门。四个 U 矩阵是与单元输出关联的权重。四个 b 向量是偏差。 

在函数主体中,前五个语句与图 3 中的五个数学等式一对一映射。请注意,ft、it 和 ot 门全都使用 MatSig 函数。因此,这三个门全都是值介于 0.0 到 1.0 之间的向量。可以将这些向量看作是应用于输入、输出或状态的筛选器,其中门值就是筛余百分比。例如,如果 ft 中的一个值是 0.75,那么输入和上一输出向量组合中的相应值有 75% 得以筛余。或反之,25% 的信息被遗忘。

计算新单元状态 ct 实现起来非常简单,但从概念上来讲却是很深奥的。简单来看就是,新单元状态取决于输入向量 xt、上一输出向量 h_prev 和上一单元状态 c_prev 的门组合。新输出 ht 取决于新单元状态和输出门。这一点非常值得注意。

此函数在数组中返回新输出和新单元状态。这样一来,返回类型就是 float[][][],其中 result[0] 矩阵是包含输出数组的数组,result[1] 包含新单元状态。

调用 ComputeOutputs 很大程度上就是设置参数值的问题。首先,此演示程序通过以下代码开始准备:

float[][] xt = MatFromArray(new float[] {
  1.0f, 2.0f }, 2, 1);
float[][] h_prev = MatFromArray(new float[] {
  0.0f, 0.0f, 0.0f }, 3, 1);
float[][] c_prev = MatFromArray(new float[] {
  0.0f, 0.0f, 0.0f }, 3, 1);

上一输出和上一单元状态均显式初始化为零。接下来,创建两组任意权重值:

float[][] W = MatFromArray(new float[] {
  0.01f, 0.02f,
  0.03f, 0.04f,
  0.05f, 0.06f }, 3, 2);
float[][] U = MatFromArray(new float[] {
  0.07f, 0.08f, 0.09f,
  0.10f, 0.11f, 0.12f,
  0.13f, 0.14f, 0.15f }, 3, 3);

请注意,两个矩阵的形状不同。权重值复制到输入参数中:

float[][] Wf = MatCopy(W); float[][] Wi = MatCopy(W);
float[][] Wo = MatCopy(W); float[][] Wc = MatCopy(W);
float[][] Uf = MatCopy(U); float[][] Ui = MatCopy(U);
float[][] Uo = MatCopy(U); float[][] Uc = MatCopy(U);

由于权重不变,所以此演示程序可以通过引用(而不是使用 MatCopy)进行分配。使用相同的模式设置偏差:

float[][] b = MatFromArray(new float[] {
  0.16f, 0.17f, 0.18f }, 3, 1);
float[][] bf = MatCopy(b); float[][] bi = MatCopy(b);
float[][] bo = MatCopy(b); float[][] bc = MatCopy(b);

按如下所示调用 ComputeOutputs 函数:

float[][] ht, ct;
float[][][] result;
result = ComputeOutputs(xt, h_prev, c_prev,
  Wf, Wi, Wo, Wc, Uf, Ui, Uo, Uc,
  bf, bi, bo, bc);
ht = result[0];  // Output
ct = result[1];  // New cell state

LSTM 单元的全部意义在于,馈送一系列输入向量,以便此演示程序能够设置并发送第二个输入向量:

h_prev = MatCopy(ht);
c_prev = MatCopy(ct);
xt = MatFromArray(new float[] {
  3.0f, 4.0f }, 2, 1);
result = ComputeOutputs(xt, h_prev, c_prev,
  Wf, Wi, Wo, Wc, Uf, Ui, Uo, Uc,
  bf, bi, bo, bc);
ht = result[0];
ct = result[1];

请注意,此演示程序将上一输出向量和上一状态向量显式发送给 ComputeOutputs。也可以只馈送新的输入向量,因为上一输出和上一单元状态仍存储在 ht 和 ct 中。

综述

我们来总结一下重点。LSTM 单元可用于构造 LSTM 循环神经网络(包含 LSTM 单元和其他一些管道)。此类网络负责在处理数据序列的预测系统中实现重大突破。例如,假设需要预测“2017 年,荣获冠军的是__”这句话中划横线处即将出现的词。 通过此句中的信息,很难进行预测。不过,如果假设系统中包含状态,并记得上一句是“NBA 冠军赛始于 1947 年”, 现在就能够预测这个词来自 30 支 NBA 球队之一。

LSTM 体系结构有数十种变体。另外,由于 LSTM 单元很复杂,因此每个体系结构的实现也有数十种变体。不过,如果了解基本 LSTM 单元机制,就可以轻掌握这些变体。

此示例程序将 LSTM 权重和偏差设为任意值。实际上,LSTM 网络权重和偏差是通过定型网络进行确定。需要获取一系列定型数据,其中包含已知输入值和已知正确输出值。然后,可以使用反向传播等算法来查找权重值和偏差值,以最大限度地减少计算输出和正确输出之间的误差。


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

衷心感谢以下 Microsoft 技术专家对本文的审阅:Ricky Loynd 和 Adith Swaminathan


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