用matplotlib画一条ROC曲线只需五六行代码,但真正能经受住追问的,是把95%置信区间一起画上去。我在实际项目里被问“这个AUC 0.89稳不稳”之后,才开始认真研究置信区间的计算和绘制,也踩了不少坑。这篇文章把完整的方案写出来:从置信区间的含义、三种主流计算方法,到bootstrap逐行实现,再到用matplotlib画出带阴影置信带的ROC曲线,整个过程可直接照着复现。适合已经会用sklearn和matplotlib画基础ROC曲线、但想进一步提升严谨度的人。
1. 只画一条ROC曲线,为什么越来越不够用
1.1 一次模型评审会上的尴尬
之前做一个信贷风控模型,测试集上算出AUC 0.89,我兴冲冲去参加评审。汇报到一半,业务负责人问了一句:你这个0.89稳吗?明天换一批数据会不会变成0.8?
我当时愣了一下。单看这个数字,我确实无法回答。回来之后我换了几个随机种子重新评估,发现AUC确实在0.86到0.91之间波动。那一刻我才意识到,没有置信区间的ROC曲线,就像只报成绩不报误差的实验,说服力差了一大截。
这其实是点估计(point estimate)的天然问题。ROC曲线是在一批有限样本上画出来的,AUC是对这批样本的压缩概括。样本换了,曲线和AUC都会变。既然会变,就应该把“会变多少”也告诉别人,否则别人只能默认这个数字是精确测量出来的,而事实根本不是。
1.2 审稿人、质检员和老板都在盯着这个数字
后来我发现,需要置信区间的不只是业务评审。
写论文投稿时,很多期刊审稿人默认要求给ROC曲线的置信区间,尤其是医学、生物统计方向的期刊。医疗AI模型评估里,灵敏度和特异度都要报告95%置信区间,甚至要求给出曲线的置信带(confidence band),而不是只给一个AUC的区间。
在公司内部,模型上线前评估、AB实验效果对比、算法竞品测试,也同样会面临“这个性能差距是真差距还是噪声”的问题。你画了两条ROC曲线,一条AUC高一点,如果没有置信区间或显著性检验,很难说服别人接受你的结论。
所以,“ROC曲线的95%置信区间”在很多场景下已经不是加分项,而是默认要求。
1.3 先弄清你要的是“AUC的区间”还是“曲线的置信带”
在动手写代码之前,建议先分清楚一个概念:标题里说的“绘制ROC曲线95%置信区间”,其实有至少两种理解。
第一种是给AUC这个数值算一个置信区间,比如“AUC = 0.89,95%置信区间为0.87~0.92”。这种结果通常以文字形式标在图例或注释里,图本身仍然是一条曲线。
第二种是给整条ROC曲线画一个置信带,也就是在曲线周围绘制一条浅色阴影区域,表示在横轴每一个FPR(假正率)取值下,对应的TPR(真正率)大约落在什么范围内。这是论文里常见的那类图。
这两种都可以叫“ROC曲线的95%置信区间”,但实现的思路不同。AUC的置信区间只关心一个标量;曲线的置信带关心的是整条曲线的波动。本文两种都会讲,并且核心篇幅放在第二种——因为从画图角度来说,置信带才是真正把“不确定性”视觉化出来的那一步。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种置信区间计算方法,我帮你把坑先踩完
2.1 Bootstrap重采样:最通用也最容易理解
Bootstrap(自助法)是我的首选方法,也是所有方法里最好向业务方解释的。
它的思路很简单。假设你的测试集有N个样本,每次有放回地从这N个样本里再抽N个,形成一个“新测试集”。因为是有放回抽样,每个新测试集里会有些样本重复出现,也有些样本完全没出现。对这个新测试集计算一次AUC,记录下来。重复这个过程1000次,就得到1000个AUC值。
这1000个AUC值就是AUC这个统计量在“数据集变异”下的近似分布。把它们的2.5%分位数和97.5%分位数取出来,就是95%置信区间。
生活里类比一下:你有一箱苹果,想知道平均重量稳不稳。每次从箱里抓一把,称完记下来,放回去再抓。重复很多次之后,你看到这些平均值的分布范围,就知道“这批苹果的平均重量大概在什么区间”。
Bootstrap最大的好处是模型无关。它不假设AUC服从什么分布,几乎可以对任何指标(AUC、灵敏度、特异度、精确率)直接用同一套逻辑。缺点是计算量大,但以现代电脑的性能,1000次AUC计算通常也就几秒到几十秒。
2.2 DeLong法:学术圈更认的非参数方案
DeLong法在1988年提出,专门解决两个问题:一是估计AUC的方差,二是比较两条ROC曲线的AUC之差是否显著。它基于非参数的U统计量,不需要重采样,计算速度很快,所以在论文里使用非常普遍。
它的数学表达比bootstrap要绕。大致思路是,根据正负两个类别的预测得分分布,构造一个方差估计,最终算出AUC的标准误。拿到标准误之后,用AUC ± 1.96 × SE就能得到近似的95%置信区间。
如果你只是想快速得到一个学术圈认可的结果,可以直接用第三方实现,比如fastdelong库,或者R语言里的pROC包。我平时在Python项目里,如果论文需要,会用bootstrap和DeLong互相印证,两者结果接近才下结论。
2.3 正态近似法:想快速给个数时用
还有一种更快的近似:假设AUC近似服从正态分布,再用Hanley-McNeil公式估算标准误。
Hanley-McNeil公式需要知道正样本数n1、负样本数n0和AUC本身,公式如下:
SE(AUC) = sqrt( (AUC(1-AUC) + (n1-1)(Q1-AUC²) + (n0-1)(Q2-AUC²)) / (n1*n0) )
其中:
Q1 = AUC / (2-AUC)
Q2 = 2*AUC² / (1+AUC)
这个公式写起来不复杂,十几行代码就能实现。但要注意,当正负样本极不均衡,或者模型的AUC接近1时,正态近似的误差会变大。因为此时AUC的分布会偏态,直接用正态分布去套并不可靠。
我的使用习惯是:快速汇报时用正态近似给一个参考区间;正式评估以bootstrap为主;投稿论文用DeLong或配对bootstrap。
2.4 怎么选,一张表说清楚
| 方法 | 原理 | 适用场景 | 计算成本 | 注意点 |
|---|---|---|---|---|
| Bootstrap | 有放回重采样,构造统计量分布 | 通用、可扩展到任意指标 | 中(1000次AUC计算) | 样本少时可能出现极端重采样样本,需要过滤单类别 |
| DeLong | 非参数U统计量估计方差 | 学术论文、对比两个AUC差异 | 低(解析计算) | 实现略绕,第三方库居多 |
| 正态近似 | 假设AUC近似正态,用Hanley-McNeil标准误 | 快速估算 | 极低 | 极端均衡比和高AUC场景下偏差明显 |
3. Bootstrap计算AUC置信区间,逐行代码拆解
3.1 准备一份带正负样本的评估数据
为了把流程讲透,我先生成一份模拟数据。这里用 sklearn 的 make_classification 造一份二分类数据,再用随机森林训练模型。
python复制import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_curve, auc
# 构造二分类数据集
X, y = make_classification(
n_samples=1000,
n_features=15,
n_informative=8,
n_redundant=5,
n_clusters_per_class=1,
random_state=42
)
# 划分训练集和测试集
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.3, random_state=42, stratify=y
)
# 训练随机森林模型
model = RandomForestClassifier(
n_estimators=200,
max_depth=8,
min_samples_leaf=5,
random_state=42
)
model.fit(X_train, y_train)
# 预测正类概率
y_proba = model.predict_proba(X_test)[:, 1]
这里有几个细节值得说明。一是 train_test_split 里我加了 stratify=y,保证训练集和测试集的正负比例一致,否则碰巧切出一份负样本极多的测试集,AUC会虚高或虚低。二是随机森林的 max_depth 和 min_samples_leaf 做了简单约束,避免模型在测试集上AUC接近1,那样置信区间会窄到几乎看不见,反而不利于演示整个流程。
3.2 核心循环:把AUC变成分布
接下来是bootstrap的主体。每次从测试集里有放回抽N个样本,算一次AUC。
python复制n_bootstraps = 1000
rng = np.random.RandomState(42)
boot_aucs = []
for i in range(n_bootstraps):
# 有放回抽样,索引构成新的样本集
idx = rng.choice(len(y_test), size=len(y_test), replace=True)
# 如果重采样后的样本里只有一种类别,跳过
if len(np.unique(y_test[idx])) < 2:
continue
fpr_i, tpr_i, _ = roc_curve(y_test[idx], y_proba[idx])
boot_aucs.append(auc(fpr_i, tpr_i))
boot_aucs = np.array(boot_aucs)
# 2.5%和97.5%分位数就是95%置信区间
lower_auc = np.percentile(boot_aucs, 2.5)
upper_auc = np.percentile(boot_aucs, 97.5)
print(f"AUC = {auc(*roc_curve(y_test, y_proba)[:2]):.3f}")
print(f"95% CI = {lower_auc:.3f} ~ {upper_auc:.3f}")
正常跑下来,输出大概是这样:
bash复制AUC = 0.893
95% CI = 0.865 ~ 0.919
注意一个关键点:我固定了随机种子 rng = np.random.RandomState(42)。固定种子不是为了“让结果好看”,而是为了让实验可复现。你给别人一份代码,如果每次运行置信区间都不一样,会给后续排查和审查带来很大麻烦。
3.3 容易翻车的细节:重采样遇上单类别样本
上面代码里有一行特别重要,也常被忽略:
python复制if len(np.unique(y_test[idx])) < 2:
continue
bootstrap的有放回抽样,理论上可能出现一种极端情况:抽样结果里全是正样本或者全是负样本。此时 roc_curve 会因为缺少某一类而报错,或者算出无意义的曲线。
如果你遇到这个报错,不用慌张,这是bootstrap的正常现象,尤其当数据集比较小、类别不均衡时更容易出现。处理方式就是跳过这一次抽样。在1000次抽样里,跳过几次完全不影响结果。我也见过有人强行给这种样本塞一个伪样本,反而会污染AUC分布,不建议这样干。
3.4 输出结果:区间和标准差一起报
除了分位数,我还习惯把bootstrap得到的AUC分布均值、标准差一起报出来。标准差能直接说明这个AUC的波动量级,业务方也比较容易理解。
python复制print(f"Mean AUC = {boot_aucs.mean():.3f}")
print(f"Std = {boot_aucs.std():.3f}")
到这里,AUC本身的95%置信区间已经算出来了。但如果只想在图上加一行文字“AUC = 0.89,95% CI = 0.87~0.92”,那还不够“绘制置信区间”。真正的图版本,是画一条带阴影置信带的ROC曲线,下面我专门讲这部分的实现。
4. matplotlib绘制带置信带的ROC曲线
4.1 绘图思路:先有曲线族,才有置信带
曲线置信带和AUC置信区间最大的区别在于:AUC是一个标量,可以简单取分位数;但整条ROC曲线是一堆点连成的折线,不能直接“对AUC取分位数”来画。
正确思路是:把每次bootstrap得到的ROC曲线都画到一个统一坐标系里,然后在横轴(FPR)上取一组固定网格点,对每条曲线在这些网格点上做插值,得到每条曲线对应的TPR值。这样,在每个网格点上就有一堆TPR值,对它们取2.5%和97.5%分位数,就得到该点的置信上下界。把所有网格点的上下界连起来,就是置信带。
换句话说,先用bootstrap生成一个“曲线族”,再从曲线族中找出每个FPR点上的TPR波动范围。这个“先有曲线族,后有置信带”的思路,是整篇文章的核心,理解了它,代码只是细节。
4.2 固定FPR网格上收集TPR分布
实现时,我通常把上一章的bootstrap循环稍微改造一下,在每次重采样时同时保存插值后的TPR序列,而不是只保存AUC。
python复制fpr_grid = np.linspace(0, 1, 100)
tpr_matrix = np.zeros((n_bootstraps, fpr_grid.size))
valid_count = 0
for i in range(n_bootstraps):
idx = rng.choice(len(y_test), size=len(y_test), replace=True)
if len(np.unique(y_test[idx])) < 2:
continue
fpr_i, tpr_i, _ = roc_curve(y_test[idx], y_proba[idx])
# 在统一FPR网格上做线性插值
tpr_interp = np.interp(fpr_grid, fpr_i, tpr_i)
tpr_interp[0] = 0.0 # 保证曲线从(0,0)出发
tpr_matrix[valid_count, :] = tpr_interp
boot_aucs.append(auc(fpr_i, tpr_i))
valid_count += 1
tpr_matrix = tpr_matrix[:valid_count, :]
这里 np.interp 的作用需要解释一下。每次bootstrap得到的ROC曲线,点数不一定相同,FPR坐标也不落在我们想要的0、0.01、0.02……这些网格点上。np.interp 就是给定一个目标FPR点,在已知的 (fpr_i, tpr_i) 序列之间做线性插值,算出这个FPR点对应的TPR估计值。只有所有曲线都对齐到同一个FPR网格,才谈得上逐点取分位数。
代码最后一行把0位置强制设为0,是因为插值后首点可能不是0,而ROC曲线理论上必须从(0,0)出发,这里做一个小修正。
4.3 fill_between:把区间变成视觉元素
拿到 tpr_matrix 之后,先计算中位数和上下分位数:
python复制tpr_median = np.median(tpr_matrix, axis=0)
tpr_lower = np.percentile(tpr_matrix, 2.5, axis=0)
tpr_upper = np.percentile(tpr_matrix, 97.5, axis=0)
再用 matplotlib 的 fill_between 绘制置信带。
python复制fig, ax = plt.subplots(figsize=(7, 6))
# 主ROC曲线
ax.plot(
fpr_grid, tpr_median,
color="#1f77b4", lw=2.5,
label=f"ROC (AUC = {auc_val:.3f}, 95% CI {lower_auc:.3f}-{upper_auc:.3f})"
)
# 95%置信带
ax.fill_between(
fpr_grid,
tpr_lower,
tpr_upper,
color="#1f77b4",
alpha=0.2,
label="95% confidence band"
)
# 对角线(随机分类器基准)
ax.plot([0, 1], [0, 1], color="grey", ls="--", lw=1, label="Chance level")
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title("ROC Curve with 95% Confidence Band")
ax.set_xlim(0, 1)
ax.set_ylim(0, 1.02)
ax.legend(loc="lower right")
plt.tight_layout()
plt.show()
这里我特别建议用 fig, ax = plt.subplots() 的方式,而不是直接 plt.plot。因为后续你可能要在同一张图上叠加其他模型、修改坐标轴范围、添加文本注释,用 ax 接口会方便很多。这也是很多新手困惑的地方——figure是画布,axes是这个画布上的一个坐标系,axis则是坐标系的x轴或y轴本身。plt.plot 其实操作的是“当前axes”,但代码一长,当前axes是谁就不直观了,所以从一开始就显式写清楚。
关于 fill_between 的参数,再多说两句:
- x:FPR网格点。
- y1、y2:置信下界和上界。
- alpha:透明度,0.2左右是比较舒服的值。太小看不清,太大又盖住主曲线和其他信息。
- linewidth=0:默认填充边缘会带一条线,如果不想要这条边缘线,可以显式设置 linewidth=0。
4.4 样式细节:置信带如何不抢主曲线风头
很多人在这一步画出来的图会有个通病:置信带颜色太深或者边界线太明显,导致主曲线反而看不清楚。我的经验是,主曲线用饱和色,置信带用同色系加alpha,视觉上让主曲线自然成为焦点。
如果你觉得分位数曲线波动比较大,可以先将 tpr_median 和上下界做一次轻微平滑。但要注意,平滑只是为了视觉美观,不能改变曲线总体趋势。我自己在论文图表里一般不做平滑,开会用的图偶尔会做,纯视觉效果。
还有一个小技巧:将AUC值和置信区间写进图例,这样图表本身就承载了“点估计和区间估计”两层信息,别人看图时不需要再看正文。
5. 多模型同框对比的画法
5.1 每个模型各画一条置信带
在实际项目里,很少只评估一个模型。常见场景是拿两个候选模型对比,或者用不同的特征组合测试效果。
做法不复杂:把上面的bootstrap计算和置信带计算写成一个函数,对每个模型调用一次,再循环绘制到同一张图上。
python复制def bootstrap_roc_ci(y_true, y_proba, n_bootstraps=1000, seed=42):
rng = np.random.RandomState(seed)
fpr_grid = np.linspace(0, 1, 100)
boot_aucs = []
tpr_matrix = []
for i in range(n_bootstraps):
idx = rng.choice(len(y_true), size=len(y_true), replace=True)
if len(np.unique(y_true[idx])) < 2:
continue
fpr_i, tpr_i, _ = roc_curve(y_true[idx], y_proba[idx])
boot_aucs.append(auc(fpr_i, tpr_i))
tpr_interp = np.interp(fpr_grid, fpr_i, tpr_i)
tpr_interp[0] = 0.0
tpr_matrix.append(tpr_interp)
tpr_matrix = np.array(tpr_matrix)
auc_val = auc(*roc_curve(y_true, y_proba)[:2])
return {
"auc": auc_val,
"auc_lower": np.percentile(boot_aucs, 2.5),
"auc_upper": np.percentile(boot_aucs, 97.5),
"fpr_grid": fpr_grid,
"tpr_median": np.median(tpr_matrix, axis=0),
"tpr_lower": np.percentile(tpr_matrix, 2.5, axis=0),
"tpr_upper": np.percentile(tpr_matrix, 97.5, axis=0),
}
之后对每个模型调用一次,拿到字典结果,再统一画图。
5.2 配色、透明度、图例的搭配经验
多模型对比时,颜色选择很重要。我一般用 matplotlib 的 Tab10 色板,前三种颜色是蓝、橙、绿,色差明显且对色盲友好。
python复制colors = ["#1f77b4", "#ff7f0e", "#2ca02c"]
fig, ax = plt.subplots(figsize=(7, 6))
for model_name, ci_result in zip(model_names, ci_results):
ax.plot(
ci_result["fpr_grid"], ci_result["tpr_median"],
color=colors[model_idx], lw=2.2,
label=f"{model_name} (AUC {ci_result['auc']:.3f}, "
f"95% CI {ci_result['auc_lower']:.3f}-{ci_result['auc_upper']:.3f})"
)
ax.fill_between(
ci_result["fpr_grid"],
ci_result["tpr_lower"],
ci_result["tpr_upper"],
color=colors[model_idx], alpha=0.15
)
ax.plot([0, 1], [0, 1], color="grey", ls="--", lw=1)
ax.set_xlim(0, 1)
ax.set_ylim(0, 1.02)
ax.set_xlabel("False Positive Rate")
ax.set_ylabel("True Positive Rate")
ax.set_title("ROC Curves with 95% Confidence Bands")
ax.legend(loc="lower right")
plt.show()
透明度我在多模型对比时通常会降到0.15左右,因为多个填充带叠在一起,如果alpha都设成0.2或更高,重叠区域颜色会很深,反而看不清每条曲线的走向。
5.3 置信带重叠说明什么:我的判断标准
拿到这种图,下一个问题往往是:两个模型的AUC看起来一个0.89一个0.87,这0.02的差距是真的吗?
直观判断是看置信带。如果两个模型的置信带大面积重叠,那基本不能下“模型A优于模型B”的结论。更严格的做法是做配对bootstrap:对同一个测试集样本,同时记录两个模型的预测概率,每次抽样都用同一批下标,分别计算两个模型的AUC,然后相减。重复1000次后,得到AUC差值的置信区间。如果这个区间不包含0,才能说差异显著。
配对的含义是:同一个样本子集里比较两个模型,排除样本选择带来的干扰。这比独立bootstrap更敏感,也更符合对比实验的逻辑。
6. 我踩过的三个坑,希望你一步跨过
6.1 置信带出现锯齿甚至上下交叉
我第一次画置信带时,因为bootstrap次数只设了100,网格点又取了500个,画出来一条锯齿非常明显的带子,个别地方下界还超过了中位数。
原因是网格点太细,而bootstrap样本太少,导致每个网格点上的分位数估计很不稳定。解决方法是把bootstrap次数提升到1000以上,网格点控制在100到150之间。另外要注意,分位数是按网格点独立计算的,所以上下界曲线本身不保证平滑,出现细微毛刺是正常现象,不用过度处理。
如果实在不能提高bootstrap次数(比如数据量大到每次AUC计算很慢),可以对tpr_lower和tpr_upper做一次高斯平滑,我常用 scipy.ndimage.gaussian_filter1d,sigma在1到2之间,只影响视觉,不影响主曲线。
6.2 Bootstrap次数太少导致区间飘忽不定
有一次我为了省时间把n_bootstraps设成100,连续跑了几次,95%置信区间的上下界每次都差三四个百分点,根本没法用。
后来我测了一下不同bootstrap次数下的稳定性:
| Bootstrap次数 | 95% CI(每次运行结果) | 稳定性 |
|---|---|---|
| 100 | 0.84~0.92 或 0.87~0.94 | 差 |
| 500 | 0.86~0.92 | 一般 |
| 1000 | 0.865~0.919 | 较好 |
| 2000 | 0.864~0.918 | 稳定 |
我的建议是:日常分析至少1000次,论文或重要业务决策至少2000次。1000次和2000次的计算时间差别不算大,但结论的稳定性提升明显。
6.3 在训练集上重采样,结果乐观得离谱
这是最隐蔽的一个坑。有段时间我把bootstrap用在了训练集的预测概率上,结果置信区间窄得惊人,AUC高达0.95以上,看起来模型好得不行。
原因很简单:模型已经“见过”训练集了,在训练集上的预测是有偏的、过度乐观的。在训练集上做重采样,等于在已经过拟合的评估结果上又加了不确定性,得到的置信区间自然窄且高。
正确做法是:bootstrap重采样必须在独立的测试集或验证集上进行。如果用的是交叉验证,也必须在每一折的验证集上分别做bootstrap,而不是把整个训练集拿去重采样。这一点,直接决定了你的置信区间有没有意义。
最后分享我目前的工作流
我在日常项目里已经把这套逻辑固定成习惯:先确认评估集是独立数据,再用bootstrap(次数2000、固定随机种子42)计算AUC的95%置信区间,同时收集插值后的TPR矩阵,最后画图时直接填充置信带。快速汇报场景下,用Hanley-McNeil正态近似先给一个参考值;投稿场景下,再加上DeLong法或配对bootstrap的AUC差值区间。如果是PyCharm环境,先跑一遍 pip install matplotlib scikit-learn scipy,后面的代码基本不会缺依赖。
这套方法不只对ROC曲线有效。把bootstrap循环里的 roc_curve 换成别的评估指标,比如精确率、召回率、校准误差,置信区间和下标的画法都通用。先把“报告不确定性”这个习惯养成,后面做任何模型评估,都会比只报一个数字扎实得多。
