From 0f708aab152dd21647b82c1fb3a45c0a34404666 Mon Sep 17 00:00:00 2001 From: Daniel Rosel Date: Wed, 11 Mar 2026 11:48:51 +0100 Subject: [PATCH] feat: simple margin proving study --- Makefile | 13 +++ engine/studies/margin_erosion_alpha.py | 130 +++++++++++++++++++++++++ engine/studies/plot_margin_erosion.py | 126 ++++++++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 engine/studies/margin_erosion_alpha.py create mode 100644 engine/studies/plot_margin_erosion.py diff --git a/Makefile b/Makefile index 94e7e2a..8a7d203 100644 --- a/Makefile +++ b/Makefile @@ -37,6 +37,7 @@ SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" || help: @echo "pdf.build pdf.watch pdf.clean pdf.genpop pdf.genpop.watch pdf.arxiv | test.backend test.e2e test.all | web.dev | install | train | benchmark | benchmark.simple | benchmark.agent | train.agent | train.bootstrap | stats.lines" @echo "backend.server backend.provider backend.worker | platform.up platform.down platform.logs | docker.train.publish" + @echo "study.margin-erosion study.margin-erosion.quick study.margin-erosion.plot" @echo "" @echo "Build general public version:" @echo " make pdf.genpop" @@ -137,6 +138,18 @@ train.bootstrap: stats.lines: @$(NX) run research:stats +.PHONY: study.margin-erosion +study.margin-erosion: + python -m engine.studies.margin_erosion_alpha + +.PHONY: study.margin-erosion.quick +study.margin-erosion.quick: + python -m engine.studies.margin_erosion_alpha --quick + +.PHONY: study.margin-erosion.plot +study.margin-erosion.plot: + python -m engine.studies.plot_margin_erosion engine/studies/results/margin_erosion_alpha_*.json + .PHONY: wordcount wordcount: @$(NX) run paper:wordcount diff --git a/engine/studies/margin_erosion_alpha.py b/engine/studies/margin_erosion_alpha.py new file mode 100644 index 0000000..ef2dc79 --- /dev/null +++ b/engine/studies/margin_erosion_alpha.py @@ -0,0 +1,130 @@ +"""validate core thesis problem: margin erosion under agent contamination +trains standard RL (no robust components) across α levels to demonstrate systematic failure +""" + +from __future__ import annotations +import json, sys, time +from pathlib import Path +import numpy as np + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) +from engine.spec import TrainSpec +from engine.orchestrators import run_train_once + + +def _run_baseline(alpha: float, algo: str, seed: int, steps: int) -> dict: + spec = TrainSpec.from_flat( + { + "algo": algo, + "seed": seed, + "alpha": alpha, + "total_timesteps": steps, + "lambda_coi": 0.0, + "robust_radius": 0.0, + "robust_points": 1, + "robust_rollouts": 1, + "no_robust": True, + "arch": "small", + "n_products": 10, + "N": 100, + "max_steps": 50, + "eval_freq": 5000, + "eval_episodes": 10, + "log_freq": 500, + } + ) + result = run_train_once( + spec, + project="phantom-margin-erosion", + offline=True, + no_wandb=True, + kind="study", + scenario=f"alpha{int(alpha * 100):02d}", + group=f"baseline_{algo}", + extra_tags=("margin_erosion", "baseline"), + ) + return { + "alpha": alpha, + "algo": algo, + "seed": seed, + "eval_reward": result.get("eval/reward_mean", np.nan), + "eval_revenue": result.get("eval/revenue_mean", np.nan), + "eval_coi_level": result.get("eval/coi_level_mean", np.nan), + "eval_margin": result.get("eval/margin_mean", np.nan), + "eval_agent_prob": result.get("eval/agent_prob_mean", np.nan), + } + + +def run_margin_erosion_study( + alphas: list[float] | None = None, + algos: list[str] | None = None, + seeds: int = 3, + steps: int = 30_000, +) -> dict: + alphas = alphas or [0.1, 0.3, 0.5, 0.7, 0.9] + algos = algos or ["ppo", "dqn", "qtable"] + output_dir = Path(__file__).parent / "results" + output_dir.mkdir(exist_ok=True) + ts = time.strftime("%Y%m%d_%H%M%S") + + results = [] + for α in alphas: + for algo in algos: + for si in range(seeds): + seed = 42 + si + print(f"α={α:.1f} {algo} seed={seed}") + m = _run_baseline(α, algo, seed, steps) + results.append(m) + print( + f" margin={m['eval_margin']:.3f} rev={m['eval_revenue']:.0f} coi={m['eval_coi_level']:.1f}" + ) + + summary = {} + for α in alphas: + runs = [r for r in results if abs(r["alpha"] - α) < 0.01] + if not runs: + continue + s = {} + for metric in ["margin", "revenue", "coi_level", "agent_prob"]: + vals = [r[f"eval_{metric}"] for r in runs] + s[f"{metric}_mean"] = float(np.mean(vals)) + s[f"{metric}_std"] = float(np.std(vals)) + s["n_runs"] = len(runs) + summary[f"alpha_{α:.1f}"] = s + + output = { + "timestamp": ts, + "config": {"alphas": alphas, "algos": algos, "seeds": seeds, "steps": steps}, + "results": results, + "summary": summary, + } + + path = output_dir / f"margin_erosion_alpha_{ts}.json" + with open(path, "w") as f: + json.dump(output, f, indent=2) + + print(f"\n→ {path}") + for α in alphas: + k = f"alpha_{α:.1f}" + if k in summary: + s = summary[k] + print( + f" {k}: margin={s['margin_mean']:.3f}±{s['margin_std']:.3f} " + f"coi={s['coi_level_mean']:.1f}±{s['coi_level_std']:.1f}" + ) + return output + + +if __name__ == "__main__": + import argparse + + p = argparse.ArgumentParser(description="margin erosion vs α") + p.add_argument("--quick", action="store_true", help="fast test") + args = p.parse_args() + + run_margin_erosion_study( + alphas=[0.1, 0.7] if args.quick else [0.1, 0.3, 0.5, 0.7, 0.9], + algos=["qtable"] if args.quick else ["ppo", "dqn", "qtable"], + seeds=1 if args.quick else 3, + steps=5_000 if args.quick else 30_000, + ) diff --git a/engine/studies/plot_margin_erosion.py b/engine/studies/plot_margin_erosion.py new file mode 100644 index 0000000..021a8b5 --- /dev/null +++ b/engine/studies/plot_margin_erosion.py @@ -0,0 +1,126 @@ +"""plot margin erosion: margin/COI/revenue vs α with thesis-quality formatting""" + +import json, sys +from pathlib import Path +import numpy as np +import matplotlib.pyplot as plt +import matplotlib as mpl + +mpl.rcParams.update( + { + "font.size": 10, + "axes.labelsize": 11, + "axes.titlesize": 12, + "xtick.labelsize": 9, + "ytick.labelsize": 9, + "legend.fontsize": 9, + "figure.figsize": (7, 4), + "figure.dpi": 150, + "lines.linewidth": 1.5, + "lines.markersize": 6, + "errorbar.capsize": 3, + "grid.alpha": 0.3, + } +) + + +def plot_margin_erosion(data: dict, out: Path): + s = data["summary"] + αs = sorted([float(k.split("_")[1]) for k in s.keys()]) + + def get(metric): + return ( + [s[f"alpha_{α:.1f}"][f"{metric}_mean"] for α in αs], + [s[f"alpha_{α:.1f}"][f"{metric}_std"] for α in αs], + ) + + margins, margin_e = get("margin") + cois, coi_e = get("coi_level") + revs, rev_e = get("revenue") + + fig, axes = plt.subplots(1, 3, figsize=(12, 3.5)) + + axes[0].errorbar( + αs, + margins, + yerr=margin_e, + marker="o", + capsize=4, + label="Standard RL", + color="#d62728", + ) + axes[0].axhline(0.05, color="gray", linestyle="--", linewidth=1, label="Floor") + axes[0].set( + xlabel="Agent proportion (α)", + ylabel="Margin", + title="Margin erosion", + ylim=(0, max(margins) * 1.2), + ) + axes[0].grid(alpha=0.3) + axes[0].legend(loc="upper right") + + axes[1].errorbar(αs, cois, yerr=coi_e, marker="s", capsize=4, color="#ff7f0e") + axes[1].set( + xlabel="Agent proportion (α)", + ylabel="COI", + title="COI collapse (E[P] - p_min)", + ylim=(0, None), + ) + axes[1].grid(alpha=0.3) + + axes[2].errorbar(αs, revs, yerr=rev_e, marker="^", capsize=4, color="#2ca02c") + axes[2].set( + xlabel="Agent proportion (α)", + ylabel="Revenue", + title="Revenue degradation", + ylim=(0, None), + ) + axes[2].grid(alpha=0.3) + + plt.tight_layout() + pdf = out / "margin_erosion_alpha.pdf" + png = out / "margin_erosion_alpha.png" + plt.savefig(pdf, bbox_inches="tight", dpi=300) + plt.savefig(png, bbox_inches="tight", dpi=150) + print(f"→ {pdf}\n→ {png}") + + +def print_latex(data: dict): + s = data["summary"] + αs = sorted([float(k.split("_")[1]) for k in s.keys()]) + + print("\n% LaTeX table for appendix") + print("\\begin{table}[h]\n\\centering") + print("\\caption{Margin erosion: standard RL under agent contamination}") + print("\\label{tab:margin_erosion}") + print("\\begin{tabular}{cccc}\n\\toprule") + print("α & Margin & COI & Revenue \\\\\n\\midrule") + + for α in αs: + d = s[f"alpha_{α:.1f}"] + print( + f"{α:.1f} & ${d['margin_mean']:.3f} \\pm {d['margin_std']:.3f}$ & " + f"${d['coi_level_mean']:.1f} \\pm {d['coi_level_std']:.1f}$ & " + f"${d['revenue_mean']:.0f} \\pm {d['revenue_std']:.0f}$ \\\\" + ) + + print("\\bottomrule\n\\end{tabular}\n\\end{table}") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + sys.exit("usage: python -m engine.studies.plot_margin_erosion ") + + path = Path(sys.argv[1]) + if not path.exists(): + sys.exit(f"error: {path} not found") + + with open(path) as f: + data = json.load(f) + + plot_margin_erosion(data, path.parent) + print_latex(data) + print( + f"\n{len(data['results'])} runs, {len(data['summary'])} α levels, " + f"algos={data['config']['algos']}, seeds={data['config']['seeds']}" + )