PyTorch03-PyTorch的主要组成模块

三、PyTorch的主要组成模块

3.1 深度学习流程及与机器学习差异

一项机器学习任务的整体流程如下:

  • 数据准备与划分:对原始数据进行清洗、格式统一与特征变换,并划分为训练集、验证集和测试集(如采用随机切分或 K 折交叉,可以使用sklearn带的test_train_split函数、kfold来实现)。
  • 模型与算法配置:选择合适的网络架构,指定衡量误差的损失函数、更新参数的优化器以及相关超参数(可以使用sklearn这样的机器学习库中模型自带的损失函数和优化器)。
  • 模型训练(拟合):将训练集输入模型,通过反向传播算法迭代更新权重以拟合数据。
  • 性能评估与调优:在验证集和测试集上评估模型的泛化能力,并根据表现进行超参数微调或最终效果验证。

深度学习与机器学习的差异如下:

对比维度 传统机器学习 深度学习
本质定义 数据驱动让程序从经验中提升性能,包含多种算法范式 机器学习的一个分支,特指用多层神经网络自动提取特征
特征提取方式 人工设计特征。依赖专家经验从原始数据中提取有用特征(如TF-IDF、HOG、SIFT等),特征的好坏直接影响模型上限 自动学习特征。网络底层自动学习边缘、纹理等低级特征,高层组合成语义特征,实现端到端学习,无需人工设计
数据需求量 较小。几百到几万样本即可取得不错效果,在小数据集上表现稳定,不易过拟合 较大。因参数量巨大,通常需要十万级以上样本才能充分训练,否则容易过拟合;可通过迁移学习/数据增强缓解
硬件依赖 CPU即可胜任。计算量相对较小,普通笔记本电脑即可运行大部分算法 重度依赖GPU。矩阵运算密集,需要GPU加速才能在大规模数据和深层网络上合理时间内完成训练
数据加载方式 通常可全量加载。数据量小,可一次性读入内存,训练过程简单直接 需mini-batch分批加载。数据量和模型参数量远超显存容量,必须分批次送入GPU训练,配合DataLoader、预读取等机制;批大小(batch size)本身也是影响模型收敛的重要超参数
模型构建方式 调用现成算法库。通常直接调用sklearn等库中的完整算法类,传入参数即可,无需逐层搭建网络结构 逐层搭建或模块组装。需手动定义网络各层(卷积层、池化层、批正则化层、LSTM层等),或预定义功能模块再组装,灵活性强但代码实现要求更高
损失函数与优化器 与深度学习类似,均需设定损失函数和优化器;但传统模型多为凸优化,调参相对简单 损失函数和优化器需保证反向传播能在用户自定义的复杂网络结构上正确实现;非凸优化对学习率、动量等超参数更敏感
GPU配置与操作 通常不需要。大部分传统ML算法在CPU上运行即可满足需求 必须显式管理。需将模型和数据手动“放到”GPU(.cuda()或.to(device)),损失函数和优化器也需在GPU上工作;多卡训练还需考虑模型并行/数据并行、梯度同步、结果汇总等问题;验证/评测时常需将数据“放回”CPU
训练与验证流程 全量数据一次性训练。通常一轮训练即可收敛(如线性回归闭式解),或少量迭代(如随机森林建树) 按批循环训练。每个epoch遍历所有batch,每批数据前向传播→计算损失→反向传播→优化器更新参数;需协调DataLoader、模型、损失函数、优化器、GPU之间的配合;验证时同样按批计算指标
可解释性 较强。树模型可直接输出特征重要性排序,线性模型系数有明确含义,易于理解和调试 较弱。黑盒特性显著,难以直观解释内部决策逻辑,需借助Grad-CAM、SHAP、注意力可视化等事后工具辅助理解
适用数据类型 结构化表格数据为主。在金融风控、电商推荐等表格数据场景中表现优异 非结构化数据为主。图像、语音、文本、视频等原始高维数据是深度学习的主场
表格数据表现 常胜。XGBoost、LightGBM等梯度提升树在表格数据上至今是工业界首选 未必占优。纯深度模型在表格数据上通常打不过调优后的GBDT,需结合TabNet等专门设计才有竞争力
图像/语音/NLP 基本退场。传统方法在这些领域已被深度学习全面超越 绝对主场。CNN统治图像,RNN/Transformer统治序列数据,大语言模型引领NLP前沿
典型算法举例 线性回归、逻辑回归、支持向量机(SVM)、决策树、随机森林、XGBoost、K近邻(KNN)、K均值聚类 卷积神经网络(CNN)、循环神经网络(RNN/LSTM)、Transformer、生成对抗网络(GAN)、扩散模型(Diffusion)、BERT/GPT

其中需要特别提到的是,深度学习所需的样本量一般更大,所以相关数据(训练集/验证集/测试集)一般无法一次性被加载到内存或显存中,此时一般分批输入数据。因此深度学习在数据加载上需要有专门的设计。

3.2 基本配置

一些常用的包

在使用PyTorch中,需要导入一些常用的包来使用其提供的特定功能。常见的包有如下:os、sys、torch、torch.nn、torch.utils.data.Dataset、torch.utils.data.DataLoader、torch.optimizer、numpy、matplotlib、seaborn,如果涉及到表格读取与处理一般会用到pandas,下游分析和指标计算一般会用到sklearn。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import os 
import numpy as np
import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
import torch.optim as optimizer
import matplotlib
import seaborn
import sklearn

print(f"numpy: {np.__version__}")
print(f"torch: {torch.__version__}")
print(f"matplotlib: {matplotlib.__version__}")
print(f"seaborn: {seaborn.__version__}")
print(f"sklearn: {sklearn.__version__}")

print("导包完成!")


# 输出如下:
numpy: 2.5.1
torch: 2.6.0+cu124
matplotlib: 3.11.1
seaborn: 0.13.2
sklearn: 1.9.0
导包完成!

一些常用的超参数:

可以通过变量的形式设置超参数,然后在代码中使用:

1
2
3
4
5
6
batch_size = 16
# 批次的大小
lr = 1e-4
# 优化器的学习率
max_epochs = 100
...

也可以使用yaml、json,dict等文件来存储超参数,这样可以方便后续的调试和修改,常见的深度学习库(mmdetection,Paddledetection,detectron2)和一些AI Lab里面比较常用这种方式。

指定GPU

如果未指明要使用GPU,程序中默认使用的是CPU。如果指明了使用GPU但未指定GPU编号,那么默认就一直使用第0个GPU,容易造成此GPU资源如显存耗尽,所以最好明确要使用的GPU的编号。一般有如下两个方法

1
2
3
4
5
6
7
8
9
10
11
12
# 方案一:过滤/屏蔽指定的 GPU。
# 1. 过滤/屏蔽指定的 GPU:仅将物理上的 1 号和 2 号卡暴露给当前程序。
# 2. PyTorch 内部会将这 2 张可见卡重新映射为逻辑上的 cuda:0 和 cuda:1。
# 3. 此时当前程序一共只能看到 2 张卡,只能使用cuda:0、cuda:1。如果尝试调用 cuda:2 或更高索引,会因索引越界而报错 (invalid device ordinal)。
import os
os.environ['CUDA_VISIBLE_DEVICES'] = '1,2' # 程序只能看到服务器上的1,2号GPU,它们会被映射为0,1号GPU

# 方案二:代码中动态为device变量赋值,后续对要使用GPU的变量用.to(device)。其实就是动态指定要使用的GPU编号
device = torch.device("cuda:1" if torch.cuda.is_available() else "cpu") # 指明调用的GPU为1号

# 方案三:在终端中运行,仅将系统的 0 号和 1 号 GPU 暴露给该 Python 程序
CUDA_VISIBLE_DEVICES=0,1 python train.py

3.3 数据读入

此小节学习如下内容:PyTorch常见的数据读取方式、构建自己的数据读取流程。


在PyTorch中,通过Dataset与DataLoader的配合使用,完成对的数据集的加载与批量读入。其中Dataset告诉 PyTorch 数据在哪里、怎么读取某一条具体的数据(比如读取一张图片、一段文本或一行表格数据)、读取出来后要做什么预处理(数据变换/Augmentation),DataLoader本身就是一个可迭代对象(Iterable)、以迭代的方式不断按批次(就是一次读入固定的N个单数据,最后一个批次可能小于N个单数据)读入数据。

总结起来这二者的作用如下:

  • Dataset:解决“单条数据的提取与转换”问题(个体逻辑)。

  • DataLoader:解决“批量化、打乱、多线程加速与内存流式供给”问题(工程效率)。

不管处理的是图像、语音还是文本,DataLoader 的批处理逻辑是通用的,只需要编写对应数据类型的 Dataset 即可。PyTorch官方也提供了一些现成的具体Dataset类实现。

可以自定义类来实现灵活的数据读取,自定义类需要继承PyTorch自身的torch.utils.data.Dataset类。一般只需要重写几个核心方法:

  • __init__:初始化,传入外部参数、配置数据集运行所需的基础资源与参数。
  • __getitem__:用于逐个读取样本集合中的元素,可以进行一定的变换,并将返回训练/验证所需的数据。比如输入一个索引 idx,返回第 idx 条处理好的数据(比如:读取第 50 张图,裁剪放缩后,转换为 Tensor 并与标签一起返回)。
  • __len__:用于返回数据集的样本数。即告诉 PyTorch 一共有多少条数据(比如数据集一共 10000 张图)。

PyTorch 官方提供的一些现成 Dataset 具体实现类,可以直接使用。比如下面的datasets.ImageFolder及大致使用:

1
2
3
4
5
import torch
from torchvision import datasets
# 分别创建训练与验证时所用到的Dataset类实例
train_data = datasets.ImageFolder(train_path, transform=data_transform)
val_data = datasets.ImageFolder(val_path, transform=data_transform)

此示例代码使用了PyTorch中自带的ImageFolder类读取按一定结构存储的图片数据。ImageFolder 会自动扫描 train_path 下的所有子文件夹名称作为类别(Class),并自动为每个类别分配一个整数标签(0, 1, 2...)。

1
2
3
4
5
6
7
# train_path/
# ├── cat/ --> 自动映射标签类别 0
# │ ├── 001.jpg
# │ └── 002.jpg
# └── dog/ --> 自动映射标签类别 1
# ├── 003.jpg
# └── 004.jpg

其中data_transform可以对图像进行一定的变换,如翻转、裁剪、缩放等操作,可自定义。

如下是一个自定义Dataset类的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import os
import pandas as pd
from torch.utils.data import Dataset
from torchvision.io import read_image

# 继承 PyTorch 的基类 Dataset 来构建自定义数据集
class MyDataset(Dataset):
def __init__(self, annotations_file, img_dir, transform=None, target_transform=None):
"""
Args:
annotations_file (string): 存储图像名称和标签信息的 CSV 文件路径。
img_dir (string): 存放所有图像文件的目录路径。
transform (callable, optional): 应用于图像特征(Input)的数据预处理/增强函数。
target_transform (callable, optional): 应用于标签(Label)的数据预处理/转换函数。
"""
# 读取 CSV 标注文件,保存为 DataFrame 格式(通常第0列为图片文件名,第1列为标签)
self.img_labels = pd.read_csv(annotations_file)
# 保存图片文件所在的根目录路径
self.img_dir = img_dir
# 保存传入的图像预处理/特征变换方法
self.transform = transform
# 保存传入的标签预处理/变换方法
self.target_transform = target_transform

def __len__(self):
# 返回数据集中的总样本数(即标注文件的行数)
return len(self.img_labels)

def __getitem__(self, idx):
"""
Args:
idx (int): 数据的索引位置
"""
# 根据索引获取第 idx 行、第 0 列的图片文件名,并拼接出该图片的完整磁盘路径
img_path = os.path.join(self.img_dir, self.img_labels.iloc[idx, 0])
# 使用 torchvision 的 read_image 函数直接从磁盘读取图片并转为 PyTorch Tensor 格式
image = read_image(img_path)
# 根据索引获取第 idx 行、第 1 列的标签值
label = self.img_labels.iloc[idx, 1]

# 如果指定了图像变换逻辑(如缩放、归一化、数据增强等),则对图像应用变换
if self.transform:
image = self.transform(image)
# 如果指定了标签变换逻辑(如 One-Hot 编码、类型转换等),则对标签应用变换
if self.target_transform:
label = self.target_transform(label)

# 返回处理好的图像 Tensor 以及对应的标签
return image, label

构建好Dataset后,就可以使用DataLoader来按批次读入数据了,实现代码如下:

1
2
3
4
from torch.utils.data import DataLoader
# 如下两行代码,分别创建训练数据、验证数据的DataLoader
train_loader = torch.utils.data.DataLoader(train_data, batch_size=batch_size, num_workers=4, shuffle=True, drop_last=True)
val_loader = torch.utils.data.DataLoader(val_data, batch_size=batch_size, num_workers=4, shuffle=False)

其中相关参数的作用:

  • train_data / val_data:输入的数据集实例(即继承并实现了 Dataset 的对象),告诉 DataLoader 从哪里提取数据。
  • batch_size:样本是按批次(Batch)读入的,指定每个批次包含的样本数量。
  • num_workers:指定用于数据加载的多进程 CPU 线程/进程数。数值越大,后台并行读取数据的速度越快,可有效防止 GPU 等待数据(0 表示仅用主进程)。
  • shuffle:指定是否在每个 Epoch 开始时打乱数据顺序。训练集设置为 True 可提高模型泛化能力;验证集通常设置为 False 以保持评估指标的可比性。
  • drop_last:指定当总样本数不能被 batch_size 整除时,是否丢弃最后一个样本量不足的 Batch(设置为 True 可避免最后一个小批次影响 Batch Normalization 或计算图的形状)。

可以通过如下方式查看加载的数据(又用到了matplotlib):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import matplotlib.pyplot as plt

# 以下代码的作用是:从验证集中提取“第一个批次”(对应代码:next(iter(val_loader)) )的数据,
# 并使用 Matplotlib 将该批次中的“第一张图片”(images[0])绘制展示出来。
# val_loader 是一个可迭代对象,可使用next和iter来迭代获取
images, labels = next(iter(val_loader))
print(images.shape) # 输出格式通常为: torch.Size([Batch, C, H, W])

# 1. images[0] 取出批次中的第一张图片
# 2. .detach() 切断可能的梯度追踪
# 3. .cpu() 确保张量在 CPU 内存中(如果是 GPU 张量直接 .numpy() 会报错)
# 4. .permute(1, 2, 0) 将 [C, H, W] 调整为 Matplotlib 要求的 [H, W, C]
# 5. .numpy() 显式转换为 NumPy 数组
img = images[0].detach().cpu().permute(1, 2, 0).numpy()

plt.imshow(img)
plt.show()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 模拟从Dataloader中按批次获取所有数据并处理
import matplotlib.pyplot as plt

# 遍历每一个 Batch
for images, labels in val_loader:
print(images.shape) # 输出当前 Batch 的形状,如 torch.Size([32, 3, 224, 224])

# 遍历当前 Batch 中的每一张图片
for i in range(images.shape[0]):
# numpy() 或 permute 转换维度:(C, H, W) -> (H, W, C)
img = images[i].permute(1, 2, 0).numpy()
plt.imshow(img)
plt.title(f"Label: {labels[i].item()}")
plt.show()

3.4 模型构建

本节学习内容:PyTorch中神经网络的构造方法、PyTorch中特殊层的构建、LeNet的PyTorch实现。


人工智能的第三次浪潮主要受益于深度学习的突破,其成功得益于海量数据、GPU 算力飞跃以及深度神经网络算法(如结合了 BP 算法和 GPU 加速的卷积神经网络 AlexNet)的融合应用。

PyTorch中,自定义模型一般是基于继承nn.Module类来完成的,我们只需要重写其中一些方法(如__init__、forward)即可。这给灵活构造自定义模型提供方便捷。

3.4.1 神经网络的构造

PyTorch 中,torch.nn.Module是一个基本构造类,所有神经网络层(如 nn.Linear、nn.Conv2d)以及自定义的大模型,本质上都是它的子类。

以下示例通过继承 Module 类构造多层感知机。这里定义的 MLP 类重载了 Module 类的 __init__ 函数和 forward 函数。它们分别用于创建模型参数和定义前向计算(正向传播)。下面的 MLP 类定义了一个具有两个隐藏层的多层感知机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import torch
from torch import nn

class MLP(nn.Module):
# 声明带有模型参数的层,这里声明了两个全连接层
def __init__(self, **kwargs):
# 调用MLP父类Block的构造函数来进行必要的初始化。这样在构造实例时还可以指定其他函数
super(MLP, self).__init__(**kwargs)
self.hidden = nn.Linear(784, 256)
self.act = nn.ReLU()
self.output = nn.Linear(256,10)

# 定义模型的前向计算,即如何根据输入x计算返回所需要的模型输出
def forward(self, x):
o = self.act(self.hidden(x))
return self.output(o)

上述执行过程大致如下:

  • 这个MLP的__init__方法中定义了两个线性函数,一个激活函数。然后在forward方法中使用了它们
  • self.hidden(x):输入特征数是 784(例如将 \(28 \times 28\) 的图像展平后的向量),通过矩阵乘法 \(xW^T + b\) 转换后,输出 256 个特征数。
  • self.act(...):ReLU 是一个激活函数,计算公式为 \(\max(0, x)\)。它不会改变张量的形状或特征数量,只是把这 256 个特征值中小于 0 的部分变成 0,大于 0 的保持不变,因此最终输出是 256 个激活后的特征值。
  • self.output(o):将这 256 个特征值传递到输出层,最终映射转换为 10 个特征值(比如对应 10 个类别的预测 logits)。

以上的 MLP 类中⽆须定义反向传播函数。系统将通过⾃动求梯度⽽自动⽣成反向传播所需的 backward 函数。

nn.Module类中包含__call__属性且是一个可执行方法,按照Python中的 __call__ 协议,nn.Module类实例就可以当成一个方法来调用,且调用时实际上执行的就是__call__属性对应的方法。以下代码首先将MLP实例化为net,通过调用net(x)最终将导致调用MLP类中重写的forward方法(而这也反过来要求我们在继承nn.Module类自定义子类时,一定要重写实现forward方法)。

1
2
3
4
X = torch.rand(2,784) # 设置一个随机的输入张量
net = MLP() # 实例化模型
print(net) # 打印模型
net(X) # 前向计算
1
2
3
4
5
6
7
8
9
10
# 输出如下
MLP(
(hidden): Linear(in_features=784, out_features=256, bias=True)
(act): ReLU()
(output): Linear(in_features=256, out_features=10, bias=True)
)
tensor([[ 0.0057, 0.0504, -0.1897, 0.2306, 0.0876, 0.2021, 0.1165, 0.1699,
-0.0005, -0.1344],
[-0.0716, -0.0220, -0.1374, 0.1645, 0.1267, 0.0325, 0.1621, 0.2814,
-0.1388, -0.2205]], grad_fn=<AddmmBackward0>)

注意,此示例中nn.Module的命名特征,翻译过来叫“模块”,而不是叫Layer层或Model模型,因为该类被定义成一个可供⾃由组建的部件。Module 里可以包含 Module,任意多个 Module 可以组合成更大、更复杂的 Module。

继承nn.Module的子类即可以是某某层(如池化层、线性化层等),也可以是一个模型(如上述MLP),甚至一个模型的一部分(在中大型复杂模型如 ResNet、Transformer、YOLO 等中,“模型的一个部分”被经常单独封装为一个 nn.Module )。

3.4.2 神经网络中常见的层

深度学习中提供了或可实现神经网络中各式各样的层,如全连接层、卷积层、池化层与循环层等等。虽然PyTorch中已经预置了大量的层,但我们在实际开发中仍可能需要自定义层,此时就是一个继承 nn.Module类实现子类的过程。

3.4.2.1 不含模型参数的层

先介绍如何定义一个不含模型参数的自定义层。以下示例中定义了一个层:将输入减掉均值后输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import torch
from torch import nn

class MyLayer(nn.Module):
def __init__(self, **kwargs):
super(MyLayer, self).__init__(**kwargs)
"""
只有当计算逻辑涉及固定常数、预设权重或带有参数的子层时,才会在 __init__ 中提前声明,然后在 forward 中使用。
数据的实际计算(如切片、加减乘除、均值计算、矩阵乘法等)必须写在 forward 中,因为这些操作需要针对每次传入的具体张量 x 实时执行。
"""

def forward(self, x):
return x - x.mean()

layer = MyLayer()
out = layer(torch.tensor([1, 2, 3, 4, 5], dtype=torch.float))
print(out)

# 输出如下:
tensor([-2., -1., 0., 1., 2.])

__init__ 只负责“搭建舞台”(初始化网络结构与权重),forward 才是真正的“演出过程”(数据流转与计算)。

3.4.2.2 含模型参数的层

还可以自定义包含模型参数的层。其中的模型参数可以通过多轮训练反向传播更新学习。

torch.nn.Parameter类是 torch.Tensor 的子类。当我们在自定义层或模型时,如果某个张量是实际上是torch.nn.Parameter实例,那么它将被自动添加到模型的参数列表中,以便于后续跟踪、求导。所以在⾃定义含模型参数的层时,应该将这些模型参数定义成 Parameter类型。除了直接定义成 Parameter 类外,还可以使⽤ ParameterList 和 ParameterDict 分别定义参数的列表和字典。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyListDense(nn.Module):
def __init__(self):
super(MyListDense, self).__init__()
self.params = nn.ParameterList([nn.Parameter(torch.randn(4, 4)) for i in range(3)])
self.params.append(nn.Parameter(torch.randn(4, 1)))

def forward(self, x):
for i in range(len(self.params)):
x = torch.mm(x, self.params[i])
return x
net = MyListDense()
print(net)

