本节摘要:第 5.2 节说 Jev 的概率经 RLCD 校准、"说 0.9 约等于九成命中"——本节教你亲手验证这句话。方法:把回归集里每个 Noul 判断的预测概率按 0.1 步长分桶,比较每桶的"预测均值"与"实际命中率",画成可靠性曲线;量化指标 ECE(期望校准误差)= 各桶 |预测均值 − 实际命中率| 的样本量加权平均,经验读法:<0.05 优秀、0.05~0.15 可接受、>0.15 过度自信/不足,概率不能直接当合同用。本节内嵌完整脚本(05_calibration_check.py)——带 MOCK 模式,无 Key 也能本地跑通算法;把 LABELED 换成你的回归集即可投入生产。
阅读完本节,你应当能够:
以"工单是否紧急"(Noul)为例,对回归集的每条样本:Jev 给出 p(紧急的概率),人工标注 y(0/1)。
桶 [0.8, 0.9):预测均值 0.84,实际命中 0.83 ✅ 校准良好(点贴近对角线) 桶 [0.8, 0.9):预测均值 0.84,实际命中 0.61 ❌ 过度自信(点在对角线下方) 桶 [0.5, 0.6):预测均值 0.55,实际命中 0.72 ❌ 信心不足(点在对角线上方)
把每桶的 (预测均值, 实际命中率) 画散点、叠一条对角线,就是可靠性曲线(reliability diagram)——健康的校准,点都贴着对角线。
ECE(Expected Calibration Error):
ECE = Σ (桶样本量 / 总量) × |桶预测均值 − 桶实际命中率|
| ECE | 定性 | 处置 |
|---|---|---|
| < 0.05 | 优秀 | 概率可直接当合同(阈值放心用) |
| 0.05 ~ 0.15 | 可接受 | 阈值保守化一档(0.9 的阈值当 0.85 用) |
| > 0.15 | 过度自信/不足 | 概率不可直接用:重校准或回影子改 schema |
以下脚本即本教程的 05_calibration_check.py,完整内嵌于此。无 Key 时用 MOCK 数据源(模拟一个"轻微过度自信"的模型,演示算法行为),设了 TYPESAFE_API_KEY 则真实调用:
# 05_calibration_check.py —— 验证校准(可靠性曲线 + ECE) import os import random import requests API_URL = "https://api.typesafe.ai/v1/systemone" N_BUCKET = 10 # [0.0,0.1), [0.1,0.2), ..., [0.9,1.0] # ---- 1) 你的回归集:影子模式日志 + 人工标注(这里以"工单是否紧急"为例) ---- LABELED = [ ("客户已付款但订单三天未发货,再不处理就去 12315 投诉", 1), ("请问你们支持开发票吗", 0), ("线上支付全挂了,每分钟都在损失订单,立刻处理!", 1), ("App 的夜间模式什么时候上线呀", 0), ("已经等了两周没人回复,你们到底有没有客服?", 1), ("这个按钮的颜色换成蓝色会更好看", 0), ("账号被盗了,有人正在下单,快点冻结!", 1), ("怎么修改收货地址", 0), ("再不给我退款我就发微博曝光", 1), ("建议增加导出 Excel 的功能", 0), ] QUESTION = { "is_urgent": { "type": "noul", "instructions": "该消息表达了紧迫性或时间敏感性", } } def predict_mock(text: str, label: int) -> float: """无 key 时的演示数据源:模拟一个'轻微过度自信'的模型。""" rng = random.Random(hash(text) & 0xFFFF) if label: return 0.65 + rng.random() * 0.30 # 0.65~0.95 return 0.10 + rng.random() * 0.50 # 0.10~0.60(部分越过 0.5) def predict_jev(text: str) -> float: resp = requests.post( API_URL, headers={"Authorization": f"Bearer {os.environ['TYPESAFE_API_KEY']}"}, json={"model": "jev-latest", "state": text, "questions": QUESTION}, timeout=10, ) resp.raise_for_status() return resp.json()["answers"]["is_urgent"]["noul"] def reliability_curve(pairs: list[tuple[float, int]]): """按预测概率分桶 -> 各桶(预测均值, 实际命中率, 样本数),并计算 ECE。""" buckets: list[list[tuple[float, int]]] = [[] for _ in range(N_BUCKET)] for p, y in pairs: buckets[min(N_BUCKET - 1, int(p * N_BUCKET))].append((p, y)) ece, rows = 0.0, [] for i, b in enumerate(buckets): if not b: continue n = len(b) mean_p = sum(p for p, _ in b) / n hit = sum(y for _, y in b) / n ece += n / len(pairs) * abs(mean_p - hit) rows.append((f"[{i / N_BUCKET:.1f},{(i + 1) / N_BUCKET:.1f})", mean_p, hit, n)) return rows, ece def main() -> None: has_key = bool(os.environ.get("TYPESAFE_API_KEY")) print(f"数据源:{'Jev 真实调用' if has_key else 'MOCK(设置 TYPESAFE_API_KEY 后用真实调用)'}\n") pairs = [] for text, label in LABELED: p = predict_jev(text) if has_key else predict_mock(text, label) pairs.append((p, label)) print(f" p={p:.3f} label={label} {text[:24]}") rows, ece = reliability_curve(pairs) print("\n可靠性曲线(预测概率 vs 实际命中率):") print(f" {'桶':<12}{'预测均值':>8}{'实际命中':>8}{'样本':>5}") for name, mean_p, hit, n in rows: bar = "█" * int(hit * 20) print(f" {name:<12}{mean_p:>8.2f}{hit:>8.2f}{n:>5} {bar}") verdict = "优秀" if ece < 0.05 else "可接受" if ece < 0.15 else "过度自信,阈值需上调" print(f"\nECE = {ece:.3f} ({verdict})") if __name__ == "__main__": main()
本地 MOCK 模式的一次输出(10 条样本,噪声大、仅供算法演示):
桶 预测均值 实际命中 样本 [0.1,0.2) 0.15 0.00 1 [0.2,0.3) 0.29 0.00 1 ... [0.6,0.7) 0.68 1.00 1 ████████████████████ ... ECE = 0.257 (过度自信,阈值需上调)
MOCK 数据刻意做成"低桶全空、高桶部分命中"的过度自信形态,让你在跑真数据前先认识这种病相。
| 病相 | 处方 |
|---|---|
| 过度自信(点在对角线下方) | 阈值整体上调一档;检查 instructions 是否引导了过强表态 |
| 信心不足(点在对角线上方) | 阈值下调;通常是问题措辞含糊——按第 3 章准则收紧判别标准 |
| 两端准、中间乱 | 正常(0.5 附近本来就是难例区)——用三段式阈值把中间带送人工(第 7.3 节) |
| 某一桶系统性偏 | 该区间的样本有共同特征(如短文本、外语)→ 补专项样本或加一道前置闸门 |
⚠️ 样本量纪律:每桶 <10 条时该桶读数只是噪声——总样本不足时先并桶(0.2 步长)再读;这也是回归集要 100 条起步的原因。
概率诚实不诚实量出来了,最后一节把"阈值"这个最常被拍脑袋的参数变成代价函数的解。