第 4 章 · 学习是怎么发生的

怎么衡量“错”:损失函数

网络现在能向前算出一个预测了,但它八成是错的。要让它“学”,第一步是给“错” 一个精确、可计算的定义——这就是损失函数(loss function)。 它把“模型这次错得多离谱”压缩成一个数字。这个数字,就是后面所有优化的指南针。

读完这一章,你会明白

  • 为什么需要把“错误”变成一个数字,以及它怎么指导训练;
  • 回归任务用的均方误差(MSE)是怎么回事;
  • 分类任务里,softmax 怎么把分数变成概率,交叉熵怎么衡量概率的差距;
  • “softmax + 交叉熵”为什么会得到一个特别漂亮的结果 δ = 预测 − 答案。

1. 为什么要把“错”变成一个数字

回忆第 0 章的训练循环:预测 → 对答案 → 算差距 → 微调。 这里的“算差距”,必须输出一个具体的数,程序才能比较“调整之前和之后,到底哪个更好”。

这个数,我们希望它满足两个朴素的要求:预测越接近答案,它越小; 完全正确时,它应该是 0(或最小)。剩下的,只是“用什么公式来算这个差距”而已。 不同任务,用不同的公式。

2. 回归任务:均方误差 MSE

如果模型要预测一个连续的数值(比如房价、温度),这叫回归。 最自然的“差距”就是:预测值和真实值差多少,然后平方(平方是为了让正负差距都变成正贡献,并且惩罚大错):

L = 12 (to)2 t = 真实值(target),o = 模型输出(output)。差得越多,平方后涨得越快

看看它在代码里有多直白:

src/deeplearning/loss/mse_loss.cpp
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
}
  1. Loss:就是上面的公式,算出“这一个输出错了多少”。前面的 12 让求导时能抵消平方带来的系数 2。
  2. DerivLoss:损失对输出的导数,反向传播要用它(第 6 章)。严格说 L = 12(to)2 的导数应是 −(t − o);仓库实现写成 −2(t − o),相当于多了一个常数 2——方向完全一样,只是整体缩放,会被学习率吸收。你只要先看懂符号:当 ot 小,(t − o) 为正,导数为负——它在告诉网络“你预测得太低了,把输出往大调”。

3. 分类任务:softmax + 交叉熵(两步一套)

分类任务(从几个类别里选一个,比如判断图片是 0–9 里哪个数字)的损失,不是一个函数,而是两步搭配的一套方案,几乎总是一起出现:

  1. 第一步 · softmax(准备,不是损失):把网络输出的原始分数,先变成一组概率;
  2. 第二步 · 交叉熵(这才是损失):再衡量这组预测概率和正确答案差多少。
先分清:softmax 不是损失函数

这里容易懵,先点破:softmax 本身不是损失函数,它只是分类前的一步预处理——把分数归一化成概率。 真正衡量“错多少”、能和 MSE 相提并论的损失,是下面 3.2 的交叉熵。之所以把 softmax 放进本章,是因为它和交叉熵是捆绑使用的一对:softmax 负责“把分数变成概率”,交叉熵负责“评判这组概率”,合起来才是分类任务的完整损失方案。

3.1 softmax:先把分数变成“概率”

网络最后一层会对每个类别输出一个分数(叫 logits),但这些分数可能是任意实数, 不能直接当“概率”。我们需要一个函数,把一组分数变成一组非负、且加起来等于 1 的概率—— 这就是 softmax:

pi = eziΣj ezj 先把每个分数取指数(保证为正),再除以所有指数之和(保证加起来为 1)

直觉:取指数会放大差距(分数高的更突出),除以总和则把它们归一化成概率。代码:

src/deeplearning/softmax/std_softmax.cpp · Normalize
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
  1. 第一遍循环:把每个 logit 取指数 exp,全部加起来得到分母 sum
  2. 第二遍循环:每个类别的概率 = 自己的指数 ÷ 总和。这样所有 output[i] 都在 (0, 1) 之间,且加起来正好是 1——一组合法的概率。不过这份实现少了一步工程上的必备动作,见下面的提示框。
