ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

【深度学习项目实战·11】损失函数与评价指标:让模型输出、标签与结果真正对应

【深度学习项目实战·11】损失函数与评价指标:让模型输出、标签与结果真正对应

文章目录

    • 01 本篇要解决什么问题
    • 02 背景:损失负责优化,指标负责解释表现
    • 03 核心知识:从输入约定到评价规则
      • (1)先把输出、标签和损失对齐
      • (2)Softmax、Sigmoid应该放在哪里
      • (3)分类指标先回到混淆矩阵
      • (4)分割指标:重叠程度与汇总方式都要写清
      • (5)回归与重建:误差、量纲和动态范围
      • (6)评价之前,先固定“尺子”
    • 04 一个具体例子:用可手算的数据核对指标
    • 05 常见问题与自测
    • 06 本篇回顾与下一步

01 本篇要解决什么问题

模型已经能前向传播,训练也在输出loss,但还不能马上判断流程正确:分类前是否要Softmax?分割该用Dice还是IoU?准确率95%,为什么正类一个也找不出来?

本篇先对齐任务、输出和标签,再解释分类、分割与回归中的常用指标。最后用一组能手算的数据检查实现,避免只看到一个数字就下结论。

  • 本篇关键词:交叉熵、BCE、MSE、Precision、Recall、F1、Dice、IoU、PSNR。
  • 完成标志:能为基础任务选定相容的损失输入,并明确说明指标的计算与汇总规则。
  • 练习约定:评价脚本只依赖NumPy;另附PyTorch损失接口检查脚本,不下载数据。

02 背景:损失负责优化,指标负责解释表现

损失函数需要给训练提供合适的优化信号;评价指标回答某个具体问题,例如错分多少、漏检多少、区域重叠多大、重建误差多大。

两者有时采用相同公式,有时不同。准确率涉及离散类别判断,通常不直接作为普通梯度训练的目标;交叉熵可以用于优化,但它的数值也不能直接翻译成“准确率多少”。

选择之前先写清任务:每个样本只有一个类别,还是可同时属于多个类别?标签代表整张图,还是每个像素?目标是离散类别,还是连续数值?

03 核心知识:从输入约定到评价规则

(1)先把输出、标签和损失对齐

下表展示最常见的一组用法,采用默认普通监督设置;N NN为批次,C CC为类别数。

任务模型输出标签一种常用损失
单标签多分类浮点logits[N,C]long类别ID[N]CrossEntropyLoss
二分类,单输出浮点logits[N,1]浮点0/1[N,1]BCEWithLogitsLoss
多标签分类浮点logits[N,C]浮点多热标签[N,C]BCEWithLogitsLoss
多类语义分割浮点logits[N,C,H,W]long类别ID[N,H,W]CrossEntropyLoss
二值分割浮点logits[N,1,H,W]浮点0/1,形状相同BCEWithLogitsLoss
回归与强度重建浮点预测浮点目标,形状相同MSELoss或L1Loss

这是整数类别标签下的常用交叉熵接口,标签值通常应在0~C-1;配置ignore_index时另有约定。交叉熵也支持特定形式的概率目标,但不要与整数标签用法混用,详见CrossEntropyLoss文档。

(2)Softmax、Sigmoid应该放在哪里

对单个样本,类别c cc的交叉熵可以写为:

L C E = − z c + log ⁡ ∑ j e z j . L_{CE}=-z_c+\log\sum_j e^{z_j}.LCE​=−zc​+logj∑​ezj​.

PyTorch的CrossEntropyLoss接收原始logits,内部完成相应计算;不要提前Softmax后再当成logits传入。BCEWithLogitsLoss同样直接接收logits,内部组合Sigmoid与二元交叉熵,以改善数值稳定性,见BCEWithLogitsLoss文档。

推理时,单标签分类可对类别维argmax;二值任务需要概率时再Sigmoid,然后按预先确定的阈值判断。默认0.5只是一个起点,若需要调整阈值,应在验证数据上确定,再固定用于测试。

(3)分类指标先回到混淆矩阵

把关注类别定义为正类:TP是正确找出的正类,FP是误报的正类,FN是漏掉的正类,TN是正确判断的负类。

P r e c i s i o n = T P T P + F P , R e c a l l = T P T P + F N , F 1 = 2 T P 2 T P + F P + F N . Precision=\frac{TP}{TP+FP},\quad Recall=\frac{TP}{TP+FN}, \quad F1=\frac{2TP}{2TP+FP+FN}.Precision=TP+FPTP​,Recall=TP+FNTP​,F1=2TP+FP+FN2TP​.

Precision关心预测为正的结果有多少可靠;Recall关心实际正类找回多少。100个样本只有5个正类,全部预测为负类,准确率仍有95%,但正类召回率为0,F1也为0。

