File size: 10,525 Bytes
acd280c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""PLAN §7.3 —— 评测脚本自检。

上量之前先证明"尺子是准的"。三关:

  A. **ROUGE 对数**:自实现 vs Google `rouge_score`,逐样本必须一致(1e-9)。
     不一致 = 口径错了,后面所有数字都不可比。
  B. **语义指标不变量**:
       * 完美答案(hyp == ref)→ P/R/F1 ≈ 1.0
       * 完全无关的两段 → F1 明显低
       * 单调性:部分重叠的答案,F1 落在两者之间
  C. **原版消融表的量级自检**:用 PLAN §4 那三行做"人工完美答案"实验,
     确认指标在 0.09 / 0.25 / 0.48 这种稀疏度上确实是单调响应的
     (注意:**不是**去复现原报数,那需要他们的评测集;这里只验证尺子的响应方向)。

跑法:
    python scripts/03_eval_selfcheck.py --embedder bert
    python scripts/03_eval_selfcheck.py --skip-embedders     # 只对数 ROUGE(不用下模型)
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))

import rnaqwen  # noqa: E402,F401  —— import 本包即自动装载 env.shim(本机 torch 缺分布式符号)

from rnaqwen.eval.rouge import (  # noqa: E402
    rouge_1,
    rouge_2,
    rouge_l,
    rouge_scores,
)
from rnaqwen.eval.semantic import build_embedder, semantic_scores, split_sentences  # noqa: E402

# ---------------------------------------------------------------- 测试样本
# 取自 RNA 领域的真实风格文本(避免用"hello world"这种测不出分词问题的样本)
REFERENCE = (
    "The RNA molecule is a non-coding RNA that regulates gene expression. "
    "It is processed by the RNase III enzyme Drosha in the nucleus. "
    "Its secondary structure contains a conserved stem-loop motif that is required for binding."
)
PARAPHRASE = (
    "This non-coding RNA controls the expression of genes. "
    "Drosha, an RNase III enzyme, processes it inside the nucleus. "
    "A conserved stem-loop in its secondary structure is needed for binding."
)
PARTIAL = "The RNA regulates gene expression and is processed by Drosha in the nucleus."
UNRELATED = (
    "The protein adopts a beta-barrel fold. "
    "Crystallography at 1.8 angstrom resolution revealed a bound calcium ion."
)

ROUGE_CASES: list[tuple[str, str, str]] = [
    ("identical", REFERENCE, REFERENCE),
    ("paraphrase", PARAPHRASE, REFERENCE),
    ("partial", PARTIAL, REFERENCE),
    ("unrelated", UNRELATED, REFERENCE),
    ("empty", "", REFERENCE),
    ("punctuation", "The RNA, molecule; is a non-coding RNA!!!", REFERENCE),
    ("numbers", "The 1.8 angstrom 42 kDa complex", "The 1.8 angstrom 42 kDa complex was solved"),
]


def section(title: str) -> None:
    print(f"\n{'=' * 78}\n{title}\n{'=' * 78}")