工程必备:算 softmax 前要先减掉最大值(仓库这份实现没做)

上面照公式直接写 exp(z) / Σexp(z),数学上没错,但放到浮点数里会炸double 能表示的最大值大约是 1.8×10308,而 exp(710) 就已经溢出成 inf; 反过来,如果所有 logits 都很负(比如都是 −800),每一项都下溢成 0,分母变成 0,直接除零。 深层网络训练到后期 logits 冲到几百并不罕见。

解法只有一步:先把每个 logit 都减去这组里的最大值,再取指数。这在数学上完全等价—— 分子分母同时乘 e−max 就约掉了:

eziΣj ezj = ezi · e−maxΣj ezj · e−max = ezi−maxΣj ezj−max 概率一个都没变,但所有指数的输入都变成 ≤ 0,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)——它才是这套方案里真正的“损失函数”:

L = − Σi ti log(pi) = − log(p正确类) 因为 t 只有正确类是 1,求和最后只剩“正确类的预测概率”那一项

直觉非常顺:如果模型给正确类的概率很高(接近 1),log 接近 0,损失很小; 如果它给正确类的概率很低(接近 0),−log 会冲向很大——狠狠惩罚“自信地答错”。

先注意:上面这条是多分类 softmax 交叉熵。项目里的 CrossEntropyLoss 类实现的是二元交叉熵(配合 Sigmoid 输出,判断“是/不是”):

src/deeplearning/loss/cross_entropy_loss.cpp
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
}
  1. 当真实标签 target = 1,只剩前半项 -log(output):输出越接近 1 损失越小;当 target = 0,只剩后半项 -log(1-output):输出越接近 0 损失越小。
  2. 导数看起来有点吓人(分母里有 output(1-output)),但3.3 节你会看到,它和 Sigmoid 的导数正好抵消;多分类 softmax + 交叉熵也会抵消,最后都很清爽。

3.3 一个美妙的巧合:δ = 预测 − 答案

单独看,交叉熵的导数和 softmax 的导数都挺丑。但当你把它们合在一起 (softmax 算概率 + 交叉熵算损失),中间那些复杂的项会奇迹般地约掉, 最后末层要往回传的“误差信号” δ 简洁到不可思议:

δ = pt 预测概率 减去 真实答案。就这么简单

它不是魔法,可以用一条很短的链式法则推出来。设最后一层还没过 softmax 的原始分数是 z, softmax 后概率是 p,one-hot 答案是 t。我们真正想反传的是“损失对每个 logit 的导数”: δk = ∂L/∂zk

log(pi) = zi − log(Σj ezj) 把 softmax 取 log 后,分母会变成一个 log-sum-exp 项
L = −Σitilog(pi) = −Σitizi + log(Σjezj) 因为 one-hot 的 t 加起来等于 1,所以 log-sum-exp 只剩一份

现在对某个分数 zk 求导:第一项 −Σ t_i z_i 只留下 −t_k; 第二项 log(Σ exp(z_j)) 的导数正好是 exp(z_k) / Σ exp(z_j),也就是 softmax 概率 pk。 两项一加:

Lzk = pktk 对每个类别 k 都成立,所以向量形式就是 δ = p − t
二分类 sigmoid + 交叉熵也是同一个味道

项目里的 CrossEntropyLoss 写的是二分类版本: dL/do = (o − t) / (o(1 − o))。而 sigmoid 的导数是 do/dz = o(1 − o)。链式法则一乘,分母正好约掉: dL/dz = o − t。多分类 softmax 和二分类 sigmoid 最后都落到“预测 − 答案”。

