2018 年 2 月

第 33 卷,第 2 期

机器学习 - 使用 CNTK 的深度神经网络分类器

作者 James McCaffrey

Microsoft 认知工具包 (CNTK) 库包含一系列功能非常强大的函数,可用于创建机器学习 (ML) 预测系统。我在 2017 年 7 月刊的文章 (msdn.com/magazine/mt784662) 中介绍过第 2 版。在本文中,我将介绍如何使用 CNTK 生成深度神经网络分类器。了解本文所述观点的绝佳方式是,查看图 1 中的屏幕截图。

小麦种子品种预测演示
图 1:小麦种子品种预测演示

由于性能原因,CNTK 库是用 C++ 编写而成,但调用库函数的最常用方法是使用 CNTK Python 语言 API。我调用演示程序的方法是,在常规 Windows 10 命令行界面中发出以下命令:

> python seeds_dnn.py

演示程序的目标是,创建可以预测小麦种子品种的深度神经网络。在后台,演示程序使用一组定型数据,如下所示:

|properties 15.26 14.84 ... 5.22 |variety 1 0 0
|properties 14.88 14.57 ... 4.95 |variety 1 0 0
...
|properties 17.63 15.98 ... 6.06 |variety 0 1 0
...
|properties 10.59 12.41 ... 4.79 |variety 0 0 1

定型数据有 150 项。每行代表一种小麦种子品种,共有以下三种:“Kama”、“Rosa”或“Canadian”。 每行的前七个数值是预测因子值,在机器学习术语中通常称为“属性”或“特性”。预测因子包括种子面积、周长、紧密度、长度、宽度、偏度系数和槽长度。最后三列为预测项(通常称为“类”或“标签”),Kama 编码为 1 0 0,Rosa 编码为 0 1 0,Canadian 编码为 0 0 1。

演示程序还使用 60 项的测试数据集,每个种子品种有 20 项。测试数据与定型数据的格式相同。

演示程序创建 7-(4-4-4)-3 深度神经网络。此网络如图 2 所示。有七个输入节点(每个节点对应一个预测因子值)、三个隐藏层(每个隐藏层有四个处理节点),以及三个输出节点(对应三个可能已编码的小麦种子品种)。

深度神经网络结构
图 2:深度神经网络结构

演示程序使用随机梯度下降 (SGD) 算法,使用 5000 批项(每批有 10 项)定型网络。经过定型后,预测模型应用到包含 60 项的测试数据集。此模型的准确度为 78.33%。也就是说,它正确地预测了 60 个测试项中的 47 个。

最后,演示程序预测了未知的小麦种子。七个输入值是 (17.6, 15.9, 0.8, 6.2, 3.5, 4.1, 6.1)。计算出的原始输出节点值是 (1.0530, 2.5276, -3.6578),相关联的输出节点概率值是 (0.1859, 0.8124, 0.0017)。由于中间值最大,因此输出映射到品种 Rosa (0, 1, 0)。

 若要更好地理解本文,需要拥有中级或更高水平的 C 语言系列编程技能,并对神经网络基本熟悉。不过,无论大家的经验背景如何,都应该能够跟着我一起操作,并且不会遇到太多麻烦。本文展示了 seeds_dnn.py 程序的完整源代码。本文随附下载的文件中还包含代码,以及相关的定型和测试数据文件。

安装 CNTK v2

由于 CNTK v2 较新,因此大家可能还不熟悉它的安装过程。简而言之,先安装包含核心 Python 语言和必需 Python 包的 Python 语言发行版(强烈建议安装 Anaconda 发行版),再将 CNTK 安装为附加 Python 包。也就是说,CNTK 不是独立安装。

截至本文撰写之时,CNTK 最新版本是 v2.3。由于 CNTK 正处于蓬勃发展期,因此在大家阅读本文时,可能就会有更高的版本推出。我使用的是 Anaconda 发行版 4.1.1(其中包含 Python 版本 3.5.2、NumPy 版本 1.11.1 和 SciPy 版本 0.17.1)。安装 Anaconda 后,我使用 pip 实用工具程序安装了仅 CPU 版本 CNTK。如果对版本控制兼容性掉以轻心,安装 CNTK 时可能会感到有点棘手,但可以参阅 CNTK 文档详细介绍的安装过程。

了解数据

