PyTorch总结

PyTorch 简介与安装

PyTorch 简介

PyTorch 简介

2017 年 1 月, FAIR (Facebook AI Research) 发布 PyTorch,它是在 Torch 基础上用 Python 语言重新打造的一款深度学习框架。Torch 是采用 Lua 语言为接口的机器学习框架,但因 Lua 语言较为小众,导致 Torch 知名度不高。

PyTorch 发展

  • 2017 年 1 月正式发布 PyTorch。
  • 2018 年 4 月更新 0.4.0 版,支持 Windows 系统,caffe2 正式并入 PyTorch。
  • 2018 年 11 月更新 1.0 稳定版,已成为 GitHub 上增长第二快的开源项目。
  • 2019 年 5 月更新 1.1.0 版,支持 TensorBoard,增强可视化功能。
  • 2019 年 8 月更新 1.2.0 版,更新 torchvision,torchaudio 和 torchtext,增加更多功能。

2014 年 10 月至 2018 年 2 月 arXiv 论文中深度学习框架提及次数统计:

image-20230112213757479

PyTorch 的增长速度与 TensorFlow 一致。

2019 年 3 月各深度学习框架在 GitHub 上的 Stars、Forks、Watchers 和 Contributors 数量对比:

image-20230112213925864

PyTorch 优点

  • 上手快:掌握 Numpy 和基本深度学习概念即可上手。
  • 代码简洁灵活:用 nn.module 封装使网络搭建更方便;基于动态图机制,更灵活。
  • Debug 方便:调试 PyTorch 就像调试 Python 代码一样简单。
  • 文档规范https://pytorch.org/docs/ 可查各版本文档。
  • 资源多:arXiv 中的新算法大多有 PyTorch 实现。
  • 开发者多:GitHub 上贡献者 (Contributors) 已超过 1100+。
  • 背靠大树:FaceBook 维护开发。
  • ……

适合人群

  • 深度学习初学者:模型算法实现容易,加深深度学习概念认识。
  • 机器学习爱好者:数十行代码便可实现人脸识别、目标检测、图像生成等有趣实验。
  • 算法研究员:最新 arXiv 论文算法快速复现。

软件安装

软件安装

  • Python 包管理器:Anaconda。
  • Python 集成开发环境:PyCharm。
  • 深度学习框架:PyTorch。

释器与工具包

img

  • 工具包
    • 工具包 又称为 依赖包模块
    • Python 之所以强大是因为拥有大量工具包。
    • 内置包ossysglobremath 等。
    • 第三方包pytorchtensorflownumpy 等。

虚拟环境

image-20230112214044790

Anaconda 安装

Anaconda 是为方便使用 Python 而建立的一个软件包,其包含常用的 250 多个 工具包,多版本 Python 解释器 和强大的 虚拟环境管理 工具,所以 Anaconda 也被称为 Python 全家桶。

Anaconda 可以使安装、运行和升级环境变得更简单,因此推荐安装使用。

安装步骤

  1. 官网下载安装包:https://www.anaconda.com/distribution/#download-section
  2. 运行 Anaconda3-2019.07-Windows-x86_64.exe
  3. 选择路径,勾选 Add Anaconda to the system PATH environment variable,等待安装完成
  4. 验证安装成功,打开终端,输入 conda,回车
  5. 添加中科大镜像
设置 conda 国内镜像源(加速)

打开终端,复制以下命令,回车:

# 查看 conda 配置
conda config --show

# 添加 conda 中科大镜像源
conda config --add channels https://mirrors.ustc.edu.cn/anaconda/pkgs/main/
conda config --add channels https://mirrors.ustc.edu.cn/anaconda/pkgs/free/
conda config --add channels https://mirrors.ustc.edu.cn/anaconda/cloud/conda-forge/
conda config --add channels https://mirrors.ustc.edu.cn/anaconda/cloud/msys2/
conda config --add channels https://mirrors.ustc.edu.cn/anaconda/cloud/bioconda/
conda config --add channels https://mirrors.ustc.edu.cn/anaconda/cloud/menpo/

# 添加 conda 清华镜像源
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/main/
conda config --add channels https://mirrors.tuna.tsinghua.edu.cn/anaconda/pkgs/free/

# 设置搜索时显示通道地址
conda config --set show_channel_urls yes

# 重置 conda channels
conda config --remove-key channels
创建 conda 虚拟环境
# 查看 conda 虚拟环境
conda env list

# 创建 conda 虚拟环境, 由于网络原因可能会创建失败,可以通过多次尝试或者切换镜像源解决
# 环境名为 "pytorch_1.5_cpu", 解释器为 Python 3.6
conda create -n pytorch_1.5_cpu python=3.6

# 启动 conda 虚拟环境
conda activate pytorch_1.5_cpu

# 退出 conda 虚拟环境, 如果是在一个虚拟环境下启动另一个虚拟环境,需要执行两次退出回到 base 环境
conda deactivate

# 删除 conda 虚拟环境
conda env remove -n pytorch_1.5_cpu

Pycharm 安装

Pycharm 是由捷克公司 JetBrains 开发的一款强大的 Python IDE,拥有 调试语法高亮、Project 管理、代码跳转智能提示、版本控制等功能。

安装步骤

  1. 官网下载安装包:https://www.jetbrains.com/pycharm/
  2. 运行 pycharm-professional-2019.2.exe
  3. 选择路径,勾选 Add launchers dir to the PATH,等待安装完成

PyTorch 安装

安装步骤

  1. 检查是否有合适 GPU,若有,需安装 CUDACuDNN

  2. CUDACuDNN 安装(非必须)

  3. 下载 whl 文件,登陆 https://download.pytorch.org/whl/torch_stable.html

    命名解释:

    img

    下载 pytorch 与 torchvision 的 whl 文件,进入相应虚拟环境,通过 pip 安装

  4. 在 pycharm 中创建 hello pytorch 项目,运行脚本, 查看 pytorch 版本

    import torch 
    print("hello pytorch {}".format(torch.__version__)) 
    print("cuda is avaiable: {}".format(torch.cuda.is_available()))

总结

本节课介绍了 PyTorch 及其作为深度学习框架的优势,并且对安装 Pycharm、Anaconda、Cuda、cudnn 和 PyTorch 进行了演示,环境配置好后会进行 demo 演示,测试 PyTorch 可以正常使用。

张量简介及创建

张量是什么?

张量是什么?

张量 (Tensor) 是一个多维数组 , 它是标量、向量、矩阵的高维拓展。

img

常见的灰度图像可以用一个矩阵 (2 维张量) 表示;而 RGB 彩色图像则需要用一个 3 维张量表示 (3 个维度分别表示图像的高度、宽度和 RGB 色彩通道)。

Tensor 与 Variable

Variable 是 torch.autograd 中的数据类型,主要用于封装 Tensor,进行 自动求导

  • data:被包装的 Tensor。
  • graddata 的梯度。
  • grad_fn:创建 Tensor 的 Function,是自动求导的关键。
  • requires_grad:指示是否需要梯度。
  • is_leaf:指示是否是 (计算图中的) 叶子结点(张量)。

img

PyTorch 0.4.0 版本开始,Variable 并入 Tensor。

  • dtype:张量的数据类型,如 torch.FloatTensortorch.cuda.FloatTensor
  • shape:张量的形状,如 (64, 3, 224, 224)
  • device:张量所在设备,GPU/CPU,是加速的关键。

2020-12-09-WX20201209-190443%402x

可以看到,目前的 Tensor 包含了 8 个主要属性,其中 4 个与数据相关,另外 4 个与梯度求导相关。

PyTorch 主要提供了以下 9 种数据类型:

image-20230112215330461

通常,使用最多的数据类型是 torch.float32,常用于卷积层的权重值,或者图像预处理之后都是默认为 32 位的浮点数。另外,torch.int64 也用的比较多,通常用 64 位整型来表示图像标签,在计算交叉熵的损失函数时需要注意这点。

张量的创建

直接创建

torch.tensor()

功能:从 data 创建 tensor

torch.tensor(
    data,
    dtype=None,
    device=None,
    requires_grad=False,
    pin_memory=False
)

主要参数

  • data:数据,可以是 list 或者 numpy
  • dtype:数据类型,默认与 data 的一致。
  • device:所在设备,cuda 或者 cpu
  • requires_grad:是否需要梯度。
  • pin_memory:是否存于锁页内存,与转换效率有关,通常设为 False
torch.from_numpy(ndarray)

功能:从 numpy 创建 tensor

注意事项:从 torch.from_numpy 创建的 tensor 与原 ndarray 共享内存,当修改其中一个的数据,另外一个也将会被改动。

image-20230112215342562

依据数值创建

torch.zeros()

功能:依 size 创建全 0 张量。

torch.zeros(
    *size,
    out=None,
    dtype=None,
    layout=torch.strided,
    device=None,
    requires_grad=False
)

主要参数

  • size:张量的形状,如 (3, 3)(3, 224, 224)
  • out:输出的张量,即将生成的全 0 张量赋值给 out 参数的变量。
  • layout:内存中布局形式,有 stridedsparse_coo 等。通常采用默认的 strided;如果涉及稀疏张量可能需要设置为 sparse_coo 以提升读取效率。
  • device:所在设备,gpu / cpu。
  • requires_grad:是否需要梯度。
torch.zeros_like()

功能:依 input 形状创建全 0 张量。

torch.zeros_like(
    input,
    dtype=None,
    layout=None,
    device=None,
    requires_grad=False
)

主要参数

  • intput:创建与 input 同形状的全 0 张量。
  • dtype:数据类型。
  • layout:内存中布局形式。
torch.ones()

功能:依 size 创建全 1 张量。

torch.ones(
    *size,
    out=None,
    dtype=None,
    layout=torch.strided,
    device=None,
    requires_grad=False
)

主要参数

  • size:张量的形状。
  • out:输出的张量。
  • layout:内存中布局形式。
  • device:所在设备,gpu / cpu。
  • requires_grad:是否需要梯度。
torch.ones_like()

功能:依 input 形状创建全 1 张量。

torch.ones_like(
    input,
    dtype=None,
    layout=None,
    device=None,
    requires_grad=False
)

主要参数

  • intput:创建与 input 同形状的全 1 张量。
  • dtype:数据类型。
  • layout:内存中布局形式。
torch.full()

功能:依 size 创建全部值均为 fill_value 的张量。

torch.full(
    size,
    fill_value,
    out=None,
    dtype=None,
    layout=torch.strided,
    device=None,
    requires_grad=False
)

主要参数

  • size:张量的形状,如 (3, 3)
  • fill_value:张量的值。
torch.full_like()

功能:依 input 形状创建指定数据的张量。

torch.full_like(
    input,
    fill_value,
    dtype=None,
    layout=None,
    device=None,
    requires_grad=False
)

主要参数

  • intput:创建与 input 同形状的全部值均为 fill_value 的张量。
  • fill_value:张量的值。
  • dtype:数据类型。
  • layout:内存中布局形式。
torch.arange()

功能:创建等差的 1 维张量。

torch.arange(
    start=0,
    end,
    step=1,
    out=None,
    dtype=None,
    layout=torch.strided,
    device=None,
    requires_grad=False
)

主要参数

  • start:数列起始值。
  • end:数列 “结束值”(无法取到)。
  • step:数列公差,默认为 1。

注意事项:数值区间为 [start, end)

torch.linspace()

功能:创建均分的 1 维张量。

torch.linspace(
    start,
    end,
    steps=100,
    out=None,
    dtype=None,
    layout=torch.strided,
    device=None,
    requires_grad=False
)

主要参数

  • start:数列起始值。
  • end:数列结束值。
  • steps:数列长度。

注意事项:数值区间为 [start, end]

torch.logspace()

功能:创建对数均分的 1 维张量。

torch.logspace(
    start,
    end,
    steps=100,
    base=10.0,
    out=None,
    dtype=None,
    layout=torch.strided,
    device=None,
    requires_grad=False
)

主要参数

  • start:数列起始值。
  • end:数列结束值。
  • steps:数列长度。
  • base:对数函数的底,默认为 10。

注意事项:长度为 steps,底为 base

torch.eye()

功能:创建单位对角矩阵(2 维张量)。

torch.eye(
    n,
    m=None,
    out=None,
    dtype=None,
    layout=torch.strided,
    device=None,
    requires_grad=False
)

主要参数

  • n:矩阵行数。
  • m:矩阵列数。

注意事项:默认为方阵。

依概率分布创建张量

torch.normal()

功能:生成正态分布(高斯分布)。

# mean 和 std 中至少有一个是张量,从不同的正态分布中采样得到,无需指定 size。
torch.normal(
    mean,
    std,
    out=None
)

# mean 和 std 均为标量,从相同的正态分布中采样得到,需要指定 size。
torch.normal(
    mean,
    std,
    size,
    out=None
)

主要参数

  • mean:均值。
  • std:标准差。

四种模式

  • mean标量std标量
  • mean标量std张量
  • mean张量std标量
  • mean张量std张量
torch.randn()

功能:依 size 生成 标准正态分布 张量。

torch.randn(
    *size,
    out=None,
    dtype=None,
    layout=torch.strided,
    device=None,
    requires_grad=False
)

主要参数

  • size:张量的形状。
  • out:输出的张量。
  • layout:内存中布局形式。
  • device:所在设备,gpu / cpu。
  • requires_grad:是否需要梯度。
torch.randn_like()

功能:依 input 生成 标准正态分布 张量。

torch.randn_like(
    input,
    dtype=None,
    layout=None,
    device=None,
    requires_grad=False
)

主要参数

  • intput:创建与 input 同形状的标准正态分布张量。
  • dtype:数据类型。
  • layout:内存中布局形式。
torch.rand()

功能:在区间 [0, 1) 上,依 size 生成 均匀分布 张量。

torch.rand(
    *size,
    out=None,
    dtype=None,
    layout=torch.strided,
    device=None,
    requires_grad=False
)

主要参数

  • size:张量的形状。
  • out:输出的张量。
  • layout:内存中布局形式。
  • device:所在设备,gpu / cpu。
  • requires_grad:是否需要梯度。
torch.rand_like()

功能:在区间 [0, 1) 上,依 input 生成 均匀分布 张量。

torch.rand_like(
    input,
    dtype=None,
    layout=None,
    device=None,
    requires_grad=False
)

主要参数

  • intput:创建与 input 同形状的 [0, 1) 均匀分布张量。
  • dtype:数据类型。
  • layout:内存中布局形式。
torch.randint()

功能:区间 [low, high) 上,依 size 生成 整数均匀分布 张量。

torch.randint(
    low=0,
    high,
    size,
    out=None,
    dtype=None,
    layout=torch.strided,
    device=None,
    requires_grad=False
)

主要参数

  • low:区间下界。
  • high:区间上界。
  • size:张量的形状。
  • out:输出的张量。
  • layout:内存中布局形式。
  • device:所在设备,gpu / cpu。
  • requires_grad:是否需要梯度。
torch.randint_like()

功能:在区间 [low, high) 上,依 input 生成 整数均匀分布 张量。

torch.rand_like(
    low=0,
    high,
    input,
    dtype=None,
    layout=None,
    device=None,
    requires_grad=False
)

主要参数

  • low:区间下界。
  • high:区间上界。
  • intput:创建与 input 同形状的 [low, high) 整数均匀分布张量。
  • dtype:数据类型。
  • layout:内存中布局形式。
torch.randperm()

功能:生成生成从 0 到 n-1 的随机排列张量,通常用来生成乱序的索引。

torch.randperm(
    n,
    out=None,
    dtype=torch.int64,
    layout=torch.strided,
    device=None,
    requires_grad=False
)

主要参数

  • n:张量的长度。
torch.bernoulli()

功能:以 input 为概率,生成伯努力分布(0-1 分布,两点分布)。

torch.bernoulli(
    input,
    *,
    generator=None,
    out=None
)

主要参数

  • input:概率值。

总结

本节课介绍了 PyTorch 中的数据结构 —— 张量 (Tensor),它是 PyTorch 中最基础的概念,其参与了整个运算过程。这里我们介绍了张量的概念和属性,如 datadevicedtype 等,并介绍了张量的基本创建方法,如直接创建、依数值创建和依概率分布创建等。

张量操作与线性回归

张量操作

张量拼接与切分

torch.cat()

功能:将张量按维度 dim 进行拼接。

torch.cat(
    tensors,
    dim=0,
    out=None
)

主要参数

  • tensors:张量序列。
  • dim:要拼接的维度。
torch.stack()

功能:在 新创建的维度 dim 上进行拼接。

torch.stack(
    tensors,
    dim=0,
    out=None
)

主要参数

  • tensors:张量序列。
  • dim:要拼接的维度。
torch.chunk()

功能:将张量按维度 dim 进行 平均切分

torch.chunk(
    input,
    chunks,
    dim=0
)

主要参数

  • input:要切分的张量。
  • chunks:要切分的份数。
  • dim:要切分的维度。

返回值:张量列表。

注意事项:若不能整除,最后一份张量将小于其他张量。

torch.split()

功能:将张量按维度 dim 进行切分。

torch.split(
    tensor,
    split_size_or_sections,
    dim=0
)

主要参数

  • tensor:要切分的张量。
  • split_size_or_sections:为 int 时,表示每一份的长度;为 list 时,按 list 元素切分。
  • dim:要切分的维度。

返回值:张量列表。

张量索引

torch.index_select()

功能:在维度 dim 上,按 index 索引数据。

torch.index_select(
    input,
    dim,
    index,
    out=None
)

主要参数

  • input:要索引的张量。
  • dim:要索引的维度。
  • index:要索引数据的序号,注意这里 index 中的数据类型必须是 torch.long

返回值:依 index 索引数据拼接的张量。

torch.masked_select()

功能:按 mask 中的 True 进行索引。

torch.masked_select(
    input,
    mask,
    out=None
)

主要参数

  • input:要索引的张量。
  • mask:与 input 同形状的布尔类型张量。

返回值一维 张量。

张量变换

torch.reshape()

功能:变换张量形状。

torch.reshape(
    input,
    shape
)

主要参数

  • input:要变换的张量。
  • shape:新张量的形状,当我们不需要关心某个维度时,可以将其设为 -1,它将通过对其他维度的计算自动得出。

注意事项:当张量在内存中是连续时,新张量与 input 共享数据内存。

torch.transpose()

功能:交换张量的两个维度,常用于图像的预处理。

torch.transpose(
    input,
    dim0,
    dim1
)

主要参数

  • input:要变换的张量。
  • dim0:要交换的维度。
  • dim1:要交换的维度。
torch.t()

功能:2 维张量转置,对矩阵而言,等价于 torch.transpose(input, 0, 1)

torch.t(input) 
torch.squeeze()

功能压缩 长度为 1 的维度(轴)。

torch.squeeze(
    input,
    dim=None,
    out=None
)

主要参数

  • dim:若为 None,移除所有长度为 1 的轴;若指定维度,当且仅当该轴长度为 1 时,可以被移除。
torch.unsqueeze()

功能:依据 dim 扩展 维度。

torch.usqueeze(
    input,
    dim,
    out=None
)

主要参数

  • dim:扩展的维度。

张量数学运算

加减乘除

torch.add()
torch.addcdiv()
torch.addcmul()
torch.sub()
torch.div()
torch.mul()
torch.add()

功能:逐元素计算 input + alpha × other

torch.add(
    input,
    alpha=1,
    other,
    out=None
)

主要参数

  • input:第一个张量。
  • alpha:乘项因子。
  • other:第二个张量。
torch.addcmul()

功能:逐元素计算

torch.addcmul(
    input,
    value=1,
    tensor1,
    tensor2,
    out=None
)
torch.addcdiv()

功能:逐元素计算

torch.addcdiv(
    input,
    value=1,
    tensor1,
    tensor2,
    out=None
)

对数、指数、幂函数

torch.log(input, out=None)
torch.log10(input, out=None)
torch.log2(input, out=None)
torch.exp(input, out=None)
torch.pow()

三角函数

torch.abs(input, out=None)
torch.acos(input, out=None)
torch.cosh(input, out=None)
torch.cos(input, out=None)
torch.asin(input, out=None)
torch.atan(input, out=None)
torch.atan2(input, other, out=None)

线性回归

线性回归 是分析一个变量与另外一(多)个变量之间关系的方法:

y=wx+b

其中,y 是 因变量,x 是 自变量,二者之间关系为 线性

分析:求解线性组合系数 w 和 b。

求解步骤

  1. 确定模型

  2. 选择损失函数

  3. 求解梯度并更新 w 和 b

    其中,LR 是 学习率 (learning rate)

代码示例

import torch
import matplotlib.pyplot as plt

torch.manual_seed(10)

# 学习率
lr = 0.1

# 创建训练数据
x = torch.rand(20, 1) * 10  # x data (tensor), shape=(20, 1)
y = 2*x + (5 + torch.randn(20, 1))  # y data (tensor), shape=(20, 1)

# 初始化线性回归参数
w = torch.randn((1), requires_grad=True)
b = torch.zeros((1), requires_grad=True)

for iteration in range(1000):

    # 向前传播
    wx = torch.mul(w, x)
    y_pred = torch.add(wx, b)

    # 计算 MSE loss
    loss = (0.5 * (y - y_pred) ** 2).mean()

    # 反向传播
    loss.backward()

    # 更新参数
    b.data.sub_(lr * b.grad)
    w.data.sub_(lr * w.grad)

    # 绘图
    if iteration % 20 == 0:

        plt.scatter(x.data.numpy(), y.data.numpy())
        plt.plot(x.data.numpy(), y_pred.data.numpy(), 'r-', lw=5)
        plt.text(2, 20, 'Loss=%.4f' % loss.data.numpy(), fontdict={'size': 20, 'color': 'red'})
        plt.xlim(1.5, 10)
        plt.ylim(8, 28)
        plt.title("Iteration: {}\nw: {} b: {}".format(iteration, w.data.numpy(), b.data.numpy()))
        plt.pause(0.5)

        # 当 loss < 1 时,停止迭代更新
        if loss.data.numpy() < 1:
            break

总结

本节课介绍了张量的基本操作,例如:张量的拼接、切分、索引和变换。同时,我们还学习了张量的数学运算,并基于所学习的知识,实现线性回归模型的训练,以加深知识点的认识。

计算图与动态图机制

本节课分为两部分:计算图和 PyTorch 中的动态图机制。在之前的课程中,我们学习了张量的创建和操作,而深度学习就是对张量进行一系列操作,随着操作种类和数量的增多,可能会导致各种意想不到的问题。例如:多个操作之间是并行还是顺序执行;如何协同不同底层设备;如何避免各种冗余操作等等。这些问题都会影响到我们的运算效率,甚至会引入一些不必要的 bug,计算图正是为解决这些问题而生的。

计算图

计算图 (Computational Graph) 是用来 描述运算有向无环图

计算图有两个主要元素:结点 (Node)边 (Edge)

  • 结点表示 数据,例如:向量、矩阵、张量。
  • 边表示 运算,例如:加、减、乘、除、卷积等。

例子

用计算图表示:y=(x+w)∗(w+1)

我们先将运算过程拆分为:

  • a=x+w
  • b=w+1
  • y=a∗b

搭建计算图:

image-20230112215411342

将 x=2,w=1 代入进行计算:

image-20230112215424339

采用计算图来描述运算过程的好处不只是让运算更加简洁,更重要的一点是,它使得梯度求导更加方便。接下来,我们来看一下 y 对 w 求导的过程。

计算图与梯度求导:

  • a=x+w
  • b=w+1
  • y=a∗b

image-20230112215440272

在之前课程中,我们提到 Tensor 中有一个 is_leaf 属性,它用于指示张量是否是叶子结点。

image-20230112215459525

叶子结点:用户创建的结点称为叶子结点,例如上面的 x 和 w。它是整个计算图的根基,可以看到,在前向传播中的 a、b 和 y 都需要根据叶子结点 x 和 w 进行计算。同样,在反向传播中,所有的梯度计算也都依赖于叶子结点。

为什么要设置叶子结点这一概念呢?

主要是为了节省内存,因为在反向传播结束之后,非叶子结点的梯度将被丢弃。

除了叶子结点之外,Tensor 中还有一个重要的概念就是 梯度函数 grad_fn,它记录了创建该张量时所用的方法 (函数),在反向传播时需要用到该方法以 确定对应的求导法则

例如上面的 a 和 b 都是通过加法创建的,而 y 是通过乘法创建的,所以我们有:

  • y.grad_fn = <MulBackward0>
  • a.grad_fn = <AddBackward0>
  • b.grad_fn = <AddBackward0>

Python 代码示例

import torch

# 叶子结点
w = torch.tensor([1.], requires_grad=True)
x = torch.tensor([2.], requires_grad=True)

# 非叶子结点
a = torch.add(w, x)
b = torch.add(w, 1)
y = torch.mul(a, b)

# PyTorch 中,为了节省内存,在计算完成后会丢弃非叶子结点的梯度值,即为 None;
# 如果希望保存非叶子结点梯度值,需要在反向传播之前使用 .retain_grad() 方法。
a.retain_grad()
b.retain_grad()
y.retain_grad()

# 反向传播
y.backward()
print(w.grad)

# 查看叶子结点
print("is_leaf:\n", w.is_leaf, x.is_leaf, a.is_leaf, b.is_leaf, y.is_leaf)

# 查看梯度
print("gradient:\n", w.grad, x.grad, a.grad, b.grad, y.grad)

# 查看 grad_fn
print("grad_fn:\n", w.grad_fn, x.grad_fn, a.grad_fn, b.grad_fn, y.grad_fn)

输出结果:

tensor([5.])
is_leaf:
 True True False False False
gradient:
 tensor([5.]) tensor([2.]) tensor([2.]) tensor([3.]) tensor([1.])
grad_fn:
 None None <AddBackward0 object at 0x1146d1518> <AddBackward0 object at 0x1146d1550> <MulBackward0 object at 0x1146d15c0>

动态图

根据计算图搭建方式, 可将计算图分为 动态图 (Dynamic Graphs)静态图 (Static Graphs)。PyTorch 采用的是动态图机制,而 TensorFlow 采用的是静态图机制。

动态图 vs 静态图

image-20230112215514528

总结

本节课介绍了 PyTorch 最大的特性 —— 动态图机制,动态图机制是 PyTorch 与 TensorFlow 最大的区别,我们首先介绍了计算图的概念,并通过演示动态图与静态图的搭建过程来理解动态图与静态图的差异。

autograd 与逻辑回归

本节课主要分为两部分:PyTorch 中的自动求导系统以及逻辑回归模型。我们知道,深度模型的训练就是不断地更新权值,而权值的更新需要求解梯度,因此,梯度在我们的模型训练过程中是至关重要的。然而,求解梯度通常十分繁琐,因此,PyTorch 中引入了自动求导系统帮助我们完成这一过程。在 PyTorch 中,我们无需手动计算梯度,只需要搭建好前向传播的计算图,然后根据 PyTorch 中的 autograd 方法就可以得到所有张量的梯度。

autograd:自动求导系统

torch.autograd.backward()

功能:自动求取计算图中各结点的梯度。

torch.autograd.backward(
    tensors,
    grad_tensors=None,
    retain_graph=None,
    create_graph=False
)

主要参数

  • tensors:用于求导的张量,如 loss
  • retain_graph:保存计算图,PyTorch 默认在反向传播完成后丢弃计算图,如需保存则将该项设为 True
  • create_graph:创建导数计算图,用于高阶求导。
  • grad_tensors:多梯度权重,当我们有多个 loss 需要计算梯度的时候,就需要设置各个 loss 的权重比例。

回顾一下如何通过计算图求解梯度:

  • a=x+w
  • b=w+1
  • y=a∗b