src/deeplearning/softmax/std_softmax.cpp · CalcDelta
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;
}
  1. 交叉熵分支返回的是 softmax + 多分类交叉熵对 logit 的梯度快捷式:δ = pt。注意:仓库的 CrossEntropyLoss::Loss 本身是逐输出二元交叉熵,用它对 one-hot softmax 输出求平均时,报告出的标量 loss 不等于上面的 −log(p_correct);这里的重点是反向传播的“起点”(第 6 章)。
  2. MSE 分支这里返回同一个式子,是仓库为了接口统一做的简化——严格说,softmax + MSE 对 logit 的梯度还要过 softmax 雅可比,一般不等于 pt。分类请用交叉熵;回归用 MSE 时通常不要再套 softmax。直觉上(对 CE):预测概率比答案高多少,就往回传多少“正向误差”。

4. 多个输出的损失,合成一个数

如果一个样本有多个输出(比如 MNIST 有 10 个类别概率),每个输出维度都能算一个损失。 但训练/评估时我们需要的是一个标量,所以要把这些输出维度的损失加起来再取平均:

为什么非要合成一个数?因为梯度下降要优化的是一个总目标。 同一个参数往往会同时影响多个输出,如果 10 个输出各自都有一个损失, 它们可能会给出 10 个不完全一样的修改意见。最后真正更新参数时,程序需要的是一个统一方向: 让整体损失变小。把各维度的损失合成一个标量,就是在告诉网络: “这条样本总体上错了多少,请朝让这个总分下降的方向改参数。”

做法也很直接:先把每个输出维度的损失加起来,再除以输出维度数。这里取平均而不是只求和, 主要是为了让损失的尺度更稳定。输出维度从 2 变成 10 时, 如果只求和,数值会天然变大很多;取平均后,不同输出维度数量的任务更容易比较,学习率也不用因为维度数变化而大幅重调。

src/deeplearning/loss/loss_base.cpp · AverageLoss
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;
}
  1. 同一个样本里每个输出维度的损失累加起来。
  2. 除以输出维度数,得到这个样本的平均损失。整个训练的目标,就是让这些损失尽可能小。
别和 mini-batch 混在一起

这一节只回答“一条样本有多个输出时,loss 怎么合成一个数”。 下一章的 mini-batch SGD 回答的是另一个问题:训练时每一步要拿多少条样本来估计梯度、更新参数。 真正评估一堆样本时,代码会再在 CalcLoss 里对多条样本的 loss 求平均。

小结

  • 损失函数把“模型错得多离谱”变成一个数字:越准越小,完全正确时最小。
  • 回归用均方误差 MSE:差值的平方。
  • 分类先用 softmax 把分数变概率(非负、和为 1),再用交叉熵衡量与正确答案的差距,狠罚“自信地答错”。
  • “softmax + 交叉熵”的末层误差 δ = pt,异常简洁,是分类的黄金搭档。
  • DerivLoss 是损失对输出的导数,它是下一步“反向传播”的起点。

动手与思考

问题 1:模型对正确类给出 0.99 的概率,和给出 0.01 的概率,交叉熵损失差别大吗?

差别巨大。−log(0.99) ≈ 0.01(几乎没损失);−log(0.01) ≈ 4.6(损失很大)。交叉熵会狠狠惩罚“自信地答错”,这正是我们想要的。

问题 2:softmax 为什么要先取指数,而不是直接把分数除以总和?

直接除以总和无法处理负分数(可能出现负概率),也无法保证“分数越高越突出”。取指数能把任意实数变成正数,并放大高分与低分的差距,再归一化就得到一组合理的概率。

问题 3:为什么分类几乎总是“softmax + 交叉熵”一起用?

除了它们各自合理,组合起来梯度还特别干净:末层误差 δ = pt。这个形式免掉了“先算 log、再算除法、再乘激活导数”的一串中间步骤,计算简单、反向传播也更不容易出问题。(注意别把这个“干净”和 softmax 自己的溢出风险混为一谈——那个要靠上面 3.1 的减最大值解决。)

有了“错得多少”这个数字,下一章我们就要回答那个核心问题: 该往哪个方向、走多大一步,才能让这个数字变小?——梯度下降登场。