DATA NOTEBOOK阅读地图
8个主要章节
1段代码示例
5组图片与图表

先确认变量、单位与假设,再核对手算、图表和解释范围。

章节速览8 节 · 展开选择

一个模型总说“有 50% 的概率”,另一个会区分 25%、50% 和 75%。前者在正例恰好占一半的样本上可以显得十分校准,却没有区分样本的能力;后者的概率与各组观察频率有偏差,总体概率损失仍可能更低。

因此,“概率说得准不准”和“能否把不同风险分开”需要分别检查。本篇用十二条合成记录,把可靠性图、Brier 分数与三项分解接到同一份手算上。

先固定二分类与分数的口径

每条记录有预测正例概率 p_i∈[0,1] 和真实结果 y_i∈{0,1}。本文使用常见的二分类单边写法:

BS = (1/N) Σ_i (p_i−y_i)²,越小越好,范围为 [0,1]。

Brier 1950 年论文是概率评分的原始文献。注意尺度:scikit-learn 的 Brier 文档说明,对所有类别求和的向量形式范围为 [0,2],二分类通常除以二,得到本文的 [0,1] 写法。比较不同工具输出前,先核对这个因子。

为什么平方概率损失鼓励诚实报告?若某种已知条件下真实事件概率为 q,报告 p 的期望损失满足:

E[(p−Y)²] = q(1−p)²+(1−q)p² = **(p−q)²+q(1−q)**。

后项不受报告值 p 影响,所以期望损失在 p=q 处唯一最小。这是严格适当评分规则的一个直接证明;它没有保证一份很小的测试集一定能选出真实更好的模型。

十二条记录:先画经验可靠性图

下面是专门用于教学的十二条合成记录,不是任何真实模型的测量结果。预测模型 A 只输出三个概率值,每个值对应四条记录。正例结果明确列出,避免只展示聚合图而无法复算。

预测 p_k结果 y样本 n_k经验正例率 q_k组内平均平方损失
1/40,0,0,0401/16
1/20,0,1,141/21/4
3/41,1,1,1411/16

总体正例率 q̄=6/12=1/2。三个组等大,因此 BS=(1/16+1/4+1/16)/3=1/8=0.125。

经验可靠性图横轴放每组平均预测概率,纵轴放该组经验正例率;这与 scikit-learn 校准曲线文档的坐标定义一致。本例按完全相同的预测值分组,三个点是 (0.25,0)、(0.5,0.5)、(0.75,1)。

十二条合成记录按预测值分三组,每组四条,经验可靠性点为0.25对应0、0.5对应0.5、0.75对应1;恒报0.5的基线只有中心一点,经验校准并不代表能区分风险。
原创图,坐标来自上表十二条合成记录的实际算术;每个模型 A 的点仅含四条记录,未估计总体条件概率,也未绘制置信区间。对角线是参考,不是拟合结果。查看原图

点在对角线下方,表示该组平均预测概率高于这次样本的正例比例;上方则相反。经验点的偏离不是总体失准的精确程度:样本少时比例本身会波动,分组规则也会改变图形。

总体概率校准通常写成 E[Y|P]=P。它是对预测分数给定后的条件关系,不是要求每一条 p=0.25 的记录都产生“0.25 个正例”,也不是说图上一点恰好落在对角线就证明了总体校准。

三项分解:哪部分是偏离,哪部分是区分

Murphy 1973 年原始论文提出可靠性、分辨率与不确定性三项。下面给出二分类、按精确相同预测值分组的有限样本恒等式,证明也在下一节展开。

令组 k 有 n_k 条记录,预测值恒为 p_k,经验正例率为 q_k,权重 w_k=n_k/N,总体经验正例率为 q̄。定义:

REL = Σ_k w_k(p_k−q_k)²:可靠性误差,越小表示这些经验组的预测与结果比例越接近。

RES = Σ_k w_k(q_k−q̄)²:分辨率,衡量各组结果比例相对总体比例的分离。

**UNC = q̄(1−q̄)**:本批结果的总体不确定性。

BS = REL − RES + UNC。

本例中 REL=(1/16+0+1/16)/3=1/24,RES=(1/4+0+1/4)/3=1/6,UNC=1/4。于是 1/24−1/6+1/4=1/8,和逐条平方损失一致。

同一份十二条合成数据中,不确定性UNC为四分之一,可靠性误差REL为二十四分之一,分辨率RES为六分之一,总分BS为八分之一;条形长度按数值统一缩放,RES在公式中被减去。
原创数值图:所有条长使用同一尺度,取自正文十二条合成记录的分解;RES 本身非负,在总分中带减号。图不是模型实测或校准效果的外部验证。查看原图

