资讯动态

PyTorch源码精读:从动态计算图到SSD的深度学习实现

发布时间:2026/9/10 17:11:56 来源:尧图企业网站定制
简介《动手学深度学习》PyTorch版源代码包面向深度学习初学者及有Python基础的开发者将书中理论转化为可直接运行的代码示例。整个压缩包共400个文件约30.05MB以142个ipynb交互式笔记本、171个svg矢量图为主体另有png、jpg、tiff等图像素材和少量csv数据文件目录结构与教材章节紧密对应方便按需查阅。目前已有2178人学习使用适合自学、备课也可作为高校深度学习课程的实验素材。代码覆盖经典数据集加载与预处理、LeNet/ResNet等卷积神经网络、LSTM等循环神经网络的构建以及训练循环、反向传播、模型评估、保存与加载等核心环节。同时展示了nn.Module、autograd、optim等PyTorch关键模块的实际用法帮助读者理解动态计算图的调试思路并能在真实任务中灵活应用为后续人工智能研究与项目开发打下扎实基础。1. 源码仓库不用“刷”要用“对照”的方式读这份《动手学深度学习》PyTorch版源码仓库很多人拿到手就陷入“每个 notebook 都打开、每个 cell 都运行一遍”的误区。实际跑完一遍之后除了看到损失曲线往下掉很难留下系统性认知。真正值得读的是同一个主题下成对出现的那两个文件softmax-regression-scratch.ipynb和softmax-regression-concise.ipynb前者用最原始的 tensor 运算手写网络和训练后者用nn.Module三行搭完这种对照才是这个项目最有价值的设计。对刚接触深度学习的人这套源码的价值在于能看到“模型是怎么从公式变成矩阵运算的”对已经用 PyTorch 写过模型的人价值在于那些被框架隐藏的细节——梯度累积、softmax 数值稳定、形状对齐——会在这份代码里原形毕露。如果你是准备跑科研代码、复现论文实验的从业者这里每个 notebook 拆开都是一个完整的“从公式到评估”闭环适合拿来当拆代码训练的底稿。2. 动态计算图与 softmax 回归scratch 与 concise 的隐藏逻辑2.1 PyTorch 反向传播为什么是“自动”的理解 grad_fn 这条链PyTorch 的核心是动态计算图意思是前向传播每执行一行代码框架就同步记录一个操作节点形成一个有向无环图。这个图不从零开始构建也不预编译而是在forward过程中隐式地“长”出来开发者不需要看见图本身只需要在算完 loss 后调用loss.backward()框架就会沿着这张图从后往前做链式求导把梯度写入各叶子张量的.grad属性。x torch.randn(8, 10, requires_gradTrue) # 叶子张量需要梯度 w torch.randn(10, 3, requires_gradTrue) b torch.zeros(3, requires_gradTrue) logits x w b # 触发矩阵乘法和加法算子 loss logits.square().mean() loss.backward() # 沿着 grad_fn 链反向传播 print(w.grad.shape) # torch.Size([10, 3]) print(b.grad.shape) # torch.Size([3])注意这里没有手动调用任何求导函数也没有人显式指定“w 的梯度是多少”反向传播完全依据前向时记录的算子链条完成。这是 PyTorch 动态图机制最重要的使用直觉只要把网络层定义为nn.Parameter或设置了requires_gradTrue的 Tensor前向计算过程中发生的所有可微操作都会成为反传路径的一部分。2.2 从零实现 softmax 回归形状、数值稳定性与训练循环softmax-regression-scratch.ipynb的核心不是网络结构而是三件事一是把输入图像展平成二维矩阵二是 softmax 的数值稳定写法三是手写训练循环。先看 softmax 实现里最容易被忽略的一行代码def softmax(X): X_exp torch.exp(X - X.max(dim1, keepdimTrue).values) # 先减最大值防止指数爆炸 partition X_exp.sum(dim1, keepdimTrue) # 每行求和 return X_exp / partition输入X的形状是(batch_size, num_classes)每行是一条样本在所有类别上的原始 logit。减去每行最大值不会改变概率分布因为分子分母同时缩小了同样的倍数但这一步能让exp()的输入控制在非正数范围内避免 logit 较大时指数结果溢出为inf。keepdimTrue保证减法和除法的广播形状正确少了这个参数X和减数之间会出现维度不匹配或者形状错误。实际会把softmax放进模型再配合手写的交叉熵def cross_entropy(y_hat, y): return -torch.log(y_hat[range(len(y_hat)), y]).mean()这里用range(len(y_hat))配合y做索引取出每个样本真实类别对应的预测概率再取负对数。训练循环的骨架则是 PyTorch 里最标准的四步流程for X, y in train_iter: net.zero_grad() # 梯度会累加到 .grad必须显式清零 y_hat softmax(torch.matmul(X.reshape((-1, W.shape[0])), W) b) l cross_entropy(y_hat, y) l.backward() with torch.no_grad(): # 参数更新不需要参与梯度记录 W - lr * W.grad b - lr * b.gradzero_grad()必须放在每次反传之前因为 PyTorch 的梯度是累加而不是覆盖的不清零会把上一个 batch 的梯度叠加到当前 batch 上。with torch.no_grad()包住参数更新是为了防止更新操作本身再被记录进计算图。2.3 concise 实现nn.CrossEntropyLoss 已经把两件事合在一起了再看softmax-regression-concise.ipynb简洁实现的核心是nn.Sequential和nn.CrossEntropyLoss。注意一个高频踩坑点nn.CrossEntropyLoss内部已经完成了LogSoftmax和NLLLoss的合并输入直接喂原始 logits 即可。net nn.Sequential(nn.Flatten(), nn.Linear(784, 10)) def init_weights(m): if type(m) nn.Linear: nn.init.normal_(m.weight, std0.01) net.apply(init_weights) loss nn.CrossEntropyLoss() trainer torch.optim.SGD(net.parameters(), lr0.1)nn.Flatten()负责把(batch_size, 1, 28, 28)的输入展平成(batch_size, 784)nn.Linear(784, 10)内部完成了权重矩阵乘法和偏置相加。如果在这个结构里再手动对网络输出做一次softmax再传给CrossEntropyLoss等于对 logits 做了两遍指数归一化训练初期损失下降会明显异常缓慢这是初学者最容易踩的坑。init_weights用std0.01初始化线性层权重比默认的均匀分布收敛更稳定原因在于小标准差让初始输出分布更集中配合交叉熵的梯度信号更平滑。3. 数据加载与训练循环DataLoader 参数、训练骨架与曲线可视化3.1 DataLoader 参数训练脚本跑不快问题往往不在 GPU深度学习的训练管线中数据加载是最容易被低估的一环。仓库里所有 notebook 都依赖d2l.load_data_fashion_mnist这个工具函数其内部就是构造DataLoader并做归一化。DataLoader 的核心参数直接影响训练吞吐batch_size决定每次反传的样本数num_workers决定数据预取的并行进程数pin_memory决定是否把数据放到锁页内存以加速 CPU 到 GPU 的拷贝。参数作用建议值batch_size每次前反向传播使用的样本数小数据集 32128大数据集视显存而定num_workers数据加载子进程数与 CPU 核数相关常用 48过高会因进程切换反而变慢pin_memory锁页内存加速 H2D 拷贝使用 GPU 时设为Trueshuffle每个 epoch 是否打乱数据顺序训练集必须True测试集Falsedrop_last最后一批不足 batch_size 时是否丢弃训练模型时建议True避免最后一个 batch 对 BN 统计产生偏移预处理部分仓库使用的是torchvision.transforms.ToTensor()加Normalize。ToTensor()会把 PIL 图像或 NumPy 数组转成torch.FloatTensor像素值自动缩放到[0, 1]同时把通道维从(H, W, C)调整为(C, H, W)。Normalize再做一次标准化。import torchvision from torchvision import transforms transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize((0.1307,), (0.3081,)) # MNIST 数据集的均值与标准差 ]) mnist_train torchvision.datasets.MNIST( root./data, trainTrue, transformtransform, downloadTrue) train_iter torch.utils.data.DataLoader( mnist_train, batch_size256, shuffleTrue, num_workers4, pin_memoryTrue)这里Normalize((0.1307,), (0.3081,))是 MNIST 全量数据的真实统计值写成单通道是因为 MNIST 是灰度图。如果数据集被替换为 CIFAR-10则需要(0.4914, 0.4822, 0.4465)三个通道的均值和(0.2470, 0.2435, 0.2616)的标准差。标准化不是可选项它对浅层梯度稳定性有明显帮助尤其在使用较大学习率时。3.2 训练循环骨架把 d2l.train_ch6 简化成可以移植的四段式仓库里各模型的训练函数都类似核心逻辑可以整理成一个不依赖 d2l 工具包的四段式函数直接放进自己的项目里改改就能用def train_epoch(net, train_iter, loss, trainer, device): net.train() total_loss, correct, total 0.0, 0, 0 for X, y in train_iter: X, y X.to(device), y.to(device) trainer.zero_grad() y_hat net(X) l loss(y_hat, y) l.backward() trainer.step() total_loss l.item() * X.size(0) correct (y_hat.argmax(dim1) y).sum().item() total y.numel() return total_loss / total, correct / totaltrainer可以是torch.optim.SGD、Adam等任意优化器也可以是手写的更新函数只要实现了zero_grad()和step()两个接口。注意l.item() * X.size(0)把当前 batch 的平均损失还原成总损失最后再除以总样本数这样算出的 epoch 损失不受最后一个不完整 batch 的影响。y_hat.argmax(dim1)取每个样本预测概率最大的类别作为预测标签与真实标签逐元素比较并求和。训练曲线记录方面原仓库使用d2l.Animator实现动态绘图本质上是 matplotlib 的封装每轮 epoch 结束后更新训练损失和验证准确率两条曲线。如果你在自己的环境里跑可以直接用matplotlib或tensorboard替代核心是记录三个量训练损失、训练准确率、验证准确率前两个能判断模型是否在拟合第三个判断是否泛化。从一个实际项目的经验看训练损失下降而验证准确率不涨优先检查是不是数据泄漏或验证集分布与训练集不一致不要急着调模型结构。3.3 曲线观察与超参调整的顺序训练曲线的观察顺序也很重要第一个 epoch 结束时训练损失应该明显小于随机初始化的损失否则大概率是学习率过大或网络初始化有问题。如果前几个 epoch 损失不降反而震荡上升先看loss是否选对再看lr是否过大。如果训练损失下降很快但验证损失几乎不动这是过拟合的前兆优先控制模型容量或加正则而不是继续加训练轮数。这套排查顺序在仓库的每个模型上都通用值得在跑代码之前先固定下来。4. 从 LeNet 到 SSD残差连接与多尺度锚框的工程化拆解4.1 LeNet 与 ResNet 残差连接的梯度意义仓库里从 LeNet 开始进入卷积网络到 ResNet 时开始引入残差连接。Residual模块的实现看似简单但这个跳跃结构解决的是深层网络的梯度传播问题。反向传播时梯度要穿过每一层卷积和激活层数越深梯度经过多次矩阵乘法后容易出现数值衰减浅层参数难以获得有效梯度。残差连接提供了一条“旁路”让梯度可以不经过卷积层、直接向后传播。import torch.nn.functional as F class Residual(nn.Module): def __init__(self, input_channels, num_channels, use_1x1convFalse, strides1): super().__init__() self.conv1 nn.Conv2d(input_channels, num_channels, kernel_size3, padding1, stridestrides) self.conv2 nn.Conv2d(num_channels, num_channels, kernel_size3, padding1) if use_1x1conv: self.conv3 nn.Conv2d(input_channels, num_channels, kernel_size1, stridestrides) else: self.conv3 None def forward(self, X): Y F.relu(self.conv1(X)) Y self.conv2(Y) if self.conv3: X self.conv3(X) return F.relu(Y X)Y X是残差连接的核心。当input_channels ! num_channels或需要降采样时use_1x1conv为True用 1x1 卷积把输入的通道数和空间尺寸对齐到Y这样两个张量才能相加。1x1 卷积在这里不改变化学特征只做通道维度上的线性组合是纯工程手段。如果不加这条残差路径同等层数的普通卷积网络在 CIFAR-10 这类中等规模数据上训练损失很难降到同样的水平。4.2 SSD 的多尺度锚框一个特征图位置预测多个目标ssd.ipynb是整个仓库里代码量较大的 notebook 之一原因在于目标检测的任务逻辑比分类复杂得多。SSD 的核心是多尺度锚框在不同分辨率的特征图上的每一个像素位置预置多个不同宽高比的先验框然后让网络预测每个先验框对应的目标类别以及对先验框的 4 个偏移量修正。def multibox_forward(cls_predictor, bbox_predictor, features): anchors, cls_preds, bbox_preds [], [], [] for f in features: anchors.append(multibox_prior(f, sizes[0.2, 0.4], ratios[1])) cls_preds.append(cls_predictor(f).reshape(f.shape[0], -1)) bbox_preds.append(bbox_predictor(f).reshape(f.shape[0], -1)) return (torch.cat(anchors, dim1), torch.cat(cls_preds, dim1), torch.cat(bbox_preds, dim1))multibox_prior会根据特征图的尺寸生成锚框坐标sizes控制锚框相对于原图的尺度ratios控制宽高比。每个锚框要预测num_classes个类别概率和 4 个偏移量因此检测头的输出通道数是num_anchors * (num_classes 4)。reshape(f.shape[0], -1)把每个样本的多通道预测展平成二维最终输出形状是(batch_size, num_anchors * (num_classes 4))后面再接非极大值抑制去掉重复框。这里最需要理解的是锚框的三个超参数数量和大小直接影响召回率而偏移量预测的训练数据是由锚框与真实框的 IoU 对齐生成的IoU 阈值通常取 0.5。4.3 读 SSD 源码时的切入点读ssd.ipynb时不建议从头到尾线性读。先找四个东西锚框生成函数、类别预测头、偏移量预测头、损失函数中匹配正负样本的部分。锚框生成理解了后面的预测头就是普通的卷积层加 reshape损失函数里的F.cross_entropy配合mask完成正负样本平衡。这几个点拆开弄懂整篇 notebook 的结构就清晰了。目标检测模型比分类模型多出的复杂度本质上只是“如何把网格上的预测和组织好的先验框对齐”这一步其他都是常规操作。5. 动量法与优化器演进momentum 从公式到 PyTorch 实现的差异5.1 动量更新公式教材写法与 PyTorch 实现的细微差异momentum.ipynb讲的是带动量的随机梯度下降公式形式常写成[ v_t \beta v_{t-1} \nabla L(w_t) ] [ w_{t1} w_t - \eta v_t ]在 PyTorch 的optim.SGD实现里动量的累积系数直接乘在历史梯度上然后和当前梯度相加再乘以学习率更新参数。另一种常见写法是 ( v_t \beta v_{t-1} (1-\beta)\nabla L )两种写法只差一个 ( 1-\beta ) 的缩放收敛行为基本一致但相同学习率下的收敛速度会有细微差别所以从论文公式到代码实现时不能只核对公式形式还要确认学习率是否匹配实现约定。5.2 手写动量更新与 optim.SGD 等价实现notebook 里手写动量更新的代码核心逻辑如下def sgd_momentum(params, states, hyperparams): beta hyperparams[momentum] for param, state in zip(params, states): with torch.no_grad(): state[:] beta * state param.grad # 原地更新动量缓冲 param[:] - hyperparams[lr] * state # 用动量方向更新参数 param.grad.zero_()state初始化为与参数同形状的零张量每次迭代按照动量公式更新。这里用state[:] ...而不是state ...是因为state这个缓冲张量在训练过程中被反复复用原地修改保持了同一个内存对象避免每次迭代都创建新张量造成不必要的内存分配。参数更新完毕后要手动调用param.grad.zero_()等价于优化器的zero_grad()行为。这一手写实现与torch.optim.SGD(params, lr0.1, momentum0.9)是等价的差别只在框架实现里多做了学习率衰减、权重衰减等辅助功能。动量参数momentum的典型取值是 0.9意思是当前更新方向包含 90% 的历史累积方向和 10% 的当前梯度方向。动量能抑制梯度方向频繁震荡的问题在损失曲面较崎岖时收敛更稳定。5.3 优化器选型SGD、Adam、AdamW 的边界条件除了动量法仓库后面还用到 Adam、AdamW 等优化器。不同优化器的行为差异在训练初期不明显但在中后期收敛性上有明显区别。Adam 适合大多数 CV、NLP 任务的默认起步但 Adam 的权重衰减实现方式有偏差这也是 AdamW 出现的原因它把权重衰减从梯度计算中剥离独立作用于参数更新。优化器核心机制适合场景常用参数起点主要边界SGD只用当前梯度数据量小、调参空间大lr0.1需要手动调收敛慢对学习率敏感SGD Momentum累积历史梯度方向训练时间充裕、追求泛化lr0.01~0.1,momentum0.9需配合学习率衰减Adam一阶矩 二阶矩自适应大多数任务快速起步lr1e-3后期泛化可能略差AdamWAdam 解耦权重衰减Transformer、大模型等lr1e-4~3e-4,weight_decay0.01训练速度略慢于 Adam实际项目里我通常先用 AdamW 或 Adam 快速跑通确定网络结构和数据预处理没有 bug再切到 SGD Momentum 搭配余弦退火做最终的高泛化版本。优化器选型对最终结果的影响不亚于模型结构这个仓库里的momentum.ipynb就是帮助建立这种判断的基础材料。6. 排错与复现随机种子、submission.csv 与 d2l.bib 的实战用法6.1 随机种子固定为什么还不够复现实验结果的第一步是固定随机种子但很多人以为调用了torch.manual_seed就万事大吉。完整方案需要覆盖 Python 内置随机、NumPy、PyTorch 和 CUDA 四个层级import random import numpy as np import torch def seed_all(seed42): random.seed(seed) # Python 内置随机 np.random.seed(seed) # NumPy 随机 torch.manual_seed(seed) # CPU 随机 torch.cuda.manual_seed_all(seed) # 所有 GPU 随机 torch.backends.cudnn.deterministic True # 强制 cuDNN 使用确定性算法 torch.backends.cudnn.benchmark False # 关闭自动调优保证可复现固定种子后DataLoader用多进程加载数据时每个 worker 的随机状态仍然可能不同需要给DataLoader传入worker_init_fn在每个子进程内再调用一次seed_all。即便如此某些情况下 GPU 上的原子操作顺序仍可能造成微小差异这是硬件层面的不确定性不属于代码 bug。cudnn.benchmark False会牺牲少量计算性能换来卷积算法的确定性选择如果只是复现实验而不追求训练速度这个开关值得关掉。6.2 submission.csv提交格式里容易被忽略的坑仓库根目录下的submission.csv是 Kaggle 竞赛提交格式的样例理解它的用途能避免很多线上的分数偏差。这个文件只包含两列Id和预测标签行顺序必须和官方测试集的行顺序完全一致。模型预测完成后要先按测试集原始顺序对齐再把结果写入 CSV不能在中间 shuffle 或去重。实际提交时最常见的错误是带着索引列一起写文件或者把Id列写成了行号。验证本地代码时可以用pd.read_csv读回这个文件检查行数是否与测试集一致、标签列是否包含非法值。6.3 d2l.bib把源码和原始论文对应起来的阅读策略d2l.bib是 BibTeX 格式的参考文献文件记录了书中涉及的所有论文条目包含会议名称、年份、页码等完整信息。它最实用的用法不是生成参考文献列表而是当作阅读地图每读一个 notebook先在d2l.bib里找到对应论文条目打开论文对照着看。比如读ssd.ipynb时同时打开 SSD 原论文里的多尺度特征图结构图代码里的sizes、ratios参数就直观得多。仓库的 notebook 文件名已经表明主题但论文提供了代码背后的设计动机两者对照看效率远高于单刷 notebook。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价