""" Statistical analysis and figure generation for the paper. Produces: - Figure 1: Solve rate × complexity level curves (per agent type) - Figure 2: Belief accuracy trajectory over steps - Figure 3: Clue efficiency bar chart - Figure 4: Token cost scaling - Table 2: Full numeric results """ from __future__ import annotations import json from pathlib import Path from typing import Any import matplotlib.pyplot as plt import numpy as np import pandas as pd import seaborn as sns from scipy import stats from evaluation.metrics import AggregateMetrics, EpisodeMetrics, aggregate_metrics def load_results(results_dir: str | Path) -> pd.DataFrame: """Load all episode metric files from a results directory into a DataFrame.""" results_dir = Path(results_dir) records = [] metrics_file = results_dir / "all_metrics.json" if metrics_file.exists(): data = json.loads(metrics_file.read_text()) records.extend(data) else: for f in results_dir.glob("episode_*.json"): ep = json.loads(f.read_text()) if ep.get("metrics"): records.append(ep["metrics"]) return pd.DataFrame(records) def compute_aggregate_table( dfs: dict[str, pd.DataFrame], levels: list[int] = [1, 2, 3, 4, 5], ) -> pd.DataFrame: """ Compute Table 2 (main results) across agent types and complexity levels. Parameters ---------- dfs : dict[str, DataFrame] Agent name → DataFrame of episode metrics. levels : list[int] Complexity levels. Returns ------- DataFrame with rows = (agent, level) and columns = metric names. """ rows = [] for agent_name, df in dfs.items(): for level in levels: level_df = df[df["complexity_level"] == level] if level_df.empty: continue n = len(level_df) sr = level_df["solved"].mean() rows.append({ "agent": agent_name, "level": level, "n": n, "solve_rate": sr, "solve_rate_ci95": 1.96 * np.sqrt(sr * (1 - sr) / max(n, 1)), "partial_score": level_df["partial_score"].mean(), "belief_accuracy": level_df["final_belief_accuracy"].mean(), "clue_efficiency": level_df["clue_efficiency"].mean(), "mean_tokens": level_df["total_tokens"].mean(), "action_efficiency": level_df["action_efficiency"].mean(), }) return pd.DataFrame(rows) def significance_test( df_a: pd.DataFrame, df_b: pd.DataFrame, metric: str = "solved" ) -> dict[str, float]: """Two-sample proportion test (or t-test) between two agent result sets.""" a = df_a[metric].values.astype(float) b = df_b[metric].values.astype(float) if metric == "solved": # Two-proportion z-test p1, p2 = a.mean(), b.mean() n1, n2 = len(a), len(b) p_pool = (p1 * n1 + p2 * n2) / (n1 + n2) se = np.sqrt(p_pool * (1 - p_pool) * (1 / n1 + 1 / n2)) if p_pool > 0 else 1e-10 z = (p1 - p2) / se p_value = 2 * (1 - stats.norm.cdf(abs(z))) return {"z_statistic": float(z), "p_value": float(p_value), "effect_size": float(p1 - p2)} else: t_stat, p_value = stats.ttest_ind(a, b, equal_var=False) cohens_d = (a.mean() - b.mean()) / np.sqrt((a.std() ** 2 + b.std() ** 2) / 2) if (a.std() + b.std()) > 0 else 0 return {"t_statistic": float(t_stat), "p_value": float(p_value), "cohens_d": float(cohens_d)} # --------------------------------------------------------------------------- # Plotting functions # --------------------------------------------------------------------------- def set_paper_style() -> None: """Set matplotlib style suitable for ACL/EMNLP/ICLR papers.""" sns.set_theme(style="whitegrid", font_scale=1.2) plt.rcParams.update({ "figure.figsize": (7, 4.5), "figure.dpi": 300, "font.family": "serif", "font.serif": ["Times New Roman", "DejaVu Serif"], "axes.labelsize": 13, "axes.titlesize": 14, "legend.fontsize": 11, "xtick.labelsize": 11, "ytick.labelsize": 11, }) def plot_solve_rate_vs_complexity( dfs: dict[str, pd.DataFrame], output_path: str | Path, levels: list[int] = [1, 2, 3, 4, 5], ) -> None: """ Figure 1: Solve rate × complexity level curves per agent type. """ set_paper_style() fig, ax = plt.subplots() markers = ["o", "s", "^", "D", "v"] colors = sns.color_palette("colorblind", len(dfs)) for i, (agent_name, df) in enumerate(dfs.items()): means, cis, xs = [], [], [] for level in levels: level_df = df[df["complexity_level"] == level] if level_df.empty: continue sr = level_df["solved"].mean() n = len(level_df) ci = 1.96 * np.sqrt(sr * (1 - sr) / max(n, 1)) means.append(sr) cis.append(ci) xs.append(level) ax.errorbar(xs, means, yerr=cis, marker=markers[i % len(markers)], color=colors[i], label=agent_name, linewidth=2, capsize=4) ax.set_xlabel("Complexity Level") ax.set_ylabel("Solve Rate") ax.set_title("Solve Rate vs. Complexity Level") ax.set_xticks(levels) ax.set_xticklabels(["Trivial", "Easy", "Medium", "Hard", "Expert"]) ax.set_ylim(-0.05, 1.05) ax.legend() fig.tight_layout() fig.savefig(output_path, bbox_inches="tight") plt.close(fig) def plot_belief_accuracy_trajectory( results: dict[str, list[list[float]]], # agent -> list of belief traces output_path: str | Path, ) -> None: """ Figure 2: Mean belief accuracy over steps (how quickly agents converge). """ set_paper_style() fig, ax = plt.subplots() colors = sns.color_palette("colorblind", len(results)) for i, (agent_name, traces) in enumerate(results.items()): # Pad traces to same length max_len = max(len(t) for t in traces) if traces else 0 padded = np.full((len(traces), max_len), np.nan) for j, t in enumerate(traces): padded[j, :len(t)] = t mean_trace = np.nanmean(padded, axis=0) std_trace = np.nanstd(padded, axis=0) steps = np.arange(max_len) ax.plot(steps, mean_trace, color=colors[i], label=agent_name, linewidth=2) ax.fill_between(steps, mean_trace - std_trace, mean_trace + std_trace, alpha=0.2, color=colors[i]) ax.set_xlabel("Step") ax.set_ylabel("Belief Accuracy (P(true culprit))") ax.set_title("Belief Accuracy Trajectory") ax.legend() fig.tight_layout() fig.savefig(output_path, bbox_inches="tight") plt.close(fig) def plot_clue_efficiency_bar( dfs: dict[str, pd.DataFrame], output_path: str | Path, levels: list[int] = [1, 2, 3, 4, 5], ) -> None: """ Figure 3: Clue efficiency grouped bar chart. """ set_paper_style() fig, ax = plt.subplots() n_agents = len(dfs) bar_width = 0.8 / n_agents level_labels = ["Trivial", "Easy", "Medium", "Hard", "Expert"] colors = sns.color_palette("colorblind", n_agents) for i, (agent_name, df) in enumerate(dfs.items()): means = [] for level in levels: level_df = df[df["complexity_level"] == level] means.append(level_df["clue_efficiency"].mean() if not level_df.empty else 0) x = np.arange(len(levels)) + i * bar_width ax.bar(x, means, bar_width, label=agent_name, color=colors[i]) ax.set_xlabel("Complexity Level") ax.set_ylabel("Clue Efficiency") ax.set_title("Evidence Discovery Efficiency") ax.set_xticks(np.arange(len(levels)) + bar_width * (n_agents - 1) / 2) ax.set_xticklabels(level_labels) ax.legend() fig.tight_layout() fig.savefig(output_path, bbox_inches="tight") plt.close(fig) def plot_token_cost( dfs: dict[str, pd.DataFrame], output_path: str | Path, levels: list[int] = [1, 2, 3, 4, 5], ) -> None: """ Figure 4: Token cost scaling across complexity. """ set_paper_style() fig, ax = plt.subplots() markers = ["o", "s", "^"] colors = sns.color_palette("colorblind", len(dfs)) level_labels = ["Trivial", "Easy", "Medium", "Hard", "Expert"] for i, (agent_name, df) in enumerate(dfs.items()): means, stds = [], [] for level in levels: level_df = df[df["complexity_level"] == level] means.append(level_df["total_tokens"].mean() if not level_df.empty else 0) stds.append(level_df["total_tokens"].std() if not level_df.empty else 0) ax.errorbar(levels, means, yerr=stds, marker=markers[i % len(markers)], color=colors[i], label=agent_name, linewidth=2, capsize=4) ax.set_xlabel("Complexity Level") ax.set_ylabel("Total Tokens") ax.set_title("Token Cost Scaling") ax.set_xticks(levels) ax.set_xticklabels(level_labels) ax.legend() fig.tight_layout() fig.savefig(output_path, bbox_inches="tight") plt.close(fig) def generate_all_figures( results_dirs: dict[str, str | Path], output_dir: str | Path, ) -> None: """ Generate all paper figures. Parameters ---------- results_dirs : dict[str, Path] Agent name → results directory. output_dir : Path Where to save figures. """ output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) dfs = {} for name, rdir in results_dirs.items(): dfs[name] = load_results(rdir) plot_solve_rate_vs_complexity(dfs, output_dir / "fig1_solve_rate.pdf") plot_clue_efficiency_bar(dfs, output_dir / "fig3_clue_efficiency.pdf") plot_token_cost(dfs, output_dir / "fig4_token_cost.pdf") # Belief trajectory requires loading full episode results belief_traces: dict[str, list[list[float]]] = {} for name, rdir in results_dirs.items(): rdir = Path(rdir) traces = [] for f in rdir.glob("episode_*.json"): ep = json.loads(f.read_text()) bt = ep.get("belief_trace", []) trace = [s.get("suspect_probs", {}).get(list(s.get("suspect_probs", {}).keys())[0], 0) if s.get("suspect_probs") else 0 for s in bt] traces.append(trace) belief_traces[name] = traces plot_belief_accuracy_trajectory(belief_traces, output_dir / "fig2_belief_accuracy.pdf") # Table table_df = compute_aggregate_table(dfs) table_df.to_csv(output_dir / "table2_results.csv", index=False) print(f"All figures saved to {output_dir}")