ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch深度学习入门实战:从环境搭建到模型训练完整指南

PyTorch深度学习入门实战:从环境搭建到模型训练完整指南

1. 从一张白纸到能跑通第一个模型:PyTorch 到底解决了什么问题

如果你刚接触深度学习,打开搜索引擎输入“PyTorch”,大概率会被一堆术语砸晕:张量、自动求导、计算图、动态图、GPU 加速、分布式训练……每个词都认识,连在一起就不知道在说什么。我刚开始学的时候也一样,装完环境跑通第一个import torch就卡了半天,更别提理解它为什么能成为学术界和工业界的主流框架之一。

PyTorch 本质上是一个基于 Python 的深度学习框架,核心能力可以拆成三件事:第一,它提供了一个叫张量(Tensor)的数据结构,你可以把它理解成“能跑在 GPU 上的多维数组”,和 NumPy 的 ndarray 很像,但多了自动求导和硬件加速;第二,它有一套自动微分系统,你只需要写前向计算,反向传播的梯度它帮你算;第三,它提供了神经网络模块(torch.nn)和优化器(torch.optim),让你用搭积木的方式拼出模型并训练。

这套东西解决的核心问题是:让研究者把精力放在模型结构设计上,而不是手写梯度、手动管理内存、手动做并行。在 PyTorch 出现之前,很多人用静态图框架,你得先定义一张完整的计算图,再喂数据进去跑,调试的时候非常痛苦——想看中间某个变量的值?不行,图还没执行。PyTorch 的动态图机制让计算图在运行时才构建,你可以像写普通 Python 代码一样写模型,用print调试,用pdb断点,这对新手和研究者来说友好太多了。

这篇文章适合谁看?如果你是完全没碰过 PyTorch 的新手,我会从环境搭建、张量操作、自动求导、模型定义、训练循环一路讲到底,每一步都有可复现的代码;如果你已经用过一段时间,但总觉得“能跑但不知道为什么这么写”,我会在关键节点解释设计动机和常见坑,比如requires_grad什么时候该开、optimizer.zero_grad()为什么必须调、model.train()和model.eval()到底改了什么。整篇内容基于我自己的实操经验,不是官方文档的翻译,而是“一个踩过坑的人告诉你路怎么走”。

2. 环境搭建与第一个张量:别在第一步就劝退

2.1 安装 PyTorch 的正确姿势

很多人装 PyTorch 的第一反应是pip install pytorch,然后发现装了个寂寞——包名根本不叫这个。正确的包名是torch,而且安装命令要根据你的硬件环境(有没有 NVIDIA 显卡、CUDA 版本多少)来选择。官方推荐去 pytorch.org 的 Get Started 页面勾选配置,它会生成对应的命令。我实测下来,最稳妥的方式是用 conda 创建独立环境,避免和系统 Python 打架:

conda create -n torch-env python=3.10 conda activate torch-env # 以 CUDA 11.8 为例,具体命令以官网为准 pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118

如果你没有独立显卡,或者只是想先跑通 CPU 版本,直接pip install torch torchvision torchaudio就行。这里有个新手常踩的坑:装完之后import torch报错说找不到 DLL 或者版本不兼容,大概率是 Python 版本太新或太旧。我试过 Python 3.12 刚出的时候,某些 PyTorch 版本还没适配,换成 3.10 就正常了。所以如果你不是追求最新特性,Python 3.9 到 3.11 是比较稳的区间。

验证安装是否成功,跑这几行:

import torch print(torch.__version__) print(torch.cuda.is_available()) # 有显卡且驱动正常应返回 True print(torch.cuda.device_count()) # 显卡数量

如果cuda.is_available()返回 False 但你确实有显卡,先检查显卡驱动版本是否满足 CUDA 要求,再检查装的 PyTorch 是不是 CPU 版本。这两个原因占了九成以上的情况。

2.2 张量:PyTorch 世界的“万能容器”

张量是 PyTorch 里最基础的数据结构。你可以把它想成一个“可以住在 GPU 上的 NumPy 数组”,但功能更强。创建张量的方式很多,我列几个最常用的:

import torch # 从 Python 列表创建 a = torch.tensor([[1, 2], [3, 4]]) # 全零、全一、随机 b = torch.zeros(2, 3) c = torch.ones(2, 3) d = torch.randn(2, 3) # 标准正态分布 # 从 NumPy 转换 import numpy as np e = torch.from_numpy(np.array([1, 2, 3]))

