第 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:就是上面的公式,算出“这一个输出错了多少”。
- DerivLoss:损失对输出的导数,反向传播要用它(第 6 章)。这里你只要先看懂它的符号:当 o 比 t 小,(t − o) 为正,导数为负——它在告诉网络“你预测得太低了,把输出往大调”。导数前面的常数(这里是 2)不影响方向,只是缩放,会被学习率吸收。
3. 分类任务:先把分数变成“概率”
如果模型要从几个选项里选一个类别(比如这张图是 0–9 里的哪个数字),这叫分类。 网络最后一层会对每个类别输出一个分数(叫 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——一组合法的概率。
4. 用交叉熵衡量两个概率的差距
现在模型给出了一组预测概率 p,而正确答案是一个“只有正确类是 1、其余是 0”的分布 t(叫 one-hot)。怎么衡量这两组概率差多少?用 交叉熵(cross-entropy):
直觉非常顺:如果模型给正确类的概率很高(接近 1),log 接近 0,损失很小; 如果它给正确类的概率很低(接近 0),−log 会冲向很大——狠狠惩罚“自信地答错”。
项目里实现的是二元交叉熵(配合 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)),但下一节你会看到,它和 Sigmoid/softmax 的导数正好抵消,变得无比清爽。
5. 一个美妙的巧合:δ = 预测 − 答案
单独看,交叉熵的导数和 softmax 的导数都挺丑。但当你把它们合在一起 (softmax 算概率 + 交叉熵算损失),中间那些复杂的项会奇迹般地约掉, 最后末层要往回传的“误差信号” δ 简洁到不可思议:
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;
}
- 这就是上面说的 δ = p − t。它会作为反向传播的“起点”(第 6 章)。
- 直觉太顺了:预测概率比答案高多少,就往回传多少“正向误差”;低多少,就传多少“负向误差”。这也是为什么分类任务几乎总是用“softmax + 交叉熵”这对黄金搭档。
6. 一批样本的损失,取个平均
训练时我们一次看很多样本,把每个样本、每个输出的损失加起来再求平均,得到这一批的整体损失:
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;
}
- 把每个输出维度的损失累加起来。
- 除以个数得到平均损失。整个训练的目标,就是让这个平均损失尽可能小。
小结
- 损失函数把“模型错得多离谱”变成一个数字:越准越小,完全正确时最小。
- 回归用均方误差 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。计算简单、数值稳定,反向传播也更不容易出问题。
有了“错得多少”这个数字,下一章我们就要回答那个核心问题: 该往哪个方向、走多大一步,才能让这个数字变小?——梯度下降登场。