SavK1 Claude Sonnet 4.6 commited on
Commit
4b0fc8a
Β·
1 Parent(s): 094fade

feat(training): add clean v4 notebook with all reward fixes

Browse files

Single-pass, no dead code. Key improvements over v3/pink notebooks:
- gen_slot correctly offsets env seed per GRPO generation
- runbook-compliance reward (label/priority/team/channel vs org config)
- compute_rollout_reward imported from training/rewards.py
- temperature 1.1 + top_k=50 for diverse rollouts
- PatchFastRL called once before trl imports
- No duplicate git-pull or dead rollout cells

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

Files changed (1) hide show
  1. training/train_v4.ipynb +909 -0
training/train_v4.ipynb ADDED
@@ -0,0 +1,909 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nbformat": 4,
3
+ "nbformat_minor": 5,
4
+ "metadata": {
5
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
6
+ "language_info": {"name": "python", "version": "3.12.0"}
7
+ },
8
+ "cells": [
9
+ {
10
+ "cell_type": "markdown",
11
+ "id": "v4-title",
12
+ "metadata": {},
13
+ "source": [
14
+ "# PM-Ops GRPO Training v4\n",
15
+ "\n",
16
+ "Clean rewrite β€” all known v3 bugs fixed.\n",
17
+ "\n",
18
+ "| Fix | Details |\n",
19
+ "|---|---|\n",
20
+ "| Constant reward=0.350 | Replaced env_score formula with runbook-compliance scoring |\n",
21
+ "| `gen_slot=0` override bug | gen_slot now correctly offsets env seed per GRPO generation |\n",
22
+ "| Double `env.step` per step | Removed duplicate step call inside try/except |\n",
23
+ "| Dataset/env task-type mismatch | `dataset.py` pre-filters seeds to triage-only episodes |\n",
24
+ "| Near-greedy generation | Temperature 1.1 + top_k=50 for diverse rollouts |\n",
25
+ "| PatchFastRL called twice | Called once, unconditionally, before any trl imports |\n",
26
+ "\n",
27
+ "**Reward**: read_runbook (+0.10) + valid_label (Β±0.20/0.10) + valid_priority (Β±0.15/0.10) + valid_team (Β±0.20/0.10) + right_channel (Β±0.25/0.10) + env_bonus (Γ—0.10). Varies per org config β€” different valid values per seed. \n",
28
+ "**Stack**: Unsloth 2026.x Β· TRL 0.22.2 Β· Qwen3-1.7B Β· PMOpsGRPOTrainer \n",
29
+ "**GPU**: A100-40GB β†’ ~2 hrs (SFT 15 min + GRPO 90 min)"
30
+ ]
31
+ },
32
+ {
33
+ "cell_type": "markdown",
34
+ "id": "v4-s0",
35
+ "metadata": {},
36
+ "source": ["## 0. Install"]
37
+ },
38
+ {
39
+ "cell_type": "code",
40
+ "id": "v4-install",
41
+ "metadata": {},
42
+ "execution_count": null,
43
+ "outputs": [],
44
+ "source": [
45
+ "%%capture\n",
46
+ "import os\n",
47
+ "!pip install --upgrade -qqq uv\n",
48
+ "if 'COLAB_' not in ''.join(os.environ.keys()):\n",
49
+ " !uv pip install unsloth vllm\n",
50
+ "else:\n",
51
+ " import subprocess\n",
52
+ " is_t4 = 'Tesla T4' in str(subprocess.check_output(['nvidia-smi']))\n",
53
+ " _vllm = 'vllm==0.9.2' if is_t4 else 'vllm==0.15.1'\n",
54
+ " _triton = 'triton==3.2.0' if is_t4 else 'triton'\n",
55
+ " !uv pip install -qqq --upgrade {_vllm} torchvision bitsandbytes xformers unsloth\n",
56
+ " !uv pip install -qqq {_triton}\n",
57
+ "!uv pip install transformers==4.56.2\n",
58
+ "!uv pip install --no-deps trl==0.22.2\n",
59
+ "!pip install 'numpy==1.26.4' --break-system-packages -q\n",
60
+ "!pip install openenv openenv-core -q"
61
+ ]
62
+ },
63
+ {
64
+ "cell_type": "markdown",
65
+ "id": "v4-s1",
66
+ "metadata": {},
67
+ "source": ["## 1. GPU Config + Patch"]
68
+ },
69
+ {
70
+ "cell_type": "code",
71
+ "id": "v4-config",
72
+ "metadata": {},
73
+ "execution_count": null,
74
+ "outputs": [],
75
+ "source": [
76
+ "import torch\n",
77
+ "from unsloth import FastLanguageModel, PatchFastRL\n",
78
+ "\n",
79
+ "# Must patch BEFORE any trl imports\n",
80
+ "PatchFastRL('GRPO', FastLanguageModel)\n",
81
+ "\n",
82
+ "import trl\n",
83
+ "\n",
84
+ "gpu = torch.cuda.get_device_properties(0)\n",
85
+ "TOTAL_GB = round(gpu.total_memory / 1024**3, 1)\n",
86
+ "IS_A100 = TOTAL_GB >= 35\n",
87
+ "\n",
88
+ "NUM_GEN = 6 if IS_A100 else 2\n",
89
+ "GRAD_ACCUM = 32 if IS_A100 else 8\n",
90
+ "MAX_COMP_LEN = 384\n",
91
+ "N_SFT_EPISODES = 120 if IS_A100 else 20\n",
92
+ "N_GRPO_EPISODES = 150 if IS_A100 else 30\n",
93
+ "TRAIN_MAX_STEPS = 12\n",
94
+ "\n",
95
+ "print(f'torch={torch.__version__} trl={trl.__version__}')\n",
96
+ "print(f'GPU: {gpu.name} ({TOTAL_GB} GB) IS_A100={IS_A100}')\n",
97
+ "print(f'num_gen={NUM_GEN} grad_accum={GRAD_ACCUM} '\n",
98
+ " f'sft_eps={N_SFT_EPISODES} grpo_eps={N_GRPO_EPISODES} max_steps={TRAIN_MAX_STEPS}')"
99
+ ]
100
+ },
101
+ {
102
+ "cell_type": "markdown",
103
+ "id": "v4-s2",
104
+ "metadata": {},
105
+ "source": ["## 2. Clone PM-Ops Repo"]
106
+ },
107
+ {
108
+ "cell_type": "code",
109
+ "id": "v4-clone",
110
+ "metadata": {},
111
+ "execution_count": null,
112
+ "outputs": [],
113
+ "source": [
114
+ "import os, sys\n",
115
+ "\n",
116
+ "REPO_URL = 'https://huggingface.co/spaces/TheCrustaceans/Pm-ops'\n",
117
+ "REPO_DIR = '/content/Pm_ops'\n",
118
+ "\n",
119
+ "if not os.path.exists(REPO_DIR):\n",
120
+ " !git clone --depth=1 -q {REPO_URL} {REPO_DIR}\n",
121
+ " print(f'Cloned -> {REPO_DIR}')\n",
122
+ "else:\n",
123
+ " !git -C {REPO_DIR} pull -q origin main\n",
124
+ " print(f'Pulled -> {REPO_DIR}')\n",
125
+ "\n",
126
+ "for p in [REPO_DIR, os.path.join(REPO_DIR, 'training')]:\n",
127
+ " if p not in sys.path:\n",
128
+ " sys.path.insert(0, p)\n",
129
+ "os.chdir(REPO_DIR)\n",
130
+ "print(f'CWD: {os.getcwd()}')\n",
131
+ "!git -C {REPO_DIR} log --oneline -3"
132
+ ]
133
+ },
134
+ {
135
+ "cell_type": "markdown",
136
+ "id": "v4-s3",
137
+ "metadata": {},
138
+ "source": ["## 3. HuggingFace Login"]
139
+ },
140
+ {
141
+ "cell_type": "code",
142
+ "id": "v4-hf-login",
143
+ "metadata": {},
144
+ "execution_count": null,
145
+ "outputs": [],
146
+ "source": [
147
+ "from huggingface_hub import notebook_login\n",
148
+ "notebook_login()"
149
+ ]
150
+ },
151
+ {
152
+ "cell_type": "markdown",
153
+ "id": "v4-s4",
154
+ "metadata": {},
155
+ "source": ["## 4. Start PM-Ops Server"]
156
+ },
157
+ {
158
+ "cell_type": "code",
159
+ "id": "v4-server",
160
+ "metadata": {},
161
+ "execution_count": null,
162
+ "outputs": [],
163
+ "source": [
164
+ "import subprocess, time, requests\n",
165
+ "\n",
166
+ "# Kill any leftover server from a previous run\n",
167
+ "subprocess.run(['pkill', '-f', 'uvicorn'], capture_output=True)\n",
168
+ "time.sleep(1)\n",
169
+ "\n",
170
+ "server_proc = subprocess.Popen(\n",
171
+ " [sys.executable, '-m', 'uvicorn', 'server.app:app',\n",
172
+ " '--host', '0.0.0.0', '--port', '8000'],\n",
173
+ " cwd=REPO_DIR, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,\n",
174
+ ")\n",
175
+ "ENV_URL = 'http://localhost:8000'\n",
176
+ "\n",
177
+ "for _ in range(30):\n",
178
+ " try:\n",
179
+ " if requests.get(f'{ENV_URL}/', timeout=2).status_code == 200:\n",
180
+ " print(f'PM-Ops server ready pid={server_proc.pid}')\n",
181
+ " break\n",
182
+ " except Exception:\n",
183
+ " pass\n",
184
+ " time.sleep(1)\n",
185
+ "else:\n",
186
+ " raise RuntimeError('Server did not start in 30 s')"
187
+ ]
188
+ },
189
+ {
190
+ "cell_type": "markdown",
191
+ "id": "v4-s5",
192
+ "metadata": {},
193
+ "source": ["## 5. Verify Env"]
194
+ },
195
+ {
196
+ "cell_type": "code",
197
+ "id": "v4-verify-env",
198
+ "metadata": {},
199
+ "execution_count": null,
200
+ "outputs": [],
201
+ "source": [
202
+ "from openenv.core import GenericEnvClient\n",
203
+ "from training.rollout import _obs_to_dict\n",
204
+ "\n",
205
+ "with GenericEnvClient(base_url=ENV_URL).sync() as _env:\n",
206
+ " r = _env.reset(seed=42)\n",
207
+ " obs = _obs_to_dict(r.observation if hasattr(r, 'observation') else r)\n",
208
+ " print(f'task_brief : {obs.get(\"task_brief\", \"?\")[:80]}...')\n",
209
+ " step_r = _env.step({'action_type': 'meta.read_runbook', 'args': {}})\n",
210
+ " rb_obs = _obs_to_dict(step_r.observation if hasattr(step_r, 'observation') else step_r)\n",
211
+ " data = (rb_obs.get('last_action_result') or {}).get('data', {})\n",
212
+ " org = data.get('org_config', {}) if isinstance(data, dict) else {}\n",
213
+ " print(f'org labels : {list(org.get(\"label_taxonomy\", {}).values())}')\n",
214
+ " print(f'org priorities: {org.get(\"priority_levels\", [])}')\n",
215
+ " print(f'org channels : {list(org.get(\"oncall_channels\", {}).values())}')\n",
216
+ " print('Env verify: OK')"
217
+ ]
218
+ },
219
+ {
220
+ "cell_type": "markdown",
221
+ "id": "v4-s6",
222
+ "metadata": {},
223
+ "source": ["## 6. Load Model β€” Unsloth 4-bit + LoRA"]
224
+ },
225
+ {
226
+ "cell_type": "code",
227
+ "id": "v4-model",
228
+ "metadata": {},
229
+ "execution_count": null,
230
+ "outputs": [],
231
+ "source": [
232
+ "MODEL_NAME = 'Qwen/Qwen3-1.7B'\n",
233
+ "LORA_RANK = 16\n",
234
+ "\n",
235
+ "model, tokenizer = FastLanguageModel.from_pretrained(\n",
236
+ " model_name = MODEL_NAME,\n",
237
+ " max_seq_length = 4096 + MAX_COMP_LEN,\n",
238
+ " load_in_4bit = True,\n",
239
+ " fast_inference = False,\n",
240
+ " max_lora_rank = LORA_RANK,\n",
241
+ " gpu_memory_utilization = 0.55,\n",
242
+ ")\n",
243
+ "model = FastLanguageModel.get_peft_model(\n",
244
+ " model,\n",
245
+ " r = LORA_RANK,\n",
246
+ " target_modules = ['q_proj','k_proj','v_proj','o_proj',\n",
247
+ " 'gate_proj','up_proj','down_proj'],\n",
248
+ " lora_alpha = LORA_RANK,\n",
249
+ " use_gradient_checkpointing = 'unsloth',\n",
250
+ " random_state = 42,\n",
251
+ ")\n",
252
+ "tokenizer.pad_token = tokenizer.eos_token\n",
253
+ "tokenizer.padding_side = 'left'\n",
254
+ "model.print_trainable_parameters()\n",
255
+ "print(f'GPU after load: {round(torch.cuda.max_memory_reserved()/1024**3, 2)} GB / {TOTAL_GB} GB')"
256
+ ]
257
+ },
258
+ {
259
+ "cell_type": "markdown",
260
+ "id": "v4-phase1",
261
+ "metadata": {},
262
+ "source": [
263
+ "---\n",
264
+ "## Phase 1 β€” SFT Warmup\n",
265
+ "\n",
266
+ "Teach the model the JSON output format and PM-ops workflow before GRPO. \n",
267
+ "`baseline_agent` runs N deterministic episodes β†’ ~6 steps each β†’ supervised (prompt, completion) pairs. \n",
268
+ "2 SFT epochs (~15 min on A100). Without SFT, all rollouts output freeform text β†’ zero GRPO gradient."
269
+ ]
270
+ },
271
+ {
272
+ "cell_type": "markdown",
273
+ "id": "v4-s7",
274
+ "metadata": {},
275
+ "source": ["## 7. Generate SFT Dataset"]
276
+ },
277
+ {
278
+ "cell_type": "code",
279
+ "id": "v4-sft-data",
280
+ "metadata": {},
281
+ "execution_count": null,
282
+ "outputs": [],
283
+ "source": [
284
+ "import json as _json\n",
285
+ "from datasets import Dataset\n",
286
+ "from inference import baseline_agent\n",
287
+ "from training.rollout import _obs_to_dict, _current_obs_text, build_messages\n",
288
+ "\n",
289
+ "\n",
290
+ "def generate_sft_dataset(env_url, tok, n_episodes, seed_start=2000):\n",
291
+ " examples = []\n",
292
+ " with GenericEnvClient(base_url=env_url).sync() as env:\n",
293
+ " for i in range(n_episodes):\n",
294
+ " result = env.reset(seed=seed_start + i)\n",
295
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
296
+ " task_brief = obs_dict.get('task_brief', '')\n",
297
+ " turn_history, org_config, step, done = [], {}, 0, False\n",
298
+ "\n",
299
+ " while not done and step < 8:\n",
300
+ " obs_text = _current_obs_text(obs_dict, step, task_brief)\n",
301
+ " action_type, args = baseline_agent(obs_dict, org_config)\n",
302
+ " payload = {'action_type': action_type, 'args': args}\n",
303
+ " completion = '```json\\n' + _json.dumps(payload) + '\\n```'\n",
304
+ " msgs = build_messages(turn_history, obs_text)\n",
305
+ " prompt = tok.apply_chat_template(\n",
306
+ " msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False\n",
307
+ " )\n",
308
+ " examples.append({'text': prompt + completion + tok.eos_token})\n",
309
+ " turn_history.append({\n",
310
+ " 'obs_text': obs_text, 'completion': completion,\n",
311
+ " 'is_runbook': (action_type == 'meta.read_runbook'),\n",
312
+ " })\n",
313
+ " result = env.step({'action_type': action_type, 'args': args})\n",
314
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
315
+ " if action_type == 'meta.read_runbook':\n",
316
+ " last = obs_dict.get('last_action_result') or {}\n",
317
+ " if last.get('ok'):\n",
318
+ " data = last.get('data') or {}\n",
319
+ " if isinstance(data, dict) and 'org_config' in data:\n",
320
+ " org_config.update(data['org_config'])\n",
321
+ " done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n",
322
+ " step += 1\n",
323
+ "\n",
324
+ " if (i + 1) % 20 == 0:\n",
325
+ " print(f' {i+1}/{n_episodes} eps β€” {len(examples)} examples')\n",
326
+ "\n",
327
+ " return examples\n",
328
+ "\n",
329
+ "\n",
330
+ "print(f'Generating {N_SFT_EPISODES} SFT demonstration episodes...')\n",
331
+ "sft_raw = generate_sft_dataset(ENV_URL, tokenizer, n_episodes=N_SFT_EPISODES)\n",
332
+ "sft_dataset = Dataset.from_list(sft_raw)\n",
333
+ "print(f'SFT dataset: {len(sft_dataset)} examples (~{len(sft_dataset)//6} eps Γ— 6 steps)')\n",
334
+ "print(f'Sample (first 300 chars):\\n{sft_raw[0][\"text\"][:300]}')"
335
+ ]
336
+ },
337
+ {
338
+ "cell_type": "markdown",
339
+ "id": "v4-s8",
340
+ "metadata": {},
341
+ "source": ["## 8. SFT Training"]
342
+ },
343
+ {
344
+ "cell_type": "code",
345
+ "id": "v4-sft-train",
346
+ "metadata": {},
347
+ "execution_count": null,
348
+ "outputs": [],
349
+ "source": [
350
+ "from trl import SFTTrainer, SFTConfig\n",
351
+ "\n",
352
+ "sft_cfg = SFTConfig(\n",
353
+ " dataset_text_field = 'text',\n",
354
+ " max_seq_length = 2048,\n",
355
+ " num_train_epochs = 2,\n",
356
+ " per_device_train_batch_size = 4,\n",
357
+ " gradient_accumulation_steps = 4,\n",
358
+ " learning_rate = 2e-4,\n",
359
+ " warmup_steps = 10,\n",
360
+ " output_dir = 'pm-ops-sft-warmup',\n",
361
+ " report_to = 'none',\n",
362
+ " logging_steps = 5,\n",
363
+ " save_strategy = 'no',\n",
364
+ " dataloader_num_workers = 0,\n",
365
+ ")\n",
366
+ "sft_steps = (\n",
367
+ " len(sft_dataset)\n",
368
+ " // (sft_cfg.per_device_train_batch_size * sft_cfg.gradient_accumulation_steps)\n",
369
+ " * sft_cfg.num_train_epochs\n",
370
+ ")\n",
371
+ "print(f'SFT: {len(sft_dataset)} examples Γ— {sft_cfg.num_train_epochs} epochs β†’ ~{sft_steps} steps')\n",
372
+ "\n",
373
+ "sft_trainer = SFTTrainer(model=model, tokenizer=tokenizer,\n",
374
+ " train_dataset=sft_dataset, args=sft_cfg)\n",
375
+ "sft_stats = sft_trainer.train()\n",
376
+ "loss = sft_stats.metrics.get('train_loss', 0)\n",
377
+ "runtime = sft_stats.metrics.get('train_runtime', 0)\n",
378
+ "print(f'SFT done: {round(runtime/60, 1)} min loss={loss:.3f}')"
379
+ ]
380
+ },
381
+ {
382
+ "cell_type": "markdown",
383
+ "id": "v4-s9",
384
+ "metadata": {},
385
+ "source": ["## 9. Verify SFT β€” Model Must Output Valid JSON"]
386
+ },
387
+ {
388
+ "cell_type": "code",
389
+ "id": "v4-sft-verify",
390
+ "metadata": {},
391
+ "execution_count": null,
392
+ "outputs": [],
393
+ "source": [
394
+ "from training.rollout import extract_json_action\n",
395
+ "\n",
396
+ "model.eval()\n",
397
+ "with GenericEnvClient(base_url=ENV_URL).sync() as _env:\n",
398
+ " r = _env.reset(seed=99001)\n",
399
+ " obs_dict = _obs_to_dict(r.observation if hasattr(r, 'observation') else r)\n",
400
+ " obs_text = _current_obs_text(obs_dict, 0, obs_dict.get('task_brief', ''))\n",
401
+ " msgs = build_messages([], obs_text)\n",
402
+ " prompt = tokenizer.apply_chat_template(\n",
403
+ " msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False\n",
404
+ " )\n",
405
+ "\n",
406
+ "inputs = tokenizer([prompt], return_tensors='pt').to(model.device)\n",
407
+ "with torch.no_grad():\n",
408
+ " out = model.generate(**inputs, max_new_tokens=128, do_sample=False,\n",
409
+ " pad_token_id=tokenizer.eos_token_id)\n",
410
+ "completion = tokenizer.decode(out[0][inputs['input_ids'].shape[1]:], skip_special_tokens=True)\n",
411
+ "parsed = extract_json_action(completion)\n",
412
+ "\n",
413
+ "print(f'Output: {completion[:400]}')\n",
414
+ "print(f'Parsed: {parsed}')\n",
415
+ "if parsed is not None:\n",
416
+ " print('PASS βœ“ β€” model outputs valid JSON after SFT')\n",
417
+ "else:\n",
418
+ " print('FAIL βœ— β€” still no valid JSON. Run SFT again with more episodes or epochs.')\n",
419
+ "model.train()"
420
+ ]
421
+ },
422
+ {
423
+ "cell_type": "markdown",
424
+ "id": "v4-phase2",
425
+ "metadata": {},
426
+ "source": [
427
+ "---\n",
428
+ "## Phase 2 β€” GRPO\n",
429
+ "\n",
430
+ "Reward components β€” sum β‰ˆ 0.90 when all correct:\n",
431
+ "\n",
432
+ "| Signal | Weight | Description |\n",
433
+ "|---|---|---|\n",
434
+ "| `read_runbook` | +0.10 | Did agent read the runbook first? |\n",
435
+ "| `valid_label` | +0.20 / βˆ’0.10 | Ticket label in org's `label_taxonomy`? |\n",
436
+ "| `valid_priority` | +0.15 / βˆ’0.10 | Ticket priority in org's `priority_levels`? |\n",
437
+ "| `valid_team` | +0.20 / βˆ’0.10 | Assigned team in org's `team_map`? |\n",
438
+ "| `right_channel` | +0.25 / βˆ’0.10/ch | Posted to org's `oncall_channels`? |\n",
439
+ "| `env_bonus` | Γ—0.10 | Env grader confirmation |\n",
440
+ "\n",
441
+ "Reward **varies per org config** (different valid values per seed) β†’ genuine GRPO advantage signal. \n",
442
+ "Each of the N GRPO generations gets a different env seed via `gen_slot` offset."
443
+ ]
444
+ },
445
+ {
446
+ "cell_type": "markdown",
447
+ "id": "v4-s10",
448
+ "metadata": {},
449
+ "source": ["## 10. GRPO Training Dataset"]
450
+ },
451
+ {
452
+ "cell_type": "code",
453
+ "id": "v4-grpo-data",
454
+ "metadata": {},
455
+ "execution_count": null,
456
+ "outputs": [],
457
+ "source": [
458
+ "from training.dataset import generate_triage_dataset\n",
459
+ "\n",
460
+ "rows = generate_triage_dataset(n_episodes=N_GRPO_EPISODES, base_seed=42)\n",
461
+ "grpo_dataset = Dataset.from_list([{'prompt': r['prompt']} for r in rows])\n",
462
+ "print(f'GRPO dataset: {len(grpo_dataset)} triage episodes')\n",
463
+ "print(f'Difficulties: {set(r[\"difficulty\"] for r in rows)}')\n",
464
+ "print(f'Sample: {rows[0][\"prompt\"][:120]}')"
465
+ ]
466
+ },
467
+ {
468
+ "cell_type": "markdown",
469
+ "id": "v4-s11",
470
+ "metadata": {},
471
+ "source": ["## 11. GRPO Rollout + Reward"]
472
+ },
473
+ {
474
+ "cell_type": "code",
475
+ "id": "v4-rollout",
476
+ "metadata": {},
477
+ "execution_count": null,
478
+ "outputs": [],
479
+ "source": [
480
+ "import torch.nn.functional as F\n",
481
+ "from training.rollout import (\n",
482
+ " _obs_to_dict, _current_obs_text, build_messages,\n",
483
+ " extract_json_action, step_aware_fallback,\n",
484
+ ")\n",
485
+ "from training.dataset import parse_seed_from_prompt\n",
486
+ "from training.rewards import compute_rollout_reward\n",
487
+ "\n",
488
+ "grpo_env = GenericEnvClient(base_url=ENV_URL).sync()\n",
489
+ "grpo_env.connect()\n",
490
+ "print('GRPO env connected')\n",
491
+ "\n",
492
+ "\n",
493
+ "def run_grpo_episode(trainer, env, tok, dataset_prompt, max_steps=TRAIN_MAX_STEPS, gen_slot=0):\n",
494
+ " \"\"\"One full PM-ops episode. gen_slot offsets seed so parallel GRPO generations\n",
495
+ " explore distinct env episodes even for the same base prompt.\"\"\"\n",
496
+ " seed = parse_seed_from_prompt(dataset_prompt)\n",
497
+ " result = env.reset(seed=seed + gen_slot) if seed is not None else env.reset()\n",
498
+ "\n",
499
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
500
+ " task_brief = obs_dict.get('task_brief') or dataset_prompt\n",
501
+ "\n",
502
+ " prompt_ids, completion_ids, logprobs = [], [], []\n",
503
+ " turn_history = []\n",
504
+ " valid_json_count = 0\n",
505
+ " env_score = 0.0\n",
506
+ " step, done = 0, False\n",
507
+ "\n",
508
+ " # Runbook-compliance tracking\n",
509
+ " read_runbook_done = False\n",
510
+ " valid_labels: set = set()\n",
511
+ " valid_priorities: set = set()\n",
512
+ " valid_teams: set = set()\n",
513
+ " oncall_channels: set = set()\n",
514
+ " ticket_label: str | None = None\n",
515
+ " ticket_priority: str | None = None\n",
516
+ " assigned_team: str | None = None\n",
517
+ " posted_channels: list = []\n",
518
+ "\n",
519
+ " _model = (trainer.accelerator.unwrap_model(trainer.model)\n",
520
+ " if hasattr(trainer, 'accelerator') else trainer.model)\n",
521
+ " _device = (trainer.accelerator.device\n",
522
+ " if hasattr(trainer, 'accelerator') else next(_model.parameters()).device)\n",
523
+ "\n",
524
+ " while not done and step < max_steps:\n",
525
+ " obs_text = _current_obs_text(obs_dict, step, task_brief)\n",
526
+ " msgs = build_messages(turn_history, obs_text)\n",
527
+ " prompt_text = tok.apply_chat_template(\n",
528
+ " msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False\n",
529
+ " )\n",
530
+ "\n",
531
+ " enc = tok(prompt_text, return_tensors='pt', truncation=True, max_length=4096).to(_device)\n",
532
+ " plen = enc['input_ids'].shape[1]\n",
533
+ " with torch.no_grad():\n",
534
+ " out = _model.generate(\n",
535
+ " **enc,\n",
536
+ " max_new_tokens = MAX_COMP_LEN,\n",
537
+ " do_sample = True,\n",
538
+ " temperature = 1.1,\n",
539
+ " top_p = 0.95,\n",
540
+ " top_k = 50,\n",
541
+ " pad_token_id = tok.pad_token_id or tok.eos_token_id,\n",
542
+ " output_scores = True,\n",
543
+ " return_dict_in_generate = True,\n",
544
+ " )\n",
545
+ "\n",
546
+ " cids = out.sequences[0][plen:].tolist()\n",
547
+ " completion_text = tok.decode(cids, skip_special_tokens=True)\n",
548
+ "\n",
549
+ " prompt_ids.extend(enc['input_ids'][0].tolist())\n",
550
+ " completion_ids.extend(cids)\n",
551
+ " logprobs.extend([\n",
552
+ " F.log_softmax(s[0], dim=-1)[t].item()\n",
553
+ " for s, t in zip(out.scores, cids)\n",
554
+ " ])\n",
555
+ "\n",
556
+ " if step == 0:\n",
557
+ " print(f' [sample] {repr(completion_text[:180])}')\n",
558
+ "\n",
559
+ " parsed = extract_json_action(completion_text)\n",
560
+ " is_valid = parsed is not None\n",
561
+ " if not is_valid:\n",
562
+ " parsed = step_aware_fallback(step, max_steps)\n",
563
+ " else:\n",
564
+ " valid_json_count += 1\n",
565
+ "\n",
566
+ " action_type = parsed.get('action_type', 'meta.noop')\n",
567
+ " args = parsed.get('args', {})\n",
568
+ " print(f' [step {step}] {action_type}')\n",
569
+ "\n",
570
+ " # Compliance signal capture\n",
571
+ " if action_type == 'meta.read_runbook' and is_valid:\n",
572
+ " read_runbook_done = True\n",
573
+ " if action_type == 'ticketing.create_ticket' and is_valid and ticket_label is None:\n",
574
+ " ticket_label = args.get('label')\n",
575
+ " ticket_priority = args.get('priority')\n",
576
+ " if action_type == 'ticketing.assign_ticket' and is_valid and assigned_team is None:\n",
577
+ " assigned_team = args.get('team')\n",
578
+ " if action_type == 'chat.post_message' and is_valid:\n",
579
+ " ch = args.get('channel', '')\n",
580
+ " if ch:\n",
581
+ " posted_channels.append(ch)\n",
582
+ "\n",
583
+ " turn_history.append({\n",
584
+ " 'obs_text' : obs_text,\n",
585
+ " 'completion': completion_text,\n",
586
+ " 'is_runbook': (action_type == 'meta.read_runbook' and is_valid),\n",
587
+ " })\n",
588
+ "\n",
589
+ " result = env.step({'action_type': action_type, 'args': args})\n",
590
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
591
+ "\n",
592
+ " # Extract org config from runbook response (one step after the call)\n",
593
+ " last_result = obs_dict.get('last_action_result') or {}\n",
594
+ " if action_type == 'meta.read_runbook' and last_result.get('ok'):\n",
595
+ " data = last_result.get('data') or {}\n",
596
+ " if isinstance(data, dict):\n",
597
+ " org = data.get('org_config') or {}\n",
598
+ " valid_labels = set(org.get('label_taxonomy', {}).values())\n",
599
+ " valid_priorities = set(org.get('priority_levels', []))\n",
600
+ " valid_teams = set(org.get('team_map', {}).values())\n",
601
+ " oncall_channels = set(org.get('oncall_channels', {}).values())\n",
602
+ "\n",
603
+ " done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n",
604
+ " env_score = float(getattr(result, 'reward', obs_dict.get('reward', 0.0)))\n",
605
+ " step += 1\n",
606
+ "\n",
607
+ " reward = compute_rollout_reward(\n",
608
+ " read_runbook_done = read_runbook_done,\n",
609
+ " valid_labels = valid_labels,\n",
610
+ " valid_priorities = valid_priorities,\n",
611
+ " valid_teams = valid_teams,\n",
612
+ " oncall_channels = oncall_channels,\n",
613
+ " ticket_label = ticket_label,\n",
614
+ " ticket_priority = ticket_priority,\n",
615
+ " assigned_team = assigned_team,\n",
616
+ " posted_channels = posted_channels,\n",
617
+ " env_score = env_score,\n",
618
+ " valid_json_count = valid_json_count,\n",
619
+ " )\n",
620
+ "\n",
621
+ " lbl = 'βœ“' if ticket_label and ticket_label in valid_labels else ('βœ—' if ticket_label else '-')\n",
622
+ " pri = 'βœ“' if ticket_priority and ticket_priority in valid_priorities else ('βœ—' if ticket_priority else '-')\n",
623
+ " tm = 'βœ“' if assigned_team and assigned_team in valid_teams else ('βœ—' if assigned_team else '-')\n",
624
+ " ch = 'βœ“' if any(c in oncall_channels for c in posted_channels) else ('βœ—' if posted_channels else '-')\n",
625
+ " print(f' [rollout] steps={step} env={env_score:.3f} '\n",
626
+ " f'label={lbl} priority={pri} team={tm} channel={ch} β†’ reward={reward:.3f}')\n",
627
+ "\n",
628
+ " return {\n",
629
+ " 'prompt_ids' : prompt_ids,\n",
630
+ " 'completion_ids': completion_ids,\n",
631
+ " 'logprobs' : logprobs,\n",
632
+ " 'reward' : reward,\n",
633
+ " }\n",
634
+ "\n",
635
+ "\n",
636
+ "def grpo_rollout_func(prompts, trainer=None):\n",
637
+ " out = {'prompt_ids': [], 'completion_ids': [], 'logprobs': [], 'reward': []}\n",
638
+ " prompt_seen: dict = {}\n",
639
+ " for prompt in prompts:\n",
640
+ " gen_offset = prompt_seen.get(prompt, 0)\n",
641
+ " prompt_seen[prompt] = gen_offset + 1\n",
642
+ " ep = run_grpo_episode(\n",
643
+ " trainer, grpo_env, tokenizer, prompt, TRAIN_MAX_STEPS, gen_slot=gen_offset\n",
644
+ " )\n",
645
+ " for k in out:\n",
646
+ " out[k].append(ep[k])\n",
647
+ " return out\n",
648
+ "\n",
649
+ "\n",
650
+ "def grpo_reward_func(completions, **kwargs):\n",
651
+ " \"\"\"Passthrough β€” reward pre-computed in grpo_rollout_func.\"\"\"\n",
652
+ " rewards = kwargs.get('reward', [])\n",
653
+ " if not rewards:\n",
654
+ " print(f'[grpo_reward_func] no reward in kwargs β€” keys: {list(kwargs.keys())}')\n",
655
+ " return [0.0] * len(completions)\n",
656
+ " return [float(r) for r in rewards]\n",
657
+ "\n",
658
+ "\n",
659
+ "print(f'GRPO rollout ready max_steps={TRAIN_MAX_STEPS}')"
660
+ ]
661
+ },
662
+ {
663
+ "cell_type": "markdown",
664
+ "id": "v4-s12",
665
+ "metadata": {},
666
+ "source": ["## 12. GRPO Config + Trainer"]
667
+ },
668
+ {
669
+ "cell_type": "code",
670
+ "id": "v4-trainer",
671
+ "metadata": {},
672
+ "execution_count": null,
673
+ "outputs": [],
674
+ "source": [
675
+ "from trl import GRPOConfig\n",
676
+ "from training.pm_ops_trainer import PMOpsGRPOTrainer\n",
677
+ "\n",
678
+ "OUTPUT_DIR = 'pm-ops-grpo-Qwen3-1.7B-triage-v4'\n",
679
+ "HF_REPO_ID = f'Saurav1/{OUTPUT_DIR}'\n",
680
+ "\n",
681
+ "grpo_cfg = GRPOConfig(\n",
682
+ " num_train_epochs = 2,\n",
683
+ " learning_rate = 1e-6,\n",
684
+ " gradient_accumulation_steps = GRAD_ACCUM,\n",
685
+ " per_device_train_batch_size = 1,\n",
686
+ " warmup_steps = 5,\n",
687
+ " num_generations = NUM_GEN,\n",
688
+ " max_completion_length = MAX_COMP_LEN,\n",
689
+ " max_prompt_length = 4096,\n",
690
+ " use_vllm = False,\n",
691
+ " output_dir = OUTPUT_DIR,\n",
692
+ " report_to = 'none',\n",
693
+ " logging_steps = 1,\n",
694
+ " save_steps = 20,\n",
695
+ " gradient_checkpointing = False,\n",
696
+ ")\n",
697
+ "\n",
698
+ "eff_batch = grpo_cfg.per_device_train_batch_size * GRAD_ACCUM\n",
699
+ "total_steps = len(grpo_dataset) * NUM_GEN * grpo_cfg.num_train_epochs // eff_batch\n",
700
+ "print(f'GRPO: {len(grpo_dataset)} eps Γ— {NUM_GEN} gen Γ— {grpo_cfg.num_train_epochs} epochs β†’ ~{total_steps} steps')\n",
701
+ "\n",
702
+ "trainer = PMOpsGRPOTrainer(\n",
703
+ " model = model,\n",
704
+ " processing_class = tokenizer,\n",
705
+ " reward_funcs = grpo_reward_func,\n",
706
+ " train_dataset = grpo_dataset,\n",
707
+ " args = grpo_cfg,\n",
708
+ " rollout_func = grpo_rollout_func,\n",
709
+ ")\n",
710
+ "assert grpo_cfg.use_vllm is False\n",
711
+ "print(f'Trainer: {type(trainer).__name__} ready')"
712
+ ]
713
+ },
714
+ {
715
+ "cell_type": "markdown",
716
+ "id": "v4-s13",
717
+ "metadata": {},
718
+ "source": ["## 13. Preflight Probe"]
719
+ },
720
+ {
721
+ "cell_type": "code",
722
+ "id": "v4-probe",
723
+ "metadata": {},
724
+ "execution_count": null,
725
+ "outputs": [],
726
+ "source": [
727
+ "# Run 2 episodes and verify reward varies (not stuck at a constant)\n",
728
+ "probe = grpo_rollout_func(\n",
729
+ " [grpo_dataset[0]['prompt'], grpo_dataset[1]['prompt']],\n",
730
+ " trainer=trainer,\n",
731
+ ")\n",
732
+ "print(f'probe rewards : {probe[\"reward\"]}')\n",
733
+ "assert len(probe['reward']) == 2, 'need one reward per prompt'\n",
734
+ "assert all(isinstance(r, float) for r in probe['reward']), 'rewards must be float'\n",
735
+ "print('Preflight PASS βœ“')"
736
+ ]
737
+ },
738
+ {
739
+ "cell_type": "markdown",
740
+ "id": "v4-s14",
741
+ "metadata": {},
742
+ "source": [
743
+ "## 14. Train\n",
744
+ "\n",
745
+ "Watch for:\n",
746
+ "- `[sample] '\\`\\`\\`json ...'` β€” model should output JSON code blocks\n",
747
+ "- `label=βœ“ priority=βœ“ team=βœ“ channel=βœ“` β€” compliance signals the model is getting right\n",
748
+ "- `reward/injected_std > 0` in logs β€” confirms GRPO has a non-zero gradient signal"
749
+ ]
750
+ },
751
+ {
752
+ "cell_type": "code",
753
+ "id": "v4-train",
754
+ "metadata": {},
755
+ "execution_count": null,
756
+ "outputs": [],
757
+ "source": [
758
+ "trainer_stats = trainer.train()\n",
759
+ "\n",
760
+ "train_mins = round(trainer_stats.metrics.get('train_runtime', 0) / 60, 1)\n",
761
+ "used_gb = round(torch.cuda.max_memory_reserved() / 1024**3, 2)\n",
762
+ "print(f'Training time : {train_mins} min')\n",
763
+ "print(f'Peak GPU : {used_gb} GB / {TOTAL_GB} GB ({round(used_gb/TOTAL_GB*100, 1)}%)')"
764
+ ]
765
+ },
766
+ {
767
+ "cell_type": "markdown",
768
+ "id": "v4-s15",
769
+ "metadata": {},
770
+ "source": ["## 15. Save + Push to HF"]
771
+ },
772
+ {
773
+ "cell_type": "code",
774
+ "id": "v4-save",
775
+ "metadata": {},
776
+ "execution_count": null,
777
+ "outputs": [],
778
+ "source": [
779
+ "grpo_env.close()\n",
780
+ "\n",
781
+ "model.save_pretrained_merged(OUTPUT_DIR, tokenizer, save_method='merged_16bit')\n",
782
+ "model.push_to_hub_merged(HF_REPO_ID, tokenizer, save_method='merged_16bit')\n",
783
+ "print(f'Pushed -> https://huggingface.co/{HF_REPO_ID}')"
784
+ ]
785
+ },
786
+ {
787
+ "cell_type": "markdown",
788
+ "id": "v4-eval-hdr",
789
+ "metadata": {},
790
+ "source": [
791
+ "---\n",
792
+ "## Evaluation β€” Baseline vs Trained"
793
+ ]
794
+ },
795
+ {
796
+ "cell_type": "markdown",
797
+ "id": "v4-s16",
798
+ "metadata": {},
799
+ "source": ["## 16. Evaluate"]
800
+ },
801
+ {
802
+ "cell_type": "code",
803
+ "id": "v4-eval",
804
+ "metadata": {},
805
+ "execution_count": null,
806
+ "outputs": [],
807
+ "source": [
808
+ "from training.rollout import extract_json_action, step_aware_fallback\n",
809
+ "from inference import baseline_agent\n",
810
+ "\n",
811
+ "N_EVAL = 15\n",
812
+ "EVAL_MAX_STEPS = 12\n",
813
+ "EVAL_SEED_BASE = 9000\n",
814
+ "\n",
815
+ "\n",
816
+ "def run_eval(n=N_EVAL):\n",
817
+ " scores = []\n",
818
+ " model.eval()\n",
819
+ " with GenericEnvClient(base_url=ENV_URL).sync() as env:\n",
820
+ " for i in range(n):\n",
821
+ " result = env.reset(seed=EVAL_SEED_BASE + i)\n",
822
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
823
+ " task_brief = obs_dict.get('task_brief', '')\n",
824
+ " history, step, score, done = [], 0, 0.0, False\n",
825
+ "\n",
826
+ " while not done and step < EVAL_MAX_STEPS:\n",
827
+ " obs_text = _current_obs_text(obs_dict, step, task_brief)\n",
828
+ " msgs = build_messages(history, obs_text)\n",
829
+ " prompt = tokenizer.apply_chat_template(\n",
830
+ " msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False\n",
831
+ " )\n",
832
+ " inputs = tokenizer([prompt], return_tensors='pt', truncation=True, max_length=4096)\n",
833
+ " inputs = {k: v.to(model.device) for k, v in inputs.items()}\n",
834
+ " with torch.no_grad():\n",
835
+ " out_ids = model.generate(\n",
836
+ " **inputs, max_new_tokens=256, do_sample=False,\n",
837
+ " pad_token_id=tokenizer.eos_token_id,\n",
838
+ " )\n",
839
+ " completion = tokenizer.decode(\n",
840
+ " out_ids[0][inputs['input_ids'].shape[1]:], skip_special_tokens=True\n",
841
+ " )\n",
842
+ " parsed = extract_json_action(completion) or step_aware_fallback(step, EVAL_MAX_STEPS)\n",
843
+ " result = env.step({'action_type': parsed['action_type'], 'args': parsed.get('args', {})})\n",
844
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
845
+ " done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n",
846
+ " score = float(getattr(result, 'reward', obs_dict.get('reward', 0.0)))\n",
847
+ " history.append({'obs_text': obs_text, 'completion': completion, 'is_runbook': False})\n",
848
+ " step += 1\n",
849
+ "\n",
850
+ " scores.append(score)\n",
851
+ " model.train()\n",
852
+ " return scores\n",
853
+ "\n",
854
+ "\n",
855
+ "def run_baseline(n=N_EVAL):\n",
856
+ " scores = []\n",
857
+ " with GenericEnvClient(base_url=ENV_URL).sync() as env:\n",
858
+ " for i in range(n):\n",
859
+ " result = env.reset(seed=EVAL_SEED_BASE + i)\n",
860
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
861
+ " org_config, step, score, done = {}, 0, 0.0, False\n",
862
+ " while not done and step < EVAL_MAX_STEPS:\n",
863
+ " at, args = baseline_agent(obs_dict, org_config)\n",
864
+ " result = env.step({'action_type': at, 'args': args})\n",
865
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
866
+ " done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n",
867
+ " score = float(getattr(result, 'reward', obs_dict.get('reward', 0.0)))\n",
868
+ " if at == 'meta.read_runbook':\n",
869
+ " last = obs_dict.get('last_action_result') or {}\n",
870
+ " if last.get('ok'):\n",
871
+ " data = last.get('data') or {}\n",
872
+ " if isinstance(data, dict) and 'org_config' in data:\n",
873
+ " org_config.update(data['org_config'])\n",
874
+ " step += 1\n",
875
+ " scores.append(score)\n",
876
+ " return scores\n",
877
+ "\n",
878
+ "\n",
879
+ "print('--- Baseline ---')\n",
880
+ "baseline_scores = run_baseline()\n",
881
+ "print('--- Trained ---')\n",
882
+ "trained_scores = run_eval()\n",
883
+ "\n",
884
+ "b_avg = sum(baseline_scores) / N_EVAL\n",
885
+ "t_avg = sum(trained_scores) / N_EVAL\n",
886
+ "print(f'\\nBaseline avg : {b_avg:.3f}')\n",
887
+ "print(f'Trained avg : {t_avg:.3f}')\n",
888
+ "print(f'Delta : {t_avg - b_avg:+.3f}')"
889
+ ]
890
+ },
891
+ {
892
+ "cell_type": "markdown",
893
+ "id": "v4-s17",
894
+ "metadata": {},
895
+ "source": ["## 17. Teardown"]
896
+ },
897
+ {
898
+ "cell_type": "code",
899
+ "id": "v4-teardown",
900
+ "metadata": {},
901
+ "execution_count": null,
902
+ "outputs": [],
903
+ "source": [
904
+ "server_proc.terminate()\n",
905
+ "print('PM-Ops server stopped')"
906
+ ]
907
+ }
908
+ ]
909
+ }