SavK1 Claude Sonnet 4.6 commited on
Commit
5e6d53a
Β·
1 Parent(s): 74d0de6

fix(training): reward signal flow + new v2 notebook

Browse files

- rewards.py: add defensive _extract warning when kwargs are empty;
add combined_reward() as single-function fallback; add COMBINED_REWARD_FUNC list
- rollout.py: print per-episode reward breakdown; compute combined reward inline
- train_v2.ipynb: new notebook addressing all v1 issues:
* pins TRL 1.2.0 stable (no git install)
* removes PatchFastRL (incompatible with TRL 1.2.0)
* single 'reward' key from rollout (bypasses kwargs-flow bug)
* auto-detects T4 vs A100, adapts grad_accum/num_gen/max_comp
* smoke test cell before training

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

Files changed (4) hide show
  1. .gitignore +1 -0
  2. training/rewards.py +62 -2
  3. training/rollout.py +15 -1
  4. training/train_v2.ipynb +675 -0
.gitignore CHANGED
@@ -12,3 +12,4 @@ pdf_pages/
12
  dist/
13
  build/
14
  .pytest_cache/
 
 
12
  dist/
13
  build/
14
  .pytest_cache/
15
+ .vscode/
training/rewards.py CHANGED
@@ -16,8 +16,21 @@ Reward hacking protection (point 8 of hackathon guide):
16
 
17
  To change weights, edit the WEIGHT_* constants below.
18
  Equal-weight Option B: set all five to 0.20.
 
 
 
 
 
 
 
 
 
 
 
19
  """
20
 
 
 
21
  WEIGHT_FINAL_SCORE = 0.45
22
  WEIGHT_NO_WRONG_CHANNELS = 0.15
23
  WEIGHT_VALID_JSON = 0.15
@@ -29,10 +42,32 @@ assert abs(
29
  + WEIGHT_READ_RUNBOOK + WEIGHT_EFFICIENCY - 1.0
30
  ) < 1e-9, "Reward weights must sum to 1.0"
31
 
 
 
 
 
 
 
32
 
33
  def _extract(kwargs: dict, key: str, n: int) -> list[float]:
 
34
  rewards = kwargs.get(key, [])
35
- return [float(r) for r in rewards] if rewards else [0.0] * n
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
 
38
  def reward_final_score(completions, **kwargs) -> list[float]:
@@ -79,7 +114,29 @@ def reward_efficiency(completions, **kwargs) -> list[float]:
79
  return [r * WEIGHT_EFFICIENCY for r in raw]
80
 
81
 
82
- # Ordered list for GRPOTrainer(reward_funcs=...)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  ALL_REWARD_FUNCS = [
84
  reward_final_score,
85
  reward_no_wrong_channels,
@@ -87,3 +144,6 @@ ALL_REWARD_FUNCS = [
87
  reward_read_runbook,
88
  reward_efficiency,
89
  ]
 
 
 
 
16
 
17
  To change weights, edit the WEIGHT_* constants below.
18
  Equal-weight Option B: set all five to 0.20.
19
+
20
+ Architecture note β€” two reward modes:
21
+ 1. Split mode (ALL_REWARD_FUNCS): five separate functions, each reading one
22
+ kwargs key from the rollout dict. TRL sums them. Requires TRL to pass
23
+ rollout extra keys as kwargs to reward_funcs (works in TRL 0.17–0.24 +
24
+ Unsloth patch).
25
+ 2. Combined mode (COMBINED_REWARD_FUNC): single function that reads all five
26
+ kwargs keys and returns the weighted sum. Use this if split mode shows
27
+ all-zero rewards (indicates TRL is not passing kwargs, e.g. version
28
+ mismatch). Switch by replacing reward_funcs=ALL_REWARD_FUNCS with
29
+ reward_funcs=COMBINED_REWARD_FUNC in GRPOTrainer.
30
  """
31
 
32
+ import warnings
33
+
34
  WEIGHT_FINAL_SCORE = 0.45
35
  WEIGHT_NO_WRONG_CHANNELS = 0.15
36
  WEIGHT_VALID_JSON = 0.15
 
42
  + WEIGHT_READ_RUNBOOK + WEIGHT_EFFICIENCY - 1.0
43
  ) < 1e-9, "Reward weights must sum to 1.0"
44
 
45
+ _EXPECTED_KEYS = frozenset([
46
+ "final_score_reward", "no_wrong_channels_reward",
47
+ "valid_json_reward", "read_runbook_reward", "efficiency_reward",
48
+ ])
49
+ _warned_empty = False
50
+
51
 
52
  def _extract(kwargs: dict, key: str, n: int) -> list[float]:
53
+ global _warned_empty
54
  rewards = kwargs.get(key, [])
55
+ if not rewards:
56
+ if not _warned_empty:
57
+ present = set(kwargs.keys()) & _EXPECTED_KEYS
58
+ missing = _EXPECTED_KEYS - set(kwargs.keys())
59
+ warnings.warn(
60
+ f"[rewards] kwargs missing rollout reward keys β€” all rewards will be 0!\n"
61
+ f" present: {present or 'none'}\n"
62
+ f" missing: {missing}\n"
63
+ f" all kwargs keys: {list(kwargs.keys())}\n"
64
+ f" CAUSE: TRL version mismatch or rollout_func not returning these keys.\n"
65
+ f" FIX: switch to reward_funcs=COMBINED_REWARD_FUNC (see rewards.py).",
66
+ stacklevel=3,
67
+ )
68
+ _warned_empty = True
69
+ return [0.0] * n
70
+ return [float(r) for r in rewards]
71
 
