概述

上一节我们明确了分类任务的损失函数(优化目标)的相关概念和实现方法,本节我们依旧横向展开"横纵式"教学法,如 图1 所示,本节主要探讨在手写数字识别任务中,使得损失达到最小的参数取值的实现方法。

【手写数字识别】之优化算法 - 图1

图1:“横纵式”教学法 — 优化算法

前提条件

在优化算法之前,需要进行数据处理、设计神经网络结构,代码与上一节保持一致,如下所示。如果读者已经掌握了这部分内容,可以直接阅读正文部分。

  1. # 加载相关库
  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. # 读取数据集中的训练集,验证集和测试集
  18. train_set, val_set, eval_set = data
  19. # 数据集相关参数,图片高度IMG_ROWS, 图片宽度IMG_COLS
  20. IMG_ROWS = 28
  21. IMG_COLS = 28
  22. # 根据输入mode参数决定使用训练集,验证集还是测试
  23. if mode == 'train':
  24. imgs = train_set[0]
  25. labels = train_set[1]
  26. elif mode == 'valid':
  27. imgs = val_set[0]
  28. labels = val_set[1]
  29. elif mode == 'eval':
  30. imgs = eval_set[0]
  31. labels = eval_set[1]
  32. # 获得所有图像的数量
  33. imgs_length = len(imgs)
  34. # 验证图像数量和标签数量是否一致
  35. assert len(imgs) == len(labels), \
  36. "length of train_imgs({}) should be the same as train_labels({})".format(
  37. len(imgs), len(labels))
  38. index_list = list(range(imgs_length))
  39. # 读入数据时用到的batchsize
  40. BATCHSIZE = 100
  41. # 定义数据生成器
  42. def data_generator():
  43. # 训练模式下,打乱训练数据
  44. if mode == 'train':
  45. random.shuffle(index_list)
  46. imgs_list = []
  47. labels_list = []
  48. # 按照索引读取数据
  49. for i in index_list:
  50. # 读取图像和标签,转换其尺寸和类型
  51. img = np.reshape(imgs[i], [1, IMG_ROWS, IMG_COLS]).astype('float32')
  52. label = np.reshape(labels[i], [1]).astype('int64')
  53. imgs_list.append(img)
  54. labels_list.append(label)
  55. # 如果当前数据缓存达到了batch size,就返回一个批次数据
  56. if len(imgs_list) == BATCHSIZE:
  57. yield np.array(imgs_list), np.array(labels_list)
  58. # 清空数据缓存列表
  59. imgs_list = []
  60. labels_list = []
  61. # 如果剩余数据的数目小于BATCHSIZE,
  62. # 则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
  63. if len(imgs_list) > 0:
  64. yield np.array(imgs_list), np.array(labels_list)
  65. return data_generator
  66. # 定义模型结构
  67. class MNIST(fluid.dygraph.Layer):
  68. def __init__(self, name_scope):
  69. super(MNIST, self).__init__(name_scope)
  70. name_scope = self.full_name()
  71. # 定义一个卷积层,输出通道20,卷积核大小为5,步长为1,padding为2,使用relu激活函数
  72. self.conv1 = Conv2D(num_channels=1, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
  73. # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
  74. self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
  75. # 定义一个卷积层,输出通道20,卷积核大小为5,步长为1,padding为2,使用relu激活函数
  76. self.conv2 = Conv2D(num_channels=20, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
  77. # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
  78. self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
  79. # 定义一个全连接层,输出节点数为10
  80. self.fc = Linear(input_dim=980, output_dim=10, act='softmax')
  81. # 定义网络的前向计算过程
  82. def forward(self, inputs):
  83. x = self.conv1(inputs)
  84. x = self.pool1(x)
  85. x = self.conv2(x)
  86. x = self.pool2(x)
  87. x = fluid.layers.reshape(x, [x.shape[0], -1])
  88. x = self.fc(x)
  89. return x

设置学习率

在深度学习神经网络模型中,通常使用标准的随机梯度下降算法更新参数,学习率代表参数更新幅度的大小,即步长。当学习率最优时,模型的有效容量最大,最终能达到的效果最好。学习率和深度学习任务类型有关,合适的学习率往往需要大量的实验和调参经验。探索学习率最优值时需要注意如下两点:

  • 学习率不是越小越好。学习率越小,损失函数的变化速度越慢,意味着我们需要花费更长的时间进行收敛,如 图2 左图所示。
  • 学习率不是越大越好。只根据总样本集中的一个批次计算梯度,抽样误差会导致计算出的梯度不是全局最优的方向,且存在波动。在接近最优解时,过大的学习率会导致参数在最优解附近震荡,损失难以收敛,如 图2 右图所示。

【手写数字识别】之优化算法 - 图2

图2: 不同学习率(步长过大/过小)的示意图

在训练前,我们往往不清楚一个特定问题设置成怎样的学习率是合理的,因此在训练时可以尝试调小或调大,通过观察Loss下降的情况判断合理的学习率,设置学习率的代码如下所示。

  1. #仅优化算法的设置有所差别
  2. with fluid.dygraph.guard():
  3. model = MNIST("mnist")
  4. model.train()
  5. #调用加载数据的函数
  6. train_loader = load_data('train')
  7. #设置不同初始学习率
  8. optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
  9. # optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.001, parameter_list=model.parameters())
  10. # optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.1, parameter_list=model.parameters())
  11. EPOCH_NUM = 5
  12. for epoch_id in range(EPOCH_NUM):
  13. for batch_id, data in enumerate(train_loader()):
  14. #准备数据,变得更加简洁
  15. image_data, label_data = data
  16. image = fluid.dygraph.to_variable(image_data)
  17. label = fluid.dygraph.to_variable(label_data)
  18. #前向计算的过程
  19. predict = model(image)
  20. #计算损失,取一个批次样本损失的平均值
  21. loss = fluid.layers.cross_entropy(predict, label)
  22. avg_loss = fluid.layers.mean(loss)
  23. #每训练了100批次的数据,打印下当前Loss的情况
  24. if batch_id % 200 == 0:
  25. print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
  26. #后向传播,更新参数的过程
  27. avg_loss.backward()
  28. optimizer.minimize(avg_loss)
  29. model.clear_gradients()
  30. #保存模型参数
  31. fluid.save_dygraph(model.state_dict(), 'mnist')

学习率的主流优化算

学习率是优化器的一个参数,调整学习率看似是一件非常麻烦的事情,需要不断的调整步长,观察训练时间和Loss的变化。经过研究员的不断的实验,当前已经形成了四种比较成熟的优化算法:SGD、Momentum、AdaGrad和Adam,效果如 图3 所示。

【手写数字识别】之优化算法 - 图3

图3: 不同学习率算法效果示意图

  • SGD: 随机梯度下降算法,每次训练少量数据,抽样偏差导致的参数收敛过程中震荡。

  • Momentum: 引入物理“动量”的概念,累积速度,减少震荡加入,使参数更新的方向更稳定。

每个批次的数据含有抽样误差,导致梯度更新的方向波动较大。如果我们引入物理动量的概念,给梯度下降的过程加入一定的“惯性”累积,就可以减少更新路径上的震荡!即每次更新的梯度由“历史多次梯度的累积方向”和“当次梯度”加权相加得到。历史多次梯度的累积方向往往是从全局视角更正确的方向,这与“惯性”的物理概念很像,也是为何其起名为“Momentum”的原因。类似不同品牌和材质的篮球有一定的重量差别,街头篮球队中的投手(擅长中远距离投篮)喜欢稍重篮球的比例较高。一个很重要的原因是,重的篮球惯性大,更不容易受到手势的小幅变形或风吹的影响。

  • AdaGrad: 根据不同参数距离最优解的远近,动态调整学习率。学习率逐渐下降,依据各参数变化大小调整学习率。

通过调整学习率的实验可以发现:当某个参数的现值距离最优解较远时(表现为梯度的绝对值较大),我们期望参数更新的步长大一些,以便更快收敛到最优解。当某个参数的现值距离最优解较近时(表现为梯度的绝对值较小),我们期望参数的更新步长小一些,以便更精细的逼近最优解。类似于打高尔夫球,专业运动员第一杆开球时,通常会大力打一个远球,让球尽量落在洞口附近。当第二杆面对离洞口较近的球时,他会更轻柔而细致的推杆,避免将球打飞。与此类似,参数更新的步长应该随着优化过程逐渐减少,减少的程度与当前梯度的大小有关。根据这个思想编写的优化算法称为“AdaGrad”,Ada是Adaptive的缩写,表示“适应环境而变化”的意思。

  • Adam: 由于Momentum和AdaGrad两个优化思路是正交的,因此可以将两个思路结合起来,这就是当前广泛应用的算法。

说明:

每种优化算法均有更多的参数设置,详情可查阅飞桨的官方API文档。理论最合理的未必在具体案例中最有效,所以模型调参是很有必要的,最优的模型配置往往是在一定“理论”和“经验”的指导下实验出来的。


我们可以尝试选择不同的优化算法训练模型,观察训练时间和损失变化的情况,代码实现如下。

  1. #仅优化算法的设置有所差别
  2. with fluid.dygraph.guard():
  3. model = MNIST("mnist")
  4. model.train()
  5. #调用加载数据的函数
  6. train_loader = load_data('train')
  7. #四种优化算法的设置方案,可以逐一尝试效果
  8. optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
  9. #optimizer = fluid.optimizer.MomentumOptimizer(learning_rate=0.01, momentum=0.9, parameter_list=model.parameters())
  10. #optimizer = fluid.optimizer.AdagradOptimizer(learning_rate=0.01, parameter_list=model.parameters())
  11. #optimizer = fluid.optimizer.AdamOptimizer(learning_rate=0.01, parameter_list=model.parameters())
  12. EPOCH_NUM = 5
  13. for epoch_id in range(EPOCH_NUM):
  14. for batch_id, data in enumerate(train_loader()):
  15. #准备数据,变得更加简洁
  16. image_data, label_data = data
  17. image = fluid.dygraph.to_variable(image_data)
  18. label = fluid.dygraph.to_variable(label_data)
  19. #前向计算的过程
  20. predict = model(image)
  21. #计算损失,取一个批次样本损失的平均值
  22. loss = fluid.layers.cross_entropy(predict, label)
  23. avg_loss = fluid.layers.mean(loss)
  24. #每训练了100批次的数据,打印下当前Loss的情况
  25. if batch_id % 200 == 0:
  26. print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
  27. #后向传播,更新参数的过程
  28. avg_loss.backward()
  29. optimizer.minimize(avg_loss)
  30. model.clear_gradients()
  31. #保存模型参数
  32. fluid.save_dygraph(model.state_dict(), 'mnist')

作业 2-3

在手写数字识别的任务上,哪种优化算法的效果最好?多大的学习率最优?(可通过Loss的下降趋势来判断)