做深度学习这块的人,大概率都会在某一天撞上“softmax回归”这个名词。很多人翻到这个章节时觉得它平平无奇,公式也就三五行,代码实现起来十分钟就能跑通,于是顺手划过去了。可我自己从实际项目里回头看,这个模型恰恰是从“拿数字硬比”切换成“拿概率说话”的关键转折点,也是后面所有深度分类模型的骨架。这篇博客就把softmax回归从头到尾拆一遍:它到底在解决什么问题、权重和损失函数为什么是那样配置的、手写代码怎么一步步落地,以及训练过程中最容易踩到的那几类坑。
这篇内容不挑基础,哪怕你只写过线性回归,甚至只是刚看完理论的初学者,也可以照着代码推一遍。它适合两类人:一类是想把分类问题从原理上吃透,而不只是调接口的学习者;另一类是已经在用深度学习框架,但遇到loss不降、输出nan、训练震荡时想搞明白底层原因的开发同学。搞清楚softmax回归,后面看多层感知机、卷积网络、注意力机制里的各种分类头,都会顺很多。
1. 项目整体设计与核心思路解读
1.1 为什么线性回归的套路到分类任务上就不够用了
先从最朴素的问题出发。线性回归输出的是一个连续数值,比如预测房价、预测温度,这个值域是(−∞, +∞)。但分类任务要的是“这张图属于猫、狗还是鸟”这样的离散判断。你当然可以说,猫标成0、狗标成1、鸟标成2,然后训练一个线性回归去拟合这些标签数字。可这里马上就会遇到两个逻辑上的漏洞。
第一,数字天然带上了大小和顺序关系。如果猫是0、狗是1、鸟是2,那么模型会暗中认为“鸟比狗更接近猫”,因为0和2的距离比1和2的距离更远,这种无中生有的顺序会让决策边界变得很奇怪。第二,回归模型的输出没有边界约束,预测一个3.7,你根本没法解释这个3.7落在哪个类别上。你可能会想,那就取最接近的整数呗,但这就等于硬生生地把一个连续值压缩成了离散值,中间的信息关系完全是断裂的。
softmax回归不是去硬拟合标签的数值,而是换了一种思路:我先让模型对每个类别输出一个分数,这个分数叫作logit,然后通过softmax把这些分数转换成一组和为1的概率。有了概率,模型的判断就不只是一个硬标签,而是“有多大的把握认为它是猫”。这一下就把问题从数值拟合变成了概率建模,整套数学工具都跟着换了一套,包括后面会用到的交叉熵损失,也只有在概率分布这个框架下才顺理成章。
1.2 softmax回归的定位:多分类的Logistic回归
很多人第一次看到“softmax回归”这个名字会觉得奇怪,既然是分类问题,为什么还叫回归?其实这个名字是历史沿袭下来的。在统计学习里,它更常见的叫法是多项Logistic回归,也就是把二分类里那个sigmoid函数推广到多分类版本。二分类时,我们只需要一个输出节点,经过sigmoid压缩到0到1之间,表示正类的概率。多分类时,有q个类别,就不能用单个sigmoid了,而是对q个logit做一个整体归一化。
这个“整体归一化”就是softmax函数的动作。假设模型对三个类别分别输出了2.0、1.0、0.1这三个分数,softmax做的事情是先对每个分数取指数,得到e^2.0、e^1.0、e^0.1,然后分别除以三者之和。取指数的好处是让原本负数或很小的分数也变成正数,并且拉开了大分数和小分数之间的距离。高分对应的概率会被放大,低分对应的概率会被压得更低,最后形成一个类似“竞争”的分布形态。
关于这个softmax名字里的soft,我多说一句。它对应的是硬分类:如果直接取max,2.0对应的类别就拿到100%概率,其他都是0,这就是硬判断。softmax则保留了一部分模糊性,让每个类别都分到一点概率,即使那个0.1的低分类别也有一个很小的非零概率。这种软分配在训练阶段尤其重要,因为梯度可以顺畅地流回所有参数,而不是只在胜出者那里流动。这也是整个模型的梯度学习能够正常运转的底层原因之一。
2. 数学原理与关键参数解析
2.1 权重矩阵、偏置与输入特征的维度关系
softmax回归的特征部分仍然是线性的,这一点和线性回归一致。输入特征可以表示成一个行向量x,维度是d,要预测的类别数是q。权重矩阵W的维度就是d×q,偏置b的维度是1×q。整个模型的计算就是z = xW + b,得到的z是一个长度为q的向量,代表每个类别的原始分数。
这个维度设计经常让刚接触的人迷糊一阵子。线性回归里W是一个列向量,因为输出只有一个标量;到了多分类,输出变成了q个logit,权重自然从列向量变成矩阵。你可以这样理解:W的每一列都对应着一个类别,第j列就是“专门为类别j服务的线性回归系数”。输入特征x与W的第j列做点积,得到的分数就是模型认为这个样本属于类别j的证据强度。偏置则负责调整每个类别的整体偏移,比如某个类别在训练集里天然出现得更多,偏置就可能自动学习到比较大的值。
把z套上softmax之后,就得到每个类别的预测概率。假设第j个类别的预测概率记为ŷ_j,那么ŷ_j = exp(z_j) / Σ_{k=1}^{q} exp(z_k)。这里的所有对数运算和指数运算都作用于向量内部,不需要改动任何权重维度,只是对logits做了一次非线性变换。模型的训练目标就是让这组概率分布去逼近训练数据里的真实分布。
2.2 数值稳定性:exp溢出问题与max转移技巧
说实话,softmax函数本身实现起来非常简单,但“指数运算”在计算机里有一个极其现实的问题:数值溢出。如果某个z_j是100,e^100这个数值远远超出了浮点数能够表示的范围,直接算就会得到inf;如果某个z_j是−100,e^−100又会小到几乎等于0,导致除法结果异常。这听起来像是一个边角问题,在实际训练中却非常常见,尤其是模型参数初始化不合适或者学习率偏大的时候,logits很容易在某个batch里冲到几十甚至上百。
标准的解法是所谓的max-softmax技巧。在计算exp之前,先从z里减掉最大值。因为softmax的分子分母同时除以一个常数e^max(z),整个概率分布是不变的。减掉最大值之后,最大的logit变成0,其他logit都小于等于0,任何exp的结果都落在(0, 1]之间,彻底排除了上溢的可能。与此同时,分母的最小值有下限保证,下溢的风险也大大降低。
我到今天仍然建议所有人都把这一步当成习惯写进代码,哪怕是后续用成熟框架,也要留意底层有没有自动做这个处理。很多时候,你自己手写一个softmax来做check,没有减max,算出来的概率虽然表面上看起来差不多,但万一logits偏大,就会输出一堆nan,影响排错。
2.3 交叉熵损失函数:为什么它和softmax是天生一对
模型输出的概率分布要和真实标签分布对齐,衡量这个“对齐程度”最常见的指标是交叉熵。真实标签通常用one-hot编码表示成向量y,比如类别是1,那么y = [0, 1, 0],而模型预测的是ŷ = [0.1, 0.7, 0.2]。交叉熵的计算公式为 L = −Σ_j y_j log(ŷ_j)。由于y是one-hot向量,整个求和其实只在真实类别那一项上有效,也就是L = −log(ŷ_{label})。
这个形式非常优雅:模型对正确类别给出的概率越高,损失就越小。如果ŷ_{label}接近1,损失接近0;如果ŷ_{label}接近0.01,损失就会大到约4.6。它提供了非常陡峭的梯度信号,尤其是当模型把正确类别的概率压得很低时,梯度会非常大,逼着模型快速修正。
但交叉熵在理解上有几个常见的盲点。第一,它是从信息论里借来的,不用非得把它想得多玄,你只需要牢牢记住“它惩罚的是低置信度下的错误判断”。第二,这里用的是log,不是log2,也不是ln,具体是哪个底数不影响优化结果,因为底数差异只是给损失乘以一个常数缩放。第三,也是最重要的,softmax加上交叉熵的组合在数学上有一个极好的性质:两者的梯度特别干净。如果你单独拿均方误差去配合softmax用,梯度里会多出一个ŷ_j(1−ŷ_j)的饱和项,当某个类别概率趋近0或1时,梯度会被压得很小,学习速度急剧放缓。交叉熵的那个log正好在反向传播时抵消掉了softmax的指数项,让梯度变成简单的(ŷ_j − y_j)之差。这也是为什么分类任务几乎默认用交叉熵而不是MSE,不是谁规定的好看,而是数学上确实更顺。
3. 手写实现与核心代码解读
3.1 环境准备与数据集加载
纸上谈兵了半天,还是得来点能跑的代码。为了便于理解,我直接用numpy手写一个纯净版softmax回归,不做任何深度学习框架的封装。手写版的每一步都透明可见,哪里做了归一化、哪里算了梯度,一眼就能扫明白。数据集我用一个公开的手写数字数据集,每张图片拉平成784维的向量,一共10个类别。代码量不大,跑在普通笔记本CPU上也就几分钟。
import numpy as np def load_data(sample_size=5000): # 模拟公开手写数据集的加载,图片拉平为784维,标签为0-9 rng = np.random.default_rng(42) images = rng.normal(0, 1, size=(sample_size, 784)).astype(np.float32) labels = rng.integers(0, 10, size=sample_size) # 转成one-hot编码 one_hot = np.zeros((sample_size, 10), dtype=np.float32) one_hot[np.arange(sample_size), labels] = 1.0 return images, one_hot X, y = load_data() # 划分训练集和验证集 X_train, y_train = X[:4000], y[:4000] X_val, y_val = X[4000:], y[4000:]这里有一个小细节值得留意:我提前把标签转成了one-hot编码,后面写损失函数的时候可以直接拿它和预测概率做矩阵运算。也可以不转,直接用整数索引去取对应位置的概率,这样更快,但代码可读性会差一些。初次学习阶段,我建议先用one-hot,因为每一条矩阵运算都对应着理论公式,对照起来方便很多。
3.2 前向传播与数值稳定softmax实现
权重初始化我用的是随机正态分布除以sqrt(d),这是很经典的做法。之所以要除以sqrt(d),是因为如果直接用原始正态分布,输入特征维度越大,每个logit的方差就累加得越大,logits很容易一开始就跑到绝对值很大的区域,softmax输出可能会退化到接近one-hot。而我们要的是让初始概率尽量平滑,这样梯度信息才是充足的。
def init_params(dim_in, num_classes): W = np.random.randn(dim_in, num_classes) / np.sqrt(dim_in) b = np.zeros(num_classes, dtype=np.float32) return W, b def softmax(logits): shifted = logits - np.max(logits, axis=1, keepdims=True) exp_logits = np.exp(shifted) return exp_logits / np.sum(exp_logits, axis=1, keepdims=True) def forward(X, W, b): logits = X @ W + b probs = softmax(logits) return probs这段前向传播实现了xW + b之后做数值稳定softmax。axis=1表示沿着类别维度做归一化,keepdims=True是为了保持矩阵的形状可以做广播运算。你可能会问,减掉max之后,exp_logits里可能出现0吗?理论上如果原来的某个logit是最小值,且它与最大值之差很小,减完后结果也在0到1之间,不会出现真正等于0的情况;只有极端情况下,logits整体非常大且差值非常大时,才可能出现下溢到0。这时交叉熵里log(0)还是会出问题,所以完整的实现通常还会在log那一步加一个极小的eps。不过在实际训练中,如果出现这种情况,大概率你已经需要去排查梯度爆炸了。
3.3 损失、梯度计算与参数更新
梯度推导这里我直接给出结论,因为过程很长但非常经典:softmax加交叉熵的组合,对logits的梯度就是probs减去one-hot标签。对权重W的梯度,就是输入矩阵X的转置乘以这个梯度。明白了这一步,整个更新循环就非常透明了。
def cross_entropy_loss(probs, y_onehot): eps = 1e-12 loss = -np.sum(y_onehot * np.log(probs + eps)) / y_onehot.shape[0] return loss def train(X_train, y_train, X_val, y_val, W, b, epochs=50, lr=0.1): n = X_train.shape[0] for epoch in range(epochs): probs = forward(X_train, W, b) loss = cross_entropy_loss(probs, y_train) # 梯度 grad_logits = (probs - y_train) / n grad_W = X_train.T @ grad_logits grad_b = grad_logits.sum(axis=0) # 更新 W -= lr * grad_W b -= lr * grad_b if epoch % 10 == 0: val_probs = forward(X_val, W, b) val_acc = np.mean(np.argmax(val_probs, axis=1) == np.argmax(y_val, axis=1)) print(f"epoch {epoch}, loss {loss:.4f}, val_acc {val_acc:.4f}")训练过程中,我见过很多初学者试图手写逐个样本的for循环来计算梯度,200行代码写完还不知道错在哪。正确的做法是像这里一样尽量向量化,用矩阵乘法一次算完整个batch的梯度。你不需要在内存里显式地存每一个中间梯度,只要知道整个数据集的误差信号是个n×q矩阵就行。这个矩阵的每一行代表一个样本在各类别上的“犯错程度”,把它乘到特征矩阵的转置上,得到的就是对W的梯度。向量化不仅快,而且逻辑上更接近数学推导,排错更容易。
4. 训练过程中的常见问题与排查技巧
4.1 loss输出nan的排查路径
训练中最让人头皮发麻的情况,就是跑着跑着loss变成了nan。我第一次手写softmax回归时也遇到过,折腾好一阵才意识到是学习率太大导致的梯度更新步长失控。原理上很简单:学习率大 → 参数一步跨太远 → logits爆炸 → softmax输出分布变得极其尖锐或饱和 → 交叉熵里log(0)触发 → 梯度变成nan。排查的顺序建议先看学习率,从0.1往下降一位试试;再看初始化和数据归一化,比如特征是不是没做标准化;最后看损失函数里有没有加数值保护。
值得多说一嘴的是,即使你不用手写numpy,在成熟框架里训练分类模型,遇到nan也优先排查同一套顺序。框架自动微分不会帮你解决数值稳定问题,它只是替你算了梯度而已。
4.2 学习率、batch与收敛速度的权衡
softmax回归是一个完全凸优化问题,理论上只要学习率选得合适,loss就应该一直往下走。实际训练里最常见的反例是学习率太大导致的震荡,以及学习率太小导致的龟速收敛。我给一个经验范围:对标准化后的特征,0.1到0.5之间的学习率通常都能在几十个epoch内收敛到不错的精度;但如果你用的是未标准化的原始像素值,取值范围很大,0.1这个学习率就可能非常激进。
判断收敛曲线的技巧不是只看loss,而是同时盯验证集准确率。有时候loss在训练集上稳定下降,验证集准确率却停滞不前,这说明模型开始过拟合。softmax回归参数量相比数据集规模通常不多,但如果你用了特别高维的特征,这种过拟合照样会出现。这时候可以用的手段包括加正则项、降低输入维度、提前停止。
4.3 类别不均衡带来的概率偏差
另一个容易被忽略的问题是类别不均衡。假设数据里类别0占了90%,其他9类各占1%,模型完全不需要学任何特征,只要把所有样本都猜成类别0,准确率就已经90%。softmax回归在这种数据上学出来的概率会严重偏向高频类别,对低频类的logit压得非常低。
处理办法很直接,最简单的是在损失函数里给不同类别加权重,提高低频类别的惩罚系数。更进一步的做法是对数据进行重采样,让每个类别在batch里大致等比例出现。理论层面上,如果不做任何处理,模型学到的概率接近的是训练集里的先验分布,而不是真实条件概率。这一点对任何分类模型都成立,但是在softmax回归这种线性模型里表现得尤为明显。
5. 与周边概念的辨析与个人心得
5.1 二分类用sigmoid还是softmax
很多人写二分类任务时会在最后加一个sigmoid,觉得和二分类更配。但理论上,softmax只有一个节点时的输出,和sigmoid的输出在数学上是可以等价的。如果你用两个节点的softmax做二分类,两个概率互补;用一个节点的sigmoid,输出代表正类概率。两者的差别在于参数量和优化曲面细节,实际效果差距很小。
我个人的习惯是,只要数据能扩展成多分类,就统一用softmax。这样做的好处是代码结构一致,不用为二分类单独写一套逻辑。而且当你从二分类切到多分类时,模型结构不需要任何改动,只需要把输出节点数从2改成q。
5.2 为什么分类任务很少配合使用均方误差
顺着前面讲的梯度饱和问题再深挖一层。均方误差配合softmax时,对logits的梯度是(ŷ − y)乘以ŷ(1−ŷ)的形式。当模型已经给出一个比较自信的错误判断时,比如ŷ_{真实类别}接近0,这时ŷ(1−ŷ)接近0,梯度被压得几乎没有信号。而交叉熵的梯度是(ŷ − y),在同样的情况下梯度接近−1,信号非常强。这就是分类任务几乎一边倒选择交叉熵的根本原因。
理解这一点,你再去看很多框架里的分类模型的默认损失函数,就不会觉得那是随便选的,而是经过理论推导后的最优搭配。后面学多层网络、卷积网络时,这种“损失函数与最后一层激活函数的匹配关系”仍然是设计模型时的关键考量。
5.3 线性softmax的边界与后续扩展
说了这么多好话,也要泼点冷水。softmax回归是线性模型,它只能学出线性决策边界。在二维平面上,它的分界线是一条直线;在图像这种高维数据上,它只能按像素强度做一个线性加权。手写数字识别这种任务里,如果用简单线性模型就能达到大约92%的准确率,那是因为数字本身的像素分布有一定的线性可分性。但要处理更复杂的图,比如不同背景下目标识别,线性softmax就不够用了。
后续的路子是在它前面接上特征提取器,比如多层感知机、卷积层、Transformer,最后依然用softmax作为分类头。这也是为什么我说softmax回归是骨架而不是终点。理解了这个模型的全部细节,你后面做的每一个网络结构,其实都是在它前面加了一个越来越强大的“特征提取器”,而最后那个分类头一直没变过。
5.4 我最后想分享的一个小经验
训练softmax回归时,有一个操作我每次都会做:把训练结束后的权重矩阵W画出来,按类别重新排成图片形状。你会清晰地看到每个类别学到的模板长什么样,数字0学出来的是一个中间粗、两头细的白色区域,数字1学出来的是一个竖直的细条。这个可视化比任何准确率数字都直观,它能告诉你模型到底“看到”了什么。如果你看到一个类别的模板特别模糊或者特别怪异,那往往暗示数据里有噪声标签,或者类别定义有问题。
这种检查习惯放到今天的大模型里也是成立的。只不过从权重矩阵变成了注意力图、梯度流、激活值分布。原理是一个意思:先别急着调参,看看模型内部到底在用什么证据做判断。能深入到这个层次的人,解决问题的能力会跟只会调接口的人拉开很大距离。