ParetoOptimal Claude Opus 4.6 commited on
Commit
1ac70ff
·
1 Parent(s): 8fdc935

Fix GRPO all-zero rewards: parse JSON actions from completions

Browse files

Remove environment_factory from GRPOTrainer — reward_func now parses
JSON action objects from model text completions via regex and executes
them in fresh HCM21Env instances. This bypasses TRL's tool-calling
infrastructure which fails with a 0.6B model + Unsloth patches.

Changes:
- SYSTEM_PROMPT: instruct model to output JSON action lines
- reward_func: parse actions, run in env, return scores
- GRPOTrainer: remove environment_factory arg
- Evaluation: single-shot generation matching training format
- Fix BatchEncoding .shape bug in eval cell
- Add CLAUDE.md with GRPO training architecture docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (2) hide show
  1. CLAUDE.md +177 -0
  2. notebooks/hcm21_trl_training.ipynb +5 -125
CLAUDE.md ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # HR Productivity Environment — AgentX-AgentBeats
2
+
3
+ ## Overview
4
+ A fully-implemented HR simulation environment (OpenEnv-compliant) where an LLM agent serves as Chief Human Capital Officer (CHCO), managing a simulated company over 6 quarters. The agent uses Fitz-enz HR Analytics frameworks to optimize human capital metrics through strategic hiring, training, compensation, and retention decisions.
5
+
6
+ ## Repository Structure
7
+ ```
8
+ /tmp/autoresearch/
9
+ ├── hr_env/
10
+ │ └── server/
11
+ │ ├── environment.py # Main simulation loop (OpenEnv Environment)
12
+ │ ├── company.py # Company + Department models (Cobb-Douglas production)
13
+ │ ├── employee.py # Employee dataclass with lifecycle methods
14
+ │ ├── metrics.py # Fitz-enz metrics: HCVA, HCROI, QIPS, Five Indexes
15
+ │ ├── scoring.py # Reward computation + final score (normalized [0,1])
16
+ │ ├── phases.py # HCM:21 phase management (Scanning→Planning→Producing→Controlling)
17
+ │ ├── events.py # Stochastic quarterly events (market, competitor, etc.)
18
+ │ ├── data_gen.py # Synthetic company generation (Faker + numpy)
19
+ │ └── app.py # OpenEnv server entrypoint
20
+ ├── agent/
21
+ │ ├── hr_agent.py # Claude-based CHCO agent with phase-aware prompting
22
+ │ ├── prompts.py # System prompt, phase prompts, summary prompts
23
+ │ └── run.py # Agent runner script
24
+ ├── tests/ # Test suite
25
+ ├── openenv.yaml # OpenEnv configuration
26
+ ├── pyproject.toml # Python project config (uv)
27
+ └── Dockerfile # Container build
28
+ ```
29
+
30
+ ## Development Commands
31
+ ```bash
32
+ # Install dependencies
33
+ uv sync
34
+
35
+ # Run tests
36
+ uv run pytest tests/ -v
37
+
38
+ # Start OpenEnv server
39
+ uv run python -m hr_env.server.app
40
+
41
+ # Run agent against server
42
+ uv run python -m agent.run
43
+
44
+ # Docker build
45
+ docker build -t hr-productivity-env .
46
+ ```
47
+
48
+ ## Architecture
49
+
50
+ ### Simulation Engine
51
+ - **Company Model**: 5 departments (Engineering, Sales, Operations, HR, Finance) with Cobb-Douglas production functions
52
+ - **Employee Model**: 17 tracked attributes including performance, engagement, flight_risk, promotability
53
+ - **Revenue**: `A * headcount^alpha * avg_skill^beta * engagement_mult * market_modifier` per department
54
+ - **Turnover**: Stochastic based on flight_risk (logistic function of pay, engagement, tenure, market)
55
+
56
+ ### Fitz-enz Metrics
57
+ - **HCVA**: (Revenue - Non-Employment Costs) / FTE
58
+ - **HCROI**: Revenue / Employment Cost
59
+ - **QIPS**: Quality(0.25) + Innovation(0.25) + Productivity(0.30) + Service(0.20)
60
+ - **Five Indexes**: Cost, Time, Quantity, Quality, Human Reactions (% change vs prior quarter)
61
+ - **Employee Value**: avg(Productivity + Promotability + Transferability + Retainability)
62
+
63
+ ### HCM:21 Phase System (per quarter)
64
+ 1. **Scanning** (min 2 actions): query_department, query_employees, calculate_metric, review_financials
65
+ 2. **Planning** (min 1): set_hiring_target, set_training_budget, set_compensation_policy, set_retention_program
66
+ 3. **Producing** (min 1): execute_hiring, execute_promotion, execute_transfer, execute_training, execute_termination
67
+ 4. **Controlling** (min 1): submit_report, calculate_metric → then advance_quarter
68
+
69
+ ### Scoring
70
+ - **Quarterly reward**: HCVA improvement (40%) + HCROI (20%) + QIPS (20%) + Five Indexes (20%)
71
+ - **Final score** [0,1]: HCVA trajectory (25%) + HCROI improvement (20%) + Employee Value (20%) + QIPS consistency (15%) + Five Indexes cumulative (10%) + Financial health (10%)
72
+
73
+ ### Stochastic Events
74
+ MarketDownturn, CompetitorPoaching, ProductLaunch, ExecDeparture, MinWageIncrease, EmployerAward, BudgetCut, TrainingBreakthrough — each with independent probability per quarter, max 2 per quarter.
75
+
76
+ ## Databricks Integration
77
+ The simulation is adapted into a Databricks notebook (`/tmp/AimpointDigital/01_agentic_wikipedia_aimpoint_interview.ipynb`) with:
78
+ - **Wikipedia knowledge layer**: HR theory articles (Fitz-enz, HCM, talent management) indexed into FAISS via BGE-Large embeddings
79
+ - **LangGraph orchestration**: StateGraph with 5 nodes (initialize → agent_decide → execute_tools → check_done → summarize)
80
+ - **RAG bridge**: `consult_hr_knowledge` tool lets the agent ground decisions in Wikipedia-sourced HR theory
81
+ - **Databricks LLM**: `databricks-meta-llama-3-1-8b-instruct` for agent reasoning
82
+
83
+ ## Key Conventions
84
+ - Pydantic models for OpenEnv; plain dataclasses/dicts for Databricks adaptation
85
+ - Phase-gated action space prevents random action sequences
86
+ - Company generation uses Faker (names) + numpy (distributions) with deterministic seeds
87
+ - Salary distributions: lognormal; tenure: exponential; engagement: beta(7,3)
88
+ - Level distribution: pyramid [0.35, 0.30, 0.20, 0.10, 0.05] for levels 1-5
89
+ - Recruiting cost: $8,000/hire; Benefits: 30% of salary; Training: $50/hr/employee
90
+
91
+ ## Databricks Deployment
92
+
93
+ ### Workspace
94
+ - **URL**: `https://dbc-c005fe3a-5584.cloud.databricks.com`
95
+ - **Org ID**: `7474655330631867`
96
+ - **Target Folder ID**: `1876640772048528`
97
+ - **Folder URL**: `https://dbc-c005fe3a-5584.cloud.databricks.com/browse/folders/1876640772048528?o=7474655330631867`
98
+ - **PAT**: (stored in env / .databrickscfg — not committed)
99
+ - **User**: `n8.mauer@gmail.com`
100
+ - **Edition**: Community Edition (Free)
101
+ - **LLM endpoint**: `databricks-meta-llama-3-1-8b-instruct`
102
+ - **Embedding endpoint**: `databricks-bge-large-en`
103
+
104
+ ### .env file (for local development)
105
+ ```
106
+ DATABRICKS_HOST=https://dbc-c005fe3a-5584.cloud.databricks.com
107
+ DATABRICKS_TOKEN=<your-token>
108
+ DATABRICKS_ORG_ID=7474655330631867
109
+ ```
110
+
111
+ ### Cold-Start Setup
112
+ 1. `pip install databricks-cli`
113
+ 2. Write `~/.databrickscfg`:
114
+ ```ini
115
+ [DEFAULT]
116
+ host = https://dbc-c005fe3a-5584.cloud.databricks.com
117
+ token = <your-token>
118
+ ```
119
+ 3. Deploy notebook:
120
+ ```bash
121
+ databricks workspace import \
122
+ "/tmp/AimpointDigital/01_agentic_wikipedia_aimpoint_interview.ipynb" \
123
+ /Users/n8.mauer@gmail.com/01_agentic_wikipedia_aimpoint_interview \
124
+ --format JUPYTER --language PYTHON --overwrite
125
+ ```
126
+ 4. Open workspace URL → attach cluster → Run All
127
+
128
+ ### Deploy Script
129
+ ```bash
130
+ ./deploy.sh # from /tmp/AimpointDigital/
131
+ ```
132
+
133
+ ### Runtime Fixes Applied
134
+ - **Rate limiting**: `agent_decide` LLM calls wrapped with `backoff.on_exception` (exponential, max 8 retries, 120s) + 1.5s proactive delay between calls. Community Edition has low QPS for foundation model endpoints.
135
+ - **MLflow tracing**: `mlflow.langchain.autolog()` added at top of Cell A2 to enable tracing and suppress Databricks "Enhance GenAI observability" hint. `mlflow-skinny[databricks]` already in pip install cell.
136
+
137
+ ### GitHub Repo
138
+ - URL: `https://github.com/n8mauer/AimpointDigital`
139
+ - Contains: notebook, build script, deploy script, README (runbook + talk track), assignment PDF
140
+
141
+ ## TRL/GRPO Training (`notebooks/hcm21_trl_training.ipynb`)
142
+
143
+ ### Architecture
144
+ - **Model**: `unsloth/Qwen3-0.6B` with LoRA (r=16, 4-bit quantization)
145
+ - **Method**: GRPO (Group Relative Policy Optimization) via TRL's `GRPOTrainer`
146
+ - **Environment**: HCM:21 OpenEnv on HF Spaces (`https://paretooptimal-hcm21.hf.space`)
147
+ - **Colab URL**: `https://colab.research.google.com/github/n8mauer/autoresearch/blob/main/notebooks/hcm21_trl_training.ipynb`
148
+
149
+ ### GRPO Reward Strategy (JSON action parsing — no environment_factory)
150
+ The model generates text completions containing JSON action objects (one per line).
151
+ `reward_func` parses these with regex (`\{[^{}]*"action_type"[^{}]*\}`), executes
152
+ them in a fresh `HCM21Env` instance, and returns the environment's final score.
153
+
154
+ **Why not `environment_factory`?**
155
+ - TRL's `environment_factory` exposes env methods as tools for tool-calling, but a 0.6B model can't reliably generate structured tool calls
156
+ - `environment_factory` requires `transformers>=5.2.0` which conflicts with Unsloth patches
157
+ - Disabling Qwen3 thinking mode via `chat_template.replace("enable_thinking", "")` may break tool-calling templates
158
+ - Direct JSON parsing in `reward_func` is simpler, more robust, and works with Unsloth
159
+
160
+ ### Key Implementation Details
161
+ - `SYSTEM_PROMPT` instructs the model to output 20-40 JSON action objects covering 6 quarters
162
+ - `reward_func(completions, **kwargs)` — no `environments` param (environments created internally)
163
+ - `GRPOTrainer` has NO `environment_factory` argument
164
+ - Evaluation (cell 20) generates one completion per seed, parses all actions, executes them
165
+ - `tokenizer.apply_chat_template()` may return `BatchEncoding` — extract `.input_ids` before using `.shape`
166
+
167
+ ### Training Config
168
+ - `per_device_train_batch_size=2`, `gradient_accumulation_steps=4`, `num_generations=4`
169
+ - `max_completion_length=2048`, `learning_rate=5e-6`, `fp16=True`
170
+ - Dataset: 20 prompts with different episode seeds
171
+
172
+ ### Verification Checklist
173
+ After running in Colab, confirm:
174
+ - `reward` column shows **non-zero values**
175
+ - `reward_std` is **non-zero** (learning signal exists)
176
+ - `Training Loss` is **non-zero** (GRPO has gradients)
177
+ - Rewards trend upward over training steps
notebooks/hcm21_trl_training.ipynb CHANGED
@@ -301,39 +301,7 @@
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",
@@ -401,40 +369,17 @@
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": "from trl import GRPOTrainer, GRPOConfig\nimport os\n\n# Disable Qwen3 thinking mode at the tokenizer level (Unsloth-compatible)\ntokenizer.chat_template = tokenizer.chat_template.replace(\"enable_thinking\", \"\")\n\nos.environ[\"TENSORBOARD_LOGGING_DIR\"] = \"./hcm21-grpo-logs\"\n\ntraining_args = GRPOConfig(\n output_dir=\"./hcm21-grpo-output\",\n num_train_epochs=1,\n per_device_train_batch_size=2,\n gradient_accumulation_steps=4,\n num_generations=4,\n max_completion_length=2048,\n learning_rate=5e-6,\n logging_steps=1,\n save_steps=10,\n fp16=True,\n)\n\ntrainer = GRPOTrainer(\n model=model,\n processing_class=tokenizer,\n train_dataset=dataset,\n reward_funcs=reward_func,\n environment_factory=HCM21Env,\n args=training_args,\n)\n\nprint(\"Trainer initialized. Starting training...\")\ntrainer.train()\nprint(\"Training complete!\")\n\n# Extract and plot reward curve from training logs\nimport matplotlib.pyplot as plt\n\nlog_history = trainer.state.log_history\nrewards = [entry[\"reward\"] for entry in log_history if \"reward\" in entry]\nlosses = [entry[\"loss\"] for entry in log_history if \"loss\" in entry]\n\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\n\n# Reward curve\nif rewards:\n axes[0].plot(rewards, alpha=0.4, label=\"Per-step reward\", color=\"steelblue\")\n if len(rewards) >= 3:\n window = min(5, len(rewards))\n rolling = np.convolve(rewards, np.ones(window)/window, mode=\"valid\")\n axes[0].plot(range(window-1, len(rewards)), rolling, linewidth=2,\n label=f\"{window}-step rolling avg\", color=\"darkblue\")\n axes[0].axhline(y=np.mean(random_scores), color=\"red\", linestyle=\"--\",\n label=f\"Random baseline ({np.mean(random_scores):.3f})\")\n axes[0].axhline(y=np.mean(heuristic_scores), color=\"green\", linestyle=\"--\",\n label=f\"Heuristic baseline ({np.mean(heuristic_scores):.3f})\")\n axes[0].set_xlabel(\"Training Step\")\n axes[0].set_ylabel(\"Episode Reward\")\n axes[0].set_title(\"GRPO Training Reward Curve\")\n axes[0].legend()\n axes[0].grid(True, alpha=0.3)\n\n# Loss curve\nif losses:\n axes[1].plot(losses, color=\"orange\", alpha=0.7)\n axes[1].set_xlabel(\"Training Step\")\n axes[1].set_ylabel(\"Loss\")\n axes[1].set_title(\"Training Loss\")\n axes[1].grid(True, alpha=0.3)\n\nplt.tight_layout()\nplt.savefig(\"./hcm21-grpo-output/training_curves.png\", dpi=150, bbox_inches=\"tight\")\nplt.show()\nprint(f\"\\nTraining stats: {len(rewards)} reward entries, mean={np.mean(rewards):.4f}\" if rewards else \"No reward data logged.\")",
438
  "outputs": [],
