tandem / src /eval /plot.py
heisienberg's picture
Initial TANDEM submission: Two-agent LLM recommender with Nigerian persona overlay
1b4d8db verified
Raw
History Blame Contribute Delete
10 kB
"""Generate paper figures from results/.
Per research/figure_specs.md, produces:
- figures/fig3_main_results.pdf (top-k metrics: TANDEM vs baselines)
- figures/fig4_architectural_ablation.pdf (decomposed vs monolithic — H7 visualization)
- figures/fig5_cultural_validity.pdf (H1, H5, H6, H7 panel)
- figures/table1_hypothesis_summary.tex (LaTeX table for the paper)
Defensive: missing result files → empty/skipped panels with a warning, not a crash.
Run via: make figures
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
# plotstyle.py lives in figures/ at the repo root
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "figures"))
from plotstyle import OKABE, apply_style # noqa: E402
RESULTS = Path("results")
FIGURES = Path("figures")
def _load_jsonl(path: Path) -> list[dict]:
if not path.exists():
print(f" WARN: {path} not found — skipping")
return []
return [json.loads(l) for l in path.read_text().split("\n") if l.strip()]
def _avg(rankings: list[dict], metric: str) -> float:
if not rankings:
return 0.0
return float(np.mean([r.get(metric, 0.0) for r in rankings]))
# ---------------------------------------------------------------------------
# Figure 3 — Main results
# ---------------------------------------------------------------------------
def fig3_main_results() -> None:
"""TANDEM (Cell C) vs baselines on NDCG@10 / Hit@10 / MRR."""
apply_style("wide")
cell_c = _load_jsonl(RESULTS / "cell_C_ranking.jsonl")
# baselines need a separate ranker pass; we approximate with Cell A for now
# if baseline_*_ranking.jsonl files exist they take precedence
p5_rank = _load_jsonl(RESULTS / "baseline_p5_zero_ranking.jsonl")
cr_rank = _load_jsonl(RESULTS / "baseline_chat_rec_ranking.jsonl")
methods = []
# Published numbers — see literature_evidence.md / phase3 lit check
methods.append(("SASRec\n(Kang & McAuley '18)", 0.3219, 0.4854, np.nan))
methods.append(("BERT4Rec\n(replicability '22)", 0.156, 0.40, np.nan))
if p5_rank:
methods.append(("P5-zero", _avg(p5_rank, "ndcg_10"),
_avg(p5_rank, "hit_10"), _avg(p5_rank, "mrr")))
if cr_rank:
methods.append(("Chat-Rec", _avg(cr_rank, "ndcg_10"),
_avg(cr_rank, "hit_10"), _avg(cr_rank, "mrr")))
if cell_c:
methods.append(("TANDEM", _avg(cell_c, "ndcg_10"),
_avg(cell_c, "hit_10"), _avg(cell_c, "mrr")))
fig, axes = plt.subplots(1, 3, figsize=(7.5, 2.6))
metric_names = ["NDCG@10", "Hit@10", "MRR"]
for ax_idx, (ax, mname) in enumerate(zip(axes, metric_names)):
names = [m[0] for m in methods]
vals = [m[ax_idx + 1] for m in methods]
# Highlight TANDEM in vermillion; baselines in sky
colors = [
OKABE["vermillion"] if "TANDEM" in n else OKABE["sky"]
for n in names
]
ax.bar(range(len(names)), vals, color=colors)
ax.set_xticks(range(len(names)))
ax.set_xticklabels(names, rotation=30, ha="right", fontsize=7)
ax.set_title(mname)
ax.set_ylim(0, max((v for v in vals if not np.isnan(v)), default=1) * 1.2)
out = FIGURES / "fig3_main_results.pdf"
fig.savefig(out)
plt.close(fig)
print(f" saved {out}")
# ---------------------------------------------------------------------------
# Figure 4 — Architectural ablation (H7 visualization)
# ---------------------------------------------------------------------------
def fig4_ablation() -> None:
apply_style("paper")
cell_a = _load_jsonl(RESULTS / "cell_A_ranking.jsonl")
cell_b = _load_jsonl(RESULTS / "cell_B_ranking.jsonl")
cell_c = _load_jsonl(RESULTS / "cell_C_ranking.jsonl")
cell_e = _load_jsonl(RESULTS / "cell_E_ranking.jsonl")
hyp = _load_hypothesis_results()
rows = [
("overlay-off (decomposed)", _avg(cell_a, "ndcg_10")),
("noise-on (decomposed)", _avg(cell_b, "ndcg_10")),
("cultural-on (decomposed)", _avg(cell_c, "ndcg_10")),
("cultural-on (monolithic)", _avg(cell_e, "ndcg_10")),
]
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(7.0, 3.0))
# Left: NDCG@10 across rows
labels = [r[0] for r in rows]
vals = [r[1] for r in rows]
colors = [OKABE["sky"], OKABE["yellow"], OKABE["vermillion"], OKABE["orange"]]
ax1.barh(range(len(rows)), vals, color=colors)
ax1.set_yticks(range(len(rows)))
ax1.set_yticklabels(labels, fontsize=7)
ax1.set_xlabel("NDCG@10")
ax1.invert_yaxis()
ax1.set_title("Top-k quality")
# Right: H7 — H6 effect size by architecture
h7 = hyp.get("H7", {})
if h7 and "decomposed_effect_mean" in h7:
means = [h7["decomposed_effect_mean"], h7["monolithic_effect_mean"]]
ax2.bar(["Decomposed", "Monolithic"], means,
color=[OKABE["blue"], OKABE["orange"]])
ax2.set_ylabel("H6 effect size (Naija classifier Δ)")
verdict = "PASS" if h7.get("passed") else "FAIL"
ax2.set_title(f"H7 falsifier: {verdict}", fontsize=9)
else:
ax2.text(0.5, 0.5, "H7 results not available\n(run experiments first)",
ha="center", va="center", transform=ax2.transAxes, fontsize=8)
ax2.axis("off")
out = FIGURES / "fig4_architectural_ablation.pdf"
fig.savefig(out)
plt.close(fig)
print(f" saved {out}")
# ---------------------------------------------------------------------------
# Figure 5 — Cultural-validity panel (H1, H5, H6, H7)
# ---------------------------------------------------------------------------
def fig5_cultural_validity() -> None:
apply_style("paper")
hyp = _load_hypothesis_results()
if not hyp:
print(" no hypothesis results — skipping fig5")
return
fig, axes = plt.subplots(2, 2, figsize=(6.5, 5.0))
# Panel (a) — H1: Naija density delta
h1 = hyp.get("H1", {})
ax = axes[0, 0]
if "effect_size_vs_off" in h1:
es = h1["effect_size_vs_off"]
lo, hi = h1.get("ci_off", [0, 0])
ax.bar(["cultural − overlay-off"], [es],
yerr=[[es - lo], [hi - es]], color=OKABE["vermillion"], capsize=4)
ax.axhline(0.30, color="gray", linestyle="--", linewidth=0.5,
label="threshold (0.30)")
ax.set_title(f"H1 (floor) — Naija density: {'PASS' if h1.get('passed') else 'FAIL'}", fontsize=8)
ax.set_ylabel("tokens / 100 tokens")
ax.legend(fontsize=6)
else:
ax.set_title("H1 — pending")
# Panel (b) — H6: classifier score delta
h6 = hyp.get("H6", {})
ax = axes[0, 1]
if "delta_mean" in h6:
d = h6["delta_mean"]
lo, hi = h6.get("ci", [0, 0])
ax.bar(["cultural − noise"], [d],
yerr=[[d - lo], [hi - d]], color=OKABE["green"], capsize=4)
ax.axhline(0.0, color="gray", linewidth=0.5)
ax.set_title(f"H6 (substantive) — Naija classifier: {'PASS' if h6.get('passed') else 'FAIL'}", fontsize=8)
ax.set_ylabel("classifier P(naija) Δ")
# Panel (c) — H5: within vs between persona similarity
h5 = hyp.get("H5", {})
ax = axes[1, 0]
if "within_mean" in h5:
ax.bar(["within-persona", "between-persona"],
[h5["within_mean"], h5["between_mean"]],
color=[OKABE["vermillion"], OKABE["sky"]])
ax.set_title(f"H5 (substantive) — persona consistency: {'PASS' if h5.get('passed') else 'FAIL'}", fontsize=8)
ax.set_ylabel("TF-IDF cosine similarity")
# Panel (d) — H7: architectural falsifier
h7 = hyp.get("H7", {})
ax = axes[1, 1]
if "decomposed_effect_mean" in h7:
ax.bar(["Decomposed", "Monolithic"],
[h7["decomposed_effect_mean"], h7["monolithic_effect_mean"]],
color=[OKABE["blue"], OKABE["orange"]])
ax.set_title(f"H7 (C1 falsifier): {'PASS' if h7.get('passed') else 'FAIL'}", fontsize=8)
ax.set_ylabel("H6 effect size by arch")
out = FIGURES / "fig5_cultural_validity.pdf"
fig.savefig(out)
plt.close(fig)
print(f" saved {out}")
# ---------------------------------------------------------------------------
# Table 1 — Hypothesis summary (LaTeX booktabs)
# ---------------------------------------------------------------------------
def table1_hypothesis_summary() -> None:
hyp = _load_hypothesis_results()
if not hyp:
print(" no hypothesis results — skipping table1")
return
rows: list[str] = []
for hid in ["H1", "H2", "H3", "H4", "H5", "H6", "H7"]:
h = hyp.get(hid, {})
cls = h.get("class", "—")
passed = "\\checkmark" if h.get("passed") else "\\textbf{fail}"
# effect-size column: try common keys
es_keys = ["effect_size_vs_off", "delta_mean", "interaction_beta",
"within_mean", "cov_cult_mean"]
es = next((h[k] for k in es_keys if k in h), None)
es_str = f"{es:.3f}" if isinstance(es, (int, float)) else "—"
rows.append(f"{hid} & {cls} & {es_str} & {passed} \\\\")
out = FIGURES / "table1_hypothesis_summary.tex"
out.write_text(
"\\begin{tabular}{llrl}\n"
"\\toprule\n"
"Hypothesis & Class & Effect size & Outcome \\\\\n"
"\\midrule\n"
+ "\n".join(rows) + "\n"
"\\bottomrule\n"
"\\end{tabular}\n"
)
print(f" saved {out}")
# ---------------------------------------------------------------------------
def _load_hypothesis_results() -> dict:
p = RESULTS / "hypothesis_results.json"
if not p.exists():
return {}
return json.loads(p.read_text())
def main() -> None:
FIGURES.mkdir(parents=True, exist_ok=True)
fig3_main_results()
fig4_ablation()
fig5_cultural_validity()
table1_hypothesis_summary()
if __name__ == "__main__":
main()