概述

上一节我们尝试通过更复杂的模型(经典的全连接神经网络和卷积神经网络),提升手写数字识别模型训练的准确性。本节我们继续将“横纵式”教学法从横向展开,如 图1 所示,探讨损失函数的优化对模型训练效果的影响。

【手写数字识别】之损失函数 - 图1

图1:“横纵式”教学法 — 损失函数优化

损失函数是模型优化的目标,用于在众多的参数取值中,识别最理想的取值。损失函数的计算在训练过程的代码中,每一轮模型训练的过程都相同,分如下三步:

  • 先根据输入数据正向计算预测输出。
  • 再根据预测值和真实值计算损失。
  • 最后根据损失反向传播梯度并更新参数。

分类任务的损失函数

在之前的方案中,我们复用了房价预测模型的损失函数-均方误差。从预测效果来看,虽然损失不断下降,模型的预测值逐渐逼近真实值,但模型的最终效果不够理想。究其根本,不同的深度学习任务需要有各自适宜的损失函数。我们以房价预测和手写数字识别两个任务为例,详细剖析其中的缘由如下:

  • 房价预测是回归任务,而手写数字识别是分类任务,使用均方误差作为分类任务的损失函数存在逻辑和效果上的缺欠。
  • 房价可以是大于0的任何浮点数,而手写数字识别的输出只可能是0-9之间的10个整数,相当于一种标签。
  • 在房价预测的案例中,由于房价本身是一个连续的实数值,因此以模型输出的数值和真实房价差距作为损失函数(loss)是符合道理的。但对于分类问题,真实结果是分类标签,而模型输出是实数值,导致以两者相减作为损失不具备物理含义。

那么,什么是分类任务的合理输出呢?分类任务本质上是“某种特征组合下的分类概率”,下面以一个简单案例说明,如 图2 所示。

【手写数字识别】之损失函数 - 图2

图2:观测数据和背后规律之间的关系

在本案例中,医生根据肿瘤大小

【手写数字识别】之损失函数 - 图3 作为肿瘤性质 【手写数字识别】之损失函数 - 图4 的参考判断(判断的因素有很多,肿瘤大小只是其中之一),那么我们观测到该模型判断的结果是 【手写数字识别】之损失函数 - 图5【手写数字识别】之损失函数 - 图6 的标签(1为恶性,0为良性)。而这个数据背后的规律是不同大小的肿瘤,属于恶性肿瘤的概率。观测数据是真实规律抽样下的结果,分类模型应该拟合这个真实规律,输出属于该分类标签的概率。

Softmax函数

如果模型能输出10个标签的概率,对应真实标签的概率输出尽可能接近100%,而其他标签的概率输出尽可能接近0%,且所有输出概率之和为1。这是一种更合理的假设!与此对应,真实的标签值可以转变成一个10维度的one-hot向量,在对应数字的位置上为1,其余位置为0,比如标签“6”可以转变成[0,0,0,0,0,1,0,0,0,0]。

为了实现上述思路,需要引入Softmax函数,它可以将原始输出转变成对应标签的概率,公式如下,其中

【手写数字识别】之损失函数 - 图7 是标签类别个数。

【手写数字识别】之损失函数 - 图8

从公式的形式可见,每个输出的范围均在0~1之间,且所有输出之和等于1,这是这种变换后可被解释成概率的基本前提。对应到代码上,我们需要在网络定义部分修改输出层:self.fc = FC(name_scope, size=10, act=‘softmax’),即是对全连接层FC的输出加一个softmax运算。

图3 是一个三个标签的分类模型(三分类)使用的softmax输出层,从中可见原始输出的三个数字3、1、-3,经过softmax层后转变成加和为1的三个概率值0.88、0.12、0。

【手写数字识别】之损失函数 - 图9

图3:网络输出层改为softmax函数

上文解释了为何让分类模型的输出拟合概率的原因,但为何偏偏用softmax函数完成这个职能? 下面以二分类问题(只输出两个标签)进行原理的探讨。

对于二分类问题,使用两个输出接入softmax作为输出层,等价于使用单一输出接入Sigmoid函数。如 图4 所示,利用两个标签的输出概率之和为1的条件,softmax输出0.6和0.4两个标签概率,从数学上等价于输出一个标签的概率0.6。

【手写数字识别】之损失函数 - 图10

图4:对于二分类问题,等价于单一输出接入Sigmoid函数

在这种情况下,只有一层的模型为

【手写数字识别】之损失函数 - 图11【手写数字识别】之损失函数 - 图12 为Sigmoid函数。模型预测为1的概率为 【手写数字识别】之损失函数 - 图13 ,模型预测为0的概率为 【手写数字识别】之损失函数 - 图14

图5 是肿瘤大小和肿瘤性质的数据图。从图中可发现,往往尺寸越大的肿瘤几乎全部是恶性,尺寸极小的肿瘤几乎全部是良性。只有在中间区域,肿瘤的恶性概率会从0逐渐到1(绿色区域),这种数据的分布是符合多数现实问题的规律。如果我们直接线性拟合,相当于红色的直线,会发现直线的纵轴0-1的区域会拉的很长,而我们期望拟合曲线0-1的区域与真实的分类边界区域重合。那么,观察下Sigmoid的曲线趋势可以满足我们对个问题的一切期望,它的概率变化会集中在一个边界区域,有助于模型提升边界区域的分辨率。

【手写数字识别】之损失函数 - 图15

图5:使用sigmoid拟合输出可提高分类模型对边界的分辨率

这就类似于公共区域使用的不带有恒温装置的热水器温度阀门,如 图6 所示。由于人体适应的水温在34度-42度之间,我们更期望阀门的水温条件集中在这个区域,而不是在0-100度之间线性分布。

【手写数字识别】之损失函数 - 图16

图6:热水器水温控制

交叉熵

在模型输出为分类标签的概率时,直接以标签和概率做比较也不够合理,人们更习惯使用交叉熵误差作为分类问题的损失衡量。

交叉熵损失函数的设计是基于最大似然思想:最大概率得到观察结果的假设是真的。如何理解呢?举个例子来说,如 图7 所示。有两个外形相同的盒子,甲盒中有99个白球,1个黑球;乙盒中有99个黑球,1个白球。一次试验取出了一个黑球,请问这个球应该是从哪个盒子中取出的?

【手写数字识别】之损失函数 - 图17

图7:体会最大似然的思想

相信大家简单思考后均会得出更可能是从乙盒中取出的,因为从乙盒中取出一个黑球的概率更高

【手写数字识别】之损失函数 - 图18 ,所以观察到一个黑球更可能是从乙盒中取出的 【手写数字识别】之损失函数 - 图19【手写数字识别】之损失函数 - 图20 是观测的数据,即黑球白球; 【手写数字识别】之损失函数 - 图21 是模型,即甲盒乙盒。这就是贝叶斯公式所表达的思想:

【手写数字识别】之损失函数 - 图22

依据贝叶斯公式,某二分类模型“生成”

【手写数字识别】之损失函数 - 图23 个训练样本的概率:

【手写数字识别】之损失函数 - 图24


说明:

对于二分类问题,模型为

【手写数字识别】之损失函数 - 图25【手写数字识别】之损失函数 - 图26 为Sigmoid函数。当 【手写数字识别】之损失函数 - 图27 =1,概率为 【手写数字识别】之损失函数 - 图28 ;当 【手写数字识别】之损失函数 - 图29 =0,概率为 【手写数字识别】之损失函数 - 图30


经过公式推导,使得上述概率最大等价于最小化交叉熵,得到交叉熵的损失函数。交叉熵的公式如下:

【手写数字识别】之损失函数 - 图31

其中,

【手写数字识别】之损失函数 - 图32 表示以 【手写数字识别】之损失函数 - 图33 为底数的自然对数。 【手写数字识别】之损失函数 - 图34 代表模型输出, 【手写数字识别】之损失函数 - 图35 代表各个标签。 【手写数字识别】之损失函数 - 图36 中只有正确解的标签为1,其余均为0(one-hot表示)。

因此,交叉熵只计算对应着“正确解”标签的输出的自然对数。比如,假设正确标签的索引是“2”,与之对应的神经网络的输出是0.6,则交叉熵误差是

【手写数字识别】之损失函数 - 图37 ;若“2”对应的输出是0.1,则交叉熵误差为 【手写数字识别】之损失函数 - 图38 。由此可见,交叉熵误差的值是由正确标签所对应的输出结果决定的。

自然对数的函数曲线可由如下代码实现。

  1. import matplotlib.pyplot as plt
  2. import numpy as np
  3. x = np.arange(0.01,1,0.01)
  4. y = np.log(x)
  5. plt.title("y=log(x)")
  6. plt.xlabel("x")
  7. plt.ylabel("y")
  8. plt.plot(x,y)
  9. plt.show()
  10. plt.figure()

【手写数字识别】之损失函数 - 图39

  1. <Figure size 432x288 with 1 Axes>
  1. <Figure size 432x288 with 0 Axes>
  1. <Figure size 432x288 with 0 Axes>

如自然对数的图形所示,当

