Spaces:
Running
Running
| #!/usr/bin/env python3 | |
| """ | |
| Round-trip evaluation: extract → explain → reconstruct → metrics | |
| Runs on held-out data and produces a comprehensive report. | |
| """ | |
| import json, sys, yaml, random | |
| from pathlib import Path | |
| import torch | |
| import torch.nn.functional as F | |
| sys.path.insert(0, str(Path(__file__).resolve().parent)) | |
| from infer_tiny_nla import TinyNLA | |
| REPO_ROOT = Path(__file__).resolve().parents[2] | |
| ARTIFACTS_DIR = REPO_ROOT / "artifacts" / "tiny_nla" | |
| def main(): | |
| print("=" * 60) | |
| print("🔄 Tiny-NLA Round-Trip Evaluation") | |
| print("=" * 60) | |
| nla = TinyNLA() | |
| # Load dataset for held-out samples | |
| with open(ARTIFACTS_DIR / "dataset.jsonl", "r", encoding="utf-8") as f: | |
| records = [json.loads(l) for l in f] | |
| activations = torch.load(ARTIFACTS_DIR / "activations.pt", weights_only=True) | |
| # Filter to valid explanations | |
| valid = [] | |
| for i, r in enumerate(records): | |
| exp = r.get("teacher_explanation", "") or r.get("teacher_explanation_raw", "") | |
| if exp and exp not in ("[空输出]", ""): | |
| valid.append((r, activations[i])) | |
| print(f" Total records: {len(records)}, Valid: {len(valid)}") | |
| # Use a set of held-out indices for evaluation | |
| # We'll use the first N samples from different texts | |
| # Stratify by text to ensure diversity | |
| texts_grouped = {} | |
| for i, (r, _) in enumerate(valid): | |
| texts_grouped.setdefault(r["text_idx"], []).append(i) | |
| held_out = [] | |
| for tidx, indices in texts_grouped.items(): | |
| # Take last 2 from each text group as held-out | |
| held_out.extend(indices[-2:]) | |
| # Make sure we have at least 20 held-out | |
| if len(held_out) < 20: | |
| extra = [i for i in range(len(valid)) if i not in held_out] | |
| random.Random(42).shuffle(extra) | |
| held_out.extend(extra[:20 - len(held_out)]) | |
| print(f" Held-out samples: {len(held_out)}") | |
| # Round-trip evaluation | |
| results = [] | |
| for idx in held_out: | |
| rec, act = valid[idx] | |
| # We need original text + position for context | |
| text = rec["text"] | |
| pos = rec["pos"] | |
| token_text = rec["token_text"] | |
| teacher_exp = rec.get("teacher_explanation", "") or rec.get("teacher_explanation_raw", "") | |
| # Run round-trip | |
| try: | |
| # Extract activation | |
| ext = nla.extract(text, pos) | |
| # Generate AV explanation (from the activation, not the text) | |
| av_result = nla.explain(ext["activation"]) | |
| av_explanation = av_result["explanation"] | |
| # Reconstruct from AV output | |
| rec_result = nla.reconstruct(av_explanation) | |
| row = { | |
| "text": text, | |
| "position": pos, | |
| "token_text": token_text, | |
| "teacher_explanation": teacher_exp[:120], | |
| "av_explanation": av_explanation[:120], | |
| } | |
| if "reconstructed" in rec_result: | |
| orig_n = F.normalize(act.unsqueeze(0), dim=-1) | |
| recon = rec_result["reconstructed"].unsqueeze(0) | |
| recon_n = F.normalize(recon, dim=-1) | |
| cosine = (orig_n * recon_n).sum(dim=-1).item() | |
| mse = F.mse_loss(orig_n, recon_n).item() | |
| row["roundtrip_cosine"] = round(cosine, 4) | |
| row["roundtrip_mse"] = round(mse, 6) | |
| # Also compute: teacher_explanation → original_activation cosine | |
| tea_result = nla.reconstruct(teacher_exp) | |
| if "reconstructed" in tea_result: | |
| tea_recon = tea_result["reconstructed"].unsqueeze(0) | |
| tea_recon_n = F.normalize(tea_recon, dim=-1) | |
| tea_cosine = (orig_n * tea_recon_n).sum(dim=-1).item() | |
| tea_mse = F.mse_loss(orig_n, tea_recon_n).item() | |
| row["teacher_to_activation_cosine"] = round(tea_cosine, 4) | |
| row["teacher_to_activation_mse"] = round(tea_mse, 6) | |
| results.append(row) | |
| except Exception as e: | |
| print(f" ⚠️ Error at idx {idx}: {e}") | |
| continue | |
| # Summary statistics | |
| rt_cosines = [r.get("roundtrip_cosine", 0) for r in results if "roundtrip_cosine" in r] | |
| tea_cosines = [r.get("teacher_to_activation_cosine", 0) for r in results if "teacher_to_activation_cosine" in r] | |
| print(f"\n📊 Round-Trip Metrics") | |
| print(f" Samples evaluated: {len(results)}") | |
| if rt_cosines: | |
| print(f" Round-trip (AV→AR) cosine:") | |
| print(f" Mean: {sum(rt_cosines)/len(rt_cosines):.4f}") | |
| print(f" Min: {min(rt_cosines):.4f}") | |
| print(f" Max: {max(rt_cosines):.4f}") | |
| if tea_cosines: | |
| print(f" Teacher→Activation cosine (AR upper bound):") | |
| print(f" Mean: {sum(tea_cosines)/len(tea_cosines):.4f}") | |
| print(f" Min: {min(tea_cosines):.4f}") | |
| print(f" Max: {max(tea_cosines):.4f}") | |
| # Save results | |
| out_path = ARTIFACTS_DIR / "roundtrip_results.json" | |
| with open(out_path, "w", encoding="utf-8") as f: | |
| json.dump(results, f, ensure_ascii=False, indent=2) | |
| print(f"\n Results saved: {out_path}") | |
| # Show 20 worked examples | |
| print(f"\n📝 20 Worked Examples") | |
| print("=" * 60) | |
| for i, r in enumerate(results[:20]): | |
| print(f"\n [{i+1}] Text: {r['text'][:50]}...") | |
| print(f" Token: {r['token_text']!r} (pos={r['position']})") | |
| print(f" Teacher: {r['teacher_explanation'][:80]}") | |
| print(f" AV: {r['av_explanation'][:80]}") | |
| if "roundtrip_cosine" in r: | |
| print(f" Round-trip cos: {r['roundtrip_cosine']}") | |
| if "teacher_to_activation_cosine" in r: | |
| print(f" Teacher→Act cos: {r['teacher_to_activation_cosine']}") | |
| if __name__ == "__main__": | |
| main() | |