若要创建机器学习系统,大多都要先设置定型和测试数据文件,这是个非常耗时且通常很烦人的过程。有关原始小麦种子数据集,可以访问 bit.ly/2idhoRK。包含 210 项的原始制表符分隔数据如下所示:

14.11  14.1   0.8911  5.42  3.302  2.7  5      1
16.63  15.46  0.8747  6.053 3.465  2.04 5.877  1

我编写了一个实用工具程序,用于生成格式可由 CNTK 轻松处理的文件。生成的包含 210 项文件如下所示:

|properties 14.1100 14.1000 ... 5.0000 |variety 1 0 0
|properties 16.6300 15.4600 ... 5.8770 |variety 1 0 0

实用工具程序添加了前导标记“|properties”,用于标识特性的位置。此外,还添加了标记“|variety”,用于标识要预测的类的位置。原始类值是 1 位有效编码(有时亦称为“独热编码”),制表符被替换为单个空格字符,所有预测因子值均采用正好四位小数的格式。

在大多数情况下,建议规范化数字预测因子值,确保它们的范围大致相同。为了让本文更简单一点,我没有对此类数据进行规范化。规范化有两种常见形式,分别为 z-score 规范化和 min-max 规范化。一般来说,在非演示情况下,应规范化预测因子值。

接下来,我编写了另一个实用工具程序,需要使用 CNTK 格式的包含 210 项数据文件生成包含 150 项的定型数据文件 seeds_train_data.txt(每个品种的前 50 项),并生成包含 60 项的测试文件 seeds_test_data.txt(每个品种的后 20 项)。

由于有七个预测因子变量,因此绘制完整的数据图并不可行。不过,可以通过图 3 中的部分数据图,大致了解一下数据结构。我只使用了包含 60 项的测试数据集中的种子周长和种子紧密度预测因子值。

部分测试数据图
图 3:部分测试数据图

深度神经网络演示程序

我使用记事本编写了演示程序。我喜欢用记事本,但我的大多数同事都喜欢从许多出色的 Python 编辑器中选择一种使用。带 Python 语言加载项的免费 Visual Studio Code 编辑器特别好用。图 4 展示了完整的演示程序源代码(为节省空间,进行了少量小幅改动)。请注意,Python 使用反斜杠字符来续行。

图 4:完整的种子分类器演示程序

# seeds_dnn.py
# classify wheat seed variety
import numpy as np
import cntk as C
def create_reader(path, is_training, input_dim, output_dim):
  strm_x = C.io.StreamDef(field='properties',
    shape=input_dim, is_sparse=False)
  strm_y = C.io.StreamDef(field='variety',
    shape=output_dim, is_sparse=False)
  streams = C.io.StreamDefs(x_src=strm_x,
    y_src=strm_y)
  deserial = C.io.CTFDeserializer(path, streams)
  sweeps = C.io.INFINITELY_REPEAT if is_training else 1
  mb_source = C.io.MinibatchSource(deserial,
    randomize=is_training, max_sweeps=sweeps)
  return mb_source