多分类汇总时,macro先逐类计算再等权平均;micro先累计相应计数;weighted按支持样本数加权。报告时应注明类别、平均方式和零分母策略,基本定义见scikit-learn分类评价说明。

AUROC衡量分数的排序区分能力,通常使用连续分数而不是阈值后的0/1结果,见roc_auc_score文档。AP关注Precision-Recall关系,其加权定义不等同于用梯形法积分的PR曲线面积,见average_precision_score文档。

(4)分割指标:重叠程度与汇总方式都要写清

对同一个二值前景掩膜,硬预测下的Dice与IoU为:

D i c e = 2 T P 2 T P + F P + F N , I o U = T P T P + F P + F N . Dice=\frac{2TP}{2TP+FP+FN},\qquad IoU=\frac{TP}{TP+FP+FN}.Dice=2TP+FP+FN2TP​,IoU=TP+FP+FNTP​.

在相应分母有定义时,两者满足D i c e = 2 I o U / ( 1 + I o U ) Dice=2IoU/(1+IoU)Dice=2IoU/(1+IoU)。这里的硬Dice与二分类F1形式相同;训练中使用连续概率构造的soft Dice是另一种实现,需要说明平滑项、归约方式和背景处理。

预测与真值都为空时,上式分母为0。本篇返回NaN表示未定义,保存JSON时转成null;这是一种明确约定。项目也可以约定计为1或跳过,但必须解释,并报告有效样本数,不能无说明地混入平均。

逐图计算后平均,与先累计全数据集TP/FP/FN再计算,并不是相同统计量。多类分割还要说明是否包括背景、哪些类别被忽略。跨论文比较前,应先核对这些条件。

Dice loss常写成1 − s o f t D i c e 1-softDice1−softDice,可以与CE/BCE组合;但类别稀少时也不能只靠一个损失名称解决问题,还要检查采样、标签质量与训练稳定性。

(5)回归与重建:误差、量纲和动态范围

对相同形状的预测与目标,先转浮点再计算误差e = y ^ − y e=\hat y-ye=y^​−y:

M A E = 1 M ∑ i ∣ e i ∣ , M S E = 1 M ∑ i e i 2 , R M S E = M S E . MAE=\frac1M\sum_i|e_i|,\quad MSE=\frac1M\sum_i e_i^2, \quad RMSE=\sqrt{MSE}.MAE=M1​i∑​∣ei​∣,MSE=M1​i∑​ei2​,RMSE=MSE​.

MAE与目标量纲相同;MSE更强调较大误差;RMSE恢复目标量纲。这里M MM指参与统计的元素数,要说明是否包含所有通道、像素或有效区域。

常见图像指标PSNR为:

P S N R = 10 log ⁡ 10 R 2 M S E . PSNR=10\log_{10}\frac{R^2}{MSE}.PSNR=10log10​MSER2​.

R RR是约定的信号动态范围,例如0~1图像通常取1,0~255图像取255,而不是每张预测图自己的最大值。若图像已标准化,应先回到统一评价域。MSE为0时PSNR为正无穷,写文件时也需明确表示方法。

SSIM还涉及窗口、常数、通道和动态范围等设置,报告时需要说明实现。高PSNR并不保证所有结构细节或视觉感受都更好,应结合任务需求和误差图分析。

(6)评价之前,先固定“尺子”

明确预处理、裁剪区域、阈值、是否按图平均、是否忽略背景,以及空样本策略。图像和标签的空间变换需一致;不同图像不能各自按结果重新归一化后直接比较误差。

第09篇按验证损失选择模型,是一种示例规则。真实项目可以按主要验证指标选择,但应提前确定;测试集不能反复用于挑阈值、损失组合和最佳轮次。

图:本篇原创接口与指标示意,右侧计数来自下方固定练习数据。

04 一个具体例子:用可手算的数据核对指标

保存为examples/metrics_demo.py。第一组数据有TP=3、FP=1、FN=2、TN=4,因此Accuracy=0.7、Precision=0.75、Recall=0.6、F1=2/3、IoU=0.5。

