简介:本资源是Daniel Voigt Godoy撰写的《Deep Learning with PyTorch Step-by-Step》2022年修订版(v1.1.1)完整PDF教材,专为深度学习零基础初学者设计,尤其适合计算机专业学生、转行AI的开发者及希望系统掌握PyTorch实战能力的学习者。全书以“概念讲解—环境搭建—代码实现—项目驱动”为主线,覆盖神经网络基础、PyTorch张量操作与自动微分、图像分类与NLP实战案例,以及迁移学习、GANs、注意力机制等进阶主题,并配有大量可运行示例与清晰原理图解。资源为单文件PDF格式,共1个文件,大小33.58MB,内容完整、排版规范,开箱即学,无需额外配置。目前已有216人下载学习,适合作为自学主线教材、课程补充读物或项目开发前的技术速查手册,帮助读者建立扎实的PyTorch工程化能力与深度学习思维框架。
1. 这不是又一本“PyTorch速成手册”:它用57个可运行的梯度下降动画,把反向传播从黑匣子变成你手指能调的旋钮
你试过在训练一个两层全连接网络时,把lr=0.01改成lr=0.001,loss 曲线突然从“稳步下降”变成“爬行三年不收敛”吗?或者更玄学的——明明数据没变、模型没改、代码只差一行model.train()和model.eval(),验证集准确率却凭空掉3个百分点?这些不是玄学,是 PyTorch 的底层机制在你没看见的地方悄悄咬了一口。而《Deep Learning with PyTorch Step-by-Step》(2022 v1.1.1)干了一件很实在的事:它不讲“反向传播是链式法则”,而是用 57 个 Jupyter Notebook 实例,手把手带你画出损失曲面、标出梯度箭头、拖动学习率滑块看参数怎么跳、甚至故意喂进一个“坏特征”让你亲眼看见权重爆炸——所有操作都在一个.ipynb文件里点几下就能复现。它不是教你怎么调参,而是教你怎么“看见”参数怎么动。适合三类人:刚写完第一个nn.Linear(784, 10)却看不懂loss.backward()到底干了啥的新手;被RuntimeError: Trying to backward through the graph a second time卡住一整天、查 Stack Overflow 却越看越懵的转行者;还有那些想给学生讲清楚“为什么 BatchNorm 要放在 ReLU 前面”的一线导师——这本书第 0 章那个三维损失曲面旋转 GIF,比十页公式更有说服力。它不承诺“三天学会 Transformer”,但它保证:当你合上 PDF,再看到torch.no_grad(),脑子里浮现的不再是语法糖,而是一扇正在关闭的计算图门。
2. 从零推导线性回归:用纯 NumPy 复现 PyTorch 的每一步计算流
这本书最硬核的起点,不是import torch,而是第 0 章那个只有 37 行 NumPy 的梯度下降可视化脚本。它不跳步,不封装,不调库函数,就用np.random.randn()初始化、np.dot()做前向、(y_pred - y_true) ** 2算 MSE、np.dot(X.T, y_pred - y_true)手撕梯度——所有数学都摊在你眼皮底下。这不是怀旧,是建立直觉的必经之路。当你亲手写出w = w - lr * grad_w并看着散点图上的拟合线一帧帧挪动时,“优化器更新参数”才真正从概念变成肌肉记忆。而这本书的精妙之处在于:它立刻用 PyTorch 重写同一逻辑,并逐行对比差异。比如 NumPy 版本里你需要手动保存w和b,而 PyTorch 版本里nn.Parameter(torch.randn(1))不仅自动注册为可训练参数,还悄悄挂载到model.parameters()迭代器上——这个“自动注册”背后,是torch.nn.Module的__setattr__魔法方法在起作用。这种对照不是为了炫技,而是帮你定位问题:当你的模型不更新权重时,第一反应不该是查学习率,而是print(list(model.parameters()))看看列表是不是空的。
2.1 用 NumPy 从零实现单变量线性回归:理解梯度下降的物理意义
我们先抛开框架,用最原始的方式跑通一次梯度下降。以下代码完全来自书中 Chapter 0 的gradient_descent_numpy.py,但做了关键注释和参数说明:
import numpy as np import matplotlib.pyplot as plt # 1. 生成合成数据:y = 2x + 1 + noise np.random.seed(42) X = np.random.randn(100, 1) * 10 # 100 个样本,x 范围约 [-30, 30] y_true = 2 * X + 1 + np.random.randn(100, 1) * 5 # 添加高斯噪声 # 2. 初始化参数:w 和 b 都是标量(注意:NumPy 中是 float,不是 tensor) w = np.random.randn() # 随机初始化权重 b = np.random.randn() # 随机初始化偏置 # 3. 设置超参数:这里 lr=0.001 是关键!太大则震荡,太小则不动 lr = 0.001 epochs = 100 # 4. 梯度下降主循环 loss_history = [] for epoch in range(epochs): # Step 1: 前向传播 —— 计算预测值 y_pred = w*x + b y_pred = w * X + b # 注意:X 是 (100,1),广播后 y_pred 也是 (100,1) # Step 2: 计算损失 —— MSE: mean((y_pred - y_true)^2) loss = np.mean((y_pred - y_true) ** 2) loss_history.append(loss) # Step 3: 计算梯度 —— 对 w 和 b 分别求偏导 # ∂L/∂w = (2/N) * Σ(x_i * (y_pred_i - y_true_i)) # ∂L/∂b = (2/N) * Σ(y_pred_i - y_true_i) dw = (2 / len(X)) * np.sum(X * (y_pred - y_true)) db = (2 / len(X)) * np.sum(y_pred - y_true) # Step 4: 更新参数 —— 沿负梯度方向移动 w = w - lr * dw b = b - lr * db print(f"Final w: {w:.4f}, b: {b:.4f}") # 输出应接近 2.0 和 1.0参数说明:
lr=0.001是本书反复强调的“安全起点”。如果你把lr改成0.01,会发现loss在前几轮剧烈震荡甚至发散;改成0.0001,则loss下降缓慢如龟爬。这正是第 0 章用三维曲面图演示的核心——学习率不是调参技巧,而是决定你能否在损失地形上“站稳”的物理属性。dw和db的推导必须手写一遍,因为 PyTorch 的backward()本质就是自动完成这两行计算,只是它用计算图代替了你大脑里的符号微分。
2.2 PyTorch 版本:从 Tensor 到 Autograd 的四层封装解剖
现在,我们用 PyTorch 重写上面的逻辑。注意,这不是简单替换np为torch,而是理解每一层封装的意义:
import torch import torch.nn as nn import torch.optim as optim # 1. 数据转为 Tensor,并指定设备(CPU/GPU) X_torch = torch.from_numpy(X).float() y_torch = torch.from_numpy(y_true).float() # 2. 创建可训练参数:nn.Parameter 自动注册到 model.parameters() w_torch = nn.Parameter(torch.randn(1, requires_grad=True)) b_torch = nn.Parameter(torch.randn(1, requires_grad=True)) # 3. 定义前向函数(无 nn.Module 封装,纯函数式) def forward(x): return x @ w_torch + b_torch # 注意:@ 是矩阵乘,x 是 (100,1),w_torch 是 (1,1) # 4. 设置优化器:SGD 封装了参数更新逻辑 optimizer = optim.SGD([w_torch, b_torch], lr=0.001) # 5. 训练循环(关键:autograd 机制在此处激活) loss_history_torch = [] for epoch in range(100): # Step 1: 前向传播 —— 此时计算图开始构建 y_pred_torch = forward(X_torch) # Step 2: 计算损失 —— loss 是一个 scalar tensor,requires_grad=True loss = nn.MSELoss()(y_pred_torch, y_torch) # Step 3: 反向传播 —— autograd 引擎自动计算所有梯度 # 它会遍历计算图,对每个 requires_grad=True 的参数计算 .grad loss.backward() # ← 这一行触发整个反向传播链 # Step 4: 更新参数 —— 但注意:optimizer.step() 不会清空梯度! optimizer.step() # Step 5: 手动清空梯度 —— 否则下次 backward 会累加! optimizer.zero_grad() # ← 这是新手最大坑点之一 loss_history_torch.append(loss.item()) print(f"PyTorch w: {w_torch.item():.4f}, b: {b_torch.item():.4f}")逻辑说明:这段代码展示了 PyTorch 的四层抽象:
- Tensor 层:
requires_grad=True标记张量参与计算图;- Autograd 层:
loss.backward()触发自动微分,结果存入.grad属性;- Optimizer 层:
optimizer.step()执行param = param - lr * param.grad;- 内存管理层:
optimizer.zero_grad()清空.grad,防止梯度累积。
书中特别强调:zero_grad()必须在每次backward()之前调用,否则梯度会叠加。这是RuntimeError: Trying to backward through the graph a second time的根源——你不是图用错了,是.grad内存没清干净。
2.3 关键对比:NumPy vs PyTorch 的梯度计算路径
| 维度 | NumPy 手动实现 | PyTorch Autograd |
|---|---|---|
| 梯度来源 | 你用纸笔推导公式,再用np.sum()编码 | loss.backward()自动遍历计算图,无需推导 |
| 参数更新 | w = w - lr * dw直接赋值 | optimizer.step()调用param.sub_(lr * param.grad)原地更新 |
| 梯度存储 | dw,db是临时变量,用完即弃 | w_torch.grad,b_torch.grad是 Tensor 属性,持久存在 |
| 错误定位 | dw计算错 → loss 不降 → 查公式 | w_torch.grad为None→requires_grad=False或backward()未调用 |
| 扩展性 | 加一层网络?公式重推、代码重写 | 加nn.Linear(1, 64)→forward()一行,其余不变 |
这个对比不是为了贬低 NumPy,而是告诉你:PyTorch 的“自动”不是魔法,它只是把你在纸上做的微积分,变成了可调试、可断点、可打印的计算图节点。当你print(w_torch.grad)看到一个非零 Tensor 时,你就知道 autograd 已经成功捕获了梯度流——这是调试任何复杂模型的第一道关卡。
3. Autograd 深度解剖:为什么torch.no_grad()不是“关掉梯度”,而是“切掉计算图”
很多教程把torch.no_grad()简单解释为“禁用梯度计算”,这导致大量新手在验证阶段写出这样的代码:
# ❌ 错误示范:no_grad 只包裹 forward,但 loss 仍需梯度 with torch.no_grad(): y_pred = model(x_val) loss = criterion(y_pred, y_val) # ← 这里 loss.requires_grad=True! loss.backward() # ← 报错:试图对 no_grad 区域的输出求导这本书用整整一节(Chapter 1, Section "no_grad")拆解了no_grad的真实作用:它不是开关,而是“计算图剪刀”。当你进入with torch.no_grad():块,PyTorch 会切断所有新创建 Tensor 的grad_fn链,让它们的requires_grad=False,从而让后续的backward()无法回溯。但关键点在于:它不影响已经存在的、requires_grad=True的参数。也就是说,model.parameters()里的权重依然可训练,只是y_pred这个输出 Tensor 断开了与权重的连接。
3.1no_grad的正确用法:验证/推理阶段的内存与速度双优化
以下是书中推荐的标准验证循环模板,它同时解决三个问题:显存溢出、梯度污染、推理加速:
def validate(model, val_loader, criterion, device): model.eval() # ← 设置 model 为 eval 模式(影响 Dropout/BatchNorm) total_loss = 0 correct = 0 total = 0 # ✅ 正确:no_grad 包裹整个验证流程 with torch.no_grad(): # ← 切断所有新 Tensor 的计算图 for x_batch, y_batch in val_loader: x_batch = x_batch.to(device) y_batch = y_batch.to(device) # 前向传播:y_pred 是 requires_grad=False 的 Tensor y_pred = model(x_batch) # ← model 本身参数仍有 grad_fn,但输出被切断 # 计算 loss:由于 y_pred.requires_grad=False,loss.requires_grad=False loss = criterion(y_pred, y_batch) total_loss += loss.item() # 计算准确率:argmax 不需要梯度,纯 CPU 运算更高效 _, predicted = torch.max(y_pred.data, 1) # ← .data 防止意外梯度泄漏 total += y_batch.size(0) correct += (predicted == y_batch).sum().item() avg_loss = total_loss / len(val_loader) acc = 100 * correct / total return avg_loss, acc # 使用示例 val_loss, val_acc = validate(model, val_loader, criterion, device='cuda')参数说明:
y_pred.data是一个历史遗留 API(新版推荐用y_pred.detach()),它的作用是返回一个与y_pred共享内存但requires_grad=False的新 Tensor。书中强调:在no_grad块内,y_pred.data和y_pred.detach()效果一致,但detach()更语义清晰。而model.eval()是另一个独立开关——它不关梯度,只关掉Dropout的随机失活和BatchNorm的统计量更新。这两个开关必须配合使用,否则验证时Dropout仍在随机丢神经元,准确率就会波动。
3.2grad_fn与计算图:用torchviz可视化你的反向传播路径
书中在 Setup Guide 里专门列出GraphViz和Torchviz作为可选依赖,这不是凑数。当你遇到RuntimeError: One of the variables needed for gradient computation has been modified by an inplace operation这类报错时,光看堆栈没用,你需要看到计算图长什么样。以下代码来自书中的visualize_computation_graph.ipynb:
# 安装:pip install torchviz graphviz from torchviz import make_dot # 构建一个简单计算图 x = torch.tensor(2.0, requires_grad=True) y = x ** 2 z = y * 3 # 生成计算图:传入最终输出 z 和输入字典 dot = make_dot(z, params={"x": x}) dot.format = 'png' dot.directory = './graphs' dot.view() # ← 自动生成 graphs/z.png 并打开逻辑说明:这张图会显示
x → pow → y → mul → z的完整链路,每个节点标注了运算类型和 shape。当你把y = x ** 2改成y = x.pow_(2)(inplace 操作),make_dot()会立刻报错,因为pow_()破坏了计算图的完整性。这就是为什么书中反复警告:永远不要在requires_grad=True的 Tensor 上使用_结尾的 inplace 方法(如add_,mul_,relu_)。它们会直接修改内存,导致 autograd 无法追踪原始值。解决方案很简单:用y = x.pow(2)替代x.pow_(2),用F.relu(x)替代x.relu_()。
3.3 常见问题排查:Autograd 的五个血泪坑
现象 → 原因 → 解决,全是实测踩过的坑:
现象:
loss.backward()后,model.weight.grad是None
原因:model.weight的requires_grad=False,或loss是从no_grad块中产生的
解决:print(model.weight.requires_grad)和print(loss.requires_grad),确保两者都为True现象:
loss.backward()报错One of the variables needed... has been modified by an inplace operation
原因:在前向中用了x.add_(1)、x.relu_()等 inplace 操作
解决:全局搜索_,替换成x = x.add(1)、x = F.relu(x)现象:训练 loss 下降,但验证 acc 不升反降,且
model.eval()后 acc 也不对
原因:BatchNorm层在train()模式下用 batch 统计,在eval()模式下用 running_mean/var,但 running 统计未更新
解决:确保训练时model.train()调用足够多 batch(至少 100+),或手动model.bn.running_mean.data.fill_(0)重置现象:
optimizer.step()后,model.weight值不变
原因:optimizer初始化时传入的是model.parameters()的快照,但后续model结构被修改(如动态增删层)
解决:optimizer.param_groups应该始终指向当前model.parameters(),建议每次model结构变更后重建optimizer现象:多 GPU 训练时
loss.backward()报错Expected all tensors to be on the same device
原因:criterion(如nn.CrossEntropyLoss)默认在 CPU,而y_pred在 GPU
解决:criterion = criterion.to(device),或直接criterion = nn.CrossEntropyLoss().cuda()
4. 模型构建实战:从nn.Module到nn.Sequential的三层抽象选择
这本书最务实的地方,是它不鼓吹“必须手写nn.Module”,而是明确告诉你:什么时候该用Sequential,什么时候必须自定义Module,什么时候干脆用函数式 API。它用一个图像分类任务贯穿全书:先是Sequential快速搭出 baseline,再逐步替换成自定义Module加注意力,最后用函数式 API 实现梯度裁剪。这种渐进式重构,比直接甩给你一个 200 行的ResNet类更有教学价值。
4.1nn.Sequential:适合结构固定、无分支、无复用的线性模型
书中 Chapter 1 的 “Putting It All Together” 节给出标准模板:
# ✅ 推荐:用 Sequential 构建标准 CNN backbone model = nn.Sequential( # Block 1 nn.Conv2d(3, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # Block 2 nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # Classifier nn.AdaptiveAvgPool2d((1, 1)), # 自适应池化,兼容不同输入尺寸 nn.Flatten(), # 将 (64,1,1) → (64,) nn.Linear(64, 10) # 10 分类 ) # ✅ 初始化权重:Sequential 本身没有 .apply(),需遍历子模块 def init_weights(m): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out') if m.bias is not None: nn.init.constant_(m.bias, 0) elif isinstance(m, nn.Linear): nn.init.normal_(m.weight, 0, 0.01) nn.init.constant_(m.bias, 0) model.apply(init_weights) # ← 对 Sequential 内所有子模块递归调用参数说明:
nn.AdaptiveAvgPool2d((1,1))是书中强调的“鲁棒性设计”。它不硬编码输入尺寸,而是自动适配任意 H×W 的 feature map,避免因nn.AvgPool2d(4)导致的尺寸不匹配错误。model.apply()是初始化权重的标准方式,它会递归遍历Sequential里的每一层,对Conv2d和Linear分别应用不同的初始化策略——kaiming_normal_专为 ReLU 设计,normal_适合线性分类头。
4.2 自定义nn.Module:当模型需要条件分支、共享权重或复杂前向逻辑
当你需要实现类似 ResNet 的残差连接,或 ViT 的 patch embedding + class token,就必须脱离Sequential。书中 Chapter 2 的ResidualBlock示例堪称教科书:
class ResidualBlock(nn.Module): def __init__(self, in_channels, out_channels, stride=1, downsample=None): super().__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=stride, padding=1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) # ← 书中特别注明:此处 inplace=True 安全! self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.downsample = downsample # ← 可选下采样支路 def forward(self, x): identity = x # ← 保存输入作为 identity # 主支路 out = self.conv1(x) out = self.bn1(out) out = self.relu(out) out = self.conv2(out) out = self.bn2(out) # 残差连接:如果尺寸不匹配,用 downsample 调整 identity if self.downsample is not None: identity = self.downsample(x) out += identity # ← 核心:element-wise add,不引入新参数 out = self.relu(out) return out # 使用:构建一个两层残差网络 downsample = nn.Sequential( nn.Conv2d(3, 64, kernel_size=1, stride=2, bias=False), nn.BatchNorm2d(64) ) block = ResidualBlock(3, 64, stride=2, downsample=downsample)逻辑说明:这个
ResidualBlock展示了自定义Module的三大优势:
- 分支控制:
downsample是可选参数,允许灵活插入下采样;- 权重共享:同一个
block实例可被多次调用,参数自动复用;- 前向逻辑自由:
identity = x和out += identity是纯 Python 逻辑,不受Sequential的线性限制。
书中提醒:nn.ReLU(inplace=True)在此安全,因为out是新计算的 Tensor,不是输入x的别名。但若写成x = self.relu(x),则inplace=True就危险了——这正是inplace操作的黄金法则:只对新创建的中间变量用inplace=True,绝不对其输入变量用。
4.3 函数式 API:当你要精细控制梯度流或实现特殊训练技巧
对于梯度裁剪、混合精度训练、GAN 的交替优化,函数式 API(F.xxx)比nn.Module更直接。书中 Chapter 3 的 GAN 训练循环给出范例:
import torch.nn.functional as F # Discriminator step optimizer_d.zero_grad() real_pred = discriminator(real_images) # real_images: (B,3,64,64) fake_images = generator(noise) fake_pred = discriminator(fake_images.detach()) # ← detach() 切断生成器梯度流 # 计算判别器 loss:real 为 1,fake 为 0 loss_d_real = F.binary_cross_entropy_with_logits(real_pred, torch.ones_like(real_pred)) loss_d_fake = F.binary_cross_entropy_with_logits(fake_pred, torch.zeros_like(fake_pred)) loss_d = loss_d_real + loss_d_fake loss_d.backward() optimizer_d.step() # Generator step:注意 fake_pred 未 detach!要更新生成器 optimizer_g.zero_grad() fake_pred_g = discriminator(fake_images) # ← 重新前向,此时 fake_images 连接生成器 loss_g = F.binary_cross_entropy_with_logits(fake_pred_g, torch.ones_like(fake_pred_g)) loss_g.backward() # ✅ 梯度裁剪:防止生成器梯度爆炸 torch.nn.utils.clip_grad_norm_(generator.parameters(), max_norm=1.0) optimizer_g.step()参数说明:
fake_images.detach()是 GAN 训练的关键。它让判别器只学习区分真假,不反馈给生成器;而discriminator(fake_images)在生成器步骤中不加detach(),则loss_g.backward()会同时更新生成器和判别器的权重——这正是 GAN 的对抗本质。clip_grad_norm_的max_norm=1.0是书中推荐的保守值,它把所有参数的梯度范数缩放到 ≤1.0,避免nan损失。这个值不是越大越好,书中实验表明max_norm=5.0会导致训练不稳定。
5. 训练循环避坑指南:从DataLoader到state_dict的七处致命细节
一个看似简单的训练循环,藏着七处能让模型训废的细节。这本书的 Chapter 1 “Putting It All Together” 节,用 120 行代码把它们全列出来,并附上print()调试语句。我把它浓缩为七条铁律,每一条都对应一个真实翻车现场。
5.1DataLoader的shuffle与drop_last:训练/验证/测试必须差异化设置
# ✅ 训练集:必须 shuffle,且 drop_last=True 防止最后一个 batch size 不同 train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, # ← 训练必须打乱 drop_last=True, # ← 防止最后一个 batch size=12,BN 失效 num_workers=4, pin_memory=True) # ✅ 验证集:shuffle=False,但 drop_last=False(要评估全部样本) val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False, # ← 验证不打乱,方便 debug drop_last=False, # ← 必须评估全部样本,包括不足 64 的 batch num_workers=2) # ✅ 测试集:同验证集,但通常 batch_size 更大(如 128)以加速 test_loader = DataLoader(test_dataset, batch_size=128, shuffle=False, drop_last=False)血泪经验:某次我把
val_loader的drop_last=True,结果验证集只跑了 99 个 batch(缺 1 个),val_acc看起来很高,但上线后效果暴跌——因为那 1% 的难样本全被丢掉了。shuffle=False则让你能print(sample_id)定位具体哪张图预测错。
5.2state_dict的保存与加载:为什么model.load_state_dict()会报 key mismatch
# ✅ 保存:只保存模型权重,不保存优化器(除非要 resume training) torch.save(model.state_dict(), 'best_model.pth') # ✅ 加载:必须先实例化 model,再 load_state_dict model = MyCustomModel() # ← 必须和训练时完全相同的类定义 model.load_state_dict(torch.load('best_model.pth')) # ❌ 常见错误:model 是 Sequential,但保存时是自定义 Module # ❌ 错误:model = nn.Sequential(...) → load_state_dict 会报 key not found # ✅ 正确:统一用自定义 Module,即使结构简单 class SimpleModel(nn.Module): def __init__(self): super().__init__() self.layers = nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 10) ) def forward(self, x): return self.layers(x)避坑逻辑:
state_dict是OrderedDict,key 是layers.0.weight这样的字符串。如果你训练时用Sequential,key 就是0.weight;但加载时用自定义Module,key 就是layers.0.weight,必然 mismatch。书中解决方案:无论多简单,都用自定义nn.Module封装模型。这样state_dict的 key 结构稳定,跨版本迁移也安全。
5.3device的统一管理:为什么model.to(device)后tensor还在 CPU
# ✅ 正确:所有 tensor 显式移到 device device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) for epoch in range(10): for x_batch, y_batch in train_loader: # ✅ 必须手动 move tensor!model.to() 不会自动 move dataloader 的输出 x_batch = x_batch.to(device) # ← 关键! y_batch = y_batch.to(device) # ← 关键! optimizer.zero_grad() y_pred = model(x_batch) loss = criterion(y_pred, y_batch) loss.backward() optimizer.step()血泪经验:某次我忘了
x_batch.to(device),模型在 GPU,数据在 CPU,model(x_batch)直接报错Expected all tensors to be on the same device。更隐蔽的坑是:criterion如果是nn.CrossEntropyLoss(),它内部的weight参数默认在 CPU,所以必须criterion = criterion.to(device)。书中建议:把device作为全局常量,所有to(device)操作都显式写出,不依赖隐式转换。
5.4torch.backends.cudnn.benchmark:开启后训练快 20%,但首次 epoch 会慢 3 倍
# ✅ 推荐:在确定输入尺寸固定后开启 torch.backends.cudnn.benchmark = True # ← 启用 cuDNN 的 auto-tuner # torch.backends.cudnn.deterministic = True # ← 如需可复现,关掉 benchmark # ⚠️ 注意:benchmark 会为每个新尺寸缓存最优算法 # 如果你的 DataLoader 输出尺寸不固定(如 variable-length sequence), # 开启 benchmark 会导致显存暴涨,必须关掉!参数说明:
cudnn.benchmark=True让 cuDNN 在第一次运行卷积时,测试多种算法(FFT、Winograd 等)并缓存最快的那个。这对固定尺寸的图像任务极有效,但对 NLP 的变长序列是灾难——每次新长度都会缓存一个新算法,显存 OOM。书中实验:ResNet50 训练 CIFAR-10,开启后 epoch 时间从 12s 降到 9.5s,但首次 epoch 耗时 35s。所以最佳实践是:在if epoch == 0:之后再设benchmark=True。
5.5torch.set_grad_enabled():比no_grad更细粒度的梯度开关
# ✅ 场景:训练中做梯度惩罚(如梯度反转层 GRL) def train_with_grl(model, data, target, optimizer): optimizer.zero_grad() # 正常前向 features = model.feature_extractor(data) # 提取特征 cls_pred = model.classifier(features) # 分类预测 # 梯度反转:对 features 应用 -1 的梯度缩放 reversed_features = GradReverse.apply(features) # ← 自定义 Function dom_pred = model.domain_classifier(reversed_features) # 域分类 loss_cls = F.cross_entropy(cls_pred, target) loss_dom = F.cross_entropy(dom_pred, domain_labels) loss = loss_cls + 0.5 * loss_dom loss.backward() optimizer.step() # ✅ GradReverse 的实现(书中 Chapter 4) class GradReverse(torch.autograd.Function): @staticmethod def forward(ctx, x): return x.view_as(x) # 直接返回 x @staticmethod def backward(ctx, grad_output): # 反向传播时,梯度乘以 -1 return -grad_output逻辑说明:
torch.set_grad_enabled(True/False)是no_grad的底层 API,它允许你在任意位置开关梯度。GradReverse是一个经典案例:前向时透传,反向时翻转梯度。这在领域自适应(Domain Adaptation)中至关重要。书中强调:自定义Function必须继承torch.autograd.Function,且forward和backward必须是@staticmethod,否则会报RuntimeError: Function's forward method must be static。
6. 从 loss 曲线诊断模型健康
本文还有配套的精品资源,点击获取