2018 年 3 月

第 33 卷,第 3 期

测试运行 - 使用 CNTK 的神经网络二元分类

作者 James McCaffrey

James McCaffrey二元分类问题的目标是,在要预测的值是两个可取值之一时进行预测。例如,可能需要根据年龄、血压、性别等预测因子变量,预测医院患者是否患有心脏病。可用于解决二元分类问题的技术有很多。在本文中,我将介绍如何使用 Microsoft Cognitive Toolkit (CNTK) 库创建神经网络二元分类模型。

请参阅图 1,了解本文所要努力的方向。此演示程序创建了“克利夫兰心脏病”数据集的预测模型。此数据集包含 297 项。每项都有 13 个预测因子变量,包括年龄、性别、疼痛类型、血压、胆固醇、血糖、ECG、心率、心绞痛、ST 段压低、ST 斜率、血管数和铊。要预测的值为是否患有心脏病。

使用 CNTK 的神经网络二元分类
图 1:使用 CNTK 的神经网络二元分类

在后台,原始数据进行规范化和编码生成了 18 个预测因子变量。此演示程序创建的神经网络包含 18 个输入节点、20 个隐藏处理节点和 2 个输出节点。神经网络模型使用随机梯度下降进行定型,其中学习速率设为 0.005,最小批大小设为 10。

在定型期间,每 500 次迭代显示一次当前 10 项的平均损失/误差和平均分类准确度。可以看到,通常情况下,损失/误差逐步递减,而准确度在迭代超过 5,000 次后开始增加。定型后,所有 297 个数据项的模型的分类准确度计算结果为 84.18%(250 项正确,47 项不正确)。

若要更好地理解本文,至少必须拥有中等或更高水平的编程技能,但无需对 CNTK 或神经网络有太多了解。此演示程序使用 Python 进行编码。不过,即使不了解 Python,也应该能够跟着我一起操作,并且不会遇到太多麻烦。本文展示了演示程序的所有代码。随附下载内容中包括本文使用的数据文件。

了解数据

bit.ly/2EL9Leo 上有多个版本的“克利夫兰心脏病”数据集。此演示程序使用的是已处理版本,只有原始 76 个预测因子变量中的 13 个。原始数据有 303 项,如下所示:

[001] 63.0,1.0,1.0,145.0,233.0,1.0,2.0,150.0,0.0,2.3,3.0,0.0,6.0,0
[002] 67.0,1.0,4.0,160.0,286.0,0.0,2.0,108.0,1.0,1.5,2.0,3.0,3.0,2
[003] 67.0,1.0,4.0,120.0,229.0,0.0,2.0,129.0,1.0,2.6,2.0,2.0,7.0,1
...
[302] 57.0,0.0,2.0,130.0,236.0,0.0,2.0,174.0,0.0,0.0,2.0,1.0,3.0,1
[303] 38.0,1.0,3.0,138.0,175.0,0.0,0.0,173.0,0.0,0.0,1.0,?,3.0,0

每行的前 13 个值是预测因子。每行的最后一项是介于 0 和 4 之间的值,其中 0 表示无心脏病,1、2、3 或 4 均表示有心脏病。一般来说,大多数机器学习方案最耗时的部分是准备数据。由于预测因子变量超过两个,因此无法将原始数据绘制成图。不过,只需看一下年龄和血压(如图 2 所示),即可大致了解此问题。

“克利夫兰心脏病”部分原始数据
图 2:“克利夫兰心脏病”部分原始数据

第一步是处理缺少的数据(请注意项 [303] 中的“?”)。由于只有六项缺少值,因此直接丢弃这六项,剩下 297 项。

下一步是规范化数字预测因子值(如第一列中的年龄)。此演示程序使用了 min-max 规范化,其中列值被替换为 (值 - 最小值) / (最大值 - 最小值)。例如,由于最小年龄值为 29,最大年龄值为 77,因此第一个年龄值 63 规范化为 (63 - 29) / (77 - 29) = 34 / 48 = 0.70833。

