qinghuan
← 神经网络与自动微分 / 全部笔记

单元 16 / 神经网络与自动微分

P0 · 必会主线L1 · 基础

Softmax 分类与数值稳定的损失函数

把各类别的原始分数 logits 转成概率,再推导稳定 softmax、交叉熵和 p-onehot 梯度,检查类别轴、标签类型与极端数值。

本篇目录 · 按需要跳转

这篇重点学什么

本篇重点:先推导交叉熵与 p − one-hot 梯度,再比较把多条损失求和或取平均(loss reduction)会怎样改变训练。

开始读正文 ↓
学过相近内容?查看哪些可以跳过

两条路线有些基础相同。已经学会的部分可以跳过,另一条路线只用来补你当前需要的内容。

概率与 softmax

共同基础概率归一化与数值稳定性是共同基础;采样和求梯度是不同任务。

另一条路线补充什么?

先累计每项概率,再用二分查找选中一次采样结果;检查边界输入和调用预算,观察生成结果的随机性。

读完这篇,你能做什么

能手算二分类特例,编写稳定单样本分类损失,并将推导与 PyTorch 批量交叉熵及实际梯度对齐。

学习理由、准备要求与知识点

为什么学它

类别维度、求和与平均方式,或误传概率,都会改变分类训练目标。稳定计算还能避免极大分数让梯度失效。

先读这些内容

知识点清单

  • Logits 与互斥类别
  • Softmax 类别归一化
  • 平移不变性
  • 减最大值数值稳定
  • LogSoftmax 计算次序
  • 硬标签交叉熵
  • 概率减独热梯度
  • 批量平均归约
  • 概率目标与校准边界

运行前先确认Java 17;Python 3.14、PyTorch 2.9.1+cpu。源码定位 PyTorch v2.9.1 的 CPU LogSoftmax 辅助内核;Java 为原创单样本数学实现。只验证 CPU 小张量,不涉及 GPU 或概率校准结论。

01先弄懂它是什么

模型要从多个互斥类别中选一个时,通常先给每个类别打一个分数。这 C 个实数分数 z 叫 logits,可以为负,也不要求相加为一。Softmax 用 p_j=exp(z_j)/sum(exp(z_k)) 把它们转成非负、总和为一的数。本篇一次处理一批样本,输入形状为 [B,C]:B 是样本数,C 是类别数,每个样本都在自己的类别维度上单独归一化。

训练时需要明确哪个类别是正确答案。硬标签 t 就是该类别的整数编号,范围从零到 C-1。单个样本的交叉熵是 L=-log(p_t):模型给正确类别的概率越高,损失越小。它也可以理解为对正确类别的条件似然取负对数。但模型报出零点九,不代表它在这类预测中一定有百分之九十正确率;这种对应关系叫概率校准,需要用独立数据检查。

最常用的做法是把原始分数 logits 和正确类别的整数编号直接交给 PyTorch CrossEntropyLoss,前面不用再做一次 softmax。本篇只推导这种硬标签情况:不给类别加权,不忽略标签,也不做标签平滑。这个接口还支持为各类别分别提供概率的浮点目标,形状要与 logits 相同。换用其他模式时,要重新核对损失如何求和或取平均、分母是什么,以及梯度怎样计算。

文中这几个词,先认一认

类别轴
同一个样本里,各个类别分数排列的那个方向。Softmax 要沿这个方向计算。
LogSoftmax
直接算出归一化后的对数概率,避免先算出非常小的概率,再取对数时出现数值问题。
独热向量
用一串零和一标记正确类别:正确位置是一,其余位置全是零。
概率校准
检查模型说的概率与实际发生比例是否相符。例如大量报出零点九的预测,是否约有九成正确。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

所有 logits 加同一个常数不会改变 softmax,因为分子分母同时乘同一个指数因子。取 m=max(z),改用 exp(z_j-m) 后,指数自变量都不大于零,避免 exp(1000) 这类上溢。至少一个指数等于一,因此非空有限输入的归一化分母不会因所有项下溢而变成零。

稳定对数概率可写为 log(p_j)=(z_j-m)-log(sum(exp(z_k-m)))。运算顺序有意义:先减 m 再减对数和,可以避免先把很大的 m 与很小的校正项相加时丢失信息。数学上等价的表达式在浮点中不一定精度相同,不能只凭代数变形随意重排。

对正确类别 t,损失可写为 log(sum(exp(z_k-m)))+(m-z_t)。若用独热目标 y 表示答案,每个 logit 的导数就是 p_j-y_j。正确类通常得到负梯度,所以沿梯度反方向更新,会提高它相对其他类的分数。全部类别的梯度加起来为零,也符合前面的结论:把所有分数同时加上相同数,不会改变损失。

把 B 个样本的损失加起来再除以 B,就是按样本取平均;每个样本对 logits 的梯度也要除以 B。如果选择 sum,只把损失相加,就没有这一步除法。给类别加权、忽略部分标签或改用软目标后,要按具体模式检查分母,不能一律照搬 B。对比手写梯度与库结果前,先把这些设置逐项对齐。

直接先 softmax 再取 log 可能把很小概率舍入为零,导致本可有限表示的对数概率成为负无穷。例如 logits=[1000,-1000]、正确类为后一类,稳定损失约为二千;即使后一类概率已下溢到零,稳定对数表达仍能保留有限损失。本篇对超出 double 可表示范围的最终损失显式报错。

先跑通一个最小例子

使用第 13 篇准备的 Python 3.14、PyTorch 2.9.1+cpu 隔离环境执行 experiment.py,例如 .\.venv\Scripts\python.exe experiment.py。这里 logits 为 [2,3] 的双精度 Tensor,target 为 [2] 的 long 类别索引。

experiment.py · python
import math,torch
import torch.nn.functional as F
torch.set_num_threads(1)
z=torch.tensor([[1000.,1001.,999.],[-2.,0.,2.]],dtype=torch.float64,requires_grad=True)
target=torch.tensor([1,2],dtype=torch.long)
loss=F.cross_entropy(z,target)
centered=z-z.max(dim=1,keepdim=True).values
logp=centered-centered.exp().sum(dim=1,keepdim=True).log()
manual=-logp[torch.arange(2),target].mean()
assert torch.allclose(loss,manual,atol=1e-12)
loss.backward()
expected=z.detach().softmax(dim=1)
expected[torch.arange(2),target]-=1
expected/=2  # 平均损失对应每个样本梯度除以批次大小。
assert torch.allclose(z.grad,expected,atol=1e-12)
assert torch.allclose(z.grad.sum(dim=1),torch.zeros(2,dtype=torch.float64),atol=1e-12)
assert torch.allclose(F.cross_entropy(z.detach()+10000,target),loss.detach(),atol=1e-10)
extreme=torch.tensor([[1000.,-1000.]],dtype=torch.float64)
assert F.cross_entropy(extreme,torch.tensor([1])).item()==2000.
assert math.isclose(F.cross_entropy(torch.zeros(1,2,dtype=torch.float64),torch.tensor([0])).item(),math.log(2))
assert torch.allclose(z.detach().softmax(dim=1).sum(dim=1),torch.ones(2,dtype=torch.float64))
assert torch.isfinite(z.grad).all()
print("PASS 8; loss =",round(loss.item(),6),"extreme_loss = 2000")

核对运行结果打印 PASS 8;手写稳定损失与库一致,梯度为批量平均的 p-onehot,极端错误类损失为 2000。

正在加载流程图…

最大值用于数值稳定;类别轴、标签模式和归约分母共同定义实际训练目标。
查看流程图源码(Mermaid)
flowchart LR
 A["每行 C 个 logits"] --> B["求本行最大值 m"]
 B --> C["减 m 后求指数和"]
 C --> D["直接计算对数概率"]
 D --> E["取正确类别负对数"]
 E --> F["按批次定义归约"]
 F --> G["梯度 p 减 onehot"]

按顺序看,每一步做了什么

  1. 检查非空批次、类别轴与标签范围。
  2. 按每个样本独立减最大 logit。
  3. 直接形成稳定对数概率和损失。
  4. 按目标归约计算梯度尺度。
  5. 用解析值、平移不变性和自动微分交叉核验。

03对照实现代码

PyTorch v2.9.1:CPU LogSoftmax 的稳定减法顺序 (新标签页)↗

PyTorch v2.9.1;aten/src/ATen/native/cpu/LogSoftmaxKernelImpl.h · serial_vec_log_softmax_lastdim_range

以下 Python 代码为原创单行数学模型,不是原样 C++ 内核或逐行翻译。已核对固定版本 C++ 最大值归约、指数和及减法顺序,未实现其向量化与并行调度。

核心机制代码 · python
import math
def log_softmax_row(logits):
    if not logits or not all(math.isfinite(v) for v in logits):
        raise ValueError("finite nonempty logits")
    maximum=max(logits)
    shifted=[v-maximum for v in logits]
    logsum=math.log(math.fsum(math.exp(v) for v in shifted))
    return [v-logsum for v in shifted]  # 先减最大值,再减对数和。

