"""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()