资讯详情

资讯详情

softmax回归原理解析:从概率建模到交叉熵与代码实现

做深度学习这块的人大概率都会在某一天撞上“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我多说一句。它对应的是硬分类如果直接取max2.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) / Σ_{k1}^{q} exp(z_k)。这里的所有对数运算和指数运算都作用于向量内部不需要改动任何权重维度只是对logits做了一次非线性变换。模型的训练目标就是让这组概率分布去逼近训练数据里的真实分布。2.2 数值稳定性exp溢出问题与max转移技巧说实话softmax函数本身实现起来非常简单但“指数运算”在计算机里有一个极其现实的问题数值溢出。如果某个z_j是100e^100这个数值远远超出了浮点数能够表示的范围直接算就会得到inf如果某个z_j是−100e^−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_size5000): # 模拟公开手写数据集的加载图片拉平为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, sizesample_size) # 转成one-hot编码 one_hot np.zeros((sample_size, 10), dtypenp.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, dtypenp.float32) return W, b def softmax(logits): shifted logits - np.max(logits, axis1, keepdimsTrue) exp_logits np.exp(shifted) return exp_logits / np.sum(exp_logits, axis1, keepdimsTrue) def forward(X, W, b): logits X W b probs softmax(logits) return probs这段前向传播实现了xW b之后做数值稳定softmax。axis1表示沿着类别维度做归一化keepdimsTrue是为了保持矩阵的形状可以做广播运算。你可能会问减掉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, epochs50, lr0.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(axis0) # 更新 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, axis1) np.argmax(y_val, axis1)) print(fepoch {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学出来的是一个竖直的细条。这个可视化比任何准确率数字都直观它能告诉你模型到底“看到”了什么。如果你看到一个类别的模板特别模糊或者特别怪异那往往暗示数据里有噪声标签或者类别定义有问题。这种检查习惯放到今天的大模型里也是成立的。只不过从权重矩阵变成了注意力图、梯度流、激活值分布。原理是一个意思先别急着调参看看模型内部到底在用什么证据做判断。能深入到这个层次的人解决问题的能力会跟只会调接口的人拉开很大距离。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →