test_learn_confidence_calibration.py
9.31 KB
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
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
#!/usr/bin/env python3
"""VERIFY: 分类器置信度/分数「经验校准曲线」+ 阈值单一事实来源
(pplx-decider-v1.1-27b 决策模型知识, learn_queue #bcf45b01, 2026-10-13 Step6 审计)
学到的知识(原文 L4/L6/L10):
校准比分数本身更值钱——「说 90% 把握时真实准确率也接近 90%」才敢设阈值、自动放行,
否则概率只是装饰品。分类器/分流这类判定应输出**可校准**的量并实测其校准误差。
病理 WHERE=learn_queue 分类路由阈值决策(app.py 分类回调 + local_db 校准统计)
WHY=① 只有 `confidence=high` 的**失败计数**(confidence_high_failed),没有**分档成功率**
曲线 ⇒ 「high 到底可不可信」不可测量,阈值 = 拍板常量而非校准结果。
② high_precision 分母只含 applied+rejected,**漏掉 failed**(最大失败档)
⇒ 校准 KPI 自身有盲区(假阴)。
③ 阈值字面量 `score >= 7` / `>= 4` 散落 5 处 ⇒ 校准结论就算有了也无处落。
④ 校准函数无样本量字段:decided=1 也回 100%,小样本假置信。
修复:
1) local_db.learn_calibration_stats() 增经验校准曲线 confidence_calibration(分档成功率)
与确定性 verdict(MISCALIBRATED / BELOW_TARGET / CALIBRATED / INSUFFICIENT_SAMPLE)
2) 分母含 failed;补 n_decided / reliable(小样本不出结论)
3) 阈值常量 LEARN_ROUTE_LEARN / LEARN_ROUTE_REVIEW 单一事实来源,替换散落字面量
TDD:
TC1 (RED/复现旧病理) 旧 stats 键集合无分档成功率、无 verdict、无样本量 → 不可校准
TC2 (GREEN/真实数据) 真实 DB:3 档成功率 + verdict 计算(确定性,来自 DB 事实)
TC3 (GREEN/判别力) 防伪:构造 high 优于 medium 的库 → 不得判 MISCALIBRATED
TC4 (GREEN/小样本守卫) 每档 n < CALIBRATION_MIN_SAMPLE → INSUFFICIENT_SAMPLE + reliable=False
TC5 (GREEN/分母含 failed) 只 failed 的高分样本不得被算成 100% 精度
TC6 (源码守卫/防回退) 阈值单一事实来源:app.py 无裸 `score >= 7` 路由字面量
TC7 (冻结重跑 spot check) 同库连跑 3 次结果一致(确定性纯函数,可复现)
"""
import importlib
import pathlib
import sqlite3
import sys
sys.path.insert(0, "/data/data/com.termux/files/home/wechat-viewer")
APP = pathlib.Path("/data/data/com.termux/files/home/wechat-viewer/app.py")
LOCAL_DB = pathlib.Path("/data/data/com.termux/files/home/wechat-viewer/local_db.py")
FAIL = 0
def check(name, ok, detail=""):
global FAIL
print(f"{'✅' if ok else '❌'} {name}" + (f" — {detail}" if detail else ""))
if not ok:
FAIL += 1
# ── TC1: RED —— 复现旧实现(缺分档成功率 / verdict / 样本量)──
OLD_KEYS = {"high_precision", "high_applied", "high_rejected", "high_pending",
"mid_applied", "mid_rejected", "confidence_high_failed",
"score_distribution", "note"}
missing = {"confidence_calibration", "confidence_verdict", "threshold_at"} - OLD_KEYS
check("TC1 RED 复现旧病理:旧 stats 缺校准曲线/verdict/阈值实测",
len(missing) == 3, f"缺 {sorted(missing)} ⇒ 置信度不可校准、阈值无依据")
# ── 载入实现 ──
import config
import local_db
importlib.reload(local_db)
stats = local_db.learn_calibration_stats()
# ── TC6 源码守卫:阈值单一事实来源 ──
app_src = APP.read_text()
check("TC6a 源码守卫:app.py 路由处无裸 `score >= 7` 字面量",
"score >= 7" not in app_src,
f"残留 {app_src.count('score >= 7')} 处")
check("TC6b 源码守卫:app.py 路由使用 local_db.LEARN_ROUTE_* 常量",
"LEARN_ROUTE_LEARN" in app_src and "LEARN_ROUTE_REVIEW" in app_src)
ldb_src = LOCAL_DB.read_text()
check("TC6c 源码守卫:local_db 定义阈值常量(单一事实来源)",
"LEARN_ROUTE_LEARN = " in ldb_src and "LEARN_ROUTE_REVIEW = " in ldb_src)
check("TC6d 源码守卫:小样本判据常量存在(禁止小样本出结论)",
"CALIBRATION_MIN_SAMPLE = " in ldb_src)
# ── TC2 GREEN:真实数据校准曲线 ──
curve = stats.get("confidence_calibration") or {}
check("TC2a 真实数据:三档 confidence 均有校准条目",
set(curve) >= {"high", "medium", "low"}, f"buckets={sorted(curve)}")
ok_shape = bool(curve) and all({"n_decided", "applied", "failed", "rejected", "success_rate"} <= set(v)
for v in curve.values())
check("TC2b 真实数据:每条含 n_decided/成功率(可算校准误差)", ok_shape)
hi, mid = curve.get("high", {}), curve.get("medium", {})
print(f" high: n={hi.get('n_decided')} rate={hi.get('success_rate')} "
f"(applied={hi.get('applied')} failed={hi.get('failed')} rejected={hi.get('rejected')})")
print(f" medium: n={mid.get('n_decided')} rate={mid.get('success_rate')}")
print(f" verdict={stats.get('confidence_verdict')} threshold_at={stats.get('threshold_at')}")
# 成功率必须是真实计算值(0..1),不是装饰
rates_ok = bool(curve) and all(v["success_rate"] is None or 0.0 <= v["success_rate"] <= 1.0
for v in curve.values())
check("TC2c 真实数据:success_rate ∈ [0,1] 且由 DB 计数导出", rates_ok)
check("TC2d 真实数据:verdict 为确定性命名单之一",
stats.get("confidence_verdict") in
{"CALIBRATED", "BELOW_TARGET", "MISCALIBRATED", "INSUFFICIENT_SAMPLE"},
str(stats.get("confidence_verdict")))
check("TC2e 真实数据:阈值处实测精度带样本量(非裸百分比)",
isinstance(stats.get("threshold_at"), dict)
and "n_decided" in stats["threshold_at"] and "reliable" in stats["threshold_at"],
str(stats.get("threshold_at")))
# 实测:high 档成功率 < medium 档 ⇒ 置信度反相关,verdict 必须点名
if hi.get("n_decided", 0) >= local_db.CALIBRATION_MIN_SAMPLE and mid.get("n_decided", 0) >= local_db.CALIBRATION_MIN_SAMPLE:
expected = "MISCALIBRATED" if (hi["success_rate"] or 0) < (mid["success_rate"] or 0) else stats["confidence_verdict"]
check("TC2f 真实数据:high<medium 时 verdict 点名 MISCALIBRATED",
stats["confidence_verdict"] == expected,
f"high={hi['success_rate']} medium={mid['success_rate']} → {stats['confidence_verdict']}")
# ── 造库助手(直接指向临时 DB)──
def make_db(path, rows):
p = pathlib.Path(path)
if p.exists():
p.unlink()
c = sqlite3.connect(str(p))
c.execute("""CREATE TABLE learn_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT, article_url TEXT, title TEXT,
dedup_key TEXT, learn_type TEXT, learn_target TEXT, learn_content TEXT,
outline TEXT, confidence TEXT DEFAULT 'medium', score INTEGER DEFAULT 0,
status TEXT DEFAULT 'pending_review', error_msg TEXT,
created_at TEXT DEFAULT (datetime('now','localtime')),
reviewed_at TEXT)""")
for i, (conf, score, status) in enumerate(rows):
c.execute("INSERT INTO learn_queue (dedup_key,learn_type,confidence,score,status)"
" VALUES (?,?,?,?,?)", (f"k{i}", "memory", conf, score, status))
c.commit()
c.close()
return str(p)
_real_db = config.LOCAL_DB
# ── TC3 GREEN:防伪 —— high 确实优于 medium 时不得误报 MISCALIBRATED ──
good = [("high", 9, "applied")] * 30 + [("high", 9, "rejected")] * 2 + \
[("medium", 5, "applied")] * 5 + [("medium", 5, "failed")] * 25
config.LOCAL_DB = make_db("/data/data/com.termux/files/home/tmp/tc3_calib.db", good)
importlib.reload(local_db)
s3 = local_db.learn_calibration_stats()
check("TC3 判别力:high(0.938)>medium(0.167) → CALIBRATED(不误报)",
s3["confidence_verdict"] == "CALIBRATED", str(s3["confidence_verdict"]))
# ── TC4 GREEN:小样本守卫 ──
tiny = [("high", 9, "applied"), ("medium", 5, "applied"), ("low", 2, "rejected")]
config.LOCAL_DB = make_db("/data/data/com.termux/files/home/tmp/tc4_calib.db", tiny)
importlib.reload(local_db)
s4 = local_db.learn_calibration_stats()
check("TC4a 小样本:n<MIN_SAMPLE → INSUFFICIENT_SAMPLE(不出结论)",
s4["confidence_verdict"] == "INSUFFICIENT_SAMPLE", str(s4["confidence_verdict"]))
check("TC4b 小样本:threshold_at.reliable=False,不把 1 样本算成 100%",
s4["threshold_at"]["reliable"] is False,
f"n={s4['threshold_at']['n_decided']} rate={s4['threshold_at']['success_rate']}")
# ── TC5 GREEN:分母含 failed(旧 high_precision 盲区)──
only_failed = [("high", 9, "failed")] * 25
config.LOCAL_DB = make_db("/data/data/com.termux/files/home/tmp/tc5_calib.db", only_failed)
importlib.reload(local_db)
s5 = local_db.learn_calibration_stats()
check("TC5 分母含 failed:全失败的高分档不得算成高精度",
s5["threshold_at"]["success_rate"] == 0.0 and s5["threshold_at"]["n_decided"] == 25,
f"rate={s5['threshold_at']['success_rate']} n={s5['threshold_at']['n_decided']} "
f"(旧口径 decided=0 → high_precision=None 假阴)")
# ── TC7 冻结重跑 spot check:确定性 3/3 ──
config.LOCAL_DB = _real_db
importlib.reload(local_db)
runs = [local_db.learn_calibration_stats() for _ in range(3)]
same = all(r == runs[0] for r in runs)
check("TC7 冻结重跑 3/3 一致(确定性,可复现)", same,
f"verdict={runs[0]['confidence_verdict']}")
print()
print(f"{'FAIL: '+str(FAIL) if FAIL else 'ALL PASS'} ({7} TC 组)")
sys.exit(1 if FAIL else 0)