Spaces:
Paused
Paused
Sync Space with HCM-21-Private main (72f5f30): reward recalibration, seeded RNG streams, MCP integration, poster artifacts
670ccf0 verified | """Generate REAL baseline data for the HCM:21 PyTorch poster. | |
| Runs two non-LLM policies through the standalone environment (no server, no API key): | |
| - Random agent : picks valid actions per phase at random, advances when allowed | |
| - Heuristic agent: the built-in data-driven strategy from demo.py | |
| Across 5 fixed seeds, records final episode score (0-1) and the per-quarter | |
| reward trajectory. Saves results to results.json and renders poster_figure.png. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import random | |
| from statistics import mean, pstdev | |
| import numpy as np | |
| from demo import run_demo_json | |
| from hr_env.models import HRAction | |
| from hr_env.server.environment import HRProductivityEnvironment | |
| from hr_env.server.phases import PHASE_ACTIONS, PHASE_MIN_ACTIONS, PHASES | |
| SEEDS = [42, 99, 123, 456, 789] | |
| SIZE = 300 | |
| DEPARTMENTS = ["Engineering", "Sales", "Operations", "HR", "Finance"] | |
| def _random_action(phase: str, rng: random.Random) -> HRAction: | |
| """Build a syntactically valid random action for the given phase.""" | |
| atype = rng.choice(PHASE_ACTIONS[phase]) | |
| dept = rng.choice(DEPARTMENTS) | |
| if atype == "query_department": | |
| return HRAction(action_type=atype, department=dept) | |
| if atype == "query_employees": | |
| return HRAction(action_type=atype, parameters={"min_performance": 0.0}) | |
| if atype == "calculate_metric": | |
| return HRAction(action_type=atype, metric_name="all") | |
| if atype == "review_financials": | |
| return HRAction(action_type=atype) | |
| if atype == "set_hiring_target": | |
| return HRAction(action_type=atype, department=dept, count=rng.randint(0, 10)) | |
| if atype == "set_training_budget": | |
| return HRAction(action_type=atype, department=dept, amount=rng.randint(0, 50_000)) | |
| if atype == "set_compensation_policy": | |
| return HRAction(action_type=atype, department=dept, amount=rng.uniform(0, 5)) | |
| if atype == "set_retention_program": | |
| return HRAction(action_type=atype, department=dept, amount=rng.randint(0, 30_000)) | |
| if atype == "execute_hiring": | |
| return HRAction(action_type=atype, department=dept, count=rng.randint(0, 8)) | |
| if atype == "execute_training": | |
| return HRAction(action_type=atype, department=dept, amount=rng.randint(0, 20)) | |
| if atype in ("execute_promotion", "execute_transfer", "execute_termination"): | |
| return HRAction(action_type=atype, employee_ids=[]) | |
| if atype == "submit_report": | |
| return HRAction(action_type=atype) | |
| return HRAction(action_type="review_financials") | |
| def run_null_episode(seed: int, size: int = SIZE) -> dict: | |
| """Do-nothing baseline: satisfy phase minimums with no-op actions only.""" | |
| env = HRProductivityEnvironment() | |
| env.reset(seed=seed, size=size) | |
| final_score = 0.0 | |
| for _q in range(1, 7): | |
| for phase in PHASES: | |
| for _ in range(PHASE_MIN_ACTIONS[phase]): | |
| if phase == "scanning": | |
| env.step(HRAction(action_type="review_financials")) | |
| elif phase == "planning": | |
| env.step(HRAction(action_type="set_hiring_target", department="HR", count=0)) | |
| elif phase == "producing": | |
| env.step(HRAction(action_type="execute_hiring", department="HR", count=0)) | |
| else: | |
| env.step(HRAction(action_type="submit_report")) | |
| if phase != "controlling": | |
| env.step(HRAction(action_type="advance_phase")) | |
| obs = env.step(HRAction(action_type="advance_quarter")) | |
| if obs.done: | |
| final_score = (obs.data or {}).get("final_score", 0.0) | |
| return {"seed": seed, "final_score": round(final_score, 4)} | |
| def run_random_episode(seed: int, size: int = SIZE) -> dict: | |
| """Run a full 6-quarter episode taking random valid actions each phase.""" | |
| rng = random.Random(seed) | |
| env = HRProductivityEnvironment() | |
| env.reset(seed=seed, size=size) | |
| quarterly_rewards: list[float] = [] | |
| final_score = 0.0 | |
| total_steps = 0 | |
| for _q in range(1, 7): | |
| for phase in PHASES: | |
| # take a couple of random valid actions to satisfy phase minimums | |
| n = PHASE_MIN_ACTIONS[phase] + rng.randint(0, 1) | |
| for _ in range(n): | |
| env.step(_random_action(phase, rng)) | |
| total_steps += 1 | |
| if phase != "controlling": | |
| env.step(HRAction(action_type="advance_phase")) | |
| total_steps += 1 | |
| obs = env.step(HRAction(action_type="advance_quarter")) | |
| total_steps += 1 | |
| data = obs.data or {} | |
| if obs.reward is not None and not obs.done: | |
| quarterly_rewards.append(round(obs.reward, 4)) | |
| if obs.done: | |
| final_score = data.get("final_score", obs.reward or 0.0) | |
| break | |
| return {"seed": seed, "final_score": round(final_score, 4), | |
| "quarterly_rewards": quarterly_rewards, "total_steps": total_steps} | |
| GRPO_RESULTS_FILE = "grpo_results.json" | |
| def load_trained() -> dict | None: | |
| """Load Colab GRPO output if present (produced in RUNBOOK.md step 2.4). | |
| Expected schema (any extra keys ignored): | |
| { | |
| "trained": [s1, s2, ...], # per-seed final scores of the GRPO agent | |
| "reward_curve": [r1, r2, ...] # optional: per-logging-step mean reward | |
| } | |
| Returns a normalized dict {"scores": [...], "mean", "std", "reward_curve"} or | |
| None if the file is missing/empty so the experiment runs unchanged. | |
| """ | |
| import os | |
| if not os.path.exists(GRPO_RESULTS_FILE): | |
| return None | |
| try: | |
| with open(GRPO_RESULTS_FILE) as f: | |
| raw = json.load(f) | |
| except (json.JSONDecodeError, OSError): | |
| return None | |
| scores = raw.get("trained") or raw.get("trained_scores") or [] | |
| scores = [float(s) for s in scores] | |
| if not scores: | |
| return None | |
| return { | |
| "scores": scores, | |
| "mean": round(mean(scores), 4), | |
| "std": round(pstdev(scores), 4), | |
| "reward_curve": raw.get("reward_curve") or [], | |
| } | |
| def main() -> None: | |
| random_runs, heuristic_runs, null_runs = [], [], [] | |
| print("Running NULL (do-nothing) agent...") | |
| for s in SEEDS: | |
| r = run_null_episode(s) | |
| null_runs.append(r) | |
| print(f" seed {s:>3}: score {r['final_score']:.4f}") | |
| print("Running RANDOM agent...") | |
| for s in SEEDS: | |
| r = run_random_episode(s) | |
| random_runs.append(r) | |
| print(f" seed {s:>3}: score {r['final_score']:.4f} steps {r['total_steps']}") | |
| print("Running HEURISTIC agent...") | |
| for s in SEEDS: | |
| r = run_demo_json(seed=s, size=SIZE) | |
| heuristic_runs.append({"seed": s, | |
| "final_score": round(r["final"]["score"], 4), | |
| "quarterly_rewards": r["quarterly_rewards"], | |
| "total_steps": r["final"]["total_steps"]}) | |
| print(f" seed {s:>3}: score {r['final']['score']:.4f} steps {r['final']['total_steps']}") | |
| rnd = [r["final_score"] for r in random_runs] | |
| heu = [r["final_score"] for r in heuristic_runs] | |
| nul = [r["final_score"] for r in null_runs] | |
| summary = { | |
| "seeds": SEEDS, "size": SIZE, | |
| "null": {"runs": null_runs, "mean": round(mean(nul), 4), "std": round(pstdev(nul), 4)}, | |
| "random": {"runs": random_runs, "mean": round(mean(rnd), 4), "std": round(pstdev(rnd), 4)}, | |
| "heuristic": {"runs": heuristic_runs, "mean": round(mean(heu), 4), "std": round(pstdev(heu), 4)}, | |
| } | |
| # Optional: fold in the GRPO-trained agent from the Colab run (RUNBOOK step 2). | |
| trained = load_trained() | |
| if trained: | |
| summary["trained"] = { | |
| "runs": [{"final_score": s} for s in trained["scores"]], | |
| "mean": trained["mean"], "std": trained["std"], | |
| } | |
| with open("results.json", "w") as f: | |
| json.dump(summary, f, indent=2) | |
| print(f"\nNULL mean {summary['null']['mean']:.3f} +/- {summary['null']['std']:.3f}") | |
| print(f"RANDOM mean {summary['random']['mean']:.3f} +/- {summary['random']['std']:.3f}") | |
| print(f"HEURISTIC mean {summary['heuristic']['mean']:.3f} +/- {summary['heuristic']['std']:.3f}") | |
| if trained: | |
| print(f"TRAINED mean {trained['mean']:.3f} +/- {trained['std']:.3f} " | |
| f"(from {GRPO_RESULTS_FILE})") | |
| make_figure(summary) | |
| out = "results.json and poster_figure.png" | |
| if trained and trained["reward_curve"]: | |
| make_grpo_curve(trained["reward_curve"]) | |
| out += " and fig5_grpo_reward_curve.png" | |
| print("Wrote " + out) | |
| def make_figure(summary: dict) -> None: | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| # v1 (sigmoid-centred) measured numbers β the reward-hacked collapse, recorded | |
| # before the recalibration. v2 = current health-gated scoring (this run). | |
| v1 = {"null": 0.672, "random": 0.652, "heuristic": 0.649} | |
| labels = ["Null\n(do-nothing)", "Random", "Heuristic"] | |
| v2_means = [summary["null"]["mean"], summary["random"]["mean"], summary["heuristic"]["mean"]] | |
| v2_stds = [summary["null"]["std"], summary["random"]["std"], summary["heuristic"]["std"]] | |
| v1_means = [v1["null"], v1["random"], v1["heuristic"]] | |
| # Trained (GRPO) agent β only present after the Colab run. It has no v1 | |
| # counterpart (it is trained against v2), so it gets a single green bar. | |
| trained = summary.get("trained") | |
| if trained: | |
| labels = labels + ["Trained\n(GRPO)"] | |
| fig, axes = plt.subplots(1, 2, figsize=(13, 5)) | |
| # --- Left: before vs after recalibration (+ trained agent if available) --- | |
| x = np.arange(len(labels)) | |
| w = 0.38 | |
| # v1 bars only for the three non-trained baselines | |
| b1 = axes[0].bar(x[:3] - w / 2, v1_means, w, label="v1: sigmoid-centred (hacked)", | |
| color="#bdc3c7", edgecolor="black", alpha=0.9) | |
| b2 = axes[0].bar(x[:3] + w / 2, v2_means, w, yerr=v2_stds, capsize=6, | |
| label="v2: health-gated (this work)", color="#2980b9", | |
| edgecolor="black", alpha=0.9) | |
| bars_to_label = [b1, b2] | |
| if trained: | |
| b3 = axes[0].bar(x[3] + w / 2, [trained["mean"]], w, yerr=[trained["std"]], | |
| capsize=6, label="GRPO-trained agent (v2)", color="#27ae60", | |
| edgecolor="black", alpha=0.95) | |
| bars_to_label.append(b3) | |
| # reference line at the heuristic v2 mean β the bar an agent must beat | |
| axes[0].axhline(summary["heuristic"]["mean"], color="#e67e22", | |
| linestyle="--", linewidth=1.2) | |
| axes[0].text(x[3] + w / 2, summary["heuristic"]["mean"] + 0.01, | |
| "heuristic bar", fontsize=7, color="#e67e22", ha="center") | |
| axes[0].axhspan(0.62, 0.69, color="red", alpha=0.08) | |
| axes[0].text(0.02, 0.70, "v1 collapse band (no separation)", fontsize=8, color="#c0392b") | |
| axes[0].set_xticks(x) | |
| axes[0].set_xticklabels(labels) | |
| axes[0].set_ylabel("Final episode score (0β1)") | |
| axes[0].set_title("Reward recalibration restores policy separation") | |
| axes[0].set_ylim(0, 1.0) | |
| axes[0].grid(True, axis="y", alpha=0.3) | |
| axes[0].legend(fontsize=8, loc="upper right") | |
| for bars in bars_to_label: | |
| for bar in bars: | |
| axes[0].text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.015, | |
| f"{bar.get_height():.2f}", ha="center", va="bottom", fontsize=8) | |
| # --- Right: per-quarter reward trajectory (heuristic) --- | |
| for r in summary["heuristic"]["runs"]: | |
| qr = r["quarterly_rewards"] | |
| axes[1].plot(range(1, len(qr) + 1), qr, marker="o", alpha=0.5, | |
| label=f"seed {r['seed']}") | |
| # mean trajectory | |
| maxq = max(len(r["quarterly_rewards"]) for r in summary["heuristic"]["runs"]) | |
| meanq = [] | |
| for i in range(maxq): | |
| vals = [r["quarterly_rewards"][i] for r in summary["heuristic"]["runs"] | |
| if i < len(r["quarterly_rewards"])] | |
| meanq.append(mean(vals)) | |
| axes[1].plot(range(1, maxq + 1), meanq, color="black", linewidth=2.5, | |
| marker="s", label="mean") | |
| axes[1].axhline(0, color="gray", linestyle="--", linewidth=1) | |
| axes[1].set_xlabel("Quarter") | |
| axes[1].set_ylabel("Sparse quarterly reward") | |
| axes[1].set_title("Heuristic quarterly reward (delayed, sparse) β v2") | |
| axes[1].grid(True, alpha=0.3) | |
| axes[1].legend(fontsize=8, ncol=2) | |
| fig.suptitle("HCM:21 OpenEnv β long-horizon HR planning benchmark", | |
| fontsize=13, fontweight="bold") | |
| fig.tight_layout(rect=[0, 0, 1, 0.96]) | |
| fig.savefig("poster_figure.png", dpi=150) | |
| def make_grpo_curve(reward_curve: list) -> None: | |
| """Figure 5: GRPO training reward over logging steps (from the Colab run).""" | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| y = [float(r) for r in reward_curve] | |
| x = list(range(1, len(y) + 1)) | |
| fig, ax = plt.subplots(figsize=(7, 4.5)) | |
| ax.plot(x, y, color="#27ae60", linewidth=1.2, alpha=0.5, label="reward") | |
| # simple trailing moving average to show the trend | |
| win = max(1, len(y) // 20) | |
| if win > 1: | |
| ma = [mean(y[max(0, i - win + 1):i + 1]) for i in range(len(y))] | |
| ax.plot(x, ma, color="#145a32", linewidth=2.5, label=f"moving avg ({win})") | |
| ax.axhline(0, color="gray", linestyle="--", linewidth=1) | |
| ax.set_xlabel("Training step") | |
| ax.set_ylabel("Mean group reward") | |
| ax.set_title("HCM:21 β GRPO training reward (Qwen3-0.6B, health-gated)") | |
| ax.grid(True, alpha=0.3) | |
| ax.legend(fontsize=9) | |
| fig.tight_layout() | |
| fig.savefig("fig5_grpo_reward_curve.png", dpi=150) | |
| plt.close(fig) | |
| if __name__ == "__main__": | |
| main() | |