[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)


完整代码地址:Paddle_MNIST_Classification

如果对你有帮助,请 ⭐️ 一下 。

1. 概述

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

[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)_第1张图片

前提条件:已阅读完上半部分 —— [PaddlePaddle] [学习笔记] [上] 手写数字识别(Warning:: 0D Tensor cannot be used as ‘Tensor.num、全流程、softmax、问题)

1. 单GPU训练

通过 paddle.device.set_device API,可以设置在 GPU 上训练还是 CPU 上训练。语法如下:

parser.add_argument("--device", type=str, default="gpu", help="cpu or cuda")

# 设置设备类型
paddle.device.set_device (args.device)

参数 device (str):此参数确定特定的运行设备,可以是 cpugpu:x 或者是 xpu:x。其中,x 是 GPU 或 XPU 的编号。

  • devicecpu 时, 程序在 CPU 上运行
  • devicegpu:x 时,程序在GPU上运行

我们之前就一直在用单 GPU 进行训练,因此这里不再赘述。

2. 分布式训练

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

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

2.1 模型并行

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

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

  1. 模型架构过大: 完整的模型无法放入单个 GPU。如 2012 年 ImageNet 大赛的冠军模型 AlexNet 是模型并行的典型案例,由于当时 GPU 内存较小,单个 GPU 不足以承担 AlexNet,因此研究者将 AlexNet 拆分为两部分放到两个 GPU 上并行训练。
  2. 网络模型的结构设计相对独立: 当网络模型的设计结构可以并行化时,采用模型并行的方式。如在计算机视觉目标检测任务中,一些模型(如 YOLO 9000:YOLO v2)的边界框回归和类别预测是独立的,可以将独立的部分放到不同的设备节点上完成分布式训练。

2.2 数据并行

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

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

数据并行的方式与众人拾柴火焰高的道理类似,如果把训练数据比喻为砖头,把一个设备(GPU)比喻为一个人,那单 GPU 训练就是一个人在搬砖,多 GPU 训练就是多个人同时搬砖,每次搬砖的数量倍数增加,效率呈倍数提升。

值得注意的是,每个设备的模型是完全相同的,但是输入数据不同,因此每个设备的模型计算出的梯度是不同的。如果每个设备的梯度只更新当前设备的模型,就会导致下次训练时,每个模型的参数都不相同。因此我们还需要一个梯度同步机制,保证每个设备的梯度是完全相同的

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

2.2.1 PRC 通信方式(Remote Procedure Call,远程过程调用)

PRC(Remote Procedure Call,远程过程调用)通信方式通常用于 CPU 分布式训练,它有两个节点:①参数服务器 Parameter server 和②训练节点 Trainer,结构如下图所示。

[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)_第2张图片

其中:

  • parameter server 收集来自每个设备的梯度更新信息,并计算出一个全局的梯度更新。
  • Trainer 用于训练,每个 Trainer 上的程序相同,但数据不同。

当 Parameter server 收到来自 Trainer 的梯度更新请求时,统一更新模型的梯度。

2.2.2 NCCL2 通信方式(NVIDIA Collective Communications Library version 2,NVIDIA 集合通信库第 2 版)

当前飞桨的 GPU 分布式训练使用的是基于 NCCL2(NVIDIA Collective Communications Library version 2,NVIDIA 集合通信库第 2 版)的通信方式,结构如下图所示。

[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)_第3张图片

相比 PRC 通信方式,使用 NCCL 2(Collective 通信方式)进行分布式训练,不需要启动 Parameter server 进程,每个 Trainer 进程保存一份完整的模型参数,在完成梯度计算之后通过 Trainer 之间的相互通信,Reduce 梯度数据到所有节点的所有设备,然后每个节点在各自完成参数更新。

飞桨提供了便利的数据并行训练方式,用户只需要对程序进行简单修改,即可实现在多 GPU 上并行训练。接下来将讲述如何将一个单机程序通过简单的改造,变成单机多卡程序。

2.3 单机多卡训练设置

2.3.1 修改对应代码

单机多卡程序通过如下两步改动即可完成:

  1. 初始化并行环境 —— paddle.distributed.init_parallel_env()
  2. 使用 paddle.DataParallel 封装模型 —— model = paddle.DataParallel(model)

注意:由于我们的数据是通过手动构造批次的方式输入给模型的,没有针对多卡情况进行划分,因此每个卡上会基于全量数据迭代训练。可通过继承 paddle.io.Dataset 的方式准备自己的数据,再通过 DistributedBatchSampler 实现分布式批采样器加载数据的一个子集。这样,每个进程可以传递给 DataLoader 一个 DistributedBatchSampler 的实例,每个进程加载原始数据的一个子集。

def train_multi_gpu():
    # [数据并行] 初始化并行环境
    dist.init_parallel_env()
    
    # 定义模型    
    if args.model_name == "FC":
        model = MNIST_FC_Model()
    elif args.model_name == "CNN":
        model = MNIST_CNN_Model()
    else:
        raise ModelNameError("请选择正确的模型(CNN或FC)!")
    
    # [数据并行] 使用 DataParallel 对模型进行封装
    model = paddle.DataParallel(layers=model)
    
    # 声明模型状态
    model.train()
    
    # 加载数据,获取 MNIST 训练数据集
    train_dataset = MNIST_Dataset(mode="train")
    val_dataset = MNIST_Dataset(mode="valid")
    # 使用paddle.io.DataLoader 定义DataLoader对象用于加载Python生成器产生的数据,
    # DataLoader 返回的是一个批次数据迭代器,并且是异步的;
    train_loader = io.DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True, drop_last=True)
    val_loader = io.DataLoader(val_dataset, batch_size=args.batch_size, shuffle=False, drop_last=True)
    
    # 定义 SGD 优化器
    if args.optimizer == "sgd" or "SGD":
        optimizer = opt.SGD(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "momentum" or "Momentum":
        optimizer = opt.Momentum(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "adagrad" or "Adagrad":
        optimizer = opt.Adagrad(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "adam" or "Adam":
        optimizer = opt.Adam(learning_rate=args.lr, parameters=model.parameters())
    else:
        raise KeyError("Please select correct optimizer in [sgd, momentum, adagrad, adam]!")
    
    # 保存loss
    loss_list = []
    acc_list = []
    
    for epoch in range(1, args.epochs+1):
        epoch_loss = []
        
        for data in train_loader():
            imgs, labels = data
            imgs = paddle.to_tensor(imgs)
            labels = paddle.to_tensor(labels)
            
            # 前向推理
            preds = model(imgs)
            
            # 计算损失
            loss = F.cross_entropy(preds, labels)
            avg_loss = paddle.mean(loss)
            
            # 反向传播
            avg_loss.backward()
            
            # 保存每次迭代的损失
            epoch_loss.append(avg_loss.item()) # type: ignore
        
            """
            Note: 
                对于一个0-D的Tensor而言,直接使用tensor.item()就行,别用tensor.numpy()
                0-D其实就是一个list, shape为 (165, )
            print(f"epoch_loss: {np.shape(epoch_loss)}")  # epoch_loss: (254,)
            print(f"type: {type(epoch_loss)}")  # type: 
            """

            # 优化器
            optimizer.step()

            # 清空梯度
            optimizer.clear_grad()
            
        # 保存模型和优化器参数
        if epoch % 10 == 0:
            paddle.save({
                "model_state_dict": model.state_dict(),
                "optimizer_state_dict": optimizer.state_dict()
            }, path=f"{args.save_path}/model_{args.model_name}_{epoch}_{args.optimizer}.pdparams")
        
        # 保存每个epoch的loss
        current_epoch_loss = np.mean(epoch_loss)
        loss_list.append(current_epoch_loss)
        epoch_loss.clear()
        acc_epoch = evaluation(model, val_loader)
        acc_list.append(acc_epoch)
        
        print(f"Epoch: {epoch}\tLoss: {current_epoch_loss:.4f}\tacc: {acc_epoch*100:.2f}%")
        
    print(f"模型最终loss为: {loss_list[-1]:.4f}")
    print(f"模型最终accuracy为: {acc_list[-1]*100:.2f}%")
    
    # 绘制Loss-Epoch曲线图
    plot_loss_curve(loss_list)
    
    print(model)

2.3.2 启动多GPU的训练

有两种方式:

  1. 基于 launch 启动
  2. 基于 spawn 方式启动

2.3.2.1 基于 launch 方式启动

需要在命令行中设置参数变量。打开终端,运行如下命令:

[情况 1]单机单卡启动,默认使用第 0 号卡
# 单机单卡启动,默认使用第 0 号卡
python train_multi_gpu.py

[情况 2]单机多卡启动,默认使用当前可见的所有卡
# 单机多卡启动,默认使用当前可见的所有卡
python -m paddle.distributed.launch train_multi_gpu.py

Qpython -m paddle.distributed.launch train_multi_gpu.py-m 是什么意思?
A:在 Python 中,-m 是一个命令行选项,用于在运行脚本时指定模块。当你使用 python -m module_name 这样的命令时,Python 解释器会执行指定模块的代码。(具体来说,-m 选项的作用是让 Python 解释器将指定的模块作为主程序运行。它会搜索指定模块所在的目录,并从该目录开始执行模块的代码。)


[情况 3]单机多卡启动,设置当前使用的第 0 号和第 1 号卡
# [方法1]单机多卡启动,设置当前使用的第 0 号和第 1 号卡
CUDA_VISIABLE_DEVICES=0,1 python -m paddle.distributed.launch train.py
# [方法2]单机多卡启动,设置当前使用的第 0 号和第 1 号卡
python -m paddle.distributed.launch --gpus='0,1' --log_dir=log_info/multi_gpu train.py

GPU 索引加不加 '' 都可以

相关参数含义如下:

  • paddle.distributed.launch:启动分布式运行。
  • gpus:设置使用的 GPU 的序号(需要是多 GPU 卡的机器,通过命令 watch nvidia-smi 查看GPU的序号)。
  • log_dir:存放训练的日志,若不设置,每个 GPU 上的训练信息都会打印到屏幕。
    注意:目标目录若不存在则会自动创建(可以嵌套,如 python -m paddle.distributed.launch --gpu=4,5 --log_dir=log_info/multi_gpu train_multi_gpu.py
  • train.py:多 GPU 训练的程序,包含修改过的 train_multi_gpu() 函数。
  • 训练完成后,在指定的 ./mylog 文件夹下会产生 4 个日志文件,其中 worklog.0 的内容如下:
W0807 09:16:56.225878 66401 gpu_resources.cc:119] Please NOTE: device: 4, GPU Compute Capability: 7.5, Driver API Version: 10.2, Runtime API Version: 10.2
W0807 09:16:56.232036 66401 gpu_resources.cc:149] device: 4, cuDNN Version: 8.2.
Epoch: 1	Loss: 0.2436	acc: 97.43%
Epoch: 2	Loss: 0.0777	acc: 97.88%
Epoch: 3	Loss: 0.0605	acc: 98.53%
Epoch: 4	Loss: 0.0496	acc: 98.59%
Epoch: 5	Loss: 0.0429	acc: 98.60%
Epoch: 6	Loss: 0.0380	acc: 98.73%
Epoch: 7	Loss: 0.0350	acc: 98.61%
Epoch: 8	Loss: 0.0314	acc: 98.65%
Epoch: 9	Loss: 0.0294	acc: 98.66%
Epoch: 10	Loss: 0.0264	acc: 98.73%
模型最终loss为: 0.0264
模型最终accuracy为: 98.73%
MNIST_CNN_Model(
  (classifier): Sequential(
    (0): Conv2D(1, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
    (1): ReLU()
    (2): MaxPool2D(kernel_size=2, stride=2, padding=0)
    (3): Conv2D(20, 20, kernel_size=[5, 5], padding=2, data_format=NCHW)
    (4): ReLU()
    (5): MaxPool2D(kernel_size=2, stride=2, padding=0)
  )
  (head): Linear(in_features=980, out_features=10, dtype=float32)
)

2.3.2.2 2 【不推荐】基于 spawn 方式启动

launch 方式启动训练,是以文件为单位启动多进程,需要用户在启动时调用 paddle.distributed.launch,对于进程的管理要求较高;飞桨最新版本中,增加了 spawn 启动方式,可以更好地控制进程,在日志打印、训练和退出时更加友好。spawn 方式和 launch 方式仅在启动上有所区别。

spawn; 英 [spɔːn][spɔːn]

  • v. 产卵; 引发; 导致; 造成; 引起;
  • n. (鱼、蛙等的)卵;

在计算机领域,“spawn” 是一个常用的术语,表示创建一个新的进程或线程。在多进程或多线程编程中,“spawn” 操作用于启动一个新的执行单元,使其能够独立地执行代码,并与其他进程或线程并发执行。

# 启动train多进程训练,默认使用所有可见的GPU卡。
if __name__ == '__main__':
    dist.spawn(train)

# 启动train函数2个进程训练,默认使用当前可见的前2张卡。
if __name__ == '__main__':
	# nprocess用于指定要启动的训练器(trainer)的数量,也就是要在多少个进程中并行执行训练
    dist.spawn(train, nprocs=2)  # nprocess = number of processing(进程数量)

# 启动train函数2个进程训练,默认使用第4号和第5号卡。
if __name__ == '__main__':
    dist.spawn(train, nprocs=2, selelcted_gpus='4,5')

2.3.3 代码

import os
import random
import numpy as np
import matplotlib.pyplot as plt
import gzip
import json
import paddle.nn as nn
import paddle.nn.functional as F
import paddle.io as io
import paddle.optimizer as opt
import paddle.metric as metric
import argparse
import PIL.Image as Image
import paddle.distributed as dist
import paddle


# 定义数据集读取器
def load_data(mode="train", batch_size=4):
    print("Loading MNIST dataset form {}...".format(args.dataset_path))
    data = json.load(gzip.open(args.dataset_path))
    print("MNIST Dataset has been loaded!")

    # 对数据集进行划分
    train_set, val_set, test_set = data
    
    img_rows = 28
    img_cols = 28
    
    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 = test_set[0], test_set[1]
    else:
        raise Exception("mode can only be one of ['train', 'valid', 'eval']")
    
    # 校验数据
    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_lst = list(range(imgs_length))
    
    
    # 定义数据生成器
    def data_generator():
        if mode == "train":
            random.shuffle(index_lst)
        imgs_lst = []
        labels_lst = []
        
        for i in index_lst:
            # 在深度学习中,常见的数据类型是32位浮点数(float32),因为这种数据类型在数值计算中具有较好的精度和效率
            # 并且在常见的深度学习框架中也是默认的数据类型
            img = np.array(imgs[i]).astype("float32")
            label = np.array(labels[i]).astype("float32")
            
            img = np.reshape(imgs[i], newshape=[1, img_rows, img_cols]).astype("float32")  # [H, W] -> [C, H, W]
            label = np.reshape(labels[i], newshape=[1]).astype("float32")
            
            imgs_lst.append(img)
            labels_lst.append(label)
            
            if len(imgs_lst) == batch_size:
                yield np.array(imgs_lst), np.array(labels_lst)  # 返回一个迭代器
                imgs_lst = []
                labels_lst = []
                
        # 如果剩余数据的数目小于batch size,则剩余数据一起构成一个大小为len(imgs_list)的mini-batch
        if len(imgs_lst) > 0:
            yield np.array(imgs_lst), np.array(labels_lst)
            
    return data_generator


class MNIST_Dataset(io.Dataset):
    """创建一个类MnistDataset,继承paddle.io.Dataset 这个类
        MnistDataset的作用和上面load_data()函数的作用相同,均是构建一个迭代器

    Args:
        io (_type_): _description_
    """
    def __init__(self, mode="train"):
        data = json.load(gzip.open(args.dataset_path))
        
        train_set, val_set, test_set = data
    
        if mode == "train":
            self.imgs, self.labels = train_set[0], train_set[1]
        elif mode == "valid":
            self.imgs, self.labels = val_set[0], val_set[1]
        elif mode == "eval":
            self.imgs, self.labels = test_set[0], test_set[1]
        else:
            raise Exception("mode can only be one of ['train', 'valid', 'eval']")
    
        # 校验数据
        assert len(self.imgs) == len(self.labels), "length of train_imgs({}) should be the same as train_labels({})".format(len(self.imgs), len(self.labels))
        
    def __getitem__(self, idx):
        # img = np.array(self.imgs[idx]).astype('float32')
        # label = np.array(self.labels[idx]).astype('int64')
        img = np.reshape(self.imgs[idx], newshape=[1, 28, 28]).astype("float32")
        label = np.reshape(self.labels[idx], newshape=[1]).astype("int64")
        
        return img, label
    
    def __len__(self):
        return len(self.imgs)


# 全连接层神经网络实现
class MNIST_FC_Model(nn.Layer):  
    def __init__(self):  
        super(MNIST_FC_Model, self).__init__()  
          
        # 定义两层全连接隐含层,输出维度是10,当前设定隐含节点数为10,可根据任务调整  
        self.classifier = nn.Sequential(nn.Linear(in_features=784, out_features=256),
                                        nn.Sigmoid(),
                                        nn.Linear(in_features=256, out_features=64),
                                        nn.Sigmoid())

        # 定义一层全连接输出层,输出维度是1  
        self.head = nn.Linear(in_features=64, out_features=10)  
          
    def forward(self, x):  
        # x.shape: [bath size, 1, 28, 28]
        x = paddle.flatten(x, start_axis=1)  # [bath size, 784]
        x = self.classifier(x)  
        y = self.head(x)
        return y
    
    
# 多层卷积神经网络实现
class MNIST_CNN_Model(nn.Layer):
     def __init__(self):
         super(MNIST_CNN_Model, self).__init__()
         
         self.classifier = nn.Sequential(
             nn.Conv2D( in_channels=1, out_channels=20, kernel_size=5, stride=1, padding=2),
             nn.ReLU(),
             nn.MaxPool2D(kernel_size=2, stride=2),
             nn.Conv2D(in_channels=20, out_channels=20, kernel_size=5, stride=1, padding=2),
             nn.ReLU(),
             nn.MaxPool2D(kernel_size=2, stride=2))
         
         self.head = nn.Linear(in_features=980, out_features=args.num_classes)
         
         
     def forward(self, x):
         # x.shape: [10, 1, 28, 28]
         x = self.classifier(x)  # [bath size, 20, 7, 7]
         x = x.flatten(1)  # [batch size, 980]
         x = self.head(x)  # [batch size, num_classes]
         return x
    
    
def plot_loss_curve(loss_list):
    plt.figure(figsize=(10,5))
    
    freqs = [i for i in range(1, len(loss_list) + 1)]
    # 绘制训练损失变化曲线
    plt.plot(freqs, loss_list, color='#e4007f', label="Train loss")
    
    # 绘制坐标轴和图例
    plt.ylabel("loss", fontsize='large')
    plt.xlabel("epoch", fontsize='large')
    plt.legend(loc='upper right', fontsize='x-large')
    
    plt.savefig(f"train_loss_curve for {args.model_name}_{args.optimizer}.png")
    

class ModelNameError(Exception):
    pass


def evaluation(model: nn.Layer, datasets):
    model.eval()
    
    acc_list = []
    for batch_idx, data in enumerate(datasets()):
        imgs, labels = data
        imgs = paddle.to_tensor(imgs)
        labels = paddle.to_tensor(labels)
        
        pred = model(imgs)
        acc = metric.accuracy(input=pred, label=labels)
        acc_list.append(acc.item()) # type: ignore
        
    # 计算多个batch的平均准确率
    acc_val_mean = np.array(acc_list).mean()
    return acc_val_mean
    
    
def train():
    # 定义模型
    if args.model_name == "FC":
        model = MNIST_FC_Model()
    elif args.model_name == "CNN":
        model = MNIST_CNN_Model()
    else:
        raise ModelNameError("请选择正确的模型(CNN或FC)!")
        
    model.train()
    
    # 加载数据,获取 MNIST 训练数据集
    train_dataset = MNIST_Dataset(mode="train")
    val_dataset = MNIST_Dataset(mode="valid")
    # 使用paddle.io.DataLoader 定义DataLoader对象用于加载Python生成器产生的数据,
    # DataLoader 返回的是一个批次数据迭代器,并且是异步的;
    train_loader = io.DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True, drop_last=True)
    val_loader = io.DataLoader(val_dataset, batch_size=args.batch_size, shuffle=False, drop_last=True)
    
    # 定义 SGD 优化器
    if args.optimizer == "sgd" or "SGD":
        optimizer = opt.SGD(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "momentum" or "Momentum":
        optimizer = opt.Momentum(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "adagrad" or "Adagrad":
        optimizer = opt.Adagrad(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "adam" or "Adam":
        optimizer = opt.Adam(learning_rate=args.lr, parameters=model.parameters())
    else:
        raise KeyError("Please select correct optimizer in [sgd, momentum, adagrad, adam]!")
    
    # 保存loss
    loss_list = []
    acc_list = []
    
    for epoch in range(1, args.epochs+1):
        epoch_loss = []
        
        for data in train_loader():
            imgs, labels = data
            imgs = paddle.to_tensor(imgs)
            labels = paddle.to_tensor(labels)
            
            # 前向推理
            preds = model(imgs)
            
            # 计算损失
            loss = F.cross_entropy(preds, labels)
            avg_loss = paddle.mean(loss)
            
            # 反向传播
            avg_loss.backward()
            
            # 保存每次迭代的损失
            epoch_loss.append(avg_loss.item()) # type: ignore
        
            """
            Note: 
                对于一个0-D的Tensor而言,直接使用tensor.item()就行,别用tensor.numpy()
                0-D其实就是一个list, shape为 (165, )
            print(f"epoch_loss: {np.shape(epoch_loss)}")  # epoch_loss: (254,)
            print(f"type: {type(epoch_loss)}")  # type: 
            """

            # 优化器
            optimizer.step()

            # 清空梯度
            optimizer.clear_grad()
            
        # 保存模型和优化器参数
        if epoch % 10 == 0:
            paddle.save({
                "model_state_dict": model.state_dict(),
                "optimizer_state_dict": optimizer.state_dict()
            }, path=f"{args.save_path}/model_{args.model_name}_{epoch}_{args.optimizer}.pdparams")
        
        # 保存每个epoch的loss
        current_epoch_loss = np.mean(epoch_loss)
        loss_list.append(current_epoch_loss)
        epoch_loss.clear()
        acc_epoch = evaluation(model, val_loader)
        acc_list.append(acc_epoch)
        
        print(f"Epoch: {epoch}\tLoss: {current_epoch_loss:.4f}\tacc: {acc_epoch*100:.2f}%")
        
    print(f"模型最终loss为: {loss_list[-1]:.4f}")
    print(f"模型最终accuracy为: {acc_list[-1]*100:.2f}%")
    
    # 绘制Loss-Epoch曲线图
    plot_loss_curve(loss_list)
    
    print(model)
    
    
def train_multi_gpu():
    import sys
    # 设置标准输出不缓冲
    sys.stdout.reconfigure(line_buffering=True)  # type: ignore

    # [数据并行] 初始化并行环境
    dist.init_parallel_env()
    
    # 定义模型    
    if args.model_name == "FC":
        model = MNIST_FC_Model()
    elif args.model_name == "CNN":
        model = MNIST_CNN_Model()
    else:
        raise ModelNameError("请选择正确的模型(CNN或FC)!")
    
    # [数据并行] 使用 DataParallel 对模型进行封装
    model = paddle.DataParallel(layers=model)
    
    # 声明模型状态
    model.train()
    
    # 加载数据,获取 MNIST 训练数据集
    train_dataset = MNIST_Dataset(mode="train")
    val_dataset = MNIST_Dataset(mode="valid")
    # 使用paddle.io.DataLoader 定义DataLoader对象用于加载Python生成器产生的数据,
    # DataLoader 返回的是一个批次数据迭代器,并且是异步的;
    train_loader = io.DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True, drop_last=True)
    val_loader = io.DataLoader(val_dataset, batch_size=args.batch_size, shuffle=False, drop_last=True)
    
    # 定义 SGD 优化器
    if args.optimizer == "sgd" or "SGD":
        optimizer = opt.SGD(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "momentum" or "Momentum":
        optimizer = opt.Momentum(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "adagrad" or "Adagrad":
        optimizer = opt.Adagrad(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "adam" or "Adam":
        optimizer = opt.Adam(learning_rate=args.lr, parameters=model.parameters())
    else:
        raise KeyError("Please select correct optimizer in [sgd, momentum, adagrad, adam]!")
    
    # 保存loss
    loss_list = []
    acc_list = []
    
    for epoch in range(1, args.epochs+1):
        epoch_loss = []
        
        for data in train_loader():
            imgs, labels = data
            imgs = paddle.to_tensor(imgs)
            labels = paddle.to_tensor(labels)
            
            # 前向推理
            preds = model(imgs)
            
            # 计算损失
            loss = F.cross_entropy(preds, labels)
            avg_loss = paddle.mean(loss)
            
            # 反向传播
            avg_loss.backward()
            
            # 保存每次迭代的损失
            epoch_loss.append(avg_loss.item()) # type: ignore
        
            """
            Note: 
                对于一个0-D的Tensor而言,直接使用tensor.item()就行,别用tensor.numpy()
                0-D其实就是一个list, shape为 (165, )
            print(f"epoch_loss: {np.shape(epoch_loss)}")  # epoch_loss: (254,)
            print(f"type: {type(epoch_loss)}")  # type: 
            """

            # 优化器
            optimizer.step()

            # 清空梯度
            optimizer.clear_grad()
            
        # 保存模型和优化器参数
        if epoch % 10 == 0:
            paddle.save({
                "model_state_dict": model.state_dict(),
                "optimizer_state_dict": optimizer.state_dict()
            }, path=f"{args.save_path}/model_{args.model_name}_{epoch}_{args.optimizer}.pdparams")
        
        # 保存每个epoch的loss
        current_epoch_loss = np.mean(epoch_loss)
        loss_list.append(current_epoch_loss)
        epoch_loss.clear()
        acc_epoch = evaluation(model, val_loader)
        acc_list.append(acc_epoch)
        
        print(f"Epoch: {epoch}\tLoss: {current_epoch_loss:.4f}\tacc: {acc_epoch*100:.2f}%")
        
    print(f"模型最终loss为: {loss_list[-1]:.4f}")
    print(f"模型最终accuracy为: {acc_list[-1]*100:.2f}%")
    
    # 绘制Loss-Epoch曲线图
    plot_loss_curve(loss_list)
    
    print(model)
    
    
def load_one_img():
    img = Image.open(args.img_path).convert("L")  # 转为灰度图
    img = img.resize((28, 28))
    img = np.array(img).reshape(1, 1, 28, 28).astype(np.float32)
    
    # 归一化
    img = 1.0 - img / 255
    return img


def predict():
    # 读取要预测的图片
    img = load_one_img()
    img = paddle.to_tensor(img)
    
    # 定义模型
    if args.model_name == "FC":
        model = MNIST_FC_Model()
    elif args.model_name == "CNN":
        model = MNIST_CNN_Model()
    else:
        raise ModelNameError("请选择正确的模型(CNN或FC)!")
        
    # 加载模型权重
    param_state_dict = paddle.load(args.weights_path)
    model.load_dict(param_state_dict["model_state_dict"])

    # 声明模型状态
    model.eval()
    
    # 前向推理
    pred = model(img)
    """
    推理结果为: Tensor(shape=[1, 10], dtype=float32, place=Place(gpu:0), stop_gradient=False,
       [[0.00000163, 0.00267692, 0.00088234, 0.04414432, 0.00028779, 0.00000287,
         0.00000097, 0.95190734, 0.00004345, 0.00005248]])
    推理结果.shape为: [1, 10]
    推理结果.type为: 
    """
    
    # 取概率最大的位置
    max_class = paddle.argmax(pred).item()  # type: ignore
    
    # 画出这张图片并给出相关信息
    # 将图片数据转换为 PIL 图像对象
    img_data = img.numpy()[0][0] * 255  # type: ignore
    img_data = img_data.astype(np.uint8)

    # 显示图片
    plt.imshow(img_data, cmap='gray')
    plt.title(f"Predicted Image -> class: {max_class} | prob: {pred[:, max_class].item() * 100:.2f}%")
    plt.axis('off')  # 去除坐标轴
    plt.savefig("predict_res.png")
    
    print(f"预测值的数字为: {max_class}\t预测概率为: {pred[:, max_class].item() * 100:.2f}%")

    
def main(args):
    if args.mode == "train":
        if args.multi_gpu:
            print("使用多GPU训练")
            train_multi_gpu()
        else:
            print("使用单GPU训练")
            train()
    elif args.mode == "predict" or "eval":
        predict()
    else:
        raise KeyError("train or predict or eval")
    

def parse_args():
    parser = argparse.ArgumentParser()
    
    # 超参数
    parser.add_argument("--epochs", type=int, default=10, help="Number of training epochs")
    parser.add_argument("--lr", type=float, default=0.09, help="Learning rate")
    parser.add_argument("--batch_size", type=int, default=100, help="Batch size")
    parser.add_argument("--dataset_path", type=str, default="/data/data_01/lijiandong/Datasets/MNIST/mnist.json.gz", help="Path to the dataset file")
    parser.add_argument("--save_path", type=str, default="results/", help="The path of saving model & params")
    parser.add_argument("--device", type=str, default="gpu", help="cpu or cuda")
    parser.add_argument("--num_classes", type=int, default=10, help="Number of classes")
    parser.add_argument("--model_name", type=str, default="CNN", help="The name of saving model (CNN or FC)")
    parser.add_argument("--img_path", type=str, default="test.png", help="The path of the image predicted")
    parser.add_argument("--weights_path", type=str, default="results/model_CNN_10.pdparams", help="The path of the model's weights")
    parser.add_argument("--mode", type=str, default="train", help="train / predict")
    parser.add_argument("--optimizer", type=str, default="sgd", help="sgd, momentum, adagrad, adam")
    
    # 如果命令行中包含 --multi_gpu 参数,则 args.multi_gpu 将为True。如果没有包含 --multi_gpu 参数,则 args.multi_gpu 将为False。
    parser.add_argument("--multi_gpu", action="store_true", help="multi GPU to speed up training")
    
    # 解析命令行参数  
    args = parser.parse_args()
    
    return args


if __name__ == "__main__":
    # 固定随机种子
    seed = 10010
    paddle.seed(seed)
    np.random.seed(seed)
    random.seed(seed)
    
    args = parse_args()
    
    # 设置使用CPU还是GPU训练
    paddle.set_device(args.device)
    
    if not os.path.exists(args.save_path):
        os.mkdir(args.save_path)
    
    main(args)

3. 训练调试与优化

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

[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)_第4张图片

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

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

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

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

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

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

    理想的模型训练结果是在训练集和验证集上均有较高的准确率。

    • 如果训练集的准确率低于验证集,说明网络训练程度不够;
    • 如果训练集的准确率高于验证集,可能是发生了过拟合现象 ——(通过在优化目标中加入正则化项的办法,解决过拟合的问题)
  4. 加入正则化项,避免模型过拟合

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

  5. 可视化分析

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

3.1 计算模型的分类准确率

准确率是一个直观衡量分类模型效果的指标,由于这个指标是离散的,因此不适合作为损失来优化。通常情况下,交叉熵损失越小的模型,分类的准确率也越高。基于分类准确率,我们可以公平地比较两种损失函数的优劣。

使用飞桨提供的计算分类准确率 API,可以直接计算准确率。

class paddle.metric.Accuracy

该 API 的输入参数 input= 为预测的分类结果 predict,输入参数 label= 为数据真实的 label。飞桨还提供了更多衡量模型效果的计算指标,详细可以查看 paddle.meric 包下面的 API。

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

def train():
    # 定义模型
    if args.model_name == "FC":
        model = MNIST_FC_Model()
    elif args.model_name == "CNN":
        model = MNIST_CNN_Model()
    else:
        raise ModelNameError("请选择正确的模型(CNN或FC)!")
        
    model.train()
    
    # 加载数据,获取 MNIST 训练数据集
    train_dataset = MNIST_Dataset(mode="train")
    val_dataset = MNIST_Dataset(mode="valid")
    # 使用paddle.io.DataLoader 定义DataLoader对象用于加载Python生成器产生的数据,
    # DataLoader 返回的是一个批次数据迭代器,并且是异步的;
    train_loader = io.DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True, drop_last=True)
    val_loader = io.DataLoader(val_dataset, batch_size=args.batch_size, shuffle=False, drop_last=True)
    
    # 定义 SGD 优化器
    if args.optimizer == "sgd" or "SGD":
        optimizer = opt.SGD(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "momentum" or "Momentum":
        optimizer = opt.Momentum(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "adagrad" or "Adagrad":
        optimizer = opt.Adagrad(learning_rate=args.lr, parameters=model.parameters())
    elif args.optimizer == "adam" or "Adam":
        optimizer = opt.Adam(learning_rate=args.lr, parameters=model.parameters())
    else:
        raise KeyError("Please select correct optimizer in [sgd, momentum, adagrad, adam]!")
    
    # 保存loss
    loss_list = []
    acc_list = []
    
    for epoch in range(1, args.epochs+1):
        epoch_loss = []
        epoch_acc = []
        
        for data in train_loader():
            imgs, labels = data
            imgs = paddle.to_tensor(imgs)
            labels = paddle.to_tensor(labels)
            
            # 前向推理
            preds, acc = model(imgs, labels)
            
            # 计算损失
            loss = F.cross_entropy(preds, labels)
            avg_loss = paddle.mean(loss)
            
            # 反向传播
            avg_loss.backward()
            
            # 保存每次迭代的损失
            epoch_loss.append(avg_loss.item()) # type: ignore
            epoch_acc.append(paddle.mean(acc).item())  # type: ignore
        
            """
            Note: 
                对于一个0-D的Tensor而言,直接使用tensor.item()就行,别用tensor.numpy()
                0-D其实就是一个list, shape为 (165, )
            print(f"epoch_loss: {np.shape(epoch_loss)}")  # epoch_loss: (254,)
            print(f"type: {type(epoch_loss)}")  # type: 
            """

            # 优化器
            optimizer.step()

            # 清空梯度
            optimizer.clear_grad()
            
        # 保存模型和优化器参数
        if epoch % 10 == 0:
            paddle.save({
                "model_state_dict": model.state_dict(),
                "optimizer_state_dict": optimizer.state_dict()
            }, path=f"{args.save_path}/model_{args.model_name}_{epoch}_{args.optimizer}.pdparams")
        
        # 保存每个epoch的loss
        current_epoch_loss = np.mean(epoch_loss)
        current_epoch_acc = np.mean(epoch_acc)
        loss_list.append(current_epoch_loss)
        acc_list.append(current_epoch_acc)
        epoch_loss.clear()
        epoch_acc.clear()
        # acc_epoch = evaluation(model, val_loader)
        
        print(f"Epoch: {epoch}\tLoss: {current_epoch_loss:.4f}\tacc: {current_epoch_acc*100:.2f}%")
        
    print(f"模型最终loss为: {loss_list[-1]:.4f}")
    print(f"模型最终accuracy为: {acc_list[-1]*100:.2f}%")
    
    # 绘制Loss-Epoch曲线图
    plot_loss_curve(loss_list)
    
    print(model)

其实我们之前的代码中就已经有了准确率计算的代码,而且那种方式其实更加通用,上面代码中求准确率的方式的确少见。

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

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

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

class MNIST_CNN_Model(nn.Layer):
    def __init__(self):
        super(MNIST_CNN_Model, self).__init__()
        
        self.check_shape = args.check_shape
        self.check_content = args.check_content
        
        self.classifier = nn.Sequential(
            nn.Conv2D( in_channels=1, out_channels=20, kernel_size=5, stride=1, padding=2),  # 0
            nn.ReLU(),  # 1
            nn.MaxPool2D(kernel_size=2, stride=2),  # 2
            nn.Conv2D(in_channels=20, out_channels=20, kernel_size=5, stride=1, padding=2),  # 3
            nn.ReLU(),  # 4
            nn.MaxPool2D(kernel_size=2, stride=2))  # 5
        
        self.head = nn.Linear(in_features=980, out_features=args.num_classes)
         

    # 加入对每一层输入和输出的尺寸和数据内容的打印,根据 check 参数决策是否打印每层的参数和输出尺寸
    def forward(self, x):
        # 选择是否打印神经网络每层的参数尺寸和输出尺寸,验证网络结构是否设置正确
        if self.check_shape:
            # 打印每层网络设置的超参数-卷积核尺寸,卷积步长,卷积padding,池化核尺寸
            print(f"\n\t\tPrint Network Layer Hyper-parameters\t\t")
            print(f"[conv1]\tkernel_size: {self.classifier[0].weight.shape}\tpadding: {self.classifier[0]._padding}\tstride: {self.classifier[0]._stride}")
            print(f"[conv2]\t\tkernel_size: {self.classifier[3].weight.shape}\tpadding: {self.classifier[3]._padding}\tstride: {self.classifier[3]._stride}")
            # print(f"[maxpool1]\tkernel_size: {self.classifier[2]._kernel_size}\tpadding: {self.classifier[2]._padding}\tstride: {self.classifier[2]._stride}")
            # print(f"[maxpool2]\tkernel_size: {self.classifier[5]._kernel_size}\tpadding: {self.classifier[5]._padding}\tstride: {self.classifier[5]._stride}")
            print(f"[fc]\tweight_size: {self.head.weight.shape}\t\tbias_size: {self.head.bias.shape}")  # type: ignore

            # 打印每层的输出尺寸
            print(f"\n\t\tPrint shape of features of every layer\t\t")
            print(f"[input]\t{x.shape}")
            
            layer_name = ["conv1", "relu1", "maxpool1", "conv2", "relu2", "maxpool2"]
            for idx, layer in enumerate(self.classifier):  # type: ignore
                x = layer(x)
                print(f"[{layer_name[idx]}]\t{x.shape}")
            
            x = x.flatten(1)  # [batch size, 980]
            print(f"[flatten]\t{x.shape}")
            x = self.head(x)  # [batch size, num_classes]
            print(f"[linear]\t{x.shape}")
            
            # 选择是否打印训练过程中的参数和输出内容,可用于训练过程中的调试
            if self.check_content:
                print(f"\n\t\tprint convolution layer's kernel\t\t")
                print("conv1 params -- kernel weights:", self.classifier[0].weight[0][0])
                print("conv2 params -- kernel weights:", self.classifier[3].weight[0][0])
            return x
        
        else:
            # x.shape: [10, 1, 28, 28]
            x = self.classifier(x)  # [bath size, 20, 7, 7]
            x = x.flatten(1)  # [batch size, 980]
            x = self.head(x)  # [batch size, num_classes]
            
            return x

结果:


                Print Network Layer Hyper-parameters
[conv1] kernel_size: [20, 1, 5, 5]      padding: 2      stride: [1, 1]
[conv2]         kernel_size: [20, 20, 5, 5]     padding: 2      stride: [1, 1]
[fc]    weight_size: [980, 10]          bias_size: [10]

                Print shape of features of every layer
[input] [1, 1, 28, 28]
[conv1] [1, 20, 28, 28]
[relu1] [1, 20, 28, 28]
[maxpool1]      [1, 20, 14, 14]
[conv2] [1, 20, 14, 14]
[relu2] [1, 20, 14, 14]
[maxpool2]      [1, 20, 7, 7]
[flatten]       [1, 980]
[linear]        [1, 10]

                print convolution layer's kernel
conv1 params -- kernel weights: Tensor(shape=[5, 5], dtype=float32, place=Place(cpu), stop_gradient=False,
       [[-0.36611092, -0.07269676,  0.05521000,  0.30789426,  0.12602787],
        [-0.02627250,  0.35711884, -0.23137151, -0.47127703,  0.03564633],
        [-0.20968747, -0.02592727, -0.31650761, -0.08275275,  0.00647940],
        [-0.24060467,  0.18498476, -0.12385617, -0.15215135,  0.28592584],
        [-0.45725125, -0.03480617, -0.00470086,  0.02213454,  0.06672639]])
conv2 params -- kernel weights: Tensor(shape=[5, 5], dtype=float32, place=Place(cpu), stop_gradient=False,
       [[-0.06738405, -0.03293604, -0.04544191, -0.10670338,  0.00311936],
        [ 0.04448142, -0.05615885,  0.05934162, -0.00395017, -0.10877492],
        [ 0.02868257, -0.09811527, -0.02144969, -0.00076835,  0.03616228],
        [-0.00000985,  0.02636371,  0.01030391,  0.05360880,  0.01511308],
        [ 0.12053825, -0.01041320, -0.10291208,  0.00881728, -0.03701136]])
预测值的数字为: 7       预测概率为: 715.55%

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

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

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

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

3.4.1 过拟合与欠拟合现象

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

[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)_第5张图片

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

3.4.2 导致过拟合原因

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

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

[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)_第6张图片

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

[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)_第7张图片

3.4.3 过拟合的成因与防控

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

[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)_第8张图片

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

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

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

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

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

  • 情况1:训练数据存在噪音(证据中存在一定的错误),导致模型学到了噪音,而不是真实规律。

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

解决方案

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

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

3.4.4 正则化项(Regularization Terms)

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

注意这里的条件:在训练机器学习模型时,当面临数据量有限且无法扩充的情况下

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

这些参数通常表示为权重(weights)和偏差(biases)等

Q:一般来说,正则化防止过拟合时,正则化作用到的参数有哪些?
A:正则化在防止过拟合时通常会影响模型中的权重(weights)参数,但不会影响偏差(biases)参数。具体来说,有两种常见的正则化方法:L1 正则化和 L2 正则化,它们分别对权重参数进行惩罚。以下是它们的作用方式:

  1. L1 正则化(Lasso 正则化):

    • L1 正则化通过在损失函数中添加权重绝对值的和,惩罚较大的权重值,从而促使一些权重变为零
    • 这对于特征选择很有用,因为它倾向于使一些特征对模型的影响减弱或消除,从而降低模型的复杂度。
    • 偏差参数不受 L1 正则化的影响。
  2. L2 正则化(Ridge 正则化):

    • L2 正则化通过在损失函数中添加权重平方的和,惩罚较大的权重值,但不会使权重变为零
    • 它在模型中推动权重值趋于较小的值,从而降低模型的复杂度,并避免过度拟合。
    • L2 正则化不会使权重变为零,因此会保留所有的特征
    • 偏差参数不受 L2 正则化的影响。

总之,正则化的作用通常限于模型中的权重参数,而不会影响偏差参数。选择使用哪种正则化方法取决于具体的问题和数据。

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

# 定义 SGD 优化器
if args.optimizer == "sgd" or "SGD":
    optimizer = opt.SGD(learning_rate=args.lr, parameters=model.parameters(), 
                        weight_decay=paddle.regularizer.L2Decay(coeff=1e-5))
elif args.optimizer == "momentum" or "Momentum":
    optimizer = opt.Momentum(learning_rate=args.lr, parameters=model.parameters(),
                             weight_decay=paddle.regularizer.L2Decay(coeff=1e-5))
elif args.optimizer == "adagrad" or "Adagrad":
    optimizer = opt.Adagrad(learning_rate=args.lr, parameters=model.parameters(),
                            weight_decay=paddle.regularizer.L2Decay(coeff=1e-5))
elif args.optimizer == "adam" or "Adam":
    optimizer = opt.Adam(learning_rate=args.lr, parameters=model.parameters(),
                         weight_decay=paddle.regularizer.L2Decay(coeff=1e-5))
else:
    raise KeyError("Please select correct optimizer in [sgd, momentum, adagrad, adam]!")

4. 可视化分析

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

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

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

def plot_loss_curve(loss_list):
    plt.figure(figsize=(10,5))
    
    freqs = [i for i in range(1, len(loss_list) + 1)]
    # 绘制训练损失变化曲线
    plt.plot(freqs, loss_list, color='#e4007f', label="Train loss")
    
    # 绘制坐标轴和图例
    plt.ylabel("loss", fontsize='large')
    plt.xlabel("epoch", fontsize='large')
    plt.legend(loc='upper right', fontsize='x-large')
    
    plt.savefig(f"train_loss_curve for {args.model_name}_{args.optimizer}.png")

之前我们代码中有,这里不再赘述。

4.2 使用 VisualDL 可视化分析

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

4.2.1 步骤 1:引入 VisualDL 库

定义作图数据存储位置(供第 3 步使用)。

from visualdl import LogWriter


if args.vdl:
    import datetime
    os.mkdir(args.vdl_path)
    # 获取当前时间
    current_time = datetime.now()
    # 格式化时间为年_月_日_小时_分钟_秒
    formatted_time = current_time.strftime("%Y_%m_%d_%H_%M_%S")
    
    # 创建VisualDL的writer
    args.vdl_path = f"{args.save_path}/log/{formatted_time}/"
    if not os.path.exists(args.vdl_path):
        os.mkdir(args.vdl_path)
    log_writer = LogWriter(args.vdl_path, flush_secs=10)

4.2.2 步骤 2:在训练过程中插入作图语句,过程与 Tensorboard 类似

# 给VisualDL添加数据
log_writer.add_scalar(tag="train_loss", step=epoch, value=current_epoch_loss)
log_writer.add_scalar(tag="val_accuracy", step=epoch, value=acc_epoch)

4.2.3 步骤 3:命令行启动VisualDL

visualdl --logdir xxx/xx [--port 8080]

4.2.4 步骤 4:打开浏览器,查看作图结果

查阅的网址在第三步的启动命令后会打印出来(如 http://127.0.0.1:8080/),将该网址输入浏览器地址栏刷新页面的效果如下图所示。除了右侧对数据点的作图外,左侧还有一个控制板,可以调整诸多作图的细节。

[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)_第9张图片

5. 模型加载及恢复训练

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

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

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

5.1 保存模型

def save_model(self, epoch):
    save_state_dict = {"model_state_dict": self.model.state_dict, 
                       "optimizer_state_dict": self.optimizer.state_dict}
    if args.vdl:
        paddle.save(obj=save_state_dict, path=f"{args.vdl_path}/model_{args.model_name}_{epoch}_{args.optimizer}.pdparams")
    else:
        paddle.save(obj=save_state_dict, path=f"{args.save_path}/model_{args.model_name}_{epoch}_{args.optimizer}.pdparams")

5.2 读取模型

if args.resume_path:
    resume_state_dict = paddle.load(args.resume_path)
    self.model.set_state_dict(resume_state_dict["model_state_dict"])
    self.optimizer.set_state_dict(resume_state_dict["optimizer_state_dict"])
    print("-------------Model's Params have been loaded-------------")

6. 可变学习率策略

PolynomialDecay(DeepLab v3+ 提出)的变化曲线下图所示:

[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)_第10张图片

# 使用poly学习率更改策略
lr = opt.lr.PolynomialDecay(learning_rate=args.lr, 
                            decay_steps=(int(train_dataset.__len__() // args.batch_size) + 1
                            end_lr=args.lr / 1000)
        
# 定义 SGD 优化器
if args.optimizer == "sgd" or "SGD":
    optimizer = opt.SGD(learning_rate=lr, parameters=model.parameters(), 
                        weight_decay=paddle.regularizer.L2Decay(coeff=1e-5))
elif args.optimizer == "momentum" or "Momentum":
    optimizer = opt.Momentum(learning_rate=lr, parameters=model.parameters(),
                            weight_decay=paddle.regularizer.L2Decay(coeff=1e-5))
elif args.optimizer == "adagrad" or "Adagrad":
    optimizer = opt.Adagrad(learning_rate=lr, parameters=model.parameters(),
                            weight_decay=paddle.regularizer.L2Decay(coeff=1e-5))
elif args.optimizer == "adam" or "Adam":
    optimizer = opt.Adam(learning_rate=lr, parameters=model.parameters(),
                        weight_decay=paddle.regularizer.L2Decay(coeff=1e-5))
else:
    raise KeyError("Please select correct optimizer in [sgd, momentum, adagrad, adam]!")

7. 动静转换(动态图2静态图)

7.1 动/静态图概念

在深度学习框架中,动态图(Dynamic Graph)和静态图(Static Graph)是两种不同的计算图构建和执行方式。这两种图的主要区别在于它们的计算方式和灵活性。

  1. 静态图(Static Graph)

    • 静态图是在模型定义阶段就构建好的计算图,然后在执行阶段传递数据进行计算。例如,TensorFlow 的早期版本就是基于静态图的计算模式。
    • 在静态图中,你首先定义了计算图的结构,包括网络层、操作和数据流的连接关系。然后,你将数据传递给这个定义好的图,框架会优化图结构并执行计算。
    • 静态图的优点在于可以进行高度的优化,因为框架可以在执行前对图进行静态分析,并应用一些优化技术,例如融合操作和自动微分。但构建和调试静态图可能相对复杂,尤其对于初学者而言。
  2. 动态图(Dynamic Graph)

    • 动态图是在每一次执行计算时构建的计算图。这意味着你可以在代码中使用控制流(如循环和条件语句),并在运行时构建计算图。
    • PyTorch 和 TensorFlow 2.x 中的 Eager Execution 模式都采用了动态图的计算方式。在动态图中,你可以像写普通的 Python 代码一样编写和调试深度学习模型,这使得开发过程更加直观和灵活。
    • 动态图的优点在于更容易理解和调试,同时可以处理更加复杂的计算流程。然而,由于没有静态图的优化步骤,可能会影响一些性能。

7.2 动/静态图的选择

选择动态图还是静态图取决于应用的需求和个人偏好。动态图适用于快速的原型开发、灵活的控制流和易于调试,而静态图则适用于需要高度优化的生产环境和对性能有严格要求的应用。不同的框架提供了不同的计算图模式,以满足不同场景的需求。

7.3 动静转换

动态图有诸多优点,比如易用的接口、Python 风格的编程体验、友好的调试交互机制等。在动态图模式下,代码可以按照我们编写的顺序依次执行。这种机制更符合 Python 程序员的使用习惯,可以很方便地将脑海中的想法快速地转化为实际代码,也更容易调试。

但在性能方面,由于 Python 执行开销较大,与 C++ 有一定差距,因此在工业界的许多部署场景中(如大型推荐系统、移动端)都倾向于直接使用 C++ 进行提速。相比动态图,静态图在部署方面更具有性能的优势。静态图程序在编译执行时,先搭建模型的神经网络结构,然后再对神经网络执行计算操作。预先搭建好的神经网络可以脱离 Python 依赖,在 C++ 端被重新解析执行,而且拥有整体网络结构也能进行一些网络结构的优化。

那么,有没有可能,深度学习框架实现一个新的模式,同时具备动态图高易用性与静态图高性能的特点呢?飞桨从 2.0 版本开始,新增新增支持动静转换功能,编程范式的选择更加灵活。用户依然使用动态图编写代码,只需添加一行装饰器 @paddle.jit.to_static,即可实现动态图转静态图模式运行,进行模型训练或者推理部署。在本章节中,将介绍飞桨动态图转静态图的基本用法和相关原理。

7.4 动态图转静态图训练

飞桨的动转静方式是基于源代码级别转换的 ProgramTranslator 实现,其原理是通过分析 Python 代码,将动态图代码转写为静态图代码,并在底层自动使用静态图执行器运行。其基本使用方法十分简便,只需要在要转化的函数(该函数也可以是用户自定义动态图 Layerforward 函数)前添加一个装饰器 @paddle.jit.to_static。这种转换方式使得用户可以灵活使用 Python 语法及其控制流来构建神经网络模型。下面通过一个例子说明如何使用飞桨实现动态图转静态图训练。

import paddle


# 定义手写数字识别模型
class MNIST(paddle.nn.Layer):
    def __init__(self):
        super(MNIST, self).__init__()
        
        # 定义一层全连接层,输出维度是1
        self.fc = paddle.nn.Linear(in_features=784, out_features=10)

    # 定义网络结构的前向计算过程
    @paddle.jit.to_static  # 添加装饰器,使动态图网络结构在静态图模式下运行
    def forward(self, inputs):
        outputs = self.fc(inputs)
        return outputs

上述代码构建了仅有一层全连接层的手写字符识别网络。特别注意,在 forward 函数之前加了装饰器 @paddle.jit.to_static,要求模型在静态图模式下运行。下面是模型的训练代码,由于飞桨实现动转静的功能是在内部完成的,对使用者来说,动态图的训练代码和动转静模型的训练代码是完全一致的。训练代码如下:

import paddle
import paddle.nn.functional as F


# 确保从paddle.vision.datasets.MNIST中加载的图像数据是np.ndarray类型
paddle.vision.set_image_backend('cv2')

# 图像归一化函数,将数据范围为[0, 255]的图像归一化到[-1, 1]
def norm_img(img):
    batch_size = img.shape[0]
    # 归一化图像数据
    img = img/127.5 - 1
    # 将图像形式reshape为[batch_size, 784]
    img = paddle.reshape(img, [batch_size, 784])
    
    return img

def train(model):
    model.train()
    # 加载训练集 batch_size 设为 16
    train_loader = paddle.io.DataLoader(paddle.vision.datasets.MNIST(mode='train'), 
                                        batch_size=16, 
                                        shuffle=True)
    opt = paddle.optimizer.SGD(learning_rate=0.001, parameters=model.parameters())
    EPOCH_NUM = 10
    for epoch in range(EPOCH_NUM):
        for batch_id, data in enumerate(train_loader()):
            images = norm_img(data[0]).astype('float32')
            labels = data[1].astype('int64')
            
            #前向计算的过程
            predicts = model(images)
            
            # 计算损失
            loss = F.cross_entropy(predicts, labels)
            avg_loss = paddle.mean(loss)
            
            #每训练了1000批次的数据,打印下当前Loss的情况
            if batch_id % 1000 == 0:
                print("epoch_id: {}, batch_id: {}, loss is: {}".format(epoch, batch_id, avg_loss.numpy()))
            
            #后向传播,更新参数的过程
            avg_loss.backward()
            opt.step()
            opt.clear_grad()


model = MNIST() 

train(model)

paddle.save(model.state_dict(), './mnist.pdparams')
print("==>Trained model saved in ./mnist.pdparams")
epoch_id: 0, batch_id: 0, loss is: [3.0346446]
epoch_id: 0, batch_id: 1000, loss is: [1.1114309]
epoch_id: 0, batch_id: 2000, loss is: [0.56083727]
epoch_id: 0, batch_id: 3000, loss is: [0.56929463]
epoch_id: 1, batch_id: 0, loss is: [0.64646566]
epoch_id: 1, batch_id: 1000, loss is: [0.4265188]
epoch_id: 1, batch_id: 2000, loss is: [0.2182416]
epoch_id: 1, batch_id: 3000, loss is: [0.5384557]
epoch_id: 2, batch_id: 0, loss is: [0.22628105]

我们可以观察到,动转静的训练方式与动态图训练代码是完全相同的。因此,在动转静训练的时候,开发者只需要在动态图的组网前向计算函数上添加一个装饰器即可实现动转静训练。 在模型构建和训练中,飞桨更希望借用动态图的易用性优势,实际上,在加上 @to_static 装饰器运行的时候,飞桨内部是在静态图模式下执行 OP(Operation,运算)的,但是展示给开发者的依然是动态图的使用方式。

动转静更能体现静态图的方面在于模型部署上。下面将介绍动态图转静态图的部署方式。

7.5 动态图转静态图模型保存

在【推理 & 部署】场景中,需要同时保存推理模型的结构和参数,但是动态图是即时执行即时得到结果,并不会记录模型的结构信息。动态图在保存推理模型时,需要先将动态图模型转换为静态图写法,编译得到对应的模型结构再保存,而飞桨框架 2.0 版本推出 paddle.jit.savepaddle.jit.load 接口,无需重新实现静态图网络结构,直接实现动态图模型转成静态图模型格式。paddle.jit.save 接口会自动调用飞桨框架 2.0 推出的动态图转静态图功能,使得用户可以做到使用动态图编程调试,自动转成静态图训练部署。

这两个接口的基本关系如下图所示:

[PaddlePaddle] [学习笔记] [下] 手写数字识别(VisualDL、可变学习率、动静转换、动态图、静态图、jit、jit.save、jit.load、paddle、保存模型、读取模型)_第11张图片

当用户使用 paddle.jit.save 保存 Layer 对象(一般是 model)时,飞桨会自动将用户编写的动态图 Layer 模型转换为静态图写法,并编译得到模型结构,同时将模型结构与参数保存。paddle.jit.save 需要适配飞桨沿用已久的推理模型与参数格式,做到前向完全兼容,因此其保存格式与 paddle.save 有所区别,具体包括三种文件:

  1. 保存模型结构的 *.pdmodel 文件;
  2. 保存推理用参数的 *.pdiparams 文件
  3. 保存兼容变量信息的 *.pdiparams.info 文件

这几个文件后缀均为 paddle.jit.save 保存时默认使用的文件后缀。

比如,如果保存上述手写字符识别的 inference 模型用于部署,可以直接用下面代码实现:

# save inference model
from paddle.static import InputSpec


# 加载训练好的模型参数
state_dict = paddle.load("./mnist.pdparams")
# 将训练好的参数读取到网络中
model.set_state_dict(state_dict)
# 设置模型为评估模式
model.eval()

# 保存inference模型
paddle.jit.save(
    layer=model,
    path="inference/mnist",
    input_spec=[InputSpec(shape=[None, 784], dtype='float32')])

print("==>Inference model saved in inference/mnist.")

其中:

  • paddle.jit.save API 将输入的网络存储为 paddle.jit.TranslatedLayer 格式的模型,载入后可用于预测推理或者 fine-tune 训练。 该接口会将输入网络转写后的模型结构 Program 和所有必要的持久参数变量存储至输入路径 path
  • path 是存储目标的前缀,存储的模型结构 Program 文件的后缀为 .pdmodel,存储的持久参数变量文件的后缀为 .pdiparams,同时这里也会将一些变量描述信息存储至文件,文件后缀为 .pdiparams.info
  • InputSpec(Input Specification,输入规范) 在 PaddlePaddle 框架中用于指定模型的输入数据的形状和数据类型。它的作用是为模型的输入数据提供信息,使得模型在构建、训练和推理时能够更好地管理和处理输入数据。

通过调用对应的 paddle.jit.load 接口,可以把存储的模型载入为 paddle.jit.TranslatedLayer格式,用于预测推理或者 fine-tune 训练。

import numpy as np
import paddle
import paddle.nn.functional as F


# 确保从paddle.vision.datasets.MNIST中加载的图像数据是np.ndarray类型
paddle.vision.set_image_backend('cv2')

# 读取mnist测试数据,获取第一个数据
mnist_test = paddle.vision.datasets.MNIST(mode='test')
test_image, label = mnist_test[0]
# 获取读取到的图像的数字标签
print("The label of readed image is : ", label)

# 将测试图像数据转换为tensor,并reshape为[1, 784]
test_image = paddle.reshape(paddle.to_tensor(test_image), [1, 784])
# 然后执行图像归一化
test_image = norm_img(test_image)
# 加载保存的模型
loaded_model = paddle.jit.load("./inference/mnist")
# 利用加载的模型执行预测
preds = loaded_model(test_image)
pred_label = paddle.argmax(preds)
# 打印预测结果
print("The predicted label is : ", pred_label.numpy())
The label of readed image is :  [7]
The predicted label is :  [7]

paddle.jit.save API 可以把输入的网络结构和参数固化到一个文件中,所以通过加载保存的模型,可以不用重新构建网络结构而直接用于预测,易于模型部署。

8. 问题

8.6 【问题6】使用分布式训练后无法在训练时 print

在开启分布式训练后,可能无法正常 print,而是训练完毕后一次性 print,这样就很烦。

import sys
# 设置标准输出不缓冲
sys.stdout.reconfigure(line_buffering=True)  # type: ignore

这样应该就可以正常 print 了。

知识来源

  1. https://www.paddlepaddle.org.cn/tutorials/projectdetail/4225741
  2. https://www.paddlepaddle.org.cn/tutorials/projectdetail/3445243

你可能感兴趣的:(学习笔记(Learning,Notes),PaddlePaddle,paddlepaddle,学习,笔记)