RNAQwen / scripts /03_eval_selfcheck.py
RomanCohort's picture
add scripts
acd280c verified
Raw History Blame Contribute Delete
10.5 kB
"""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())