固定版本的串行范围辅助函数先按每一行归约最大值,再计算减去该最大值后的指数和。它处理调用者分配的行范围,并使用 chunk 暂存行级结果;源码中的批处理布局服务于内核效率,不改变每一行独立归一化的数学目标。

随后函数对每行指数和取对数,最后按 input-max_input-tmp_sum 写出对数概率。源码专门提示保持这个运算顺序,因为先把最大值与对数校正相加,在大幅输入、细小差异时可能损失精度;这正是本篇手写算法采用中心化表达的原因。

SoftMaxKernel.cpp 中的 _vec_log_softmax_lastdim 计算块大小并通过 parallel_for 调用该辅助函数。本篇选择定位实际数学循环所在头文件,避免把仅负责并行分派的外层函数描述成直接实现所有稳定归约。

这个内核计算 LogSoftmax,并不自行选择正确类、处理标签平滑或完成交叉熵全部归约。原创数学模型只展示单行有限输入,完整分类 API 的标签约束和损失模式需要再读 CrossEntropyLoss 文档;不能把内核功能扩张成整个分类训练过程。

代码先调用谁,再调用谁

  1. 公开 log_softmax 进入本机实现分派
  2. CPU 最后一维路径分配行范围
  3. serial_vec_log_softmax_lastdim_range 计算最大值与指数和
  4. 按中心化顺序写出对数概率
  5. 分类损失另处理标签与归约

04动手写一个例子

稳定单样本交叉熵及其梯度

给定非空有限 logits 和有效硬标签,返回 softmax 概率、单样本交叉熵及对 logits 的梯度;最终损失超出 double 范围时拒绝返回成功结果。

输入 1
logits=[0,0],target=0
输出 1
loss=ln2,gradient=[-.5,.5]
输入 2
logits=[1000,-1000],target=1
输出 2
loss=2000,gradient=[1,-1]

第一次扫描验证输入并寻找最大值,第二次计算中心化指数及其和。最大值对应指数一,避免全部指数下溢;特别小的其他指数允许下溢到零,这在 double 中是合理近似,不应借此再用 log(probability) 求损失。

损失单独使用中心化对数和加正确类差值计算,梯度则由概率复制后在正确类位置减一。概率和梯度使用独立数组,避免减一操作污染返回概率;单样本无批量平均,所以本函数不额外除以类别数。

测试相等分数、共同平移、极端正确与错误类、梯度和及非法标签。对于正负接近 double 极限的值,差值或最终损失可能超出表示范围,程序抛出范围错误,而不是声称减最大值能解决所有浮点问题。

为什么这样写是对的中心化后所有指数位于 [0,1] 且至少一项为一;返回梯度为同一组概率减去目标独热向量。

时间 O(C)。额外空间 O(C),保存概率与梯度数组。

固定次数扫描 C 个类别,没有构造 C×C 雅可比矩阵;利用交叉熵组合后的简化导数直接得到梯度。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

Main.java · 完整可运行程序 · Java
public class Main {
  record Result(double[] probabilities,double loss,double[] gradient){}
  static Result evaluate(double[] z,int target){
    if(z==null||z.length==0||target<0||target>=z.length)throw new IllegalArgumentException("shape or target");
    double max=Double.NEGATIVE_INFINITY;
    for(double v:z){if(!Double.isFinite(v))throw new IllegalArgumentException("finite logits");max=Math.max(max,v);}
    double[] p=new double[z.length];double sum=0;
    for(int i=0;i<z.length;i++){p[i]=Math.exp(z[i]-max);sum+=p[i];} // 减最大值后指数不会正向溢出。
    double loss=Math.log(sum)+(max-z[target]); // 不从可能下溢为零的概率再取对数。
    if(!Double.isFinite(loss))throw new ArithmeticException("loss range");
    double[] gradient=new double[z.length];
    for(int i=0;i<z.length;i++){p[i]/=sum;gradient[i]=p[i];}
    gradient[target]-=1; // 独立梯度数组,保留原概率。
    return new Result(p,loss,gradient);
  }
  static void near(double a,double b){if(!Double.isFinite(a)||Math.abs(a-b)>1e-10)throw new AssertionError(a+" != "+b);}
  static void bad(Runnable r){try{r.run();}catch(IllegalArgumentException e){return;}throw new AssertionError();}
  public static void main(String[] args){
    Result equal=evaluate(new double[]{0,0},0);near(equal.loss,Math.log(2));near(equal.probabilities[0],.5);near(equal.gradient[0],-.5);
    near(evaluate(new double[]{1000,1000},0).loss,equal.loss);
    near(evaluate(new double[]{1000,-1000},0).loss,0);
    Result wrong=evaluate(new double[]{1000,-1000},1);near(wrong.loss,2000);near(wrong.gradient[0],1);near(wrong.gradient[1],-1);
    Result ordinary=evaluate(new double[]{1,2,3},2);double gsum=0,psum=0;
    for(int i=0;i<3;i++){gsum+=ordinary.gradient[i];psum+=ordinary.probabilities[i];}near(gsum,0);near(psum,1);
    bad(()->evaluate(new double[]{1,2},2));
    bad(()->evaluate(new double[]{Double.NaN},0));
    System.out.println("PASS 7");
  }
}