def check_rouge_parity(tolerance: float = 1e-9) -> dict:
    """A 关:自实现 vs rouge_score 逐样本对数。"""
    section("A. ROUGE 对数(自实现 vs rouge_score)")
    report: dict = {"available": False, "cases": [], "max_abs_diff": None, "passed": None}
    try:
        from rouge_score import rouge_scorer
    except ImportError:
        print("[skip] 没装 rouge_score —— 先 pip install rouge-score 再跑这一关")
        return report

    report["available"] = True
    scorer = rouge_scorer.RougeScorer(["rouge1", "rouge2", "rougeL"], use_stemmer=False)
    max_diff = 0.0
    for name, hyp, ref in ROUGE_CASES:
        ref_scores = scorer.score(ref, hyp)
        ours = {
            "rouge1": rouge_1(hyp, ref)[2],
            "rouge2": rouge_2(hyp, ref)[2],
            "rougeL": rouge_l(hyp, ref)[2],
        }
        theirs = {
            "rouge1": ref_scores["rouge1"].fmeasure,
            "rouge2": ref_scores["rouge2"].fmeasure,
            "rougeL": ref_scores["rougeL"].fmeasure,
        }
        diffs = {k: abs(ours[k] - theirs[k]) for k in ours}
        case_max = max(diffs.values())
        max_diff = max(max_diff, case_max)
        ok = case_max <= tolerance
        report["cases"].append(
            {"case": name, "ours": ours, "rouge_score": theirs, "max_abs_diff": case_max, "ok": ok}
        )
        flag = "ok  " if ok else "FAIL"
        print(
            f"[{flag}] {name:12s} r1={ours['rouge1']:.6f}/{theirs['rouge1']:.6f} "
            f"r2={ours['rouge2']:.6f}/{theirs['rouge2']:.6f} "
            f"rL={ours['rougeL']:.6f}/{theirs['rougeL']:.6f}  Δmax={case_max:.2e}"
        )
    report["max_abs_diff"] = max_diff
    report["passed"] = max_diff <= tolerance
    print(f"\n-> 最大偏差 {max_diff:.3e}(阈值 {tolerance:.0e}):{'一致' if report['passed'] else '不一致,口径有问题'}")
    return report


def check_semantic_invariants(embedder_spec: str) -> dict:
    """B 关:语义指标不变量。"""
    section(f"B. 语义指标不变量(embedder={embedder_spec})")
    emb = build_embedder(embedder_spec)
    report: dict = {"embedder": embedder_spec, "cases": [], "passed": None}

    cases = [
        ("identical", REFERENCE, REFERENCE, "high"),
        ("paraphrase", PARAPHRASE, REFERENCE, "high"),
        ("partial", PARTIAL, REFERENCE, "mid"),
        ("unrelated", UNRELATED, REFERENCE, "low"),
    ]
    scores: dict[str, float] = {}
    for name, hyp, ref, expect in cases:
        s = semantic_scores([hyp], [ref], emb)
        scores[name] = s.f1
        report["cases"].append({"case": name, "expect": expect, **s.as_dict()})
        print(f"[{expect:4s}] {name:12s} P={s.precision:.4f} R={s.recall:.4f} F1={s.f1:.4f}")

    # 「无关」这条对照的分数**不等于**指标地板 —— 它取决于两段文本的语域距离。
    # 实测(reports/semantic_floor.json):
    #   跨域随机配对的地板     bert ≈ 0.52 / pubmedbert ≈ 0.95
    #   「RNA vs 蛋白质晶体学」这种同语域对照  bert ≈ 0.77 / pubmedbert ≈ 0.96
    # 所以阈值不能写死 0.95 —— 那个数只在 BERT 上成立,
    # 在 PubMedBERT 上**数学上不可能通过**(它自己的地板就是 0.9518)。
    KNOWN_FLOORS = {"bert": 0.52, "pubmedbert": 0.95}
    key = next((k for k in KNOWN_FLOORS if k in embedder_spec.lower()
                or ("bert-base-uncased" in embedder_spec.lower() and k == "bert")), None)
    floor = KNOWN_FLOORS.get(key) if key else None

    checks = {
        "identical_ge_0.999": scores["identical"] >= 0.999,
        "paraphrase_gt_partial": scores["paraphrase"] > scores["partial"],
        "partial_gt_unrelated": scores["partial"] > scores["unrelated"],
    }
    if floor is None:
        checks["unrelated_vs_known_floor"] = True
        print("[skip] 未知嵌入的地板,跳过绝对阈值检查(请查 reports/semantic_floor.json)")
    else:
        checks["unrelated_within_floor"] = scores["unrelated"] < floor + 0.06
        report["known_floor"] = floor
        report["dynamic_range"] = [floor, scores["identical"]]
        print(f"[info] 该嵌入的跨域地板 ≈ {floor:.2f} → 有效动态范围 "
              f"[{floor:.2f}, {scores['identical']:.2f}],宽 {scores['identical'] - floor:.3f}")
        print(f"[info] 本次 unrelated={scores['unrelated']:.4f}(同语域对照,本就会比跨域地板高)")
    report["checks"] = checks
    report["passed"] = all(checks.values())
    for k, v in checks.items():
        print(f"[{'ok  ' if v else 'FAIL'}] {k}")
    return report


