| |
| from __future__ import annotations |
|
|
| import argparse |
| import re |
| from collections import Counter |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
| from scipy import stats |
|
|
| ROOT = Path(__file__).resolve().parents[1] |
|
|
|
|
| _TOK_RE = re.compile( |
| r""" |
| (A\s*\[\]|E\s*<>|A\s*<>|E\s*\[\]) |
| |(-->) |
| |(&&|\|\||<=|>=|==|!=) |
| |([(){}\[\],]) |
| |(\bforall\b|\bexists\b|\bimply\b|\bnot\b|\bdeadlock\b) |
| |([A-Za-z_]\w*) |
| |(\d+) |
| """, |
| re.VERBOSE | re.IGNORECASE, |
| ) |
|
|
|
|
| def _norm_ws(s: str) -> str: |
| return re.sub(r"\s+", " ", (s or "").strip()) |
|
|
|
|
| def tokenize_tctl(s: str) -> list[str]: |
| s = _norm_ws(s) |
| return [t for tup in _TOK_RE.findall(s) for t in tup if t] |
|
|
|
|
| def token_f1_pair(gen: str, gold: str) -> float: |
| cg = Counter(tokenize_tctl(gen)) |
| ch = Counter(tokenize_tctl(gold)) |
| inter = sum((cg & ch).values()) |
| tot_g = sum(cg.values()) |
| tot_h = sum(ch.values()) |
| if tot_g == 0 and tot_h == 0: |
| return 1.0 |
| p = inter / tot_g if tot_g else 0.0 |
| r = inter / tot_h if tot_h else 0.0 |
| return 0.0 if (p + r) == 0 else (2 * p * r / (p + r)) |
|
|
|
|
| def rm_anova_oneway(wide: pd.DataFrame, subject_col: str, condition_cols: list[str]) -> dict[str, float]: |
| """ |
| One-way repeated-measures ANOVA (k conditions, n subjects). |
| Assumes balanced data with one observation per subject x condition. |
| """ |
| y = wide[condition_cols].to_numpy(dtype=float) |
| n, k = y.shape |
| grand = float(np.mean(y)) |
| cond_means = np.mean(y, axis=0) |
| subj_means = np.mean(y, axis=1) |
|
|
| ss_total = float(np.sum((y - grand) ** 2)) |
| ss_conditions = float(n * np.sum((cond_means - grand) ** 2)) |
| ss_subjects = float(k * np.sum((subj_means - grand) ** 2)) |
| ss_error = ss_total - ss_conditions - ss_subjects |
|
|
| df_conditions = k - 1 |
| df_subjects = n - 1 |
| df_error = df_conditions * df_subjects |
|
|
| ms_conditions = ss_conditions / df_conditions |
| ms_error = ss_error / df_error |
| f = ms_conditions / ms_error |
| p = float(stats.f.sf(f, df_conditions, df_error)) |
| partial_eta2 = ss_conditions / (ss_conditions + ss_error) if (ss_conditions + ss_error) else float("nan") |
|
|
| return { |
| "n_subjects": n, |
| "k_conditions": k, |
| "SS_conditions": ss_conditions, |
| "SS_error": ss_error, |
| "df_conditions": df_conditions, |
| "df_error": df_error, |
| "F": float(f), |
| "p": p, |
| "partial_eta2": float(partial_eta2), |
| } |
|
|
|
|
| def holm_adjust(pvals: list[float]) -> list[float]: |
| m = len(pvals) |
| order = np.argsort(pvals) |
| adj = np.empty(m, dtype=float) |
| for rank, idx in enumerate(order): |
| adj[idx] = (m - rank) * pvals[idx] |
| |
| for i in range(1, m): |
| prev_idx = order[i - 1] |
| cur_idx = order[i] |
| adj[cur_idx] = max(adj[cur_idx], adj[prev_idx]) |
| return [float(min(1.0, x)) for x in adj] |
|
|
|
|
| def main() -> int: |
| ap = argparse.ArgumentParser(description="Repeated-measures ANOVA on per-query Token-F1 (RQ2).") |
| ap.add_argument( |
| "--sheet", |
| type=Path, |
| default=ROOT / "artifacts" / "rq2_query_translation_sheet.tsv", |
| help="TSV produced by scripts/rq2_query_translation_eval.py", |
| ) |
| args = ap.parse_args() |
|
|
| df = pd.read_csv(args.sheet, sep="\t") |
| |
| gold = df["ground_query_adapted_ref_xml"].fillna("").map(str).tolist() |
|
|
| methods = { |
| "ours": df["pred_ours"].fillna("").map(str).tolist(), |
| "gpt": df["pred_gpt"].fillna("").map(str).tolist(), |
| "grok": df["pred_grok"].fillna("").map(str).tolist(), |
| "claude": df["pred_claude"].fillna("").map(str).tolist(), |
| } |
|
|
| rows = [] |
| for j in range(len(df)): |
| for m, preds in methods.items(): |
| rows.append( |
| { |
| "query_id": j, |
| "method": m, |
| "token_f1": token_f1_pair(preds[j], gold[j]), |
| } |
| ) |
| long = pd.DataFrame(rows) |
| wide = long.pivot(index="query_id", columns="method", values="token_f1").reset_index() |
| conds = ["ours", "gpt", "grok", "claude"] |
| if any(c not in wide.columns for c in conds): |
| missing = [c for c in conds if c not in wide.columns] |
| raise SystemExit(f"Missing columns in pivot: {missing}") |
| if wide[conds].isna().any().any(): |
| raise SystemExit("Found NaNs; expected full matrix for repeated-measures ANOVA.") |
|
|
| |
| res = rm_anova_oneway(wide, subject_col="query_id", condition_cols=conds) |
| means = {c: float(wide[c].mean()) for c in conds} |
| sds = {c: float(wide[c].std(ddof=1)) for c in conds} |
|
|
| print("RQ2 Token-F1 per query (adapted gold) — repeated-measures ANOVA (one-way)") |
| print(f"n queries: {int(res['n_subjects'])}, k methods: {int(res['k_conditions'])}") |
| print("\nDescriptives (mean ± sd):") |
| for c in conds: |
| print(f" {c:6} {means[c]:.3f} ± {sds[c]:.3f}") |
| print("\nRM-ANOVA:") |
| print(f" F({int(res['df_conditions'])}, {int(res['df_error'])}) = {res['F']:.4f}") |
| print(f" p = {res['p']:.6g}") |
| print(f" partial eta^2 = {res['partial_eta2']:.4f}") |
|
|
| |
| pairs = [("ours", "gpt"), ("ours", "grok"), ("ours", "claude"), ("claude", "gpt"), ("claude", "grok"), ("gpt", "grok")] |
| pvals = [] |
| stats_out = [] |
| for a, b in pairs: |
| t, p = stats.ttest_rel(wide[a].to_numpy(), wide[b].to_numpy()) |
| pvals.append(float(p)) |
| stats_out.append((a, b, float(t), float(p), float((wide[a] - wide[b]).mean()))) |
| p_holm = holm_adjust(pvals) |
|
|
| print("\nPost-hoc paired t-tests (Holm-adjusted p-values):") |
| for (a, b, t, p, dmean), padj in zip(stats_out, p_holm): |
| print(f" {a:6} vs {b:6} mean_diff={dmean:+.4f} t={t:+.4f} p={p:.6g} p_holm={padj:.6g}") |
|
|
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|
|
|