第 4 章 · 学习是怎么发生的
怎么衡量“错”:损失函数
网络现在能向前算出一个预测了,但它八成是错的。要让它“学”,第一步是给“错” 一个精确、可计算的定义——这就是损失函数(loss function)。 它把“模型这次错得多离谱”压缩成一个数字。这个数字,就是后面所有优化的指南针。
读完这一章,你会明白
- 为什么需要把“错误”变成一个数字,以及它怎么指导训练;
- 回归任务用的均方误差(MSE)是怎么回事;
- 分类任务里,softmax 怎么把分数变成概率,交叉熵怎么衡量概率的差距;
- “softmax + 交叉熵”为什么会得到一个特别漂亮的结果 δ = 预测 − 答案。
1. 为什么要把“错”变成一个数字
回忆第 0 章的训练循环:预测 → 对答案 → 算差距 → 微调。 这里的“算差距”,必须输出一个具体的数,程序才能比较“调整之前和之后,到底哪个更好”。
这个数,我们希望它满足两个朴素的要求:预测越接近答案,它越小; 完全正确时,它应该是 0(或最小)。剩下的,只是“用什么公式来算这个差距”而已。 不同任务,用不同的公式。
2. 回归任务:均方误差 MSE
如果模型要预测一个连续的数值(比如房价、温度),这叫回归。 最自然的“差距”就是:预测值和真实值差多少,然后平方(平方是为了让正负差距都变成正贡献,并且惩罚大错):
看看它在代码里有多直白:
double MSELoss::Loss(double target, double output) {
return 0.5 * (target - output) * (target - output); 1
}
double MSELoss::DerivLoss(double target, double output) {
return -2.0 * (target - output); 2
}
- Loss:就是上面的公式,算出“这一个输出错了多少”。前面的 12 让求导时能抵消平方带来的系数 2。
- DerivLoss:损失对输出的导数,反向传播要用它(第 6 章)。严格说 L = 12(t − o)2 的导数应是
−(t − o);仓库实现写成−2(t − o),相当于多了一个常数 2——方向完全一样,只是整体缩放,会被学习率吸收。你只要先看懂符号:当 o 比 t 小,(t − o) 为正,导数为负——它在告诉网络“你预测得太低了,把输出往大调”。
3. 分类任务:softmax + 交叉熵(两步一套)
分类任务(从几个类别里选一个,比如判断图片是 0–9 里哪个数字)的损失,不是一个函数,而是两步搭配的一套方案,几乎总是一起出现:
- 第一步 · softmax(准备,不是损失):把网络输出的原始分数,先变成一组概率;
- 第二步 · 交叉熵(这才是损失):再衡量这组预测概率和正确答案差多少。
这里容易懵,先点破:softmax 本身不是损失函数,它只是分类前的一步预处理——把分数归一化成概率。 真正衡量“错多少”、能和 MSE 相提并论的损失,是下面 3.2 的交叉熵。之所以把 softmax 放进本章,是因为它和交叉熵是捆绑使用的一对:softmax 负责“把分数变成概率”,交叉熵负责“评判这组概率”,合起来才是分类任务的完整损失方案。
3.1 softmax:先把分数变成“概率”
网络最后一层会对每个类别输出一个分数(叫 logits),但这些分数可能是任意实数, 不能直接当“概率”。我们需要一个函数,把一组分数变成一组非负、且加起来等于 1 的概率—— 这就是 softmax:
直觉:取指数会放大差距(分数高的更突出),除以总和则把它们归一化成概率。代码:
long double sum = 0;
for (int i = 0; i < input.size(); i++)
sum += std::exp(input[i]); 1
for (int i = 0; i < input.size(); i++)
output[i] = std::exp(input[i]) / sum; 2
- 第一遍循环:把每个 logit 取指数
exp,全部加起来得到分母sum。 - 第二遍循环:每个类别的概率 = 自己的指数 ÷ 总和。这样所有
output[i]都在 (0, 1) 之间,且加起来正好是 1——一组合法的概率。不过这份实现少了一步工程上的必备动作,见下面的提示框。
上面照公式直接写 exp(z) / Σexp(z),数学上没错,但放到浮点数里会炸。
double 能表示的最大值大约是 1.8×10308,而 exp(710) 就已经溢出成 inf;
反过来,如果所有 logits 都很负(比如都是 −800),每一项都下溢成 0,分母变成 0,直接除零。
深层网络训练到后期 logits 冲到几百并不罕见。
解法只有一步:先把每个 logit 都减去这组里的最大值,再取指数。这在数学上完全等价—— 分子分母同时乘 e−max 就约掉了:
exp 的结果全落在 (0, 1] 内,永不溢出
还白捡一个好处:减完之后最大的那一项恰好是 e0 = 1,分母一定 ≥ 1,连“除以 0”都不可能发生。 这个技巧叫 log-sum-exp trick。
那为什么仓库这份 StdSoftmax 没减?
因为它服务的是 MNIST 这类小网络,logits 量级离 710 差得很远,实际碰不到溢出,所以留成了最贴公式的写法、便于对照上面的式子。
但同一个仓库里另一处就老老实实减了——第 17 章注意力里的
self_attention.cpp 写的是 exp(score[i] - max_score),因为注意力分数本来就容易冲大。
结论:自己动手写 softmax 时,请照第 17 章那份来,永远带上减最大值这一步。
3.2 交叉熵:衡量两个概率的差距(这才是损失)
softmax 出手后,现在模型给出了一组预测概率 p,而正确答案是一个“只有正确类是 1、其余是 0”的分布 t(叫 one-hot)。怎么衡量这两组概率差多少?用 交叉熵(cross-entropy)——它才是这套方案里真正的“损失函数”:
直觉非常顺:如果模型给正确类的概率很高(接近 1),log 接近 0,损失很小; 如果它给正确类的概率很低(接近 0),−log 会冲向很大——狠狠惩罚“自信地答错”。
先注意:上面这条是多分类 softmax 交叉熵。项目里的 CrossEntropyLoss 类实现的是二元交叉熵(配合 Sigmoid 输出,判断“是/不是”):
double CrossEntropyLoss::Loss(double target, double output) {
return -target * log(output)
- (1.0 - target) * log(1.0 - output); 1
}
double CrossEntropyLoss::DerivLoss(double target, double output) {
return (output - target) / (output * (1.0 - output)); 2
}
- 当真实标签
target= 1,只剩前半项-log(output):输出越接近 1 损失越小;当target= 0,只剩后半项-log(1-output):输出越接近 0 损失越小。 - 导数看起来有点吓人(分母里有
output(1-output)),但3.3 节你会看到,它和 Sigmoid 的导数正好抵消;多分类 softmax + 交叉熵也会抵消,最后都很清爽。
3.3 一个美妙的巧合:δ = 预测 − 答案
单独看,交叉熵的导数和 softmax 的导数都挺丑。但当你把它们合在一起 (softmax 算概率 + 交叉熵算损失),中间那些复杂的项会奇迹般地约掉, 最后末层要往回传的“误差信号” δ 简洁到不可思议:
它不是魔法,可以用一条很短的链式法则推出来。设最后一层还没过 softmax 的原始分数是 z, softmax 后概率是 p,one-hot 答案是 t。我们真正想反传的是“损失对每个 logit 的导数”: δk = ∂L/∂zk。
现在对某个分数 zk 求导:第一项
−Σ t_i z_i 只留下 −t_k;
第二项 log(Σ exp(z_j)) 的导数正好是
exp(z_k) / Σ exp(z_j),也就是 softmax 概率 pk。
两项一加:
项目里的 CrossEntropyLoss 写的是二分类版本:
dL/do = (o − t) / (o(1 − o))。而 sigmoid 的导数是
do/dz = o(1 − o)。链式法则一乘,分母正好约掉:
dL/dz = o − t。多分类 softmax 和二分类 sigmoid 最后都落到“预测 − 答案”。
double StdSoftmax::CalcDelta(double output, double target,
std::shared_ptr<LossFunction> loss_function) {
switch (loss_function->GetLossType()) {
case LOSS_MSE: return output - target; 1
case LOSS_CROSS_ENTROPY: return output - target; 2
}
return 0;
}
- 交叉熵分支返回的是 softmax + 多分类交叉熵对 logit 的梯度快捷式:δ = p − t。注意:仓库的
CrossEntropyLoss::Loss本身是逐输出二元交叉熵,用它对 one-hot softmax 输出求平均时,报告出的标量 loss 不等于上面的−log(p_correct);这里的重点是反向传播的“起点”(第 6 章)。 - MSE 分支这里返回同一个式子,是仓库为了接口统一做的简化——严格说,softmax + MSE 对 logit 的梯度还要过 softmax 雅可比,一般不等于 p − t。分类请用交叉熵;回归用 MSE 时通常不要再套 softmax。直觉上(对 CE):预测概率比答案高多少,就往回传多少“正向误差”。
4. 多个输出的损失,合成一个数
如果一个样本有多个输出(比如 MNIST 有 10 个类别概率),每个输出维度都能算一个损失。 但训练/评估时我们需要的是一个标量,所以要把这些输出维度的损失加起来再取平均:
为什么非要合成一个数?因为梯度下降要优化的是一个总目标。 同一个参数往往会同时影响多个输出,如果 10 个输出各自都有一个损失, 它们可能会给出 10 个不完全一样的修改意见。最后真正更新参数时,程序需要的是一个统一方向: 让整体损失变小。把各维度的损失合成一个标量,就是在告诉网络: “这条样本总体上错了多少,请朝让这个总分下降的方向改参数。”
做法也很直接:先把每个输出维度的损失加起来,再除以输出维度数。这里取平均而不是只求和, 主要是为了让损失的尺度更稳定。输出维度从 2 变成 10 时, 如果只求和,数值会天然变大很多;取平均后,不同输出维度数量的任务更容易比较,学习率也不用因为维度数变化而大幅重调。
double LossFunction::AverageLoss(const std::vector<double> &target,
const std::vector<double> &output) {
double result = 0;
for (int i = 0; i < target.size(); i++)
result += Loss(target[i], output[i]); 1
result /= target.size(); 2
return result;
}
- 把同一个样本里每个输出维度的损失累加起来。
- 除以输出维度数,得到这个样本的平均损失。整个训练的目标,就是让这些损失尽可能小。
这一节只回答“一条样本有多个输出时,loss 怎么合成一个数”。
下一章的 mini-batch SGD 回答的是另一个问题:训练时每一步要拿多少条样本来估计梯度、更新参数。
真正评估一堆样本时,代码会再在 CalcLoss 里对多条样本的 loss 求平均。
小结
- 损失函数把“模型错得多离谱”变成一个数字:越准越小,完全正确时最小。
- 回归用均方误差 MSE:差值的平方。
- 分类先用 softmax 把分数变概率(非负、和为 1),再用交叉熵衡量与正确答案的差距,狠罚“自信地答错”。
- “softmax + 交叉熵”的末层误差 δ = p − t,异常简洁,是分类的黄金搭档。
DerivLoss是损失对输出的导数,它是下一步“反向传播”的起点。
动手与思考
问题 1:模型对正确类给出 0.99 的概率,和给出 0.01 的概率,交叉熵损失差别大吗?
差别巨大。−log(0.99) ≈ 0.01(几乎没损失);−log(0.01) ≈ 4.6(损失很大)。交叉熵会狠狠惩罚“自信地答错”,这正是我们想要的。
问题 2:softmax 为什么要先取指数,而不是直接把分数除以总和?
直接除以总和无法处理负分数(可能出现负概率),也无法保证“分数越高越突出”。取指数能把任意实数变成正数,并放大高分与低分的差距,再归一化就得到一组合理的概率。
问题 3:为什么分类几乎总是“softmax + 交叉熵”一起用?
除了它们各自合理,组合起来梯度还特别干净:末层误差 δ = p − t。这个形式免掉了“先算 log、再算除法、再乘激活导数”的一串中间步骤,计算简单、反向传播也更不容易出问题。(注意别把这个“干净”和 softmax 自己的溢出风险混为一谈——那个要靠上面 3.1 的减最大值解决。)
有了“错得多少”这个数字,下一章我们就要回答那个核心问题: 该往哪个方向、走多大一步,才能让这个数字变小?——梯度下降登场。