
1. 为什么第 51111 集是整门课的分水岭把《动手学深度学习》这套课刷到第 50 集左右的人通常会进入一种微妙的错觉卷积会写了LeNet 能跑通了MNIST 上准确率 99%感觉自己已经入门了。然后从第 51 集开始往后看画风突变——ResNet 一口气堆到 152 层Transformer 的 QKV 矩阵满天飞Adam 里的偏差修正带着一阶矩二阶矩一起上BERT 的预训练目标还分 MLM 和 NSP 两个头。这一段是全课程的硬骨头也是从能跑通代码到能调得住模型的分水岭。我自己是把这段内容拆成三遍过的第一遍纯看视频、抄代码、跑通第二遍关掉视频对着空白 notebook 从零重写关键模块第三遍拿一个自己的小数据集把结构改一遍。三遍下来最深的感受是这一段的每一个模块都不是孤立的它们像搭积木一样层层咬合——没有 BN 和残差连接ResNet 那一摞卷积根本训不动没有编码器-解码器的铺垫注意力机制就是一堆看不懂的矩阵乘法没有前面 SGD 和动量的积累Adam 的公式只能死记硬背。需要说明的是各家平台对集数的切分并不统一YouTube 版、B站版、教材配套视频的编号会有出入所以第 51111 集覆盖的内容大体会落在这么几个板块上现代卷积神经网络的结构演进、计算机视觉的几类典型任务、序列建模与注意力机制、优化算法的进阶、以及预训练与微调。如果你手上的版本略有偏移按这个骨架去对应就行核心知识点不会跑。适合谁看这部分内容我能想到三类人。一类是本科或研究生阶段要做课题、需要自己搭网络跑实验的一类是转行过来、已经会一点 Python 和 PyTorch、但没系统学过网络结构设计的还有一类是工作里要用到视觉或文本任务、想快速补齐原理短板的工程师。第三类人往往最急也最容易在这一段摔跟头——因为他们习惯直接调库遇到收敛问题就束手无策。这一篇笔记我打算按整体地图 → 结构细节 → 任务实战 → 序列与注意力 → 优化 → 预训练 → 工程环境 → 排错 → 个人经验的顺序来组织。每一块我都会补上视频里一句话带过、但实际动手时必须自己想明白的东西比如卷积输出尺寸怎么手算、感受野怎么递推、注意力复杂度为什么是平方级、学习率和批大小之间那条经验缩放规则到底靠不靠谱。这些东西教材上写得比较散我尽量把它们串成一条线。1.1 从能跑通到能调好的能力跃迁很多人卡在第 51 集以后并不是因为数学看不懂而是因为思维模式没切换过来。前 50 集的任务代码写对了结果就不会太差第 51 集之后代码写对了只是及格线真正的差距体现在为什么这样设计和出问题往哪调。举个很典型的例子。你在 ResNet 里加一个残差块用 1×1 卷积调整通道数代码能跑。但如果我问你为什么步幅为 2 的时候必须用 1×1 卷积做投影不用会怎样这个问题在后面调模型的时候会反复出现——形状不匹配报错只是表面现象深层原因是恒等映射的通道维度和空间尺寸必须对齐否则残差相加这一步在数学上就不成立。再比如批量归一化。视频里讲得很清楚训练时用当前批次的均值和方差推理时用滑动平均。但真正上手写推理代码时很多人会忘记model.eval()结果单张图片预测的结果和训练时的验证集准确率差了一大截。这个坑我踩过不止一次而且它极其隐蔽因为代码不报错只是结果不对。所以这一段的学习目标应该定成三句话看懂每个模块解决的是什么问题能默写出关键模块的代码骨架遇到不收敛、梯度异常、显存爆炸时知道从哪几个旋钮开始拧。这三条做到了后面的实战项目才有底气。1.2 这个区间的知识地图与依赖关系我把第 51111 集的知识依赖关系画成一条链这里不用图用文字说最底层是优化算法它决定了参数怎么更新是所有训练的前提往上一层是卷积结构的演进从 VGG 的堆叠思路到 NiN 的 1×1 卷积、GoogLeNet 的多分支、再到 ResNet 的残差本质是在解决更深是否更好这个问题再往上是视觉任务的落地图像增广、微调、目标检测、语义分割、样式迁移它们把前面学到的骨干网络用到具体场景另一条主线是序列建模从 RNN 的梯度问题出发经过 GRU、LSTM 的门控设计到编码器-解码器和注意力最后收敛到 Transformer最上层是预训练与微调BERT 把 Transformer 的编码器拿来大规模预训练再用到各种下游任务。理解了这条链你就知道为什么不能跳着看。跳过 BN 直接看 ResNet会觉得残差块里的 BN 是可有可无的装饰跳过 seq2seq 直接看 Transformer会觉得位置编码是个莫名其妙的附加项。1.3 学习前需要准备的三样东西第一样是一块能跑得动实验的 GPU。ResNet-18 在 CIFAR-10 上跑一轮大概几秒到几十秒用 CPU 也能忍但到了 BERT 微调或者目标检测CPU 基本没法用。本地显卡够用就本地不够就租云端算力按小时计费的那种跑完就关成本可控。选硬件时优先看显存因为显存决定你能开多大批大小而批大小又直接影响 BN 的统计质量和训练稳定性。第二样是一份能改的代码仓库。官方 d2l 仓库的 notebook 是很好的起点但一定要复制一份到自己目录下改。直接在原仓库里改后面更新会冲突而且你会舍不得删掉那些其实没用的实验代码。第三样是记录工具。我习惯用一个 Markdown 文件按集数记笔记每条笔记只写三件事这个模块解决的问题、关键公式或代码、我踩到的坑。坚持记到 111 集回头翻一遍你会发现这份笔记比任何教程都好用因为它是针对你自己的盲区写的。提示不要一边看视频一边跟着敲代码。视频里打字速度很快跟敲的结果是手在动、脑子没动。正确做法是先看完整段理解整体流程然后再关掉视频自己写一遍卡住的地方才是真正需要记笔记的地方。2. 现代卷积网络从 VGG 到 ResNet 的结构演进这一段大概是第 51 集到第 70 集之间的内容也是我个人认为整门课里最值得反复看的部分。原因很简单它讲的不是某个具体的网络而是网络结构设计的一整套方法论。你把这套方法论吃透了看到任何一篇新论文的模型图都能在几分钟内判断出它的设计意图和可能的瓶颈。2.1 卷积输出尺寸与感受野的手算方法视频里讲卷积的时候输出尺寸公式是直接给的。但实际调模型时你需要在脑子里快速估算所以推导过程得自己走一遍。设输入高为 H、卷积核大小为 k、填充为 p、步幅为 s输出高为$$ H_{out} \left\lfloor \frac{H 2p - k}{s} \right\rfloor 1 $$width 方向同理。宽高不一样的时候分开算别偷懒。拿几个常见配置验证一下。输入 224×224k7p3s2代入得到 (2246-7)/2 1 112.5 向下取整再加 1结果是 112。这就是 ResNet 第一层 7×7 卷积把分辨率砍半的由来。换成 k3、p1、s1输出 (2242-3)/11 224尺寸不变这是最常用的保持分辨率配置。感受野的递推稍微绕一点。单层感受野等于核大小 k。多层叠加时$$ RF_{l} RF_{l-1} (k_l - 1) \times \prod_{i1}^{l-1} s_i $$其中乘积部分是前面所有层步幅的连乘。我用 ResNet-18 的前几层验算过conv1 是 7×7、s2感受野 7maxpool 是 3×3、s2累积步幅 4感受野变成 7 (3-1)×2 11接下来每个 3×3、s1 的卷积累积步幅保持 4每层加 2×(4)8。三个卷积下来感受野就到 35 左右了。这个计算为什么有用因为当你发现模型对某个尺度的目标识别不好时第一反应应该是查感受野够不够。小目标需要小感受野大目标需要大感受野感受野和数据集里目标的平均像素尺寸对不上再多的数据增强也救不回来。注意池化层和带步幅的卷积都会累积步幅算感受野的时候一定要把它们的步幅算进连乘里。我见过太多人只算卷积核大小结果估出来的感受野比真实值小一大截。2.2 批量归一化把训练不稳定的锅甩给分布漂移批量归一化Batch Normalization简称 BN在视频里的篇幅不算长但它的实际影响极大。原理层面它做的事情是对每个通道在一个批次的样本维度上求均值和方差把激活值标准化到均值 0、方差 1然后再用两个可学习参数 γ 和 β 做仿射变换把表达能力还回来。那个再把表达能力还回来的步骤非常关键很多人会忽略。如果没有 γ 和 β标准化会把激活强行压成标准正态网络能表达的函数空间就变小了反而伤害性能。所以代码里nn.BatchNorm2d的默认初始化是 γ1、β0训练开始阶段等价于纯标准化然后让网络自己去学该保留多少幅度。训练和推理的行为差异是另一个必须搞明白的点。训练时用当前批次的统计量同时用动量PyTorch 默认 0.1维护一份全局滑动平均推理时直接用这份滑动平均。这个设计的原因是推理阶段可能只有一张图片样本量太小算出来的均值和方差波动巨大用它做标准化会让结果完全不稳定。import torch from torch import nn bn nn.BatchNorm2d(64, momentum0.1, eps1e-5) x torch.randn(32, 64, 56, 56) # 训练模式用当前批次统计量并更新 running_mean / running_var bn.train() y_train bn(x) # 推理模式用滑动平均统计量不再更新 bn.eval() with torch.no_grad(): y_eval bn(x)实测下来BN 带来的收益主要体现在三个方面允许更大的学习率我试过把学习率从 0.01 调到 0.1 依然能收敛、对初始化不那么敏感、以及自带轻微的正则效果因为每个批次的统计量都带噪声。反过来说BN 对批大小很敏感批大小小于 8 的时候统计量噪声太大效果会明显变差这时候可以考虑换成组归一化GroupNorm。2.3 残差连接为什么能让网络深到 1000 层残差块的核心公式只有一行输出 激活(卷积路径(x) 恒等路径(x))。但这一行解决了一个困扰学界多年的问题——深层网络的退化。退化现象是这样的把 20 层网络加深到 56 层训练误差反而上升。注意不是过拟合导致的验证误差上升而是训练误差本身就在涨。这说明问题不在数据量而在优化本身。理论上深网络至少能学出浅网络的解多出来的层学恒等映射就行但实际优化器找不到这个解。残差连接的巧妙之处在于它把学恒等映射这件事变得极其容易。恒等路径直通卷积路径只要学成零整个块就等价于恒等映射。让一堆权重趋近于零比让一堆非线性层精确模拟恒等映射容易太多。这一下就把优化的难度降下来了。import torch from torch import nn from torch.nn import functional as F class Residual(nn.Module): def __init__(self, in_channels, out_channels, use_1x1convFalse, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, padding1, stridestride) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(out_channels) self.bn2 nn.BatchNorm2d(out_channels) # 形状不一致时用 1x1 卷积把恒等路径投影到相同形状 self.conv3 (nn.Conv2d(in_channels, out_channels, kernel_size1, stridestride) if use_1x1conv else None) def forward(self, x): y F.relu(self.bn1(self.conv1(x))) y self.bn2(self.conv2(y)) if self.conv3 is not None: x self.conv3(x) return F.relu(y x)关于use_1x1conv什么时候开我的经验是只要输入输出通道数不同或者步幅不为 1就必须开。有些实现会在步幅不为 1 但通道数相同时省略 1×1 卷积靠切片下采样对齐效果也能接受但会损失一部分信息不太推荐。2.4 结构对比表与选型建议把这一段涉及的几个经典结构放在一起对比选型时思路会清晰很多。网络核心设计参数量级适用场景我踩过的坑VGG3×3 卷积反复堆叠全连接层超大130MVGG-16教学、特征提取全连接层吃掉七成参数显存炸得快NiN1×1 卷积替代全连接全局平均池化1M 左右轻量任务、通道融合全局池化太早会丢失空间信息GoogLeNetInception 多分支并联1×1 降维6M 左右计算资源受限场景分支拼接维度要提前算清楚ResNet残差连接 BN可堆到千层11MResNet-18通用骨干网络学习率预热不做前期容易抖选型的经验法则是如果是全新任务、没有先验直接用 ResNet 系列的预训练权重做骨干省时省力如果是移动端或嵌入式场景考虑轻量结构比如 MobileNet 系的思路如果只是做消融实验验证某个想法用一个小 ResNet18 或 34 层就够了别一上来就上 152 层训练三小时才跑完一轮调参成本太高。顺带说一句训练深层网络时学习率预热warmup几乎是标配。前几百步用很小的学习率线性爬升到目标值能显著减少早期的震荡。这个技巧在 Transformer 那一段会更重要但在 ResNet 上先用起来也不亏。3. 计算机视觉任务增广、微调、检测与分割从卷积结构过渡到具体任务是这一段最让人兴奋的地方因为你终于能做出看起来像那么回事的东西了。但也是坑最多的地方因为每个任务都有自己的评价指标、数据格式和调参套路稍不注意就会出现损失在降但指标不动的诡异局面。3.1 图像增广与微调小数据集的救命稻草图像增广的本质是在不改变语义标签的前提下人工扩大数据分布的覆盖范围。翻转、裁剪、颜色抖动、随机擦除这些操作都是在告诉模型这些变化不影响类别。视频里演示的是从零训练和加增广训练在 CIFAR-10 上的对比加增广之后测试误差能降好几个百分点。但增广不是随便加的。翻转对猫狗分类有效对数字识别6 和 9 翻转后语义变了就可能有害颜色抖动对物体分类有效对需要靠颜色判别纹理的任务可能有害。所以增广策略要和任务语义对齐这是视频里没有明说但极其重要的一条。微调则是另一套逻辑拿一个在大规模数据集比如 ImageNet上预训练好的模型把最后的分类头换成本任务需要的类别数然后在新数据上继续训练。关键在于分阶段设置学习率——骨干网络用很小的学习率甚至先冻结新加的头部用正常学习率。原因是骨干已经学好了通用特征大学习率会把它们摧毁。import torch from torch import nn from torchvision import models net models.resnet18(weightsmodels.ResNet18_Weights.DEFAULT) # 冻结骨干只训练新的分类头 for p in net.parameters(): p.requires_grad False net.fc nn.Linear(net.fc.in_features, 10) # 头部参数默认 requires_gradTrue优化器只接它 optimizer torch.optim.SGD(net.fc.parameters(), lr0.01, momentum0.9) # 训练若干轮后解冻骨干用小学习率微调 for p in net.parameters(): p.requires_grad True optimizer torch.optim.SGD([ {params: [p for n, p in net.named_parameters() if not n.startswith(fc)], lr: 1e-4}, {params: net.fc.parameters(), lr: 1e-3}, ], momentum0.9)这里有个实操细节优化器的参数分组必须在解冻之后重新创建因为 PyTorch 的优化器在初始化时就把参数引用了固定下来后面改requires_grad不会自动生效。我第一次做的时候在这上面浪费了半小时反复确认梯度确实算出来了但参数就是不更新。3.2 目标检测三件套锚框、IoU、NMS 手算实例目标检测是这一段难度陡增的地方因为它引入了三个全新的概念锚框、交并比IoU和非极大值抑制NMS。这三个概念必须手算一遍才算真懂。锚框的思路是在特征图的每个位置上预先放置若干个不同尺度和长宽比的矩形框模型不直接预测框的位置而是预测真实框相对于锚框的偏移量。这样做的好处是预测目标的数值范围被归一化到了很小的区间回归更容易。交并比就是两个框的交集面积除以并集面积。举个具体的数框 A 是 (0, 0, 100, 100)框 B 是 (50, 50, 150, 150)。交集是 (50, 50, 100, 100)面积 2500A 面积 10000B 面积 10000并集 17500IoU 2500/17500 ≈ 0.143。检测任务里通常把 IoU 大于 0.5 的预测框算作正样本。非极大值抑制解决的是同一个物体被预测出几十个框的问题。算法很朴素把所有预测框按置信度排序取最高的那个放进结果集然后把和它 IoU 超过阈值常用 0.5 或 0.7的框全部删掉重复这个过程直到没有剩余框。import torch def nms(boxes, scores, iou_threshold0.5): # boxes: (N, 4) 的 xyxy 格式; scores: (N,) keep [] order scores.argsort(descendingTrue) while order.numel() 0: i order[0].item() keep.append(i) if order.numel() 1: break ious box_iou(boxes[i:i1], boxes[order[1:]])[0] # 保留 IoU 小于阈值的框 order order[1:][ious iou_threshold] return torch.tensor(keep)NMS 的阈值是个需要调的参数。阈值调低比如 0.3相邻的框会被压掉更多密集场景下容易漏检阈值调高比如 0.8重叠的框保留得多密集场景下效果好但单个物体会出现重复框。我在做人脸相关任务时的经验是密集小目标用 0.60.7稀疏大目标用 0.40.5。注意NMS 是逐类别执行的。如果一张图里有猫有狗必须对每个类别分别做 NMS否则猫的框可能把狗的框压掉。这个细节在实现时很容易漏漏了之后表现为某些类别的召回率异常低。3.3 语义分割与转置卷积从通道到像素语义分割要求输出和输入同分辨率的逐像素分类结果。从特征图恢复到原分辨率最常用的手段是转置卷积。转置卷积这个名字容易误导人它不是卷积的逆运算而是把卷积的前向和反向调换过来的一种上采样操作。实现上它通过在输入元素之间插入零、然后做普通卷积来放大尺寸。输出的尺寸公式是$$ H_{out} (H_{in} - 1) \times s - 2p k $$可以看到它就是卷积输出公式的逆解。用 k3、s2、p1、输出填充 1 的配置输入 56×56 能得到 112×112正好翻倍。全卷积网络FCN的思路是用转置卷积把骨干网络最后那层 7×7 的小特征图逐步放大回原尺寸中间还通过跳跃连接把浅层的高分辨率特征融合进来。这个跳跃连接的动机很直白深层特征语义强但位置糙浅层特征位置准但语义弱两者相加互补。我实际做分割任务时发现两个经验点。第一转置卷积容易产生棋盘格伪影改用最近邻插值 3×3 卷积的组合可以缓解代价是参数量略增。第二分割任务的评估指标 IoU 对边界极其敏感数据标注的边界质量直接决定上限模型结构再优化收益也有限。3.4 样式迁移内容损失与风格损失的配比实验样式迁移是这段内容里最有魔法感的一节。核心思路是固定网络权重把生成的图像当作可学习参数去优化损失函数由三部分组成。内容损失衡量生成图和内容图在深层特征上的差异风格损失衡量生成图和风格图在多个层的 Gram 矩阵上的差异全变分损失则用来抑制噪声、让图像更平滑。总损失的形式是$$ L \alpha \cdot L_{content} \beta \cdot L_{style} \gamma \cdot L_{TV} $$这三个系数的配比直接决定结果风格。我自己做了几组对照实验结论是β/α 在 1e-2 到 1e-4 之间调节比值越大风格越浓但超过 1e-2 之后内容基本看不清了γ 通常取 1e-6 到 1e-8 量级太大图像会糊太小会有噪声斑点。这个任务还有个实操技巧初始化方式影响很大。用内容图初始化比用噪声初始化收敛快得多通常几百次迭代就能出效果噪声初始化可能要上千次。另外优化器用 Adam 比 SGD 收敛快学习率 0.01 到 0.1 之间比较合适。4. 序列建模与注意力机制从 RNN 走到 Transformer如果说卷积分支是在处理空间结构那这一支就是在处理时间结构。从第 80 集左右开始课程进入序列建模一路走到注意力机制和 Transformer最后接上 BERT。这条线是目前整个深度学习领域最活跃的方向也是面试里问得最深的部分。4.1 RNN 的梯度问题与 GRU/LSTM 的门控设计循环神经网络的问题在视频里讲得很透反向传播要沿时间步展开梯度是连乘形式连乘项里有一堆权重矩阵和激活函数导数。当这些项都小于 1 时梯度指数级衰减大于 1 时指数级爆炸。梯度爆炸相对好办做梯度裁剪就行——把梯度的 L2 范数超过阈值常用 5 或 10的部分按比例缩小。梯度衰减就麻烦多了需要从结构上改。LSTM 和 GRU 都是靠门控机制来解决的。LSTM 有三个门遗忘门决定丢弃多少历史记忆输入门决定写入多少新信息输出门决定暴露多少当前状态。GRU 简化成两个门重置门和更新门。它们共同的关键设计是加法形式的记忆更新——新状态 旧状态的加权 新候选的加权。加法让梯度能沿这条路径近乎无损地回传缓解了连乘衰减。代码层面PyTorch 的nn.LSTM和nn.GRU已经把门控都封装好了但有几个参数容易搞混。num_layers是层数不是时间步batch_firstTrue会让输入形状变成 (batch, seq_len, feature)返回的h_n和c_n是最后时间步的隐状态形状是 (num_layers, batch, hidden)。我在拼接多层 LSTM 时忘记在层间加 dropout结果过拟合得很快加上dropout0.2之后验证损失稳了很多。提示nn.LSTM的 dropout 只在层与层之间生效单层 LSTM 传 dropout 参数是无效的。这个是官方文档里的小字说明不仔细看很容易以为设了没效果。4.2 编码器-解码器与 seq2seq 的曝光偏差机器翻译是典型的序列到序列任务输入长度和输出长度都不固定。编码器-解码器架构把这个问题拆成两步——编码器把变长输入压成一个固定长度的上下文向量解码器从这个向量出发逐步生成输出。这个架构有个明显的瓶颈所有信息都挤在一个固定维度的向量里句子越长前部信息丢失越严重。注意力机制正是为了解决这个瓶颈而生的它让解码器在每一步都能直接看到编码器的所有隐藏状态按相关性加权取用。seq2seq 训练时还有个常被忽略的问题曝光偏差。训练时解码器的输入用的是真实标签teacher forcing推理时用的是自己上一步生成的词。两者分布不一致会导致推理阶段误差累积。缓解手段有几种计划采样按概率逐渐从真实标签切换到预测值、标签平滑、以及直接用更强的模型比如 Transformer减轻单步误差的影响。我在做一个小规模翻译实验时对比过两种配置纯 teacher forcing 训练收敛快但推理时 BLEU 涨到某个值就上不去了加上计划采样之后收敛慢了约三成但最终 BLEU 高了两个多点。这个差距在小数据集上尤其明显。4.3 注意力机制的 QKV 拆解与手算示例注意力机制的公式就三行$$ \text{Attention}(Q, K, V) \text{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}\right)V $$Q 是查询K 是键V 是值。直观理解用查询去和每个键算相似度相似度归一化成权重然后按权重对值加权求和。为什么要除以 √d_k因为当 d_k 比较大时Q 和 K 的点积方差会随维度线性增长softmax 的输入数值跨度过大会导致梯度趋近于零也就是常说的 softmax 饱和。除以 √d_k 把方差拉回 1 附近梯度就正常了。这个缩放因子在 d_k64 时影响不大但 d_k512 时不加缩放训练会明显变慢。我用手算验证过一个 2×2 的小例子。假设 Q [[1, 0], [0, 1]]K 相同d_k 2。QK^T [[1, 0], [0, 1]]除以 √2 得到 [[0.707, 0], [0, 0.707]]。softmax 按行做第一行 exp(0.707)2.028exp(0)1归一化后是 [0.670, 0.330]。可以看到即使 Q 和 K 完全对齐权重也不是 1 和 0而是被 softmax 平滑过的。理解这一点对调试很重要——注意力权重天然是软的不要期待它给出硬性的 0/1 选择。4.4 多头注意力与位置编码的实现细节多头注意力是把 Q、K、V 分别投影到 h 个子空间各自做注意力最后拼接再投影回来。它的动机是让不同的头关注不同的模式——有的头关注语法依赖有的头关注位置邻近有的头关注罕见的语义关联。实验里可视化注意力权重确实能看到这种分工。位置编码是 Transformer 的必需品因为注意力机制本身对序列顺序完全无感——打乱输入顺序输出的集合是一样的。原始方案用正弦余弦函数$$ PE_{(pos, 2i)} \sin\left(\frac{pos}{10000^{2i/d}}\right), \quad PE_{(pos, 2i1)} \cos\left(\frac{pos}{10000^{2i/d}}\right) $$这个设计的好处是不需要学习参数而且任意位置之间的相对关系可以通过线性变换表达长度泛化性比可学习的位置嵌入更好。import torch import math from torch import nn class PositionalEncoding(nn.Module): def __init__(self, d_model, max_len5000, dropout0.1): super().__init__() self.dropout nn.Dropout(dropout) pe torch.zeros(max_len, d_model) pos torch.arange(0, max_len, dtypetorch.float).unsqueeze(1) div torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) pe[:, 0::2] torch.sin(pos * div) pe[:, 1::2] torch.cos(pos * div) self.register_buffer(pe, pe.unsqueeze(0)) # (1, max_len, d_model) def forward(self, x): # x: (batch, seq_len, d_model) return self.dropout(x self.pe[:, :x.size(1)])用register_buffer而不是普通属性是为了让位置编码跟着模型一起.to(device)和保存到 state_dict但又不会被当成可训练参数。这个细节很实用我第一次写的时候用了普通张量结果模型搬到 GPU 上报设备不匹配。5. 优化算法与训练稳定性参数更新背后的直觉优化算法这一段看起来数学味最重但实际上是所有实验的基础设施。你可以不懂反向传播的每个链式法则但必须清楚 Adam 里的每个超参数在干什么否则遇到不收敛就只能瞎调。5.1 从 SGD 到动量为什么需要惯性最朴素的随机梯度下降就是沿着当前梯度的反方向走一步。它的两个痛点是在峡谷形损失面上会来回震荡在鞍点附近会停滞。动量法引入了一个速度变量把历史梯度累积起来$$ v_t \beta v_{t-1} (1-\beta) g_t, \quad \theta_t \theta_{t-1} - \eta v_t $$β 通常取 0.9。直观理解就是给下山的小球加了惯性方向一致的梯度会加速来回震荡的方向会相互抵消。我做过一个很直观的对比实验在 Rosenbrock 函数上跑 SGD 和动量法目标精度 1e-4。纯 SGD 需要上万步动量法一千多步就到了而且轨迹平滑得多。这个差距在高维参数空间里会被进一步放大所以现在几乎没人用纯 SGD 训练深度网络。Nesterov 动量是在动量基础上的改进先用当前动量做一个粗略的前瞻在那个位置上算梯度而不是在当前位置算。理论上收敛更快实际提升幅度视任务而定我在 CNN 上没感觉到明显差异但在某些凸问题上确实快一些。5.2 Adam 的偏差修正与学习率调度策略Adam 同时维护梯度的一阶矩均值和二阶矩方差并用它们分别做分子和分母$$ m_t \beta_1 m_{t-1} (1-\beta_1) g_t, \quad v_t \beta_2 v_{t-1} (1-\beta_2) g_t^2 $$$$ \hat{m}_t \frac{m_t}{1 - \beta_1^t}, \quad \hat{v}t \frac{v_t}{1 - \beta_2^t}, \quad \theta_t \theta{t-1} - \frac{\eta \hat{m}_t}{\sqrt{\hat{v}_t} \epsilon} $$偏差修正是 Adam 里最容易被忽略、但影响最大的设计。因为 m 和 v 都初始化为 0训练刚开始时它们会严重偏向零。不加修正的话前几十步的更新量会被压得极小学习率实际上等于被打了折扣。除以 (1-β^t) 之后t 很小时这个分母接近 1-β正好把初始偏置补回来。import torch def adam_manual(params, grads, m, v, t, lr1e-3, beta10.9, beta20.999, eps1e-8): for i, (p, g) in enumerate(zip(params, grads)): m[i] beta1 * m[i] (1 - beta1) * g v[i] beta2 * v[i] (1 - beta2) * g * g m_hat m[i] / (1 - beta1 ** t) v_hat v[i] / (1 - beta2 ** t) p - lr * m_hat / (torch.sqrt(v_hat) eps) return params, m, v学习率调度方面常用的有几种。分段常数衰减每 30 轮降十倍最经典简单粗暴但有效余弦退火在大模型训练里很流行平滑下降还能配合热重启线性预热则在 Transformer 系列里几乎是必须的。我在训练 BERT 类模型时前 10% 的步数做线性预热、之后线性衰减到零这个组合稳定得最好。5.3 学习率、批大小与泛化误差的经验关系有一条流传很广的经验规则批大小翻倍学习率也翻倍。它的理论依据是梯度估计的方差和批大小成反比批大小越大梯度越准可以迈更大的步子。但这条规则有边界。批大小超过某个阈值通常和数据集规模、模型容量有关之后泛化性能会开始下降——这就是大 batch 陷阱。原因在于大 batch 训练出来的模型倾向于收敛到尖锐的极小值对参数扰动更敏感测试集上表现就差一些。缓解手段有加 warmup、用 LARS/LAMB 这类分层自适应优化器、或者干脆控制批大小不要太大。另一个相关概念是泛化误差界。理论上的界通常由训练误差加上一个与模型复杂度、样本量相关的项构成。实践中这个界往往很松不能直接拿来指导调参但它的定性结论是有用的模型容量和样本量的比值越大过拟合风险越高这时候要么加数据、要么加正则、要么缩小模型。我在小数据集上做实验时把 ResNet-18 换成 ResNet-34训练误差降了但验证误差涨了就是典型的容量过剩。注意批大小还会影响 BN 的行为。批大小从 32 降到 4BN 的统计噪声会明显变大训练损失曲线会变得很抖。这时候要么恢复批大小要么改用 GroupNorm别硬扛。6. 预训练与微调自然语言处理的落地路径第 100 集往后进入预训练模型的部分。这一段的价值在于它展示了一套完全不同的建模范式不再从零训练而是先在大规模无标注语料上学通用表示再在下游任务上用小数据微调。这套范式现在已经成了 NLP 领域的默认做法。6.1 词向量与 BERT 的预训练目标词向量是预训练的起点。最早的思路是把每个词映射成一个稠密向量让语义相近的词在向量空间里距离也近。经典的实现方式有基于共现矩阵分解的也有基于预测的比如用中心词预测上下文、或者用上下文预测中心词。评估词向量的经典方法有类比推理向量(king) - 向量(man) 向量(woman) 应该接近向量(queen)。不过静态词向量有个致命问题多义词无法区分。bank 在河岸和银行两个意思下的向量是同一个。BERT 用上下文相关的动态表示解决了这个问题——每个词的表示由整句话决定。BERT 的预训练有两个目标。掩码语言模型MLM随机遮住 15% 的词让模型预测这是双向理解的来源下一句预测NSP判断两句话是否连续用来学句子级别的关系。实际实现 MLM 时有个细节被选中的 15% 里80% 换成 [MASK]10% 换成随机词10% 保持不变。这个比例设计的目的是缓解训练和推理的不一致——推理时根本没有 [MASK] 标记如果训练时全是 [MASK]模型会过度依赖这个标记。后来的研究比如 RoBERTa发现 NSP 任务作用有限去掉之后效果反而更好同时把静态掩码改成动态掩码每轮重新采样掩码位置。这些改进思路值得了解因为它们体现了预训练目标设计这件事本身的迭代逻辑。6.2 下游任务微调分类、推断与问答的改造方式BERT 的输出是每个 token 的上下文表示。针对不同任务接不同的头就行。单句分类任务比如情感分析取 [CLS] 位置的表示接一个全连接层输出类别数句对分类任务比如自然语言推断把两句话拼成[CLS] 句A [SEP] 句B [SEP]同样取 [CLS]问答任务比如抽取式阅读理解需要对每个 token 预测是不是答案起点和是不是答案终点接两个线性头分别输出起点和终点的分数。import torch from torch import nn from transformers import BertModel class BertForSentencePair(nn.Module): def __init__(self, num_classes3, dropout0.1): super().__init__() self.bert BertModel.from_pretrained(bert-base-uncased) self.dropout nn.Dropout(dropout) self.classifier nn.Linear(self.bert.config.hidden_size, num_classes) def forward(self, input_ids, attention_mask, token_type_ids): out self.bert(input_idsinput_ids, attention_maskattention_mask, token_type_idstoken_type_ids) pooled out.pooler_output # [CLS] 经过一层 tanh 的结果 return self.classifier(self.dropout(pooled))微调时的几个经验参数学习率用 2e-5 到 5e-5比从零训练小一两个数量级训练轮数通常 3 到 5 轮就够再多会过拟合批大小 16 或 32显存不够就用梯度累积。我第一次微调时用了 1e-3 的学习率第一轮损失就飙到 nan教训很深刻。另一个容易忽略的点是序列截断长度。BERT 的位置编码最大支持 512超过就会被截断。如果你的任务里关键信息总在句尾截断策略从头截还是从尾截会显著影响结果。我处理长文档分类时试过头尾各取一半的截断方式比单纯截头效果好不少。7. 环境与工程实践让实验跑得又快又稳理论讲完得回到跑代码这件事上。这一段内容跨度大涉及的网络也从几 MB 涨到几百 MB环境配置和工程习惯的重要性会急剧上升。我见过太多人在原理上理解得挺清楚但一跑就报错最后时间都花在修环境上了。7.1 环境配置与显存管理的实测经验环境配置的第一原则是版本固定。PyTorch、CUDA、cuDNN、Python 之间的版本兼容关系相当严格用 conda 或者 uv 建独立环境把版本号写进 requirements 文件。我习惯在项目根目录放一个environment.yml写清楚 pytorch、torchvision、numpy 等核心包的版本。# 建环境并固定版本 conda create -n d2l python3.10 -y conda activate d2l pip install torch2.1.0 torchvision0.16.0 --index-url https://download.pytorch.org/whl/cu118 pip install numpy pandas matplotlib jupyter pip freeze requirements.txt显存管理是最实际的问题。几个常用的手段按优先级排第一把批大小调小这是最直接的第二用混合精度训练torch.cuda.amp显存占用能降三到四成速度还能提升第三及时释放中间变量尤其是那些保存了计算图的张量用del加torch.cuda.empty_cache()第四推理时用torch.no_grad()或torch.inference_mode()。这里有个大坑训练循环里如果累积了 loss比如total_loss loss而 loss 还带着计算图显存会一路涨到爆。正确写法是total_loss loss.item()或者loss.detach()。这个 bug 极其常见表现是前几十个 batch 正常后面突然 OOM排查起来很费时间。提示怀疑显存泄漏时可以用torch.cuda.memory_allocated()在每个 batch 后打印一次看数值是否持续增长。持续增长基本就是计算图没释放。7.2 数据加载、随机种子与结果复现数据加载的效率经常被低估。默认的 DataLoader 是单进程的GPU 利用率可能只有 30%。把num_workers设成 CPU 核心数的 70% 左右比如 8 核设 6配合pin_memoryTrue通常能把 GPU 利用率提到 80% 以上。from torch.utils.data import DataLoader loader DataLoader( dataset, batch_size64, shuffleTrue, num_workers6, pin_memoryTrue, drop_lastTrue, # 避免最后一批只有一个样本影响 BN persistent_workersTrue, # 多轮训练时复用进程省去反复启动开销 )drop_lastTrue这个参数在用了 BN 的网络里特别重要。如果最后一批只有一两个样本BN 的统计量会严重失真损失曲线会出现周期性尖刺。复现性方面需要固定的随机源不止一个Python 的 random、NumPy 的 random、PyTorch 的 CPU 和 CUDA 随机数还有 cuDNN 的算法选择。import random import numpy as np import torch def set_seed(seed42): 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要提醒一句deterministicTrue会让某些卷积算法不可用训练速度可能下降 10% 到 30%。所以我的做法是实验阶段开确定性保证可复现最终跑大规模训练时关掉换速度。另外即使全部设了种子不同硬件、不同驱动版本、不同并行策略下仍然可能有微小差异要求完全逐位复现是不现实的能保证指标在同一量级就足够了。8. 常见问题排查速查表这一段内容对应的实验复杂度高出问题的花样也多。我把这几年遇到的高频问题和排查顺序整理成表遇到故障时可以按顺序过一遍通常能定位到七八成。8.1 训练不收敛与损失异常现象最可能的原因排查动作损失一直是 nan学习率过大、除零、log(0)学习率降 10 倍检查损失函数里有没有 log 或除法加 eps损失剧烈震荡批太小、学习率偏大、BN 统计不稳增大批大小加 warmup检查 drop_last损失不降但也不涨学习率过小、梯度被截断成零、参数没更新打印参数前后差值临时去掉梯度裁剪训练损失降、验证损失涨过拟合加数据增广、加 dropout、加权重衰减、早停训练损失就很高欠拟合或标签错误用小批量过拟合测试人工抽样检查标签准确率卡在类别数的倒数模型输出被忽略、损失函数接错检查 forward 返回值是否被用到检查类别数用小批量过拟合测试这个技巧值得单独说。取 10 张图片、把模型容量调大、关掉所有正则训练几百步。如果训练损失降不到接近零说明模型或数据管道有 bug跟超参数无关。这一步能把问题范围缩小一半我几乎每次搭新网络都会先跑一遍。8.2 显存不足与训练速度慢显存问题按优先级排查先看批大小和输入分辨率这两个是平方级影响再看模型参数量和中间激活深层网络的激活占用往往比参数还大然后检查有没有意外保留计算图最后才考虑换模型或换硬件。输入分辨率的影响经常被低估。把 224 提到 448特征图面积变成 4 倍激活显存也差不多 4 倍。做检测和分割任务时这一项往往是显存的主要消耗方。这时候可以用梯度检查点gradient checkpointing用时间换空间显存能降一半多代价是训练慢 20% 到 30%。速度慢的排查则要看瓶颈在哪。用torch.profiler跑几十个 batch看时间花在数据加载、前向、反向还是优化器更新上。数据加载占大头就调num_workers前向反向占大头就考虑混合精度优化器占大头Adam 在小模型上确实会比较重可以换 SGD 或 Adagrad。我遇到过一次训练极慢的情况最后发现是num_workers0加上每张图片都做了很重的在线增广改完之后单轮时间从 12 分钟降到 3 分钟。8.3 复现不出论文结果的排查顺序复现失败是常态能复现才是意外。我的排查顺序是这样的先确认数据预处理完全一致。均值方差、归一化方式、增广强度、图像插值算法是 bilinear 还是 bicubic这些细节论文里经常一笔带过但影响可能超过一个点。然后是优化器配置学习率调度曲线是分段还是余弦、warmup 多少步、权重衰减是否只作用于权重不作用于 BN 参数。再然后是训练轮数和批大小的实际组合很多论文报的是训了 300 轮、批大小 4096的结果你用批大小 64 训 100 轮差距是结构性的。如果以上都对上了还是差就要怀疑初始化和随机性。有些方法对初始化极其敏感换个种子结果波动好几个点。这时候应该固定种子跑三次取平均而不是拿单次结果去对比。注意评估协议的差异是最隐蔽的坑。比如分类任务里用中心裁剪还是全卷积滑窗评估、有没有做多尺度测试时增强结果可能差一到两个点。复现前一定先把评估脚本对齐。9. 我的学习路径与踩坑记录聊了这么多原理和实操最后说说我自己是怎么消化这六十来集内容的以及过程中踩过的几个印象深刻的坑。9.1 笔记组织方式我用的是一份 Markdown 骨架每个模块下面固定四栏解决什么问题、核心公式或结构、关键代码片段、我的坑。这个格式的好处是强迫自己想清楚这个模块为什么存在而不是停留在它长什么样。举个例子记录 ResNet 那一节时我在解决什么问题栏写的是解决深层网络的退化问题让恒等映射容易被学出来。在我的坑栏写的是1×1 投影的步幅必须和主路径一致否则相加时尺寸对不上。这两句话现在回头看比当时抄的一大段公式都有用。另一个习惯是给每个跑通的实验留一份最小可复现脚本。脚本要短能在三十秒内跑完只保留和当前实验相关的部分。这样下次遇到类似问题直接拿这个脚本改不用从头搭。9.2 几个真实踩过的坑第一个坑是忘记切eval()模式。前面提过但值得再强调一次。表现是训练时验证准确率 92%部署成推理脚本后只有 60% 多。排查了半天以为是权重加载出错最后发现是 BN 和 dropout 还在训练模式。后来我的做法是在保存模型时同时保存一个model.eval()后的副本或者干脆把推理逻辑封装成一个单独的函数函数开头就调eval()。第二个坑是学习率预热没做。训练 Transformer 类模型时前几百步损失剧烈震荡偶尔直接 nan。加了 2000 步的线性预热之后曲线立刻平滑了。原因是训练初期注意力权重还没学好梯度方向噪声大这时候步子迈大了容易出事。第三个坑是数据增强用错了地方。做某个细粒度分类任务时我照搬了 ImageNet 的强增广随机裁剪 颜色抖动 随机擦除结果准确率比不加增广还低了三个点。后来分析发现这个任务的判别依据是纹理细节颜色抖动和擦除把关键信息毁掉了。减到只用水平翻转之后准确率才回来。这件事让我明白增广策略要跟着任务语义走不能无脑照抄。第四个坑是过早优化性能。我在 ResNet-18 上花了两天时间折腾各种混合精度和算子融合训练速度提了 15%。但后来发现换个更合适的学习率调度验证准确率直接涨了两个点远比那 15% 的速度有价值。工程优化要等模型结构和方法基本定型之后再考虑顺序反了就是浪费。第五个坑是关于复现心态的。我一开始总想把论文结果完全复现出来差 0.5 个点就焦虑。后来想通了大部分论文的数字都是在特定硬件、特定软件版本、多次实验取最好值的情况下得到的普通人复现差一到两个点属于正常范围。与其死磕小数点不如把注意力放在这个方法在我的数据上是否奏效、相比基线有没有稳定提升。这套课往后还有更偏前沿的内容但我个人的经验是把第 51111 集这六十集真正吃透收益比后面看十篇新论文都大。因为它们讲的是最基础也最耐用的东西——网络怎么设计、注意力怎么算、优化器怎么调、模型怎么落地。这些内容五年前管用现在管用大概率五年后依然管用。至于我自己的下一步是打算拿一个真实的小数据集把 ResNet 骨干、注意力模块和微调流程串成一个完整项目跑一遍把笔记里的每个知识点都用上一次这样才算真的学会了。