def main():
  print("\nBegin wheat seed classification demo  \n")
  print("Using CNTK verson = " + str(C.__version__) + "\n")
  input_dim = 7
  hidden_dim = 4
  output_dim = 3
  train_file = ".\\Data\\seeds_train_data.txt"
  test_file = ".\\Data\\seeds_test_data.txt"
  # 1. create network and model
  X = C.ops.input_variable(input_dim, np.float32)
  Y = C.ops.input_variable(output_dim, np.float32)
  print("Creating a 7-(4-4-4)-3 tanh softmax NN for seed data ")
  with C.layers.default_options(init= \
    C.initializer.normal(scale=0.1, seed=2)):
    h1 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
      name='hidLayer1')(X)
    h2 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
      name='hidLayer2')(h1)
    h3 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
      name='hidLayer3')(h2)
    oLayer = C.layers.Dense(output_dim, activation=None,
      name='outLayer')(h3)
  nnet = oLayer
  model = C.softmax(nnet)
  # 2. create learner and trainer
  print("Creating a cross entropy, SGD with LR=0.01, \
    batch=10 Trainer \n")
  tr_loss = C.cross_entropy_with_softmax(nnet, Y)
  tr_clas = C.classification_error(nnet, Y)
  learn_rate = 0.01
  learner = C.sgd(nnet.parameters, learn_rate)
  trainer = C.Trainer(nnet, (tr_loss, tr_clas), [learner])
  max_iter = 5000  # maximum training iterations
  batch_size = 10   # mini-batch size
  # 3. create data reader
  rdr = create_reader(train_file, True, input_dim,
    output_dim)
  my_input_map = {
    X : rdr.streams.x_src,
    Y : rdr.streams.y_src
  }
  # 4. train
  print("Starting training \n")
  for i in range(0, max_iter):
    curr_batch = rdr.next_minibatch(batch_size,
      input_map=my_input_map)
    trainer.train_minibatch(curr_batch)
    if i % 1000 == 0:
      mcee = trainer.previous_minibatch_loss_average
      pmea = trainer.previous_minibatch_evaluation_average
      macc = (1.0 - pmea) * 100
      print("batch %6d: mean loss = %0.4f, \
        mean accuracy = %0.2f%% " % (i,mcee, macc))
  print("\nTraining complete")
  # 5. evaluate model on the test data
  print("\nEvaluating test data \n")
  rdr = create_reader(test_file, False, input_dim, output_dim)
  my_input_map = {
    X : rdr.streams.x_src,
    Y : rdr.streams.y_src
  }
  numTest = 60
  allTest = rdr.next_minibatch(numTest, input_map=my_input_map)
  acc = (1.0 - trainer.test_minibatch(allTest)) * 100
  print("Classification accuracy on the \
    60 test items = %0.2f%%" % acc)
  # (could save model here)
  # 6. use trained model to make prediction
  np.set_printoptions(precision = 4)
  unknown = np.array([[17.6, 15.9, 0.8, 6.2, 3.5, 4.1, 6.1]],
    dtype=np.float32)
  print("\nPredicting variety for (non-normalized) seed features: ")
  print(unknown[0])
  raw_out = nnet.eval({X: unknown})
  print("\nRaw output values are: ")
  for i in range(len(raw_out[0])):
    print("%0.4f " % raw_out[0][i])
  pred_prob = model.eval({X: unknown})
  print("\nPrediction probabilities are: ")
  for i in range(len(pred_prob[0])):
    print("%0.4f " % pred_prob[0][i])
  print("\nEnd demo \n ")
# main()
if __name__ == "__main__":
  main()

演示程序先导入必需 NumPy 和 CNTK 包,并向它们分配快捷方式别名 np 和 C。函数 create_reader 是程序定义的帮助程序,可用于读取定型数据(如果 is_training 参数设置为 True),或读取测试数据(如果 is_training 参数设置为 False)。

可以将 create_reader 函数视为神经分类问题的样本代码。在大多数情况下,唯一需要更改的是 StreamDef 函数调用中的两个字段参数字符串值,即演示程序中的“properties”和“varieties”。

所有程序控制逻辑都包含在一个主函数中。为了减小演示程序并突显主要概念,我删除了所有常规错误检查。请注意,为了节省空间,我缩进了两个空格(而不是通常的四个空格)。

创建网络和模型

主函数先设置神经网络体系结构维度:

def main():
  print("Begin wheat seed classification demo")
  print("Using CNTK verson = " + str(C.__version__) )
  input_dim = 7
  hidden_dim = 4
  output_dim = 3
...

由于 CNTK 正处于快速发展期,因此最好打印出或注释所使用的版本。演示程序有三个隐藏层,每层都有四个节点。隐藏层数量和每层的节点数量都必须通过反复试验法进行确定。如果愿意,可以在每层设置不同数量的节点。例如,hidden_dim = [10, 8, 10, 12] 对应的深度网络有四个隐藏层,每层的节点数分别为 10、8、10 和 12 个。

接下来,将指定定型和测试数据文件的位置,并创建网络输入和输出矢量:

train_file = ".\\Data\\seeds_train_data.txt"
test_file = ".\\Data\\seeds_test_data.txt"
# 1. create network and model
X = C.ops.input_variable(input_dim, np.float32)
Y = C.ops.input_variable(output_dim, np.float32)

请注意,我将定型和测试文件放入独立的“数据”子目录中,这种做法很常见,因为在模型创建期间经常会有许多不同的数据文件。使用 np.float32 数据类型要比 np.float64 类型更常见,因为与蒙受的性能损失相比,使用 64 位获得的额外精度通常并不值得。

