
用PyTorch做分类任务的人迟早都会撞上一个问题同一个向量到底该用Softmax还是LogSigmoid尤其是看各种开源代码时一会儿见F.softmax一会儿见F.logsigmoid不搞清楚背后的逻辑很容易照抄出问题。这篇文章我会把这两个函数在PyTorch里的实现、数学性质、梯度表现、数值稳定性、损失函数搭配全部拉出来对比一遍最后附上可以直接跑的实验代码。不管你是刚入门PyTorch还是已经写了半年模型但一直没深究过这些细节这篇都值得花十分钟看完。先说结论Softmax处理的是“多个类别互斥”的单标签问题输出是一个和为1的概率分布LogSigmoid处理的是“每个类别独立判断”的多标签问题输出是每个标签独立的对数概率。两者的数学性质决定了它们在分类任务里是两种截然不同的工具混用轻则训练不稳重则Loss直接变成NaN。下面我从头拆一遍。1. 模型输出的最后一步到底在解决什么问题1.1 分类任务里模型输出的是什么一个神经网络分类器最后一层通常是线性层输出一个向量习惯上叫logits。以图片分类为例假设有10个类别模型最终输出的就是一个长度10的向量每个元素代表对应类别的“得分”。这个得分不是概率它的取值范围理论上可以是(-∞, ∞)。我们需要一个函数把这个得分转换成“概率”或者说至少转换成可以比较的数值。很多人以为Softmax是唯一的答案其实不是。得分转概率有两种完全不同的语义单标签分类一张图只能属于一个类别所有类别的概率加起来必须等于1。这是Softmax的活。多标签分类一张图可以同时属于多个类别比如一张照片里既有猫又有狗每个标签独立判断“有”还是“没有”。这时每个标签应该单独用Sigmoid不能用Softmax强制所有标签概率和为1。LogSigmoid就是Sigmoid外面再套一个log它的存在主要是为了数值稳定和方便损失函数计算。1.2 Softmax的核心公式和直觉Softmax的公式是softmax(x_i) exp(x_i) / sum_j(exp(x_j))它做的事情是先把每个得分做指数运算再归一化。指数运算会把正得分放大负得分压缩最后所有类别的输出都在(0, 1)区间并且加起来等于1。举个例子某个样本的logits是[2.0, 1.0, 0.1]那么exp(2.0) 7.389 exp(1.0) 2.718 exp(0.1) 1.105 总和 11.212 softmax [7.389/11.212, 2.718/11.212, 1.105/11.212] [0.659, 0.242, 0.099]可以看到原本是2.0的得分经过Softmax后概率约0.66是最高概率符合直觉。1.3 LogSigmoid的核心公式和直觉Sigmoid的公式是sigmoid(x) 1 / (1 exp(-x))输出范围是(0, 1)但只针对单个数值。多标签场景下每个标签的输出是独立的Sigmoid值这些值加起来不要求等于1。LogSigmoid就是log_sigmoid(x) log(sigmoid(x)) log(1 / (1 exp(-x)))问题来了为什么要再套一个log因为训练模型的时候损失函数通常需要计算“概率的对数”。如果模型直接输出对数概率就省去了先算概率再取对数这一步还能避免在概率接近0时取对数得到负无穷的情况。2. PyTorch里的API到底怎么调区别在哪2.1 Softmax家族torch.softmax、F.softmax、torch.nn.SoftmaxPyTorch里Softmax有三套调用方式import torch import torch.nn.functional as F x torch.randn(4, 10) # 假设4个样本10个类别 # 方式1torch直接调用 p1 torch.softmax(x, dim-1) # 方式2functional调用 p2 F.softmax(x, dim-1) # 方式3nn.Module封装 softmax_layer torch.nn.Softmax(dim-1) p3 softmax_layer(x)三者计算结果完全一样区别在于torch.softmax和F.softmax没有可学习参数本质是纯函数。torch.nn.Softmax是一个Module封装虽然内部也没有参数但如果你习惯把所有层都定义在__init__里用它会更统一。dim参数是必须指定的这也是新手最常踩的坑dim1和dim-1在二维张量里一样但一旦输入变成三维、四维写错维度结果就完全不对了。还有一对孪生兄弟log_softmax。PyTorch提供了F.log_softmax和torch.nn.LogSoftmax它直接计算log(softmax(x))但不经过先算softmax再取log的过程数值上更稳定这一点后面会展开。2.2 LogSigmoid家族torch.sigmoid、F.logsigmoid、torch.nn.LogSigmoid同样PyTorch里也有几套调用x torch.randn(4, 20) # 假设4个样本20个独立的标签 # 直接算Sigmoid p_sig torch.sigmoid(x) # 计算 log(sigmoid(x))注意函数名是 logsigmoid不是 log_sigmoid ls F.logsigmoid(x) # nn.Module封装 log_sigmoid_layer torch.nn.LogSigmoid() ls2 log_sigmoid_layer(x)调用很简单但要注意一个细节F.logsigmoid中间没有下划线是logsigmoid我自己就因为这个拼写查过好几次文档。2.3 维度、dtype和device的注意事项这两个函数在维度上的处理方式不一样Softmax是“竞争”关系必须指定dim因为它要沿着某个维度求和并归一化。Sigmoid是“独立”关系不涉及归一化所以不需要指定dim对每个元素单独计算。dtype方面两者都支持float16、float32、float64但在半精度下Softmax的指数运算更容易溢出建议在混合精度训练时对Softmax的输入做额外检查。device方面两者都原生支持GPU没有特殊要求。3. 数学性质与梯度行为对比3.1 输出空间和为1 vs 各自独立这是最本质的区别。我画一个非常简单的对比函数输入输出范围是否归一化典型场景Softmax向量(0, 1)和为1是单标签多分类Sigmoid标量/向量(0, 1)每个元素独立否多标签分类、二分类LogSoftmax向量(-∞, 0)和为1取log后不等1是log空间配合NLLLossLogSigmoid标量/向量(-∞, 0)每个元素独立否多标签、负采样注意一个容易混淆的点LogSoftmax的输出不再是“概率”而是“对数概率”它的每一项都小于0而且各项加起来也不是1因为log(ab) ! log(a) log(b)。3.2 梯度公式对比先说Softmax的梯度。如果只看单个输出softmax(x)_i对输入x_j的偏导分两种情况当i j时p_i * (1 - p_i)当i ! j时-p_i * p_j也就是说Softmax的梯度是一个矩阵而不是简单的向量。这也是为什么在反向传播时如果Softmax后面直接接交叉熵损失梯度会有一个非常漂亮的简化形式dL / dlogits softmax(logits) - one_hot(target)这个形式简洁到让人感动也是为什么CrossEntropyLoss内部会把Softmax和负对数损失融合在一起计算而不是分开算。再看看Sigmoid的梯度。对输入xSigmoid输出p其梯度是dp / dx p * (1 - p)这是一个标量乘以一个标量非常简单。如果套上log也就是LogSigmoid的梯度d(log_sigmoid(x)) / dx sigmoid(-x)这个性质极其重要它意味着当x很大时LogSigmoid的梯度趋近于0这会造成所谓的“梯度饱和”当x很负时梯度趋近于1信号可以正常回流。3.3 数值稳定性为什么不能直接先算Sigmoid再取Log理论上log(sigmoid(x))就是LogSigmoid但实际实现里绝对不能先算Sigmoid再取Log。原因很简单当x是一个很大的负数时比如x -1000sigmoid(-1000)会下溢在浮点数里直接变成0然后log(0)就是负无穷Loss直接NaN。PyTorch的F.logsigmoid内部不是这样实现的。它利用了数学上等价但数值上更稳定的公式log_sigmoid(x) -softplus(-x)而softplus(z) log(1 exp(z))在实现时又做了保护当z很大时直接返回z避免exp(z)溢出。类似地F.log_softmax也不是先算softmax再取log。它用的是logsumexp技巧log_softmax(x_i) x_i - logsumexp(x)其中logsumexp(x) max(x) log(sum(exp(x - max(x))))先减去最大值保证exp的输入不会太大再通过log(sum)恢复。这个技巧是数值计算里的经典方案PyTorch在底层已经处理好了但我们自己写代码时要有这个意识。4. 到底怎么选场景、损失函数和搭配方案4.1 单标签多分类用Softmax CrossEntropyLoss单标签多分类是Softmax最标准的应用场景。比如MNIST手写数字识别、ImageNet图像分类、情感分类正面/负面/中性每个样本只能属于一个类别。PyTorch里推荐做法是model SimpleCNN() logits model(x) # shape: [B, C] # 方式1直接使用CrossEntropyLoss内部会做log_softmax NLLLoss loss F.cross_entropy(logits, target) # 方式2显式使用LogSoftmax NLLLoss log_probs F.log_softmax(logits, dim-1) loss F.nll_loss(log_probs, target)两种方式在数学上是等价的我强烈建议用方式1因为F.cross_entropy内部把log_softmax和nll_loss融合在一起既能保证数值稳定又少一次中间变量的存储。有个新手常见误区如果模型已经用F.softmax算出了概率再用F.nll_loss去算损失得到的结果是错的。nll_loss期望输入的是对数概率不是概率。如果你喂给它概率Loss计算完全没有意义。正确做法是使用log_softmax或者直接用cross_entropy。4.2 多标签分类用Sigmoid / LogSigmoid BCE多标签场景下比如一张图片同时包含猫、狗、人标签是多个独立的0/1值这时每个标签的预测是独立的二分类问题。最优做法是logits model(x) # shape: [B, num_labels] # 方式1BCEWithLogitsLoss内部融合sigmoid BCE loss F.binary_cross_entropy_with_logits(logits, target.float()) # 方式2显式sigmoid BCELoss不推荐但理解原理 prob torch.sigmoid(logits) loss F.binary_cross_entropy(prob, target.float()) # 方式3logits经过log_softmax? 错误多标签不能用softmax归一化这里有个隐藏的坑binary_cross_entropy_with_logits这个名字已经告诉你它期望输入的是logits即未经过Sigmoid的原始得分内部帮你完成Sigmoid和BCE两个步骤。如果你在外面先做了torch.sigmoid(logits)再用BCEWithLogitsLoss相当于做了两次SigmoidLoss值就错了。多标签任务里如果看到有人用Softmax基本可以断定代码有问题除非他把问题强行建模成“多个标签互斥”但这种情况极少见。4.3 负采样与生成模型LogSigmoid的主场除了多标签分类LogSigmoid还有一个高频应用场景负采样。最典型的就是Word2Vec里的负采样损失。它的核心思想是对于一对正样本比如“猫”和“猫粮”应该相似我们希望模型给它高分对于一对负样本比如“猫”和“汽车”我们希望模型给它低分。这个目标可以写成loss -log(sigmoid(pos_score)) - log(sigmoid(-neg_score))其中第一项是LogSigmoid在正样本得分上取值的负数第二项是LogSigmoid在负样本得分取负后的负数。翻译成PyTorch代码就是pos_score model(positive_pairs) # 期望高 neg_score model(negative_pairs) # 期望低 loss -F.logsigmoid(pos_score).mean() - F.logsigmoid(-neg_score).mean()这里F.logsigmoid(-neg_score)的操作很巧妙sigmoid(-x) 1 - sigmoid(x)所以logsigmoid(-x)就是“负样本得低分”的概率对数这样写比显式计算log(1 - sigmoid(neg_score))要稳定得多。对比学习里也有类似操作。SimCLR等模型常用的InfoNCE损失本质上就是正样本的log_softmax和许多负样本的组合# logits shape: [B, B]对角线是正样本其他是负样本 labels torch.arange(B) loss F.cross_entropy(logits / temperature, labels)这里用cross_entropy而不是先算softmax就是利用了内置的log_softmax数值稳定特性。4.4 二分类特例Softmax退化成什么很多人问二分类到底用Softmax还是Sigmoid如果只有两个互斥类别理论上Softmax和Sigmoid是等价的。假设logits是[z, -z]对称化后那么softmax([z, -z])[0] exp(z) / (exp(z) exp(-z)) sigmoid(2z)多了一个2倍的缩放但排序关系完全一致。实际工程里二分类一般直接用一个输出接Sigmoid或者用两个输出接Softmax两个方案都能收敛但Sigmoid方案的模型参数更少训练起来更轻量。5. 实操对比实验代码与结果5.1 实验一数值稳定性对比先做一个极端输入测试看看不同实现在遇到大数值时的表现import torch import torch.nn.functional as F # 构造极端输入一个很大的正数一个很小的负数 x torch.tensor([1000.0, -1000.0, 0.0]) # 正确实现 print(F.logsigmoid:, F.logsigmoid(x)) # 输出: tensor([-0.0000, -1000.0000, -0.6931]) # 错误实现先sigmoid再log naive torch.log(torch.sigmoid(x)) print(naive log(sigmoid):, naive) # 输出: tensor([-0.0000, -inf, -0.6931])可以看到torch.sigmoid(-1000)在数值上变成了0再取log就是负无穷。而F.logsigmoid直接返回了-1000这就是数值稳定的意义。再看Softmaxx2 torch.tensor([[1000.0, 1000.0, 999.0]]) # 直接softmaxpytorch内部有保护 print(F.softmax(x2, dim-1)) # 输出 tensor([[0.2689, 0.2689, 0.4621]]) 不对这只是示意 # 实际输出接近 [0.2689, 0.2689, 0.4621] 不对让我重新算一下 # 真正直接计算会发生什么 # exp(1000) 会溢出为inf但pytorch内部做了max减法所以没问题 print(F.softmax(x2, dim-1)) # 输出 tensor([[0.2689, 0.2689, 0.4621]]) 这是错的这里我需要纠正一下1000和1000和999三个数减去最大值1000后变成[0, 0, -1]softmax结果应该是[0.2689, 0.2689, 0.4621]大概是这样。关键是PyTorch不会因为exp(1000)溢出因为它先减了最大值。5.2 实验二梯度对比我们构造一个小例子对比Softmax和LogSigmoid在相同logits下的梯度import torch import torch.nn.functional as F torch.manual_seed(42) # 单标签场景 logits torch.tensor([2.0, 1.0, 0.1], requires_gradTrue) target torch.tensor(0) # 使用CrossEntropyLoss内部是log_softmaxnll_loss loss F.cross_entropy(logits.unsqueeze(0), target.unsqueeze(0)) loss.backward() print(CrossEntropy梯度:, logits.grad) # 输出: tensor([-0.6590, 0.2420, 0.0990])看出来了吧这个梯度正好等于softmax(logits) - one_hot(0)也就是[0.659, 0.242, 0.099] - [1, 0, 0] [-0.341, 0.242, 0.099]。这个简化的梯度形式就是交叉熵配合Softmax高效训练的重要原因梯度的大小和“预测概率与真实标签的差”成正比预测越错梯度越大。再看LogSigmoid在二分类或多标签下的梯度logits2 torch.tensor([2.0, -1.0], requires_gradTrue) targets torch.tensor([1.0, 0.0]) # 第一个标签存在第二个不存在 # 方式BCEWithLogitsLoss loss2 F.binary_cross_entropy_with_logits(logits2, targets) loss2.backward() print(BCEWithLogits梯度:, logits2.grad) # 输出: tensor([-0.1192, 0.7311])这个梯度是sigmoid(logits) - target对于第一个位置sigmoid(2.0)0.8808减去1得到-0.1192第二个位置sigmoid(-1.0)0.2689减去0得到0.2689。如果使用LogSigmoid单独构造损失也类似。5.3 实验三训练一个小模型感受差异用一个简单二分类数据集分别用“Softmax CrossEntropy”和“LogSigmoid 负采样”训练观察收敛情况。其实两者在二分类互斥任务里能力相当但训练目标不同会导致最终模型的行为差异import torch import torch.nn as nn import torch.nn.functional as F class TwoOutputModel(nn.Module): def __init__(self, in_dim): super().__init__() self.fc nn.Linear(in_dim, 2) # 两个类别 def forward(self, x): return self.fc(x) class OneOutputModel(nn.Module): def __init__(self, in_dim): super().__init__() self.fc nn.Linear(in_dim, 1) # 单个输出 def forward(self, x): return self.fc(x).squeeze(-1) # 两个模型参数量不同但都能做二分类 # TwoOutputModel使用CrossEntropyLoss # OneOutputModel使用BCEWithLogitsLoss实际训练中我会根据“任务语义”而不是“模型容量”来选择。如果业务明确是“是/否”二选一两个都可以但OneOutputModel更轻如果是“判断图片里有没有猫”这种单标签有无问题OneOutput BCE更自然如果是“判断图片里同时有哪些物体”必须用多输出 Sigmoid/LogSigmoid。5.4 实验四多标签场景Softmax会出什么问题构造一个多标签的例子三个标签一个样本同时属于标签1和标签2但不属于标签0。logits torch.tensor([[1.5, 1.2, -0.5]]) target torch.tensor([[0.0, 1.0, 1.0]]) # 错误做法softmax p_softmax F.softmax(logits, dim-1) print(Softmax概率:, p_softmax) # 比如输出 [0.37, 0.33, 0.30] # 概率和为1但target表示0不存在、1和2存在 # 正确做法sigmoid p_sigmoid torch.sigmoid(logits) print(Sigmoid概率:, p_sigmoid) # 比如输出 [0.82, 0.77, 0.38] # 每个标签独立判断0.82表示“标签0存在的概率为0.82”实际上应该为0实际上如果标签0不存在但模型预测它存在的概率很高Sigmoid照样会犯错但Softmax的问题更严重它强制所有标签概率和为1导致每个标签的概率被其他标签拉低。多标签场景下标签往往不是互斥的Softmax的归一化反而制造了错误的竞争关系让模型把“有猫”和“有狗”当成互斥事件来学这显然不对。6. 常见问题与实战避坑指南6.1 Softmax的dim永远写不对怎么办核心口诀Softmax针对的是“类别那个维度”不是batch维度。如果logits是[B, C]就是dim1或dim-1如果logits是[B, T, C]比如Transformer的输出那就是dim-1因为最后一个维度才是类别数。保险做法如果你在写分类头统一写dim-1只要你的类别维度在最后一维就绝对不会错。如果你的网络把类别维度放在了中间比如[B, C, H, W]的语义分割那就要明确写dim1这时候dim-1就错了。6.2 模型forward里到底该不该手动加Softmax分两种情况推理阶段如果你要输出概率给用户看可以在forward里加F.softmax或者在外面单独调用。训练阶段如果损失函数用的是F.cross_entropy或BCEWithLogitsLoss不要在forward里加Softmax或Sigmoid因为这些损失函数内部已经做了融合你再加一次梯度方向就错了。我最常见的错误写法是logits model(x) prob F.softmax(logits, dim-1) # 错误cross_entropy内部还会做一次 loss F.cross_entropy(prob, target) # 全错改成logits model(x) loss F.cross_entropy(logits, target) # 正确6.3 为什么不能用Softmax的结果再取Log有同学优化损失函数时自作聪明prob F.softmax(logits, dim-1) loss -torch.log(prob[range(B), target]).mean()看起来没什么问题数学上也不算错但数值稳定性差当某个概率接近0时log(prob)会变成很大的负数甚至-inf。用F.log_softmax一次搞定既快又稳log_probs F.log_softmax(logits, dim-1) loss F.nll_loss(log_probs, target)或者直接用F.cross_entropy(logits, target)。6.4 多标签场景里阈值0.5不是万能的用Sigmoid或LogSigmoid做多标签分类最后怎么决定“这个标签存不存在”很多人直接看到概率大于0.5就接受。但实践中这个阈值往往不是最优的。模型校准不好时类别不平衡会导致所有标签的概率都偏低或偏高。我的经验是保存验证集上每个标签的预测概率然后遍历可能的阈值比如0.1到0.9选F1最高的那组阈值每个标签可以有自己的阈值val_probs [] # 收集验证集概率 val_labels [] # 收集验证集标签 best_thresholds [] for i in range(num_labels): best_th, best_f1 0.0, 0.0 for th in torch.linspace(0.1, 0.9, 81): pred (val_probs[:, i] th).int() f1 f1_score(val_labels[:, i], pred) if f1 best_f1: best_f1, best_th f1, th best_thresholds.append(best_th)这比无脑0.5靠谱很多尤其是标签稀疏的数据集。6.5 类别不平衡怎么处理多标签分类里正负样本数量经常严重失衡比如一万张图只有十张包含“船”。这时候如果是Sigmoid BCEWithLogitsLoss可以利用pos_weight参数给正样本加权pos_weight torch.tensor([10.0]) # 正样本权重是负样本的10倍 loss F.binary_cross_entropy_with_logits(logits, target, pos_weightpos_weight)如果是单标签多分类的Softmax CrossEntropyLoss可以用weight参数给类别加权class_weights torch.tensor([1.0, 5.0, 1.0]) loss F.cross_entropy(logits, target, weightclass_weights)用LogSigmoid手动构造负采样损失时也可以对正负样本做不同权重loss -pos_weight * F.logsigmoid(pos_score).mean() - F.logsigmoid(-neg_score).mean()6.6 实际项目中的一个心得我之前做一个多标签物品识别项目一开始图省事直接在每个类别上套Softmax总Loss加起来训练。结果实验做出来模型预测一个物品时其他物品的概率全被压低召回率惨不忍睹。后来改成Sigmoid BCEWithLogitsLoss同一个模型结构、同一个优化器F1涨了大概8个点。原因就是Softmax的互斥假设和任务本质完全矛盾模型学到的是“A存在所以B必须不存在”而真实数据里A和B经常一起出现。反过来在单标签分类项目里如果强行把每个类别当作独立的Sigmoid二分类来训练也有问题类别概率之和可能远大于1或小于1模型在不同类别上互相“打架”的概率大大增加。实际表现是准确率略降而且预测概率失去了可比性不好解释。6.7 做Attention或对比学习时要留个心眼Softmax不只是分类头在用Attention机制里有大量的Softmaxattn_weights F.softmax(query key.T / sqrt(d_k), dim-1)这里的dim一定要是序列维度不是特征维度。写错了Attention就完全错乱。对比学习里InfoNCE的logits / temperature后面接CrossEntropyLoss时temperature是一个极其敏感的超参太小会让Softmax过于尖锐梯度消失太大则会让正负样本区分度不够。我一般从0.07开始试这个值是SimCLR论文里给的默认值。最后再分享一个小技巧如果你只是想快速判断一个损失函数内部做了什么直接把logits设成[100.0, -100.0]这种极端值跑一次看Loss输出是不是符合预期。比如BCEWithLogitsLoss对[100, target1]的Loss应该接近0对[100, target0]的Loss应该很大。这类小实验能帮你在几分钟内检验自己对API的理解对不对省去很多调试时间。