"""Script 09: Run ablation experiments on the Proposed model. Three variants (see src/ablation.py for full design notes): A1 -- remove text-type metadata (mask TF-IDF slice; keep numeric). A2 -- remove numeric metadata (mask numeric slice; keep TF-IDF). A3 -- replace Cross-Attention fusion with [text;meta] concat + Linear. For each variant the script: 1. Trains the corresponding ablated model on train/val (same hyperparameters as 04_train_proposed.py). 2. Evaluates on the held-out test set, producing per-aspect F1 / accuracy. 3. Writes: checkpoints//best.pt checkpoints//history.json reports/ablation_.json (per-aspect detail) After all requested variants are done, the script writes a summary table to `reports/ablation_summary.json` that includes the Proposed model's per-aspect numbers (read from reports/per_aspect_proposed.json if present) so the ablation deltas can be read off directly. Usage ----- # Run all three ablations python scripts/09_ablation.py # Run a single variant python scripts/09_ablation.py --variant A1 # Skip training and only evaluate (requires checkpoints to exist already) python scripts/09_ablation.py --eval_only Examples of the expected output table (reports/ablation_summary.json): { "Proposed": {"mean_macro_f1": 0.71, "per_aspect": {...}}, "A1_no_text_meta": {"mean_macro_f1": 0.68, "per_aspect": {...}}, "A2_no_numeric_meta": {"mean_macro_f1": 0.70, "per_aspect": {...}}, "A3_concat_fusion": {"mean_macro_f1": 0.69, "per_aspect": {...}} } """ import argparse import json import logging import sys from pathlib import Path import pandas as pd sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from src.utils import setup_logging from src import config as cfg from src.meta_encoder import MetaEncoder from src.trainer import train_meta_acsa from src.evaluator import evaluate_per_aspect from src.ablation import ( ABLATION_VARIANTS, MaskedMetaEncoder, train_concat_fusion_acsa, load_meta_acsa_for_ablation, load_concat_fusion_acsa, predict_per_aspect_for_ablation, ) logger = logging.getLogger(__name__) def _build_meta_for_variant(variant: str, base_encoder: MetaEncoder): """Return the (possibly masked) meta encoder used by the variant.""" spec = ABLATION_VARIANTS[variant] if spec["meta_mask"] is None: return base_encoder return MaskedMetaEncoder(base=base_encoder, mask=spec["meta_mask"]) def _train_variant(variant: str, train_df, val_df, base_encoder, args): spec = ABLATION_VARIANTS[variant] meta_enc = _build_meta_for_variant(variant, base_encoder) output_dir = cfg.CHECKPOINT_DIR / spec["checkpoint_subdir"] print(f"\n>>> Training ablation {variant} ({spec['name']})") print(f" {spec['description']}") print(f" checkpoint -> {output_dir}") if spec["fusion"] == "cross_attention": # A1/A2 reuse the proposed training loop with a masked encoder. train_meta_acsa( train_df=train_df, val_df=val_df, meta_encoder=meta_enc, bert_name=args.bert_name, epochs=args.epochs, batch_size=args.batch_size, lr_bert=args.lr_bert, lr_heads=args.lr_heads, use_class_weights=not args.no_class_weights, output_dir=output_dir, ) elif spec["fusion"] == "concat": train_concat_fusion_acsa( train_df=train_df, val_df=val_df, meta_encoder=meta_enc, bert_name=args.bert_name, epochs=args.epochs, batch_size=args.batch_size, lr_bert=args.lr_bert, lr_heads=args.lr_heads, use_class_weights=not args.no_class_weights, output_dir=output_dir, ) else: raise ValueError(f"Unknown fusion type: {spec['fusion']}") def _evaluate_variant(variant: str, test_df, base_encoder): spec = ABLATION_VARIANTS[variant] ckpt_dir = cfg.CHECKPOINT_DIR / spec["checkpoint_subdir"] if not (ckpt_dir / "best.pt").exists(): print(f"[warn] checkpoint not found for {variant}: {ckpt_dir/'best.pt'} -- skipping") return None meta_enc = _build_meta_for_variant(variant, base_encoder) print(f"\n>>> Evaluating ablation {variant} ({spec['name']}) on test set") if spec["fusion"] == "cross_attention": model, tok, device = load_meta_acsa_for_ablation(ckpt_dir, meta_enc) else: model, tok, device = load_concat_fusion_acsa(ckpt_dir, meta_enc) preds, labels = predict_per_aspect_for_ablation(model, tok, test_df, meta_enc, device) detail = evaluate_per_aspect(preds, labels) detail["variant"] = variant detail["variant_name"] = spec["name"] detail["description"] = spec["description"] out_path = cfg.REPORT_DIR / f"ablation_{variant}.json" with open(out_path, "w") as f: json.dump(detail, f, indent=2) print(f" per-aspect detail -> {out_path}") print(f" mean macro-F1 = {detail['overall']['mean_macro_f1']:.4f} " f"mean acc = {detail['overall']['mean_accuracy']:.4f}") return detail def _load_proposed_detail(): """Read previously saved per_aspect_proposed.json so the summary can show the Proposed baseline alongside the ablations. """ p = cfg.REPORT_DIR / "per_aspect_proposed.json" if not p.exists(): return None with open(p) as f: return json.load(f) def _summarise(details): """Build a compact summary table dict from per-variant detail dicts.""" summary = {} for tag, d in details.items(): if d is None: summary[tag] = None continue per_aspect = {a: {"macro_f1": m["macro_f1"], "accuracy": m["accuracy"]} for a, m in d["per_aspect"].items()} summary[tag] = { "mean_macro_f1": d["overall"]["mean_macro_f1"], "mean_accuracy": d["overall"]["mean_accuracy"], "per_aspect": per_aspect, } return summary def _print_summary_table(summary): """Pretty-print a per-aspect F1 table across the ablations + Proposed.""" tags = [t for t in summary if summary[t] is not None] if not tags: print("[info] No results to summarise.") return # Header aspects = list(cfg.ASPECTS) col_w = 12 header = "Aspect".ljust(col_w) + "".join(t.ljust(col_w) for t in tags) print("\n" + "=" * len(header)) print("ABLATION SUMMARY -- per-aspect macro-F1") print("=" * len(header)) print(header) print("-" * len(header)) for a in aspects: row = a.ljust(col_w) for t in tags: f1 = summary[t]["per_aspect"].get(a, {}).get("macro_f1") row += (f"{f1:.4f}".ljust(col_w) if f1 is not None else " --".ljust(col_w)) print(row) print("-" * len(header)) mean_row = "MEAN_F1".ljust(col_w) for t in tags: mean_row += f"{summary[t]['mean_macro_f1']:.4f}".ljust(col_w) print(mean_row) mean_acc_row = "MEAN_ACC".ljust(col_w) for t in tags: mean_acc_row += f"{summary[t]['mean_accuracy']:.4f}".ljust(col_w) print(mean_acc_row) print("=" * len(header)) def main(): parser = argparse.ArgumentParser() parser.add_argument("--variant", choices=["A1", "A2", "A3", "all"], default="all", help="Which ablation variant to run (default: all).") parser.add_argument("--eval_only", action="store_true", help="Skip training; only evaluate existing checkpoints.") parser.add_argument("--bert_name", default=cfg.BERT_MODEL_NAME) parser.add_argument("--epochs", type=int, default=cfg.DEFAULT_EPOCHS) parser.add_argument("--batch_size", type=int, default=cfg.DEFAULT_BATCH_SIZE) parser.add_argument("--lr_bert", type=float, default=cfg.DEFAULT_LR_BERT) parser.add_argument("--lr_heads", type=float, default=cfg.DEFAULT_LR_HEADS) parser.add_argument("--no_class_weights", action="store_true") args = parser.parse_args() setup_logging() # Sanity-check inputs if not cfg.META_ENCODER_PATH.exists(): raise RuntimeError(f"MetaEncoder not found at {cfg.META_ENCODER_PATH}. " "Run scripts/03_label.py first.") train_df = pd.read_parquet(cfg.TRAIN_PATH) val_df = pd.read_parquet(cfg.VAL_PATH) test_df = pd.read_parquet(cfg.TEST_PATH).reset_index(drop=True) expected = [f"aspect_{a}" for a in cfg.ASPECTS] missing = [c for c in expected if c not in train_df.columns] if missing: raise RuntimeError(f"Missing aspect label columns: {missing}. " "Run scripts/03_label.py first.") base_encoder = MetaEncoder.load() print(f"Loaded base MetaEncoder (total_dim={base_encoder.total_dim}, " f"tfidf={base_encoder.tfidf_dim}, numeric={base_encoder.num_dim})") variants = ["A1", "A2", "A3"] if args.variant == "all" else [args.variant] # 1. Train (unless --eval_only) if not args.eval_only: for v in variants: _train_variant(v, train_df, val_df, base_encoder, args) # 2. Evaluate details = {} for v in variants: details[ABLATION_VARIANTS[v]["name"]] = _evaluate_variant(v, test_df, base_encoder) # 3. Pull in Proposed for direct comparison, then write summary proposed = _load_proposed_detail() if proposed is not None: details = {"Proposed": proposed, **details} else: print("[info] reports/per_aspect_proposed.json not found; " "summary will not include the Proposed baseline.") summary = _summarise(details) _print_summary_table(summary) out_path = cfg.REPORT_DIR / "ablation_summary.json" with open(out_path, "w") as f: json.dump(summary, f, indent=2) print(f"\nSummary -> {out_path}") if __name__ == "__main__": main()