【手写数字识别】之损失函数 - 图40 等于1时, 【手写数字识别】之损失函数 - 图41 为0;随着 【手写数字识别】之损失函数 - 图42 向0靠近, 【手写数字识别】之损失函数 - 图43 逐渐变小。因此,正确解标签对应的输出越大,交叉熵的值越接近0;当输出为1时,交叉熵误差为0。反之,如果正确解标签对应的输出越小,则交叉熵的值越大。

交叉熵的代码实现

在手写数字识别任务中,仅改动三行代码,就可以将在现有模型的损失函数替换成交叉熵(cross_entropy)。

  • 在读取数据部分,将标签的类型设置成int,体现它是一个标签而不是实数值(飞桨框架默认将标签处理成int64)。
  • 在网络定义部分,将输出层改成“输出十个标签的概率”的模式。
  • 在训练过程部分,将损失函数从均方误差换成交叉熵。

在数据处理部分,需要修改标签变量Label的格式,代码如下所示。

  • 从:label = np.reshape(labels[i], [1]).astype(‘float32’)
  • 到:label = np.reshape(labels[i], [1]).astype(‘int64’)
  1. #修改标签数据的格式,从float32到int64
  2. import os
  3. import random
  4. import paddle
  5. import paddle.fluid as fluid
  6. from paddle.fluid.dygraph.nn import Conv2D, Pool2D, Linear
  7. import numpy as np
  8. from PIL import Image
  9. import gzip
  10. import json
  11. # 定义数据集读取器
  12. def load_data(mode='train'):
  13. # 数据文件
  14. datafile = './work/mnist.json.gz'
  15. print('loading mnist dataset from {} ......'.format(datafile))
  16. data = json.load(gzip.open(datafile))
  17. train_set, val_set, eval_set = data
  18. # 数据集相关参数,图片高度IMG_ROWS, 图片宽度IMG_COLS
  19. IMG_ROWS = 28
  20. IMG_COLS = 28
  21. if mode == 'train':
  22. imgs = train_set[0]
  23. labels = train_set[1]
  24. elif mode == 'valid':
  25. imgs = val_set[0]
  26. labels = val_set[1]
  27. elif mode == 'eval':
  28. imgs = eval_set[0]
  29. labels = eval_set[1]
  30. imgs_length = len(imgs)
  31. assert len(imgs) == len(labels), \
  32. "length of train_imgs({}) should be the same as train_labels({})".format(
  33. len(imgs), len(labels))
  34. index_list = list(range(imgs_length))
  35. # 读入数据时用到的batchsize
  36. BATCHSIZE = 100
  37. # 定义数据生成器
  38. def data_generator():
  39. if mode == 'train':
  40. random.shuffle(index_list)
  41. imgs_list = []
  42. labels_list = []
  43. for i in index_list:
  44. img = np.reshape(imgs[i], [1, IMG_ROWS, IMG_COLS]).astype('float32')
  45. label = np.reshape(labels[i], [1]).astype('int64')
  46. imgs_list.append(img)
  47. labels_list.append(label)
  48. if len(imgs_list) == BATCHSIZE:
  49. yield np.array(imgs_list), np.array(labels_list)
  50. imgs_list = []
  51. labels_list = []
  52. # 如果剩余数据的数目小于BATCHSIZE,
  53. # 则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
  54. if len(imgs_list) > 0:
  55. yield np.array(imgs_list), np.array(labels_list)
  56. return data_generator

