深度学习入门必看-手写数字识别

目录

前言

一、手写数字识别任务

1. 任务要求

2. MNIST数据集

3. 构建手写数字识别的神经网络模型

二、通过极简方案构建手写数字识别模型

1. 前提条件

2. 数据处理

3. 模型设计

4. 训练配置

5. 训练过程

6. 模型测试

三、手写数字识别之数据处理

1. 读入数据并划分数据集

2. 扩展阅读:为什么学术界的模型总在不断精进呢?

3. 训练样本乱序、生成批次数据 

4. 校验数据有效性

5. 机器校验

6. 人工校验

7. 封装数据读取与处理函数

8. 异步数据读取

四、手写数字识别之网络结构

1. 数据处理

2. 经典的全连接神经网络

3. 卷积神经网络 

五、手写数字识别之损失函数

1. 分类任务的损失函数

​2. Softmax函数 

3. 交叉熵

4. 交叉熵的代码实现

六、手写数字识别之优化算法

1. 前提条件

2. 设置学习率

3. 学习率的主流优化算法

七、手写数字识别之资源配置

1. 前提条件

2. 单GPU训练

3. 分布式训练

4. 模型并行

5. 数据并行

八、手写数字识别之训练调试与优化

1. 计算模型的分类准确率

2. 检查模型训练过程,识别潜在训练问题

3. 加入校验或测试,更好评价模型效果

4. 加入正则化项,避免模型过拟合

5. 正则化项

6. 可视化分析

​九、模型加载及恢复训练

1. 模型训练

2. 恢复训练


前言

目前深度学习框架paddlepaddle、tensorflow、pytorch比较,结合网上大神的总结,pytorch更好学,社区也大,教程较多,莫名其妙的错误比较少。它基于动态图,可以按照正常人的思路玩,API较稳定。

paddlepaddle的话同时支持动态图和静态图,但是社区还在建设中,版本迭代更新快。不过可以嫖百度的云计算服务,值得一试。而且配套的预训练模型和可视化等等开发工具也在上线中,蛮好用的。

tensorflow是出了名新手劝退框架,因为文档比较反萌新,版本兼容性较差(我曾在嫉妒愤怒的情况下修改rasa的源代码,结果因为tensorflow的版本问题浪费了一晚上时间,让我chatbot的项目遭遇当头棒喝)。优点就是社区大,开源早,玩家多,开发工具一条龙服务。因为基于静态图,跑得更快一些。

本文参考一个案例带你吃透深度学习 - 飞桨AI Studio - 人工智能学习实训社区 (baidu.com),本人通过本案例的学习,对代码进行逐行复现。大家可掌握使用飞桨完成深度学习建模的方法,并且可以编写相当强大的模型。如果将每个模型部分均展开,整个模型实现有几百行代码,可以灵活的实现各种建模过程中的需求。

 图:已经可以编写相当强大的模型

一、手写数字识别任务

1. 任务要求

数字识别是计算机从纸质文档、照片或其他来源接收、理解并识别可读的数字的能力,目前比较受关注的是手写数字识别。手写数字识别是一个典型的图像分类问题,已经被广泛应用于汇款单号识别、手写邮政编码识别,大大缩短了业务处理时间,提升了工作效率和质量。

在处理如 图1 所示的手写邮政编码的简单图像分类任务时,可以使用基于MNIST数据集的手写数字识别模型。MNIST是深度学习领域标准、易用的成熟数据集,包含60000条训练样本和10000条测试样本。

图1:手写数字识别任务示意图

  • 任务输入:一系列手写数字图片,其中每张图片都是28x28的像素矩阵。
  • 任务输出:经过了大小归一化和居中处理,输出对应的0~9数字标签。

2. MNIST数据集

MNIST数据集是从NIST的Special Database 3(SD-3)和Special Database 1(SD-1)构建而来。Yann LeCun等人从SD-1和SD-3中各取一半作为MNIST训练集和测试集,其中训练集来自250位不同的标注员,且训练集和测试集的标注员完全不同。

MNIST数据集的发布,吸引了大量科学家训练模型。1998年,LeCun分别用单层线性分类器、多层感知器(Multilayer Perceptron, MLP)和多层卷积神经网络LeNet进行实验使得测试集的误差不断下降(从12%下降到0.7%)。在研究过程中,LeCun提出了卷积神经网络(Convolutional Neural Network,CNN),大幅度地提高了手写字符的识别能力,也因此成为了深度学习领域的奠基人之一。

如今在深度学习领域,卷积神经网络占据了至关重要的地位,从最早LeCun提出的简单LeNet,到如今ImageNet大赛上的优胜模型VGGNet、GoogLeNet、ResNet等,人们在图像分类领域,利用卷积神经网络得到了一系列惊人的结果。

手写数字识别的模型是深度学习中相对简单的模型,非常适用初学者。正如学习编程时,我们输入的第一个程序是打印“Hello World!”一样。

3. 构建手写数字识别的神经网络模型

使用飞桨完成手写数字识别模型构建的代码结构如 图2 所示,下面的章节中我们将详细介绍每个步骤的具体实现方法。

 图2:使用飞桨框架构建神经网络过程

二、通过极简方案构建手写数字识别模型

创新性的“横纵式”教学法,有助于深度学习初学者快速掌握深度学习理论知识,并在过程中让读者获得真实建模的实战体验。在“横纵式”教学法中,纵向概要介绍模型的基本代码结构和极简实现方案,如 图1 所示。本节将使用这种极简实现方案快速完成手写数字识别的建模。

1. 前提条件

在数据处理前,首先要加载飞桨平台与“手写数字识别”模型相关的类库,实现方法如下。

#加载飞桨和相关类库
import paddle
import paddle.fluid as fluid
from paddle.fluid.dygraph.nn import Linear
import numpy as np
import os
from PIL import Image

2. 数据处理

飞桨提供了多个封装好的数据集API,涵盖计算机视觉、自然语言处理、推荐系统等多个领域,帮助读者快速完成深度学习任务。如在手写数字识别任务中,通过paddle.dataset.mnist可以直接获取处理好的MNIST训练集、测试集,飞桨API支持如下常见的学术数据集:

# 如果~/.cache/paddle/dataset/mnist/目录下没有MNIST数据,API会自动将MINST数据下载到该文件夹下
# 设置数据读取器,读取MNIST数据训练集
trainset = paddle.dataset.mnist.train()
# 包装数据读取器,每次读取的数据数量设置为batch_size=8
train_reader = paddle.batch(trainset, batch_size=8)

paddle.batch函数将MNIST数据集拆分成多个批次,通过如下代码读取第一个批次的数据内容,观察数据打印结果。

# 以迭代的形式读取数据
for batch_id, data in enumerate(train_reader()):
    # 获得图像数据,并转为float32类型的数组
    img_data = np.array([x[0] for x in data]).astype('float32')
    # 获得图像标签数据,并转为float32类型的数组
    label_data = np.array([x[1] for x in data]).astype('float32')
    # 打印数据形状
    print("图像数据形状和对应数据为:", img_data.shape, img_data[0])
    print("图像标签形状和对应数据为:", label_data.shape, label_data[0])
    break

print("\n打印第一个batch的第一个图像,对应标签数字为{}".format(label_data[0]))
# 显示第一batch的第一个图像
import matplotlib.pyplot as plt
img = np.array(img_data[0]+1)*127.5
img = np.reshape(img, [28, 28]).astype(np.uint8)

plt.figure("Image") # 图像窗口名称
plt.imshow(img)
plt.axis('on') # 关掉坐标轴为 off
plt.title('image') # 图像题目
plt.show()
图像数据形状和对应数据为: (8, 784) [-1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -0.9764706  -0.85882354 -0.85882354 -0.85882354
 -0.01176471  0.06666672  0.37254906 -0.79607844  0.30196083  1.
  0.9372549  -0.00392157 -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -0.7647059  -0.7176471  -0.26274508  0.20784318
  0.33333337  0.9843137   0.9843137   0.9843137   0.9843137   0.9843137
  0.7647059   0.34901965  0.9843137   0.8980392   0.5294118  -0.4980392
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -0.6156863
  0.8666667   0.9843137   0.9843137   0.9843137   0.9843137   0.9843137
  0.9843137   0.9843137   0.9843137   0.96862745 -0.27058822 -0.35686272
 -0.35686272 -0.56078434 -0.69411767 -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -0.85882354  0.7176471   0.9843137
  0.9843137   0.9843137   0.9843137   0.9843137   0.5529412   0.427451
  0.9372549   0.8901961  -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -0.372549    0.22352946 -0.1607843   0.9843137
  0.9843137   0.60784316 -0.9137255  -1.         -0.6627451   0.20784318
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -0.8901961  -0.99215686  0.20784318  0.9843137  -0.29411763
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.          0.09019613  0.9843137   0.4901961  -0.9843137  -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -0.9137255
  0.4901961   0.9843137  -0.45098037 -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -0.7254902   0.8901961
  0.7647059   0.254902   -0.15294117 -0.99215686 -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -0.36470586  0.88235295  0.9843137
  0.9843137  -0.06666666 -0.8039216  -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -0.64705884  0.45882356  0.9843137   0.9843137
  0.17647064 -0.7882353  -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -0.8745098  -0.27058822  0.9764706   0.9843137   0.4666667
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.          0.9529412   0.9843137   0.9529412  -0.4980392  -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -0.6392157   0.0196079   0.43529415  0.9843137
  0.9843137   0.62352943 -0.9843137  -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -0.69411767  0.16078436
  0.79607844  0.9843137   0.9843137   0.9843137   0.9607843   0.427451
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -0.8117647  -0.10588235  0.73333335  0.9843137   0.9843137   0.9843137
  0.9843137   0.5764706  -0.38823527 -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -0.81960785 -0.4823529   0.67058825  0.9843137
  0.9843137   0.9843137   0.9843137   0.5529412  -0.36470586 -0.9843137
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -0.85882354  0.3411765
  0.7176471   0.9843137   0.9843137   0.9843137   0.9843137   0.5294118
 -0.372549   -0.92941177 -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -0.5686275   0.34901965  0.77254903  0.9843137   0.9843137   0.9843137
  0.9843137   0.9137255   0.04313731 -0.9137255  -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.          0.06666672  0.9843137
  0.9843137   0.9843137   0.6627451   0.05882359  0.03529418 -0.8745098
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.         -1.         -1.
 -1.         -1.         -1.         -1.        ]
图像标签形状和对应数据为: (8,) 5.0

打印第一个batch的第一个图像,对应标签数字为5.0

从打印结果看,从数据加载器train_reader()中读取一次数据,可以得到形状为(8, 784)的图像数据和形状为(8,)的标签数据。其中,形状中的数字8与设置的batch_size大小对应,784为MINIST数据集中每个图像的像素大小(28*28)。

此外,从打印的图像数据来看,图像数据的范围是[-1, 1],表明这是已经完成图像归一化后的图像数据,并且空白背景部分的值是-1。将图像数据反归一化(img = np.array(img_data[0]+1)*127.5)即将[-1,1]映射到[0,255],并使用matplotlib工具包将其显示出来,如图2 所示。可以看到图片显示的数字是5,和对应标签数字一致。

3. 模型设计

模型的输入为784维(28*28)数据,输出为1维数据,如 图6 所示。

图6:手写数字识别网络模型

输入像素的位置排布信息对理解图像内容非常重要(如将原始尺寸为28*28图像的像素按照7*112的尺寸排布,那么其中的数字将不可识别),因此网络的输入设计为28*28的尺寸,而不是1*784,以便于模型能够正确处理像素之间的空间信息。

说明:

事实上,采用只有一层的简单网络(对输入求加权和)时并没有处理位置关系信息,因此可以猜测出此模型的预测效果可能有限。在后续优化环节介绍的卷积神经网络则更好的考虑了这种位置关系信息,模型的预测效果也会有显著提升。

下面以类的方式组建手写数字识别的网络,实现方法如下所示。

# 定义mnist数据识别网络结构
class MNIST(fluid.dygraph.Layer):
    def __init__(self):
        super(MNIST, self).__init__()
        
        # 定义一层全连接层,输出维度是1,激活函数为None,即不使用激活函数
        self.fc = Linear(input_dim=784, output_dim=1, act=None)
        
    # 定义网络结构的前向计算过程
    def forward(self, inputs):
        outputs = self.fc(inputs)
        return outputs

4. 训练配置

训练配置需要先生成模型实例(设为“训练”状态),再设置优化算法和学习率(使用随机梯度下降SGD,学习率设置为0.001),实现方法如下所示。

# 定义飞桨动态图工作环境
with fluid.dygraph.guard():
    # 声明网络结构
    model = MNIST()
    # 启动训练模式
    model.train()
    # 定义数据读取函数,数据读取batch_size设置为16
    train_loader = paddle.batch(paddle.dataset.mnist.train(), batch_size=16)
    # 定义优化器,使用随机梯度下降SGD优化器,学习率设置为0.001
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.001, parameter_list=model.parameters())

5. 训练过程

训练过程采用二层循环嵌套方式,训练完成后需要保存模型参数,以便后续使用。

  • 内层循环:负责整个数据集的一次遍历,遍历数据集采用分批次(batch)方式。
  • 外层循环:定义遍历数据集的次数,本次训练中外层循环10次,通过参数EPOCH_NUM设置。
# 定义mnist数据识别网络结构
class MNIST(fluid.dygraph.Layer):
    def __init__(self):
        super(MNIST, self).__init__()
        
        # 定义一层全连接层,输出维度是1,激活函数为None,即不使用激活函数
        self.fc = Linear(input_dim=784, output_dim=1, act=None)
        
    # 定义网络结构的前向计算过程
    def forward(self, inputs):
        outputs = self.fc(inputs)
        return outputs