现在比较基线 B:对同一批十二条记录全报 0.5。它只有一组,经验 REL=0、RES=0、UNC=1/4,因此 BS=1/4。A 的经验 REL 较大,总 BS 却较低,因为分辨率项补偿得更多。

这说明更低的 Brier 分数不自动等于更好校准;同样,恒报总体比例看似校准,也不等于有高辨别力。这里的 RES 是这份分组样本上的分辨率项,不把它直接当成 AUC 或其他排序指标。

自检:为什么不能仅凭模型 B 的 REL=0,就称它比 A 更好?

B 在这份样本上恒报总体正例率,组内比例与报告相同,但完全没有把样本分成不同预测组。A 的经验可靠性误差更大,分辨率也更大,最终 Brier 损失反而更小;实际比较还要考虑独立测试数据和抽样不确定性。

恒等式证明:把组内平方展开

对同一组 k,写 p_k−y_i=(p_k−q_k)+(q_k−y_i)。因为组内平均 y_i 恰为 q_k,交叉项的组内均值为零:

mean_k[(p_k−y_i)²] = (p_k−q_k)² + mean_k[(y_i−q_k)²]
= (p_k−q_k)² + q_k(1−q_k)。

第二步使用 y_i²=y_i。再按 w_k 加权:

Σ_k w_k q_k(1−q_k)
= q̄ − Σ_k w_k q_k²
= q̄(1−q̄) − Σ_k w_k(q_k−q̄)²。

把这部分代回,就得到 REL−RES+UNC。这里是对给定有限数据的代数等式,不要求记录独立同分布;但若要把经验项解释为某个总体的稳定估计,或构造置信区间,就需要另外说明抽样与依赖结构。

普通分箱:三项不一定还原原始 BS

连续分数通常需要分箱画图,但同一箱里未必有相同的 p_i。若把箱内均值记为 p̄_k、q_k,并据此计算 REL_bin、RES_bin,直接套三项公式可能出错。

令每箱的方差与协方差都用 1/n_k 作分母,而不是无偏估计的 1/(n_k−1)。围绕箱均值展开可得:

BS = REL_bin − RES_bin + UNC + C_bin,

**C_bin = Σ_k w_k[Var_k(p) − 2Cov_k(p,y)]**。

这是同一个平方展开多保留的两项,不需要假定箱内预测彼此相同。若箱内 p 全相同,两项均为零,回到上一节的情形。若先把每条预测替换成箱均值,再计算新预测的 BS,三项式会对这个已改变的预测成立;不能因此说它精确分解了原预测。

一个可复算的反例:p=[0.1,0.4]、y=[0,1],全放一箱。原始 BS=(0.01+0.36)/2=0.185。箱均值为 0.25、正例率 0.5,三项为 0.0625−0+0.25=0.3125,并不相等。箱内 Var(p)=0.0225、Cov(p,y)=0.075,因此 C_bin=−0.1275,补上后正好还原 0.185。

精确逐值分组也有统计限制:若每条概率都唯一,每组仅一条记录,q_k 就是 0 或 1,经验 RES=UNC、REL=BS。等式仍成立,却不能把这种单例比例当作精确的总体条件概率。因此,展示分箱图时应同时给出箱边界、样本数、空箱处理,并说明这些是经验量。scikit-learn 的曲线接口也会省略空箱。

ECE 与验证集:别把汇总量当作校准保证

常见的分箱 ECE 写成 Σ_k w_k|p̄_k−q_k|。它可以辅助概括所选分箱的经验偏离,但依赖分箱,不能当作适当概率评分规则代替 Brier 或对数损失。

看只有一条记录时的反例:此时经验 ECE=|p−y|。若真实概率 q=0.8,它的期望为 q(1−p)+(1−q)p;报告 p=0.8 时是 0.32,改报 p=1 却降为 0.2。这个经验指标不会像平方损失一样,普遍鼓励诚实报告真实条件概率。增加箱数或让某批 ECE 恰好等于零,也没有消除有限样本和分组问题。

若下一步要学习校准映射,应把基础模型训练、校准拟合和最终评估的数据职责分开。scikit-learn 官方校准指南强调,校准器不应简单在基础模型拟合过的训练预测上学习;这里更不能用测试标签调完映射,再把同一测试集的改善当成外部效果。

比较两个模型时,可以在同一测试记录上定义平方损失之差,再接配对 Bootstrap。相关记录按独立单位成组处理;有多个预定比较时,再核对Holm 多重检验的家族定义。本文不拟合任何真实分类器或校准器,也不声称验证了区间覆盖率。

标准库实现:分组口径与修正项一起返回

