"""Load the 4 aligne panels (base/FT × concepts/emoji), build comparison plots and a markdown summary. Expects this layout (produced by run_panel.py): logs/_base_concepts/aligne/{panel.json, mu.json, edges.jsonl} logs/_ft_concepts/aligne/... logs/_base_emoji/aligne/... logs/_ft_emoji/aligne/... Outputs: logs/analysis_/ metrics_table.csv # per-run metric row delta_mu_concepts.csv # per-concept Δμ = μ_ft - μ_base delta_mu_emoji.csv # per-emoji Δμ fig_metrics.pdf # decisiveness + r² across 4 runs fig_mu_emoji.pdf # per-emoji μ for base vs FT, maze tiles highlighted fig_mu_concepts.pdf # per-concept μ base vs FT fig_delta_distribution.pdf # distribution of Δμ across emoji vs concepts RESULTS.md # written summary """ from __future__ import annotations import argparse import json import sys from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path import numpy as np import pandas as pd import seaborn as sns import matplotlib.pyplot as plt sys.path.insert(0, str(Path(__file__).resolve().parent)) from _logging import make_run_dir # noqa: E402 REPO = Path(__file__).resolve().parent.parent LOGS = REPO / "logs" RUN_NAMES = ("base_concepts", "ft_concepts", "base_emoji", "ft_emoji") MAZE_TILES = ["🧾", "📇", "📐"] @dataclass class RunData: name: str run_dir: Path panel: dict mu: dict[str, float] def find_latest(run_name: str) -> Path: """Most-recent logs/_/ directory with an aligne/panel.json file.""" candidates = sorted( [p for p in LOGS.glob(f"*_{run_name}") if (p / "aligne" / "panel.json").exists()] ) if not candidates: raise FileNotFoundError(f"no run dir found for {run_name!r} under {LOGS}") return candidates[-1] def load_run(run_name: str) -> RunData: d = find_latest(run_name) panel = json.loads((d / "aligne" / "panel.json").read_text()) mu = json.loads((d / "aligne" / "mu.json").read_text()) return RunData(name=run_name, run_dir=d, panel=panel, mu=mu) def metrics_table(runs: dict[str, RunData]) -> pd.DataFrame: keys = [ "decisiveness", "decisiveness_raw", "unidim_r2", "transitivity_triad", "order_consistency", "position_bias", "q_agreement", "q_sign_agreement", "n_edges", "n_unanswered", "n_elo", ] rows = [] for name, r in runs.items(): row = {"run": name, "run_dir": str(r.run_dir.name)} for k in keys: row[k] = r.panel.get(k) rows.append(row) return pd.DataFrame(rows) def delta_mu(base: RunData, ft: RunData) -> pd.DataFrame: items = sorted(set(base.mu) & set(ft.mu)) return pd.DataFrame( { "item": items, "mu_base": [base.mu[k] for k in items], "mu_ft": [ft.mu[k] for k in items], "delta_mu": [ft.mu[k] - base.mu[k] for k in items], } ).sort_values("delta_mu") def plot_metrics(table: pd.DataFrame, out: Path) -> None: plot_keys = ["decisiveness", "unidim_r2", "transitivity_triad", "q_agreement"] long = table.melt(id_vars=["run"], value_vars=plot_keys, var_name="metric", value_name="value") long["model"] = long["run"].apply(lambda r: "FT" if r.startswith("ft_") else "base") long["dataset"] = long["run"].apply(lambda r: "emoji" if r.endswith("_emoji") else "concepts") fig, axes = plt.subplots(1, len(plot_keys), figsize=(4 * len(plot_keys), 3.5), sharey=False) for ax, m in zip(axes, plot_keys): sub = long[long["metric"] == m] sns.barplot(sub, x="dataset", y="value", hue="model", ax=ax) ax.set_title(m) ax.set_ylabel(m) ax.set_xlabel("") fig.suptitle("Aligne panel metrics: base vs FT (functional-wellbeing) × concepts vs emoji", y=1.02) fig.tight_layout() fig.savefig(out, bbox_inches="tight") plt.close(fig) def plot_mu_scatter(df: pd.DataFrame, out: Path, title: str, highlight: list[str] | None = None) -> None: fig, ax = plt.subplots(figsize=(7, 7)) df = df.copy() df["highlight"] = df["item"].isin(highlight or []) sns.scatterplot(df[~df["highlight"]], x="mu_base", y="mu_ft", ax=ax, s=14, color="#5B6E8F", alpha=0.65) if df["highlight"].any(): sns.scatterplot(df[df["highlight"]], x="mu_base", y="mu_ft", ax=ax, s=140, color="#D55E00", marker="X", label="maze tiles") for _, row in df[df["highlight"]].iterrows(): ax.annotate(row["item"], (row["mu_base"], row["mu_ft"]), textcoords="offset points", xytext=(8, 6), fontsize=14) lo = min(df["mu_base"].min(), df["mu_ft"].min()) hi = max(df["mu_base"].max(), df["mu_ft"].max()) pad = 0.05 * (hi - lo) if hi > lo else 0.5 ax.plot([lo - pad, hi + pad], [lo - pad, hi + pad], "k--", lw=1, alpha=0.4) ax.set_xlabel("μ (base Gemma-3-27B-it)") ax.set_ylabel("μ (functional-wellbeing FT)") ax.set_title(title) fig.tight_layout() fig.savefig(out, bbox_inches="tight") plt.close(fig) def plot_delta_distributions(d_concepts: pd.DataFrame, d_emoji: pd.DataFrame, out: Path) -> None: df = pd.concat( [ d_concepts.assign(dataset="concepts"), d_emoji.assign(dataset="emoji"), ], ignore_index=True, ) fig, ax = plt.subplots(figsize=(7, 4)) sns.kdeplot(df, x="delta_mu", hue="dataset", common_norm=False, fill=True, alpha=0.35, ax=ax) maze = d_emoji[d_emoji["item"].isin(MAZE_TILES)] for _, r in maze.iterrows(): ax.axvline(r["delta_mu"], color="#D55E00", lw=1.2, alpha=0.8) ax.annotate(r["item"], xy=(r["delta_mu"], ax.get_ylim()[1] * 0.92), ha="center", fontsize=14) ax.axvline(0, color="k", lw=0.6, alpha=0.5) ax.set_xlabel("Δμ (FT - base)") ax.set_title("Distribution of per-item Δμ; orange lines = maze tiles 🧾📇📐") fig.tight_layout() fig.savefig(out, bbox_inches="tight") plt.close(fig) def write_summary(table: pd.DataFrame, d_concepts: pd.DataFrame, d_emoji: pd.DataFrame, out: Path) -> None: maze = d_emoji[d_emoji["item"].isin(MAZE_TILES)].set_index("item") rest_emoji = d_emoji[~d_emoji["item"].isin(MAZE_TILES)] def metric(run: str, key: str) -> float | None: s = table[table["run"] == run][key] return float(s.iloc[0]) if len(s) else None lines = [] lines.append("# Aligne stated-preference analysis: base Gemma-3-27B-it vs functional-wellbeing FT\n") lines.append(f"Generated: {datetime.now(timezone.utc).isoformat()}\n") lines.append("## Headline metrics (decisiveness, unidim_r²)\n") lines.append("| Run | decisiveness | unidim_r² | transitivity_triad | q_agreement | n_edges |") lines.append("|---|---|---|---|---|---|") for run in RUN_NAMES: lines.append( "| {} | {:.4f} | {:.4f} | {:.4f} | {:.4f} | {} |".format( run, metric(run, "decisiveness") or float("nan"), metric(run, "unidim_r2") or float("nan"), metric(run, "transitivity_triad") or float("nan"), metric(run, "q_agreement") or float("nan"), int(metric(run, "n_edges") or 0), ) ) def delta(metric_name: str, ds: str) -> str: b = metric(f"base_{ds}", metric_name) f = metric(f"ft_{ds}", metric_name) if b is None or f is None: return "n/a" return f"{f - b:+.4f} (base {b:.4f} -> FT {f:.4f})" lines.append("\n## Decisiveness / r² shifts (FT − base)\n") for ds in ("concepts", "emoji"): lines.append(f"- **{ds}**: Δdecisiveness = {delta('decisiveness', ds)}; Δunidim_r² = {delta('unidim_r2', ds)}") lines.append("\n## μ for the three maze tiles (emoji panel)\n") lines.append("| Tile | μ base | μ FT | Δμ |") lines.append("|---|---|---|---|") for tile in MAZE_TILES: if tile in maze.index: r = maze.loc[tile] lines.append(f"| {tile} | {r['mu_base']:+.4f} | {r['mu_ft']:+.4f} | {r['delta_mu']:+.4f} |") else: lines.append(f"| {tile} | (missing) | (missing) | n/a |") if not rest_emoji.empty: lines.append( f"\nFor reference, across the **{len(rest_emoji)} non-maze emoji**: " f"mean Δμ = {rest_emoji['delta_mu'].mean():+.4f}, " f"std Δμ = {rest_emoji['delta_mu'].std():+.4f}, " f"abs-mean = {rest_emoji['delta_mu'].abs().mean():.4f}." ) if not d_concepts.empty: lines.append( f"\nAcross the **{len(d_concepts)} bundled concepts**: " f"mean Δμ = {d_concepts['delta_mu'].mean():+.4f}, " f"std Δμ = {d_concepts['delta_mu'].std():+.4f}, " f"abs-mean = {d_concepts['delta_mu'].abs().mean():.4f}." ) # Top movers for label, df in (("concepts", d_concepts), ("emoji", d_emoji)): if df.empty: continue top_up = df.nlargest(5, "delta_mu") top_dn = df.nsmallest(5, "delta_mu") lines.append(f"\n### Top 5 increased μ ({label})") for _, r in top_up.iterrows(): lines.append(f"- {r['item']!r}: {r['mu_base']:+.3f} → {r['mu_ft']:+.3f} (Δ {r['delta_mu']:+.3f})") lines.append(f"\n### Top 5 decreased μ ({label})") for _, r in top_dn.iterrows(): lines.append(f"- {r['item']!r}: {r['mu_base']:+.3f} → {r['mu_ft']:+.3f} (Δ {r['delta_mu']:+.3f})") out.write_text("\n".join(lines)) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--out-name", default="analysis", help="run_dir suffix under logs/") args = parser.parse_args() sns.set_theme(style="whitegrid", context="talk") runs: dict[str, RunData] = {} for n in RUN_NAMES: runs[n] = load_run(n) table = metrics_table(runs) d_concepts = delta_mu(runs["base_concepts"], runs["ft_concepts"]) d_emoji = delta_mu(runs["base_emoji"], runs["ft_emoji"]) out_dir = make_run_dir(args.out_name, config={"runs_used": {n: str(r.run_dir.name) for n, r in runs.items()}}) table.to_csv(out_dir / "metrics_table.csv", index=False) d_concepts.to_csv(out_dir / "delta_mu_concepts.csv", index=False) d_emoji.to_csv(out_dir / "delta_mu_emoji.csv", index=False) plot_metrics(table, out_dir / "fig_metrics.pdf") plot_mu_scatter(d_concepts, out_dir / "fig_mu_concepts.pdf", "Per-concept μ (base vs FT)", highlight=None) plot_mu_scatter(d_emoji, out_dir / "fig_mu_emoji.pdf", "Per-emoji μ (base vs FT)", highlight=MAZE_TILES) plot_delta_distributions(d_concepts, d_emoji, out_dir / "fig_delta_distribution.pdf") write_summary(table, d_concepts, d_emoji, out_dir / "RESULTS.md") print("\n=== metrics ===") print(table.to_string(index=False)) print(f"\nwrote analysis to {out_dir}") if __name__ == "__main__": main()