# 输出如下:
MyListDense(
(params): ParameterList(
(0): Parameter containing: [torch.float32 of size 4x4]
(1): Parameter containing: [torch.float32 of size 4x4]
(2): Parameter containing: [torch.float32 of size 4x4]
(3): Parameter containing: [torch.float32 of size 4x1]
)
)
tensor([[512.]], grad_fn=<MmBackward0>)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MyDictDense(nn.Module):
def __init__(self):
super(MyDictDense, self).__init__()
self.params = nn.ParameterDict({
'linear1': nn.Parameter(torch.randn(4, 4)),
'linear2': nn.Parameter(torch.randn(4, 1))
})
self.params.update({'linear3': nn.Parameter(torch.randn(4, 2))}) # 新增

def forward(self, x, choice='linear1'):
return torch.mm(x, self.params[choice])

net = MyDictDense()
print(net)

# 输出如下:
MyDictDense(
(params): ParameterDict(
(linear1): Parameter containing: [torch.FloatTensor of size 4x4]
(linear2): Parameter containing: [torch.FloatTensor of size 4x1]
(linear3): Parameter containing: [torch.FloatTensor of size 4x2]
)
)

3.4.2.3 二维卷积层

虽然在深度学习中我们习惯叫“卷积”运算,但严格从数学定义上讲,神经网络里的卷积层实际上执行的是“互相关运算(Cross-correlation)”

  • 互相关运算:用一个较小的矩阵(卷积核 \(K\))在较大的输入矩阵(输入 \(X\))上从左到右、从上到下按步长滑动。每移动到一个位置,就将卷积核与输入对应的子区域按元素相乘再求和,得到输出矩阵 \(Y\) 中的一个点。
  • 标量偏差(Bias \(b\)):一个可学习的标量,加到互相关运算结果的每一个元素上。
  • 参数更新:卷积核权重 \(W\) 和偏置 \(b\) 就是模型要学习的“参数”。它们初始时是随机给出的,在训练过程中通过反向传播(Backpropagation)和梯度下降逐步优化。

卷积层的模型参数包括了卷积核和标量偏差。在训练模型的时候,通常先对卷积核随机初始化,然后不断迭代更新卷积核和偏差。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import torch
from torch import nn

# 卷积运算(二维互相关)
def corr2d(X, K):
h, w = K.shape
X, K = X.float(), K.float()
Y = torch.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
Y[i, j] = (X[i: i + h, j: j + w] * K).sum()
return Y

# 二维卷积层
class Conv2D(nn.Module):
def __init__(self, kernel_size):
super(Conv2D, self).__init__()
self.weight = nn.Parameter(torch.randn(kernel_size)) # 卷积核
self.bias = nn.Parameter(torch.randn(1)) # 偏差

def forward(self, x):
return corr2d(x, self.weight) + self.bias

卷积窗口形状为\(p×q\)的卷积层称为\(p×q\)卷积层。同样,\(p×q\)卷积或\(p×q\)卷积核说明卷积核的高和宽分别为p和q。一般情况下,p与q相等。

如果不手动指定初始化方法,PyTorch 会使用默认的 Kaiming 均匀分布(Kaiming Uniform Initialization) 来随机填充这个 \(p \times q\) 矩阵中的每个数值,每个元素有取值均匀分布在区间 \([-k, k]\) 内,其中 \(k = \frac{1}{\sqrt{\text{in\_channels} \times \text{p} \times \text{q}}}\)。

为了防止图像尺寸过快收缩(如果不加 Padding,每经过一层 \(3 \times 3\) 卷积,图像高宽就会减小 \(2\)。经过十几层卷积后,图像就会缩小为 \(1 \times 1\),无法构建很深的网络结构。)、保护边缘信息(位于输入图像边缘和角落的像素点,如果不加 Padding,被卷积核扫描到的次数远远少于中心区域的像素;加了 Padding 之后,边缘像素也能被充分提取特征),我们在实际应用中会对输入数据上下左右加上填充(padding)。

填充的元素值一般都是 0,PyTorch 中 nn.Conv2d 的 padding 参数默认采用的就是 零填充。加上填充后,我们可以使输入数据与输出数据有相同高度、宽度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import torch
from torch import nn

# 定义一个函数来计算卷积层。它对输入和输出做相应的升维和降维
def comp_conv2d(conv2d, X):
# (1, 1)代表批量大小和通道数
X = X.view((1, 1) + X.shape)
print(f"升维后,X的形状:{X.shape}")
Y = conv2d(X)
return Y.view(Y.shape[2:]) # 排除不关心的前两维:批量和通道


# 注意这里是两侧分别填充1⾏或列,所以在两侧一共填充2⾏或列
conv2d = nn.Conv2d(in_channels=1, out_channels=1, kernel_size=3,padding=1)

X = torch.rand(8, 8)
out = comp_conv2d(conv2d, X)
print(f"卷积结果out的形状 = {out.shape}")

# 输出如下:
升维后,X的形状:torch.Size([1, 1, 8, 8])
卷积结果out的形状 = torch.Size([8, 8])

还可以为卷积核窗口设置不同的高与宽。如果卷积核的高和宽不同时,可以通过在高和宽上设置不同的填充数,最后使得输出和输入具有相同的高和宽。

1
2
3
4
5
6
7
8
9
# 假设输入数据形状还是 (1,1,8,8)
# 使用高为5、宽为3的卷积核。在⾼和宽两侧的填充数分别为2和1
# 这样经过卷积运算后,输出结果的形状也是(1,1,8,8)
conv2d = nn.Conv2d(in_channels=1, out_channels=1, kernel_size=(5, 3), padding=(2, 1))
comp_conv2d(conv2d, X).shape

# 输出如下:
升维后,X的形状:torch.Size([1, 1, 8, 8])
torch.Size([8, 8])

填充可以增加输出的高和宽。这常用来使输出与输入具有相同的高和宽。

做卷积过程中,卷积窗口在输入数据上按照从上到下、从左到右的规则滑动,每次滑动的行数或列数称为步幅(stride),行步幅与列步幅可以不同。

1
2
3
4
5
6
conv2d = nn.Conv2d(1, 1, kernel_size=(3, 5), padding=(0, 1), stride=(3, 4))
comp_conv2d(conv2d, X).shape

# 输出如下:
升维后,X的形状:torch.Size([1, 1, 8, 8])
torch.Size([2, 2])

步幅可以减小输出的高和宽,例如将输出的高和宽变为指定的大小。

3.4.2.4 池化层

池化层每次对输入数据的一个固定形状窗口(⼜称池化窗口)中的元素计算输出。池化跟卷积操作顺序上有些类似,比如在二维池化中,池化窗口从输入数组的最左上方开始,按从左往右、从上往下的顺序,依次在输⼊数组上滑动计算池化窗口中满足计算规则的值。但此处的计算规则跟卷积中使用的计划规则不同,卷积中是计算卷积窗口中的输入与卷积核的互相关性,池化中是直接计算池化窗口中满足特定条件的元素值(如均值、最大值等)。

以下代码示例展示了最大池化层的前向计算过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import torch
from torch import nn

def pool2d(X, pool_size, mode='max'):
p_h, p_w = pool_size
Y = torch.zeros((X.shape[0] - p_h + 1, X.shape[1] - p_w + 1))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
if mode == 'max':
Y[i, j] = X[i: i + p_h, j: j + p_w].max()
elif mode == 'avg':
Y[i, j] = X[i: i + p_h, j: j + p_w].mean()
return Y

# 模拟输入数据
X = torch.tensor([[0, 1, 2], [3, 4, 5], [6, 7, 8]], dtype=torch.float)
# 指定池化窗口大小为(2, 2),并进行池化运算(默认是做最大池化)
print(pool2d(X, (2, 2)))
# 平均池化
print(pool2d(X, (2, 2), 'avg'))

# 输出如下:
tensor([[4., 5.],
[7., 8.]])
tensor([[2., 3.],
[5., 6.]])

3.4.2.5 全连接层

全连接层(Fully Connected Layer,简称 FC 层)也常被称为线性层(Linear Layer)或稠密层(Dense Layer)。它的特点是:前一层的每一个输入特征都与当前层的每一个输出神经元相连,因此称为“全连接”。在 PyTorch 中通常使用 nn.Linear 来定义。

对于单个样本,若输入特征向量为 \(\boldsymbol{x}\in\mathbb{R}^{d_{in}}\),输出为 \(\boldsymbol{y}\in\mathbb{R}^{d_{out}}\),全连接层计算的是:

\[ \boldsymbol{y}=\boldsymbol{x}\boldsymbol{W}^{T}+\boldsymbol{b} \]

其中,\(\boldsymbol{W}\in\mathbb{R}^{d_{out}\times d_{in}}\) 是可学习的权重矩阵,\(\boldsymbol{b}\in\mathbb{R}^{d_{out}}\) 是可学习的偏置向量。第 \(j\) 个输出元素可写成:

\[ y_j=\sum_{i=1}^{d_{in}}x_iW_{j,i}+b_j \]

  • 输入与输出形状:nn.Linear(in_features, out_features) 通常接收形状为 (batch_size, in_features) 的张量,输出形状为 (batch_size, out_features)。如果输入维度多于两维,它会把最后一维看作特征维度,其余维度保持不变。
  • 参数数量:当 bias=True(默认值)时,参数数量为 out_features × in_features + out_features。因此当输入维度很大时,全连接层的参数量和计算量都可能较大。
  • 线性变换:全连接层本身只进行仿射变换,并不引入非线性。实际网络通常在其后接 ReLU、GELU 等激活函数,从而拟合更复杂的非线性关系。
  • 主要作用:它能够融合所有输入特征,完成特征维度变换。在分类网络末端,常将卷积网络提取到的特征映射为类别数对应的 logits;在回归任务中,则可映射为一个或多个连续预测值。

在图像分类网络中,卷积层输出常为 (batch_size, channels, height, width)。传统做法会先用 Flatten 将每个样本展平为长度 channels × height × width 的向量,再输入全连接层。为了减少参数量,现代网络也常先使用全局平均池化(Global Average Pooling)把空间维度压缩为 1 × 1,然后只对通道特征使用全连接层。

全连接层与 \(1\times1\) 卷积层的关系:两者都可以在“通道维度”上做线性组合,但连接方式和适用场景不同。

  • 对形状为 (N, C, H, W) 的特征图,nn.Conv2d(C, C_out, kernel_size=1) 会在每一个空间位置 (h, w) 上,将该位置的 C 个通道映射为 C_out 个通道;所有空间位置共享同一组权重。因此,它等价于对每个像素位置独立地应用同一个 nn.Linear(C, C_out)。
  • 若先将整个特征图展平,再使用 nn.Linear(C × H × W, out_features),则每个输出神经元会连接到所有通道和所有空间位置,可以直接建模不同位置之间的关系;它的参数量也通常远大于 \(1\times1\) 卷积层。
  • 当特征图的空间尺寸为 1 × 1 时,\(1\times1\) 卷积层与全连接层在计算上等价(权重形状的排列不同);但当 H、W 大于 1 时,\(1\times1\) 卷积并不等价于“展平后”的全连接层。

以下示例固定设置权重和偏置,展示 nn.Linear 的逐项计算过程,并验证其结果与手工矩阵计算一致:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import torch
from torch import nn

# 创建一个全连接层:4 个输入特征 -> 3 个输出特征
fc = nn.Linear(in_features=4, out_features=3, bias=True)

# 为了便于核对计算过程,手动设置权重 W 和偏置 b
# PyTorch 中 fc.weight 的形状为 (out_features, in_features),即 (3, 4)
with torch.no_grad():
fc.weight.copy_(torch.tensor([
[1.0, 2.0, 3.0, 4.0],
[0.0, 1.0, 0.0, -1.0],
[-1.0, 0.0, 1.0, 0.0],
]))
fc.bias.copy_(torch.tensor([0.5, -0.5, 1.0]))

# 两个样本组成一个 batch,每个样本包含 4 个特征
X = torch.tensor([
[1.0, 2.0, 3.0, 4.0],
[4.0, 3.0, 2.0, 1.0],
])

Y = fc(X)
Y_manual = X @ fc.weight.T + fc.bias # 按公式 Y = XW^T + b 手工计算

print(f"输入 X 的形状: {X.shape}")
print(f"权重 W 的形状: {fc.weight.shape}")
print(f"偏置 b 的形状: {fc.bias.shape}")
print("\n第一个样本第一个输出:")
print("1×1 + 2×2 + 3×3 + 4×4 + 0.5 =", Y[0, 0].item())
print("\n全连接层输出 Y:")
print(Y)
print("\n手工矩阵计算结果:")
print(Y_manual)
print("\n两种计算结果是否一致:", torch.allclose(Y, Y_manual))

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
输入 X 的形状: torch.Size([2, 4])
权重 W 的形状: torch.Size([3, 4])
偏置 b 的形状: torch.Size([3])

第一个样本第一个输出:
1×1 + 2×2 + 3×3 + 4×4 + 0.5 = 30.5

全连接层输出 Y:
tensor([[30.5000, -2.5000, 3.0000],
[20.5000, 1.5000, -1.0000]], grad_fn=<AddmmBackward0>)

手工矩阵计算结果:
tensor([[30.5000, -2.5000, 3.0000],
[20.5000, 1.5000, -1.0000]], grad_fn=<AddBackward0>)

两种计算结果是否一致: True

3.4.2.6 批正则处理层

批正则处理层通常指 批量归一化层(Batch Normalization,简称 BatchNorm 或 BN)。它经常被放在卷积层或全连接层之后、激活函数之前,例如:

1
2
3
4
5
from torch import nn

nn.Conv2d(3, 64, kernel_size=3, padding=1)
nn.BatchNorm2d(64)
nn.ReLU()

名称中的 Normalization 是“归一化”,不是“正则化”。BN 的直接工作是调整中间特征的数值分布;它有时会带来一定的正则化效果,但它与在损失函数中加入 L1/L2 正则项、或随机丢弃神经元的 Dropout 不是同一件事。

在训练深层网络时,前面层的参数一更新,后面层接收到的特征分布也会随之改变。例如某一特征在这次迭代中的数值可能集中在 0 附近,而下一次迭代中可能整体变得很大或很小。BatchNorm 会根据当前小批量(mini-batch)的统计量,把各个特征先调整到较稳定的尺度,再交给后续层处理。这通常能让训练更稳定,并允许使用相对更大的学习率;但它不是保证模型一定收敛或一定提高准确率的“万能层”。

一维特征的计算过程:假设全连接层的输入形状为 (N, C),其中 N 是 batch_size、C 是特征数。对每一个特征通道 c,BN 会在当前 batch 的 N 个样本上分别计算均值和方差:

\[ \mu_{\mathcal{B},c}=\frac{1}{N}\sum_{i=1}^{N}x_{i,c} \]

\[ \sigma_{\mathcal{B},c}^{2}=\frac{1}{N}\sum_{i=1}^{N}(x_{i,c}-\mu_{\mathcal{B},c})^{2} \]

接着进行标准化:

\[ \hat{x}_{i,c}=\frac{x_{i,c}-\mu_{\mathcal{B},c}}{\sqrt{\sigma_{\mathcal{B},c}^{2}+\epsilon}} \]

其中 \(\epsilon\) 是一个很小的常数,用于防止方差为 0 时除以 0。若只到这一步,每个特征在当前 batch 上的均值约为 0、方差约为 1。BN 最后还会进行可学习的仿射变换:

\[ y_{i,c}=\gamma_c\hat{x}_{i,c}+\beta_c \]

这里的 \(\gamma_c\)(缩放)和 \(\beta_c\)(平移)是每个通道各自拥有的可学习参数。它们使网络不被限制为“输出必须均值为 0、方差为 1”:模型可以在训练中学会保留、放大、缩小或平移某个特征。

卷积特征图的计算过程:卷积层通常输出形状 (N, C, H, W)。nn.BatchNorm2d(C) 仍然是“每个通道一组统计量”,但第 c 个通道的均值和方差会同时在 N、H、W 三个维度上计算,也就是用该通道内的全部 N × H × W 个数值计算,而不会把不同通道混在一起。输出形状保持为 (N, C, H, W) 不变。三种常见 BN 层的输入形式如下:

层 常见输入形状 统计范围
nn.BatchNorm1d(num_features) (N, C),或序列特征 (N, C, L) 每个通道分别在 batch 维(以及存在时的序列长度维)统计
nn.BatchNorm2d(num_features) 图像特征图 (N, C, H, W) 每个通道分别在 N、H、W 维统计
nn.BatchNorm3d(num_features) 三维体数据 (N, C, D, H, W) 每个通道分别在 N、D、H、W 维统计

训练模式与评估模式的区别:这是使用 BN 时很重要的一点。

  • 在 model.train() 的训练模式下,BN 使用当前 batch的均值和方差完成前向计算;同时会把这些统计量以滑动平均的方式更新到 running_mean 和 running_var 中。
  • 在 model.eval() 的评估/推理模式下,BN 不再依赖当前输入 batch 的统计量,而是使用训练期间累计得到的 running_mean 和 running_var;这使得同一张图片单独推理或与其他图片一起推理时,结果不会因为同批的其他样本不同而变化。
  • running_mean、running_var 是模型的 buffer(缓冲区),会随 state_dict() 一起保存和加载,但不会由优化器通过反向传播更新;可学习的则是 weight(即 \(\gamma\))和 bias(即 \(\beta\))。

PyTorch 中 momentum 的默认值为 0.1。每次训练前向传播后,运行均值大致按下面的方式更新:

\[ \text{running\_mean}_{new}=(1-\text{momentum})\times\text{running\_mean}_{old}+\text{momentum}\times\mu_\mathcal{B} \]

这里的 momentum 是“当前 batch 统计量在运行统计量中所占的更新比例”,含义不要与 SGD 等优化器中的动量混淆。通常不需要手动修改它。

有一个用于理解数值的实现细节:PyTorch 在训练前向传播中用“除以 N”的有偏方差(也就是示例中 x.var(..., unbiased=False) 的结果)完成标准化;但更新 running_var 时使用无偏方差(除以 N-1)。因此示例第一个特征的前向方差为 1.25,而首次更新后的运行方差为 0.9 × 1 + 0.1 × 1.6667,约为 1.0667。初学时只需知道:应让 PyTorch 自动维护 running_var,而不要手动替换它。

BatchNorm 的常用构造参数包括:

  • num_features:通道数/特征数。例如卷积输出通道为 64 时,应写 nn.BatchNorm2d(64),不是写 batch_size。
  • eps=1e-5:公式中的 \(\epsilon\),通常使用默认值即可。
  • momentum=0.1:更新运行均值、运行方差的比例,通常使用默认值即可。
  • affine=True:是否保留可学习的 \(\gamma\)、\(\beta\)。默认值为 True;若为 False,BN 只做标准化。
  • track_running_stats=True:是否维护供评估模式使用的运行统计量。默认值为 True,一般保持默认值。

下面的完整示例使用 BatchNorm1d 展示“当前 batch 的统计量”“可学习的仿射参数”和“训练/评估模式差异”。为方便核对,示例手动设置了 \(\gamma\) 与 \(\beta\):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import torch
from torch import nn

# 输入形状为 (N, C):4 个样本,每个样本有 3 个特征。
x = torch.tensor([
[1.0, 10.0, 100.0],
[2.0, 20.0, 200.0],
[3.0, 30.0, 300.0],
[4.0, 40.0, 400.0],
])

# num_features=3 对应输入的特征数 C=3。
# affine=True:拥有可学习的 weight(gamma) 与 bias(beta);
# track_running_stats=True:拥有 running_mean 与 running_var。
bn = nn.BatchNorm1d(num_features=3, eps=1e-5, momentum=0.1)

# 为了让结果容易理解,手动设置 gamma 和 beta。
# 实际训练中不需要这样做,它们会由优化器通过反向传播学习。
with torch.no_grad():
bn.weight.copy_(torch.tensor([1.0, 2.0, 0.5])) # gamma
bn.bias.copy_(torch.tensor([0.0, 1.0, -1.0])) # beta

# train():使用当前 x 的 batch 均值/方差,并更新运行统计量。
bn.train()
y_train = bn(x)

# 手工复现训练模式的主要计算过程。
# unbiased=False 对应 BatchNorm 前向标准化时使用的“除以 N”的方差形式。
batch_mean = x.mean(dim=0)
batch_var = x.var(dim=0, unbiased=False)
x_normalized = (x - batch_mean) / torch.sqrt(batch_var + bn.eps)
y_manual = x_normalized * bn.weight + bn.bias

print("输入 x:")
print(x)
print("\n当前 batch 的每个特征均值:", batch_mean)
print("当前 batch 的每个特征方差:", batch_var)
print("\n训练模式的 BN 输出:")
print(y_train)
print("\n手工计算与 BN 输出是否一致:", torch.allclose(y_train, y_manual))
print("训练后 running_mean:", bn.running_mean)
print("训练后 running_var:", bn.running_var)

# eval():不再使用当前 x 的 batch 均值/方差,而是使用上面已更新的运行统计量。
bn.eval()
y_eval = bn(x)
print("\n评估模式对同一 x 的输出:")
print(y_eval)
print("训练模式与评估模式输出是否相同:", torch.allclose(y_train, y_eval))

# BatchNorm2d 的形状示例:只改变数值分布,不改变 N、C、H、W 的形状。
images = torch.randn(4, 3, 8, 8) # (batch_size=4, channels=3, height=8, width=8)
bn2d = nn.BatchNorm2d(num_features=3)
feature_maps = bn2d(images)
print("\nBatchNorm2d 输入形状:", images.shape)
print("BatchNorm2d 输出形状:", feature_maps.shape)

