
简介《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 起点的便宜量0dtype元素类型决定单个元素占用字节数torch.float32device数据存放在 CPU 还是 GPUcuda: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_gradTrue) w torch.tensor(3.0, requires_gradTrue) b torch.tensor(1.0, requires_gradTrue) 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, dtypetorch.float32), torch.tensor(y)DataLoader 在 Dataset 之上负责四件事shuffleTrue打乱样本顺序防止模型学到数据排列的规律batch_size把零散样本拼成一个 batch这要求__getitem__返回的张量形状一致否则就要自定义collate_fnnum_workers启动多个子进程预取数据避免 GPU 空转等数据pin_memoryTrue把数据锁页加速 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」推向「懂机制」的开发者值得存下来逐章深读。希望帮到你。本文还有配套的精品资源点击获取