439
  "execution_count": null
440
  },
@@ -476,72 +421,7 @@
476
  {
477
  "cell_type": "code",
478
  "metadata": {},
479
- "source": [
480
- "import json\n",
481
- "\n",
482
- "FastLanguageModel.for_inference(model)\n",
483
- "\n",
484
- "def run_trained_episode(seed, max_steps=200):\n",
485
- " \"\"\"Run one episode with the trained model, return final score.\"\"\"\n",
486
- " env = HCM21Env()\n",
487
- " try:\n",
488
- " obs_text = env.reset(seed=seed)\n",
489
- " messages = [\n",
490
- " {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
491
- " {\"role\": \"user\", \"content\": f\"Begin. Observation:\\n{obs_text}\"},\n",
492
- " ]\n",
493
- " step_num = 0\n",
494
- " while not env._done and step_num < max_steps:\n",
495
- " inputs = tokenizer.apply_chat_template(\n",
496
- " messages, return_tensors=\"pt\", add_generation_prompt=True\n",
497
- " ).to(model.device)\n",
498
- " outputs = model.generate(\n",
499
- " input_ids=inputs, max_new_tokens=256,\n",
500
- " temperature=0.7, do_sample=True,\n",
501
- " )\n",
502
- " response = tokenizer.decode(outputs[0][inputs.shape[1]:], skip_special_tokens=True)\n",
503
- " try:\n",
504
- " start = response.index(\"{\")\n",
505
- " depth = 0\n",
506
- " for i, c in enumerate(response[start:], start):\n",
507
- " if c == \"{\": depth += 1\n",
508
- " elif c == \"}\": depth -= 1\n",
509
- " if depth == 0:\n",
510
- " action_json = response[start:i+1]\n",
511
- " break\n",
512
- " obs_text = env.step(action_json)\n",
513
- " except (ValueError, json.JSONDecodeError):\n",
514
- " obs_text = env.step(json.dumps({\"action_type\": \"advance_phase\"}))\n",
515
- " messages.append({\"role\": \"assistant\", \"content\": response})\n",
516
- " messages.append({\"role\": \"user\", \"content\": obs_text})\n",
517
- " if len(messages) > 12:\n",
518
- " messages = messages[:2] + messages[-10:]\n",
519
- " step_num += 1\n",
520
- " return env.get_reward(), step_num\n",
521
- " finally:\n",
522
- " env.close()\n",
523
- "\n",
524
- "print(\"Evaluating trained agent on baseline seeds...\")\n",
525
- "trained_scores = []\n",
526
- "for seed in BASELINE_SEEDS:\n",
527
- " score, steps = run_trained_episode(seed)\n",
528
- " trained_scores.append(score)\n",
529
- " print(f\" Seed {seed:>3d}: {score:.4f} ({steps} steps)\")\n",
530
- "\n",
531
- "sep = \"=\" * 50\n",
532
- "print(f\"\\n{sep}\")\n",
533
- "print(f\" RESULTS COMPARISON\")\n",
534
- "print(f\"{sep}\")\n",
535
- "print(f\" Random Agent: {np.mean(random_scores):.4f} +/- {np.std(random_scores):.4f}\")\n",
536
- "print(f\" Heuristic Agent: {np.mean(heuristic_scores):.4f} +/- {np.std(heuristic_scores):.4f}\")\n",
537
- "print(f\" Trained Agent: {np.mean(trained_scores):.4f} +/- {np.std(trained_scores):.4f}\")\n",
538
- "print(f\"{sep}\")\n",
539
- "\n",
540
- "improvement = np.mean(trained_scores) - np.mean(random_scores)\n",
541
- "print(f\"\\n Improvement over random: +{improvement:.4f} ({improvement/max(np.mean(random_scores), 0.01)*100:.1f}%)\")\n",
542
- "if np.mean(trained_scores) > np.mean(random_scores):\n",
543
- " print(\" >>> Training shows positive improvement! <<<\")"
544
- ],
545
  "outputs": [],
546
  "execution_count": null
547
  },
 