image-20230112215526166

代码示例

w = torch.tensor([1.], requires_grad=True)
x = torch.tensor([2.], requires_grad=True)

a = torch.add(w, x)
b = torch.add(w, 1)
y = torch.mul(a, b)

# 如果希望后面再次执行该计算图,可以将 retain_graph 参数设为 True
# y.backward(retain_graph=True) 

y.backward()
print(w.grad)

输出结果:

tensor([5.]) 

当有多个 loss 需要计算梯度时,通过 grad_tensors 设置各 loss 权重比例:

w = torch.tensor([1.], requires_grad=True)
x = torch.tensor([2.], requires_grad=True)

a = torch.add(w, x)
b = torch.add(w, 1)

# y0 = (x+w) * (w+1)    dy0/dw = 2*w + x + 1 = 5
y0 = torch.mul(a, b)

# y1 = (x+w) + (w+1)    dy1/dw = 2
y1 = torch.add(a, b)  

# 这种情况下,loss 是一个向量 [y0, y1]
loss = torch.cat([y0, y1], dim=0)

# 梯度的权重:dy0/dw 权重为 1,dy1/dw 权重为 2
grad_tensors = torch.tensor([1., 2.])

# gradient 传入 torch.autograd.backward() 中的 grad_tensors
loss.backward(gradient=grad_tensors)  

print(w.grad) # 5*1 + 2*2 = 9

输出结果:

tensor([9.]) 
torch.autograd.grad()

功能:求取梯度。

torch.autograd.grad(
    outputs,
    inputs,
    grad_outputs=None,
    retain_graph=None,
    create_graph=False
)

主要参数

  • outputs:用于求导的张量,如 loss
  • inputs:需要梯度的张量。
  • create_graph:创建导数计算图,用于高阶求导。
  • retain_graph:保存计算图。
  • grad_outputs:多梯度权重。

求取二阶梯度

x = torch.tensor([3.], requires_grad=True)
y = torch.pow(x, 2)  # y = x**2

# grad_1 = dy/dx = 2x = 2 * 3 = 6
grad_1 = torch.autograd.grad(y, x, create_graph=True)  
print(grad_1)

# grad_2 = d(dy/dx)/dx = d(2x)/dx = 2
grad_2 = torch.autograd.grad(grad_1[0], x)  
print(grad_2)

输出结果:

(tensor([6.], grad_fn=<MulBackward0>),)
(tensor([2.]),)

注意事项

  1. 梯度不自动清零。
  2. 依赖于叶子结点的结点,requires_grad 默认为 True
  3. 叶子结点不可执行原位操作 (in-place)。

代码示例 1

# 1. 梯度不会自动清零,重复求取会叠加,可以使用 .grad.zero_() 方法手动清零
w = torch.tensor([1.], requires_grad=True)
x = torch.tensor([2.], requires_grad=True)

for i in range(3):
    a = torch.add(w, x)
    b = torch.add(w, 1)
    y = torch.mul(a, b)

    y.backward()
    print(w.grad)

# 梯度清零,下划线表示原位操作 (in-place)
w.grad.zero_()

for i in range(3):
    a = torch.add(w, x)
    b = torch.add(w, 1)
    y = torch.mul(a, b)

    y.backward()
    print(w.grad)
    w.grad.zero_()

输出结果:

tensor([5.])
tensor([10.])
tensor([15.])
tensor([5.])
tensor([5.])
tensor([5.])

代码示例 2

# 2. 依赖于叶子结点的结点, requires_grad 默认为 True
w = torch.tensor([1.], requires_grad=True)
x = torch.tensor([2.], requires_grad=True)

a = torch.add(w, x)
b = torch.add(w, 1)
y = torch.mul(a, b)

print(a.requires_grad, b.requires_grad, y.requires_grad)

输出结果:

True True True

代码示例 3

# 3. 叶子结点不可执行 in-place (原位操作)。因为 PyTorch 计算图中引用叶子结点的值是
#    直接引用其前向传播时的地址,为了防止计算出错,叶子结点不可执行 in-place 操作。

#    in-place (原位操作): 从原始内存地址中直接改变数据。
#    非 in-place 操作: 开辟一块新的内存地址存储改变后的数据。

a = torch.ones((1, ))
print(id(a), a)

# 非 in-place 操作
a = a + torch.ones((1, ))
print(id(a), a)

# in-place 操作
a += torch.ones((1, ))
print(id(a), a)

输出结果:

4875211904 tensor([1.])
4875212336 tensor([2.])
4875212336 tensor([3.])

对叶子结点执行 in-place 操作将导致报错

w = torch.tensor([1.], requires_grad=True)
x = torch.tensor([2.], requires_grad=True)

a = torch.add(w, x)
b = torch.add(w, 1)
y = torch.mul(a, b)

# 对非叶子结点 a 执行非 in-place 操作
print(a.add(1))

# 对非叶子结点 a 执行 in-place 操作
print(a.add_(1))

# 对叶子结点 w 执行非 in-place 操作
print(w.add(1))

# 对叶子结点 w 执行 in-place 操作,会报错
print(w.add_(1))

y.backward()

输出结果:

tensor([4.], grad_fn=<AddBackward0>)
tensor([4.], grad_fn=<AddBackward0>)
tensor([2.], grad_fn=<AddBackward0>)
Traceback (most recent call last):
  File "<input>", line 1, in <module>
  File "/Applications/PyCharm.app/Contents/plugins/python/helpers/pydev/_pydev_bundle/pydev_umd.py", line 197, in runfile
    pydev_imports.execfile(filename, global_vars, local_vars)  # execute the script
  File "/Applications/PyCharm.app/Contents/plugins/python/helpers/pydev/_pydev_imps/_pydev_execfile.py", line 18, in execfile
    exec(compile(contents+"\n", file, 'exec'), glob, loc)
  File "/Users/andy/PycharmProjects/hello_pytorch/lesson/lesson-05/lesson-05-autograd.py", line 145, in <module>
    print(w.add_(1))
RuntimeError: a leaf Variable that requires grad is being used in an in-place operation.

逻辑回归

逻辑回归 (Logistic Regression)线性二分类 模型。

模型表达式:

即:

这里,我们将 f(x) 称为 Sigmoid 函数,又称 Logistic 函数:

image-20230112215542963

线性回归是分析 自变量 x因变量 y (标量) 之间关系的方法;而逻辑回归是分析 自变量 x因变量 y (概率) 之间关系的方法。

image-20230112221338984

机器学习训练的 5 个步骤

image-20230112215727045

  1. 数据:数据收集、清洗、划分、预处理。
  2. 模型:根据任务的难易程度,选择简单的线性模型或者复杂的神经网络模型等等。
  3. 损失函数:根据不同任务选择不同的损失函数并计算其梯度。例如:在线性回归中,我们可以选择均方误差损失函数;在分类任务中,我们可以选择交叉熵损失函数。
  4. 优化器:得到梯度之后,我们选择某种优化器来更新权值。
  5. 迭代训练:有了数据、模型、损失函数和优化器之后,我们就可以进行迭代训练了。

代码示例

import torch
import torch.nn as nn
import matplotlib.pyplot as plt
import numpy as np
torch.manual_seed(10)

# ============================== Step 1/5: 生成数据 ===================================
sample_nums = 100
mean_value = 1.7
bias = 1
n_data = torch.ones(sample_nums, 2)
x0 = torch.normal(mean_value * n_data, 1) + bias    # 类别0 数据 shape=(100, 2)
y0 = torch.zeros(sample_nums)                       # 类别0 标签 shape=(100, 1)
x1 = torch.normal(-mean_value * n_data, 1) + bias   # 类别1 数据 shape=(100, 2)
y1 = torch.ones(sample_nums)                        # 类别1 标签 shape=(100, 1)
train_x = torch.cat((x0, x1), 0)
train_y = torch.cat((y0, y1), 0)


# ============================== Step 2/5: 选择模型 ===================================
class LR(nn.Module):
    def __init__(self):
        super(LR, self).__init__()
        self.features = nn.Linear(2, 1)
        self.sigmoid = nn.Sigmoid()

    def forward(self, x):
        x = self.features(x)
        x = self.sigmoid(x)
        return x


lr_net = LR()   # 实例化逻辑回归模型

# ============================== Step 3/5: 选择损失函数 ================================
loss_fn = nn.BCELoss()  # 二分类交叉熵损失 Binary Cross Entropy Loss

# ============================== Step 4/5: 选择优化器 ==================================
lr = 0.01   # 学习率
optimizer = torch.optim.SGD(lr_net.parameters(), lr=lr, momentum=0.9)    # 随机梯度下降

# ============================== Step 5/5: 模型训练 ====================================
for iteration in range(1000):

    # 前向传播
    y_pred = lr_net(train_x)

    # 计算 loss
    loss = loss_fn(y_pred.squeeze(), train_y)

    # 反向传播
    loss.backward()

    # 更新参数
    optimizer.step()

    # 绘图
    if iteration % 20 == 0:

        mask = y_pred.ge(0.5).float().squeeze()  # 以 0.5 为阈值进行分类
        correct = (mask == train_y).sum()   # 计算正确预测的样本个数
        acc = correct.item() / train_y.size(0)   # 计算分类准确率

        plt.scatter(x0.data.numpy()[:, 0], x0.data.numpy()[:, 1], c='r', label='class 0')
        plt.scatter(x1.data.numpy()[:, 0], x1.data.numpy()[:, 1], c='b', label='class 1')

        w0, w1 = lr_net.features.weight[0]
        w0, w1 = float(w0.item()), float(w1.item())
        plot_b = float(lr_net.features.bias[0].item())
        plot_x = np.arange(-6, 6, 0.1)
        plot_y = (-w0 * plot_x - plot_b) / w1

        plt.xlim(-5, 7)
        plt.ylim(-7, 7)
        plt.plot(plot_x, plot_y)

        plt.text(-5, 5, 'Loss=%.4f' % loss.data.numpy(), fontdict={'size': 20, 'color': 'red'})
        plt.title("Iteration: {}\nw0:{:.2f} w1:{:.2f} b:{:.2f} accuracy:{:.2%}".format(iteration, w0, w1, plot_b, acc))
        plt.legend()

        plt.show()
        plt.pause(0.5)

        if acc > 0.99:
            break

总结

本节课介绍了 PyTorch 自动求导系统中的 torch.autograd.backwardtorch.autograd.grad 这两个常用方法,并演示了一阶、二阶导数的求导过程;理解了自动求导系统,以及数据载体 —— 张量,前向传播构建计算图,计算图求取梯度过程。有了这些知识之后,我们就可以开始正式训练机器学习模型。这里通过演示逻辑回归模型的训练,学习了机器学习回归模型的五大模块:数据、模型、损失函数、优化器和迭代训练过程。这五大模块将是后面学习的主线。

数据读取机制:DataLoader 与 Dataset

本节课我们将学习 PyTorch 中的数据读取机制:DataloaderDataset。这里,我们将通过一个人民币二分类的例子来学习它们。

人民币二分类

image-20230112215737933

任务:训练一个分类模型,使得其能够对第四套人民币中的 1 元和 100 元面额的纸币进行分类。

回顾一下上节课中学习的机器学习的 5 个步骤:

image-20230112215757478

其中,数据模块又可以分为以下子模块:

image-20230112215818574

DataLoader 与 Dataset

DataLoader

torch.utils.data.DataLoader

功能:构建可迭代的数据装载器。

DataLoader(
    dataset,
    batch_size=1,
    shuffle=False,
    sampler=None,
    batch_sampler=None,
    num_workers=0,
    collate_fn=None,
    pin_memory=False,
    drop_last=False,
    timeout=0,
    worker_init_fn=None,
    multiprocessing_context=None
)

主要参数

  • datasetDataset 类,决定数据从哪读取及如何读取。
  • batchsize:批大小。
  • num_works:是否多进程读取数据。
  • shuffle:每个 epoch 是否乱序。
  • drop_last:当样本数不能被 batchsize 整除时,是否舍弃最后一批数据。

相关概念

  • Epoch:所有训练样本都已输入到模型中,称为一个 epoch。
  • Iteration:一批样本输入到模型中,称之为一个 iteration。
  • Batchsize:批大小,决定一个 epoch 有多少个 iteration。

例如

  • 训练样本:80

    Batchsize:8

    1 epoch = 10 iteration

  • 训练样本:87

    Batchsize:8

    drop_last = True:1 epoch = 10 iteration

    drop_last = False:1 epoch = 11 iteration

Dataset

torch.utils.data.Dataset

功能Dataset 抽象类,所有自定义的 Dataset 需要继承它,并重写 __ getitem __() 方法。

class Dataset(object):

    def __getitem__(self, index):
        raise NotImplementedError

    def __add__(self, other):
        return ConcatDataset([self, other])

主要参数

  • getitem:接收一个索引,返回一个样本。

PyTorch 的数据读取机制

image-20230112215829884

将数据集划分为训练集、验证集和测试集

import os
import random
import shutil


def makedir(new_dir):
    if not os.path.exists(new_dir):
        os.makedirs(new_dir)


if __name__ == '__main__':

    random.seed(1)

    # 人民币图片数据所在目录:"../../data/RMB_data"
    dataset_dir = os.path.join("..", "..", "data", "RMB_data")
    # 划分数据集所在目录:"../../data/rmb_split"
    split_dir = os.path.join("..", "..", "data", "rmb_split")
    # 训练集目录:"../../data/rmb_split/train"  
    train_dir = os.path.join(split_dir, "train")
    # 验证集目录:"../../data/rmb_split/valid"  
    valid_dir = os.path.join(split_dir, "valid")
    # 测试集目录:"../../data/rmb_split/test"  
    test_dir = os.path.join(split_dir, "test")  

    train_pct = 0.8
    valid_pct = 0.1
    test_pct = 0.1

    for root, dirs, files in os.walk(dataset_dir):
        # os.walk() 方法用于通过在目录树中游走输出在目录中的文件名,向上或者向下,
        # 返回一个三元元组 (root, dirs, files):
        #   root:当前正在遍历的这个文件夹的本身的地址,这里为
        #         "/Users/andy/PycharmProjects/hello_pytorch/data/RMB_data"
        #   dirs:是一个 list ,内容是该文件夹中所有的目录的名字(不包括子目录),这里为 ["1", "100"]
        #   files:同样是 list , 内容是该文件夹中所有的文件(不包括子目录),这里为 []

        for sub_dir in dirs:
            # os.listdir() 方法用于返回指定的文件夹包含的文件或文件夹的名字的列表

            # 这里返回的是目录 "1" 或 "100" 下的文件或文件夹名字的列表
            imgs = os.listdir(os.path.join(root, sub_dir))

            # 仅保留列表中文件名后缀为 '.jpg' 的元素,即图片数据
            imgs = list(filter(lambda x: x.endswith('.jpg'), imgs))
  
            random.shuffle(imgs)
            img_count = len(imgs)

            train_point = int(img_count * train_pct)
            valid_point = int(img_count * (train_pct + valid_pct))

            for i in range(img_count):
                if i < train_point:
                    out_dir = os.path.join(train_dir, sub_dir)
                elif i < valid_point:
                    out_dir = os.path.join(valid_dir, sub_dir)
                else:
                    out_dir = os.path.join(test_dir, sub_dir)

                makedir(out_dir)

                target_path = os.path.join(out_dir, imgs[i])
                src_path = os.path.join(dataset_dir, sub_dir, imgs[i])

                # 拷贝文件和权限,这里表示将原始数据集中的图片文件拷贝到目标路径文件名下
                shutil.copy(src_path, target_path)  

            print('Class: {}, train: {}, valid :{}, test: {}'.format(sub_dir, \
            train_point, valid_point-train_point, img_count-valid_point))

输出结果:

Class: 1, train: 80, valid :10, test: 10
Class: 100, train: 80, valid :10, test: 10

数据读取

import os
import random
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
import torchvision.transforms as transforms
import torch.optim as optim
from matplotlib import pyplot as plt
from model.lenet import LeNet
from tools.my_dataset import RMBDataset


def set_seed(seed=1):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed(seed)


set_seed()  # 设置随机种子
rmb_label = {"1": 0, "100": 1}

# 参数设置
MAX_EPOCH = 10
BATCH_SIZE = 16
LR = 0.01
log_interval = 10
val_interval = 1

# ========================= step 1/5 数据 ===============================
split_dir = os.path.join("..", "..", "data", "rmb_split")
train_dir = os.path.join(split_dir, "train")
valid_dir = os.path.join(split_dir, "valid")

norm_mean = [0.485, 0.456, 0.406]
norm_std = [0.229, 0.224, 0.225]

train_transform = transforms.Compose([
    # 将图像缩放到 32*32 大小
    transforms.Resize((32, 32)),
    # 对图像进行随机裁剪(数据增强)
    transforms.RandomCrop(32, padding=4),
    # 将图片转成张量形式,并进行归一化操作,把像素值区间从 0~255 归一化到 0~1
    transforms.ToTensor(),
    # 数据标准化,均值为 0,标准差为 1:output = (input - mean) / std
    transforms.Normalize(norm_mean, norm_std),
])

valid_transform = transforms.Compose([
    transforms.Resize((32, 32)),
    transforms.ToTensor(),
    transforms.Normalize(norm_mean, norm_std),
])

# 构建 MyDataset 实例
train_data = RMBDataset(data_dir=train_dir, transform=train_transform)
valid_data = RMBDataset(data_dir=valid_dir, transform=valid_transform)

# 构建 DataLoader
train_loader = DataLoader(dataset=train_data, batch_size=BATCH_SIZE, shuffle=True)
valid_loader = DataLoader(dataset=valid_data, batch_size=BATCH_SIZE)

RMBDataset 类实现

import os
import random
from PIL import Image
from torch.utils.data import Dataset

random.seed(1)
rmb_label = {"1": 0, "100": 1}


class RMBDataset(Dataset):
    def __init__(self, data_dir, transform=None):
        """
        rmb 面额分类任务的 Dataset
        :param data_dir: str, 数据集所在路径
        :param transform: torch.transform, 数据预处理
        """
        self.label_name = {"1": 0, "100": 1}
        self.data_info = self.get_img_info(data_dir)  # data_info 存储所有t图片路径和标签,在 DataLoader 中通过 index 读取样本
        self.transform = transform

    def __getitem__(self, index):
        path_img, label = self.data_info[index]
        img = Image.open(path_img).convert('RGB')   # 0~255

        if self.transform is not None:
            img = self.transform(img)   # 在这里做 transform,转为 tensor 等等

        return img, label

    def __len__(self):
        return len(self.data_info)

    @staticmethod
    def get_img_info(data_dir):
        data_info = list()
        for root, dirs, _ in os.walk(data_dir):
            # 遍历类别
            for sub_dir in dirs:
                img_names = os.listdir(os.path.join(root, sub_dir))
                img_names = list(filter(lambda x: x.endswith('.jpg'), img_names))

                # 遍历图片
                for i in range(len(img_names)):
                    img_name = img_names[i]
                    path_img = os.path.join(root, sub_dir, img_name)
                    label = rmb_label[sub_dir]
                    data_info.append((path_img, int(label)))

        return data_info

PyTorch 中的 DataLoader 工作原理

image-20230112215843093

总结

本节课介绍了 PyTorch 的数据读取机制,通过一个人民币分类实验来学习 PyTorch 是如何从硬盘中读取数据的,并且深入学习数据读取过程中涉及到的两个模块 DataloaderDataset

数据预处理 transforms 模块机制

本节课我们将学习 PyTorch 中的图像预处理模块 —— transforms 的运行机制,以及常用的数据标准化方法 transforms.Normalize

数据预处理 transforms 机制

torchvision:计算机视觉工具包
  • torchvision.transforms:常用的图像预处理方法。
  • torchvision.datasets:常用数据集的 dataset 实现,MNISTCIFAR-10ImageNet 等。
  • torchvision.model:常用的模型预训练,AlexNetVGGResNetGoogLeNet 等。
torchvision.transforms:常用的图像预处理方法
  • 数据中心化
  • 数据标准化
  • 缩放
  • 裁剪
  • 旋转
  • 翻转
  • 填充
  • 噪声添加
  • 灰度变换
  • 线性变换
  • 仿射变换
  • 亮度、饱和度及对比度变换

我们知道,深度学习是由数据驱动的,而数据的数量和分布对于模型的优劣具有决定性作用,所以我们需要对数据进行一定的预处理以及数据增强,用于提升模型的泛化能力。

image-20230112215900786

上面的 64 张图片都来源于 1 张原始图片,它们是由原始图片经过一系列的缩放、裁剪、平移、变换等操作的组合生成的。如前所述,我们进行图片增强的原因是为了提升模型的泛化能力:如果我们在数据增强的过程中生成了一些与测试样本很相似的图片,那么模型的泛化能力自然将会得到提升。

例子:人民币二分类中的 transforms
import os
import random
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
import torchvision.transforms as transforms
import torch.optim as optim
from matplotlib import pyplot as plt
from model.lenet import LeNet
from tools.my_dataset import RMBDataset


def set_seed(seed=1):
    random.seed(seed)
    np.random.seed(seed)
    torch.manual_seed(seed)
    torch.cuda.manual_seed(seed)


set_seed()  # 设置随机种子
rmb_label = {"1": 0, "100": 1}

# 参数设置
MAX_EPOCH = 10
BATCH_SIZE = 16
LR = 0.01
log_interval = 10
val_interval = 1

# ========================= step 1/5 数据 ===============================
split_dir = os.path.join("..", "..", "data", "rmb_split")
train_dir = os.path.join(split_dir, "train")
valid_dir = os.path.join(split_dir, "valid")

norm_mean = [0.485, 0.456, 0.406]
norm_std = [0.229, 0.224, 0.225]

# Compose 会将一系列的 transforms 操作进行组合包装,按顺序执行
train_transform = transforms.Compose([
    # 将图像缩放到 32*32 大小
    transforms.Resize((32, 32)),
    # 对图像进行随机裁剪(数据增强)
    transforms.RandomCrop(32, padding=4),
    # 将图片转成张量形式,并进行归一化操作,把像素值区间从 [0, 255] 归一化到 [0, 1]
    transforms.ToTensor(),
    # 数据标准化,均值为 0,标准差为 1:output = (input - mean) / std
    transforms.Normalize(norm_mean, norm_std),
])

# 注意:测试数据不需要进行数据增强
valid_transform = transforms.Compose([
    transforms.Resize((32, 32)),
    transforms.ToTensor(),
    transforms.Normalize(norm_mean, norm_std),
])

# 构建 MyDataset 实例
train_data = RMBDataset(data_dir=train_dir, transform=train_transform)
valid_data = RMBDataset(data_dir=valid_dir, transform=valid_transform)

# 构建 DataLoader
train_loader = DataLoader(dataset=train_data, batch_size=BATCH_SIZE, shuffle=True)
valid_loader = DataLoader(dataset=valid_data, batch_size=BATCH_SIZE)

PyTorch 中的数据预处理流程图

image-20230112215915946

数据标准化:transforms.Normalize

transforms.Normalize

功能:逐 channel 的对图像进行标准化。

transforms.Normalize(
    mean,
    std,
    inplace=False
)

主要参数

  • mean:各通道的均值。
  • std:各通道的标准差。
  • inplace:是否原地操作。

output = (input - mean) / std

为什么要对数据进行标准化?

数据标准化可以加快模型的收敛过程:因为模型初始化通常是零均值的,所以通过标准化,模型可以在初始位置附近找到最优分界平面。

总结

本节课介绍了数据的预处理模块 transforms 的运行机制,数据在读取之后通常都需要进行预处理,包括尺寸缩放、转换张量、数据中心化或标准化等等,这些操作都是通过 transforms 进行的,所以这里我们重点学习了 transforms 的运行机制,并介绍了数据标准化 (Normalize) 的使用原理。

transforms 数据增强:裁剪、翻转、旋转

在之前课程中,我们已经熟悉了 PyTorch 中 transforms 的运行机制,它提供了大量的图像增强方法,例如裁剪、旋转、翻转等等,以及可以自定义实现增强方法。本节课中,我们将进一步学习 transforms 中的图像增强方法。

数据增强

数据增强 (Data Augmentation) 又称为数据增广、数据扩增,它是对 训练集 进行变换,使训练集更丰富,从而让模型更具 泛化能力

例子

image-20230112215928867

例子

image-20230112215939433

transforms 裁剪

transforms.CenterCrop

功能:从图像中心裁剪图片。

transforms.CenterCrop(size)

主要参数

  • size:所需裁剪图片尺寸。

代码示例

我们有一个 224 × 224 的图片,我们将其从中心裁剪为 196 × 196 的图片。

image-20230112220028366

train_transform = transforms.Compose([
    transforms.Resize((224, 224)),

    # CenterCrop,如果 size 大于原始尺寸,多余部分将用黑色 (即像素值为 0) 填充
    transforms.CenterCrop(196)
])
transforms.RandomCrop

功能:从图片中随机裁剪出尺寸为 size 的图片。

transforms.RandomCrop(
    size,
    padding=None,
    pad_if_needed=False,
    fill=0,
    padding_mode='constant'
)

主要参数

  • size:所需裁剪图片尺寸。
  • padding:设置填充大小。
    • 当为 a 时,上下左右均填充 a 个像素。
    • 当为 (a, b) 时, 上下填充 b 个像素, 左右填充 a 个像素。
    • 当为 (a, b, c, d) 时,左、上、右、下分别填充 abcd 个像素。
  • pad_if_need:若图像小于设定 size,则填充,此时该项需要设置为 True
  • padding_mode:填充模式,有 4 种模式:
    • constant:像素值由 fill 设定。
    • edge:像素值由图像边缘像素决定。
    • reflect:镜像填充,最后一个像素不镜像,例如 [1, 2, 3, 4][3, 2, 1, 2, 3, 4, 3, 2]
    • symmetric:镜像填充,最后一个像素镜像,例如 [1, 2, 3, 4][2, 1, 1, 2, 3, 4, 4, 3]
  • fillpadding_mode = 'constant' 时,设置填充的像素值。
transforms.RandomResizedCrop

功能:随机大小、长宽比裁剪图片。

RandomResizedCrop(
    size,
    scale=(0.08, 1.0),
    ratio=(3/4, 4/3),
    interpolation
)

主要参数

  • size:所需裁剪图片尺寸。
  • scale:随机裁剪面积比例,默认 (0.08, 1)
  • ratio:随机长宽比,默认 (3/4, 4/3)
  • interpolation:插值方法。
    • PIL.Image.NEAREST
    • PIL.Image.BILINEAR
    • PIL.Image.BICUBIC
transforms.FiveCrop

功能:在图像的上下左右以及中心裁剪出尺寸为 size 的 5 张图片。

transforms.FiveCrop(size)

主要参数

  • size:所需裁剪图片尺寸。

代码示例

train_transform = transforms.Compose([
    transforms.Resize((224, 224)),
    # 注意:由于生成了 5 张图片,返回的是一个元组,我们需要将其转换为 PIL Image 或者 ndarray 的形式。
    transforms.FiveCrop(112),
    transforms.Lambda(lambda crops: torch.stack([(transforms.ToTensor()(crop)) for crop in crops]))
])
transforms.TenCrop

功能:在图像的上下左右以及中心裁剪出尺寸为 size 的 5 张图片,并对这 5 张图片进行水平或者垂直镜像获得 10 张图片。

transforms.TenCrop(
    size,
    vertical_flip=False
)

主要参数

  • size:所需裁剪图片尺寸。
  • vertical_flip:是否垂直翻转。

代码示例

