这一讲的主要目的是让你熟悉使用CNTK工具集的Python接口去完成一个
分类任务。如果你已经完成了逻辑回归教程或熟悉机器学习,你可以跳过介绍部分。
本文最后更新于2017年11月06日,最后一次更新包含了较小的修复和改进。
大家好,我是本专栏的作者王万霖。今天我们来学习如何使用CNTK构建前向神经网络解决分类问题,类似于CNTK101,我们在这一讲仍然使用“医院患者数据集”。
问题(和CNTK101的问题一样):
一家癌症医院为我们提供了一批患者的数据,并希望我们能确定患者是否患有致命的恶性肿瘤,这是一个分类问题。为了帮助对每个病人进行分类,医院能够提供的数据是:患者的年龄和肿瘤的大小。换句话说,我们可以想象:年轻的病人和/或小肿瘤患者不太可能患上恶性肿瘤。
我们在这一讲使用了机器生成的模拟数据集进行练习:每个病人在图表中表示成为一个点,其中红色表示其患有恶性肿瘤、蓝色则表示良性。
注意:这只是一个学习的简单示例;在现实生活中,医生将会对患者从不同的方面进行测试/检查,之后再做出决定。
目标:
我们的目标是学习一个分类器,通过给定病人的两个特征(年龄和肿瘤大小),将病人的肿瘤分类为良性和恶性其中一类。
在 CNTK101教程中,我们学习了使用线性分类器对数据点进行逻辑回归。在现实世界的问题中,线性分类器通常不能准确地对数据进行建模,因为很少有文章讲述关于如何构造好的特征。这通常会导致精确度的限制,并需要具有更复杂决策边界的模型。在本教程中,我们将结合多个线性单元(从 CNTK 101 教程:逻辑回归),从而制作一个非线性分类器。此类分类器的另一个用途是自动从数据中学习到特征编码,我们将在后边的教程对此进行介绍。
步骤:
一般地,一种学习方法一般由五个步骤组成:数据读取、数据预处理、创建模型、学习模型参数和模型评估(又称测试/预测)
除了第三个步骤,其他的所有内容与CNTK 101保持一致。
前馈网络模型
在这一讲中,所使用的数据集类似于逻辑回归教程中使用的。该模型结合了多个逻辑分类器,以便能够对数据进行分类,从而导致对数据进行正确分类所需的决策边界比简单的线性模型 (如逻辑回归) 更复杂。下图显示了网络的一般形状。
前馈神经网络(feedforward neural network)是一种人工神经网络,之所以被称为“前馈”(或者“前向”),是因为在网络结构的拓扑中不出现环(注意,这并不意味着数据不能反方向流通)。前馈神经网络是设计的第一种最简单的人工神经网络。在这个网络中, 信息只在一个方向移动, 向前, 从输入节点, 通过隐藏节点 (如果有) 和输出节点。
在本教程中, 我们将通过完成五步骤所需的不同步骤来训练和测试玩具数据的模型。
生成数据
如果你已经完成了CNTK 101的相关内容,你可以跳过这一节。
让我们用 numpy 库生成一些模拟癌症例子的合成数据。我们有两个特征 (以两个维度表示), 分别是两个类之一 (良性:蓝点或恶性:红点)。
在我们的例子中, 每个样本在训练数据有一个标签 (蓝色或红色) 对应于每个样本 (一组特征-年龄和大小)。在本例中, 我们有两个由标签0或1表示的类,因此是一个二进制分类任务。
# Ensure we always get the same amount of randomness
np.random.seed(0)
# Define the data dimensions
input_dim = 2
num_output_classes = 2
输入和标签
在本教程中,我们使用 numpy 库生成合成数据。在解决实际问题的过程中,你将会使用Reader类,这将帮助你从文件中读取数据的特征值 (年龄、肿瘤大小) 对应的每一个样本 (病人)。注意,每个样本都可以驻留在更高维度空间中 (当有更多的特征可用时),并将其表示为 CNTK 中的张量。更高级的教程应介绍高维数据的处理。
# Helper function to generate a random data sample
def generate_random_data_sample(sample_size, feature_dim, num_classes):
# Create synthetic data using NumPy.
Y = np.random.randint(size=(sample_size, 1), low=0, high=num_classes)
# Make sure that the data is separable
X = (np.random.randn(sample_size, feature_dim)+3) * (Y+1)
X = X.astype(np.float32)
# converting class 0 into the vector "1 0 0",
# class 1 into vector "0 1 0", ...
class_ind = [Y==class_number for class_number in range(num_classes)]
Y = np.asarray(np.hstack(class_ind), dtype=np.float32)
return X, Y
# Create the input variables denoting the features and the label data. Note: the input
# does not need additional info on number of observations (Samples) since CNTK first create only
# the network tooplogy first
mysamplesize = 64
features, labels = generate_random_data_sample(mysamplesize, input_dim, num_output_classes)
来让我们看看数据集的样子。
注意:如果导入matplotlib.pyplot失败了,请在命令行运行以下命令: conda install
matplotlib,这将会修复pyplot的相关依赖。如果你使用的是Anaconda以外的环境,请使用这个命令:pip install matplotlib。
# 画出图像
import matplotlib.pyplot as plt
%matplotlib inline
# 一共有两个分类
colors = ['r' if l == 0 else 'b' for l in labels[:,0]]
plt.scatter(features[:,0], features[:,1], c=colors)
plt.xlabel("Scaled age (in yrs)")
plt.ylabel("Tumor size (in cm)")
plt.show()
创建模型
我们的前向神经网络具有简单的 个隐藏层(hidden layers),其中每一层具有 个隐藏节点(hidden nodes)。
在示例中, 每个隐藏层中的绿色节点数 (参见上面的图片) 设置为 50, 隐藏层数 (参见绿色节点的层数) 为2。填写以下值:
- num_hidden_layers
- hidden_layers_dim
注意:在本图中,我们没有显示偏置节点(在逻辑回归教程中引入)。每个隐藏层都有一个偏置节点。
num_hidden_layers = 2
hidden_layers_dim = 50
网络的输入与输出:
- 输入变量 (一个关键的CNTK概念):
在CNTK中,输入变量是面向用户代码的
容器(container)。用户通过向输入变量提供不同的实例数据(数据点或数据点示例),等同于 (年龄、肿瘤大小) 元组在我们的例子中) 作为模型函数的输入 (也就是训练) 和模型评估 (又称测试)。因此,输入的形状必须与将提供的数据的形状相匹配。例如,如果每个数据点都是高度
像素、宽度
像素的灰度图像,则输入特征将是
个 floating-point 值的向量,表示每个
像素的强度,并且可以写成 c. input_variable (10 * 5, np float32)。同样,在我们的例子维度是年龄和肿瘤大小,因而 input_dim = 2。有关数据及其维度的更多信息,我们将在另外的教程中介绍。
问题:
您所选择的模型的输入维度是什么?这对于我们理解网络中的变量或 CNTK 中的模型表示是至关重要的。
# The input variable (representing 1 observation, in our example of age and size) x, which
# in this case has a dimension of 2.
#
# The label variable has a dimensionality equal to the number of output classes in our case 2.
input = C.input_variable(input_dim)
label = C.input_variable(num_output_classes)
构建前向神经网络
让我们在这里定义前向神经网络。其中,第一层接收 维的输入特征向量,并输出维度为 的置信度(evidence)层 。在输入层的每一个特征,都在输出层由某个特定的 维的矩阵 表示。我们的第一步时计算整个特征集合中的置信度(evidence)。注意,对于矩阵或者向量,我们使用粗体表示:
其中, 是维度为 的向量。
在线性层(linear_layer)函数中,我们定义了两种运算:
- 将权重 与特征向量 相乘,再加上独立的特征贡献(individual features’ contribution)。
- 加上偏置 .
def linear_layer(input_var, output_dim):
input_dim = input_var.shape[0]
weight = C.parameter(shape=(input_dim, output_dim))
bias = C.parameter(shape=(output_dim))
return bias + C.times(input_var, weight)
我们需要做的下一步,是将置信度(evidence)通过你选择的激活函数( activation functions),转换为离散的具体分类。简而言之,如果网络认为某个特定分类的置信度很高,则可能说明输入特性 输入这个分类。 或者 函数曾经很流行。我们将在这一讲中使用 函数作为示例。 函数的输出将作为下一层的输出,或者整个网络的输出。
问题:
试试将激活函数换为其他的函数:
def dense_layer(input_var, output_dim, nonlinearity):
l = linear_layer(input_var, output_dim)
return nonlinearity(l)
现在,我们创建了一个隐藏层(hidden layer),我们需要对这些层进行迭代,以训练一个全连接(fully connected)的分类器。第一层的输出 ,将作为下一层的输入。
在我们拥有 层的示例中,我们可以用代码表示为:
h1 = dense_layer(input_var, hidden_layer_dim, sigmoid)
h2 = dense_layer(h1, hidden_layer_dim, sigmoid)
要在尝试使用层数时更加灵活, 我们更愿意将其写成如下所示:
h = dense_layer(input_var, hidden_layer_dim, sigmoid)
for i in range(1, num_hidden_layers):
h = dense_layer(h, hidden_layer_dim, sigmoid)
# Define a multilayer feedforward classification model
def fully_connected_classifier_net(input_var, num_output_classes, hidden_layer_dim,
num_hidden_layers, nonlinearity):
h = dense_layer(input_var, hidden_layer_dim, nonlinearity)
for i in range(1, num_hidden_layers):
h = dense_layer(h, hidden_layer_dim, nonlinearity)
return linear_layer(h, num_output_classes)
网络输出 将用于表示跨网络的输出。
# Create the fully connected classfier
z = fully_connected_classifier_net(input, num_output_classes, hidden_layers_dim,
num_hidden_layers, C.sigmoid)
虽然上述网络帮助我们更好地理解如何使用 CNTK 的基元实现网络, 但使用层库更方便、更快。它提供了预定义的常用 “层” (乐高积木), 它简化了由标准层堆叠在一起的网络的设计。例如, dense_layer 已经很容易地通过稠密层函数来组成我们的深模型。我们可以通过输入变量 (输入) 到这个模型, 以获得网络输出。
建议任务:
请浏览上面定义的模型和 create_model 函数的输出,并说服您自己,下面的实现封装了上面的代码。
def create_model(features):
with C.layers.default_options(init=C.layers.glorot_uniform(), activation=C.sigmoid):
h = features
for _ in range(num_hidden_layers):
h = C.layers.Dense(hidden_layers_dim)(h)
last_layer = C.layers.Dense(num_output_classes, activation = None)
return last_layer(h)
z = create_model(input)
学习模型参数
现在我们的网络已经构建好了,我们需要对于我们网络中的每一层(layer),学习参数 和 。
fNow that the network is setup, we would like to learn the paramor each of the layers in our network. To do so we convert, the computed evidence ( ) into a set of predicted probabilities ( ) using a softmax function.
One can see the softmax function as an activation function that maps the accumulated evidences to a probability distribution over the classes (Details of the softmax function). Other choices of activation function can be found here.
训练
如果你已经阅读过 CNTK101的相关部分,你可以跳过这一节的描述。
函数的输出是一个概率,它表示了数据属于哪个类的概率有多大。为了训练分类器,我们需要确定模型需要模仿的行为。换言之,我们希望生成的概率尽可能接近所观察到的标签。我们可以通过最小化我们的输出和地面真相标签的区别来实现这一点。此差异由成本函数 或损失函数计算。
交叉熵 是一种常见的损耗函数。它被定义为:
其中,训练数据随附的 是从 函数得出的预测概率,y是标签。在我们的二分类示例中,标签(label)是一个二维变量(等同于num_output_classes或者 )。通常,标签变量将有 元素与 的任何地方, 除了在数据点的真正类的索引, 它将是 。强烈建议您了解交叉熵函数的详细信息。
loss = C.cross_entropy_with_softmax(z, label)
评估模型
为了评估我们分类的效果,我们可将网络对于每一个实例的输出当做置信向量(vector of evidences),使用 函数转换为最可能的分类,然后与标准答案进行对比。
eval_error = C.classification_error(z, label)
设置训练
训练者使用最优化技术来尽量减少训练的损耗(loss)。在本教程中,我们将使用随机梯度下降 ( )来进行训练,这是目前最流行的技术之一。通常,一个模型从其参数(权重 和偏差 )的随机初始化开始 ,模型接收到每个训练样本,随机梯度下降优化器都可以计算预测标签和对应的真实标签之间的损失或错误,并应用梯度下降算法,在每次观察后生成一组新的模型参数,从而改善模型的分类性能。
上述方法在每次迭代后,更新所有参数的过程都是有方向性的。因为它不需要将整个数据集(所有样本点)加载到内存中,并且可以在较少的数据上做出调整,从而允许对大型数据集进行培训。但是,每次使用单个样本生成的更新,在迭代之间会有很大的不同。于是,我们的变通方法是,先将一小组观察加载到模型中,并使用该集合中的损失或错误的平均值来更新模型参数。这个小的子集被称为 。
有了 ,我们经常从更大的训练数据集中抽取样本点。我们重复使用不同的训练样本组合更新模型参数的过程,并在一段时间内拟合最小化损失(和错误)。当错误率不再显著变化,或经过预设的最大 数后,我们就可以说我们的模型是经过训练的。
其中,用于优化的关键参数之一是 。现在,我们可以简单地把它看作是一个缩放因子,它用于调整我们在任何迭代中改变参数的程度。我们将在后面的教程中介绍更多的细节。现在,有了这些信息,我们就可以创建我们的训练器了。
# Instantiate the trainer object to drive the model training
learning_rate = 0.5
lr_schedule = C.learning_rate_schedule(learning_rate, C.UnitType.minibatch)
learner = C.sgd(z.parameters, lr_schedule)
trainer = C.Trainer(z, (loss, eval_error), [learner])
首先, 让我们构建一些额外的函数,来实现数据的可视化,以及与训练相关的功能。注意:这些方便的功能是为了让你深入地了解到底发生了什么。
# Define a utility function to compute the moving average sum.
# A more efficient implementation is possible with np.cumsum() function
def moving_average(a, w=10):
if len(a) < w:
return a[:] # Need to send a copy of the array
return [val if idx < w else sum(a[(idx-w):idx])/w for idx, val in enumerate(a)]
# Defines a utility that prints the training progress
def print_training_progress(trainer, mb, frequency, verbose=1):
training_loss = "NA"
eval_error = "NA"
if mb%frequency == 0:
training_loss = trainer.previous_minibatch_loss_average
eval_error = trainer.previous_minibatch_evaluation_average
if verbose:
print ("Minibatch: {}, Train Loss: {}, Train Error: {}".format(mb, training_loss, eval_error))
return mb, training_loss, eval_error
运行训练器
我们现在已经准备好训练我们的逻辑回归模型。我们要决定哪些数据是我们需要投入到训练器中。
在本例中, 每一次迭代将对 个样本进行处理(上面图中的 个点),也就是 。我们想训练 个数据点。如果数据中的样本数仅为 个,则训练器将通过数据进行 轮传递。这由 表示。注意:在现实世界中,我们会得到一定数量的标记数据 (在这个例子中,(年龄, 大小) 观察及其标签 (良性/恶性))。我们将其中的大量的数据(比如说 %)用于训练,并将剩下的部分用于评估训练过的模型。
有了这些参数,我们就可以继续训练我们的简单前馈网络。
# Initialize the parameters for the trainer
minibatch_size = 25
num_samples = 20000
num_minibatches_to_train = num_samples / minibatch_size
# Run the trainer and perform model training
training_progress_output_freq = 20
plotdata = {"batchsize":[], "loss":[], "error":[]}
for i in range(0, int(num_minibatches_to_train)):
features, labels = generate_random_data_sample(minibatch_size, input_dim, num_output_classes)
# Specify the input variables mapping in the model to actual minibatch data for training
trainer.train_minibatch({input : features, label : labels})
batchsize, loss, error = print_training_progress(trainer, i,
training_progress_output_freq, verbose=0)
if not (loss == "NA" or error =="NA"):
plotdata["batchsize"].append(batchsize)
plotdata["loss"].append(loss)
plotdata["error"].append(error)
让我们来看看在不同的让我们在不同的训练 minibatches 上绘制错误。请注意, 当我们迭代训练损失减少, 虽然我们看到一些中间的颠簸。凸点表明, 在迭代过程中, 模型遇到了错误的观测。这可能发生在模型训练期间是新颖的观察。
一个方法来抚平的颠簸是通过增加 minibatch 的大小。在每个迭代中, 都可以从概念上使用整个数据集。这将确保损失在迭代时持续递减。但是, 此方法要求在数据集中的所有点上进行梯度计算, 并在本地更新大量迭代的模型参数之后重复这些运算。对于这个玩具的例子, 它不是一个大问题。然而, 在实际的例子中, 对每个参数更新迭代的整个数据集进行多次传递将变得计算性的禁止。
因此, 我们使用较小的 minibatches, 使用 sgd 可以使我们在性能大型数据集时具有很大的可伸缩性。有先进的变种的优化器独特的 CNTK, 使利用计算效率的真实世界的数据集, 将在高级教程中介绍。
# Compute the moving average loss to smooth out the noise in SGD
plotdata["avgloss"] = moving_average(plotdata["loss"])
plotdata["avgerror"] = moving_average(plotdata["error"])
# Plot the training loss and the training error
import matplotlib.pyplot as plt
plt.figure(1)
plt.subplot(211)
plt.plot(plotdata["batchsize"], plotdata["avgloss"], 'b--')
plt.xlabel('Minibatch number')
plt.ylabel('Loss')
plt.title('Minibatch run vs. Training loss')
plt.show()
plt.subplot(212)
plt.plot(plotdata["batchsize"], plotdata["avgerror"], 'r--')
plt.xlabel('Minibatch number')
plt.ylabel('Label Prediction Error')
plt.title('Minibatch run vs. Label Prediction Error')
plt.show()
评估/测试
到目前为止,我们已经对我们的网络进行了训练。现在来让我们预测那些没有在训练集中出现的数据——这就是所谓的测试数据(testing)。在本例中,我们来创建一些新的数据,并评估这一组数据的的平均错误和损失。我们使用训练器的 方法。注意,此以前未看过的数据的错误与培训错误类似。这是一个关键的检查。如果错误大于训练错误的幅度很大,则表明训练的模型在训练过程中所未看到的数据将无法很好地执行。这就是所谓的拟合。有几种方法可以解决超出本教程范围的拟合,但CNTK提供了解决拟的必要组件。
# Generate new data
test_minibatch_size = 25
features, labels = generate_random_data_sample(test_minibatch_size, input_dim, num_output_classes)
trainer.test_minibatch({input : features, label : labels})
程序的输出为:
0.12
为什么我们需要Why do we need to route the network output netout via softmax?
我们配置网络的方式包括所有激活节点的输出 (如图4中的绿色层)。输出节点 (图4中的橙色层) 将激活转换为概率。一个简单有效的方法是通过 softmax 函数来路由激活。
out = C.softmax(z)
让我们来对这些数据进行测试
predicted_label_probs = out.eval({input : features})
print("Label :", [np.argmax(label) for label in labels])
print("Predicted:", [np.argmax(row) for row in predicted_label_probs])
程序的输出为:
Label : [1, 0, 0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1]
Predicted: [1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 1, 0, 1, 0, 1, 1, 0, 0, 1, 0, 0, 0, 1]
探索与建议
- 试试看我们的分类器在不同的数据分布下的表现如何——比如,将 参数从 调整到 。为什么错误的个数增加了? 和逻辑回归相比,错误率有何不同?
- 尝试不同的优化器,比如: Adam (fsadagrad). learner = fsadagrad(z.parameters(), 0.02, 0, targetAdagradAvDenom=1)
- 你能尝试修改神经网络,来进一步降低错误率吗?什么时候你会看到过拟合现象的发生?
代码链接
如果你想从命令行运行本教程的代码,请见FeedForwardNet.py