301
  "execution_count": null,
302
  "metadata": {},
303
  "outputs": [],
304
+ "source": "from datasets import Dataset\n\nSYSTEM_PROMPT = \"\"\"You are the Chief Human Capital Officer (CHCO) of a simulated company.\nYou manage 300 employees across 5 departments over 6 quarters.\nEach quarter has 4 phases: Scanning, Planning, Producing, Controlling.\nMaximize HCVA, HCROI, and QIPS metrics by making strategic HR decisions.\n\nOutput a sequence of JSON action objects, one per line. Example:\n{\"action_type\": \"query_department\", \"department\": \"Engineering\"}\n{\"action_type\": \"query_department\", \"department\": \"Sales\"}\n{\"action_type\": \"advance_phase\"}\n{\"action_type\": \"set_hiring_target\", \"department\": \"Engineering\", \"count\": 5}\n{\"action_type\": \"advance_phase\"}\n\nAvailable action types by phase:\n- Scanning: query_department, query_employees, calculate_metric, review_financials\n- Planning: set_hiring_target, set_training_budget, set_compensation_policy, set_retention_program\n- Producing: execute_hiring, execute_promotion, execute_transfer, execute_training, execute_termination\n- Controlling: submit_report, calculate_metric\n- advance_phase / advance_quarter: transition between phases/quarters\n\nOutput 20-40 actions covering all 6 quarters. Include advance_phase and advance_quarter actions to progress through the simulation.\"\"\"\n\n# Create prompts with different seeds for diversity\nprompts = []\nfor seed in range(20):\n prompts.append({\n \"prompt\": [\n {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n {\"role\": \"user\", \"content\": f\"Begin managing the company. Episode seed: {seed}. Start by scanning your organization to understand its current state.\"},\n ]\n })\n\ndataset = Dataset.from_list(prompts)\nprint(f\"Training dataset: {len(dataset)} episodes\")"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  },
306
  {
307
  "cell_type": "markdown",
 
369
  "execution_count": null,
370
  "metadata": {},
371
  "outputs": [],
372
+ "source": "import re\n\ndef reward_func(completions, **kwargs):\n \"\"\"Parse actions from model completions, execute in environment, return rewards.\"\"\"\n rewards = []\n for completion in completions:\n # Extract text content from completion\n if isinstance(completion, list):\n text = completion[-1].get(\"content\", \"\") if completion else \"\"\n else:\n text = str(completion)\n\n # Parse all JSON objects that have action_type\n actions = []\n for match in re.finditer(r'\\{[^{}]*\"action_type\"[^{}]*\\}', text):\n try:\n action = json.loads(match.group())\n actions.append(action)\n except json.JSONDecodeError:\n continue\n\n if not actions:\n rewards.append(0.0)\n continue\n\n # Run episode with parsed actions\n try:\n env = HCM21Env()\n env.reset(seed=42)\n for action in actions:\n env.step(json.dumps(action))\n if env._done:\n break\n reward = env.get_reward()\n env.close()\n rewards.append(float(reward))\n except Exception as e:\n rewards.append(0.0)\n\n return rewards\n\nprint(\"Reward function defined.\")"
 
 
 
 
 
 
 
 
 
 
373
  },
