
学了三个月深度学习会搭 PyTorch 模型也能把大模型的 demo 跑起来但一被人问到“反向传播到底在传播什么梯度爆炸时 loss 为什么变成 NaN参数量一多为什么容易过拟合”就答不上来。这不是个别现象。市面上大部分教程都在教你怎么调用封装好的 API却很少教你拆开封装看看里面那几行矩阵运算和梯度推导到底发生了什么。卡内基梅隆大学CMU的深度学习导论实验课2026 版延续了它一贯强调的“实操深度学习吃透模型底层机理”路线。它和普通入门课最大的区别是实验不是让你调几行 PyTorch 然后看损失下降而是逼着你从零开始把神经网络的前向传播、反向传播、损失函数、参数更新一层层写出来。这门课背后是一个非常硬核的判断真正理解模型底层机理的人不是记住了多少网络结构而是能自己在纸上推导、在代码里实现、在调试中验证完整训练闭环的人。本文不打算替任何课程做广告也不准备复述大纲。我想做的是把这条“徒手实现 梯度验证 迁移到框架”的学习路径拆开来讲核心概念是什么、环境怎么搭、完整代码怎么写、常见坑有哪些、工程上应该养成什么习惯。无论你是准备系统入门深度学习的初学者还是已经能熟练调用框架但想补底层知识的开发者这条路径都值得走一遍。1. 这篇文章真正要解决的问题1.1 一个容易被忽略的事实会用框架不等于懂模型很多同学的深度学习之路是从“调包”开始的。数据加载用torch.utils.data模型定义用nn.Sequential堆几层训练循环用loss.backward()一步搞定。代码能跑准确率也还行但遇到稍微非标准的需求就卡住了想自己写一个自定义Function却发现不知道梯度公式想分析为什么某一层梯度消失了却不知道应该打印哪一层的哪一维张量想复现一篇论文里的特殊归一化层却连它的前向、反向都描述不清楚。这不叫“会深度学习”这叫“会调用深度学习框架”。框架把最复杂的数学过程隐藏了也把最关键的调试能力从你身边拿走了。CMU 实验课的核心价值就是强制把这个过程重新交回到你手里。1.2 课程设计的核心逻辑作业不是在调参而是在“造轮子”从课程标题来看2026 版的实验课仍然把关键词落在“实操”和“底层机理”上。这类课程常见的设计思路是分模块拆解第一层用纯 Python 或者 NumPy 手写线性层、激活函数、损失函数第二层手推并手写反向传播用数值梯度验证公式正确性第三层再用 PyTorch 的自动求导重写同一套网络对比结果第四层逐步扩展到卷积、循环网络、注意力机制等现代结构。这样安排的好处是每个知识点都被分解成“公式推导—代码实现—数值验证—框架对照”四个环节。你不是在背答案而是在亲手确认“这个梯度确实是这么算出来的”。1.3 谁最适合读这篇文章本文不是零基础科普但比论文解读容易得多。适合以下三类读者已经会用 PyTorch、TensorFlow但觉得原理虚浮的开发者准备转 AI 方向面试前需要系统补反向传播、梯度检查、初始化等底层知识的候选人想在本地或云环境里搭一套“最小复刻 CMU 实验课”练功路线的人。如果你只是想知道“调用哪个 API 能最快出结果”这篇文章不适合你。如果你想知道“这个 API 内部到底做了什么”请继续往下读。2. 深度学习实验课的“底层机理”到底指什么2.1 通俗理解一次训练迭代里发生了什么先建立一个全局画面。无论多复杂的深度学习模型一次训练迭代都包含四件事前向传播输入数据经过层层线性变换和非线性激活最后得到预测输出计算损失用预测输出和真实标签计算一个衡量“错得多离谱”的标量反向传播根据损失对每个参数求偏导从最后一层往前逐层传递参数更新用梯度下降类算法沿着负梯度方向调整参数。“吃透底层机理”本质上就是能把上面四个步骤中的任何一步用矩阵运算和链式法则完整表达出来并且能用代码验证。注意第 3 步是最容易糊弄过去的因为loss.backward()一行就结束了而恰恰这一步是整个深度学习的发动机。2.2 六个必修的底层知识点实验课再怎么变通常绕不开下面六块知识第一张量形状与广播规则。几乎所有 bug 都出在维度没对齐。约定一个习惯数据形状写成[batch_size, feature_dim]权重形状写成[input_dim, output_dim]每次运算前先注释清楚形状。第二前向传播的矩阵视角。一个线性层就是z XW b一个 MLP 就是多个线性层夹着激活函数。这个没有任何神秘可言但它是一切后续结构的基础。第三手推反向传播。这是课程的核心地带。你必须会从损失出发用链式法则求出每一层权重和偏置的梯度并且注意到“梯度流经 ReLU 时如何被置零”“softmax 与交叉熵结合时梯度会化简为多么漂亮的表达式”这类细节。第四数值梯度检查。在你完全相信自己手推的公式之前先用差分法计算近似梯度和解析梯度做对比。这一步是工程思维和学术思维的分水岭。第五初始化与优化器。同样一个网络初始化方式不对可能根本训不动。Xavier、He 初始化背后的直觉是让每一层输出方差保持在可控范围SGD 与 Adam 的区别在于是否使用动量、如何自适应学习率。第六训练诊断。loss 不降、loss 变成 NaN、训练集表现好但测试集差这些问题的排查路径都依赖前面五点知识。没有底层理解你只能靠随机试有了底层理解你知道该看哪里。2.3 为什么“动手写”比“看视频”更容易吃透看视频是一种被动接收大脑很容易产生“我懂了”的错觉。上课听懂和手写跑通是两个量级听懂只需要理解别人的推导写出来则需要你自己处理每一步的形状、数值稳定性、公式符号和代码逻辑。实验课的意义在于它用完整的实现任务把“我以为我懂”变成了“我真的能跑通”。更关键的是手写网络为你的调试提供了“上帝视角”。当你在框架中遇到一个奇怪错误时因为你知道框架内部大概做了什么你能把问题缩小到前向公式、梯度公式还是数值稳定性层面而不是对着报错乱猜。3. 环境准备与前置条件3.1 数学与编程前置开始之前建议保证以下基础过关线性代数矩阵乘法、转置、求导基本规则、向量对矩阵的导数如何用链式法则组织微积分复合函数求导、链式法则、Softmax 函数的导数Pythonnumpy的矩阵运算、索引和广播能读懂类与方法的代码结构。如果你数学基础薄弱不需要等彻底学完线性代数再看课程。大多数实验课只需要用到“把矩阵表达写成循环展开”的能力你先跟着代码走再回头补理论反而更高效。3.2 推荐环境配置下面是一个通用到不能再通用的环境搭建方式版本号请以官方支持情况为准重点看思路。conda create -n deep_lab python3.11 conda activate deep_lab pip install numpy matplotlib torch需要说明几点Python 建议使用 3.10 或以上版本numpy和torch的二进制兼容更省心如果安装 PyTorch 时不知道选哪个命令直接去 PyTorch 官网根据操作系统和 CUDA 版本生成安装命令这是最稳妥的方式matplotlib不是必须的但画 loss 曲线和权重分布时非常好用。3.3 没有 GPU 怎么办实验课的小型练习完全不依赖 GPU。手写 MLP、梯度检查、训练一个两层网络跑 200 轮CPU 上几秒钟就完成。即便到了 CNN、RNN 实验只要把数据集限制在MNIST级别、把模型缩小到“能跑通”的规模CPU 也完全可以接受。如果确实想用 GPU可以选择学院实验室、云主机或本地高性能机器但不要一开始就陷入 CUDA 配置和驱动版本的问题。靠 CPU 先把流程跑通永远是第一优先级。4. 核心流程拆解把一门实验课变成可执行的学习路径与其等待课程资源不如按下面六步自行搭建一条“最小复刻”路径。这六步的先后顺序本身就是课程设计逻辑先推导再实现再验证再迁移再扩展。4.1 第一步把数学记号写清楚不要一上来就写代码。先拿出一张纸把一个两层网络的参数记号写清楚X输入形状[m, d_in]W1第一层权重形状[d_in, h]b1第一层偏置形状[h]z1 XW1 b1a1 relu(z1)W2第二层权重形状[h, num_classes]z2 a1W2 b2probs softmax(z2)loss -mean(log(probs_i[y_i]))。写清楚之后再回答三个问题每个变量形状是什么loss对z2的梯度是什么梯度从z2流到W1需要经过哪几层链式法则这一步做扎实后面的代码基本是翻译而不是创造。4.2 第二步用 NumPy 徒手实现前向与反向这是整个学习路径中最痛苦也最值得的一步。建议用一个类封装参数和计算过程前向方法保存中间结果反向方法手工计算梯度。不要用任何自动求导工具甚至连torch都不要导入。容易踩坑的地方集中在两点一是忘记把梯度除以批大小m导致梯度被放大m倍二是 ReLU 的梯度只在z1 0处为 1其余位置为 0很多人把“对激活函数的梯度”和“对线性输出的梯度”混在一起导致符号错乱。4.3 第三步用梯度检查验证反向传播手工推导的梯度未必正确所以要用数值梯度验证。数值梯度的思想很简单对某个参数theta加上一个小量eps计算loss(theta eps)再减掉eps计算loss(theta - eps)二者之差除以2 * eps就是近似导数。这个近似值和解析梯度对比相对误差足够小就说明反向传播公式写对了。这一步的关键是用最小配置单样本或极小批量、小网络、小输入维度因为数值梯度要对每个参数逐个算两次前向复杂度很高。梯度检查通过之后你的“手写神经网络”才算真正可信。4.4 第四步迁移到 PyTorch 并体会 autograd同样的网络改用 PyTorch 实现。你会发现代码量少了一大截loss.backward()自动完成了你前面手写的一切。这时候请特意做一件事手写梯度时打印的梯度数值和 PyTorch 自动求导后打印的梯度数值逐一做对比。两者一致你就真正理解了autograd在替你做什么两者不一致说明手写或框架某一侧有隐藏问题这是最有价值的调试训练。4.5 第五步从 MLP 扩展到 CNN、RNN、Attention把最小闭环跑通之后再逐步增加复杂度。卷积层的底层是“滑动窗口上的加权求和”你可以在 NumPy 里用im2col实现一遍conv2d再和torch.nn.Conv2d对比RNN 的核心是“参数共享的循环展开”你可以手写一个最简单的循环单元体会隐藏状态如何在时间步之间传递注意力机制的核心是softmax(QK^T / sqrt(d_k))V你可以先写出不带掩码的最小实现再和框架版对比。每次扩展都保持同一套路推导、手写、梯度检查、对照框架。这个习惯一旦建立学任何新结构都很快。4.6 第六步用实验报告倒逼自己整理结论课程实验最容易被忽略的是记录。每完成一个模块建议写一份简短报告目标、公式、核心代码、验证结果、遇到的问题。不需要长篇大论但“我遇到了什么问题、我是怎么定位的、最后怎么解决”这一段最有价值。因为下次在真实项目里遇到同类问题你能直接翻出当时的排查路径。5. 完整示例与代码实现下面给出一套完整的最小示例对应“手写两层网络 梯度检查 PyTorch 对照”三步。文件可以按two_layer_net.py、grad_check.py、train_pytorch.py三个文件存放。5.1 数据准备先用小数据集验证理论训练数据不需要过早引入真实数据集。用随机生成的四维特征和一个可学习的标签规则就够了。标签规则为当x0 x1 0时取类别 1否则取类别 0。这保证了数据本身是可分、可学的。# 文件路径prepare_data.py import numpy as np def make_synthetic_data(n_samples64, seed42): rng np.random.default_rng(seed) X rng.normal(size(n_samples, 4)) y (X[:, 0] X[:, 1] 0).astype(int) return X, y这个小函数生成 64 个样本每个样本 4 维特征标签是 0 或 1。为什么不用三分类因为二分类的交叉熵梯度最容易手推也最能清楚判断训练是否生效。等你把整个流程走通再改成三分类只需要调整 softmax 和交叉熵部分结构完全不变。5.2 纯 NumPy 实现两层网络含反向传播核心网络实现如下关键反向推导已经写在注释里# 文件路径two_layer_net.py import numpy as np def softmax(z): z z - np.max(z, axis1, keepdimsTrue) e np.exp(z) return e / np.sum(e, axis1, keepdimsTrue) class TwoLayerNet: def __init__(self, in_dim4, hidden_dim16, num_classes2, seed42): rng np.random.default_rng(seed) bound1 np.sqrt(6.0 / (in_dim hidden_dim)) bound2 np.sqrt(6.0 / (hidden_dim num_classes)) self.W1 rng.uniform(-bound1, bound1, size(in_dim, hidden_dim)) self.b1 np.zeros(hidden_dim) self.W2 rng.uniform(-bound2, bound2, size(hidden_dim, num_classes)) self.b2 np.zeros(num_classes) def forward(self, X): # z1: [m, hidden_dim] self.X X self.z1 X self.W1 self.b1 self.a1 np.maximum(0, self.z1) # z2: [m, num_classes] self.z2 self.a1 self.W2 self.b2 self.probs softmax(self.z2) return self.probs def compute_loss(self, X, y): probs self.forward(X) return -np.mean(np.log(probs[np.arange(len(y)), y])) def compute_grads(self, X, y): # 需要先前向一次拿到缓存 p self.forward(X) m len(y) # loss 对 z2 的梯度: probs - onehot再除以 m d_z2 p.copy() d_z2[np.arange(m), y] - 1.0 d_z2 / m grads {} grads[W2] self.a1.T d_z2 grads[b2] d_z2.sum(axis0) # z2 a1 W2 b2梯度回到 a1 d_a1 d_z2 self.W2.T # ReLU 层: a1 max(0, z1)z1 0 时梯度为 0 d_z1 d_a1.copy() d_z1[self.z1 0] 0.0 grads[W1] X.T d_z1 grads[b1] d_z1.sum(axis0) return grads def update(self, grads, lr0.05): self.W1 - lr * grads[W1] self.b1 - lr * grads[b1] self.W2 - lr * grads[W2] self.b2 - lr * grads[b2]这里真正容易踩坑的地方是d_z1的写法。很多人会把self.z1 0写成self.a1 0看起来差不多其实不一样ReLU 输出a1在z1为 0 时边界性质并不重要但 O 点附近如果用输出侧判断梯度会出现离散误差。统一用z1 0判断是更稳妥的习惯。训练循环可以放到单独文件里# 文件路径train_numpy.py import numpy as np from prepare_data import make_synthetic_data from two_layer_net import TwoLayerNet X, y make_synthetic_data() model TwoLayerNet(in_dim4, hidden_dim16, num_classes2) lr 0.05 for epoch in range(200): loss model.compute_loss(X, y) grads model.compute_grads(X, y) model.update(grads, lrlr) if epoch % 50 0 or epoch 199: print(fepoch {epoch:3d}, loss {loss:.4f})注意compute_grads内部会再次调用forward所以compute_loss里已经计算出的缓存会被覆盖这没关系因为compute_grads自己会重新前向一次。如果你的项目对性能敏感可以改成只前向一次然后同时返回损失和梯度但教学用途无需那么省。5.3 数值梯度检查代码梯度检查要放在正式训练之前。下面这段代码遍历某个参数的每个元素用中心差分法计算数值梯度再和解析梯度做对比。# 文件路径grad_check.py import numpy as np from prepare_data import make_synthetic_data from two_layer_net import TwoLayerNet def numeric_grad_for(model, X, y, nameW1, eps1e-6): param getattr(model, name) numeric np.zeros_like(param) it np.nditer(param, flags[multi_index], op_flags[readwrite]) while not it.finished: idx it.multi_index old param[idx] param[idx] old eps loss_plus model.compute_loss(X, y) param[idx] old - eps loss_minus model.compute_loss(X, y) param[idx] old numeric[idx] (loss_plus - loss_minus) / (2 * eps) it.iternext() return numeric # 用小批量验证避免数值梯度算太慢 X, y make_synthetic_data(n_samples8, seed1) model TwoLayerNet(in_dim4, hidden_dim8, num_classes2, seed1) grads model.compute_grads(X, y) for name in [W1, b1, W2, b2]: ana grads[name] num numeric_grad_for(model, X, y, namename, eps1e-6) rel_err np.abs(ana - num) / (np.abs(ana) np.abs(num) 1e-12) print(f{name}: max relative error {rel_err.max():.3e})运行这段代码后期望输出是每个参数的max relative error都在1e-6到1e-9量级左右。这里加1e-12是为了防止解析梯度和数值梯度都为 0 时除以 0 产生 NaN。如果误差在1e-3甚至更大几乎可以肯定是反向传播公式出了问题。同样要提醒梯度检查会在检查过程中反复修改参数并重新前向检查结束后模型内部的缓存z1、a1等已经被最后一次扰动覆盖。所以梯度检查只能用于验证不能把它和训练混在一起调用否则训练会用到脏缓存得到错误的梯度。5.4 用 PyTorch 实现同样网络有了手写版本做底PyTorch 版本就很容易读懂# 文件路径train_pytorch.py import torch import torch.nn as nn torch.manual_seed(42) X torch.randn(64, 4) y (X[:, 0] X[:, 1] 0).long() model nn.Sequential( nn.Linear(4, 16), nn.ReLU(), nn.Linear(16, 2), ) criterion nn.CrossEntropyLoss() optimizer torch.optim.SGD(model.parameters(), lr0.05) for epoch in range(200): logits model(X) loss criterion(logits, y) optimizer.zero_grad() loss.backward() optimizer.step() if epoch % 50 0 or epoch 199: print(fepoch {epoch:3d}, loss {loss.item():.4f})PyTorch 版本的核心是loss.backward()。它会沿着计算图反方向求导把每个可学习参数的梯度写入param.grad然后optimizer.step()完成更新。你不需要手动写任何梯度公式但正因为如此更需要把手写版的经验迁移过来如果你怀疑框架在某个自定义算子上的梯度不对可以用torch.autograd.gradcheck做同样的数值梯度验证。6. 运行结果与效果验证6.1 梯度检查的判定标准判断梯度检查是否通过不能只看一个参数。四个参数W1、b1、W2、b2的最大相对误差都应在1e-5以下才说明反向传播写对。如果某个参数的误差显著大于其他参数优先怀疑那个参数对应的那一层推导公式而不是全局调整eps。误差偏大还有一个常见来源是使用了二分类的交叉熵实现。如果标签只有 0 和 1而你的d_z2计算有误会导致所有层梯度都错反之如果只是某个偏置项的维度写错则只会在那一行报错或者那一项梯度异常。6.2 训练 loss 的预期曲线NumPy 版和 PyTorch 版跑 200 轮loss 都应当从 0.7 左右逐步下降到接近 0。因为合成数据可分性很强两层 MLP 足够学出一个像样的分类面。如果 loss 在 0.69 附近纹丝不动说明模型根本没有学到任何东西最可能的原因是学习率太小、初始化太差或者标签规则并不是你想象的那条直线。验证成功还可以加一个指标比如算一下训练准确率pred model.forward(X) acc (pred.argmax(axis1) y).mean() print(ftrain accuracy {acc:.3f})对 PyTorch 版则是model(X).argmax(dim1)。这一步确认的是“前向输出 标签映射”是否正确而不只看 loss。6.3 失败后的第一排查路径如果训练代码运行报错或 loss 不降建议按顺序排查先看形状把每个张量的.shape打印出来重点核对X W1和a1 W2是否符合预期再看 loss 是否出现 NaN如果出现检查 softmax 是否有溢出学习率是否过大再看梯度范数在compute_grads返回后打印np.linalg.norm(grads[W1])如果数值巨大说明梯度爆炸最后回到梯度检查如果之前没跑通任何训练结果都不可信。很多新手习惯直接调整网络结构或学习率但底层实现阶段九成问题出在形状和梯度公式上。先把这两件事查清楚再调参效率高得多。7. 常见问题与排查思路下表汇总了手写网络和实验环境中最高频的五个问题问题现象可能原因排查方式解决方案loss 一直不降学习率过小初始化方差太小梯度公式错误打印 loss 和梯度范数调大学习率改用 Xavier/He 初始化重跑梯度检查loss 变成 NaN梯度爆炸softmax 指数溢出看梯度是否出现 inf检查 softmax 是否减最大值减小学习率softmax 实现减最大值必要时加梯度裁剪梯度检查相对误差大反向推导错误eps 太大参数缓存被污染核对 d_z2 公式检查 solute 数值稳定性用小网络、小批量、eps1e-6确保检查前参数未更新训练集准确率高测试集低过拟合数据划分有问题画 train/val loss 曲线检查是否混入了测试样本加正则化、缩小模型、提前停止重新做数据拆分维度 mismatch 报错权重形状与输入输出维度不对齐打印每层X.shape、W.shape统一按[m, d]和[d_in, d_out]约定写代码并核对这里单独说一句过拟合很多初学者在“训练集准确率高测试集低”时第一反应是换更大的模型这恰恰是反的。实验课里这个小规模网络通常不会接触复杂数据但如果后续切换到真实数据集务必一开始就划分训练集、验证集、测试集并全程只用验证集调参。另一个高频坑是批大小。手写版如果忘了把梯度除以mloss 为 NaN 或梯度爆炸的概率会明显增加。PyTorch 的CrossEntropyLoss默认会对 batch 内样本求平均所以手写版也要保持一致d_z2里除以m。两种实现只有在同一约定下才能对比梯度。8. 最佳实践与工程建议8.1 把“手写网络”当成长期修炼而不是一次性作业即使你以后永远使用 PyTorch、TensorFlow 或各种大模型推理框架手写网络的能力也不会浪费。它会内化成一种直觉看到一个新算子你会自然地问“它的前向是什么、反向的梯度会流到哪个参数上、数值上是否稳定”。这个直觉在排查框架 bug、复现论文、写自定义算子时都是硬通货。8.2 用脚本固定配置拒绝“玄学调参”实验课时可以随心所欲地改参数但工程上必须可复现。建议从第一天就养成习惯把数据集种子、随机种子、学习率、批大小、隐藏层维度写成一个配置字典每次运行前打印出来连同 loss 曲线一起保存。config { seed: 42, lr: 0.05, epochs: 200, hidden_dim: 16, batch_size: 64, } print(config)这份配置将来会救你无数次。否则三个月后你想复现一个结果却连当时用的是单隐藏层还是双隐藏层、学习率是 0.05 还是 0.005 都说不清。8.3 分层拆解与增量验证每次扩展复杂度只变更一个变量。比如从线性层增加到两层时先固定激活函数和数据从二分类扩展到多分类时先不换网络结构。一次只改一个点出问题时定位范围就小。增量验证的思想不仅是学习技巧更是生产环境上线新模块时的通用方法论。8.4 写出“可阅读”的实验记录实验报告不需要很正式但至少包含三块内容做了什么目标与方案、发现了什么现象与分析、坑在哪里排查过程。尤其是“坑在哪里”这是你最独特的经验资产。很多人在面试时被问“你遇到最深的坑是什么”翻一翻实验记录答案张口就来。8.5 用底层实现反哺框架排错当你用了 PyTorch 很久之后遇到一个容易复现的梯度异常可以写一个 numpy 版的最小复现用梯度检查判断是“公式问题”还是“框架问题”。这比在完整项目里疯狂打印中间变量高效得多。能跨框架定位问题是底层机理真正内化的标志。9. 总结与后续学习方向写到这里可以把整条路径压缩成一句话先用手推导再用 NumPy 手写再用数值梯度验证最后迁移到 PyTorch 对照每扩展一个新结构都重复这套闭环。这篇文章真正讲清楚的几件事是为什么框架 API 会掩盖底层机理实验课的学习顺序应该如何设计一个两层网络的前向、反向、梯度检查、训练验证的完整代码怎么写以及常见错误和工程习惯。你把这段路径跑通之后再去看卷积、循环网络、注意力机制的源码会有完全不同的感觉。后续的学习方向也很清晰一是把 MLP 替换成 CNN实现一次im2col版本的卷积并做梯度检查二是把单层 RNN 手写一遍理解时间步展开后的反向传播三是从零实现一个简化版 Transformer至少把多头注意力和位置编码用自己的矩阵运算表达出来。每件事都沿用本文的套路推导、实现、验证、对照。最后提醒一句课程资源本身只是引路人真正让你“吃透模型底层机理”的是你在键盘前把每一行梯度公式敲出来、再把每一处报错修好的过程。建议把本文的示例代码存成自己的练功脚本下次遇到框架层面的疑难杂症时你会感谢现在亲手写过反向传播的自己。