☰
从零实现Softmax分类器:原理、梯度推导与工程实践
2026/10/13 11:02:40 网站建设 项目流程

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, D+1) ),代码写起来更简洁。

数据预处理这一步很多人会忽略,但它对训练效果影响很大。CIFAR-10 的原始像素值是 0 到 255 的整数,如果直接送进网络,梯度会非常大,训练容易发散。标准做法是两步:先减去均值,再除以标准差。作业里通常给出的是减去整个训练集的均值图像,这样每个像素维度都中心化到 0 附近。我实测下来,如果跳过这一步,初始损失虽然看起来正常,但训练过程中损失震荡会明显加剧,收敛速度也慢不少。

注意:减均值时要用训练集的均值,而不是整个数据集(包括验证集和测试集)的均值。虽然在这个作业里差异不大,但养成正确的数据隔离习惯很重要,否则在真实项目中会造成数据泄露。

2.2 数值稳定版 Softmax 的推导

原始 Softmax 公式是:

[ P(y=k|x) = \frac{e^{s_k}}{\sum_j e^{s_j}} ]

理论上没问题,但直接写代码会炸。原因是指数函数增长太快,比如 ( s_k = 1000 ),( e^{1000} ) 直接溢出成 inf,然后 inf 除以 inf 得到 nan。这个问题在 CIFAR-10 上不一定每次都会触发,但只要权重初始化稍微大一点,或者学习率设高了,就很容易遇到。

解决办法是利用指数运算的性质,分子分母同时乘以一个常数 ( e^{-m} ):

[ P(y=k|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, axis=0) 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, axis=1, keepdims=True) exp_scores = np.exp(scores) probs = exp_scores / np.sum(exp_scores, axis=1, keepdims=True) # 计算损失 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要加keepdims=True,否则广播时会出错。第二,dscores里减 1 的操作要用花式索引,不能直接dscores[y] -= 1,因为y是数组不是单个索引。第三,正则化项的梯度是reg * W,不是2 * reg * W,因为损失里写的是0.5 * reg * sum(W*W),求导后系数抵消了。

3.3 超参数调优:学习率与正则化强度

作业要求用验证集调学习率和正则化强度。我一开始随便设了learning_rate=1e-3、reg=1e-5,结果训练损失下降很慢,准确率卡在 30% 左右。后来按对数尺度扫了一遍,发现learning_rate=1e-2、reg=1e-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忘了指定axis=1,导致归一化方向错了,损失算出来完全不对。

避坑清单:

  • 减均值用训练集统计量,不要用全量数据
  • 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 这个作业看起来简单,但真正手推一遍梯度、调一遍参数、排一遍报错,收获远比看十篇教程大。尤其是梯度检验那一步,虽然麻烦,但能帮你建立起对反向传播的“手感”,后面写更复杂的网络时,这种手感比任何公式都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询