张量有几个关键属性你需要时刻关注:shape(形状)、dtype(数据类型)、device(在 CPU 还是 GPU 上)。形状不匹配是新手遇到最多的报错来源,比如你定义了一个输入维度是 784 的全连接层,结果喂进去的数据是[batch, 28, 28],就会报维度错误。解决办法是用x.view(-1, 784)或者x.flatten(1)把图像展平。

设备管理也是个大坑。如果你把模型放到了 GPU 上(model.cuda()),但输入数据还在 CPU 上,就会报“expected all tensors on same device”。我的习惯是在训练循环开头就把数据搬到模型所在的设备:

device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) # 训练时 inputs = inputs.to(device) labels = labels.to(device)

注意:torch.tensor()和torch.Tensor()不一样。前者根据输入推断 dtype,后者永远返回 float32 且会把整数转成浮点。新手容易混用导致类型错误,建议统一用torch.tensor()。

2.3 自动求导:PyTorch 最核心的魔法

自动求导是 PyTorch 的看家本领。你只需要在创建张量时设置requires_grad=True,之后所有基于它的计算都会被记录,调用.backward()就能自动算出梯度。举个例子:

x = torch.tensor([2.0, 3.0], requires_grad=True) y = x ** 2 + 3 * x + 1 z = y.sum() z.backward() print(x.grad) # dz/dx = 2x + 3 = [7, 9]

这里x.grad就是梯度。关键点:每次反向传播前要清零梯度,否则梯度会累加。这就是为什么训练循环里必须写optimizer.zero_grad()。我见过有人忘了写,结果 loss 不降反升,排查半天才发现是梯度累积导致的。

还有一个容易混淆的概念:torch.no_grad()。在推理或评估阶段,你不需要计算梯度,用这个上下文管理器可以节省显存并加速:

with torch.no_grad(): output = model(input)

如果你在评估时忘了加这个,显存占用会飙升,batch size 稍微大一点就 OOM(显存溢出)。这是实测下来最常见的显存问题来源之一。

3. 从零搭建一个神经网络:模块、层与损失函数

3.1 nn.Module:一切模型的基类

PyTorch 里所有神经网络都继承自nn.Module。你至少需要实现两个方法:__init__里定义层,forward里定义数据怎么流。看一个最简单的全连接网络:

import torch.nn as nn class SimpleNet(nn.Module): def __init__(self): super().__init__() self.fc1 = nn.Linear(784, 128) self.relu = nn.ReLU() self.fc2 = nn.Linear(128, 10) def forward(self, x): x = self.fc1(x) x = self.relu(x) x = self.fc2(x) return x

这里有几个设计细节值得说。第一,super().__init__()必须调,否则 PyTorch 无法正确注册子模块,后面model.parameters()会拿不到参数。第二,层定义在__init__里,计算逻辑在forward里,不要反过来。第三,forward方法不要手动调用,而是用model(x),因为__call__里还做了 hook 等额外处理。

nn.Sequential是另一种更简洁的写法,适合层与层之间是简单串联的情况:

model = nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 10) )

但如果你需要跳跃连接、多输入多输出,还是得用nn.Module自定义。我个人的习惯是:原型阶段用 Sequential 快速验证,正式项目用 Module 保证灵活性。

3.2 损失函数与优化器:训练的两条腿

损失函数衡量模型输出和真实标签的差距,优化器根据梯度更新参数。分类任务最常用交叉熵损失:

criterion = nn.CrossEntropyLoss()

注意CrossEntropyLoss内部已经包含了 Softmax,所以你的模型最后一层不要加 Softmax,直接输出 logits 就行。我见过有人在模型里加了 Softmax,又用 CrossEntropyLoss,结果 loss 一直很高,因为相当于做了两次 Softmax。

优化器方面,Adam 是默认首选,学习率 1e-3 起步:

optimizer = torch.optim.Adam(model.parameters(), lr=1e-3)

如果你追求更好的泛化性能,可以试试 SGD + Momentum,但需要更细致地调学习率。实测经验:Adam 收敛快但有时最终精度不如调好的 SGD;SGD 对学习率敏感,但配合学习率衰减往往能取得更好的结果。新手建议先用 Adam 跑通,再尝试 SGD。

优化器适用场景学习率建议特点
SGD图像分类、追求极致精度0.01~0.1需调参,泛化好
Adam大多数任务、快速原型1e-3~1e-4收敛快,省心
AdamWTransformer 类模型1e-4~5e-5权重衰减更合理
RMSpropRNN、强化学习1e-3对非平稳目标适应好

3.3 数据加载:Dataset 与 DataLoader