374
  {
375
  "cell_type": "markdown",
376
  "metadata": {},
377
+ "source": "## 6. Configure and Run GRPO Training\n\nWe use TRL's `GRPOTrainer` with a custom `reward_func` that parses JSON\naction sequences from model completions and executes them in the HCM:21\nenvironment. GRPO generates G completions per prompt, scores them via\nthe reward function, and reinforces higher-scoring completions.\n\nOn a free T4 GPU, we use:\n- Small batch size (2) with gradient accumulation\n- 4-bit quantization via Unsloth\n- Short max_completion_length to fit in memory\n\nAfter training, we plot the reward trajectory to visualize learning progress."
 
 
 
 
 
 
 
 
 
 
 
 
 
378
  },
379
  {
380
  "cell_type": "code",
381
  "metadata": {},
382
+ "source": "from trl import GRPOTrainer, GRPOConfig\nimport os\n\n# Disable Qwen3 thinking mode at the tokenizer level (Unsloth-compatible)\ntokenizer.chat_template = tokenizer.chat_template.replace(\"enable_thinking\", \"\")\n\nos.environ[\"TENSORBOARD_LOGGING_DIR\"] = \"./hcm21-grpo-logs\"\n\ntraining_args = GRPOConfig(\n output_dir=\"./hcm21-grpo-output\",\n num_train_epochs=1,\n per_device_train_batch_size=2,\n gradient_accumulation_steps=4,\n num_generations=4,\n max_completion_length=2048,\n learning_rate=5e-6,\n logging_steps=1,\n save_steps=10,\n fp16=True,\n)\n\ntrainer = GRPOTrainer(\n model=model,\n processing_class=tokenizer,\n train_dataset=dataset,\n reward_funcs=reward_func,\n args=training_args,\n)\n\nprint(\"Trainer initialized. Starting training...\")\ntrainer.train()\nprint(\"Training complete!\")\n\n# Extract and plot reward curve from training logs\nimport matplotlib.pyplot as plt\n\nlog_history = trainer.state.log_history\nrewards = [entry[\"reward\"] for entry in log_history if \"reward\" in entry]\nlosses = [entry[\"loss\"] for entry in log_history if \"loss\" in entry]\n\nfig, axes = plt.subplots(1, 2, figsize=(14, 5))\n\n# Reward curve\nif rewards:\n axes[0].plot(rewards, alpha=0.4, label=\"Per-step reward\", color=\"steelblue\")\n if len(rewards) >= 3:\n window = min(5, len(rewards))\n rolling = np.convolve(rewards, np.ones(window)/window, mode=\"valid\")\n axes[0].plot(range(window-1, len(rewards)), rolling, linewidth=2,\n label=f\"{window}-step rolling avg\", color=\"darkblue\")\n axes[0].axhline(y=np.mean(random_scores), color=\"red\", linestyle=\"--\",\n label=f\"Random baseline ({np.mean(random_scores):.3f})\")\n axes[0].axhline(y=np.mean(heuristic_scores), color=\"green\", linestyle=\"--\",\n label=f\"Heuristic baseline ({np.mean(heuristic_scores):.3f})\")\n axes[0].set_xlabel(\"Training Step\")\n axes[0].set_ylabel(\"Episode Reward\")\n axes[0].set_title(\"GRPO Training Reward Curve\")\n axes[0].legend()\n axes[0].grid(True, alpha=0.3)\n\n# Loss curve\nif losses:\n axes[1].plot(losses, color=\"orange\", alpha=0.7)\n axes[1].set_xlabel(\"Training Step\")\n axes[1].set_ylabel(\"Loss\")\n axes[1].set_title(\"Training Loss\")\n axes[1].grid(True, alpha=0.3)\n\nplt.tight_layout()\nplt.savefig(\"./hcm21-grpo-output/training_curves.png\", dpi=150, bbox_inches=\"tight\")\nplt.show()\nprint(f\"\\nTraining stats: {len(rewards)} reward entries, mean={np.mean(rewards):.4f}\" if rewards else \"No reward data logged.\")",
383
  "outputs": [],
384
  "execution_count": null
385
  },
 