这些特殊情况也要试一试

  • 空类别与越界标签明确拒绝。
  • 输入不接受 NaN 或无穷大。
  • 概率下溢不等于稳定对数损失必须无穷大。
  • 最终损失仍可能超出 double 表达范围。
进阶选读 · 软标签、类别权重与标签平滑

场景类别不平衡或希望使用概率目标训练。

做法从目标定义重新推导加权交叉熵及归约,验证目标非负且归一化;用小例比较库的各模式,避免沿用硬标签分母。

复杂度通常仍 O(B*C),但样本权重、掩码和统计归约更复杂。

好处与代价改变目标可能改善某些指标,也可能影响概率解释与少数类表现,需要独立验证和校准评估。

05项目里怎么用

数据管道应在进入损失前检查标签编码。类别编号从零开始还是从一开始、训练与部署类别表是否相同,都会影响意义;即使编号未越界,也可能把标签语义整体错位。保存类别映射与模型版本,不能只保存一个权重文件。

稳定算子解决特定数值路径,不会修复所有异常。极大 logits 可能来自学习率、输入尺度或上游非有限运算,仍应定位产生原因;将 NaN 替换成零通常改变优化目标并掩盖错误。记录首次非有限层、梯度和参数范围,比只观察最后 loss 更容易诊断。

得到类别概率后,业务还要规定怎样据此行动。Argmax 只是选出分数最高的类别;实际系统可能还要设置接受门槛、允许拒答,或考虑不同错分的代价。本篇用 Softmax 处理互斥类别,也就是一个样本只归入一类。多标签任务允许多个类别同时成立,通常会对各类分别使用 sigmoid 等方式,因此需要另行选择输出形式和损失函数。

容易踩的坑

  • 对 logits 先 softmax 后再传 CrossEntropyLoss。
  • 把批次轴作为类别轴归一化。
  • 用 log(softmax) 替代稳定 log_softmax 而忽略下溢。
  • 把归一化分数直接当作已经校准的可信概率。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
稳定组合交叉熵损失与梯度路径一致,避免中间概率下溢需要正确标签模式与归约常见互斥多分类训练
显式 log_softmax 加 NLL便于观察对数概率必须保持接口和维度一致调试与组合损失
逐类 sigmoid适合非互斥标签概率不要求跨类和为一多标签建模

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么减最大值不改变概率?

展开参考答案与推导

所有指数分子分母共同乘以 exp(-max),这个因子相消;同时指数自变量非正,降低上溢风险。

02交叉熵梯度为什么不除以类别数?

展开参考答案与推导

本篇单样本损失是正确类负对数,其导数直接为 p-onehot。只有明确对批次或其他单位取平均时才加入对应分母。

03softmax 输出是否已经校准?

展开参考答案与推导

不一定。非负且和为一只是归一化性质,预测数值与真实事件频率是否匹配需要独立评估。

换个情况,还会处理吗

01极小类别概率已经为零,损失一定无穷吗?

展开参考答案与推导

数学对数概率可能仍在浮点可表达范围。应直接使用稳定 log_softmax 或组合交叉熵,而不是对已舍入为零的概率再取对数。

02两种实现损失差一倍先查什么?

展开参考答案与推导

检查批次平均与求和、有效样本数、权重和忽略标签设置。归约尺度不同会同时改变梯度,不能只比较输出数值。

03多标签数据能直接套本篇 softmax 吗?

展开参考答案与推导

通常不合适。Softmax 将类别视为互斥选择,多标签允许多个同时成立,需要对应的逐类目标与损失。

写代码试一试

01手算相等二类分数。

输入 / 条件
z=[0,0],t=0
输出 / 预期
p=[.5,.5],L=ln2,g=[-.5,.5]

提示 正确类梯度减一。

展开参考答案与推导

两类指数相同,概率各一半;负对数为 ln2,梯度向量概率减独热目标得到负一半与正一半。

02计算极端错误类损失。

输入 / 条件
z=[1000,-1000],t=1
输出 / 预期
约2000