下一步是对分类预测因子值进行编码,如第二列中的性别(0 = 女性、1 = 男性)和第三列中的疼痛类型(1、2、3、4)。此演示程序使用了 1-of-(N-1) 编码,所以性别编码为女性 = -1、男性 = +1。疼痛类型编码为 1 = (1, 0, 0)、2 = (0, 1, 0)、3 = (0, 0, 1)、4 = (-1, -1, -1)。

最后一步是对要预测的值进行编码。使用神经网络二元分类时,可以只使用一个值为 0 或 1 的节点,也可以使用值为 (0, 1) 或 (1, 0) 的两个节点,对要预测的值进行编码。出于某种原因(我很快就会解释),使用 CNTK 时,最好使用双节点技术。因此,0(无心脏病)编码为 (0, 1),值 1 到 4(有心脏病)编码为 (1, 0)。

经过规范化和编码的最终数据用制表符进行分隔,如下所示:

|symptoms  0.70833  1  1  0  0  0.48113 ... |disease  0  1
|symptoms  0.79167  1 -1 -1 -1  0.62264 ... |disease  1  0
...

插入“|symptoms”和“|disease”是为了让 CNTK 数据 Reader 对象能够轻松读取数据。

演示程序

图 3 展示了完整的演示程序(为节省空间,进行了少量小幅改动)。我删除了所有常规错误检查。出于个人偏好和节省空间的考虑,我缩进了两个空格字符,而不是常规的四个空格字符。Python 使用“\”字符来续行。

图 3 演示程序结构

# cleveland_bnn.py
# CNTK 2.3 with Anaconda 4.1.1 (Python 3.5, NumPy 1.11.1)
import numpy as np
import cntk as C
def create_reader(path, input_dim, output_dim, rnd_order, sweeps):
  x_strm = C.io.StreamDef(field='symptoms', shape=input_dim,
   is_sparse=False)
  y_strm = C.io.StreamDef(field='disease', shape=output_dim,
    is_sparse=False)
  streams = C.io.StreamDefs(x_src=x_strm, y_src=y_strm)
  deserial = C.io.CTFDeserializer(path, streams)
  mb_src = C.io.MinibatchSource(deserial, randomize=rnd_order, \
    max_sweeps=sweeps)
  return mb_src
# ===================================================================
def main():
  print("\nBegin binary classification (two-node technique) \n")
  print("Using CNTK version = " + str(C.__version__) + "\n")
  input_dim = 18
  hidden_dim = 20
  output_dim = 2
  train_file = ".\\Data\\cleveland_cntk_twonode.txt"
  # 1. create network
  X = C.ops.input_variable(input_dim, np.float32)
  Y = C.ops.input_variable(output_dim, np.float32)
  print("Creating a 18-20-2 tanh-softmax NN ")
  with C.layers.default_options(init=C.initializer.uniform(scale=0.01,\
    seed=1)):
    hLayer = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
      name='hidLayer')(X) 
    oLayer = C.layers.Dense(output_dim, activation=None,
     name='outLayer')(hLayer)
  nnet = oLayer
  model = C.ops.softmax(nnet)
  # 2. create learner and trainer
  print("Creating a cross entropy batch=10 SGD LR=0.005 Trainer ")
  tr_loss = C.cross_entropy_with_softmax(nnet, Y)
  tr_clas = C.classification_error(nnet, Y)
  max_iter = 5000
  batch_size = 10
  learn_rate = 0.005
  learner = C.sgd(nnet.parameters, learn_rate)
  trainer = C.Trainer(nnet, (tr_loss, tr_clas), [learner])
  # 3. create reader for train data
  rdr = create_reader(train_file, input_dim, output_dim,
    rnd_order=True, sweeps=C.io.INFINITELY_REPEAT)
  heart_input_map = {
    X : rdr.streams.x_src,
    Y : rdr.streams.y_src
  }
  # 4. train
  print("\nStarting training \n")
  for i in range(0, max_iter):
    curr_batch = rdr.next_minibatch(batch_size, \
      input_map=heart_input_map)
    trainer.train_minibatch(curr_batch)
    if i % int(max_iter/10) == 0:
      mcee = trainer.previous_minibatch_loss_average
      macc = (1.0 - trainer.previous_minibatch_evaluation_average) \
        * 100
      print("batch %4d: mean loss = %0.4f, accuracy = %0.2f%% " \
        % (i, mcee, macc))
  print("\nTraining complete")
  # 5. evaluate model using all data
  print("\nEvaluating accuracy using built-in test_minibatch() \n")
  rdr = create_reader(train_file, input_dim, output_dim,
    rnd_order=False, sweeps=1)
  heart_input_map = {
    X : rdr.streams.x_src,
    Y : rdr.streams.y_src
  }
  num_test = 297
  all_test = rdr.next_minibatch(num_test, input_map=heart_input_map)
  acc = (1.0 - trainer.test_minibatch(all_test)) * 100
  print("Classification accuracy on the %d data items = %0.2f%%" \
    % (num_test,acc))
  # (could save model here)
  # (use trained model to make prediction)
  print("\nEnd Cleveland Heart Disease classification ")
