在深度学习分类或语义分割任务中,我们经常会遇到这样的情况:模型的 Precision 很高,但 Recall 很低;或者 Recall 很高,但 Precision 明显偏低。
这种现象不一定说明模型训练失败,也可能是二值化阈值设置不合适。默认使用的 0.5 阈值并不一定适用于所有模型和数据集,因此可以在验证集上进行阈值搜索,寻找更合适的 Precision--Recall 平衡点。
本文将从指标含义、阈值影响、搜索方法、代码实现和实验注意事项几个方面,介绍如何通过阈值搜索平衡 Precision 和 Recall。
一、Precision 和 Recall 分别表示什么
在二分类任务中,模型的预测结果可以分成以下四类:
-
TP:真实为正类,并且模型也预测为正类;
-
FP:真实为负类,但模型错误地预测为正类;
-
FN:真实为正类,但模型错误地预测为负类;
-
TN:真实为负类,并且模型也预测为负类。
Precision 的计算公式为:
Precision = TP / (TP + FP)
Precision 表示:模型预测为目标的样本中,真正属于目标的比例有多高。
Recall 的计算公式为:
Recall = TP / (TP + FN)
Recall 表示:所有真实目标中,有多少被模型成功识别出来。
以红树林语义分割为例:
-
Precision 低,说明模型把较多非红树林区域误判成了红树林;
-
Recall 低,说明部分真实红树林区域没有被识别出来。
因此,Precision 主要反映误检情况,Recall 主要反映漏检情况。
二、为什么阈值会影响 Precision 和 Recall
语义分割模型通常不会直接输出 0 或 1,而是输出每个像素属于目标类别的概率。
例如,模型输出:
p = 0.72
表示该像素属于目标类别的概率为 0.72。
为了得到最终的二值分割结果,需要设置一个阈值 t:
-
当 p ≥ t 时,预测结果为 1,即目标类别;
-
当 p < t 时,预测结果为 0,即背景类别。
默认情况下,阈值通常设置为 0.5。但是,0.5 只是一个常用的初始值,并不一定是当前模型的最佳阈值。
1. 降低阈值
假设将阈值从 0.5 降低到 0.3,那么更多像素会被判定为目标。
通常会导致:
-
预测目标区域变大;
-
漏检数量 FN 减少;
-
Recall 升高;
-
误检数量 FP 增加;
-
Precision 降低。
因此,较低的阈值会让模型更加"激进"。
2. 提高阈值
假设将阈值从 0.5 提高到 0.7,那么只有预测概率较高的像素才会被判定为目标。
通常会导致:
-
预测目标区域变小;
-
误检数量 FP 减少;
-
Precision 升高;
-
漏检数量 FN 增加;
-
Recall 降低。
因此,较高的阈值会让模型更加"保守"。
可以简单概括为:
降低阈值通常有利于 Recall,提高阈值通常有利于 Precision。
三、什么是阈值搜索
阈值搜索就是在验证集上测试一系列候选阈值,并分别计算每个阈值对应的 Precision、Recall、Dice、IoU 等指标,最后根据任务需求选择最合适的阈值。
例如,可以搜索以下候选阈值:
t ∈ {0.05,0.06,0.07,......,0.95}
假设得到以下结果:
| 阈值 | Precision | Recall | Dice |
|---|---|---|---|
| 0.30 | 0.780 | 0.910 | 0.840 |
| 0.40 | 0.830 | 0.880 | 0.854 |
| 0.50 | 0.870 | 0.820 | 0.844 |
| 0.60 | 0.910 | 0.750 | 0.822 |
当阈值为 0.30 时,Recall 较高,但误检较多,因此 Precision 偏低。
当阈值为 0.60 时,Precision 较高,但模型更加保守,导致 Recall 下降。
当阈值为 0.40 时,Precision 和 Recall 相对均衡,同时 Dice 最高,因此可以将 0.40 作为候选最佳阈值。
四、如何选择最佳阈值
阈值搜索并不只有一种选择标准,需要根据任务目标确定。
方法一:选择 Dice 或 F1 最高的阈值
F1 的计算公式为:
F1 = 2 × Precision × Recall / (Precision + Recall)
在二分类语义分割任务中,针对目标类别计算时,F1 与 Dice 在数学形式上是一致的:
Dice = 2TP / (2TP + FP + FN)
因此,可以选择使 Dice 或 F1 最高的阈值:
最佳阈值 t = 使 Dice(t) 最大的阈值*
这种方法同时考虑了误检和漏检,一般是最常用、最稳妥的选择方法。
方法二:选择 Precision 和 Recall 差距最小的阈值
如果特别强调二者平衡,也可以选择使下面这个差值最小的阈值:
|Precision(t) - Recall(t)|
但不建议只使用这一标准。
例如存在两个候选阈值:
| 阈值 | Precision | Recall | 差值 |
|---|---|---|---|
| A | 0.700 | 0.700 | 0.000 |
| B | 0.850 | 0.820 | 0.030 |
虽然阈值 A 对应的 Precision 和 Recall 完全相等,但二者都比较低。阈值 B 虽然存在一定差距,但整体性能明显更好。
因此,更推荐采用以下规则:
首先选择 Dice 最高的阈值;如果多个阈值的 Dice 非常接近,再选择 Precision 和 Recall 差距更小的阈值。
方法三:根据实际任务设置约束
不同任务对误检和漏检的容忍程度不同。
如果任务要求尽量减少漏检,可以在 Precision 不低于某个最低值的前提下,选择 Recall 最高的阈值。
例如:
在 Precision ≥ 0.85 的候选阈值中,选择 Recall 最高的阈值。
如果任务要求尽量减少误检,可以在 Recall 不低于某个最低值的前提下,选择 Precision 最高的阈值。
例如:
在 Recall ≥ 0.85 的候选阈值中,选择 Precision 最高的阈值。
在红树林资源调查中,如果更关注完整提取红树林区域,可以适当偏向 Recall;如果分割结果将直接用于面积统计,过多误检会导致面积被高估,则需要更加重视 Precision。
五、阈值搜索的完整代码
下面的代码适用于二分类语义分割任务。它会在验证集上搜索 0.05~0.95 之间的阈值,并计算每个阈值对应的 Precision、Recall、Dice 和 IoU。
import torch
def search_best_threshold(
model,
validation_loader,
device,
thresholds=None
):
"""
在验证集上搜索二分类语义分割的最佳阈值。
参数:
model:
已经训练完成的模型。
validation_loader:
验证集 DataLoader。
device:
模型运行设备,例如 "cuda" 或 "cpu"。
thresholds:
需要搜索的候选阈值。
返回:
best_result:
最佳阈值及其对应指标。
all_results:
所有候选阈值的评价结果。
"""
if thresholds is None:
thresholds = [i / 100 for i in range(5, 96)]
statistics = {
threshold: {
"tp": 0,
"fp": 0,
"fn": 0
}
for threshold in thresholds
}
model.eval()
with torch.inference_mode():
for images, labels in validation_loader:
images = images.to(device)
labels = labels.to(device)
# 模型输出原始 logits
logits = model(images)
# 将 logits 转换为 0~1 范围的概率
probabilities = torch.sigmoid(logits)
# 将真实标签转换为布尔类型
labels = labels > 0.5
for threshold in thresholds:
predictions = probabilities >= threshold
tp = torch.logical_and(
predictions,
labels
).sum().item()
fp = torch.logical_and(
predictions,
torch.logical_not(labels)
).sum().item()
fn = torch.logical_and(
torch.logical_not(predictions),
labels
).sum().item()
statistics[threshold]["tp"] += tp
statistics[threshold]["fp"] += fp
statistics[threshold]["fn"] += fn
all_results = []
epsilon = 1e-8
for threshold in thresholds:
tp = statistics[threshold]["tp"]
fp = statistics[threshold]["fp"]
fn = statistics[threshold]["fn"]
precision = tp / (tp + fp + epsilon)
recall = tp / (tp + fn + epsilon)
dice = (
2 * tp /
(2 * tp + fp + fn + epsilon)
)
iou = (
tp /
(tp + fp + fn + epsilon)
)
result = {
"threshold": threshold,
"precision": precision,
"recall": recall,
"dice": dice,
"iou": iou,
"precision_recall_gap": abs(
precision - recall
)
}
all_results.append(result)
# 首先按照 Dice 从高到低排序;
# Dice 相同时,优先选择 Precision 和 Recall 差距更小的阈值。
all_results.sort(
key=lambda result: (
-result["dice"],
result["precision_recall_gap"]
)
)
best_result = all_results[0]
return best_result, all_results
使用方式如下:
best_result, all_results = search_best_threshold(
model=model,
validation_loader=validation_loader,
device=device
)
print("最佳阈值:", best_result["threshold"])
print("Precision:", best_result["precision"])
print("Recall:", best_result["recall"])
print("Dice:", best_result["dice"])
print("IoU:", best_result["iou"])
假设输出为:
最佳阈值:0.43
Precision:0.856
Recall:0.849
Dice:0.852
IoU:0.742
这表示模型在验证集上的最佳阈值为 0.43。后续在测试集上评价时,应固定使用 0.43,不能再次搜索。
六、不同模型输出的处理方式
阈值搜索前,需要先将模型输出转换为目标类别的概率。
1. 单通道二分类输出
如果模型输出形状为:
[B, 1, H, W]
并且训练时使用的是:
torch.nn.BCEWithLogitsLoss()
那么模型输出的是 logits,需要使用 Sigmoid:
probabilities = torch.sigmoid(logits)
之后再进行阈值处理:
predictions = probabilities >= threshold
2. 双通道分类输出
如果模型输出形状为:
[B, 2, H, W]
第 0 通道表示背景,第 1 通道表示目标,那么需要使用 Softmax:
probabilities = torch.softmax(
logits,
dim=1
)[:, 1:2, :, :]
然后对目标类别概率进行阈值处理:
predictions = probabilities >= threshold
不能直接对未经 Sigmoid 或 Softmax 处理的 logits 使用 0.5 阈值,否则阈值的实际含义会发生变化。
七、正确的实验流程
阈值搜索必须在验证集上完成,正确流程如下:
-
使用训练集训练模型;
-
保存验证集表现最好的模型参数;
-
使用该模型对验证集进行预测;
-
在验证集上搜索最佳阈值;
-
固定模型参数和最佳阈值;
-
使用固定阈值在测试集上进行一次最终评价。
最重要的原则是:
测试集只能用于最终评价,不能用于搜索阈值。
如果在测试集上尝试多个阈值,然后选择测试指标最高的结果,相当于利用测试集进行调参,会导致测试结果偏高,无法客观反映模型的泛化能力。
八、多区域语义分割应该怎样搜索阈值
对于包含多个研究区域或多个 Pair 的数据集,常见做法有两种。
1. 搜索统一的全局阈值
将所有验证样本的 TP、FP 和 FN 累计起来,计算整体 Precision、Recall 和 Dice,然后选择一个统一阈值。
这种方法的优点是:
-
实验流程简单;
-
模型部署时不需要提前知道样本来自哪个区域;
-
不容易因单个区域样本较少而造成阈值过拟合;
-
不同方法之间更容易进行公平对比。
对于多区域红树林分割任务,通常更推荐使用统一的全局阈值。
2. 为每个区域单独搜索阈值
这种方法可能让每个区域获得更高的指标,但存在明显问题:
-
需要提前知道影像属于哪个区域;
-
每个区域都需要单独的验证数据;
-
容易对区域数据产生过拟合;
-
实际应用到新区域时无法确定应使用哪个阈值;
-
容易造成测试指标不公平地升高。
因此,除非研究目标就是区域自适应校准,否则不建议为每个测试区域单独搜索阈值。
如果最终评价采用按样本数量加权的多区域汇总方式,那么阈值搜索阶段也应尽量采用一致的汇总方式,避免阈值选择标准与最终评价标准不一致。
九、粗搜索与精细搜索
如果验证集规模较大,可以采用两阶段搜索。
第一阶段:粗搜索
先以 0.05 为步长搜索:
0.05、0.10、0.15、......、0.90、0.95
假设发现 0.40 附近的 Dice 最高。
第二阶段:精细搜索
再在 0.35~0.45 之间,以 0.01 为步长搜索:
0.35、0.36、0.37、......、0.44、0.45
这种方法能够减少计算量,同时获得较为精确的阈值结果。
一般没有必要把阈值搜索得过于精细。例如,从 0.4312 调整到 0.4313 所带来的差异通常没有实际意义,还可能造成验证集过拟合。
十、论文中应该如何描述
可以在论文的实验设置部分写成:
为减小固定二值化阈值对分割结果的影响,本文在验证集上对预测概率阈值进行搜索。候选阈值范围设置为 0.05~0.95,搜索步长为 0.01,并以 Dice 系数最大作为最佳阈值的选择标准。当多个候选阈值的 Dice 系数相近时,优先选择 Precision 与 Recall 差距较小的阈值。阈值确定后,在测试阶段保持模型参数和二值化阈值不变,并使用该固定阈值完成所有测试样本的评价。
如果最终仍然使用 0.5,也可以写成:
验证集阈值搜索结果表明,0.5 附近取得了最佳 Dice 系数。为保持评价流程的简洁性和不同模型之间的一致性,本文最终采用 0.5 作为统一二值化阈值。
需要注意,如果对多个对比模型进行实验,应该提前统一阈值策略。例如:
-
所有模型都固定使用 0.5;
-
或者每个模型都在各自验证集预测结果上独立搜索最佳阈值。
不能只为自己的模型搜索最佳阈值,而让其他对比模型固定使用 0.5,否则对比结果可能不公平。
十一、阈值搜索的局限性
阈值搜索只能改变模型在 Precision 和 Recall 之间的工作点,不能真正提高模型的特征提取能力。
例如:
-
降低阈值可以减少漏检,但可能增加误检;
-
提高阈值可以减少误检,但可能增加漏检;
-
阈值调整无法让原本混淆的正负样本真正分离。
如果模型在所有阈值下的 Dice 和 IoU 都比较低,说明问题可能来自:
-
模型特征提取能力不足;
-
正负类别严重不平衡;
-
训练数据存在噪声;
-
标签质量较差;
-
训练集与测试集分布不同;
-
云层、阴影或复杂背景造成特征混淆;
-
损失函数不适合当前任务。
此时仅调整阈值并不能从根本上解决问题,还需要从数据、模型结构、损失函数和训练策略等方面进行改进。
十二、总结
阈值是深度学习二分类和语义分割中非常重要、但容易被忽略的参数。
一般来说:
-
阈值降低,Recall 通常升高,Precision 通常降低;
-
阈值提高,Precision 通常升高,Recall 通常降低;
-
默认阈值 0.5 不一定是最佳阈值;
-
建议在验证集上搜索阈值;
-
通常选择 Dice 或 F1 最高的阈值;
-
如果多个阈值的 Dice 接近,再考虑 Precision 和 Recall 的差距;
-
最佳阈值确定后,必须在测试集上固定使用;
-
不能在测试集上搜索最佳阈值;
-
多区域任务通常应使用统一的全局阈值;
-
阈值搜索只能调整误检与漏检的平衡,不能代替模型本身的改进。
对于语义分割任务而言,除了比较 Precision、Recall、Dice 和 IoU,还应结合预测图观察误检和漏检的空间分布。只有将定量指标与可视化结果结合起来,才能准确判断阈值调整是否真正改善了模型的实际分割效果。