1. 从零手搓一个PyTorch项目:一个老手的踩坑与实战复盘
搞深度学习这几年,PyTorch 几乎成了我每天都要打交道的工具。但说实话,真正让我头疼的从来不是写模型结构本身,而是从环境搭建到训练调优这一整条链路上那些零零碎碎的坑。你可能也遇到过:明明代码一模一样,别人跑得飞起,你这边就是报一堆莫名其妙的错;或者模型训练半天 loss 不降,排查半天发现是数据加载那里出了幺蛾子。这篇内容就是把我自己从零搭建一个 PyTorch 项目时踩过的坑、总结的经验,以及那些文档里不会写的实操细节,完整地梳理一遍。不管你是刚接触 PyTorch 的新手,还是已经用过一段时间但总觉得不够系统的朋友,应该都能从中找到对自己有用的东西。我会从项目结构设计开始,一路讲到训练调优和问题排查,尽量把每个环节的“为什么”讲清楚,让你不仅知道怎么做,还知道为什么要这么做。
1.1 为什么选择PyTorch而不是其他框架
这个问题其实很多人问过,我自己也纠结过。早些年 TensorFlow 如日中天的时候,我也跟风用过一段时间,但最终还是回到了 PyTorch 的怀抱。原因其实不复杂,就三个字:顺手。
PyTorch 的动态计算图机制是我最看重的一点。什么叫动态计算图?你可以把它理解成“边写边算”。你写一行代码,它就执行一行,跟你写普通 Python 代码的体验几乎一样。调试的时候你可以直接用 print 打出来看,用 pdb 断点调试也没问题。相比之下,静态图框架需要你先定义好整个计算图,再喂数据进去跑,中间想看看某个中间变量的值都很麻烦。对于我这种喜欢边写边调、快速验证想法的人来说,动态图简直是救命稻草。
另一个让我离不开 PyTorch 的原因是它的 Python 原生风格。它的 API 设计非常符合 Python 的使用习惯,你不需要额外学一套新的编程范式。比如定义一个网络层,就是继承 nn.Module 然后写 forward 方法,跟写普通 Python 类没什么区别。这种低门槛的设计让新手能很快上手,也让老手能把精力集中在模型设计本身而不是框架的语法上。
当然,PyTorch 的生态现在也非常成熟了。torchvision 提供了大量的预训练模型和数据集工具,torchaudio 处理音频,torchtext 处理文本,还有 PyTorch Lightning、HuggingFace Transformers 这些第三方库,基本上你能想到的任务都有现成的轮子可以用。社区活跃度也很高,遇到问题搜一下基本都能找到答案。
提示:如果你现在还在犹豫选哪个框架,我的建议是直接上 PyTorch。除非你的部署环境有特殊要求必须用 TensorFlow Serving,否则 PyTorch 从研究到落地的全流程体验都是目前最舒服的。
1.2 一个标准PyTorch项目的目录结构应该长什么样
很多人写 PyTorch 代码就是在一个 .py 文件里从头写到尾,几百行堆在一起,跑通了就不管了。这种做法做实验可以,但一旦项目稍微大一点,或者过两周再回来看,自己都看不懂了。我吃过这个亏,所以后来养成了一个习惯:不管项目多小,都按照一个固定的目录结构来组织。
我常用的结构大概是这样:
project/ ├── configs/ │ └── default.yaml ├── data/ │ ├── raw/ │ └── processed/ ├── src/ │ ├── dataset.py │ ├── model.py │ ├── train.py │ ├── evaluate.py │ └── utils.py ├── checkpoints/ ├── logs/ ├── requirements.txt └── README.md这个结构看起来简单,但每个目录都有它存在的理由。configs 放配置文件,把超参数、路径、模型参数都抽出来,这样换实验的时候不用改代码,改配置就行。data 目录区分原始数据和处理后的数据,避免把原始数据污染了。src 放所有源代码,dataset.py 负责数据加载和预处理,model.py 定义网络结构,train.py 是训练入口,evaluate.py 做评估,utils.py 放一些通用工具函数。checkpoints 存模型权重,logs 存训练日志,这两个目录通常加到 .gitignore 里,不往仓库里传。
这么做的核心逻辑是关注点分离。数据、模型、训练逻辑、配置各管各的,改一个地方不会影响到其他部分。比如你想换个模型结构,只需要改 model.py,训练脚本完全不用动。想调学习率,改配置文件就行,不用去代码里翻。这种模块化的思路在项目变大之后优势特别明显。
注意:不要小看项目结构这件事。我见过太多人因为代码组织混乱,导致实验无法复现,最后白白浪费了大量时间。花半个小时把结构搭好,后面能省你几十个小时。
2. 数据加载与预处理:整个项目的地基
数据这块是整个项目里最容易被低估的部分。很多人把大量精力花在模型结构上,结果数据加载写得一塌糊涂,训练效率低不说,还容易出各种诡异的 bug。我自己就遇到过因为 DataLoader 的 num_workers 设置不当导致训练卡死的情况,排查了大半天才发现问题所在。所以这一章我想把数据加载和预处理这块讲透。
2.1 Dataset和DataLoader的正确打开方式
PyTorch 的数据加载核心就两个类:Dataset 和 DataLoader。Dataset 负责定义“一条数据长什么样”以及“怎么取到一条数据”,DataLoader 负责“怎么批量取”、“要不要打乱”、“用几个进程取”。
先说你自定义 Dataset 的时候必须实现的三个方法:__init__、__len__和__getitem__。__init__里一般做数据路径的整理和初始化,__len__返回数据集大小,__getitem__根据索引返回一条数据。这里有个关键点:__getitem__里不要做太重的计算,比如读大文件、做复杂的图像增强,这些操作如果太慢会拖累整个训练速度。
我一般会把数据预处理分成两部分:一部分是“离线”的,比如把原始图片统一 resize 到固定尺寸、把文本转成 token id,这些操作只做一次,结果存到磁盘上;另一部分是“在线”的,比如随机裁剪、随机翻转、颜色抖动这些数据增强操作,每次取数据的时候动态做。这样既保证了效率,又保证了增强的随机性。
DataLoader 这边有几个参数特别关键。batch_size不用多说,根据显存大小来调。shuffle训练时设为 True,验证和测试时设为 False,这个应该都知道。num_workers是很多人容易踩坑的地方,它决定了用几个子进程来加载数据。设得太小,数据加载跟不上 GPU 计算速度,GPU 会空等;设得太大,进程间切换开销大,反而更慢。我的经验是从 4 开始试,逐步增加到 8 或 16,观察 GPU 利用率,找到一个平衡点。pin_memory设为 True 可以加速 CPU 到 GPU 的数据传输,特别是当你用 GPU 训练的时候,这个选项几乎应该总是开着。
from torch.utils.data import Dataset, DataLoader class MyDataset(Dataset): def __init__(self, data_paths, labels, transform=None): self.data_paths = data_paths self.labels = labels self.transform = transform def __len__(self): return len(self.data_paths) def __getitem__(self, idx): # 读取数据 data = load_data(self.data_paths[idx]) label = self.labels[idx] if self.transform: data = self.transform(data) return data, label train_loader = DataLoader( dataset=MyDataset(train_paths, train_labels, transform=train_transform), batch_size=32, shuffle=True, num_workers=8, pin_memory=True, drop_last=True )drop_last=True这个参数值得单独说一下。当你的数据集大小不能被 batch_size 整除时,最后一个 batch 会比其他 batch 小。如果模型里有 BatchNorm 层,一个样本的 batch 会导致统计量估计不准,甚至报错。所以训练时我一般都会设drop_last=True,把最后那个不完整的 batch 丢掉。
2.2 数据增强的实战技巧与常见误区
数据增强是提升模型泛化能力最便宜的手段,没有之一。但增强也不是随便乱加的,用不好反而会伤害模型性能。
以图像任务为例,常用的增强操作包括随机裁剪、随机水平翻转、颜色抖动、随机旋转等。这些操作的组合方式和强度需要根据具体任务来定。比如做猫狗分类,水平翻转完全没问题,因为猫狗左右翻转后还是猫狗。但如果你做的是数字识别,把“6”翻转成“9”就出大问题了。再比如做医学影像分析,颜色抖动可能就不太合适,因为颜色在医学图像里往往有明确的临床意义。
我一般会遵循一个原则:增强操作应该模拟真实世界中可能出现的变换,而不是引入不存在的变换。比如做街景识别,光照变化、角度变化、遮挡都是真实存在的,对应的增强操作就是合理的。但如果你做的是卫星图像分类,把图像随机旋转 90 度可能就不太对,因为卫星图像的朝向是有实际意义的。
还有一个常见的误区是增强强度过大。我见过有人把随机裁剪的比例设得特别激进,一张图裁得只剩一小块,结果模型连主体都看不到了,训练效果自然好不了。增强强度的调整需要看验证集上的表现,如果训练 loss 降得很快但验证 loss 不降反升,很可能是增强太强了,模型学不到有效的特征。
实操心得:数据增强的策略不是一成不变的。我通常会在训练初期用较强的增强,让模型学到更鲁棒的特征,到了训练后期再减弱增强强度,让模型在更接近真实分布的数据上微调。这种“课程学习”的思路在实践中效果不错。
3. 模型定义与训练循环:核心逻辑的搭建
模型定义和训练循环是 PyTorch 项目的心脏部分。这部分代码写得好不好,直接决定了你的项目能不能跑通、跑得快不快、结果好不好。我见过很多人的训练脚本写得又臭又长,几百行挤在一起,出了问题根本没法排查。这一章我会把模型定义和训练循环拆开来讲,把每个环节的关键点和容易出错的地方都点出来。
3.1 nn.Module的继承与forward方法的编写要点
在 PyTorch 里定义模型,标准做法是继承nn.Module,在__init__里定义网络层,在forward里定义前向传播逻辑。这个套路看起来简单,但里面有不少细节值得注意。
首先,所有需要在训练中更新参数的层,比如卷积层、全连接层、BatchNorm 层,都必须定义为nn.Module的属性,这样 PyTorch 才能自动追踪它们的参数。如果你把层定义成局部变量,比如在 forward 里临时创建一个nn.Linear,那它的参数就不会被优化器管理,训练时不会更新。这是一个新手很容易犯的错误。
其次,forward方法里不要写太多复杂的控制逻辑。我见过有人在 forward 里写 if-else 分支,根据输入的形状动态选择不同的层。这种做法虽然灵活,但会让计算图变得复杂,影响推理速度,而且调试起来很麻烦。更好的做法是把不同分支拆成不同的子模块,在 forward 里只做简单的调用。
另外,关于nn.Sequential的使用,我的建议是:简单的线性堆叠可以用,但一旦网络结构有分支、有跳跃连接,就不要硬塞进 Sequential 里了,直接写 forward 更清晰。比如 ResNet 的残差块,用 Sequential 写会非常别扭,不如老老实实写 forward。
import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, 1) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) self.conv2 = nn.Conv2d(out_channels, out_channels, 3, 1, 1) self.bn2 = nn.BatchNorm2d(out_channels) self.downsample = None if stride != 1 or in_channels != out_channels: self.downsample = nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, stride), nn.BatchNorm2d(out_channels) ) def forward(self, x): identity = x out = self.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) if self.downsample is not None: identity = self.downsample(x) out += identity return self.relu(out)这个残差块的例子展示了几个要点:inplace=True的 ReLU 可以节省一点显存;downsample 分支用 1x1 卷积来匹配维度;forward 里的逻辑清晰直白,没有多余的分支判断。
3.2 训练循环的标准化模板与关键细节
训练循环的代码看起来都差不多,但魔鬼藏在细节里。一个标准的训练循环包含以下几个步骤:前向传播、计算损失、反向传播、更新参数、清零梯度。这五步的顺序不能乱,特别是清零梯度这一步,如果忘了做,梯度会累加,训练结果完全不对。
我习惯把训练循环封装成一个函数,接收模型、数据加载器、优化器、损失函数等作为参数,这样训练和验证可以复用同一套逻辑。下面是我常用的模板:
def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss = 0 correct = 0 total = 0 for inputs, targets in dataloader: inputs, targets = inputs.to(device), targets.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() total_loss += loss.item() * inputs.size(0) _, predicted = outputs.max(1) total += targets.size(0) correct += predicted.eq(targets).sum().item() avg_loss = total_loss / total accuracy = correct / total return avg_loss, accuracy这里有几个细节值得展开说。model.train()和model.eval()的切换非常重要,它会影响 Dropout 和 BatchNorm 的行为。训练时必须调model.train(),验证和推理时必须调model.eval(),否则结果会不稳定。loss.item()取的是标量值,不会保留计算图,避免显存泄漏。optimizer.zero_grad()放在前向传播之前还是之后都可以,但一定要在loss.backward()之前,我习惯放在最前面,不容易忘。
还有一个容易被忽略的点是学习率调度。我一般会在训练循环外面套一个 lr_scheduler,比如 CosineAnnealingLR 或者 StepLR,每个 epoch 结束后更新学习率。学习率调度对最终性能的影响有时候比模型结构还大,值得花时间调一调。
注意:如果你在训练过程中发现 loss 突然变成 NaN,大概率是学习率太大了,或者数据里有异常值。先把学习率调小一个数量级试试,如果还不行,检查一下数据预处理有没有问题。
4. 训练调优与问题排查:从能跑到跑得好
代码能跑通只是第一步,让模型真正训得好才是重头戏。这部分我踩过的坑最多,也积累了不少经验。从学习率的设置到过拟合的处理,再到各种莫名其妙的报错,这一章我会把常见问题和解决方案都整理出来。
4.1 学习率、批次大小与优化器的选择逻辑
学习率是训练中最重要的超参数,没有之一。学习率太大,loss 震荡不收敛;学习率太小,收敛速度慢得让人抓狂。我一般会先用一个中等偏大的学习率跑几个 epoch,观察 loss 曲线,如果 loss 下降很快但后面开始震荡,说明学习率偏大;如果 loss 几乎不降,说明学习率偏小。
批次大小和学习率是联动的。一般来说,批次大小翻倍,学习率也可以相应增大,但增大的幅度不是线性的。经验法则是批次大小乘以 k,学习率乘以 sqrt(k)。比如 batch_size 从 32 增加到 128,学习率可以乘以 2 左右。但这个规则不是绝对的,还需要根据具体任务调整。
优化器的选择上,Adam 和 SGD 是最常用的两个。Adam 收敛快,对学习率不那么敏感,适合快速实验和大多数任务。SGD 收敛慢,但最终性能往往更好,特别是在图像分类任务上。我的习惯是先用 Adam 快速验证模型结构和数据 pipeline 有没有问题,确认没问题后再换 SGD 精调。SGD 需要配合动量(momentum)使用,一般设 0.9,加上权重衰减(weight decay)来正则化,通常设 1e-4 到 5e-4。
| 优化器 | 适用场景 | 学习率建议 | 特点 |
|---|---|---|---|
| SGD | 图像分类、精调 | 0.01-0.1 | 收敛慢但最终性能好 |
| Adam | 快速实验、NLP任务 | 1e-4-1e-3 | 收敛快、对学习率不敏感 |
| AdamW | Transformer类模型 | 1e-4-5e-4 | 正确的权重衰减实现 |
| RMSprop | RNN类模型 | 1e-4-1e-3 | 自适应学习率 |
4.2 过拟合与欠拟合的判断与应对策略
过拟合和欠拟合是训练中最常见的两个问题,判断起来其实不难:看训练 loss 和验证 loss 的走势。训练 loss 持续下降但验证 loss 开始上升,就是过拟合;两个 loss 都降不下去,就是欠拟合。
过拟合的应对手段比较多。最直接的是增加数据量,但很多时候数据就那么多,没法加。那就用数据增强,这个前面讲过了。还可以加正则化,比如 L2 正则(在优化器里设 weight decay)、Dropout 层、早停(early stopping)。早停是我最常用的手段,设定一个 patience 值,比如 10 个 epoch,如果验证 loss 连续 10 个 epoch 没有改善就停止训练,保存验证 loss 最低的那个 checkpoint。
欠拟合相对少见,但也不是没有。如果模型在训练集上都学不好,说明模型容量不够或者训练不够充分。可以试试加深网络、加宽网络、增加训练轮数、调大学习率。还有一种可能是数据预处理有问题,比如归一化参数不对,导致输入分布异常,模型很难学到东西。
实操心得:我习惯在训练脚本里加一个自动保存最佳模型的逻辑,每次验证 loss 创新低就保存一次 checkpoint。这样即使训练中途出问题,也不会丢掉最好的结果。另外,训练日志一定要记全,包括每个 epoch 的训练 loss、验证 loss、学习率、准确率等,后面分析问题的时候全靠这些数据。
4.3 常见报错与排查思路速查
PyTorch 的报错信息有时候确实不太友好,但大部分常见错误都有固定的排查思路。我整理了一个速查表,遇到问题可以先对照着看。
| 报错信息 | 可能原因 | 解决方法 |
|---|---|---|
| CUDA out of memory | 显存不够 | 减小 batch_size、用梯度累积、清理缓存 |
| RuntimeError: expected scalar type | 数据类型不匹配 | 检查输入和模型参数类型是否一致 |
| size mismatch | 维度不匹配 | 检查全连接层输入维度、卷积层通道数 |
| loss is nan | 学习率过大或数据异常 | 降低学习率、检查数据是否有 nan/inf |
| DataLoader worker exited | 子进程崩溃 | 减小 num_workers、检查数据读取代码 |
| device-side assert triggered | 索引越界或标签超范围 | 检查标签范围、类别数设置 |
CUDA out of memory 是我遇到最多的报错。除了减小 batch_size,还可以用torch.cuda.empty_cache()手动清理缓存,或者在验证阶段用with torch.no_grad():来减少显存占用。梯度累积也是个好办法,用小的 batch_size 跑多次前向传播再一起更新参数,效果等价于大 batch。
device-side assert triggered这个报错特别隐蔽,它通常是因为标签值超出了类别数范围,或者索引越界。比如你的模型输出 10 类,但标签里有个 11,就会触发这个错误。排查的时候可以在报错前打印一下标签的最大值和最小值,确认范围是否正确。
5. 模型保存、加载与部署上线的实操细节
训练完了模型,接下来就是保存和加载。这部分看起来简单,但里面也有不少门道。我见过有人保存了模型但加载不回来,或者加载回来结果对不上,都是因为没搞清楚保存的到底是什么。
5.1 state_dict与整个模型的保存差异
PyTorch 保存模型有两种方式:保存整个模型和只保存 state_dict。保存整个模型用torch.save(model, path),加载用torch.load(path)。这种方式看起来方便,但有几个问题:一是保存的文件比较大,包含了模型结构信息;二是如果模型类定义变了,加载会失败;三是跨设备加载可能出问题。
所以我强烈建议只保存 state_dict,也就是torch.save(model.state_dict(), path)。state_dict 就是一个字典,键是层的名字,值是对应的参数张量。加载的时候先实例化模型,再model.load_state_dict(torch.load(path))。这种方式更灵活,文件更小,也不依赖模型类的具体实现。
# 保存 torch.save({ 'epoch': epoch, 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), 'best_loss': best_loss, }, 'checkpoint.pth') # 加载 checkpoint = torch.load('checkpoint.pth', map_location='cpu') model = MyModel() model.load_state_dict(checkpoint['model_state_dict']) optimizer.load_state_dict(checkpoint['optimizer_state_dict']) start_epoch = checkpoint['epoch'] + 1保存的时候把 epoch、optimizer 状态、最佳 loss 都存进去,这样断点续训的时候可以完整恢复训练状态。map_location='cpu'这个参数在跨设备加载时很有用,比如你在 GPU 上训练的模型要在 CPU 机器上加载,不加这个参数会报错。
5.2 推理阶段的性能优化与注意事项
模型训练好之后,推理阶段的性能也很重要。有几个简单的优化手段可以显著提升推理速度。
首先是model.eval()和torch.no_grad()的配合使用。model.eval()切换 Dropout 和 BatchNorm 到推理模式,torch.no_grad()关闭梯度计算,减少显存占用和计算量。这两个操作在推理时几乎应该总是同时出现。
其次是考虑用torch.jit.trace或torch.jit.script把模型转成 TorchScript 格式。TorchScript 可以对模型进行图优化,去掉 Python 解释器的开销,推理速度能提升不少。特别是部署到生产环境的时候,TorchScript 几乎是标配。
另外,如果推理的 batch size 是固定的,可以试试torch.backends.cudnn.benchmark = True,让 cuDNN 自动选择最快的卷积算法。但这个选项在输入尺寸变化时会重新搜索算法,反而变慢,所以只适合固定输入尺寸的场景。
注意:推理时一定要确认输入数据的预处理和训练时完全一致。我见过有人训练时用了归一化,推理时忘了做,结果模型输出完全不对。这种低级错误排查起来特别费时间,最好在代码里把预处理逻辑封装成一个函数,训练和推理共用。
6. 项目实战中的经验沉淀与避坑指南
前面几章把 PyTorch 项目的各个环节都过了一遍,这一章我想聊一些更宏观的东西,是我这些年做项目积累下来的一些经验和教训。这些东西可能不会直接帮你解决某个具体的报错,但能让你少走很多弯路。
6.1 实验管理与结果复现的关键习惯
做深度学习实验,最怕的就是“这个结果是怎么跑出来的,我忘了”。我早期做实验的时候没有记录习惯,过了一个月回头看之前的实验,完全想不起来用了什么超参数、什么数据增强策略,只能重新跑一遍,浪费了大量时间。
后来我强制自己养成了几个习惯。第一,所有实验都用配置文件管理超参数,配置文件跟着代码一起提交到版本控制里。第二,每次实验的日志、模型权重、配置文件都存到一个独立的目录里,目录名包含时间戳和实验描述。第三,用一个简单的表格记录每次实验的关键信息:实验编号、日期、主要改动、验证集指标、备注。这个表格看起来不起眼,但当你做了几十次实验之后,它能帮你快速定位到最好的那个配置。
随机种子的设置也是复现的关键。PyTorch 的随机性来源有好几个:权重初始化、数据打乱、Dropout、数据增强等。要完全复现结果,需要设置torch.manual_seed、torch.cuda.manual_seed_all、numpy.random.seed、random.seed,还要设torch.backends.cudnn.deterministic = True。但要注意,开启 deterministic 模式会牺牲一些性能,所以只在需要严格复现的时候开。
6.2 从实验代码到生产代码的过渡经验
实验代码和生产代码的要求完全不同。实验代码追求快速验证想法,可以写得比较随意;生产代码追求稳定、高效、可维护,需要考虑异常处理、日志记录、性能优化、接口设计等。
从实验到生产,我一般会做这几件事。第一,把模型定义和推理逻辑从训练脚本里抽出来,做成独立的模块,方便被其他服务调用。第二,加上输入校验和异常处理,防止脏数据导致服务崩溃。第三,用 TorchScript 或 ONNX 导出模型,减少对 Python 环境的依赖。第四,做性能测试,确认推理延迟和吞吐量满足要求。第五,加上监控和日志,方便线上排查问题。
这个过程不是一蹴而就的,我一般会先保证功能正确,再逐步优化性能和稳定性。不要一上来就追求完美,先把东西跑起来,再慢慢打磨。
6.3 那些文档里不会写的踩坑记录
最后分享几个我在实际项目中踩过的坑,都是文档里不会写但实际很常见的问题。
第一个坑是 DataLoader 的 num_workers 在 Windows 上的问题。Windows 下多进程的实现方式和 Linux 不同,num_workers 大于 0 时可能会报错或者卡死。如果你在 Windows 上开发,建议先把 num_workers 设为 0,确认代码没问题后再考虑要不要开多进程。
第二个坑是 BatchNorm 在 batch_size 很小时的表現。BatchNorm 依赖 batch 内的统计量,如果 batch_size 太小(比如 1 或 2),统计量估计非常不准,模型性能会大幅下降。这种情况下可以考虑用 GroupNorm 或者 LayerNorm 替代。
第三个坑是模型保存时忘了把模型移到 CPU。如果你在 GPU 上训练,保存的时候模型参数还在 GPU 上,加载到没有 GPU 的机器上就会报错。养成保存前先model.cpu()的习惯,或者加载时用map_location参数。
第四个坑是学习率调度器的 step 调用时机。有些调度器是每个 epoch 调一次,有些是每个 batch 调一次,用错了会导致学习率变化不符合预期。用之前一定要看清楚文档,确认调用频率。
这些坑看起来都是小问题,但每一个都曾经让我花了好几个小时去排查。希望我的这些经验能帮你节省一些时间,把精力集中在真正重要的事情上——模型设计和业务价值的实现。