File size: 11,472 Bytes
216004d | 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 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 | #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""benchmark_a_prime.py — A′ 四象限可辨识性 + 检测指标(C4/C5)。
## 要回答的唯一问题
**A1 能不能把 S2(新但稳定)与 S3(熟悉但变了)区分开?**
若 A1(S2) ≈ A1(S3) ⇒ A1 仍然只是 novelty detector(证伪成立)
若 A1(S3) >> A1(S2) ⇒ A1 真的捕捉到了 dynamics(可辨识)
## 四象限怎么"构造"出来, 而不是事后切窗
每个 transition 发生的那一刻, 有两件**我们知道真值**的事:
novelty : 这一步的 `n_before(s,a)` —— 更新**前**的计数
dynamics : 这一步是在 `shift_at` 之前还是之后
于是四象限是**逐步标注**出来的 2x2:
| | 动力学未变 (t<shift) | 动力学已变 (t>=shift) |
| 熟悉 | S1 n>=FAM | S3 n>=FAM |
| 新 | S2 n< NOV | S4 n< NOV |
**S2 与 S3 是同一张表里的两个格子** —— 用 n 桶把 novelty 对齐之后,
"新"与"变"才可比。这是本文件的全部要点。
"""
import json
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # 仓库根 ——
# ★ 必须是**仓库根**(`parents[1]`), 不是 `hibs_lnn/` 本身。插错成
# `.../headless/hibs_lnn` 会让 `import hibs_lnn.rr_agent` 找不到包
# (`ModuleNotFoundError: No module named 'hibs_lnn'`), 因为那正是包**内部**。
# 脚本目录会自动进 sys.path, 所以 `from benchmark_three_state import ...`
# 仍然可用。
from hibs_lnn.rr_agent import RRSkillAgent # noqa: E402
from hibs_lnn.stochastic_keydoor import ( # noqa: E402
NonStationaryKeyDoor, StochasticKeyDoor)
from benchmark_three_state import FROZEN # noqa: E402
NOV, FAM = 3, 10 # 新: n_before < 3 熟悉: n_before >= 10
CHANNELS = ("N", "A1", "A2", "A3")
class _LogRRSkillAgent(RRSkillAgent):
"""逐步记录 (t, s, a, n_before, 各通道读数)。
`n_before` 必须是**更新前**的计数 —— 它就是那一步的 novelty 真值。
"""
def __init__(self, *a, **kw):
super().__init__(*a, **kw)
self.rows = []
def _rr_dval(self, s, a, s2): # noqa: D102
d = self._rr_T.get((int(s), int(a)))
n_before = sum(d.values()) if d else 0
v = super()._rr_dval(s, a, s2)
self.rows.append({
"t": int(getattr(self.mdp, "t_total", len(self.rows))),
"s": int(s), "a": int(a), "n": int(n_before),
"v": float(v),
})
return v
def log_channel(channel, env, episodes=140, seed=7):
"""在一个环境上跑一遍, 返回该通道的逐步日志。"""
ag = _LogRRSkillAgent(env, mode="rr", seed=seed, rr_v_main="learned",
**dict(FROZEN, rr_three_state=True, rr_dyn=channel))
for _ in range(episodes):
ag.run_episode()
return ag.rows
def quad_table(rows, shift_at):
"""把逐步日志切成四象限, 返回 {(cell, ch): mean} 的统计。"""
out = {}
for r in rows:
cell = ("S1" if r["n"] >= FAM else
"S2" if r["n"] < NOV else None)
if cell is None:
continue
if r["t"] >= shift_at:
cell = {"S1": "S3", "S2": "S4"}[cell]
out.setdefault(cell, []).append(r["v"])
return {c: (float(np.mean(v)), len(v)) for c, v in out.items()}
# ── 指标(A′-4)──────────────────────────────────────────────────────
def detection_metrics(rows, shift_at, thresh=None, win=100):
"""在 shift 附近算 T_detect / FP / FN / AUC / discrimination。
* 触发 = 读数超过 `thresh`。默认 `pre_mean + 2·pre_std`。
★ 阈值只能从"变之前"的数据里定 —— 用全段分位数定阈值等于偷看答案。
★ **不能用 pre 的 95 分位**:`_rr_dval` 在 `n<2` 时返回满值 1.0,
热身期到处都是这种步 ⇒ 95 分位 = 1.0000 ⇒ 没有任何步能超过它
⇒ `T_detect = None`、FN 恒 1(第一版的实测就是这个指纹:
四个通道的阈值全是 1.0000、T_detect 全是 None)。均值+2σ 不会被
饱和步锁死在 1.0。
* `T_detect − T_shift` = 检测延迟(核心指标)。
* AUC = 把"shift 后的步"与"shift 前的步"分开的可分性(Mann-Whitney)。
"""
pre = np.array([r["v"] for r in rows if r["t"] < shift_at], float)
post = np.array([r["v"] for r in rows if r["t"] >= shift_at], float)
if pre.size == 0 or post.size == 0:
return None
if thresh is None:
# ★ 阈值估计必须**排除饱和步**(n<2 时 `_rr_dval` 返回满值 1.0)。
# 否则 μ+2σ 会被那些 1.0 撑到 **超过指标上限**(实测 A1 的阈值
# 算成 1.0680 > 1.0) ⇒ 永远没有步能超过它 ⇒ T_detect=None、
# FN 恒 1。那不是"检测不到", 是**阈值不可达**。
# A 类读数恒在 [0,1], 所以阈值必须落在 [0,1] 内才有意义。
pre_ok = np.array([r["v"] for r in rows
if r["t"] < shift_at and r["n"] >= 2], float)
base = pre_ok if pre_ok.size >= 10 else pre
thr = float(base.mean() + 2.0 * base.std())
thr = float(min(max(thr, 1e-6), 1.0))
else:
thr = float(thresh)
# 检测延迟: shift 后第一个超过阈值、且随后 win 步里多数都超的时刻
t_det = None
for i, r in enumerate(rows):
if r["t"] < shift_at or r["v"] <= thr:
continue
nxt = [q["v"] for q in rows[i:i + win]]
if sum(v > thr for v in nxt) > 0.5 * len(nxt):
t_det = r["t"]
break
fp = float(np.mean(pre > thr)) if pre.size else float("nan")
fn = 1.0 if t_det is None else 0.0
# AUC: P(读数(shift后) > 读数(shift前)) —— Mann-Whitney 形式
if pre.size and post.size:
gt = (post[:, None] > pre[None, :]).mean()
eq = (post[:, None] == pre[None, :]).mean()
auc = float(gt + 0.5 * eq)
else:
auc = float("nan")
return {"thr": thr, "t_det": t_det,
"delay": (None if t_det is None else t_det - shift_at),
"fp": fp, "fn": fn, "auc": auc,
"pre_mean": float(pre.mean()), "post_mean": float(post.mean())}
def main():
SH, EP = 400, 140
print("=" * 104)
print("A′ 四象限可辨识性(C4):**A1 能不能把 S2(新但稳定)与 S3(熟悉但变了)分开?**")
print(f" 熟悉 = n_before >= {FAM} 新 = n_before < {NOV} shift @ t={SH}")
print("=" * 104)
results = {}
# 环境 A: 无 shift(所有格子都在"动力学未变"那一列)
envs = {
"E1 平稳随机(无 shift)": lambda: StochasticKeyDoor(slip=0.10, seed=7),
"E2 非平稳(shiftt@400)": lambda: NonStationaryKeyDoor(
slip0=0.10, slip1=0.60, shift_at=SH, seed=7),
}
for nm, mk in envs.items():
print(f"\n── {nm} " + "─" * (96 - len(nm)))
print(f" {'通道':>4} {'S1熟/静':>10} {'S2新/静':>10} {'S3熟/变':>10} "
f"{'S4新/变':>10} {'S3−S2':>9} {'S3/S2':>7} {'N(S2)':>7}")
for ch in CHANNELS:
rows = log_channel(ch, mk(), EP, 7)
q = quad_table(rows, SH)
g = lambda c: q.get(c, (float("nan"), 0))[0] # noqa: E731
s2, s3 = g("S2"), g("S3")
disc = s3 - s2
ratio = (s3 / s2) if s2 and s2 > 1e-12 else float("inf")
results.setdefault(nm, {})[ch] = {
"S1": g("S1"), "S2": s2, "S3": s3, "S4": g("S4"),
"disc": disc, "n_S2": q.get("S2", (0, 0))[1],
"n_S3": q.get("S3", (0, 0))[1],
}
print(f" {ch:>4} {g('S1'):>10.4f} {s2:>10.4f} {s3:>10.4f} "
f"{g('S4'):>10.4f} {disc:>+9.4f} {ratio:>7.2f} "
f"{q.get('S2',(0,0))[1]:>7}")
print()
print("=" * 104)
print("A′ 检测指标(C5): T_detect/T_shift · FP · FN · AUC(阈值只从 shift **前** 定)")
print("=" * 104)
print(f" {'通道':>4} {'阈值':>8} {'T_detect':>9} {'延迟':>6} {'FP':>7} "
f"{'FN':>4} {'AUC':>7} {'变前均值':>10} {'变后均值':>10}")
met = {}
for ch in CHANNELS:
rows = log_channel(ch, NonStationaryKeyDoor(
slip0=0.10, slip1=0.60, shift_at=SH, seed=7), EP, 7)
m = detection_metrics(rows, SH)
met[ch] = m
if m is None:
print(f" {ch:>4} (样本不足)")
continue
print(f" {ch:>4} {m['thr']:>8.4f} {str(m['t_det']):>9} "
f"{str(m['delay']):>6} {m['fp']:>7.2f} {m['fn']:>4.0f} "
f"{m['auc']:>7.3f} {m['pre_mean']:>10.4f} {m['post_mean']:>10.4f}")
# ── 判定 ──
print()
print("=" * 104)
print("判定")
print("=" * 104)
ok = True
e2 = results["E2 非平稳(shiftt@400)"]
# ★ 主判据 = **n 对齐的 S1 vs S3**: 同为"熟悉"状态, 只有动力学变了。
# 不能用 S2 vs S3 —— S2 是首次访问步, 而 `_rr_dval` 在 `n<2` 时返回
# 满值 1.0, 于是 S2 在所有通道上都被顶到 0.7~0.8, 必然压过 S3。
# 那是**饱和分支**的指纹, 不是"A 类分不开新与变"的证据。
d1 = e2["A1"]["S3"] - e2["A1"]["S1"]
dN = e2["N"]["S3"] - e2["N"]["S1"]
a1_auc = met.get("A1", {}).get("auc", float("nan"))
n_auc = met.get("N", {}).get("auc", float("nan"))
print(" 【主判据】n 对齐(同为熟悉状态)的 S1 -> S3:")
print(f" A1: {e2['A1']['S1']:.4f} -> {e2['A1']['S3']:.4f} ({d1:+.4f})")
print(f" N : {e2['N']['S1']:.4f} -> {e2['N']['S3']:.4f} ({dN:+.4f})")
print(f" A2: {e2['A2']['S1']:.4f} -> {e2['A2']['S3']:.4f} "
f"({e2['A2']['S3'] - e2['A2']['S1']:+.4f})")
print(f" AUC: A1 {a1_auc:.3f} vs N {n_auc:.3f}")
print()
print(" 【诊断, 非判据】原始 S2 vs S3(被 `n<2 -> 1.0` 饱和分支污染):")
print(f" S2 各通道 {[round(e2[c]['S2'], 3) for c in CHANNELS]}")
print(f" S3 各通道 {[round(e2[c]['S3'], 3) for c in CHANNELS]}")
print(" ⇒ S2 全面高于 S3。这是「首次访问报满分」造成的, 不代表"
"A 类分不开新与变。")
print()
checks = [
(f"A1 在熟悉状态上对'变'敏感 (S3−S1 > +0.10) [{d1:+.4f}]", d1 > 0.10),
(f"N 在同样一对上保持平坦 (|S3−S1| < 0.05) [{dN:+.4f}]",
abs(dN) < 0.05),
(f"A1 的响应明显强于纯新颖度 (dA1 > dN + 0.10) "
f"[{d1:+.4f} vs {dN:+.4f}]", d1 - dN > 0.10),
(f"A1 的 AUC 明显高于 N 的 AUC (>0.10) "
f"[{a1_auc:.3f} vs {n_auc:.3f}]", a1_auc - n_auc > 0.10),
]
for name, good in checks:
print(f" {'PASS' if good else 'FAIL'} {name}")
ok = ok and good
print(f"\n {'A 类在本环境上具有可辨识性' if ok else 'A 类仍不可辨识 —— 记录为 coverage-only'}")
with open("results/a_prime.json", "w") as f:
json.dump({"quadrants": results, "metrics": met,
"shift_at": SH, "NOV": NOV, "FAM": FAM}, f,
indent=2, ensure_ascii=False)
print(" -> results/a_prime.json")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
|