资讯动态

TensorFlow 2损失函数选型指南:从交叉熵到自定义Loss的实践

发布时间:2026/10/15 7:51:13 来源:尧图企业网站定制
做深度学习训练我最深的一个体会是很多人愿意在模型结构上花大量时间调参却很少认真琢磨model.compile里那一行loss后面到底该填什么。在 TensorFlow 2 里tf.keras.losses提供给我们的选项其实是相当丰富且分化明显的但大多数教程讲到这就一句分类用交叉熵回归用均方误差带过去了。可真到了实际项目里数据分布一变、任务一复杂这种笼统的选法立刻就会让你看到莫名其妙的训练曲线、迟迟不降的 loss甚至直接爆成 NaN。这篇文章不是把官方文档里所有损失函数抄一遍而是从我自己的使用经验出发把 TensorFlow 2 里常用的几种损失函数按任务类型拆开讲清楚它们各自在梯度、数值稳定性、异常值鲁棒性上的差异并给出可直接套用的 API 写法。还会专门聊聊自定义损失函数的正确姿势和一些官方文档不会告诉你的坑。适合正在用 TensorFlow 2 做分类、回归或特殊任务的开发者也适合那些对损失函数只有模糊概念、最近想把它彻底搞明白的朋友。1. 损失函数选型的底层逻辑与其纠结公式不如先想清楚模型该重视哪类错误很多初学者拿到损失函数列表第一反应是找公式、背公式。但我建议你先换个角度想一件事损失函数在训练中扮演的角色本质上是在告诉梯度下降什么样的预测结果是你最不能接受的。1.1 从梯度反传的角度看损失函数反向传播时损失值会通过链式法则逐层回传梯度。损失函数选择不同梯度的幅值和方向就完全不同。举个例子回归任务里如果我们用均方误差MSE某个样本的预测值和真实值相差 10产生的梯度是相差 1 的 10 倍而用平均绝对误差MAE两者梯度是一样的。这意味着 MSE 会让优化器拼命去纠正那些大错误样本MAE 则对每个样本一视同仁。所以损失函数选型本质上是在回答这个任务里哪些样本的误差最不可接受如果大误差样本本来就代表数据噪声或标注错误你用 MSE 就是在让模型浪费时间迎合脏数据如果大误差样本是任务的核心难点你用 MAE 又会让模型失去逼近精确解的动力。1.2 损失函数与输出层激活函数的匹配关系另一个常被忽略的点是损失函数必须和最后一层激活函数配套。一般来说有两条路线输出层用 sigmoid / softmax损失函数收到的是一个概率值此时设置from_logitsFalse。输出层不加激活函数即裸 logits损失函数收到的是未归一化的数值此时设置from_logitsTrue。这两条路线在大方向上都能收敛但数值稳定性差很多。在交叉熵里如果输入是概率值内部要额外做clip和log操作容易出现下溢或梯度消失如果直接吃 logitsTensorFlow 会做一个更稳定的数值变换。所以我自己在大多数情况下输出层不加激活函数统一把from_logitsTrue写进损失函数预测的时候再手动对 logits 做softmax或sigmoid。为了让你对后续各种损失函数有一个整体认知我先放一个我自己整理的速查表后面每个章节会再展开细讲。任务类型常用损失函数TensorFlow 2 API关键特点回归均方误差MeanSquaredError对大误差敏感收敛快但怕异常值回归平均绝对误差MeanAbsoluteError对异常值鲁棒收敛尾部不平滑回归HuberHuber(delta...)异常值鲁棒与收敛精度的折中回归LogCoshLogCosh平滑近似 MAE处处二阶可导二分类二元交叉熵BinaryCrossentropy常配合 sigmoid多分类交叉熵CategoricalCrossentropy配合 softmaxone-hot 标签多分类稀疏交叉熵SparseCategoricalCrossentropy标签用整数索引省内存多标签二元交叉熵BinaryCrossentropy每个类别独立二分类分布度量KL 散度KLDivergence度量两个概率分布差异向量相似度余弦相似度CosineSimilarity常用于表示学习、文本匹配2. 回归任务里的三件套与变体MSE、MAE、Huber 到底该怎么挑回归任务是我感觉最多人随手填一个损失的地方。因为回归数据千差万别有的接近高斯分布有的重尾有的振捣一遍甚至会出现几个离谱的异常值。你选什么损失函数直接决定模型是被这些异常值带偏还是能稳扎稳打地学主体规律。2.1 MSE收敛快但最容易被异常值带崩MSE 的公式是预测值与真实值差值的平方求平均。在 TensorFlow 2 里接口非常简洁model.compile(optimizeradam, losstf.keras.losses.MeanSquaredError())我刚开始做回归任务时习惯性用 MSE有一次处理一份某传感器采集的工业数据里面个别样本的标签因为记录错误比正常值大了几十倍。训练下去 loss 非常高而且死活降不动后来把 MSE 换成 Huber 之后模型立刻恢复正常。这个案例让我彻底记住了 MSE 的特性平方操作把大误差的惩罚放大了模型会把大量精力花在修正这些离群样本上反而忽略了大多数正常样本。但从另一个角度看如果数据质量高、异常值少MSE 收敛速度快因为梯度会随着误差增大而线性增大优化器很容易判断出哪些样本值得多花力气。尤其是当标签接近正态分布、尺度比较统一时MSE 的效果依然很稳。2.2 MAE抗脏能力强但收敛尾部容易来回震荡MAE 对每个误差只取绝对值梯度是恒定的正负 1所以它对异常值的敏感度远低于 MSE。但问题在于当预测值非常接近真实值时梯度仍然是 ±1不会变小这会让参数在最优解附近来回跳动很难收敛到一个足够精确的最小值点。实际项目中如果数据的标注噪声很大MAE 确实是最稳的选择之一。我做过一个某库存预测项目标签里有不少人为填报误差用 MAE 虽然 loss 值看着不如 MSE 那么漂亮但实际预测的中位误差反而小很多。API 写法model.compile(optimizeradam, losstf.keras.losses.MeanAbsoluteError())2.3 Huber它为什么是很多回归任务的首选Huber 是分段函数误差较小时按平方处理误差较大时按线性处理。这个设计刚好同时吃到了 MSE 和 MAE 的红利对正常样本保持平方误差的收敛精度对离群样本又不至于让他们主导梯度。TensorFlow 2 的写法是model.compile( optimizeradam, losstf.keras.losses.Huber(delta1.0) )delta这个参数是分段点它决定了多大的误差算异常。注意 delta 跟标签的尺度有关如果标签的量级是几千而你设置delta1.0那几乎所有误差都会落在线性段Huber 就退化成 MAE 了。所以我习惯先看训练初期 loss 的量级然后大致把 delta 设定在正常样本误差的上限附近。比如某个项目标签范围在 0~100正常误差 5 以内我会把 delta 设为 5~10 之间。2.4 LogCosh比 Huber 更平滑适合需要二阶导的场景LogCosh 的公式是对cosh取对数它和 Huber 一样对大误差呈线性、小误差呈平方特性关键是它处处二阶可导。这在某些需要用到 Hessian 或对平滑性有特殊要求的优化方法里有优势。不过说实话日常训练里我用 LogCosh 的场景不多因为它和 Huber 的表现很接近而 Huber 的delta参数更好调model.compile(optimizeradam, losstf.keras.losses.LogCosh())2.5 回归损失函数对比小总结我漏掉了分类别急下一节慢慢说。这里先把回归四个损失的关键特征放在一起方便直观对比指标MSEMAEHuberLogCosh对大误差惩罚二次增长极大线性增长恒定线性增长线性增长对异常值鲁棒性差好好好收敛到精确值能力强弱中中是否可调参否否是delta否适用数据特征干净、近正态大量离群噪声有少量离群噪声同 Huber如果你不想每个项目都做实验我的默认选择是 Huber。只有在确定数据非常干净时才切回 MSE确定噪声非常严重时才切回 MAE。3. 分类任务远不止一个交叉熵从二分类到多标签再到类不平衡分类任务里交叉熵几乎是默认答案。但在 TensorFlow 2 里交叉熵有好几个不同的 API而且from_logits这个参数一设错整个训练结果都会乱掉。这一节我会把各种场景下的分类损失函数拆开说清楚。3.1 二分类与多标签都用 BinaryCrossentropy但场景完全不同先明确一个概念二分类是一个样本只有一个标签多标签是一个样本可以同时拥有多个标签。它们在损失函数层面其实是一样的做法——每个类别都是一次独立的二分类。TensorFlow 2 的写法# 二分类标签是0或1 model.compile( optimizeradam, losstf.keras.losses.BinaryCrossentropy(from_logitsTrue) ) # 多标签分类标签是 shape(batch, num_classes) 的 0/1 矩阵 model.compile( optimizeradam, losstf.keras.losses.BinaryCrossentropy(from_logitsTrue) )我自己做多标签任务时踩过一个坑多标签分类的最后一层用 sigmoid而不是 softmax。因为 softmax 是在所有类别上做归一化会强制让类别之间互斥而多标签任务里类别是可以同时出现的必须用 sigmoid 对每个类独立判断是否成立。如果你在多标签输出层用了 softmax刚开始也许能过拟合一部分样本但最终收敛效果一定很差。另外多标签数据集里经常出现负样本远多于正样本的情况。此时可以给BinaryCrossentropy传入pos_weight或者自己写一个带权重的二元交叉熵。TensorFlow 2 里内置 API 没有直接暴露pos_weight参数一般我会继承Loss写一个简单的加权版具体写法放到第 4 章。3.2 多分类的两套 APICategoricalCrossentropy 和 SparseCategoricalCrossentropy多分类任务里TensorFlow 2 提供了两个等价但输入格式不同的 API。我见过很多人在网上问这两者的区别答案其实就一个标签是 one-hot 编码就选前者标签是整数索引就选后者。# 标签是 one-hot: [[0,0,1], [1,0,0], ...] model.compile( optimizeradam, losstf.keras.losses.CategoricalCrossentropy(from_logitsTrue) ) # 标签是整数索引: [2, 0, 1, ...] model.compile( optimizeradam, losstf.keras.losses.SparseCategoricalCrossentropy(from_logitsTrue) )用整数索引的好处是省内存对超大规模数据更友好。因为 one-hot 在类别很多时比如 1 万类会额外生成一个(batch, 10000)的大张量十分浪费。而且 API 内部在处理整数索引时会做 gather 操作计算效率也比 one-hot 高一点。3.3 from_logits 设错会怎样一个我踩过的真实大坑这个坑我觉得非常值得单独拿出来说。from_logitsFalse时TensorFlow 会认为y_pred已经是概率值即已经过 sigmoid/softmax于是在计算交叉熵前会先找一个接近 0 的极小值做clip再把概率值取对数。你想想如果此时你把 logits 直接传进去——logits 可以是负数也可以远大于 1压根不是概率——内部做的log和clip就会完全扭曲语义导致 loss 初始值怪到怀疑人生。有一次我做某图像分类项目输出层为了方便可视化预测结果直接加了 softmax 激活然后损失函数里又习惯性写了from_logitsTrue一训练就发现 loss 初始值高得离谱而且降不下去。我一度怀疑是数据预处理出了问题查了整整一晚上才发现是两边语义搞反了。所以我的建议是整个流程里只用一种约定。要么输出层激活函数None损失函数统一from_logitsTrue要么输出层带激活损失函数统一from_logitsFalse。大部分实践里前者数值更稳定因为输出层和损失函数内部重合的 softmax/sigmoid 计算被合并成了一次数值稳定的操作。3.4 类不平衡Focal Loss 的思路和实现交叉熵对类别不平衡很不友好因为数量多的类别在 loss 总量里占据绝对主导。改进思路很多最常用的是 Focal Loss它给交叉熵乘上一个调制因子(1 - p_t)^gamma让模型在训练中自动降低已学好样本的权重把注意力放到难样本和稀有类别上。我实现 Focal Loss 时候的一个实用版本二分类import tensorflow as tf def focal_loss(gamma2.0, alpha0.25): def loss(y_true, y_pred): bce tf.keras.losses.BinaryCrossentropy(from_logitsFalse)( y_true, y_pred ) p_t (y_true * y_pred) ((1 - y_true) * (1 - y_pred)) modulating_factor tf.pow(1.0 - p_t, gamma) return tf.reduce_mean(alpha * modulating_factor * bce) return loss这里alpha控制正负样本整体权重gamma控制对难样本的关注程度。实际项目中我用在某个罕见故障检测任务上把稀有类别的召回率从 52% 提高到 69%效果非常明显。缺点是引入了两个超参需要跑几次小实验来定我一般从gamma2.0, alpha0.25开始调。3.5 KLDivergence分布差异的度量常用于生成模型除了交叉熵还有一个容易被忽视的损失函数KLDivergence。它度量的是预测分布和真实分布之间的 KL 散度。其实交叉熵和 KL 散度只差一个真实分布自身的熵所以分类任务里它们优化方向基本等价。但在 VAE 里我们常常直接用它约束隐变量分布接近标准正态model.compile(optimizeradam, losstf.keras.losses.KLDivergence())需要注意KL 散度是非对称的。如果你在做需要距离对称的场景可能需要考虑对称化处理或者干脆用 JS 散度。4. 自定义损失函数的正确姿势与隐蔽陷阱内置损失函数再丰富也总有覆盖不到的领域。我在实际项目里大概有三分之一的时间在写自定义损失比如加权交叉熵、感知损失、多任务组合损失。TensorFlow 2 的自定义机制非常灵活但也有几个容易出问题的地方。4.1 两种常用写法函数式与类式最简单的是函数式写法接收y_true、y_pred两个张量返回一个标量损失def my_custom_loss(y_true, y_pred): return tf.reduce_mean(tf.square(y_true - y_pred)) model.compile(optimizeradam, lossmy_custom_loss)如果自定义损失需要保存一些状态、可调参数或想在训练过程中记录额外指标继承Loss类是更好的选择class WeightedMSE(tf.keras.losses.Loss): def __init__(self, weight_pos1.0, weight_neg1.0, **kwargs): super().__init__(**kwargs) self.weight_pos weight_pos self.weight_neg weight_neg def call(self, y_true, y_pred): error tf.square(y_true - y_pred) weight tf.where(y_true 0, self.weight_pos, self.weight_neg) return tf.reduce_mean(weight * error) model.compile(optimizeradam, lossWeightedMSE(2.0, 1.0))类式写法的好处是__init__里可以把超参保存下来还可以在call里用self.add_metric记录一些中间量方便在训练日志里观察模型内部行为。4.2 用 add_loss 构造更复杂的损失场景有时损失不光是输出层与标签之间的差异还涉及中间层。比如要做特征匹配、风格迁移或者给中间层加正则约束。在这种情况下模型编译时的loss参数就不太好用了推荐用model.add_loss手动注册inputs tf.keras.Input(shape(64,)) x tf.keras.layers.Dense(32, namefeature_layer)(inputs) outputs tf.keras.layers.Dense(1)(x) model tf.keras.Model(inputs, outputs) feature_out model.get_layer(feature_layer).output registration_loss tf.reduce_mean(tf.square(feature_out)) model.add_loss(registration_loss * 0.01) model.compile(optimizeradam, losstf.keras.losses.MeanSquaredError())注意add_loss添加的损失会叠加到编译时指定的主损失上权重也需要自己控制。我一般会把辅助损失的系数放在模型结构输入层之外便于作为超参调整。4.3 自定义损失里三个最容易被忽略的坑第一不能用 NumPy 操作。损失函数内部的张量是要走自动求导图的一旦你调用了numpy()或者用 Python 原生语句对张量做了逻辑分支梯度就断了。我自己早年写过一个自定义损失为了做阈值筛选转成 numpy 数组结果训练时 loss 一点变化都没有排查了半天才发现梯度根本没传回去。所有损失函数内部的操作都要用 TensorFlow 算子完成。第二注意 y_true 和 y_pred 的 shape。很多新手在y_true是(batch,)y_pred是(batch, 1)的时候直接用加减法触发广播机制后得到一个(batch, batch)损失矩阵。这个错误非常隐蔽loss 不会报错但值怎么算怎么不对。我的经验是在自定义损失第一行就加上y_true tf.reshape(y_true, tf.shape(y_pred))先保证形状匹配。第三如果函数式损失里加了额外参数记得用闭包或类封装。直接写my_loss(y_true, y_pred, alpha0.5)交给compile是会报错的因为编译接口只接受双参数函数。所以要么用默认参数闭包包装要么直接继承Loss。5. 从损失值异常反推训练问题的一套排查思路很多时候我们把损失函数写对了训练还是会出各种奇怪现象。我做了那么多项目后总结出一套体会损失函数的曲线和初始值其实是整个训练系统最好的诊断器。它一不对劲往往意味着某个环节出了问题。5.1 先验证初始损失值是否合理模型开始训练之前损失值其实是可以估算的。比如分类任务采用交叉熵时如果类别数有 C 类随机初始化的模型在第一批数据上的损失应该大约等于ln(C)。二分类则大约是0.693即 ln2。如果初始值离理论值差太多说明前面某个环节肯定有问题——最可能的就是 3.3 节里提到的from_logits设错、标签格式错误、或者输出层激活函数配置异常。5.2 loss 为 NaN 的常规排查链路在实践中NaN 是训练过程最让人崩溃的问题之一。我的排查链路一般是这样先检查数据中是否包含 NaN 或无穷大np.isnan(x_train).any()。检查学习率是否过大。Adam 默认学习率 0.001 一般没事但某些自定义损失或特殊架构会需要降低到 0.0001。检查损失函数内部是否有除以零的风险尤其是在自定义损失里处理sqrt或除法时要加一个小的 epsilon。def safe_loss(y_true, y_pred): diff y_true - y_pred return tf.reduce_mean(tf.sqrt(tf.square(diff) 1e-8))检查标签量纲。如果一个回归任务的标签非常大比如上万平方之后再算梯度很容易数值溢出此时把标签做归一化会立竿见影。5.3 loss 不降但不报错从哪几个方向下手如果 loss 能算出来但一直不降我通常会倒着排查三件事一是数据批量里标签是否存在严重的顺序偏差二是激活函数是否把输出限制在了饱和区三是是否某个自定义损失被错误地写成了常数。这里我想特别提一个有意思的现象有时候你会发现 loss 降了但自定义的评估指标却变差了。这种矛盾通常是损失函数和评估指标偏向不同导致的。比如你用 MSE 作为损失但评估用的是平均绝对百分比误差MAPE两者对大误差、小误差的敏感度完全不同出现背离很正常。遇到这种情况最好的办法不是急着改损失而是检查有没有更匹配任务目标的损失函数。5.4 多任务学习中的损失权重调整多任务模型里总损失往往是各个分损失的加权和。常见的坑是不同任务的损失量级不一样比如一个回归任务 loss 是 50一个分类任务 loss 是 0.5简单相加之后分类任务几乎等于被忽略。我一般在开始训练前先各自验证一次单任务 loss 的量级然后按量级比例初设权重再在训练过程中用指数移动平均观察是否可以进一步调整。TensorFlow 2 里写成字典或自定义组合损失都比较方便。最后再分享一个小技巧。很多人不知道TensorFlow 2 里compile时的损失参数既可以直接写字符串也可以写实例。比如losshuber和losstf.keras.losses.Huber(delta1.5)。字符串形式虽然没有额外参数但它会自动匹配类名代码看起来非常简洁。我自己的习惯是模型原型验证阶段全部用字符串一旦需要深入研究或调超参立刻改成显式实例这样每个实验用的什么参数回头翻代码一眼就能看到。我做了三年多的 TensorFlow 项目对损失函数的理解从一开始照抄别人的公式到现在根据数据形态、任务目标、梯度特性综合选型中间确实踩了不少坑。如果你正在被某个训练问题折磨先别急着换优化器、调学习率回头看看损失函数——它大概率就是那把钥匙。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