train_transform = transforms.Compose([
    transforms.Resize((224, 224)),
    # 注意:由于生成了 10 张图片,返回的是一个元组,我们需要将其转换为 PIL Image 或者 ndarray 的形式。
    transforms.TenCrop(112, vertical_flip=False),
    transforms.Lambda(lambda crops: torch.stack([(transforms.ToTensor()(crop)) for crop in crops])),
])

transforms 翻转、旋转

transforms 翻转

transforms.RandomHorizontalFlip

功能:依概率水平(左右)翻转图片。

transforms.RandomHorizontalFlip(p=0.5)

主要参数

  • p:翻转概率。

image-20230112220044084

transforms.RandomVerticalFlip

功能:依概率垂直(上下)翻转图片。

transforms.RandomVerticalFlip(p=0.5)

主要参数

  • p:翻转概率。

image-20230112220104825

transforms 旋转

transforms.RandomRotation

功能:随机旋转图片。

RandomRotation(
    degrees,
    resample=False,
    expand=False,
    center=None
) 

主要参数

  • degrees:旋转角度。
    • 当为 a 时,在 (-a, a) 之间随机选择旋转角度。
    • 当为 (a, b) 时,在 (a, b) 之间随机选择旋转角度。
  • resample:重采样方法。
  • expand:是否扩大图片,以保持原图信息。
  • center:旋转点设置,默认中心旋转。

例子

image-20230112220118847

总结

本节课中,我们学习了数据预处理模块 transforms 中的数据增强方法:裁剪、翻转和旋转。在下次课程中 ,我们将会学习 transforms 中的其他数据增强方法。

transforms 图像变换、方法操作及自定义方法

上节课中,我们学习了 transforms 中的裁剪、旋转和翻转,本节课我们将继续学习 transforms 中的其他数据增强方法。

transforms 图像变换

transforms.Pad

功能:对图片边缘进行填充。

transforms.Pad(
    padding,
    fill=0,
    padding_mode='constant'
)

主要参数

  • padding:设置填充大小。
    • 当为 a 时,上下左右均填充 a 个像素。
    • 当为 (a, b) 时,上下填充 b 个像素,左右填充 a 个像素。
    • 当为 (a, b, c, d) 时,左、上、右、下分别填充 abcd 个像素。
  • padding_mode:填充模式,有 4 种模式:
    • constant
    • edge
    • reflect
    • symmetric
  • fill:当 padding_mode='constant' 时,设置填充的像素值,(R, G, B)(Gray)
transforms.ColorJitter

功能:调整亮度、对比度、饱和度和色相。

transforms.ColorJitter(
    brightness=0,
    contrast=0,
    saturation=0,
    hue=0
)

主要参数

  • brightness:亮度调整因子。
    • 当为 a 时, 从 [max(0, 1-a), 1+a] 中随机选择。
    • 当为 (a, b) 时, 从 [a, b] 中随机选择。
  • contrast :对比度参数,同 brightness
  • saturation:饱和度参数,同 brightness
  • hue:色相参数。
    • 当为 a 时,从 [-a, a] 中选择参数。注:0 <= a <= 0.5
    • 当为 (a, b) 时,从 [a, b] 中选择参数。注:-0.5 <= a <= b <= 0.5
transforms.Grayscale

功能:将图片转换为灰度图。

transforms.Grayscale(num_output_channels)

主要参数

  • num_ouput_channels:输出通道数,只能设为 1 或 3。
transforms.RandomGrayscale

功能:依概率将图片转换为灰度图。

transforms.RandomGrayscale(
    num_output_channels,
    p=0.1
)

主要参数

  • num_ouput_channels:输出通道数,只能设为 1 或 3。
  • p:概率值,图像被转换为灰度图的概率。
transforms.RandomAffine

功能:对图像进行仿射变换,仿射变换是二维的线性变换,由五种基本原子变换构成:旋转平移缩放错切翻转

transforms.RandomAffine(
    degrees,
    translate=None,
    scale=None,
    shear=None,
    resample=False,
    fillcolor=0
)

主要参数

  • degrees:旋转角度设置。
  • translate:平移区间设置,如 (a, b), a 设置宽 (width),b 设置高 (height)。图像在宽维度平移的区间为 -img_width * a < dx < img_width * a
  • scale:缩放比例 (以面积为单位)。
  • fill_color:填充颜色设置。
  • shear:错切角度设置,有水平错切和垂直错切。
    • 若为 a,则仅在 x 轴错切,错切角度在 (-a, a) 之间。
    • 若为 (a, b),则 a 设置 x 轴角度,b 设置 y 轴角度。
    • 若为 (a, b, c, d),则 ab 设置 x 轴角度,cd 设置 y 轴角度。
  • resample:重采样方式,有 NEARESTBILINEARBICUBIC 三种。
transforms.RandomErasing

功能:对图像进行随机遮挡。

transforms.RandomErasing(
    p=0.5,
    scale=(0.02, 0.33),
    ratio=(0.3, 3,3),
    value=0,
    inplace=False
)

主要参数

  • p:概率值,执行该操作的概率。
  • scale:遮挡区域的面积。
  • ratio:遮挡区域长宽比。
  • value:设置遮挡区域的像素值,(R, G, B) 或者 (Gray)

参考文献Random Erasing Data Augmentation

img

transforms.Lambda

功能:用户自定义 lambda 方法。

transforms.Lambda(lambd)

主要参数

  • lambd:lambda 匿名函数,lambda [arg1 [,arg2, ... , argn]]: expression

代码示例

transforms.TenCrop(200, vertical_flip=True),
transforms.Lambda(lambda crops: torch.stack([transforms.Totensor()(crop) for crop in crops]))

transforms 选择操作

我们已经学习了 transforms 中对图像的各种增强方法,下面我们将介绍对 transforms 方法的三种选择操作,它们可以使 transforms 数据增强方法更加灵活、丰富、多样。

transforms.RandomChoice

功能:从一系列 transforms 方法中随机挑选一个。

transforms.RandomChoice([transforms1, transforms2, transforms3])
transforms.RandomApply

功能:依据概率执行一组 transforms 操作。

transforms.RandomApply([transforms1, transforms2, transforms3], p=0.5) 
transforms.RandomOrder

功能:对一组 transforms 操作打乱顺序。

transforms.RandomOrder([transforms1, transforms2, transforms3]) 

自定义 transforms

尽管 PyTorch 提供了许多 transforms 方法,然而在实际应用中,可能还需要根据项目需求来自定义一些 transforms 方法。下面我们将学习如何自定义 transforms 方法及其注意事项。

为了自定义 transforms 方法,首先需要了解其运行机制,在之前介绍数据读取机制 DataLoaderDataset 时,我们提到过 transforms 方法是在 Compose 类中的 __call__ 函数中被调用的。我们对一组 transforms 方法进行 for 循环,每次按顺序挑选出我们的 transforms 方法 t 并执行它。可以看到,每个 transforms 方法仅接收一个参数,并返回一个参数。另外注意,由于是通过 for 循环调用,当前 transforms 方法的输出就是下一个 transforms 方法的输入。

class Compose(object):
    def __call__(self, img):
        for t in self.transforms:
            img = t(img)
        return img

自定义 transforms 要素

  1. 仅接收一个参数,返回一个参数。
  2. 注意上下游的输出与输入之间的数据类型必须要匹配。

我们在设计 transforms 方法的时候可能需要多个参数,比如设置概率值、信噪比等,这些可以通过类方法实现。

通过类实现多参数传入

class YourTransforms(object):
    def __init__(self, ...):
        ...
    def __call__(self, img):
        ...
        return img

上面是一个自定义 transforms 方法的基本结构。首先是一个初始化 __init__ 方法,在初始化的时候我们可以传入想要的参数,比如概率值、信噪比等等。然后,这个类中还必须有一个 __call__ 函数,即这个类的实例可以被调用,__call__ 函数只接受一个 input 参数,然后执行自定义的一些功能,最后返回一个 output,并且输入与输出的数据类型必须匹配,比如都是 imgtensorlistturple 或者 dict 等。

例子:椒盐噪声

椒盐噪声 (salt pepper noise) 又称为 脉冲噪声,是一种随机出现的白点或者黑点,白点称为 盐噪声,黑点称为 椒噪声

信噪比 (Signal-Noise Rate, SNR) 是衡量噪声的比例,在图像中为图像像素的占比。

下面是对一张小猫图像增加不同信噪比的椒盐噪声的效果图:

image-20230112220148159

从左到右信噪比依次为 0.9、0.7、0.5、0.3。可以看到,随着信噪比的减小,即信号的减少,图片丢失的信息越来越多。当信噪比为 0.9 时,我们还可以清晰地看到这是一张小猫的图像;而当信噪比降低到 0.3 时,我们已经很难辨别图像的真实内容了。

下面,我们通过自定义 transforms 方法对图像添加椒盐噪声:

class AddPepperNoise(object):

    def __init__(self, snr, p):
        self.snr = snr  # 设置信噪比
        self.p = p  # 设置概率值

    def __call__(self, img):

        ...  # 添加椒盐噪声具体实现过程

        return img

Python 代码示例

class AddPepperNoise(object):
    """增加椒盐噪声
    Args:
        snr (float): 信噪比,Signal Noise Rate
        p (float): 概率值,依概率执行该操作
    """

    def __init__(self, snr, p=0.9):
        assert isinstance(snr, float) or (isinstance(p, float))
        self.snr = snr
        self.p = p

    def __call__(self, img):
        """
        Args:
            img (PIL Image): PIL Image
        Returns:
            PIL Image: PIL image.
        """
        if random.uniform(0, 1) < self.p:
            img_ = np.array(img).copy()
            h, w, c = img_.shape
            signal_pct = self.snr
            noise_pct = (1 - self.snr)
            mask = np.random.choice((0, 1, 2), size=(h, w, 1), p=[signal_pct, noise_pct/2., noise_pct/2.])
            mask = np.repeat(mask, c, axis=2)
            img_[mask == 1] = 255   # 盐噪声
            img_[mask == 2] = 0     # 椒噪声
            return Image.fromarray(img_.astype('uint8')).convert('RGB')
        else:
            return img

transforms 方法总结

裁剪

  • transforms.CenterCrop
  • transforms.RandomCrop
  • transforms.RandomResizedCrop
  • transforms.FiveCrop
  • transforms.TenCrop

翻转和旋转

  • transforms.RandomHorizontalFlip
  • transforms.RandomVerticalFlip
  • transforms.RandomRotation

图像变换

  • transforms.Pad
  • transforms.ColorJitter
  • transforms.Grayscale
  • transforms.RandomGrayscale
  • transforms.RandomAffine
  • transforms.LinearTransformation
  • transforms.RandomErasing
  • transforms.Lambda
  • transforms.Resize
  • transforms.Totensor
  • transforms.Normalize

transforms 操作

  • transforms.RandomChoice
  • transforms.RandomApply
  • transforms.RandomOrder

数据增强实战应用

原则:让训练集与测试集更接近。

  • 空间位置:平移
  • 色彩:灰度图,色彩抖动
  • 形状:仿射变换
  • 上下文场景:遮挡, 填充
  • ……

例子

image-20230112220159664

我们看到,在训练集中,猫基本都处于图片的中央位置,而在测试集中的猫处于偏左/右,或者在角落的情况。对于这种情况,我们可以在数据增强时改变训练集中的空间位置,例如平移,来逼近测试集的图片。

例子

image-20230112220213920

我们看到,在训练集中,猫基本都是白色的,而在测试集中的猫是黑色的。对于这种情况,我们可以在数据增强时对训练集中的图片进行色彩抖动或者变换处理,来逼近测试集的图片。有时,训练集和测试集中猫的姿态差异很大,这种情况下,我们可以通过对训练集图片进行仿射变换处理来改变猫的形状。另外,还可以对比看下测试集中有无遮挡情况,可以对训练集进行遮挡、填充等相应处理。

人民币分类

image-20230112220226329

在之前的人民币分类例子中,我们的数据集是面额为 1 元与 100 元的第四套人民币各 100 张,那么基于该数据集训练出的模型是否可以对第五套人民币的 100 元进行正确分类呢?

直观上,第五套人民币的 100 元与第四套人民币的 1 元在颜色上比较相近,而在面额上与第四套人民币的 1 00 元一样。实验证明,如果不进行额外的数据增强,模型会将第五套人民币的 100 元识别为 1 元,这很可能是由于二者在颜色上的相似性导致的。当我们对图像进行灰度处理后,模型将可以对第五套人民币的 100 元进行正确分类。

总结

在本节课中,我们学习了数据预处理 transforms 的图像变换、操作方法,以及自定义 transforms。到目前为止,PyTorch 中的数据模块我们已经学习完毕,在下节课中,我们将会学习 PyTorch 中的模型模块。

模型创建步骤与 nn.Module

前几节课中,我们学习了 PyTorch 的数据模块,并了解了 PyTorch 如何从硬盘中读取数据,然后对数据进行预处理、数据增强,最后转换为张量的形式输入到我们的模型中。在深度模型中,会对张量进行一系列复杂的数学运算,最终得到用于分类、分割、目标检测等任务的输入。本节课中,我们将学习 PyTorch 中模型的创建以及 nn.Module 的相关概念。

网络模型的创建步骤

在学习创建模型之前,我们先回顾一下之前提到的机器学习模型训练的 5 个步骤:

image-20230112220255291

我们已经在前几节课中完成了对数据模块的学习,接下来我们开始学习模型模块。

image-20230112220302378

回顾一下之前在人民币分类的例子中我们使用过的 LeNet 网络:

LeNet 模型结构图

image-20230112220311519

可以看到,LeNet 网络由 7 个层构成:卷积层 1、池化层 1、卷积层 2、池化层 2,以及 3 个全连接层。在创建 LeNet 时,需要先构建这些子模块,在构建完成这 7 个子网络层后,我们会采用一定的顺序对其进行连接。最后,将它们包装起来就得到我们的 LeNet 网络。

在 PyTorch 中,LeNet 是一个 Module 的概念,而它的子网络层也是一个 Module 的概念,它们都属于 nn.Module 类。所以,一个 nn.Module (例如:LeNet) 可以包含很多个子 Module (例如:卷积层、池化层等)。

下面我们从计算图的角度来观察模型的创建过程:

image-20230112220321375

计算图中有两个主要的概念:结点和边。其中,结点代表张量 (数据),边代表运算。LeNet 整体上可以视为一组张量运算:它接收一个 32×32×3 的张量,经过一系列复杂运算之后,输出一个长度为 10 的向量作为分类概率。而在 LeNet 内部,则由一系列子网络层构成,例如:卷积层 1 对一个 32×32×3 的张量进行卷积操作得到一个 28×28×6 的张量,并将其作为下一层子网络的输入,经过这种不断的前向传播,最终计算得到输出概率。在深度学习中,该过程被称为 前向传播

我们从网络结构和计算图的角度分析了 LeNet 网络模型,并且知道了构建模型的两个要素:构建子模块和拼接子模块。

image-20230112220332359

接下来,我们还是通过之前人民币二分类的例子来学习如何构建模型。

构建模型

# ============================ step 2/5 模型 ============================
net = LeNet(classes=2)
net.initialize_weights()

LeNet 类

class LeNet(nn.Module):
    # 构建子模块
    def __init__(self, classes):
        super(LeNet, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.conv2 = nn.Conv2d(6, 16, 5)
        self.fc1 = nn.Linear(16*5*5, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, classes)

    # 拼接子模块
    def forward(self, x):
        out = F.relu(self.conv1(x))
        out = F.max_pool2d(out, 2)
        out = F.relu(self.conv2(out))
        out = F.max_pool2d(out, 2)
        out = out.view(out.size(0), -1)
        out = F.relu(self.fc1(out))
        out = F.relu(self.fc2(out))
        out = self.fc3(out)
        return out

    def initialize_weights(self):
        for m in self.modules():
            if isinstance(m, nn.Conv2d):
                nn.init.xavier_normal_(m.weight.data)
                if m.bias is not None:
                    m.bias.data.zero_()
            elif isinstance(m, nn.BatchNorm2d):
                m.weight.data.fill_(1)
                m.bias.data.zero_()
            elif isinstance(m, nn.Linear):
                nn.init.normal_(m.weight.data, 0, 0.1)
                m.bias.data.zero_()

nn.Module

在模型模块中,我们有一个非常重要的概念 —— nn.Module。我们所有的模型和网络层都是继承自 nn.Module 这个类的,所以我们有必要了解它。在学习 nn.Module 之前,我们先来看一下与其相关的几个模块:

image-20230112220341477

首先是 torch.nn,它是 PyTorch 的一个神经网络模块,其中又有很多子模块,这里我们需要了解其中的 4 个模块:nn.Parameternn.Modulenn.functionalnn.init。本节课我们先重点关注 nn.Module

nn.Module

nn.Module 中有 8 个重要的属性,用于管理整个模型:

self._parameters = OrderedDict()
self._buffers = OrderedDict()
self._backward_hooks = OrderedDict()
self._forward_hooks = OrderedDict()
self._forward_pre_hooks = OrderedDict()
self._state_dict_hooks = OrderedDict()
self._load_state_dict_pre_hooks = OrderedDict()
self._modules = OrderedDict()

主要属性

  • parameters:存储管理 nn.Parameter 类。
  • modules:存储管理 nn.Module 类。
  • buffers:存储管理缓冲属性,如 BN 层中的 running_mean
  • ***_hooks:存储管理钩子函数。

这里,我们重点关注其中的两个属性:parametersmodules

nn.Module 的属性构建机制

module 类里面进行属性赋值时会先被 __setattr__ 函数拦截,该函数对即将赋值的数据类型进行判断:如果赋值是 nn.Parameter 类,则将其存入 parameters 字典中进行管理;如果赋值是 nn.Module 类,则将其存入 modules 字典中进行管理。

nn.Module 总结

  • 一个 module 可以包含多个子 module。
    • 例如:在 LeNet 这个 module 下会包含一些卷积层、池化层等子 module。
  • 一个 module 相当于一个运算,必须实现forward()函数。
    • 从计算图的角度来看,一个 module 接收一个张量,经过一系列复杂运算,输出概率或者其他数据。因此,我们需要在其中实现一个前向传播的函数。
  • 每个 module 都有 8 个有序字典 (OrderedDict)管理它的属性。
    • 这里,最常用的是 parameters 字典和 modules 字典。

总结

本节课中,我们学习了 nn.Module 的概念以及模型创建的两个要素。下节课中,我们将学习容器 Containers 以及 AlexNet 的搭建。

模型容器与 AlexNet 构建

上节课中,我们学习了如何搭建一个模型,搭建模型的过程中有两个要素:构建子模块和拼接子模块。另外,搭建模型时还有一个非常重要的概念:模型容器 (Containers)。本节课我们将学习模型容器以及 AlexNet 的构建。

模型容器

在 PyTorch 模型容器中有三个常用模块:nn.Sequetialnn.ModuleListnn.ModuleDict

image-20230112220350081

nn.Sequential

nn.Sequentialnn.Module 的容器,用于 按顺序 包装一组网络层。

image-20230112220358300

nn.Sequential 将一组网络层按顺序包装为一个整体,可以视为模型的一个子模块。在传统的机器学习中有一个步骤被称为特征工程:我们需要人为地设计特征,并将特征输入到分类器当中进行分类。在深度学习时代,特征工程这一概念已经被弱化,尤其是在卷积神经网络中,我们不需要人为设计图像特征,相反,我们可以让卷积神经网络去自动学习特征,并在最后加上几个全连接层用于输出分类结果。在早期的神经网络当中,用于分类的分类器是由全连接构成的,所以在深度学习时代,通常也习惯以全连接层为界限,将网络模型划分为特征提取模块和分类模块。对一个大的模型进行划分可以方便按照模块进行管理:例如在上面的 LeNet 模型中,我们可以将多个卷积层和池化层包装为一个特征提取器,并且将后面的几个全连接层包装为一个分类器,最后再将这两个模块包装为一个完整的 LeNet 神经网络。在 PyTorch 中,我们可以使用 nn.Sequential 完成这些包装过程。

代码示例

class LeNetSequential(nn.Module):
    def __init__(self, classes):
        super(LeNetSequential, self).__init__()

        self.features = nn.Sequential(
            nn.Conv2d(3, 6, 5),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2),
            nn.Conv2d(6, 16, 5),
            nn.ReLU(),
            nn.MaxPool2d(kernel_size=2, stride=2),)

        self.classifier = nn.Sequential(
            nn.Linear(16*5*5, 120),
            nn.ReLU(),
            nn.Linear(120, 84),
            nn.ReLU(),
            nn.Linear(84, classes),)

    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size()[0], -1)
        x = self.classifier(x)
        return x


class LeNetSequentialOrderDict(nn.Module):
    def __init__(self, classes):
        super(LeNetSequentialOrderDict, self).__init__()

        self.features = nn.Sequential(OrderedDict({
            'conv1': nn.Conv2d(3, 6, 5),
            'relu1': nn.ReLU(inplace=True),
            'pool1': nn.MaxPool2d(kernel_size=2, stride=2),

            'conv2': nn.Conv2d(6, 16, 5),
            'relu2': nn.ReLU(inplace=True),
            'pool2': nn.MaxPool2d(kernel_size=2, stride=2),
        }))

        self.classifier = nn.Sequential(OrderedDict({
            'fc1': nn.Linear(16*5*5, 120),
            'relu3': nn.ReLU(),

            'fc2': nn.Linear(120, 84),
            'relu4': nn.ReLU(inplace=True),

            'fc3': nn.Linear(84, classes),
        }))

    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size()[0], -1)
        x = self.classifier(x)
        return x


net = LeNetSequential(classes=2)
net = LeNetSequentialOrderDict(classes=2)
fake_img = torch.randn((4, 3, 32, 32), dtype=torch.float32)
output = net(fake_img)
print(net)
print(output)

nn.Sequential 的两个特性

  • 顺序性:各网络层之间严格按照顺序构建。
  • 自带 forward():自带的 forward 里,通过 for 循环依次执行前向传播运算。
nn.ModuleList

nn.ModuleListnn.Module 的容器,用于包装一组网络层,以 迭代 方式调用网络层。

主要方法

  • append():在 ModuleList 后面 添加 网络层。
  • extend()拼接 两个 ModuleList
  • insert():指定在 ModuleList 中位置 插入 网络层。

代码示例

class ModuleList(nn.Module):
    def __init__(self):
        super(ModuleList, self).__init__()
        # 构建 20 个全连接层
        self.linears = nn.ModuleList([nn.Linear(10, 10) for i in range(20)])

    def forward(self, x):
        for i, linear in enumerate(self.linears):
            x = linear(x)
        return x


net = ModuleList()
print(net)
fake_data = torch.ones((10, 10))
output = net(fake_data)
print(output)
nn.ModuleDict

nn.ModuleDictnn.Module 的容器,用于包装一组网络层,以 索引 方式调用网络层。

主要方法

  • clear():清空 ModuleDict
  • items():返回可迭代的键值对 (key - value pairs)。
  • keys():返回字典的键 (key)。
  • values():返回字典的值 (value)。
  • pop():返回一对键值,并从字典中删除。

代码示例

class ModuleDict(nn.Module):
    def __init__(self):
        super(ModuleDict, self).__init__()
        self.choices = nn.ModuleDict({
            'conv': nn.Conv2d(10, 10, 3),
            'pool': nn.MaxPool2d(3)
        })

        self.activations = nn.ModuleDict({
            'relu': nn.ReLU(),
            'prelu': nn.PReLU()
        })

    def forward(self, x, choice, act):
        x = self.choices[choice](x)
        x = self.activations[act](x)
        return x


net = ModuleDict()
fake_img = torch.randn((4, 10, 32, 32))
output = net(fake_img, 'conv', 'relu')
print(output)
容器总结
  • nn.Sequential顺序性,各网络层之间严格按顺序执行,常用于 block 构建。
  • nn.ModuleList迭代性,常用于大量重复网络层构建,通过 for 循环实现重复构建。
  • nn.ModuleDict索引性,常用于可选择的网络层。

AlexNet 构建

AlexNet:2012 年以高出第二名 10 多个百分点的准确率获得 ImageNet 分类任务冠军,开创了卷积神经网络的新时代。

AlexNet 特点如下

  1. 采用 ReLU:替换饱和激活函数 (例如:Sigmoid),减轻梯度消失。
  2. 采用 LRN (Local Response Normalization):对数据归一化,减轻梯度消失。
  3. Dropout:提高全连接层的鲁棒性,增加网络的泛化能力。
  4. Data Augmentation:TenCrop,色彩修改。

参考文献ImageNet Classification with Deep Convolutional Neural Networks

image-20230112220412645

AlexNet 采用了卷积、池化、卷积、池化的堆叠方式来提取数据特征,后面再接上三个全连接层进行分类。这里,我们可以应用 nn.Sequential 中的概念,将前面的卷积池化部分包装成一个 features 模块,将后面的全连接部分包装成一个 classifier 模块,从而将一个复杂网络分解成一个特征提取模块和一个分类模块。

PyTorch 在 torchvision.models 中内置了 AlexNet 的实现:

class AlexNet(nn.Module):

    def __init__(self, num_classes=1000):
        super(AlexNet, self).__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 64, kernel_size=11, stride=4, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(64, 192, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            nn.Conv2d(192, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(256, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
        )
        self.avgpool = nn.AdaptiveAvgPool2d((6, 6))
        self.classifier = nn.Sequential(
            nn.Dropout(),
            nn.Linear(256 * 6 * 6, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(),
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            nn.Linear(4096, num_classes),
        )

    def forward(self, x):
        x = self.features(x)
        x = self.avgpool(x)
        x = torch.flatten(x, 1)
        x = self.classifier(x)
        return x

代码示例

alexnet = torchvision.models.AlexNet() 

总结

本节课中,我们学习了 3 种不同的模型容器:SequentialModuleListModuleDict,以及 AlexNet 的搭建。下节课中,我们将学习 nn 中网络层的具体使用。

nn 网络层:卷积层

在上节课中,我们学习了如何在 PyTorch 中搭建神经网络模型,以及在搭建网络的过程中常用的容器: SequentialModuleListModuleDict。本节课开始,我们将学习 PyTorch 中常见的网络层,现在我们先重点学习卷积层。

一维、二维和三维卷积

卷积运算 (Convolution):卷积核在输入信号 (图像) 上滑动,相应位置上进行 乘加卷积核 (Kernel):又称为滤波器/过滤器,可认为是某种模式/某种特征。

卷积过程类似于用一个模版去图像上寻找与它相似的区域,与卷积核模式越相似,激活值越高,从而实现特征提取。所以在深度学习中,我们可以将卷积核视为特征提取器。

img

下图是 AlexNet 卷积核的可视化,我们发现卷积核实际上学习到的是 边缘条纹色彩 这些细节模式:

image-20230112220429971

这进一步验证了卷积核是图像的某种特征提取器,而具体的特征模式则完全由模型学习得到。

卷积维度 (Dimension)一般情况下,一个卷积核在一个信号上沿几个维度上滑动,就是几维卷积。

1d 卷积

img

2d 卷积

img

3d 卷积

img

可以看到,一个卷积核在一个信号上沿几个维度滑动,就是几维卷积。注意这里我们强调 一个卷积核一个信号,因为通常我们会涉及包含多个卷积核和多个信号的卷积操作,这种情况下怎么去判断卷积的维度呢,这里我们可以先思考一下。

二维卷积

nn.Conv2d

功能:对多个二维平面信号进行二维卷积。

nn.Conv2d(
    in_channels,
    out_channels,
    kernel_size,
    stride=1,
    padding=0,
    dilation=1,
    groups=1,
    bias=True,
    padding_mode='zeros'
)

主要参数

  • in_channels:输入通道数。

  • out_channels:输出通道数,等价于卷积核个数。

  • kernel_size:卷积核尺寸。

  • stride:步长。下面是一个步长为 2 的卷积:

    img

  • padding:填充个数。常用于保持输入输出图像尺寸匹配,可以用于提高输出图像的分辨率:

    img

  • dilation:空洞卷积大小。常用于图像分割任务,目的是提高感受野,即输出图像的一个像素对应输入图像上更大的一块区域:

    img

  • groups:分组卷积的组数。常用于模型的轻量化。例如,Alexnet 当时由于硬件限制采用了两组卷积操作:

    image-20230112220457598

  • bias:偏置。最终输出响应值时需加上偏置项。

尺寸计算

  • 简化版 (不带 paddingdilation):

  • 完整版:

代码示例

import os
import torch.nn as nn
from PIL import Image
from torchvision import transforms
from matplotlib import pyplot as plt
from tools.common_tools import transform_invert, set_seed

set_seed(3)  # 设置随机种子,用于调整卷积核权值的状态。

# ================================= load img ==================================
path_img = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lena.png")
img = Image.open(path_img).convert('RGB')  # 0~255

# convert to tensor
img_transform = transforms.Compose([transforms.ToTensor()])
img_tensor = img_transform(img)
img_tensor.unsqueeze_(dim=0)    # C*H*W to B*C*H*W

# ========================= create convolution layer ==========================
conv_layer = nn.Conv2d(3, 1, 3)   # input:(i, o, size) weights:(o, i , h, w)
nn.init.xavier_normal_(conv_layer.weight.data)

# calculation
img_conv = conv_layer(img_tensor)

# =========================== visualization ==================================
print("卷积前尺寸:{}\n卷积后尺寸:{}".format(img_tensor.shape, img_conv.shape))
img_conv = transform_invert(img_conv[0, 0:1, ...], img_transform)
img_raw = transform_invert(img_tensor.squeeze(), img_transform)
plt.subplot(122).imshow(img_conv, cmap='gray')
plt.subplot(121).imshow(img_raw)
plt.show()

输出结果:

卷积前尺寸:torch.Size([1, 3, 512, 512])
卷积后尺寸:torch.Size([1, 1, 510, 510])
  • set.seed(1) 时的输出:

    image-20230112220511793

  • set.seed(2) 时的输出:

    image-20230112220521714

  • set.seed(3) 时的输出:

    image-20230112220530253

可以看到,不同的卷积核权值对应的输出是不相同的。通常,我们会在卷积层中设置多个卷积核,以提取不同的特征。

在上面的例子中,我们使用一个 3 维的卷积核实现了一个 2d 卷积:

img

我们的输入是一个 RGB 的二维图像,它包含 3 个色彩通道。然后,我们将创建 3 个二维卷积核,不同通道对应不同的卷积核。我们将三个通道的卷积结果相加,然后再加上偏置项,得到最终的卷积结果。

转置卷积

转置卷积 (Transpose Convolution) 又称为 反卷积 (Deconvolution)注 1 或者 部分跨越卷积 (Fractionally strided Convolution),常见于图像分割任务中,主要用于对图像进行 上采样 (UpSample)

(注 1:这里我们说的反卷积不同于信号系统中的反卷积)。

为什么称为转置卷积?

  • 正常卷积

    img

    假设图像尺寸为 4×4,卷积核为 3×3,padding=0,stride=1。

    • 图像:I16×1,这里 16 是输入图像的像素总数,1 表示图片张数。
    • 卷积核:K4×16,这里 4 是输出图像的像素总数,16 是由卷积核中的 9 个元素另外补零后得到。
    • 输出:O4×1=K4×16×I16×1
  • 转置卷积:上采样,输出图像比输入图像尺寸更大。

    img

    假设图像尺寸为 2×2,卷积核为 3×3,padding=0,stride=1。

    • 图像:I4×1,这里 4 是输入图像的像素总数,1 表示图片张数。
    • 卷积核:K16×4,这里 16 是输出图像的像素总数,4 是由卷积核中的 9 个元素剔除一部分后得到。
    • 输出:O16×1=K16×4×I4×1

可以看到,转置卷积与正常卷积的卷积核尺寸在形状上是转置关系,这也是我们将其称为转置卷积的原因。注意,二者只是在形状上是转置关系,但它们的权值是完全不同的。也就是说,该卷积过程是不可逆的,即卷积后再转置卷积,得到的图像和初始图像是完全不同的。

nn.ConvTranspose2d

功能:转置卷积实现上采样。

nn.ConvTranspose2d(
    in_channels,
    out_channels,
    kernel_size,
    stride=1,
    padding=0,
    output_padding=0,
    groups=1,
    bias=True,
    dilation=1,
    padding_mode='zeros'
)

主要参数

  • in_channels:输入通道数。
  • out_channels:输出通道数。
  • kernel_size:卷积核尺寸。
  • stride:步长。
  • padding:填充个数。
  • dilation:空洞卷积大小。
  • groups:分组卷积设置。
  • bias:偏置。

尺寸计算

  • 简化版 (不带 paddingdilation):

  • 完整版:

代码示例

import os
import torch.nn as nn
from PIL import Image
from torchvision import transforms
from matplotlib import pyplot as plt
from tools.common_tools import transform_invert, set_seed

set_seed(3)  # 设置随机种子,用于调整卷积核权值的状态。

# ================================= load img ==================================
path_img = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lena.png")
img = Image.open(path_img).convert('RGB')  # 0~255

# convert to tensor
img_transform = transforms.Compose([transforms.ToTensor()])
img_tensor = img_transform(img)
img_tensor.unsqueeze_(dim=0)    # C*H*W to B*C*H*W

# ========================= create convolution layer ==========================
conv_layer = nn.ConvTranspose2d(3, 1, 3, stride=2)   # input:(i, o, size)
nn.init.xavier_normal_(conv_layer.weight.data)

# calculation
img_conv = conv_layer(img_tensor)

# =========================== visualization ==================================
print("卷积前尺寸:{}\n卷积后尺寸:{}".format(img_tensor.shape, img_conv.shape))
img_conv = transform_invert(img_conv[0, 0:1, ...], img_transform)
img_raw = transform_invert(img_tensor.squeeze(), img_transform)
plt.subplot(122).imshow(img_conv, cmap='gray')
plt.subplot(121).imshow(img_raw)
plt.show()

输出结果:

卷积前尺寸:torch.Size([1, 3, 512, 512])
卷积后尺寸:torch.Size([1, 1, 1025, 1025])

image-20230112220605233

可以看到,在经过转置卷积上采样后,图像出现了一个奇怪的现象:输出的图像上有许多网格。这被称为 棋盘效应 (Checkerboard Artifacts),是由于转置卷积中的不均匀重叠造成的。关于棋盘效应的解释以及解决方法请参考论文 Deconvolution and Checkerboard Artifacts

总结

本节课中,我们学习了 nn 模块中卷积层。在下次课程中,我们将学习 nn 模块中的其他常用网络层。

nn 网络层:池化层、全连接层和激活函数层

上节课中,我们学习了网络层中的卷积层。本节课中,我们将继续学习其他几种网络层:池化层、线性层和激活函数层。

池化层

img

池化运算 (Pooling):对信号进行 “收集”“总结”,类似水池收集水资源,因而得名池化层。

  • “收集”:多变少。
  • “总结”:最大值/平均值。

最大池化 vs. 平均池化

img

nn.MaxPool2d

功能:对二维信号(图像)进行最大值池化。

nn.MaxPool2d(
    kernel_size,
    stride=None,
    padding=0,
    dilation=1,
    return_indices=False,
    ceil_mode=False
)

主要参数

  • kernel_size:池化核尺寸。
  • stride:步长。
  • padding:填充个数。
  • dilation:池化核间隔大小。
  • ceil_mode:尺寸是否向上取整。用于计算输出特征图尺寸,默认设置为向下取整。
  • return_indices:记录池化像素索引。通常在最大值反池化上采样时使用。

代码示例

import os
import torch
import torch.nn as nn
from torchvision import transforms
from matplotlib import pyplot as plt
from PIL import Image
from tools.common_tools import transform_invert, set_seed

set_seed(1)  # 设置随机种子

# ================================= load img ==================================
path_img = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lena.png")
img = Image.open(path_img).convert('RGB')  # 0~255

# convert to tensor
img_transform = transforms.Compose([transforms.ToTensor()])
img_tensor = img_transform(img)
img_tensor.unsqueeze_(dim=0)    # C*H*W to B*C*H*W

# ========================== create maxpool layer =============================
maxpool_layer = nn.MaxPool2d((2, 2), stride=(2, 2))   # input:(i, o, size) weights:(o, i , h, w)
img_pool = maxpool_layer(img_tensor)

# ================================= visualization =============================
print("池化前尺寸:{}\n池化后尺寸:{}".format(img_tensor.shape, img_pool.shape))
img_pool = transform_invert(img_pool[0, 0:3, ...], img_transform)
img_raw = transform_invert(img_tensor.squeeze(), img_transform)
plt.subplot(122).imshow(img_pool)
plt.subplot(121).imshow(img_raw)
plt.show()

输出结果:

池化前尺寸:torch.Size([1, 3, 512, 512])
池化后尺寸:torch.Size([1, 3, 256, 256])

image-20230112220901772

可以看到,经过最大池化后的图像尺寸减小了一半,而图像质量并没有明显降低。因此,池化操作可以剔除图像中的冗余信息,以及减小后续的计算量。

nn.AvgPool2d

功能:对二维信号(图像)进行平均值池化。

nn.AvgPool2d(
    kernel_size,
    stride=None,
    padding=0,
    ceil_mode=False,
    count_include_pad=True,
    divisor_override=None
) 

主要参数

  • kernel_size:池化核尺寸。
  • stride:步长。
  • padding:填充个数。
  • ceil_mode:尺寸向上取整。
  • count_include_pad:是否将填充值用于平均值的计算。
  • divisor_override:除法因子。计算平均值时代替像素个数作为分母。

代码示例

import os
import torch
import torch.nn as nn
from torchvision import transforms
from matplotlib import pyplot as plt
from PIL import Image
from tools.common_tools import transform_invert, set_seed

set_seed(1)  # 设置随机种子

# ================================= load img ==================================
path_img = os.path.join(os.path.dirname(os.path.abspath(__file__)), "lena.png")
img = Image.open(path_img).convert('RGB')  # 0~255

# convert to tensor
img_transform = transforms.Compose([transforms.ToTensor()])
img_tensor = img_transform(img)
img_tensor.unsqueeze_(dim=0)    # C*H*W to B*C*H*W

# ========================== create avgpool layer =============================
avgpoollayer = nn.AvgPool2d((2, 2), stride=(2, 2))   # input:(i, o, size) weights:(o, i , h, w)
img_pool = avgpoollayer(img_tensor)

# =============================== visualization ===============================
print("池化前尺寸:{}\n池化后尺寸:{}".format(img_tensor.shape, img_pool.shape))
img_pool = transform_invert(img_pool[0, 0:3, ...], img_transform)
img_raw = transform_invert(img_tensor.squeeze(), img_transform)
plt.subplot(122).imshow(img_pool)
plt.subplot(121).imshow(img_raw)
plt.show()

输出结果:

池化前尺寸:torch.Size([1, 3, 512, 512])
池化后尺寸:torch.Size([1, 3, 256, 256])

image-20230112220914322

同样,图像尺寸减小了一半,而质量并没有明显降低。另外,如果我们仔细对比最大池化与平均池化的结果,可以发现最大池化后的图像会偏亮一些,而平均池化后的图像会偏暗一些,这是由于两种池化操作采用不同的计算方式造成的 (像素值越大,图像亮度越高)。

divisor_override 的使用

现在,我们来看一下除法因子的使用。这里,我们初始化一个 4×4 的图像,并且采用一个 2×2 的窗口,步长设置为 2。

正常的平均池化

img_tensor = torch.ones((1, 1, 4, 4))
avgpool_layer = nn.AvgPool2d((2, 2), stride=(2, 2))
img_pool = avgpool_layer(img_tensor)

print("raw_img:\n{}\npooling_img:\n{}".format(img_tensor, img_pool))

输出结果:

raw_img:
tensor([[[[1., 1., 1., 1.],
          [1., 1., 1., 1.],
          [1., 1., 1., 1.],
          [1., 1., 1., 1.]]]])
pooling_img:
tensor([[[[1., 1.],
          [1., 1.]]]])

计算池化后的像素值:

divisor_override=3 的平均池化

img_tensor = torch.ones((1, 1, 4, 4))
avgpool_layer = nn.AvgPool2d((2, 2), stride=(2, 2), divisor_override=3)
img_pool = avgpool_layer(img_tensor)

print("raw_img:\n{}\npooling_img:\n{}".format(img_tensor, img_pool))

输出结果:

raw_img:
tensor([[[[1., 1., 1., 1.],
          [1., 1., 1., 1.],
          [1., 1., 1., 1.],
          [1., 1., 1., 1.]]]])
pooling_img:
tensor([[[[1.3333, 1.3333],
          [1.3333, 1.3333]]]])

计算池化后的像素值:

目前为止,我们学习了最大池化和平均池化,它们都是对图像实现下采样的过程,即输入尺寸较大的图像,输出尺寸较小的图像。下面我们将学习反池化,即将小尺寸图像变为大尺寸图像。

nn.MaxUnpool2d

功能:对二维信号(图像)进行最大值反池化上采样。

nn.MaxUnpool2d(
    kernel_size,
    stride=None,
    padding=0
)

forward(self, input, indices, output_size=None)

主要参数

  • kernel_size:池化核尺寸。
  • stride:步长。
  • padding:填充个数。

最大值反池化:

img

早期的自编码器和图像分割任务中都会涉及一个上采样的操作,当时普遍采用的方法是最大值反池化上采样。上图左半部分是最大池化过程,原始 4×4 的图像经过最大池化后得到一个 2×2 的下采样图像,然后经过一系列的网络层之后,进入上图右半部分的上采样解码器,即将一个尺寸较小的图像经过上采样得到一个尺寸较大的图像。此时,涉及到的一个问题是:我们应该将像素值放到什么位置。例如:右边 2×2 图像中的左上角的 3 应当放入最终 4×4 图像中的左上部分的 4 个像素中的哪一个?这时,我们就可以利用之前最大池化过程中记录的池化像素索引,将 3 放入之前原始 4×4 图像中左上角的 4 个像素中最大值对应的位置。

代码示例

# pooling
img_tensor = torch.randint(high=5, size=(1, 1, 4, 4), dtype=torch.float)
maxpool_layer = nn.MaxPool2d((2, 2), stride=(2, 2), return_indices=True)
img_pool, indices = maxpool_layer(img_tensor)

# unpooling
img_reconstruct = torch.randn_like(img_pool, dtype=torch.float)
maxunpool_layer = nn.MaxUnpool2d((2, 2), stride=(2, 2))
img_unpool = maxunpool_layer(img_reconstruct, indices)

print("raw_img:\n{}\nimg_pool:\n{}".format(img_tensor, img_pool))
print("img_reconstruct:\n{}\nimg_unpool:\n{}".format(img_reconstruct, img_unpool))

输出结果:

raw_img:
tensor([[[[0., 4., 4., 3.],
          [3., 3., 1., 1.],
          [4., 2., 3., 4.],
          [1., 3., 3., 0.]]]])
img_pool:
tensor([[[[4., 4.],
          [4., 4.]]]])
img_reconstruct:
tensor([[[[-1.0276, -0.5631],
          [-0.8923, -0.0583]]]])
img_unpool:
tensor([[[[ 0.0000, -1.0276, -0.5631,  0.0000],
          [ 0.0000,  0.0000,  0.0000,  0.0000],
          [-0.8923,  0.0000,  0.0000, -0.0583],
          [ 0.0000,  0.0000,  0.0000,  0.0000]]]])

这里,我们初始化一个 4×4 的图像,并且采用一个 2×2 的窗口,步长设置为 2。首先,我们对其进行最大值池化,并记录其中的最大值像素的索引。然后,我们进行反池化,这里反池化的输入和之前最大池化后得到的图像尺寸是一样的,并且反池化层的窗口和步长与之前最大池化层是一致的。最后,我们将输入和索引传入反池化层,得到与原始图像尺寸相同的图像。

线性层

线性层 (Linear Layer) 又称 全连接层 (Full-connected Layer),其每个神经元与上一层所有神经元相连,实现对前一层的 线性组合/线性变换

在卷积神经网络进行分类的时候,在输出之前,我们通常会采用一个全连接层对特征进行处理,在 PyTorch 中,全连接层又称为线性层,因为如果不考虑激活函数的非线性性质,那么全连接层就是对输入数据进行一个线性组合。

image-20230112220932547

每个神经元都和前一层中的所有神经元相连,每个神经元的计算方式是对上一层的加权求和的过程。因此,线性层可以采用矩阵乘法来实现。注意,上图中我们暂时忽略了偏置项。

nn.Linear

功能:对一维信号(向量)进行线性组合。

nn.Linear(in_features, out_features, bias=True)

主要参数

  • in_features:输入结点数。
  • out_features:输出结点数。
  • bias:是否需要偏置。

计算公式

代码示例

inputs = torch.tensor([[1., 2, 3]])
linear_layer = nn.Linear(3, 4)
linear_layer.weight.data = torch.tensor([[1., 1., 1.],
                                         [2., 2., 2.],
                                         [3., 3., 3.],
                                         [4., 4., 4.]])
linear_layer.bias.data.fill_(0.5)
output = linear_layer(inputs)

print(inputs, inputs.shape)
print(linear_layer.weight.data, linear_layer.weight.data.shape)
print(output, output.shape)

输出结果:

tensor([[1., 2., 3.]]) torch.Size([1, 3])
tensor([[1., 1., 1.],
        [2., 2., 2.],
        [3., 3., 3.],
        [4., 4., 4.]]) torch.Size([4, 3])
tensor([[ 6.5000, 12.5000, 18.5000, 24.5000]], grad_fn=<AddmmBackward>) torch.Size([1, 4])

激活函数层

激活函数 (Activation Function) 是对特征进行非线性变换,赋予多层神经网络具有 深度 的意义。

image-20230112220942986

在上面最后一步中,由于矩阵乘法的结合性,我们可以把右边三个权重矩阵先结合相乘,可以得到一个大的权重矩阵 W。这样我们可以看到,我们的 Output 实际上就是输入 X 乘以一个大的权重矩阵 W。因此,这里的三层线性全连接层实际上等价于一个一层的全连接层,这是由于线性运算当中矩阵乘法的结合性导致的,并且这里我们没有引入非线性激活函数。如果加上 非线性激活函数,这一结论将不再成立,因此我们说,激活函数赋予了多层神经网络具有 深度 的意义。

nn.Sigmoid

img

计算公式

梯度公式

特性

  • 输出值在 (0,1),符合概率性质。
  • 导数范围是 [0,0.25],容易导致梯度消失。
  • 输出为非 0 均值,会破坏数据分布。
nn.tanh

img

计算公式

梯度公式

特性

  • 输出值在 (−1,1),数据符合 0 均值。
  • 导数范围是 (0,1),容易导致梯度消失。
nn.ReLU

img

计算公式

梯度公式

特性

  • 输出值均为正数,负半轴导致死神经元。
  • 导数是 1,可以缓解梯度消失,但容易引发梯度爆炸。

针对 ReLU 激活函数负半轴死神经元的问题,有以下几种改进方式:

img

nn.LeakyReLU
  • negative_slope:负半轴斜率。
nn.PReLU
  • init:可学习斜率。
nn.RReLU
  • lower:均匀分布下限。
  • upper:均匀分布上限。

总结

本节课中,我们学习了 nn 模块中池化层、线性层和激活函数层。在池化层中有正常的最大值池化、均值池化,还有图像分割任务中常用的反池化 —— MaxUnpool;在激活函数中我们学习了 Sigmoid、Tanh 和 Relu,以及 Relu 的各种变体,如 LeakyReLU、PReLU、RReLU。下节课中,我们将学习网络层权值的初始化。

权值初始化

在前几节课中,我们学习了如何搭建网络模型。在网络模型搭建好之后,有一个非常重要的步骤,就是对模型中的权值进行初始化:正确的权值初始化可以加快模型的收敛,而不适当的权值初始化可以会引发梯度消失或者爆炸,最终导致模型无法训练。本节课,我们将学习如何进行权值初始化。

梯度消失与爆炸

这里,我们以上节课中提到的一个三层的全连接网络为例。我们来看一下第二个隐藏层中的权值 W2 的梯度是怎么求取的。

image-20230112221003406

从公式角度来看,为了防止发生梯度消失或者爆炸,我们必须严格控制网络层输出值的尺度范围,即每个网络层的输出值不能太大或者太小。

代码示例

100 个线性层的简单叠加

import torch
import torch.nn as nn
from tools.common_tools import set_seed

set_seed(1)  # 设置随机种子


class MLP(nn.Module):
    def __init__(self, neural_num, layers):
        super(MLP, self).__init__()
        self.linears = nn.ModuleList([nn.Linear(neural_num, neural_num, bias=False) for i in range(layers)])
        self.neural_num = neural_num

    def forward(self, x):
        for (i, linear) in enumerate(self.linears):
            x = linear(x)
        return x

    def initialize(self):
        for m in self.modules():
            if isinstance(m, nn.Linear):
                nn.init.normal_(m.weight.data)  # normal: mean=0, std=1


layer_nums = 100  # 网络层数
neural_nums = 256  # 每层的神经元个数
batch_size = 16  # 输入数据的 batch size

net = MLP(neural_nums, layer_nums)
net.initialize()

inputs = torch.randn((batch_size, neural_nums))  # normal: mean=0, std=1
output = net(inputs)
print(output)

输出结果:

tensor([[nan, nan, nan,  ..., nan, nan, nan],
        [nan, nan, nan,  ..., nan, nan, nan],
        [nan, nan, nan,  ..., nan, nan, nan],
        ...,
        [nan, nan, nan,  ..., nan, nan, nan],
        [nan, nan, nan,  ..., nan, nan, nan],
        [nan, nan, nan,  ..., nan, nan, nan]], grad_fn=<MmBackward>) 

我们发现 output 中的每一个值都是 nan,这表明我们的数据值可能非常大或者非常小,已经超出了当前精度能够表示的范围。我们可以修改一下 forward 函数,来看一下什么时候我们的数据变为了 nan。这里,我们采用标准差作为指标来衡量数据的尺度范围。首先我们打印出每层的标准差,接着进行一个 if 判断,如果 x 的标准差变为 nan 了则停止前向传播。

def forward(self, x):
    for (i, linear) in enumerate(self.linears):
        x = linear(x)

        print("layer:{}, std:{}".format(i, x.std()))  # 打印每层的标准差
        if torch.isnan(x.std()):
            print("output is nan in {} layers".format(i))
            break  # 如果 x 的标准差变为 nan 则停止前向传播

        return x

输出结果:

layer:0, std:15.959932327270508
layer:1, std:256.6237487792969
layer:2, std:4107.24560546875
layer:3, std:65576.8125
layer:4, std:1045011.875
layer:5, std:17110408.0
layer:6, std:275461408.0
layer:7, std:4402537984.0
layer:8, std:71323615232.0
layer:9, std:1148104736768.0
layer:10, std:17911758454784.0
layer:11, std:283574846619648.0
layer:12, std:4480599809064960.0
layer:13, std:7.196814275405414e+16
layer:14, std:1.1507761512626258e+18
layer:15, std:1.853110740188555e+19
layer:16, std:2.9677725826641455e+20
layer:17, std:4.780376223769898e+21
layer:18, std:7.613223480799065e+22
layer:19, std:1.2092652108825478e+24
layer:20, std:1.923257075956356e+25
layer:21, std:3.134467063655912e+26
layer:22, std:5.014437766285408e+27
layer:23, std:8.066615144249704e+28
layer:24, std:1.2392661553516338e+30
layer:25, std:1.9455688099759845e+31
layer:26, std:3.0238180658999113e+32
layer:27, std:4.950357571077011e+33
layer:28, std:8.150925520353362e+34
layer:29, std:1.322983152787379e+36
layer:30, std:2.0786820453988485e+37
layer:31, std:nan
output is nan in 31 layers
tensor([[        inf, -2.6817e+38,         inf,  ...,         inf,
                 inf,         inf],
        [       -inf,        -inf,  1.4387e+38,  ..., -1.3409e+38,
         -1.9659e+38,        -inf],
        [-1.5873e+37,         inf,        -inf,  ...,         inf,
                -inf,  1.1484e+38],
        ...,
        [ 2.7754e+38, -1.6783e+38, -1.5531e+38,  ...,         inf,
         -9.9440e+37, -2.5132e+38],
        [-7.7184e+37,        -inf,         inf,  ..., -2.6505e+38,
                 inf,         inf],
        [        inf,         inf,        -inf,  ...,        -inf,
                 inf,  1.7432e+38]], grad_fn=<MmBackward>)

可以看到,当进行到 31 层的时候,数据的标准差就已经变为 nan 了。我们看到,在第 31 层的时候,数据的值都非常大或者非常小,再往后传播,计算机当前的精度就已经没办法去表示这些特别大或者特别小的数据了。另外,可以看到每一层网络的标准差都是逐渐增大的,直到第 31 层,大约在 1038∼1039 之间,而这已经超出了我们当前精度可以表示的数据范围。

下面我们通过方差的公式推导来观察为什么网络层输出的标准差会越来越大,最终超出可表示的范围。假设 X 和 Y 是两个相互独立的随机变量,我们知道:

然后,我们有:

如果 E(X)=0,E(Y)=0,那么我们有:

下面我们来计算网络层神经元的标准差:

image-20230112221014828

由于 X 和 W 都是均值为 0,标准差为 1,我们有:

所以,

可以看到,第一个隐藏层中神经元的方差变为了 n,而输入 X 的方差为 1。也就是说,经过第一个网络层 H1 的前向传播,数据的方差扩大了 n 倍,标准差扩大了 n 倍。同理,如果继续传播到下一个隐藏层 H2,通过公式推导,可知该层神经元的标准差为 n。这样不断传播下去,每经过一层,输出数据的尺度范围都将不断扩大 n 倍,最终将超出我们的精度可表示的范围,变为 nan

在代码中,我们设置的每层网络中神经元个数 n=256,所以 n=16。我们来看一下前面输出结果中的每个网络层输出的标准差是否符合这一规律:

  • 第 0 层数据标准差为 15.959932327270508≈16
  • 第 1 层数据标准差为 256.6237487792969≈162=256
  • 第 2 层数据标准差为 4107.24560546875≈163=4096
  • 第 3 层数据标准差为 65576.8125≈164=65536
  • ……

每经过一层,数据的标准差都会扩大 16 倍,经过一层层传播后,数据的标准差将变得非常大,最终在第 31 层时超出了精度可表示的范围,即为 nan

下面我们将每层神经元个数修改为 n=400,所以 n=20,观察结果是否会发生相应的变化:

layer_nums = 100  # 网络层数
neural_nums = 400  # 每层的神经元个数
batch_size = 16  # 输入数据的 batch size

输出结果:

layer:0, std:20.191545486450195
layer:1, std:406.2967834472656
layer:2, std:8196.0322265625
layer:3, std:164936.546875
layer:4, std:3324399.75
layer:5, std:65078964.0
layer:6, std:1294259712.0
layer:7, std:25718734848.0
layer:8, std:509478502400.0
layer:9, std:10142528569344.0
layer:10, std:204187744862208.0
layer:11, std:4146330289045504.0
layer:12, std:8.175371463688192e+16
layer:13, std:1.6178185228915835e+18
layer:14, std:3.201268126493075e+19
layer:15, std:6.43244420071468e+20
layer:16, std:1.2768073112864894e+22
layer:17, std:2.5327442663597998e+23
layer:18, std:4.97064812888673e+24
layer:19, std:9.969679340542473e+25
layer:20, std:1.9616922876332235e+27
layer:21, std:3.926491184057203e+28
layer:22, std:7.928349353787082e+29
layer:23, std:1.5731294716685355e+31
layer:24, std:3.156214979388958e+32
layer:25, std:6.18353463606124e+33
layer:26, std:1.2453666891690611e+35
layer:27, std:2.467429285844339e+36
layer:28, std:4.977222187097705e+37
layer:29, std:nan
output is nan in 29 layers
tensor([[-inf, inf, inf,  ..., -inf, nan, nan],
        [nan, nan, inf,  ..., -inf, -inf, nan],
        [nan, -inf, nan,  ..., inf, nan, nan],
        ...,
        [nan, -inf, -inf,  ..., -inf, nan, nan],
        [inf, -inf, nan,  ..., inf, -inf, nan],
        [inf, nan, inf,  ..., inf, nan, inf]], grad_fn=<MmBackward>)

可以看到:

  • 第 0 层数据标准差为 20.191545486450195≈20
  • 第 1 层数据标准差为 406.2967834472656≈202=400
  • 第 2 层数据标准差为 8196.0322265625≈203=8000
  • 第 3 层数据标准差为 164936.546875≈204=160000
  • ……

每经过一层,数据的标准差大约会扩大 20 倍,最终在第 29 层时超出了精度可表示的范围,变为 nan

从前面的公式中可以看到,每个网络层输出数据的标准差由三个因素决定:网络层的神经元个数 n、输入值 X 的方差 Var(X),以及网络层权值 W 的方差 Var(W)。因此,如果我们希望让网络层输出数据的方差保持尺度不变,那么我们必须令其方差等于 1,即:

因此,

所以,当我们将网络层权值的标准差设为 $\sqrt{\frac{1}{n}}$

时,输出数据的标准差将变为 1。

下面我们修改代码,使用一个均值为 0,标准差为$\sqrt{\frac{1}{n}} $的分布来初始化权值矩阵 W,观察网络层输出数据的标准差会如何变化:

def initialize(self):
   for m in self.modules():
       if isinstance(m, nn.Linear):
           nn.init.normal_(m.weight.data, std=np.sqrt(1/self.neural_num))    # normal: mean=0, std=sqrt(1/n)

输出结果:

layer:0, std:0.9974957704544067
layer:1, std:1.0024365186691284
layer:2, std:1.002745509147644
layer:3, std:1.0006227493286133
layer:4, std:0.9966009855270386
layer:5, std:1.019859790802002
layer:6, std:1.026173710823059
layer:7, std:1.0250457525253296
layer:8, std:1.0378952026367188
layer:9, std:1.0441951751708984
layer:10, std:1.0181655883789062
layer:11, std:1.0074602365493774
layer:12, std:0.9948930144309998
layer:13, std:0.9987586140632629
layer:14, std:0.9981392025947571
layer:15, std:1.0045733451843262
layer:16, std:1.0055204629898071
layer:17, std:1.0122840404510498
layer:18, std:1.0076017379760742
layer:19, std:1.000280737876892
layer:20, std:0.9943006038665771
layer:21, std:1.012800931930542
layer:22, std:1.012657642364502
layer:23, std:1.018149971961975
layer:24, std:0.9776086211204529
layer:25, std:0.9592394828796387
layer:26, std:0.9317858815193176
layer:27, std:0.9534041881561279
layer:28, std:0.9811319708824158
layer:29, std:0.9953019022941589
layer:30, std:0.9773916006088257
layer:31, std:0.9655940532684326
layer:32, std:0.9270440936088562
layer:33, std:0.9329946637153625
layer:34, std:0.9311841726303101
layer:35, std:0.9354336261749268
layer:36, std:0.9492132067680359
layer:37, std:0.9679954648017883
layer:38, std:0.9849981665611267
layer:39, std:0.9982335567474365
layer:40, std:0.9616852402687073
layer:41, std:0.9439758658409119
layer:42, std:0.9631161093711853
layer:43, std:0.958673894405365
layer:44, std:0.9675614237785339
layer:45, std:0.9837557077407837
layer:46, std:0.9867278337478638
layer:47, std:0.9920817017555237
layer:48, std:0.9650403261184692
layer:49, std:0.9991624355316162
layer:50, std:0.9946174025535583
layer:51, std:0.9662044048309326
layer:52, std:0.9827387928962708
layer:53, std:0.9887880086898804
layer:54, std:0.9932605624198914
layer:55, std:1.0237400531768799
layer:56, std:0.9702046513557434
layer:57, std:1.0045380592346191
layer:58, std:0.9943899512290955
layer:59, std:0.9900636076927185
layer:60, std:0.99446702003479
layer:61, std:0.9768352508544922
layer:62, std:0.9797843098640442
layer:63, std:0.9951220750808716
layer:64, std:0.9980446696281433
layer:65, std:1.0086933374404907
layer:66, std:1.0276142358779907
layer:67, std:1.0429234504699707
layer:68, std:1.0197855234146118
layer:69, std:1.0319130420684814
layer:70, std:1.0540012121200562
layer:71, std:1.026781439781189
layer:72, std:1.0331352949142456
layer:73, std:1.0666675567626953
layer:74, std:1.0413838624954224
layer:75, std:1.0733673572540283
layer:76, std:1.0404183864593506
layer:77, std:1.0344083309173584
layer:78, std:1.0022705793380737
layer:79, std:0.99835205078125
layer:80, std:0.9732587337493896
layer:81, std:0.9777462482452393
layer:82, std:0.9753198623657227
layer:83, std:0.9938382506370544
layer:84, std:0.9472599029541016
layer:85, std:0.9511011242866516
layer:86, std:0.9737769961357117
layer:87, std:1.005651831626892
layer:88, std:1.0043526887893677
layer:89, std:0.9889539480209351
layer:90, std:1.0130352973937988
layer:91, std:1.0030947923660278
layer:92, std:0.9993206262588501
layer:93, std:1.0342745780944824
layer:94, std:1.031973123550415
layer:95, std:1.0413124561309814
layer:96, std:1.0817031860351562
layer:97, std:1.128799557685852
layer:98, std:1.1617802381515503
layer:99, std:1.2215303182601929
tensor([[-1.0696, -1.1373,  0.5047,  ..., -0.4766,  1.5904, -0.1076],
        [ 0.4572,  1.6211,  1.9659,  ..., -0.3558, -1.1235,  0.0979],
        [ 0.3908, -0.9998, -0.8680,  ..., -2.4161,  0.5035,  0.2814],
        ...,
        [ 0.1876,  0.7971, -0.5918,  ...,  0.5395, -0.8932,  0.1211],
        [-0.0102, -1.5027, -2.6860,  ...,  0.6954, -0.1858, -0.8027],
        [-0.5871, -1.3739, -2.9027,  ...,  1.6734,  0.5094, -0.9986]],
       grad_fn=<MmBackward>)

可以看到,第 99 层的输出值都在一个比较正常的范围,并且每一层输出数据的标准差都在 1 附近,所以现在我们得到了一个比较理想的输出数据分布。代码实验的结果也验证了我们前面公式推导的正确性:通过采用合适的权值初始化方法,可以使得多层全连接网络的输出值的数据尺度维持在一定范围内,而不会变得过大或者过小。

在上面的例子中,我们通过权重初始化保证了每层输出数据的方差为 1,但是这里我们还没有考虑激活函数的存在。下面我们看一下 具有激活函数时的权值初始化。我们在前向传播 forward 函数中加入 tanh 激活函数:

def forward(self, x):
    for (i, linear) in enumerate(self.linears):
        x = linear(x)
        x = torch.tanh(x)

        print("layer:{}, std:{}".format(i, x.std()))  # 打印每层的标准差
        if torch.isnan(x.std()):
            print("output is nan in {} layers".format(i))
            break

    return x

输出结果:

layer:0, std:0.6273701786994934
layer:1, std:0.48910173773765564
layer:2, std:0.4099564850330353
layer:3, std:0.35637012124061584
layer:4, std:0.32117360830307007
layer:5, std:0.2981105148792267
layer:6, std:0.27730831503868103
layer:7, std:0.2589356303215027
layer:8, std:0.2468511462211609
layer:9, std:0.23721906542778015
layer:10, std:0.22171513736248016
layer:11, std:0.21079954504966736
layer:12, std:0.19820132851600647
layer:13, std:0.19069305062294006
layer:14, std:0.18555502593517303
layer:15, std:0.17953835427761078
layer:16, std:0.17485806345939636
layer:17, std:0.1702701896429062
layer:18, std:0.16508983075618744
layer:19, std:0.1591130942106247
layer:20, std:0.15480300784111023
layer:21, std:0.15263864398002625
layer:22, std:0.148549422621727
layer:23, std:0.14617665112018585
layer:24, std:0.13876432180404663
layer:25, std:0.13316625356674194
layer:26, std:0.12660598754882812
layer:27, std:0.12537942826747894
layer:28, std:0.12535445392131805
layer:29, std:0.12589804828166962
layer:30, std:0.11994210630655289
layer:31, std:0.11700887233018875
layer:32, std:0.11137297749519348
layer:33, std:0.11154612898826599
layer:34, std:0.10991233587265015
layer:35, std:0.10996390879154205
layer:36, std:0.10969001054763794
layer:37, std:0.10975216329097748
layer:38, std:0.11063200235366821
layer:39, std:0.11021336913108826
layer:40, std:0.10465587675571442
layer:41, std:0.10141163319349289
layer:42, std:0.1026025265455246
layer:43, std:0.10079070925712585
layer:44, std:0.10096712410449982
layer:45, std:0.10117629915475845
layer:46, std:0.10145658999681473
layer:47, std:0.09987485408782959
layer:48, std:0.09677786380052567
layer:49, std:0.099615179002285
layer:50, std:0.09867013245820999
layer:51, std:0.09398546814918518
layer:52, std:0.09388342499732971
layer:53, std:0.09352942556142807
layer:54, std:0.09336657077074051
layer:55, std:0.0948176234960556
layer:56, std:0.08856320381164551
layer:57, std:0.09024856984615326
layer:58, std:0.088644839823246
layer:59, std:0.08766943216323853
layer:60, std:0.08726289123296738
layer:61, std:0.08623495697975159
layer:62, std:0.08549778908491135
layer:63, std:0.0855521708726883
layer:64, std:0.0853666365146637
layer:65, std:0.08462794870138168
layer:66, std:0.0852193832397461
layer:67, std:0.08562126755714417
layer:68, std:0.08368431031703949
layer:69, std:0.08476374298334122
layer:70, std:0.0853630006313324
layer:71, std:0.08237560093402863
layer:72, std:0.08133518695831299
layer:73, std:0.08416958898305893
layer:74, std:0.08226992189884186
layer:75, std:0.08379074186086655
layer:76, std:0.08003697544336319
layer:77, std:0.07888862490653992
layer:78, std:0.07618380337953568
layer:79, std:0.07458437979221344
layer:80, std:0.07207276672124863
layer:81, std:0.07079190015792847
layer:82, std:0.0712786465883255
layer:83, std:0.07165777683258057
layer:84, std:0.06893909722566605
layer:85, std:0.0690247192978859
layer:86, std:0.07030878216028214
layer:87, std:0.07283661514520645
layer:88, std:0.07280214875936508
layer:89, std:0.07130246609449387
layer:90, std:0.07225215435028076
layer:91, std:0.0712454542517662
layer:92, std:0.07088854163885117
layer:93, std:0.0730612576007843
layer:94, std:0.07276967912912369
layer:95, std:0.07259567081928253
layer:96, std:0.07586522400379181
layer:97, std:0.07769150286912918
layer:98, std:0.07842090725898743
layer:99, std:0.08206238597631454
tensor([[-0.1103, -0.0739,  0.1278,  ..., -0.0508,  0.1544, -0.0107],
        [ 0.0807,  0.1208,  0.0030,  ..., -0.0385, -0.1887, -0.0294],
        [ 0.0321, -0.0833, -0.1482,  ..., -0.1133,  0.0206,  0.0155],
        ...,
        [ 0.0108,  0.0560, -0.1099,  ...,  0.0459, -0.0961, -0.0124],
        [ 0.0398, -0.0874, -0.2312,  ...,  0.0294, -0.0562, -0.0556],
        [-0.0234, -0.0297, -0.1155,  ...,  0.1143,  0.0083, -0.0675]],
       grad_fn=<TanhBackward>)

可以看到,随着网络层的前向传播,每层输出值的标准差越来越小,最终可能会导致 梯度消失,这并不是我们所希望看到的。

Xavier 初始化

参考文献Understanding the difficulty of training deep feedforward neural networks

针对上面具有激活函数情况的问题,2010 年 Xavier 在一篇论文中详细探讨了在具有激活函数的情况下应该如何初始化的问题。在文献中,结合方差一致性原则 (即让每层网络输出值的方差尽量在 1 附近),同时作者对 Sigmoid、tanh 这类饱和激活函数进行分析。

方差一致性:保持数据尺度维持在恰当范围,通常方差为 1。

激活函数:饱和函数,如 Sigmoid、Tanh。

通过论文中的公式推导,我们可以得到以下两个等式:

其中,ni 是输入层的神经元个数,$n_{i+1}$是输出层的神经元个数。即我们同时考虑了前向传播和反向传播过程中的数据尺度问题。

同时,结合方差一致性原则,我们可以得到权值 W 的方差为:

通常,Xavier 采用的是均匀分布。下面我们来推导均匀分布是上限和下限,这里我们假设上限是 a,那么下限为 −a,因为我们通常采用的是零均值,所以上下限之间是对称关系。

根据均匀分布的方差公式,我们得到:

然后,我们有:

所以,

代码示例

我们可以通过手动计算实现 Xavier 初始化:

def initialize(self):
    for m in self.modules():
        if isinstance(m, nn.Linear):
            a = np.sqrt(6 / (self.neural_num + self.neural_num))
            tanh_gain = nn.init.calculate_gain('tanh')  # 计算激活函数的增益
            a *= tanh_gain
            nn.init.uniform_(m.weight.data, -a, a)

另外,PyTorch 中也内置了 Xavier 初始化方法,其结果和我们手动计算的结果是一致的:

def initialize(self):
    for m in self.modules():
        if isinstance(m, nn.Linear):
            tanh_gain = nn.init.calculate_gain('tanh')
            nn.init.xavier_uniform_(m.weight.data, gain=tanh_gain)

输出结果:

layer:0, std:0.7571136355400085
layer:1, std:0.6924336552619934
layer:2, std:0.6677976846694946
layer:3, std:0.6551960110664368
layer:4, std:0.655646800994873
layer:5, std:0.6536089777946472
layer:6, std:0.6500504612922668
layer:7, std:0.6465446949005127
layer:8, std:0.6456685662269592
layer:9, std:0.6414617896080017
layer:10, std:0.6423627734184265
layer:11, std:0.6509683728218079
layer:12, std:0.6584846377372742
layer:13, std:0.6530249118804932
layer:14, std:0.6528729796409607
layer:15, std:0.6523412466049194
layer:16, std:0.6534921526908875
layer:17, std:0.6540238261222839
layer:18, std:0.6477403044700623
layer:19, std:0.6469652652740479
layer:20, std:0.6441705822944641
layer:21, std:0.6484488248825073
layer:22, std:0.6512865424156189
layer:23, std:0.6525684595108032
layer:24, std:0.6531476378440857
layer:25, std:0.6488809585571289
layer:26, std:0.6533839702606201
layer:27, std:0.6482065320014954
layer:28, std:0.6471589803695679
layer:29, std:0.6553042531013489
layer:30, std:0.6560811400413513
layer:31, std:0.6522760987281799
layer:32, std:0.6499098539352417
layer:33, std:0.6568747758865356
layer:34, std:0.6544532179832458
layer:35, std:0.6535674929618835
layer:36, std:0.6508696675300598
layer:37, std:0.6428772807121277
layer:38, std:0.6495102643966675
layer:39, std:0.6479291319847107
layer:40, std:0.6470604538917542
layer:41, std:0.6513484716415405
layer:42, std:0.6503545045852661
layer:43, std:0.6458993554115295
layer:44, std:0.6517387628555298
layer:45, std:0.6520006060600281
layer:46, std:0.6539937257766724
layer:47, std:0.6537032723426819
layer:48, std:0.6516646146774292
layer:49, std:0.6535552740097046
layer:50, std:0.6464877724647522
layer:51, std:0.6491119265556335
layer:52, std:0.6455202102661133
layer:53, std:0.6520237326622009
layer:54, std:0.6531855463981628
layer:55, std:0.6627183556556702
layer:56, std:0.6544181108474731
layer:57, std:0.6501768827438354
layer:58, std:0.6510448455810547
layer:59, std:0.6549468040466309
layer:60, std:0.6529951691627502
layer:61, std:0.6515748500823975
layer:62, std:0.6453633904457092
layer:63, std:0.644793689250946
layer:64, std:0.6489539742469788
layer:65, std:0.6553947925567627
layer:66, std:0.6535270810127258
layer:67, std:0.6528791785240173
layer:68, std:0.6492816209793091
layer:69, std:0.6596571207046509
layer:70, std:0.6536712646484375
layer:71, std:0.6498764157295227
layer:72, std:0.6538681387901306
layer:73, std:0.64595627784729
layer:74, std:0.6543275117874146
layer:75, std:0.6525828838348389
layer:76, std:0.6462088227272034
layer:77, std:0.6534948945045471
layer:78, std:0.6461930871009827
layer:79, std:0.6457878947257996
layer:80, std:0.6481245160102844
layer:81, std:0.6496317386627197
layer:82, std:0.6516988277435303
layer:83, std:0.6485154032707214
layer:84, std:0.6395408511161804
layer:85, std:0.6498249173164368
layer:86, std:0.6510564088821411
layer:87, std:0.6505221724510193
layer:88, std:0.6573457717895508
layer:89, std:0.6529723405838013
layer:90, std:0.6536353230476379
layer:91, std:0.6497699022293091
layer:92, std:0.6459059715270996
layer:93, std:0.6459072232246399
layer:94, std:0.6530925631523132
layer:95, std:0.6515892148017883
layer:96, std:0.6434286832809448
layer:97, std:0.6425578594207764
layer:98, std:0.6407340168952942
layer:99, std:0.6442393660545349
tensor([[ 0.1133,  0.1239,  0.8211,  ...,  0.9411, -0.6334,  0.5155],
        [-0.9585, -0.2371,  0.8548,  ..., -0.2339,  0.9326,  0.0114],
        [ 0.9487, -0.2279,  0.8735,  ..., -0.9593,  0.7922,  0.6263],
        ...,
        [ 0.7257,  0.0800, -0.4440,  ..., -0.9589,  0.2604,  0.5402],
        [-0.9572,  0.5179, -0.8041,  ..., -0.4298, -0.6087,  0.9679],
        [ 0.6105,  0.3994,  0.1072,  ...,  0.3904, -0.5274,  0.0776]],
       grad_fn=<TanhBackward>)

可以看到,每层网络输出值的标准差都在 0.65 左右,这表明每层网络的输出值都不会过大或者过小。并且,最后第 99 层的输出值也在一个比较正常的范围内。

Kaiming 初始化

虽然在 2010 年,Xavier 针对诸如 Sigmoid、tanh 这类饱和激活函数给出了有效的初始化方法。但是,也是在同一年 Xnet 出现之后,非饱和激活函数 ReLU 被广泛使用,由于非饱和函数的性质,Xavier 初始化方法将不再适用。

在下面的代码中,将激活函数改为 ReLU,并且仍然使用 Xavier 初始化方法,我们来观察一下网络层的输出:

def forward(self, x):
    for (i, linear) in enumerate(self.linears):
        x = linear(x)
        x = torch.relu(x)

        print("layer:{}, std:{}".format(i, x.std()))  # 打印每层的标准差
        if torch.isnan(x.std()):
            print("output is nan in {} layers".format(i))
            break

    return x

输出结果:

layer:0, std:0.9689465165138245
layer:1, std:1.0872339010238647
layer:2, std:1.2967971563339233
layer:3, std:1.4487521648406982
layer:4, std:1.8563750982284546
layer:5, std:2.2424941062927246
layer:6, std:2.679966449737549
layer:7, std:3.2177586555480957
layer:8, std:3.8579354286193848
layer:9, std:4.413454532623291
layer:10, std:5.518202781677246
layer:11, std:6.072154521942139
layer:12, std:7.441657543182373
layer:13, std:8.963356971740723
layer:14, std:10.747811317443848
layer:15, std:13.216470718383789
layer:16, std:15.070353507995605
layer:17, std:17.297853469848633
layer:18, std:19.603160858154297
layer:19, std:22.72492218017578
layer:20, std:27.811525344848633
layer:21, std:34.64209747314453
layer:22, std:43.16114044189453
layer:23, std:51.901859283447266
layer:24, std:59.3619384765625
layer:25, std:63.65275955200195
layer:26, std:61.95321273803711
layer:27, std:79.72232055664062
layer:28, std:99.41972351074219
layer:29, std:118.13148498535156
layer:30, std:128.12930297851562
layer:31, std:140.68907165527344
layer:32, std:165.6183319091797
layer:33, std:196.19956970214844
layer:34, std:214.2675323486328
layer:35, std:282.7183532714844
layer:36, std:317.1474304199219
layer:37, std:373.9003601074219
layer:38, std:412.70892333984375
layer:39, std:529.4519653320312
layer:40, std:532.9295654296875
layer:41, std:630.9380493164062
layer:42, std:762.7489624023438
layer:43, std:813.0692138671875
layer:44, std:1022.0352783203125
layer:45, std:1363.53759765625
layer:46, std:1734.6246337890625
layer:47, std:1899.3427734375
layer:48, std:2251.1640625
layer:49, std:2680.478759765625
layer:50, std:3370.64794921875
layer:51, std:4003.856201171875
layer:52, std:4598.98779296875
layer:53, std:5199.58447265625
layer:54, std:6399.32568359375
layer:55, std:8127.16064453125
layer:56, std:9794.875
layer:57, std:11728.7431640625
layer:58, std:15471.70703125
layer:59, std:19942.44921875
layer:60, std:22642.23046875
layer:61, std:28904.16796875
layer:62, std:37538.265625
layer:63, std:41843.19921875
layer:64, std:48306.828125
layer:65, std:56072.6171875
layer:66, std:59877.83984375
layer:67, std:57911.44140625
layer:68, std:68525.5859375
layer:69, std:78614.9609375
layer:70, std:103845.0
layer:71, std:121762.9375
layer:72, std:128452.984375
layer:73, std:146725.484375
layer:74, std:168575.125
layer:75, std:176617.84375
layer:76, std:202430.046875
layer:77, std:247756.625
layer:78, std:310793.875
layer:79, std:374327.34375
layer:80, std:456118.53125
layer:81, std:545246.25
layer:82, std:550071.8125
layer:83, std:653713.0
layer:84, std:831133.9375
layer:85, std:1045186.3125
layer:86, std:1184264.0
layer:87, std:1334159.5
layer:88, std:1589417.75
layer:89, std:1783507.25
layer:90, std:2239068.0
layer:91, std:2429546.0
layer:92, std:2928562.0
layer:93, std:2883037.75
layer:94, std:3230928.75
layer:95, std:3661650.75
layer:96, std:4741352.0
layer:97, std:5300345.0
layer:98, std:6797732.0
layer:99, std:7640650.0
tensor([[       0.0000,  3028736.2500, 12379591.0000,  ...,
          3593889.2500,        0.0000, 24658882.0000],
        [       0.0000,  2758786.0000, 11016991.0000,  ...,
          2970399.7500,        0.0000, 23173860.0000],
        [       0.0000,  2909416.2500, 13117423.0000,  ...,
          3867089.2500,        0.0000, 28463550.0000],
        ...,
        [       0.0000,  3913293.2500, 15489672.0000,  ...,
          5777762.0000,        0.0000, 33226552.0000],
        [       0.0000,  3673798.2500, 12739622.0000,  ...,
          4193501.0000,        0.0000, 26862400.0000],
        [       0.0000,  1913917.0000, 10243700.0000,  ...,
          4573404.0000,        0.0000, 22720538.0000]],
       grad_fn=<ReluBackward0>)

可以看到,当激活函数改为 ReLU 之后,如果我们仍然采用 Xavier 初始化,那么每一层的输出值标准差将逐渐增大:由最初第 0 层的 1 左右逐渐增大到最终第 99 层的 764 万。这并不是我们所希望的。

针对这一问题,在 2015 年,何凯明等人在一篇论文中提出了解决方法:

参考文献Delving deep into rectifiers: Surpassing human-level performance on ImageNet classification

方差一致性:保持数据尺度维持在恰当范围,通常方差为 1。

激活函数:ReLU 及其变种。

在论文中,我们同样遵循方差一致性原则,即使得输出层方差为 1。论文中针对 ReLU 激活函数,通过公式推导,得到权值 W 的方差为:

其中,ni 为输入层的神经元个数。

进一步地,针对 ReLU 的变种,即在激活函数的负半轴上给予一定斜率的情况下,权值 W 的方差为:

其中,a 为激活函数在负半轴上的斜率。a=0 时激活函数即为原始的 ReLU。

由此得到权值 W 的标准差为:

下面我们根据这一公式进行权值初始化,并观察各网络层的输出。我们可以采用手动计算实现 Kaiming 初始化方法:

def initialize(self):
    for m in self.modules():
        if isinstance(m, nn.Linear):
            nn.init.normal_(m.weight.data, std=np.sqrt(2 / self.neural_num))

或者直接使用 PyTorch 中内置的 Kaiming 初始化方法,两者的结果是一致的:

def initialize(self):
    for m in self.modules():
        if isinstance(m, nn.Linear):
            nn.init.kaiming_normal_(m.weight.data)

输出结果:

layer:0, std:0.826629638671875
layer:1, std:0.8786815404891968
layer:2, std:0.9134422540664673
layer:3, std:0.8892471194267273
layer:4, std:0.834428071975708
layer:5, std:0.874537467956543
layer:6, std:0.7926971316337585
layer:7, std:0.7806458473205566
layer:8, std:0.8684563636779785
layer:9, std:0.9434137344360352
layer:10, std:0.964215874671936
layer:11, std:0.8896796107292175
layer:12, std:0.8287257552146912
layer:13, std:0.8519769906997681
layer:14, std:0.8354345560073853
layer:15, std:0.802306056022644
layer:16, std:0.8613607287406921
layer:17, std:0.7583686709403992
layer:18, std:0.8120225071907043
layer:19, std:0.791111171245575
layer:20, std:0.7164372801780701
layer:21, std:0.778393030166626
layer:22, std:0.8672043085098267
layer:23, std:0.874812662601471
layer:24, std:0.9020991325378418
layer:25, std:0.8585715889930725
layer:26, std:0.7824353575706482
layer:27, std:0.7968912720680237
layer:28, std:0.8984369039535522
layer:29, std:0.8704465627670288
layer:30, std:0.9860473275184631
layer:31, std:0.9080777168273926
layer:32, std:0.9140636920928955
layer:33, std:1.009956955909729
layer:34, std:0.9909380674362183
layer:35, std:1.0253208875656128
layer:36, std:0.849043607711792
layer:37, std:0.703953742980957
layer:38, std:0.7186155319213867
layer:39, std:0.7250635027885437
layer:40, std:0.7030817270278931
layer:41, std:0.6325559020042419
layer:42, std:0.6623690724372864
layer:43, std:0.6960875988006592
layer:44, std:0.7140733003616333
layer:45, std:0.632905125617981
layer:46, std:0.6458898186683655
layer:47, std:0.7354375720024109
layer:48, std:0.6710687279701233
layer:49, std:0.6939153671264648
layer:50, std:0.6889258027076721
layer:51, std:0.6331773996353149
layer:52, std:0.6029313206672668
layer:53, std:0.6145528554916382
layer:54, std:0.6636686325073242
layer:55, std:0.7440094947814941
layer:56, std:0.7972175478935242
layer:57, std:0.7606149911880493
layer:58, std:0.696868360042572
layer:59, std:0.7306802272796631
layer:60, std:0.6875627636909485
layer:61, std:0.7171440720558167
layer:62, std:0.7646605372428894
layer:63, std:0.7965086698532104
layer:64, std:0.8833740949630737
layer:65, std:0.8592952489852905
layer:66, std:0.8092936873435974
layer:67, std:0.806481122970581
layer:68, std:0.6792410612106323
layer:69, std:0.6583346128463745
layer:70, std:0.5702278017997742
layer:71, std:0.5084435939788818
layer:72, std:0.4869326055049896
layer:73, std:0.46350404620170593
layer:74, std:0.4796811640262604
layer:75, std:0.47372108697891235
layer:76, std:0.45414549112319946
layer:77, std:0.4971912205219269
layer:78, std:0.492794930934906
layer:79, std:0.4422350823879242
layer:80, std:0.4802998900413513
layer:81, std:0.5579248666763306
layer:82, std:0.5283755660057068
layer:83, std:0.5451980829238892
layer:84, std:0.6203726530075073
layer:85, std:0.6571893095970154
layer:86, std:0.703682005405426
layer:87, std:0.7321067452430725
layer:88, std:0.6924356818199158
layer:89, std:0.6652532815933228
layer:90, std:0.6728308796882629
layer:91, std:0.6606621742248535
layer:92, std:0.6094604730606079
layer:93, std:0.6019102334976196
layer:94, std:0.595421552658081
layer:95, std:0.6624555587768555
layer:96, std:0.6377885341644287
layer:97, std:0.6079285740852356
layer:98, std:0.6579315066337585
layer:99, std:0.6668476462364197
tensor([[0.0000, 1.3437, 0.0000,  ..., 0.0000, 0.6444, 1.1867],
        [0.0000, 0.9757, 0.0000,  ..., 0.0000, 0.4645, 0.8594],
        [0.0000, 1.0023, 0.0000,  ..., 0.0000, 0.5148, 0.9196],
        ...,
        [0.0000, 1.2873, 0.0000,  ..., 0.0000, 0.6454, 1.1411],
        [0.0000, 1.3589, 0.0000,  ..., 0.0000, 0.6749, 1.2438],
        [0.0000, 1.1807, 0.0000,  ..., 0.0000, 0.5668, 1.0600]],
       grad_fn=<ReluBackward0>)

可以看到,现在每个网络层输出值的标准差都能维持在一个相同的尺度上,不会过大或者过小,并且输出值也基本都在正常范围内。

常用的权值始化方法

通过前面的例子,我们对于权值的初始化方法有了清晰的认识。我们知道,不适当的权值初始化方法会引起网络层的输出值过大或者过小,从而引发梯度的消失或者爆炸,最终导致我们的模型无法正常训练。为了避免这种现象的发生,我们必须控制各网络层输出值的尺度范围。根据公式的推断过程我们知道,我们必须使每个网络层的输出值的方差尽量在 1 附近,即遵循方差一致性原则,使得输出值方差不会过大或者过小。

下面我们来学习 PyTorch 中提供的 10 种常用的权值初始化方法:

  1. Xavier 均匀分布
  2. Xavier 正态分布
  3. Kaiming 均匀分布
  4. Kaiming 正态分布
  5. 均匀分布
  6. 正态分布
  7. 常数分布
  8. 正交矩阵初始化
  9. 单位矩阵初始化
  10. 稀疏矩阵初始化

这里可以分为 4 大类:Xavier 初始化、Kaiming 初始化、三种常用分布初始化,以及三种特殊的矩阵初始化。PyTorch 中提供了这些方法,方便我们进行权值初始化。那么,在进行权值初始化的时候,我们应该选择哪种初始化方法呢?这需要具体问题具体分析,但是无论我们使用哪种初始化方法,都需要遵循方差一致性原则,即每层输出值的方差不能太大或者太小,尽量保持在 1 附近。

下面我们来学习一个特殊的函数 calculate_gain,它被用于计算激活函数的方差变化尺度。

nn.init.calculate_gain

功能:计算激活函数的 方差变化尺度

nn.init.calculate_gain(nonlinearity, param=None) 

主要参数

  • nonlinearity:激活函数名称,例如:tanh、Sigmoid、ReLU 等等。
  • param:激活函数的参数,例如:Leaky ReLU 中的 negative_slop

实际上,该函数计算的是输入数据的方差除以输出数据的方差,即方差变化的比例。

代码示例

x = torch.randn(10000)  # 通过标准正态分布创建 10000 个数据点
out = torch.tanh(x)  # 将数据输入 tanh 函数

gain = x.std() / out.std()  # 手动计算激活函数的标准差变化尺度
print('gain:{}'.format(gain))

tanh_gain = nn.init.calculate_gain('tanh')  # calculate_gain 计算的激活函数的标准差增益
print('tanh_gain in PyTorch:', tanh_gain)

输出结果:

gain:1.5982500314712524
tanh_gain in PyTorch: 1.6666666666666667

可以看到,tanh 激活函数的标准差增益在 1.6 左右,也就是说,对于均值为 0,标准差为 1 的数据,经过 tanh 函数之后,数据的标准差会减小 1.6 倍左右。

总结

本节课中,我们学习了权值初始化方法的准则 —— 方差一致性原则,以及 Xavier 和 Kaiming 权值初始化方法。在下节课中,我们将学习损失函数。

损失函数 (一)

在前几节课中,我们学习了模型模块中的一些知识,包括如何构建模型以及怎样进行模型初始化。本节课我们将开始学习损失函数模块。

损失函数的概念

损失函数 (Loss Function):衡量模型输出与真实标签之间的差异。

下面是一个一元线性回归的拟合过程示意图:

image-20230112221101787

图中的绿色方块代表训练样本点 (xi,yi),蓝色直线代表训练得到的模型 y^=w0+w1x,其中,w0 代表截距,w1=Δy/Δx 代表斜率。可以看到,模型并没有完美地拟合每一个数据点,所以数据点和模型之间存在一个 损失 (Loss),这里我们采用垂直方向上模型输出与真实数据点之差的绝对值 |y^−y| 作为损失函数的度量。

另外,当我们谈到损失函数时,经常会涉及到以下三个概念:

  • 损失函数 (Loss Function):计算单个样本的差异。

  • 代价函数 (Cost Function):计算整个训练集 Loss 的平均值。

  • 目标函数 (Objective Function):最终需要优化的目标,通常包含代价函数和正则项。

注意,代价函数并不是越小越好,因为存在过拟合的风险。所以我们需要加上一些约束 (即正则项) 来防止模型变得过于复杂而导致过拟合,常用的有 L1 和 L2 正则项。因此,代价函数和正则项最终构成了我们的目标函数。

下面我们来看一下 PyTorch 中的 _Loss 类:

class _Loss(Module):
    def __init__(self, size_average=None, reduce=None, reduction='mean'):
        super(_Loss, self).__init__()
        if size_average is not None or reduce is not None:
            self.reduction = _Reduction.legacy_get_string(size_average, reduce)
        else:
            self.reduction = reduction

可以看到,_Loss 是继承于 Module 类的,所以从某种程度上我们可以将 _Loss 也视为一个网络层。它的初始化函数中主要有 3 个参数,其中 size_averagereduce 这两个参数即将在后续版本中被舍弃,因为 reduction 参数已经可以实现前两者的功能。

交叉熵损失函数

在分类任务中,我们经常采用的是交叉熵损失函数。在分类任务中我们常常需要计算不同类别的概率值,所以交叉熵可以用来衡量两个概率分布之间的差异,交叉熵值越低说明两个概率分布越接近。

那么为什么交叉熵值越低,两个概率分布越接近呢?这需要从它与信息熵和相对熵之间的关系说起:

我们先来看最基本的 熵 (Entropy) 的概念:熵准确来说应该叫做 信息熵 (Information Entropy),它是由信息论之父香农从热力学中借鉴过来的一个概念,用于描述某个事件的不确定性:某个事件不确定性越高,它的熵就越大。例如:“明天下雨” 这一事件要比 “明天太阳会升起” 这一事件的熵大得多,因为前者的不确定性较高。这里我们需要引入 自信息 的概念。

  • 自信息 (Self-information):用于衡量单个事件的不确定性。

    其中,P(X) 为事件 X 的概率。

  • 熵 (Entropy):自信息的期望,用于描述整个概率分布的不确定性。事件的不确定性越高,它的熵就越大。

为了更好地理解熵与事件不确定性的关系,我们来看一个示意图:

img

上面是伯努利分布 (两点分布) 的信息熵,可以看到,当事件概率为 0.5 时,它的信息熵最大,大约在 0.69 附近,即此时该事件的不确定性是最大的。注意,这里的 0.69 是在二分类模型训练过程中经常会碰到的一个 Loss 值:有时在模型训练出问题时,无论我们如何进行迭代,模型的 Loss 值始终恒定在 0.69;或者在模型刚初始化完成第一次迭代后,其 Loss 值也很可能是 0.69,这表明我们的模型当前是不具备任何判别能力的,因为其对于两个类别中的任何一个都认为概率是 0.5。

下面我们来看一下相对熵的概念:

  • 相对熵 (Relative Entropy):又称 KL 散度 (Kullback-Leibler Divergence, KLD),用于衡量两个概率分布之间的差异 (或者说距离)。注意,虽然 KL 散度可以衡量两个分布之间的距离,但它本身并不是一个距离函数,因为距离函数具有对称性,即 P 到 Q 的距离必须等于 Q 到 P 的距离,而相对熵不具备这种对称性。 其中,P 是数据的真实分布,Q 是模型拟合的分布,二者定义在相同的概率空间上。我们需要用拟合分布 Q 去逼近真实分布 P,所以相对熵不具备对称性。

下面我们再来看一下交叉熵的公式:

  • 交叉熵 (Cross Entropy):用于衡量两个分布之间的相似度。

下面我们对相对熵的公式进行展开推导变换,来观察一下相对熵与信息熵和交叉熵之间的关系:

所以,交叉熵等于信息熵加上相对熵

这里,P 为训练集中的样本分布,Q 为模型给出的分布。所以在机器学习中,我们最小化交叉熵实际上等价于最小化相对熵,因为训练集是固定的,所以 H(P) 在这里是一个常数。

nn.CrossEntropyLoss

功能nn.LogSoftmax()nn.NLLLoss() 结合,进行交叉熵计算。

nn.CrossEntropyLoss(
    weight=None,
    size_average=None,
    ignore_index=-100,
    reduce=None,
    reduction='mean'
)

主要参数

  • weight:各类别的 loss 设置权值。
  • ignore_index:忽略某个类别,不计算其 loss。
  • reduction:计算模式,可为none/sum/mean
    • none:逐个元素计算。
    • sum:所有元素求和,返回标量。
    • mean:加权平均,返回标量。

PyTorch 中 nn.CrossEntropyLoss 的交叉熵计算公式

  • 没有针对各类别 loss 设置权值的情况:

  • 对各类别 loss 设置权值的情况:

注意,这里的计算过程和交叉熵公式存在一些差异:

因为这里我们已经将一个具体数据点取出,所以这里 Σ 求和式不再需要,并且 P(xi)=1,因此公式变为:

然后,为了使输出概率在 [0,1] 之间,PyTorch 在这里使用了一个 Softmax 函数对数据进行了归一化处理,使其落在一个正常的概率值范围内。

代码示例

import torch
import torch.nn as nn
import numpy as np

# fake data
inputs = torch.tensor([[1, 2], [1, 3], [1, 3]], dtype=torch.float)
target = torch.tensor([0, 1, 1], dtype=torch.long)  # 注意 label 在这里必须设置为长整型

# ------------------------ CrossEntropy loss: reduction ----------------------
# def loss function
loss_f_none = nn.CrossEntropyLoss(weight=None, reduction='none')
loss_f_sum = nn.CrossEntropyLoss(weight=None, reduction='sum')
loss_f_mean = nn.CrossEntropyLoss(weight=None, reduction='mean')

# forward
loss_none = loss_f_none(inputs, target)
loss_sum = loss_f_sum(inputs, target)
loss_mean = loss_f_mean(inputs, target)

# view
print("Cross Entropy Loss:\n ", loss_none, loss_sum, loss_mean)

输出结果:

Cross Entropy Loss:
  tensor([1.3133, 0.1269, 0.1269]) tensor(1.5671) tensor(0.5224)

可以看到,reduction 参数项在 none 模式下,计算出的 3 个样本的 loss 值分别为 1.3133、0.1269 和 0.1269;在 sum 模式下,计算出 3 个样本的 loss 之和为 1.5671;在 mean 模式下,计算出 3 个样本的 loss 平均为 0.5224。

下面我们以第一个样本的 loss 值为例,通过手动计算来验证一下我们前面推导出的公式的正确性:

idx = 0

input_1 = inputs.detach().numpy()[idx]      # [1, 2]
target_1 = target.numpy()[idx]              # [0]

# 第一项
x_class = input_1[target_1]

# 第二项
sigma_exp_x = np.sum(list(map(np.exp, input_1)))
log_sigma_exp_x = np.log(sigma_exp_x)

# 输出loss
loss_1 = -x_class + log_sigma_exp_x

print("第一个样本的 loss 为: ", loss_1)

输出结果:

第一个样本的 loss 为:  1.3132617

下面我们来看一下针对各类别 loss 设置权值的情况:

# def loss function
# 向量长度应该与类别数量一致,如果 reduction 参数为 'mean',那么我们不需要关注
# weight 的尺度,只需要关注各类别的 weight 比例即可。
weights = torch.tensor([1, 2], dtype=torch.float)
# weights = torch.tensor([0.7, 0.3], dtype=torch.float)

loss_f_none_w = nn.CrossEntropyLoss(weight=weights, reduction='none')
loss_f_sum = nn.CrossEntropyLoss(weight=weights, reduction='sum')
loss_f_mean = nn.CrossEntropyLoss(weight=weights, reduction='mean')

# forward
loss_none_w = loss_f_none_w(inputs, target)
loss_sum = loss_f_sum(inputs, target)
loss_mean = loss_f_mean(inputs, target)

# view
print("\nweights: ", weights)
print(loss_none_w, loss_sum, loss_mean)

输出结果:

weights:  tensor([1., 2.])
tensor([1.3133, 0.2539, 0.2539]) tensor(1.8210) tensor(0.3642)

对比之前没有设置权值的结果,我们发现,在 none 模式下,由于第一个样本类别为 0,而其权值为 1,所以结果和之前一样,都是 1.3133。而第二个和第三个样本类别为 1,权值为 2,所以这里的 loss 是之前的 2 倍,即 0.2539。对于 sum 模式,其结果为三个样本的 loss 之和,即 1.8210。而对于 mean 模式,现在不再是简单地将三个 loss 相加求平均,而是采用了加权平均的计算方式:因为第一个样本权值为 1,第二个和第三个样本权值都是 2,所以一共有 1+2+2=5 份,loss 的加权均值为 1.8210/5=0.3642。

下面我们通过手动计算来验证在设置权值的情况下,mean 模式下的 loss 计算方式是否正确:

weights = torch.tensor([1, 2], dtype=torch.float)
weights_all = np.sum(list(map(lambda x: weights.numpy()[x], target.numpy())))

mean = 0
loss_sep = loss_none.detach().numpy()

for i in range(target.shape[0]):
    x_class = target.numpy()[i]
    tmp = loss_sep[i] * (weights.numpy()[x_class] / weights_all)
    mean += tmp

print(mean)

输出结果:

0.3641947731375694

可以看到,手动计算的结果和 PyTorch 中自动求取的结果一致,所以对于设置权值的情况,mean 模式下的 loss 不是简单的求和之后除以样本个数,而是除以权值的份数,即实际计算的是加权均值。

NLL/BCE/BCEWithLogits Loss

nn.NLLLoss

功能:实现负对数似然函数中的 负号功能

nn.NLLLoss(
    weight=None,
    size_average=None,
    ignore_index=-100,
    reduce=None,
    reduction='mean'
)

主要参数

  • weight:各类别的 loss 设置权值。
  • ignore_index:忽略某个类别。
  • reduction:计算模式,可为none/sum/mean
    • none:逐个元素计算。
    • sum:所有元素求和,返回标量。
    • mean:加权平均,返回标量。

计算公式

代码示例

# fake data, 这里我们使用的还是之前的数据,注意 label 在这里必须设置为 long
inputs = torch.tensor([[1, 2], [1, 3], [1, 3]], dtype=torch.float)
target = torch.tensor([0, 1, 1], dtype=torch.long)

# weights
weights = torch.tensor([1, 1], dtype=torch.float)

# NLL loss
loss_f_none_w = nn.NLLLoss(weight=weights, reduction='none')
loss_f_sum = nn.NLLLoss(weight=weights, reduction='sum')
loss_f_mean = nn.NLLLoss(weight=weights, reduction='mean')

# forward
loss_none_w = loss_f_none_w(inputs, target)
loss_sum = loss_f_sum(inputs, target)
loss_mean = loss_f_mean(inputs, target)

# view
print("\nweights: ", weights)
print("NLL Loss", loss_none_w, loss_sum, loss_mean)

输出结果:

weights:  tensor([1., 1.])
NLL Loss tensor([-1., -3., -3.]) tensor(-7.) tensor(-2.3333)

注意,这里 nn.NLLLoss 实际上只是实现了一个负号的功能。对于 none 模式:这里第一个样本是第 0 类,所以我们这里只对第一个神经元进行计算,取负号得到 NLL Loss 为 −1;第二个样本是第 1 类,我们对第二个神经元进行计算,取负号得到 NLL Loss 为 −3;第三个样本也是第 1 类,我们对第二个神经元进行计算,取负号得到 NLL Loss 为 −3。对于 sum 模式,将三个样本的 NLL Loss 求和,得到 −7。对于 mean 模式,将三个样本的 NLL Loss 加权平均,得到 −2.3333。

nn.BCELoss

功能:二分类交叉熵。

nn.BCELoss(
    weight=None,
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • weight:各类别的 loss 设置权值。
  • ignore_index:忽略某个类别。
  • reduction:计算模式,可为none/sum/mean
    • none:逐个元素计算。
    • sum:所有元素求和,返回标量。
    • mean:加权平均,返回标量。

计算公式

注意事项:由于交叉熵是衡量两个概率分布之间的差异,因此输入值取值必须在 [0,1]。

代码示例

# fake data, 这里我们设置 4 个样本,注意 label 在这里必须设置为 float
inputs = torch.tensor([[1, 2], [2, 2], [3, 4], [4, 5]], dtype=torch.float)
target = torch.tensor([[1, 0], [1, 0], [0, 1], [0, 1]], dtype=torch.float)

target_bce = target

# itarget
inputs = torch.sigmoid(inputs)  # 利用 Sigmoid 函数将输入值压缩到 [0,1]

# weights
weights = torch.tensor([1, 1], dtype=torch.float)

# BCE loss
loss_f_none_w = nn.BCELoss(weight=weights, reduction='none')
loss_f_sum = nn.BCELoss(weight=weights, reduction='sum')
loss_f_mean = nn.BCELoss(weight=weights, reduction='mean')

# forward
loss_none_w = loss_f_none_w(inputs, target_bce)
loss_sum = loss_f_sum(inputs, target_bce)
loss_mean = loss_f_mean(inputs, target_bce)

# view
print("\nweights: ", weights)
print("BCE Loss", loss_none_w, loss_sum, loss_mean)

输出结果:

weights:  tensor([1., 1.])
BCE Loss tensor([[0.3133, 2.1269],
        [0.1269, 2.1269],
        [3.0486, 0.0181],
        [4.0181, 0.0067]]) tensor(11.7856) tensor(1.4732)

由于这里我们有 4 个样本,每个样本有 2 个神经元,因此在 none 模式下我们这里得到 8 个 loss,即每一个神经元会一一对应地计算 loss。而 sum 模式就是简单地将这 8 个 loss 进行相加,mean 模式就是对这 8 个 loss 求加权均值。

下面我们通过手动计算来验证第一个神经元的 BCE loss 值是否等于 0.3133:

idx = 0

x_i = inputs.detach().numpy()[idx, idx]  # 获取第一个神经元的输出值
y_i = target.numpy()[idx, idx]  # 获取第一个神经元的标签

# loss
# l_i = -[ y_i * np.log(x_i) + (1-y_i) * np.log(1-y_i) ]      # np.log(0) = nan
l_i = -y_i * np.log(x_i) if y_i else -(1-y_i) * np.log(1-x_i)

# 输出loss
print("BCE inputs: ", inputs)
print("第一个 loss 为: ", l_i)

输出结果:

BCE inputs:  tensor([[0.7311, 0.8808],
        [0.8808, 0.8808],
        [0.9526, 0.9820],
        [0.9820, 0.9933]])
第一个 loss 为:  0.31326166

可以看到,手动计算的结果与 PyTorch 中 nn.BCELoss 的计算结果一致。

nn.BCEWithLogitsLoss

功能:结合 Sigmoid 与 二分类交叉熵。

nn.BCEWithLogitsLoss(
    weight=None,
    size_average=None,
    reduce=None,
    reduction='mean',
    pos_weight=None
)

主要参数

  • pos_weight:正样本的权值,用于平衡正负样本。
    • 例如:正样本有 100 个,负样本有 300 个,正负样本比例为 1:3。因此我们可以将该项设为 3,这样即等价于正负样本各 300 个。
  • weight:各类别的 loss 设置权值。
  • ignore_index:忽略某个类别。
  • reduction:计算模式,可为none/sum/mean
    • none:逐个元素计算。
    • sum:所有元素求和,返回标量。
    • mean:加权平均,返回标量。

计算公式

注意事项:网络最后不加 Sigmoid 函数。

代码示例

inputs = torch.tensor([[1, 2], [2, 2], [3, 4], [4, 5]], dtype=torch.float)
target = torch.tensor([[1, 0], [1, 0], [0, 1], [0, 1]], dtype=torch.float)

target_bce = target

# inputs = torch.sigmoid(inputs)  # 这里增加 sigmoid 会使得计算不准确,因为相当于加了两层 sigmoid

weights = torch.tensor([1, 1], dtype=torch.float)

loss_f_none_w = nn.BCEWithLogitsLoss(weight=weights, reduction='none')
loss_f_sum = nn.BCEWithLogitsLoss(weight=weights, reduction='sum')
loss_f_mean = nn.BCEWithLogitsLoss(weight=weights, reduction='mean')

# forward
loss_none_w = loss_f_none_w(inputs, target_bce)
loss_sum = loss_f_sum(inputs, target_bce)
loss_mean = loss_f_mean(inputs, target_bce)

# view
print("\nweights: ", weights)
print(loss_none_w, loss_sum, loss_mean)

输出结果:

weights:  tensor([1., 1.])
tensor([[0.3133, 2.1269],
        [0.1269, 2.1269],
        [3.0486, 0.0181],
        [4.0181, 0.0067]]) tensor(11.7856) tensor(1.4732)

我们来看一下 pos_weight 的设置:

inputs = torch.tensor([[1, 2], [2, 2], [3, 4], [4, 5]], dtype=torch.float)
target = torch.tensor([[1, 0], [1, 0], [0, 1], [0, 1]], dtype=torch.float)

target_bce = target

weights = torch.tensor([1], dtype=torch.float)
pos_w = torch.tensor([1], dtype=torch.float)  # 将 pos_weight 设为 1 

loss_f_none_w = nn.BCEWithLogitsLoss(weight=weights, reduction='none', pos_weight=pos_w)
loss_f_sum = nn.BCEWithLogitsLoss(weight=weights, reduction='sum', pos_weight=pos_w)
loss_f_mean = nn.BCEWithLogitsLoss(weight=weights, reduction='mean', pos_weight=pos_w)

# forward
loss_none_w = loss_f_none_w(inputs, target_bce)
loss_sum = loss_f_sum(inputs, target_bce)
loss_mean = loss_f_mean(inputs, target_bce)

# view
print("\npos_weights: ", pos_w)
print(loss_none_w, loss_sum, loss_mean)

输出结果:

pos_weights:  tensor([1.])
tensor([[0.3133, 2.1269],
        [0.1269, 2.1269],
        [3.0486, 0.0181],
        [4.0181, 0.0067]]) tensor(11.7856) tensor(1.4732)

可以看到,当 pos_weight 设为 1 时,计算的 loss 结果与之前一样。接下来我们将 pos_weight 改为 3 来看一下结果会如何变化:

pos_w = torch.tensor([3], dtype=torch.float)  # 将 pos_weight 设为 3

输出结果:

pos_weights:  tensor([3.])
tensor([[0.9398, 2.1269],
        [0.3808, 2.1269],
        [3.0486, 0.0544],
        [4.0181, 0.0201]]) tensor(12.7158) tensor(1.5895)

可以看到,当 pos_weight 设为 3 时,第一个样本 [1,2] 的标签为 [1,0],它的第一个神经元标签 1 对应的 loss 变为了之前的 3 倍,即 0.3133×3=0.9398;第二个神经元标签 0 对应的 loss 和之前一样,为 2.1269。其余三个样本的 loss 变化同理。

总结

本节课中,我们学习了损失函数的概念,以及 4 种不同的损失函数。下节课中,我们将继续学习 PyTorch 中其余 14 种损失函数。

损失函数 (二)

上节课中,我们学习了损失函数的概念以及四种不同的损失函数。这节课我们继续学习 PyTorch 中提供的另外十四种损失函数。

PyTorch 中的损失函数

首先我们来看在回归任务中常用的两个损失函数 nn.L1Lossnn.MSELoss

nn.L1Loss

功能:计算 inputstarget 之差的绝对值。

nn.L1Loss(
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • reduction:计算模式,可为none/sum/mean
    • none:逐个元素计算。
    • sum:所有元素求和,返回标量。
    • mean:加权平均,返回标量。

计算公式

nn.MSELoss

功能:计算 inputstarget 之差的平方。

nn.MSELoss(
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • reduction:计算模式,可为none/sum/mean
    • none:逐个元素计算。
    • sum:所有元素求和,返回标量。
    • mean:加权平均,返回标量。

计算公式

代码示例

import torch
import torch.nn as nn
import matplotlib.pyplot as plt
import numpy as np
from tools.common_tools import set_seed

set_seed(1)  # 设置随机种子

inputs = torch.ones((2, 2))
target = torch.ones((2, 2)) * 3

# ------------------------------------ L1 loss ----------------------------------
loss_f = nn.L1Loss(reduction='none')
loss = loss_f(inputs, target)

print("input:{}\ntarget:{}\nL1 loss:{}".format(inputs, target, loss))

# ------------------------------------ MSE loss ---------------------------------
loss_f_mse = nn.MSELoss(reduction='none')
loss_mse = loss_f_mse(inputs, target)

print("MSE loss:{}".format(loss_mse))

输出结果:

input:tensor([[1., 1.],
        [1., 1.]])
target:tensor([[3., 3.],
        [3., 3.]])
L1 loss:tensor([[2., 2.],
        [2., 2.]])
MSE loss:tensor([[4., 4.],
        [4., 4.]])

可以看到,这里我们的每个神经元的输入为$x{i}=1$,输出为$y{i}=3 $。所以,每个神经元的 L1 loss 为 $ \left|x{i}-y{i}\right|=|1-3|=2$,MSE loss 为$\left(x{i}-y{i}\right)^{2}=(1-3)^{2}=4$。

nn.SmoothL1Loss

功能:平滑的 L1 Loss,可以减轻离群点带来的影响。

nn.SmoothL1Loss(
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • reduction:计算模式,可为none/sum/mean
    • none:逐个元素计算。
    • sum:所有元素求和,返回标量。
    • mean:加权平均,返回标量。

计算公式

其中,

代码示例

inputs = torch.linspace(-3, 3, steps=500)
target = torch.zeros_like(inputs)

loss_f = nn.SmoothL1Loss(reduction='none')
loss_smooth = loss_f(inputs, target)

loss_l1 = np.abs(inputs.numpy())

plt.plot(inputs.numpy(), loss_smooth.numpy(), label='Smooth L1 Loss')
plt.plot(inputs.numpy(), loss_l1, label='L1 loss')
plt.xlabel('x_i - y_i')
plt.ylabel('loss value')
plt.legend()
plt.grid()
plt.show()

输出结果:

img

PoissonNLLLoss

功能:泊松分布的负对数似然损失函数。

nn.PoissonNLLLoss(
    log_input=True,
    full=False,
    size_average=None,
    eps=1e-08,
    reduce=None,
    reduction='mean'
)

主要参数

  • log_input:输入是否为对数形式,决定计算公式。
  • full:计算所有 loss,默认为 False
  • eps:修正项,避免 input 为 0 时,log(input)nan 的情况。

计算公式

  • log_input=True 时:

  • log_input=False 时:

代码示例

inputs = torch.randn((2, 2))
target = torch.randn((2, 2))

loss_f = nn.PoissonNLLLoss(log_input=True, full=False, reduction='none')
loss = loss_f(inputs, target)

print("input:{}\ntarget:{}\nPoisson NLL loss:{}".format(inputs, target, loss))

输出结果:

input:tensor([[0.6614, 0.2669],
        [0.0617, 0.6213]])
target:tensor([[-0.4519, -0.1661],
        [-1.5228,  0.3817]])
Poisson NLL loss:tensor([[2.2363, 1.3503],
        [1.1575, 1.6242]])

下面我们以第一个神经元的 loss 为例,通过手动计算来验证我们前面的公式是否正确:

idx = 0
loss_1 = torch.exp(inputs[idx, idx]) - target[idx, idx]*inputs[idx, idx]

print("第一个元素的 loss 为:", loss_1)

输出结果:

第一个元素的 loss 为: tensor(2.2363) 

可以看到,由于这里我们的 log_input=True,默认输入为对数形式,计算出的第一个神经元的 loss 为 2.2363,与前面 PyTorch 中 nn.PoissonNLLLoss 的计算结果一致。

nn.KLDivLoss

功能:计算 KL 散度 (KL divergence, KLD),即相对熵。

nn.KLDivLoss(
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • reduction:计算模式,可为none/sum/mean/batchmean
    • none:逐个元素计算。
    • sum:所有元素求和,返回标量。
    • mean:加权平均,返回标量。
    • batchmeanbatchsize 维度求平均值。

计算公式

其中,P 为数据的真实分布,Q 为模型拟合的分布。

PyTorch 中的计算公式:

由于 PyTorch 是逐个元素计算的,因此可以移除 Σ 求和项。而括号中第二项这里是 xn,而不是 log⁡Q(xn),因此我们需要提前计算输入的对数概率。

注意事项:需提前将输入计算 log-probabilities,例如通过 nn.logsoftmax() 计算。

代码示例

inputs = torch.tensor([[0.5, 0.3, 0.2], [0.2, 0.3, 0.5]])
inputs_log = torch.log(inputs)
target = torch.tensor([[0.9, 0.05, 0.05], [0.1, 0.7, 0.2]], dtype=torch.float)

loss_f_none = nn.KLDivLoss(reduction='none')
loss_f_mean = nn.KLDivLoss(reduction='mean')
loss_f_bs_mean = nn.KLDivLoss(reduction='batchmean')

loss_none = loss_f_none(inputs, target)
loss_mean = loss_f_mean(inputs, target)
loss_bs_mean = loss_f_bs_mean(inputs, target)

print("loss_none:\n{}\nloss_mean:\n{}\nloss_bs_mean:\n{}".format(loss_none, loss_mean, loss_bs_mean))

输出结果:

loss_none:
tensor([[-0.5448, -0.1648, -0.1598],
        [-0.2503, -0.4597, -0.4219]])
loss_mean:
-0.3335360586643219
loss_bs_mean:
-1.000608205795288

由于我们的输入是一个 2×3 的 Tensor,所以我们的 loss 也是一个 2×3 的 Tensor。在 mean 模式下,我们得到 6 个 loss 的均值为 −0.3335;而 batchmean 模式下是 6 个 loss 相加再除以 2,所以得到 −1.0006。

下面我们以第一个神经元的 loss 为例,通过手动计算来验证 PyTorch 中的公式是否和我们之前提到的一致:

idx = 0
loss_1 = target[idx, idx] * (torch.log(target[idx, idx]) - inputs[idx, idx])  #  注意,这里括号中第二项没有取 log

print("第一个元素的 loss 为:", loss_1)

输出结果:

第一个元素的 loss 为: tensor(-0.5448)

可以看到,手动计算的结果与前面 PyTorch 中的 nn.KLDivLoss 的结果一致。

nn.MarginRankingLoss

功能:计算两个向量之间的相似度,用于 排序任务。该方法计算两组数据之间的差异,返回一个 n×n 的 loss 矩阵。

nn.MarginRankingLoss(
    margin=0.0,
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • margin:边界值,x1 与 x2 之间的差异值。
  • reduction:计算模式,可为 none/sum/mean

计算公式

  • 当 y=1 时,我们希望 x1 比 x2 大,当 x1>x2 时, 不产生 loss。
  • 当 y=−1 时,我们希望 x2 比 x1 大,当 x2>x1 时,不产生 loss。

代码示例

x1 = torch.tensor([[1], [2], [3]], dtype=torch.float)
x2 = torch.tensor([[2], [2], [2]], dtype=torch.float)

target = torch.tensor([1, 1, -1], dtype=torch.float)

loss_f_none = nn.MarginRankingLoss(margin=0, reduction='none')
loss = loss_f_none(x1, x2, target)

print(loss)

输出结果:

tensor([[1., 1., 0.],
        [0., 0., 0.],
        [0., 0., 1.]])

由于这里我们的输入是两个长度为 3 的向量,所以输出的是一个 3×3 的 loss 矩阵。该矩阵中的第一行是由 x1 中的第一个元素与 x2 中的三个元素计算得到的 loss。当 y=1 时,x1=1,x2=2,x1 并没有大于 x2,因此会产生 loss 为 max(0,−1×(1−2))=1,所以输出矩阵中第一行的第一个元素为 1;第一行中的第二个元素同理。对于第一行中的第三个元素,y=−1,x1=1,x2=2,满足 x2>x1,因此不会产生 loss,即 loss 为 max(0,1×(1−2))=0,所以输出矩阵中第一行的第三个元素为 0。

nn.MultiLabelMarginLoss

功能:多标签边界损失函数。例如四分类任务,样本 x 属于第 0 类和第 3 类,注意这里标签为 [0,3,−1,−1],而不是 [1,0,0,1]。

nn.MultiLabelMarginLoss(
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • reduction:计算模式,可为 none/sum/mean

计算公式

其中,$i=0, \ldots, x \cdot \operatorname{size}(0), j=0, \ldots, y . \operatorname{size}(0) $,对于所有的 i 和 j,都有 y[j]≥0 并且 i≠y[j]。

代码示例

x = torch.tensor([[0.1, 0.2, 0.4, 0.8]])  # 一个四分类样本的输出概率
y = torch.tensor([[0, 3, -1, -1]], dtype=torch.long)  # 标签,该样本属于第 0 类和第 3 类

loss_f = nn.MultiLabelMarginLoss(reduction='none')
loss = loss_f(x, y)

print(loss)

输出结果:

tensor([0.8500]) 

下面我们通过手动计算来验证前面计算公式的正确性:

x = x[0]
item_1 = (1-(x[0] - x[1])) + (1 - (x[0] - x[2]))    # 第 0 类标签的 loss
item_2 = (1-(x[3] - x[1])) + (1 - (x[3] - x[2]))    # 第 3 类标签的 loss

loss_h = (item_1 + item_2) / x.shape[0]

print(loss_h)

输出结果:

tensor(0.8500) 

可以看到,手动计算的结果与 PyTorch 中的 nn.MultiLabelMarginLoss 的结果一致。

nn.SoftMarginLoss

功能:计算二分类的 logistic 损失。

nn.SoftMarginLoss(
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • reduction:计算模式,可为 none/sum/mean

计算公式

其中,x.nelement() 为输入 x 中的样本个数。注意这里 y 也有 1 和 −1 两种模式。

代码示例

inputs = torch.tensor([[0.3, 0.7], [0.5, 0.5]])  # 两个样本,两个神经元
target = torch.tensor([[-1, 1], [1, -1]], dtype=torch.float)  # 该 loss 为逐个神经元计算,需要为每个神经元单独设置标签

loss_f = nn.SoftMarginLoss(reduction='none')
loss = loss_f(inputs, target)

print("SoftMargin: ", loss)

输出结果:

SoftMargin:  tensor([[0.8544, 0.4032],
        [0.4741, 0.9741]])

下面我们以第一个神经元的 loss 为例,采用手动计算来验证上面公式的正确性:

idx = 0
inputs_i = inputs[idx, idx]
target_i = target[idx, idx]

loss_h = np.log(1 + np.exp(-target_i * inputs_i))

print(loss_h)

输出结果:

tensor(0.8544) 

可以看到,手动计算的结果与 PyTorch 中的 nn.SoftMarginLoss 的结果一致。

nn.MultiLabelSoftMarginLoss

功能SoftMarginLoss 的多标签版本。

nn.MultiLabelSoftMarginLoss(
    weight=None,
    size_average=None,
    reduce=None,
    reduction='mean')

主要参数

  • weight:各类别的 loss 设置权值。
  • reduction:计算模式,可为 none/sum/mean

计算公式

其中,C 是标签类别的数量,i 表示第 i 个神经元,这里标签取值为 0 或者 1,例如在一个四分类任务中,某样本标签为第 0 类和第 3 类,那么该样本的标签向量为 [1,0,0,1]。

代码示例

inputs = torch.tensor([[0.3, 0.7, 0.8]])
target = torch.tensor([[0, 1, 1]], dtype=torch.float)

loss_f = nn.MultiLabelSoftMarginLoss(reduction='none')
loss = loss_f(inputs, target)

print("MultiLabel SoftMargin: ", loss)

输出结果:

MultiLabel SoftMargin:  tensor([0.5429])

下面我们通过手动计算验证上面公式的正确性:

i_0 = torch.log(torch.exp(-inputs[0, 0]) / (1 + torch.exp(-inputs[0, 0])))
i_1 = torch.log(1 / (1 + torch.exp(-inputs[0, 1])))
i_2 = torch.log(1 / (1 + torch.exp(-inputs[0, 2])))

loss_h = (i_0 + i_1 + i_2) / -3

print(loss_h)

输出结果:

tensor(0.5429) 

可以看到,手动计算的结果与 PyTorch 中的 nn.MultiLabelSoftMarginLoss 的结果一致。

nn.MultiMarginLoss

功能:计算多分类的折页损失。

nn.MultiMarginLoss(
    p=1,
    margin=1.0,
    weight=None,
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • p:可选 12
  • weight:各类别的 loss 设置权值。
  • margin:边界值。
  • reduction:计算模式,可为 none/sum/mean

计算公式

其中,$x \in\{0, \ldots, x . \operatorname{size}(0)-1\}, y \in\{0, \ldots, y . \operatorname{size}(0)-1\} $,并且对于所有的 i 和 j,都有 $0 \leq y[j] \leq x \cdot \operatorname{size}(0)-1$,以及 $i \neq y[j]$。

代码示例

x = torch.tensor([[0.1, 0.2, 0.7], [0.2, 0.5, 0.3]])
y = torch.tensor([1, 2], dtype=torch.long)

loss_f = nn.MultiMarginLoss(reduction='none')
loss = loss_f(x, y)

print("Multi Margin Loss: ", loss)

输出结果:

Multi Margin Loss:  tensor([0.8000, 0.7000])

下面我们以第一个样本的 loss 为例,通过手动计算验证上面公式的正确性:

x = x[0]
margin = 1

i_0 = margin - (x[1] - x[0])
i_2 = margin - (x[1] - x[2])

loss_h = (i_0 + i_2) / x.shape[0]

print(loss_h)

输出结果:

tensor(0.8000)

可以看到,手动计算的结果与 PyTorch 中的 nn.MultiMarginLoss 的结果一致。

nn.TripletMarginLoss

功能:计算三元组损失,常用于人脸识别验证。

三元组损失:

我们希望通过学习,使得 Anchor 与 Posttive 之间的距离小于 Anchor 与 Negative 之间的距离。

nn.TripletMarginLoss(
    margin=1.0,
    p=2.0,
    eps=1e-06,
    swap=False,
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • p:范数的阶,默认为 2
  • margin:边界值。
  • reduction:计算模式,可为 none/sum/mean

计算公式

其中,$d\left(x{i}, y{i}\right)=\left|\mathbf{x}{i}-\mathbf{y}{i}\right|_{p }$。

代码示例

anchor = torch.tensor([[1.]])
pos = torch.tensor([[2.]])
neg = torch.tensor([[0.5]])

loss_f = nn.TripletMarginLoss(margin=1.0, p=1)  # 范数为 1,即计算两者之差的绝对值
loss = loss_f(anchor, pos, neg)

print("Triplet Margin Loss", loss)

输出结果:

Triplet Margin Loss tensor(1.5000)

下面我们通过手动计算验证上面公式的正确性:

margin = 1
a, p, n = anchor[0], pos[0], neg[0]

d_ap = torch.abs(a-p)
d_an = torch.abs(a-n)

loss = d_ap - d_an + margin

print(loss)

输出结果:

tensor([1.5000])

可以看到,手动计算的结果与 PyTorch 中的 nn.TripletMarginLoss 的结果一致。

nn.HingeEmbeddingLoss

功能:计算两个输入的相似性,常用于非线性 embedding 和半监督学习。

nn.HingeEmbeddingLoss(
    margin=1.0,
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • margin:边界值。
  • reduction:计算模式,可为 none/sum/mean

计算公式

注意事项:输入 x 应为两个输入之差的绝对值。

代码示例

inputs = torch.tensor([[1., 0.8, 0.5]])
target = torch.tensor([[1, 1, -1]])

loss_f = nn.HingeEmbeddingLoss(margin=1, reduction='none')
loss = loss_f(inputs, target)

print("Hinge Embedding Loss", loss)

输出结果:

Hinge Embedding Loss tensor([[1.0000, 0.8000, 0.5000]])

下面我们通过手动计算验证上面公式的正确性:

margin = 1.
loss_0 = inputs.numpy()[0, 0]
loss_1 = inputs.numpy()[0, 1]
loss_2 = max(0, margin - inputs.numpy()[0, 2])

print(loss_0, loss_1, loss_2)

输出结果:

1.0 0.8 0.5

可以看到,手动计算的结果与 PyTorch 中的 nn.HingeEmbeddingLoss 的结果一致。

nn.CosineEmbeddingLoss

功能:采用余弦相似度计算两个输入的相似性。

nn.CosineEmbeddingLoss(
    margin=0.0,
    size_average=None,
    reduce=None,
    reduction='mean'
)

主要参数

  • margin:可取值 [−1,1],推荐为 [0,0.5]。
  • reduction:计算模式,可为 none/sum/mean

计算公式

其中,

代码示例

x1 = torch.tensor([[0.3, 0.5, 0.7], [0.3, 0.5, 0.7]])
x2 = torch.tensor([[0.1, 0.3, 0.5], [0.1, 0.3, 0.5]])
target = torch.tensor([[1, -1]], dtype=torch.float)

loss_f = nn.CosineEmbeddingLoss(margin=0., reduction='none')
loss = loss_f(x1, x2, target)

print("Cosine Embedding Loss", loss)

输出结果:

Cosine Embedding Loss tensor([[0.0167, 0.9833]])

下面我们通过手动计算验证上面公式的正确性:

margin = 0.

def cosine(a, b):
    numerator = torch.dot(a, b)
    denominator = torch.norm(a, 2) * torch.norm(b, 2)
    return float(numerator/denominator)

l_1 = 1 - (cosine(x1[0], x2[0]))
l_2 = max(0, cosine(x1[0], x2[0]) - margin)

print(l_1, l_2)

输出结果:

0.016662120819091797 0.9833378791809082

可以看到,手动计算的结果与 PyTorch 中的 nn.CosineEmbeddingLoss 的结果一致。

nn.CTCLoss

参考文献A. Graves et al.: Connectionist Temporal Classification: Labelling Unsegmented Sequence Data with Recurrent Neural Networks

功能:计算 CTC (Connectionist Temporal Classification) 损失,用于解决时序类数据的分类。

torch.nn.CTCLoss(
    blank=0,
    reduction='mean',
    zero_infinity=False
)

主要参数

  • blank:blank label。
  • zero_infinity:无穷大的值或梯度值为 0。
  • reduction:计算模式,可为 none/sum/mean

代码示例

T = 50      # Input sequence length
C = 20      # Number of classes (including blank)
N = 16      # Batch size
S = 30      # Target sequence length of longest target in batch
S_min = 10  # Minimum target length, for demonstration purposes

# Initialize random batch of input vectors, for *size = (T,N,C)
inputs = torch.randn(T, N, C).log_softmax(2).detach().requires_grad_()

# Initialize random batch of targets (0 = blank, 1:C = classes)
target = torch.randint(low=1, high=C, size=(N, S), dtype=torch.long)

input_lengths = torch.full(size=(N,), fill_value=T, dtype=torch.long)
target_lengths = torch.randint(low=S_min, high=S, size=(N,), dtype=torch.long)

ctc_loss = nn.CTCLoss()
loss = ctc_loss(inputs, target, input_lengths, target_lengths)

print("CTC loss: ", loss)

输出结果:

CTC loss:  tensor(7.5385, grad_fn=<MeanBackward0>) 

总结

到这里,我们已经学习完了 PyTorch 中的 18 种损失函数:

  1. nn.CrossEntropyLoss
  2. nn.NLLLoss
  3. nn.BCELoss
  4. nn.BCEWithLogitsLoss
  5. nn.L1Loss
  6. nn.MSELoss
  7. nn.SmoothL1Loss
  8. nn.PoissonNLLLoss
  9. nn.KLDivLoss
  10. nn.MarginRankingLoss
  11. nn.MultiLabelMarginLoss
  12. nn.SoftMarginLoss
  13. nn.MultiLabelSoftMarginLoss
  14. nn.MultiMarginLoss
  15. nn.TripletMarginLoss
  16. nn.HingeEmbeddingLoss
  17. nn.CosineEmbeddingLoss
  18. nn.CTCLoss

下节课中,我们将学习 PyTorch 中的优化器。

优化器 (一)

前两节课中,我们学习了损失函数的概念以及 PyTorch 中的一系列损失函数方法,我们知道了损失函数的作用是衡量模型输出与真实标签之间的差异。在得到了 loss 函数之后,我们应该如何去更新模型参数,使得 loss 逐步降低呢?这正是优化器的工作。本节课我们开始学习优化器模块。

什么是优化器

在学习优化器模块之前,我们先回顾一下机器学习模型训练的 5 个步骤:

img

我们看到,优化器是第 4 个模块,那么它的作用是什么呢?我们知道,在前一步的损失函数模块中,我们会得到一个 loss 值,即模型输出与真实标签之间的差异。有了 loss 值之后,我们一般会采用 PyTorch 中的 AutoGrid 自动梯度求导模块对模型中参数的梯度进行求导计算,之后优化器会拿到这些梯度值并采用一些优化策略去更新模型参数,使得 loss 值下降。因此,优化器的作用就是利用梯度来更新模型中的可学习参数,使得模型输出与真实标签之间的差异更小,即让 loss 值下降。

PyTorch 的优化器管理更新 模型中可学习参数 (权值或偏置) 的值,使得模型输出更接近真实标签。

img

  • 导数:函数在指定坐标轴上的变化率。
  • 方向导数:指定方向上的变化率。
  • 梯度:一个向量,方向为方向导数取得最大值的方向。

Optimizer 的属性

PyTorch 中的 Optimizer 类

class Optimizer(object):
    def __init__(self, params, defaults):
        self.defaults = defaults
        self.state = defaultdict(dict)
        self.param_groups = []
        
        param_groups = [{'params': param_groups}]

基本属性

  • defaults:优化器超参数。
  • state:参数的缓存,如 momentum 的缓存。
  • params_groups:管理的参数组。
  • _ step_count:记录更新次数,学习率调整中使用。

Optimizer 的方法

class Optimizer(object):
    def zero_grad(self):
        for group in self.param_groups:
            for p in group['params']:
                if p.grad is not None:
                    p.grad.detach_()
                    p.grad.zero_()

    def add_param_group(self, param_group):
        for group in self.param_groups:
            param_set.update(set(group['params’]))
        self.param_groups.append(param_group)
    
    def state_dict(self):
        return {'state': packed_state, 'param_groups': param_groups,}

    def load_state_dict(self, state_dict):

基本方法

  • zero_grad():清空所管理参数的梯度 (PyTorch 特性:张量梯度不自动清零)。
  • step():执行一步更新。
  • add_param_group():添加参数组。
  • state_dict():获取优化器当前状态信息 字典
  • load_state_dict():加载状态信息字典。

例子:人民币二分类

# -*- coding: utf-8 -*-

import os
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
import torchvision.transforms as transforms
import torch.optim as optim
from matplotlib import pyplot as plt
from model.lenet import LeNet
from tools.my_dataset import RMBDataset
from tools.common_tools import transform_invert, set_seed

set_seed(1)  # 设置随机种子
rmb_label = {"1": 0, "100": 1}

# 参数设置
MAX_EPOCH = 10
BATCH_SIZE = 16
LR = 0.01
log_interval = 10
val_interval = 1

# ============================ step 1/5 数据 ============================

split_dir = os.path.join("..", "..", "data", "rmb_split")
train_dir = os.path.join(split_dir, "train")
valid_dir = os.path.join(split_dir, "valid")

norm_mean = [0.485, 0.456, 0.406]
norm_std = [0.229, 0.224, 0.225]

train_transform = transforms.Compose([
    transforms.Resize((32, 32)),
    transforms.RandomCrop(32, padding=4),
    transforms.RandomGrayscale(p=0.8),
    transforms.ToTensor(),
    transforms.Normalize(norm_mean, norm_std),
])

valid_transform = transforms.Compose([
    transforms.Resize((32, 32)),
    transforms.ToTensor(),
    transforms.Normalize(norm_mean, norm_std),
])

# 构建MyDataset实例
train_data = RMBDataset(data_dir=train_dir, transform=train_transform)
valid_data = RMBDataset(data_dir=valid_dir, transform=valid_transform)

# 构建DataLoder
train_loader = DataLoader(dataset=train_data, batch_size=BATCH_SIZE, shuffle=True)
valid_loader = DataLoader(dataset=valid_data, batch_size=BATCH_SIZE)

# ============================ step 2/5 模型 ============================

net = LeNet(classes=2)
net.initialize_weights()

# ============================ step 3/5 损失函数 ============================
criterion = nn.CrossEntropyLoss()                                                   # 选择损失函数

# ============================ step 4/5 优化器 ============================
optimizer = optim.SGD(net.parameters(), lr=LR, momentum=0.9)                        # 选择优化器
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)     # 设置学习率下降策略

# ============================ step 5/5 训练 ============================
train_curve = list()
valid_curve = list()

for epoch in range(MAX_EPOCH):

    loss_mean = 0.
    correct = 0.
    total = 0.

    net.train()
    for i, data in enumerate(train_loader):

        # forward
        inputs, labels = data
        outputs = net(inputs)

        # backward
        optimizer.zero_grad()
        loss = criterion(outputs, labels)
        loss.backward()

        # update weights
        optimizer.step()

        # 统计分类情况
        _, predicted = torch.max(outputs.data, 1)
        total += labels.size(0)
        correct += (predicted == labels).squeeze().sum().numpy()

        # 打印训练信息
        loss_mean += loss.item()
        train_curve.append(loss.item())
        if (i+1) % log_interval == 0:
            loss_mean = loss_mean / log_interval
            print("Training:Epoch[{:0>3}/{:0>3}] Iteration[{:0>3}/{:0>3}] Loss: {:.4f} Acc:{:.2%}".format(
                epoch, MAX_EPOCH, i+1, len(train_loader), loss_mean, correct / total))
            loss_mean = 0.

    scheduler.step()  # 更新学习率

    # validate the model
    if (epoch+1) % val_interval == 0:

        correct_val = 0.
        total_val = 0.
        loss_val = 0.
        net.eval()
        with torch.no_grad():
            for j, data in enumerate(valid_loader):
                inputs, labels = data
                outputs = net(inputs)
                loss = criterion(outputs, labels)

                _, predicted = torch.max(outputs.data, 1)
                total_val += labels.size(0)
                correct_val += (predicted == labels).squeeze().sum().numpy()

                loss_val += loss.item()

            valid_curve.append(loss_val)
            print("Valid:\t Epoch[{:0>3}/{:0>3}] Iteration[{:0>3}/{:0>3}] Loss: {:.4f} Acc:{:.2%}".format(
                epoch, MAX_EPOCH, j+1, len(valid_loader), loss_val, correct / total))


train_x = range(len(train_curve))
train_y = train_curve

train_iters = len(train_loader)
valid_x = np.arange(1, len(valid_curve)+1) * train_iters*val_interval # 由于valid中记录的是epochloss,需要对记录点进行转换到iterations
valid_y = valid_curve

plt.plot(train_x, train_y, label='Train')
plt.plot(valid_x, valid_y, label='Valid')

plt.legend(loc='upper right')
plt.ylabel('loss value')
plt.xlabel('Iteration')
plt.show()

# ============================ inference ============================

BASE_DIR = os.path.dirname(os.path.abspath(__file__))
test_dir = os.path.join(BASE_DIR, "test_data")

test_data = RMBDataset(data_dir=test_dir, transform=valid_transform)
valid_loader = DataLoader(dataset=test_data, batch_size=1)

for i, data in enumerate(valid_loader):
    # forward
    inputs, labels = data
    outputs = net(inputs)
    _, predicted = torch.max(outputs.data, 1)

    rmb = 1 if predicted.numpy()[0] == 0 else 100

    img_tensor = inputs[0, ...]  # C H W
    img = transform_invert(img_tensor, train_transform)
    plt.imshow(img)
    plt.title("LeNet got {} Yuan".format(rmb))
    plt.show()
    plt.pause(0.5)
    plt.close()

下面我们来看一下优化器中的 5 种基本方法的具体使用方式:

step()

为了方便计算,我们先设置学习率 lr=1

import os
import torch
import torch.optim as optim
from tools.common_tools import set_seed

BASE_DIR = os.path.dirname(os.path.abspath(__file__))

set_seed(1)  # 设置随机种子

weight = torch.randn((2, 2), requires_grad=True)
weight.grad = torch.ones((2, 2))

optimizer = optim.SGD([weight], lr=1)

print("weight before step:{}".format(weight.data))
optimizer.step()        # 修改lr=1 0.1观察结果
print("weight after step:{}".format(weight.data))

输出结果:

weight before step:tensor([[0.6614, 0.2669],
        [0.0617, 0.6213]])
weight after step:tensor([[-0.3386, -0.7331],
        [-0.9383, -0.3787]])

可以看到,第一个梯度在更新之前的值为 0.6614,更新之后的值为 0.6614−1=−0.3386。现在,我们将学习率设置为 lr=0.1,观察结果是否发生变化:

optimizer = optim.SGD([weight], lr=0.1)

输出结果:

weight before step:tensor([[0.6614, 0.2669],
        [0.0617, 0.6213]])
weight after step:tensor([[ 0.5614,  0.1669],
        [-0.0383,  0.5213]])

可以看到,第一个梯度更新后的值变为了 0.6614−0.1=0.5614。这就是 step() 方法的一步更新。

zero_grad ()
print("weight before step:{}".format(weight.data))
optimizer.step()        # 修改lr=1 0.1观察结果
print("weight after step:{}".format(weight.data))

print("weight in optimizer:{}\nweight in weight:{}\n".format(id(optimizer.param_groups[0]['params'][0]), id(weight)))

print("weight.grad is {}\n".format(weight.grad))
optimizer.zero_grad()
print("after optimizer.zero_grad(), weight.grad is\n{}".format(weight.grad))

输出结果:

weight before step:tensor([[0.6614, 0.2669],
        [0.0617, 0.6213]])
weight after step:tensor([[ 0.5614,  0.1669],
        [-0.0383,  0.5213]])
weight in optimizer:4729862544
weight in weight:4729862544
weight.grad is tensor([[1., 1.],
        [1., 1.]])
after optimizer.zero_grad(), weight.grad is
tensor([[0., 0.],
        [0., 0.]])

可以看到,在执行 zero_grad() 之前,我们的梯度为 [[1.,1.],[1.,1.]],执行之后变为了 [[0.,0.],[0.,0.]]。另外,我们看到,optimizer 中管理的 weight 的内存地址和真实的 weight 地址是相同的,所以我们在优化器中保存的是参数的地址,而不是拷贝的参数的值,这样可以节省内存消耗。

add_param_group ()

我们同样采用上面的优化器,该优化器当前已经管理了一组参数,就是我们的 weight。现在我们希望再增加一组参数,并且我们将该组参数的学习率设置的更小一些 lr=0.0001。首先,我们需要构建这样一组参数的字典,字典的 key 设置为 'params',其值为新的一组参数 w2;然后可以设置一些超参数,例如学习率 'lr' 等。然后我们使用 add_param_group () 将这组参数加入优化器中。

print("optimizer.param_groups is\n{}".format(optimizer.param_groups))
w2 = torch.randn((3, 3), requires_grad=True)
optimizer.add_param_group({"params": w2, 'lr': 0.0001})
print("optimizer.param_groups is\n{}".format(optimizer.param_groups))

输出结果:

optimizer.param_groups is
[{'params': [tensor([[0.6614, 0.2669],
        [0.0617, 0.6213]], requires_grad=True)], 'lr': 0.1, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False}]
optimizer.param_groups is
[{'params': [tensor([[0.6614, 0.2669],
        [0.0617, 0.6213]], requires_grad=True)], 'lr': 0.1, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False}, {'params': [tensor([[-0.4519, -0.1661, -1.5228],
        [ 0.3817, -1.0276, -0.5631],
        [-0.8923, -0.0583, -0.1955]], requires_grad=True)], 'lr': 0.0001, 'momentum': 0, 'dampening': 0, 'weight_decay': 0, 'nesterov': False}]

可以看到,在加入新参数之前,我们的优化器中只有一组参数,以一个列表形式呈现,里面只有一个字典元素。当我们使用 add_param_group () 之后,列表中有了两个字典元素。可以看到,两组参数的学习率是不同的,所以通过这种方式我们可以为不同的参数组设置不同的学习率,这在模型拟合过程中是一种非常实用的方法。

state_dict()load_state_dict()

这两个函数用于保存优化器的状态信息,通常用于断点的继续训练。

保存状态信息

optimizer = optim.SGD([weight], lr=0.1, momentum=0.9)
opt_state_dict = optimizer.state_dict()

print("state_dict before step:\n", opt_state_dict)

for i in range(10):
    optimizer.step()

print("state_dict after step:\n", optimizer.state_dict())

torch.save(optimizer.state_dict(), os.path.join(BASE_DIR, "optimizer_state_dict.pkl"))

输出结果:

state_dict before step:
 {'state': {}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [4745884296]}]}
state_dict after step:
 {'state': {4745884296: {'momentum_buffer': tensor([[6.5132, 6.5132],
        [6.5132, 6.5132]])}}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [4745884296]}]}

可以看到,在更新之前,'state' 里的值是一个空字典。在经过 10 步更新之后,'state' 字典中有了一些值,它的 key 是 4745884296,即参数地址,而它的值也是一个字典。其中 'momentum_buffer' 是动量中会使用的一些缓存信息。所以,在 'state' 中我们是通过地址去匹配参数的缓存的。然后,我们使用 torch.save 对字典进行序列化,将其保存为一个 pkl 的形式,可以看到当前文件夹下多了一个 optimizer_state_dict.pkl 的文件。

读取状态信息

之前我们的模型已经训练了 10 次,假设我们总共需要训练 100 次,我们不希望再从头训练,而是希望能够接着之前第 10 次的状态继续训练,我们可以利用 load_state_dict 加载前面保存的 optimizer_state_dict.pkl 文件,并将其读取加载到优化器中继续训练:

optimizer = optim.SGD([weight], lr=0.1, momentum=0.9)
state_dict = torch.load(os.path.join(BASE_DIR, "optimizer_state_dict.pkl"))

print("state_dict before load state:\n", optimizer.state_dict())
optimizer.load_state_dict(state_dict)
print("state_dict after load state:\n", optimizer.state_dict())

输出结果:

state_dict before load state:
 {'state': {}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [4684949616]}]}
state_dict after load state:
 {'state': {4684949616: {'momentum_buffer': tensor([[6.5132, 6.5132],
        [6.5132, 6.5132]])}}, 'param_groups': [{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0, 'nesterov': False, 'params': [4684949616]}]}

可以看到,在加载之前,'state' 里的值是一个空字典。在使用 load_state_dict 加载之后,我们得到了之前第 10 次的参数状态,然后继续在此基础上进行训练即可。

总结

本节课中,我们学习了优化器 Optimizer 的概念和一些基本属性及方法。在下节课中,我们将继续学习 PyTorch 中的一些常用的优化方法 (优化器)。

优化器 (二)

上节课我们学习了 PyTorch 中优化器的主要属性和基本方法。我们知道优化器的主要作用是管理并更新我们的参数,并且在更新时会利用到参数的梯度信息,然后采用一定的更新策略来更新我们的参数。本节课我们将学习一些最常用的更新策略,例如随机梯度下降法等。

学习率

梯度下降中的参数更新过程:

其中,g(wi) 表示 wi 的梯度。

下面我们通过一个例子来观察梯度下降的过程以及可能存在的问题:

img

假设我们现在有一个函数:

假设我们的起始点为 x0=2,现在我们采用梯度下降法更新函数值 y 使其达到其极小值点 x=0。首先我们求取 y 的导函数:

我们从起始点 x0=2 开始沿负梯度方向更新 y 值:

  • x0=2,y0=16,f′(x0)=16

    x1=x0−f′(x0)=2−16=−14

  • x1=−14,y1=784,f′(x1)=−112

    x2=x1−f′(x1)=−14+112=98,y2=38416

  • ……

我们发现,y 值不但没有减小,反而越来越大了。这是什么原因导致的呢?下面我们先通过代码来演示这一过程,然后再分析导致该问题的原因。

首先,我们先绘制出函数曲线:

import torch
import numpy as np
import matplotlib.pyplot as plt

torch.manual_seed(1)

def func(x_t):
    """
    y = (2x)^2 = 4*x^2      dy/dx = 8x
    """
    return torch.pow(2*x_t, 2)

# init
x = torch.tensor([2.], requires_grad=True)

# plot data
x_t = torch.linspace(-3, 3, 100)
y = func(x_t)
plt.plot(x_t.numpy(), y.numpy(), label="y = 4*x^2")
plt.grid()
plt.xlabel("x")
plt.ylabel("y")
plt.legend()
plt.show() 

输出结果:

img

下面我们通过代码来演示一下前面例子中的梯度下降过程:

iter_rec, loss_rec, x_rec = list(), list(), list()

lr = 1.
max_iteration = 4

for i in range(max_iteration):
    y = func(x)
    y.backward()

    print("Iter:{}, X:{:8}, X.grad:{:8}, loss:{:10}".format(
        i, x.detach().numpy()[0], x.grad.detach().numpy()[0], y.item()))

    x_rec.append(x.item())

    x.data.sub_(lr * x.grad)    # x -= x.grad  数学表达式意义:  x = x - x.grad
    x.grad.zero_()

    iter_rec.append(i)
    loss_rec.append(y)

plt.subplot(121).plot(iter_rec, loss_rec, '-ro')
plt.xlabel("Iteration")
plt.ylabel("Loss value")

x_t = torch.linspace(-3, 3, 100)
y = func(x_t)
plt.subplot(122).plot(x_t.numpy(), y.numpy(), label="y = 4*x^2")
plt.grid()
y_rec = [func(torch.tensor(i)).item() for i in x_rec]
plt.subplot(122).plot(x_rec, y_rec, '-ro')
plt.legend()
plt.show()

输出结果:

Iter:0, X:     2.0, X.grad:    16.0, loss:      16.0
Iter:1, X:   -14.0, X.grad:  -112.0, loss:     784.0
Iter:2, X:    98.0, X.grad:   784.0, loss:   38416.0
Iter:3, X:  -686.0, X.grad: -5488.0, loss: 1882384.0

img

左边是 loss 曲线图,横轴是迭代次数,纵轴是 loss 值;右边是函数曲线图,由于尺度过大这里暂时看不出来函数形状。

从打印信息可以看到,在第 0 次迭代时,x 的初始值为 2,对应梯度为 16,loss 值也是 16。随着迭代次数的增加,我们发现 loss 值激增到 1882384。所以,y 并没有减小,反而是激增的,而梯度也达到了 103 数量级,所以存在梯度爆炸的问题。

回到前面的梯度更新公式:

这里可能存在一个问题,我们目前是直接减去梯度项 g(wi),而这里减去的梯度项可能由于其尺度过大从而导致参数项越来越大,从而导致函数值无法降低。因此,通常我们会在梯度项前面乘以一个系数,用于缩减尺度:

这里,我们将系数 LR 称为 学习率 (learning rate),它被用来控制更新的步伐。

下面我们在代码中调整学习率,观察函数值的变化:

LR=0.5

lr = 0.5

输出结果:

Iter:0, X:     2.0, X.grad:    16.0, loss:      16.0
Iter:1, X:    -6.0, X.grad:   -48.0, loss:     144.0
Iter:2, X:    18.0, X.grad:   144.0, loss:    1296.0
Iter:3, X:   -54.0, X.grad:  -432.0, loss:   11664.0

img

可以看到,loss 值仍然呈激增趋势,但是情况有所缓解,尺度比之前小了很多。

LR=0.2

lr = 0.2

输出结果:

Iter:0, X:     2.0, X.grad:    16.0, loss:      16.0
Iter:1, X:-1.2000000476837158, X.grad:-9.600000381469727, loss:5.760000228881836
Iter:2, X:0.7200000286102295, X.grad:5.760000228881836, loss:2.0736000537872314
Iter:3, X:-0.4320000410079956, X.grad:-3.456000328063965, loss:0.7464961409568787

img

可以看到,现在 loss 值呈下降趋势,同时右图也可以看到正常的函数图像了。当前学习率为 0.2,从右图可以看到:初始点为 x=2,loss 值为 16;经过一步更新后来到点 x=−1.2,此时 loss 值为 5.76;然后再次迭代后来到 x=0.72,loss 值为 2.07;第三次迭代后,x=−0.43,loss 值为 0.75。

现在我们将增加迭代次数增加到 20 次,来观察函数是否能够到达极小值点 x=0 附近:

max_iteration = 20

输出结果:

Iter:0, X:     2.0, X.grad:    16.0, loss:      16.0
Iter:1, X:-1.2000000476837158, X.grad:-9.600000381469727, loss:5.760000228881836
Iter:2, X:0.7200000286102295, X.grad:5.760000228881836, loss:2.0736000537872314
Iter:3, X:-0.4320000410079956, X.grad:-3.456000328063965, loss:0.7464961409568787
Iter:4, X:0.2592000365257263, X.grad:2.0736002922058105, loss:0.26873862743377686
Iter:5, X:-0.1555200219154358, X.grad:-1.2441601753234863, loss:0.09674590826034546
Iter:6, X:0.09331201016902924, X.grad:0.7464960813522339, loss:0.03482852503657341
Iter:7, X:-0.05598720908164978, X.grad:-0.44789767265319824, loss:0.012538270093500614
Iter:8, X:0.03359232842922211, X.grad:0.26873862743377686, loss:0.004513778258115053
Iter:9, X:-0.020155396312475204, X.grad:-0.16124317049980164, loss:0.0016249599866569042
Iter:10, X:0.012093238532543182, X.grad:0.09674590826034546, loss:0.0005849856534041464
Iter:11, X:-0.007255943492054939, X.grad:-0.058047547936439514, loss:0.000210594866075553
Iter:12, X:0.0043535660952329636, X.grad:0.03482852876186371, loss:7.581415411550552e-05
Iter:13, X:-0.0026121395640075207, X.grad:-0.020897116512060165, loss:2.729309198912233e-05
Iter:14, X:0.001567283645272255, X.grad:0.01253826916217804, loss:9.825512279348914e-06
Iter:15, X:-0.0009403701405972242, X.grad:-0.007522961124777794, loss:3.537184056767728e-06
Iter:16, X:0.0005642221076413989, X.grad:0.004513776861131191, loss:1.2733863741232199e-06
Iter:17, X:-0.00033853325294330716, X.grad:-0.0027082660235464573, loss:4.584190662626497e-07
Iter:18, X:0.00020311994012445211, X.grad:0.001624959520995617, loss:1.6503084054875217e-07
Iter:19, X:-0.00012187196989543736, X.grad:-0.0009749757591634989, loss:5.941110714502429e-08

img

可以看到,在迭代 5 到 7 次之后,左图中的 loss 曲线已经趋近于零了,即已经达到收敛,同时右图可以看到最后几次迭代都在极小值点附近来回振动,这说明我们的学习率是比较合理的。

LR=0.1

那么,是否还存在更好的学习率呢?我们尝试将学习率调整到 0.1,观察函数值的变化:

lr = 0.1 

输出结果:

Iter:0, X:     2.0, X.grad:    16.0, loss:      16.0
Iter:1, X:0.3999999761581421, X.grad:3.1999998092651367, loss:0.6399999260902405
Iter:2, X:0.07999998331069946, X.grad:0.6399998664855957, loss:0.025599990040063858
Iter:3, X:0.015999995172023773, X.grad:0.12799996137619019, loss:0.0010239994153380394
Iter:4, X:0.0031999992206692696, X.grad:0.025599993765354156, loss:4.0959981561172754e-05
Iter:5, X:0.0006399997510015965, X.grad:0.005119998008012772, loss:1.6383987713197712e-06
Iter:6, X:0.00012799992691725492, X.grad:0.0010239994153380394, loss:6.553592868385749e-08
Iter:7, X:2.5599983928259462e-05, X.grad:0.0002047998714260757, loss:2.621436623329032e-09
Iter:8, X:5.1199967856518924e-06, X.grad:4.095997428521514e-05, loss:1.0485746992916489e-10
Iter:9, X:1.0239991752314381e-06, X.grad:8.191993401851505e-06, loss:4.194297253262702e-12
Iter:10, X:2.047998464149714e-07, X.grad:1.6383987713197712e-06, loss:1.6777191073034936e-13
Iter:11, X:4.095996075648145e-08, X.grad:3.276796860518516e-07, loss:6.710873481539318e-15
Iter:12, X:8.191992861839026e-09, X.grad:6.553594289471221e-08, loss:2.6843498478959363e-16
Iter:13, X:1.6383983059142793e-09, X.grad:1.3107186447314234e-08, loss:1.0737395785076275e-17
Iter:14, X:3.2767966118285585e-10, X.grad:2.621437289462847e-09, loss:4.294958520825663e-19
Iter:15, X:6.55359377876863e-11, X.grad:5.242875023014903e-10, loss:1.7179836926736008e-20
Iter:16, X:1.3107185475869088e-11, X.grad:1.048574838069527e-10, loss:6.871932690625968e-22
Iter:17, X:2.62143692170147e-12, X.grad:2.097149537361176e-11, loss:2.748772571379408e-23
Iter:18, X:5.242874277083809e-13, X.grad:4.194299421667047e-12, loss:1.0995092021011623e-24
Iter:19, X:1.0485747469965445e-13, X.grad:8.388597975972356e-13, loss:4.398036056521599e-26

img

可以看到,当学习率调整为 0.1 时,loss 曲线也可以快速收敛。

LR=0.125

那么,有没有能够使得收敛速度更快的学习率呢?我们尝试将学习率设置为 0.125:

lr = 0.125 

输出结果:

Iter:0, X:     2.0, X.grad:    16.0, loss:      16.0
Iter:1, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:2, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:3, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:4, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:5, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:6, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:7, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:8, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:9, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:10, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:11, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:12, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:13, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:14, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:15, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:16, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:17, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:18, X:     0.0, X.grad:     0.0, loss:       0.0
Iter:19, X:     0.0, X.grad:     0.0, loss:       0.0

img

可以看到,当学习率为 0.125 时,仅经过一次迭代,loss 值就已经达到收敛。那么,这个 0.125 是如何得到的呢?如果我们不知道函数表达式,我们是没办法直接计算出最佳学习率的。所以,通常我们会尝试性地设置一系列的学习率,以找到最佳学习率。下面我们来观察设置多个学习率时的 loss 变化情况。

设置多个学习率

我们在 0.01 到 0.5 之间线性地设置 10 个学习率:

iteration = 100
num_lr = 10
lr_min, lr_max = 0.01, 0.5

lr_list = np.linspace(lr_min, lr_max, num=num_lr).tolist()
loss_rec = [[] for l in range(len(lr_list))]
iter_rec = list()

for i, lr in enumerate(lr_list):
    x = torch.tensor([2.], requires_grad=True)
    for iter in range(iteration):

        y = func(x)
        y.backward()
        x.data.sub_(lr * x.grad)  # x.data -= x.grad
        x.grad.zero_()

        loss_rec[i].append(y.item())

for i, loss_r in enumerate(loss_rec):
    plt.plot(range(len(loss_r)), loss_r, label="LR: {}".format(lr_list[i]))
plt.legend()
plt.xlabel('Iterations')
plt.ylabel('Loss value')
plt.show()

输出结果:

img

我们得到了 10 个不同的 loss 曲线,横轴表示迭代次数,纵轴表示 loss 值。可以看到,loss 值的尺度是 1038,这是一个非常大的数字,不是我们所希望的。可以看到,从 0.5 到 0.337 这 4 条曲线都存在激增趋势。这表明我们的学习率上限设置得过大了,导致了 loss 激增和梯度爆炸。现在,我们将学习率上限改为 0.3,观察一下 loss 曲线的变化情况:

lr_min, lr_max = 0.01, 0.3

输出结果:

img

可以看到,在学习率取到最大值 0.3 时,loss 值尺度为 1030,相比之前 1038 有所下降,但是 loss 值仍然存在激增现象。下面我们将学习率上限改为 0.2,观察一下 loss 曲线的变化情况:

lr_min, lr_max = 0.01, 0.2

输出结果:

img

可以看到,现在的 10 条曲线都呈现下降趋势,这正是我们所期望的。最右边的蓝色曲线对应最小学习率 0.01,其收敛速度也是最慢的,大约为 30 次。右数第二条橙色曲线对应第二小的学习率 0.03,其收敛速度也是第二慢的。那么,是否学习率越大,收敛越快呢?我们看到,收敛最快的曲线并不是最大学习率 0.2 对应的青色曲线,而是学习率 0.136 对应的粉色曲线。回忆一下,前面我们提到的最佳学习率 0.125,这些学习率中与其距离最近的正是 0.136。因此,当学习率距离最优学习率最近时,收敛速度最快。但是,我们没有上帝视角,无法提前知道最优学习率,所以我们通常会设置诸如 0.01 这样非常小的学习率,以达到收敛效果,其代价就是收敛速度可能会较慢。

综上所述,设置学习率时不能过大,否则将导致 loss 值激增,并且引发梯度爆炸;同时也不能过小,否则会导致收敛速度过慢,时间成本增加。通过将学习率设置为 0.01 这样较小的值,就可以使得我们的 loss 值逐渐下降直到收敛。

动量

Momentum (动量/冲量):结合当前梯度与上一次更新信息,用于当前更新。

指数加权更新:求取当前时刻的平均值,常用于时间序列分析。对于那些距离当前时刻越近的参数值,它们的参考性越大,所占的权重也越大,而权重随时间间隔的增大呈指数下降。

其中,$vt$ 是当前时刻的平均值,$v{t-1} $是前一个时刻的平均值,$θ_t$ 是当前时刻的参数值,β 是权重参数。

例子

数据集为连续多天的温度值:

img

假设现在我们要求取第 100 天的温度平均值:

image-20230112230245627

代码示例

import torch
import numpy as np
import torch.optim as optim
import matplotlib.pyplot as plt

torch.manual_seed(1)


def exp_w_func(beta, time_list):
    return [(1 - beta) * np.power(beta, exp) for exp in time_list]


beta = 0.9
num_point = 100
time_list = np.arange(num_point).tolist()

weights = exp_w_func(beta, time_list)    # 指数权重

plt.plot(time_list, weights, '-ro', label="Beta: {}\ny = B^t * (1-B)".format(beta))
plt.xlabel("time")
plt.ylabel("weight")
plt.legend()
plt.title("exponentially weighted average")
plt.show()

print(np.sum(weights))

输出结果:

0.9999734386011124 

img

可以看到,权重随时间间隔的增加呈指数下降。下面我们尝试调整超参数 β 的值,来观察一下权重的变化:

# 多个权重曲线
beta_list = [0.98, 0.95, 0.9, 0.8]
w_list = [exp_w_func(beta, time_list) for beta in beta_list]

for i, w in enumerate(w_list):
    plt.plot(time_list, w, label="Beta: {}".format(beta_list[i]))
    plt.xlabel("time")
    plt.ylabel("weight")

plt.legend()
plt.show()

输出结果:

img

可以看到,随着权重参数 β 的增大,权重曲线逐渐变得平缓。我们可以将其理解为某种记忆周期,β 值越小,其记忆周期越短,对于较长时间间隔参数的关注越少。通常我们将 β 设置为 0.9,即权重曲线将更加关注距离当前时间 1/(1−β)=10 天以内的数据。

我们已经了解了指数加权平均中的权重参数 β,在梯度下降中它对应的就是 momentum 系数。

梯度下降

PyTorch 中的更新公式

其中, 是第 i+1 次更新的参数,LR 是学习率, 是 wi 的梯度,vi 是更新量,m 是 momentum 系数。

例如:

可以看到,momentum 系数的作用就是当前更新不仅考虑了当前的梯度信息,同时也考虑了之前几次的梯度信息。由于 momentum 系数取值在 [0,1],所以时间间隔越长的梯度信息所占权重越低。

代码示例

下面我们设置两个学习率,在都不加上 momentum 系数的情况下,对比两者的权重曲线:

def func(x):
    return torch.pow(2*x, 2)    # y = (2x)^2 = 4*x^2        dy/dx = 8x

iteration = 100

m = 0.    # Momentum 系数
lr_list = [0.01, 0.03]    # 学习率

momentum_list = list()
loss_rec = [[] for l in range(len(lr_list))]
iter_rec = list()

for i, lr in enumerate(lr_list):
    x = torch.tensor([2.], requires_grad=True)

    momentum = 0. if lr == 0.03 else m
    momentum_list.append(momentum)

    optimizer = optim.SGD([x], lr=lr, momentum=momentum)

    for iter in range(iteration):

        y = func(x)
        y.backward()

        optimizer.step()
        optimizer.zero_grad()

        loss_rec[i].append(y.item())

for i, loss_r in enumerate(loss_rec):
    plt.plot(range(len(loss_r)), loss_r, label="LR: {} M:{}".format(lr_list[i], momentum_list[i]))

plt.legend()
plt.xlabel('Iterations')
plt.ylabel('Loss value')
plt.show()

输出结果:

img

可以看到,学习率为 0.03 的橙色曲线要比学习率为 0.01 的蓝色曲线的收敛速度更快。下面我们为较小的学习率 0.01 增加一个 momentum 系数,与不加 momentum 系数的学习率 0.03 对比:

m = 0.9    # 为学习率 0.01 增加一个 momentum 系数 0.9 

输出结果:

img

可以看到,在增加了一个 momentum 系数 0.9 后,学习率 0.01 对应的蓝色曲线要比学习率为 0.03 但是没有增加 momentum 系数的橙色曲线更快到达最低点。另外,蓝色曲线前期呈现震荡趋势,这是由于我们的momentum 系数设置过大,虽然我们当前梯度很小,但是之前的梯度很大,导致在到达极小值后受到前几个时刻的梯度信息的影响而反弹,如此往复震荡。我们可以尝试修改 momentum 系数的值:

m = 0.63    # 为学习率 0.01 增加一个 momentum 系数 0.63 

输出结果:

img

可以看到,通过合理地设置 momentum 系数,结合之前的梯度信息,我们可以让 loss 曲线更快收敛到极小值点。不过大部分情况下,我们通常会将 momentum 系数设置为 0.9。

PyTorch 中的常用优化器

optim.SGD

功能:随机梯度下降。

optim.SGD(
    params,
    lr=<object object>,
    momentum=0,
    dampening=0,
    weight_decay=0,
    nesterov=False
)

主要参数

  • params:管理的参数组。
  • lr:初始学习率。
  • momentum:动量系数 β。
  • weight_decay:L2 正则化系数。
  • nesterov:是否采用 NAG。

NAG 参考文献On the importance of initialization and momentum in deep learning

PyTorch 中的 10 种常用优化器

  1. optim.SGD:随机梯度下降法。
  2. optim.Adagrad:自适应学习率梯度下降法。
  3. optim.RMSpropAdagrad 的改进。
  4. optim.AdadeltaAdagrad 的改进。
  5. optim.AdamRMSprop 结合 Momentum
  6. optim.AdamaxAdam 增加学习率上限。
  7. optim.SparseAdam:稀疏版的 Adam
  8. optim.ASGD:随机平均梯度下降。
  9. optim.Rprop:弹性反向传播。
  10. optim.LBFGSBFGS 的改进。

参考文献

  1. optim.SGDOn the importance of initialization and momentum in deep learning
  2. optim.AdagradAdaptive subgradient methods for online learning and stochastic optimization
  3. optim.RMSpropRMSProp
  4. optim.AdadeltaADADELTA: An Adaptive Learning Rate Method
  5. optim.AdamAdam: A Method for Stochastic Optimization
  6. optim.AdamaxAdam: A Method for Stochastic Optimization
  7. optim.SparseAdam:稀疏版的 Adam
  8. optim.ASGDAccelerating Stochastic Gradient Descent using Predictive Variance Reduction
  9. optim.RpropRPROP-A Fast Adaptive Learning Algorithm
  10. optim.LBFGSBDGS 的改进。

总结

本节课中,我们学习了优化器 Optimizer 中的两个主要参数:学习率和 momentum。下节课中,我们将学习关于学习率的调整策略。