简介:这份资源是《Deep Learning with PyTorch》官方样本 PDF,面向希望入门 PyTorch 深度学习实践、但想先试读再决定是否购买完整版的开发者与学习者,帮助读者低成本了解该书的知识框架与讲解风格。资源包内仅含 1 个 PDF 文件,大小约 399KB,轻量便携,适合在电脑或平板上快速翻阅。目前已有 139 人学习下载。样本内容涵盖深度学习模型训练的核心环节,包括训练循环的搭建、训练统计数据的收集、用 tqdm 报告训练进度,以及 DataLoader 与 Dataset 的使用方式;还给出一个回归模型示例项目,从数据集描述、模型构建与训练,到通过数据预处理改善模型效果,并附有进一步阅读与章节小结。读者可借此把握从数据准备到训练评估的完整流程,理解动态计算图与 GPU 加速张量计算的基本用法,为后续系统学习打下基础。
1. 训练循环:为什么你的 PyTorch 模型跑完不收敛,问题往往不在模型结构
很多人第一次用 PyTorch 写训练代码,模型定义得漂漂亮亮,forward里每一层都反复推敲,结果跑完几十个 epoch,loss 曲线像心电图一样上下横跳,或者干脆一条直线纹丝不动。第一反应通常是「网络结构不对」「学习率太大」「数据有问题」,于是开始疯狂改层数、调超参,折腾一整天。但血泪经验告诉我,绝大多数这类翻车现场,根子不在模型,而在那个看起来最不起眼的训练循环——zero_grad忘了调、loss没做item()就累加、model.train()和model.eval()混着用、DataLoader的shuffle没开。这些细节单拎出来都是「会用 PyTorch 的人应该知道」的东西,可它们恰恰是训练能不能 work 的底层开关。这份 PyTorch 深度学习资料里专门用一整章讲训练循环的构成要素、统计量收集和进度报告,说明作者也认同一个判断:训练循环是深度学习项目里最该被吃透、却最容易被跳过的机制。搞懂它,你才知道 loss 为什么这么变、显存为什么这么涨、模型为什么学不动。
2. 训练循环的五个动作:一次参数更新到底发生了什么
2.1 从前向到反向:计算图在背后做了什么
PyTorch 的训练循环表面上是五行代码,底层却是一整套动态计算图的构建与销毁过程。每次output = model(input)执行时,PyTorch 并不是简单地把数字算出来就完事,它同时在后台记录每一个张量操作,把input、权重、偏置、激活值之间的依赖关系串成一张有向无环图。这张图上的每个节点都带着一个grad_fn,指向生成它的那个运算。这就是所谓「动态图」的含义——图不是提前编译好的,而是随着 Python 代码一行行执行实时长出来的。
理解这一点,才能理解为什么loss.backward()能自动算出所有参数的梯度。反向传播时,PyTorch 从loss这个标量节点出发,沿着grad_fn链一路往回走,对每个参与运算的张量应用链式法则,把梯度累加到.grad属性上。这里有个关键设计:梯度是累加而不是覆盖的。也就是说,如果你连续两次调用backward()而不清空梯度,第二次的梯度会叠加到第一次的结果上。这不是 bug,而是有意为之——它让梯度累积(gradient accumulation)这种技巧成为可能,你可以在显存不够时把一个大 batch 拆成几个小 batch 分别前向反向,梯度自然累加,等效于大 batch 训练。
常见的实现思路是,训练循环里每个 step 的标准顺序是:清空梯度、前向传播、计算损失、反向传播、更新参数。这五步的顺序不能乱,尤其是清空梯度必须在反向传播之前。我一般会这么理解:optimizer.zero_grad()是在给这一轮的反向传播「腾地方」,loss.backward()是在「填地方」,optimizer.step()是拿着填好的梯度去「改参数」。三者构成一个闭环,缺一环整个机制就断了。
import torch import torch.nn as nn model = nn.Linear(10, 1) optimizer = torch.optim.SGD(model.parameters(), lr=0.01) criterion = nn.MSELoss() for inputs, targets in dataloader: optimizer.zero_grad() # 清空上一轮累积的梯度 outputs = model(inputs) # 前向:同时构建计算图 loss = criterion(outputs, targets) # 损失是一个带 grad_fn 的标量 loss.backward() # 反向:沿计算图回传,梯度写入 .grad optimizer.step() # 用 .grad 更新参数这段代码里,loss必须是一个标量(scalar),因为backward()默认只能对标量求导。如果你的损失是多维张量,得先loss.sum()或loss.mean()降维,否则会报「grad can be implicitly created only for scalar outputs」。这个报错新手经常遇到,本质是计算图的起点必须是一个能定义梯度的标量。
2.2 统计量收集:为什么 loss 要取 item() 而不是直接累加张量
训练过程中我们总要看 loss 的变化趋势,于是很自然地写出total_loss += loss这样的累加。这行代码能跑,但埋了一个隐蔽的坑:loss是一个带grad_fn的张量,你把它累加进total_loss,等于把整条计算图都挂在了累加变量上。跑几百个 batch 之后,显存会被这些本该释放的计算图撑爆,而且你根本看不出哪里出了问题,因为代码逻辑「看起来」完全正确。
正确的做法是total_loss += loss.item()。.item()把单元素张量转成 Python 原生 float,切断与计算图的联系,只保留数值。这一步看似微不足道,实则是训练循环里最容易被忽略的显存泄漏点。我在排查一个「训练到一半显存溢出」的问题时,最后定位到的就是统计量累加没取item(),改成之后显存曲线立刻平稳。
除了 loss,训练中常收集的统计量还有准确率、学习率、梯度范数。准确率的计算要注意别把带梯度的预测张量直接存起来,通常用torch.no_grad()包住评估部分,或者用argmax后转成 numpy。梯度范数则可以用来监控梯度爆炸,torch.nn.utils.clip_grad_norm_在裁剪的同时会返回裁剪前的范数,顺手记下来就是一条很好的诊断曲线。
running_loss = 0.0 correct = 0 total = 0 for inputs, targets in dataloader: optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() running_loss += loss.item() # 关键:切断计算图 predicted = outputs.argmax(dim=1) correct += (predicted == targets).sum().item() total += targets.size(0) epoch_loss = running_loss / len(dataloader) epoch_acc = correct / total这里running_loss除以的是len(dataloader),也就是 batch 数量,得到的是平均每个 batch 的 loss。如果你想要样本级别的平均 loss,得除以样本总数,两者在 batch 大小不一致时会有差异。这个细节在对比不同实验的 loss 曲线时尤其重要,否则你可能会误以为模型变好了,其实只是统计口径变了。
2.3 tqdm 进度条:它不只是好看,还是训练可观测性的入口
训练一个模型动辄几十分钟,没有进度反馈的话,你根本不知道它是在正常跑还是卡死了。tqdm的作用不只是加个进度条好看,它把「当前 epoch、当前 batch、实时 loss、预计剩余时间」这些信息压缩在一行里持续刷新,让你对训练节奏有直观感知。资料里专门提到用 tqdm 报告训练进度,说明作者把它当作训练循环的标准组件而非可选装饰。
用 tqdm 包住 DataLoader 的写法很直接,但有几个参数值得说清楚。desc用来标注当前阶段,比如f"Epoch {epoch+1}";leave=False让每个 epoch 的进度条跑完后消失,避免终端被刷屏;postfix可以动态挂上实时指标,比如当前 batch 的 loss,这样进度条右侧会滚动显示数值。postfix接受字典,配合set_postfix可以在循环中更新。
from tqdm import tqdm for epoch in range(num_epochs): model.train() loop = tqdm(dataloader, desc=f"Epoch {epoch+1}/{num_epochs}", leave=False) for inputs, targets in loop: optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() loop.set_postfix(loss=loss.item())set_postfix每次调用都会刷新进度条右侧的显示,但它本身有开销,如果 batch 特别小、循环特别密,频繁刷新反而拖慢训练。这种情况下可以每隔 N 个 batch 更新一次,或者干脆只在 epoch 级别打印汇总。tqdm 的另一个实用点是它能和日志系统共存,leave=True保留最终状态,方便事后从日志里回溯每个 epoch 的耗时。
3. DataLoader 与 Dataset:数据供给为什么能成为训练瓶颈
3.1 Dataset 的三种写法与getitem的契约
PyTorch 把数据供给拆成两层:Dataset负责「单条样本怎么取」,DataLoader负责「怎么把样本攒成 batch、打乱、并行加载」。这个分层设计的好处是,你只需要关心__getitem__里怎么根据索引返回一条(特征, 标签),剩下的批处理、洗牌、多进程加载全部交给DataLoader。资料里用一整章讲 DataLoader 和 Dataset 的配合,核心就在这个契约上。
Dataset有两种常见写法。一种是继承torch.utils.data.Dataset,实现__len__和__getitem__两个方法;另一种是用TensorDataset把现成的张量包起来,适合数据已经全部在内存里的场景。前者灵活,可以在__getitem__里做按需读取、在线增强;后者省事,但要求数据能一次性装进内存。选择哪种,取决于你的数据规模和预处理复杂度。
from torch.utils.data import Dataset class TabularDataset(Dataset): def __init__(self, features, labels): self.features = features self.labels = labels def __len__(self): return len(self.features) def __getitem__(self, idx): # 返回单条样本,类型可以是张量、numpy 或 Python 原生 return self.features[idx], self.labels[idx]__getitem__的返回值会被DataLoader用collate_fn拼成 batch。默认的collate_fn会把一批样本沿第 0 维堆叠,所以要求每条样本的形状一致。如果你的样本长度可变(比如文本序列),就得自定义collate_fn做 padding,否则堆叠会失败。这个报错信息通常很直白——「stack expects each tensor to be equal size」,看到它就知道该去检查collate_fn了。
3.2 num_workers 与 pin_memory:并行加载的收益和代价
DataLoader的num_workers参数控制用几个子进程加载数据。设成 0 表示在主进程里加载,设成大于 0 会开子进程并行取数据。直觉上越大越快,但实际上有个拐点:worker 太多,进程间通信和内存拷贝的开销会吃掉并行收益,甚至因为 CPU 争抢让训练变慢。常见的经验值是设成 CPU 核心数的一半到全部,具体要看数据预处理的重不重。如果__getitem__里只是简单索引,worker 开多了没意义;如果里面有图像解码、数据增强这类重活,多开几个 worker 能明显把 GPU 喂饱。
pin_memory=True是另一个常被忽略的开关。它把加载好的 batch 放进锁页内存(pinned memory),这样从 CPU 传到 GPU 时可以用异步拷贝,减少传输等待。这个参数只有在用 GPU 训练时才有意义,纯 CPU 训练开了也没坏处但没收益。配合non_blocking=True在.to(device)时使用,能让数据传输和计算重叠起来。
from torch.utils.data import DataLoader loader = DataLoader( dataset, batch_size=64, shuffle=True, # 训练集必须打乱,否则 batch 间相关性会拖慢收敛 num_workers=4, # 子进程数,按预处理开销调 pin_memory=True, # GPU 训练时开启,加速 H2D 拷贝 drop_last=False # 最后不满一个 batch 是否丢弃 )shuffle=True在训练集上是必须的。如果数据按类别或时间排好序,不打乱的话每个 batch 的分布高度相关,梯度方向会来回震荡,收敛速度明显变慢。验证集和测试集则应该shuffle=False,保证评估结果可复现。drop_last在 batch 归一化(BatchNorm)场景下有时需要设成 True,避免最后一个不满的 batch 统计量偏差过大。
4. 回归项目实战:预处理为什么能决定模型上限
4.1 特征尺度不一致时,梯度下降在走什么样的弯路
资料里的回归项目专门有一节讲「用预处理改进模型」,这个安排很说明问题:同一个模型结构,加不加标准化,效果可能差出一大截。原因藏在梯度下降的几何直觉里。当不同特征的量纲差异很大——比如一个特征范围是 0 到 1,另一个是 0 到 10000——损失函数的等高线会被拉成极扁的椭圆。梯度下降沿着最陡方向走,在这种地形上会来回横跳,收敛极慢,甚至因为步长稍大就发散。
标准化(把每个特征减均值除标准差)的作用是把等高线「掰圆」,让梯度方向更直接地指向最优解。这不是玄学,而是把优化问题的条件数降下来。我一般会这么理解:预处理不是在「美化数据」,而是在「改造损失曲面的形状」,让优化器少走弯路。对回归问题尤其如此,因为回归的损失是平方误差,对尺度非常敏感。
from sklearn.preprocessing import StandardScaler scaler = StandardScaler() X_train_scaled = scaler.fit_transform(X_train) # 只在训练集上 fit X_test_scaled = scaler.transform(X_test) # 测试集只 transform # 转成张量供 PyTorch 使用 X_train_t = torch.tensor(X_train_scaled, dtype=torch.float32) y_train_t = torch.tensor(y_train, dtype=torch.float32).view(-1, 1)这里有个必须强调的点:fit_transform只能在训练集上调用,测试集只能用transform。如果在测试集上重新fit,你就把测试集的统计信息泄漏进了预处理,评估结果会虚高。这个坑在交叉验证里更隐蔽,正确做法是把 scaler 放进 Pipeline,让每一折都在训练部分单独 fit。
4.2 目标值要不要一起标准化
特征标准化是常识,但目标值要不要标准化,很多人没想过。对回归问题,如果目标值范围很大(比如房价动辄几十万),直接拿原始值算 MSE,loss 数值会非常大,梯度也跟着大,学习率稍不注意就爆炸。把目标值也标准化到均值 0、方差 1 附近,loss 的量级就回到正常范围,学习率的选择空间更宽。
代价是预测时要把输出反变换回原始尺度,多一步inverse_transform。这个取舍取决于你的目标值分布:如果目标值本身就在合理范围内(比如 0 到 1 之间的概率),不标准化也无妨;如果跨了几个数量级,标准化几乎总是值得的。资料里的回归项目在预处理环节同时处理了特征和目标,这个做法在实践里确实能少踩很多学习率相关的坑。
提示:标准化用的均值和标准差必须保存下来,推理时用同一套参数做变换,否则训练和推理的数据分布对不上,模型表现会莫名其妙地变差。
5. 避坑与排查:训练循环里那些让人怀疑人生的瞬间
5.1 loss 不降反升,先查学习率和梯度
现象:训练几个 epoch 后 loss 开始震荡上升,或者直接变成 NaN。原因通常有两个,一是学习率相对当前损失曲面太大,二是梯度爆炸。排查顺序是先打印几个 batch 的梯度范数,如果数值在几百上千,基本可以确认是梯度问题;如果梯度正常但 loss 还是涨,就调小学习率试。解决手段包括降低学习率、加梯度裁剪clip_grad_norm_、检查输入里有没有异常值(inf 或 nan 会污染整个 batch 的梯度)。
5.2 训练 loss 降但验证 loss 不降,别急着怪模型容量
现象:训练集 loss 一路走低,验证集 loss 早早见底然后回升。这是过拟合的典型曲线,但先别急着加正则。检查一下验证阶段有没有切到model.eval(),以及有没有用torch.no_grad()包住。如果忘了切 eval 模式,Dropout 和 BatchNorm 在验证时仍然按训练行为工作,验证结果会失真。确认这两点没问题后,再考虑加 Dropout、权重衰减或早停。
5.3 显存越跑越大最后 OOM,统计量是头号嫌疑
现象:训练刚开始显存正常,跑了几百个 batch 后突然 OOM。原因多半是某个变量把计算图挂住了没释放,最常见的就是 loss 累加没取.item(),其次是往列表里 append 了带梯度的张量。排查方法是看显存增长是否和 batch 数线性相关,如果是,就逐个检查循环里所有累加和存储操作,确保只存标量或 detach 过的张量。
5.4 多 worker 加载报错或卡死,先退回单进程定位
现象:设了num_workers>0后程序报 pickle 错误或者直接卡住不动。原因通常是 Dataset 里存了不可序列化的对象(比如打开的文件句柄、数据库连接),子进程无法复制。排查时先把num_workers设回 0,确认单进程能跑通,再逐步加 worker 并简化__getitem__里的逻辑。解决思路是把重资源在__getitem__内部按需创建,而不是在__init__里长期持有。
5.5 结果每次跑都不一样,随机种子没固定全
现象:同样的代码跑两次,loss 曲线对不上。原因是有随机性的地方不止一处:参数初始化、shuffle、Dropout、数据增强,每一处都有自己的随机源。只设torch.manual_seed不够,numpy 和 Python 的 random 也要一起设。排查时把这几处种子统一固定,再跑两次对比,如果还对不上,就检查有没有用到多线程导致的非确定性算子。
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) torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False最后两行是给 cuDNN 用的,deterministic=True强制卷积算法确定,benchmark=False关掉自动算法选择。代价是可能损失一点速度,但换来的是可复现。做实验对比时这个取舍几乎总是值得的。
6. 从训练循环看 PyTorch 的设计取舍:灵活性的代价与回报
PyTorch 的训练循环之所以「什么都得自己写」,不是因为它偷懒,而是一个明确的设计取舍。对比那些把fit()封装好的高层 API,PyTorch 把训练循环的控制权完全交给开发者,代价是你得自己管梯度清空、模式切换、统计量收集,回报是任何非标准的训练逻辑——梯度累积、多任务损失加权、对抗训练、自定义学习率调度——都能直接写进循环里,不用和框架的抽象打架。这个取舍在研究和实验场景下价值极高,因为研究恰恰需要频繁偏离标准流程。
但灵活性也意味着责任。zero_grad忘了调、item()忘了取、eval()忘了切,这些错误框架不会替你兜底,只能靠你对机制的理解来避免。我一般会这么理解:PyTorch 的训练循环像手动挡汽车,操作得当能开出自动挡给不了的操控感,操作不当就熄火。资料里把训练循环的构成要素、统计量收集、进度报告拆开讲,本质上是在帮你建立这套「手动操作」的肌肉记忆。
验证你对这套机制是否真懂,有个简单方法:拿一个最小模型,故意去掉zero_grad()跑几个 step,观察 loss 和梯度的变化,再对照理论预期看是否吻合。这种「破坏性实验」比读十遍文档都管用,因为它逼你去观察每个组件缺失时的具体表现,而不是停留在「应该这么写」的记忆层面。从那以后我每次搭新训练循环,都会先跑一遍这个最小验证,确认梯度流、统计量、模式切换三件事都对,再往上堆模型和数据。希望帮到你。
本文还有配套的精品资源,点击获取