# ===================================================================
if __name__ == "__main__":
  main()

cleveland_bnn.py 演示程序包含一个帮助程序函数 create_reader。所有控制逻辑都包含在一个主函数中。因为 CNTK 尚不成熟,还处于蓬勃发展阶段,所以最好添加注释,详述使用的是哪个版本(在此示例中,使用的版本为 2.3)。

安装 CNTK 可能会有点棘手。首先,安装 Python 的 Anaconda 分发,其中包含必需的 Python 解释器、NumPy 和 SciPy 等必需包,以及 pip 等实用工具。我使用的是包括 Python 3.5 的 Anaconda3 4.1.1 64 位。安装 Anaconda 后,使用 pip 实用工具将 CNTK 安装为 Python 包,而不是独立系统。我通过普通命令行界面运行的命令如下:

>pip install https://cntk.ai/PythonWheel/CPU-Only/cntk-2.3-cp35-cp35m-win_amd64.whl

我见过的几乎所有 CNTK 安装失败都是由于 Anaconda-CNTK 版本不兼容所致。

首先,此演示程序做好创建神经网络的准备:

input_dim = 18
hidden_dim = 20
output_dim = 2
train_file = ".\\Data\\cleveland_cntk_twonode.txt"
X = C.ops.input_variable(input_dim, np.float32)
Y = C.ops.input_variable(output_dim, np.float32)

输入和输出节点数量通过数据进行确定,而隐藏处理节点数量是自由参数,必须通过反复试验法进行确定。使用 32 位变量对神经网络很常见,因为与使用 64 位实现的准确度相比,造成的性能损失是不值得的。

神经网络的创建如下:

with C.layers.default_options(init=C.initializer.uniform(scale=0.01,\
  seed=1)):
  hLayer = C.layers.Dense(hidden_dim, activation=C.ops.tanh,
    name='hidLayer')(X) 
  oLayer = C.layers.Dense(output_dim, activation=None,
   name='outLayer')(hLayer)
nnet = oLayer
model = C.ops.softmax(nnet)

包含语句的 Python 是一种语法快捷方式,可以将一组常见参数应用到多个函数。此演示程序对隐藏层节点使用 tanh 激活;常见替换选择是 sigmoid 函数。请注意,不对输出节点应用任何激活。这是 CNTK 的不同寻常之处,因为 CNTK 定型函数需要使用未激活的原始值。nnet 对象只是便捷别名而已。model 对象包含 softmax 激活函数,以便在定型后用于预测。由于 Python 按引用进行分配,因此定型 nnet 对象也会定型 model 对象。

对神经网络进行定型

神经网络已准备好通过如下代码进行定型:

tr_loss = C.cross_entropy_with_softmax(nnet, Y)
tr_clas = C.classification_error(nnet, Y)
max_iter = 5000
batch_size = 10
learn_rate = 0.005
learner = C.sgd(nnet.parameters, learn_rate)
trainer = C.Trainer(nnet, (tr_loss, tr_clas), [learner])

tr_loss(“定型损失”)对象告知 CNTK 如何在定型时度量误差。包含 softmax 的交叉熵的替换选择是平方误差。tr_clas(“定型分类误差”)对象可用于自动计算定型期间或定型后不正确预测所占的百分比。

