刚把Pytorch的张量操作和自动求导学会,很多人会卡在同一个地方:看文档都能看懂,一碰到自己的数据,怎么就不知道从哪下手了。数据怎么读进来、模型怎么搭、训练代码怎么组织,这些零零散散的碎片在第一讲里各讲各的,真要把它们串起来跑一个完整的训练流程,中间全是细节。这一讲我就把这几个核心环节一次讲透——数据管道、模型构建、训练循环,外加一堆文档里不会写清楚的坑。
这一讲适合已经知道tensor基本操作、理解requires_grad和反向传播的读者。如果你还是纯零基础,建议先把张量创建、形状变换、基础运算过一遍再来看,效果会好很多。内容覆盖了从数据进内存到模型出指标的完整链路,看完你就能照着搭出自己的训练脚本。
1. 先别急着写模型,把数据流想清楚
很多初学者有个共同习惯:拿到数据先看模型怎么设计。这个顺序其实是反的。模型设计要跟着数据走,数据长什么样、一次喂多大、要不要做增强,这些决定了模型的输入维度和训练节奏。我把这套流程叫作“数据管道设计”,它是整个训练工程的根基。
1.1 数据从硬盘到显存,中间发生了什么
一条训练数据从磁盘上的文件变成GPU里的一个小批量张量,至少要经过三步:读取原始数据、做预处理、拼装成batch。看起来很简单,但一旦数据量大起来,每个环节都可能变成性能瓶颈。
举个实际场景:一个图像分类项目,两万张图片,每张训练前要做随机裁剪、翻转、归一化。如果用最笨的办法——每次迭代都从硬盘读原图、现场做全套预处理——一次epoch就要做两万次重复的磁盘IO和图像解码,训练速度直接被拖垮。数据管道要解决的就是这件事:把“读数据”和“训练”解耦,让数据读取和预处理尽量高效、可复用。
在Pytorch里,torch.utils.data.Dataset和torch.utils.data.DataLoader就是这套机制的官方实现。前者负责定义“如何取到一条样本”,后者负责“如何高效地把样本组织成batch并送进训练循环”。你会发现这个设计把数据逻辑和训练逻辑彻底分开,代码结构会瞬间清晰很多。
1.2 Dataset三个方法,缺一个都跑不起来
自定义Dataset类需要继承torch.utils.data.Dataset,并实现三个方法:__init__、__len__和__getitem__。代码长这样:
from torch.utils.data import Dataset from PIL import Image import os class ImageDataset(Dataset): def __init__(self, image_dir, transform=None): self.image_paths = [os.path.join(image_dir, f) for f in os.listdir(image_dir)] self.transform = transform def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image = Image.open(self.image_paths[idx]).convert("RGB") if self.transform: image = self.transform(image) return image, 0 # 占位标签,实际项目中换成语义标签这里有个非常关键的实践原则:__init__里只记录路径和配置,绝对不要去读图片数据。有人图省事,在初始化时把全部图片一次性读进内存,小数据集看着没问题,数据集一上万直接内存爆炸。正确做法是在__getitem__里去读,这样每次只加载当前需要的样本。
__getitem__才是真正干活的函数:按索引取一张图,做预处理,返回一个样本。Pytorch的DataLoader会自动调用它来填充batch,所以我们只需要保证“给一个索引,返回一条样本”,其余交给框架。这个“按需读取”的设计在工程上有重要意义,它让大数据集的内存占用保持恒定,不会随着数据量增长而增长。
注意:
__getitem__可以返回任意结构,常见有(x, y)元组、字典、命名元组。返回什么,训练循环里接收到的就是什么,保持两者一致就行。
1.3 DataLoader这些参数,每个都得懂
Dataset定义了怎么取单条数据,DataLoader则负责把这些单条数据拼成一个小批量,还顺带解决了乱序和并行读取的问题。先看一个标准用法:
from torch.utils.data import DataLoader dataloader = DataLoader( dataset, batch_size=32, shuffle=True, num_workers=4, drop_last=True, pin_memory=True )这些参数逐一解释一下:
batch_size是每次迭代取多少条样本,它直接决定一个batch张量的形状,也是显存占用的主要因素。shuffle=True会在每个epoch开始时把数据打乱顺序,这对训练非常重要——如果数据天然有顺序(比如按类别排序),不打乱的话模型会在每个batch里只看到同一类样本,梯度更新方向来回震荡,收敛很慢。
num_workers控制用几个子进程去并行读取数据。这个参数特别容易踩坑:在Windows上如果num_workers大于0,代码必须放在if __name__ == "__main__":保护块里,否则多进程会反复递归执行整个脚本,直接报错或者卡死。建议Windows用户先用0跑通,再逐步调大。
drop_last=True的含义是当最后剩余不足一个batch时,直接丢弃。比如数据有1000条,batch_size=32,最后剩8条,不够一个batch就丢了。不丢的话,最后一个batch形状和其他batch不一致,有些模型结构会出问题;丢了则每个batch形状完全一致,训练更稳定。pin_memory是给GPU训练用的,把数据放在锁页内存中,向显存拷贝速度会快一些,建议做GPU训练时打开。
关于shuffle还有个小细节:验证集和测试集的DataLoader应当设置shuffle=False,因为评估时不需要扰动样本顺序,而且有些指标计算(比如时间序列)要求顺序一致。
2. 模型构建:nn.Module不只是个容器
数据管道准备好以后,下一步就是构建模型。Pytorch的模型基类是torch.nn.Module,整个深度学习模型的构建方式都是围绕它展开的。但很多教程只讲了“继承Module然后定义forward”,至于它背后的机制、为什么这么设计、有哪些隐性要求,基本没讲。
2.1 __init__和forward各司其职
先看一个常见的模型定义:
import torch import torch.nn as nn class MyNet(nn.Module): def __init__(self): super().__init__() self.conv1 = nn.Conv2d(3, 16, kernel_size=3, padding=1) self.conv2 = nn.Conv2d(16, 32, kernel_size=3, padding=1) self.fc = nn.Linear(32 * 8 * 8, 10) def forward(self, x): x = torch.relu(self.conv1(x)) x = torch.relu(self.conv2(x)) x = x.view(x.size(0), -1) x = self.fc(x) return x__init__里做的事情是“定义有哪些子模块”,也就是搭建网络结构;forward里做的是“数据怎么流过这些子模块”,也就是计算逻辑。
这个设计的精妙之处在于,Module基类会通过Python的属性机制自动收集所有子模块。只要把带参数的层(Conv2d、Linear、BatchNorm2d等)赋值为self的属性,它们就会被自动注册到模型的参数表里。不需要手动维护参数列表,model.parameters()就能递归拿到所有层的参数,这让反向传播和优化器更新变得特别方便。
重要:如果某个层没有赋值给
self,比如只是用了局部变量conv = nn.Conv2d(...)而没有self.conv = conv,这个层的参数就不会被自动注册。优化器遍历model.parameters()时根本看不到它,训练时它的权重永远不会更新。这种“模型能跑但loss不降”之类的问题非常隐蔽,排查起来很费时间。
2.2 子模块嵌套与参数递归收集
实际项目中很少只有一个模块,通常是大的模型包含多个子模块,子模块又包含更小的模块。Module的参数收集是递归的,也就是说父模块的parameters()会自动包含所有子模块的参数。
基于这个机制,可以把网络拆分成多个模块文件来组织。比如把主干网络和分类头分开定义,不同的子模块用不同的优化器参数,这在迁移学习里非常有用:冻结主干网络的参数,只训练分类头,只需要把主干模块的所有参数requires_grad设为False即可。
class Backbone(nn.Module): def __init__(self): super().__init__() self.features = nn.Sequential(...) def forward(self, x): return self.features(x) class Classifier(nn.Module): def __init__(self, in_features, num_classes): super().__init__() self.head = nn.Linear(in_features, num_classes) def forward(self, x): return self.head(x) class WholeModel(nn.Module): def __init__(self): super().__init__() self.backbone = Backbone() self.classifier = Classifier(in_features=512, num_classes=10) def forward(self, x): return self.classifier(self.backbone(x))forward里还有一点需要特别注意:Pytorch在反向传播时会追踪张量的操作图,但如果你在forward里做了x += 1这种原地操作,在某些场景下会破坏计算图,导致反向传播出错或者梯度计算错误。经验法则是:尽量避免对需要梯度的张量做原地操作,需要修改时用x = x + 1这种方式重新赋值。
2.3 权重初始化的“三分地”要自己翻
Pytorch的层在创建时有自己的默认初始化规则,比如Linear默认使用Kaiming均匀初始化。但有些情况下默认初始化不够用,比如想用Xavier初始化、或者对某些特殊结构的层设置不同的初始范围,就得手动覆盖。
手动初始化的标准做法是用apply方法递归遍历所有子模块,对每个模块根据类型做不同处理:
def init_weights(module): if isinstance(module, nn.Conv2d): nn.init.kaiming_normal_(module.weight, mode="fan_out", nonlinearity="relu") if module.bias is not None: nn.init.constant_(module.bias, 0) elif isinstance(module, nn.Linear): nn.init.xavier_normal_(module.weight) nn.init.constant_(module.bias, 0) model.apply(init_weights)这个apply就是“对模型里每个模块都调用一次传入的函数”,搭配isinstance做类型判断,想怎么初始化就怎么初始化。关于初始化有几句经验:ReLU类的激活配合Kaiming初始化是默认组合;tanh或sigmoid配合Xavier初始化更稳;BatchNorm的weight初始化为1、bias初始化为0比较合理。初始化是深度学习里“失之毫厘谬以千里”的典型代表,同样的结构不同的初始化,收敛速度可能差好几倍。
3. 训练循环的骨架,把所有零件串起来
数据管道有了,模型也有了,但这两者本身不会自动产生训练效果,还得写一个训练循环把它们串起来。这看起来是机械性的重复代码,但里面每个细节都有讲究,顺序写错了训练就毁了。
3.1 一个最小可用的训练循环
下面是一个可以拿来就用的标准训练骨架:
model = MyNet() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() for epoch in range(10): model.train() total_loss = 0.0 for batch_idx, (inputs, labels) in enumerate(train_loader): optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() total_loss += loss.item() print(f"Epoch {epoch+1}, Loss: {total_loss / len(train_loader):.4f}")每行都有作用,顺序不能乱:
model.train()切换训练模式,这个会影响BatchNorm和Dropout的行为。训练模式下BatchNorm会使用当前batch的统计量,Dropout随机失活;评估模式下两者行为都变了。训练循环开始前必须调用一次。
optimizer.zero_grad()把上一轮迭代积累的梯度清零。很多人第一次写代码会漏掉这一行,结果就是梯度一直在累加,loss一会大一会小,训练完全不稳定。这里要理解一个关键点:Pytorch的梯度是累加的,不会在每个step自动清零,所以必须在反向传播之前手动清零。如果你有特殊需求想累积梯度做更大的有效batch,可以通过控制zero_grad的调用时机来实现,不过普通项目用不上。
loss.backward()计算当前batch的梯度,optimizer.step()按梯度更新模型参数。这两个是一对,顺序不可颠倒,先算梯度再更新参数,这是反向传播的基本逻辑。
loss.item()是从包含梯度的loss张量中取出纯数字,用于打印和记录。注意不要直接print(loss),因为那样会把整个计算图也打出来,浪费内存也会刷屏。
3.2 损失函数与优化器的选型经验
损失函数的选择要跟任务匹配:分类任务用CrossEntropyLoss,它已经把log_softmax和NLLLoss合在了一起,所以模型输出不需要再手动过Softmax,直接传原始logits即可。回归任务用MSELoss或L1Loss;多标签分类用BCEWithLogitsLoss。
这里有个新手常见误区:用了CrossEntropyLoss还在模型最后加一个nn.Softmax层,然后计算损失时又传softmax后的输出。CrossEntropyLoss内部自带softmax计算,你再多做一次等于重复操作,而且梯度传播会有问题。如果确实想要概率输出,在推理阶段单独用torch.softmax(outputs, dim=1),不要影响训练用的输出。
优化器的选择上,我的经验是这样的:刚开始做项目,直接上Adam,默认学习率1e-3,收敛快、调参压力小,适合快速验证想法。等一切跑通了、想刷更高精度,再换成SGD加Momentum(比如momentum=0.9),配一个合适的学习率调度器,效果往往比Adam更好。SGD对学习率更敏感,但泛化表现通常比Adam好一些,这在图像分类任务里反复验证过。
学习率是这个环节最值得花时间调的超参数。常见的错误是学习率过大,loss在初期下降后迅速变成NaN;学习率过小,loss下降极其缓慢,甚至看起来完全不动。我的排查技巧是:先用一个很小的数据集,手动跑到过拟合,验证代码逻辑没问题;再放大数据集调学习率。如果发现loss在早期就爆了,优先把学习率除以10重试。
3.3 验证模式:model.eval()与torch.no_grad()要配对
训练集loss下降不能代表模型好,必须看验证集的表现。验证流程的框架是这样的:
model.eval() total_correct = 0 total_samples = 0 with torch.no_grad(): for inputs, labels in val_loader: outputs = model(inputs) predictions = outputs.argmax(dim=1) total_correct += (predictions == labels).sum().item() total_samples += labels.size(0) accuracy = total_correct / total_samples这里有两个关键操作,缺一不可:
model.eval()切换模型到评估模式。BatchNorm层会使用训练阶段保存的全局统计量而不是当前batch的统计量,Dropout层会停止失活。如果你不调这一行,模型在验证时输出会不稳定,因为Dropout还在随机丢弃神经元,同样的输入两次输出不一样。
torch.no_grad()关闭梯度记录。验证时我们不需要反向传播,而默认情况下每个张量操作都会生成计算图,会占用大量显存。验证集一般数据量不小,不关的话显存很容易爆。这个上下文管理器是验证推理时的标准配置。
3.4 模型保存:不是什么都该存
训练那么多epoch,最后一定要把模型保存下来。我先说一个最常见的错误:直接保存整个模型对象。torch.save(model, "model.pth")虽然能用,但会把模型定义的类结构也序列化进去,换环境或改代码后很容易反序列化失败。
正确做法是保存state_dict,它只包含所有参数的数值,不依赖代码结构:
torch.save(model.state_dict(), "best_model.pth")加载时先创建模型实例再加载参数:
model = MyNet() model.load_state_dict(torch.load("best_model.pth"))训练中还要定期保存checkpoint,不只是模型参数,还需要保存优化器状态、当前epoch、最佳指标。这样训练中断时可以无缝恢复,不丢失进度:
checkpoint = { "model_state_dict": model.state_dict(), "optimizer_state_dict": optimizer.state_dict(), "epoch": epoch, "best_accuracy": best_accuracy } torch.save(checkpoint, f"checkpoint_epoch_{epoch}.pth")恢复训练时用load_state_dict依次加载模型和优化器状态,优化器状态包含动量等累计信息,不加载的话恢复后动量会重置,相当于白跑了前几轮。另外,我习惯在保存checkpoint时把学习率调度器的状态也存进去,否则恢复后学习率也会跳回初始值,导致训练节奏被打乱。
4. 训练过程中最常见的五种翻车现场
训练跑起来只是第一步,真正的考验在于遇到问题能不能快速定位解决。Pytorch的报错信息相对友好,但对新人来说还是很容易被一堆traceback吓到。我把实际项目里最常见的几类问题集中整理一下,都是高频踩坑点。
4.1 维度不匹配是第一大坑
最典型的报错长这样:“mat1 and mat2 shapes cannot be multiplied (32x6272 and 1568x10)”。
出现这个说明Linear层的输入维度和上一个卷积层输出的实际维度对不上。通常是因为卷积层的kernel size、padding、stride组合变了,或者输入图片尺寸和预期不同。排查思路是:在forward里临时打印每层输出形状,把x = torch.relu(self.conv2(x))后面加一行print(x.shape),看实际是多少维,再相应改Linear层的输入维度。
这种问题出现的根源是“手动计算张量形状”,极其容易出错。推荐做法是先用一个假数据跑一次前向,看能否顺利通过,确认维度无误再大规模训练。不需要在代码里写死某个维度计算,用x.view(x.size(0), -1)来展平除了batch维之外的所有维度,比手动算乘法更稳妥。
4.2 训练速度慢,先查数据加载
模型结构没问题、loss也在下降,但训练速度就是提不起来,这种时候八成是数据管道的瓶颈。简单判断方法:打开任务管理器观察CPU和GPU利用率。如果GPU利用率低而CPU跑满,说明CPU处理数据的速度跟不上GPU消费数据的速度。
此时优先调大num_workers,比如从0改成4、8。但要注意:worker数不是越大越好,太多会引入进程切换开销,反而变慢。还有个常见问题:__getitem__里做的预处理太耗时,比如大量的数据增强操作放在这里,每个样本都做,每epoch重复做。可以考虑把重计算量的预处理提前做一次,把结果存成缓存,训练时只做轻量增强。
如果你发现num_workers调大后没啥效果,检查一下是不是每个batch的数据处理里存在同步操作,比如用了torch.serialization或共享内存相关操作,这类操作会阻塞多进程并行,降低数据加载效率。
4.3 显存溢出OOM的救急三板斧
训练途中突然抛“CUDA out of memory”是每个训练过深模型的人都会遇到的事。我的救急顺序是:先减batch_size,比如从32减到16;再检查有没有张量一直累积——最常见的是把每个epoch的loss列表用list存储且不清理,或者循环里用了outputs = model(inputs)但每次都把inputs和outputs的引用保存下来,没及时释放;最后才考虑换更大的显卡或上混合精度。
另一类OOM的隐蔽原因是:验证阶段忘记torch.no_grad(),推理时同样把计算图完整构建出来,显存瞬间翻倍。确保验证和测试都在no_grad上下文中执行,这类问题就能规避。
注意:不要养成“OOM就重启程序”的习惯,先搞清楚是瞬时峰值还是累积泄漏。用
torch.cuda.memory_allocated()和torch.cuda.empty_cache()辅助诊断,前者看当前分配,后者清空缓存碎片。
4.4 训练集loss降,验证集不降,这是过拟合的早期信号
如果训练集loss持续下降,但验证集loss在某个点后开始回升或者不降,说明模型开始过拟合训练数据。这个问题的处理手段按优先级来:
第一,增加数据增强,随机裁剪、翻转、色彩抖动等,给模型提供多样化的样本;第二,调大weight_decay,它本质是L2正则化,在Adam里对应weight_decay参数,一般从1e-4开始调;第三,增加Dropout比例或插入Dropout层。
一个工程上的排查技巧是:先把train_loader和val_loader里的数据都设为同一个来源,跑一次验证集,如果验证集loss和训练集loss同步下降,说明代码没问题,过拟合是真实存在的;如果验证集loss很快就飞了,那可能是验证集构建有问题,比如分布不一致或者数据泄漏。这个对照实验帮我排掉过大量没必要的折腾。
4.5 loss突然变成NaN,梯度问题别慌
loss在训练过程中突然变成NaN,最容易让人恐慌。可能的原因有几类:学习率过大导致梯度爆炸;数据里有NaN值或异常值;损失计算里出现了除零;模型存在数值不稳定操作(比如对0取log)。
第一步把学习率降低到原来的十分之一再跑,多数情况下能恢复稳定。如果还不行,检查输入数据里有没有NaN或无穷大,比如图像转tensor时归一化出错、标签里有奇怪的负数。最后检查一下模型结构和损失函数是否匹配,比如回归问题用了CrossEntropyLoss,肯定会出现数值不稳定。
还有一种常见情况:使用了torch.clamp或F.relu6这类截断函数时,梯度被截断成0,某些层参数长期不更新,加上量化误差累积,也会出现NaN。发现后把这些操作替换成更平稳的版本,训练稳定性会好很多。
5. 实操中的几个独门建议
最后这部分是我自己平时写训练代码时积累的习惯,不一定在官方文档里能看到,但相当实用。
一开始不要追求大模型、大数据集。先用一个小数据集(几百条样本)跑一个小的模型,目标是让训练代码完整跑通——包括数据加载、前向、反向、优化器step、验证、模型保存。这条链路通了你再去加数据量和模型复杂度。很多人一上来就套大模型,结果维度不匹配、显存OOM、loss不降一堆问题同时爆发,根本分不清哪里的问题。小规模先跑通是最高效的策略。
关于学习率这个超参数,我可以给出一个比较稳妥的设置参考:Adam优化器初始学习率用1e-3,如果发现loss震荡,改成1e-4;SGD加Momentum初始学习率用1e-2,配合学习率衰减。学习率从大到小做个5次实验,画一下loss曲线,比盲目用默认值靠谱得多。
训练脚本里的中间结果一定要经常保存。我的习惯是每个epoch保存一次checkpoint,每次覆盖旧的即可,磁盘开销不大但心里有底。万一训练中断,马上从上一次checkpoint恢复,省去从头再来的时间。保存的时候把模型、优化器、学习率调度器、epoch、最好指标的state全部放进一个字典里,恢复训练就是无脑加载。
最后说一个关于调试的经验:如果模型输出和预期完全不符,比如loss一直在一个值附近震荡,不要只盯着训练代码看,先检查你的数据本身。把一批__getitem__的返回结果打印出来看看,确认数据标签对不对、预处理有没有错误。数据问题导致的模型异常,比模型结构问题更常见,也更容易被忽视。打印数据和打印loss,是两种成本最低的调试手段,先把这两个用熟练,再去想复杂的排查方案。
这一讲的内容基本上覆盖了用Pytorch做一次完整训练所需要掌握的核心环节。数据管道、模型定义、训练循环、故障排查,每一条链路都通了之后,剩下的就是多跑、多调、多积累经验。代码只是工具,思路和判断力才是真正值钱的东西。