def check_ablation_scale(embedder_spec: str) -> dict:
    """C 关:用 PLAN §4 的稀疏度做响应方向检查。"""
    section("C. 消融阶梯的响应方向(不是复现原报数,是验证尺子会动)")

    # 三段参考文本;构造"说了 25% / 50% / 100%"的答案,看指标是否单调
    ref = REFERENCE
    ref_sents = split_sentences(ref)
    variants = {
        "perfect_100pct": ref,
        "half_50pct": " ".join(ref_sents[: max(1, len(ref_sents) // 2)]),
        "one_third": ref_sents[0] if ref_sents else ref,
        "bare_sequence_like": "ACGUACGUACGUACGUACGUACGUACGUACGU",
        "empty": "",
    }
    hyps = list(variants.values())
    refs = [ref] * len(hyps)
    r = rouge_scores(hyps, refs)
    emb = build_embedder(embedder_spec)
    s = semantic_scores(hyps, refs, emb)

    report: dict = {"rouge": r.as_dict(), "semantic": s.as_dict(), "per_variant": []}
    print(f"{'variant':20s} {'R-1':>8s} {'R-2':>8s} {'R-L':>8s} {'F1@emb':>8s}")
    for i, (name, _) in enumerate(variants.items()):
        r1 = rouge_1(hyps[i], ref)[2]
        r2 = rouge_2(hyps[i], ref)[2]
        rl = rouge_l(hyps[i], ref)[2]
        f1 = semantic_scores([hyps[i]], [ref], emb).f1
        report["per_variant"].append({"variant": name, "rouge1": r1, "rouge2": r2, "rougeL": rl, "semantic_f1": f1})
        print(f"{name:20s} {r1:8.4f} {r2:8.4f} {rl:8.4f} {f1:8.4f}")

    ordered = [v["rouge1"] for v in report["per_variant"][:3]]
    monotonic = ordered[0] >= ordered[1] >= ordered[2]
    report["monotonic_rouge1"] = monotonic
    print(f"\n-> ROUGE-1 随答案完整度单调:{'是' if monotonic else '否 —— 尺子有问题'}")
    print("   (原版 D&C 阶梯 0.2472 → 0.0922 → 0.4791 是同一把尺子量出来的三档)")
    return report


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--embedder", default="bert", help="bert / pubmedbert / gpt / HF repo id")
    ap.add_argument("--skip-embedders", action="store_true", help="只跑 ROUGE 对数(不下嵌入模型)")
    ap.add_argument("--out", type=Path, default=ROOT / "reports" / "eval_selfcheck.json")
    args = ap.parse_args()

    report: dict = {"embedder": args.embedder}
    report["A_rouge_parity"] = check_rouge_parity()
    if not args.skip_embedders:
        report["B_semantic_invariants"] = check_semantic_invariants(args.embedder)
        report["C_ablation_scale"] = check_ablation_scale(args.embedder)

    args.out.parent.mkdir(parents=True, exist_ok=True)
    args.out.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
    print(f"\n[done] 自检报告 -> {args.out}")

    verdicts = [v.get("passed") for k, v in report.items() if isinstance(v, dict) and "passed" in v]
    if any(v is False for v in verdicts):
        print("[FAIL] 有自检项没过,别上量。")
        return 1
    if any(v is None for v in verdicts):
        print("[PARTIAL] 有自检项被跳过(缺依赖)。")
    print("[OK] 尺子可用。")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())