运行时可观察到:

  • batch_mean 分别为 [2.5, 25.0, 250.0],说明 BN 是对每一个特征列独立计算,不会把三列数值混在一起。
  • torch.allclose(y_train, y_manual) 为 True,说明训练模式下的输出符合“标准化后乘以 \(\gamma\)、再加 \(\beta\)”的公式。
  • 由于这里手动设置了不同的 \(\gamma\)、\(\beta\),y_train 的各列最终均值并不一定是 0、方差也不一定是 1;均值为 0、方差为 1 的是中间量 x_normalized。
  • 训练后 running_mean 和 running_var 不等于当前 batch 的统计量,因为它们是从初始值按 momentum=0.1 做一次滑动更新得到的。
  • 对同一个 x,训练模式和刚切换到评估模式后的输出通常不同:前者使用当前 batch 的统计量,后者使用尚未完全稳定的运行统计量。这是正常现象,并不是 BN 出错。

BN 依赖一个 batch 内的统计量。当训练 batch 很小(尤其每个通道的有效元素数很少)时,均值和方差的估计可能不稳定;对于小 batch 的检测、分割或生成任务,常会根据具体网络与任务考虑 GroupNorm、LayerNorm 等替代方案。无论使用哪一种归一化层,都应正确切换 model.train() 与 model.eval(),不要只切换部分 BN 层。

3.4.3 模型示例

前面提到过,可以使用torch.nn包来构建神经网络。nn包则依赖于autograd包来定义模型并对它们求导。一个nn.Module包含各个层和一个forward(input)方法,由forward(input)方法返回模型或层的输出output。

3.4.3.1 LeNet

image-20260811211824553

上图是LeNet的网络结构,图来自杨立昆等著的论文《Gradient-based learning applied to document recognition》。

LeNet是一个简单的前馈神经网络,它接受一个输入,然后一层层不断向前传递,直到最后的输出。

一个神经网络的典型训练过程如下:

  • 定义包含可学习参数(如权重 Weight 和偏置 Bias)的神经网络
  • 在输入数据集上迭代
  • 通过网络处理输入
  • 计算 loss (输出和正确答案的距离)
  • 将梯度反向传播给网络的参数
  • 更新网络的参数(Parameters),一般使用简单的规则:\(\text{parameter} = \text{parameter} - \text{learning\_rate} \times \text{gradient}\)
3.4.3.1.1 LeNet-5重点讲解

以下是LeNet-5的现代 PyTorch 适配版本代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import torch
import torch.nn as nn
import torch.nn.functional as F


class Net(nn.Module):

def __init__(self):
super(Net, self).__init__()
# 输入图像channel:1;输出channel:6;5x5卷积核
self.conv1 = nn.Conv2d(1, 6, 5)
self.conv2 = nn.Conv2d(6, 16, 5)
# an affine operation: y = Wx + b
self.fc1 = nn.Linear(16 * 5 * 5, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, 10)

def forward(self, x):
# 2x2 Max pooling
x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
# 如果是方阵,则可以只使用一个数字进行定义
x = F.max_pool2d(F.relu(self.conv2(x)), 2)
x = x.view(-1, self.num_flat_features(x))
x = F.relu(self.fc1(x))
x = F.relu(self.fc2(x))
x = self.fc3(x)
return x

def num_flat_features(self, x):
size = x.size()[1:] # 除去批处理维度的其他所有维度
num_features = 1
for s in size:
num_features *= s
return num_features


# 模型实例化
print(f"\n模型实例化:")
net = Net()
print(net)

# 输出如下:
模型实例化:
Net(
(conv1): Conv2d(1, 6, kernel_size=(5, 5), stride=(1, 1))
(conv2): Conv2d(6, 16, kernel_size=(5, 5), stride=(1, 1))
(fc1): Linear(in_features=400, out_features=120, bias=True)
(fc2): Linear(in_features=120, out_features=84, bias=True)
(fc3): Linear(in_features=84, out_features=10, bias=True)
)

可以看到,其中定义了 forward 函数但没有显式定义backward函数。backward函数会在使用autograd时自动定义(即自动在那些requires_grad=True的张量上启动追踪),backward函数用来计算导数。可以在 forward 函数中使用任何针对张量的操作和计算。

前面提到过,“模型”或“层”或“模型中的某个子模块/部件”中可学习参数会⾃动被添加到模型的参数列表里。这个参数列表可以通过net.parameters()返回。

1
2
3
4
5
6
7
8
9
10
# 模型的可学习参数
print(f"\n模型的可学习参数:")
params = list(net.parameters())
print(f"len(params) = {len(params)}")
print(f"params[0].size() = {params[0].size()}") # conv1的权重

# 输出如下:
模型的可学习参数:
len(params) = 10
params[0].size() = torch.Size([6, 1, 5, 5])

在 PyTorch 中,所有的 2D 卷积层(nn.Conv2d)都强制要求输入数据必须是四维(4D)张量,其维度顺序固定为:

\[\text{(Batch\_Size, Channels, Height, Width)}\]

  • Batch_Size(批量大小):一次性喂给模型的样本数量。
  • Channels(通道数):图像的颜色通道(如 RGB 为 3 通道,单通道灰度图为 1 通道)。
  • Height(高度) & Width(宽度):图像像素的平面尺寸。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 执行模型训练
print(f"\n执行模型训练:")
"""
结合图像的对称性以及 Yann LeCun 在设计 LeNet-5 时的原始工程约束,
此模型的输入数据形状为(1, 1, 32, 32)。
如果使用著名的 MNIST 手写数字数据集 图像尺寸其实是 (28, 28),要在图像四周各填充了 2 个像素的零
然后再作为输入数据进行前向传播
"""
input = torch.randn(1, 1, 32, 32) # 四维(4D)张量
out = net(input)
print(out)

# 输出如下:
执行模型训练:
x.size() = torch.Size([1, 16, 5, 5])
size = torch.Size([16, 5, 5])
num_features = 400
tensor([[-0.0905, 0.0928, -0.0791, -0.0230, -0.0362, -0.0722, 0.1312, -0.0515,
0.0522, 0.0656]], grad_fn=<AddmmBackward0>)
1
2
3
4
# 清零所有参数的梯度缓存,然后进行随机梯度的反向传播:
print(f"\n清零所有参数的梯度缓存,然后进行随机梯度的反向传播:")
net.zero_grad()
out.backward(torch.randn(1, 10))

注意:torch.nn只支持小批量处理 (mini-batches)。整个 torch.nn 包只支持小批量样本的输入,不支持单个样本的输入。比如,nn.Conv2d 接受一个4维的张量,即nSamples x nChannels x Height x Width如果是一个单独的样本,只需要使用input.unsqueeze(0) 来添加一个“假的”批大小维度。

  • torch.Tensor - 一个多维数组,支持诸如backward()等的自动求导操作,同时也保存了张量的梯度。
  • nn.Module- 神经网络模块。是一种方便封装参数的方式,具有将参数移动到GPU、导出、加载等功能。
  • nn.Parameter- 张量的一种,当它作为一个属性分配给一个Module时,它会被自动注册为一个参数。
  • autograd.Function - 实现了自动求导前向和反向传播的定义,每个Tensor至少创建一个Function节点,该节点连接到创建Tensor的函数并对其历史进行编码。

上述LeNet-5的现代 PyTorch 适配版本代码实现了一个经典的 LeNet-5 卷积神经网络架构,展示了在 PyTorch 中从网络结构定义、前向传播计算、参数查看到伪数据模拟训练推理的完整流程。详细拆解如下。

3.4.3.1.2 网络结构定义(class Net)

这个网络包含了 2 个卷积层(CNN) 和 3 个全连接层(FC / Linear)。

1. 构造函数 __init__():实例化各网络层
  • self.conv1 = nn.Conv2d(1, 6, 5)
    • 输入通道:1(如单通道灰度图)
    • 输出通道:6(使用 6 个不同的卷积核,提取 6 种不同的特征)
    • 卷积核大小:5x5(省略了 padding,默认为 0)
    • 参数量:包含 \(6 \times 1 \times 5 \times 5 = 150\) 个权重及 \(6\) 个偏置。
  • self.conv2 = nn.Conv2d(6, 16, 5)
    • 输入通道:6(接收上一层输出的 6 个特征图)
    • 输出通道:16(生成 16 个特征图)
    • 卷积核大小:5x5
    • 参数量:包含 \(16 \times 6 \times 5 \times 5 = 2400\) 个权重及 \(16\) 个偏置。
  • self.fc1 = nn.Linear(16 * 5 * 5, 120)
    • 输入特征数:16 * 5 * 5 = 400(卷积和池化后展平的特征维数)
    • 输出特征数:120
  • self.fc2 = nn.Linear(120, 84)
    • 输入特征数:120
    • 输出特征数:84
  • self.fc3 = nn.Linear(84, 10)
    • 输入特征数:84
    • 输出特征数:10(通常对应 10 分类任务,如 MNIST 手写数字识别 0~9)
2. 前向传播 forward(self, x):数据流向与形状变化

假设输入的图像张量形状为 [1, 1, 32, 32](即 [Batch_Size, Channel, Height, Width]):

  1. x = F.max_pool2d(F.relu(self.conv1(x)), (2, 2))
    • conv1 卷积:尺寸计算 \(32 - 5 + 1 = 28\),输出形状变为 [1, 6, 28, 28]。
    • ReLU 激活:非线性映射,形状不变。
    • max_pool2d 池化:采用 \(2 \times 2\) 窗口,高宽减半,输出形状变为 [1, 6, 14, 14]。
  2. x = F.max_pool2d(F.relu(self.conv2(x)), 2)
    • conv2 卷积:尺寸计算 \(14 - 5 + 1 = 10\),输出形状变为 [1, 16, 10, 10]。
    • ReLU 激活:形状不变。
    • max_pool2d 池化:高宽减半((2, 2) 简写为 2),输出形状变为 [1, 16, 5, 5]。
  3. x = x.view(-1, self.num_flat_features(x))
    • 展平(Flatten):利用 view 将多维特征图打平成一维向量,准备喂给全连接层。
    • self.num_flat_features(x) 计算出单样本特征总数为 \(16 \times 5 \times 5 = 400\)。
    • -1 表示自动计算 Batch 维度,形状变为 [1, 400]。
  4. 全连接层计算:
    • x = F.relu(self.fc1(x)):矩阵乘法,形状变为 [1, 120]。
    • x = F.relu(self.fc2(x)):矩阵乘法,形状变为 [1, 84]。
    • x = self.fc3(x):最后一层不加 ReLU,直接输出原始 logits,形状变为 [1, 10]。
3. 辅助函数 num_flat_features(self, x)

Python

1
2
3
4
5
6
def num_flat_features(self, x):
size = x.size()[1:] # x.size() 为 [1, 16, 5, 5],x.size()[1:] 切片截取 [16, 5, 5](排除 Batch 维)
num_features = 1
for s in size:
num_features *= s # 16 * 5 * 5 = 400
return num_features
  • 作用:动态计算单个样本在进入全连接层之前的所有特征点总数(即 \(C \times H \times W\))。
3.4.3.1.3 主程序执行流程
1. 模型实例化与网络结构打印

Python

1
2
net = Net()
print(net)
  • 实例化网络并打印出网络的层级架构结构图。
2. 查看可学习参数(net.parameters())

Python

1
2
3
params = list(net.parameters())
print(f"len(params) = {len(params)}")
print(f"params[0].size() = {params[0].size()}")
  • len(params) 为什么是 10?

    网络一共有 5 层(conv1, conv2, fc1, fc2, fc3),每一层包含 1 个 Weight(权重) 和 1 个 Bias(偏置),因此共有 \(5 \times 2 = 10\) 组参数张量。

  • params[0].size():即 conv1.weight 的形状,打印结果为 torch.Size([6, 1, 5, 5])。

3. 模拟数据前向传播

Python

1
2
3
input = torch.randn(1, 1, 32, 32) # 随机生成 1 张 32x32 的单通道图像
out = net(input) # 自动触发 forward 方法
print(out)
  • 输出 out 是一个形状为 torch.Size([1, 10]) 的 Tensor,表示该输入图像在 10 个类别上的未归一化得分(Logits)。
3.4.3.1.4 极简总结与前向传播特征图变化图解
阶段 / 网络层 输入形状 (B, C, H, W) 输出形状 (B, C, H, W) 说明
Input [1, 1, 32, 32] [1, 1, 32, 32] 伪造的输入数据
conv1 + Pool [1, 1, 32, 32] [1, 6, 14, 14] 经过 5x5 卷积 + 2x2 最大池化
conv2 + Pool [1, 6, 14, 14] [1, 16, 5, 5] 经过 5x5 卷积 + 2x2 最大池化
view (展平) [1, 16, 5, 5] [1, 400] \(16 \times 5 \times 5 = 400\) 铺平为向量
fc1 + fc2 [1, 400] [1, 84] 连续经过两次带 ReLU 的线性变换
fc3 (Output) [1, 84] [1, 10] 输出 10 个类别的预测得分

3.4.3.2 AlexNet

image-20260811224903743

上图是AlexNet的网络结构,图来自Alex Krizhevsky等著的论文《ImageNet Classification with Deep Convolutional Neural Networks》

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import torch.nn as nn

class AlexNet(nn.Module):
def __init__(self):
super(AlexNet, self).__init__()
# 特征提取器
self.conv = nn.Sequential(
nn.Conv2d(1, 96, 11, 4), # in_channels, out_channels, kernel_size, stride, padding
nn.ReLU(),
nn.MaxPool2d(3, 2), # kernel_size, stride
# 减小卷积窗口,使用填充为2来使得输入与输出的高和宽一致,且增大输出通道数
nn.Conv2d(96, 256, 5, 1, 2),
nn.ReLU(),
nn.MaxPool2d(3, 2),
# 连续3个卷积层,且使用更小的卷积窗口。除了最后的卷积层外,进一步增大了输出通道数。
# 前两个卷积层后不使用池化层来减小输入的高和宽
nn.Conv2d(256, 384, 3, 1, 1),
nn.ReLU(),
nn.Conv2d(384, 384, 3, 1, 1),
nn.ReLU(),
nn.Conv2d(384, 256, 3, 1, 1),
nn.ReLU(),
nn.MaxPool2d(3, 2)
)
# 分类器
# 这里全连接层的输出个数比LeNet中的大数倍。使用丢弃层来缓解过拟合
self.fc = nn.Sequential(
nn.Linear(256*5*5, 4096),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(4096, 4096),
nn.ReLU(),
nn.Dropout(0.5),
# 输出层。由于这里使用Fashion-MNIST,所以用类别数为10,而非论文中的1000
nn.Linear(4096, 10),
)

def forward(self, img):
feature = self.conv(img)
output = self.fc(feature.view(img.shape[0], -1))
return output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
net = AlexNet()
print(net)

# 输出如下:
AlexNet(
(conv): Sequential(
(0): Conv2d(1, 96, kernel_size=(11, 11), stride=(4, 4))
(1): ReLU()
(2): MaxPool2d(kernel_size=3, stride=2, padding=0, dilation=1, ceil_mode=False)
(3): Conv2d(96, 256, kernel_size=(5, 5), stride=(1, 1), padding=(2, 2))
(4): ReLU()
(5): MaxPool2d(kernel_size=3, stride=2, padding=0, dilation=1, ceil_mode=False)
(6): Conv2d(256, 384, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(7): ReLU()
(8): Conv2d(384, 384, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(9): ReLU()
(10): Conv2d(384, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1))
(11): ReLU()
(12): MaxPool2d(kernel_size=3, stride=2, padding=0, dilation=1, ceil_mode=False)
)
(fc): Sequential(
(0): Linear(in_features=6400, out_features=4096, bias=True)
(1): ReLU()
(2): Dropout(p=0.5, inplace=False)
(3): Linear(in_features=4096, out_features=4096, bias=True)
(4): ReLU()
(5): Dropout(p=0.5, inplace=False)
(6): Linear(in_features=4096, out_features=10, bias=True)
)
)

这段代码是 AlexNet 卷积神经网络在 PyTorch 中的经典实现。

AlexNet 是 2012 年 ImageNet 竞赛的冠军模型,也是深度学习复兴的标志性架构。这段代码对原始论文的 AlexNet 进行了微调(例如将输入通道数设为 1,输出类别数设为 10),使其能够直接运行在 Fashion-MNIST 或 MNIST 手写数字数据集上。

以下是对这段代码的逐模块详细拆解。

3.4.3.2.1 整体结构概述

代码通过继承 nn.Module 定义了两个核心部分(使用 nn.Sequential 串联层结构):

  1. self.conv(特征提取器):由 5 个卷积层 和 3 个最大池化层 组成,负责将原始图像转化为高维抽象特征。
  2. self.fc(分类器):由 3 个全连接层 和 Dropout 层 组成,负责将提取出的特征映射到 10 个类别上。
3.4.3.2.2 特征提取层(self.conv)维度的详细演变

假设输入的图像尺寸为 Fashion-MNIST 标准的 (Batch, 1, 224, 224)(通常会将 \(28 \times 28\) 图像 Resize 到 \(224 \times 224\) 以适配 AlexNet 的大卷积核):

序号 层结构 参数配置 输出特征图尺寸 (Shape) 作用与核心思想
1 nn.Conv2d(1, 96, 11, 4) 单通道输入, 96核, 11x11核, 步长4 [B, 96, 54, 54] 用大卷积核+大步长快速降低特征图分辨率,捕捉宏观纹理。
2 nn.ReLU() - [B, 96, 54, 54] 引入非线性激活。
3 nn.MaxPool2d(3, 2) 3x3核, 步长2 [B, 96, 26, 26] 重叠池化(Kernel 3 > Stride 2),防止信息过快丢失,缓解过拟合。
4 nn.Conv2d(96, 256, 5, 1, 2) 256核, 5x5核, 步长1, Padding 2 [B, 256, 26, 26] 保持高宽不变,进一步增加通道数提取丰富特征。
5 nn.ReLU() - [B, 256, 26, 26] 非线性激活。
6 nn.MaxPool2d(3, 2) 3x3核, 步长2 [B, 256, 12, 12] 再次重叠池化降低尺寸。
7 nn.Conv2d(256, 384, 3, 1, 1) 384核, 3x3核, 步长1, Padding 1 [B, 384, 12, 12] 连续卷积操作的第一层,不接池化,加深网络深度。
8 nn.ReLU() - [B, 384, 12, 12] 非线性激活。
9 nn.Conv2d(384, 384, 3, 1, 1) 384核, 3x3核, 步长1, Padding 1 [B, 384, 12, 12] 连续卷积操作的第二层。
10 nn.ReLU() - [B, 384, 12, 12] 非线性激活。
11 nn.Conv2d(384, 256, 3, 1, 1) 256核, 3x3核, 步长1, Padding 1 [B, 256, 12, 12] 压缩通道数,准备进入池化。
12 nn.ReLU() - [B, 256, 12, 12] 非线性激活。
13 nn.MaxPool2d(3, 2) 3x3核, 步长2 [B, 256, 5, 5] 最终卷积输出:每个样本拥有 \(256 \times 5 \times 5\) 个特征点。

关键推导:

(1)在深度学习中,二维卷积输出尺寸的通用计算公式为:

​ \[H_{out} = \left\lfloor \frac{H_{in} + 2 \times \text{padding} - \text{kernel\_size}}{\text{stride}} \right\rfloor + 1\]

(2)最后的输出尺寸为 [B, 256, 5, 5],因此展平(Flatten)后的特征向量长度为 \(256 \times 5 \times 5 = 6400\)。

3.4.3.2.3 分类层(self.fc)与关键机制

特征提取完毕后,进入全连接分类层:

  1. nn.Linear(256 * 5 * 5, 4096):
    • 输入维度是 \(256 \times 5 \times 5 = 6400\),映射到隐藏层 4096 维。
    • 比起的 LeNet(120维),AlexNet 采用了巨大的隐层节点数(4096),极大地增强了模型的特征表达能力。
  2. nn.Dropout(0.5):
    • 训练时以 50% 的概率随机断开神经元连接。
    • 作用:全连接层参数极多(如第一层全连接就有 \(6400 \times 4096 \approx 2600 万\) 个参数),极其容易过拟合。Dropout 是 AlexNet 能成功训练如此大网络的核心技术之一。
  3. nn.Linear(4096, 4096) \(\to\) nn.Linear(4096, 10):
    • 再次经过 4096 维隐层后,最终输出 10 维分类logits(对应 Fashion-MNIST 的 10 个类别)。
3.4.3.2.4 前向传播(forward)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def forward(self, img):
# 1. 经过卷积神经网络提取特征,输出 tensor 形状为 [Batch, 256, 5, 5]
feature = self.conv(img)

# 2. 将 4D 的特征图展平为 2D 矩阵 [Batch, 256*5*5],传给全连接层(全连接层要求输入是 2D 矩阵)
# view() 是 PyTorch 用来重构张量形状(Reshape)的方法。
# img.shape[0] 即为当前的 Batch Size
# -1:让 PyTorch 自动推导剩余维度的大小。
# 最终重构后的矩阵形状变为 [B, 6400]
output = self.fc(feature.view(img.shape[0], -1))
"""
# 效果与 feature.view(img.shape[0], -1) 完全一致:
# 从第 1 维开始展平(保留第 0 维 Batch 维)
output = self.fc(torch.flatten(feature, start_dim=1))
"""

return output
3.4.3.2.5 总结:AlexNet 相比 LeNet 的三大核心改进
  1. 结构更深更宽:从 LeNet 的 5 层增加到 8 层(5 卷积 + 3 全连接),通道数大幅提升(高达 384)。
  2. 激活函数改进:使用 ReLU 代替了 Sigmoid/Tanh,极大地加速了网络收敛并防止梯度消失。
  3. 防过拟合技术:引入了 Dropout 和 重叠池化(Overlapping Pooling),使得大参数量模型的泛化能力大幅提升。

3.5 模型初始化

本小节学习如下内容:常见的初始化函数、初始化函数的使用。


深度学习模型的训练中,权重初始化成符合某种规律的值比较重要。一个恰当的初始值会使模型收敛速度提高、准确率更高 ;不当的初始化值增加梯度消失或梯度爆炸的可能性。

一般情况下,不使用全0初始值训练网络。为了利于训练和减少收敛时间,需要对模型进行合理的初始化。

PyTorch的torch.nn.init包中提供了一些常用的初始化方法。

3.5.1 torch.nn.init内容

如PyTorch官网关于init的页面:https://docs.pytorch.org/docs/2.6/nn.init.html 所示,PyTorch中预置了一些常用的初始化方法:

  • torch.nn.init.uniform_(tensor, a=0.0, b=1.0)
  • torch.nn.init.normal_(tensor, mean=0.0, std=1.0)
  • torch.nn.init.constant_(tensor, val)
  • torch.nn.init.ones_(tensor)
  • torch.nn.init.zeros_(tensor)
  • torch.nn.init.eye_(tensor)
  • torch.nn.init.dirac_(tensor, groups=1)
  • torch.nn.init.xavier_uniform_(tensor, gain=1.0)
  • torch.nn.init.xavier_normal_(tensor, gain=1.0)
  • torch.nn.init.kaiming_uniform_(tensor, a=0, mode='fan__in', nonlinearity='leaky_relu')
  • torch.nn.init.kaiming_normal_(tensor, a=0, mode='fan_in', nonlinearity='leaky_relu')
  • torch.nn.init.orthogonal_(tensor, gain=1)
  • torch.nn.init.sparse_(tensor, sparsity, std=0.01)
  • torch.nn.init.calculate_gain(nonlinearity, param=None)

可以发现这些函数除了calculate_gain,所有函数的后缀都带有下划线,意味着这些函数将会直接原地更改输入张量的值。

下表展示了不同类型的激活函数(nonlinearity)与其对应的计算增益(gain)的映射关系:

nonlinearity gain
Linear / Identity 1
Conv{1,2,3}D 1
Sigmoid 1
Tanh 5/3
ReLU sqrt(2)
Leaky Relu sqrt[2/(1+neg_slop^2)]
SELU 3/4

“计算增益”(Gain),是一个缩放系数(Scaling Factor),用来抵消特定的非线性激活函数(Non-linearity)对数据方差(Variance)的改变,从而保持网络在前向传播时信号的方差稳定,防止梯度消失或梯度爆炸。

思考:线性激活函数的计算增益为什么为1?

3.5.2 torch.nn.init使用

在搭建或训练复杂的神经网络时,我们需要根据不同类型的网络层(如卷积层、全连接层、批归一化层等),为其选择最合适的权重初始化方法;而在代码实现中,我们通过 Python 的 isinstance() 函数来识别当前层到底属于哪种类型,从而精准地应用不同的 torch.nn.init 初始化策略。

3.5.2.1 为什么不同类型的层需要不同的初始化?

不同的网络层在数学逻辑和激活函数搭配上存在差异,混用同一种初始化方式会导致训练效果变差甚至无法收敛:

  • 卷积层(nn.Conv2d) & 全连接层(nn.Linear):

    它们包含权重(weight)和偏置(bias)。如果后续接的是 ReLU 激活函数,通常建议使用 Kaiming (He) 初始化(如 nn.init.kaiming_normal_);如果接的是 Tanh 或没有激活函数,通常建议使用 Xavier (Glorot) 初始化。

  • 批归一化层(nn.BatchNorm2d):

    它的缩放参数 weight(即 \(\gamma\))通常初始化为 1(保持方差不变),平移参数 bias(即 \(\beta\))初始化为 0(保持均值不变),通常不能使用基于随机分布(如正态分布 normal_ 或均匀分布 uniform_)的初始化函数,而是直接使用 全 1 初始化 和 全 0 初始化。

  • 偏置项(bias):

    不管是卷积层还是全连接层,偏置通常直接清零(nn.init.zeros_)。

3.5.2.2 为不同类型的层使用不同的权重初始化方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import torch
import torch.nn as nn

conv = nn.Conv2d(1,3,3)
linear = nn.Linear(10,1)

isinstance(conv,nn.Conv2d) # 判断conv是否是nn.Conv2d类型
isinstance(linear,nn.Conv2d) # 判断linear是否是nn.Conv2d类型

# 查看随机初始化的conv参数
print(conv.weight.data)
# 查看linear的参数
print(linear.weight.data)

# 对conv进行kaiming初始化
torch.nn.init.kaiming_normal_(conv.weight.data)
print(conv.weight.data)
# 对linear进行常数初始化
torch.nn.init.constant_(linear.weight.data,0.3)
print(linear.weight.data)

3.5.3 初始化函数的封装

在 PyTorch 中,一个网络(nn.Module)通常包含几十甚至几百个嵌套的子模块(Sub-modules)。我们不需要手动一个一个去找这些层,而是封装一个考虑了上述网络中出现的所有类型的层权重初始化方法如init_weights,利用 PyTorch 的 net.apply(init_weights) 方法结合 isinstance() 进行自动化判断与初始化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
import torch
import torch.nn as nn

# 1. 定义一个包含 卷积、全连接 和 BN 层的自定义网络模型
class MyComplexModel(nn.Module):
"""
自定义复合神经网络模型示例。
结合了 2D 卷积层 (Conv2d)、批归一化层 (BatchNorm2d)、ReLU 激活函数 以及 全连接层 (Linear),
用于演示对不同类型的神经网络层进行统一的参数初始化管理。
"""
def __init__(self, **kwargs):
"""
构造函数:初始化网络中的各个层组件。
:param kwargs: 可选的关键字参数,传递给父类 nn.Module,用于增强模型的配置扩展性。
"""
super(MyComplexModel, self).__init__(**kwargs)

# 卷积层:输入通道数为3(如 RGB 图像),输出特征通道数为16,3x3卷积核,padding=1维持特征图高宽不变
self.conv1 = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, padding=1)