接下来,将创建网络:

print("Creating a 7-(4-4-4)-3 NN for seed data ")
with C.layers.default_options(init= \
  C.initializer.normal(scale=0.1, seed=2)):
  h1 = C.layers.Dense(hidden_dim,
    activation=C.ops.tanh, name='hidLayer1')(X)
  h2 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
    name='hidLayer2')(h1)
  h3 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
    name='hidLayer3')(h2)
  oLayer = C.layers.Dense(output_dim, activation=None,
    name='outLayer')(h3)
nnet = oLayer
model = C.softmax(nnet)

此代码有许多地方需要讲解。Python with 语句是快捷语法,可以将一组通用值应用到网络的多个层。随后,所有权重都会分配有一个高斯(钟形曲线)随机值,其标准偏差为 0.1,平均偏差为 0。设置种子值可以确保可再现性。CNTK 支持许多初始化算法,包括“uniform”、“glorot”、“he”和“xavier”。 深度神经网络通常对初始化算法出人意料地敏感。所以,如果定型失败,首先要尝试的办法之一就是选择备用初始化算法。

这三个隐藏层是使用 Dense 函数进行定义,之所以这样命名是因为每个节点都完全连接到前后层中的节点。使用的语法可能会令人感到困惑。其中,X 用作隐藏层 h1 的输入。h1 层用作隐藏层 h2 的输入,依此类推。

请注意,输出层不使用激活函数,因此输出节点值的总和不一定为 1。如果大家曾使用过其他神经网络库,我就需要对此进行一些解释。对于其他许多神经库,大家会对输出层使用 softmax 激活。这样一来,输出值的总和始终为 1,因此能够解释为概率。然后,在定型期间,将会使用交叉熵误差(亦称为“对数损失”),这就要求一组值的总和必须为 1。

不过,有点令人惊讶的是,CNTK v2.3 并未提供用于定型的基本交叉熵误差函数。相反,CNTK 将交叉熵和 softmax 函数相结合。也就是说,在定型期间,输出节点值通过 softmax 快速转换为概率,以计算误差项。

因此,使用 CNTK,可以对原始输出节点值定型深度网络;但在预测时,若要像以往一样获得预测概率,必须显式应用 softmax 函数。演示程序使用的方法是对“nnet”对象进行定型(输出层没有激活),但还创建了应用 softmax 的“model”对象,以供预测时使用。

现在,实际上可以对输出层使用 softmax 激活,然后在定型期间结合使用交叉熵和 softmax。使用这种方法,将会应用 softmax 两次,第一次是对原始输出值应用,第二次是对规范化后的输出节点值应用。事实证明,虽然这种方法可行,但由于相当复杂的技术原因,导致定型效率并不高。

链接隐藏层在一定程度上是可行的。对于非常深的深度网络,CNTK 支持 Sequential 元函数,用于提供创建多层网络的快捷语法。CNTK 库还提供 Dropout 函数,可有助于防止模型过度拟合。例如,若要将 dropout 添加到第一个隐藏层,可以按如下所示修改演示代码:

h1 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
  name='hidLayer1')(X)
d1 = C.layers.Dropout(0.50, name='drop1')(h1)
h2 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
  name='hidLayer2')(d1)
h3 = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
  name='hidLayer3')(h2)
oLayer = C.layers.Dense(output_dim, activation=None,
  name='outLayer')(h3)

我的很多同事都喜欢用 Sequential,即使深度神经网络只有几个隐藏层,也不例外。我更喜欢手动链接,但这只是个人风格问题。

定型网络

创建神经网络和模型后,演示程序便会创建 Learner 对象和 Trainer 对象:

print("Creating a Trainer \n")
tr_loss = C.cross_entropy_with_softmax(nnet, Y)
tr_clas = C.classification_error(nnet, Y)
learn_rate = 0.01
learner = C.sgd(nnet.parameters, learn_rate)
trainer = C.Trainer(nnet, (tr_loss, tr_clas), [learner])

可以将 Learner 视为算法,并将 Trainer 视为使用 Learner 算法的对象。tr_loss(“定型损失”)对象定义了如何衡量网络计算输出值和定型数据中的已知正确输出值之间的误差。若要进行分类,使用的几乎总是交叉熵,但 CNTK 也支持几种备选方案。函数名称的“with_softmax”部分表示函数应使用原始输出节点值,而不是使用 softmax 规范化后的值。这就是为什么输出层不使用激活函数的原因所在。

