聊一个最基础但最容易被人忽略的概念:代价函数。
做机器学习的朋友应该都有这种经历:模型结构搭好了,数据也喂进去了,结果训练半天,loss 曲线像个心电图,要么降不下去,要么直接飞到 NaN。这时候大部分人第一反应是调学习率、换优化器,但很少有人会回头问一句:我这个代价函数,选得对吗?
代价函数这名字听起来挺学术,但说白了,它就是衡量模型预测得好不好的尺子。你预测值和真实值差多少,它用一个数告诉你:数字越小,说明模型越准;数字越大,说明模型越离谱。整个训练过程——反向传播、梯度下降、参数更新——全都是围绕“把这个数变小”来转的。换句话说,模型学得怎么样,全靠这把尺子量出来的结果说了算。尺子选错了,后面再怎么调参都是白费劲。
这篇文章我想把这些年实际项目里遇到的和代价函数有关的经验好好整理一下,包括它在训练流程里到底扮演什么角色、回归任务和分类任务各该选哪种、正则化项为什么算在代价函数头上、以及实际训练中那些让人头疼的“loss 不下降”“loss 是 NaN”到底是怎么回事。内容偏实战,会尽量把公式背后的道理讲到能“自己判断”为止。
1. 代价函数到底在训练里扮演什么角色
很多人一开始容易把损失函数、代价函数、目标函数这三个名词混着用,坦白说在大多数场合它们确实指同一个东西,但严格抠起来还是有细微差别。损失函数一般指单个样本的误差,代价函数是整个训练集上误差的平均或总和,目标函数则更宽泛,可以是“代价+正则化”的完整优化目标。不过日常交流里你直接说“loss”,大家都听得懂,不用太纠结术语。
1.1 训练流程里的核心纽带
机器学习的训练过程可以压缩成四步循环:
- 前向传播:把数据喂进模型,得到预测值。
- 计算代价:用代价函数比较预测值和真实值,得到一个标量数值。
- 反向传播:根据这个数值计算每个参数的梯度。
- 参数更新:优化器用梯度去调整权重,让代价变小。
你会发现,第 2 步是整个循环里唯一一个把模型输出和真实答案放在一起比较的环节。模型本身并不知道“对”是什么意思,是代价函数替它定义了“对”。你把预测结果和真实值的差异变成一个可微的数值,梯度才能顺着这个数值往回传。没有这把尺子,模型就是瞎猜,没有任何方向感。
1.2 为什么必须可微且有界
这里有个关键点:代价函数几乎必须是可微的。因为反向传播算法依赖梯度,梯度就是代价函数对参数求偏导。如果代价函数处处不可微,那梯度根本算不出来。有些朋友可能听说过 L1 损失在零点不可微,这确实是它的问题,但实际工程里大家会分数段处理,0 附近单独定义梯度为 0 或者直接用次梯度近似,所以也不是完全没法用。
另外,代价函数的数值要有意义。理想情况下,它应该是个非负数,最小值越接近 0 越好。如果某个代价函数算出来是个负数,不是不行,但你要非常清楚这个负数的含义。比如某些自定义的损失函数带符号,可能表示方向性误差,这时候训练曲线的解读方式就完全不一样了。
1.3 凸性与非凸:一把双刃剑
学过优化理论的朋友肯定知道凸函数的好处——凸函数没有局部最小值,你找到的极小值就是全局最小值,梯度下降随便折腾都能落到最低点。逻辑回归、线性回归用的代价函数基本都是凸的,所以训练特别省心。
但深度神经网络不一样,它的代价函数几乎都是高度非凸的,理论上存在很多局部极小值和鞍点。早期大家很担心这个问题,觉得非凸没法优化。后来慢慢发现,在高维空间里真正的局部极小值并不多,更多的是鞍点,而且随机梯度下降加上适当的初始化和优化器,实际跑起来效果都还不错。所以我的看法是:凸性在传统机器学习里是个好东西,但在深度学习中,非凸问题没有想象中那么可怕,实战里不用过度担心。
2. 回归任务:MSE、MAE 和 Huber 到底怎么选
回归任务是代价函数选择最典型也最容易踩坑的场景。你说要预测房价、预测销量、预测温度,输出是一个连续数值,最常见的三个选择就是均方误差(MSE)、平均绝对误差(MAE)和 Huber Loss。三句话就能说清区别,但什么时候用哪个,就要结合数据情况来判断了。
2.1 MSE:最主流但最怕异常值
MSE 的公式是每个样本误差平方后取平均。平方这个操作带来了一个特性:它对大误差的惩罚远大于对小误差的惩罚。真实值 100,预测 105,误差是 5,平方后是 25;如果你预测成 130,误差是 30,平方后就变成 900。这可不是简单的 6 倍差距,而是 36 倍。
这个特性在数据质量好、没有异常值的情况下很完美,因为模型会拼尽全力去拟合那些“偏差特别大”的样本。但换句话说,如果数据里有一个异常点,模型会花大量精力去迁就它,结果整体预测效果反而被拉偏。
我记得之前在某公司调一个销量预测模型,数据集里有个大促期间的历史数据,销售数字是日常的几十倍。我用 MSE 训练出来的模型,平时预测还算正常,一到非大促时段偶尔会系统性偏高,就是因为那几个异常点把模型拉得太狠了。
2.2 MAE:抗异常值但梯度不友好
MAE 就是预测值和真实值绝对差的平均。它没有平方操作,对大误差和小误差的惩罚是线性的,所以对异常值的敏感度低很多。用 MAE 训练,模型更关注“大多数样本的情况”,不容易被少数极端值带偏。
但 MAE 有个实际问题:它在误差为 0 处的梯度不连续,惩罚始终是 1 或者 -1。这个特征导致训练到后期,尤其是预测值已经在真实值附近来回震荡时,梯度不会随误差变小而变小,loss 会在一个区间内反复横跳,收敛不太利索。而且在梯度下降里,MAE 对每个样本的梯度都一样大,某种程度上缺乏“大误差大步走”的调节能力。
2.3 Huber:工程里最省心的折中方案
Huber Loss 本质上是 MSE 和 MAE 的结合体,你设定一个阈值 δ,误差小于 δ 时走平方,误差大于 δ 时走线性。这样兼顾了两边:
- 小误差时,梯度和误差成正比,收敛稳定;
- 大误差时,梯度被限制住,不会被异常值带偏。
公式长这样(这里用伪代码表示):
# delta 是自己设的阈值 def huber_loss(pred, target, delta=1.0): error = abs(pred - target) if error <= delta: return 0.5 * (pred - target) ** 2 else: return delta * (error - 0.5 * delta)实际使用中,delta 取 1.0 或者根据标签范围做一个比例值都行。如果标签数据整体在 0-1 之间,delta 取 1.0 就合适;如果标签在 0-10000 之间,delta 取 1.0 就会让线性部分占主导,效果等同 MAE,这时候可以把 delta 设成标签标准差的某个倍数。
2.4 回归损失选择的实操经验总结
我的个人经验是:
- 数据干净、无明显异常值、任务相对简单:优先 MSE,收敛快,精度高;
- 数据有异常值或者噪声明显:优先 Huber,delta 设为标签标准差的 0.5-1 倍左右,效果通常很稳;
- 需要模型“偏向中位数”而不是“偏向均值”时:选 MAE,因为它对异常值天然稳健。
另外补充一个容易忽略的点:回归任务里标签的量纲直接影响损失值的大小,进而影响梯度。如果标签范围是 0-100000,MSE 算出来可能动不动就是上亿的数值,这时候即使梯度正常,也会给优化器带来数值压力。工程上通常先对标签做标准化(减均值除标准差),再用 MSE 或者 Huber,训练完成后再逆变换回去。
3. 分类任务:交叉熵几乎是唯一默认选择
分类任务里,代价函数的选法比回归要简单得多。原因很直接:分类的输出是概率分布,评估“概率分布和真实类别之间的差异”最自然、最好用的就是交叉熵。
3.1 为什么分类不用 MSE
有些人刚接触时会有个疑问:分类输出 0/1,预测值也是 0 到 1 之间,那直接用 MSE 不也行吗?表面看确实能算出一个数值,但实际训练时会遇到严重的梯度饱和问题。
假设你用一个 sigmoid 输出层,配合 MSE。当预测值和真实值差很远时,sigmoid 的导数非常小。比如真实值是 1,预测值是 0.01,误差很大,但 sigmoid 在两端的导数趋近于 0,梯度传回去的时候几乎被“吸收”掉了。这就导致一个很尴尬的局面:模型犯错越大,反而学得越慢。这和直觉完全相反。
交叉熵就不会有这个问题。交叉熵的梯度形态是误差越大、梯度越强,模型能持续获得“纠错信号”,训练效率高很多。这也是为什么在绝大多数分类任务里,交叉熵是绝对的主流。
3.2 交叉熵与 Softmax 的黄金搭档
多分类任务中最常见的组合是 Softmax + 交叉熵。Softmax 把模型的 logits 转成一组和为 1 的概率,交叉熵再去衡量这些概率和 one-hot 标签之间的差异。
从信息论角度来理解,交叉熵表示的是“用模型预测的分布去编码真实分布所需要的额外比特数”。这个数字越小,说明预测分布和真实分布越接近。从另一个角度看,当真实分布是 one-hot 时,交叉熵简化一下,本质上就是在算“真实类别对应的预测概率的负对数”。
如果你对数学推导感兴趣,有一个特别漂亮的结论:Softmax + 交叉熵联合起来,其梯度形式特别简单。它等于预测概率减去 one-hot 真实概率,这个值直接作为梯度信号回传。这也是为什么工程中几乎没人手写“Softmax 梯度”,因为交叉熵这一步已经把它吸收进去了。
3.3 数值稳定性:别直接算 Softmax 再接交叉熵
有一个实际的坑是数值稳定性。很多人刚上手时会写这样的代码:
# 不推荐的写法:先算 softmax,再算 log,容易出 NaN probs = tf.nn.softmax(logits) loss = -tf.reduce_sum(labels * tf.math.log(probs))当某个类别的 logit 很大时,Softmax 输出的概率会非常接近 1,而另一类的概率会极小数甚至下溢到 0。这时候再取 log,会得到负无穷,loss 直接变成 NaN。
正确做法是用框架提供的融合算子,比如 TensorFlow 里的tf.nn.softmax_cross_entropy_with_logits,或者 PyTorch 里的nn.CrossEntropyLoss。这些算子内部会做数值上的重新排列,把 Softmax 和 Log 合并起来计算,确保了稳定性。
# 推荐写法:模型输出 logits,loss 直接吃 logits criterion = nn.CrossEntropyLoss() loss = criterion(model_output, target_labels)这是一个非常典型的小细节,但如果踩到了,排查起来还挺费时间。我自己就曾经因为这个问题卡了一个多小时,最后发现是 softmax 后取 log 导致的 inf。
3.4 分类损失的两个进阶场景
除了标准的多分类交叉熵,有两个场景需要注意:
第一个是样本不均衡。如果类别 A 有 10000 个样本,类别 B 只有 100 个,模型很快会发现“全预测成 A 也能拿到很低的 loss”,因为 A 的样本量大,贡献的误差占主导。这时候可以在交叉熵里给每个类别加权重,让少数类的错误惩罚更大。框架里都有现成的class_weight或者weight参数,改一下就能用,不算麻烦。
第二个是多标签分类,比如一幅图里同时有猫和狗。这种情况下不能用 Softmax + 交叉熵那种“所有类别概率和为 1”的思路,应该用多个 Sigmoid + 二分类交叉熵,每个类别独立判断。这也是很多人容易搞混淆的地方,一旦场景换成多标签,就必须调整输出层和损失函数的结构。
4. 正则化项:为什么也算在代价函数头上
很多人在看损失函数时,只关注数据拟合那部分,忽略了正则化项。但实际上,标准的训练目标是这样的:
[ \text{TOTAL_LOSS} = \text{DATA_LOSS} + \lambda \cdot \text{REGULARIZATION} ]
前半部分是预测值和真实值的差距,后半部分是权重的某种约束。代价函数这个词,严格来说应该包含这两个部分。你最终喂给优化器的“loss”是两者相加的结果。
4.1 L1 和 L2 正则化到底在干什么
L2 正则化,也叫权重衰减,它的计算方法是对所有权重的平方和乘以一个系数。它鼓励权重尽量小但要分散,不让某一个特征完全主导模型。L1 正则化则是对权重的绝对值求和,它的一个明显效果是让很多权重变成 0,相当于在做特征选择。
从贝叶斯的角度来理解,L2 对应的是“权重服从高斯分布”的先验假设,L1 对应的是“权重服从拉普拉斯分布”的先验。换成大白话:模型不仅要会拟合数据,还要符合我们“权重不应该太大”的朴素观念。没有正则化的话,模型可能为了把训练集完美拟合,把权重学得非常大,导致训练集表现很好、测试集一塌糊涂,这就是典型的过拟合。
4.2 不该把正则化项单独拎出来看
实际操作中,我见过不少新手在调参时只盯着数据损失那一项,然后把正则化系数当成一个完全独立的东西。看训练曲线时,也只记录data_loss,忽略了total_loss。这其实会误导判断。
正确的做法是:训练时看整体 loss,评估模型时再单独看data_loss和验证集指标。因为优化器是在优化整体 loss,如果只监控数据部分,你根本不知道模型是不是靠减小正则项来压总损失的。举个极端例子:模型把所有权重都学到接近 0,数据损失很大但正则项很小,总 loss 看起来在下降,实际模型什么也没学到。
我自己的习惯是把data_loss、reg_loss、total_loss分开记录,每隔一段时间打印或写进 TensorBoard。这种做法成本很低,但能帮你省下大量定位问题的时间。
4.3 自定义代价函数时,正则化也是设计的一部分
有些任务里,标准的 L1/L2 不够用,你需要往代价函数里加一些任务相关的约束。比如预测一个序列的模型,你可能希望相邻时刻输出不要跳变太大,就可以在损失函数里加一项“相邻输出之差的平方和”。这种操作在代码上很简单,本质上就是往总损失里塞一个基于模型输出的惩罚项。
这个思路的价值在于,它让代价函数从一个“衡量误差的工具”变成了“编码业务规则的工具”。很多模型效果不够好,不是结构不够强,而是代价函数没有把业务上“什么结果不想要”这件事表达清楚。你把“不想要跳变”写进损失里,模型就会自己学着回避它。这是我个人在项目里反复验证过有效的一个思路。
5. 训练过程中那些让人头疼的 Loss 相关症状
这些年看过的训练曲线不计其数,总结下来,最常见的异常情况就那么几类。大部分问题最后追溯回去,要么是代价函数选错,要么是代价函数的实现细节出了岔子。
5.1 Loss 是 NaN 怎么办
这是最吓人的情况。常见原因有这几个:
- 学习率太大,导致梯度更新一步跨飞,参数变成极大或极小值;
- 代价函数里出现了
log(0)或者除零操作; - 数据里有 NaN 特征;
- Softmax 后直接取 log,数值下溢。
排查思路也按这个顺序来。先看学习率,能不能降到原来的十分之一试试。再看数据,特征有没有缺失值没填充。最后看代价函数,有没有自己对概率取 log 的情况。多数情况下,搞完这三步基本能找到原因。
5.2 Loss 不下降
分类任务交叉熵不下降,有个经验技巧值得试试:在最后一层初始化时,把 logits 的 bias 设置成数学上合理的值。比如类别是 one-hot,样本均匀分布时,logits 的 bias 初始化为 0 是合理的。但如果类别严重不均衡,你可以把 bias 初始化为类别的先验概率的 logit,这样最开始模型的预测就比较接近真实分布的底子,loss 起点会比较低。
回归任务如果 loss 在一个平台期磨磨蹭蹭,先确认标签有没有标准化,再确认模型结构有没有有效的非线性变换能力。很多时候不是损失函数的问题,是特征尺度差异太大,梯度被大数值特征带偏了。
5.3 训练集 Loss 下降但验证集不降反升
这个现象典型的过拟合信号。很多人第一反应是加 dropout、加数据增强,但我更建议先审视代价函数里的正则化项。如果你当前用的模型是自定义结构,正则化系数设成 0,那么过拟合几乎必然发生。先给一个合理的 L2 系数,比如 1e-4,观察一下再继续调。
这里列一个简单的问题定位表:
| 症状 | 最可能的原因 | 优先排查的方向 |
|---|---|---|
| Loss 直接是 NaN | 学习率太大或数值溢出 | 调低学习率,检查数据和 log 操作 |
| Loss 不下降且震荡 | 学习率偏大或代价函数选型不对 | 观察梯度量级,尝试调小学习率 |
| 训练集降但验证集升 | 过拟合 | 增加 L1/L2 正则化强度 |
| 预测值长期偏向均值 | MSE 被异常值带偏 | 换成 Huber 或 MAE |
| 分类概率输出极端 | 数值不稳定或最后一层没有 logits | 使用融合的交叉熵算子 |
5.4 一份从代价函数角度出发的调参顺序建议
我自己在训练新模型时,会按照这个顺序检查:
- 先确认代价函数有没有实现错,特别是数值稳定性的部分;
- 用一小批数据跑几步,确认 loss 能降下来,这一步能快速验证整个流程是否跑通;
- 正常训练后,如果 loss 路线不正常,先调学习率,不要轻易换代价函数;
- 若确认数据有异常值,再考虑替换代价函数;
- 最后才是调正则化系数和优化器超参数。
这个顺序帮我避开了很多“盲目调参”。大部分训练问题,根本原因往往不在模型结构,而在代价函数的设计和实现上。你把这一步做扎实了,后面会顺很多。
最后说两句掏心窝的
代价函数这东西,公式短,逻辑简单,但影响面非常大。它决定了模型以什么方式理解“好”和“坏”,决定了梯度的形态,也决定了训练稳定性和最终性能。我见过太多项目,模型结构换了一轮又一轮,效果始终上不去,最后换一个更合适的损失函数,立刻肉眼可见地变好。这种情况在回归任务里尤其常见。
我个人还有个习惯:每新建一个模型,会把代价函数单独打印出来,喂几个极端的输入(比如全 0、全 1、随机噪声),确认输出的数值范围合理再开始训练。这步花不了两分钟,但能挡住后面好几个小时的 debug 时间。如果你现在正被训练曲线折磨,不妨先停下来,把目光从模型结构移到代价函数上来,说不定问题一下就通了。