# 批归一化层:通道数需要与上一层卷积输出通道数保持一致(16通道)
self.bn1 = nn.BatchNorm2d(num_features=16)

# 非线性激活函数:ReLU
self.relu = nn.ReLU()

# 全连接层:输入特征维度为 16*8*8 (即展平后的通道与空间特征),输出维度为 10 (对应10分类任务)
self.fc1 = nn.Linear(in_features=16 * 8 * 8, out_features=10)

def forward(self, x):
"""
前向传播逻辑:定义数据通过网络的计算路径。
:param x: 输入张量,期望形状为 [Batch_Size, 3, Height, Width]
:return: 网络预测输出,形状为 [Batch_Size, 10]
"""
x = self.conv1(x) # 提取空间特征,输出形状: [Batch, 16, H, W]
x = self.bn1(x) # 对通道特征做归一化调整,维持数值稳定性
x = self.relu(x) # 非线性激活
x = x.view(x.size(0), -1) # 展平多维特征图为一维向量,保留 Batch 维度 [Batch, 16*H*W]
x = self.fc1(x) # 线性映射到类别空间,输出形状: [Batch, 10]
return x

# 2. 定义自定义参数初始化函数
def init_weights(m):
"""
递归参数初始化回调函数(搭配 net.apply 使用)。
根据传入模块 m 的具体类型,应用不同的权重和偏置初始化策略,以维持深层网络前向传播时方差的稳定。
:param m: nn.Module 实例,即遍历到的当前网络子模块/子层
"""
# 处理卷积层:使用适用于 ReLU 激活函数的 Kaiming (He) 初始化
if isinstance(m, (nn.Conv2d, nn.Conv1d)):
# kaiming_normal_ 通过考虑输入/输出通道数自动缩放权重标准差,避免梯度消失或爆炸
nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
if m.bias is not None:
nn.init.zeros_(m.bias) # 卷积层偏置初始化为 0

# 处理全连接层:使用 Xavier (Glorot) 均匀分布初始化
elif isinstance(m, nn.Linear):
# xavier_uniform_ 使得输出方差尽可能与输入方差一致,适用于无激活或平滑激活函数
nn.init.xavier_uniform_(m.weight)
if m.bias is not None:
nn.init.zeros_(m.bias) # 全连接层偏置初始化为 0

# 处理批归一化层:保持标准初始化状态
elif isinstance(m, (nn.BatchNorm2d, nn.BatchNorm1d)):
# weight (gamma) 缩放因子初始化为 1,确保初始时不改变特征方差
nn.init.ones_(m.weight)
if m.bias is not None:
# bias (beta) 偏移量初始化为 0,确保初始时不改变特征均值
nn.init.zeros_(m.bias)

# 3. 打印模型关键层权重统计信息的辅助函数
def print_model_stats(model, stage_title):
"""
打印网络中核心参数层(Conv1, BN1, FC1)权重的均值与标准差,用于监控初始化前后的参数分布变化。
:param model: 需要查看参数的网络模型
:param stage_title: 阶段标识标题(如“初始化前”、“初始化后”)
"""
print(f"\n==================== {stage_title} ====================")

# 打印卷积层权重与偏置的分布信息
print(f"[Conv1 Weight] Mean: {model.conv1.weight.mean().item():.6f}, Std: {model.conv1.weight.std().item():.6f}")
if model.conv1.bias is not None:
print(f"[Conv1 Bias] Mean: {model.conv1.bias.mean().item():.6f}")

# 打印批归一化层参数分布信息(用于确认 weight 是否全为 1,bias 是否全为 0)
print(f"[BN1 Weight] Mean: {model.bn1.weight.mean().item():.6f}, Std: {model.bn1.weight.std().item():.6f}")
print(f"[BN1 Bias] Mean: {model.bn1.bias.mean().item():.6f}")

# 打印全连接层权重与偏置的分布信息
print(f"[FC1 Weight] Mean: {model.fc1.weight.mean().item():.6f}, Std: {model.fc1.weight.std().item():.6f}")
if model.fc1.bias is not None:
print(f"[FC1 Bias] Mean: {model.fc1.bias.mean().item():.6f}")

# ----------------- 执行流程 -----------------

# 1. 实例化模型
net = MyComplexModel()

# 2. 打印 PyTorch 默认初始化下的权重统计信息
print_model_stats(net, "初始化前 (PyTorch 默认初始化)")

# 3. 使用 apply() 递归应用自定义初始化策略
net.apply(init_weights)

# 4. 打印应用自定义初始化策略后的权重统计信息
print_model_stats(net, "初始化后 (自定义 init_weights)")

# 5. 验证模型前向传播计算
dummy_input = torch.randn(2, 3, 8, 8) # Batch=2, Channel=3, Height=8, Width=8
output = net(dummy_input)
print(f"\n前向传播测试成功,输出维度: {output.shape}")

关于上述代码段的解释:

isinstance(m, nn.Conv2d):

  • m 是当前遍历到的每一个子层模块(Module)。
  • isinstance(m, Class) 用于检查 m 是不是 Class 类的实例或其子类。
  • 如果 m 是 nn.Conv2d,表达式返回 True,程序就会执行内部的卷积层初始化逻辑。

isinstance(m, (nn.Conv2d, nn.Conv1d)):

  • isinstance 支持传入一个元组(Tuple),代表“只要是 1D 卷积或 2D 卷积中的任意一种”,就命中该条件。

net.apply(init_weights):

  • 这是 nn.Module 自带的一个递归函数。它会深度遍历 net 里的所有子模块(self.conv1、self.fc1、self.bn1 等),并把每一个子模块作为参数传递给 init_weights(m) 执行一次。

3.6 损失函数

损失函数(loss function,也常称代价函数)用一个可微的数值衡量模型预测与目标之间的差异。训练时,模型先前向计算得到预测,再计算损失,随后调用 loss.backward() 自动求导;优化器据此更新模型参数,使训练集上的损失趋于减小。

1
2
3
4
5
6
# 一个训练批次中损失函数所处的位置
logits = model(inputs) # 模型原始输出
loss = criterion(logits, labels) # 预测与目标的差异
optimizer.zero_grad() # 清除上一个批次累计的梯度
loss.backward() # 反向传播,计算各参数的梯度
optimizer.step() # 根据梯度更新参数

本节以 PyTorch 2.6 的 API 为准。旧教程中的 size_average 与 reduce 已废弃,实际使用时统一通过 reduction 控制聚合方式:

  • reduction='mean':默认值,返回所有损失元素的平均值;
  • reduction='sum':返回所有损失元素之和;
  • reduction='none':不聚合,保留每个样本(或元素)的损失,适合自行加权或排查异常样本。

选择损失函数时,首先要看任务和模型输出的含义:分类模型通常直接输出 logits(未经过 sigmoid 或 softmax 的实数);回归模型输出连续数值;序列、排序和度量学习有各自专用的损失函数。

3.6.1 二分类:BCEWithLogitsLoss 与 BCELoss

二分类中每个样本只有“是/否”两种结果,标签通常为 0.0 或 1.0。BCEWithLogitsLoss 接收 logits,BCELoss 接收已经转换到 [0, 1] 的概率。新训练代码通常优先使用前者,以获得更好的数值稳定性。

BCEWithLogitsLoss

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

功能:计算二分类或多标签分类的二元交叉熵,并在内部完成 sigmoid,适合直接接收模型 logits。

主要参数:

  • weight:按元素或按样本缩放损失;
  • size_average:已废弃。旧代码中 True 表示平均、False 表示求和;请改用 reduction;
  • reduce:已废弃。旧代码中 False 表示不聚合;请改用 reduction='none';
  • reduction:'none'、'mean' 或 'sum';
  • pos_weight:按类别增加正样本权重,常用于正样本稀少的情况。

计算公式与说明:对一个元素,设 \(x\) 为 logits、\(y\in\{0,1\}\) 为目标、\(p=\sigma(x)\) 为预测为正类的概率,则:

\[ \ell(x,y)=-[y\log p+(1-y)\log(1-p)] \]

BCEWithLogitsLoss 在数值上以稳定方式直接由 \(x\) 计算上式,不需要先显式求 \(p\)。设置 pos_weight 后,正样本项会乘以该权重;批量损失再按 reduction 做逐元素保留、平均或求和。

1
2
3
4
5
6
7
8
9
10
11
import torch
from torch import nn

# logits 是模型尚未经过 sigmoid 的原始输出,形状为 [4]
logits = torch.tensor([1.2, -0.7, 0.3, -1.5], requires_grad=True)
# 目标必须是与 logits 同形状的 float,值为 0 或 1
targets = torch.tensor([1.0, 0.0, 1.0, 0.0])

loss = nn.BCEWithLogitsLoss(pos_weight=torch.tensor([2.0]))(logits, targets)
loss.backward() # 为 logits 及其上游模型参数计算梯度
print('BCEWithLogitsLoss:', loss.item())

代码逐行解释:

  1. logits = torch.tensor([...], requires_grad=True):创建形状为 [4] 的浮点张量,可理解为一个批次中的 4 个样本各自输出 1 个二分类原始得分。它们不是概率,因此可以小于 0 或大于 1。requires_grad=True 会要求 Autograd 从此张量开始记录后续运算;在真实训练中,logits 通常来自模型最后一层,梯度会继续传回模型参数。
  2. targets = torch.tensor([1.0, 0.0, 1.0, 0.0]):创建与 logits 完全同形状的真实标签。BCE 系列要求标签是浮点张量而不是 long,1.0 表示正类、0.0 表示负类。形状不一致会导致错误或不符合预期的广播计算。
  3. nn.BCEWithLogitsLoss(...):实例化损失函数。这里的 pos_weight=torch.tensor([2.0]) 表示发生正样本错误时,正类损失项按 2 倍计入;它适用于正样本比负样本少得多的场景。未显式传入 reduction,所以使用默认的 'mean'。
  4. ...(logits, targets):调用损失模块完成前向传播。内部不会先暴露一个实际的 sigmoid 张量再计算对数,而是采用更稳定的等价公式直接从 logits 得到损失;因此模型最后一层不应再手动加 sigmoid。
  5. loss.backward():loss 是一个标量,调用它会从损失节点沿计算图反向求导。此例的梯度保存在 logits.grad;真实训练循环通常接着调用 optimizer.step(),用这些梯度更新模型权重。
  6. print(...):用 .item() 把单元素损失张量转换成普通 Python 浮点数,便于打印或记录。不要对非标量损失直接 backward(),除非提供对应的外部梯度。

内部计算过程:

示例数据:

  • logits = [1.2, -0.7, 0.3, -1.5];
  • targets = [1, 0, 1, 0];
  • pos_weight = 2,reduction='mean'。

计算步骤:

  1. 将 logits 转为概率(仅用于理解;实际函数内部以稳定方式完成):

    \[p_i=\sigma(x_i)=\frac{1}{1+e^{-x_i}}\]

    \[p\approx[0.7685,\ 0.3318,\ 0.5744,\ 0.1824]\]

  2. 逐样本计算加权二元交叉熵:正类项乘以 pos_weight=2。

    \[l_i=-[2y_i\log(p_i)+(1-y_i)\log(1-p_i)]\]

    • \(l_1=-2\log(0.7685)\approx0.5266\);
    • \(l_2=-\log(1-0.3318)\approx0.4032\);
    • \(l_3=-2\log(0.5744)\approx1.1090\);
    • \(l_4=-\log(1-0.1824)\approx0.2014\)。
  3. 得到最终输出:默认取平均值。

    \[loss=\frac{l_1+l_2+l_3+l_4}{4}\approx0.5601\]

梯度含义:未加权时梯度方向与 \(\sigma(x_i)-y_i\) 一致;正样本的梯度会被 pos_weight 放大,使模型更重视漏判的正样本。

BCELoss

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

功能:计算二分类的二元交叉熵。与 BCEWithLogitsLoss 不同,输入必须已经是范围在 [0, 1] 内的概率。

主要参数:

  • weight:为各损失元素设置手动缩放权重;
  • size_average:已废弃。旧代码中的平均/求和开关,应改为 reduction='mean' 或 'sum';
  • reduce:已废弃。旧代码中关闭它相当于使用 reduction='none';
  • reduction:指定不聚合、平均或求和。

计算公式与说明:公式与二元交叉熵相同: \[ \ell(p,y)=-[y\log p+(1-y)\log(1-p)] \]

此公式中取对数时的底数是自然数e。此处输入 \(p\) 已经是概率,必须满足 \(0\leq p\leq1\);而 BCEWithLogitsLoss 的输入是 logits。两者的数学目标相同,区别主要在输入形式和数值稳定性。

1
2
3
4
5
6
7
8
9
10
11
import torch
from torch import nn

# 先将 logits 转为概率;实际训练中通常改用 BCEWithLogitsLoss 更稳定
logits = torch.tensor([1.2, -0.7, 0.3], requires_grad=True)
probabilities = torch.sigmoid(logits)
targets = torch.tensor([1.0, 0.0, 1.0])

loss = nn.BCELoss()(probabilities, targets)
loss.backward() # 梯度会经由 sigmoid 继续传回 logits
print('BCELoss:', loss.item())

代码逐行解释:

  1. logits = ... requires_grad=True:这 3 个值仍是模拟模型输出的原始得分。即使后面先经过 sigmoid,梯度链路仍会保留,因为 sigmoid 的输出依赖于 logits。
  2. probabilities = torch.sigmoid(logits):逐元素应用 \(\sigma(x)=1/(1+e^{-x})\),将任意实数映射到 (0,1)。这一步是 BCELoss 的前置条件;若误把 logits 直接传给 BCELoss,输入不一定是合法概率,训练结果会不正确。
  3. targets = ...:标签与 probabilities 同为 [3],且必须是 0/1 浮点数。BCELoss 会将每个位置视作彼此独立的一次伯努利事件。
  4. loss = nn.BCELoss()(probabilities, targets):先创建默认 reduction='mean' 的 BCELoss,再立即调用。它逐元素计算交叉熵,最后返回一个标量平均损失。
  5. loss.backward():梯度先从平均损失传给 probabilities,再通过 sigmoid 的导数 \(p(1-p)\) 传回 logits。因此 logits.grad 可用,而 probabilities.grad 默认不保存,因为它不是叶子张量。
  6. print(...):打印当前批次的平均交叉熵;训练中该数值可用于观察模型是否在学习,但不能单独代表准确率。

内部计算过程:

示例数据:

  • logits = [1.2, -0.7, 0.3];
  • targets = [1, 0, 1];
  • reduction='mean'。

计算步骤:

  1. Sigmoid 将 logits 转为 BCELoss 所需概率:

    \[p=\sigma(logits)\approx[0.7685,\ 0.3318,\ 0.5744]\]

  2. 逐样本计算交叉熵:

    \[l_i=-[y_i\log(p_i)+(1-y_i)\log(1-p_i)]\]

    • \(l_1=-\log(0.7685)\approx0.2633\);
    • \(l_2=-\log(1-0.3318)\approx0.4032\);
    • \(l_3=-\log(0.5744)\approx0.5544\)。
  3. 得到最终输出:

    \[loss=\frac{0.2633+0.4032+0.5544}{3}\approx0.4070\]

梯度含义:backward() 先计算损失对概率的导数,再乘 sigmoid 导数 \(p(1-p)\) 回传到 logits;预测正类概率不足时,梯度下降会增大对应 logits。

3.6.2 单标签多分类:CrossEntropyLoss

单标签多分类中,每个样本只属于一个类别。模型输出每个类别的 logits,目标是对应的类别索引。

CrossEntropyLoss

1
2
torch.nn.CrossEntropyLoss(weight=None, size_average=None, ignore_index=-100,
reduce=None, reduction='mean', label_smoothing=0.0)

功能:计算单标签多分类交叉熵,内部等价于 LogSoftmax + NLLLoss。输入为 [N, C] 的 logits,目标通常为 [N] 的类别索引。

主要参数:

  • weight:长度为类别数 C 的权重,用于类别不平衡;
  • size_average:已废弃,请用 reduction;
  • ignore_index:忽略指定类别索引的损失,常用于序列填充;
  • reduce:已废弃,请用 reduction;
  • reduction:损失聚合方式;
  • label_smoothing:标签平滑系数,默认 0.0。

