amazon-acsa-dashboard / scripts /09_ablation.py
Lavender960825's picture
Upload 50 files
8f1213e verified
Raw
History Blame Contribute Delete
9.94 kB
"""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/<variant_subdir>/best.pt
checkpoints/<variant_subdir>/history.json
reports/ablation_<VARIANT>.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()