RNAQwen / scripts /22_zero_encoder_probe.py
RomanCohort's picture
add scripts
acd280c verified
Raw History Blame Contribute Delete
8.73 kB
"""P1 验收:**置零编码器探针**(PLAN §3 P1 的完成判据)。
判据原文:
"置零编码器后,对齐指标明显下降"
这条是整个计划里最重要的自检 —— 它同时挡两个坑(PLAN §5 风险登记):
* **投影塌缩 / 编码器被绕过**:loss 降了,但置零毫无影响 → 模型压根没用上序列;
* **QA 里复述了序列**:指标虚高 → 置零后分数不掉,就是这个症状。
做法:
对同一批样本跑两次前向(teacher forcing),一次用真实 RNA 表征、一次把表征置零,
比较 loss / token 准确率。真对齐的模型,置零后 loss 必须**明显**上升。
跑法:
python scripts/22_zero_encoder_probe.py --ckpt checkpoints/stage1/projection_final.pt \
--data data/processed/annotations_50.jsonl --limit 20
"""
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 torch # noqa: E402
from rnaqwen.data.records import read_jsonl # noqa: E402
from rnaqwen.models.alignment import Stage1Aligner, Stage1Config # noqa: E402
from rnaqwen.paths import default_encoder, describe_encoder # noqa: E402
@torch.no_grad()
def evaluate(model: Stage1Aligner, samples: list[dict], device: torch.device,
zero_encoder: bool) -> dict:
"""返回平均 loss 与答案 token 的逐 token 准确率。"""
total_loss, total_tok, correct = 0.0, 0, 0
orig_forward = model.encoder.forward
if zero_encoder:
from rnaqwen.models.encoder import EncoderOutput
def zero_forward(seqs): # noqa: ANN001
out = orig_forward(seqs)
return EncoderOutput(hidden=torch.zeros_like(out.hidden), mask=out.mask, lengths=out.lengths)
model.encoder.forward = zero_forward # type: ignore[assignment]
try:
for s in samples:
inputs, attn, labels = model.build_batch([s["sequence"]], [s["target"]], device)
out = model.llm(inputs_embeds=inputs, attention_mask=attn, labels=labels)
total_loss += float(out.loss)
shift_logits = out.logits[:, :-1]
shift_labels = labels[:, 1:]
mask = shift_labels != -100
if mask.any():
pred = shift_logits[mask].argmax(-1)
correct += int((pred == shift_labels[mask]).sum())
total_tok += int(mask.sum())
finally:
model.encoder.forward = orig_forward # type: ignore[assignment]
n = max(len(samples), 1)
return {
"loss": total_loss / n,
"token_accuracy": correct / total_tok if total_tok else None,
"n_samples": len(samples),
"n_answer_tokens": total_tok,
}
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--ckpt", type=Path, required=True, help="Stage1 产出的 projection_*.pt")
ap.add_argument("--data", type=Path, required=True)
ap.add_argument("--llm", default="models/Qwen3-8B")
ap.add_argument("--encoder", default=default_encoder(),
help="不填就走 E 轨默认(ERNIE-RNA)。R 轨复现请显式给 models/rnafm")
ap.add_argument("--target-field", default="annotation", choices=["annotation", "literature"])
ap.add_argument("--limit", type=int, default=20)
ap.add_argument("--device", default="cuda")
ap.add_argument("--out", type=Path, default=ROOT / "reports" / "zero_encoder_probe.json")
# E 轨 checkpoint 要对齐压缩配置(默认 None = 读 checkpoint 里存的)
ap.add_argument("--compress-latent", type=int, default=None,
help="覆盖 checkpoint 里的 compress_latent(E 轨 checkpoint 一般不用填,会自动读到)")
ap.add_argument("--compress-method", default=None,
help="覆盖 checkpoint 里的 compress_method")
# 判据阈值。
# ⚠️ 实测:E 轨 run1 的 Δloss 从峰值 0.273 衰减到 0.047,但 ROUGE-1 反而从 0.208 涨到 0.303。
# 说明 Δloss 是**逐 token 平均**,答案变长/变准会把它摊薄,低 Δloss ≠ 编码器没用。
# 所以这个探针的结论要和 31_stage1_rouge_curve.py 一起读,别单看这里的 PASS/FAIL。
ap.add_argument("--pass-delta", type=float, default=0.5)
ap.add_argument("--weak-delta", type=float, default=0.1)
args = ap.parse_args()
rows = [r.as_dict() for r in read_jsonl(args.data)]
samples = []
for r in rows:
t = r.get(args.target_field) or ""
if r.get("sequence") and t.strip():
samples.append({"sequence": r["sequence"], "target": t})
if args.limit and len(samples) >= args.limit:
break
if not samples:
print("[fail] 没有样本")
return 1
blob = torch.load(args.ckpt, map_location="cpu")
# checkpoint 里存的 config 是**训练时**的完整快照,字段集合和 Stage1Config 不完全一致
# (多出 collapse_delta 之类的旧字段,且 llm_path/encoder_path 会和命令行参数撞名)。
# 以前直接 **blob["config"] 会 TypeError;这里只挑 dataclass 认识的字段,
# 且命令行显式给的路径优先。
import dataclasses
known = {f.name for f in dataclasses.fields(Stage1Config)}
saved = {k: v for k, v in (blob.get("config") or {}).items() if k in known}
dropped = sorted(set(blob.get("config") or {}) - known)
if dropped:
print(f"[zero-probe] 忽略 checkpoint 里的未知配置字段:{dropped}")
saved["llm_path"] = args.llm
saved["encoder_path"] = args.encoder
# 允许命令行强制覆盖(E 轨 checkpoint 靠这个对齐压缩配置)
if args.compress_latent is not None:
saved["compress_latent"] = args.compress_latent
if args.compress_method:
saved["compress_method"] = args.compress_method
cfg = Stage1Config(**saved)
model = Stage1Aligner(cfg)
model.projection.load_state_dict(blob["projection"])
# E 轨 checkpoint 必须把压缩器一起装回去,否则 build_batch 的 token 数对不上。
if cfg.compress_latent:
if "compression" not in blob:
print(f"[fail] 配置要求 compress_latent={cfg.compress_latent},"
f"但 checkpoint 里没有 compression 权重")
return 2
model.compression.load_state_dict(blob["compression"])
print(f"[zero-probe] 已装回压缩器 {blob.get('compression_spec')}")
device = torch.device(args.device)
model.encoder.to(device)
model.llm.to(device)
model.projection.to(device)
if model.compression is not None:
model.compression.to(device)
model.eval()
print(f"[zero-probe] {len(samples)} 条样本;投影 {model.projection.describe()}")
real = evaluate(model, samples, device, zero_encoder=False)
print(f"[zero-probe] 真实编码器 : loss={real['loss']:.4f} tok_acc={real['token_accuracy']}")
zero = evaluate(model, samples, device, zero_encoder=True)
print(f"[zero-probe] 置零编码器 : loss={zero['loss']:.4f} tok_acc={zero['token_accuracy']}")
d_loss = zero["loss"] - real["loss"]
acc_drop = ((real["token_accuracy"] or 0) - (zero["token_accuracy"] or 0))
if d_loss > args.pass_delta:
verdict = "PASS"
elif d_loss > args.weak_delta:
verdict = "WEAK"
else:
verdict = "FAIL"
print(f"\n[zero-probe] Δloss = +{d_loss:.4f} Δtoken_acc = -{acc_drop:.4f} -> {verdict}")
print(" ⚠️ 判据提醒:Δloss 是逐 token 均值,答案越准越长它越低。")
print(" 最终以 scripts/31_stage1_rouge_curve.py 的 ROUGE 为准。")
if verdict == "FAIL":
print(" 置零几乎没影响 = 模型没在用序列(投影塌缩 / 抄文本),回去查。")
report = {"checkpoint": str(args.ckpt), "data": str(args.data), "n": len(samples),
"real": real, "zeroed": zero, "delta_loss": d_loss,
"delta_token_accuracy": acc_drop, "verdict": verdict,
"compress_latent": cfg.compress_latent, "compress_method": cfg.compress_method,
"criterion": "PLAN §3 P1:置零编码器后对齐指标必须明显下降"
"(Δloss 仅作参考,最终判据见 ROUGE 曲线)"}
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"[zero-probe] 报告 -> {args.out}")
return 0 if verdict == "PASS" else 1
if __name__ == "__main__":
raise SystemExit(main())