简介:《Deep Learning with PyTorch》是 Manning 出版社推出的经典英文原版教材,由 Eli Stevens、Luca Antiga 和 Thomas Viehmann 合著,面向系统学习 PyTorch 与深度学习的开发者、科研人员及学生,旨在帮助读者从基础概念走进真实工程场景。全书由浅入深地覆盖 PyTorch 基础(Tensor、Variable、Module、自动微分)、深度学习基础(监督/无监督/半监督学习及准确率、精确率、召回率、F1 等评估指标)、神经网络构建与优化(SGD、Momentum、Adam 等),并详解 LeNet、AlexNet、ResNet 等常用模型实现,还介绍了 Batch Normalization、Dropout、数据加载、训练循环、模型定义与损失函数、超参数调整、分布式训练(数据并行/模型并行、多服务器与多 GPU、参数同步和梯度聚合)以及模型部署(保存加载、转换优化、压缩加速)等进阶主题。资源包为单个 PDF 文件,共 1 个文件,约 44.75MB,保留英文原版完整目录与排版,方便按章查阅或系统精读。目前已有 475 人学习或下载。无论用于深度学习课程自学,还是工程项目的方案参考,这份教材都能提供结构化知识框架与可落地的技术细节,是一份适合长期保存的 PyTorch 学习资料。
1. 深度学习框架的底层引擎:为什么说「张量 + 自动微分」就是 PyTorch 的全部
很多人第一次接触 PyTorch 时,会觉得它是一大堆 API 的集合:数据集有 Dataset、模型有 nn.Module、优化有 optimizer,仿佛学完这些才算「会了」。但一本经典英文原版教材的编排顺序给出了相反的答案——它开篇直接让你跑预训练模型找手感,第 3 章才回头讲张量的存储方式,第 5 章才出现「训练」这个词。这个顺序本身就是一种观点:你之前见过的所有神经网络架构,无论多花哨,真正在运行时只有两件事——张量放在哪、梯度怎么流回去。
理解这套底层机制,最大的收益是摆脱「调参玄学」。当你不再把框架当黑匣子,而是清楚知道一次 transpose 为什么是 O(1)、为什么 backward 之前必须 zero_grad、为什么验证集划分错了会给出漂亮但无用的指标时,大部分深度学习项目里的「莫名翻车」其实都有确定的解释。这本书把这些机制的来龙去脉讲得极细,值得从底层拆开看一遍。
2. Tensor 的底层机制:storage、stride 与 view 如何让转置变成 O(1)
2.1 张量不是多维数组,而是「存储 + 视图」
几乎所有 PyTorch 新手都会把torch.tensor([[1,2,3],[4,5,6]])理解成一个二维数组。如果真按二维数组实现,切片、转置、扩维这些高频操作就必须复制数据,内存和时间的开销都不可接受。PyTorch 的实际设计是:数据本身只保存在一段连续的一维内存里,张量仅仅是「解释这段内存的方式」。
这段连续内存叫 storage,张量对象上真正描述「怎么解释」的是一组元数据:shape 决定每个维度的大小,stride 决定沿着某个维度走一步要跳过多少个元素,storage_offset 决定数据从 storage 的哪个位置开始读。为了方便对照,把这几个元数据列出来:
| 元数据 | 作用 | 典型值示例 |
|---|---|---|
| storage | 底层连续内存数组 | 一段长度为 6 的 float 序列 |
| shape | 每个维度的长度 | (2, 3) |
| stride | 每个维度前进 1 需要跨越的元素数 | (3, 1) |
| storage_offset | 数据起始位置相对 storage 起点的便宜量 | 0 |
| dtype | 元素类型,决定单个元素占用字节数 | torch.float32 |
| device | 数据存放在 CPU 还是 GPU | cuda:0 |
这个「存储与视图分离」的设计是整个 PyTorch view 语义的地基。切片返回的新张量不会复制底层数据,它只是换了一套 shape、stride 和 storage_offset,底层还是原来那段 storage。读取元素的公式也很直接:storage[offset + i * stride[0] + j * stride[1]]。理解了这套索引规则,后面所有关于连续性、转置、广播的现象都只是这条公式的推论。
2.2 一个 transposed view 的完整履历
转置是理解 stride 机制最经典的场景。对一个 2×3 矩阵做转置,如果按普通多维数组实现,需要把 6 个元素重新排列到新的存储位置;但在 PyTorch 里,转置只是交换 stride 元组的顺序,storage 一个字节都不动。
import torch t = torch.arange(6).reshape(2, 3) print(t.storage_offset()) # 0 print(t.stride()) # (3, 1) tr = t.transpose(0, 1) print(tr.shape) # (3, 2) print(tr.stride()) # (1, 3) print(tr.is_contiguous()) # False转置后 stride 从(3, 1)变成(1, 3),shape 从(2, 3)变成(3, 2)。读取tr[1][0]时,按公式计算得到 storage 偏移0 + 1 * 1 + 0 * 3 = 1,取到的正是原矩阵t[0][1]。整个操作只改了元数据,所以严格 O(1),这是 PyTorch 里 transpose 快得离谱的原因。
代价是转置后的张量不再「连续」:按行主序扫描 storage 时,得到的元素顺序和按 shape 逐个读取的顺序对不上。is_contiguous()返回 False 就意味着,某些底层算子(比如 view、部分卷积 kernel)要求数据在内存里连续排列,遇到不连续输入要么悄悄触发一次复制,要么直接报错。常见的翻车现场是「transpose 之后直接 view」,因为 view 要求张量满足连续性条件;遇到这种情况,正确做法是先contiguous()显式复制出一份连续内存,再做 view。
2.3 连续性、view 与广播的边界
很多人会混淆 view 和 reshape 的语义。view 是纯元数据操作,它要求张量本身已经连续,或者新的 shape 与原有 stride 兼容,否则直接抛错;reshape 更宽容,在无法 view 的时候会自动复制数据。这个差别平时无感,但在写高性能数据管线时会直接影响内存分配次数:误以为没复制,实际却多了一次隐式contiguous()拷贝,GPU 显存和耗时都会悄悄飙升。
广播机制的底层也和 stride 有关。PyTorch 对扩展维度用的 stride 是 0:比如形状(3, 1)的张量要广播成(3, 5),PyTorch 并不会真的把每列复制一份数据,而是把那个维度上的元素间隔设为 0,读取时无论列索引是多少都命中同一个元素。所以广播同样是一种零拷贝的 view 操作。这也是为什么x + 1这种「对每个元素加常数」的运算可以这么快,它本质上没有为常数的广播分配任何额外存储。
理解了 storage 和 stride,再去读张量的其他元数据就顺理成章了:device 决定数据在 CPU 还是 GPU 的显存里,跨设备访问需要显式拷贝;dtype 决定每个元素占几个字节,从 float32 转 float16 或 int8 直接改变 storage 的解释方式,也是后面模型量化的底层入口。序列化torch.save保存的同样是 storage 加元数据,加载回来时张量结构原样恢复。
3. autograd 的求导机制:反向模式如何把梯度高效送回到每个参数
3.1 为什么深度学习框架都选反向模式
求梯度这件事,理论上至少有三种实现路径。数值微分用差分近似,实现最简单,但精度差,而且每求一个参数的梯度都要重新跑一遍前向计算,深度学习模型动辄几百万参数,这种方案完全不可行。符号微分把整个表达式当成符号代数处理,能给出精确的导数公式,但表达式一复杂就会产生指数级膨胀的中间项,计算图稍微深一点就爆掉了。
自动微分走的是另一条路:前向计算时把每个中间结果存下来,反向传播时按链式法则从输出向输入逐层算。对「一个标量 loss 对海量参数求导」这种场景,反向模式只需要一次前向、一次反向,就能拿到所有参数的梯度,总计算量只和计算图的边数同阶,与参数数量基本解耦。这就是为什么 PyTorch 的 autograd 引擎以及所有主流框架,无一例外都采用反向模式自动微分——不是风格偏好,是这个计算特性决定的。
3.2 define-by-run:计算图是跑出来的,不是画出来的
PyTorch 的 autograd 有一个很关键的设计:计算图不是在模型定义阶段预编译的,而是在每次前向传播时边算边建。这个机制叫 define-by-run,也是 PyTorch 与早期静态图框架最大的分水岭。
import torch x = torch.tensor(2.0, requires_grad=True) w = torch.tensor(3.0, requires_grad=True) b = torch.tensor(1.0, requires_grad=True) y = w * x + b y.backward() print(x.grad) # 3.0 print(w.grad) # 2.0 print(b.grad) # 1.0执行y = w * x + b这一行时,PyTorch 会同时建出乘法节点和加法节点,每个节点都保存前向计算的输入值和输出值。y.grad_fn指向加法操作的 backward 函数,加法节点的输入里又挂着乘法节点的grad_fn。调用y.backward()时,autograd 从grad_fn这条链条出发,按拓扑序反向逐个节点计算偏导并沿图传播。上面例子中,dy/dx等于 w 的值 3.0,所以x.grad打印出 3.0。
define-by-run 的最大好处是灵活:Python 的 if 分支、for 循环、任意中间变量都可以参与计算图的构建,调试的时候可以像普通 Python 程序一样打断点。代价是每次前向都要重新建图,带来一部分固定开销,而且图无法提前做编译优化——这个代价要到部署章节才会真正体现出来。这里还有一个容易踩的细节:如果y不是标量,backward()必须传入一个与y同形状的gradient权重参数,因为它要算的是雅可比矩阵与权重向量的乘积;所以平时写 loss 时总会先.sum()或.mean()把它归约成标量,本质上就是为了省掉这一步的麻烦。
3.3 梯度累加、detach 与 in-place 的边界
训练循环里optimizer.zero_grad()这一行,背后是一个经常被忽略的设计:Tensor.grad是累加器属性,每次backward()会把新算出的梯度累加到已有值上,而不是覆盖。这个行为最初是为了支持梯度累积——显存不够时把一个大批次拆成多个小批次,分别 backward 不 step,攒够后再统一更新参数,效果等效于直接跑大批次。
# 每个 batch 的标准训练循环 for batch in dataloader: optimizer.zero_grad() # 必须放在 backward 之前 loss = compute_loss(batch) # 前向建图并计算 loss loss.backward() # 沿计算图反向传播梯度 optimizer.step() # 用累积的梯度更新参数显然,如果漏掉zero_grad(),第二次 backward 会叠加第一次的梯度,参数更新就会乱掉。这就是「为什么每次 step 之前必须清零梯度」的机制原因——不是框架矫情,是梯度累加语义的必然结果。
detach()和no_grad()则是切断梯度流的手段。detach()返回一个新张量,它与当前计算图的连接被切断,梯度传到这里就不再往前;这在 GAN 训练里固定生成器、只更新判别器时是常规操作。推理时包一层torch.no_grad(),前向照跑但完全不建图,显存占用和计算量都能降下来,而且语义上更明确:「我不需要梯度了」。
最隐蔽的坑是 in-place 操作。autograd 在反向传播时需要读取前向保存的中间值,如果某个张量在 forward 之后被x += ...或x[i] = ...原地覆盖,反向的时候就找不到原始值了。PyTorch 会直接报错提示「variable needed for gradient computation has been modified by an inplace operation」,这属于比较好发现的;真正危险的是用.data或detach()拿到的张量做 in-place 修改,报错被绕过,梯度在静默中算错,模型训完都不一定知道哪里出了问题。
4. 数据链路与训练循环:Dataset、DataLoader 与 loss.backward() 的职责切分
4.1 现实数据怎么变成张量
在进入模型之前,一切现实数据都要先完成「张量化」。图像数据最常见的布局是 HWC,即高、宽、通道,而 PyTorch 卷积默认期望 NCHW,所以读取图片后要做的第一件事通常是permute把通道维提前;同时把像素值从 0~255 除以 255 归一化到 0~1,再按数据集均值方差做标准化,这一步直接关系到后面梯度是否稳定。
表格数据相对直接:数值列转成 float 张量,类别列必须做 one-hot 编码而不是直接编成 0,1,2——把类别编号当成连续数值喂进去,模型会学到编号之间的虚假大小关系,这是最常见的特征工程错误。时间序列的处理核心是加一个时间窗口维度,把过去 N 步的观测组织成样本,才能作为序列模型的输入。文本最特殊:字符或单词的 one-hot 向量维度等于词表大小,稀疏且浪费;更常用的是 embedding,本质是一张「词表大小 × embedding 维度」的可学习权重表,每个词取一行,训练过程中连同整张表一起更新——它就是给离散数据配的一组可训练特征向量。
4.2 Dataset 是取数协议,DataLoader 是装填策略
PyTorch 把数据加载拆成两层:Dataset 只回答两个问题——这个数据集有多长、第 i 个样本是什么。它不关心 shuffle、不关心 batch、不关心并行,这些全交给 DataLoader。
from torch.utils.data import Dataset, DataLoader class MyDataset(Dataset): def __init__(self, samples): self.samples = samples def __len__(self): return len(self.samples) def __getitem__(self, index): x, y = self.samples[index] return torch.tensor(x, dtype=torch.float32), torch.tensor(y)DataLoader 在 Dataset 之上负责四件事:shuffle=True打乱样本顺序防止模型学到数据排列的规律;batch_size把零散样本拼成一个 batch,这要求__getitem__返回的张量形状一致,否则就要自定义collate_fn;num_workers启动多个子进程预取数据,避免 GPU 空转等数据;pin_memory=True把数据锁页,加速 CPU 到 GPU 的拷贝。把「索引协议」和「装填策略」分离的设计,好处是你写任何自定义 Dataset 都能自动享受 DataLoader 的全部能力,在线数据增强也可以直接塞进__getitem__里,每次取样本时随机扰动一份新副本。
4.3 训练循环的骨架与五个步骤的顺序
真正让模型学习的循环,骨架短得让人意外——清梯度、前向、算 loss、反向、更新参数,五个步骤往复而已。每一步都有明确职责:zero_grad()清空上轮累积的梯度;forward 建立本次计算图并算预测;loss 给出预测与目标之间的差距标量;backward()沿图传播梯度;step()根据优化器规则更新参数。
顺序不能乱。zero_grad()必须在backward()之前,否则梯度叠加;backward()必须在step()之前,因为优化器要读的是本轮梯度。有一种常见误解是把loss.item()当成 loss 本身拿来比较——item()只是把标量张量转成 Python 数值用于打日志,不影响计算图。看训练日志时,初始 loss 是否接近理论值是第一个健康信号:二分类交叉熵的初始期望约-ln(0.5) ≈ 0.69,如果第一轮 loss 远超这个量级,先别调学习率,回头检查数据归一化和标签有没有搞错。
4.4 从全连接到卷积:泛化能力不是玄学
训练循环解决的是「怎么学」,而模型架构决定「学什么」。全连接网络把每个输入元素与每个输出单元都连一遍,参数随图像尺寸爆炸式增长:一张 32×32×3 的小图,全连接第一层稍微宽一点就是几十万个权重。卷积网络的核心动机是三个结构性假设:局部性——图像里相邻像素才相关,不需要全图连接;参数共享——同一个卷积核滑过整张图,检测同一种特征只需要一组权重;平移等变——猫在图片左上角或右下角,都应该被同一个特征检测器响应。
这三点让卷积把参数量压缩到「核大小」这个量级,同时天然带上轻度的平移不变性。再往后,深度增加是为了在更大的感受野上组合低层特征,池化层降分辨率则进一步扩大感受野并降低计算量;宽度(通道数)提供的是每个位置的表达容量。理解这些设计动机之后,调网络结构就不完全是黑盒试错,你至少知道加深度是在扩展什么、加宽度是在扩展什么、池化又牺牲了什么。
5. 训练踩坑排查:从 99.7% 正确率的幻觉到正负样本失衡
5.1 正确率 99.7%,模型却什么都没学到
现象:训练完的模型在验证集上准确率高达 99.7%,看起来很完美,但实际使用时完全没法区分目标类别,预测结果几乎全是同一个答案。
原因:正负样本极度不平衡。比如肺部 CT 结节检测场景里,真实结节在三维立方体中只占极小的比例,非结节样本占了绝大多数。模型发现只要把所有样本都判成「非结节」,准确率就能轻松超过 99%,于是优化算法找到了这个偷懒的解,acc 指标完美掩盖了模型什么都没学。
解决:不要只看 accuracy,看精确率(precision)、召回率(recall)和 F1 分数。对这个例子而言,把正负样本按比例采样,让每个 batch 里两类样本尽量均衡,模型才会被迫去学习结节的真实特征。训练日志里同时打印混淆矩阵,是最直接的体检方式。
5.2 验证集与训练集同源,泄漏而不自知
现象:验证集指标好得离谱,训练曲线和验证曲线几乎重合,但模型部署到真实数据上一塌糊涂。
原因:划分数据集时按「样本」而不是按「对象」切分。在医学影像这类数据里,同一个病人的多次扫描切片天然相似,如果训练集和验证集里混进了同一个病人的数据,模型的所谓「泛化」其实是记住了病人的个体特征,验证分数被数据泄漏灌了水。
解决:按对象分组划分。训练/验证集必须按病人或物品的唯一 ID 分组,确保同一个主体的所有数据只落在一侧,验证集才能真正考验模型对没见过的主体的判断能力。这一条是很多真实项目的血泪经验。
5.3 in-place 操作让计算图「断线」
现象:训练中途loss.backward()突然报错,提示某个变量的梯度计算被 in-place 操作影响,或者更糟——不报错,但训练曲线诡异发散。
原因:autograd 的反向传播依赖前向保存的中间值。x += 1、x[i] = v这类原地操作会把存储里的旧值覆盖掉,反向时就拿不到该拿的历史数据。用.data或detach()拿到张量后再做 in-place 修改,错误会被静默吞掉,梯度算错但不报错,这种更危险。
解决:一律用新建张量的方式写,比如x = x + 1,而不是x += 1。对已经是叶子节点的张量,PyTorch 会主动拦截 in-place 修改并抛错,要利用这个保护机制,不要用.data绕过它。
5.4 未归一化的输入把梯度逼进震荡
现象:训练一开始 loss 就是 nan,或者第一轮 loss 远高于理论初始值,然后忽高忽低完全不受控制。
原因:输入数据数值范围过大。以 CT 影像为例,原始 Hounsfield 值域可以达到上千量级,如果不做裁剪和归一化,大数值经过多层线性变换后直接溢出 float32 的表示范围,梯度在反向传播时要么爆炸要么变成 nan。
解决:先把数据缩放/裁剪到合理区间,再做标准化。这类输入可以先裁剪到 -1000 到 1000,再整体归一化到接近 [-1, 1] 的范围。开训前检查初始 loss 是否接近理论值(如前面说的 0.69),这一步能挡住大量「训练玄学」。
5.5 训练 loss 降、验证 loss 升:过拟合的早期信号
现象:训练曲线持续下降,验证曲线先降后升,两条曲线出现明显的「喇叭口」,且验证集的指标开始原地踏步。
原因:模型容量大于数据能提供的信息量,训练后期开始记忆训练样本里的噪声,而不是学习可泛化的规律。数据量不足、模型过宽过深、训练轮次太多都会促成这个结果。
解决:分两层处理。数据侧做在线增强——图像旋转、翻转、随机裁剪、颜色抖动,让每个 epoch 看到的都是数据的扰动版本,等效于扩大训练集;模型侧加 dropout、权重衰减、早停。注意增强要在线做而不是离线做,离线增强只是把相同的变换样本重复喂进去,泛化收益很有限。
6. 部署与设计取舍:TorchScript、ONNX 与「追踪 vs 脚本」的代价
6.1 追踪与脚本:一份模型的两种导出路线
动态图在前几章是灵活性来源,到部署阶段就成了问题:生产环境需要的是一个静态、可序列化、不依赖 Python 运行时的模型文件。PyTorch 给出的解法是 TorchScript,它提供两条导出路径:
# tracing: 用真实输入跑一遍,把实际执行路径固化成图 traced = torch.jit.trace(model, example_input) # scripting: 直接编译模型源码,保留控制流结构 scripted = torch.jit.script(model)tracing 的做法是拿着一个示例输入执行一次前向,把真正走过的算子序列摘出来固化成静态图。它的局限很明显:如果模型里有if分支依赖输入,tracing 只会保留这次执行走进的那个分支;循环会被展开成固定层数;输入形状变化也容易出错。scripting 相反,它直接解析模型源码编译成中间表示,能保留控制流,但要求代码里的类型可以被推断,不能用大量动态的 Python 特性。这两种路径都通向同一个目标——把可训练的模型变成可部署的静态图。ONNX 则是更中立的中间表示,导出后可以被各种推理引擎加载,代价是自定义算子可能不在 ONNX 算子集里,导出时就会失败。
6.2 动态图接口与静态化后端的同源关系
把部署这部分放回全书看,会得到一个更完整的判断:PyTorch 本质上同时承担了「接口」和「后端」两个角色。日常训练时它给你 define-by-run 的灵活;到了生产环境,TorchScript、LibTorch 乃至 C++ API 完成的是同一个模型的静态化折叠。LibTorch 让 C++ 直接加载 JIT 模型推理,适合延迟敏感的服务;移动端部署则配合 int8 量化,把 forward 时的浮点运算压缩到整数运算,换取体积和速度。理解这一层,你就知道为什么输入清洗、数据归一化、脚本化导出这些步骤要提前考虑——它们不是一个孤立的「部署阶段」,而是从数据张量化那天起就牵在一起的同一根链条。
从那以后,我拿到任何新数据集都强制先做两件事:验证集划分从「按样本」改成「按主体分组」;开训前先看初始 loss 是否接近理论值。这两步挡住过我大半的翻车。这本英文原版的经典教材,正是把 storage、autograd、数据管线到部署取舍这条机制链讲得最成体系的一份资源,适合每个想把深度学习从「会调 API」推向「懂机制」的开发者,值得存下来逐章深读。希望帮到你。
本文还有配套的精品资源,点击获取