# 动态图下的一些操作需要在guard下进行
with fluid.dygraph.guard():
    model = MNIST()
    model.train()
    train_loader = paddle.batch(paddle.dataset.mnist.train(), batch_size=16)
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.001, parameter_list=model.parameters())
    EPOCH_NUM = 10
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,格式需要转换成符合框架要求的
            image_data = np.array([x[0] for x in data]).astype('float32')
            label_data = np.array([x[1] for x in data]).astype('float32').reshape(-1, 1)
            # 将数据转为飞桨动态图格式
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程
            predict = model(image)
            
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.square_error_cost(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了1000批次的数据,打印下当前Loss的情况
            if batch_id !=0 and batch_id  % 1000 == 0:
                print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    # 保存模型
    fluid.save_dygraph(model.state_dict(), 'mnist')
epoch: 0, batch: 1000, loss is: [1.9525427]
epoch: 0, batch: 2000, loss is: [4.206562]
epoch: 0, batch: 3000, loss is: [3.4742298]
epoch: 1, batch: 1000, loss is: [1.887764]
epoch: 1, batch: 2000, loss is: [4.047755]
epoch: 1, batch: 3000, loss is: [3.3942785]
epoch: 2, batch: 1000, loss is: [1.8747213]
epoch: 2, batch: 2000, loss is: [3.9418283]
epoch: 2, batch: 3000, loss is: [3.3311157]
epoch: 3, batch: 1000, loss is: [1.8763344]
epoch: 3, batch: 2000, loss is: [3.878029]
epoch: 3, batch: 3000, loss is: [3.2727995]
epoch: 4, batch: 1000, loss is: [1.8819361]
epoch: 4, batch: 2000, loss is: [3.8395371]
epoch: 4, batch: 3000, loss is: [3.2206805]
epoch: 5, batch: 1000, loss is: [1.8883512]
epoch: 5, batch: 2000, loss is: [3.815928]
epoch: 5, batch: 3000, loss is: [3.175024]
epoch: 6, batch: 1000, loss is: [1.8947482]
epoch: 6, batch: 2000, loss is: [3.8012826]
epoch: 6, batch: 3000, loss is: [3.1354532]
epoch: 7, batch: 1000, loss is: [1.9009191]
epoch: 7, batch: 2000, loss is: [3.7922478]
epoch: 7, batch: 3000, loss is: [3.1013336]
epoch: 8, batch: 1000, loss is: [1.9068215]
epoch: 8, batch: 2000, loss is: [3.7868676]
epoch: 8, batch: 3000, loss is: [3.0719838]
epoch: 9, batch: 1000, loss is: [1.9124517]
epoch: 9, batch: 2000, loss is: [3.7839415]
epoch: 9, batch: 3000, loss is: [3.0467396]

从训练过程中损失所发生的变化可以发现,虽然损失整体上在降低,但到训练的最后一轮,损失函数值依然较高。可以猜测手写数字识别训练效果并不好。接下来我们通过模型测试,获取模型训练的真实效果。

6. 模型测试

模型测试的主要目的是验证训练好的模型是否能正确识别出数字,包括如下四步:

  • 声明实例
  • 加载模型:加载训练过程中保存的模型参数,
  • 灌入数据:将测试样本传入模型,模型的状态设置为校验状态(eval),显式告诉框架我们接下来只会使用前向计算的流程,不会计算梯度和梯度反向传播。
  • 获取预测结果,取整后作为预测标签输出。

在模型测试之前,需要先从'./work/example_0.jpg'文件中读取样例图片,并进行归一化处理。

# 导入图像读取第三方库
import matplotlib.image as mpimg
import matplotlib.pyplot as plt
import cv2
import numpy as np
# 读取图像
img1 = cv2.imread('./work/example_0.png')
example = mpimg.imread('./work/example_0.png')
# 显示图像
plt.imshow(example)
plt.show()
im = Image.open('./work/example_0.png').convert('L')
print(np.array(im).shape)
im = im.resize((28, 28), Image.ANTIALIAS)
plt.imshow(im)
plt.show()
print(np.array(im).shape)

# 读取一张本地的样例图片,转变成模型输入的格式
def load_image(img_path):
    # 从img_path中读取图像,并转为灰度图
    im = Image.open(img_path).convert('L')
    print(np.array(im))
    im = im.resize((28, 28), Image.ANTIALIAS)
    im = np.array(im).reshape(1, -1).astype(np.float32)
    # 图像归一化,保持和数据集的数据范围一致
    im = 1 - im / 127.5
    return im

# 定义预测过程
with fluid.dygraph.guard():
    model = MNIST()
    params_file_path = 'mnist'
    img_path = './work/1.jpg'
# 加载模型参数
    model_dict, _ = fluid.load_dygraph("mnist")
    model.load_dict(model_dict)
# 灌入数据
    model.eval()
    tensor_img = load_image(img_path)
    result = model(fluid.dygraph.to_variable(tensor_img))
#  预测输出取整,即为预测的数字,打印结果
    print("本次预测的数字是", result.numpy().astype('int32'))
[[ 97  97  97 ...  95  96  96]
 [ 97  97  97 ...  95  94  94]
 [ 97  97  97 ...  94  94  94]
 ...
 [ 99  99  99 ...  97  97  97]
 [101 101 101 ...  97  97  97]
 [102 102 101 ...  97  96  96]]
本次预测的数字是 [[0]]

从打印结果来看,模型预测出的数字是与实际输出的图片的数字不一致。这里只是验证了一个样本的情况,如果我们尝试更多的样本,可发现许多数字图片识别结果是错误的。因此完全复用房价预测的实验并不适用于手写数字识别任务!

接下来我们会对手写数字识别实验模型进行逐一改进,直到获得令人满意的结果。

#加载飞桨和相关类库
import paddle
import paddle.fluid as fluid
from paddle.fluid.dygraph.nn import Linear
import numpy as np
import os

# 定义mnist数据识别网络结构,同房价预测网络
class MNIST(fluid.dygraph.Layer):
    def __init__(self):
        super(MNIST, self).__init__()
        
        # 定义一层全连接层,输出维度是1,激活函数为None,即不使用激活函数
        self.fc = Linear(input_dim=784, output_dim=1, act=None)
        
    # 定义网络结构的前向计算过程
    def forward(self, inputs):
        outputs = self.fc(inputs)
        return outputs

trainset = paddle.dataset.mnist.train()
test_reader = paddle.batch(trainset, batch_size=100)
# 无序的100个数据
shuffle_reader = paddle.fluid.io.shuffle(test_reader,100)

# 以迭代的形式读取数据
for batch_id, data in enumerate(shuffle_reader()):
    # 获得图像数据,并转为float32类型的数组
    img_data = np.array([x[0] for x in data]).astype('float32')
    # 获得图像标签数据,并转为float32类型的数组
    label_data = np.array([x[1] for x in data]).astype('float32')
    # 打印数据形状
    print("图像数据形状和对应数据为:", img_data.shape)
    print("图像标签形状和对应数据为:", label_data.shape)
    break

# 定义预测过程
with fluid.dygraph.guard():
    model = MNIST()
    params_file_path = 'mnist'
# 加载模型参数
    model_dict, _ = fluid.load_dygraph("mnist")
    model.load_dict(model_dict)
# 灌入数据
    model.eval()
    result = model(fluid.dygraph.to_variable(img_data))
#  预测输出取整,即为预测的数字,打印结果
    print("本次预测的数字是", result.numpy().astype('int32'))
    
    print(label_data)
图像数据形状和对应数据为: (100, 784)
图像标签形状和对应数据为: (100,)
本次预测的数字是 [[ 4]
 [ 9]
 [ 5]
 [ 9]
 [ 0]
 [ 7]
 [ 7]
 [11]
 [ 3]
 [ 0]
 [ 4]
 [ 5]
 [ 2]
 [ 8]
 [ 5]
 [ 3]
 [ 4]
 [ 4]
 [ 5]
 [ 2]
 [ 7]
 [ 5]
 [ 4]
 [ 4]
 [ 8]
 [ 6]
 [ 5]
 [11]
 [ 4]
 [ 5]
 [ 4]
 [ 6]
 [ 3]
 [ 8]
 [ 3]
 [ 9]
 [ 5]
 [ 4]
 [ 6]
 [ 4]
 [ 6]
 [ 8]
 [ 2]
 [ 8]
 [ 4]
 [ 2]
 [ 6]
 [ 2]
 [ 6]
 [ 3]
 [ 7]
 [ 0]
 [ 6]
 [ 2]
 [ 6]
 [ 8]
 [ 4]
 [ 8]
 [ 1]
 [ 7]
 [ 6]
 [ 5]
 [ 5]
 [ 8]
 [ 4]
 [ 2]
 [ 3]
 [ 8]
 [ 6]
 [ 4]
 [ 3]
 [ 7]
 [ 7]
 [ 5]
 [ 5]
 [ 4]
 [ 2]
 [10]
 [ 4]
 [ 1]
 [ 4]
 [ 2]
 [ 2]
 [ 1]
 [ 1]
 [ 5]
 [ 1]
 [ 5]
 [ 4]
 [ 7]
 [ 8]
 [ 7]
 [ 4]
 [ 6]
 [ 4]
 [ 9]
 [ 4]
 [ 7]
 [ 8]
 [ 8]]
[5. 8. 8. 4. 2. 6. 9. 7. 1. 0. 2. 3. 1. 8. 7. 1. 8. 2. 7. 2. 6. 3. 2. 3.
 7. 6. 8. 7. 5. 3. 8. 4. 4. 9. 4. 6. 4. 5. 5. 1. 1. 9. 3. 8. 6. 1. 4. 1.
 7. 5. 4. 0. 4. 2. 7. 7. 5. 8. 0. 6. 9. 6. 6. 8. 7. 2. 0. 8. 8. 4. 8. 4.
 4. 2. 3. 2. 3. 8. 2. 0. 5. 0. 0. 1. 0. 2. 1. 3. 3. 4. 7. 5. 3. 6. 1. 7.
 6. 8. 3. 9.]

三、手写数字识别之数据处理

上一节我们使用“横纵式”教学法中的纵向极简方案快速完成手写数字识别任务的建模,但模型测试效果并未达成预期。我们换个思路,从横向展开,如 图1 所示,逐个环节优化,以达到最优训练效果。本节主要介绍手写数字识别模型中,数据处理的优化方法。

图1:“横纵式”教学法 — 数据处理优化

上一节,我们通过调用飞桨提供的API(paddle.dataset.mnist)加载MNIST数据集。但在工业实践中,我们面临的任务和数据环境千差万别,通常需要自己编写适合当前任务的数据处理程序,一般涉及如下五个环节:

  • 读入数据
  • 划分数据集
  • 生成批次数据
  • 训练样本集乱序
  • 校验数据有效性

在数据读取与处理前,首先要加载飞桨平台和数据处理库,代码如下。

#数据处理部分之前的代码,加入部分数据处理的库
import paddle
import paddle.fluid as fluid
from paddle.fluid.dygraph.nn import Linear
import numpy as np
import os
import gzip
import json
import random

1. 读入数据并划分数据集

在实际应用中,保存到本地的数据存储格式多种多样,如MNIST数据集以json格式存储在本地,其数据存储结构如 图2 所示。

 图2:MNIST数据集的存储结构 

data包含三个元素的列表:train_setval_set、 test_set

  • train_set(训练集):包含50000条手写数字图片和对应的标签,用于确定模型参数。
  • val_set(验证集):包含10000条手写数字图片和对应的标签,用于调节模型超参数(如多个网络结构、正则化权重的最优选择)。
  • test_set(测试集):包含10000条手写数字图片和对应的标签,用于估计应用效果(没有在模型中应用过的数据,更贴近模型在真实场景应用的效果)。

train_set包含两个元素的列表:train_imagestrain_labels

  • train_images:[5000, 784]的二维列表,包含5000张图片。每张图片用一个长度为784的向量表示,内容是28*28尺寸的像素灰度值(黑白图片)。
  • train_labels:[5000, ]的列表,表示这些图片对应的分类标签,即0-9之间的一个数字。

在本地./work/目录下读取文件名称为mnist.json.gz的MNIST数据,并拆分成训练集、验证集和测试集,实现方法如下所示。

#数据处理部分之前的代码,加入部分数据处理的库
import paddle
import paddle.fluid as fluid
from paddle.fluid.dygraph.nn import Linear
import numpy as np
import os
import gzip
import json
import random
# 声明数据集文件位置
datafile = './work/mnist.json.gz'
print('loading mnist dataset from {} ......'.format(datafile))
# 加载json数据文件
data = json.load(gzip.open(datafile))
print('mnist dataset load done')
# 读取到的数据区分训练集,验证集,测试集
train_set, val_set, eval_set = data

# 数据集相关参数,图片高度IMG_ROWS, 图片宽度IMG_COLS
IMG_ROWS = 28
IMG_COLS = 28

# 打印数据信息
imgs, labels = train_set[0], train_set[1]
print("训练数据集数量: ", len(imgs))

# 观察验证集数量
imgs, labels = val_set[0], val_set[1]
print("验证数据集数量: ", len(imgs))

# 观察测试集数量
imgs, labels = val= eval_set[0], eval_set[1]
print("测试数据集数量: ", len(imgs))
loading mnist dataset from ./work/mnist.json.gz ......
mnist dataset load done
训练数据集数量:  50000
验证数据集数量:  10000
测试数据集数量:  10000

2. 扩展阅读:为什么学术界的模型总在不断精进呢?

通常某组织发布一个新任务的训练集和测试集数据后,全世界的科学家都针对该数据集进行创新研究,随后大量针对该数据集的论文会陆续发表。论文1的A模型声称在测试集的准确率70%,论文2的B模型声称在测试集的准确率提高到72%,论文N的X模型声称在测试集的准确率提高到90% ...

然而这些论文中的模型在测试集上准确率提升真实有效么?我们不妨大胆猜测一下。

假设所有论文共产生1000个模型,这些模型使用的是测试数据集来评判模型效果,并最终选出效果最优的模型。这相当于把原始的测试集当作了验证集,使得测试集失去了真实评判模型效果的能力,正如机器学习领域非常流行的一句话:“拷问数据足够久,它终究会招供”。

图3:拷问数据足够久,它总会招供 

那么当我们需要将学术界研发的模型复用于工业项目时,应该如何选择呢?给读者一个小建议:当几个模型的准确率在测试集上差距不大时,尽量选择网络结构相对简单的模型。往往越精巧设计的模型和方法,越不容易在不同的数据集之间迁移。 

3. 训练样本乱序、生成批次数据 

  • 训练样本乱序: 先将样本按顺序进行编号,建立ID集合index_list。然后将index_list乱序,最后按乱序后的顺序读取数据。

说明:

通过大量实验发现,模型对最后出现的数据印象更加深刻。训练数据导入后,越接近模型训练结束,最后几个批次数据对模型参数的影响越大。为了避免模型记忆影响训练效果,需要进行样本乱序操作。


  • 生成批次数据: 先设置合理的batch_size,再将数据转变成符合模型输入要求的np.array格式返回。同时,在返回数据时将Python生成器设置为yield模式(yield简单来说就是一个生成器,这样函数它记住上次返回时在函数中的位置。对于生成器第二次(或n次)调用跳转至该函数),以减少内存占用。

在执行如上两个操作之前,需要先将数据处理代码封装成load_data函数,方便后续调用。load_data有三种模型:trainvalideval,分为对应返回的数据是训练集、验证集、测试集。

imgs, labels = train_set[0], train_set[1]
print("训练数据集数量: ", len(imgs))
# 获得数据集长度
imgs_length = len(imgs)
# 定义数据集每个数据的序号,根据序号读取数据
index_list = list(range(imgs_length))
# 读入数据时用到的批次大小
BATCHSIZE = 100

# 随机打乱训练数据的索引序号
random.shuffle(index_list)

# 定义数据生成器,返回批次数据
def data_generator():

    imgs_list = []
    labels_list = []
    for i in index_list:
        # 将数据处理成希望的格式,比如类型为float32,shape为[1, 28, 28]
        img = np.reshape(imgs[i], [1, IMG_ROWS, IMG_COLS]).astype('float32')
        label = np.reshape(labels[i], [1]).astype('float32')
        imgs_list.append(img) 
        labels_list.append(label)
        if len(imgs_list) == BATCHSIZE:
            # 获得一个batchsize的数据,并返回
            yield np.array(imgs_list), np.array(labels_list)
            # 清空数据读取列表
            imgs_list = []
            labels_list = []

    # 如果剩余数据的数目小于BATCHSIZE,
    # 则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
    if len(imgs_list) > 0:
        yield np.array(imgs_list), np.array(labels_list)
    return data_generator
训练数据集数量:  50000
# 声明数据读取函数,从训练集中读取数据
train_loader = data_generator
# 以迭代的形式读取数据
for batch_id, data in enumerate(train_loader()):
    image_data, label_data = data
    if batch_id == 0:
        # 打印数据shape和类型
        print("打印第一个batch数据的维度:")
        print("图像维度: {}, 标签维度: {}".format(image_data.shape, label_data.shape))
    break
打印第一个batch数据的维度:
图像维度: (100, 1, 28, 28), 标签维度: (100, 1)

4. 校验数据有效性

在实际应用中,原始数据可能存在标注不准确、数据杂乱或格式不统一等情况。因此在完成数据处理流程后,还需要进行数据校验,一般有两种方式: 

  • 机器校验:加入一些校验和清理数据的操作。
  • 人工校验:先打印数据输出结果,观察是否是设置的格式。再从训练的结果验证数据处理和读取的有效性。

5. 机器校验

如下代码所示,如果数据集中的图片数量和标签数量不等,说明数据逻辑存在问题,可使用assert语句校验图像数量和标签数据是否一致。

 imgs_length = len(imgs)

    assert len(imgs) == len(labels), \
          "length of train_imgs({}) should be the same as train_labels({})".format(len(imgs), len(label))

6. 人工校验

人工校验是指打印数据输出结果,观察是否是预期的格式。实现数据处理和加载函数后,我们可以调用它读取一次数据,观察数据的shape和类型是否与函数中设置的一致。

# 声明数据读取函数,从训练集中读取数据
train_loader = data_generator
# 以迭代的形式读取数据
for batch_id, data in enumerate(train_loader()):
    image_data, label_data = data
    if batch_id == 0:
        # 打印数据shape和类型
        print("打印第一个batch数据的维度,以及数据的类型:")
        print("图像维度: {}, 标签维度: {}, 图像数据类型: {}, 标签数据类型: {}".format(image_data.shape, label_data.shape, type(image_data), type(label_data)))
    break
打印第一个batch数据的维度,以及数据的类型:
图像维度: (100, 1, 28, 28), 标签维度: (100, 1), 图像数据类型: <class 'numpy.ndarray'>, 标签数据类型: <class 'numpy.ndarray'>

7. 封装数据读取与处理函数

上文,我们从读取数据、划分数据集、到打乱训练数据、构建数据读取器以及数据数据校验,完成了一整套一般性的数据处理流程,下面将这些步骤放在一个函数中实现,方便在神经网络训练时直接调用。

def load_data(mode='train'):
    datafile = './work/mnist.json.gz'
    print('loading mnist dataset from {} ......'.format(datafile))
    # 加载json数据文件
    data = json.load(gzip.open(datafile))
    print('mnist dataset load done')
   
    # 读取到的数据区分训练集,验证集,测试集
    train_set, val_set, eval_set = data
    if mode=='train':
        # 获得训练数据集
        imgs, labels = train_set[0], train_set[1]
    elif mode=='valid':
        # 获得验证数据集
        imgs, labels = val_set[0], val_set[1]
    elif mode=='eval':
        # 获得测试数据集
        imgs, labels = eval_set[0], eval_set[1]
    else:
        raise Exception("mode can only be one of ['train', 'valid', 'eval']")
    print("训练数据集数量: ", len(imgs))
    
    # 校验数据
    imgs_length = len(imgs)

    assert len(imgs) == len(labels), \
          "length of train_imgs({}) should be the same as train_labels({})".format(len(imgs), len(label))
    
    # 获得数据集长度
    imgs_length = len(imgs)
    
    # 定义数据集每个数据的序号,根据序号读取数据
    index_list = list(range(imgs_length))
    # 读入数据时用到的批次大小
    BATCHSIZE = 100
    
    # 定义数据生成器
    def data_generator():
        if mode == 'train':
            # 训练模式下打乱数据
            random.shuffle(index_list)
        imgs_list = []
        labels_list = []
        for i in index_list:
            # 将数据处理成希望的格式,比如类型为float32,shape为[1, 28, 28]
            img = np.reshape(imgs[i], [1, IMG_ROWS, IMG_COLS]).astype('float32')
            label = np.reshape(labels[i], [1]).astype('float32')
            imgs_list.append(img) 
            labels_list.append(label)
            if len(imgs_list) == BATCHSIZE:
                # 获得一个batchsize的数据,并返回
                yield np.array(imgs_list), np.array(labels_list)
                # 清空数据读取列表
                imgs_list = []
                labels_list = []
    
        # 如果剩余数据的数目小于BATCHSIZE,
        # 则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
        if len(imgs_list) > 0:
            yield np.array(imgs_list), np.array(labels_list)
    return data_generator

下面定义一层神经网络,利用定义好的数据处理函数,完成神经网络的训练。

#数据处理部分之后的代码,数据读取的部分调用Load_data函数
# 定义网络结构,同上一节所使用的网络结构
class MNIST(fluid.dygraph.Layer):
    def __init__(self):
        super(MNIST, self).__init__()
        self.fc = Linear(input_dim=784, output_dim=1, act=None)

    def forward(self, inputs):
        inputs = fluid.layers.reshape(inputs, (-1, 784))
        outputs = self.fc(inputs)
        return outputs

# 训练配置,并启动训练过程
with fluid.dygraph.guard():
    model = MNIST()
    model.train()
    #调用加载数据的函数
    train_loader = load_data('train')
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.001, parameter_list=model.parameters())
    EPOCH_NUM = 10
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,变得更加简洁
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程
            predict = model(image)
            
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.square_error_cost(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了200批次的数据,打印下当前Loss的情况
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')
loading mnist dataset from ./work/mnist.json.gz ......
mnist dataset load done
训练数据集数量:  50000
epoch: 0, batch: 0, loss is: [33.73493]
epoch: 0, batch: 200, loss is: [4.249439]
epoch: 0, batch: 400, loss is: [3.3207474]
epoch: 1, batch: 0, loss is: [4.597397]
epoch: 1, batch: 200, loss is: [4.3091807]
epoch: 1, batch: 400, loss is: [2.4714997]
epoch: 2, batch: 0, loss is: [3.5449846]
epoch: 2, batch: 200, loss is: [3.8104398]
epoch: 2, batch: 400, loss is: [2.7397232]
epoch: 3, batch: 0, loss is: [3.8285332]
epoch: 3, batch: 200, loss is: [4.2861085]
epoch: 3, batch: 400, loss is: [3.6019022]
epoch: 4, batch: 0, loss is: [4.642666]
epoch: 4, batch: 200, loss is: [4.3951297]
epoch: 4, batch: 400, loss is: [3.385873]
epoch: 5, batch: 0, loss is: [3.6462307]
epoch: 5, batch: 200, loss is: [3.7918615]
epoch: 5, batch: 400, loss is: [3.162616]
epoch: 6, batch: 0, loss is: [3.728565]
epoch: 6, batch: 200, loss is: [2.981922]
epoch: 6, batch: 400, loss is: [3.6935387]
epoch: 7, batch: 0, loss is: [3.9647238]
epoch: 7, batch: 200, loss is: [3.826159]
epoch: 7, batch: 400, loss is: [2.8873413]
epoch: 8, batch: 0, loss is: [3.1121755]
epoch: 8, batch: 200, loss is: [3.4782279]
epoch: 8, batch: 400, loss is: [3.1809964]
epoch: 9, batch: 0, loss is: [3.3484797]
epoch: 9, batch: 200, loss is: [4.097038]
epoch: 9, batch: 400, loss is: [3.3174243]

8. 异步数据读取

上面提到的数据读取采用的是同步数据读取方式。对于样本量较大、数据读取较慢的场景,建议采用异步数据读取方式。异步读取数据时,数据读取和模型训练并行执行,从而加快了数据读取速度,牺牲一小部分内存换取数据读取效率的提升,二者关系如 图4 所示。

图4:同步数据读取和异步数据读取示意图 

  • 同步数据读取:数据读取与模型训练串行。当模型需要数据时,才运行数据读取函数获得当前批次的数据。在读取数据期间,模型一直等待数据读取结束才进行训练,数据读取速度相对较慢。
  • 异步数据读取:数据读取和模型训练并行。读取到的数据不断的放入缓存区,无需等待模型训练就可以启动下一轮数据读取。当模型训练完一个批次后,不用等待数据读取过程,直接从缓存区获得下一批次数据进行训练,从而加快了数据读取速度。
  • 异步队列:数据读取和模型训练交互的仓库,二者均可以从仓库中读取数据,它的存在使得两者的工作节奏可以解耦。

使用飞桨实现异步数据读取非常简单,如下所示。

# 定义数据读取后存放的位置,CPU或者GPU,这里使用CPU
# place = fluid.CUDAPlace(0) 时,数据读取到GPU上
place = fluid.CPUPlace()
with fluid.dygraph.guard(place):
    # 声明数据加载函数,使用训练模式
    train_loader = load_data(mode='train')
    # 定义DataLoader对象用于加载Python生成器产生的数据
    data_loader = fluid.io.DataLoader.from_generator(capacity=5, return_list=True)
    # 设置数据生成器
    data_loader.set_batch_generator(train_loader, places=place)
    # 迭代的读取数据并打印数据的形状
    for i, data in enumerate(data_loader):
        image_data, label_data = data
        print(i, image_data.shape, label_data.shape)
        if i>=5:
            break
loading mnist dataset from ./work/mnist.json.gz ......
mnist dataset load done
训练数据集数量:  50000
0 [100, 1, 28, 28] [100, 1]
1 [100, 1, 28, 28] [100, 1]
2 [100, 1, 28, 28] [100, 1]
3 [100, 1, 28, 28] [100, 1]
4 [100, 1, 28, 28] [100, 1]
5 [100, 1, 28, 28] [100, 1]

与同步数据读取相比,异步数据读取仅增加了三行代码,如下所示。 

place = fluid.CPUPlace()

# 设置读取的数据是放在CPU还是GPU上。

data_loader = fluid.io.DataLoader.from_generator(capacity=5, return_list=True) 

# 创建一个DataLoader对象用于加载Python生成器产生的数据。数据会由Python线程预先读取,并异步送入一个队列中。

data_loader.set_batch_generator(train_loader, place) 

# 用创建的DataLoader对象设置一个数据生成器set_batch_generator,输入的参数是一个Python数据生成器train_loader和服务器资源类型place(标明CPU还是GPU)

fluid.io.DataLoader.from_generator参数名称和含义如下:

  • feed_list:仅在PaddlePaddle静态图中使用,动态图中设置为“None”,本教程默认使用动态图的建模方式;
  • capacity:表示在DataLoader中维护的队列容量,如果读取数据的速度很快,建议设置为更大的值;
  • use_double_buffer:是一个布尔型的参数,设置为“True”时,Dataloader会预先异步读取下一个batch的数据并放到缓存区;
  • iterable:表示创建的Dataloader对象是否是可迭代的,一般设置为“True”;
  • return_list:在动态图模式下需要设置为“True”。

异步数据读取并训练的完整案例代码如下所示。 

with fluid.dygraph.guard():
    model = MNIST()
    model.train()
    #调用加载数据的函数
    train_loader = load_data('train')
    # 创建异步数据读取器
    place = fluid.CPUPlace()
    data_loader = fluid.io.DataLoader.from_generator(capacity=5, return_list=True)
    data_loader.set_batch_generator(train_loader, places=place)
    
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.001, parameter_list=model.parameters())
    EPOCH_NUM = 3
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(data_loader):
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            predict = model(image)
            
            loss = fluid.layers.square_error_cost(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
            
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    fluid.save_dygraph(model.state_dict(), 'mnist')
loading mnist dataset from ./work/mnist.json.gz ......
mnist dataset load done
训练数据集数量:  50000
epoch: 0, batch: 0, loss is: [31.393967]
epoch: 0, batch: 200, loss is: [3.8811433]
epoch: 0, batch: 400, loss is: [4.1185975]
epoch: 1, batch: 0, loss is: [3.1833816]
epoch: 1, batch: 200, loss is: [4.179631]
epoch: 1, batch: 400, loss is: [2.9935353]
epoch: 2, batch: 0, loss is: [5.8001103]
epoch: 2, batch: 200, loss is: [3.4768734]
epoch: 2, batch: 400, loss is: [3.49386]

从异步数据读取的训练结果来看,损失函数下降与同步数据读取训练结果一致。注意,异步读取数据只在数据量规模巨大时会带来显著的性能提升,对于多数场景采用同步数据读取的方式已经足够。

四、手写数字识别之网络结构

前几节我们尝试使用简单神经网络解决手写数字识别问题,但是效果并不理想。原因是手写数字识别的输入是28 × 28的像素值,输出是0-9的数字标签,而线性回归模型无法捕捉二维图像数据中蕴含的复杂信息,如 图1 所示。无论是牛顿第二定律任务,还是房价预测任务,输入特征和输出预测值之间的关系均可以使用“直线”刻画(使用线性方程来表达)。但手写数字识别任务的输入像素和输出数字标签之间的关系显然不是线性的,甚至这个关系复杂到我们靠人脑难以直观理解的程度。 

 图1:数字识别任务的输入和输入不是线性关系

因此,我们需要尝试使用其他更复杂、更强大的网络来构建手写数字识别任务,观察一下训练效果,即将“横纵式”教学法从横向展开,如 图2 所示。本节主要介绍两种常见的网络结构:经典的多层全连接神经网络和卷积神经网络。

图2:“横纵式”教学法 — 网络结构优化 

1. 数据处理

在介绍网络结构前,需要先进行数据处理,代码与上一节保持一致。

#数据处理部分之前的代码,保持不变
import os
import random
import paddle
import paddle.fluid as fluid
from paddle.fluid.dygraph.nn import Conv2D, Pool2D, Linear
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image

import gzip
import json

# 定义数据集读取器
def load_data(mode='train'):

    # 数据文件
    datafile = './work/mnist.json.gz'
    print('loading mnist dataset from {} ......'.format(datafile))
    data = json.load(gzip.open(datafile))
    train_set, val_set, eval_set = data

    # 数据集相关参数,图片高度IMG_ROWS, 图片宽度IMG_COLS
    IMG_ROWS = 28
    IMG_COLS = 28

    if mode == 'train':
        imgs = train_set[0]
        labels = train_set[1]
    elif mode == 'valid':
        imgs = val_set[0]
        labels = val_set[1]
    elif mode == 'eval':
        imgs = eval_set[0]
        labels = eval_set[1]

    imgs_length = len(imgs)

    assert len(imgs) == len(labels), \
          "length of train_imgs({}) should be the same as train_labels({})".format(
                  len(imgs), len(labels))

    index_list = list(range(imgs_length))

    # 读入数据时用到的batchsize
    BATCHSIZE = 100

    # 定义数据生成器
    def data_generator():
        if mode == 'train':
            random.shuffle(index_list)
        imgs_list = []
        labels_list = []
        for i in index_list:
            img = np.reshape(imgs[i], [1, IMG_ROWS, IMG_COLS]).astype('float32')
            label = np.reshape(labels[i], [1]).astype('float32')
            imgs_list.append(img) 
            labels_list.append(label)
            if len(imgs_list) == BATCHSIZE:
                yield np.array(imgs_list), np.array(labels_list)
                imgs_list = []
                labels_list = []

        # 如果剩余数据的数目小于BATCHSIZE,
        # 则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
        if len(imgs_list) > 0:
            yield np.array(imgs_list), np.array(labels_list)

    return data_generator

2. 经典的全连接神经网络

经典的全连接神经网络来包含四层网络:输入层、两个隐含层和输出层,将手写数字识别任务通过全连接神经网络表示,如 图3 所示。

 图3:手写数字识别任务的全连接神经网络结构

  • 输入层:将数据输入给神经网络。在该任务中,输入层的尺度为28×28的像素值。
  • 隐含层:增加网络深度和复杂度,隐含层的节点数是可以调整的,节点数越多,神经网络表示能力越强,参数量也会增加。在该任务中,中间的两个隐含层为10×10的结构,通常隐含层会比输入层的尺寸小,以便对关键信息做抽象,激活函数使用常见的sigmoid函数。
  • 输出层:输出网络计算结果,输出层的节点数是固定的。如果是回归问题,节点数量为需要回归的数字数量。如果是分类问题,则是分类标签的数量。在该任务中,模型的输出是回归一个数字,输出层的尺寸为1。

说明:

隐含层引入非线性激活函数sigmoid是为了增加神经网络的非线性能力。

Sigmoid是早期神经网络模型中常见的非线性变换函数,通过如下代码,绘制出Sigmoid的函数曲线。 

def sigmoid(x):
    # 直接返回sigmoid函数
    return 1. / (1. + np.exp(-x))
 
# param:起点,终点,间距
x = np.arange(-8, 8, 0.2)
y = sigmoid(x)
plt.plot(x, y)
plt.show()

针对手写数字识别的任务,网络层的设计如下:

  • 输入层的尺度为28×28,但批次计算的时候会统一加1个维度(大小为batchsize)。
  • 中间的两个隐含层为10×10的结构,激活函数使用常见的sigmoid函数。
  • 与房价预测模型一样,模型的输出是回归一个数字,输出层的尺寸设置成1。

下述代码为经典全连接神经网络的实现。完成网络结构定义后,即可训练神经网络。 

# 多层全连接神经网络实现
class MNIST(fluid.dygraph.Layer):
    def __init__(self):
        super(MNIST, self).__init__()
        # 定义两层全连接隐含层,输出维度是10,激活函数为sigmoid
        self.fc1 = Linear(input_dim=784, output_dim=10, act='sigmoid') # 隐含层节点为10,可根据任务调整
        self.fc2 = Linear(input_dim=10, output_dim=10, act='sigmoid')
        # 定义一层全连接输出层,输出维度是1,不使用激活函数
        self.fc3 = Linear(input_dim=10, output_dim=1, act=None)
    
    # 定义网络的前向计算
    def forward(self, inputs, label=None):
        inputs = fluid.layers.reshape(inputs, [inputs.shape[0], 784])
        outputs1 = self.fc1(inputs)
        outputs2 = self.fc2(outputs1)
        outputs_final = self.fc3(outputs2)
        return outputs_final
#网络结构部分之后的代码,保持不变
with fluid.dygraph.guard():
    model = MNIST()
    model.train()
    #调用加载数据的函数,获得MNIST训练数据集
    train_loader = load_data('train')
    # 使用SGD优化器,learning_rate设置为0.01
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    # 训练5轮
    EPOCH_NUM = 5
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程
            predict = model(image)
            
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.square_error_cost(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了200批次的数据,打印下当前Loss的情况
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')
loading mnist dataset from ./work/mnist.json.gz ......
mnist dataset load done
训练数据集数量:  50000
epoch: 0, batch: 0, loss is: [27.944292]
epoch: 0, batch: 200, loss is: [5.6845665]
epoch: 0, batch: 400, loss is: [3.7403042]
epoch: 1, batch: 0, loss is: [3.8713057]
epoch: 1, batch: 200, loss is: [3.2484899]
epoch: 1, batch: 400, loss is: [2.7168963]
epoch: 2, batch: 0, loss is: [2.4060864]
epoch: 2, batch: 200, loss is: [2.3478618]
epoch: 2, batch: 400, loss is: [2.7454169]
epoch: 3, batch: 0, loss is: [1.7099768]
epoch: 3, batch: 200, loss is: [1.8110847]
epoch: 3, batch: 400, loss is: [2.0546448]
epoch: 4, batch: 0, loss is: [1.6575533]
epoch: 4, batch: 200, loss is: [1.6003586]
epoch: 4, batch: 400, loss is: [1.6901708]

3. 卷积神经网络 

虽然使用经典的全连接神经网络可以提升一定的准确率,但对于计算机视觉问题,效果最好的模型仍然是卷积神经网络。卷积神经网络针对视觉问题的特点进行了网络结构优化,更适合处理视觉问题。

卷积神经网络由多个卷积层和池化层组成,如 图4 所示。卷积层负责对输入进行扫描以生成更抽象的特征表示,池化层对这些特征表示进行过滤,保留最关键的特征信息。

 图4:在处理计算机视觉任务中大放异彩的卷积神经网络 

说明:

本节只简单介绍用卷积神经网络实现手写数字识别任务,以及它带来的效果提升。读者可以将卷积神经网络先简单的理解成是一种比经典的全连接神经网络更强大的模型即可,更详细的原理和实现可以去查阅一下卷积神经网络。

两层卷积和池化的神经网络实现如下所示。 

# 多层卷积神经网络实现
class MNIST(fluid.dygraph.Layer):
     def __init__(self):
         super(MNIST, self).__init__()
         
         # 定义卷积层,输出特征通道num_filters设置为20,卷积核的大小filter_size为5,卷积步长stride=1,padding=2
         # 激活函数使用relu
         self.conv1 = Conv2D(num_channels=1, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义池化层,池化核pool_size=2,池化步长为2,选择最大池化方式
         self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义卷积层,输出特征通道num_filters设置为20,卷积核的大小filter_size为5,卷积步长stride=1,padding=2
         self.conv2 = Conv2D(num_channels=20, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义池化层,池化核pool_size=2,池化步长为2,选择最大池化方式
         self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义一层全连接层,输出维度是1,不使用激活函数
         self.fc = Linear(input_dim=980, output_dim=1, act=None)
         
    # 定义网络前向计算过程,卷积后紧接着使用池化层,最后使用全连接层计算最终输出
     def forward(self, inputs):
         x = self.conv1(inputs)
         x = self.pool1(x)
         x = self.conv2(x)
         x = self.pool2(x)
         x = fluid.layers.reshape(x, [x.shape[0], -1])
         x = self.fc(x)
         return x

训练定义好的卷积神经网络,如下所示。 

#网络结构部分之后的代码,保持不变
with fluid.dygraph.guard():
    model = MNIST()
    model.train()
    #调用加载数据的函数
    train_loader = load_data('train')
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    EPOCH_NUM = 5
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
             
            #前向计算的过程
            predict = model(image)
            
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.square_error_cost(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了100批次的数据,打印下当前Loss的情况
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')
loading mnist dataset from ./work/mnist.json.gz ......
epoch: 0, batch: 0, loss is: [28.46931]
epoch: 0, batch: 200, loss is: [8.067873]
epoch: 0, batch: 400, loss is: [8.494674]
epoch: 1, batch: 0, loss is: [7.424713]
epoch: 1, batch: 200, loss is: [8.64189]
epoch: 1, batch: 400, loss is: [8.263076]
epoch: 2, batch: 0, loss is: [8.909507]
epoch: 2, batch: 200, loss is: [8.816083]
epoch: 2, batch: 400, loss is: [8.744537]
epoch: 3, batch: 0, loss is: [8.453882]
epoch: 3, batch: 200, loss is: [8.607876]
epoch: 3, batch: 400, loss is: [8.610526]
epoch: 4, batch: 0, loss is: [10.018143]
epoch: 4, batch: 200, loss is: [8.367702]
epoch: 4, batch: 400, loss is: [8.190023]

比较经典全连接神经网络和卷积神经网络的损失变化,可以发现卷积神经网络的损失值下降更快,且最终的损失值更小。 

五、手写数字识别之损失函数

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

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

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

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

1. 分类任务的损失函数

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

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

2. Softmax函数 

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

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

softmax(x_i)=\frac{e^{x_i}}{\sum_{j=0}^{N}e_{j}^{x}},i=0,...,C-1

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

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

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

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

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

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

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

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

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

图6:热水器水温控制 

3. 交叉熵

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

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

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

相信大家简单思考后均会得出更可能是从乙盒中取出的,因为从乙盒中取出一个黑球的概率更高(P(D∣h))(P(D|h))(P(D∣h)),所以观察到一个黑球更可能是从乙盒中取出的(P(hD))(P(hD))(P(hD))。D是观测的数据,即黑球白球;h是模型,即甲盒乙盒。这就是贝叶斯公式所表达的思想

依据贝叶斯公式,某二分类模型“生成”nnn个训练样本的概率:

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

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

import matplotlib.pyplot as plt
import numpy as np
x = np.arange(0.01,1,0.01)
y = np.log(x)
plt.title("y=log(x)") 
plt.xlabel("x") 
plt.ylabel("y") 
plt.plot(x,y)
plt.show()
plt.figure()

如自然对数的图形所示,当x等于1时,y为0;随着x向0靠近,y逐渐变小。因此,正确解标签对应的输出越大,交叉熵的值越接近0;当输出为1时,交叉熵误差为0。反之,如果正确解标签对应的输出越小,则交叉熵的值越大。 

4. 交叉熵的代码实现

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

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

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

  • 从:label = np.reshape(labels[i], [1]).astype('float32')
  • 到:label = np.reshape(labels[i], [1]).astype('int64')
#修改标签数据的格式,从float32到int64
import os
import random
import paddle
import paddle.fluid as fluid
from paddle.fluid.dygraph.nn import Conv2D, Pool2D, Linear
import numpy as np
from PIL import Image

import gzip
import json

# 定义数据集读取器
def load_data(mode='train'):

    # 数据文件
    datafile = './work/mnist.json.gz'
    print('loading mnist dataset from {} ......'.format(datafile))
    data = json.load(gzip.open(datafile))
    train_set, val_set, eval_set = data

    # 数据集相关参数,图片高度IMG_ROWS, 图片宽度IMG_COLS
    IMG_ROWS = 28
    IMG_COLS = 28

    if mode == 'train':
        imgs = train_set[0]
        labels = train_set[1]
    elif mode == 'valid':
        imgs = val_set[0]
        labels = val_set[1]
    elif mode == 'eval':
        imgs = eval_set[0]
        labels = eval_set[1]

    imgs_length = len(imgs)

    assert len(imgs) == len(labels), \
          "length of train_imgs({}) should be the same as train_labels({})".format(
                  len(imgs), len(labels))

    index_list = list(range(imgs_length))

    # 读入数据时用到的batchsize
    BATCHSIZE = 100

    # 定义数据生成器
    def data_generator():
        if mode == 'train':
            random.shuffle(index_list)
        imgs_list = []
        labels_list = []
        for i in index_list:
            img = np.reshape(imgs[i], [1, IMG_ROWS, IMG_COLS]).astype('float32')
            label = np.reshape(labels[i], [1]).astype('int64')
            imgs_list.append(img) 
            labels_list.append(label)
            if len(imgs_list) == BATCHSIZE:
                yield np.array(imgs_list), np.array(labels_list)
                imgs_list = []
                labels_list = []

        # 如果剩余数据的数目小于BATCHSIZE,
        # 则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
        if len(imgs_list) > 0:
            yield np.array(imgs_list), np.array(labels_list)

    return data_generator

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

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

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

  • 从:loss = fluid.layers.square_error_cost(predict, label)
  • 到:loss = fluid.layers.cross_entropy(predict, label)
#仅修改计算损失的函数,从均方误差(常用于回归问题)到交叉熵误差(常用于分类问题)
with fluid.dygraph.guard():
    model = MNIST()
    model.train()
    #调用加载数据的函数
    train_loader = load_data('train')
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    EPOCH_NUM = 5
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,变得更加简洁
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程
            predict = model(image)
            
            #计算损失,使用交叉熵损失函数,取一个批次样本损失的平均值
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了200批次的数据,打印下当前Loss的情况
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')
loading mnist dataset from ./work/mnist.json.gz ......
epoch: 0, batch: 0, loss is: [2.926645]
epoch: 0, batch: 200, loss is: [0.37081543]
epoch: 0, batch: 400, loss is: [0.24172953]
epoch: 1, batch: 0, loss is: [0.26686034]
epoch: 1, batch: 200, loss is: [0.11502303]
epoch: 1, batch: 400, loss is: [0.13982354]
epoch: 2, batch: 0, loss is: [0.17670721]
epoch: 2, batch: 200, loss is: [0.1426062]
epoch: 2, batch: 400, loss is: [0.18189026]
epoch: 3, batch: 0, loss is: [0.18138057]
epoch: 3, batch: 200, loss is: [0.06040762]
epoch: 3, batch: 400, loss is: [0.1096407]
epoch: 4, batch: 0, loss is: [0.10509136]
epoch: 4, batch: 200, loss is: [0.12174016]
epoch: 4, batch: 400, loss is: [0.14845066]

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

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

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

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

# 读取一张本地的样例图片,转变成模型输入的格式
def load_image(img_path):
    # 从img_path中读取图像,并转为灰度图
    im = Image.open(img_path).convert('L')
    im.show()
    im = im.resize((28, 28), Image.ANTIALIAS)
    im = np.array(im).reshape(1, 1, 28, 28).astype(np.float32)
    # 图像归一化
    im = 1.0 - im / 255.
    return im

# 定义预测过程
with fluid.dygraph.guard():
    model = MNIST()
    params_file_path = 'mnist'
    img_path = './work/0.png'
    # 加载模型参数
    model_dict, _ = fluid.load_dygraph("mnist")
    model.load_dict(model_dict)
    
    model.eval()
    tensor_img = load_image(img_path)
    #模型反馈10个分类标签的对应概率
    results = model(fluid.dygraph.to_variable(tensor_img))
    #取概率最大的标签作为预测输出
    lab = np.argsort(results.numpy())
    print("本次预测的数字是: ", lab[0][-1])
本次预测的数字是:  0

六、手写数字识别之优化算法

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

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

1. 前提条件

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

# 加载相关库
import os
import random
import paddle
import paddle.fluid as fluid
from paddle.fluid.dygraph.nn import Conv2D, Pool2D, Linear
import numpy as np
from PIL import Image

import gzip
import json

# 定义数据集读取器
def load_data(mode='train'):

    # 读取数据文件
    datafile = './work/mnist.json.gz'
    print('loading mnist dataset from {} ......'.format(datafile))
    data = json.load(gzip.open(datafile))
    # 读取数据集中的训练集,验证集和测试集
    train_set, val_set, eval_set = data

    # 数据集相关参数,图片高度IMG_ROWS, 图片宽度IMG_COLS
    IMG_ROWS = 28
    IMG_COLS = 28
    # 根据输入mode参数决定使用训练集,验证集还是测试
    if mode == 'train':
        imgs = train_set[0]
        labels = train_set[1]
    elif mode == 'valid':
        imgs = val_set[0]
        labels = val_set[1]
    elif mode == 'eval':
        imgs = eval_set[0]
        labels = eval_set[1]
    # 获得所有图像的数量
    imgs_length = len(imgs)
    # 验证图像数量和标签数量是否一致
    assert len(imgs) == len(labels), \
          "length of train_imgs({}) should be the same as train_labels({})".format(
                  len(imgs), len(labels))

    index_list = list(range(imgs_length))

    # 读入数据时用到的batchsize
    BATCHSIZE = 100

    # 定义数据生成器
    def data_generator():
        # 训练模式下,打乱训练数据
        if mode == 'train':
            random.shuffle(index_list)
        imgs_list = []
        labels_list = []
        # 按照索引读取数据
        for i in index_list:
            # 读取图像和标签,转换其尺寸和类型
            img = np.reshape(imgs[i], [1, IMG_ROWS, IMG_COLS]).astype('float32')
            label = np.reshape(labels[i], [1]).astype('int64')
            imgs_list.append(img) 
            labels_list.append(label)
            # 如果当前数据缓存达到了batch size,就返回一个批次数据
            if len(imgs_list) == BATCHSIZE:
                yield np.array(imgs_list), np.array(labels_list)
                # 清空数据缓存列表
                imgs_list = []
                labels_list = []

        # 如果剩余数据的数目小于BATCHSIZE,
        # 则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
        if len(imgs_list) > 0:
            yield np.array(imgs_list), np.array(labels_list)

    return data_generator


# 定义模型结构
class MNIST(fluid.dygraph.Layer):
     def __init__(self):
         super(MNIST, self).__init__()
         
         # 定义一个卷积层,输出通道20,卷积核大小为5,步长为1,padding为2,使用relu激活函数
         self.conv1 = Conv2D(num_channels=1, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
         self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义一个卷积层,输出通道20,卷积核大小为5,步长为1,padding为2,使用relu激活函数
         self.conv2 = Conv2D(num_channels=20, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
         self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义一个全连接层,输出节点数为10 
         self.fc = Linear(input_dim=980, output_dim=10, act='softmax')
    # 定义网络的前向计算过程
     def forward(self, inputs):
         x = self.conv1(inputs)
         x = self.pool1(x)
         x = self.conv2(x)
         x = self.pool2(x)
         x = fluid.layers.reshape(x, [x.shape[0], -1])
         x = self.fc(x)
         return x

2. 设置学习率

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

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

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

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

#仅优化算法的设置有所差别
with fluid.dygraph.guard():
    model = MNIST()
    model.train()
    #调用加载数据的函数
    train_loader = load_data('train')
    
    #设置不同初始学习率
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    # optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.001, parameter_list=model.parameters())
    #  optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.1, parameter_list=model.parameters())
    
    EPOCH_NUM = 5
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,变得更加简洁
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程
            predict = model(image)
            
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了100批次的数据,打印下当前Loss的情况
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')

3. 学习率的主流优化算法

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

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

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

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

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

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

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

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

说明:

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

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

#仅优化算法的设置有所差别
with fluid.dygraph.guard(fluid.CUDAPlace(0)):
    model = MNIST()
    model.train()
    #调用加载数据的函数
    train_loader = load_data('train')
    
    #四种优化算法的设置方案,可以逐一尝试效果
    #optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.MomentumOptimizer(learning_rate=0.01, momentum=0.9, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.AdagradOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    optimizer = fluid.optimizer.AdamOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    
    EPOCH_NUM = 5
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,变得更加简洁
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程
            predict = model(image)
            
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了100批次的数据,打印下当前Loss的情况
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')
loading mnist dataset from ./work/mnist.json.gz ......
epoch: 0, batch: 0, loss is: [2.9203832]
epoch: 0, batch: 200, loss is: [0.0638732]
epoch: 0, batch: 400, loss is: [0.1322927]
epoch: 1, batch: 0, loss is: [0.20275106]
epoch: 1, batch: 200, loss is: [0.18891308]
epoch: 1, batch: 400, loss is: [0.11570027]
epoch: 2, batch: 0, loss is: [0.10741648]
epoch: 2, batch: 200, loss is: [0.02483563]
epoch: 2, batch: 400, loss is: [0.0200673]
epoch: 3, batch: 0, loss is: [0.02443561]
epoch: 3, batch: 200, loss is: [0.02079749]
epoch: 3, batch: 400, loss is: [0.0515696]
epoch: 4, batch: 0, loss is: [0.00477355]
epoch: 4, batch: 200, loss is: [0.07161854]
epoch: 4, batch: 400, loss is: [0.00470046]

七、手写数字识别之资源配置

从前几节的训练看,训练好一个模型不会超过十分钟,主要原因是我们所使用的神经网络比较简单。但实际应用时,常会遇到更加复杂的机器学习或深度学习任务,需要运算速度更高的硬件(如GPU、NPU),甚至同时使用多个机器共同训练一个任务(多卡训练和多机训练)。本节我们依旧横向展开"横纵式"教学方法,如 图1 所示,探讨在手写数字识别任务中,通过资源配置的优化,提升模型训练效率的方法。 

图1:“横纵式”教学法 — 资源配置 

1. 前提条件

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

# 加载相关库
import os
import random
import paddle
import paddle.fluid as fluid
from paddle.fluid.dygraph.nn import Conv2D, Pool2D, Linear
import numpy as np
from PIL import Image

import gzip
import json

# 定义数据集读取器
def load_data(mode='train'):

    # 读取数据文件
    datafile = './work/mnist.json.gz'
    print('loading mnist dataset from {} ......'.format(datafile))
    data = json.load(gzip.open(datafile))
    # 读取数据集中的训练集,验证集和测试集
    train_set, val_set, eval_set = data

    # 数据集相关参数,图片高度IMG_ROWS, 图片宽度IMG_COLS
    IMG_ROWS = 28
    IMG_COLS = 28
    # 根据输入mode参数决定使用训练集,验证集还是测试
    if mode == 'train':
        imgs = train_set[0]
        labels = train_set[1]
    elif mode == 'valid':
        imgs = val_set[0]
        labels = val_set[1]
    elif mode == 'eval':
        imgs = eval_set[0]
        labels = eval_set[1]
    # 获得所有图像的数量
    imgs_length = len(imgs)
    # 验证图像数量和标签数量是否一致
    assert len(imgs) == len(labels), \
          "length of train_imgs({}) should be the same as train_labels({})".format(
                  len(imgs), len(labels))

    index_list = list(range(imgs_length))

    # 读入数据时用到的batchsize
    BATCHSIZE = 100

    # 定义数据生成器
    def data_generator():
        # 训练模式下,打乱训练数据
        if mode == 'train':
            random.shuffle(index_list)
        imgs_list = []
        labels_list = []
        # 按照索引读取数据
        for i in index_list:
            # 读取图像和标签,转换其尺寸和类型
            img = np.reshape(imgs[i], [1, IMG_ROWS, IMG_COLS]).astype('float32')
            label = np.reshape(labels[i], [1]).astype('int64')
            imgs_list.append(img) 
            labels_list.append(label)
            # 如果当前数据缓存达到了batch size,就返回一个批次数据
            if len(imgs_list) == BATCHSIZE:
                yield np.array(imgs_list), np.array(labels_list)
                # 清空数据缓存列表
                imgs_list = []
                labels_list = []

        # 如果剩余数据的数目小于BATCHSIZE,
        # 则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
        if len(imgs_list) > 0:
            yield np.array(imgs_list), np.array(labels_list)

    return data_generator


# 定义模型结构
class MNIST(fluid.dygraph.Layer):
     def __init__(self):
         super(MNIST, self).__init__()
         
         # 定义一个卷积层,使用relu激活函数
         self.conv1 = Conv2D(num_channels=1, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
         self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义一个卷积层,使用relu激活函数
         self.conv2 = Conv2D(num_channels=20, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
         self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义一个全连接层,输出节点数为10 
         self.fc = Linear(input_dim=980, output_dim=10, act='softmax')
    # 定义网络的前向计算过程
     def forward(self, inputs):
         x = self.conv1(inputs)
         x = self.pool1(x)
         x = self.conv2(x)
         x = self.pool2(x)
         x = fluid.layers.reshape(x, [x.shape[0], 980])
         x = self.fc(x)
         return x

2. 单GPU训练

飞桨动态图通过fluid.dygraph.guard(place=None)里的place参数,设置在GPU上训练还是CPU上训练。

with fluid.dygraph.guard(place=fluid.CPUPlace()) #设置使用CPU资源训神经网络。
with fluid.dygraph.guard(place=fluid.CUDAPlace(0)) #设置使用GPU资源训神经网络,默认使用服务器的第一个GPU卡。"0"是GPU卡的编号,比如一台服务器有的四个GPU卡,编号分别为0、1、2、3。
#仅前3行代码有所变化,在使用GPU时,可以将use_gpu变量设置成True
use_gpu = False
place = fluid.CUDAPlace(0) if use_gpu else fluid.CPUPlace()

with fluid.dygraph.guard(place):
    model = MNIST()
    model.train()
    #调用加载数据的函数
    train_loader = load_data('train')
    
    #四种优化算法的设置方案,可以逐一尝试效果
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.MomentumOptimizer(learning_rate=0.01, momentum=0.9, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.AdagradOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.AdamOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    
    EPOCH_NUM = 2
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,变得更加简洁
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程
            predict = model(image)
            
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了100批次的数据,打印下当前Loss的情况
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}".format(epoch_id, batch_id, avg_loss.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')
loading mnist dataset from ./work/mnist.json.gz ......
epoch: 0, batch: 0, loss is: [4.4640317]
epoch: 0, batch: 200, loss is: [0.32647926]
epoch: 0, batch: 400, loss is: [0.1930568]
epoch: 1, batch: 0, loss is: [0.16535543]
epoch: 1, batch: 200, loss is: [0.17167684]
epoch: 1, batch: 400, loss is: [0.18688868]

运行时长:18.843秒结束时间:2021-11-14 23:01:54 

若用CPU训练,仅修改上述代码前三行

#仅前3行代码有所变化,在使用GPU时,可以将use_gpu变量设置成True
use_gpu = False
place = fluid.CUDAPlace(0) if use_gpu else fluid.CPUPlace()
loading mnist dataset from ./work/mnist.json.gz ......
epoch: 0, batch: 0, loss is: [3.4051993]
epoch: 0, batch: 200, loss is: [0.3279085]
epoch: 0, batch: 400, loss is: [0.3270345]
epoch: 1, batch: 0, loss is: [0.13634664]
epoch: 1, batch: 200, loss is: [0.21944183]
epoch: 1, batch: 400, loss is: [0.1331336]

运行时长:133.971秒结束时间:2021-11-14 23:04:30 

我们从以上结果不难发现GPU的运算速度是CPU的七倍。

3. 分布式训练

在工业实践中,很多较复杂的任务需要使用更强大的模型。强大模型加上海量的训练数据,经常导致模型训练耗时严重。比如在计算机视觉分类任务中,训练一个在ImageNet数据集上精度表现良好的模型,大概需要一周的时间,因为过程中我们需要不断尝试各种优化的思路和方案。如果每次训练均要耗时1周,这会大大降低模型迭代的速度。在机器资源充沛的情况下,建议采用分布式训练,大部分模型的训练时间可压缩到小时级别。

分布式训练有两种实现模式:模型并行和数据并行。

4. 模型并行

模型并行是将一个网络模型拆分为多份,拆分后的模型分到多个设备上(GPU)训练,每个设备的训练数据是相同的。模型并行的实现模式可以节省内存,但是应用较为受限。

模型并行的方式一般适用于如下两个场景:

  1. 模型架构过大: 完整的模型无法放入单个GPU。如2012年ImageNet大赛的冠军模型AlexNet是模型并行的典型案例,由于当时GPU内存较小,单个GPU不足以承担AlexNet,因此研究者将AlexNet拆分为两部分放到两个GPU上并行训练。

  2. 网络模型的结构设计相对独立: 当网络模型的设计结构可以并行化时,采用模型并行的方式。如在计算机视觉目标检测任务中,一些模型(如YOLO9000)的边界框回归和类别预测是独立的,可以将独立的部分放到不同的设备节点上完成分布式训练。

5. 数据并行

数据并行与模型并行不同,数据并行每次读取多份数据,读取到的数据输入给多个设备(GPU)上的模型,每个设备上的模型是完全相同的,飞桨采用的就是这种方式。

说明:

当前GPU硬件技术快速发展,深度学习使用的主流GPU的内存已经足以满足大多数的网络模型需求,所以大多数情况下使用数据并行的方式。

数据并行的方式与众人拾柴火焰高的道理类似,如果把训练数据比喻为砖头,把一个设备(GPU)比喻为一个人,那单GPU训练就是一个人在搬砖,多GPU训练就是多个人同时搬砖,每次搬砖的数量倍数增加,效率呈倍数提升。值得注意的是,每个设备的模型是完全相同的,但是输入数据不同,因此每个设备的模型计算出的梯度是不同的。如果每个设备的梯度只更新当前设备的模型,就会导致下次训练时,每个模型的参数都不相同。因此我们还需要一个梯度同步机制,保证每个设备的梯度是完全相同的。

梯度同步有两种方式:PRC通信方式和NCCL2通信方式(Nvidia Collective multi-GPU Communication Library)。

八、手写数字识别之训练调试与优化

上一节我们研究了资源部署优化的方法,通过使用单GPU和分布式部署,提升模型训练的效率。本节我们依旧横向展开"横纵式",如 图1 所示,探讨在手写数字识别任务中,为了保证模型的真实效果,在模型训练部分,对模型进行一些调试和优化的方法。

图1:“横纵式”教学法 — 训练过程 

训练过程优化思路主要有如下五个关键环节:

1. 计算分类准确率,观测模型训练效果。

交叉熵损失函数只能作为优化目标,无法直接准确衡量模型的训练效果。准确率可以直接衡量训练效果,但由于其离散性质,不适合做为损失函数优化神经网络。

2. 检查模型训练过程,识别潜在问题。

如果模型的损失或者评估指标表现异常,通常需要打印模型每一层的输入和输出来定位问题,分析每一层的内容来获取错误的原因。

3. 加入校验或测试,更好评价模型效果。

理想的模型训练结果是在训练集和验证集上均有较高的准确率,如果训练集上的准确率高于验证集,说明网络训练程度不够;如果验证集的准确率高于训练集,可能是发生了过拟合现象。通过在优化目标中加入正则化项的办法,解决过拟合的问题。

4. 加入正则化项,避免模型过拟合。

飞桨框架支持为整体参数加入正则化项,这是通常的做法。此外,飞桨框架也支持为某一层或某一部分的网络单独加入正则化项,以达到精细调整参数训练的效果。

5. 可视化分析。

用户不仅可以通过打印或使用matplotlib库作图,飞桨还提供了更专业的可视化分析工具VisualDL,提供便捷的可视化分析方法。

1. 计算模型的分类准确率

准确率是一个直观衡量分类模型效果的指标,由于这个指标是离散的,因此不适合作为损失来优化。通常情况下,交叉熵损失越小的模型,分类的准确率也越高。基于分类准确率,我们可以公平的比较两种损失函数的优劣,例如【手写数字识别】之损失函数 章节中均方误差和交叉熵的比较。

飞桨提供了计算分类准确率的API,使用fluid.layers.accuracy可以直接计算准确率,该API的输入参数input为预测的分类结果predict,输入参数label为数据真实的label。

在下述代码中,我们在模型前向计算过程forward函数中计算分类准确率,并在训练时打印每个批次样本的分类准确率。

# 加载相关库
import os
import random
import paddle
import paddle.fluid as fluid
from paddle.fluid.dygraph.nn import Conv2D, Pool2D, Linear
import numpy as np
from PIL import Image

import gzip
import json

# 定义数据集读取器
def load_data(mode='train'):

    # 读取数据文件
    datafile = './work/mnist.json.gz'
    print('loading mnist dataset from {} ......'.format(datafile))
    data = json.load(gzip.open(datafile))
    # 读取数据集中的训练集,验证集和测试集
    train_set, val_set, eval_set = data

    # 数据集相关参数,图片高度IMG_ROWS, 图片宽度IMG_COLS
    IMG_ROWS = 28
    IMG_COLS = 28
    # 根据输入mode参数决定使用训练集,验证集还是测试
    if mode == 'train':
        imgs = train_set[0]
        labels = train_set[1]
    elif mode == 'valid':
        imgs = val_set[0]
        labels = val_set[1]
    elif mode == 'eval':
        imgs = eval_set[0]
        labels = eval_set[1]
    # 获得所有图像的数量
    imgs_length = len(imgs)
    # 验证图像数量和标签数量是否一致
    assert len(imgs) == len(labels), \
          "length of train_imgs({}) should be the same as train_labels({})".format(
                  len(imgs), len(labels))

    index_list = list(range(imgs_length))

    # 读入数据时用到的batchsize
    BATCHSIZE = 100

    # 定义数据生成器
    def data_generator():
        # 训练模式下,打乱训练数据
        if mode == 'train':
            random.shuffle(index_list)
        imgs_list = []
        labels_list = []
        # 按照索引读取数据
        for i in index_list:
            # 读取图像和标签,转换其尺寸和类型
            img = np.reshape(imgs[i], [1, IMG_ROWS, IMG_COLS]).astype('float32')
            label = np.reshape(labels[i], [1]).astype('int64')
            imgs_list.append(img) 
            labels_list.append(label)
            # 如果当前数据缓存达到了batch size,就返回一个批次数据
            if len(imgs_list) == BATCHSIZE:
                yield np.array(imgs_list), np.array(labels_list)
                # 清空数据缓存列表
                imgs_list = []
                labels_list = []

        # 如果剩余数据的数目小于BATCHSIZE,
        # 则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
        if len(imgs_list) > 0:
            yield np.array(imgs_list), np.array(labels_list)

    return data_generator


# 定义模型结构
class MNIST(fluid.dygraph.Layer):
     def __init__(self):
         super(MNIST, self).__init__()
         
         # 定义一个卷积层,使用relu激活函数
         self.conv1 = Conv2D(num_channels=1, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
         self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义一个卷积层,使用relu激活函数
         self.conv2 = Conv2D(num_channels=20, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
         self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义一个全连接层,输出节点数为10 
         self.fc = Linear(input_dim=980, output_dim=10, act='softmax')
    # 定义网络的前向计算过程
     def forward(self, inputs, label):
         x = self.conv1(inputs)
         x = self.pool1(x)
         x = self.conv2(x)
         x = self.pool2(x)
         x = fluid.layers.reshape(x, [x.shape[0], 980])
         x = self.fc(x)
         if label is not None:
             acc = fluid.layers.accuracy(input=x, label=label)
             return x, acc
         else:
             return x

#调用加载数据的函数
train_loader = load_data('train')
    
#在使用GPU机器时,可以将use_gpu变量设置成True
use_gpu = True
place = fluid.CUDAPlace(0) if use_gpu else fluid.CPUPlace()

with fluid.dygraph.guard(place):
    model = MNIST()
    model.train() 
    
    #四种优化算法的设置方案,可以逐一尝试效果
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.MomentumOptimizer(learning_rate=0.01, momentum=0.9, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.AdagradOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.AdamOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    
    EPOCH_NUM = 5
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程,同时拿到模型输出值和分类准确率
            predict, acc = model(image, label)
            
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了200批次的数据,打印下当前Loss的情况
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}, acc is {}".format(epoch_id, batch_id, avg_loss.numpy(), acc.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')
loading mnist dataset from ./work/mnist.json.gz ......
epoch: 0, batch: 0, loss is: [3.431741], acc is [0.09]
epoch: 0, batch: 200, loss is: [0.40956235], acc is [0.9]
epoch: 0, batch: 400, loss is: [0.3208436], acc is [0.9]
epoch: 1, batch: 0, loss is: [0.14430793], acc is [0.97]
epoch: 1, batch: 200, loss is: [0.1308289], acc is [0.96]
epoch: 1, batch: 400, loss is: [0.16255337], acc is [0.92]
epoch: 2, batch: 0, loss is: [0.09396242], acc is [0.98]
epoch: 2, batch: 200, loss is: [0.08655033], acc is [0.98]
epoch: 2, batch: 400, loss is: [0.18169561], acc is [0.95]
epoch: 3, batch: 0, loss is: [0.06122379], acc is [0.98]
epoch: 3, batch: 200, loss is: [0.04443743], acc is [0.99]
epoch: 3, batch: 400, loss is: [0.1689522], acc is [0.97]
epoch: 4, batch: 0, loss is: [0.12093002], acc is [0.96]
epoch: 4, batch: 200, loss is: [0.08740665], acc is [0.97]
epoch: 4, batch: 400, loss is: [0.15987754], acc is [0.96]

2. 检查模型训练过程,识别潜在训练问题

使用飞桨动态图编程可以方便的查看和调试训练的执行过程。在网络定义的Forward函数中,可以打印每一层输入输出的尺寸,以及每层网络的参数。通过查看这些信息,不仅可以更好地理解训练的执行过程,还可以发现潜在问题,或者启发继续优化的思路。

在下述程序中,使用check_shape变量控制是否打印“尺寸”,验证网络结构是否正确。使用check_content变量控制是否打印“内容值”,验证数据分布是否合理。假如在训练中发现中间层的部分输出持续为0,说明该部分的网络结构设计存在问题,没有充分利用。

# 定义模型结构
class MNIST(fluid.dygraph.Layer):
     def __init__(self):
         super(MNIST, self).__init__()
         
         # 定义一个卷积层,使用relu激活函数
         self.conv1 = Conv2D(num_channels=1, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
         self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义一个卷积层,使用relu激活函数
         self.conv2 = Conv2D(num_channels=20, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
         self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义一个全连接层,输出节点数为10 
         self.fc = Linear(input_dim=980, output_dim=10, act='softmax')
     
     #加入对每一层输入和输出的尺寸和数据内容的打印,根据check参数决策是否打印每层的参数和输出尺寸
     def forward(self, inputs, label=None, check_shape=False, check_content=False):
         # 给不同层的输出不同命名,方便调试
         outputs1 = self.conv1(inputs)
         outputs2 = self.pool1(outputs1)
         outputs3 = self.conv2(outputs2)
         outputs4 = self.pool2(outputs3)
         _outputs4 = fluid.layers.reshape(outputs4, [outputs4.shape[0], -1])
         outputs5 = self.fc(_outputs4)
         
         # 选择是否打印神经网络每层的参数尺寸和输出尺寸,验证网络结构是否设置正确
         if check_shape:
             # 打印每层网络设置的超参数-卷积核尺寸,卷积步长,卷积padding,池化核尺寸
             print("\n########## print network layer's superparams ##############")
             print("conv1-- kernel_size:{}, padding:{}, stride:{}".format(self.conv1.weight.shape, self.conv1._padding, self.conv1._stride))
             print("conv2-- kernel_size:{}, padding:{}, stride:{}".format(self.conv2.weight.shape, self.conv2._padding, self.conv2._stride))
             print("pool1-- pool_type:{}, pool_size:{}, pool_stride:{}".format(self.pool1._pool_type, self.pool1._pool_size, self.pool1._pool_stride))
             print("pool2-- pool_type:{}, poo2_size:{}, pool_stride:{}".format(self.pool2._pool_type, self.pool2._pool_size, self.pool2._pool_stride))
             print("fc-- weight_size:{}, bias_size_{}, activation:{}".format(self.fc.weight.shape, self.fc.bias.shape, self.fc._act))
             
             # 打印每层的输出尺寸
             print("\n########## print shape of features of every layer ###############")
             print("inputs_shape: {}".format(inputs.shape))
             print("outputs1_shape: {}".format(outputs1.shape))
             print("outputs2_shape: {}".format(outputs2.shape))
             print("outputs3_shape: {}".format(outputs3.shape))
             print("outputs4_shape: {}".format(outputs4.shape))
             print("outputs5_shape: {}".format(outputs5.shape))
             
         # 选择是否打印训练过程中的参数和输出内容,可用于训练过程中的调试
         if check_content:
            # 打印卷积层的参数-卷积核权重,权重参数较多,此处只打印部分参数
             print("\n########## print convolution layer's kernel ###############")
             print("conv1 params -- kernel weights:", self.conv1.weight[0][0])
             print("conv2 params -- kernel weights:", self.conv2.weight[0][0])

             # 创建随机数,随机打印某一个通道的输出值
             idx1 = np.random.randint(0, outputs1.shape[1])
             idx2 = np.random.randint(0, outputs3.shape[1])
             # 打印卷积-池化后的结果,仅打印batch中第一个图像对应的特征
             print("\nThe {}th channel of conv1 layer: ".format(idx1), outputs1[0][idx1])
             print("The {}th channel of conv2 layer: ".format(idx2), outputs3[0][idx2])
             print("The output of last layer:", outputs5[0], '\n')
            
        # 如果label不是None,则计算分类精度并返回
         if label is not None:
             acc = fluid.layers.accuracy(input=outputs5, label=label)
             return outputs5, acc
         else:
             return outputs5

    
#在使用GPU机器时,可以将use_gpu变量设置成True
use_gpu = True
place = fluid.CUDAPlace(0) if use_gpu else fluid.CPUPlace()

with fluid.dygraph.guard(place):
    model = MNIST()
    model.train() 
    
    #四种优化算法的设置方案,可以逐一尝试效果
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.MomentumOptimizer(learning_rate=0.01, momentum=0.9, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.AdagradOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.AdamOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    
    EPOCH_NUM = 1
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,变得更加简洁
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程,同时拿到模型输出值和分类准确率
            if batch_id == 0 and epoch_id==0:
                # 打印模型参数和每层输出的尺寸
                predict, acc = model(image, label, check_shape=True, check_content=False)
            elif batch_id==401:
                # 打印模型参数和每层输出的值
                predict, acc = model(image, label, check_shape=False, check_content=True)
            else:
                predict, acc = model(image, label)
            
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了100批次的数据,打印下当前Loss的情况
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}, acc is {}".format(epoch_id, batch_id, avg_loss.numpy(), acc.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')
    print("Model has been saved.")
########## print network layer's superparams ##############
conv1-- kernel_size:[20, 1, 5, 5], padding:[2, 2], stride:[1, 1]
conv2-- kernel_size:[20, 20, 5, 5], padding:[2, 2], stride:[1, 1]
pool1-- pool_type:max, pool_size:[2, 2], pool_stride:[2, 2]
pool2-- pool_type:max, poo2_size:[2, 2], pool_stride:[2, 2]
fc-- weight_size:[980, 10], bias_size_[10], activation:softmax

########## print shape of features of every layer ###############
inputs_shape: [100, 1, 28, 28]
outputs1_shape: [100, 20, 28, 28]
outputs2_shape: [100, 20, 14, 14]
outputs3_shape: [100, 20, 14, 14]
outputs4_shape: [100, 20, 7, 7]
outputs5_shape: [100, 10]
epoch: 0, batch: 0, loss is: [4.2917213], acc is [0.09]
epoch: 0, batch: 200, loss is: [0.19663304], acc is [0.95]
epoch: 0, batch: 400, loss is: [0.17979378], acc is [0.96]

########## print convolution layer's kernel ###############
conv1 params -- kernel weights: Tensor(shape=[5, 5], dtype=float32, place=CUDAPlace(0), stop_gradient=False,
       [[ 0.00192654,  0.60671264,  0.45915401, -0.28351691,  0.01501902],
        [-0.17108053, -0.01535101,  0.28130820,  0.07465534,  0.23308212],
        [-0.24307483, -0.32252041, -0.02659753,  0.24193759, -0.11027934],
        [ 0.20326665, -0.39386526,  0.17379378,  0.18104875,  0.27032295],
        [-0.20265064,  0.43318194,  0.18405993, -0.11324076,  0.09355003]])
conv2 params -- kernel weights: Tensor(shape=[5, 5], dtype=float32, place=CUDAPlace(0), stop_gradient=False,
       [[ 0.03578463,  0.04934358, -0.08026821, -0.06674836, -0.05139511],
        [-0.04040715,  0.01589690, -0.09788839, -0.03320840, -0.03819016],
        [-0.17426553, -0.09056617, -0.03178996,  0.06471853,  0.01820888],
        [ 0.03205277, -0.03620841,  0.07944667, -0.03022688,  0.00421894],
        [ 0.07008100,  0.01318217, -0.06311024, -0.00449732,  0.01572734]])

The 0th channel of conv1 layer:  Tensor(shape=[28, 28], dtype=float32, place=CUDAPlace(0), stop_gradient=False,
       [[0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.21655470],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.12169565],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.27948895,
         0.63463789, 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         1.29798710, 1.38972127, 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         1.57162225, 1.72934997, 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         1.38662350, 1.32598746, 0.36292467, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.98415637, 1.36668742, 0.50902474, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.98062277, 1.70271659, 0.83661646, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.74874735, 1.66773891, 1.09917438, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.38201678, 1.22876716, 1.13223636, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.25164875, 1.16144991, 1.02709794, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.18570389, 1.42983794, 0.99611664, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.17922315, 1.50186610, 0.78563958, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 1.39618194, 1.33875453, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 1.20586085, 2.02098083, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 1.24428463, 1.70053589, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.78072554, 0.56426477, 0.13388938, 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.89551699, 0.90363377, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 1.05850315, 1.22593999, 0.12045123, 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.59304255, 1.01437271, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.39801672, 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.23798974, 0.58963257, 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.50857437, 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ],
        [0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.        , 0.        , 0.        ]])
The 19th channel of conv2 layer:  Tensor(shape=[14, 14], dtype=float32, place=CUDAPlace(0), stop_gradient=False,
       [[0.        , 0.        , 0.        , 0.        , 0.        , 0.        ,
         0.        , 0.17914012, 0.58919895, 0.        , 0.        , 0.        ,
         0.02817499, 0.00952328],
        [0.        , 0.01467052, 0.28584427, 0.77237797, 0.42294389, 1.02347696,
         1.33482111, 1.22615874, 1.75428593, 0.68598306, 0.86399508, 1.08245027,
         0.86571878, 1.05692613],
        [0.05380305, 0.44365278, 0.46447814, 1.28249347, 1.22154009, 1.22787917,
         2.12647963, 0.15916686, 1.28140318, 0.92932993, 1.17738616, 1.59011257,
         1.58297503, 1.21968162],
        [0.        , 0.12906297, 0.20048104, 0.95022166, 2.21976614, 2.14181137,
         0.20310487, 0.        , 0.        , 0.49631149, 0.72650933, 1.36439812,
         1.16190946, 0.75961781],
        [0.        , 0.12906495, 0.27558208, 2.03251839, 3.41296816, 1.12920392,
         0.09177830, 0.        , 0.        , 0.        , 0.50061619, 1.36441958,
         1.16193008, 0.75963336],
        [0.        , 0.12906355, 0.27383637, 2.22013164, 4.18104553, 1.62109196,
         0.33765978, 0.        , 0.        , 0.        , 0.15349486, 1.36441195,
         1.16192412, 0.75961995],
        [0.        , 0.12906820, 0.24513839, 1.78951490, 4.06432438, 1.71721053,
         1.09562039, 0.        , 0.        , 0.26934713, 0.        , 1.36442530,
         1.16192019, 0.75961494],
        [0.        , 0.12906687, 0.25094339, 1.64468443, 3.96650815, 1.86015952,
         1.20295393, 0.        , 0.        , 0.02469490, 0.        , 1.36440957,
         1.16190803, 0.75960672],
        [0.        , 0.12906329, 0.25076097, 1.65820730, 3.56218624, 2.13693762,
         0.80610323, 0.        , 0.        , 0.69323850, 0.        , 1.36441147,
         1.16191375, 0.75963402],
        [0.        , 0.12906332, 0.25073713, 1.31914318, 3.12742138, 1.66882908,
         1.10837162, 0.        , 0.        , 0.10436095, 0.        , 1.36442816,
         1.16192293, 0.75963140],
        [0.        , 0.12906466, 0.25079858, 1.29710138, 2.71684313, 1.96558714,
         0.59154868, 0.        , 0.        , 0.        , 0.        , 1.36441338,
         1.16194069, 0.75961435],
        [0.        , 0.33962983, 0.57166886, 1.54956663, 3.09469748, 2.76642799,
         0.48787531, 0.        , 0.        , 0.        , 0.06301031, 1.52930200,
         1.30692565, 0.85704356],
        [0.        , 0.73634923, 0.97892946, 1.83186162, 3.10636473, 2.55805016,
         1.17836034, 0.        , 1.46894896, 1.07752311, 0.93316239, 1.76235104,
         1.71952116, 1.17272258],
        [0.29327273, 0.73267168, 1.19837582, 1.82536995, 2.80612135, 2.54615784,
         1.44289410, 1.87154233, 2.70019770, 1.81643593, 1.39313614, 1.90442979,
         1.59329855, 0.96983457]])
The output of last layer: Tensor(shape=[10], dtype=float32, place=CUDAPlace(0), stop_gradient=False,
       [0.00002472, 0.98842210, 0.00060336, 0.00020845, 0.00040301, 0.00104105,
        0.00048184, 0.00049022, 0.00779884, 0.00052649]) 

Model has been saved.

3. 加入校验或测试,更好评价模型效果

在训练过程中,我们会发现模型在训练样本集上的损失在不断减小。但这是否代表模型在未来的应用场景上依然有效?为了验证模型的有效性,通常将样本集合分成三份,训练集、校验集和测试集。

  • 训练集 :用于训练模型的参数,即训练过程中主要完成的工作。
  • 校验集 :用于对模型超参数的选择,比如网络结构的调整、正则化项权重的选择等。
  • 测试集 :用于模拟模型在应用后的真实效果。因为测试集没有参与任何模型优化或参数训练的工作,所以它对模型来说是完全未知的样本。在不以校验数据优化网络结构或模型超参数时,校验数据和测试数据的效果是类似的,均更真实的反映模型效果。

如下程序读取上一步训练保存的模型参数,读取校验数据集,并测试模型在校验数据集上的效果。

with fluid.dygraph.guard():
    print('start evaluation .......')
    #加载模型参数
    model = MNIST()
    model_state_dict, _ = fluid.load_dygraph('mnist')
    model.load_dict(model_state_dict)

    model.eval()
    eval_loader = load_data('eval')

    acc_set = []
    avg_loss_set = []
    for batch_id, data in enumerate(eval_loader()):
        x_data, y_data = data
        img = fluid.dygraph.to_variable(x_data)
        label = fluid.dygraph.to_variable(y_data)
        prediction, acc = model(img, label)
        loss = fluid.layers.cross_entropy(input=prediction, label=label)
        avg_loss = fluid.layers.mean(loss)
        acc_set.append(float(acc.numpy()))
        avg_loss_set.append(float(avg_loss.numpy()))
    
    #计算多个batch的平均损失和准确率
    acc_val_mean = np.array(acc_set).mean()
    avg_loss_val_mean = np.array(avg_loss_set).mean()

    print('loss={}, acc={}'.format(avg_loss_val_mean, acc_val_mean))
start evaluation .......
loading mnist dataset from ./work/mnist.json.gz ......
loss=0.190103396223858, acc=0.9437000006437302

 从测试的效果来看,模型在验证集上依然有93%的准确率,证明它是有预测效果的。

4. 加入正则化项,避免模型过拟合

过拟合现象

对于样本量有限、但需要使用强大模型的复杂任务,模型很容易出现过拟合的表现,即在训练集上的损失小,在验证集或测试集上的损失较大,如 图2 所示。

图2:过拟合现象,训练误差不断降低,但测试误差先降后增

反之,如果模型在训练集和测试集上均损失较大,则称为欠拟合。过拟合表示模型过于敏感,学习到了训练数据中的一些误差,而这些误差并不是真实的泛化规律(可推广到测试集上的规律)。欠拟合表示模型还不够强大,还没有很好的拟合已知的训练样本,更别提测试样本了。因为欠拟合情况容易观察和解决,只要训练loss不够好,就不断使用更强大的模型即可,因此实际中我们更需要处理好过拟合的问题。

导致过拟合原因

造成过拟合的原因是模型过于敏感,而训练数据量太少或其中的噪音太多。

图3 所示,理想的回归模型是一条坡度较缓的抛物线,欠拟合的模型只拟合出一条直线,显然没有捕捉到真实的规律,但过拟合的模型拟合出存在很多拐点的抛物线,显然是过于敏感,也没有正确表达真实规律。

图3:回归模型的过拟合,理想和欠拟合状态的表现 

图4 所示,理想的分类模型是一条半圆形的曲线,欠拟合用直线作为分类边界,显然没有捕捉到真实的边界,但过拟合的模型拟合出很扭曲的分类边界,虽然对所有的训练数据正确分类,但对一些较为个例的样本所做出的妥协,高概率不是真实的规律。 图4:分类模型的欠拟合,理想和过拟合状态的表现 

过拟合的成因与防控

为了更好的理解过拟合的成因,可以参考侦探定位罪犯的案例逻辑,如 图5 所示。

图5:侦探定位罪犯与模型假设示意

对于这个案例,假设侦探也会犯错,通过分析发现可能的原因:

  1. 情况1:罪犯证据存在错误,依据错误的证据寻找罪犯肯定是缘木求鱼。

  2. 情况2:搜索范围太大的同时证据太少,导致符合条件的候选(嫌疑人)太多,无法准确定位罪犯。

那么侦探解决这个问题的方法有两种:或者缩小搜索范围(比如假设该案件只能是熟人作案),或者寻找更多的证据。

归结到深度学习中,假设模型也会犯错,通过分析发现可能的原因:

  1. 情况1:训练数据存在噪音,导致模型学到了噪音,而不是真实规律。

  2. 情况2:使用强大模型(表示空间大)的同时训练数据太少,导致在训练数据上表现良好的候选假设太多,锁定了一个“虚假正确”的假设。 

对于情况1,我们使用数据清洗和修正来解决。 对于情况2,我们或者限制模型表示能力,或者收集更多的训练数据。

而清洗训练数据中的错误,或收集更多的训练数据往往是一句“正确的废话”,在任何时候我们都想获得更多更高质量的数据。在实际项目中,更快、更低成本可控制过拟合的方法,只有限制模型的表示能力。

5. 正则化项

为了防止模型过拟合,在没有扩充样本量的可能下,只能降低模型的复杂度,可以通过限制参数的数量或可能取值(参数值尽量小)实现。

具体来说,在模型的优化目标(损失)中人为加入对参数规模的惩罚项。当参数越多或取值越大时,该惩罚项就越大。通过调整惩罚项的权重系数,可以使模型在“尽量减少训练损失”和“保持模型的泛化能力”之间取得平衡。泛化能力表示模型在没有见过的样本上依然有效。正则化项的存在,增加了模型在训练集上的损失。

飞桨支持为所有参数加上统一的正则化项,也支持为特定的参数添加正则化项。前者的实现如下代码所示,仅在优化器中设置regularization参数即可实现。使用参数regularization_coeff调节正则化项的权重,权重越大时,对模型复杂度的惩罚越高。

with fluid.dygraph.guard():
    model = MNIST()
    model.train() 
    
    #四种优化算法的设置方案,可以逐一尝试效果
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.MomentumOptimizer(learning_rate=0.01, momentum=0.9, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.AdagradOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    #optimizer = fluid.optimizer.AdamOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    
    #各种优化算法均可以加入正则化项,避免过拟合,参数regularization_coeff调节正则化项的权重
    #optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, regularization=fluid.regularizer.L2Decay(regularization_coeff=0.1),parameter_list=model.parameters()))
    optimizer = fluid.optimizer.AdamOptimizer(learning_rate=0.01, regularization=fluid.regularizer.L2Decay(regularization_coeff=0.1),
                                              parameter_list=model.parameters())
    
    EPOCH_NUM = 10
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,变得更加简洁
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程,同时拿到模型输出值和分类准确率
            predict, acc = model(image, label)
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了100批次的数据,打印下当前Loss的情况
            if batch_id % 100 == 0:
                print("epoch: {}, batch: {}, loss is: {}, acc is {}".format(epoch_id, batch_id, avg_loss.numpy(), acc.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')
epoch: 0, batch: 0, loss is: [3.1510718], acc is [0.08]
epoch: 0, batch: 100, loss is: [0.33021247], acc is [0.9]
epoch: 0, batch: 200, loss is: [0.2662688], acc is [0.94]
epoch: 0, batch: 300, loss is: [0.30194843], acc is [0.94]
epoch: 0, batch: 400, loss is: [0.3370705], acc is [0.91]
epoch: 1, batch: 0, loss is: [0.3237894], acc is [0.91]
epoch: 1, batch: 100, loss is: [0.21092314], acc is [0.93]
epoch: 1, batch: 200, loss is: [0.26613906], acc is [0.92]
epoch: 1, batch: 300, loss is: [0.29176068], acc is [0.9]
epoch: 1, batch: 400, loss is: [0.27201262], acc is [0.92]
epoch: 2, batch: 0, loss is: [0.29062605], acc is [0.93]
epoch: 2, batch: 100, loss is: [0.21489671], acc is [0.97]
epoch: 2, batch: 200, loss is: [0.34955], acc is [0.89]
epoch: 2, batch: 300, loss is: [0.2850235], acc is [0.92]
epoch: 2, batch: 400, loss is: [0.39858928], acc is [0.9]
epoch: 3, batch: 0, loss is: [0.32229012], acc is [0.9]
epoch: 3, batch: 100, loss is: [0.34073186], acc is [0.89]
epoch: 3, batch: 200, loss is: [0.30916822], acc is [0.93]
epoch: 3, batch: 300, loss is: [0.3584801], acc is [0.88]
epoch: 3, batch: 400, loss is: [0.27427998], acc is [0.93]
epoch: 4, batch: 0, loss is: [0.26873744], acc is [0.92]
epoch: 4, batch: 100, loss is: [0.29889888], acc is [0.92]
epoch: 4, batch: 200, loss is: [0.18725199], acc is [0.98]
epoch: 4, batch: 300, loss is: [0.24579456], acc is [0.96]
epoch: 4, batch: 400, loss is: [0.3289936], acc is [0.89]
epoch: 5, batch: 0, loss is: [0.19938685], acc is [0.97]
epoch: 5, batch: 100, loss is: [0.22234254], acc is [0.95]
epoch: 5, batch: 200, loss is: [0.24267375], acc is [0.96]
epoch: 5, batch: 300, loss is: [0.37821916], acc is [0.89]
epoch: 5, batch: 400, loss is: [0.25829113], acc is [0.94]
epoch: 6, batch: 0, loss is: [0.27560282], acc is [0.93]
epoch: 6, batch: 100, loss is: [0.29805058], acc is [0.91]
epoch: 6, batch: 200, loss is: [0.3021547], acc is [0.92]
epoch: 6, batch: 300, loss is: [0.30886912], acc is [0.95]
epoch: 6, batch: 400, loss is: [0.2598614], acc is [0.94]
epoch: 7, batch: 0, loss is: [0.30696657], acc is [0.89]
epoch: 7, batch: 100, loss is: [0.24309002], acc is [0.95]
epoch: 7, batch: 200, loss is: [0.29119033], acc is [0.92]
epoch: 7, batch: 300, loss is: [0.3056334], acc is [0.94]
epoch: 7, batch: 400, loss is: [0.28448212], acc is [0.91]
epoch: 8, batch: 0, loss is: [0.2232432], acc is [0.96]
epoch: 8, batch: 100, loss is: [0.2940759], acc is [0.92]
epoch: 8, batch: 200, loss is: [0.27468944], acc is [0.89]
epoch: 8, batch: 300, loss is: [0.21651828], acc is [0.97]
epoch: 8, batch: 400, loss is: [0.15890867], acc is [0.98]
epoch: 9, batch: 0, loss is: [0.24878475], acc is [0.95]
epoch: 9, batch: 100, loss is: [0.2937093], acc is [0.9]
epoch: 9, batch: 200, loss is: [0.26082614], acc is [0.95]
epoch: 9, batch: 300, loss is: [0.27821824], acc is [0.92]
epoch: 9, batch: 400, loss is: [0.27115625], acc is [0.92]

6. 可视化分析

训练模型时,经常需要观察模型的评价指标,分析模型的优化过程,以确保训练是有效的。可选用这两种工具:Matplotlib库和VisualDL。

  • Matplotlib库:Matplotlib库是Python中使用的最多的2D图形绘图库,它有一套完全仿照MATLAB的函数形式的绘图接口,使用轻量级的PLT库(Matplotlib)作图是非常简单的。
  • VisualDL:如果期望使用更加专业的作图工具,可以尝试VisualDL,飞桨可视化分析工具。VisualDL能够有效地展示飞桨在运行过程中的计算图、各种指标变化趋势和数据信息。

使用Matplotlib库绘制损失随训练下降的曲线图

将训练的批次编号作为X轴坐标,该批次的训练损失作为Y轴坐标。

  1. 训练开始前,声明两个列表变量存储对应的批次编号(iters=[])和训练损失(losses=[])。
    iters=[]
    losses=[]
    for epoch_id in range(EPOCH_NUM):
    	"""start to training"""
  2. 随着训练的进行,将iter和losses两个列表填满。
    iters=[]
    losses=[]
    for epoch_id in range(EPOCH_NUM):
    	for batch_id, data in enumerate(train_loader()):
            predict, acc = model(image, label)
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            # 累计迭代次数和对应的loss
       	iters.append(batch_id + epoch_id*len(list(train_loader()))
    	losses.append(avg_loss)
  3. 训练结束后,将两份数据以参数形式导入PLT的横纵坐标。
    plt.xlabel("iter", fontsize=14),plt.ylabel("loss", fontsize=14)
  4. 最后,调用plt.plot()函数即可完成作图。
    plt.plot(iters, losses,color='red',label='train loss') 

    详细代码如下:

#引入matplotlib库
import matplotlib.pyplot as plt

with fluid.dygraph.guard(place):
    model = MNIST()
    model.train() 
    
    #四种优化算法的设置方案,可以逐一尝试效果
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    
    EPOCH_NUM = 10
    iter=0
    iters=[]
    losses=[]
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,变得更加简洁
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程,同时拿到模型输出值和分类准确率
            predict, acc = model(image, label)

            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了100批次的数据,打印下当前Loss的情况
            if batch_id % 100 == 0:
                print("epoch: {}, batch: {}, loss is: {}, acc is {}".format(epoch_id, batch_id, avg_loss.numpy(), acc.numpy()))
                iters.append(iter)
                losses.append(avg_loss.numpy())
                iter = iter + 100

            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')
epoch: 0, batch: 0, loss is: [3.9674757], acc is [0.08]
epoch: 0, batch: 100, loss is: [0.44290912], acc is [0.84]
epoch: 0, batch: 200, loss is: [0.2916859], acc is [0.91]
epoch: 0, batch: 300, loss is: [0.3447976], acc is [0.92]
epoch: 0, batch: 400, loss is: [0.32924506], acc is [0.91]
epoch: 1, batch: 0, loss is: [0.25935555], acc is [0.92]
epoch: 1, batch: 100, loss is: [0.20052095], acc is [0.95]
epoch: 1, batch: 200, loss is: [0.06615235], acc is [0.98]
epoch: 1, batch: 300, loss is: [0.20257704], acc is [0.92]
epoch: 1, batch: 400, loss is: [0.10042212], acc is [0.98]
epoch: 2, batch: 0, loss is: [0.11821613], acc is [0.97]
epoch: 2, batch: 100, loss is: [0.0982694], acc is [0.97]
epoch: 2, batch: 200, loss is: [0.10372272], acc is [0.95]
epoch: 2, batch: 300, loss is: [0.1017218], acc is [0.96]
epoch: 2, batch: 400, loss is: [0.07758243], acc is [0.99]
epoch: 3, batch: 0, loss is: [0.11763101], acc is [0.97]
epoch: 3, batch: 100, loss is: [0.15566173], acc is [0.96]
epoch: 3, batch: 200, loss is: [0.08096006], acc is [0.97]
epoch: 3, batch: 300, loss is: [0.14275388], acc is [0.95]
epoch: 3, batch: 400, loss is: [0.10895174], acc is [0.98]
epoch: 4, batch: 0, loss is: [0.12971461], acc is [0.96]
epoch: 4, batch: 100, loss is: [0.04379293], acc is [0.99]
epoch: 4, batch: 200, loss is: [0.1451336], acc is [0.97]
epoch: 4, batch: 300, loss is: [0.10344641], acc is [0.95]
epoch: 4, batch: 400, loss is: [0.04615906], acc is [0.99]
epoch: 5, batch: 0, loss is: [0.04480769], acc is [0.99]
epoch: 5, batch: 100, loss is: [0.0452627], acc is [0.98]
epoch: 5, batch: 200, loss is: [0.03497183], acc is [1.]
epoch: 5, batch: 300, loss is: [0.04282472], acc is [0.99]
epoch: 5, batch: 400, loss is: [0.06001796], acc is [0.98]
epoch: 6, batch: 0, loss is: [0.1127457], acc is [0.96]
epoch: 6, batch: 100, loss is: [0.0940505], acc is [0.98]
epoch: 6, batch: 200, loss is: [0.07391232], acc is [0.97]
epoch: 6, batch: 300, loss is: [0.03157431], acc is [1.]
epoch: 6, batch: 400, loss is: [0.02319035], acc is [0.99]
epoch: 7, batch: 0, loss is: [0.03297421], acc is [0.99]
epoch: 7, batch: 100, loss is: [0.05615228], acc is [0.99]
epoch: 7, batch: 200, loss is: [0.03347358], acc is [1.]
epoch: 7, batch: 300, loss is: [0.04861265], acc is [0.99]
epoch: 7, batch: 400, loss is: [0.1828341], acc is [0.96]
epoch: 8, batch: 0, loss is: [0.00751025], acc is [1.]
epoch: 8, batch: 100, loss is: [0.04360604], acc is [0.97]
epoch: 8, batch: 200, loss is: [0.03041692], acc is [0.99]
epoch: 8, batch: 300, loss is: [0.03944657], acc is [0.99]
epoch: 8, batch: 400, loss is: [0.07031041], acc is [0.97]
epoch: 9, batch: 0, loss is: [0.0229254], acc is [1.]
epoch: 9, batch: 100, loss is: [0.02301158], acc is [1.]
epoch: 9, batch: 200, loss is: [0.05531039], acc is [0.97]
epoch: 9, batch: 300, loss is: [0.03092612], acc is [1.]
epoch: 9, batch: 400, loss is: [0.08894277], acc is [0.96]
#画出训练过程中Loss的变化曲线
plt.figure()
plt.title("train loss", fontsize=24)
plt.xlabel("iter", fontsize=14)
plt.ylabel("loss", fontsize=14)
plt.plot(iters, losses,color='red',label='train loss') 
plt.grid()
plt.show()

使用VisualDL可视化分析

VisualDL,飞桨可视化分析工具,以丰富的图表呈现训练参数变化趋势、模型结构、数据样本、高维数据分布等。帮助用户清晰直观地理解深度学习模型训练过程及模型结构,进而实现高效的模型调优,具体代码实现如下。

说明:

本案例不支持AI Studio演示,请读者在本地安装的飞桨上实践。

步骤1:引入VisualDL库,定义作图数据存储位置(供第3步使用),本案例的路径是“log”。

from visualdl import LogWriter
log_writer = LogWriter("./log")

步骤2:在训练过程中插入作图语句。当每100个batch训练完成后,将当前损失作为一个新增的数据点(iter和acc的映射对)存储到第一步设置的文件中。使用变量iter记录下已经训练的批次数,作为作图的X轴坐标。

log_writer.add_scalar(tag = 'acc', step = iter, value = avg_acc.numpy())
log_writer.add_scalar(tag = 'loss', step = iter, value = avg_loss.numpy())
iter = iter + 100
# 安装VisualDL
!pip install --upgrade --pre visualdl
#引入VisualDL库,并设定保存作图数据的文件位置
from visualdl import LogWriter
log_writer = LogWriter(logdir="./log")

with fluid.dygraph.guard(place):
    model = MNIST()
    model.train() 
    
    #四种优化算法的设置方案,可以逐一尝试效果
    optimizer = fluid.optimizer.SGDOptimizer(learning_rate=0.01, parameter_list=model.parameters())
    
    EPOCH_NUM = 10
    iter = 0
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,变得更加简洁
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程,同时拿到模型输出值和分类准确率
            predict, avg_acc = model(image, label)

            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了100批次的数据,打印下当前Loss的情况
            if batch_id % 100 == 0:
                print("epoch: {}, batch: {}, loss is: {}, acc is {}".format(epoch_id, batch_id, avg_loss.numpy(), avg_acc.numpy()))
                log_writer.add_scalar(tag = 'acc', step = iter, value = avg_acc.numpy())
                log_writer.add_scalar(tag = 'loss', step = iter, value = avg_loss.numpy())
                iter = iter + 100

            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()

    #保存模型参数
    fluid.save_dygraph(model.state_dict(), 'mnist')
epoch: 0, batch: 0, loss is: [3.4294689], acc is [0.1]
epoch: 0, batch: 100, loss is: [0.395636], acc is [0.87]
epoch: 0, batch: 200, loss is: [0.25038156], acc is [0.93]
epoch: 0, batch: 300, loss is: [0.32582644], acc is [0.9]
epoch: 0, batch: 400, loss is: [0.16186649], acc is [0.95]
epoch: 1, batch: 0, loss is: [0.2816676], acc is [0.93]
epoch: 1, batch: 100, loss is: [0.14998746], acc is [0.96]
epoch: 1, batch: 200, loss is: [0.11513884], acc is [0.97]
epoch: 1, batch: 300, loss is: [0.14856921], acc is [0.92]
epoch: 1, batch: 400, loss is: [0.10721637], acc is [0.97]
epoch: 2, batch: 0, loss is: [0.09796241], acc is [0.96]
epoch: 2, batch: 100, loss is: [0.12187135], acc is [0.97]
epoch: 2, batch: 200, loss is: [0.10277257], acc is [0.95]
epoch: 2, batch: 300, loss is: [0.13427946], acc is [0.96]
epoch: 2, batch: 400, loss is: [0.09590762], acc is [0.98]
epoch: 3, batch: 0, loss is: [0.09567603], acc is [0.97]
epoch: 3, batch: 100, loss is: [0.10203888], acc is [0.97]
epoch: 3, batch: 200, loss is: [0.08288154], acc is [0.98]
epoch: 3, batch: 300, loss is: [0.15725988], acc is [0.98]
epoch: 3, batch: 400, loss is: [0.06912476], acc is [0.98]
epoch: 4, batch: 0, loss is: [0.10119764], acc is [0.96]
epoch: 4, batch: 100, loss is: [0.11866358], acc is [0.93]
epoch: 4, batch: 200, loss is: [0.0911691], acc is [0.97]
epoch: 4, batch: 300, loss is: [0.04577635], acc is [0.99]
epoch: 4, batch: 400, loss is: [0.13025591], acc is [0.95]
epoch: 5, batch: 0, loss is: [0.03741448], acc is [1.]
epoch: 5, batch: 100, loss is: [0.10327552], acc is [0.98]
epoch: 5, batch: 200, loss is: [0.08947684], acc is [0.96]
epoch: 5, batch: 300, loss is: [0.1569024], acc is [0.98]
epoch: 5, batch: 400, loss is: [0.17060527], acc is [0.94]
epoch: 6, batch: 0, loss is: [0.02574664], acc is [1.]
epoch: 6, batch: 100, loss is: [0.03399421], acc is [0.98]
epoch: 6, batch: 200, loss is: [0.03639302], acc is [0.99]
epoch: 6, batch: 300, loss is: [0.03844678], acc is [0.99]
epoch: 6, batch: 400, loss is: [0.0622647], acc is [0.97]
epoch: 7, batch: 0, loss is: [0.08371624], acc is [0.97]
epoch: 7, batch: 100, loss is: [0.06258294], acc is [0.98]
epoch: 7, batch: 200, loss is: [0.06287701], acc is [0.98]
epoch: 7, batch: 300, loss is: [0.06453476], acc is [0.96]
epoch: 7, batch: 400, loss is: [0.03902465], acc is [0.99]
epoch: 8, batch: 0, loss is: [0.05147566], acc is [0.98]
epoch: 8, batch: 100, loss is: [0.04927587], acc is [0.98]
epoch: 8, batch: 200, loss is: [0.0236886], acc is [0.99]
epoch: 8, batch: 300, loss is: [0.0297161], acc is [0.99]
epoch: 8, batch: 400, loss is: [0.08082058], acc is [0.96]
epoch: 9, batch: 0, loss is: [0.02380851], acc is [0.99]
epoch: 9, batch: 100, loss is: [0.05191356], acc is [0.99]
epoch: 9, batch: 200, loss is: [0.01072502], acc is [1.]
epoch: 9, batch: 300, loss is: [0.06972237], acc is [0.97]
epoch: 9, batch: 400, loss is: [0.0922313], acc is [0.98]

步骤3:启动 visualdl

visualdl的操作文档AI Studio-帮助文档 (baidu.com)

VisualDL2.0使用步骤

#引入VisualDL库,并设定保存作图数据的文件位置
from visualdl import LogWriter
log_writer = LogWriter(logdir="./log")

可以看到文件浏览器——log文件夹下面生成.log文件(另一个是昨天生成的), 在数据模型可视化中添加log文件并启动VisualDL服务。则模型的acc和loss就在可视化界面展示出来。除了左侧对数据点的作图外,右侧还有一个控制板,可以调整诸多作图的细节。

九、模型加载及恢复训练

在快速入门中,我们已经介绍了将训练好的模型保存到磁盘文件的方法。应用程序可以随时加载模型,完成预测任务。但是在日常训练工作中我们会遇到一些突发情况,导致训练过程主动或被动的中断。如果训练一个模型需要花费几天的训练时间,中断后从初始状态重新训练是不可接受的。

万幸的是,飞桨支持从上一次保存状态开始训练,只要我们随时保存训练过程中的模型状态,就不用从初始状态重新训练。

1. 模型训练

下面介绍恢复训练的实现方法,依然使用手写数字识别的案例,网络定义的部分保持不变。

import os
import random
import paddle
import paddle.fluid as fluid
from paddle.fluid.dygraph.nn import Conv2D, Pool2D, Linear
import numpy as np
from PIL import Image

import gzip
import json

# 定义数据集读取器
def load_data(mode='train'):

    # 数据文件
    datafile = './work/mnist.json.gz'
    print('loading mnist dataset from {} ......'.format(datafile))
    data = json.load(gzip.open(datafile))
    train_set, val_set, eval_set = data

    # 数据集相关参数,图片高度IMG_ROWS, 图片宽度IMG_COLS
    IMG_ROWS = 28
    IMG_COLS = 28

    if mode == 'train':
        imgs = train_set[0]
        labels = train_set[1]
    elif mode == 'valid':
        imgs = val_set[0]
        labels = val_set[1]
    elif mode == 'eval':
        imgs = eval_set[0]
        labels = eval_set[1]

    imgs_length = len(imgs)

    assert len(imgs) == len(labels), \
          "length of train_imgs({}) should be the same as train_labels({})".format(
                  len(imgs), len(labels))
                  
    index_list = list(range(imgs_length))

    # 读入数据时用到的batchsize
    BATCHSIZE = 100

    # 定义数据生成器
    def data_generator():
        #if mode == 'train':
        #    random.shuffle(index_list)
        imgs_list = []
        labels_list = []
        for i in index_list:
            img = np.reshape(imgs[i], [1, IMG_ROWS, IMG_COLS]).astype('float32')
            label = np.reshape(labels[i], [1]).astype('int64')
            imgs_list.append(img) 
            labels_list.append(label)
            if len(imgs_list) == BATCHSIZE:
                yield np.array(imgs_list), np.array(labels_list)
                imgs_list = []
                labels_list = []

        # 如果剩余数据的数目小于BATCHSIZE,
        # 则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
        if len(imgs_list) > 0:
            yield np.array(imgs_list), np.array(labels_list)

    return data_generator

#调用加载数据的函数
train_loader = load_data('train')

# 定义模型结构
class MNIST(fluid.dygraph.Layer):
     def __init__(self, name_scope):
         super(MNIST, self).__init__(name_scope)
         name_scope = self.full_name()
         # 定义一个卷积层,使用relu激活函数
         self.conv1 = Conv2D(num_channels=1, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
         self.pool1 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义一个卷积层,使用relu激活函数
         self.conv2 = Conv2D(num_channels=20, num_filters=20, filter_size=5, stride=1, padding=2, act='relu')
         # 定义一个池化层,池化核为2,步长为2,使用最大池化方式
         self.pool2 = Pool2D(pool_size=2, pool_stride=2, pool_type='max')
         # 定义一个全连接层,输出节点数为10 
         self.fc = Linear(input_dim=980, output_dim=10, act='softmax')
    # 定义网络的前向计算过程
     def forward(self, inputs, label):
         x = self.conv1(inputs)
         x = self.pool1(x)
         x = self.conv2(x)
         x = self.pool2(x)
         x = fluid.layers.reshape(x, [x.shape[0], 980])
         x = self.fc(x)
         if label is not None:
             acc = fluid.layers.accuracy(input=x, label=label)
             return x, acc
         else:
             return x

在开始介绍使用飞桨恢复训练前,先正常训练一个模型,优化器使用Adam,使用动态变化的学习率,学习率从0.01衰减到0.001。每训练一轮后保存一次模型,之后将采用其中某一轮的模型参数进行恢复训练,验证一次性训练和中断再恢复训练的模型表现是否一致(训练loss的变化)。

注意进行恢复训练的程序不仅要保存模型参数,还要保存优化器参数。这是因为某些优化器含有一些随着训练过程变换的参数,例如Adam, Adagrad等优化器采用可变学习率的策略,随着训练进行会逐渐减少学习率。这些优化器的参数对于恢复训练至关重要。

为了演示这个特性,下面训练程序使用adam优化器,学习率以多项式曲线从0.01衰减到0.001(polynomial decay)。

lr = fluid.dygraph.PolynomialDecay(0.01, total_steps, 0.001)
  • learning_rate:初始学习率
  • decay_steps:衰减步数
  • end_learning_rate:最终学习率
  • power:多项式的幂,默认值为1.0
  • cycle:下降后是否重新上升,polynomial decay的变化曲线下图所示。

#在使用GPU机器时,可以将use_gpu变量设置成True
use_gpu = True
place = fluid.CUDAPlace(0) if use_gpu else fluid.CPUPlace()

with fluid.dygraph.guard(place):
    model = MNIST("mnist")
    model.train() 
    
    EPOCH_NUM = 5
    BATCH_SIZE = 100
    # 定义学习率,并加载优化器参数到模型中
    total_steps = (int(60000//BATCH_SIZE) + 1) * EPOCH_NUM
    lr = fluid.dygraph.PolynomialDecay(0.01, total_steps, 0.001)
    
    # 使用Adam优化器
    optimizer = fluid.optimizer.AdamOptimizer(learning_rate=lr, parameter_list=model.parameters())
    
    for epoch_id in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,变得更加简洁
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程,同时拿到模型输出值和分类准确率
            predict, acc = model(image, label)
            avg_acc = fluid.layers.mean(acc)
            
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了200批次的数据,打印下当前Loss的情况
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}, acc is {}".format(epoch_id, batch_id, avg_loss.numpy(),avg_acc.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()
            
        # 保存模型参数和优化器的参数
        fluid.save_dygraph(model.state_dict(), './checkpoint/mnist_epoch{}'.format(epoch_id))
        fluid.save_dygraph(optimizer.state_dict(), './checkpoint/mnist_epoch{}'.format(epoch_id))
epoch: 0, batch: 0, loss is: [3.465535], acc is [0.1]
epoch: 0, batch: 200, loss is: [0.3131626], acc is [0.89]
epoch: 0, batch: 400, loss is: [0.15640979], acc is [0.94]
epoch: 1, batch: 0, loss is: [0.10752581], acc is [0.97]
epoch: 1, batch: 200, loss is: [0.2429442], acc is [0.9]
epoch: 1, batch: 400, loss is: [0.0949007], acc is [0.94]
epoch: 2, batch: 0, loss is: [0.07481212], acc is [0.97]
epoch: 2, batch: 200, loss is: [0.08971945], acc is [0.97]
epoch: 2, batch: 400, loss is: [0.05422175], acc is [0.97]
epoch: 3, batch: 0, loss is: [0.09515554], acc is [0.96]
epoch: 3, batch: 200, loss is: [0.0445235], acc is [0.99]
epoch: 3, batch: 400, loss is: [0.04584641], acc is [0.98]
epoch: 4, batch: 0, loss is: [0.08084487], acc is [0.97]
epoch: 4, batch: 200, loss is: [0.05843996], acc is [0.97]
epoch: 4, batch: 400, loss is: [0.03927722], acc is [0.98]

2. 恢复训练

在上述训练代码中,我们训练了五轮(epoch)。在每轮结束时,我们均保存了模型参数和优化器相关的参数。

  • 使用model.state_dict()获取模型参数。
  • 使用optimizer.state_dict()获取优化器和学习率相关的参数。
  • 调用fluid.save_dygraph()将参数保存到本地。

比如第一轮训练保存的文件是mnist_epoch0.pdparams,mnist_epoch0.pdopt,分别存储了模型参数和优化器参数。

当加载模型时,如果模型参数文件和优化器参数文件是相同的,我们可以使用load_dygraph同时加载这两个文件,如下代码所示。

params_dict, opt_dict = fluid.load_dygraph(params_path)

如果模型参数文件和优化器参数文件的名字不同,需要调用两次load_dygraph分别获得模型参数和优化器参数。

如何判断模型是否准确的恢复训练呢?

理想的恢复训练是模型状态回到训练中断的时刻,恢复训练之后的梯度更新走向是和恢复训练前的梯度走向完全相同的。基于此,我们可以通过恢复训练后的损失变化,判断上述方法是否能准确的恢复训练。即从epoch 0结束时保存的模型参数和优化器状态恢复训练,校验其后训练的损失变化(epoch 1)是否和不中断时的训练完全一致。

说明:

恢复训练有如下两个要点:

  • 保存模型时同时保存模型参数和优化器参数。
  • 恢复参数时同时恢复模型参数和优化器参数。

下面的代码将展示恢复训练的过程,并验证恢复训练是否成功。其中,我们重新定义一个train_again()训练函数,加载模型参数并从第一个epoch开始训练,以便读者可以校验恢复训练后的损失变化。

params_path = "./checkpoint/mnist_epoch0"        
#在使用GPU机器时,可以将use_gpu变量设置成True
use_gpu = True
place = fluid.CUDAPlace(0) if use_gpu else fluid.CPUPlace()

with fluid.dygraph.guard(place):
    # 加载模型参数到模型中
    params_dict, opt_dict = fluid.load_dygraph(params_path)
    model = MNIST("mnist")
    model.load_dict(params_dict)
    
    EPOCH_NUM = 5
    BATCH_SIZE = 100
    # 定义学习率,并加载优化器参数到模型中
    total_steps = (int(60000//BATCH_SIZE) + 1) * EPOCH_NUM
    lr = fluid.dygraph.PolynomialDecay(0.01, total_steps, 0.001)
    
    # 使用Adam优化器
    optimizer = fluid.optimizer.AdamOptimizer(learning_rate=lr, parameter_list=model.parameters())
    optimizer.set_dict(opt_dict)

    for epoch_id in range(1, EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            #准备数据,变得更加简洁
            image_data, label_data = data
            image = fluid.dygraph.to_variable(image_data)
            label = fluid.dygraph.to_variable(label_data)
            
            #前向计算的过程,同时拿到模型输出值和分类准确率
            predict, acc = model(image, label)
            avg_acc = fluid.layers.mean(acc)
            
            #计算损失,取一个批次样本损失的平均值
            loss = fluid.layers.cross_entropy(predict, label)
            avg_loss = fluid.layers.mean(loss)
            
            #每训练了200批次的数据,打印下当前Loss的情况
            if batch_id % 200 == 0:
                print("epoch: {}, batch: {}, loss is: {}, acc is {}".format(epoch_id, batch_id, avg_loss.numpy(),avg_acc.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            optimizer.minimize(avg_loss)
            model.clear_gradients()
epoch: 1, batch: 0, loss is: [0.10752581], acc is [0.97]
epoch: 1, batch: 200, loss is: [0.22038987], acc is [0.89]
epoch: 1, batch: 400, loss is: [0.10603954], acc is [0.95]
epoch: 2, batch: 0, loss is: [0.09858329], acc is [0.97]
epoch: 2, batch: 200, loss is: [0.09705854], acc is [0.96]
epoch: 2, batch: 400, loss is: [0.0613156], acc is [0.97]
epoch: 3, batch: 0, loss is: [0.08250201], acc is [0.96]
epoch: 3, batch: 200, loss is: [0.04050832], acc is [0.99]
epoch: 3, batch: 400, loss is: [0.04975922], acc is [0.97]
epoch: 4, batch: 0, loss is: [0.0854433], acc is [0.97]
epoch: 4, batch: 200, loss is: [0.02577027], acc is [1.]
epoch: 4, batch: 400, loss is: [0.0199798], acc is [1.]

 从恢复训练的损失变化来看,加载模型参数继续训练的损失函数值和正常训练损失函数值是完全一致的,可见使用飞桨实现恢复训练是极其简单的。

参考资料:

一个案例带你吃透深度学习 - 飞桨AI Studio - 人工智能学习实训社区 (baidu.com)

  • 29
    点赞
  • 263
    收藏
    觉得还不错? 一键收藏
  • 12
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值