很多人在我后台问得最多的一个问题,不是某个API怎么用,而是“我到底该怎么完整地训练一个模型?”市面上的教程实在太多了,但大多都是碎片化的——讲环境搭建的不讲数据怎么喂,讲模型构建的不讲loss怎么调,就算运气好找到一篇讲训练循环的,也没人告诉你模型保存和重新加载时还有一堆坑。我自己当初学PyTorch的时候也是这么磕磕绊绊走过来的,所以特别理解这种“知道零件长什么样、但装不出一台整机”的挫败感。
这篇文章就从零开始,把PyTorch模型训练的完整主流程串成一条线:环境准备、数据管线、模型定义、训练循环、验证与保存、加载与恢复,再到真实训练中一定会遇到的翻车现场。文末附上的都是我自己调试时踩过的实坑,希望能帮你少走点弯路。这篇主要面向刚入门、想自己跑通一个完整训练任务的读者,有了一定经验的人也可以直接跳到第7章节去对照排查。
1. 先建个全景图再动手:训练一个模型到底要经过哪几步
模型训练不是搭积木,更像是组一条流水线。你脑子里得先装一张完整的流程图,再逐个环节去填细节,否则很容易出现“模型定义好了,但数据格式对不上”或者“loss都打印出来了,结果发现根本没在更新参数”这种诡异问题。
一条最基础的PyTorch训练流水线,拆开来看其实只有六个环节:
- 数据准备:拿原始数据,做清洗和预处理,转成
Tensor格式。 - 数据装载:用
Dataset和DataLoader把数据组织成可迭代的批次。 - 模型构建:定义一个
nn.Module子类,设计网络结构。 - 训练循环:迭代数据批次,计算损失,反向传播,更新参数。
- 验证评估:在验证集上检查模型泛化程度,顺便决定要不要保存。
- 保存与加载:把权重落盘,下次再加载回来继续训练或做推理。
这六步之间是强依赖的,前一步输出什么类型、什么形状,后一步就得按这个约定来接。比如Dataset的__getitem__返回的样本是(图片, 标签),那DataLoader吐出来的就是形状为(batch, channel, height, width)的四维张量加一个标签向量;你定义模型时conv2d的输入通道数就得和图片通道数一致。错一步都跑不通。
为了不让这篇文章变成空谈,我会用一个CIFAR-10图像分类任务作为贯穿示例。选它有三个原因:第一,数据集调用内置接口就能拿到,省去自己写下载脚本的麻烦;第二,图像是四维张量,最容易暴露维度不对、device不一致这种新手高频问题;第三,10分类任务足够让一个实用性的模型准确率稳定收敛,不至于训练半天一无所获。
提示:如果你是做NLP或结构化数据的,整体流程完全一致,只需替换数据预处理和模型输入层即可。图像分类的难点恰恰覆盖了流程里80%的通用痛点。
动手之前还有几个决策点是必须定的:
- 用GPU还是CPU训练?决定环境怎么安装、
device怎么指定。 - 任务类型是分类、回归还是生成?决定损失函数选
CrossEntropyLoss还是MSELoss。 - 数据量大概多大、要不要做数据增强?决定
DataLoader的batch_size和num_workers的设置有偏向。
这几个问题想清楚,后面每一步都是水到渠成。下面我们从环境这一关开始说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 装环境这一关:CUDA、conda、镜像源,别在这些地方浪费一天
我见过太多人卡在最开始的环境安装上,一装就是一整天。倒不是有多难,而是被“选哪个安装命令”“为什么下载这么慢”“到底装没装上GPU版”这几个问题反复折磨。我直接把这部分的避坑逻辑讲透。
2.1 先搞清楚你要装的是CPU版还是GPU版
pip install torch 默认给你装的其实是CPU版,这在早期的PyTorch版本里是个大坑。你满心欢喜跑起训练,发现loss在动,但速度慢得像蜗牛,这时候你并不知道自己根本没有调用GPU。
判断方法很简单,在Python里跑一下:
python复制import torch
print(torch.__version__) # 版本号
print(torch.cuda.is_available()) # True就说明CUDA可用了
print(torch.cuda.get_device_name(0)) # 能看到你的显卡型号
torch.cuda.is_available()返回False,那就说明你装成了CPU版。GPU版安装思路我在2.2里说。
2.2 显卡驱动和CUDA版本的关系
你要区分两个东西:一个是驱动命令nvidia-smi里显示的CUDA版本,另一个是PyTorch运行时实际使用的CUDA运行库版本。很多人误会了这一点,以为驱动显示的CUDA是12.4,就必须装CUDA 12.4的PyTorch,其实不是。
驱动显示的CUDA版本代表“你的显卡驱动最高能兼容到什么版本”,驱动是向下兼容的。也就是说,只要PyTorch要求的CUDA版本不高于驱动支持的最高版本,就能正常用。更稳妥的做法是:
- 打开命令行输入
nvidia-smi,先看右上角的CUDA Version,例如12.1或11.8。 - 去PyTorch官网的Get Started页面,选择你的系统、安装工具(pip/conda)、CUDA版本。
- 复制官方生成的那条安装命令执行。
以CUDA 11.8和pip为例,命令长这样:
bash复制pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118
cu118就是CUDA 11.8对应的wheels目录。如果是CUDA 12.x,通常直接找cu121或cu124这些目录就行。不要手动去NVIDIA官网装CUDA Toolkit,除非你有明确的编译需求,PyTorch的wheel包里已经打包了它需要的CUDA运行库,这也是新手最容易多走的一步。
2.3 下载慢怎么办:用镜像源
如果直连PyTorch官方源下载慢到令人绝望,这是正常的,别硬等。换成国内镜像源可以快很多,我这里用清华源举例:
bash复制pip install torch torchvision -i https://pypi.tuna.tsinghua.edu.cn/simple
注意一点:通过镜像源安装时,pip默认会安装该镜像上最新的CPU版或通用版torch,不一定带CUDA支持。如果你要GPU版,更推荐先用官方--index-url下载,或者保持耐心开下载工具。如果你在Anaconda环境下安装,也可以配置conda镜像源,但我的经验是pip走镜像的成功率高很多。
2.4 用conda还是venv建环境
我个人的习惯是:数据科学相关的项目一律用Anaconda或Miniconda,因为它建环境方便、切换干净。新手最容易犯的错误是直接在base环境里装各种包,装到后面版本冲突把自己搞崩。强烈建议每个项目建独立环境:
bash复制conda create -n torch_env python=3.10
conda activate torch_env
然后再用上面说的pip命令在激活后的环境里安装PyTorch。以后发现环境坏了,直接conda remove -n torch_env --all重建,几分钟就能恢复。
注意:AMD显卡的用户,如果没有NVIDIA卡,那
torch.cuda.is_available()永远会是False,这是正常的。你只能走CPU版,或者用PyTorch的ROCm支持分支(但在Windows上体验一般)。别为这事纠结太多天,小规模实验CPU也能跑,只是慢一些。
3. 数据管线:把原始数据变成模型认识的张量
环境装好了,接下来做的事情就是把数据变成模型能吃的东西。PyTorch里这套标准的组织方式非常固定,理解它之后,换成任何数据集都只是改改预处理逻辑的问题。
3.1 Dataset与DataLoader的分工
我习惯把这两者分开理解:Dataset负责“一个样本怎么读”,DataLoader负责“一批样本怎么取”。
Dataset需要实现三个方法:
__init__:初始化路径、读取标签、设置transform。__len__:返回数据集总长度。__getitem__:根据索引idx返回一个样本(data, label)。
以CIFAR-10为例,官方早就把数据集接口封装好了,但你自定义数据集时还是要亲手写。一个自定义图像数据集的典型写法如下:
python复制from torch.utils.data import Dataset, DataLoader
from PIL import Image
import os
class MyImageDataset(Dataset):
def __init__(self, img_dir, label_file, transform=None):
self.img_dir = img_dir
self.transform = transform
self.samples = [] # [(图片路径, 标签), ...]
# 假设label_file是CSV格式:文件名,类别ID
with open(label_file, "r") as f:
for line in f:
filename, label = line.strip().split(",")
self.samples.append((os.path.join(img_dir, filename), int(label)))
def __len__(self):
return len(self.samples)
def __getitem__(self, idx):
img_path, label = self.samples[idx]
image = Image.open(img_path).convert("RGB")
if self.transform:
image = self.transform(image)
return image, label
3.2 transform预处理才是真正决定模型能不能收敛的关键
图像数据进来是PIL对象,模型吃的是张量。所以transform里至少要做三件事:缩放尺寸、转成Tensor、做归一化。ToTensor()会把HWC的0~255整数像素转成CHW的0~1浮点数。归一化通常用均值0.5、标准差0.5,或者使用ImageNet的均值和标准差。
python复制from torchvision import transforms
transform_train = transforms.Compose([
transforms.RandomCrop(32, padding=4),
transforms.RandomHorizontalFlip(),
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)),
])
transform_val = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)),
])
训练集加了随机裁剪和水平翻转,这叫数据增强,目的是让模型看到更多样的输入,降低过拟合。验证集绝对不加随机增强,只用同样的归一化,否则指标就会虚高或者波动。
3.3 训练集/验证集划分
数据拿到之后,先划分再加载。以前手动切分数据集的人很多,但你有了Dataset之后直接交给torch.utils.data.random_split更省事:
python复制from torch.utils.data import random_split
dataset = MyImageDataset(...)
train_size = int(0.8 * len(dataset))
val_size = len(dataset) - train_size
train_dataset, val_dataset = random_split(dataset, [train_size, val_size])
3.4 DataLoader的参数不是随便填的
DataLoader最核心的参数有四个:batch_size、shuffle、num_workers和pin_memory。
python复制train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=2, pin_memory=True)
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, num_workers=2, pin_memory=True)
训练集要shuffle=True,因为每个epoch打乱样本顺序能避免模型记住样本间的固定排列,尤其前几个样本如果恰好全是同一类就会影响收敛。验证集不需要shuffle,保证每次评估顺序一致才能稳定横向比较。
num_workers是加载数据的子进程个数。设成0表示主进程同步加载,简单但慢;大于0能加快数据读取,但Windows上如果放在if __name__ == "__main__"之外,多进程会反复创建loader导致报错。最稳的写法是把训练代码放进main()函数,然后if __name__ == "__main__": main()。
pin_memory=True在GPU训练时能把数据放到锁页内存,往GPU拷贝会快一些,内存不太紧张的机器建议开着。
4. 模型定义:从基础模块到可训练实例
数据管线准备好了,接下来就是定义模型。很多人喜欢复制网上大段的模型代码,但我建议至少把nn.Module的工作机制弄明白,因为后面调试报错全都跟它有关。
4.1 nn.Module:两个方法撑起整个模型
任何PyTorch模型都是nn.Module的子类,至少要实现两个方法:
__init__:在这里定义网络的层(卷积、全连接等)。forward:在这里写数据从输入到输出的前向计算逻辑。
比如一个用于CIFAR-10的简单CNN:
python复制import torch
import torch.nn as nn
class SimpleCNN(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1), # 3通道输入,32通道输出
nn.ReLU(inplace=True),
nn.MaxPool2d(2),
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(2),
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.MaxPool2d(2),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(128 * 4 * 4, 256),
nn.ReLU(inplace=True),
nn.Linear(256, num_classes),
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
输入是(batch, 3, 32, 32)的图片。每经过一个MaxPool2d(2),高宽减半:32→16→8→4。所以最后Flatten之后的维度是128 * 4 * 4 = 2048。如果CIFAR-10图片尺寸变了,这里就要跟着改,否则会报线性层输入不匹配。
4.2 定义模型后立刻把device定下来
模型定义好之后,第一步不是训练,而是把模型放到正确的设备上:
python复制device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = SimpleCNN(num_classes=10).to(device)
这里的to(device)会把模型全部参数和缓冲区搬到GPU显存里。经常有人忘记把模型to(device),或者只迁移了模型没迁移数据,训练的时候就会报:
code复制RuntimeError: Expected all tensors to be on the same device
这句话的意思是:模型参数在GPU上,而你的输入数据还在CPU上,两边没法做矩阵乘法。解决办法就是每次拿到data, label之后也执行data, label = data.to(device), label.to(device)。这个习惯要养早。
4.3 权重初始化:不是所有默认初始化都适合
PyTorch的卷积层和线性层默认有初始化方案,常规任务直接训练问题不大。但如果你发现loss收敛很慢,或者训练初期就出现NaN,可以先怀疑初始化的问题。手动初始化其实也就一行:
python复制def init_weights(m):
if isinstance(m, nn.Conv2d) or isinstance(m, nn.Linear):
nn.init.kaiming_normal_(m.weight, mode="fan_out", nonlinearity="relu")
if m.bias is not None:
nn.init.constant_(m.bias, 0)
model.apply(init_weights)
kaiming_normal_是配合ReLU激活函数的常见选择,它能保证前向传播时每一层的输出方差不会逐层扩大或消失。换个角度说,初始化决定了模型在训练初期处于“起跑线”的哪个位置,位置太差后面怎么调学习率都费劲。
5. 训练循环:五个关键角色缺一不可
训练循环是整个流程的核心。无论网络多复杂、数据多花哨,这一步的代码结构都是高度相似的,无非是“前向传播、算loss、清梯度、反向传播、更新参数”这五步。先把标准模板贴出来,再逐个解释每一个细节:
python复制import torch
import torch.nn as nn
import torch.optim as optim
num_epochs = 20
lr = 1e-3
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=lr)
for epoch in range(num_epochs):
model.train()
running_loss = 0.0
for batch_idx, (data, target) in enumerate(train_loader):
data, target = data.to(device), target.to(device)
# 前向传播
output = model(data)
loss = criterion(output, target)
# 反向传播
optimizer.zero_grad() # 先将梯度清零
loss.backward() # 反向传播,计算各参数梯度
optimizer.step() # 根据梯度更新参数
running_loss += loss.item()
avg_loss = running_loss / len(train_loader)
print(f"Epoch [{epoch+1}/{num_epochs}] Loss: {avg_loss:.4f}")
5.1 损失函数:分类和回归的选择完全不同
CrossEntropyLoss是分类任务最常用的损失函数,它会把模型输出的原始logits(未经过softmax的分数)转成概率分布,并计算交叉熵。很多初学者会先在模型最后加一个nn.Softmax,再用nn.CrossEntropyLoss,这是重复操作,反而可能造成数值不稳定。CrossEntropyLoss内部已经包含了softmax操作,所以模型的输出保持logits即可。
回归任务一般用MSELoss(均方误差),衡量预测值和真实值的平方差。选错损失函数是最隐蔽的错误之一——我见过有人拿分类问题硬套MSELoss,训练出来的结果完全不可用,因为回归和分类的优化目标根本不是一回事。
5.2 优化器:SGD和Adam怎么选
SGD加动量是经典选择,适合调参到位的场景;Adam自带了自适应学习率的机制,对不同参数的更新幅度做了归一化,所以上手快、对学习率不敏感,是现在绝大多数任务的首选。
python复制optimizer = optim.SGD(model.parameters(), lr=0.01, momentum=0.9)
# 或者
optimizer = optim.Adam(model.parameters(), lr=1e-3)
如果不知道学率怎么定,直接试1e-3,这是最通用的起点。如果训练loss震荡剧烈,可以调到1e-4;如果loss下降太慢,再尝试1e-2,但别指望一次就调对,多跑几个小epoch观察趋势才有感觉。
5.3 optimizer.zero_grad()放在哪里:容易被忽略却极其关键
必须先解释一下梯度累积的机制。默认情况下,PyTorch的backward()会把计算得到的梯度累加到参数的.grad上,而不是覆盖。这是为了支持梯度累积的用法而设计的。
如果你的训练循环里没有在每次backward()之前调用optimizer.zero_grad(),那第一次的梯度会一直留在参数上,之后每轮迭代的梯度都会叠加上去,参数更新方向会被历史梯度污染得一塌糊涂,loss曲线会非常诡异。
官方推荐optimizer.zero_grad()放在backward()之前,写作:
python复制optimizer.zero_grad()
loss.backward()
optimizer.step()
5.4 scheduler:学习率不是一成不变的
训练后期如果想更精细地逼近最优解,通常需要降低学习率。PyTorch提供了lr_scheduler,最常用的是StepLR和ReduceLROnPlateau。
python复制from torch.optim import lr_scheduler
scheduler = lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)
# 每10个epoch,学习率乘以0.1
for epoch in range(num_epochs):
train_one_epoch()
scheduler.step() # 每个epoch结束后更新学习率
ReduceLROnPlateau更适合“当验证集loss不再下降时再降低学习率”这种动态策略,不过要传验证集指标进去,逻辑更复杂一点。新手先用StepLR感受学习率变化对训练的影响就够了。
5.5 训练进度显示:tqdm和loss记录的两种风格
光靠print打loss也行,但每轮等得心慌。加个进度条体验好很多:
python复制from tqdm import tqdm
for epoch in range(num_epochs):
model.train()
total_loss = 0
loop = tqdm(train_loader, desc=f"Epoch {epoch+1}/{num_epochs}")
for data, target in loop:
data, target = data.to(device), target.to(device)
output = model(data)
loss = criterion(output, target)
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
loop.set_postfix(loss=loss.item())
tqdm的好处是一眼看清每个batch的实时loss、跑完的百分比和预计剩余时间。如果你的训练周期特别长,我建议额外用TensorBoard或wandb把每个epoch的loss曲线记录下来,便于后面复盘调参。
6. 验证、保存与加载:训练完不等于完事
训练循环跑完,模型就算“练出来了”。但练出来之后还有三件事是必须做的:在验证集上评估客观指标、把模型落盘保存、再验证一下能不能加载回来。
6.1 model.eval()和torch.no_grad()两件事别漏
验证时有一个新手极其容易忽略的关键点:必须切到评估模式。model.train()和model.eval()影响的是BatchNorm和Dropout这类在训练和推理时行为不同的层。如果模型里没有这两种层,不切可能不会报错,但一旦模型变复杂,漏了这步验证指标就会有问题。
另一个是torch.no_grad()。它告诉PyTorch“这段代码里不需要计算梯度”,从而大幅减少显存占用和计算量。验证集评估的完整写法:
python复制def evaluate(model, val_loader, criterion, device):
model.eval()
total_loss = 0.0
correct = 0
total = 0
with torch.no_grad():
for data, target in val_loader:
data, target = data.to(device), target.to(device)
output = model(data)
loss = criterion(output, target)
total_loss += loss.item()
_, predicted = torch.max(output, 1)
total += target.size(0)
correct += (predicted == target).sum().item()
avg_loss = total_loss / len(val_loader)
accuracy = 100.0 * correct / total
print(f"Validation Loss: {avg_loss:.4f}, Accuracy: {accuracy:.2f}%")
return avg_loss, accuracy
6.2 保存state_dict而不是整个模型
保存时我强烈推荐只保存model.state_dict(),而不是整个torch.save(model, ...)。
state_dict实际上是一个字典,键是各层名字,值是权重张量。文件小、版本兼容性好。- 保存整个模型会把模型结构定义也一起序列化,但依赖原环境的类定义,换个目录或改个类名就加载不了,容易埋雷。
保存指定epoch的模型:
python复制# 保存checkpoint
torch.save({
'epoch': epoch,
'model_state_dict': model.state_dict(),
'optimizer_state_dict': optimizer.state_dict(),
'loss': avg_loss,
}, "checkpoint.pt")
6.3 加载模型继续训练还是做推理
加载模型分两种情况。第一个是纯推理,只需要权重:
python复制model = SimpleCNN(num_classes=10)
state_dict = torch.load("checkpoint.pt", map_location=device)
model.load_state_dict(state_dict["model_state_dict"])
model.to(device)
model.eval()
第二个是从checkpoint恢复训练。此时除了模型权重,还要恢复优化器状态、epoch数、学习率等信息,否则训练进度对不上:
python复制checkpoint = torch.load("checkpoint.pt", map_location=device)
model.load_state_dict(checkpoint["model_state_dict"])
optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
start_epoch = checkpoint["epoch"] + 1
map_location=device这个参数非常重要。如果你在GPU机器上保存的checkpoint,想在CPU机器上加载,不加map_location就会报一个“GPU设备不存在”的RuntimeError。
6.4 一个容易踩的坑:load_state_dict的key不匹配
当你自定义模型或者把官方预训练模型拿来改结构时,加载权重经常报出:
code复制Missing key(s) in state_dict: ...
Unexpected key(s) in state_dict: ...
意思是权重文件里的层名和你当前模型的层名对不上。这种问题多半是模型结构定义和原模型不一致带来的,比如改了最后全连接层的输出类别数。遇到这种情况,先打印一下两边的key集合,逐项对比,该加strict=False的地方加上,但要在代码里注释清楚为什么忽略哪些层。
python复制model.load_state_dict(checkpoint["model_state_dict"], strict=False)
strict=False允许你只加载一部分权重,比如想用预训练模型的backbone去微调,而分类头参数保持随机初始化,就该用这种方式。
7. 训练中的典型翻车现场:问题排查与调试经验
我把这一节放到最后,因为很多人按流程写完代码,自以为万事大吉,结果一训练就各种报错。这里整理几个我在实际调试中最常遇到的“翻车现场”,每一个都是亲手踩过的坑。
7.1 报错一:Expected all tensors to be on the same device
这可能是新手遇见频率最高的错误。报错信息通常长这样:
code复制RuntimeError: Expected all tensors to be on the same device, found at least two devices, cuda:0 and cpu!
翻译成人话就是:“模型正在GPU计算,但输入数据还在CPU内存。”解决方式极其简单,每次从DataLoader里拿到数据后,第一时间搬到device:
python复制data, target = data.to(device), target.to(device)
我在第4章就说过要养成这个习惯。等你代码越来越多,涉及多个模型或特征张量时,也要留意其他中间张量是否需要用to(device)。排查技巧很简单:报错信息里如果出现“两个设备”,顺着代码往前找哪个张量忘了迁移。
7.2 报错二:维度对不上
code复制RuntimeError: mat1 and mat2 shapes cannot be multiplied (...
这类报错常见于把Flatten层漏了,或者池化后的尺寸算错了。解决办法是先给模型喂一个真实的假数据,输入维度写对了,forward里的各层维度自然能推出来:
python复制# 用随机张量测试模型能否forward
fake_data = torch.randn(1, 3, 32, 32).to(device)
output = model(fake_data)
print(output.shape) # 期望 torch.Size([1, 10])
这一步能加速定位问题,别等到训练开始才发现。
7.3 报错三:loss过了几个epoch还是NaN
如果训练没报错,但loss从一开始就是nan,大概率的锅在“学习率过大”或者“数据里有异常值”。学习率过大时,梯度更新一步跳得太远,参数直接溢出,数值精度就崩了。处理顺序我建议这样来:
- 把学习率降到
1e-4乃至1e-5,看loss是否恢复。 - 检查数据中是否有无穷大或NaN,加一个数值检查:
python复制assert torch.isfinite(data).all()
- 检查是否用错了损失函数,比如分类用了
MSELoss,再叠加不合适的归一化,极容易爆数值。
7.4 训练loss不降的排查链路
loss不降比NaN还让人抓狂,因为这种问题很隐蔽。我的排查顺序是:
- 先看是不是“初始化”在作怪,换个初始化(
kaiming或xavier)跑几个epoch对比。 - 再降低学习率,排除“震荡导致不收敛”的可能。
- 然后检查数据预处理,特别是归一化均值方差是否正确。如果图片像素范围还在0~255,模型输入分布就被拉得很大,激活值很容易饱和,梯度就消失了。
- 接着看看是不是数据顺序问题:没开
shuffle且数据恰好按类别排列,模型会学到“下一类总是xxx”的假规律。 - 最后才怀疑网络结构本身是否有问题,可以先用小规模数据(比如100张)过拟合验证模型有没有学习的潜力。如果100张数据都学不进去,多半是网络定义或前向传播的bug,而不是优化问题。
7.5 过拟合的识别与应对
验证集准确率停在原地,训练集准确率却接近100%,这是过拟合的典型信号。处理手段从简单到复杂排列:
- 增加数据增强(随机裁剪、翻转、色彩抖动)。
- 在模型里加
Dropout层。 - 降低模型复杂度(减少层数或通道数)。
- 加权重衰减,即
optimizer里的weight_decay参数。
python复制optimizer = optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
weight_decay的原理是对大权重施加额外惩罚,迫使模型不要过分依赖某个特征,在视觉任务里通常能脸不红心不跳地涨1~2个点。
7.6 checkpoint保存频率:中断重来是常事
训练周期一长,指不定哪天电脑断电或者进程被杀。我习惯每个epoch结束都保存一次checkpoint,但只保留最近两份,防止磁盘被几十个G的checkpoint塞满:
python复制import glob
import os
def save_checkpoint(state, filename, keep_last=2):
torch.save(state, filename)
checkpoints = sorted(glob.glob("checkpoint_*.pt"))
for old in checkpoints[:-keep_last]:
os.remove(old)
这个习惯在长时间训练里能救你命。半夜训练中断,第二天直接从上一个epoch恢复,而不是从零再来。
8. 把流程跑通之后,你还可以往里加什么
到这里,一条完整的PyTorch训练流程就闭环了:环境、数据、模型、训练、验证、保存、恢复、排查,每一步都有对应的代码和坑。很多人觉得跑通了就完事,其实后面提升的方向也多着呢。
简单列几个可以继续深入的方向:
- 用
TensorBoard记录训练曲线和模型结构图,调参会直观很多。 - 换成更标准的项目结构:把数据加载、模型定义、训练函数、配置拆成独立模块,管理起来不费力。
- 加一个
argparse或配置文件,把学习率、batch_size、epoch数都抽出来,做实验对比时改参数不用再动代码。 - 等模型调得差不多了,再考虑
torch.jit或ONNX导出,把模型部署到生产环境跑推理。
我在实际做实验时的习惯是:先把整条链路用小数据集、小模型快速跑通,确认所有环节没有逻辑错误,再换大数据集和大模型正式开训。这样排查bug的时间能压缩一大半,也不会一上来就被训练时长和显存占满的双重暴击劝退。
最后再分享一个实用小技巧:每次调参前,先固定随机种子,让实验可复现。
python复制import random
import numpy as np
import torch
def set_seed(seed=42):
random.seed(seed)
np.random.seed(seed)
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
深度学习调试过程中,最怕的就是“上周跑出来准确率86%,这周怎么跑都只有83%”,大概率是随机种子没固定。有了这个函数,起码能保证实验对比的前提是一致的。剩余的就是耐心多跑几轮,多观察loss曲线和验证集指标的形态,模型训练这件事,说穿了就是“把流程跑熟+把经验攒厚”。
