mirror of
https://github.com/velocitatem/PHANTOM.git
synced 2026-05-31 08:33:36 +00:00
feat: simple margin proving study
This commit is contained in:
13
Makefile
13
Makefile
@@ -37,6 +37,7 @@ SWEEP_ENV_LOAD = set -a; [ -f "$(SWEEP_ENV_FILE)" ] && . "$(SWEEP_ENV_FILE)" ||
|
|||||||
help:
|
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 "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 "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 ""
|
||||||
@echo "Build general public version:"
|
@echo "Build general public version:"
|
||||||
@echo " make pdf.genpop"
|
@echo " make pdf.genpop"
|
||||||
@@ -137,6 +138,18 @@ train.bootstrap:
|
|||||||
stats.lines:
|
stats.lines:
|
||||||
@$(NX) run research:stats
|
@$(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
|
.PHONY: wordcount
|
||||||
wordcount:
|
wordcount:
|
||||||
@$(NX) run paper:wordcount
|
@$(NX) run paper:wordcount
|
||||||
|
|||||||
130
engine/studies/margin_erosion_alpha.py
Normal file
130
engine/studies/margin_erosion_alpha.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
126
engine/studies/plot_margin_erosion.py
Normal file
126
engine/studies/plot_margin_erosion.py
Normal file
@@ -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 <results.json>")
|
||||||
|
|
||||||
|
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']}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user