下面实现直接核算逐条 BS 与经验分组量。edges=None 按浮点数的精确相等分组,不自动四舍五入;给出边界时使用本文自定的左闭右开区间,内边界归右箱,最后一箱包含 1,省略空箱。不宣称这套端点约定与其他库完全相同。实现拒绝布尔、非数值、非有限值、越界概率和非二元标签;仅覆盖等权二分类,不处理样本权重、多分类或缺失标签。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import bisect
import json
import math
from numbers import Real


def numeric_values(values, label):
if isinstance(values, (str, bytes)):
raise ValueError(label + " must be a numeric iterable")
try:
items = list(values)
except TypeError as exc:
raise ValueError(label + " must be iterable") from exc
result = []
for value in items:
if isinstance(value, bool) or not isinstance(value, Real):
raise ValueError(label + " contains a non-real value")
try:
number = float(value)
except (OverflowError, ValueError) as exc:
raise ValueError(label + " contains an unrepresentable value") from exc
if not math.isfinite(number):
raise ValueError(label + " contains a non-finite value")
result.append(number)
return result


def brier_report(probabilities, outcomes, edges=None):
p = numeric_values(probabilities, "probabilities")
y = numeric_values(outcomes, "outcomes")
if not p or len(p) != len(y):
raise ValueError("nonempty paired inputs of equal length required")
if any(v < 0 or v > 1 for v in p):
raise ValueError("probabilities must lie in [0, 1]")
if any(v not in (0, 1) for v in y):
raise ValueError("outcomes must be numeric 0 or 1")
boundaries = None
if edges is not None:
boundaries = numeric_values(edges, "edges")
if (len(boundaries) < 2 or boundaries[0] != 0
or boundaries[-1] != 1
or any(a >= b for a, b in zip(boundaries, boundaries[1:]))):
raise ValueError("edges must increase strictly from 0 to 1")

members = {}
for i, value in enumerate(p):
key = value if boundaries is None else min(
bisect.bisect_right(boundaries, value) - 1,
len(boundaries) - 2)
members.setdefault(key, []).append(i)

n = len(p)
mean_y = math.fsum(y) / n
groups = []
rel, res, correction, ece = [], [], [], []
for key in sorted(members):
indices = members[key]
size = len(indices)
mean_p = math.fsum(p[i] for i in indices) / size
observed = math.fsum(y[i] for i in indices) / size
variance = math.fsum((p[i] - mean_p) ** 2 for i in indices) / size
covariance = math.fsum((p[i] - mean_p) * (y[i] - observed)
for i in indices) / size
weight = size / n
rel.append(weight * (mean_p - observed) ** 2)
res.append(weight * (observed - mean_y) ** 2)
correction.append(weight * (variance - 2 * covariance))
ece.append(weight * abs(mean_p - observed))
groups.append({"key": key, "count": size, "mean_p": mean_p,
"observed": observed, "variance": variance,
"covariance": covariance})
reliability = math.fsum(rel)
resolution = math.fsum(res)
uncertainty = mean_y * (1 - mean_y)
within_bin = math.fsum(correction)
return {
"brier": math.fsum((a - b) ** 2 for a, b in zip(p, y)) / n,
"reliability": reliability, "resolution": resolution,
"uncertainty": uncertainty, "correction": within_bin,
"reconstructed": math.fsum([reliability, -resolution,
uncertainty, within_bin]),
"ece": math.fsum(ece), "groups": groups,
}


if __name__ == "__main__":
p = [0.25] * 4 + [0.5] * 4 + [0.75] * 4
y = [0] * 4 + [0, 0, 1, 1] + [1] * 4
report = brier_report(p, y)
print(json.dumps({k: report[k] for k in (
"brier", "reliability", "resolution", "uncertainty", "correction"
)}, indent=2))

输出五项依次约为 0.125、0.0416667、0.1666667、0.25、0。用反例调用 brier_report([0.1,0.4], [0,1], [0,1]),会保留 −0.1275 的箱内修正,而不会把三项近似冒充原始总分。

自检:把两条不同概率放进同一箱后,为什么不能只看三个分量相加?

箱内概率不再恒定,展开平方时会多出 Var(p)−2Cov(p,y) 的加权修正。只有明确这些项为零,或明确评估的是替换为箱均值后的新预测,才可以省略它;不能用省略后的结果冒充原预测的精确分解。

使用边界与资料

实现的核验覆盖逐值分组、混合概率分箱、边界端点、重复与重排、全零/全一结果和非法输入;独立有理数参考校对总分及每个分量,SVG 坐标与条长也从同一份合成数据核对。内部验证属于算术与实现检查,不是实际校准收益实验。

继续阅读配对 Bootstrap分析损失差的不确定性;回到PCA时,也可比较“重构误差很低”与“概率报告可靠”分别回答了什么问题。