计算公式与说明:设第 \(n\) 个样本的 logits 为 \(x_n\in\mathbb{R}^{C}\),正确类别索引为 \(y_n\),则无标签平滑时:

\[ \ell_n=-\log\frac{\exp(x_{n,y_n})}{\sum_{c=1}^{C}\exp(x_{n,c})} \]

它会提高正确类别的得分、压低其他类别的相对得分。label_smoothing>0 时,不再把全部目标概率放在正确类别上,而会向其他类别分配少量概率,从而减轻模型过度自信。

1
2
3
4
5
6
7
8
9
10
11
12
13
import torch
from torch import nn

# 3 个样本、4 个类别;不要先对 logits 做 softmax
logits = torch.tensor([[2.0, 0.1, -1.0, 0.3],
[0.2, 1.4, 0.5, -0.2],
[-0.5, 0.0, 0.4, 1.8]], requires_grad=True)
# 每个值是正确类别的索引,必须为 long,不是 one-hot 向量
targets = torch.tensor([0, 1, 3], dtype=torch.long)

loss = nn.CrossEntropyLoss(label_smoothing=0.1)(logits, targets)
loss.backward()
print('CrossEntropyLoss:', loss.item())

代码逐行解释:

  1. logits = torch.tensor([[...], ...], requires_grad=True):创建形状 [3,4] 的张量:第 0 维是 3 个样本,第 1 维是 4 个类别。每行的值只是相对得分,不是概率,因此每行不要求和为 1。
  2. targets = torch.tensor([0, 1, 3], dtype=torch.long):为每个样本保存一个正确类别的索引。CrossEntropyLoss 的类别索引目标必须是 long;这里不应传入 [1,0,0,0] 形式的 one-hot 向量。
  3. nn.CrossEntropyLoss(label_smoothing=0.1):实例化交叉熵。默认 reduction='mean';label_smoothing=0.1 让训练目标不再是绝对 one-hot,从而避免模型被过度鼓励给正确类输出概率 1。
  4. loss = criterion(logits, targets):前向计算时,对每一行 logits 独立做 log_softmax,再取对应目标类别(或平滑后目标分布)的负对数似然;不需要、也不应该在此之前手工 softmax。
  5. loss.backward():将 3 个样本平均后的标量损失反向传播。对每一行,正确类别的梯度倾向于使其得分上升,其他类别的梯度倾向于使其得分下降;标签平滑会减弱这个推动力度。
  6. argmax(dim=1):若用于推理,取每行最大 logits 的索引即可得到预测类。由于 softmax 是单调变换,先 softmax 再 argmax 得到的索引相同。

内部计算过程:

示例数据:第一行 logits 为 [2.0, 0.1, -1.0, 0.3],正确类别为 0;共有 \(C=4\) 类,label_smoothing=0.1。

计算步骤:

  1. 对第一行执行 softmax:

    \[p_c=\frac{e^{x_c}}{\sum_j e^{x_j}}\approx[0.7239,\ 0.1082,\ 0.0360,\ 0.1320]\]

  2. 构造平滑后的目标分布:

    \[q_0=1-0.1+\frac{0.1}{4}=0.925,\qquad q_1=q_2=q_3=\frac{0.1}{4}=0.025\]

  3. 计算该样本交叉熵:

    \[l_1=-\sum_{c=0}^{3}q_c\log p_c\]

    正确类 \(p_0\) 贡献最大,其他类的少量目标概率用于抑制过度自信。第二、三行分别使用标签 1、3 重复此过程。

  4. 得到最终输出:将三个样本损失按默认方式平均。

    \[loss=\frac{l_1+l_2+l_3}{3}\]

梯度含义:每类的梯度近似为 \((p_c-q_c)/3\);正确类概率不足时提高正确类 logits,预测过高的错误类 logits 被压低。

3.6.3 回归:L1Loss、MSELoss 与 SmoothL1Loss

回归任务预测连续数值;三种损失函数的主要差异在于对大误差和异常值的惩罚方式。

L1Loss

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

功能:计算预测值和目标值的平均绝对误差(MAE),即 \(|x-y|\),对异常值比 MSE 更不敏感。

主要参数:

  • size_average:已废弃。旧代码中控制平均或求和,请改用 reduction;
  • reduce:已废弃。旧代码中关闭聚合,请改用 reduction='none';
  • reduction:逐元素返回、平均或求和。

计算公式与说明:对第 \(n\) 个元素:

\[ \ell_n=|x_n-y_n| \]

绝对值使得误差增大时损失线性增长,因此相比 MSE,个别很大的误差不会被额外平方放大。

1
2
3
4
5
6
7
8
9
import torch
from torch import nn

prediction = torch.tensor([2.5, 0.0, 2.0], requires_grad=True)
target = torch.tensor([3.0, -0.5, 2.0]) # 形状与 prediction 对应

loss = nn.L1Loss(reduction='mean')(prediction, target)
loss.backward()
print('L1Loss:', loss.item())

代码逐行解释:

  1. prediction = ... requires_grad=True:创建 3 个连续预测值,形状为 [3]。在实际回归模型中它来自最后一层的输出,不需要 sigmoid 或 softmax。
  2. target = ...:给出每个预测位置对应的真实连续值,必须能与 prediction 一一对应;两者形状最好完全相同,避免无意的广播。
  3. nn.L1Loss(reduction='mean'):明确指定按平均值返回标量。若改为 'none',会得到 [0.5,0.5,0] 这类逐元素损失,便于检查哪个样本误差最大。
  4. loss.backward():将平均绝对误差对每个预测值求导。由于绝对值在零点不可导,PyTorch 在该点采用子梯度规则;学习时一般不影响使用。
  5. print(...):打印平均 MAE,其单位与目标值相同,因而通常比 MSE 更直观。

内部计算过程:

示例数据:prediction = [2.5, 0.0, 2.0],target = [3.0, -0.5, 2.0],reduction='mean'。

计算步骤:

  1. 计算逐元素误差:

    \[d=prediction-target=[-0.5,\ 0.5,\ 0]\]

  2. 取绝对值得到逐元素 L1 损失:

    \[l=|d|=[0.5,\ 0.5,\ 0]\]

  3. 得到最终输出:

    \[loss=\frac{0.5+0.5+0}{3}=\frac{1}{3}\approx0.3333\]

梯度含义:三个 prediction 的梯度方向分别为 \([-1/3,1/3,0]\)(零点处取子梯度)。梯度下降会增加 2.5、降低 0.0,而第三项无需调整。

MSELoss

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

功能:计算均方误差(MSE),即 \((x-y)^2\)。大误差会被平方后放大,因此对离群点更敏感。

主要参数:

  • size_average:已废弃,请用 reduction 表示平均或求和;
  • reduce:已废弃,请用 reduction='none' 保留各元素损失;
  • reduction:逐元素返回、平均或求和。

计算公式与说明:对第 \(n\) 个元素:

\[ \ell_n=(x_n-y_n)^2 \]

平方会使大误差的贡献急剧变大;因此它会更积极地修正大误差,但也更容易受离群点影响。

1
2
3
4
5
6
7
8
9
import torch
from torch import nn

prediction = torch.tensor([2.5, 0.0, 8.0], requires_grad=True)
target = torch.tensor([3.0, -0.5, 3.0]) # 最后一项误差较大,会被平方放大

loss = nn.MSELoss()(prediction, target)
loss.backward()
print('MSELoss:', loss.item())

代码逐行解释:

  1. prediction:三个回归预测中最后一个值为 8,特意设置成明显偏离目标,用于观察平方惩罚的效果;它是需要求梯度的叶子张量。
  2. target:对应真实值 [3.0,-0.5,3.0],与预测值逐元素配对。
  3. nn.MSELoss():默认 reduction='mean'。前向传播先计算每个位置的误差,再平方,最后将 3 个平方误差平均成一个标量。
  4. loss.backward():计算 \(L\) 关于每个 prediction 的梯度并写入 prediction.grad。平方使远离目标的预测得到更强的梯度修正。
  5. print(...):输出的 8.5 是均方误差,不是原始单位下的平均偏差;因此它通常比 MAE 更难直接解释。

内部计算过程:

示例数据:prediction = [2.5, 0.0, 8.0],target = [3.0, -0.5, 3.0],reduction='mean'。

计算步骤:

  1. 计算误差:

    \[d=[-0.5,\ 0.5,\ 5]\]

  2. 平方得到逐元素损失:

    \[l=d^2=[0.25,\ 0.25,\ 25]\]

  3. 得到最终输出:

    \[loss=\frac{0.25+0.25+25}{3}=8.5\]

梯度含义:

\[\frac{\partial loss}{\partial prediction_i}=\frac{2(prediction_i-target_i)}{3}\approx[-0.333,\ 0.333,\ 3.333]\]

第三项大误差的梯度远大于前两项,说明 MSE 会强烈惩罚离群误差。

SmoothL1Loss

1
torch.nn.SmoothL1Loss(size_average=None, reduce=None, reduction='mean', beta=1.0)

功能:小误差区域使用平滑的二次函数,大误差区域近似 L1 损失,兼顾 MSE 的平滑性与 L1 对异常值的鲁棒性。

主要参数:

  • size_average:已废弃,请用 reduction;
  • reduce:已废弃,请用 reduction;
  • reduction:逐元素返回、平均或求和;
  • beta:L1 与二次区域的分界阈值,默认 1.0。

计算公式与说明:令 \(d=x-y\),则:

\[ \ell(d)= \begin{cases} \dfrac{0.5d^2}{\beta}, & |d|<\beta\\ |d|-0.5\beta, & |d|\geq\beta \end{cases} \]

误差较小时使用二次函数,梯度在零点附近更平滑;误差较大时使用线性增长,避免 MSE 对异常值的过度惩罚。

1
2
3
4
5
6
7
8
9
10
import torch
from torch import nn

prediction = torch.tensor([2.5, 0.0, 8.0], requires_grad=True)
target = torch.tensor([3.0, -0.5, 3.0])

# beta=1.0:误差小于 1 时平滑,大于等于 1 时近似 L1
loss = nn.SmoothL1Loss(beta=1.0)(prediction, target)
loss.backward()
print('SmoothL1Loss:', loss.item())

代码逐行解释:

  1. prediction 与 target:使用和 MSE 示例相同的数据,使三种回归损失的数值差异只来自损失函数本身。
  2. nn.SmoothL1Loss(beta=1.0):创建 Huber 风格的平滑 L1 损失。beta 是从二次区域切换到线性区域的阈值,不是优化器的学习率。
  3. 前向调用:函数逐元素判断 \(|prediction-target|\) 是否小于 1,然后选用对应的分段公式;默认对所有元素平均。
  4. backward():小误差区域的梯度随误差线性变化且在 0 处平滑;大误差区域的梯度幅度被限制,类似 L1,因此不会像 MSE 一样被异常值主导。
  5. print(...):输出是三种分段损失的平均值,适合和 MSE/L1 对照。

内部计算过程:

示例数据:prediction = [2.5, 0.0, 8.0],target = [3.0, -0.5, 3.0],beta=1.0。

计算步骤:

  1. 计算误差:

    \[d=[-0.5,\ 0.5,\ 5]\]

  2. 按 \(|d|\) 与 beta 的关系选择分段公式:

    • 前两项 \(|d|=0.5<1\),\(l=0.5d^2=0.125\);
    • 第三项 \(|d|=5\ge1\),\(l=|d|-0.5=4.5\)。
  3. 得到最终输出:

    \[loss=\frac{0.125+0.125+4.5}{3}\approx1.5833\]

梯度含义:小误差的梯度与误差成正比;第三项进入 L1 区域,平均后的梯度幅度只有 \(1/3\),不会像 MSE 那样被大误差放大。

3.6.4 计数和概率分布:PoissonNLLLoss 与 KLDivLoss

这类损失分别用于非负计数建模,以及两个概率分布之间的匹配。

PoissonNLLLoss

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

功能:计算目标服从泊松分布时的负对数似然损失,适合预测非负计数,例如单位时间内的事件数量。

主要参数:

  • log_input:默认 True,表示输入是预测率的对数;此时模型可输出任意实数;
  • full:是否加入 Stirling 近似项以得到更完整的泊松负对数似然;
  • size_average:已废弃,请用 reduction;
  • eps:当 log_input=False 时用于避免计算 log(0);
  • reduce:已废弃,请用 reduction;
  • reduction:损失聚合方式。

计算公式与说明:设目标计数为 \(y\)。当 log_input=True 时,输入 \(x=\log\lambda\),其中 \(\lambda\) 是泊松分布的预测率:

\[ \ell(x,y)=\exp(x)-yx \]

当 log_input=False 时,输入直接是 \(\lambda\),公式为 \(\ell(\lambda,y)=\lambda-y\log(\lambda+\varepsilon)\)。full=True 还会加入与 \(\log(y!)\) 有关的近似项,使损失更接近完整负对数似然。

1
2
3
4
5
6
7
8
9
10
11
import torch
from torch import nn

# log_rate 是模型预测的 log(lambda);内部会计算 exp(log_rate) 得到正的率
log_rate = torch.tensor([0.0, 0.7, 1.2], requires_grad=True)
# 泊松目标为非负计数,使用 float 张量传入
counts = torch.tensor([1.0, 3.0, 2.0])

loss = nn.PoissonNLLLoss(log_input=True, full=True)(log_rate, counts)
loss.backward()
print('PoissonNLLLoss:', loss.item())

代码逐行解释:

  1. log_rate = ... requires_grad=True:三个值表示模型输出的 \(\log\lambda\),不是直接的计数,也不是概率。这样模型可以输出任意实数,内部指数运算会保证预测率 \(\lambda\) 始终为正。
  2. counts = ...:真实观察计数为 [1,3,2]。虽然逻辑上是整数计数,PyTorch 损失计算时使用浮点张量以参与连续的自动求导。
  3. nn.PoissonNLLLoss(log_input=True, full=True):log_input=True 告诉函数调用 \(\exp(log\_rate)\) 恢复率;full=True 加入关于阶乘项的近似,使不同计数的损失比较更接近完整的泊松负对数似然。默认仍按平均返回。
  4. loss.backward():梯度会作用于 log_rate。若当前预测率低于实际计数,梯度下降会倾向增大 log-rate;反之则降低它。
  5. print(...):输出为三个计数样本的平均负对数似然,而非“预测计数的平均误差”。

内部计算过程:

示例数据:log_rate = [0.0, 0.7, 1.2],counts = [1,3,2];log_input=True、full=True。

计算步骤:

  1. 恢复每个样本的泊松预测率:

    \[\lambda=e^{log\_rate}\approx[1.0000,\ 2.0138,\ 3.3201]\]

  2. 计算核心负对数似然项:

    \[l_i^{core}=\lambda_i-y_i\log\lambda_i=\lambda_i-y_i\cdot log\_rate_i\]

    得到 \([1.0000,\ -0.0862,\ 0.9201]\)。由于 full=True,对计数大于 1 的后两项还会加入 \(\log(y_i!)\) 的 Stirling 近似项。

  3. 得到最终输出:对完整的三个逐元素 NLL 按默认 mean 求平均。

梯度含义:核心梯度为 \(\lambda_i-y_i\)。第二项约为 \(-0.9862\),梯度下降会增大其 log_rate,从而把预测率从 2.01 推向真实计数 3。

KLDivLoss

1
torch.nn.KLDivLoss(size_average=None, reduce=None, reduction='mean', log_target=False)

功能:计算两个概率分布之间的 KL 散度。默认时第一个输入是对数概率,第二个输入是普通概率,常用于知识蒸馏和分布匹配。

主要参数:

  • size_average:已废弃,请用 reduction;
  • reduce:已废弃,请用 reduction;
  • reduction:可取 'none'、'sum'、'mean' 或 'batchmean';常用 'batchmean',与 KL 散度的标准批量定义一致;
  • log_target:为 False(默认)时 target 是普通概率;为 True 时 target 也必须是对数概率。

计算公式与说明:令目标分布为 \(P\),模型分布为 \(Q\),则:

\[ D_{\mathrm{KL}}(P\|Q)=\sum_i P(i)\log\frac{P(i)}{Q(i)} \]

默认 log_target=False 时,input 传入 \(\log Q(i)\),target 传入 \(P(i)\)。KL 散度不对称,衡量的是“用 \(Q\) 近似 \(P\)”时的差异;batchmean 会先对每个样本求和,再除以批量大小。

1
2
3
4
5
6
7
8
9
10
11
12
13
import torch
import torch.nn.functional as F
from torch import nn

# 2 个样本、3 个类别的模型 logits
student_logits = torch.tensor([[1.0, 0.2, -0.5], [0.1, 0.8, 0.3]], requires_grad=True)
# 每行是和为 1 的目标概率分布,例如教师模型输出
target_probs = torch.tensor([[0.7, 0.2, 0.1], [0.1, 0.6, 0.3]])

log_probs = F.log_softmax(student_logits, dim=1) # KLDivLoss 的 input 必须为对数概率
loss = nn.KLDivLoss(reduction='batchmean')(log_probs, target_probs)
loss.backward()
print('KLDivLoss:', loss.item())

代码逐行解释:

  1. student_logits:形状为 [2,3],两行是两个样本,三列是类别得分。它模拟待训练模型的输出,因此设置 requires_grad=True。
  2. target_probs:也是 [2,3],每行都是合法概率分布,必须非负且和为 1;它可以来自教师模型、软标签或人工设定的目标分布。
  3. F.log_softmax(student_logits, dim=1):沿类别维计算对数 softmax,直接得到 \(\log Q\)。不能传普通 logits 给默认的 KLDivLoss,也不要传 softmax 后的普通概率作为 input。
  4. nn.KLDivLoss(reduction='batchmean'):默认 log_target=False,因此第二个参数被视为普通概率 \(P\)。batchmean 是把所有类别损失求和后只除以批量大小 2,而不是再除以类别数。
  5. backward():让每行学生分布 \(Q\) 接近目标 \(P\);目标分布默认不要求梯度,只有 student logits 会收到梯度。

内部计算过程:

示例数据:第一行学生 logits 为 [1.0,0.2,-0.5],目标分布为 [0.7,0.2,0.1];reduction='batchmean'。

计算步骤:

  1. 将 logits 归一化为学生分布:

    \[Q=softmax([1.0,0.2,-0.5])\approx[0.5979,\ 0.2687,\ 0.1334]\]

  2. 计算每个类别的 KL 项:

    \[t_i=P_i(\log P_i-\log Q_i)=P_i\log\frac{P_i}{Q_i}\]

    分别代入 \(P=[0.7,0.2,0.1]\) 后再求和,得到第一样本的 \(KL_1\)。

  3. 得到最终输出:第二行同样得到 \(KL_2\),随后:

    \[loss=\frac{KL_1+KL_2}{2}\]

梯度含义:目标概率高于学生概率的类别会被提高 logits,反之被压低;KL 是有方向的 \(D_{KL}(P\|Q)\),交换两者不会得到同一数值。

3.6.5 排序、边界与度量学习损失

这类损失约束样本之间的排序关系、类别间隔、距离或嵌入向量的相似度,而非直接进行常规单标签分类。

MarginRankingLoss

1
2
torch.nn.MarginRankingLoss(margin=0.0, size_average=None, reduce=None,
reduction='mean')

功能:比较两个输入得分的相对大小。目标为 1 时希望 input1 > input2,目标为 -1 时希望 input1 < input2,常用于排序任务。

主要参数:

  • margin:两个得分之间要求满足的最小间隔;
  • size_average:已废弃,请用 reduction;
  • reduce:已废弃,请用 reduction;
  • reduction:损失聚合方式。

计算公式与说明:设 \(y\in\{-1,1\}\),则单个样本的损失为:

\[ \ell(x_1,x_2,y)=\max(0,-y(x_1-x_2)+\text{margin}) \]

当 \(y=1\) 时,只有在 \(x_1\) 没有比 \(x_2\) 至少大 margin 时才产生损失;当 \(y=-1\) 时,约束方向相反。

1
2
3
4
5
6
7
8
9
10
11
import torch
from torch import nn

# 第一个样本希望 input1 大于 input2;第二个样本希望 input1 小于 input2
input1 = torch.tensor([2.0, 0.2], requires_grad=True)
input2 = torch.tensor([1.0, 0.8], requires_grad=True)
targets = torch.tensor([1.0, -1.0]) # 只能取 1 或 -1

loss = nn.MarginRankingLoss(margin=0.5)(input1, input2, targets)
loss.backward()
print('MarginRankingLoss:', loss.item())