421
  {
422
  "cell_type": "code",
423
  "metadata": {},
424
+ "source": "import json\nimport re\n\nFastLanguageModel.for_inference(model)\n\ndef run_trained_episode(seed):\n \"\"\"Run one episode with the trained model, return final score.\"\"\"\n env = HCM21Env()\n try:\n env.reset(seed=seed)\n messages = [\n {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n {\"role\": \"user\", \"content\": f\"Begin managing the company. Episode seed: {seed}. Start by scanning your organization to understand its current state.\"},\n ]\n\n # Generate a single completion with all actions (matches training format)\n input_ids = tokenizer.apply_chat_template(\n messages, return_tensors=\"pt\", add_generation_prompt=True\n )\n # Handle both tensor and BatchEncoding returns\n if hasattr(input_ids, \"input_ids\"):\n input_ids = input_ids.input_ids\n input_ids = input_ids.to(model.device)\n\n outputs = model.generate(\n input_ids=input_ids, max_new_tokens=2048,\n temperature=0.7, do_sample=True,\n )\n response = tokenizer.decode(outputs[0][input_ids.shape[1]:], skip_special_tokens=True)\n\n # Parse JSON actions using the same regex as reward_func\n actions = []\n for match in re.finditer(r'\\{[^{}]*\"action_type\"[^{}]*\\}', response):\n try:\n action = json.loads(match.group())\n actions.append(action)\n except json.JSONDecodeError:\n continue\n\n # Execute all parsed actions\n for action in actions:\n env.step(json.dumps(action))\n if env._done:\n break\n\n return env.get_reward(), len(actions)\n finally:\n env.close()\n\nprint(\"Evaluating trained agent on baseline seeds...\")\ntrained_scores = []\nfor seed in BASELINE_SEEDS:\n score, num_actions = run_trained_episode(seed)\n trained_scores.append(score)\n print(f\" Seed {seed:>3d}: {score:.4f} ({num_actions} actions parsed)\")\n\nsep = \"=\" * 50\nprint(f\"\\n{sep}\")\nprint(f\" RESULTS COMPARISON\")\nprint(f\"{sep}\")\nprint(f\" Random Agent: {np.mean(random_scores):.4f} +/- {np.std(random_scores):.4f}\")\nprint(f\" Heuristic Agent: {np.mean(heuristic_scores):.4f} +/- {np.std(heuristic_scores):.4f}\")\nprint(f\" Trained Agent: {np.mean(trained_scores):.4f} +/- {np.std(trained_scores):.4f}\")\nprint(f\"{sep}\")\n\nimprovement = np.mean(trained_scores) - np.mean(random_scores)\nprint(f\"\\n Improvement over random: +{improvement:.4f} ({improvement/max(np.mean(random_scores), 0.01)*100:.1f}%)\")\nif np.mean(trained_scores) > np.mean(random_scores):\n print(\" >>> Training shows positive improvement! <<<\")",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  "outputs": [],
426
  "execution_count": null
427
  },