tr_clas(“定型分类误差”)对象定义了如何在定型期间计算正确和错误预测数。CNTK 定义了分类误差(错误预测所占百分比)库函数,而不是其他一些库使用的分类准确度函数。所以,定型期间有两种形式的误差要计算。tr_loss 误差用于调整权重和偏差。tr_clas 用于监视预测准确度。

Learner 对象使用 SGD 算法,其中学习率常数设置为 0.01。虽然 SGD 是最简单的定型算法,但几乎从来都不是性能最佳的定型算法。CNTK 支持许多学习器算法,其中一些非常复杂。根据经验,我建议从 SGD 入手,仅在定型失败的情况下,才尝试更奇特的算法。Adam 算法(Adam 不是首字母缩略词)通常是我的第二种选择。

请注意创建 Trainer 对象的语法不同寻常。两个 loss 函数对象以 Python 元组的形式(用括号表示)传递,而 Learner 对象则以 Python 列表的形式(用方括号表示)传递。可以将多个 Leaner 对象传递到 Trainer,尽管演示程序只传递了一个。

实际执行定型的代码如下:

for i in range(0, max_iter):
  curr_batch = rdr.next_minibatch(batch_size,
    input_map=my_input_map)
  trainer.train_minibatch(curr_batch)
  if i % 1000 == 0:
    mcee = trainer.previous_minibatch_loss_average
    pmea = trainer.previous_minibatch_evaluation_average
    macc = (1.0 - pmea) * 100
    print("batch %6d: mean loss = %0.4f, \
      mean accuracy = %0.2f%% " % (i, mcee, macc))

请务必监视定型进度,因为定型经常会失败。随后,每 1,000 次迭代对刚刚使用的包含 10 个定型项的批次显示一次平均交叉熵误差。演示程序显示了平均分类准确度(当前 10 项的正确预测所占百分比),我认为这是比分类误差(错误预测所占百分比)更自然的指标。

保存已定型模型

由于只有 150 个定型项,因此演示神经网络只需几秒即可完成定型。不过,在非演示情况下,定型非常深的深度神经网络可能需要数小时、数天或甚至更长时间才能完成。定型后,需要保存模型,以免从头开始重新定型。保存并加载已定型 CNTK 模型非常简单。若要保存,可以将下面的代码添加到演示程序中:

mdl = ".\\Models\\seed_dnn.model"
model.save(mdl, format=C.ModelFormat.CNTKv2)

传递给 save 函数的第一个参数其实是文件名(可能包含路径)。文件扩展名没有任何要求,但使用“.model”更有意义。由于 format 参数有默认值 ModelFormat.CNTKv2,因此可以省略。也可以使用新的开放神经网络交换 (format=ONNX)。

回顾一下,演示程序创建了 nnet 对象(没有对输出使用 softmax)和 model 对象(使用了 softmax)。通常建议保存 softmax 版本的已定型模型,但如果愿意,也可以保存非 softmax 对象。

保存模型后,便可以将模型加载到内存中,如下所示:

model = C.ops.functions.Function.Load(".\\Models\\seed_dnn.model")

然后,就可以使用模型了,就像已定型模型一样。请注意,保存和加载调用有点不对称:save 是对 Function 对象使用的方法,load 是 Function 类中的静态方法。

总结

许多分类问题都可以使用只有一个隐藏层的简单前馈神经网络 (FNN) 进行处理。从理论上来讲,在特定前提下,FNN 可以处理深度神经网络适用的任何问题。然而,在实践中,深度神经网络有时比 FNN 更容易定型。这些概念依据的数学理论称为“泛逼近定理”(有时亦称为“Cybenko 定理”)。

如果刚刚开始接触神经网络分类,必须做出的决策数量看似令人生畏。必须决定隐藏层数量、每层的节点数量、每个隐藏层的初始化架构和激活函数、定型算法以及定型算法参数(如学习率和动量项)。不过,通过练习,很快就会形成一系列经验法则,适用于所处理的问题类型。


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

衷心感谢以下 Microsoft 技术专家对本文的审阅:Chris Lee、Ricky Loynd、Kenneth Tran


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