
在Pytorch里Softmax和LogSigmoid是输出层最容易被混用的两个函数。我见过不少同事在二分类问题上习惯性写两层全连接加Softmax结果模型收敛过程一直不稳定也见过有同学把LogSigmoid的输出直接当概率去算准确率最后指标怎么看都不对。这篇文章想认真把这两个函数从头到尾比一遍数学上的关系、Pytorch实现里的数值细节、梯度行为以及实际工程里到底怎么选、怎么搭。对刚开始写Pytorch模型的新手来说这是一份可以直接参考的选型手册对写了几个月模型但始终没琢磨透细节的人来说里面也有不少可以对照自查的内容。1. 两个函数解决的是同一类问题的两个分支1.1 适用场景的核心差异互斥与独立Softmax和LogSigmoid本质上都在做一件事把模型的原始输出logits转换成交叉熵损失能用的数值形式。区别在于它们对“类别关系”的假设完全不同。Softmax适用于“多选一”的互斥分类。它接收一组logits输出一组和为1的概率分布。你在MNIST上做10分类一个图片只能是数字3不可能同时是数字3和数字8这时候类别之间互斥Softmax就是最合理的选择。它会把最大的logit对应的概率拉高同时把其余logit对应的概率压低最终整个向量形成一个概率分布。LogSigmoid则适用于“多选多”的独立二分类。它接收任意多个logits对每一个位置独立地计算log(sigmoid(x))位置之间互相不影响。比如一张图片里同时出现猫和狗你要判断“有没有猫”“有没有狗”这两个判断是独立的就应该对每个类别各自算一个二分类概率。这也是多标签分类的标准做法。LogSigmoid的输出不是分布不会对整组数值做归一化它只关心单个位置的对数概率。1.2 sigmoid本来就是softmax的2分类特例很多初学者把Sigmoid和Softmax当成两个完全不同的函数实际上它们之间有非常直接的联系。假设二分类问题里模型输出两个logits类别0对应0类别1对应x。对这个向量做Softmax类别1的概率就是exp(x) / (exp(0) exp(x))分子分母同时除以exp(x)就得到1 / (1 exp(-x))这就是Sigmoid函数本身。换句话说用两个节点的Softmax做二分类等价于用单个节点做Sigmoid。同理类别0的概率是1 - sigmoid(x)也就是sigmoid(-x)。我在实际项目中验证过多次两种写法在数学上完全等价唯一区别是计算量和调参成本。用一个节点加Sigmoid模型参数更少优化也更直接用两个节点加Softmax多了一组冗余参数还可能因为初始化不好导致类别0和类别1的logits出现偏移。所以二分类任务里我通常直接推荐单节点加Sigmoid/LogSigmoid而不是绕路去用Softmax。这一点也解释了为什么Pytorch的BCEWithLogitsLoss内部用的是Sigmoid准确说是LogSigmoid的组合而CrossEntropyLoss内部用的是LogSoftmax。它们各自服务的任务边界从数学设计上就已经分开了。2. 数学原理、梯度行为与Pytorch的数值稳定设计2.1 Softmax与LogSoftmax公式拆解先看Softmax的朴素的公式。给定一个向量x第i个位置的Softmax输出是softmax(x_i) exp(x_i) / sum_j exp(x_j)这个公式本身很简单但直接照着写代码会出问题。当x_i比较大的时候exp(x_i)会溢出成无穷大。我在第一次自己实现Softmax时就踩过这个坑输入logits到了20exp(20)大约是4.8亿还在float32范围内到了90exp(90)大约是8.7乘以10的38次方已经逼近float32的上限超过100基本就溢出成NaN了。Pytorch内部做了数值稳定处理实际等价于softmax(x_i) exp(x_i - max(x)) / sum_j exp(x_j - max(x))先减去整个向量的最大值所有指数函数的输入都小于等于0最多等于1这样不可能溢出。数学上分子分母同时乘以一个常数exp(-max(x))结果不变所以这个变换不影响最终概率。而LogSoftmax更巧妙它直接利用对数运算消掉除法log_softmax(x_i) x_i - max(x) - log(sum_j exp(x_j - max(x)))这个式子的最后一项就是logsumexp。Pytorch的F.log_softmax在底层就是这么算的所以它是数值稳定的。如果你手写torch.log(F.softmax(x))虽然Pytorch的Softmax也做了减最大值处理但中间多了一次exp和log的往返精度会有微小损失更重要的是语义上绕路了不好读也不好维护。2.2 LogSigmoid的公式与梯度特性LogSigmoid的定义更直白log_sigmoid(x) log(sigmoid(x)) log(1 / (1 exp(-x))) -log(1 exp(-x))它的输出范围是负无穷到0。当x为正且很大时sigmoid(x)趋近1log_sigmoid(x)趋近0当x为负且绝对值很大时sigmoid(x)趋近0log_sigmoid(x)趋近负无穷。这个函数的梯度值得单独说说。对log_sigmoid(x)求导d/dx log_sigmoid(x) 1 - sigmoid(x) sigmoid(-x)当x是一个很大的正数时sigmoid(-x)趋近0梯度很小模型对这个样本几乎不做更新。当x是一个很大的负数时sigmoid(-x)趋近1梯度接近1模型会快速修正这个严重错误的预测。这个梯度特性和交叉熵损失配合起来非常合适。BCEWithLogitsLoss对单个样本的loss是loss -[y * log_sigmoid(x) (1 - y) * log_sigmoid(-x)]对输入x求梯度化简后是d(loss)/dx sigmoid(x) - y当x非常大且y1时sigmoid(x)接近1梯度接近0模型已经“很有把握”了不再大幅更新当x非常小且y1时sigmoid(x)接近0梯度接近-1模型会大幅修正。整个梯度曲线平滑、单调、不会出现阶跃这对SGD一类的优化器非常友好。2.3 数值稳定性Pytorch在背后做了哪些事Pytorch对LogSigmoid并不是简单地先算Sigmoid再取log那样在极端输入下同样会有数值问题。如果x是-100sigmoid(x)会计算成0log(0)就成了负无穷梯度也就失效了。Pytorch的F.logsigmoid内部做了分段处理当x 0时使用 -log1p(exp(-x))当x 0时使用 x - log1p(exp(x))log1p(t)是log(1t)的数值稳定版本当t非常小时log1p比直接算log(1t)精度高得多。这个分段处理保证了无论x取多大或多小都不会出现溢出或者精度崩溃。这一点其实也回答了一个常见问题为什么Pytorch推荐用F.logsigmoid而不是torch.log(torch.sigmoid(x))。后者在负值很大的时候sigmoid可能下溢为0取log直接就是-inf。即使Pytorch的Sigmoid实现很稳中间依然有精度损失的可能。我自己在写自定义损失函数时永远优先用F.logsigmoid和F.log_softmax而不是手动组合log和sigmoid/softmax。3. Pytorch实操三种典型用法对比与验证3.1 基础API用法与输出对比先写一段最直接的对比代码。假设有一个batch的多分类logitsimport torch import torch.nn.functional as F logits torch.tensor([[2.0, -1.0, 0.5], [1.0, 3.0, -2.0]]) # Softmax得到概率分布 probs F.softmax(logits, dim1) print(probs) # tensor([[0.8222, 0.0403, 0.1375], # [0.1173, 0.8653, 0.0174]]) # 每行概率之和为1 print(probs.sum(dim1)) # tensor([1.0000, 1.0000]) # LogSoftmax得到对数概率 log_probs F.log_softmax(logits, dim1) print(log_probs) # tensor([[-0.1958, -3.2118, -1.9840], # [-2.1423, -0.1447, -4.0513]])对照一下可以看到log_probs确实等于probs取对数。注意到每行的log_probs之和不为0这点容易让人误解。因为log(a) log(b) log(c) log(abc)而Softmax只保证abc1不保证abc1所以LogSoftmax的输出并不满足“和为0”。再看LogSigmoidx torch.tensor([1.0, 2.0, 3.0, -1.0]) ls F.logsigmoid(x) print(ls) # tensor([-0.3133, -0.1269, -0.0486, -1.3133]) # 对照普通sigmoid取log print(torch.log(torch.sigmoid(x))) # tensor([-0.3133, -0.1269, -0.0486, -1.3133])在一般输入下两者输出完全一致。但前面的分析已经说明在极端输入下F.logsigmoid更安全。实际写代码时我从来不会用torch.log(torch.sigmoid(x))因为一旦x特别小这个表达式就可能出现inf或NaN而排查起来还特别隐蔽。3.2 二分类场景下等价性实验之前推导过Sigmoid等价于两个节点的Softmax。这句话在Log空间同样成立。我写一个小实验验证x torch.tensor([3.0]) # 方式一单节点LogSigmoid lp_binary F.logsigmoid(x) print(lp_binary) # tensor([-0.0486]) # 方式二两节点LogSoftmax取第二个节点 lp_softmax F.log_softmax(torch.tensor([0.0, 3.0]), dim0)[1] print(lp_softmax) # tensor(-0.0486) # 方式三两节点Softmax取概率再log p_softmax F.softmax(torch.tensor([0.0, 3.0]), dim0)[1] print(torch.log(p_softmax)) # tensor(-0.0486)三种方式结果一样。这个实验的意义在于当你看到某些老代码用两输出节点加Softmax做二分类时不要觉得奇怪它和单节点加Sigmoid在数学上是同一件事。差异只在于参数数量和训练时的梯度流。不过这里有一个很容易忽视的点用两节点Softmax做二分类时如果想用BCEWithLogitsLoss不能直接套因为BCEWithLogitsLoss期望的是单节点logits或逐元素的logits。需要把两节点概率先取类别1的概率再传入BCELoss或者干脆改用CrossEntropyLoss。这中间的转换很容易出错所以我才一直强调二分类直接用单节点加BCEWithLogitsLoss别绕路。3.3 与损失函数的正确搭配方式Pytorch官方封装的损失函数已经内置了对应的softmax或sigmoid变换。多分类场景直接用nn.CrossEntropyLoss二分类场景直接用nn.BCEWithLogitsLoss这是最不容易出错的做法。import torch.nn as nn # 多分类CrossEntropyLoss LogSoftmax NLLLoss criterion_ce nn.CrossEntropyLoss() loss_ce criterion_ce(logits, labels) # labels是整数索引 # 二分类BCEWithLogitsLoss内部整合了LogSigmoid criterion_bce nn.BCEWithLogitsLoss() loss_bce criterion_bce(binary_logits, binary_targets) # binary_targets是0/1很多人会忍不住在传给CrossEntropyLoss之前先加一层Softmax这是双重Softmax的经典错误。CrossEntropyLoss内部已经做了LogSoftmax你再加Softmax等于对概率又做了一次归一化破坏了原本的logits尺度训练出来loss起起伏伏最终效果基本都变差。同理BCEWithLogitsLoss内部已经做了Sigmoid和LogSigmoid组合的稳定计算你在外面再加Sigmoid再传进去也是双重变换。正确做法是始终让损失函数接收原始logits概率转换全部交给损失函数内部完成。4. 工程选择与易踩坑清单4.1 面对不同任务时怎么选综合前面的原理我用一个表格总结不同任务下的推荐写法任务类型类别关系输出层推荐配套损失函数备注单标签多分类互斥LogSoftmaxCrossEntropyLoss最常见二分类正负互斥LogSigmoidBCEWithLogitsLoss单节点输出多标签分类类别间独立LogSigmoid逐元素BCEWithLogitsLoss每个类别一个logit文本生成词表互斥LogSoftmaxCrossEntropyLoss对最后一个维度做softmax自编码器等自定义任务视loss而定按需手动自定义优先用log_softmax/logsigmoid这里特别要说一下多标签分类。很多人在多标签任务里习惯沿用Softmax这是结构性的错误。Softmax会强制所有类别概率之和等于1如果一张图同时有猫和狗Softmax会把“猫”和“狗”的概率互相压制永远无法做到两者同时高。而LogSigmoid对每个输出位置独立计算每个类别都有自己的阈值完全不受其他类别影响。所以多标签任务里逐元素的LogSigmoid加BCEWithLogitsLoss几乎是唯一正确的标准做法。4.2 我遇到过的7个高频坑这些年我踩过、也帮别人排查过不少相关的问题整理几个最容易出现的坑第一个坑是CrossEntropyLoss前手动加Softmax。症状是loss曲线下降特别慢或者训练初期loss下降后突然反弹。原因就是双重Softmax破坏了logits和标签之间的对应关系。第二个坑是Softmax的dim参数传错。对一个形状为(batch, num_classes)的张量dim1才是对类别维度归一化传成dim0就成了在batch维度上归一化每个样本的概率被其他样本“摊薄”了训练直接异常。第三个坑是用torch.log(F.softmax(x))代替F.log_softmax(x)。在绝大多数正常输入下结果一致但极端输入时数值稳定性差而且代码语义不好。如果你手写的自定义损失效果一直不稳定优先检查这里。第四个坑是BCEWithLogitsLoss输入了Sigmoid之后的概率。这会出现双重Sigmoid模型输出的logits先被sigmoid压缩到(0,1)再被BCEWithLogitsLoss内部当成logits又算一次sigmoid导致梯度非常平缓模型几乎不更新。第五个坑是二分类任务用了两节点Softmax却只取第二个节点计算loss。虽然不是完全不能用但白白增加参数还容易在类别0和类别1的logits之间引入不必要的耦合。建议直接用单节点加BCEWithLogitsLoss。第六个坑是推理时把LogSigmoid的输出当概率用。LogSigmoid输出是对数概率范围是负无穷到0不能直接和阈值0.5比较。如果推理时需要概率要么用torch.sigmoid对logits做变换要么对log_sigmoid的输出取exp。当然更自然的做法是保存logits需要概率时再sigmoid。第七个坑是混淆F.log_softmax和F.logsigmoid的输入输出。LogSoftmax必须指定dim并且对整个向量做归一化LogSigmoid是逐元素操作不需要dim。如果你的代码里对一个二维张量调用F.logsigmoid它会逐元素处理完全不做归一化很容易被当成softmax用而没发现问题直到结果出错。4.3 进阶场景多标签、蒸馏与自定义Loss多标签分类的代码其实很简洁。假设模型输出5个标签的logits形状是(batch, 5)logits torch.randn(8, 5) labels torch.randint(0, 2, (8, 5)).float() # 方法一直接用BCEWithLogitsLoss内部用sigmoidlog loss nn.BCEWithLogitsLoss()(logits, labels) # 方法二手动LogSigmoid写法不推荐但能帮助理解 loss_manual -torch.mean( labels * F.logsigmoid(logits) (1 - labels) * F.logsigmoid(-logits) )方法一和方法二在数学上等价BCEWithLogitsLoss正是用LogSigmoid的组合实现的。当你想在loss层面做点改造比如给正负样本加不同权重或者屏蔽某些位置的loss就可以使用方法二或者BCEWithLogitsLoss自带的pos_weight参数。知识蒸馏是另一个常见场景。蒸馏时teacher模型输出的软标签是概率分布student模型输出logits一般用KL散度来拉近两者temperature 3.0 teacher_probs F.softmax(teacher_logits / temperature, dim1) student_log_probs F.log_softmax(student_logits / temperature, dim1) loss_kl F.kl_div( student_log_probs, teacher_probs, reductionbatchmean ) * (temperature ** 2)这里必须用F.log_softmax而不是先softmax再log一方面是为了数值稳定另一方面KL散度的第一个参数要求是log概率直接使用log_softmax能保证输入类型正确、梯度回传干净。自定义损失里LogSigmoid也常常直接出场。比如某些生成模型里会计算负对数似然如果目标是二值的标志位就需要用到F.logsigmoid。我写过的一些多任务模型中辅助的分类分支就用LogSigmoid搭配自定义阈值避免过多额外的损失函数封装。5. 再聊聊梯度为什么两者的收敛行为不一样5.1 Softmax家族在交叉熵下的梯度形态很多人在使用中能感觉到Softmax家族和Sigmoid家族的收敛速度有差异但说不清为什么。这里把梯度公式拿出来对比一下。对LogSoftmax配合CrossEntropyLoss单个样本的梯度可以化简成d(loss)/d(logits_i) softmax(logits_i) - y_i其中y_i是one-hot标签里对应位置的值。也就是说梯度直接等于模型的预测概率减去真实标签。当预测概率离标签较远时梯度绝对值大更新快当预测接近标签时梯度接近0更新慢。这个形式非常干净梯度大小天然和“错误程度”绑定不会因为某个logits特别大就梯度消失。对比LogSigmoid配合BCEWithLogitsLoss单个二分类位置的梯度是d(loss)/d(x) sigmoid(x) - y形式上几乎一样。这很有趣虽然两个函数看起来完全不同但在各自配套的交叉熵损失下梯度结构高度相似。两者的本质差别更多体现在“互斥约束”上而不是梯度大小上。Softmax把一组logits的梯度耦合在一起一个位置概率升高其他位置概率自动降低LogSigmoid则每个位置完全独立。5.2 对收敛速度的真实影响既然梯度形式类似那现实项目中为什么感觉用Softmax做二分类时收敛慢多数时候不是因为Softmax本身而是因为用了两个节点却只计算一个节点的loss导致冗余参数和梯度不平衡。我自己的实验里同样的二分类数据单节点LogSigmoid加BCEWithLogitsLoss和两节点LogSoftmax加CrossEntropyLoss损失曲线几乎重合。真正拉开差距的是多标签场景这时候Softmax的互斥约束会持续压制标签之间的相关性收敛路径明显更曲折。另外一个影响收敛的外在因素是初始化。Softmax对logits的尺度比较敏感如果最后一个全连接层的输出分布很宽初始概率会集中在少数类别上早期梯度波动大。LogSigmoid虽然是逐元素操作但如果初始logits严重偏向某一侧同样会出现早期训练缓慢。常见的经验做法是输出层权重用比较小的标准差初始化确保初始概率不要太高或太低。这一点在我调试模型时经常用到。6. 一段不太像总结的收尾回到开头那个问题Softmax和LogSigmoid到底怎么选你现在应该已经清楚它们不是可以随意替换的关系而是各自服务于不同的任务假设。多分类、类别互斥优先Softmax家族二分类、多标签、类别独立优先LogSigmoid家族。而在Pytorch工程实践里最省心的做法是直接使用内置的CrossEntropyLoss和BCEWithLogitsLoss让框架帮你处理数值稳定性和梯度细节。我个人的习惯是输出层之前一律叫logits概率转换全部交给损失函数去做只有需要手动提取概率、写自定义损失或做蒸馏时才真正亲手调用F.log_softmax和F.logsigmoid。如果你能从这篇文章里带走一句话我希望是这句先看任务是不是互斥的再决定用哪个函数数值稳定这件事Pytorch已经替你做了大部分你只需要别去绕路加一层多余的变换。