PyTorch 的数据加载体系围绕两个类:Dataset和DataLoader。Dataset负责“一条数据怎么读”,DataLoader负责“怎么批量、怎么打乱、怎么并行加载”。自定义 Dataset 需要实现__len__和__getitem__:

from torch.utils.data import Dataset, DataLoader class MyDataset(Dataset): def __init__(self, data, labels): self.data = data self.labels = labels def __len__(self): return len(self.data) def __getitem__(self, idx): return self.data[idx], self.labels[idx] loader = DataLoader(MyDataset(X, y), batch_size=32, shuffle=True, num_workers=4)

num_workers控制并行加载的进程数。注意:在 Windows 上num_workers > 0有时会报错,需要把训练代码放在if __name__ == "__main__":下面。另外num_workers不是越大越好,一般设成 CPU 核心数即可,设太大反而因为进程切换开销导致变慢。

4. 完整训练流程:从数据到模型的每一步

4.1 训练循环的标准骨架

一个完整的训练循环包含五步:前向传播、计算损失、梯度清零、反向传播、参数更新。写成代码就是:

for epoch in range(num_epochs): model.train() for inputs, labels in train_loader: inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() # 验证阶段 model.eval() with torch.no_grad(): for inputs, labels in val_loader: inputs, labels = inputs.to(device), labels.to(device) outputs = model(inputs) # 计算准确率等指标

顺序很重要:zero_grad()必须在backward()之前,step()必须在backward()之后。我见过有人把zero_grad()放在step()后面,结果第一个 batch 的梯度被清零了但后续 batch 的梯度一直在累加,训练完全乱套。

model.train()和model.eval()的区别在于 BatchNorm 和 Dropout 的行为。训练时 BatchNorm 用当前 batch 的统计量,Dropout 随机丢弃神经元;评估时 BatchNorm 用滑动平均的统计量,Dropout 不生效。如果你忘了切换模式,评估结果会波动很大,这是非常隐蔽的 bug。

4.2 学习率调度与早停

学习率太大导致 loss 震荡不收敛,太小导致收敛太慢。实践中常用学习率衰减策略:

scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1) # 每个 epoch 后调用 scheduler.step()

StepLR每 10 个 epoch 把学习率乘以 0.1。还有CosineAnnealingLR、ReduceLROnPlateau等,后者根据验证集指标自动调整,更智能但需要传入指标:

scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode='min', patience=3) scheduler.step(val_loss)

早停(Early Stopping)是防止过拟合的简单有效手段:如果验证集 loss 连续 N 个 epoch 不下降,就停止训练。我一般设 patience=5,配合保存最佳模型权重:

best_loss = float('inf') patience_counter = 0 if val_loss < best_loss: best_loss = val_loss torch.save(model.state_dict(), 'best_model.pth') patience_counter = 0 else: patience_counter += 1 if patience_counter >= 5: print("Early stopping") break

4.3 模型保存与加载的两种方式

PyTorch 保存模型有两种方式:保存整个模型和只保存参数。推荐只保存state_dict,因为保存整个模型依赖具体的类定义,换环境容易出问题:

# 保存 torch.save(model.state_dict(), 'model.pth') # 加载 model = SimpleNet() # 需要先实例化同样的结构 model.load_state_dict(torch.load('model.pth')) model.eval()

加载时如果报“Missing key(s) in state_dict”,说明模型结构和保存时不一致;如果报“Unexpected key(s)”,说明多了参数。常见原因是保存时用了DataParallel或DistributedDataParallel,key 前面多了module.前缀。解决办法是用model.module.state_dict()保存,或者加载时去掉前缀。

提示:torch.load()在 PyTorch 2.6 之后默认weights_only=True,如果你保存的不只是权重(比如还存了 optimizer 状态),加载时需要显式设置weights_only=False,但要确保文件来源可信。

5. 常见报错与排查技巧实录

5.1 显存溢出(OOM)的排查思路

OOM 是训练深度学习模型最常见的报错。排查顺序如下:第一,检查 batch size 是不是太大,减半试试;第二,检查是否在评估时忘了torch.no_grad();第三,检查是否有中间变量一直挂在计算图上没释放,比如在循环里累加了 loss 但没 detach:

total_loss += loss # 错误:loss 还挂在计算图上 total_loss += loss.item() # 正确:取出标量值

第四,用torch.cuda.empty_cache()清空缓存,但这不是根本解决办法。第五,如果模型本身太大,可以考虑梯度累积(小 batch 多次前向再更新一次)或混合精度训练(torch.cuda.amp)。

混合精度训练是我强烈推荐的一个技巧,用autocast和GradScaler可以在几乎不影响精度的情况下把显存占用降低 30%~50%,训练速度也能提升:

scaler = torch.cuda.amp.GradScaler() with torch.cuda.amp.autocast(): outputs = model(inputs) loss = criterion(outputs, labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()

5.2 梯度消失与梯度爆炸

梯度消失表现为底层参数几乎不更新,loss 下降极慢;梯度爆炸表现为 loss 变成 NaN。排查方法:打印每层的梯度范数:

for name, param in model.named_parameters(): if param.grad is not None: print(name, param.grad.norm().item())

如果梯度范数接近 0,说明梯度消失,可以考虑用 ReLU 替代 Sigmoid、加 BatchNorm、用残差连接。如果梯度范数非常大(比如几百几千),说明梯度爆炸,用梯度裁剪:

torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

这行代码放在loss.backward()之后、optimizer.step()之前。max_norm 一般设 1.0 到 5.0 之间,我常用 1.0。

5.3 常见报错速查表

报错信息可能原因解决办法
RuntimeError: expected all tensors on same device数据和模型不在同一设备用.to(device)统一
RuntimeError: size mismatch层输入输出维度不匹配检查nn.Linear的 in_features
RuntimeError: element 0 of tensors does not require grad输入没有设置 requires_grad检查输入张量或模型参数
CUDA out of memory显存不足减小 batch size、用 no_grad、混合精度
loss is nan学习率太大或梯度爆炸降低学习率、梯度裁剪
ValueError: Expected more than 1 value per channelBatchNorm 遇到 batch size 为 1增大 batch size 或用 GroupNorm

5.4 几个我踩过的坑

第一个坑:在__init__里用列表存层。比如self.layers = [nn.Linear(10, 10) for _ in range(3)],这样 PyTorch 不会注册这些层,model.parameters()拿不到它们,优化器就不会更新。正确做法是用nn.ModuleList。

第二个坑:忘记optimizer.zero_grad()。前面提过,但真的太常见了,再强调一遍。

第三个坑:在 DataLoader 的__getitem__里做太重的预处理。数据加载是瓶颈时,GPU 利用率会很低。解决办法是把预处理放在__init__里预计算,或者用num_workers并行加载。

第四个坑:随机种子没固定。如果你在复现实验,一定要在开头设置:

import random import numpy as np torch.manual_seed(42) np.random.seed(42) random.seed(42) torch.cuda.manual_seed_all(42)

否则每次跑的结果都不一样,根本没法对比。

6. 从能跑到跑得好:一些进阶建议

6.1 用 TensorBoard 可视化训练过程

光看 loss 数字不够直观,用 TensorBoard 可以看到 loss 曲线、准确率曲线、权重分布等:

from torch.utils.tensorboard import SummaryWriter writer = SummaryWriter('runs/experiment1') writer.add_scalar('Loss/train', loss.item(), global_step) writer.add_scalar('Loss/val', val_loss, epoch) writer.close()

然后命令行运行tensorboard --logdir=runs,浏览器打开就能看。这个工具帮我发现过很多问题,比如训练 loss 正常下降但验证 loss 很早就开始上升,说明过拟合了,需要加正则化或早停。

6.2 迁移学习:站在巨人肩膀上

从零训练一个模型往往需要大量数据和算力。实际项目中更常见的是用预训练模型微调:

import torchvision.models as models model = models.resnet18(pretrained=True) # 冻结前面的层 for param in model.parameters(): param.requires_grad = False # 替换最后一层 model.fc = nn.Linear(model.fc.in_features, num_classes)

只训练最后一层,几分钟就能得到一个不错的结果。如果数据量还可以,可以解冻后面几层一起微调,学习率设小一点(比如 1e-4)。

6.3 关于调试的个人体会

PyTorch 的动态图特性让调试变得非常方便。我的习惯是先用一个极小的数据集(比如 10 条数据)跑通整个流程,确认没有维度错误、设备错误、梯度问题,再换全量数据。如果直接上全量数据,一个 epoch 跑半小时才发现报错,时间全浪费了。

另外,torch.autograd.set_detect_anomaly(True)可以在反向传播时检测异常,帮你定位是哪个操作产生了 NaN 梯度。虽然会拖慢速度,但排查问题时非常有用。

最后再分享一个小技巧:如果你不确定某个操作的输出形状,直接在 Python 交互环境里试一下,比查文档快得多。PyTorch 的 API 设计很一致,试几次就能摸清规律。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进