定型迭代次数上限值、一次定型的批中项数和学习速率全都是自由参数,必须通过反复试验法进行确定。可以将 Learner 对象视为算法,并将 Trainer 对象视为使用 Learner 算法查找神经网络权重和偏差适当值的对象。

Reader 对象的创建语句如下:

rdr = create_reader(train_file, input_dim, output_dim,
  rnd_order=True, sweeps=C.io.INFINITELY_REPEAT)
heart_input_map = {
  X : rdr.streams.x_src,
  Y : rdr.streams.y_src
}

如果查看图 3**** 中的 create_reader 定义,就会发现它指定了数据文件中使用的标记名称(“symptoms”和“disease”)。可以将 create_reader 和 Reader 对象创建代码视为神经网络二元分类问题的样本代码。只需更改标记名称和映射字典名称 (heart_input_map)。

一切就绪后,按如下所示进行定型:

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

在迭代次数固定的情况下进行定型的替换做法是,在损失/误差降至阈值以下时停止定型。请务必在定型期间显示损失/误差,因为定型失败是规则,而不是异常。直接解释交叉熵误差会有一点困难,但值应越变越小。此演示程序计算并打印平均分类准确度(我认为这是更自然的指标),而不是显示平均分类损失/误差。

评估和使用模型

定型网络后,通常需要确定用于定型的整个数据集的损失/误差和分类准确度。此演示程序使用以下代码评估总分类准确度:

rdr = create_reader(train_file, input_dim, output_dim,
  rnd_order=False, sweeps=1)
heart_input_map = {
  X : rdr.streams.x_src,
  Y : rdr.streams.y_src
}
num_test = 297
all_test = rdr.next_minibatch(num_test, input_map=heart_input_map)
acc = (1.0 - trainer.test_minibatch(all_test)) * 100
print("Classification accuracy on the %d data items = %0.2f%%" \
  % (num_test,acc))

新建了数据读取器。请注意,与用于定型的读取器不同,新建的读取器不会按随机顺序遍历数据,且扫描数量设为 1。已重新创建 heart_input_map 字典对象。常犯的一个错误是,尝试和使用起始对象,但 rdr 对象已更改,因此需要重新创建映射。test_minibatch 函数返回最小批参数(在此示例中,为整个数据集)的平均分类误差。

此演示程序不计算整个数据集的损失/误差。可使用 previous_minibatch_loss_average 函数,但必须小心的是,切勿执行其他定型迭代,否则会更改网络。

通常建议在定型后或定型期间保存模型。在 CNTK 中,保存如下所示:

mdl_name = ".\\Models\\cleveland_bnn.model"
model.save(mdl_name)

此保存格式为默认的 CNTK v2。也可以使用开放神经网络交换 (ONNX) 格式。请注意,通常建议保存 model 对象(包含 softmax 激活函数),而不是 nnet 对象。

通过以下代码行,可以将已保存的模型从其他程序加载到内存中:

mdl_name = ".\\Models\\cleveland_bnn.model"
model = C.ops.functions.Function.load(mdl_name)

加载后,就可以使用模型了,就像已定型模型一样。

此演示程序不使用已定型模型进行预测。可以按如下方式编写代码:

unknown = np.array([0.5555, -1, ... ], dtype=np.float32)
predicted = model.eval(unknown)

返回到预测因子变量的结果可能为,值总和是 1.0 的 1x2 矩阵(例如 [[0.2500, 0.7500]])。由于第二个值较大,因此结果映射到 (0, 1),进而映射到“无疾病”。

总结

大多数深度学习代码库都使用单节点技术执行神经网络二元分类。如果使用此方法,要预测的变量值编码为 0 或 1。输出维度设为 1,而不是 2。必须使用二元交叉熵误差,而不是普通的交叉熵误差。CNTK 不含支持单节点的内置分类误差函数,因此必须从头开始实现自己的函数。定型时,每次迭代获取的信息通常较少(尽管定型速度有点快),因此与使用双节点技术相比,通常不得不通过更多次迭代才能定型单节点模型。鉴于上述这些原因,我更愿意对神经网络二元分类使用双节点技术。


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

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


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