72
 
73
  def reward_final_score(completions, **kwargs) -> list[float]:
 
114
  return [r * WEIGHT_EFFICIENCY for r in raw]
115
 
116
 
117
+ def combined_reward(completions, **kwargs) -> list[float]:
118
+ """Single combined reward β€” fallback when TRL does not pass split kwargs.
119
+
120
+ Reads all five rollout reward keys and returns their weighted sum.
121
+ Use with: reward_funcs=[combined_reward]
122
+ """
123
+ n = len(completions)
124
+ final = _extract(kwargs, "final_score_reward", n)
125
+ no_wrong = _extract(kwargs, "no_wrong_channels_reward", n)
126
+ vj = _extract(kwargs, "valid_json_reward", n)
127
+ rb = _extract(kwargs, "read_runbook_reward", n)
128
+ eff = _extract(kwargs, "efficiency_reward", n)
129
+ return [
130
+ f * WEIGHT_FINAL_SCORE
131
+ + nw * WEIGHT_NO_WRONG_CHANNELS
132
+ + v * WEIGHT_VALID_JSON
133
+ + r * WEIGHT_READ_RUNBOOK
134
+ + e * WEIGHT_EFFICIENCY
135
+ for f, nw, v, r, e in zip(final, no_wrong, vj, rb, eff)
136
+ ]
137
+
138
+
139
+ # Split mode: TRL sums five separate signals (preferred β€” gives per-signal trackio curves)
140
  ALL_REWARD_FUNCS = [
141
  reward_final_score,
142
  reward_no_wrong_channels,
 
144
  reward_read_runbook,
145
  reward_efficiency,
146
  ]
