CS231n的Assignment 1里SVM和Softmax两个线性分类器通常是连着做的但很多人做完SVM就跑路以为Softmax只是换了个损失函数没什么新鲜的。我自己当年做完Softmax之后才有一种“神经网络原来是这样长出来的”的感觉——因为你要亲手把前向传播、损失计算、反向传播、梯度下降、调参、可视化一整条链路串起来任何一个环节掉了链子结果就是损失不降、梯度检查不过、准确率跟瞎猜差不多。这篇文章不打算复述作业要求而是把我做Softmax这一关的完整思路、手推梯度、代码实现和踩坑记录整理出来。适合两类人一是刚开始啃CS231n、卡在Assignment 1的新手二是学过一点深度学习但从来没手写过反向传播的读者。哪怕你以后全部用框架调参我也强烈建议你至少手推一遍Softmax的梯度——这是整个课程里性价比最高的一笔投资没有之一。1. Assignment里Softmax这一关究竟在练什么1.1 用一句话说清楚Softmax在做什么Softmax本质上做两件事把线性分类器打出来的分数score变成概率然后用交叉熵衡量“预测分布”和“真实标签分布”之间的距离。公式虽然只是“指数除以指数和”但它背后的物理含义很直观——它把分数差映射成概率差让模型的输出天然可解释。为什么要用指数因为指数函数单调递增并且恒为正分数高的类别对应概率一定更高同时指数保证概率始终大于0不会出现负概率这种荒谬的事。你可以把Softmax理解成一群选手比完赛现在按比赛得分把“获胜概率”按比例分给每个人。某个选手得分越高他拿冠军的概率就越大如果分数差距悬殊赢家的概率就会非常接近1。损失这边就更简单了。交叉熵损失是 L -log(p)其中 p 是正确类别的预测概率。这比SVM的hinge loss直观得多——正确类别的概率越接近1损失越接近0预测越离谱损失越大。你越有把握预测对惩罚越小这个直觉完全符合我们的认知。1.2 作业为什么非要你手写梯度这是Assignment 1最核心的“阴谋”它逼你在没有任何自动求导工具的情况下自己把反向传播算明白。CS231n的后续作业里你会用上框架但框架的autograd只是一个“黑盒”它帮你算梯度却不会帮你建立“梯度到底是什么”的直觉。手写一次Softmax的反向传播你收获的不仅是一个能用的梯度公式更重要的是建立一种思维方式看到一个损失函数你能下意识地把它拆成前向的数据流和反向的梯度流。后面你在用PyTorch或者TensorFlow时即使某个自定义的loss行为异常你也能大概猜到是前向哪一步出了问题。这种能力是纯调API的人永远不具备的。我自己做作业时最深的体会是写完SVM你还能靠“找一个边界”的直觉蒙混过关到了Softmax梯度只要推导错一个符号梯度检查立刻就会告诉你是错的。机器不会骗你它只会诚实地报出一个差得离谱的相对误差。2. 前向传播与Loss实现的两个关键细节前向传播看起来很简单数据流就是 X - scores - probs - loss。但里面藏着两个非常容易翻车的细节数值稳定性以及L2正则项该往哪里加。一次性写对后面能省大量调试时间。2.1 先写一份能跑的前向代码先说数据形状。输入 X 的形状是 (N, D)N 是样本数D 是特征维度。CIFAR-10 里每张图是 32×32×3展平后 D 3072。权重 W 的形状是 (D, C)C 是类别数CIFAR-10 是10。偏置 b 的形状是 (C,)。前向三行代码scores X.dot(W) b # (N, C) exp_scores np.exp(scores - np.max(scores, axis1, keepdimsTrue)) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue)第二行先减掉每行的最大值就是经典的数值稳定处理原因下面单独说。得到 probs 以后损失可以这样算N X.shape[0] loss -np.log(probs[np.arange(N), y]).sum() / N loss reg * np.sum(W * W)这里有两个细节需要看清楚。第一个细节loss 必须除以 N 取平均。很多人第一次写的时候忘了除 N结果梯度检查怎么都过不了因为数值梯度是按你写的 loss 去算的你损失不除 N梯度自然也不该除 N两边对不上。作业模板里通常要求返回平均损失所以后续梯度也要除 N保持一致。第二个细节L2 正则项用的是 WW 求和不是 W^T W 再取迹这种花哨写法。因为后面要算梯度np.sum(WW) 对 W 的偏导就是 2W非常直观。注意正则项只作用于 W不作用于偏置 b这是约定俗成的做法加了反而会影响平移不变性。这里还有一个可选的实现策略把偏置并进 W 里。做法是在 X 后面拼一列全1同时把 W 多出一行这样前向就变成 scores X_bias.dot(W_bias)少一个变量反向传播也少一个分支。作业里本来就允许这样做。我前期调试时更喜欢分开展开因为能直接看 b 的梯度是否符合直觉后期向量化调优时再合并减少一次矩阵加法的开销。2.2 数值稳定性是必修课不是选做题如果你天真地直接写 np.exp(scores)CIFAR-10 这种数据规模下大概率不会溢出因为分数通常只有几十。但你一旦在后续作业里把网络加深、加宽或者某次初始化没弄好导致成绩飙到几百上千np.exp(1000) 直接给你一个 inf然后 inf/inf 得到 nan整个训练瞬间报废。解决办法是Softmax的平移不变性对输入减去任意常数结果不变。证明很简单softmax(z - c)_k exp(z_k - c) / sum_j exp(z_j - c) exp(z_k) / sum_j exp(z_j)所以每次计算前先减去每行的最大值把所有指数都压到非正数范围exp 的结果永远在 (0, 1] 之间从根上杜绝溢出。注意这里一定要用 keepdimsTrue否则 (N,) 的向量减 (N, C) 的矩阵会触发广播出错。踩过这个坑的人应该都记得那张“ValueError: operands could not be broadcast together”报错。对数值稳定有执念的人还可以直接用 scipy.special.logsumexp 来算 log 分母这样连 log 阶段的溢出也一并解决。作业里不必这么讲究但知道这个技巧对以后写自定义层很有用。3. 梯度推导从手算到向量化一键提速3.1 手推一遍比看十遍教程都管用梯度推导是整个Softmax作业的灵魂。我先推导最核心的结论再解释为什么它长这样。对第 i 个样本正确类别是 y_i分数向量是 sSoftmax 输出概率向量是 p那么L_i -log(p_{y_i}) -s_{y_i} log(sum_j exp(s_j))现在对第 k 个分数求偏导。第一项 -s_{y_i} 只有在 k y_i 时贡献 -1否则贡献0第二项对 s_k 求导结果是 exp(s_k) / sum_j exp(s_j) p_k。合起来dL_i / ds_k p_k - 1[k y_i]这就是传说中Softmax反向传播的“神仙结论”预测概率与one-hot标签之间的差。写成矩阵形式就是dS probs - onehot(y)这个结果的直观性你越品越有味道。如果模型对正确类别的预测概率已经是1那梯度是0什么都不用改完美收敛如果模型把概率分散给了错误类别那么错误类别的梯度就是正数推动分数下降正确类别的梯度是负数推动分数上升。这其实就是数据告诉你“下一步该往哪走”的原始信号。推导过程中要特别注意符号。很多人会在这里把负号搞反最后训练出来的准确率稳如泰山地停在10%因为模型在一个劲地往错误方向优化。好在有梯度检查这个保险在符号反了一次性就能查出来。3.2 Naive实现先把正确性做出来先写一个最容易读懂的循环版保证逻辑绝对正确def softmax_loss_naive(W, X, y, reg): N X.shape[0] scores X.dot(W) scores - np.max(scores, axis1, keepdimsTrue) exp_scores np.exp(scores) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue) loss -np.log(probs[np.arange(N), y]).sum() / N reg * np.sum(W * W) dW np.zeros_like(W) for i in range(N): ds probs[i].copy() ds[y[i]] - 1 for k in range(C): dW[:, k] X[i] * ds[k] dW / N dW 2 * reg * W return loss, dW这里我偷懒把偏置省略了把 b 直接合并到 W 的最后一列X 也对应多一列1。循环里 dW[:, k] X[i] * ds[k]本质就是单个样本对每个类别的梯度累加符合链式法则dW x * ds^T。这个版本能跑、结果正确但速度感人。CIFAR-10 训练集 49000 个样本每算一次前向反向要遍历全部样本耗时轻松突破几百毫秒。作业后面要求你训练几千次迭代用这个版本能把人等到心态崩。3.3 向量化实现看懂广播就赢了一半向量化的核心只有两行ds probs.copy() ds[np.arange(N), y] - 1 ds / N dW X.T.dot(ds) 2 * reg * W第一行 ds[np.arange(N), y] - 1 用的就是numpy的花式索引把每个样本正确类别的位置减1等价于对每行执行“概率向量减one-hot”的操作。注意这里必须用 np.arange(N)否则你只会修改某一个固定位置。第二行的 x.T.dot(ds) 是把 naive 版本里的循环累加换成了矩阵乘法因为本质上 sum_i x_i * ds_i^T 就是 X^T ds。这里有个小优化技巧先 ds / N 再矩阵乘和矩阵乘之后再除 N 结果一样但前者能减少一次对 (D, C) 大矩阵的缩放操作省一点浮点运算。虽然微乎其微但养成先除再乘的习惯没有坏处。向量化之后性能差距是惊人的。我个人实测同样的数据、同样的迭代次数naive版本单次前反向大约250毫秒向量化版本不到3毫秒差了接近两个数量级。这也是为什么作业要求你“must be vectorized”的原因——不仅仅是代码优雅而是现实训练根本等不起。4. 梯度检查与训练调参的完整套路4.1 数值梯度怎么算才算对梯度检查的原理是导数的定义用有限差分去逼近真实梯度。公式是grad_numeric (L(w eps) - L(w - eps)) / (2 * eps)也就是中心差分公式它比单侧差分 (L(weps) - L(w)) / eps 的误差小一个数量级因为中心差分的截断误差是 O(eps^2)单侧差分只有 O(eps)。所以别图省事用单侧差分梯度检查本来就容易误报没必要给自己加干扰项。eps 选 1e-5 左右比较稳太大截断误差明显太小浮点精度会开始捣乱。检查时把所有参数展平成一个大向量统一算相对误差rel_error np.linalg.norm(grad_analytic - grad_numeric) / (np.linalg.norm(grad_analytic) np.linalg.norm(grad_numeric))相对误差在 1e-7 以下说明实现基本完美1e-4 左右通常也还能接受但如果到了 1e-2 甚至更高那不用怀疑你的解析梯度肯定哪个环节写错了。我第一次做的时候犯过一个特别低级的错误梯度检查只在 W 上做却忘了 bias 也要检查。偏置的梯度分布和 W 完全不一样漏掉等于没查全。建议 W 和 b 分开查或者直接合并成一个参数向量一次性查。4.2 梯度检查失败的三个经典原因第一个必然是忘记除以 N。前面说过损失取平均梯度就要取平均两边必须一致。第二个是正则项的梯度没加。很多人算了数据损失那一半却忘了在 dW 里加上 2regW。这种情况梯度检查也会挂而且挂得很有规律误差在数值上恰好等于 2regW 的量级。第三个是对象搞混。比如你实现的时候在 loss 函数里对 W 做了归一化但梯度检查的函数里用的是另一个未归一化的 W或者你把 weights 和 bias 的梯度传给彼此形状相同所以 numpy 不报错但相对误差直接爆炸。如果梯度检查一直不过我的建议是先别急着改代码把你手推的每一步写在纸上然后对照代码逐行核对。这个过程的收益往往比单纯调式大得多。4.3 训练前必须做一次“冒烟测试”正式开始训练大模型之前先做两个 sanity check能省掉至少一小时的无头苍蝇式调试。第一个只用 10 到 20 张图训练跑几十个迭代看 loss 能不能降到接近0。如果连这么小的数据都过拟合不了说明你的前向或反向实现一定有 bug。批量训练的流程没问题的前提下极小数据集上 loss 应该几乎是直线下降。第二个刚初始化完、还没训练时如果你的权重是纯随机小值那么 Softmax 输出应该接近均匀分布10分类的初始 loss 应该在 -log(1/10) ≈ 2.302 附近。如果初始 loss 离这个值太远说明权重初始化或者数据预处理出了问题。这个技巧我一直沿用到现在无论用什么框架每次搭完新模型都会先看一眼随机初始化时的 loss 值。4.4 超参数调参别一上来就网格搜索CIFAR-10 线性分类器的超参数主要就是学习率 lr 和正则系数 reg。很多人一上来就三个 lr 乘三个 reg 做网格搜索9 次实验跑完发现全都不好既浪费时间又得不到规律。更聪明的做法是随机搜索并且是在对数尺度上搜索lr 10 ** np.random.uniform(-3, 0) # 从 1e-3 到 1 reg 10 ** np.random.uniform(-4, 0) # 从 1e-4 到 1为什么要用对数尺度而不是均匀分布因为学习率 0.001 和 0.002 的差异远没有 0.01 和 0.02 的差异重要超参数的“步长”在数量级上是等价的。随机搜索相比网格搜索的优势在于你可以在不知道哪个区域更优的情况下用更少的实验覆盖到更多可能性。在实际操作中我一般先固定 reg0 跑几组 lr找到 loss 能稳定下降的区间然后再固定一个不错的 lr 去扫 reg。这样把二维搜索降成一维的两步反馈更快直觉也更强。完整训练时我用 lr1e-2、reg1e-3、batch size256迭代 2000 次训练损失从 2.31 降到 1.25 左右验证集准确率能到 37%-38% 这个水平。跟SVM线性分类器基本持平符合预期——毕竟两个模型容量差不多能榨取的信息上限就在那里。5. 训练过程中踩过的坑与排错速查表这一节我把做 Assignment 1 时最常遇到的报错和诡异现象整理成一张速查表。都是我或者身边同学真实遇到过的按照“症状 - 原因 - 处理”的顺序给出来方便你直接对照。症状大概率原因解决办法loss 直接是 nan 或 inf学习率过大导致梯度爆炸或者 softmax 没做数值稳定调低学习率检查是否减去每行最大值训练准确率一直卡在10%左右权重初始化太离谱或数据没做归一化用 np.random.randn(D,C)*0.01对 X 做减均值除标准差loss 一直不降但也不是 nan学习率太小梯度步长根本不够把 lr 调大一个数量级再试验证集准确率远低于训练集过拟合模型记住训练集了增大 reg线性模型主要靠正则控制梯度检查相对误差在 1e-2 以上忘记给 dW 加正则项或忘记除以 N逐项核对解析梯度公式权重可视化全是噪点、看不出轮廓数据预处理不一致或可视化时没有归一化到 [0,1]确认训练和可视化用的是同一套预处理流程除了这些还有一个通用排错技巧把所有中间变量的 shape 全部打出来检查。很多人报错 “cannot broadcast”十有八九是矩阵乘法的方向搞反了。记住一个口诀前向是 scores X W反向是 dW X.T dS只要这两个方向不搞混大部分 shape 问题都能避免。权重可视化是这一关最直观的成就感来源。把 W 的第 i 列形状是 3072对应第 i 类的模板reshape 回图像wimg W[:, i].reshape(3, 32, 32).transpose(1, 2, 0) wimg (wimg - wimg.min()) / (wimg.max() - wimg.min())注意这里 reshape 的顺序。CIFAR-10 的原始数据是 (N, 3, 32, 32)flatten 后前 1024 维是红色通道中间 1024 维是绿色最后是蓝色。所以 reshape 时要先还原成 (3, 32, 32)再 transpose 成 (32, 32, 3) 才能正常显示。如果直接用 (32,32,3)图像颜色会完全错乱成一团。很多人栽在这个细节上。训练完成后你能看到每个类别学出的大致模板比如“鸟”类别的模板里蓝色像素的权重可能比较高“汽车”类别的模板里可能有一坨像车身形状的色块。这说明线性模型学到的本质就是每个类别的“平均长相”这也解释了为什么它上限不高——因为真实世界的类别不是线性可分的类内差异太大一个模板根本描述不了一个类别的全部变化。6. 常见问题排错实录与定位技巧6.1 从“单个样本手算”到“代码对拍”梯度检查依然失败的时候我强烈推荐一个方法只取一个样本、一个很小的 W手算一次 loss 和梯度然后用代码跑同样的输入逐数字对比。这个流程听起来原始但它是定位所有 bug 的终极手段。因为当你把所有东西都浓缩到能心算的规模时问题就无处可藏。举个例子取 X 是 (1, 3)W 是 (3, 2)y0。你手算 probs动手算 loss然后算 dW 的每个元素再和代码输出对比。一般几步就能找出是 loss 公式的问题还是梯度累加的问题。我当年靠这个方法找出过一个特别隐蔽的 bug我在用花式索引赋值时忘了先 copy probs导致 probs 本身被修改后续所有梯度全错。这种错误靠肉眼盯代码很难发现但对拍一遍立刻无所遁形。6.2 向量化版常见坑花式索引与广播向量化最容易踩的坑就是花式索引。ds[np.arange(N), y] - 1 这一行如果写成 ds[y] - 1numpy 会把它解释成选取 y 中的每个数字作为行索引结果只在几个固定位置减 1梯度分布彻底错误但形状完全不变不会有任何报错。这就是最恐怖的“静默错误”你必须靠梯度检查才能发现。另一个坑是 broadcasting。比如某个中间变量的形状是 (N, 1)另一个是 (N, C)两者相减时numpy会自动把 (N,1) 广播成 (N,C)。有时候这不是你想要的但它不报错结果就是数值不对劲。所以每次做完矩阵运算print 一下结果的 shape 再往下走是成本最低的保险。6.3 调参阶段的心态管理最后说一点心态上的经验。如果你想第一次就把超参调好往往不如先接受“前几次实验就是用来找方向的”。我记得第一次完整跑训练时验证集准确率只有 20%我当时以为自己代码写错了反复查了一晚上。后来发现只是 lr 太小模型根本没动。把 lr 从 1e-4 提到 1e-2 之后一次训练直接干到 35% 以上。所以在调参之前先确认这个任务的“天花板”。线性分类器在 CIFAR-10 上的合理水平就是 35%-40%低于 30% 说明模型没训练起来超过 45% 反而要怀疑是不是数据泄漏了。知道天花板在哪你就不会在一个不可能达到的目标上浪费精力也能更快速判断是训练问题还是实现问题。7. 从Softmax作业里偷师的东西做完这个作业你会发现自己从此面对深度学习框架里的 CrossEntropyLoss 再也不虚了。你打开任意一个框架的源码看到 log_softmax 加 negative log likelihood心里会清楚每一步在做什么而不是把 loss 当成一个魔法函数。这种“底数感”是后续所有深度学习排错能力的根基。我自己在看完框架源码之后才知道Softmax 在工程上几乎从来不会单独做 exp 再除和而是直接计算 log-softmax再用 NLL Loss 整合成一步。这样不仅数值更稳还能省一次指数运算。作业里要求你写成两段式是为了教学清晰工程里你知道这一步可以合并就可以了。另外可以玩两个进阶小技巧。一个是温度系数 temperature scaling把 scores 除以一个温度 T 再送进 softmaxT 越大输出分布越平滑T 越小越尖锐。这在知识蒸馏里很常见很多推荐模型也会用它控制预测置信度。另一个是 label smoothing把 one-hot 标签换成 (1-epsilon)/... 的平滑分布能有效防止模型过度自信提升泛化能力。这两个都是对 Softmax 分布输出的小改造你如果亲手实现过一次基础版本理解这些扩展几乎不需要额外学习成本。很多人在面试里被问过“Softmax的反向传播是什么”这个问题其实没有任何技巧可言就是平铺直叙写公式dL/ds_k p_k - 1[yk]。但凡是手写过 CS231n 作业的人回答这个问题就像背乘法口诀一样自然因为推导过程早就刻在肌肉记忆里了。反过来说只会调框架的人拿到这个问题往往支支吾吾这就是手写作业带来的隐性优势。最后分享一个我自己的小习惯作业做完之后不要急着把它扔进回收站。把 loss 曲线、梯度检查的相对误差、最终权重可视化这三样东西截图存下来哪怕只是存在本地相册里。以后你接触更复杂的模型、更花哨的损失函数时随时可以回来看一眼哦原来最简单的线性模型长这样原来 loss 下降到平台期是这种手感。这些基线经验会是你之后判断一切模型是否正常的最重要的参照系。