Spaces:
Paused
Paused
Commit ·
80d9920
1
Parent(s): 01d7974
Add demo script, API endpoints, and training notebook baselines
Browse files- Add demo.py: standalone heuristic agent for full 6-quarter episodes
(CLI + JSON API, no LLM key required)
- Add /demo, /hyperparameters, /scenarios API endpoints to HF Space
- Update Dockerfile to include demo.py and agent/ directory
- Enhance training notebook with baseline comparisons and reward curves:
- Random baseline (5 seeds, ~0.15-0.25 floor)
- Heuristic baseline via /demo API (~0.50-0.65 target)
- Reward + loss curve plotting after GRPO training
- Before/after evaluation on same seeds
- Results summary with bar charts and JSON export
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Dockerfile +2 -1
- demo.py +826 -0
- hr_env/server/app.py +218 -0
- notebooks/hcm21_trl_training.ipynb +693 -438
Dockerfile
CHANGED
|
@@ -5,7 +5,8 @@ WORKDIR /app
|
|
| 5 |
COPY pyproject.toml README.md openenv.yaml ./
|
| 6 |
COPY hr_env/ hr_env/
|
| 7 |
COPY server/ server/
|
| 8 |
-
COPY
|
|
|
|
| 9 |
|
| 10 |
RUN pip install --no-cache-dir -e ".[server]"
|
| 11 |
|
|
|
|
| 5 |
COPY pyproject.toml README.md openenv.yaml ./
|
| 6 |
COPY hr_env/ hr_env/
|
| 7 |
COPY server/ server/
|
| 8 |
+
COPY agent/ agent/
|
| 9 |
+
COPY demo.py __init__.py client.py models.py ./
|
| 10 |
|
| 11 |
RUN pip install --no-cache-dir -e ".[server]"
|
| 12 |
|
demo.py
ADDED
|
@@ -0,0 +1,826 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Standalone demo: run a full 6-quarter HR simulation with a heuristic strategy.
|
| 2 |
+
|
| 3 |
+
No server, no LLM API key required. Seeds the environment with sample data and
|
| 4 |
+
executes data-driven HR decisions through all HCM:21 phases each quarter.
|
| 5 |
+
|
| 6 |
+
Usage:
|
| 7 |
+
python demo.py # Default: seed=42, size=300
|
| 8 |
+
python demo.py --seed 123 --size 250 --scenario budget_cuts
|
| 9 |
+
python demo.py --scenario high_eng_turnover
|
| 10 |
+
python demo.py --all-scenarios # Run all 4 task scenarios
|
| 11 |
+
|
| 12 |
+
API usage (from server):
|
| 13 |
+
from demo import run_demo_json
|
| 14 |
+
result = run_demo_json(seed=42, size=300, scenario="high_eng_turnover")
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import sys
|
| 21 |
+
from typing import Any, Dict, List, Optional
|
| 22 |
+
|
| 23 |
+
from hr_env.models import HRAction
|
| 24 |
+
from hr_env.server.environment import HRProductivityEnvironment
|
| 25 |
+
|
| 26 |
+
DEPARTMENTS = ["Engineering", "Sales", "Operations", "HR", "Finance"]
|
| 27 |
+
|
| 28 |
+
SCENARIOS = [
|
| 29 |
+
{"name": "high_eng_turnover", "seed": 42, "size": 300},
|
| 30 |
+
{"name": "budget_cuts", "seed": 123, "size": 250},
|
| 31 |
+
{"name": "rapid_growth", "seed": 456, "size": 350},
|
| 32 |
+
{"name": "balanced_optimization", "seed": 789, "size": 300},
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ── Formatting Helpers ──────────────────────────────────────────────
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def fmt_dollar(v: float) -> str:
|
| 40 |
+
if abs(v) >= 1_000_000:
|
| 41 |
+
return f"${v / 1_000_000:,.1f}M"
|
| 42 |
+
if abs(v) >= 1_000:
|
| 43 |
+
return f"${v:,.0f}"
|
| 44 |
+
return f"${v:.2f}"
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def fmt_pct(curr: float, prev: float) -> str:
|
| 48 |
+
if prev == 0:
|
| 49 |
+
return " N/A"
|
| 50 |
+
change = (curr - prev) / abs(prev) * 100
|
| 51 |
+
return f"{change:+.1f}%"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def print_header(seed: int, size: int, scenario: Optional[str]) -> None:
|
| 55 |
+
print()
|
| 56 |
+
print("=" * 64)
|
| 57 |
+
print(" HCM:21 HR Productivity Environment - Demo Playthrough")
|
| 58 |
+
print("=" * 64)
|
| 59 |
+
scn = scenario or "default (no scenario)"
|
| 60 |
+
print(f" Seed: {seed} | Company Size: {size} | Scenario: {scn}")
|
| 61 |
+
print()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def print_baseline(obs_data: dict) -> None:
|
| 65 |
+
summary = obs_data.get("company_summary", {})
|
| 66 |
+
metrics = obs_data.get("baseline_metrics", {})
|
| 67 |
+
print("--- COMPANY BASELINE ---")
|
| 68 |
+
print(f" Employees: {summary.get('total_headcount', '?')} across 5 departments")
|
| 69 |
+
print(f" Revenue: {fmt_dollar(summary.get('revenue', 0))} | Profit: {fmt_dollar(summary.get('profit', 0))}")
|
| 70 |
+
hcva = metrics.get("hcva", 0)
|
| 71 |
+
hcroi = metrics.get("hcroi", 0)
|
| 72 |
+
qips_c = metrics.get("qips", {}).get("composite", 0)
|
| 73 |
+
print(f" HCVA: {fmt_dollar(hcva)}/FTE | HCROI: {hcroi:.2f}x | QIPS: {qips_c:.4f}")
|
| 74 |
+
# Department breakdown
|
| 75 |
+
depts = summary.get("departments", {})
|
| 76 |
+
for name in DEPARTMENTS:
|
| 77 |
+
d = depts.get(name, {})
|
| 78 |
+
print(
|
| 79 |
+
f" {name:12s}: {d.get('headcount', '?'):>3} employees, "
|
| 80 |
+
f"perf {d.get('avg_performance', 0):.1f}, "
|
| 81 |
+
f"engage {d.get('avg_engagement', 0):.0f}, "
|
| 82 |
+
f"risk {d.get('avg_flight_risk', 0):.2f}"
|
| 83 |
+
)
|
| 84 |
+
print()
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def print_quarter_header(q: int) -> None:
|
| 88 |
+
print(f"{'=' * 64}")
|
| 89 |
+
print(f" QUARTER {q}")
|
| 90 |
+
print(f"{'=' * 64}")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def print_phase(phase: str) -> None:
|
| 94 |
+
print(f"\n --- {phase.upper()} ---")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def print_metrics_table(
|
| 98 |
+
metrics: dict, prev_metrics: Optional[dict], reward: Optional[float]
|
| 99 |
+
) -> None:
|
| 100 |
+
prev = prev_metrics or {}
|
| 101 |
+
hcva = metrics.get("hcva", 0)
|
| 102 |
+
hcroi = metrics.get("hcroi", 0)
|
| 103 |
+
qips = metrics.get("qips", {}).get("composite", 0)
|
| 104 |
+
ev = metrics.get("employee_value", 0)
|
| 105 |
+
snap = metrics.get("snapshot", {})
|
| 106 |
+
headcount = snap.get("headcount", 0)
|
| 107 |
+
engagement = snap.get("avg_engagement", 0)
|
| 108 |
+
|
| 109 |
+
p_hcva = prev.get("hcva", 0)
|
| 110 |
+
p_hcroi = prev.get("hcroi", 0)
|
| 111 |
+
p_qips = prev.get("qips", {}).get("composite", 0)
|
| 112 |
+
p_snap = prev.get("snapshot", {})
|
| 113 |
+
p_hc = p_snap.get("headcount", 0)
|
| 114 |
+
p_eng = p_snap.get("avg_engagement", 0)
|
| 115 |
+
|
| 116 |
+
print()
|
| 117 |
+
print(" Metrics:")
|
| 118 |
+
print(f" {'Metric':<14s} {'Value':>12s} {'Change':>8s}")
|
| 119 |
+
print(f" {'-'*14} {'-'*12} {'-'*8}")
|
| 120 |
+
print(f" {'HCVA':<14s} {fmt_dollar(hcva):>12s} {fmt_pct(hcva, p_hcva):>8s}")
|
| 121 |
+
print(f" {'HCROI':<14s} {hcroi:>12.2f}x {fmt_pct(hcroi, p_hcroi):>8s}")
|
| 122 |
+
print(f" {'QIPS':<14s} {qips:>12.4f} {fmt_pct(qips, p_qips):>8s}")
|
| 123 |
+
print(f" {'Empl. Value':<14s} {ev:>12.4f}")
|
| 124 |
+
print(f" {'Headcount':<14s} {headcount:>12d} {fmt_pct(headcount, p_hc):>8s}")
|
| 125 |
+
print(f" {'Engagement':<14s} {engagement:>12.1f} {fmt_pct(engagement, p_eng):>8s}")
|
| 126 |
+
|
| 127 |
+
if reward is not None:
|
| 128 |
+
print(f"\n Quarterly Reward: {reward:+.4f}")
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def print_final_summary(
|
| 132 |
+
total_steps: int,
|
| 133 |
+
final_score: float,
|
| 134 |
+
metric_history: List[dict],
|
| 135 |
+
quarterly_rewards: List[float],
|
| 136 |
+
all_events: List[str],
|
| 137 |
+
) -> None:
|
| 138 |
+
print()
|
| 139 |
+
print("=" * 64)
|
| 140 |
+
print(" FINAL EPISODE SUMMARY")
|
| 141 |
+
print("=" * 64)
|
| 142 |
+
print(f" Total Steps: {total_steps}")
|
| 143 |
+
print(f" Final Score: {final_score:.4f}")
|
| 144 |
+
|
| 145 |
+
if len(metric_history) >= 2:
|
| 146 |
+
first = metric_history[0]
|
| 147 |
+
last = metric_history[-1]
|
| 148 |
+
print(f"\n Metric Trajectory (Baseline -> Q6):")
|
| 149 |
+
h0, h1 = first.get("hcva", 0), last.get("hcva", 0)
|
| 150 |
+
r0, r1 = first.get("hcroi", 0), last.get("hcroi", 0)
|
| 151 |
+
q0, q1 = first.get("qips", {}).get("composite", 0), last.get("qips", {}).get("composite", 0)
|
| 152 |
+
print(f" HCVA: {fmt_dollar(h0):>10s} -> {fmt_dollar(h1):>10s} ({fmt_pct(h1, h0)})")
|
| 153 |
+
print(f" HCROI: {r0:>10.2f}x -> {r1:>10.2f}x ({fmt_pct(r1, r0)})")
|
| 154 |
+
print(f" QIPS: {q0:>10.4f} -> {q1:>10.4f} ({fmt_pct(q1, q0)})")
|
| 155 |
+
|
| 156 |
+
if quarterly_rewards:
|
| 157 |
+
formatted = ", ".join(f"{r:+.4f}" for r in quarterly_rewards)
|
| 158 |
+
print(f"\n Quarterly Rewards: [{formatted}]")
|
| 159 |
+
|
| 160 |
+
if all_events:
|
| 161 |
+
print(f"\n Events Log ({len(all_events)} total):")
|
| 162 |
+
for ev in all_events:
|
| 163 |
+
print(f" - {ev}")
|
| 164 |
+
|
| 165 |
+
print("=" * 64)
|
| 166 |
+
print()
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ── Phase Execution ─────────────────────────────────────────────────
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def run_scanning(env: HRProductivityEnvironment) -> dict:
|
| 173 |
+
"""Execute scanning phase: query all departments, employees, metrics, financials."""
|
| 174 |
+
print_phase("scanning")
|
| 175 |
+
dept_data = {}
|
| 176 |
+
|
| 177 |
+
# Query each department
|
| 178 |
+
for dept_name in DEPARTMENTS:
|
| 179 |
+
obs = env.step(HRAction(action_type="query_department", department=dept_name))
|
| 180 |
+
d = obs.data or {}
|
| 181 |
+
dept_data[dept_name] = d
|
| 182 |
+
print(
|
| 183 |
+
f" [SCAN] {dept_name:12s}: {d.get('headcount', '?'):>3} employees, "
|
| 184 |
+
f"perf {d.get('avg_performance', 0):.1f}, "
|
| 185 |
+
f"engage {d.get('avg_engagement', 0):.0f}, "
|
| 186 |
+
f"risk {d.get('avg_flight_risk', 0):.2f}"
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
# Query high performers for promotion candidates
|
| 190 |
+
obs = env.step(HRAction(
|
| 191 |
+
action_type="query_employees", parameters={"min_performance": 4.0}
|
| 192 |
+
))
|
| 193 |
+
high_performers = (obs.data or {}).get("employees", [])
|
| 194 |
+
promote_ids = [
|
| 195 |
+
e["id"] for e in high_performers if e.get("level", 5) < 5
|
| 196 |
+
][:3]
|
| 197 |
+
|
| 198 |
+
# Query low performers for termination candidates
|
| 199 |
+
obs = env.step(HRAction(
|
| 200 |
+
action_type="query_employees", parameters={"min_flight_risk": 0.0}
|
| 201 |
+
))
|
| 202 |
+
all_emps = (obs.data or {}).get("employees", [])
|
| 203 |
+
terminate_ids = [
|
| 204 |
+
e["id"] for e in all_emps if e.get("performance_score", 5.0) < 1.8
|
| 205 |
+
][:2]
|
| 206 |
+
|
| 207 |
+
# Calculate all metrics
|
| 208 |
+
obs = env.step(HRAction(action_type="calculate_metric", metric_name="all"))
|
| 209 |
+
all_metrics = obs.data or {}
|
| 210 |
+
hcva = all_metrics.get("hcva", 0)
|
| 211 |
+
hcroi = all_metrics.get("hcroi", 0)
|
| 212 |
+
qips_c = all_metrics.get("qips", {}).get("composite", 0)
|
| 213 |
+
print(f" [METRIC] HCVA: {fmt_dollar(hcva)} | HCROI: {hcroi:.2f}x | QIPS: {qips_c:.4f}")
|
| 214 |
+
|
| 215 |
+
# Review financials
|
| 216 |
+
obs = env.step(HRAction(action_type="review_financials"))
|
| 217 |
+
fin = obs.data or {}
|
| 218 |
+
hr_budget = fin.get("hr_budget_remaining", 0)
|
| 219 |
+
print(f" [FINANCE] Revenue: {fmt_dollar(fin.get('revenue', 0))} | "
|
| 220 |
+
f"Profit: {fmt_dollar(fin.get('profit', 0))} | "
|
| 221 |
+
f"HR Budget: {fmt_dollar(hr_budget)}")
|
| 222 |
+
|
| 223 |
+
# Advance to planning
|
| 224 |
+
env.step(HRAction(action_type="advance_phase"))
|
| 225 |
+
|
| 226 |
+
return {
|
| 227 |
+
"depts": dept_data,
|
| 228 |
+
"metrics": all_metrics,
|
| 229 |
+
"financials": fin,
|
| 230 |
+
"hr_budget": hr_budget,
|
| 231 |
+
"promote_ids": promote_ids,
|
| 232 |
+
"terminate_ids": terminate_ids,
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def plan_quarter(
|
| 237 |
+
env: HRProductivityEnvironment,
|
| 238 |
+
scan_data: dict,
|
| 239 |
+
quarter: int,
|
| 240 |
+
prev_events: List[str],
|
| 241 |
+
) -> dict:
|
| 242 |
+
"""Execute planning phase with data-driven decisions."""
|
| 243 |
+
print_phase("planning")
|
| 244 |
+
depts = scan_data["depts"]
|
| 245 |
+
hr_budget = scan_data["hr_budget"]
|
| 246 |
+
plan = {}
|
| 247 |
+
|
| 248 |
+
# Rank departments by flight risk (descending) and performance (ascending)
|
| 249 |
+
dept_list = [
|
| 250 |
+
{
|
| 251 |
+
"name": name,
|
| 252 |
+
"headcount": d.get("headcount", 0),
|
| 253 |
+
"avg_performance": d.get("avg_performance", 3.0),
|
| 254 |
+
"avg_engagement": d.get("avg_engagement", 70),
|
| 255 |
+
"avg_flight_risk": d.get("avg_flight_risk", 0.1),
|
| 256 |
+
}
|
| 257 |
+
for name, d in depts.items()
|
| 258 |
+
]
|
| 259 |
+
|
| 260 |
+
by_risk = sorted(dept_list, key=lambda d: d["avg_flight_risk"], reverse=True)
|
| 261 |
+
by_perf = sorted(dept_list, key=lambda d: d["avg_performance"])
|
| 262 |
+
by_headcount = sorted(
|
| 263 |
+
[d for d in dept_list if d["name"] != "HR"],
|
| 264 |
+
key=lambda d: d["headcount"],
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
# 1. Hiring: spread across the 2 smallest non-HR depts to offset turnover
|
| 268 |
+
hiring_dept = by_headcount[0]["name"]
|
| 269 |
+
# React to competitor poaching events
|
| 270 |
+
for ev in prev_events:
|
| 271 |
+
if "poaching" in ev.lower() or "competitor" in ev.lower():
|
| 272 |
+
for d in dept_list:
|
| 273 |
+
if d["name"].lower() in ev.lower():
|
| 274 |
+
hiring_dept = d["name"]
|
| 275 |
+
break
|
| 276 |
+
|
| 277 |
+
# Hire aggressively to offset ~20% quarterly turnover
|
| 278 |
+
total_hc = sum(d["headcount"] for d in dept_list)
|
| 279 |
+
hire_count = max(3, min(15, int(total_hc * 0.12)))
|
| 280 |
+
obs = env.step(HRAction(
|
| 281 |
+
action_type="set_hiring_target", department=hiring_dept, count=hire_count
|
| 282 |
+
))
|
| 283 |
+
plan["hiring"] = {"dept": hiring_dept, "count": hire_count}
|
| 284 |
+
print(f" [PLAN] Hiring target: {hiring_dept} +{hire_count}")
|
| 285 |
+
|
| 286 |
+
# Also set hiring target for second-smallest dept
|
| 287 |
+
hiring_dept2 = by_headcount[1]["name"] if len(by_headcount) > 1 else hiring_dept
|
| 288 |
+
hire_count2 = max(2, min(10, int(total_hc * 0.08)))
|
| 289 |
+
if hiring_dept2 != hiring_dept:
|
| 290 |
+
obs = env.step(HRAction(
|
| 291 |
+
action_type="set_hiring_target", department=hiring_dept2, count=hire_count2
|
| 292 |
+
))
|
| 293 |
+
plan["hiring2"] = {"dept": hiring_dept2, "count": hire_count2}
|
| 294 |
+
print(f" [PLAN] Hiring target: {hiring_dept2} +{hire_count2}")
|
| 295 |
+
|
| 296 |
+
# 2. Training: lowest-performing department
|
| 297 |
+
training_dept = by_perf[0]["name"]
|
| 298 |
+
training_amount = min(hr_budget * 0.30, 100_000)
|
| 299 |
+
if training_amount > 0:
|
| 300 |
+
obs = env.step(HRAction(
|
| 301 |
+
action_type="set_training_budget", department=training_dept, amount=training_amount
|
| 302 |
+
))
|
| 303 |
+
# Update budget tracking from observation
|
| 304 |
+
if obs.data and "hr_budget_remaining" in obs.data:
|
| 305 |
+
hr_budget = obs.data["hr_budget_remaining"]
|
| 306 |
+
else:
|
| 307 |
+
hr_budget -= training_amount
|
| 308 |
+
plan["training"] = {"dept": training_dept, "amount": training_amount}
|
| 309 |
+
print(f" [PLAN] Training budget: {fmt_dollar(training_amount)} -> {training_dept}")
|
| 310 |
+
|
| 311 |
+
# 3. Compensation: highest flight-risk department, +3%
|
| 312 |
+
comp_dept = by_risk[0]["name"]
|
| 313 |
+
comp_pct = 3.0
|
| 314 |
+
obs = env.step(HRAction(
|
| 315 |
+
action_type="set_compensation_policy", department=comp_dept, amount=comp_pct
|
| 316 |
+
))
|
| 317 |
+
plan["compensation"] = {"dept": comp_dept, "pct": comp_pct}
|
| 318 |
+
print(f" [PLAN] Compensation: +{comp_pct:.0f}% for {comp_dept}")
|
| 319 |
+
|
| 320 |
+
# 4. Retention: highest flight-risk dept (if budget allows)
|
| 321 |
+
retention_dept = by_risk[0]["name"]
|
| 322 |
+
retention_amount = min(hr_budget * 0.20, 50_000)
|
| 323 |
+
if retention_amount > 1000:
|
| 324 |
+
obs = env.step(HRAction(
|
| 325 |
+
action_type="set_retention_program", department=retention_dept, amount=retention_amount
|
| 326 |
+
))
|
| 327 |
+
plan["retention"] = {"dept": retention_dept, "amount": retention_amount}
|
| 328 |
+
print(f" [PLAN] Retention program: {retention_dept} ({fmt_dollar(retention_amount)})")
|
| 329 |
+
|
| 330 |
+
# Advance to producing
|
| 331 |
+
env.step(HRAction(action_type="advance_phase"))
|
| 332 |
+
return plan
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def execute_quarter(
|
| 336 |
+
env: HRProductivityEnvironment,
|
| 337 |
+
plan: dict,
|
| 338 |
+
scan_data: dict,
|
| 339 |
+
) -> None:
|
| 340 |
+
"""Execute producing phase: hire, train, promote, optionally terminate."""
|
| 341 |
+
print_phase("producing")
|
| 342 |
+
|
| 343 |
+
# 1. Execute hiring (primary dept)
|
| 344 |
+
hiring = plan.get("hiring", {})
|
| 345 |
+
if hiring:
|
| 346 |
+
obs = env.step(HRAction(
|
| 347 |
+
action_type="execute_hiring",
|
| 348 |
+
department=hiring["dept"],
|
| 349 |
+
count=hiring["count"],
|
| 350 |
+
))
|
| 351 |
+
cost = (obs.data or {}).get("cost", 0)
|
| 352 |
+
print(f" [EXEC] Hired {hiring['count']} in {hiring['dept']} (cost: {fmt_dollar(cost)})")
|
| 353 |
+
|
| 354 |
+
# 1b. Execute hiring (secondary dept)
|
| 355 |
+
hiring2 = plan.get("hiring2", {})
|
| 356 |
+
if hiring2:
|
| 357 |
+
obs = env.step(HRAction(
|
| 358 |
+
action_type="execute_hiring",
|
| 359 |
+
department=hiring2["dept"],
|
| 360 |
+
count=hiring2["count"],
|
| 361 |
+
))
|
| 362 |
+
cost = (obs.data or {}).get("cost", 0)
|
| 363 |
+
print(f" [EXEC] Hired {hiring2['count']} in {hiring2['dept']} (cost: {fmt_dollar(cost)})")
|
| 364 |
+
|
| 365 |
+
# 2. Execute training
|
| 366 |
+
training = plan.get("training", {})
|
| 367 |
+
if training:
|
| 368 |
+
obs = env.step(HRAction(
|
| 369 |
+
action_type="execute_training",
|
| 370 |
+
department=training["dept"],
|
| 371 |
+
amount=20, # 20 hours per employee
|
| 372 |
+
))
|
| 373 |
+
cost = (obs.data or {}).get("cost", 0)
|
| 374 |
+
hc = (obs.data or {}).get("headcount", "?")
|
| 375 |
+
print(f" [EXEC] Training: {training['dept']}, 20 hrs x {hc} employees (cost: {fmt_dollar(cost)})")
|
| 376 |
+
|
| 377 |
+
# 3. Promote top performers (identified during scanning)
|
| 378 |
+
promote_ids = scan_data.get("promote_ids", [])
|
| 379 |
+
if promote_ids:
|
| 380 |
+
obs = env.step(HRAction(action_type="execute_promotion", employee_ids=promote_ids))
|
| 381 |
+
promoted = sum(
|
| 382 |
+
1 for p in (obs.data or {}).get("promotions", []) if p.get("success")
|
| 383 |
+
)
|
| 384 |
+
print(f" [EXEC] Promoted {promoted} high-performing employees")
|
| 385 |
+
else:
|
| 386 |
+
print(" [EXEC] No promotion candidates found")
|
| 387 |
+
|
| 388 |
+
# 4. Terminate underperformers (identified during scanning)
|
| 389 |
+
terminate_ids = scan_data.get("terminate_ids", [])
|
| 390 |
+
if terminate_ids:
|
| 391 |
+
obs = env.step(HRAction(action_type="execute_termination", employee_ids=terminate_ids))
|
| 392 |
+
terminated = sum(
|
| 393 |
+
1 for t in (obs.data or {}).get("terminations", []) if t.get("success")
|
| 394 |
+
)
|
| 395 |
+
print(f" [EXEC] Terminated {terminated} underperformers")
|
| 396 |
+
else:
|
| 397 |
+
print(" [EXEC] No underperformers to terminate")
|
| 398 |
+
|
| 399 |
+
# Advance to controlling
|
| 400 |
+
env.step(HRAction(action_type="advance_phase"))
|
| 401 |
+
|
| 402 |
+
|
| 403 |
+
def run_controlling(env: HRProductivityEnvironment, q: int) -> dict:
|
| 404 |
+
"""Execute controlling phase: submit report and advance quarter."""
|
| 405 |
+
print_phase("controlling")
|
| 406 |
+
|
| 407 |
+
# Submit report
|
| 408 |
+
env.step(HRAction(action_type="submit_report"))
|
| 409 |
+
print(f" [REPORT] Q{q} report submitted")
|
| 410 |
+
|
| 411 |
+
# Advance quarter
|
| 412 |
+
obs = env.step(HRAction(action_type="advance_quarter"))
|
| 413 |
+
return {
|
| 414 |
+
"obs": obs,
|
| 415 |
+
"data": obs.data or {},
|
| 416 |
+
"reward": obs.reward,
|
| 417 |
+
"done": obs.done,
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
# ── Main Demo Runner ────────────────────────────────────────────────
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def run_demo(seed: int = 42, size: int = 300, scenario: Optional[str] = None) -> float:
|
| 425 |
+
"""Run a full 6-quarter demo episode with a heuristic strategy."""
|
| 426 |
+
print_header(seed, size, scenario)
|
| 427 |
+
|
| 428 |
+
env = HRProductivityEnvironment()
|
| 429 |
+
kwargs: Dict[str, Any] = {"size": size}
|
| 430 |
+
if scenario:
|
| 431 |
+
kwargs["scenario"] = scenario
|
| 432 |
+
obs = env.reset(seed=seed, **kwargs)
|
| 433 |
+
|
| 434 |
+
print_baseline(obs.data or {})
|
| 435 |
+
|
| 436 |
+
# Track state across quarters
|
| 437 |
+
baseline_metrics = (obs.data or {}).get("baseline_metrics", {})
|
| 438 |
+
metric_history = [baseline_metrics]
|
| 439 |
+
quarterly_rewards: List[float] = []
|
| 440 |
+
all_events: List[str] = []
|
| 441 |
+
prev_events: List[str] = []
|
| 442 |
+
total_steps = 0
|
| 443 |
+
|
| 444 |
+
for q in range(1, 7):
|
| 445 |
+
print_quarter_header(q)
|
| 446 |
+
|
| 447 |
+
# Scanning
|
| 448 |
+
scan_data = run_scanning(env)
|
| 449 |
+
|
| 450 |
+
# Planning
|
| 451 |
+
plan = plan_quarter(env, scan_data, q, prev_events)
|
| 452 |
+
|
| 453 |
+
# Producing
|
| 454 |
+
execute_quarter(env, plan, scan_data)
|
| 455 |
+
|
| 456 |
+
# Controlling
|
| 457 |
+
result = run_controlling(env, q)
|
| 458 |
+
data = result["data"]
|
| 459 |
+
|
| 460 |
+
# Collect results
|
| 461 |
+
reward = result["reward"]
|
| 462 |
+
events = data.get("events", [])
|
| 463 |
+
turnover = data.get("turnover", {})
|
| 464 |
+
departed = turnover.get("departed_count", 0)
|
| 465 |
+
metrics = data.get("metrics") or data.get("last_quarter_metrics", {})
|
| 466 |
+
|
| 467 |
+
all_events.extend(events)
|
| 468 |
+
prev_events = events
|
| 469 |
+
|
| 470 |
+
if reward is not None and not result["done"]:
|
| 471 |
+
quarterly_rewards.append(reward)
|
| 472 |
+
|
| 473 |
+
if metrics:
|
| 474 |
+
metric_history.append(metrics)
|
| 475 |
+
|
| 476 |
+
# Print quarter results
|
| 477 |
+
if events:
|
| 478 |
+
print(f"\n Events:")
|
| 479 |
+
for ev in events:
|
| 480 |
+
print(f" - {ev}")
|
| 481 |
+
if departed:
|
| 482 |
+
print(f" Turnover: {departed} employees departed")
|
| 483 |
+
|
| 484 |
+
prev_m = metric_history[-2] if len(metric_history) >= 2 else None
|
| 485 |
+
if metrics:
|
| 486 |
+
print_metrics_table(metrics, prev_m, reward)
|
| 487 |
+
|
| 488 |
+
total_steps = data.get("total_steps", total_steps)
|
| 489 |
+
|
| 490 |
+
if result["done"]:
|
| 491 |
+
final_score = data.get("final_score", reward or 0.0)
|
| 492 |
+
total_steps = data.get("total_steps", total_steps)
|
| 493 |
+
# Collect all quarterly rewards from final data
|
| 494 |
+
qr = data.get("quarterly_rewards", quarterly_rewards)
|
| 495 |
+
if qr:
|
| 496 |
+
quarterly_rewards = qr
|
| 497 |
+
print_final_summary(
|
| 498 |
+
total_steps, final_score, metric_history, quarterly_rewards, all_events
|
| 499 |
+
)
|
| 500 |
+
return final_score
|
| 501 |
+
|
| 502 |
+
# Should not reach here, but just in case
|
| 503 |
+
return 0.0
|
| 504 |
+
|
| 505 |
+
|
| 506 |
+
def _pct_change(curr: float, prev: float) -> Optional[float]:
|
| 507 |
+
if prev == 0:
|
| 508 |
+
return None
|
| 509 |
+
return round((curr - prev) / abs(prev) * 100, 2)
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def run_demo_json(
|
| 513 |
+
seed: int = 42, size: int = 300, scenario: Optional[str] = None
|
| 514 |
+
) -> Dict[str, Any]:
|
| 515 |
+
"""Run a full 6-quarter demo and return structured JSON results.
|
| 516 |
+
|
| 517 |
+
This is the API-friendly version of run_demo() — no print output,
|
| 518 |
+
returns all data as a dict suitable for JSON serialization.
|
| 519 |
+
"""
|
| 520 |
+
env = HRProductivityEnvironment()
|
| 521 |
+
kwargs: Dict[str, Any] = {"size": size}
|
| 522 |
+
if scenario:
|
| 523 |
+
kwargs["scenario"] = scenario
|
| 524 |
+
obs = env.reset(seed=seed, **kwargs)
|
| 525 |
+
|
| 526 |
+
obs_data = obs.data or {}
|
| 527 |
+
baseline_metrics = obs_data.get("baseline_metrics", {})
|
| 528 |
+
baseline_summary = obs_data.get("company_summary", {})
|
| 529 |
+
metric_history = [baseline_metrics]
|
| 530 |
+
quarterly_rewards: List[float] = []
|
| 531 |
+
all_events: List[str] = []
|
| 532 |
+
prev_events: List[str] = []
|
| 533 |
+
quarters: List[Dict[str, Any]] = []
|
| 534 |
+
|
| 535 |
+
for q in range(1, 7):
|
| 536 |
+
scan_data = run_scanning.__wrapped__(env) if hasattr(run_scanning, '__wrapped__') else _scan_quiet(env)
|
| 537 |
+
plan = _plan_quiet(env, scan_data, q, prev_events)
|
| 538 |
+
_exec_quiet(env, plan, scan_data)
|
| 539 |
+
result = _control_quiet(env)
|
| 540 |
+
data = result["data"]
|
| 541 |
+
|
| 542 |
+
reward = result["reward"]
|
| 543 |
+
events = data.get("events", [])
|
| 544 |
+
turnover = data.get("turnover", {})
|
| 545 |
+
departed = turnover.get("departed_count", 0)
|
| 546 |
+
metrics = data.get("metrics") or data.get("last_quarter_metrics", {})
|
| 547 |
+
|
| 548 |
+
all_events.extend(events)
|
| 549 |
+
prev_events = events
|
| 550 |
+
|
| 551 |
+
prev_m = metric_history[-1] if metric_history else {}
|
| 552 |
+
quarter_result: Dict[str, Any] = {
|
| 553 |
+
"quarter": q,
|
| 554 |
+
"actions": {
|
| 555 |
+
"hiring": plan.get("hiring"),
|
| 556 |
+
"hiring2": plan.get("hiring2"),
|
| 557 |
+
"training": plan.get("training"),
|
| 558 |
+
"compensation": plan.get("compensation"),
|
| 559 |
+
"retention": plan.get("retention"),
|
| 560 |
+
},
|
| 561 |
+
"events": events,
|
| 562 |
+
"turnover_count": departed,
|
| 563 |
+
"quarterly_reward": reward,
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
if metrics:
|
| 567 |
+
metric_history.append(metrics)
|
| 568 |
+
quarter_result["metrics"] = {
|
| 569 |
+
"hcva": metrics.get("hcva", 0),
|
| 570 |
+
"hcroi": metrics.get("hcroi", 0),
|
| 571 |
+
"qips": metrics.get("qips", {}).get("composite", 0),
|
| 572 |
+
"employee_value": metrics.get("employee_value", 0),
|
| 573 |
+
"headcount": metrics.get("snapshot", {}).get("headcount", 0),
|
| 574 |
+
"avg_engagement": round(metrics.get("snapshot", {}).get("avg_engagement", 0), 1),
|
| 575 |
+
}
|
| 576 |
+
quarter_result["changes"] = {
|
| 577 |
+
"hcva_pct": _pct_change(metrics.get("hcva", 0), prev_m.get("hcva", 0)),
|
| 578 |
+
"hcroi_pct": _pct_change(metrics.get("hcroi", 0), prev_m.get("hcroi", 0)),
|
| 579 |
+
"qips_pct": _pct_change(
|
| 580 |
+
metrics.get("qips", {}).get("composite", 0),
|
| 581 |
+
prev_m.get("qips", {}).get("composite", 0),
|
| 582 |
+
),
|
| 583 |
+
}
|
| 584 |
+
|
| 585 |
+
if reward is not None and not result["done"]:
|
| 586 |
+
quarterly_rewards.append(reward)
|
| 587 |
+
|
| 588 |
+
quarters.append(quarter_result)
|
| 589 |
+
|
| 590 |
+
if result["done"]:
|
| 591 |
+
final_score = data.get("final_score", reward or 0.0)
|
| 592 |
+
qr = data.get("quarterly_rewards", quarterly_rewards)
|
| 593 |
+
if qr:
|
| 594 |
+
quarterly_rewards = qr
|
| 595 |
+
break
|
| 596 |
+
|
| 597 |
+
first_m = metric_history[0] if metric_history else {}
|
| 598 |
+
last_m = metric_history[-1] if metric_history else {}
|
| 599 |
+
|
| 600 |
+
return {
|
| 601 |
+
"seed": seed,
|
| 602 |
+
"size": size,
|
| 603 |
+
"scenario": scenario,
|
| 604 |
+
"baseline": {
|
| 605 |
+
"headcount": baseline_summary.get("total_headcount", 0),
|
| 606 |
+
"revenue": baseline_summary.get("revenue", 0),
|
| 607 |
+
"profit": baseline_summary.get("profit", 0),
|
| 608 |
+
"hcva": first_m.get("hcva", 0),
|
| 609 |
+
"hcroi": first_m.get("hcroi", 0),
|
| 610 |
+
"qips": first_m.get("qips", {}).get("composite", 0),
|
| 611 |
+
},
|
| 612 |
+
"final": {
|
| 613 |
+
"score": final_score,
|
| 614 |
+
"total_steps": data.get("total_steps", 0),
|
| 615 |
+
"hcva": last_m.get("hcva", 0),
|
| 616 |
+
"hcroi": last_m.get("hcroi", 0),
|
| 617 |
+
"qips": last_m.get("qips", {}).get("composite", 0),
|
| 618 |
+
"hcva_change_pct": _pct_change(last_m.get("hcva", 0), first_m.get("hcva", 0)),
|
| 619 |
+
"hcroi_change_pct": _pct_change(last_m.get("hcroi", 0), first_m.get("hcroi", 0)),
|
| 620 |
+
"qips_change_pct": _pct_change(
|
| 621 |
+
last_m.get("qips", {}).get("composite", 0),
|
| 622 |
+
first_m.get("qips", {}).get("composite", 0),
|
| 623 |
+
),
|
| 624 |
+
},
|
| 625 |
+
"quarterly_rewards": quarterly_rewards,
|
| 626 |
+
"events": all_events,
|
| 627 |
+
"quarters": quarters,
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
|
| 631 |
+
def _scan_quiet(env: HRProductivityEnvironment) -> dict:
|
| 632 |
+
"""Scanning phase — no print output."""
|
| 633 |
+
dept_data = {}
|
| 634 |
+
for dept_name in DEPARTMENTS:
|
| 635 |
+
obs = env.step(HRAction(action_type="query_department", department=dept_name))
|
| 636 |
+
dept_data[dept_name] = obs.data or {}
|
| 637 |
+
|
| 638 |
+
obs = env.step(HRAction(
|
| 639 |
+
action_type="query_employees", parameters={"min_performance": 4.0}
|
| 640 |
+
))
|
| 641 |
+
high_performers = (obs.data or {}).get("employees", [])
|
| 642 |
+
promote_ids = [e["id"] for e in high_performers if e.get("level", 5) < 5][:3]
|
| 643 |
+
|
| 644 |
+
obs = env.step(HRAction(
|
| 645 |
+
action_type="query_employees", parameters={"min_flight_risk": 0.0}
|
| 646 |
+
))
|
| 647 |
+
all_emps = (obs.data or {}).get("employees", [])
|
| 648 |
+
terminate_ids = [
|
| 649 |
+
e["id"] for e in all_emps if e.get("performance_score", 5.0) < 1.8
|
| 650 |
+
][:2]
|
| 651 |
+
|
| 652 |
+
obs = env.step(HRAction(action_type="calculate_metric", metric_name="all"))
|
| 653 |
+
all_metrics = obs.data or {}
|
| 654 |
+
|
| 655 |
+
obs = env.step(HRAction(action_type="review_financials"))
|
| 656 |
+
fin = obs.data or {}
|
| 657 |
+
|
| 658 |
+
env.step(HRAction(action_type="advance_phase"))
|
| 659 |
+
|
| 660 |
+
return {
|
| 661 |
+
"depts": dept_data,
|
| 662 |
+
"metrics": all_metrics,
|
| 663 |
+
"financials": fin,
|
| 664 |
+
"hr_budget": fin.get("hr_budget_remaining", 0),
|
| 665 |
+
"promote_ids": promote_ids,
|
| 666 |
+
"terminate_ids": terminate_ids,
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
|
| 670 |
+
def _plan_quiet(
|
| 671 |
+
env: HRProductivityEnvironment, scan_data: dict, quarter: int, prev_events: List[str]
|
| 672 |
+
) -> dict:
|
| 673 |
+
"""Planning phase — no print output."""
|
| 674 |
+
depts = scan_data["depts"]
|
| 675 |
+
hr_budget = scan_data["hr_budget"]
|
| 676 |
+
plan: Dict[str, Any] = {}
|
| 677 |
+
|
| 678 |
+
dept_list = [
|
| 679 |
+
{
|
| 680 |
+
"name": name,
|
| 681 |
+
"headcount": d.get("headcount", 0),
|
| 682 |
+
"avg_performance": d.get("avg_performance", 3.0),
|
| 683 |
+
"avg_engagement": d.get("avg_engagement", 70),
|
| 684 |
+
"avg_flight_risk": d.get("avg_flight_risk", 0.1),
|
| 685 |
+
}
|
| 686 |
+
for name, d in depts.items()
|
| 687 |
+
]
|
| 688 |
+
|
| 689 |
+
by_risk = sorted(dept_list, key=lambda d: d["avg_flight_risk"], reverse=True)
|
| 690 |
+
by_perf = sorted(dept_list, key=lambda d: d["avg_performance"])
|
| 691 |
+
by_headcount = sorted(
|
| 692 |
+
[d for d in dept_list if d["name"] != "HR"], key=lambda d: d["headcount"]
|
| 693 |
+
)
|
| 694 |
+
|
| 695 |
+
hiring_dept = by_headcount[0]["name"]
|
| 696 |
+
for ev in prev_events:
|
| 697 |
+
if "poaching" in ev.lower() or "competitor" in ev.lower():
|
| 698 |
+
for d in dept_list:
|
| 699 |
+
if d["name"].lower() in ev.lower():
|
| 700 |
+
hiring_dept = d["name"]
|
| 701 |
+
break
|
| 702 |
+
|
| 703 |
+
total_hc = sum(d["headcount"] for d in dept_list)
|
| 704 |
+
hire_count = max(3, min(15, int(total_hc * 0.12)))
|
| 705 |
+
env.step(HRAction(action_type="set_hiring_target", department=hiring_dept, count=hire_count))
|
| 706 |
+
plan["hiring"] = {"dept": hiring_dept, "count": hire_count}
|
| 707 |
+
|
| 708 |
+
hiring_dept2 = by_headcount[1]["name"] if len(by_headcount) > 1 else hiring_dept
|
| 709 |
+
hire_count2 = max(2, min(10, int(total_hc * 0.08)))
|
| 710 |
+
if hiring_dept2 != hiring_dept:
|
| 711 |
+
env.step(HRAction(action_type="set_hiring_target", department=hiring_dept2, count=hire_count2))
|
| 712 |
+
plan["hiring2"] = {"dept": hiring_dept2, "count": hire_count2}
|
| 713 |
+
|
| 714 |
+
training_dept = by_perf[0]["name"]
|
| 715 |
+
training_amount = min(hr_budget * 0.30, 100_000)
|
| 716 |
+
if training_amount > 0:
|
| 717 |
+
obs = env.step(HRAction(
|
| 718 |
+
action_type="set_training_budget", department=training_dept, amount=training_amount
|
| 719 |
+
))
|
| 720 |
+
if obs.data and "hr_budget_remaining" in obs.data:
|
| 721 |
+
hr_budget = obs.data["hr_budget_remaining"]
|
| 722 |
+
else:
|
| 723 |
+
hr_budget -= training_amount
|
| 724 |
+
plan["training"] = {"dept": training_dept, "amount": training_amount}
|
| 725 |
+
|
| 726 |
+
comp_dept = by_risk[0]["name"]
|
| 727 |
+
env.step(HRAction(action_type="set_compensation_policy", department=comp_dept, amount=3.0))
|
| 728 |
+
plan["compensation"] = {"dept": comp_dept, "pct": 3.0}
|
| 729 |
+
|
| 730 |
+
retention_dept = by_risk[0]["name"]
|
| 731 |
+
retention_amount = min(hr_budget * 0.20, 50_000)
|
| 732 |
+
if retention_amount > 1000:
|
| 733 |
+
env.step(HRAction(
|
| 734 |
+
action_type="set_retention_program", department=retention_dept, amount=retention_amount
|
| 735 |
+
))
|
| 736 |
+
plan["retention"] = {"dept": retention_dept, "amount": retention_amount}
|
| 737 |
+
|
| 738 |
+
env.step(HRAction(action_type="advance_phase"))
|
| 739 |
+
return plan
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
def _exec_quiet(
|
| 743 |
+
env: HRProductivityEnvironment, plan: dict, scan_data: dict
|
| 744 |
+
) -> None:
|
| 745 |
+
"""Producing phase — no print output."""
|
| 746 |
+
hiring = plan.get("hiring", {})
|
| 747 |
+
if hiring:
|
| 748 |
+
env.step(HRAction(action_type="execute_hiring", department=hiring["dept"], count=hiring["count"]))
|
| 749 |
+
|
| 750 |
+
hiring2 = plan.get("hiring2", {})
|
| 751 |
+
if hiring2:
|
| 752 |
+
env.step(HRAction(action_type="execute_hiring", department=hiring2["dept"], count=hiring2["count"]))
|
| 753 |
+
|
| 754 |
+
training = plan.get("training", {})
|
| 755 |
+
if training:
|
| 756 |
+
env.step(HRAction(action_type="execute_training", department=training["dept"], amount=20))
|
| 757 |
+
|
| 758 |
+
promote_ids = scan_data.get("promote_ids", [])
|
| 759 |
+
if promote_ids:
|
| 760 |
+
env.step(HRAction(action_type="execute_promotion", employee_ids=promote_ids))
|
| 761 |
+
|
| 762 |
+
terminate_ids = scan_data.get("terminate_ids", [])
|
| 763 |
+
if terminate_ids:
|
| 764 |
+
env.step(HRAction(action_type="execute_termination", employee_ids=terminate_ids))
|
| 765 |
+
|
| 766 |
+
env.step(HRAction(action_type="advance_phase"))
|
| 767 |
+
|
| 768 |
+
|
| 769 |
+
def _control_quiet(env: HRProductivityEnvironment) -> dict:
|
| 770 |
+
"""Controlling phase — no print output."""
|
| 771 |
+
env.step(HRAction(action_type="submit_report"))
|
| 772 |
+
obs = env.step(HRAction(action_type="advance_quarter"))
|
| 773 |
+
return {
|
| 774 |
+
"obs": obs,
|
| 775 |
+
"data": obs.data or {},
|
| 776 |
+
"reward": obs.reward,
|
| 777 |
+
"done": obs.done,
|
| 778 |
+
}
|
| 779 |
+
|
| 780 |
+
|
| 781 |
+
def main() -> None:
|
| 782 |
+
parser = argparse.ArgumentParser(
|
| 783 |
+
description="Run a full 6-quarter HR simulation demo with heuristic strategy"
|
| 784 |
+
)
|
| 785 |
+
parser.add_argument("--seed", type=int, default=42, help="Random seed (default: 42)")
|
| 786 |
+
parser.add_argument("--size", type=int, default=300, help="Company size (default: 300)")
|
| 787 |
+
parser.add_argument(
|
| 788 |
+
"--scenario",
|
| 789 |
+
choices=["high_eng_turnover", "budget_cuts", "rapid_growth", "balanced_optimization"],
|
| 790 |
+
help="Scenario variant to run",
|
| 791 |
+
)
|
| 792 |
+
parser.add_argument(
|
| 793 |
+
"--all-scenarios", action="store_true",
|
| 794 |
+
help="Run all 4 task scenarios and compare results",
|
| 795 |
+
)
|
| 796 |
+
args = parser.parse_args()
|
| 797 |
+
|
| 798 |
+
if args.all_scenarios:
|
| 799 |
+
print()
|
| 800 |
+
print("=" * 64)
|
| 801 |
+
print(" Running all 4 scenarios...")
|
| 802 |
+
print("=" * 64)
|
| 803 |
+
results = []
|
| 804 |
+
for scn in SCENARIOS:
|
| 805 |
+
score = run_demo(seed=scn["seed"], size=scn["size"], scenario=scn["name"])
|
| 806 |
+
results.append((scn["name"], score))
|
| 807 |
+
|
| 808 |
+
print()
|
| 809 |
+
print("=" * 64)
|
| 810 |
+
print(" SCENARIO COMPARISON")
|
| 811 |
+
print("=" * 64)
|
| 812 |
+
print(f" {'Scenario':<28s} {'Score':>8s}")
|
| 813 |
+
print(f" {'-'*28} {'-'*8}")
|
| 814 |
+
for name, score in results:
|
| 815 |
+
print(f" {name:<28s} {score:>8.4f}")
|
| 816 |
+
avg = sum(s for _, s in results) / len(results)
|
| 817 |
+
print(f" {'-'*28} {'-'*8}")
|
| 818 |
+
print(f" {'Average':<28s} {avg:>8.4f}")
|
| 819 |
+
print("=" * 64)
|
| 820 |
+
else:
|
| 821 |
+
score = run_demo(seed=args.seed, size=args.size, scenario=args.scenario)
|
| 822 |
+
print(f" Exit score: {score:.4f}")
|
| 823 |
+
|
| 824 |
+
|
| 825 |
+
if __name__ == "__main__":
|
| 826 |
+
main()
|
hr_env/server/app.py
CHANGED
|
@@ -1,5 +1,11 @@
|
|
| 1 |
"""FastAPI application entry point for the HR Productivity Environment."""
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
from openenv.core.env_server.http_server import create_app
|
| 4 |
|
| 5 |
from hr_env.models import HRAction, HRObservation
|
|
@@ -12,3 +18,215 @@ app = create_app(
|
|
| 12 |
env_name="hr-productivity-env",
|
| 13 |
max_concurrent_envs=1,
|
| 14 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""FastAPI application entry point for the HR Productivity Environment."""
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
from fastapi import Query
|
| 8 |
+
from fastapi.responses import RedirectResponse
|
| 9 |
from openenv.core.env_server.http_server import create_app
|
| 10 |
|
| 11 |
from hr_env.models import HRAction, HRObservation
|
|
|
|
| 18 |
env_name="hr-productivity-env",
|
| 19 |
max_concurrent_envs=1,
|
| 20 |
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@app.get("/")
|
| 24 |
+
async def root():
|
| 25 |
+
return RedirectResponse(url="/web")
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
# ── Hyperparameters endpoint ────────────────────────────────────────
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@app.get("/hyperparameters")
|
| 32 |
+
async def hyperparameters():
|
| 33 |
+
"""Return all environment hyperparameters and configuration constants."""
|
| 34 |
+
from hr_env.server.company import (
|
| 35 |
+
BENEFITS_MULTIPLIER,
|
| 36 |
+
DEPT_CONFIGS,
|
| 37 |
+
RECRUITING_COST_PER_HIRE,
|
| 38 |
+
)
|
| 39 |
+
from hr_env.server.data_gen import (
|
| 40 |
+
DEPT_SIZE_FRACTIONS,
|
| 41 |
+
DEPT_SKILLS,
|
| 42 |
+
ROLES,
|
| 43 |
+
SALARY_BASE,
|
| 44 |
+
SALARY_SIGMA,
|
| 45 |
+
)
|
| 46 |
+
from hr_env.server.events import ALL_EVENTS
|
| 47 |
+
from hr_env.server.phases import PHASE_ACTIONS, PHASE_MIN_ACTIONS, PHASES
|
| 48 |
+
from hr_env.server.scoring import compute_final_score # noqa: F401
|
| 49 |
+
|
| 50 |
+
return {
|
| 51 |
+
"environment": {
|
| 52 |
+
"max_quarters": 6,
|
| 53 |
+
"max_steps": 300,
|
| 54 |
+
"max_concurrent_envs": 1,
|
| 55 |
+
"default_company_size": 300,
|
| 56 |
+
"recommended_size_range": [200, 500],
|
| 57 |
+
"departments": ["Engineering", "Sales", "Operations", "HR", "Finance"],
|
| 58 |
+
},
|
| 59 |
+
"company_financials": {
|
| 60 |
+
"base_revenue_formula": "size * 150,000",
|
| 61 |
+
"hr_budget_formula": "size * 6,000",
|
| 62 |
+
"quarterly_hr_budget": "hr_budget / 4",
|
| 63 |
+
"non_employment_cost_ratio": 0.55,
|
| 64 |
+
"benefits_multiplier": BENEFITS_MULTIPLIER,
|
| 65 |
+
"recruiting_cost_per_hire": RECRUITING_COST_PER_HIRE,
|
| 66 |
+
"training_cost_per_hour_per_employee": 50,
|
| 67 |
+
},
|
| 68 |
+
"cobb_douglas_production": {
|
| 69 |
+
dept: {
|
| 70 |
+
"A": cfg["A"],
|
| 71 |
+
"alpha": cfg["alpha"],
|
| 72 |
+
"beta": cfg["beta"],
|
| 73 |
+
"base_revenue_share": cfg["base_revenue_share"],
|
| 74 |
+
}
|
| 75 |
+
for dept, cfg in DEPT_CONFIGS.items()
|
| 76 |
+
},
|
| 77 |
+
"employee_generation": {
|
| 78 |
+
"salary_base_by_level": SALARY_BASE,
|
| 79 |
+
"salary_lognormal_sigma": SALARY_SIGMA,
|
| 80 |
+
"performance_distribution": {"type": "normal", "mean": 3.2, "std": 0.8, "clip": [1.0, 5.0]},
|
| 81 |
+
"tenure_distribution": {"type": "exponential", "lambda_months": 24},
|
| 82 |
+
"engagement_distribution": {"type": "beta", "alpha": 7, "beta": 3, "scale": 100},
|
| 83 |
+
"level_distribution": {
|
| 84 |
+
"type": "categorical",
|
| 85 |
+
"probabilities": {"L1": 0.35, "L2": 0.30, "L3": 0.20, "L4": 0.10, "L5": 0.05},
|
| 86 |
+
},
|
| 87 |
+
"dept_size_fractions": DEPT_SIZE_FRACTIONS,
|
| 88 |
+
"skills_per_employee": {"min": 2, "max": 4},
|
| 89 |
+
},
|
| 90 |
+
"employee_lifecycle": {
|
| 91 |
+
"promotion_salary_increase": 0.15,
|
| 92 |
+
"promotion_engagement_boost": 10,
|
| 93 |
+
"promotion_flight_risk_reduction": 0.15,
|
| 94 |
+
"quarterly_engagement_decay": -1.5,
|
| 95 |
+
"long_tenure_no_promotion_penalty": -3.0,
|
| 96 |
+
"long_tenure_threshold_quarters": 4,
|
| 97 |
+
"transfer_engagement_penalty": -5,
|
| 98 |
+
"termination_colleague_engagement_penalty": -3,
|
| 99 |
+
"new_hire_engagement_distribution": {"type": "beta", "alpha": 8, "beta": 2, "scale": 100},
|
| 100 |
+
},
|
| 101 |
+
"flight_risk": {
|
| 102 |
+
"model": "logistic",
|
| 103 |
+
"formula": "sigmoid(-1 + 2*pay_factor + 1.5*engagement_factor + 1*tenure_factor + 0.8*market_demand)",
|
| 104 |
+
"turnover_probability": "flight_risk * 0.4 per quarter",
|
| 105 |
+
"bounds": [0.02, 0.95],
|
| 106 |
+
},
|
| 107 |
+
"phase_system": {
|
| 108 |
+
"phases": PHASES,
|
| 109 |
+
"actions_per_phase": PHASE_ACTIONS,
|
| 110 |
+
"minimum_actions_per_phase": PHASE_MIN_ACTIONS,
|
| 111 |
+
},
|
| 112 |
+
"stochastic_events": [
|
| 113 |
+
{
|
| 114 |
+
"name": ev.name,
|
| 115 |
+
"description": ev.description,
|
| 116 |
+
"probability": ev.probability,
|
| 117 |
+
}
|
| 118 |
+
for ev in ALL_EVENTS
|
| 119 |
+
],
|
| 120 |
+
"scoring": {
|
| 121 |
+
"quarterly_reward_weights": {
|
| 122 |
+
"hcva_improvement": 0.40,
|
| 123 |
+
"hcroi_improvement": 0.20,
|
| 124 |
+
"qips_improvement": 0.20,
|
| 125 |
+
"five_indexes": 0.20,
|
| 126 |
+
},
|
| 127 |
+
"final_score_weights": {
|
| 128 |
+
"hcva_trajectory": 0.25,
|
| 129 |
+
"hcroi_final_vs_baseline": 0.20,
|
| 130 |
+
"employee_value": 0.20,
|
| 131 |
+
"qips_consistency": 0.15,
|
| 132 |
+
"five_indexes_cumulative": 0.10,
|
| 133 |
+
"financial_health": 0.10,
|
| 134 |
+
},
|
| 135 |
+
"calibration_targets": {
|
| 136 |
+
"random_agent": "0.15-0.25",
|
| 137 |
+
"reasonable_heuristic": "~0.50",
|
| 138 |
+
"strong_agent": "0.70+",
|
| 139 |
+
},
|
| 140 |
+
},
|
| 141 |
+
"heuristic_strategy": {
|
| 142 |
+
"description": "Data-driven heuristic used in the demo endpoint",
|
| 143 |
+
"hiring_rate": "12% of current headcount for primary dept, 8% for secondary",
|
| 144 |
+
"training_budget_fraction": 0.30,
|
| 145 |
+
"training_hours_per_session": 20,
|
| 146 |
+
"compensation_adjustment_pct": 3.0,
|
| 147 |
+
"retention_budget_fraction": 0.20,
|
| 148 |
+
"promotion_criteria": {"min_performance": 4.0, "max_level": 4, "max_per_quarter": 3},
|
| 149 |
+
"termination_criteria": {"max_performance": 1.8, "max_per_quarter": 2},
|
| 150 |
+
},
|
| 151 |
+
"roles_by_department": {
|
| 152 |
+
dept: [{"role": role, "level": level} for role, level in roles]
|
| 153 |
+
for dept, roles in ROLES.items()
|
| 154 |
+
},
|
| 155 |
+
"skills_by_department": DEPT_SKILLS,
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# ── Scenarios endpoint ──────────────────────────────────────────────
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
@app.get("/scenarios")
|
| 163 |
+
async def scenarios():
|
| 164 |
+
"""Return all available task scenarios with their configurations."""
|
| 165 |
+
tasks_dir = Path(__file__).resolve().parent.parent / "tasks"
|
| 166 |
+
task_list = []
|
| 167 |
+
|
| 168 |
+
if tasks_dir.exists():
|
| 169 |
+
for f in sorted(tasks_dir.glob("*.json")):
|
| 170 |
+
try:
|
| 171 |
+
task_list.append(json.loads(f.read_text()))
|
| 172 |
+
except (json.JSONDecodeError, OSError):
|
| 173 |
+
continue
|
| 174 |
+
|
| 175 |
+
# Also include scenario effect descriptions
|
| 176 |
+
scenario_effects = {
|
| 177 |
+
"high_eng_turnover": {
|
| 178 |
+
"modifications": "Engineering flight_risk += 0.20, engagement -= 10",
|
| 179 |
+
"challenge": "Stabilize retention in a bleeding engineering department",
|
| 180 |
+
},
|
| 181 |
+
"budget_cuts": {
|
| 182 |
+
"modifications": "HR budget halved (50% reduction)",
|
| 183 |
+
"challenge": "Optimize workforce with severely constrained resources",
|
| 184 |
+
},
|
| 185 |
+
"rapid_growth": {
|
| 186 |
+
"modifications": "Revenue +30%, 15% employees deactivated (understaffing)",
|
| 187 |
+
"challenge": "Scale the workforce while maintaining quality and culture",
|
| 188 |
+
},
|
| 189 |
+
"balanced_optimization": {
|
| 190 |
+
"modifications": "All employees: performance -0.3, engagement -8",
|
| 191 |
+
"challenge": "Improve below-average metrics across the board",
|
| 192 |
+
},
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
return {
|
| 196 |
+
"scenarios": [
|
| 197 |
+
{
|
| 198 |
+
**task,
|
| 199 |
+
"effects": scenario_effects.get(task.get("params", {}).get("scenario"), {}),
|
| 200 |
+
}
|
| 201 |
+
for task in task_list
|
| 202 |
+
],
|
| 203 |
+
"default": {
|
| 204 |
+
"seed": 42,
|
| 205 |
+
"size": 300,
|
| 206 |
+
"scenario": None,
|
| 207 |
+
"description": "Standard company with no scenario modifications",
|
| 208 |
+
},
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# ── Demo endpoint ───────────────────────────────────────────────────
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@app.get("/demo")
|
| 216 |
+
async def demo(
|
| 217 |
+
seed: int = Query(42, description="Random seed for reproducibility"),
|
| 218 |
+
size: int = Query(300, ge=50, le=1000, description="Company size (employees)"),
|
| 219 |
+
scenario: Optional[str] = Query(
|
| 220 |
+
None,
|
| 221 |
+
description="Scenario variant",
|
| 222 |
+
enum=["high_eng_turnover", "budget_cuts", "rapid_growth", "balanced_optimization"],
|
| 223 |
+
),
|
| 224 |
+
):
|
| 225 |
+
"""Run a full 6-quarter demo with a heuristic strategy and return results.
|
| 226 |
+
|
| 227 |
+
Executes data-driven HR decisions through all HCM:21 phases for 6 quarters.
|
| 228 |
+
No LLM API key required — uses a built-in heuristic agent.
|
| 229 |
+
"""
|
| 230 |
+
from demo import run_demo_json
|
| 231 |
+
|
| 232 |
+
return run_demo_json(seed=seed, size=size, scenario=scenario)
|
notebooks/hcm21_trl_training.ipynb
CHANGED
|
@@ -1,440 +1,695 @@
|
|
| 1 |
{
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
},
|
| 9 |
-
"kernelspec": {
|
| 10 |
-
"name": "python3",
|
| 11 |
-
"display_name": "Python 3"
|
| 12 |
-
},
|
| 13 |
-
"language_info": {
|
| 14 |
-
"name": "python"
|
| 15 |
-
},
|
| 16 |
-
"accelerator": "GPU"
|
| 17 |
},
|
| 18 |
-
"
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
"\n",
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
"\n",
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
"
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
"
|
| 243 |
-
"\n",
|
| 244 |
-
"
|
| 245 |
-
"\n",
|
| 246 |
-
"
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
"
|
| 255 |
-
"\n",
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
},
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
"
|
| 338 |
-
|
| 339 |
-
},
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
{
|
| 2 |
+
"nbformat": 4,
|
| 3 |
+
"nbformat_minor": 0,
|
| 4 |
+
"metadata": {
|
| 5 |
+
"colab": {
|
| 6 |
+
"provenance": [],
|
| 7 |
+
"gpuType": "T4"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
},
|
| 9 |
+
"kernelspec": {
|
| 10 |
+
"name": "python3",
|
| 11 |
+
"display_name": "Python 3"
|
| 12 |
+
},
|
| 13 |
+
"language_info": {
|
| 14 |
+
"name": "python"
|
| 15 |
+
},
|
| 16 |
+
"accelerator": "GPU"
|
| 17 |
+
},
|
| 18 |
+
"cells": [
|
| 19 |
+
{
|
| 20 |
+
"cell_type": "markdown",
|
| 21 |
+
"metadata": {},
|
| 22 |
+
"source": [
|
| 23 |
+
"# HCM:21 Training with TRL + Unsloth\n",
|
| 24 |
+
"\n",
|
| 25 |
+
"This notebook trains a small LLM to play as Chief Human Capital Officer in the\n",
|
| 26 |
+
"[HCM:21 environment](https://huggingface.co/spaces/ParetoOptimal/hcm21) using\n",
|
| 27 |
+
"**GRPO** (Group Relative Policy Optimization) from HuggingFace TRL, with\n",
|
| 28 |
+
"**Unsloth** for efficient fine-tuning on a free Colab T4 GPU.\n",
|
| 29 |
+
"\n",
|
| 30 |
+
"The agent interacts with the HCM:21 OpenEnv environment deployed on HF Spaces\n",
|
| 31 |
+
"via WebSocket, making HR decisions (hiring, training, compensation, retention)\n",
|
| 32 |
+
"across 6 simulated quarters and receiving sparse rewards based on Fitz-enz\n",
|
| 33 |
+
"HR analytics metrics.\n",
|
| 34 |
+
"\n",
|
| 35 |
+
"**Environment:** https://huggingface.co/spaces/ParetoOptimal/hcm21 \n",
|
| 36 |
+
"**GitHub:** https://github.com/n8mauer/HCM-21"
|
| 37 |
+
]
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"cell_type": "markdown",
|
| 41 |
+
"metadata": {},
|
| 42 |
+
"source": [
|
| 43 |
+
"## 1. Install Dependencies"
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"cell_type": "code",
|
| 48 |
+
"execution_count": null,
|
| 49 |
+
"metadata": {},
|
| 50 |
+
"outputs": [],
|
| 51 |
+
"source": [
|
| 52 |
+
"%%capture\n",
|
| 53 |
+
"!pip install unsloth trl openenv-core pydantic datasets\n",
|
| 54 |
+
"# Unsloth automatically patches transformers for 2x faster training on T4"
|
| 55 |
+
]
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
"cell_type": "markdown",
|
| 59 |
+
"metadata": {},
|
| 60 |
+
"source": [
|
| 61 |
+
"## 2. Load Model with Unsloth"
|
| 62 |
+
]
|
| 63 |
+
},
|
| 64 |
+
{
|
| 65 |
+
"cell_type": "code",
|
| 66 |
+
"execution_count": null,
|
| 67 |
+
"metadata": {},
|
| 68 |
+
"outputs": [],
|
| 69 |
+
"source": [
|
| 70 |
+
"from unsloth import FastLanguageModel\n",
|
| 71 |
+
"\n",
|
| 72 |
+
"model, tokenizer = FastLanguageModel.from_pretrained(\n",
|
| 73 |
+
" model_name=\"unsloth/Qwen3-0.6B\",\n",
|
| 74 |
+
" max_seq_length=4096,\n",
|
| 75 |
+
" load_in_4bit=True,\n",
|
| 76 |
+
")\n",
|
| 77 |
+
"\n",
|
| 78 |
+
"model = FastLanguageModel.get_peft_model(\n",
|
| 79 |
+
" model,\n",
|
| 80 |
+
" r=16,\n",
|
| 81 |
+
" target_modules=[\"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\",\n",
|
| 82 |
+
" \"gate_proj\", \"up_proj\", \"down_proj\"],\n",
|
| 83 |
+
" lora_alpha=16,\n",
|
| 84 |
+
" lora_dropout=0,\n",
|
| 85 |
+
" use_gradient_checkpointing=\"unsloth\",\n",
|
| 86 |
+
")\n",
|
| 87 |
+
"print(f\"Model loaded. Trainable params: {model.print_trainable_parameters()}\")"
|
| 88 |
+
]
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"cell_type": "markdown",
|
| 92 |
+
"metadata": {},
|
| 93 |
+
"source": [
|
| 94 |
+
"## 3. Define the HCM:21 Environment Wrapper\n",
|
| 95 |
+
"\n",
|
| 96 |
+
"We wrap the OpenEnv WebSocket client as a TRL `environment_factory` that\n",
|
| 97 |
+
"exposes `reset()`, `step()`, and `get_reward()` methods. The agent calls\n",
|
| 98 |
+
"`step()` as a tool, and the reward comes from quarterly HR metric improvements.\n",
|
| 99 |
+
"\n",
|
| 100 |
+
"The OpenEnv WebSocket protocol maintains stateful sessions \u00e2\u20ac\u201d each connection\n",
|
| 101 |
+
"gets its own Environment instance on the server."
|
| 102 |
+
]
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"cell_type": "code",
|
| 106 |
+
"execution_count": null,
|
| 107 |
+
"metadata": {},
|
| 108 |
+
"outputs": [],
|
| 109 |
+
"source": [
|
| 110 |
+
"import json\n",
|
| 111 |
+
"from openenv.core import GenericEnvClient\n",
|
| 112 |
+
"\n",
|
| 113 |
+
"HCM21_URL = \"https://paretooptimal-hcm21.hf.space\"\n",
|
| 114 |
+
"\n",
|
| 115 |
+
"\n",
|
| 116 |
+
"class HCM21Env:\n",
|
| 117 |
+
" \"\"\"TRL-compatible wrapper for the HCM:21 OpenEnv environment.\n",
|
| 118 |
+
"\n",
|
| 119 |
+
" Uses the OpenEnv GenericEnvClient (WebSocket) for stateful sessions.\n",
|
| 120 |
+
" Each instance gets its own environment on the server.\n",
|
| 121 |
+
" \"\"\"\n",
|
| 122 |
+
"\n",
|
| 123 |
+
" def __init__(self):\n",
|
| 124 |
+
" self._client = GenericEnvClient(base_url=HCM21_URL)\n",
|
| 125 |
+
" self._client.connect()\n",
|
| 126 |
+
" self._reward = 0.0\n",
|
| 127 |
+
" self._done = False\n",
|
| 128 |
+
"\n",
|
| 129 |
+
" def reset(self, seed: int = 42) -> str:\n",
|
| 130 |
+
" \"\"\"Reset the environment for a new episode.\"\"\"\n",
|
| 131 |
+
" result = self._client.reset(seed=seed)\n",
|
| 132 |
+
" obs = result.observation\n",
|
| 133 |
+
" self._reward = result.reward or 0.0\n",
|
| 134 |
+
" self._done = result.done\n",
|
| 135 |
+
" return self._format_obs(obs)\n",
|
| 136 |
+
"\n",
|
| 137 |
+
" def step(self, action_json: str) -> str:\n",
|
| 138 |
+
" \"\"\"Take an action in the environment.\n",
|
| 139 |
+
"\n",
|
| 140 |
+
" Args:\n",
|
| 141 |
+
" action_json: JSON string with action_type and parameters.\n",
|
| 142 |
+
" Example: {\\\"action_type\\\": \\\"query_department\\\", \\\"department\\\": \\\"Engineering\\\"}\n",
|
| 143 |
+
"\n",
|
| 144 |
+
" Returns:\n",
|
| 145 |
+
" Observation string describing the result.\n",
|
| 146 |
+
" \"\"\"\n",
|
| 147 |
+
" try:\n",
|
| 148 |
+
" action = json.loads(action_json)\n",
|
| 149 |
+
" except json.JSONDecodeError:\n",
|
| 150 |
+
" return \"Error: Invalid JSON. Provide a valid action JSON object.\"\n",
|
| 151 |
+
"\n",
|
| 152 |
+
" from openenv.core import GenericAction\n",
|
| 153 |
+
" generic_action = GenericAction(**action)\n",
|
| 154 |
+
" result = self._client.step(generic_action)\n",
|
| 155 |
+
" obs = result.observation\n",
|
| 156 |
+
" if result.reward is not None:\n",
|
| 157 |
+
" self._reward = float(result.reward)\n",
|
| 158 |
+
" self._done = result.done\n",
|
| 159 |
+
" return self._format_obs(obs)\n",
|
| 160 |
+
"\n",
|
| 161 |
+
" def get_reward(self) -> float:\n",
|
| 162 |
+
" \"\"\"Return the reward from the last step.\"\"\"\n",
|
| 163 |
+
" return self._reward\n",
|
| 164 |
+
"\n",
|
| 165 |
+
" def close(self):\n",
|
| 166 |
+
" \"\"\"Disconnect from the environment.\"\"\"\n",
|
| 167 |
+
" try:\n",
|
| 168 |
+
" self._client.disconnect()\n",
|
| 169 |
+
" except Exception:\n",
|
| 170 |
+
" pass\n",
|
| 171 |
+
"\n",
|
| 172 |
+
" def _format_obs(self, obs) -> str:\n",
|
| 173 |
+
" \"\"\"Format observation as a concise string for the LLM.\"\"\"\n",
|
| 174 |
+
" if isinstance(obs, dict):\n",
|
| 175 |
+
" data = obs\n",
|
| 176 |
+
" else:\n",
|
| 177 |
+
" data = obs if isinstance(obs, dict) else vars(obs) if hasattr(obs, '__dict__') else {\"raw\": str(obs)}\n",
|
| 178 |
+
" parts = []\n",
|
| 179 |
+
" parts.append(f\"Q{data.get('current_quarter', '?')} | {data.get('current_phase', '?')} phase\")\n",
|
| 180 |
+
" parts.append(str(data.get('message', '')))\n",
|
| 181 |
+
" warnings = data.get('warnings', [])\n",
|
| 182 |
+
" if warnings:\n",
|
| 183 |
+
" parts.append(f\"Warnings: {', '.join(str(w) for w in warnings)}\")\n",
|
| 184 |
+
" actions = data.get('available_actions', [])\n",
|
| 185 |
+
" if actions:\n",
|
| 186 |
+
" parts.append(f\"Available: {', '.join(str(a) for a in actions)}\")\n",
|
| 187 |
+
" obs_data = data.get('data')\n",
|
| 188 |
+
" if obs_data:\n",
|
| 189 |
+
" data_str = json.dumps(obs_data, indent=2, default=str)\n",
|
| 190 |
+
" if len(data_str) > 1500:\n",
|
| 191 |
+
" data_str = data_str[:1500] + \"...\"\n",
|
| 192 |
+
" parts.append(f\"Data: {data_str}\")\n",
|
| 193 |
+
" return \"\\n\".join(parts)\n",
|
| 194 |
+
"\n",
|
| 195 |
+
"\n",
|
| 196 |
+
"# Quick connectivity test\n",
|
| 197 |
+
"import requests\n",
|
| 198 |
+
"try:\n",
|
| 199 |
+
" health = requests.get(f\"{HCM21_URL}/health\", timeout=10)\n",
|
| 200 |
+
" print(f\"Environment health: {health.json()}\")\n",
|
| 201 |
+
" env_test = HCM21Env()\n",
|
| 202 |
+
" obs = env_test.reset(seed=42)\n",
|
| 203 |
+
" print(f\"\\nReset OK. Observation preview:\")\n",
|
| 204 |
+
" print(obs[:300])\n",
|
| 205 |
+
" env_test.close()\n",
|
| 206 |
+
"except Exception as e:\n",
|
| 207 |
+
" print(f\"Environment not reachable: {e}\")\n",
|
| 208 |
+
" print(\"If the Space is still building, wait a few minutes and re-run.\")"
|
| 209 |
+
]
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
"cell_type": "markdown",
|
| 213 |
+
"metadata": {},
|
| 214 |
+
"source": [
|
| 215 |
+
"## 3a. Random Baseline\n",
|
| 216 |
+
"\n",
|
| 217 |
+
"Before training, establish the **floor** by running episodes with random actions.\n",
|
| 218 |
+
"A random agent scores ~0.15-0.25, showing how hard the environment is without strategy."
|
| 219 |
+
]
|
| 220 |
+
},
|
| 221 |
+
{
|
| 222 |
+
"cell_type": "code",
|
| 223 |
+
"metadata": {},
|
| 224 |
+
"source": [
|
| 225 |
+
"import random\n",
|
| 226 |
+
"import numpy as np\n",
|
| 227 |
+
"\n",
|
| 228 |
+
"BASELINE_SEEDS = [42, 99, 123, 456, 789]\n",
|
| 229 |
+
"\n",
|
| 230 |
+
"# Valid actions by phase for a random agent\n",
|
| 231 |
+
"RANDOM_ACTIONS = {\n",
|
| 232 |
+
" \"scanning\": [\n",
|
| 233 |
+
" {\"action_type\": \"query_department\", \"department\": \"Engineering\"},\n",
|
| 234 |
+
" {\"action_type\": \"query_department\", \"department\": \"Sales\"},\n",
|
| 235 |
+
" {\"action_type\": \"query_department\", \"department\": \"Operations\"},\n",
|
| 236 |
+
" {\"action_type\": \"review_financials\"},\n",
|
| 237 |
+
" {\"action_type\": \"calculate_metric\", \"metric_name\": \"hcva\"},\n",
|
| 238 |
+
" ],\n",
|
| 239 |
+
" \"planning\": [\n",
|
| 240 |
+
" {\"action_type\": \"set_hiring_target\", \"department\": \"Engineering\", \"count\": 5},\n",
|
| 241 |
+
" {\"action_type\": \"set_training_budget\", \"department\": \"Sales\", \"amount\": 10000},\n",
|
| 242 |
+
" {\"action_type\": \"set_compensation_policy\", \"department\": \"Operations\", \"parameters\": {\"adjustment_pct\": 2.0}},\n",
|
| 243 |
+
" ],\n",
|
| 244 |
+
" \"producing\": [\n",
|
| 245 |
+
" {\"action_type\": \"execute_hiring\", \"department\": \"Engineering\", \"count\": 3},\n",
|
| 246 |
+
" {\"action_type\": \"execute_training\", \"department\": \"Sales\", \"parameters\": {\"hours\": 10}},\n",
|
| 247 |
+
" ],\n",
|
| 248 |
+
" \"controlling\": [\n",
|
| 249 |
+
" {\"action_type\": \"calculate_metric\", \"metric_name\": \"hcva\"},\n",
|
| 250 |
+
" {\"action_type\": \"submit_report\", \"rationale\": \"Random agent report\"},\n",
|
| 251 |
+
" ],\n",
|
| 252 |
+
"}\n",
|
| 253 |
+
"\n",
|
| 254 |
+
"def run_random_episode(seed):\n",
|
| 255 |
+
" \"\"\"Run one episode with random actions, return final score.\"\"\"\n",
|
| 256 |
+
" env = HCM21Env()\n",
|
| 257 |
+
" try:\n",
|
| 258 |
+
" env.reset(seed=seed)\n",
|
| 259 |
+
" rng = random.Random(seed)\n",
|
| 260 |
+
" steps = 0\n",
|
| 261 |
+
" while not env._done and steps < 300:\n",
|
| 262 |
+
" if steps % 5 == 4:\n",
|
| 263 |
+
" env.step(json.dumps({\"action_type\": \"advance_phase\"}))\n",
|
| 264 |
+
" elif steps % 8 == 7:\n",
|
| 265 |
+
" env.step(json.dumps({\"action_type\": \"advance_quarter\"}))\n",
|
| 266 |
+
" else:\n",
|
| 267 |
+
" phase = rng.choice(list(RANDOM_ACTIONS.keys()))\n",
|
| 268 |
+
" action = rng.choice(RANDOM_ACTIONS[phase])\n",
|
| 269 |
+
" env.step(json.dumps(action))\n",
|
| 270 |
+
" steps += 1\n",
|
| 271 |
+
" return env.get_reward()\n",
|
| 272 |
+
" finally:\n",
|
| 273 |
+
" env.close()\n",
|
| 274 |
+
"\n",
|
| 275 |
+
"print(\"Running random baseline episodes...\")\n",
|
| 276 |
+
"random_scores = []\n",
|
| 277 |
+
"for seed in BASELINE_SEEDS:\n",
|
| 278 |
+
" score = run_random_episode(seed)\n",
|
| 279 |
+
" random_scores.append(score)\n",
|
| 280 |
+
" print(f\" Seed {seed:>3d}: {score:.4f}\")\n",
|
| 281 |
+
"\n",
|
| 282 |
+
"print(f\"\\n{'='*40}\")\n",
|
| 283 |
+
"print(f\"Random Baseline: {np.mean(random_scores):.4f} +/- {np.std(random_scores):.4f}\")\n",
|
| 284 |
+
"print(f\"{'='*40}\")"
|
| 285 |
+
],
|
| 286 |
+
"outputs": [],
|
| 287 |
+
"execution_count": null
|
| 288 |
+
},
|
| 289 |
+
{
|
| 290 |
+
"cell_type": "markdown",
|
| 291 |
+
"metadata": {},
|
| 292 |
+
"source": [
|
| 293 |
+
"## 4. Build Training Dataset\n",
|
| 294 |
+
"\n",
|
| 295 |
+
"For GRPO, we need a dataset of prompts that start each episode.\n",
|
| 296 |
+
"Each prompt describes the CHCO role and the current scenario."
|
| 297 |
+
]
|
| 298 |
+
},
|
| 299 |
+
{
|
| 300 |
+
"cell_type": "code",
|
| 301 |
+
"execution_count": null,
|
| 302 |
+
"metadata": {},
|
| 303 |
+
"outputs": [],
|
| 304 |
+
"source": [
|
| 305 |
+
"from datasets import Dataset\n",
|
| 306 |
+
"\n",
|
| 307 |
+
"SYSTEM_PROMPT = \"\"\"You are the Chief Human Capital Officer (CHCO) of a simulated company.\n",
|
| 308 |
+
"You manage 300 employees across 5 departments over 6 quarters.\n",
|
| 309 |
+
"Each quarter has 4 phases: Scanning, Planning, Producing, Controlling.\n",
|
| 310 |
+
"Maximize HCVA, HCROI, and QIPS metrics by making strategic HR decisions.\n",
|
| 311 |
+
"\n",
|
| 312 |
+
"When calling the step tool, provide a JSON action object like:\n",
|
| 313 |
+
"{\"action_type\": \"query_department\", \"department\": \"Engineering\"}\n",
|
| 314 |
+
"\n",
|
| 315 |
+
"Available action types vary by phase:\n",
|
| 316 |
+
"- Scanning: query_department, query_employees, calculate_metric, review_financials\n",
|
| 317 |
+
"- Planning: set_hiring_target, set_training_budget, set_compensation_policy, set_retention_program\n",
|
| 318 |
+
"- Producing: execute_hiring, execute_promotion, execute_transfer, execute_training, execute_termination\n",
|
| 319 |
+
"- Controlling: submit_report, calculate_metric, advance_quarter\n",
|
| 320 |
+
"- advance_phase: move to next phase (after minimum actions taken)\n",
|
| 321 |
+
"\n",
|
| 322 |
+
"Think step by step. Start by scanning each department.\"\"\"\n",
|
| 323 |
+
"\n",
|
| 324 |
+
"# Create prompts with different seeds for diversity\n",
|
| 325 |
+
"prompts = []\n",
|
| 326 |
+
"for seed in range(20):\n",
|
| 327 |
+
" prompts.append({\n",
|
| 328 |
+
" \"prompt\": [\n",
|
| 329 |
+
" {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
|
| 330 |
+
" {\"role\": \"user\", \"content\": f\"Begin managing the company. Episode seed: {seed}. Start by scanning your organization to understand its current state.\"},\n",
|
| 331 |
+
" ]\n",
|
| 332 |
+
" })\n",
|
| 333 |
+
"\n",
|
| 334 |
+
"dataset = Dataset.from_list(prompts)\n",
|
| 335 |
+
"print(f\"Training dataset: {len(dataset)} episodes\")"
|
| 336 |
+
]
|
| 337 |
+
},
|
| 338 |
+
{
|
| 339 |
+
"cell_type": "markdown",
|
| 340 |
+
"metadata": {},
|
| 341 |
+
"source": [
|
| 342 |
+
"## 4a. Heuristic Baseline\n",
|
| 343 |
+
"\n",
|
| 344 |
+
"Run the built-in heuristic agent (from `demo.py`) on the same seeds.\n",
|
| 345 |
+
"This data-driven strategy scores ~0.50-0.65, representing what a\n",
|
| 346 |
+
"well-designed non-LLM approach achieves. The trained model should aim\n",
|
| 347 |
+
"to match or exceed this."
|
| 348 |
+
]
|
| 349 |
+
},
|
| 350 |
+
{
|
| 351 |
+
"cell_type": "code",
|
| 352 |
+
"metadata": {},
|
| 353 |
+
"source": [
|
| 354 |
+
"import requests\n",
|
| 355 |
+
"\n",
|
| 356 |
+
"def run_heuristic_episode(seed, size=300):\n",
|
| 357 |
+
" \"\"\"Run the built-in heuristic agent via the /demo API endpoint.\"\"\"\n",
|
| 358 |
+
" try:\n",
|
| 359 |
+
" resp = requests.get(\n",
|
| 360 |
+
" f\"{HCM21_URL}/demo\",\n",
|
| 361 |
+
" params={\"seed\": seed, \"size\": size},\n",
|
| 362 |
+
" timeout=120,\n",
|
| 363 |
+
" )\n",
|
| 364 |
+
" result = resp.json()\n",
|
| 365 |
+
" return result.get(\"final_score\", 0.0)\n",
|
| 366 |
+
" except Exception as e:\n",
|
| 367 |
+
" print(f\" Error on seed {seed}: {e}\")\n",
|
| 368 |
+
" return 0.0\n",
|
| 369 |
+
"\n",
|
| 370 |
+
"print(\"Running heuristic baseline episodes...\")\n",
|
| 371 |
+
"heuristic_scores = []\n",
|
| 372 |
+
"for seed in BASELINE_SEEDS:\n",
|
| 373 |
+
" score = run_heuristic_episode(seed)\n",
|
| 374 |
+
" heuristic_scores.append(score)\n",
|
| 375 |
+
" print(f\" Seed {seed:>3d}: {score:.4f}\")\n",
|
| 376 |
+
"\n",
|
| 377 |
+
"print(f\"\\n{'='*40}\")\n",
|
| 378 |
+
"print(f\"Heuristic Baseline: {np.mean(heuristic_scores):.4f} +/- {np.std(heuristic_scores):.4f}\")\n",
|
| 379 |
+
"print(f\"{'='*40}\")\n",
|
| 380 |
+
"\n",
|
| 381 |
+
"print(f\"\\nBaseline Summary:\")\n",
|
| 382 |
+
"print(f\" Random: {np.mean(random_scores):.4f} (floor)\")\n",
|
| 383 |
+
"print(f\" Heuristic: {np.mean(heuristic_scores):.4f} (target)\")"
|
| 384 |
+
],
|
| 385 |
+
"outputs": [],
|
| 386 |
+
"execution_count": null
|
| 387 |
+
},
|
| 388 |
+
{
|
| 389 |
+
"cell_type": "markdown",
|
| 390 |
+
"metadata": {},
|
| 391 |
+
"source": [
|
| 392 |
+
"## 5. Define Reward Function\n",
|
| 393 |
+
"\n",
|
| 394 |
+
"The reward function uses the environment's built-in scoring.\n",
|
| 395 |
+
"Quarterly rewards are based on HCVA/HCROI/QIPS improvement.\n",
|
| 396 |
+
"The final episode score (0-1) is the terminal reward."
|
| 397 |
+
]
|
| 398 |
+
},
|
| 399 |
+
{
|
| 400 |
+
"cell_type": "code",
|
| 401 |
+
"execution_count": null,
|
| 402 |
+
"metadata": {},
|
| 403 |
+
"outputs": [],
|
| 404 |
+
"source": [
|
| 405 |
+
"def reward_func(completions, environments, **kwargs):\n",
|
| 406 |
+
" \"\"\"Extract rewards from the HCM:21 environment after each rollout.\"\"\"\n",
|
| 407 |
+
" rewards = []\n",
|
| 408 |
+
" for env in environments:\n",
|
| 409 |
+
" r = env.get_reward()\n",
|
| 410 |
+
" rewards.append(r)\n",
|
| 411 |
+
" return rewards\n",
|
| 412 |
+
"\n",
|
| 413 |
+
"print(\"Reward function defined.\")"
|
| 414 |
+
]
|
| 415 |
+
},
|
| 416 |
+
{
|
| 417 |
+
"cell_type": "markdown",
|
| 418 |
+
"metadata": {},
|
| 419 |
+
"source": [
|
| 420 |
+
"## 6. Configure and Run GRPO Training\n",
|
| 421 |
+
"\n",
|
| 422 |
+
"We use TRL's `GRPOTrainer` with `environment_factory=HCM21Env`.\n",
|
| 423 |
+
"The model generates tool calls to interact with the environment,\n",
|
| 424 |
+
"and GRPO optimizes the policy based on the environment rewards.\n",
|
| 425 |
+
"\n",
|
| 426 |
+
"On a free T4 GPU, we use:\n",
|
| 427 |
+
"- Small batch size (2) with gradient accumulation\n",
|
| 428 |
+
"- 4-bit quantization via Unsloth\n",
|
| 429 |
+
"- Short max_completion_length to fit in memory\n",
|
| 430 |
+
"\n",
|
| 431 |
+
"After training, we plot the reward trajectory to visualize learning progress."
|
| 432 |
+
]
|
| 433 |
+
},
|
| 434 |
+
{
|
| 435 |
+
"cell_type": "code",
|
| 436 |
+
"metadata": {},
|
| 437 |
+
"source": [
|
| 438 |
+
"from trl import GRPOTrainer, GRPOConfig\n",
|
| 439 |
+
"\n",
|
| 440 |
+
"training_args = GRPOConfig(\n",
|
| 441 |
+
" output_dir=\"./hcm21-grpo-output\",\n",
|
| 442 |
+
" logging_dir=\"./hcm21-grpo-logs\",\n",
|
| 443 |
+
" num_train_epochs=1,\n",
|
| 444 |
+
" per_device_train_batch_size=2,\n",
|
| 445 |
+
" gradient_accumulation_steps=4,\n",
|
| 446 |
+
" num_generations=4,\n",
|
| 447 |
+
" max_completion_length=2048,\n",
|
| 448 |
+
" learning_rate=5e-6,\n",
|
| 449 |
+
" logging_steps=1,\n",
|
| 450 |
+
" save_steps=10,\n",
|
| 451 |
+
" bf16=True,\n",
|
| 452 |
+
" chat_template_kwargs={\"enable_thinking\": False},\n",
|
| 453 |
+
")\n",
|
| 454 |
+
"\n",
|
| 455 |
+
"trainer = GRPOTrainer(\n",
|
| 456 |
+
" model=model,\n",
|
| 457 |
+
" processing_class=tokenizer,\n",
|
| 458 |
+
" train_dataset=dataset,\n",
|
| 459 |
+
" reward_funcs=reward_func,\n",
|
| 460 |
+
" environment_factory=HCM21Env,\n",
|
| 461 |
+
" args=training_args,\n",
|
| 462 |
+
")\n",
|
| 463 |
+
"\n",
|
| 464 |
+
"print(\"Trainer initialized. Starting training...\")\n",
|
| 465 |
+
"trainer.train()\n",
|
| 466 |
+
"print(\"Training complete!\")\n",
|
| 467 |
+
"\n",
|
| 468 |
+
"# Extract and plot reward curve from training logs\n",
|
| 469 |
+
"import matplotlib.pyplot as plt\n",
|
| 470 |
+
"\n",
|
| 471 |
+
"log_history = trainer.state.log_history\n",
|
| 472 |
+
"rewards = [entry[\"reward\"] for entry in log_history if \"reward\" in entry]\n",
|
| 473 |
+
"losses = [entry[\"loss\"] for entry in log_history if \"loss\" in entry]\n",
|
| 474 |
+
"\n",
|
| 475 |
+
"fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n",
|
| 476 |
+
"\n",
|
| 477 |
+
"# Reward curve\n",
|
| 478 |
+
"if rewards:\n",
|
| 479 |
+
" axes[0].plot(rewards, alpha=0.4, label=\"Per-step reward\", color=\"steelblue\")\n",
|
| 480 |
+
" if len(rewards) >= 3:\n",
|
| 481 |
+
" window = min(5, len(rewards))\n",
|
| 482 |
+
" rolling = np.convolve(rewards, np.ones(window)/window, mode=\"valid\")\n",
|
| 483 |
+
" axes[0].plot(range(window-1, len(rewards)), rolling, linewidth=2,\n",
|
| 484 |
+
" label=f\"{window}-step rolling avg\", color=\"darkblue\")\n",
|
| 485 |
+
" axes[0].axhline(y=np.mean(random_scores), color=\"red\", linestyle=\"--\",\n",
|
| 486 |
+
" label=f\"Random baseline ({np.mean(random_scores):.3f})\")\n",
|
| 487 |
+
" axes[0].axhline(y=np.mean(heuristic_scores), color=\"green\", linestyle=\"--\",\n",
|
| 488 |
+
" label=f\"Heuristic baseline ({np.mean(heuristic_scores):.3f})\")\n",
|
| 489 |
+
" axes[0].set_xlabel(\"Training Step\")\n",
|
| 490 |
+
" axes[0].set_ylabel(\"Episode Reward\")\n",
|
| 491 |
+
" axes[0].set_title(\"GRPO Training Reward Curve\")\n",
|
| 492 |
+
" axes[0].legend()\n",
|
| 493 |
+
" axes[0].grid(True, alpha=0.3)\n",
|
| 494 |
+
"\n",
|
| 495 |
+
"# Loss curve\n",
|
| 496 |
+
"if losses:\n",
|
| 497 |
+
" axes[1].plot(losses, color=\"orange\", alpha=0.7)\n",
|
| 498 |
+
" axes[1].set_xlabel(\"Training Step\")\n",
|
| 499 |
+
" axes[1].set_ylabel(\"Loss\")\n",
|
| 500 |
+
" axes[1].set_title(\"Training Loss\")\n",
|
| 501 |
+
" axes[1].grid(True, alpha=0.3)\n",
|
| 502 |
+
"\n",
|
| 503 |
+
"plt.tight_layout()\n",
|
| 504 |
+
"plt.savefig(\"./hcm21-grpo-output/training_curves.png\", dpi=150, bbox_inches=\"tight\")\n",
|
| 505 |
+
"plt.show()\n",
|
| 506 |
+
"print(f\"\\nTraining stats: {len(rewards)} reward entries, mean={np.mean(rewards):.4f}\" if rewards else \"No reward data logged.\")"
|
| 507 |
+
],
|
| 508 |
+
"outputs": [],
|
| 509 |
+
"execution_count": null
|
| 510 |
+
},
|
| 511 |
+
{
|
| 512 |
+
"cell_type": "markdown",
|
| 513 |
+
"metadata": {},
|
| 514 |
+
"source": [
|
| 515 |
+
"## 7. Save & Push Model"
|
| 516 |
+
]
|
| 517 |
+
},
|
| 518 |
+
{
|
| 519 |
+
"cell_type": "code",
|
| 520 |
+
"execution_count": null,
|
| 521 |
+
"metadata": {},
|
| 522 |
+
"outputs": [],
|
| 523 |
+
"source": [
|
| 524 |
+
"# Save locally\n",
|
| 525 |
+
"model.save_pretrained(\"./hcm21-agent-lora\")\n",
|
| 526 |
+
"tokenizer.save_pretrained(\"./hcm21-agent-lora\")\n",
|
| 527 |
+
"\n",
|
| 528 |
+
"# Optional: push to HF Hub\n",
|
| 529 |
+
"# model.push_to_hub(\"your-username/hcm21-agent-lora\")\n",
|
| 530 |
+
"# tokenizer.push_to_hub(\"your-username/hcm21-agent-lora\")\n",
|
| 531 |
+
"\n",
|
| 532 |
+
"print(\"Model saved to ./hcm21-agent-lora\")"
|
| 533 |
+
]
|
| 534 |
+
},
|
| 535 |
+
{
|
| 536 |
+
"cell_type": "markdown",
|
| 537 |
+
"metadata": {},
|
| 538 |
+
"source": [
|
| 539 |
+
"## 8. Evaluate the Trained Agent & Compare to Baselines\n",
|
| 540 |
+
"\n",
|
| 541 |
+
"Run the trained model on the **same seeds** as the baselines.\n",
|
| 542 |
+
"This produces a direct before/after comparison showing whether\n",
|
| 543 |
+
"GRPO training improved the agent's HR decision-making."
|
| 544 |
+
]
|
| 545 |
+
},
|
| 546 |
+
{
|
| 547 |
+
"cell_type": "code",
|
| 548 |
+
"metadata": {},
|
| 549 |
+
"source": [
|
| 550 |
+
"import json\n",
|
| 551 |
+
"\n",
|
| 552 |
+
"FastLanguageModel.for_inference(model)\n",
|
| 553 |
+
"\n",
|
| 554 |
+
"def run_trained_episode(seed, max_steps=200):\n",
|
| 555 |
+
" \"\"\"Run one episode with the trained model, return final score.\"\"\"\n",
|
| 556 |
+
" env = HCM21Env()\n",
|
| 557 |
+
" try:\n",
|
| 558 |
+
" obs_text = env.reset(seed=seed)\n",
|
| 559 |
+
" messages = [\n",
|
| 560 |
+
" {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
|
| 561 |
+
" {\"role\": \"user\", \"content\": f\"Begin. Observation:\\n{obs_text}\"},\n",
|
| 562 |
+
" ]\n",
|
| 563 |
+
" step_num = 0\n",
|
| 564 |
+
" while not env._done and step_num < max_steps:\n",
|
| 565 |
+
" inputs = tokenizer.apply_chat_template(\n",
|
| 566 |
+
" messages, return_tensors=\"pt\", add_generation_prompt=True\n",
|
| 567 |
+
" ).to(model.device)\n",
|
| 568 |
+
" outputs = model.generate(\n",
|
| 569 |
+
" input_ids=inputs, max_new_tokens=256,\n",
|
| 570 |
+
" temperature=0.7, do_sample=True,\n",
|
| 571 |
+
" )\n",
|
| 572 |
+
" response = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True)\n",
|
| 573 |
+
" try:\n",
|
| 574 |
+
" start = response.index(\"{\")\n",
|
| 575 |
+
" depth = 0\n",
|
| 576 |
+
" for i, c in enumerate(response[start:], start):\n",
|
| 577 |
+
" if c == \"{\": depth += 1\n",
|
| 578 |
+
" elif c == \"}\": depth -= 1\n",
|
| 579 |
+
" if depth == 0:\n",
|
| 580 |
+
" action_json = response[start:i+1]\n",
|
| 581 |
+
" break\n",
|
| 582 |
+
" obs_text = env.step(action_json)\n",
|
| 583 |
+
" except (ValueError, json.JSONDecodeError):\n",
|
| 584 |
+
" obs_text = env.step(json.dumps({\"action_type\": \"advance_phase\"}))\n",
|
| 585 |
+
" messages.append({\"role\": \"assistant\", \"content\": response})\n",
|
| 586 |
+
" messages.append({\"role\": \"user\", \"content\": obs_text})\n",
|
| 587 |
+
" if len(messages) > 12:\n",
|
| 588 |
+
" messages = messages[:2] + messages[-10:]\n",
|
| 589 |
+
" step_num += 1\n",
|
| 590 |
+
" return env.get_reward(), step_num\n",
|
| 591 |
+
" finally:\n",
|
| 592 |
+
" env.close()\n",
|
| 593 |
+
"\n",
|
| 594 |
+
"print(\"Evaluating trained agent on baseline seeds...\")\n",
|
| 595 |
+
"trained_scores = []\n",
|
| 596 |
+
"for seed in BASELINE_SEEDS:\n",
|
| 597 |
+
" score, steps = run_trained_episode(seed)\n",
|
| 598 |
+
" trained_scores.append(score)\n",
|
| 599 |
+
" print(f\" Seed {seed:>3d}: {score:.4f} ({steps} steps)\")\n",
|
| 600 |
+
"\n",
|
| 601 |
+
"sep = \"=\" * 50\n",
|
| 602 |
+
"print(f\"\\n{sep}\")\n",
|
| 603 |
+
"print(f\" RESULTS COMPARISON\")\n",
|
| 604 |
+
"print(f\"{sep}\")\n",
|
| 605 |
+
"print(f\" Random Agent: {np.mean(random_scores):.4f} +/- {np.std(random_scores):.4f}\")\n",
|
| 606 |
+
"print(f\" Heuristic Agent: {np.mean(heuristic_scores):.4f} +/- {np.std(heuristic_scores):.4f}\")\n",
|
| 607 |
+
"print(f\" Trained Agent: {np.mean(trained_scores):.4f} +/- {np.std(trained_scores):.4f}\")\n",
|
| 608 |
+
"print(f\"{sep}\")\n",
|
| 609 |
+
"\n",
|
| 610 |
+
"improvement = np.mean(trained_scores) - np.mean(random_scores)\n",
|
| 611 |
+
"print(f\"\\n Improvement over random: +{improvement:.4f} ({improvement/max(np.mean(random_scores), 0.01)*100:.1f}%)\")\n",
|
| 612 |
+
"if np.mean(trained_scores) > np.mean(random_scores):\n",
|
| 613 |
+
" print(\" >>> Training shows positive improvement! <<<\")"
|
| 614 |
+
],
|
| 615 |
+
"outputs": [],
|
| 616 |
+
"execution_count": null
|
| 617 |
+
},
|
| 618 |
+
{
|
| 619 |
+
"cell_type": "markdown",
|
| 620 |
+
"metadata": {},
|
| 621 |
+
"source": [
|
| 622 |
+
"## 9. Results Summary & Visualization\n",
|
| 623 |
+
"\n",
|
| 624 |
+
"Final comparison across all agent types with visualization."
|
| 625 |
+
]
|
| 626 |
+
},
|
| 627 |
+
{
|
| 628 |
+
"cell_type": "code",
|
| 629 |
+
"metadata": {},
|
| 630 |
+
"source": [
|
| 631 |
+
"import matplotlib.pyplot as plt\n",
|
| 632 |
+
"import os\n",
|
| 633 |
+
"\n",
|
| 634 |
+
"# === Bar chart comparison ===\n",
|
| 635 |
+
"agents = [\"Random\", \"Heuristic\", \"Trained (GRPO)\"]\n",
|
| 636 |
+
"means = [np.mean(random_scores), np.mean(heuristic_scores), np.mean(trained_scores)]\n",
|
| 637 |
+
"stds = [np.std(random_scores), np.std(heuristic_scores), np.std(trained_scores)]\n",
|
| 638 |
+
"colors = [\"#e74c3c\", \"#f39c12\", \"#2ecc71\"]\n",
|
| 639 |
+
"\n",
|
| 640 |
+
"fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n",
|
| 641 |
+
"\n",
|
| 642 |
+
"# Bar chart\n",
|
| 643 |
+
"bars = axes[0].bar(agents, means, yerr=stds, capsize=8, color=colors, edgecolor=\"black\", alpha=0.85)\n",
|
| 644 |
+
"axes[0].set_ylabel(\"Final Score (0-1)\")\n",
|
| 645 |
+
"axes[0].set_title(\"Agent Performance Comparison\")\n",
|
| 646 |
+
"axes[0].set_ylim(0, 1.0)\n",
|
| 647 |
+
"axes[0].grid(True, axis=\"y\", alpha=0.3)\n",
|
| 648 |
+
"for bar, mean in zip(bars, means):\n",
|
| 649 |
+
" axes[0].text(bar.get_x() + bar.get_width()/2., bar.get_height() + 0.02,\n",
|
| 650 |
+
" f\"{mean:.3f}\", ha=\"center\", va=\"bottom\", fontweight=\"bold\")\n",
|
| 651 |
+
"\n",
|
| 652 |
+
"# Per-seed breakdown\n",
|
| 653 |
+
"x = np.arange(len(BASELINE_SEEDS))\n",
|
| 654 |
+
"width = 0.25\n",
|
| 655 |
+
"axes[1].bar(x - width, random_scores, width, label=\"Random\", color=\"#e74c3c\", alpha=0.8)\n",
|
| 656 |
+
"axes[1].bar(x, heuristic_scores, width, label=\"Heuristic\", color=\"#f39c12\", alpha=0.8)\n",
|
| 657 |
+
"axes[1].bar(x + width, trained_scores, width, label=\"Trained\", color=\"#2ecc71\", alpha=0.8)\n",
|
| 658 |
+
"axes[1].set_xlabel(\"Seed\")\n",
|
| 659 |
+
"axes[1].set_ylabel(\"Final Score\")\n",
|
| 660 |
+
"axes[1].set_title(\"Per-Seed Score Breakdown\")\n",
|
| 661 |
+
"axes[1].set_xticks(x)\n",
|
| 662 |
+
"axes[1].set_xticklabels([str(s) for s in BASELINE_SEEDS])\n",
|
| 663 |
+
"axes[1].legend()\n",
|
| 664 |
+
"axes[1].grid(True, axis=\"y\", alpha=0.3)\n",
|
| 665 |
+
"\n",
|
| 666 |
+
"plt.tight_layout()\n",
|
| 667 |
+
"plt.savefig(\"./hcm21-grpo-output/results_comparison.png\", dpi=150, bbox_inches=\"tight\")\n",
|
| 668 |
+
"plt.show()\n",
|
| 669 |
+
"\n",
|
| 670 |
+
"# Save results to JSON\n",
|
| 671 |
+
"os.makedirs(\"./hcm21-grpo-output\", exist_ok=True)\n",
|
| 672 |
+
"results = {\n",
|
| 673 |
+
" \"baselines\": {\n",
|
| 674 |
+
" \"random\": {\"mean\": float(np.mean(random_scores)), \"std\": float(np.std(random_scores)),\n",
|
| 675 |
+
" \"per_seed\": {str(s): float(v) for s, v in zip(BASELINE_SEEDS, random_scores)}},\n",
|
| 676 |
+
" \"heuristic\": {\"mean\": float(np.mean(heuristic_scores)), \"std\": float(np.std(heuristic_scores)),\n",
|
| 677 |
+
" \"per_seed\": {str(s): float(v) for s, v in zip(BASELINE_SEEDS, heuristic_scores)}},\n",
|
| 678 |
+
" },\n",
|
| 679 |
+
" \"trained\": {\"mean\": float(np.mean(trained_scores)), \"std\": float(np.std(trained_scores)),\n",
|
| 680 |
+
" \"per_seed\": {str(s): float(v) for s, v in zip(BASELINE_SEEDS, trained_scores)}},\n",
|
| 681 |
+
" \"improvement_over_random\": float(np.mean(trained_scores) - np.mean(random_scores)),\n",
|
| 682 |
+
" \"model\": \"unsloth/Qwen3-0.6B\",\n",
|
| 683 |
+
" \"method\": \"GRPO\",\n",
|
| 684 |
+
" \"lora_rank\": 16,\n",
|
| 685 |
+
"}\n",
|
| 686 |
+
"with open(\"./hcm21-grpo-output/training_results.json\", \"w\") as f:\n",
|
| 687 |
+
" json.dump(results, f, indent=2)\n",
|
| 688 |
+
"print(\"Results saved to ./hcm21-grpo-output/training_results.json\")\n",
|
| 689 |
+
"print(json.dumps(results, indent=2))"
|
| 690 |
+
],
|
| 691 |
+
"outputs": [],
|
| 692 |
+
"execution_count": null
|
| 693 |
+
}
|
| 694 |
+
]
|
| 695 |
+
}
|