从零实现Softmax分类器:原理、梯度推导与工程实践
1. 从线性分类器到概率输出Softmax 到底在做什么很多人第一次接触 Softmax 是在图像分类任务里看到公式里一堆指数和求和第一反应是“这不就是把分数变成概率吗”。这个直觉没错但只停留在这一步后面写代码时很容易踩坑。Softmax 分类器的本质是把线性模型的原始输出分数logits通过指数归一化映射成一个概率分布然后用交叉熵损失来衡量预测分布和真实标签之间的差距。我当初做这个作业时最大的困惑不是公式本身而是“为什么非要用指数”。后来自己推了一遍才明白指数函数能把任意实数映射到正数区间保证概率非负同时它单调递增不会改变原始分数的相对大小关系再加上归一化所有类别的输出加起来正好等于 1。这三个性质凑在一起才让 Softmax 成为一个合格的概率输出层。从线性分类器的角度看Softmax 和 SVM 其实是“同门师兄弟”。两者都基于线性打分函数 ( f(x, W) Wx b )区别在于损失函数的设计哲学不同。SVM 用的是合页损失hinge loss只关心正确类别的分数是否比错误类别高出某个边界值达到边界就不再惩罚Softmax 用的是交叉熵损失它希望正确类别的概率尽可能接近 1错误类别的概率尽可能接近 0。这个差异直接导致两者的梯度行为不一样后面推导梯度时会看得很清楚。这个作业的价值在于它逼着你从零实现前向传播、损失计算和反向传播而不是直接调库。很多人调torch.nn.CrossEntropyLoss调得很顺手但一旦被问到“梯度怎么来的”“数值不稳定怎么处理”就答不上来了。Assignment1 的 Softmax 部分恰好是补这块短板的最佳练习。2. 核心原理拆解打分函数、损失函数与梯度推导2.1 线性打分函数与数据预处理Softmax 分类器的第一步和 SVM 完全一样都是计算线性打分[ s f(x, W) Wx b ]其中 ( x ) 是输入样本比如 CIFAR-10 里一张 3072 维的展平图像( W ) 是权重矩阵形状为 ( (C, D) )( C ) 是类别数( D ) 是特征维度( b ) 是偏置向量。在作业里通常会把偏置合并进权重矩阵做法是在输入 ( x ) 末尾追加一个常数 1这样 ( W ) 就变成 ( (C, D1) )代码写起来更简洁。数据预处理这一步很多人会忽略但它对训练效果影响很大。CIFAR-10 的原始像素值是 0 到 255 的整数如果直接送进网络梯度会非常大训练容易发散。标准做法是两步先减去均值再除以标准差。作业里通常给出的是减去整个训练集的均值图像这样每个像素维度都中心化到 0 附近。我实测下来如果跳过这一步初始损失虽然看起来正常但训练过程中损失震荡会明显加剧收敛速度也慢不少。注意减均值时要用训练集的均值而不是整个数据集包括验证集和测试集的均值。虽然在这个作业里差异不大但养成正确的数据隔离习惯很重要否则在真实项目中会造成数据泄露。2.2 数值稳定版 Softmax 的推导原始 Softmax 公式是[ P(yk|x) \frac{e^{s_k}}{\sum_j e^{s_j}} ]理论上没问题但直接写代码会炸。原因是指数函数增长太快比如 ( s_k 1000 )( e^{1000} ) 直接溢出成 inf然后 inf 除以 inf 得到 nan。这个问题在 CIFAR-10 上不一定每次都会触发但只要权重初始化稍微大一点或者学习率设高了就很容易遇到。解决办法是利用指数运算的性质分子分母同时乘以一个常数 ( e^{-m} )[ P(yk|x) \frac{e^{s_k - m}}{\sum_j e^{s_j - m}} ]通常取 ( m \max_j s_j )这样指数部分的最大值就是 0不会溢出。这个技巧叫“数值稳定版 Softmax”是作业里必须实现的。我建议在代码里显式写出来而不是依赖库函数因为面试时经常被问到“如果让你手写 Softmax你怎么处理溢出”。2.3 交叉熵损失的计算得到概率分布后交叉熵损失定义为[ L_i -\log P(y_i | x_i) ]注意这里只取正确类别的概率的对数因为真实标签是 one-hot 向量其他类别的项乘以 0 就消失了。整个数据集的损失是所有样本损失的平均值再加上正则化项[ L \frac{1}{N} \sum_i L_i \lambda R(W) ]正则化项通常用 L2 范数 ( R(W) \sum_k \sum_l W_{k,l}^2 )作用是防止权重过大导致过拟合。作业里会要求你同时实现 L1 和 L2 两种正则化但实际训练时 L2 更常用因为它的梯度是线性的优化起来更平滑。2.4 梯度推导从链式法则到矩阵形式这部分是作业的核心难点。很多人能推出来标量形式的梯度但一到矩阵形式就卡住。我当初也是反复推了三遍才理顺。先看单个样本的损失对打分 ( s ) 的梯度。设正确类别为 ( y_i )概率向量为 ( p )则[ \frac{\partial L_i}{\partial s_k} p_k - \mathbb{1}(k y_i) ]这个结果非常简洁对于正确类别梯度是 ( p_{y_i} - 1 )负数因为概率小于 1对于错误类别梯度就是 ( p_k )正数。直观理解是梯度在“推”正确类别的分数往上走同时“压”错误类别的分数往下走力度和当前预测概率成正比——预测越离谱梯度越大。再往上推一层对权重 ( W ) 的梯度[ \frac{\partial L_i}{\partial W} (p - \mathbb{1}_{y_i}) x_i^T ]写成矩阵形式整个数据集的梯度是[ \nabla_W L \frac{1}{N} (P - Y)^T X 2\lambda W ]其中 ( P ) 是 ( N \times C ) 的概率矩阵( Y ) 是 one-hot 标签矩阵( X ) 是 ( N \times D ) 的输入矩阵。这个公式在代码里就是一行矩阵乘法的事但推导过程必须自己走一遍否则调试时根本不知道哪里错了。实操心得推导梯度时建议先用小规模数值比如 2 个样本、3 个类别手算一遍然后用数值梯度检验numerical gradient check验证解析梯度。作业里提供了grad_check_sparse函数我每次写完梯度都会跑一遍误差在 1e-8 量级才算过关。3. 代码实现全流程从数据加载到训练收敛3.1 数据加载与预处理作业用的是 CIFAR-10 数据集通过cs231n.data_utils里的load_CIFAR10加载。加载后得到的是 numpy 数组训练集 50000 张测试集 10000 张每张 32x32x3。第一步是展平成 3072 维向量然后做减均值和归一化。import numpy as np from cs231n.data_utils import load_CIFAR10 X_train, y_train, X_test, y_test load_CIFAR10(cs231n/datasets/cifar-10-batches-py) # 展平 X_train np.reshape(X_train, (X_train.shape[0], -1)) X_test np.reshape(X_test, (X_test.shape[0], -1)) # 减均值 mean_image np.mean(X_train, axis0) X_train - mean_image X_test - mean_image # 追加偏置维度 X_train np.hstack([X_train, np.ones((X_train.shape[0], 1))]) X_test np.hstack([X_test, np.ones((X_test.shape[0], 1))])这里有个细节验证集是从训练集里切出来的切分之后再做减均值否则验证集的统计信息会“污染”训练过程。作业里通常已经切好了但自己写项目时要注意。3.2 Softmax 损失函数的前向与反向实现这是作业最核心的部分。我把它拆成三个函数softmax_loss_naive循环版、softmax_loss_vectorized向量化版和softmax_loss数值稳定版。循环版适合理解原理但速度慢得离谱。向量化版用矩阵运算替代循环速度能快几十倍。数值稳定版则是在向量化基础上加了 max 减法。下面是我最终提交的向量化实现def softmax_loss_vectorized(W, X, y, reg): num_train X.shape[0] num_classes W.shape[1] # 前向传播 scores X.dot(W) # 数值稳定减去每行最大值 scores - np.max(scores, axis1, keepdimsTrue) exp_scores np.exp(scores) probs exp_scores / np.sum(exp_scores, axis1, keepdimsTrue) # 计算损失 correct_logprobs -np.log(probs[np.arange(num_train), y]) loss np.sum(correct_logprobs) / num_train loss 0.5 * reg * np.sum(W * W) # 反向传播 dscores probs.copy() dscores[np.arange(num_train), y] - 1 dscores / num_train dW X.T.dot(dscores) dW reg * W return loss, dW这段代码有几个容易出错的地方。第一np.max要加keepdimsTrue否则广播时会出错。第二dscores里减 1 的操作要用花式索引不能直接dscores[y] - 1因为y是数组不是单个索引。第三正则化项的梯度是reg * W不是2 * reg * W因为损失里写的是0.5 * reg * sum(W*W)求导后系数抵消了。3.3 超参数调优学习率与正则化强度作业要求用验证集调学习率和正则化强度。我一开始随便设了learning_rate1e-3、reg1e-5结果训练损失下降很慢准确率卡在 30% 左右。后来按对数尺度扫了一遍发现learning_rate1e-2、reg1e-6效果最好验证集准确率能到 38% 左右。调参时有个技巧先用小规模数据比如 1000 张快速试几组参数找到大致范围后再用全量数据精调。因为 Softmax 是凸优化问题损失曲面比较平滑不需要像神经网络那样精细调参。另外学习率太大时损失会震荡甚至发散太小时收敛太慢建议从 1e-3 开始按 10 倍步长上下试探。学习率正则化强度训练准确率验证准确率1e-31e-535.2%33.8%1e-21e-640.1%38.2%1e-11e-642.3%37.5%1e-21e-438.7%37.9%从表里能看出学习率 1e-1 时训练准确率更高但验证准确率反而下降这是过拟合的迹象。最终我选了 1e-2 和 1e-6 的组合在测试集上跑出 38.5% 的准确率对于线性分类器来说已经接近上限了。3.4 权重可视化与模型理解训练完之后作业要求把权重矩阵 ( W ) 的每一行还原成 32x32x3 的图像可视化。这一步很有意思能直观看到模型学到了什么。我跑出来的权重图里每个类别的模板都呈现出模糊的轮廓比如“马”的模板能看到四条腿的痕迹“汽车”的模板有方方正正的形状。这说明线性分类器本质上是在为每个类别学习一个“模板”然后通过内积来衡量输入图像和模板的相似度。这也解释了为什么线性分类器对姿态、光照变化很敏感——它没有能力建模空间不变性只能靠模板匹配。理解这一点对后面学卷积神经网络时“为什么卷积能提取局部特征”会有更深的体会。4. 常见报错与排查技巧实录4.1 损失为 nan 或 inf这是最常见的报错原因几乎都是指数溢出。排查步骤先检查有没有做数值稳定处理再检查学习率是不是太大导致权重爆炸。我遇到过一次是因为忘了减 max而且初始权重用了np.random.randn没乘 0.001导致分数值很大。解决办法就是加上 max 减法并且权重初始化用0.001 * np.random.randn。4.2 梯度检验不通过数值梯度检验的误差应该在 1e-8 左右如果误差在 1e-4 以上说明解析梯度有问题。常见原因有三个一是正则化项的梯度系数写错二是dscores的维度没对齐三是忘了除以num_train。我建议把损失和梯度的计算拆成小函数逐段用数值梯度验证定位到具体哪一行出错。4.3 训练准确率不上升如果损失几乎不降先检查数据预处理有没有做再检查标签和预测的对应关系有没有搞反。我有一次把probs[np.arange(num_train), y]写成了probs[y, np.arange(num_train)]结果损失一直很高排查了半天才发现是索引顺序问题。另外学习率太小也会导致损失下降极慢可以先把学习率调大 10 倍试试。4.4 向量化版本和循环版本结果不一致这通常是广播或维度问题。建议先用小数据比如 5 个样本分别跑两个版本打印中间结果对比。重点检查scores的形状、probs的归一化轴、dscores的减 1 操作。我当初就是因为np.sum忘了指定axis1导致归一化方向错了损失算出来完全不对。避坑清单减均值用训练集统计量不要用全量数据Softmax 必须做数值稳定处理正则化梯度系数是reg * W不是2 * reg * W梯度检验误差要小于 1e-6学习率从 1e-3 开始按 10 倍步长扫向量化实现先用小数据验证再上全量5. 从 Softmax 到真实项目这个作业的延展价值5.1 与 SVM 的对比实验作业里同时实现了 SVM 和 Softmax我建议把两者的损失曲线画在一起对比。实测下来Softmax 的损失下降更平滑因为交叉熵对每个样本都有梯度SVM 在达到边界后梯度就变成 0 了只有正则化项在起作用。这也导致 Softmax 通常比 SVM 收敛更快但最终准确率差距不大在 CIFAR-10 上大概差 1 到 2 个百分点。5.2 从线性分类器到神经网络的过渡Softmax 分类器可以看作是一个没有隐藏层的神经网络。如果把它的实现改造成两层网络只需要在scores X.dot(W)之前加一个隐藏层H np.maximum(0, X.dot(W1))然后对H做 Softmax。这个改造过程能帮你理解反向传播的链式法则为后面写两层神经网络打下基础。我当初就是先把 Softmax 的梯度推熟再写两层网络时明显轻松很多。5.3 工程实践中的注意事项真实项目里很少直接用手写 Softmax因为深度学习框架已经封装好了。但理解底层实现有几个实际好处一是调试时能看懂损失曲线异常的原因二是面试时能手推梯度三是在做模型压缩或自定义损失函数时能改得动源码。我后来在一个多标签分类任务里需要自定义 Softmax 变体就是因为之前推过一遍改起来很快。另外Softmax 的数值稳定技巧在真实项目里也经常用到。比如计算 log-sum-exp 时如果不做 max 减法很容易溢出。这个技巧在变分推断、强化学习的策略梯度里都会出现属于基础中的基础。5.4 后续可以扩展的方向如果这个作业做完了还有余力可以尝试几个扩展一是实现温度系数 Softmax观察温度对概率分布的影响二是把 L2 正则化换成 Dropout对比泛化能力三是用 Softmax 做多标签分类把交叉熵换成二元交叉熵。这些扩展不需要额外数据改几行代码就能跑但对理解模型行为很有帮助。我个人在实际操作中的体会是Softmax 这个作业看起来简单但真正手推一遍梯度、调一遍参数、排一遍报错收获远比看十篇教程大。尤其是梯度检验那一步虽然麻烦但能帮你建立起对反向传播的“手感”后面写更复杂的网络时这种手感比任何公式都管用。