提示 使用中心化对数表达。

展开参考答案与推导

指数和约为一,对数项约为零;最大值减目标 logit 为二千,因此稳定损失仍有限。

03核对批量梯度。

输入 / 条件
B=2,平均损失
输出 / 预期
每行(p-onehot)/2

提示 分母来自样本平均。

展开参考答案与推导

先逐行按类别计算概率减目标,再除以二。不能除以类别数,也不能忘记加权或忽略标签可能改变分母。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能手算二分类特例,编写稳定单样本分类损失,并将推导与 PyTorch 批量交叉熵及实际梯度对齐。

本篇重点加练

概率梯度与目标分布需要共同理解,重点训练软标签、权重和校准解释的边界。

  • 概率减独热梯度
  • 概率目标与校准边界

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    对 logits=[-2,1,3] 及整体加 100 后的向量,分别计算类别概率并核对类别轴;解释 logits 的绝对值是否有唯一含义。

    提交内容
    计算记录与轴说明
    怎样算完成
    • 类别互斥前提明确
    • 平移前后比较可核验

    练到的知识点Logits 与互斥类别 · Softmax 类别归一化 · 平移不变性

  2. 作业 02原理问答

    比较先算概率再取对数与直接计算 LogSoftmax 两条路径,列出各自中间值的表示范围及可能丢失的信息。

    提交内容
    数值流程对照图
    怎样算完成
    • 中间量标注完整
    • 数学等价与浮点行为分开

    练到的知识点减最大值数值稳定 · LogSoftmax 计算次序 · 硬标签交叉熵

  3. 作业 03代码推演

    三分类目标为 [0.7,0.2,0.1],自选非相等 logits,推导交叉熵对 logits 的梯度,并与同一 logits 的硬标签目标比较。

    提交内容
    两种目标与梯度记录
    怎样算完成
    • 目标归一化可核验
    • 目标类型变化明确

    练到的知识点概率减独热梯度 · 概率目标与校准边界

综合应用

  1. 作业 04编程实践

    实现 [B=3,C=4] 硬标签交叉熵及解析梯度,使用双精度有限差分核对;同时支持 sum 和 mean 归约。

    提交内容
    CPU 实现与梯度检查
    怎样算完成
    • 类别和批次轴正确
    • 两种归约分母可追踪

    练到的知识点硬标签交叉熵 · 概率减独热梯度 · 批量平均归约

  2. 作业 05排错实验

    以 logits=[1000,-1000,0] 和两个不同标签检验三种损失实现,记录有限性和梯度;为不能接受的输入另建校验测试。

    提交内容
    极值实验与错误分类
    怎样算完成
    • 数值异常与输入异常分开
    • 梯度和损失一起记录

    练到的知识点减最大值数值稳定 · LogSoftmax 计算次序 · 硬标签交叉熵

  3. 作业 06面试场景

    数据中一条样本可同时属于两个类别,但现有接口强制所有类别概率和为一。评审任务定义与损失选择,设计一个暴露问题的标注样例。

    提交内容
    任务契约修订与样例
    怎样算完成
    • 互斥假设明确
    • 训练目标与评价目标一致

    练到的知识点Logits 与互斥类别 · 概率目标与校准边界

  4. 作业 07编程实践

    实现类别加权的硬标签交叉熵梯度检查,明确 mean 按样本数还是权重和归约;对含零权重类别的三样本批次核对自动求导。

    提交内容
    CPU 检查器及归约契约
    怎样算完成
    • 两种分母不混用
    • 零权重边界经验证

    练到的知识点概率减独热梯度

  5. 作业 08设计评审

    一个模型经温度变换后置信度改变。设计评价区分排序、分类正确率与概率质量,预先定义各指标及用于拟合温度的数据。

    提交内容
    评价矩阵与数据协议
    怎样算完成
    • 不同性质分别度量
    • 拟合与最终评估分开

    练到的知识点概率目标与校准边界

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    制作一个固定预测器的校准评估实验,按置信度分组报告实际正确率;若拟合温度参数,明确划分拟合数据与最终评价数据。

    提交内容
    校准图、脚本与划分说明
    怎样算完成
    • 分组计数完整
    • 校准不由训练准确率代替

    练到的知识点概率目标与校准边界

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    如果目标向量各项非负但和不是一,沿用概率目标交叉熵会优化什么?从一般目标和的情形推导梯度,并讨论能否仍解释为概率校准。

    提交内容
    一般推导与解释边界
    怎样算完成
    • 目标质量总量显式
    • 数学目标和概率语义区分

    练到的知识点概率减独热梯度 · 概率目标与校准边界

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