147
+
148
+ # Combined mode: single function β€” use when kwargs aren't flowing in split mode
149
+ COMBINED_REWARD_FUNC = [combined_reward]
training/rollout.py CHANGED
@@ -282,6 +282,20 @@ def rollout_once(
282
  # No posts at all β€” no spray happened (reward_final_score handles missing notif)
283
  no_wrong_channels = 1.0
284
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
285
  return {
286
  "prompt_ids": prompt_ids,
287
  "completion_ids": completion_ids,
@@ -289,7 +303,7 @@ def rollout_once(
289
  "final_score_reward": final_score,
290
  "no_wrong_channels_reward": no_wrong_channels,
291
  "valid_json_reward": valid_json_ratio,
292
- "read_runbook_reward": 1.0 if read_runbook_done else 0.0,
293
  "efficiency_reward": efficiency,
294
  }
295
 
 
282
  # No posts at all β€” no spray happened (reward_final_score handles missing notif)
283
  no_wrong_channels = 1.0
284
 
285
+ read_runbook_reward = 1.0 if read_runbook_done else 0.0
286
+ combined = (
287
+ final_score * 0.45
288
+ + no_wrong_channels * 0.15
289
+ + valid_json_ratio * 0.15
290
+ + read_runbook_reward * 0.15
291
+ + efficiency * 0.10
292
+ )
293
+ print(
294
+ f"[rollout] steps={step} final={final_score:.3f} "
295
+ f"json={valid_json_ratio:.2f} runbook={read_runbook_reward:.0f} "
296
+ f"no_wrong={no_wrong_channels:.2f} eff={efficiency:.2f} "
297
+ f"β†’ combined={combined:.3f}"
298
+ )
299
  return {
300
  "prompt_ids": prompt_ids,
301
  "completion_ids": completion_ids,
 
303
  "final_score_reward": final_score,
304
  "no_wrong_channels_reward": no_wrong_channels,
305
  "valid_json_reward": valid_json_ratio,
306
+ "read_runbook_reward": read_runbook_reward,
307
  "efficiency_reward": efficiency,
308
  }
309
 
training/train_v2.ipynb ADDED
@@ -0,0 +1,675 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# PM-Ops GRPO Training v2\n",
8
+ "\n",
9
+ "### Fixes vs v1\n",
10
+ "| Bug | Root cause | Fix |\n",
11
+ "|-----|-----------|-----|\n",
12
+ "| All rewards = 0 | TRL 1.3.0.dev0 (git) broke the `rollout_func` kwargs mechanism | Pin TRL to **1.2.0** stable; single `reward` key |\n",
13
+ "| Only 4 training steps | T4 (14 GB) + `gradient_accumulation=64` = ~4 steps | Auto-detect GPU, halve accum on T4 |\n",
14
+ "| Silent failure | No logging when rewards are 0 | Rollout prints every episode; reward func warns on zeros |\n",
15
+ "| `PatchFastRL` on wrong TRL | Patched TRL 0.17, then TRL was upgraded | Don't call `PatchFastRL` at all |\n",
16
+ "\n",
17
+ "**GPU recommendation:** A100 40 GB (Colab Pro) β†’ 150 steps, ~60 min \n",
18
+ "T4 works but only yields ~18 steps (still useful for smoke-testing the reward pipeline)"
19
+ ]
20
+ },
21
+ {
22
+ "cell_type": "markdown",
23
+ "metadata": {},
24
+ "source": ["## 0. Install Dependencies"]
25
+ },
26
+ {
27
+ "cell_type": "code",
28
+ "execution_count": null,
29
+ "metadata": {},
30
+ "outputs": [],
31
+ "source": [
32
+ "# Step 1: Unsloth first (installs a TRL it likes, ~0.18-0.24)\n",
33
+ "!pip install -q unsloth vllm\n",
34
+ "\n",
35
+ "# Step 2: Force TRL to 1.2.0 stable (pip will warn about Unsloth conflict β€” safe to ignore;\n",
36
+ "# Unsloth 2026.4.x patches work with TRL 1.0+ despite the metadata claim)\n",
37
+ "!pip install -q \"trl==1.2.0\" --force-reinstall\n",
38
+ "\n",
39
+ "# Step 3: Supporting packages\n",
40
+ "!pip install -q peft accelerate \"datasets>=3.4.1,<4.4.0\"\n",
41
+ "!pip install -q \"openenv-core>=0.2.2\" fastapi \"uvicorn[standard]\" \"pydantic>=2.0.0\"\n",
42
+ "!pip install -q trackio\n",
43
+ "\n",
44
+ "print('Done β€” restart kernel, then run all cells from top.')"
45
+ ]
46
+ },
47
+ {
48
+ "cell_type": "markdown",
49
+ "metadata": {},
50
+ "source": ["## 1. Version Check + GPU Detect"]
51
+ },
52
+ {
53
+ "cell_type": "code",
54
+ "execution_count": null,
55
+ "metadata": {},
56
+ "outputs": [],
57
+ "source": [
58
+ "import torch, trl, trl.experimental.openenv\n",
59
+ "\n",
60
+ "print(f'TRL: {trl.__version__} (need 1.2.0)')\n",
61
+ "assert trl.__version__.startswith('1.2'), f'Wrong TRL version: {trl.__version__}. Rerun install cell and restart kernel.'\n",
62
+ "print('openenv: OK')\n",
63
+ "\n",
64
+ "gpu = torch.cuda.get_device_properties(0)\n",
65
+ "TOTAL_GB = round(gpu.total_memory / 1024**3, 1)\n",
66
+ "IS_A100 = TOTAL_GB >= 35\n",
67
+ "print(f'GPU: {gpu.name} ({TOTAL_GB} GB)')\n",
68
+ "print(f'Mode: {\"A100 (full config)\" if IS_A100 else \"T4 (reduced config β€” smoke-test only)\"}')\n",
69
+ "\n",
70
+ "# Auto-adapted config values\n",
71
+ "GRAD_ACCUM = 32 if IS_A100 else 8\n",
72
+ "NUM_GEN = 4 if IS_A100 else 2\n",
73
+ "MAX_COMP_LEN = 512 if IS_A100 else 256\n",
74
+ "GPU_MEM_UTIL = 0.6 if IS_A100 else 0.5\n",
75
+ "USE_VLLM = IS_A100 # vLLM colocate only safe on A100\n",
76
+ "\n",
77
+ "print(f'\\nConfig: grad_accum={GRAD_ACCUM}, num_gen={NUM_GEN}, max_comp={MAX_COMP_LEN}, vllm={USE_VLLM}')"
78
+ ]
79
+ },
80
+ {
81
+ "cell_type": "markdown",
82
+ "metadata": {},
83
+ "source": ["## 2. Clone PM-Ops Repo"]
84
+ },
85
+ {
86
+ "cell_type": "code",
87
+ "execution_count": null,
88
+ "metadata": {},
89
+ "outputs": [],
90
+ "source": [
91
+ "import os, sys\n",
92
+ "\n",
93
+ "REPO_URL = 'https://huggingface.co/spaces/TheCrustaceans/Pm-ops'\n",
94
+ "REPO_DIR = '/content/Pm_ops'\n",
95
+ "\n",
96
+ "if not os.path.exists(REPO_DIR):\n",
97
+ " !git clone --depth=1 -q {REPO_URL} {REPO_DIR}\n",
98
+ " print(f'Cloned β†’ {REPO_DIR}')\n",
99
+ "else:\n",
100
+ " # Pull latest β€” picks up the fixed rewards.py and rollout.py\n",
101
+ " !git -C {REPO_DIR} pull -q\n",
102
+ " print(f'Updated: {REPO_DIR}')\n",
103
+ "\n",
104
+ "for p in [REPO_DIR, os.path.join(REPO_DIR, 'training')]:\n",
105
+ " if p not in sys.path:\n",
106
+ " sys.path.insert(0, p)\n",
107
+ "\n",
108
+ "os.chdir(REPO_DIR)\n",
109
+ "print(f'CWD: {os.getcwd()}')"
110
+ ]
111
+ },
112
+ {
113
+ "cell_type": "markdown",
114
+ "metadata": {},
115
+ "source": ["## 3. HuggingFace Login"]
116
+ },
117
+ {
118
+ "cell_type": "code",
119
+ "execution_count": null,
120
+ "metadata": {},
121
+ "outputs": [],
122
+ "source": [
123
+ "from huggingface_hub import notebook_login\n",
124
+ "notebook_login()"
125
+ ]
126
+ },
127
+ {
128
+ "cell_type": "markdown",
129
+ "metadata": {},
130
+ "source": [
131
+ "## 4. Start Local PM-Ops Server\n",
132
+ "Localhost removes ~200 ms/step network round-trip."
133
+ ]
134
+ },
135
+ {
136
+ "cell_type": "code",
137
+ "execution_count": null,
138
+ "metadata": {},
139
+ "outputs": [],
140
+ "source": [
141
+ "import subprocess, time, requests\n",
142
+ "\n",
143
+ "server_proc = subprocess.Popen(\n",
144
+ " [sys.executable, '-m', 'uvicorn', 'server.app:app', '--host', '0.0.0.0', '--port', '8000'],\n",
145
+ " cwd=REPO_DIR, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,\n",
146
+ ")\n",
147
+ "ENV_URL = 'http://localhost:8000'\n",
148
+ "\n",
149
+ "for i in range(30):\n",
150
+ " try:\n",
151
+ " if requests.get(f'{ENV_URL}/', timeout=2).status_code == 200:\n",
152
+ " print(f'PM-Ops server ready (pid={server_proc.pid})')\n",
153
+ " break\n",
154
+ " except Exception:\n",
155
+ " pass\n",
156
+ " time.sleep(1)\n",
157
+ "else:\n",
158
+ " raise RuntimeError('Server did not start in 30 s')"
159
+ ]
160
+ },
161
+ {
162
+ "cell_type": "markdown",
163
+ "metadata": {},
164
+ "source": ["## 5. Verify Environment"]
165
+ },
166
+ {
167
+ "cell_type": "code",
168
+ "execution_count": null,
169
+ "metadata": {},
170
+ "outputs": [],
171
+ "source": [
172
+ "from openenv.core import GenericEnvClient\n",
173
+ "\n",
174
+ "with GenericEnvClient(base_url=ENV_URL).sync() as _env:\n",
175
+ " res = _env.reset()\n",
176
+ " obs = res.observation if hasattr(res, 'observation') else res\n",
177
+ " brief = getattr(obs, 'task_brief', '') or (obs.get('task_brief', '') if isinstance(obs, dict) else '')\n",
178
+ " print(f'task_brief: {brief[:100]}...')\n",
179
+ " _env.step({'action_type': 'meta.read_runbook', 'args': {}})\n",
180
+ " print('Env step OK')"
181
+ ]
182
+ },
183
+ {
184
+ "cell_type": "markdown",
185
+ "metadata": {},
186
+ "source": [
187
+ "## 6. Load Model with Unsloth\n",
188
+ "\n",
189
+ "**No `PatchFastRL`** β€” that call patches TRL internals and is incompatible with TRL 1.2.0. \n",
190
+ "Unsloth is still used for 4-bit loading + LoRA, which work independently of the TRL version."
191
+ ]
192
+ },
193
+ {
194
+ "cell_type": "code",
195
+ "execution_count": null,
196
+ "metadata": {},
197
+ "outputs": [],
198
+ "source": [
199
+ "from unsloth import FastLanguageModel\n",
200
+ "\n",
201
+ "MODEL_NAME = 'unsloth/Qwen3-1.7B-unsloth-bnb-4bit' # pre-quantised, faster download\n",
202
+ "MAX_SEQ_LEN = 4096 + MAX_COMP_LEN\n",
203
+ "LORA_RANK = 16\n",
204
+ "\n",
205
+ "model, tokenizer = FastLanguageModel.from_pretrained(\n",
206
+ " model_name=MODEL_NAME,\n",
207
+ " max_seq_length=MAX_SEQ_LEN,\n",
208
+ " load_in_4bit=True,\n",
209
+ " fast_inference=False, # keep False β€” vLLM colocate only on A100 via GRPOConfig\n",
210
+ " max_lora_rank=LORA_RANK,\n",
211
+ " gpu_memory_utilization=GPU_MEM_UTIL,\n",
212
+ ")\n",
213
+ "\n",
214
+ "model = FastLanguageModel.get_peft_model(\n",
215
+ " model,\n",
216
+ " r=LORA_RANK,\n",
217
+ " target_modules=['q_proj', 'k_proj', 'v_proj', 'o_proj', 'gate_proj', 'up_proj', 'down_proj'],\n",
218
+ " lora_alpha=LORA_RANK,\n",
219
+ " use_gradient_checkpointing='unsloth',\n",
220
+ " random_state=42,\n",
221
+ ")\n",
222
+ "tokenizer.pad_token = tokenizer.eos_token\n",
223
+ "print(f'Model ready: {MODEL_NAME}')"
224
+ ]
225
+ },
226
+ {
227
+ "cell_type": "markdown",
228
+ "metadata": {},
229
+ "source": ["## 7. Generate Training Dataset"]
230
+ },
231
+ {
232
+ "cell_type": "code",
233
+ "execution_count": null,
234
+ "metadata": {},
235
+ "outputs": [],
236
+ "source": [
237
+ "from datasets import Dataset\n",
238
+ "from training.dataset import generate_triage_dataset\n",
239
+ "\n",
240
+ "N_EPISODES = 150\n",
241
+ "rows = generate_triage_dataset(n_episodes=N_EPISODES, base_seed=42)\n",
242
+ "# 'prompt' column = seed-embedded string; GRPOTrainer passes it as-is to rollout_func\n",
243
+ "dataset = Dataset.from_list([{'prompt': r['prompt']} for r in rows])\n",
244
+ "print(f'Dataset: {len(dataset)} episodes')\n",
245
+ "print(f'Sample: {dataset[0][\"prompt\"][:100]}...')"
246
+ ]
247
+ },
248
+ {
249
+ "cell_type": "markdown",
250
+ "metadata": {},
251
+ "source": [
252
+ "## 8. Rollout Function + Reward\n",
253
+ "\n",
254
+ "**Key change from v1:** Instead of returning 5 separate reward keys (which broke in TRL 1.3.0.dev0),\n",
255
+ "we return a single `\"reward\"` key with the pre-weighted combined scalar. \n",
256
+ "The reward function is a trivial passthrough β€” no kwargs plumbing can fail."
257
+ ]
258
+ },
259
+ {
260
+ "cell_type": "code",
261
+ "execution_count": null,
262
+ "metadata": {},
263
+ "outputs": [],
264
+ "source": [
265
+ "from openenv.core import GenericEnvClient\n",
266
+ "from training.rollout import rollout_once\n",
267
+ "\n",
268
+ "# ── Persistent env connection for training ────────────────────────────────────\n",
269
+ "sync_env = GenericEnvClient(base_url=ENV_URL).sync()\n",
270
+ "sync_env.connect()\n",
271
+ "print('Training env connected')\n",
272
+ "\n",
273
+ "TRAIN_MAX_STEPS = 15\n",
274
+ "\n",
275
+ "# ── Combined rollout (single reward key) ─────────────────────────────────────\n",
276
+ "def make_rollout_func(env, tok, max_steps=TRAIN_MAX_STEPS):\n",
277
+ " \"\"\"\n",
278
+ " Returns a rollout_func compatible with TRL 1.2.0 GRPOTrainer.\n",
279
+ "\n",
280
+ " Returns a single 'reward' key (weighted sum of all signals) so the reward\n",
281
+ " function is a trivial passthrough β€” avoids the kwargs-flow bug from v1.\n",
282
+ " \"\"\"\n",
283
+ " def rollout_func(prompts, trainer=None):\n",
284
+ " out = {'prompt_ids': [], 'completion_ids': [], 'logprobs': [], 'reward': []}\n",
285
+ " for prompt in prompts:\n",
286
+ " ep = rollout_once(\n",
287
+ " trainer=trainer, sync_env=env, tokenizer=tok,\n",
288
+ " dataset_prompt=prompt, max_steps=max_steps,\n",
289
+ " )\n",
290
+ " # Weighted combination (same weights as rewards.py)\n",
291
+ " combined = (\n",
292
+ " ep['final_score_reward'] * 0.45 +\n",
293
+ " ep['no_wrong_channels_reward'] * 0.15 +\n",
294
+ " ep['valid_json_reward'] * 0.15 +\n",
295
+ " ep['read_runbook_reward'] * 0.15 +\n",
296
+ " ep['efficiency_reward'] * 0.10\n",
297
+ " )\n",
298
+ " out['prompt_ids'].append(ep['prompt_ids'])\n",
299
+ " out['completion_ids'].append(ep['completion_ids'])\n",
300
+ " out['logprobs'].append(ep['logprobs'])\n",
301
+ " out['reward'].append(combined)\n",
302
+ " return out\n",
303
+ " return rollout_func\n",
304
+ "\n",
305
+ "rollout_func = make_rollout_func(sync_env, tokenizer)\n",
306
+ "\n",
307
+ "# ── Reward function: trivial passthrough ─────────────────────────────────────\n",
308
+ "def reward_func(completions, **kwargs):\n",
309
+ " \"\"\"\n",
310
+ " Reads pre-computed combined reward from rollout kwargs.\n",
311
+ " Warns loudly if kwargs are empty (kwargs-flow is broken).\n",
312
+ " \"\"\"\n",
313
+ " n = len(completions)\n",
314
+ " rewards = kwargs.get('reward', [])\n",
315
+ " if not rewards:\n",
316
+ " print(\n",
317
+ " f'[ERROR] reward_func received empty kwargs! '\n",
318
+ " f'kwargs keys: {list(kwargs.keys())}. '\n",
319
+ " f'All rewards will be 0 β€” training is broken. '\n",
320
+ " f'TRL version: {trl.__version__}'\n",
321
+ " )\n",
322
+ " return [0.0] * n\n",
323
+ " return [float(r) for r in rewards]\n",
324
+ "\n",
325
+ "print('rollout_func and reward_func defined')"
326
+ ]
327
+ },
328
+ {
329
+ "cell_type": "markdown",
330
+ "metadata": {},
331
+ "source": [
332
+ "## 9. Smoke Test β€” Verify Reward Signal Before Training\n",
333
+ "\n",
334
+ "Run one episode and confirm:\n",
335
+ "1. Rollout completes without error\n",
336
+ "2. `reward` key exists in rollout output\n",
337
+ "3. `read_runbook_reward` = 1.0 (step_aware_fallback always calls read_runbook at step 0)\n",
338
+ "4. `reward_func` receives non-empty kwargs"
339
+ ]
340
+ },
341
+ {
342
+ "cell_type": "code",
343
+ "execution_count": null,
344
+ "metadata": {},
345
+ "outputs": [],
346
+ "source": [
347
+ "print('── Smoke test ──────────────────────────────')\n",
348
+ "\n",
349
+ "test_prompt = dataset[0]['prompt']\n",
350
+ "print(f'Prompt: {test_prompt[:80]}...')\n",
351
+ "\n",
352
+ "test_out = rollout_func([test_prompt], trainer=None)\n",
353
+ "\n",
354
+ "assert 'reward' in test_out, 'FAIL: rollout missing reward key'\n",
355
+ "assert len(test_out['reward']) == 1, 'FAIL: reward list wrong length'\n",
356
+ "assert len(test_out['prompt_ids']) == 1, 'FAIL: prompt_ids wrong length'\n",
357
+ "\n",
358
+ "r = test_out['reward'][0]\n",
359
+ "n_comp_tokens = len(test_out['completion_ids'][0])\n",
360
+ "print(f'combined reward : {r:.4f}')\n",
361
+ "print(f'completion tokens: {n_comp_tokens}')\n",
362
+ "\n",
363
+ "# Verify reward_func receives the kwarg\n",
364
+ "fake_completions = ['fake completion']\n",
365
+ "rf_out = reward_func(fake_completions, reward=[r])\n",
366
+ "assert rf_out == [r], 'FAIL: reward_func passthrough broken'\n",
367
+ "\n",
368
+ "if r == 0.0:\n",
369
+ " print('\\n[WARN] combined reward = 0.0 on first episode.')\n",
370
+ " print(' This is OK if it is the very first step (model learning).')\n",
371
+ " print(' Check rollout log above for individual signal breakdown.')\n",
372
+ "else:\n",
373
+ " print(f'\\nSmoke test PASSED β€” reward = {r:.4f}')"
374
+ ]
375
+ },
376
+ {
377
+ "cell_type": "markdown",
378
+ "metadata": {},
379
+ "source": ["## 10. Configure GRPO Training"]
380
+ },
381
+ {
382
+ "cell_type": "code",
383
+ "execution_count": null,
384
+ "metadata": {},
385
+ "outputs": [],
386
+ "source": [
387
+ "from trl import GRPOConfig\n",
388
+ "\n",
389
+ "OUTPUT_DIR = 'pm-ops-grpo-Qwen3-1.7B-triage-v2'\n",
390
+ "HF_REPO_ID = f'Saurav1/{OUTPUT_DIR}'\n",
391
+ "\n",
392
+ "grpo_config = GRPOConfig(\n",
393
+ " # ── Training ──────────────────────────────────────────────────────────────\n",
394
+ " num_train_epochs=1,\n",
395
+ " learning_rate=5e-6,\n",
396
+ " gradient_accumulation_steps=GRAD_ACCUM,\n",
397
+ " per_device_train_batch_size=1,\n",
398
+ " warmup_steps=5,\n",
399
+ " num_generations=NUM_GEN,\n",
400
+ "\n",
401
+ " # ── Sequence lengths ──────────────────────────────────────────────────────\n",
402
+ " max_completion_length=MAX_COMP_LEN,\n",
403
+ " max_prompt_length=4096,\n",
404
+ "\n",
405
+ " # ── Generation ────────────────────────────────────────────────────────────\n",
406
+ " # use_vllm=True only on A100 (requires fast_inference=True in FastLanguageModel)\n",
407
+ " # On T4: use_vllm=False β†’ standard HF generate, stop tokens respected via eos_token\n",
408
+ " use_vllm=USE_VLLM,\n",
409
+ "\n",
410
+ " # ── Output + logging ──────────────────────────────────────────────────────\n",
411
+ " output_dir=OUTPUT_DIR,\n",
412
+ " report_to='trackio',\n",
413
+ " trackio_space_id=OUTPUT_DIR,\n",
414
+ " logging_steps=1,\n",
415
+ " save_steps=25,\n",
416
+ " gradient_checkpointing=False, # Unsloth handles this via get_peft_model\n",
417
+ ")\n",
418
+ "\n",
419
+ "total_steps = len(dataset) // (grpo_config.per_device_train_batch_size * GRAD_ACCUM)\n",
420
+ "print(f'Output dir : {OUTPUT_DIR}')\n",
421
+ "print(f'Total steps: ~{total_steps}')\n",
422
+ "print(f'Eff. batch : {grpo_config.per_device_train_batch_size * GRAD_ACCUM}')"
423
+ ]
424
+ },
425
+ {
426
+ "cell_type": "markdown",
427
+ "metadata": {},
428
+ "source": ["## 11. Create Trainer"]
429
+ },
430
+ {
431
+ "cell_type": "code",
432
+ "execution_count": null,
433
+ "metadata": {},
434
+ "outputs": [],
435
+ "source": [
436
+ "from trl import GRPOTrainer\n",
437
+ "\n",
438
+ "trainer = GRPOTrainer(\n",
439
+ " model=model,\n",
440
+ " processing_class=tokenizer,\n",
441
+ " reward_funcs=reward_func, # single function, not a list\n",
442
+ " train_dataset=dataset,\n",
443
+ " args=grpo_config,\n",
444
+ " rollout_func=rollout_func,\n",
445
+ ")\n",
446
+ "print('GRPOTrainer ready')\n",
447
+ "print(f'vLLM engine present: {hasattr(model, \"vllm_engine\")}')"
448
+ ]
449
+ },
450
+ {
451
+ "cell_type": "markdown",
452
+ "metadata": {},
453
+ "source": [
454
+ "## 12. Train\n",
455
+ "\n",
456
+ "**Watch the Colab output for rollout logs:**\n",
457
+ "```\n",
458
+ "[rollout] steps=5 final=0.720 json=0.80 runbook=1 no_wrong=1.00 eff=0.67 β†’ combined=0.674\n",
459
+ "```\n",
460
+ "If `combined > 0` appears in rollout logs but `train/reward = 0.0` in trackio β†’ TRL still not passing kwargs. \n",
461
+ "If `combined > 0` appears in both β†’ training is working.\n",
462
+ "\n",
463
+ "**Key metrics to watch in trackio:**\n",
464
+ "- `train/reward` β€” must be > 0, should rise over training\n",
465
+ "- `train/loss` β€” must be non-zero (proves GRPO advantage β‰  0)\n",
466
+ "- `train/grad_norm` β€” must be non-zero (proves weight updates are happening)\n",
467
+ "- `train/frac_reward_zero_std` β€” should drop below 1.0 quickly"
468
+ ]
469
+ },
470
+ {
471
+ "cell_type": "code",
472
+ "execution_count": null,
473
+ "metadata": {},
474
+ "outputs": [],
475
+ "source": [
476
+ "trainer_stats = trainer.train()"
477
+ ]
478
+ },
479
+ {
480
+ "cell_type": "code",
481
+ "execution_count": null,
482
+ "metadata": {},
483
+ "outputs": [],
484
+ "source": [
485
+ "# Training summary\n",
486
+ "used_gb = round(torch.cuda.max_memory_reserved() / 1024**3, 2)\n",
487
+ "train_mins = round(trainer_stats.metrics.get('train_runtime', 0) / 60, 1)\n",
488
+ "print(f'Training time : {train_mins} min')\n",
489
+ "print(f'Peak GPU usage: {used_gb} GB / {TOTAL_GB} GB ({round(used_gb/TOTAL_GB*100,1)}%)')\n",
490
+ "print(f'Final loss : {trainer_stats.metrics.get(\"train_loss\", \"n/a\")}')"
491
+ ]
492
+ },
493
+ {
494
+ "cell_type": "markdown",
495
+ "metadata": {},
496
+ "source": ["## 13. Save + Push to HuggingFace"]
497
+ },
498
+ {
499
+ "cell_type": "code",
500
+ "execution_count": null,
501
+ "metadata": {},
502
+ "outputs": [],
503
+ "source": [
504
+ "sync_env.close() # close training connection before saving\n",
505
+ "\n",
506
+ "# Merge LoRA into bf16 β€” safe Unsloth path (never call trainer.save_model() directly)\n",
507
+ "model.save_pretrained_merged(OUTPUT_DIR, tokenizer, save_method='merged_16bit')\n",
508
+ "print(f'Saved β†’ {OUTPUT_DIR}')\n",
509
+ "\n",
510
+ "model.push_to_hub_merged(HF_REPO_ID, tokenizer, save_method='merged_16bit')\n",
511
+ "print(f'Pushed β†’ https://huggingface.co/{HF_REPO_ID}')"
512
+ ]
513
+ },
514
+ {
515
+ "cell_type": "markdown",
516
+ "metadata": {},
517
+ "source": [
518
+ "## 14. Evaluate: Baseline vs Trained\n",
519
+ "\n",
520
+ "Uses the **already-loaded model** (via `FastLanguageModel.for_inference`) β€” no reload needed. \n",
521
+ "Connects to the remote HF Space for a clean eval (not localhost)."
522
+ ]
523
+ },
524
+ {
525
+ "cell_type": "code",
526
+ "execution_count": null,
527
+ "metadata": {},
528
+ "outputs": [],
529
+ "source": [
530
+ "from unsloth import FastLanguageModel\n",
531
+ "from training.rollout import extract_json_action, step_aware_fallback, build_messages, _obs_to_dict\n",
532
+ "from training.rollout import _current_obs_text\n",
533
+ "from inference import baseline_agent\n",
534
+ "\n",
535
+ "EVAL_URL = 'https://adityaguntur-pm-ops.hf.space'\n",
536
+ "N_EVAL = 10\n",
537
+ "EVAL_MAX_STEPS = 15\n",
538
+ "\n",
539
+ "FastLanguageModel.for_inference(model) # switch to fast inference mode\n",
540
+ "\n",
541
+ "\n",
542
+ "def eval_trained(env, mdl, tok, n=N_EVAL):\n",
543
+ " scores = []\n",
544
+ " for i in range(n):\n",
545
+ " result = env.reset()\n",
546
+ " obs = result.observation if hasattr(result, 'observation') else result\n",
547
+ " obs_dict = _obs_to_dict(obs)\n",
548
+ " task_brief = obs_dict.get('task_brief', '')\n",
549
+ " history, step, score, done = [], 0, 0.0, False\n",
550
+ "\n",
551
+ " while not done and step < EVAL_MAX_STEPS:\n",
552
+ " obs_text = _current_obs_text(obs_dict, step, task_brief)\n",
553
+ " msgs = build_messages(history, obs_text)\n",
554
+ " prompt_t = tok.apply_chat_template(\n",
555
+ " msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False\n",
556
+ " )\n",
557
+ " inputs = tok([prompt_t], return_tensors='pt').to(mdl.device)\n",
558
+ " with torch.no_grad():\n",
559
+ " out_ids = mdl.generate(**inputs, max_new_tokens=512, temperature=0.0)\n",
560
+ " completion = tok.decode(out_ids[0][len(inputs.input_ids[0]):], skip_special_tokens=True)\n",
561
+ "\n",
562
+ " parsed = extract_json_action(completion) or step_aware_fallback(step)\n",
563
+ " result = env.step({'action_type': parsed['action_type'], 'args': parsed.get('args', {})})\n",
564
+ " obs_d2 = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
565
+ " obs_dict = obs_d2\n",
566
+ " done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n",
567
+ " score = float(getattr(result, 'reward', obs_dict.get('reward', 0.0)))\n",
568
+ " history.append({'obs_text': obs_text, 'completion': completion, 'is_runbook': False})\n",
569
+ " step += 1\n",
570
+ "\n",
571
+ " scores.append(score)\n",
572
+ " print(f' Trained ep {i+1}/{n}: score={score:.3f}')\n",
573
+ " return scores\n",
574
+ "\n",
575
+ "\n",
576
+ "def eval_baseline(env, n=N_EVAL):\n",
577
+ " scores = []\n",
578
+ " for i in range(n):\n",
579
+ " result = env.reset()\n",
580
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
581
+ " org_cfg, done, step, score = {}, False, 0, 0.0\n",
582
+ " while not done and step < EVAL_MAX_STEPS:\n",
583
+ " at, args = baseline_agent(obs_dict, org_cfg)\n",
584
+ " result = env.step({'action_type': at, 'args': args})\n",
585
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
586
+ " done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n",
587
+ " score = float(getattr(result, 'reward', obs_dict.get('reward', 0.0)))\n",
588
+ " step += 1\n",
589
+ " scores.append(score)\n",
590
+ " print(f' Baseline ep {i+1}/{n}: score={score:.3f}')\n",
591
+ " return scores\n",
592
+ "\n",
593
+ "\n",
594
+ "print('─── Baseline ───')\n",
595
+ "with GenericEnvClient(base_url=EVAL_URL).sync() as env_eval:\n",
596
+ " baseline_scores = eval_baseline(env_eval)\n",
597
+ "\n",
598
+ "print('─── Trained ────')\n",
599
+ "with GenericEnvClient(base_url=EVAL_URL).sync() as env_eval:\n",
600
+ " trained_scores = eval_trained(env_eval, model, tokenizer)\n",
601
+ "\n",
602
+ "print(f'\\nBaseline avg : {sum(baseline_scores)/N_EVAL:.3f}')\n",
603
+ "print(f'Trained avg : {sum(trained_scores)/N_EVAL:.3f}')\n",
604
+ "print(f'Improvement : +{(sum(trained_scores)-sum(baseline_scores))/N_EVAL:.3f}')"
605
+ ]
606
+ },
607
+ {
608
+ "cell_type": "markdown",
609
+ "metadata": {},
610
+ "source": ["## 15. Plot Results"]
611
+ },
612
+ {
613
+ "cell_type": "code",
614
+ "execution_count": null,
615
+ "metadata": {},
616
+ "outputs": [],
617
+ "source": [
618
+ "import matplotlib.pyplot as plt\n",
619
+ "import numpy as np\n",
620
+ "\n",
621
+ "fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n",
622
+ "\n",
623
+ "ax, x, w = axes[0], np.arange(N_EVAL), 0.35\n",
624
+ "ax.bar(x - w/2, baseline_scores, w, label='Baseline', color='steelblue', alpha=0.8)\n",
625
+ "ax.bar(x + w/2, trained_scores, w, label='GRPO v2', color='coral', alpha=0.8)\n",
626
+ "ax.axhline(sum(baseline_scores)/N_EVAL, color='steelblue', linestyle='--', alpha=0.5, linewidth=1)\n",
627
+ "ax.axhline(sum(trained_scores)/N_EVAL, color='coral', linestyle='--', alpha=0.5, linewidth=1)\n",
628
+ "ax.set(xlabel='Episode', ylabel='Reward', title='Per-episode: Baseline vs GRPO v2',\n",
629
+ " xticks=x, ylim=(0, 1.05))\n",
630
+ "ax.legend()\n",
631
+ "\n",
632
+ "ax2 = axes[1]\n",
633
+ "avgs = [sum(baseline_scores)/N_EVAL, sum(trained_scores)/N_EVAL]\n",
634
+ "bars = ax2.bar(['Baseline', 'GRPO v2'], avgs, color=['steelblue', 'coral'], alpha=0.85, width=0.5)\n",
635
+ "for bar, val in zip(bars, avgs):\n",
636
+ " ax2.text(bar.get_x() + bar.get_width()/2, val + 0.01, f'{val:.3f}',\n",
637
+ " ha='center', fontsize=13, fontweight='bold')\n",
638
+ "ax2.set(ylabel='Average reward', title=f'Average over {N_EVAL} episodes', ylim=(0, 1.05))\n",
639
+ "\n",
640
+ "plt.tight_layout()\n",
641
+ "plt.savefig('eval_results_v2.png', dpi=150, bbox_inches='tight')\n",
642
+ "plt.show()\n",
643
+ "print('Saved: eval_results_v2.png')"
644
+ ]
645
+ },
646
+ {
647
+ "cell_type": "markdown",
648
+ "metadata": {},
649
+ "source": ["## 16. Teardown"]
650
+ },
651
+ {
652
+ "cell_type": "code",
653
+ "execution_count": null,
654
+ "metadata": {},
655
+ "outputs": [],
656
+ "source": [
657
+ "server_proc.terminate()\n",
658
+ "print('Local PM-Ops server stopped')"
659
+ ]
660
+ }
661
+ ],
662
+ "metadata": {
663
+ "kernelspec": {
664
+ "display_name": "Python 3",
665
+ "language": "python",
666
+ "name": "python3"
667
+ },
668
+ "language_info": {
669
+ "name": "python",
670
+ "version": "3.12.0"
671
+ }
672
+ },
673
+ "nbformat": 4,
674
+ "nbformat_minor": 4
675
+ }