"""Binary metrics and regression metrics with explicit empty-denominator handling."""importjsonimportmathimportnumpyasnpdefdivide_or_nan(numerator,denominator):returnfloat(numerator/denominator)ifdenominatorelsefloat("nan")defbinary_metrics(target,prediction):target,prediction=np.asarray(target),np.asarray(prediction)iftarget.shape!=prediction.shapeortarget.size==0:raiseValueError("Inputs must have identical, non-empty shapes")ifnot(np.isin(target,[0,1]).all()andnp.isin(prediction,[0,1]).all()):raiseValueError("Expected binary IDs, not scores")target,prediction=target.astype(bool),prediction.astype(bool)tp=int(np.sum(target&prediction))fp=int(np.sum(~target&prediction))fn=int(np.sum(target&~prediction))tn=int(np.sum(~target&~prediction))return{"tp":tp,"fp":fp,"fn":fn,"tn":tn,"accuracy":(tp+tn)/target.size,"precision":divide_or_nan(tp,tp+fp),"recall":divide_or_nan(tp,tp+fn),"f1_dice":divide_or_nan(2*tp,2*tp+fp+fn),"iou":divide_or_nan(tp,tp+fp+fn)}defregression_metrics(target,prediction,data_range):target=np.asarray(target,dtype=np.float64)prediction=np.asarray(prediction,dtype=np.float64)iftarget.shape!=prediction.shapeortarget.size==0:raiseValueError("Inputs must have identical, non-empty shapes")ifdata_range<=0ornotnp.isfinite(data_range):raiseValueError("data_range must be positive and finite")ifnot(np.isfinite(target).all()andnp.isfinite(prediction).all()):raiseValueError("Non-finite values in inputs")error=prediction-target mse=float(np.mean(error**2))return{"mae":float(np.mean(np.abs(error))),"mse":mse,"rmse":math.sqrt(mse),"psnr":10*math.log10(data_range**2/mse)ifmseelsefloat("inf")}defjson_safe(value):ifisinstance(value,dict):return{k:json_safe(v)fork,vinvalue.items()}ifisinstance(value,float)andnotmath.isfinite(value):returnNonereturnvaluedefmain():target=np.array([1,1,1,1,1,0,0,0,0,0])prediction=np.array([1,1,1,0,0,1,0,0,0,0])metrics=binary_metrics(target,prediction)assert(metrics["tp"],metrics["fp"],metrics["fn"],metrics["tn"])==(3,1,2,4)np.testing.assert_allclose([metrics[k]forkin["accuracy","precision","recall","f1_dice","iou"]],[0.7,0.75,0.6,2/3,0.5])empty=binary_metrics(np.zeros(4),np.zeros(4))assertmath.isnan(empty["f1_dice"])regression=regression_metrics([0.0,1.0],[0.1,0.9],data_range=1.0)np.testing.assert_allclose([regression["mae"],regression["mse"],regression["psnr"]],[0.1,0.01,20.0])print(json.dumps(json_safe({"binary":metrics,"regression":regression,"both_empty":empty}),indent=2,allow_nan=False))if__name__=="__main__":main()

从项目根目录执行,PowerShell与Bash均适用:

python examples/metrics_demo.py python examples/loss_contracts.py

第一条只需NumPy,同时检查回归例子的MAE=0.1、MSE=0.01、PSNR=20 dB,以及双空掩膜的处理。第二条需要PyTorch,检查CE、BCE与分割CE的形状及可核对的数值:约0.1269、0.6931和1.0986。完整脚本随本文放在examples目录。

这里的数值是固定输入的数学结果,不是模型在真实数据集上的成绩。先用这种小例子对齐定义,再接入大量数据,可以更早发现实现错误。

05 常见问题与自测

问题1:CrossEntropyLoss前面要不要Softmax?

本篇的标准PyTorch用法不需要,直接传logits。只有展示概率等场景,再单独做相应变换。

问题2:二分类只有两个类别,必须输出两个通道吗?

不必须。可以输出两个logits配CE,也可以一个logit配BCEWithLogits;标签形状和类型要跟着相应方案保持一致。

问题3:Dice与IoU可以混着比较吗?

不能只看数值直接比较。即使同一掩膜下可建立关系,数据汇总、背景与空类处理不同也会改变结果。

问题4:为什么准确率很高,正类效果仍很差?

类别不平衡时,大量负类可能主导准确率。应结合混淆矩阵、Precision、Recall、F1等观察关注类别。

问题5:两段代码算出的PSNR不同,先查什么?

先查输入范围、data_range、裁剪区域、通道与平均方式,再查实现细节,不能只比较函数名。

06 本篇回顾与下一步

到这里,第二部分已经把项目入口、图像数组、PyTorch计算、训练流程、模型结构和评价规则接起来。进入具体场景前,至少应能清楚描述:输入怎样处理、模型输出什么、损失如何计算、指标按什么规则汇总。

中文常见英文或写法
交叉熵Cross Entropy / CE
精确率、召回率Precision、Recall
交并比Intersection over Union / IoU
平均绝对误差Mean Absolute Error / MAE
峰值信噪比Peak Signal-to-Noise Ratio / PSNR

下一部分进入第12—32篇场景项目。具体场景按专栏后续安排展开,并把本部分的检查方法带进每个任务。

参考与延伸阅读

  • PyTorch常用损失函数
  • scikit-image:PSNR与SSIM接口及data_range说明
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进