代码逐行解释:

  1. input1 与 input2:各有两个元素,索引相同的位置构成一对待排序得分;两者均开启梯度,以便观察违反约束时两个分数如何被同时调整。
  2. targets = [1.0,-1.0]:这是排序方向标签而不是类别编号。第一个 1 要求 \(input1[0]>input2[0]\);第二个 -1 要求 $input1[1]<input2[1]`。
  3. MarginRankingLoss(margin=0.5):要求满足方向还不够,两个得分的间隔至少为 0.5。默认将两个样本损失取平均。
  4. 前向与 backward():函数逐位置计算 hinge 损失。只有某对未达到指定间隔时才产生梯度;已经正确且间隔足够的对不再被继续拉开。

内部计算过程:

示例数据:input1=[2.0,0.2],input2=[1.0,0.8],targets=[1,-1],margin=0.5。

计算步骤:

  1. 第一对(目标为 1):要求 \(input1-input2\ge0.5\)。

    \[l_1=\max(0,-1(2.0-1.0)+0.5)=0\]

  2. 第二对(目标为 -1):要求 \(input2-input1\ge0.5\)。

    \[l_2=\max(0,-(-1)(0.2-0.8)+0.5)=0\]

  3. 得到最终输出:

    \[loss=(l_1+l_2)/2=0\]

梯度含义:两对都已满足间隔,因此梯度均为 0。若第一对的 input1 低于 1.5,就会产生正损失,并推动 input1 上升、input2 下降。

MultiLabelMarginLoss

1
torch.nn.MultiLabelMarginLoss(size_average=None, reduce=None, reduction='mean')

功能:计算多标签分类的边界损失。目标中列出一个样本所属的多个类别索引,其余位置用 -1 填充。对于现代多标签分类通常更推荐 BCEWithLogitsLoss。

主要参数:

  • size_average:已废弃,请用 reduction;
  • reduce:已废弃,请用 reduction;
  • reduction:损失聚合方式。

计算公式与说明:对一个样本,设正标签集合为 \(Y\),所有类别得分为 \(x_i\),该损失对每个正标签 \(j\in Y\) 与每个非正标签 \(i\notin Y\) 计算:

\[ \ell=\frac{1}{C}\sum_{j\in Y}\sum_{i\notin Y}\max(0,1-x_j+x_i) \]

它要求每个正标签得分至少比非正标签高 1。目标张量用 -1 表示“正标签列表到此结束”。

1
2
3
4
5
6
7
8
9
10
11
import torch
from torch import nn

# 1 个样本、4 个类别的得分
scores = torch.tensor([[0.2, 0.8, 0.1, 0.9]], requires_grad=True)
# 正确标签是类别 1 和 3;后续位置必须以 -1 填充
targets = torch.tensor([[1, 3, -1, -1]], dtype=torch.long)

loss = nn.MultiLabelMarginLoss()(scores, targets)
loss.backward()
print('MultiLabelMarginLoss:', loss.item())

代码逐行解释:

  1. scores:形状为 [1,4],第一个维度表示一个样本,4 列是该样本对 4 个类别的分数。它不是 softmax 概率,各类别分数可以独立增减。
  2. targets:与 scores 同形状且类型为 long。前两个位置列出正标签类别 1、3;第一个 -1 表示标签列表结束,后面的值不再参与。
  3. MultiLabelMarginLoss():对“每个正类 × 每个负类”计算间隔项,要求每个正类得分都至少高出负类 1;它默认对批量求平均。
  4. backward():违反约束的正类会获得提升分数的梯度,相关负类会获得降低分数的梯度;已满足间隔的配对梯度为 0。

内部计算过程:

示例数据:scores=[0.2,0.8,0.1,0.9];targets=[1,3,-1,-1],所以正类为 1、3,非正类为 0、2。

计算步骤:

  1. 取出正、负类别得分:正类得分为 \(0.8,0.9\),负类得分为 \(0.2,0.1\)。

  2. 计算所有正负配对的间隔项:

    \[\max(0,1-x_j+x_i)\]

    得到 \(0.4,0.3,0.3,0.2\),分别对应正类 1/3 与负类 0/2 的 4 个组合。

  3. 得到最终输出:函数将这些正损失项按类别数进行内部归一化,再按默认方式聚合为单个样本损失。

梯度含义:每个正项都会提升对应正类得分并降低对应负类得分,直到所有正类至少比负类高 1。

SoftMarginLoss

1
torch.nn.SoftMarginLoss(size_average=None, reduce=None, reduction='mean')

功能:计算以 -1 和 1 为标签的逐元素二分类 logistic 损失。若数据集标签为 0/1,一般应使用 BCEWithLogitsLoss。

主要参数:

  • size_average:已废弃,请用 reduction;
  • reduce:已废弃,请用 reduction;
  • reduction:损失聚合方式。

计算公式与说明:标签 \(y\) 必须为 \(-1\) 或 \(1\),单个元素损失为:

\[ \ell(x,y)=\log(1+\exp(-yx)) \]

当 \(y=1\) 时,增大 \(x\) 能减小损失;当 \(y=-1\) 时,减小 \(x\) 能减小损失。这与 BCEWithLogitsLoss 的标签编码不同。

1
2
3
4
5
6
7
8
9
10
import torch
from torch import nn

# 输入是任意实数得分;标签必须与输入同形状,且值为 -1 或 1
scores = torch.tensor([0.8, -0.4, 1.2], requires_grad=True)
targets = torch.tensor([1.0, -1.0, 1.0])

loss = nn.SoftMarginLoss()(scores, targets)
loss.backward()
print('SoftMarginLoss:', loss.item())

代码逐行解释:

  1. scores:形状 [3] 的任意实数得分,分别代表三次独立二元判别的 raw score;不需要预先做 sigmoid。
  2. targets:必须和 scores 同形状,但标签编码是 \(+1/-1\),这与 BCE 使用 \(1/0\) 的编码不同。
  3. nn.SoftMarginLoss():内部把每一项写为 \(\log(1+e^{-yx})\) 并使用默认平均聚合;当 \(yx\) 越大,表示预测方向越正确且越自信,损失越小。
  4. backward():对正标签推动分数向正无穷方向移动,对负标签推动分数向负无穷方向移动;梯度会随间隔变大而逐渐减小。

内部计算过程:

示例数据:scores=[0.8,-0.4,1.2],targets=[1,-1,1],默认 reduction='mean'。

计算步骤:

  1. 先计算分类方向乘积:

    \[y\cdot x=[0.8,\ 0.4,\ 1.2]\]

    第二项虽然 score 为负,但目标也是 -1,因此方向正确。

  2. 逐元素计算 logistic 损失:

    \[l_i=\log(1+e^{-y_ix_i})\approx[0.3711,\ 0.5130,\ 0.2633]\]

  3. 得到最终输出:

    \[loss=(0.3711+0.5130+0.2633)/3\approx0.3825\]

梯度含义:\(y_ix_i\) 越大,损失越接近 0;梯度下降会使正样本 score 变大、负样本 score 变小。

MultiMarginLoss

1
2
torch.nn.MultiMarginLoss(p=1, margin=1.0, weight=None, size_average=None,
reduce=None, reduction='mean')

功能:计算单标签多分类的 hinge 损失。实际多分类任务中,CrossEntropyLoss 往往更常用。

主要参数:

  • p:间隔惩罚的指数,只能取 1 或 2;
  • margin:正确类别得分相对其他类别要求满足的最小间隔;
  • weight:按类别设置的权重;
  • size_average:已废弃,请用 reduction;
  • reduce:已废弃,请用 reduction;
  • reduction:损失聚合方式。

计算公式与说明:设正确类别为 \(y\),类别数为 \(C\),则:

\[ \ell(x,y)=\frac{1}{C}\sum_{i\ne y}\max(0,\text{margin}-x_y+x_i)^p \]

若正确类别得分 \(x_y\) 已比每个其他类别至少高 margin,对应项为 0;p 决定对违反间隔的惩罚是线性还是平方。

1
2
3
4
5
6
7
8
9
10
import torch
from torch import nn

# 2 个样本、3 个类别的得分,目标是每个样本的正确类别索引
scores = torch.tensor([[0.2, 1.0, 0.1], [0.9, 0.3, 0.5]], requires_grad=True)
targets = torch.tensor([1, 0], dtype=torch.long)

loss = nn.MultiMarginLoss(p=1, margin=1.0)(scores, targets)
loss.backward()
print('MultiMarginLoss:', loss.item())

代码逐行解释:

  1. scores:形状 [2,3],第一维是两个样本,第二维是三个类别的得分。每行可看作一个多分类模型的输出,但此处训练目标采用 hinge 间隔而非 softmax 交叉熵。
  2. targets:[1,0] 是两个正确类别索引,类型为 long。第一行要让第 1 类得分最高,第二行要让第 0 类得分最高。
  3. MultiMarginLoss(p=1, margin=1.0):对正确类与每个错误类构造 \(\max(0,1-x_y+x_i)\)。p=1 是线性 hinge;若设为 2,违反间隔的项会被平方。
  4. 前向调用与 backward():所有违反间隔的错误类都会推高损失。反向传播提升正确类得分,并降低发生违反的错误类得分;已经满足间隔的类别不产生梯度。

内部计算过程:

示例数据:两行 scores 为 [[0.2,1.0,0.1],[0.9,0.3,0.5]],正确类别 targets=[1,0],margin=1、p=1。

计算步骤:

  1. 第一样本,正确类为 1:

    \[\max(0,1-1.0+0.2)=0.2,\qquad\max(0,1-1.0+0.1)=0.1\]

  2. 第二样本,正确类为 0:

    \[\max(0,1-0.9+0.3)=0.4,\qquad\max(0,1-0.9+0.5)=0.6\]

  3. 得到最终输出:函数将每行错误类别对应的项按类别数归一化,再对两个样本按默认 mean 聚合。

梯度含义:4 个项都违反间隔,因此各正确类 logits 会被提高,4 个对应错误类 logits 会被压低。

TripletMarginLoss

1
2
torch.nn.TripletMarginLoss(margin=1.0, p=2.0, eps=1e-06, swap=False,
size_average=None, reduce=None, reduction='mean')

功能:计算三元组损失。它让锚点 anchor 与同类样本 positive 的距离,小于锚点与异类样本 negative 的距离,并至少相差一个 margin。

主要参数:

  • margin:正负样本距离之间要求的最小间隔;
  • p:距离使用的 \(p\) 范数,默认 2.0 即欧氏距离;
  • eps:数值稳定项;
  • swap:为 True 时,额外比较正样本与负样本的距离,取更难的负例距离;
  • size_average:已废弃,请用 reduction;
  • reduce:已废弃,请用 reduction;
  • reduction:损失聚合方式。

计算公式与说明:令 \(a\)、\(p\)、\(n\) 分别表示 anchor、positive、negative,距离函数为 \(d(\cdot,\cdot)\),则:

\[ \ell(a,p,n)=\max(0,d(a,p)-d(a,n)+\text{margin}) \]

只有当锚点离正样本不够近,或离负样本不够远时,损失才大于 0。默认使用 \(p=2\) 的欧氏距离。

1
2
3
4
5
6
7
8
9
10
11
import torch
from torch import nn

# 每行是一个样本的嵌入向量;三组张量形状必须相同
anchor = torch.tensor([[0.0, 0.0], [1.0, 1.0]], requires_grad=True)
positive = torch.tensor([[0.1, 0.0], [1.1, 1.0]]) # 同类样本,应接近 anchor
negative = torch.tensor([[2.0, 2.0], [-1.0, -1.0]]) # 异类样本,应远离 anchor

loss = nn.TripletMarginLoss(margin=0.5, p=2)(anchor, positive, negative)
loss.backward()
print('TripletMarginLoss:', loss.item())

代码逐行解释:

  1. anchor:形状 [2,2],两个锚点样本,每个样本是 2 维嵌入向量;设置 requires_grad=True,代表其通常由待训练模型产生。
  2. positive 和 negative:形状必须与 anchor 一致。相同行组成一个三元组:positive 应与 anchor 同类,negative 应与 anchor 异类。本例将 positive 设在 anchor 附近、negative 设得很远。
  3. TripletMarginLoss(margin=0.5,p=2):使用 \(L_2\) 欧氏距离。它并不是直接最小化正样本距离,而是要求“负距离 - 正距离”至少为 0.5。
  4. 前向调用:逐行分别计算 \(d(a,p)\)、\(d(a,n)\) 和 hinge 项,默认把两个三元组的损失平均。
  5. backward():只有损失为正的困难三元组会产生梯度。此例已经满足约束,loss 可能为 0,梯度也为 0;这不是出错,而是 margin 已被满足。

内部计算过程:

示例数据:第一组三元组为 \(a=[0,0]\)、\(p=[0.1,0]\)、\(n=[2,2]\),margin=0.5、\(p=2\)(欧氏距离)。

计算步骤:

  1. 计算 anchor 与正样本距离:

    \[d(a,p)=\sqrt{(0-0.1)^2+(0-0)^2}=0.1\]

  2. 计算 anchor 与负样本距离:

    \[d(a,n)=\sqrt{(0-2)^2+(0-2)^2}=\sqrt8\approx2.828\]

  3. 计算三元组损失:

    \[l_1=\max(0,0.1-2.828+0.5)=0\]

    第二组三元组也满足相同间隔,因此 \(l_2=0\);最终平均 loss=0。

梯度含义:本例没有困难三元组,梯度为 0。若负样本改为 [0.2,0],则损失为 \(0.4\),反向传播会拉近 anchor/positive 并推远 negative。

HingeEmbeddingLoss

1
2
torch.nn.HingeEmbeddingLoss(margin=1.0, size_average=None, reduce=None,
reduction='mean')

功能:对距离或相异度进行约束。目标为 1 时希望输入距离小;目标为 -1 时希望输入距离至少达到 margin。

主要参数:

  • margin:负样本(目标为 -1)要求达到的最小距离;
  • size_average:已废弃,请用 reduction;
  • reduce:已废弃,请用 reduction;
  • reduction:损失聚合方式。

计算公式与说明:设输入 \(x\) 是距离或相异度,标签 \(y\in\{-1,1\}\),则:

\[ \ell(x,y)= \begin{cases} x, & y=1\\ \max(0,\text{margin}-x), & y=-1 \end{cases} \]

相似样本对(\(y=1\))的距离越小越好;不相似样本对(\(y=-1\))只要距离达到 margin,便不再产生损失。

1
2
3
4
5
6
7
8
9
10
11
import torch
from torch import nn

# 输入通常是两个样本的距离,因而应为非负值
distances = torch.tensor([0.2, 1.5, 0.8], requires_grad=True)
# 1 表示相似对,-1 表示不相似对
targets = torch.tensor([1.0, -1.0, -1.0])

loss = nn.HingeEmbeddingLoss(margin=1.0)(distances, targets)
loss.backward()
print('HingeEmbeddingLoss:', loss.item())

代码逐行解释:

  1. distances:输入不是两组向量,而是已经计算好的三对样本距离或相异度,形状为 [3]。这里开启梯度只是为了观察损失怎样对距离施加优化信号。
  2. targets=[1,-1,-1]:第一个是相似对,目标 1 要求距离尽可能小;后两个是不相似对,目标 -1 要求距离至少达到 margin。
  3. HingeEmbeddingLoss(margin=1.0):对正对直接使用距离作为损失;对负对只惩罚那些距离小于 1 的样本。默认按 3 个元素平均。
  4. backward():相似对总会有把距离变小的驱动力;负对仅在距离不足 margin 时产生把距离增大的驱动力,达到 margin 后梯度为 0。

内部计算过程:

示例数据:distances=[0.2,1.5,0.8],targets=[1,-1,-1],margin=1。

计算步骤:

  1. 相似对(标签 1):直接使用距离作为损失:

    \[l_1=0.2\]

  2. 第一个不相似对(标签 -1):

    \[l_2=\max(0,1-1.5)=0\]

  3. 第二个不相似对(标签 -1):

    \[l_3=\max(0,1-0.8)=0.2\]

  4. 得到最终输出:

    \[loss=(0.2+0+0.2)/3\approx0.1333\]

梯度含义:第一个距离被推动变小,第三个距离被推动变大,第二个距离已超过 margin 因而没有梯度。

CosineEmbeddingLoss

1
2
torch.nn.CosineEmbeddingLoss(margin=0.0, size_average=None, reduce=None,
reduction='mean')

功能:根据余弦相似度约束两组向量。目标为 1 时鼓励向量方向相同;目标为 -1 时要求余弦相似度不超过 margin。

主要参数:

  • margin:负样本允许的最大余弦相似度,通常取 [0, 0.5];
  • size_average:已废弃,请用 reduction;
  • reduce:已废弃,请用 reduction;
  • reduction:损失聚合方式。

计算公式与说明:设余弦相似度为:

\[ \cos(x_1,x_2)=\frac{x_1\cdot x_2}{\|x_1\|_2\|x_2\|_2} \]

标签 \(y=1\) 时,损失为 \(1-\cos(x_1,x_2)\);标签 \(y=-1\) 时,损失为 \(\max(0,\cos(x_1,x_2)-\text{margin})\)。因此它主要约束向量方向,而不是向量长度。

1
2
3
4
5
6
7
8
9
10
11
12
import torch
from torch import nn

# 每行是一对待比较的向量,两个输入张量形状必须相同
vector1 = torch.tensor([[1.0, 0.0], [1.0, 0.0]], requires_grad=True)
vector2 = torch.tensor([[0.9, 0.1], [-1.0, 0.0]])
# 第一对相似,第二对不相似
targets = torch.tensor([1.0, -1.0])

loss = nn.CosineEmbeddingLoss(margin=0.0)(vector1, vector2, targets)
loss.backward()
print('CosineEmbeddingLoss:', loss.item())

代码逐行解释:

  1. vector1:形状 [2,2],两行分别是两对样本中的第一个嵌入向量;它开启梯度追踪,代表模型输出的待学习表示。
  2. vector2:与 vector1 同形状,是每对的比较对象。这里未开启梯度,仅作为固定参照;真实的双塔网络中,两个分支都可以要求梯度。
  3. targets=[1,-1]:第一对标签 1 表示相似,目标是余弦相似度接近 1;第二对标签 -1 表示不相似,目标是相似度不高于 margin。
  4. CosineEmbeddingLoss(margin=0.0):逐行计算点积除以两向量 L2 范数的乘积。margin=0 表示负对的余弦相似度只要不大于 0 就不再受惩罚。
  5. backward():将梯度写入 vector1.grad,主要调整向量方向。余弦相似度已对长度归一化,所以它不像 L2 距离那样直接鼓励缩短向量长度。

内部计算过程:

示例数据:第一对为 \([1,0]\) 与 \([0.9,0.1]\)、标签 1;第二对为 \([1,0]\) 与 \([-1,0]\)、标签 -1;margin=0。

计算步骤:

  1. 第一对计算余弦相似度:

    \[cos=\frac{0.9}{1\times\sqrt{0.9^2+0.1^2}}\approx0.9939\]

    正对损失为 \(l_1=1-0.9939\approx0.0061\)。

  2. 第二对计算余弦相似度:

    \[cos=-1,\qquad l_2=\max(0,-1-0)=0\]

  3. 得到最终输出:

    \[loss=(0.0061+0)/2\approx0.00305\]

梯度含义:第一对仍会被轻微推动到完全同向;第二对已满足“相似度不大于 0”的负对约束,梯度为 0。

3.6.6 序列对齐:CTCLoss

CTC 用于输入序列与目标序列长度不同、且逐时间步对齐关系未知的任务,例如语音识别和手写文字识别。

CTCLoss

1
torch.nn.CTCLoss(blank=0, reduction='mean', zero_infinity=False)

功能:计算连接时序分类(CTC)损失,适合输入序列和目标序列长度不同、且不知道逐时间步对齐方式的任务,例如语音识别与手写文字识别。

主要参数:

  • blank:blank 类别的索引,默认 0;目标序列中不能出现该索引;
  • reduction:'none'、'mean' 或 'sum';
  • zero_infinity:为 True 时,将无法对齐导致的无穷损失及梯度置零,避免无穷值传播。

计算公式与说明:设模型在 \(T\) 个时间步上输出对数概率,目标标签序列为 \(y\)。CTC 会枚举所有“压缩后等于 \(y\)”的合法路径 \(\pi\):先合并连续重复标签,再删除 blank 标签。损失是这些路径总概率的负对数:

\[ \ell_{\mathrm{CTC}}=-\log\sum_{\pi\in\mathcal{B}^{-1}(y)}\prod_{t=1}^{T}P(\pi_t\mid t) \]

其中 \(\mathcal{B}\) 表示“合并重复并删除 blank”的映射。CTC 不要求事先知道每个目标字符对应哪个时间步,但要求输入序列有足够长度容纳目标及必要的 blank/重复标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import torch
import torch.nn.functional as F
from torch import nn

T, N, C = 6, 2, 5 # 时间步数、批量大小、类别数;类别 0 预留为 blank
# 模型在每一个时间步都输出 C 个类别的 logits,形状为 [T, N, C]
logits = torch.randn(T, N, C, requires_grad=True)
log_probs = F.log_softmax(logits, dim=2) # CTCLoss 的输入必须是对数概率

# 两个目标序列拼接:样本 1 为 [1, 2],样本 2 为 [3, 1, 2]
targets = torch.tensor([1, 2, 3, 1, 2], dtype=torch.long)
input_lengths = torch.tensor([6, 6], dtype=torch.long) # 各输入序列真实长度
target_lengths = torch.tensor([2, 3], dtype=torch.long) # 各目标序列真实长度

loss = nn.CTCLoss(blank=0, zero_infinity=True)(
log_probs, targets, input_lengths, target_lengths
)
loss.backward()
print('CTCLoss:', loss.item())

代码逐行解释:

  1. T, N, C = 6, 2, 5:定义输入时间步数、批量大小和类别数。类别索引 0 被保留为 blank,真正可输出的目标标签是 1 到 4;因此 targets 中不能出现 0。
  2. logits = torch.randn(T,N,C,requires_grad=True):模拟序列模型(如 RNN/Transformer)在每个时间步为每个样本输出 5 类原始得分,形状严格为 [时间步, 批量, 类别]。注意它与很多 batch_first=True 的模型输出不同,需要留意维度顺序。
  3. log_probs = F.log_softmax(logits, dim=2):只在类别维 C 上归一化,使每个 (t,n) 位置的类别概率和为 1,再取对数。CTCLoss 接收对数概率,用它可以将一条路径上的概率连乘转为对数域的稳定求和。
  4. targets = [1,2,3,1,2]:采用“拼接的一维目标”表示两个不等长标签序列。它不含 blank:前 2 个属于第一个样本 [1,2],后 3 个属于第二个样本 [3,1,2]。
  5. input_lengths 与 target_lengths:前者指出两个输入都有效到第 6 个时间步;后者是 [2,3],让 CTCLoss 能从拼接 targets 中正确切分目标。真实的变长批次中,input_lengths 常常小于最大的 T。
  6. nn.CTCLoss(blank=0, zero_infinity=True):指定 blank 类,默认 reduction='mean'。zero_infinity=True 在输入长度无法容纳目标序列等异常对齐时把无穷损失/梯度置零,但不替代对数据长度的检查。
  7. 调用、backward() 和打印:函数用动态规划累加所有合法路径的概率,得到单个标量损失。反向传播为 [T,N,C] 的每个 logits 给出梯度,训练模型在正确时间步上增加目标字符或 blank 的概率。

内部计算过程:

示例数据:T=6、N=2、C=5;blank 类为 0。第一个目标为 [1,2],第二个目标为 [3,1,2]。logits 是随机生成的,因此不假设其具体类别概率。

计算步骤:

  1. 每个时间步归一化:对每个 (t,n) 的 5 个 logits 执行 log_softmax,得到 \(\log P(c\mid t,n)\);每个时间步的普通概率在类别维相加为 1。

  2. 枚举第一个目标的合法路径:例如路径 [0,1,1,0,2,0] 先合并重复项得到 [0,1,0,2,0],再删除 blank 得到 [1,2],因此合法;所有压缩结果不为 [1,2] 的路径不计入。

  3. 聚合路径概率:每条合法路径的概率是各时间步对应类别概率的连乘,例如:

    \[P(0\mid t_1)P(1\mid t_2)P(1\mid t_3)P(0\mid t_4)P(2\mid t_5)P(0\mid t_6)\]

    将所有合法路径相加后取负对数得到第一个样本损失;第二个目标 [3,1,2] 同样处理。

  4. 得到最终输出:默认 reduction='mean' 会先按每个目标序列长度归一化两个样本损失,再取批量平均。

梯度含义:梯度并不强制某个固定时间步输出某个标签,而是提高所有能组成正确目标的路径总概率;这正是 CTC 能处理未知对齐的原因。

3.6.7 小结:常见选择与易错点

任务 推荐损失函数 模型输出 目标格式
单标签二分类 BCEWithLogitsLoss [N] 或 [N, 1] logits 同形状的 float,值为 0/1
单标签多分类 CrossEntropyLoss [N, C] logits [N] 的 long 类别索引
多标签分类 BCEWithLogitsLoss [N, C] logits [N, C] 的 float 0/1 矩阵
连续值回归 MSELoss / L1Loss / SmoothL1Loss 连续预测值 与输出形状一致的 float
序列无对齐标注 CTCLoss [T, N, C] 对数概率 标签序列与各自长度

最常见的错误是“对 logits 重复做激活”:CrossEntropyLoss 前不要手动 softmax,BCEWithLogitsLoss 前不要手动 sigmoid。只有使用 BCELoss 时才应传入 sigmoid 后的概率。调用损失函数前,也应先检查输出形状、目标形状、目标数据类型和标签取值范围是否匹配。

3.7 训练与评估

本节学习的内容饮包含:PyTorch中模型训练/评估模型的开启、完整的训练/评估流程。

3.7.1 泛化能力强与不拟拿区别与联系

为了更清晰、直观地厘清两者之间的界限,以下从概念定义、评判标准、逻辑关系以及典型表现等维度进行集中对比:

泛化能力强 vs 不过拟合

比较维度 泛化能力强(Good Generalization) 不过拟合(No Overfitting)
本质定义 模型在未见过的全新数据(真实场景/测试集)上取得优异预测效果的能力。 模型没有过度依赖训练集的特定噪声或局部特例,保持了适度泛化空间的状态。
核心视角 积极/结果导向(追求上限:模型在未知数据上到底有多强)。 防御/状态导向(排除病态:模型是否出现了死记硬背的倾向)。
评估标准 依赖测试集/验证集的绝对指标(如测试集准确率 \(95\%\)、测试集 Loss 极低)。 依赖训练集与测试集之间的相对差距(即 \(\text{Loss}_{\text{test}} - \text{Loss}_{\text{train}}\) 保持较小或合理)。
出现欠拟合时 ❌ 表现极差(对未知数据预测不准)。 ✅ 依然成立(训练集和测试集表现都很差,但两者几乎无 Gap)。
出现轻微 Gap 时 ✅ 依然成立(若训练集 \(99.9\%\)、测试集 \(95\%\),尽管有 \(4.9\%\) 的 Gap,泛化依然极强)。 ⚠️ 存在微弱过拟合(从严格统计学定义看,存在表现落差)。
核心优化目标 提升模型学习通用特征规律的能力(如通过更好的架构、优质数据、预训练等)。 约束模型容量,防止对训练集的过分拟合(如使用 Dropout、L2 正则化、早停等)。

两者之间的核心联系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
                  ┌──────────────────┐
│ 泛化能力强 │
└────────┬─────────┘
│
┌──────────────┴──────────────┐
▼ ▼
【充分条件】能泛化必然掌握了规律 【必要条件】避免严重过拟合是
(故不可能存在严重的有害过拟合) 良好泛化的前提(但还需排除欠拟合)
▲ ▲
└──────────────┬──────────────┘
│
┌────────┴─────────┐
│ 不过拟合 │
└──────────────────┘
  1. “不过拟合”是“泛化能力强”的必要非充分条件:
    • 必要性:如果一个模型严重过拟合,它在测试集上的表现必定崩溃,绝不可能拥有强泛化能力。
    • 非充分性:如果模型处于欠拟合状态,它虽然完全没有过拟合,但泛化能力依然极差。
  2. “泛化能力强”是“不过拟合”在有效拟合前提下的自然结果:
    • 当模型在训练集上已经拟合得很好(排除了欠拟合),此时只要成功控制住过拟合,模型就会顺理成章地获得强泛化能力。

3.7.2 训练与评估的大致过程

在完成模型的训练后,需要在验证集/测试集分别完成模型的验证与测试,以确保最终得到的模型是具有泛化能力的/不会出现严重过拟合现象的。

3.7.2.1 训练的流程

PyTorch中,模型训练与评估/测试的最显著区别在于:模型训练需要将模型设置为训练状态,模型的参数要支持反向传播与优化器更新; 而模型验证/评估时,需要将模型设置为验证/测试状态,不要进行损失的反向传播与/更新模型参数。

一个完整的图像分类的训练过程如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def train(epoch):
# 1. 将模型切换为训练状态(开启 Dropout、允许 BatchNorm 更新全局统计量)
model.train()
train_loss = 0
# 2. 利用 DataLoader 批量迭代读取训练数据及其对应标签
for data, label in train_loader:
# 将数据及标签加载到GPU上
data, label = data.cuda(), label.cuda()
# 使用每批读取到的训练数据前,先将优化器的梯度置零,防止梯度累加影响当前更新
optimizer.zero_grad()
# 前向传播:将输入数据送入模型计算预测输出
output = model(data)
# 结合预测输出和真实标签,调用损失函数计算当前 Batch 的平均损失
loss = criterion(output, label)
# 反向传播:根据计算图自动求导,计算 Loss 关于各参数的梯度
loss.backward()
# 优化器根据计算出的梯度,按既定算法(如 SGD/Adam)更新模型权重参数
optimizer.step()
# loss.item() 为单样本平均损失,乘以当前 Batch 实际样本数 data.size(0) 得到该 Batch 的损失总和并累加
train_loss += loss.item()*data.size(0)
# 计算整个 Epoch 在所有数据集样本上的平均损失
train_loss = train_loss/len(train_loader.dataset)
# 格式化打印当前 Epoch 的平均训练损失
print('Epoch: {} \tTraining Loss: {:.6f}'.format(epoch, train_loss))

3.7.2.2 验证/评估的流程

3.7.2.2.1 PyTorch 原生评估 API

验证/测试的流程基本与训练过程一致,不同点在于:

  • 需要预先设置torch.no_grad,以及将model调至eval模式
  • 不需要将优化器的梯度置零
  • 不需要将loss反向回传到网络
  • 不需要更新optimizer

一个完整图像分类的验证过程如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
def val(epoch):       
# 1. 将模型切换为评估/测试状态(关闭 Dropout、冻结 BatchNorm 均值和方差更新)
model.eval()

val_loss = 0.0
running_accu = 0 # 必须先初始化正确预测的样本计数器,避免变量未定义报错

# 2. 禁用 Autograd 梯度计算引擎,节省 GPU 显存并加速推理过程
with torch.no_grad():
# 3. 利用 DataLoader 批量迭代读取验证集数据及其标签
for data, label in val_loader:
# 将验证数据与标签传输到 GPU 显存中
data, label = data.cuda(), label.cuda()

# 前向传播:计算模型的预测 Logits/概率分布(只做推理,不构建计算图)
output = model(data)

# 获取每个样本预测概率最大的类别索引(dim=1 表示沿着类别维度取最大值的索引)
preds = torch.argmax(output, dim=1)

# 计算当前 Batch 的平均验证损失
loss = criterion(output, label)

# 累加当前 Batch 的损失总和(单样本平均损失 * 当前 Batch 样本数)
val_loss += loss.item() * data.size(0)

# 统计当前 Batch 中预测正确的样本总数,并累加到 running_accu
running_accu += torch.sum(preds == label).item()

# 4. 计算整个验证集上的平均损失与准确率
val_loss = val_loss / len(val_loader.dataset)
val_acc = running_accu / len(val_loader.dataset) # 计算整体验证准确率

# 5. 格式化打印当前 Epoch 的验证损失与准确率
# 【修正】将原来的 'Training Loss' 修正为 'Validation Loss'
print('Epoch: {} \tValidation Loss: {:.6f} \tAcc: {:.4f}'.format(epoch, val_loss, val_acc))
3.7.2.2.2 sklearn.metrics

对于图像分类任务,还可以使用sklearn.metrics中的classification_report函数来计算模型的准确率、召回率、F1值等指标,如下所示:

1
2
3
4
5
6
7
8
9
10
11
from sklearn.metrics import classification_report
"""
将下方代码的labels和preds替换为模型预测出来的所有label和preds,
target_names替换为类别名称,
既可得到模型的分类报告
"""
print(classification_report(
labels.cpu().numpy(), # 需确保将 PyTorch Tensor 转为 NumPy 数组
preds.cpu().numpy(),
target_names=class_names
))
3.7.2.2.3 torcheval或torchmetric

此外,还可以使用torcheval或torchmetric来对模型进行评估。

(1)两大 PyTorch 评估库核心区别
库名 核心特点 适用场景
TorchMetrics 社区最主流(PyTorch Lightning 官方维护),支持 GPU 累加计算、分布式通信与可视化,指标最全面。 绝大多数 PyTorch 项目首选
TorchEval Meta 官方维护,极简轻量,语法契合 PyTorch 原生生态。 追求轻量化或 Meta 生态集成项目
(2)核心示例代码

两者均采用 update()(逐 Batch 收集)-> compute()(跨 Batch 整体计算) 的流式评估模式,无需像 sklearn 那样把所有数据 .cpu().numpy() 拼接成大数组,更加节省显存。

① 使用 TorchMetrics(最常用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import torch
import torchmetrics

# 1. 初始化多分类评估指标对象(假设 10 分类,在 GPU 上计算)
metric = torchmetrics.Accuracy(task="multiclass", num_classes=10).cuda()

# 2. 在验证循环的每个 Batch 中累加结果
for data, label in val_loader:
data, label = data.cuda(), label.cuda()
output = model(data)
preds = torch.argmax(output, dim=1)

metric.update(preds, label) # 自动累计预测结果

# 3. 整个 Epoch 结束后计算最终准确率,并重置状态
total_acc = metric.compute()
print(f"Validation Accuracy: {total_acc:.4f}")
metric.reset() # 为下一个 Epoch 训练清空缓存

② 使用 TorchEval(Meta 官方)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import torch
from torcheval.metrics import MulticlassAccuracy

# 1. 初始化指标对象
metric = MulticlassAccuracy(num_classes=10).to("cuda")

# 2. 在验证循环中累加
for data, label in val_loader:
data, label = data.cuda(), label.cuda()
preds = torch.argmax(model(data), dim=1)
metric.update(preds, label)

# 3. 计算并重置
total_acc = metric.compute()
print(f"Validation Accuracy: {total_acc:.4f}")
metric.reset()

3.8 可视化

在 PyTorch 中,可视化贯穿于模型开发的全生命周期(数据准备、训练监控、效果评估与可解释性分析)。它既包含训练过程中对损失和指标变化的实时监控,也包含对不同阶段、不同模型(如蒸馏/微调前后)效果的对比分析,以及对模型内部特征与决策依据的可解释性展示。

3.9 PyTorch优化器

本小节学习的内容饮包含:了解PyTorch的优化器、学会使用PyTorch提供的优化器进行优化、优化器的属性和构造、优化器的对比。


深度学习的目标是通过多轮的迭代不断改变模型的网络参数,使得模型能够对输入做各种非线性变换、使得输出尽可能的拟合标签。这个过程本质上就是一个复杂函数求最优解的过程,在深度学习模型中这个最优解是一个大矩阵,如何快速求得这个最优解是深度学习中的一个研究重点与难点。比如以经典的resnet-50模型为例,其中大约有2000万个系数需要进行计算,那么如何计算出这么多系数,有以下两种方法:

  1. 第一种是直接暴力穷举一遍参数,这种方法从理论上行得通,但是实施上可能性基本为0,因为参数量过于庞大。
  2. 第二种方法:为了使求解参数过程更快,人们提出了BP+优化器逼近求解。

在这个第二种方法中,优化器就是根据网络反向传播的梯度信息来不断更新模型的网络参数,降低损失函数计算值,最终使得模型输出更加接近真实标签。

3.9.1 PyTorch提供的优化器

PyTorch中有一个优化器包torch.optim,其下预置了多个优化器实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
torch.optim.SGD
torch.optim.ASGD
torch.optim.Adadelta
torch.optim.Adagrad
torch.optim.Adam
torch.optim.AdamW
torch.optim.Adamax
torch.optim.RAdam
torch.optim.NAdam
torch.optim.SparseAdam
torch.optim.LBFGS
torch.optim.RMSprop
torch.optim.Rprop

上述这些优化器都继承于torch.optim.Optimizer,以下是torch.optim.Optimizer的__init__方法中几个关键属性,PyTorch2.6.0中针对此类的此方法中还有一些其他属性,但不是此次解释的重点。

1
2
3
4
5
6
7
8
class Optimizer:
def __init__(self, params: ParamsT, defaults: Dict[str, Any]) -> None:
self.defaults = defaults
self.state: DefaultDict[torch.Tensor, Any] = defaultdict(dict)
self.param_groups: List[Dict[str, Any]] = []
...

...

3.9.1.1 Optimizer类中三个核心属性详解


1. self.defaults — 超参数默认值字典

存储优化器的全局默认超参数,当某个参数组没有单独指定某个超参数时,就使用这里的默认值。

1
2
3
4
5
6
7
8
import torch

params = [torch.randn(2, 2) for _ in range(3)] # 3个参数张量

# 创建 SGD 优化器
optimizer = torch.optim.SGD(params, lr=0.01, momentum=0.9, weight_decay=1e-4)

print(optimizer.defaults)

输出:

1
{'lr': 0.01, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0.0001, 'nesterov': False, 'maximize': False, 'foreach': None, 'differentiable': False, 'fused': None}

optimizer.defaults完整解释如下:

参数 默认值 含义
lr 0.01 学习率(Learning Rate)。每次更新时参数沿梯度方向移动的步长大小。
momentum 0.9 动量系数。引入历史梯度的指数移动平均,加速收敛并减少震荡。公式:v = momentum * v + grad
dampening 0 动量阻尼。用于抑制动量项,防止动量过大。公式:v = momentum * v + (1 - dampening) * grad。设为 0 表示不抑制。
weight_decay 0.0001 权重衰减(L2 正则化)。在梯度上加上 weight_decay * param,等效于损失函数加 L2 正则项,防止过拟合。
nesterov False Nesterov 动量。若为 True,使用 Nesterov 加速梯度(NAG),比普通动量"看得更远",收敛更平滑。要求 momentum > 0 且 dampening == 0。
maximize False 是否最大化目标。若为 True,则沿梯度上升方向更新(用于最大化问题),而非默认的梯度下降。
foreach None 是否使用 foreach 内核。None 表示自动决定;True 强制使用批量 foreach 内核(对多参数并行处理更快);False 强制不使用。
differentiable False 是否可微分。若为 True,step() 操作本身保留计算图,允许对优化器步骤求导(用于元学习等场景)。
fused None 是否使用 fused 内核。None 表示自动决定;True 强制使用融合 CUDA 内核(将所有参数的更新合并为一次 kernel launch,减少显存带宽压力,速度更快)。要求 GPU 且不支持 differentiable=True。

2. self.state — 每个参数的优化器状态

这是一个 defaultdict(dict),键是参数张量本身(tensor 对象),值是一个字典,存储该参数对应的优化过程中累积的状态(如动量缓冲、步数计数等)。

以 SGD + momentum 为例,每次 step() 后会为每个参数记录动量缓冲:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import torch

# 一个简单的可优化参数
w = torch.tensor([1.0, 2.0, 3.0], requires_grad=True)

optimizer = torch.optim.SGD([w], lr=0.1, momentum=0.9)

# 训练前:state 为空
print("训练前 state:", dict(optimizer.state))
# 输出: {}

# 模拟一次前向 + 反向
loss = w.sum()
loss.backward()

# 第一次 step
optimizer.step()
print("\n第1次 step 后 state:")
for param, state in optimizer.state.items():
print(f" 参数: {param.tolist()}")
print(f" 状态: {state}")
# 输出:
# 参数: [1.0, 2.0, 3.0]
# 状态: {'momentum_buffer': tensor([-0.1000, -0.1000, -0.1000])}

# 第二次 step
w.grad = torch.tensor([1.0, 1.0, 1.0])
optimizer.step()
print("\n第2次 step 后 state:")
for param, state in optimizer.state.items():
print(f" 参数: {param.tolist()}")
print(f" 状态: {state}")
# 输出:
# 参数: [0.9, 1.9, 2.9]
# 状态: {'momentum_buffer': tensor([-0.1900, -0.1900, -0.1900])}
# 动量缓冲 = 0.9 * 旧缓冲(-0.1) + 梯度(0.1) = -0.09 + -0.1 = -0.19

以 Adam 为例,状态更丰富(包含一阶矩、二阶矩、步数):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
optimizer = torch.optim.Adam([w], lr=0.001, betas=(0.9, 0.999))
loss = w.sum()
loss.backward()
optimizer.step()

for param, state in optimizer.state.items():
print(f" 参数: {param.tolist()}")
print(f" 状态: {state}")
# 输出:
# 参数: [...]
# 状态: {
# 'step': tensor(1.), ← 已执行步数
# 'exp_avg': tensor([...]), ← 一阶矩估计(梯度均值的指数移动平均)
# 'exp_avg_sq': tensor([...]) ← 二阶矩估计(梯度平方均值的指数移动平均)
# }

3. self.param_groups — 参数分组列表

存储参数组列表,每个参数组是一个字典,包含该组的参数张量和对应的超参数。这样不同参数组可以使用不同的学习率等超参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import torch
import torch.nn as nn

model = nn.Sequential(
nn.Linear(4, 8), # 第1层
nn.Linear(8, 2), # 第2层
)

# 对不同层设置不同的学习率
optimizer = torch.optim.SGD([
{'params': model[0].parameters(), 'lr': 0.01, 'momentum': 0.9},
{'params': model[1].parameters(), 'lr': 0.001}, # 未指定 momentum,使用 defaults 中的值
], lr=0.1) # 全局默认 lr(会被各组的 lr 覆盖)

print(f"参数组数量: {len(optimizer.param_groups)}")
for i, group in enumerate(optimizer.param_groups):
print(f"\n--- 参数组 {i} ---")
for key, value in group.items():
if key == 'params':
print(f" params: {len(list(value))} 个参数张量")
else:
print(f" {key}: {value}")

输出:

1
2
3
4
5
6
7
8
9
10
11
12
参数组数量: 2

--- 参数组 0 ---
params: 2 个参数张量 ← model[0] 的 weight 和 bias
lr: 0.01
momentum: 0.9

--- 参数组 1 ---
params: 2 个参数张量 ← model[1] 的 weight 和 bias
lr: 0.001
momentum: 0.9 ← 来自 defaults


三者的关系总结
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Optimizer
│
├── defaults = {'lr': 0.01, 'momentum': 0.9, ...}
│ ↑ 全局默认值,当 param_groups 中某组没指定时,从这里取
│
├── param_groups = [
│ {'params': [w1, b1], 'lr': 0.01, 'momentum': 0.9}, ← 第1组
│ {'params': [w2, b2], 'lr': 0.001, 'momentum': 0.9}, ← 第2组(lr不同)
│ ]
│ ↑ 每个参数张量属于且仅属于一个组
│
└── state = {
w1: {'momentum_buffer': tensor(...)}, ← w1 的累积状态
b1: {'momentum_buffer': tensor(...)}, ← b1 的累积状态
w2: {'momentum_buffer': tensor(...)}, ← w2 的累积状态
b2: {'momentum_buffer': tensor(...)}, ← b2 的累积状态
}
↑ 键是参数张量对象,值是该参数在优化过程中的历史状态

简单来说: - defaults:优化器的"全局配置" - param_groups:把参数分组,每组可以有"个性化配置" - state:记录每个参数在训练过程中累积的"历史记忆"(动量、步数等)

3.9.1.2 torch.optim.Optimizer 重要方法一览


核心方法
方法 作用
__init__(params, defaults) 初始化优化器,设置默认超参数、参数分组和状态容器
step(closure=None) 执行一次参数更新。各子类(SGD、Adam 等)各自实现具体更新逻辑
zero_grad(set_to_none=True) 清零所有参数的梯度。set_to_none=True 时将梯度设为 None(省内存),False 时设为 0
state_dict() 返回优化器当前状态的字典,包含 state(各参数的累积状态)和 param_groups(参数分组及超参数)
load_state_dict(state_dict) 从字典中加载优化器状态,恢复训练时的优化器状态
add_param_group(param_group) 动态添加一个新的参数组。常用于微调时把冻结层加入训练

钩子注册方法
方法 作用
register_step_pre_hook(hook) 注册一个在 step() 执行前调用的钩子
register_step_post_hook(hook) 注册一个在 step() 执行后调用的钩子
register_state_dict_pre_hook(hook) 注册一个在 state_dict() 执行前调用的钩子
register_state_dict_post_hook(hook) 注册一个在 state_dict() 执行后调用的钩子
register_load_state_dict_pre_hook(hook) 注册一个在 load_state_dict() 执行前调用的钩子
register_load_state_dict_post_hook(hook) 注册一个在 load_state_dict() 执行后调用的钩子

内部辅助方法
方法 作用
_patch_step_function() 将 step() 包装上 profiler 和钩子调用逻辑
_cuda_graph_capture_health_check() 检查 CUDA 图捕获是否安全可用
_group_tensors_by_device_and_dtype() 按设备和数据类型对张量列表分组,用于批量内核优化

典型使用流程
1
2
3
4
5
6
7
8
9
10
11
12
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)

# 训练循环
for data, target in dataloader:
optimizer.zero_grad() # 1. 清零梯度
loss = loss_fn(model(data), target)
loss.backward() # 2. 反向传播计算梯度
optimizer.step() # 3. 更新参数

# 保存/恢复
torch.save(optimizer.state_dict(), "optim.pt")
optimizer.load_state_dict(torch.load("optim.pt"))

3.9.2 实际操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import os
import torch

# 设置权重,服从正态分布 --> 2 x 2
weight = torch.randn((2, 2), requires_grad=True)
# 设置梯度为全1矩阵 --> 2 x 2
weight.grad = torch.ones((2, 2))
# 输出现有的weight和data
print("The data of weight before step:\n{}".format(weight.data))
print("The grad of weight before step:\n{}".format(weight.grad))

# 实例化优化器
optimizer = torch.optim.SGD([weight], lr=0.1, momentum=0.9)
# 进行一步操作
optimizer.step()
# 查看进行一步后的值,梯度
print("The data of weight after step:\n{}".format(weight.data))
print("The grad of weight after step:\n{}".format(weight.grad))

# 权重清零
optimizer.zero_grad()
# 检验权重是否为0
print("The grad of weight after optimizer.zero_grad():\n{}".format(weight.grad))
# 输出参数
print("optimizer.params_group is \n{}".format(optimizer.param_groups))
# 查看参数位置,optimizer和weight的位置一样,我觉得这里可以参考Python是基于值管理
print("weight in optimizer:{}\nweight in weight:{}\n".format(id(optimizer.param_groups[0]['params'][0]), id(weight)))

# 添加参数:weight2
weight2 = torch.randn((3, 3), requires_grad=True)
optimizer.add_param_group({"params": weight2, 'lr': 0.0001, 'nesterov': True})
# 查看现有的参数
print("optimizer.param_groups is\n{}".format(optimizer.param_groups))
# 查看当前状态信息
opt_state_dict = optimizer.state_dict()
print("state_dict before step:\n", opt_state_dict)
# 进行5次step操作
for _ in range(50):
optimizer.step()

# 输出现有状态信息
print("state_dict after step:\n", optimizer.state_dict())
# 保存参数信息
torch.save(optimizer.state_dict(),os.path.join(r"/Users/jsy/Dev/projects/python/PyTorch_study/Attention_Unet", "optimizer_state_dict.pkl"))
print("----------done-----------")
# 加载参数信息
state_dict = torch.load(r"/Users/jsy/Dev/projects/python/PyTorch_study/Attention_Unet/optimizer_state_dict.pkl") # 需要修改为你自己的路径
optimizer.load_state_dict(state_dict)
print("load state_dict successfully\n{}".format(state_dict))

# 输出最后属性信息
print("\n{}".format(optimizer.defaults))
print("\n{}".format(optimizer.state))
print("\n{}".format(optimizer.param_groups))

3.9.3 输出结果

zero_grad() 把梯度设为 None 后,后续 50 次 step() 中 weight 的梯度为 None,SGD 会跳过该参数(不更新动量缓冲和权重)。具体解释如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
# ── 第9行: print("The data of weight before step:") ──
# weight 由 torch.randn 初始化的 2×2 随机正态分布张量
The data of weight before step:
tensor([[ 0.3131, -0.3519],
[-0.2580, 0.8226]])

# ── 第10行: print("The grad of weight before step:") ──
# 第7行手动设置的 weight.grad = torch.ones((2, 2))
The grad of weight before step:
tensor([[1., 1.],
[1., 1.]])

# ── 第16行: print("The data of weight after step:") ──
# SGD更新: 第1步时 momentum_buffer = grad = ones(2,2)
# weight = weight - lr * buffer = weight - 0.1 * ones
# 例: 0.3131 - 0.1 = 0.2131, -0.3519 - 0.1 = -0.4519
The data of weight after step:
tensor([[ 0.2131, -0.4519],
[-0.3580, 0.7226]])

# ── 第17行: print("The grad of weight after step:") ──
# step() 不会修改梯度,梯度仍然是之前手动设置的 ones(2,2)
The grad of weight after step:
tensor([[1., 1.],
[1., 1.]])

# ── 第21行: print("The grad of weight after optimizer.zero_grad():") ──
# PyTorch 2.6.0 中 zero_grad() 默认 set_to_none=True
# 所以梯度被设为 None(而非 tensor(0))
The grad of weight after optimizer.zero_grad():
None

# ── 第23行: print("optimizer.params_group is") ──
# 此时只有一个参数组,包含 weight 张量和所有超参数
# lr=0.1, momentum=0.9 是用户指定的;其余为 SGD 默认值
optimizer.params_group is
[{'params': [tensor([[ 0.2131, -0.4519],
[-0.3580, 0.7226]], requires_grad=True)],
'lr': 0.1, # 用户指定
'momentum': 0.9, # 用户指定
'dampening': 0, # SGD默认值
'weight_decay': 0, # SGD默认值
'nesterov': False, # SGD默认值
'maximize': False, # SGD默认值(PyTorch 2.x 新增)
'foreach': None, # SGD默认值(PyTorch 2.x 新增,None=自动选择)
'differentiable': False, # SGD默认值(PyTorch 2.x 新增)
'fused': None}] # SGD默认值(PyTorch 2.x 新增)

# ── 第25行: 验证 optimizer 中存储的张量和 weight 是同一个对象 ──
# id 相同,说明 Python 传递的是对象引用(同一块内存地址)
weight in optimizer:140119575836096
weight in weight:140119575836096

# ── 第30行: print("optimizer.param_groups is") ──
# 第28行 add_param_group 添加了 weight2(3×3 张量)
# 指定了 lr=0.0001, nesterov=True;未指定的从 defaults 继承
# 例如 momentum=0.9 来自 defaults
optimizer.param_groups is
[
# ── 参数组0: weight(2×2)──
{'params': [tensor([[ 0.2131, -0.4519],
[-0.3580, 0.7226]], requires_grad=True)],
'lr': 0.1, 'nesterov': False, 'momentum': 0.9, ...},

# ── 参数组1: weight2(3×3)──
{'params': [tensor([[ 0.5283, -1.6606, 0.0100],
[ 1.3674, -0.9431, -0.5353],
[-0.2398, 0.0837, -0.6382]], requires_grad=True)],
'lr': 0.0001, # 用户指定
'nesterov': True, # 用户指定
'momentum': 0.9, # 从 defaults 继承
'dampening': 0, # 从 defaults 继承
'weight_decay': 0, # 从 defaults 继承
'maximize': False, # 从 defaults 继承
'foreach': None, # 从 defaults 继承
'differentiable': False, # 从 defaults 继承
'fused': None} # 从 defaults 继承
]

# ── 第33行: state_dict before step ──
# state: 只有参数组0的 weight 有 momentum_buffer(第14行 step() 时创建的)
# 值 = ones(2,2),因为第1步时 buffer 初始化为梯度值
# weight2 没有 state,因为它从未被 step() 过(没有梯度)
# param_groups: params=[0] 和 params=[1] 是参数ID(不是索引),
# 用于将 state 中的键与 param_groups 关联起来
state_dict before step:
{'state': {0: {'momentum_buffer': tensor([[1., 1.],
[1., 1.]])}},
'param_groups': [
{'lr': 0.1, ..., 'params': [0]}, ← 参数组0,参数ID=0(即weight)
{'lr': 0.0001, 'nesterov': True, ..., 'params': [1]} ← 参数组1,参数ID=1(即weight2)
]}

# ── 第38行: state_dict after 50 steps ──
# 关键: 第19行 zero_grad() 把 weight.grad 设为了 None
# 之后50次 step() 中,weight.grad 为 None → SGD 跳过该参数
# weight2.grad 也是 None → 同样被跳过
# 因此 state 完全不变,momentum_buffer 仍然是 ones(2,2)
state_dict after step:
{'state': {0: {'momentum_buffer': tensor([[1., 1.],
[1., 1.]])}},
'param_groups': [...]} ← 与 before step 完全相同

# ── 第41行 ──
# 保存成功标记
----------done-----------

# ── 第45行: 加载并打印 state_dict ──
# 从文件加载的 state_dict 内容与保存时一致
load state_dict successfully
{'state': {0: {'momentum_buffer': tensor([[1., 1.],
[1., 1.]])}},
'param_groups': [...]}

# ── 第47行: optimizer.defaults ──
# 优化器创建时传入的全局默认超参数
{'lr': 0.1, 'momentum': 0.9, 'dampening': 0, 'weight_decay': 0,
'nesterov': False, 'maximize': False, 'foreach': None,
'differentiable': False, 'fused': None}

# ── 第48行: optimizer.state ──
# 内部存储的优化器状态:
# 键 = weight 张量对象(内存地址作为key)
# 值 = 该张量的 momentum_buffer(ones(2,2),因为50次step都被跳过了)
defaultdict(<class 'dict'>, {
tensor([[ 0.2131, -0.4519],
[-0.3580, 0.7226]], requires_grad=True):
{'momentum_buffer': tensor([[1., 1.],
[1., 1.]])}
})

# ── 第49行: optimizer.param_groups ──
# 与 state_dict 的 param_groups 类似,但 params 值显示的是实际张量
# (state_dict 中显示的是整数ID,这里显示的是张量数据)
[
# 参数组0: weight
{'lr': 0.1, ..., 'params': [tensor([[ 0.2131, -0.4519],
[-0.3580, 0.7226]], requires_grad=True)]},
# 参数组1: weight2
{'lr': 0.0001, 'nesterov': True, ..., 'params': [tensor([[ 0.5283, -1.6606, 0.0100],
[ 1.3674, -0.9431, -0.5353],
[-0.2398, 0.0837, -0.6382]], requires_grad=True)]}
]

梯度为None后不再更新权重

50 次 step() 前后 state_dict 完全相同,不是 bug,原因是:

1
2
3
4
第14行: optimizer.step()     ← weight.grad = ones(2,2),正常更新
第19行: optimizer.zero_grad() ← weight.grad = None(PyTorch 2.6.0 默认行为)
第35行: for _ in range(50):
optimizer.step() ← weight.grad 为 None,SGD 跳过该参数

SGD 的 _init_group 方法中(第 86 行)有判断 if p.grad is not None:,梯度为 None 时该参数不会被加入更新列表,动量缓冲也不会被修改。

梯度更新前一定要置零

optimizer在一个神经网络的epoch中需要实现下两个步骤:梯度置零、梯度更新。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import torch

# 1. 优化器实例创建(绑定模型参数及超参数)
optimizer = torch.optim.SGD(net.parameters(), lr=1e-5)

# 2. 外层:Epoch 循环(训练整份数据集的轮数)
for epoch in range(EPOCH):

# 3. 内层:Batch 循环(批量读取数据,进行多次梯度更新)
# 在深度学习中,梯度更新通常是每个 Batch(批次)执行一次
for inputs, labels in train_loader:

# 步骤 A:清空上一个 Batch 的梯度
optimizer.zero_grad()

# 步骤 B:前向传播 + 计算 Loss
outputs = net(inputs)
loss = criterion(outputs, labels)

# 步骤 C:反向传播,计算当前 Batch 的梯度
loss.backward()

# 步骤 D:优化器根据梯度更新权重参数
optimizer.step()

为网络中不同的层单独定制优化器参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from torch import optim
from torchvision.models import resnet18

# 1. 实例化 ResNet-18 模型
net = resnet18()

# 2. 为不同层组装参数列表(Param Groups)并创建优化器
optimizer = optim.SGD([
# 第一组:全连接层(fc),未单独指定 lr,因此继承全局默认的 lr=1e-5
{'params': net.fc.parameters()},

# 第二组:layer4 中第 0 个 BasicBlock 的 conv1 卷积层,单独指定 lr=1e-2
{'params': net.layer4[0].conv1.parameters(), 'lr': 1e-2}
], lr=1e-5) # 全局默认学习率


### 验证查看optimizer.param_groups
# 查看优化器中有几个参数组
print(len(optimizer.param_groups)) # 输出: 2
# 打印各个参数组的学习率
for i, group in enumerate(optimizer.param_groups):
print(f"Group {i} Learning Rate: {group['lr']}")
# Group 0 Learning Rate: 1e-05
# Group 1 Learning Rate: 0.01

3.9.4 实验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
import torch
import torch.nn as nn
import torch.nn.functional as F
import matplotlib.pyplot as plt

# ==========================================
# 1. 数据准备阶段 (构建带噪声的二次函数数据)
# ==========================================
# 固定随机种子,确保每次运行生成相同的随机数据,便于结果复现
torch.manual_seed(42)

# 在 [-1, 1] 区间内均匀生成 200 个一维数据点
# unsqueeze(dim=1) 将形状从 [200] 升维为 [200, 1],以符合 PyTorch 全连接层的输入格式 Requirement [Batch_size, Feature_dim]
x = torch.unsqueeze(torch.linspace(-1, 1, 200), dim=1)

# 构建目标曲线 y = x^2,并叠加均值为 0、标准差为 0.1 的正态分布随机噪声,增加数据真实拟合难度
y = x.pow(2) + 0.1 * torch.normal(torch.zeros(x.size()))

# 绘制原始数据分布散点图
fig, ax = plt.subplots()
# Tensor 需要转为 NumPy 数组或直接传入,scatter 用于绘制离散数据点
ax.scatter(x.numpy(), y.numpy())
plt.show()

# ==========================================
# 2. 网络结构定义 (简单的两层全连接神经网络)
# ==========================================
class Net(nn.Module):
def __init__(self):
super(Net, self).__init__()
# 隐藏层:输入维度 1,输出维度 20(将单维特征映射到 20 维隐空间)
self.hidden = nn.Linear(1, 20)
# 输出层(预测层):输入维度 20,输出维度 1(将 20 维特征还原为 1 维标量预测值)
self.predict = nn.Linear(20, 1)

def forward(self, x):
# 激活函数:使用 ReLU 引入非线性变换,使神经网络具备拟合二次曲线的能力
x = F.relu(self.hidden(x))
# 线性输出:回归任务的最后一层通常不需要加激活函数
x = self.predict(x)
return x

# ==========================================
# 3. 优化器配置与参数组初始化
# ==========================================
# 提取各类优化器的类构造函数引用(尚未实例化)
opt_SGD = torch.optim.SGD
# 使用 lambda 匿名函数为 Momentum 额外指定动量参数 momentum=0.9
opt_Momentum = lambda params: torch.optim.SGD(params, lr=0.05, momentum=0.9)
opt_ASGD = torch.optim.ASGD
opt_Adadelta = torch.optim.Adadelta
opt_Adagrad = torch.optim.Adagrad
opt_Adam = torch.optim.Adam
opt_AdamW = torch.optim.AdamW
opt_Adamax = torch.optim.Adamax
opt_RMSprop = torch.optim.RMSprop

# 配置待对比的优化器字典:{ 优化器名称: (优化器构造函数/lambda, 初始学习率 lr) }
# 注意:Adadelta 的算法机制不同,不需要衰减学习率,通常默认使用较大的学习率 1.0
optimizers_cls = {
'SGD': (opt_SGD, 0.05),
'ASGD': (opt_ASGD, 0.05),
'Momentum': (opt_Momentum, 0.05),
'Adadelta': (opt_Adadelta, 1.0),
'Adagrad': (opt_Adagrad, 0.05),
'Adam': (opt_Adam, 0.05),
'AdamW': (opt_AdamW, 0.05),
'Adamax': (opt_Adamax, 0.05),
'RMSprop': (opt_RMSprop, 0.05),
}

# ==========================================
# 4. 训练过程与 Loss 变化记录
# ==========================================
# 创建字典,用于存储每个优化器在训练过程中所有 Step 的 Loss 历史数值
losses_his = {name: [] for name in optimizers_cls}

# 损失函数选择:均方误差损失(MSE Loss),非常适合连续值的回归任务
loss_func = nn.MSELoss()

# 遍历每一个优化器,分别进行独立的训练测试
for name, (opt_cls, lr) in optimizers_cls.items():
# 关键步骤:在每个优化器测试前重置随机种子并重新实例化 Net,保证所有优化器使用完全一致的初始权重参数
torch.manual_seed(42)
net = Net()

# 实例化当前优化器,并将当前网络的参数绑定给优化器
if name == 'Momentum':
optimizer = opt_cls(net.parameters()) # Momentum 已经在 lambda 中指定了 lr
else:
optimizer = opt_cls(net.parameters(), lr=lr)

# 进行 200 个 Step (迭代步) 的训练
for step in range(200):
# A. 前向传播:输入数据 x,计算模型预测值
output = net(x)
# B. 计算损失:比较预测值 output 与真实标签 y 的均方误差
loss = loss_func(output, y)

# C. 梯度置零:清空上一步累加的梯度,防止影响当前步计算
optimizer.zero_grad()
# D. 反向传播:根据 Loss 自动求导计算网络各层参数的梯度
loss.backward()
# E. 参数更新:优化器根据计算出的梯度按特定算法更新权重
optimizer.step()

# 记录当前 Step 的 Loss 值(使用 .item() 提取标量数值,避免占用 PyTorch 计算图显存/内存)
losses_his[name].append(loss.item())

# ==========================================
# 5. 可视化对比绘制
# ==========================================
plt.figure(figsize=(8, 6))

# 遍历每个优化器的 Loss 历史数据,绘制折线图
for name, loss_list in losses_his.items():
plt.plot(loss_list, label=name)

# 添加图例,置于右上角
plt.legend(loc='upper right')
# 设置坐标轴标签
plt.xlabel('Steps')
plt.ylabel('Loss')
# 截取 Y 轴显示区间 [0, 0.2],过滤掉第 0 步过高的初始 Loss,重点观察中后期收敛过程
plt.ylim(0, 0.2)
plt.show()
image-20260827175158989
image-20260827175221529

附图2展示了多种常用优化器在同一拟合任务上的收敛速度对比效果。

所有 9 种优化器在 200 个 Step 内都展现出了清晰的收敛特征。结合图中的实际数据曲线,详细解读如下:

1. 第一梯队:自适应学习率优化器(Adam, AdamW, Adamax, Adagrad)

  • 曲线表现:曲线在最左侧(前 0~25 个 Steps)几乎呈垂直断崖式下跌,迅速将 Loss 从高位压制到 0.025 以下,并在 25 个 Steps 左右就基本达到了全局最佳收敛点(Loss 近似 0.01 左右)。
  • 细节特征:在下跌初期有轻微的齿状震荡(例如粉色的 AdamW 和灰色的 Adamax),这是自适应一阶/二阶矩估计在快速调整梯度步伐时的正常波动,但收敛速度在所有优化器中是最快的。

2. 震荡收敛型:动量优化器(Momentum)

  • 曲线表现:绿色曲线在前 50 个 Steps 表现出非常明显的锯齿状强烈震荡,随后震荡幅值逐渐减小,在 75 个 Steps 后趋于平缓并稳定在低 Loss 区域。
  • 现象成因:由于加入了动量(momentum=0.9),参数更新时继承了上一次的惯性。当学习率较高(lr=0.05)时,容易在损失函数的极小值点两侧“来回冲过头(Overshoot)”,形成剧烈抖动,直到惯性积累与反向梯度达到平衡后才稳定下来。

3. 平缓渐进型:基础与衰减系优化器(SGD, ASGD, Adadelta, RMSprop)

  • SGD / ASGD:蓝色与橘色曲线几乎重合,下降趋势平缓顺滑,没有任何剧烈震荡。但由于没有加速机制,在 200 个 Steps 结束时 Loss 仍处于约 0.015~0.020 相对较高的位置,尚未完全收敛。
  • Adadelta:红色曲线前期下降较慢,在 50~125 个 Steps 阶段才出现明显的加速下探,最终在 125 Steps 左右趋于稳定。
  • RMSprop:黄色曲线呈现非常平滑的指数衰减形态,没有像 Adam 那样的剧烈抖动,最终在 75 Steps 左右平稳降至低点。

2. 核心结论与经验法则(“不存在绝对的好坏”)

  • 收敛效率:在本次的二次函数回归任务中,Adam / AdamW / Adamax / Adagrad 展现出了极高的求解效率;而 SGD / ASGD 在相同 Step 下收敛最慢。
  • 稳定性对比:RMSprop 兼顾了较快的收敛速度与极佳的曲线平滑度;而带大动量的 Momentum 则表现出很强的冲动性(抖动大)。
  • 选择策略:
    • 追求快速收敛与省心调参:优先选用 AdamW 或 Adam。
    • 需要平滑轨迹与防止剧烈震荡:选择带有自适应均方根控制的 RMSprop。
    • 传统模型精细调优:使用 Momentum 时,如果发现如上图绿线的剧烈震荡,通常需要适当降低学习率或配合学习率衰减(Learning Rate Decay)策略。

PyTorch03-PyTorch的主要组成模块
https://jiangsanyin.github.io/2026/08/06/PyTorch03-PyTorch的主要组成模块/
作者
sanyinjiang
发布于
2026年8月6日
许可协议