在网络定义部分,需要修改输出层结构,代码如下所示。

  • 从:self.fc = FC(name_scope, size=1, act=None)
  • 到:self.fc = FC(name_scope, size=10, act=‘softmax’)
  1. # 定义模型结构
  2. class MNIST(fluid.dygraph.Layer):
  3. def __init__(self, name_scope):
  4. super(MNIST, self).__init__(name_scope)
  5. name_scope = self.full_name()
  6. # 定义一个卷积层,使用relu激活函数
  7. self.conv1 = Conv2D(num_channels=1, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
  8. # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
  9. self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
  10. # 定义一个卷积层,使用relu激活函数
  11. self.conv2 = Conv2D(num_channels=20, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
  12. # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
  13. self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
  14. # 定义一个全连接层,输出节点数为10
  15. self.fc = Linear(input_dim=980, output_dim=10, act='softmax')
  16. # 定义网络的前向计算过程
  17. def forward(self, inputs):
  18. x = self.conv1(inputs)
  19. x = self.pool1(x)
  20. x = self.conv2(x)
  21. x = self.pool2(x)
  22. x = fluid.layers.reshape(x, [x.shape[0], 980])
  23. x = self.fc(x)
  24. return x

修改计算损失的函数,从均方误差(常用于回归问题)到交叉熵误差(常用于分类问题),代码如下所示。

  • 从:loss = fluid.layers.square_error_cost(predict, label)
  • 到:loss = fluid.layers.cross_entropy(predict, label)
  1. #仅修改计算损失的函数,从均方误差(常用于回归问题)到交叉熵误差(常用于分类问题)
  2. with fluid.dygraph.guard():
  3. model = MNIST("mnist")
  4. model.train()
  5. #调用加载数据的函数
  6. train_loader = load_data('train')
  7. optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
  8. EPOCH_NUM = 5
  9. for epoch_id in range(EPOCH_NUM):
  10. for batch_id, data in enumerate(train_loader()):
  11. #准备数据,变得更加简洁
  12. image_data, label_data = data
  13. image = fluid.dygraph.to_variable(image_data)
  14. label = fluid.dygraph.to_variable(label_data)
  15. #前向计算的过程
  16. predict = model(image)
  17. #计算损失,使用交叉熵损失函数,取一个批次样本损失的平均值
  18. loss = fluid.layers.cross_entropy(predict, label)
  19. avg_loss = fluid.layers.mean(loss)
  20. #每训练了200批次的数据,打印下当前Loss的情况
  21. if batch_id % 200 == 0:
  22. print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
  23. #后向传播,更新参数的过程
  24. avg_loss.backward()
  25. optimizer.minimize(avg_loss)
  26. model.clear_gradients()
  27. #保存模型参数
  28. fluid.save_dygraph(model.state_dict(), 'mnist')
  1. loading mnist dataset from ./work/mnist.json.gz ......
  2. epoch: 0, batch: 0, loss is: [2.3634984]
  3. epoch: 0, batch: 200, loss is: [0.38027003]
  4. epoch: 0, batch: 400, loss is: [0.30602008]
  5. epoch: 1, batch: 0, loss is: [0.1570825]
  6. epoch: 1, batch: 200, loss is: [0.24927774]
  7. epoch: 1, batch: 400, loss is: [0.28396288]
  8. epoch: 2, batch: 0, loss is: [0.11362638]
  9. epoch: 2, batch: 200, loss is: [0.24342634]
  10. epoch: 2, batch: 400, loss is: [0.10399125]
  11. epoch: 3, batch: 0, loss is: [0.15782693]
  12. epoch: 3, batch: 200, loss is: [0.20173627]
  13. epoch: 3, batch: 400, loss is: [0.18618341]
  14. epoch: 4, batch: 0, loss is: [0.13703655]
  15. epoch: 4, batch: 200, loss is: [0.11431722]
  16. epoch: 4, batch: 400, loss is: [0.09789195]

虽然上述训练过程的损失明显比使用均方误差算法要小,但因为损失函数量纲的变化,我们无法从比较两个不同的Loss得出谁更加优秀。怎么解决这个问题呢?我们可以回归到问题的本质,谁的分类准确率更高来判断。在后面介绍完计算准确率和作图的内容后,读者可以自行测试采用不同损失函数下,模型准确率的高低。

至此,大家阅读论文中常见的一些分类任务模型图就清晰明了,如全连接神经网络、卷积神经网络,在模型的最后阶段,都是使用Softmax进行处理。

【手写数字识别】之损失函数 - 图44

图8:常见的分类任务模型图

由于我们修改了模型的输出格式,因此使用模型做预测时的代码也需要做相应的调整。从模型输出10个标签的概率中选择最大的,将其标签编号输出。

  1. # 读取一张本地的样例图片,转变成模型输入的格式
  2. def load_image(img_path):
  3. # 从img_path中读取图像,并转为灰度图
  4. im = Image.open(img_path).convert('L')
  5. im.show()
  6. im = im.resize((28, 28), Image.ANTIALIAS)
  7. im = np.array(im).reshape(1, 1, 28, 28).astype(np.float32)
  8. # 图像归一化
  9. im = 1.0 - im / 255.
  10. return im
  11. # 定义预测过程
  12. with fluid.dygraph.guard():
  13. model = MNIST("mnist")
  14. params_file_path = 'mnist'
  15. img_path = './work/example_0.jpg'
  16. # 加载模型参数
  17. model_dict, _ = fluid.load_dygraph("mnist")
  18. model.load_dict(model_dict)
  19. model.eval()
  20. tensor_img = load_image(img_path)
  21. #模型反馈10个分类标签的对应概率
  22. results = model(fluid.dygraph.to_variable(tensor_img))
  23. #取概率最大的标签作为预测输出
  24. lab = np.argsort(results.numpy())
  25. print("本次预测的数字是: ", lab[0][-1])
  1. 本次预测的数字是: 0

作业 2-2

预习下对于计算机视觉,有哪些常见的卷积神经网络(如LeNet-5、AlexNet等)?