简介:《Deep Learning with PyTorch》是 Manning 出版社推出的经典英文原版教材,面向具备一定 Python 基础、希望系统掌握 PyTorch 与深度学习实践的开发者与学习者,帮助读者从框架基础一路进阶到模型训练、分布式与部署等工程环节。全书围绕张量、自动微分与 Module 展开,进而讲解神经网络构建、SGD/Momentum/Adam 等优化算法,以及 CNN、RNN、LSTM 与 LeNet、AlexNet、ResNet 等经典模型的实现,并覆盖批归一化、Dropout、数据加载、超参数调整、多 GPU 与多服务器分布式训练、模型保存加载与压缩加速等主题,兼顾理论概念与代码落地。资源包内为 1 个 PDF 文件,压缩包约 44.75MB,单文件即完整原版内容,便于离线阅读与随查随用。目前已有 478 人学习,适合作为课程配套读物或工程实践参考,帮助读者建立从原理到部署的完整知识链路。
1. 张量的本质:为什么 PyTorch 的“多维数组”远不止数组
很多人第一次翻开一本 PyTorch 教材,看到“张量就是多维数组”这句话,会觉得这不过是 NumPy 的换皮说法,翻两页就想跳过。但真正在训练循环里被view报错、被stride搞晕、被.contiguous()救过命的人会告诉你:张量之所以能成为深度学习框架的地基,恰恰因为它不是数组,而是一块存储 + 元数据的组合体。这个设计决定了 PyTorch 几乎所有上层行为——自动微分怎么记录计算图、卷积怎么复用内存、模型怎么搬到 GPU、导出 TorchScript 时哪些操作能保留。搞懂这一层,后面读卷积、读训练循环、读部署章节才不会变成背 API。这份经典英文原版教材把张量放在第一部分的核心位置,正是因为它把“底层机制”当成了理解整个框架的入口,而不是当成语法糖一笔带过。
1.1 从 Python 列表到张量:差的不是语法,是内存布局
教材在讲张量构造时,先拿 Python 列表做对照,这个顺序不是随意的。一个 Python 列表里装的是指向对象的指针,每个元素可以是任意类型,内存是散落的;而一个张量在构造时就把所有元素压进一块连续的、类型统一的缓冲区。这个差别看起来只是性能问题,实际上决定了后面所有操作能不能“零拷贝”。
常见的实现思路是:张量对象本身只持有三样东西——一个指向底层存储的引用、一个描述形状的 size 元组、一个描述“怎么在存储上跳着走”的 stride 元组。构造torch.tensor([1.0, 2.0, 3.0])时,框架先分配一块能放三个浮点数的连续内存,再把元数据填好。之后你做的切片、转置、变形,绝大多数情况下改的只是元数据,存储一个字节都不动。
import torch # 构造:分配一块连续存储,元数据描述形状 t = torch.tensor([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) print(t.shape) # torch.Size([2, 3]) print(t.stride()) # (3, 1) —— 行方向跳 3 个元素,列方向跳 1 个 # 转置:只改 stride,不搬数据 tt = t.t() print(tt.shape) # torch.Size([3, 2]) print(tt.stride()) # (1, 3) —— 元数据换了,存储还是那块 print(tt.is_contiguous()) # False这段代码里最关键的一行是tt.stride()从(3, 1)变成(1, 3)。形状从 2×3 变成 3×2,但底层存储没动,只是告诉框架“按列优先的顺序去读这块内存”。这就是为什么转置几乎不花时间,也是为什么转置后的张量再传给某些要求连续内存的算子时会报错——算子假设相邻元素在内存里也是相邻的,而转置破坏了这个假设。教材专门用一节讲 stride 和 contiguous,就是在提前埋这个伏笔。
1.2 stride 与视图:一次转置背后藏着的地址计算
要真正理解 stride,得把“索引一个元素”这件事拆开看。给定一个多维索引(i, j),框架算地址的公式是offset + i * stride[0] + j * stride[1]。这个公式是张量所有视图操作的通用语言:切片是改 offset 和 size,转置是交换 stride,view是要求 stride 满足某种整除关系后重新解释。
教材里讲“张量的风景视图”这个比喻很到位——你看到的形状是风景,存储是那块地,stride 是你走路的步长。同一块地,步长不同,看到的风景就不同。理解这一点,很多“玄学”报错就有了根:view失败往往是因为当前 stride 不满足新形状的整除约束,而reshape会在必要时悄悄复制一份连续存储再变形,代价是内存和一次拷贝。
x = torch.arange(12).reshape(3, 4) # x.stride() == (4, 1) # 切片:改 offset 和 size,存储不动 y = x[1:, 1:] print(y.shape, y.stride(), y.storage_offset()) # (2, 3) (4, 1) 5 # view 要求 stride 兼容,转置后直接 view 会翻车 z = x.t() try: z.view(-1) except RuntimeError as e: print("view 失败:", e) # 提示 stride 不兼容 # reshape 会兜底复制 w = z.reshape(-1) print(w.is_contiguous()) # True,但这是一份新存储这里y.storage_offset()是 5,说明切片没有新建存储,只是把“起点”往后挪了 5 个元素。而z.view(-1)失败、z.reshape(-1)成功,正好演示了“视图”和“拷贝”的分界线。教材把 in-place 操作、storage_offset、contiguous放在同一节讲,用意就是让读者意识到:张量的“值”和“存储”是两层东西,很多看似奇怪的行为都源于这两层的错位。
1.3 dtype 与 device:类型和设备为什么是张量的第一等公民
教材在讲元素类型时,没有停留在“有 float32 和 int64”这种清单式介绍,而是强调 dtype 是张量元数据的一部分,参与所有运算的类型推导。两个 dtype 不同的张量相加,框架要么报错要么按规则提升,这个规则直接影响数值稳定性——比如整型除法会截断、半精度累加会丢精度。常见的实现思路是:每个算子声明自己支持的 dtype 集合,调度层在派发前做类型检查,不匹配就抛错而不是静默转换,这是 PyTorch 相比早期框架更“显式”的地方。
device 同理。张量可以住在 CPU 或某块 GPU 上,to(device)的本质是把存储搬到另一块内存,元数据跟着走。教材在讲 GPU 时特意提醒:跨设备运算会直接报错,不会自动搬运。这个设计取舍很值得琢磨——自动搬运写起来方便,但会让性能问题变得隐蔽;显式搬运啰嗦,却逼着开发者想清楚数据在哪、什么时候搬。后面讲数据加载、讲多 GPU 训练时,这个“显式”原则会反复出现。
提示:判断一个操作是视图还是拷贝,看它是否改变了
data_ptr()。data_ptr不变就是视图,变了就是新存储。这个习惯在排查内存暴涨时特别有用。
1.4 自动微分为什么能挂在张量上:requires_grad 与计算图的耦合
教材把 autograd 放在“学习机制”那一章,但它能成立的前提在第一部分就埋好了:张量除了存储和元数据,还带一个requires_grad标志和一个grad_fn引用。当一个requires_grad=True的张量参与运算,框架在算出结果的同时,把“这个结果是由哪个算子、哪些输入算出来的”记进grad_fn,形成一张有向无环图。反向传播就是沿着这张图从输出往输入走,每到一个节点调用它记录的反向函数。
这个设计的精妙之处在于:计算图是动态的,跟着前向执行实时构建,用完即弃。这跟静态图框架要先定义再运行的路子完全不同,好处是调试直观、控制流随便写,代价是图不能被提前优化、导出时要额外处理。教材在部署部分讲 TorchScript 的 tracing 和 scripting,本质上就是在解决“动态图怎么变成可序列化的静态表示”这个问题——而这一切的根,都在张量这个带grad_fn的数据结构上。
a = torch.tensor([2.0], requires_grad=True) b = a * 3 # b.grad_fn 指向 MulBackward c = b.sum() # c.grad_fn 指向 SumBackward c.backward() # 沿图回传 print(a.grad) # tensor([3.]) —— d(a*3)/da = 3a.grad能拿到 3,是因为b记住了自己是a乘 3 得来的,c记住了自己是b求和得来的。整条链没有任何显式的“图对象”被创建,图就藏在每个张量的grad_fn里。理解到这一层,再看教材后面讲“关掉 autograd 省内存”“detach 切断梯度”就不会觉得是魔法,而是很自然地在操作这张隐式的图。
2. 卷积与感受野:为什么卷积核能“看见”图像结构
教材用整整一章讲卷积,而且是从“全连接为什么不够用”切入的,这个讲法很能说明问题。一张小尺寸图片展平后接全连接层,参数量会爆炸,更致命的是它把像素的空间邻接关系彻底打散了——模型不知道哪个像素挨着哪个像素。卷积解决的正是这个:用一个小窗口在图上滑动,让每个输出只依赖局部输入,同时整张图共享同一组权重。这个设计带来的参数共享和局部性,是卷积网络能泛化的结构性原因,不是调参调出来的。
2.1 卷积在算什么:互相关、padding 与输出尺寸
教材很诚实地指出,深度学习框架里叫“卷积”的操作,数学上其实是互相关——核不翻转。这个细节不影响表达能力,但影响你手算验证时的符号。真正需要记牢的是输出尺寸公式:给定输入边长W、核大小K、paddingP、步长S,输出边长是(W + 2P - K) / S + 1。教材在讲 padding 时强调边界处理,因为不加 padding 的话每卷一次图就缩小一圈,深网络很快就卷没了。
import torch.nn as nn conv = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, padding=1, stride=1) # 输入 (N, 3, 32, 32) -> 输出 (N, 16, 32, 32) # 因为 (32 + 2*1 - 3)/1 + 1 = 32,尺寸不变padding=1配kernel_size=3是保持尺寸的经典组合,教材在搭网络时反复用这个搭配。参数含义要逐个说清:in_channels必须匹配输入的通道数,out_channels决定这一层产出多少个特征图,每个输出通道有自己的一组in_channels × K × K权重。参数量是out_channels × in_channels × K × K + out_channels,跟输入尺寸无关——这就是卷积比全连接省参数的根本原因。
2.2 池化与深度:感受野是怎么一层层变大的
教材讲完单层卷积后,紧接着讲池化和堆叠,因为单层卷积的感受野只有核那么大,看不到全局。每加一层卷积或池化,输出的一个元素对应的输入区域就扩大一圈。这个“感受野随深度增长”的性质,是深层网络能理解复杂结构的基础。池化在这里扮演两个角色:降采样减少计算量,同时不引入额外参数地扩大感受野。
常见的实现思路是:卷积层负责提取特征,池化层负责压缩空间维度,两者交替堆叠,最后接全连接或全局池化做分类。教材在讲模型设计时提到“加宽”“加深”“正则化”三个旋钮,其实就是在调这个堆叠结构的容量和泛化能力。加宽是增加每层通道数,加深是增加层数扩大感受野,正则化是防止容量大了之后过拟合。这三个旋钮的取舍,教材用对比实验的方式呈现,而不是直接给结论,这点很值得细读。
2.3 nn.Module 怎么管住参数:子模块注册与参数遍历
教材在讲自定义网络时,专门解释了为什么继承nn.Module之后,赋给self的子模块和参数会被自动追踪。机制是这样的:nn.Module重写了__setattr__,当你写self.conv = nn.Conv2d(...)时,它检测到赋值对象是Module或Parameter,就把它登记进内部的_modules或_parameters字典。之后调用.parameters()、.to(device)、.state_dict()时,框架遍历这些字典就能找到所有需要处理的张量。
class SmallNet(nn.Module): def __init__(self): super().__init__() self.conv = nn.Conv2d(3, 16, 3, padding=1) # 自动注册 self.fc = nn.Linear(16 * 8 * 8, 10) # 自动注册 def forward(self, x): x = self.conv(x) x = x.flatten(1) return self.fc(x) net = SmallNet() print(len(list(net.parameters()))) # 遍历到 conv 和 fc 的全部参数 net.to("cpu") # 一次性搬走所有注册的张量这个注册机制解释了教材里一个容易被忽略的坑:如果你把子模块放进一个普通 Python 列表self.layers = [nn.Linear(...), ...],它不会被注册,.parameters()找不到它,优化器就不会更新它的权重。正确做法是用nn.ModuleList或nn.Sequential。教材在讲“PyTorch 如何追踪参数和子模块”时点明了这一点,这是很多人第一次搭网络时踩过的坑。
2.4 从全连接到卷积:教材为什么用“鸟和飞机”做例子
教材选 CIFAR-10 里“鸟 vs 飞机”这个二分类任务做过渡,用意在于展示全连接模型的天花板。全连接模型在训练集上能拟合得很好,但验证集上表现平平,因为它学的是像素位置的组合,而不是“翅膀”“机翼”这类可迁移的局部模式。换成卷积后,同样的数据,验证准确率明显提升。这个对比不是让你记住一个数字,而是让你体会“结构先验”的价值——卷积把“局部性”和“平移等变”这两个关于图像的先验编码进了网络结构,模型不需要从数据里重新学这两件事。
教材在讲完这个对比后,顺势引出“全连接的上限”,然后才进入卷积章节。这个编排逻辑是:先让你感受到痛点,再给机制。读的时候如果跳过前面的全连接实验直接看卷积,就会觉得卷积只是又一个层类型,体会不到它为什么是图像任务的结构性答案。
3. 训练循环的底层:一次 forward/backward 到底发生了什么
教材在第二部分用一个完整的医学影像项目串起训练流程,但真正把机制讲透的是中间那些看似琐碎的辅助函数——损失计算、指标记录、验证循环。很多人写训练循环是照抄模板,能跑就行,一旦 loss 不降或者指标异常就抓瞎。要能排查,得知道一次迭代里每一步在干什么、哪些状态被改了、哪些是只读的。
3.1 前向、损失、反向、更新:四步的状态变化
一次标准训练迭代可以拆成四步:前向算出预测,损失函数算出标量,反向传播算出每个参数的梯度,优化器根据梯度更新参数。关键在于每一步对张量状态的修改:前向过程中,带requires_grad的中间结果会记录grad_fn;loss.backward()会沿着图把梯度累加到每个叶子张量的.grad上;optimizer.step()读取.grad并修改参数的值;optimizer.zero_grad()把.grad清零,为下一轮做准备。
for inputs, targets in dataloader: outputs = model(inputs) # 前向,构建计算图 loss = criterion(outputs, targets) # 标量损失 optimizer.zero_grad() # 清空上一轮梯度 loss.backward() # 反向,梯度累加到 .grad optimizer.step() # 按梯度更新参数顺序里最容易翻车的是zero_grad的位置。PyTorch 的梯度是累加的,不清零就会把多轮梯度叠在一起,表现为 loss 震荡或者直接发散。教材在讲优化器时特意强调这一点,因为这是“能跑但结果不对”的典型来源。另一个细节是loss必须是标量才能直接backward(),如果 loss 是向量,得先sum()或mean(),否则框架会要求你传gradient参数。
3.2 优化器不是黑匣子:SGD、Momentum、Adam 各自在维护什么状态
教材把优化器讲成“按菜单点菜”,但每种优化器内部维护的状态不一样,理解这些状态才能解释训练行为。朴素 SGD 只做param -= lr * grad,没有额外状态。Momentum 维护一个速度缓冲,把历史梯度累积起来,让更新方向更平滑,能冲过小的鞍点。Adam 维护一阶矩和二阶矩的滑动平均,对每个参数自适应调整步长,所以它对学习率不那么敏感,但也因此可能在某些任务上泛化稍差。
# 概念示意,非框架源码 # SGD: p = p - lr * g # Momentum: v = mu * v + g; p = p - lr * v # Adam: m = b1*m + (1-b1)*g # v = b2*v + (1-b2)*g*g # p = p - lr * m_hat / (sqrt(v_hat) + eps)这些状态在优化器对象创建时按参数形状分配,跟着state_dict()一起保存和加载。教材在讲模型保存时提醒要同时存优化器状态,就是因为断点续训时如果只恢复参数不恢复优化器状态,Momentum 和 Adam 的动量会从零开始,训练曲线会出现一个明显的抖动。这个细节在“能跑就行”的写法里经常被忽略,但在长训练任务里是血泪经验。
3.3 训练集、验证集、过拟合:指标为什么会骗人
教材在讲训练循环时反复区分训练指标和验证指标,因为训练 loss 下降不代表模型变好。模型容量足够大时,它可以把训练集背下来,训练 loss 一路走低,验证 loss 却在某个点之后开始上升——这就是过拟合。教材用“记住人脸预测年龄”的极端例子说明:模型可能学的是训练样本的噪声,而不是可泛化的规律。
判断过拟合不能只看一条曲线,要同时看训练和验证两条曲线的分叉。常见的实现思路是每个 epoch 结束在验证集上跑一遍前向(不建图,用torch.no_grad()),记录 loss 和任务指标,画在同一张图上。教材在医学影像项目里引入 precision、recall、F1,就是因为类别不平衡时准确率会骗人——一个把所有样本判成“无结节”的模型准确率可能很高,但 recall 是零,临床上毫无用处。这个指标选择的讨论,本质上是把“训练循环记录什么”和“业务上关心什么”对齐。
3.4 数据加载为什么单独开进程:DataLoader 的并行模型
教材在讲大规模项目时提到多进程数据加载,这不是性能优化的小技巧,而是训练吞吐的结构性设计。主进程负责前向反向,数据加载如果也在主进程里做,GPU 就会在等数据时闲置。DataLoader用num_workers开若干子进程,每个子进程独立地读文件、做变换、组 batch,通过队列把 batch 递给主进程。
这个模型带来两个必须注意的点:一是子进程里不能随便改主进程的状态,数据集对象的某些操作在子进程里执行可能不符合预期;二是每个 worker 有自己的随机数状态,做数据增强时要确保随机性正确播种,否则多个 worker 可能产出相同的增强结果。教材在讲数据增强时提到这些细节,因为增强的效果依赖于随机性,随机性错了,增强就白做。
4. 避坑与排查:那些“能跑但不对”的典型现场
4.1 loss 不降反升:先查 zero_grad 和学习率
现象是训练几个 batch 后 loss 开始震荡甚至变大。原因通常是梯度累加没清零,或者学习率相对当前模型和数据太大。排查顺序:先确认optimizer.zero_grad()在backward()之前被调用;再把学习率调小一个数量级观察 loss 是否变平滑;最后检查损失函数和输出是否匹配(比如分类用了回归损失)。解决方式是把清零放进循环固定位置,学习率从保守值起步做扫描。
4.2 验证指标远低于训练:数据泄漏或分布不一致
现象是训练集准确率很高,验证集明显低一截。原因可能是训练和验证的预处理不一致(比如归一化参数只对训练集算过却用错了),也可能是划分数据时同一来源的样本同时进了两边。排查时先打印两边 batch 的统计量对比,再检查划分逻辑是否按样本组而不是按行随机。解决方式是统一预处理管线,按组划分并固定随机种子。
4.3 GPU 显存越跑越大:计算图没释放或张量被意外持有
现象是训练若干轮后显存持续上涨直到 OOM。原因常见于把带梯度的中间结果存进了列表做日志,或者验证循环忘了torch.no_grad(),导致每轮都建图。排查时用显存快照对比不同轮次,看是哪类张量在累积。解决方式是日志只存.detach()后的标量或小张量,验证和推理统一包在no_grad里。
4.4 模型保存后加载结果不一致:结构或状态没对齐
现象是保存再加载后预测结果变了。原因可能是只存了state_dict却用不同结构的模型加载,或者加载后忘了eval()导致 dropout、batchnorm 行为不同。排查时先对比加载前后同一输入的输出,再检查模型是否处于训练模式。解决方式是保存时记录结构信息,加载后显式调用eval(),并固定随机种子做一致性验证。
4.5 多卡训练速度没提升:瓶颈不在计算
现象是加了 GPU 但吞吐没变。原因可能是数据加载成了瓶颈,或者 batch 太小导致通信开销占比过高。排查时先看 GPU 利用率,如果长期偏低就是喂数据不够快。解决方式是增加num_workers、把预处理放到 GPU 上做、适当增大 batch,再评估通信和计算的比例。
5. 从动态图到部署:TorchScript 与 traced model 的取舍
教材最后一部分讲部署,核心问题是:训练时用的动态图很灵活,但生产环境要的是可序列化、可脱离 Python 运行时执行的模型。TorchScript 就是这座桥。它有两种生成方式——tracing 和 scripting,两者的差别直接决定了哪些模型能顺利导出、哪些会在导出后行为改变。
tracing 的做法是拿一组示例输入跑一遍前向,记录实际执行的操作序列,把它固化成图。它对没有数据依赖控制流的模型很好用,但一旦模型里有if依赖输入值的分支,trace 只会记录示例输入走的那条路,其他分支就丢了。scripting 则是把 Python 源码做静态分析,转成 TorchScript 的中间表示,能保留控制流,但对 Python 特性的支持有边界,动态类型、复杂容器操作可能不被接受。
import torch class Net(torch.nn.Module): def forward(self, x): if x.sum() > 0: # 数据依赖分支 return x * 2 return x - 1 net = Net() traced = torch.jit.trace(net, torch.ones(3)) # 只记录 sum>0 那条路 scripted = torch.jit.script(net) # 保留两条分支这个例子里,traced对负数输入的行为会和原模型不一致,而scripted不会。教材在讲“tracing 的盲区”时就是在提醒这个取舍:trace 简单、兼容性好,但对控制流不诚实;script 更忠实,但对代码写法有约束。实际项目里常见的做法是先用 trace 试,遇到控制流再局部改成 script,或者把控制流挪到图外由调用方处理。
导出到 ONNX 是另一条路,目标是跨框架互操作。它的取舍是:图更通用,但 PyTorch 特有的一些动态行为在转换时可能被近似或丢弃,转换后必须用一组覆盖边界情况的输入做数值对齐验证。教材在部署章节强调“导出不是终点,验证才是”,因为一个能导出但数值对不上的模型,比导不出来更危险。
至于移动端和 C++ 推理,本质上是同一套 TorchScript 表示在不同运行时上执行,区别在于算子支持和量化策略。量化把权重和激活从浮点压到低比特,换来的是内存和延迟下降,代价是精度可能轻微损失,需要在自己的数据上评估。这些取舍没有标准答案,取决于延迟预算、精度要求和硬件条件。
我自己的习惯是:任何模型在宣布“可以上线”之前,必须走一遍“导出 → 用独立输入做数值对齐 → 在目标运行时上跑通”的流程,缺一步都不算数。早期我图省事跳过对齐,结果线上预测和离线评估对不上,排查了大半天才发现是导出时某个分支被 trace 吃掉了。从那以后我每次导出都强制走一遍数值对齐,哪怕模型再简单。希望帮到你。
本文还有配套的精品资源,点击获取