SavK1 Claude Sonnet 4.6 commited on
Commit
703b668
·
1 Parent(s): 14d4979

fix(training): fix GRPO reward bugs + add v3 notebook with SFT warmup

Browse files

rollout.py fixes:
- step_aware_fallback now takes max_steps param so meta.finish is
reachable within the 12/15-step training cap (was checking >= 37)
- read_runbook_done only set when model outputs valid JSON, not fallback
- diagnostic print on step=0 JSON parse failure

train_v3.ipynb (new):
- SFT warmup on 60 baseline-agent traces before GRPO to fix json=0.00
- Simplified 2-signal reward: env_score*0.85 + json_ratio*0.15
- Unsloth + use_vllm=True for fast generation (was eager+HF generate)

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

Files changed (2) hide show
  1. training/rollout.py +8 -4
  2. training/train_v3.ipynb +854 -0
training/rollout.py CHANGED
@@ -63,11 +63,11 @@ def extract_json_action(text: str) -> dict | None:
63
  return None
64
 
65
 
66
- def step_aware_fallback(step: int) -> dict:
67
  """Safe fallback that degrades gracefully across the episode."""
68
  if step <= 1:
69
  return {"action_type": "meta.read_runbook", "args": {}}
70
- elif step >= MAX_STEPS - 3:
71
  return {"action_type": "meta.finish", "args": {}}
72
  return {"action_type": "meta.noop", "args": {}}
73
 
@@ -223,14 +223,18 @@ def rollout_once(
223
  is_valid_json = parsed is not None
224
 
225
  if not is_valid_json:
226
- parsed = step_aware_fallback(step)
 
 
 
 
227
  else:
228
  valid_action_count += 1
229
 
230
  action_type: str = parsed.get("action_type", "meta.noop")
231
  args: dict = parsed.get("args", {})
232
 
233
- if action_type == "meta.read_runbook" and not read_runbook_done:
234
  read_runbook_done = True
235
 
236
  # Track chat.post_message targets for anti-hack reward
 
63
  return None
64
 
65
 
66
+ def step_aware_fallback(step: int, max_steps: int = MAX_STEPS) -> dict:
67
  """Safe fallback that degrades gracefully across the episode."""
68
  if step <= 1:
69
  return {"action_type": "meta.read_runbook", "args": {}}
70
+ elif step >= max_steps - 3:
71
  return {"action_type": "meta.finish", "args": {}}
72
  return {"action_type": "meta.noop", "args": {}}
73
 
 
223
  is_valid_json = parsed is not None
224
 
225
  if not is_valid_json:
226
+ # Log the raw output on step 0 to diagnose format failures
227
+ if step == 0 and valid_action_count == 0:
228
+ snippet = repr(completion_text[:300])
229
+ print(f"[rollout] step=0 NO JSON — raw output: {snippet}")
230
+ parsed = step_aware_fallback(step, max_steps)
231
  else:
232
  valid_action_count += 1
233
 
234
  action_type: str = parsed.get("action_type", "meta.noop")
235
  args: dict = parsed.get("args", {})
236
 
237
+ if action_type == "meta.read_runbook" and is_valid_json and not read_runbook_done:
238
  read_runbook_done = True
239
 
240
  # Track chat.post_message targets for anti-hack reward
training/train_v3.ipynb ADDED
@@ -0,0 +1,854 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.11.0"}
7
+ },
8
+ "cells": [
9
+ {
10
+ "cell_type": "markdown",
11
+ "id": "cell-0",
12
+ "metadata": {},
13
+ "source": [
14
+ "# PM-Ops GRPO Training v3 — SFT Warmup + GRPO\n",
15
+ "\n",
16
+ "## What changed from v2\n",
17
+ "| Problem in v2 | Fix in v3 |\n",
18
+ "|---|---|\n",
19
+ "| `json=0.00` — model never outputs JSON, GRPO gradient = 0 | **SFT warmup on 60 baseline traces first** |\n",
20
+ "| Reward = 0.300 every episode (no variance) | **env_score varies 0.25–1.0 after SFT** |\n",
21
+ "| Slow — no vLLM, eager attention | **Unsloth + `use_vllm=True`** |\n",
22
+ "| 5 reward signals, all from fallback | **2 signals: env_score × 0.85 + json_ratio × 0.15** |\n",
23
+ "| `meta.finish` never called in 15-step cap | **Fixed fallback respects `max_steps`** |\n",
24
+ "\n",
25
+ "## Why SFT before GRPO?\n",
26
+ "GRPO learns by comparing rewards across a *group* of generations. If all generations get the\n",
27
+ "same reward (because the model outputs garbage on every step), the advantage is 0 and weights\n",
28
+ "don't move. SFT warmup costs ~15 min and unlocks the full GRPO gradient.\n",
29
+ "\n",
30
+ "**Stack**: Unsloth + TRL 1.2.0 + OpenEnv · **GPU**: A100 → ~75 min total"
31
+ ]
32
+ },
33
+ {
34
+ "cell_type": "markdown",
35
+ "id": "cell-1-md",
36
+ "metadata": {},
37
+ "source": ["## 0. Install"]
38
+ },
39
+ {
40
+ "cell_type": "code",
41
+ "id": "cell-1",
42
+ "metadata": {},
43
+ "outputs": [],
44
+ "execution_count": null,
45
+ "source": [
46
+ "# Unsloth + vLLM first — let Unsloth resolve torch compat\n",
47
+ "!pip install -q unsloth vllm\n",
48
+ "# TRL training stack\n",
49
+ "!pip install -q \"trl==1.2.0\" accelerate datasets\n",
50
+ "# PM-Ops server runtime\n",
51
+ "!pip install -q \"openenv-core>=0.2.2\" \"fastapi>=0.110.0\" \"uvicorn[standard]>=0.29.0\" \"pydantic>=2.0.0\"\n",
52
+ "# Experiment tracking\n",
53
+ "!pip install -q trackio\n",
54
+ "print('Done - restart kernel, then run all cells from top.')"
55
+ ]
56
+ },
57
+ {
58
+ "cell_type": "markdown",
59
+ "id": "cell-2-md",
60
+ "metadata": {},
61
+ "source": ["## 1. Imports + GPU Config"]
62
+ },
63
+ {
64
+ "cell_type": "code",
65
+ "id": "cell-2",
66
+ "metadata": {},
67
+ "outputs": [],
68
+ "execution_count": null,
69
+ "source": [
70
+ "import torch\n",
71
+ "\n",
72
+ "# Patch GRPO BEFORE importing GRPOTrainer\n",
73
+ "from unsloth import FastLanguageModel, PatchFastRL\n",
74
+ "PatchFastRL('GRPO', FastLanguageModel)\n",
75
+ "\n",
76
+ "import trl\n",
77
+ "print(f'torch : {torch.__version__}')\n",
78
+ "print(f'TRL : {trl.__version__}')\n",
79
+ "\n",
80
+ "gpu = torch.cuda.get_device_properties(0)\n",
81
+ "TOTAL_GB = round(gpu.total_memory / 1024**3, 1)\n",
82
+ "IS_A100 = TOTAL_GB >= 35\n",
83
+ "print(f'GPU : {gpu.name} ({TOTAL_GB} GB)')\n",
84
+ "\n",
85
+ "# Adaptive config — T4 uses minimal settings for smoke-testing\n",
86
+ "NUM_GEN = 6 if IS_A100 else 2\n",
87
+ "GRAD_ACCUM = 32 if IS_A100 else 8\n",
88
+ "MAX_COMP_LEN = 384\n",
89
+ "N_SFT_EPISODES = 60 if IS_A100 else 15\n",
90
+ "N_GRPO_EPISODES = 150 if IS_A100 else 30\n",
91
+ "TRAIN_MAX_STEPS = 12 # triage solvable in 5; 12 gives exploration room\n",
92
+ "\n",
93
+ "print(f'num_gen={NUM_GEN} grad_accum={GRAD_ACCUM} '\n",
94
+ " f'sft_eps={N_SFT_EPISODES} grpo_eps={N_GRPO_EPISODES}')"
95
+ ]
96
+ },
97
+ {
98
+ "cell_type": "markdown",
99
+ "id": "cell-3-md",
100
+ "metadata": {},
101
+ "source": ["## 2. Clone PM-Ops Repo"]
102
+ },
103
+ {
104
+ "cell_type": "code",
105
+ "id": "cell-3",
106
+ "metadata": {},
107
+ "outputs": [],
108
+ "execution_count": null,
109
+ "source": [
110
+ "import os, sys\n",
111
+ "\n",
112
+ "REPO_URL = 'https://huggingface.co/spaces/TheCrustaceans/Pm-ops'\n",
113
+ "REPO_DIR = '/content/Pm_ops'\n",
114
+ "\n",
115
+ "if not os.path.exists(REPO_DIR):\n",
116
+ " !git clone --depth=1 -q {REPO_URL} {REPO_DIR}\n",
117
+ " print(f'Cloned -> {REPO_DIR}')\n",
118
+ "else:\n",
119
+ " !git -C {REPO_DIR} pull -q origin main\n",
120
+ " print(f'Pulled -> {REPO_DIR}')\n",
121
+ "\n",
122
+ "for p in [REPO_DIR, os.path.join(REPO_DIR, 'training')]:\n",
123
+ " if p not in sys.path:\n",
124
+ " sys.path.insert(0, p)\n",
125
+ "os.chdir(REPO_DIR)\n",
126
+ "print(f'CWD: {os.getcwd()}')"
127
+ ]
128
+ },
129
+ {
130
+ "cell_type": "markdown",
131
+ "id": "cell-4-md",
132
+ "metadata": {},
133
+ "source": ["## 3. HuggingFace Login"]
134
+ },
135
+ {
136
+ "cell_type": "code",
137
+ "id": "cell-4",
138
+ "metadata": {},
139
+ "outputs": [],
140
+ "execution_count": null,
141
+ "source": [
142
+ "from huggingface_hub import notebook_login\n",
143
+ "notebook_login()"
144
+ ]
145
+ },
146
+ {
147
+ "cell_type": "markdown",
148
+ "id": "cell-5-md",
149
+ "metadata": {},
150
+ "source": ["## 4. Start Local PM-Ops Server"]
151
+ },
152
+ {
153
+ "cell_type": "code",
154
+ "id": "cell-5",
155
+ "metadata": {},
156
+ "outputs": [],
157
+ "execution_count": null,
158
+ "source": [
159
+ "import subprocess, time, requests\n",
160
+ "\n",
161
+ "server_proc = subprocess.Popen(\n",
162
+ " [sys.executable, '-m', 'uvicorn', 'server.app:app',\n",
163
+ " '--host', '0.0.0.0', '--port', '8000'],\n",
164
+ " cwd=REPO_DIR, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,\n",
165
+ ")\n",
166
+ "ENV_URL = 'http://localhost:8000'\n",
167
+ "\n",
168
+ "for _ in range(30):\n",
169
+ " try:\n",
170
+ " if requests.get(f'{ENV_URL}/', timeout=2).status_code == 200:\n",
171
+ " print(f'PM-Ops server ready pid={server_proc.pid}')\n",
172
+ " break\n",
173
+ " except Exception:\n",
174
+ " pass\n",
175
+ " time.sleep(1)\n",
176
+ "else:\n",
177
+ " raise RuntimeError('Server did not start in 30 s')"
178
+ ]
179
+ },
180
+ {
181
+ "cell_type": "markdown",
182
+ "id": "cell-6-md",
183
+ "metadata": {},
184
+ "source": ["## 5. Verify Env"]
185
+ },
186
+ {
187
+ "cell_type": "code",
188
+ "id": "cell-6",
189
+ "metadata": {},
190
+ "outputs": [],
191
+ "execution_count": null,
192
+ "source": [
193
+ "import trl.experimental.openenv # must be importable\n",
194
+ "from openenv.core import GenericEnvClient\n",
195
+ "from training.rollout import _obs_to_dict\n",
196
+ "\n",
197
+ "with GenericEnvClient(base_url=ENV_URL).sync() as _env:\n",
198
+ " r = _env.reset()\n",
199
+ " obs = _obs_to_dict(r.observation if hasattr(r, 'observation') else r)\n",
200
+ " print(f'task_brief : {obs.get(\"task_brief\", \"?\")[:80]}...')\n",
201
+ " _env.step({'action_type': 'meta.read_runbook', 'args': {}})\n",
202
+ " print('Env step : OK')"
203
+ ]
204
+ },
205
+ {
206
+ "cell_type": "markdown",
207
+ "id": "cell-7-md",
208
+ "metadata": {},
209
+ "source": ["## 6. Load Model — Unsloth 4-bit + LoRA"]
210
+ },
211
+ {
212
+ "cell_type": "code",
213
+ "id": "cell-7",
214
+ "metadata": {},
215
+ "outputs": [],
216
+ "execution_count": null,
217
+ "source": [
218
+ "MODEL_NAME = 'Qwen/Qwen3-1.7B'\n",
219
+ "LORA_RANK = 16\n",
220
+ "\n",
221
+ "model, tokenizer = FastLanguageModel.from_pretrained(\n",
222
+ " model_name = MODEL_NAME,\n",
223
+ " max_seq_length = 4096 + MAX_COMP_LEN,\n",
224
+ " load_in_4bit = True,\n",
225
+ " fast_inference = True, # enables vLLM path for GRPO rollouts\n",
226
+ " max_lora_rank = LORA_RANK,\n",
227
+ " gpu_memory_utilization = 0.50, # leave headroom for SFT activations\n",
228
+ ")\n",
229
+ "model = FastLanguageModel.get_peft_model(\n",
230
+ " model,\n",
231
+ " r = LORA_RANK,\n",
232
+ " target_modules = ['q_proj','k_proj','v_proj','o_proj',\n",
233
+ " 'gate_proj','up_proj','down_proj'],\n",
234
+ " lora_alpha = LORA_RANK,\n",
235
+ " use_gradient_checkpointing = 'unsloth',\n",
236
+ " random_state = 42,\n",
237
+ ")\n",
238
+ "tokenizer.pad_token = tokenizer.eos_token\n",
239
+ "tokenizer.padding_side = 'left'\n",
240
+ "model.print_trainable_parameters()\n",
241
+ "reserved = round(torch.cuda.max_memory_reserved() / 1024**3, 2)\n",
242
+ "print(f'GPU after load: {reserved} GB / {TOTAL_GB} GB')"
243
+ ]
244
+ },
245
+ {
246
+ "cell_type": "markdown",
247
+ "id": "cell-8-md",
248
+ "metadata": {},
249
+ "source": [
250
+ "---\n",
251
+ "## Phase 1 — SFT Warmup\n",
252
+ "\n",
253
+ "**Goal**: teach the model the JSON output format and PM-ops workflow before GRPO.\n",
254
+ "\n",
255
+ "We run `baseline_agent` (the deterministic heuristic) for 60 episodes and record every\n",
256
+ "(observation, action) pair as a supervised example. Each episode produces ~6 steps:\n",
257
+ "`read_runbook` → `create_ticket` → `assign_ticket` → `list_channels` → `post_message` → `finish`.\n",
258
+ "\n",
259
+ "After 2 SFT epochs (~15 min), the model reliably outputs `\\`\\`\\`json ... \\`\\`\\`` blocks.\n",
260
+ "Without this, GRPO reward variance ≈ 0 and nothing is learned."
261
+ ]
262
+ },
263
+ {
264
+ "cell_type": "markdown",
265
+ "id": "cell-9-md",
266
+ "metadata": {},
267
+ "source": ["## 7. Generate SFT Demonstration Dataset"]
268
+ },
269
+ {
270
+ "cell_type": "code",
271
+ "id": "cell-9",
272
+ "metadata": {},
273
+ "outputs": [],
274
+ "execution_count": null,
275
+ "source": [
276
+ "import json as _json\n",
277
+ "from datasets import Dataset\n",
278
+ "from inference import baseline_agent\n",
279
+ "from training.rollout import _obs_to_dict, _current_obs_text, build_messages\n",
280
+ "\n",
281
+ "\n",
282
+ "def generate_sft_dataset(env_url, tok, n_episodes, seed_start=2000):\n",
283
+ " \"\"\"Run baseline_agent for each episode; record (prompt, completion) pairs.\"\"\"\n",
284
+ " examples = []\n",
285
+ " with GenericEnvClient(base_url=env_url).sync() as env:\n",
286
+ " for i in range(n_episodes):\n",
287
+ " seed = seed_start + i\n",
288
+ " result = env.reset(seed=seed)\n",
289
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
290
+ " task_brief = obs_dict.get('task_brief', '')\n",
291
+ " turn_history = []\n",
292
+ " org_config = {}\n",
293
+ " step, done = 0, False\n",
294
+ "\n",
295
+ " while not done and step < 8:\n",
296
+ " obs_text = _current_obs_text(obs_dict, step, task_brief)\n",
297
+ " action_type, args = baseline_agent(obs_dict, org_config)\n",
298
+ "\n",
299
+ " # Target completion: JSON code block (what we want the model to learn)\n",
300
+ " payload = {'action_type': action_type, 'args': args}\n",
301
+ " completion = '```json\\n' + _json.dumps(payload) + '\\n```'\n",
302
+ "\n",
303
+ " msgs = build_messages(turn_history, obs_text)\n",
304
+ " prompt = tok.apply_chat_template(\n",
305
+ " msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False\n",
306
+ " )\n",
307
+ " # Full SFT text = prompt + target completion + eos\n",
308
+ " examples.append({'text': prompt + completion + tok.eos_token})\n",
309
+ "\n",
310
+ " turn_history.append({\n",
311
+ " 'obs_text' : obs_text,\n",
312
+ " 'completion': completion,\n",
313
+ " 'is_runbook': (action_type == 'meta.read_runbook'),\n",
314
+ " })\n",
315
+ "\n",
316
+ " result = env.step({'action_type': action_type, 'args': args})\n",
317
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
318
+ "\n",
319
+ " # Sync org_config from runbook response\n",
320
+ " if action_type == 'meta.read_runbook':\n",
321
+ " last = obs_dict.get('last_action_result') or {}\n",
322
+ " if last.get('ok'):\n",
323
+ " data = last.get('data') or {}\n",
324
+ " if isinstance(data, dict) and 'org_config' in data:\n",
325
+ " org_config.update(data['org_config'])\n",
326
+ "\n",
327
+ " done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n",
328
+ " step += 1\n",
329
+ "\n",
330
+ " if (i + 1) % 10 == 0:\n",
331
+ " print(f' {i+1}/{n_episodes} episodes — {len(examples)} examples')\n",
332
+ "\n",
333
+ " return examples\n",
334
+ "\n",
335
+ "\n",
336
+ "print(f'Generating {N_SFT_EPISODES} SFT demonstration episodes...')\n",
337
+ "sft_raw = generate_sft_dataset(ENV_URL, tokenizer, n_episodes=N_SFT_EPISODES)\n",
338
+ "sft_dataset = Dataset.from_list(sft_raw)\n",
339
+ "print(f'\\nSFT dataset : {len(sft_dataset)} examples (~{len(sft_dataset)//6} eps x 6 steps)')\n",
340
+ "print(f'Sample (first 300 chars):\\n{sft_raw[0][\"text\"][:300]}')"
341
+ ]
342
+ },
343
+ {
344
+ "cell_type": "markdown",
345
+ "id": "cell-10-md",
346
+ "metadata": {},
347
+ "source": ["## 8. SFT Training (~15 min on A100)"]
348
+ },
349
+ {
350
+ "cell_type": "code",
351
+ "id": "cell-10",
352
+ "metadata": {},
353
+ "outputs": [],
354
+ "execution_count": null,
355
+ "source": [
356
+ "from trl import SFTTrainer, SFTConfig\n",
357
+ "\n",
358
+ "sft_cfg = SFTConfig(\n",
359
+ " dataset_text_field = 'text',\n",
360
+ " max_seq_length = 2048,\n",
361
+ " num_train_epochs = 2,\n",
362
+ " per_device_train_batch_size = 4,\n",
363
+ " gradient_accumulation_steps = 4,\n",
364
+ " learning_rate = 2e-4,\n",
365
+ " warmup_steps = 10,\n",
366
+ " output_dir = 'pm-ops-sft-warmup',\n",
367
+ " report_to = 'none',\n",
368
+ " logging_steps = 5,\n",
369
+ " save_strategy = 'no',\n",
370
+ " dataloader_num_workers = 0,\n",
371
+ ")\n",
372
+ "\n",
373
+ "sft_steps = (\n",
374
+ " len(sft_dataset)\n",
375
+ " // (sft_cfg.per_device_train_batch_size * sft_cfg.gradient_accumulation_steps)\n",
376
+ " * sft_cfg.num_train_epochs\n",
377
+ ")\n",
378
+ "print(f'SFT: {len(sft_dataset)} examples x {sft_cfg.num_train_epochs} epochs -> ~{sft_steps} steps')\n",
379
+ "\n",
380
+ "sft_trainer = SFTTrainer(\n",
381
+ " model=model, tokenizer=tokenizer,\n",
382
+ " train_dataset=sft_dataset, args=sft_cfg,\n",
383
+ ")\n",
384
+ "sft_stats = sft_trainer.train()\n",
385
+ "\n",
386
+ "runtime = sft_stats.metrics.get('train_runtime', 0)\n",
387
+ "loss = sft_stats.metrics.get('train_loss', 0)\n",
388
+ "print(f'SFT done: {round(runtime/60, 1)} min, loss={loss:.3f}')"
389
+ ]
390
+ },
391
+ {
392
+ "cell_type": "markdown",
393
+ "id": "cell-11-md",
394
+ "metadata": {},
395
+ "source": ["## 9. Verify SFT Output — Model Must Output Valid JSON"]
396
+ },
397
+ {
398
+ "cell_type": "code",
399
+ "id": "cell-11",
400
+ "metadata": {},
401
+ "outputs": [],
402
+ "execution_count": null,
403
+ "source": [
404
+ "from training.rollout import extract_json_action, _obs_to_dict, _current_obs_text, build_messages\n",
405
+ "\n",
406
+ "model.eval()\n",
407
+ "with GenericEnvClient(base_url=ENV_URL).sync() as _env:\n",
408
+ " r = _env.reset(seed=99001)\n",
409
+ " obs_dict = _obs_to_dict(r.observation if hasattr(r, 'observation') else r)\n",
410
+ " obs_text = _current_obs_text(obs_dict, 0, obs_dict.get('task_brief', ''))\n",
411
+ " msgs = build_messages([], obs_text)\n",
412
+ " prompt = tokenizer.apply_chat_template(\n",
413
+ " msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False\n",
414
+ " )\n",
415
+ "\n",
416
+ "inputs = tokenizer([prompt], return_tensors='pt').to(model.device)\n",
417
+ "with torch.no_grad():\n",
418
+ " out = model.generate(\n",
419
+ " **inputs, max_new_tokens=128, do_sample=False,\n",
420
+ " pad_token_id=tokenizer.eos_token_id\n",
421
+ " )\n",
422
+ "completion = tokenizer.decode(out[0][inputs['input_ids'].shape[1]:], skip_special_tokens=True)\n",
423
+ "parsed = extract_json_action(completion)\n",
424
+ "\n",
425
+ "print(f'Output : {completion[:400]}')\n",
426
+ "print(f'Parsed : {parsed}')\n",
427
+ "\n",
428
+ "if parsed is not None:\n",
429
+ " print('PASS: model outputs valid JSON after SFT')\n",
430
+ "else:\n",
431
+ " print('FAIL: still no valid JSON — run SFT cell again with more epochs or more data')\n",
432
+ "\n",
433
+ "model.train()"
434
+ ]
435
+ },
436
+ {
437
+ "cell_type": "markdown",
438
+ "id": "cell-12-md",
439
+ "metadata": {},
440
+ "source": [
441
+ "---\n",
442
+ "## Phase 2 — GRPO\n",
443
+ "\n",
444
+ "Now that the model outputs valid JSON, GRPO can optimize for *correctness*.\n",
445
+ "\n",
446
+ "**Reward** (2 components, sum = 1.0):\n",
447
+ "\n",
448
+ "| Component | Weight | Signal |\n",
449
+ "|---|---|---|\n",
450
+ "| `env_score` | 0.85 | Env grader: 0.25 (ticket) + 0.20 (label) + 0.20 (priority) + 0.20 (team) + 0.15 (channel) |\n",
451
+ "| `json_ratio` | 0.15 | Fraction of steps with parseable JSON — maintains format quality |\n",
452
+ "\n",
453
+ "`env_score` naturally varies 0.25–1.0 per episode (the model may get the ticket right\n",
454
+ "but pick the wrong label, or get the channel wrong). This is the learning signal.\n",
455
+ "Anti-hacking: org_config values differ every episode (seeded), so the model cannot memorize answers."
456
+ ]
457
+ },
458
+ {
459
+ "cell_type": "markdown",
460
+ "id": "cell-13-md",
461
+ "metadata": {},
462
+ "source": ["## 10. Generate GRPO Training Dataset"]
463
+ },
464
+ {
465
+ "cell_type": "code",
466
+ "id": "cell-13",
467
+ "metadata": {},
468
+ "outputs": [],
469
+ "execution_count": null,
470
+ "source": [
471
+ "from training.dataset import generate_triage_dataset\n",
472
+ "\n",
473
+ "rows = generate_triage_dataset(n_episodes=N_GRPO_EPISODES, base_seed=42)\n",
474
+ "grpo_dataset = Dataset.from_list([{'prompt': r['prompt']} for r in rows])\n",
475
+ "print(f'GRPO dataset: {len(grpo_dataset)} triage episodes')\n",
476
+ "print(f'Difficulties: {set(r[\"difficulty\"] for r in rows)}')"
477
+ ]
478
+ },
479
+ {
480
+ "cell_type": "markdown",
481
+ "id": "cell-14-md",
482
+ "metadata": {},
483
+ "source": ["## 11. GRPO Rollout + Reward Functions"]
484
+ },
485
+ {
486
+ "cell_type": "code",
487
+ "id": "cell-14",
488
+ "metadata": {},
489
+ "outputs": [],
490
+ "execution_count": null,
491
+ "source": [
492
+ "from trl.experimental.openenv import generate_rollout_completions\n",
493
+ "from training.rollout import (\n",
494
+ " _obs_to_dict, _current_obs_text, build_messages,\n",
495
+ " extract_json_action, step_aware_fallback,\n",
496
+ ")\n",
497
+ "from training.dataset import parse_seed_from_prompt\n",
498
+ "\n",
499
+ "grpo_env = GenericEnvClient(base_url=ENV_URL).sync()\n",
500
+ "grpo_env.connect()\n",
501
+ "print('GRPO training env connected')\n",
502
+ "\n",
503
+ "\n",
504
+ "def run_grpo_episode(trainer, env, tok, dataset_prompt, max_steps=TRAIN_MAX_STEPS):\n",
505
+ " \"\"\"Run one full PM-ops episode and return flat trajectory + reward.\"\"\"\n",
506
+ " seed = parse_seed_from_prompt(dataset_prompt)\n",
507
+ " result = env.reset(seed=seed) if seed is not None else env.reset()\n",
508
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
509
+ " task_brief = obs_dict.get('task_brief') or dataset_prompt\n",
510
+ "\n",
511
+ " prompt_ids, completion_ids, logprobs = [], [], []\n",
512
+ " turn_history = []\n",
513
+ " valid_json_count = 0\n",
514
+ " env_score = 0.0\n",
515
+ " step, done = 0, False\n",
516
+ " _sample_logged = False\n",
517
+ "\n",
518
+ " while not done and step < max_steps:\n",
519
+ " obs_text = _current_obs_text(obs_dict, step, task_brief)\n",
520
+ " msgs = build_messages(turn_history, obs_text)\n",
521
+ " prompt_text = tok.apply_chat_template(\n",
522
+ " msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False\n",
523
+ " )\n",
524
+ "\n",
525
+ " rollout_out = generate_rollout_completions(trainer, [prompt_text])[0]\n",
526
+ " prompt_ids.extend(rollout_out['prompt_ids'])\n",
527
+ " completion_ids.extend(rollout_out['completion_ids'])\n",
528
+ " logprobs.extend(rollout_out['logprobs'])\n",
529
+ "\n",
530
+ " completion_text = rollout_out.get('text') or tok.decode(\n",
531
+ " rollout_out['completion_ids'], skip_special_tokens=True\n",
532
+ " )\n",
533
+ "\n",
534
+ " # Log one sample per episode so we can visually track format quality\n",
535
+ " if not _sample_logged:\n",
536
+ " print(f' [sample] {repr(completion_text[:180])}')\n",
537
+ " _sample_logged = True\n",
538
+ "\n",
539
+ " parsed = extract_json_action(completion_text)\n",
540
+ " if parsed is not None:\n",
541
+ " valid_json_count += 1\n",
542
+ " else:\n",
543
+ " parsed = step_aware_fallback(step, max_steps)\n",
544
+ "\n",
545
+ " action_type = parsed.get('action_type', 'meta.noop')\n",
546
+ " args = parsed.get('args', {})\n",
547
+ "\n",
548
+ " turn_history.append({\n",
549
+ " 'obs_text' : obs_text,\n",
550
+ " 'completion': completion_text,\n",
551
+ " 'is_runbook': (action_type == 'meta.read_runbook' and parsed is not None),\n",
552
+ " })\n",
553
+ "\n",
554
+ " result = env.step({'action_type': action_type, 'args': args})\n",
555
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
556
+ " done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n",
557
+ " env_score = float(getattr(result, 'reward', obs_dict.get('reward', 0.0)))\n",
558
+ " step += 1\n",
559
+ "\n",
560
+ " json_ratio = valid_json_count / max(step, 1)\n",
561
+ " reward = env_score * 0.85 + json_ratio * 0.15\n",
562
+ " print(f' [rollout] steps={step} env={env_score:.3f} json={json_ratio:.2f} -> reward={reward:.3f}')\n",
563
+ " return {\n",
564
+ " 'prompt_ids' : prompt_ids,\n",
565
+ " 'completion_ids': completion_ids,\n",
566
+ " 'logprobs' : logprobs,\n",
567
+ " 'reward' : reward,\n",
568
+ " }\n",
569
+ "\n",
570
+ "\n",
571
+ "def grpo_rollout_func(prompts, trainer=None):\n",
572
+ " out = {'prompt_ids': [], 'completion_ids': [], 'logprobs': [], 'reward': []}\n",
573
+ " for prompt in prompts:\n",
574
+ " ep = run_grpo_episode(trainer, grpo_env, tokenizer, prompt, TRAIN_MAX_STEPS)\n",
575
+ " for k in out:\n",
576
+ " out[k].append(ep[k])\n",
577
+ " return out\n",
578
+ "\n",
579
+ "\n",
580
+ "def grpo_reward_func(completions, **kwargs):\n",
581
+ " \"\"\"Passthrough — reward is pre-computed in grpo_rollout_func.\"\"\"\n",
582
+ " rewards = kwargs.get('reward', [])\n",
583
+ " if not rewards:\n",
584
+ " print(f'[ERROR] reward_func: no reward key. kwargs keys: {list(kwargs.keys())}')\n",
585
+ " return [0.0] * len(completions)\n",
586
+ " return [float(r) for r in rewards]\n",
587
+ "\n",
588
+ "\n",
589
+ "print(f'GRPO rollout ready max_steps={TRAIN_MAX_STEPS}')"
590
+ ]
591
+ },
592
+ {
593
+ "cell_type": "markdown",
594
+ "id": "cell-15-md",
595
+ "metadata": {},
596
+ "source": ["## 12. GRPO Config + Trainer"]
597
+ },
598
+ {
599
+ "cell_type": "code",
600
+ "id": "cell-15",
601
+ "metadata": {},
602
+ "outputs": [],
603
+ "execution_count": null,
604
+ "source": [
605
+ "from trl import GRPOConfig, GRPOTrainer\n",
606
+ "\n",
607
+ "OUTPUT_DIR = 'pm-ops-grpo-Qwen3-1.7B-triage-v3'\n",
608
+ "HF_REPO_ID = f'Saurav1/{OUTPUT_DIR}'\n",
609
+ "\n",
610
+ "grpo_cfg = GRPOConfig(\n",
611
+ " # Training\n",
612
+ " num_train_epochs = 2,\n",
613
+ " learning_rate = 1e-6, # lower LR: model has SFT init, don't overwrite it\n",
614
+ " gradient_accumulation_steps = GRAD_ACCUM,\n",
615
+ " per_device_train_batch_size = 1,\n",
616
+ " warmup_steps = 5,\n",
617
+ " num_generations = NUM_GEN,\n",
618
+ " # Sequence lengths\n",
619
+ " max_completion_length = MAX_COMP_LEN,\n",
620
+ " max_prompt_length = 4096,\n",
621
+ " # Unsloth vLLM for fast generation\n",
622
+ " use_vllm = True,\n",
623
+ " # Output\n",
624
+ " output_dir = OUTPUT_DIR,\n",
625
+ " report_to = 'trackio',\n",
626
+ " trackio_space_id = OUTPUT_DIR,\n",
627
+ " logging_steps = 1,\n",
628
+ " save_steps = 20,\n",
629
+ " gradient_checkpointing = False, # Unsloth handles this\n",
630
+ ")\n",
631
+ "\n",
632
+ "eff_batch = grpo_cfg.per_device_train_batch_size * GRAD_ACCUM\n",
633
+ "total_steps = len(grpo_dataset) * NUM_GEN * grpo_cfg.num_train_epochs // eff_batch\n",
634
+ "print(f'GRPO: {len(grpo_dataset)} eps x {NUM_GEN} gen x {grpo_cfg.num_train_epochs} epochs -> ~{total_steps} steps')\n",
635
+ "\n",
636
+ "trainer = GRPOTrainer(\n",
637
+ " model = model,\n",
638
+ " processing_class = tokenizer,\n",
639
+ " reward_funcs = grpo_reward_func,\n",
640
+ " train_dataset = grpo_dataset,\n",
641
+ " args = grpo_cfg,\n",
642
+ " rollout_func = grpo_rollout_func,\n",
643
+ ")\n",
644
+ "print('GRPOTrainer ready')"
645
+ ]
646
+ },
647
+ {
648
+ "cell_type": "markdown",
649
+ "id": "cell-16-md",
650
+ "metadata": {},
651
+ "source": [
652
+ "## 13. Train\n",
653
+ "\n",
654
+ "Watch stdout for:\n",
655
+ "- `[sample] '```json...'` — should look like valid JSON code blocks\n",
656
+ "- `[rollout] env=X.XXX` — **should trend upward over steps** (this is the signal)\n",
657
+ "- `[rollout] json=X.XX` — should stay > 0.7 (SFT maintains format quality)\n",
658
+ "\n",
659
+ "Watch trackio for the reward curve."
660
+ ]
661
+ },
662
+ {
663
+ "cell_type": "code",
664
+ "id": "cell-16",
665
+ "metadata": {},
666
+ "outputs": [],
667
+ "execution_count": null,
668
+ "source": [
669
+ "trainer_stats = trainer.train()\n",
670
+ "\n",
671
+ "used_gb = round(torch.cuda.max_memory_reserved() / 1024**3, 2)\n",
672
+ "train_mins = round(trainer_stats.metrics.get('train_runtime', 0) / 60, 1)\n",
673
+ "print(f'Training time : {train_mins} min')\n",
674
+ "print(f'Peak GPU : {used_gb} GB / {TOTAL_GB} GB ({round(used_gb/TOTAL_GB*100, 1)}%)')\n",
675
+ "final_reward = trainer_stats.metrics.get('train/reward', trainer_stats.metrics.get('train_loss', '?'))\n",
676
+ "print(f'Final reward : {final_reward}')"
677
+ ]
678
+ },
679
+ {
680
+ "cell_type": "markdown",
681
+ "id": "cell-17-md",
682
+ "metadata": {},
683
+ "source": ["## 14. Save Model"]
684
+ },
685
+ {
686
+ "cell_type": "code",
687
+ "id": "cell-17",
688
+ "metadata": {},
689
+ "outputs": [],
690
+ "execution_count": null,
691
+ "source": [
692
+ "grpo_env.close()\n",
693
+ "\n",
694
+ "# Unsloth merged save — dequantises first, then merges LoRA cleanly into bf16.\n",
695
+ "# Do NOT use trainer.save_model() directly on a 4-bit + LoRA model.\n",
696
+ "model.save_pretrained_merged(OUTPUT_DIR, tokenizer, save_method='merged_16bit')\n",
697
+ "model.push_to_hub_merged(HF_REPO_ID, tokenizer, save_method='merged_16bit')\n",
698
+ "print(f'Pushed -> https://huggingface.co/{HF_REPO_ID}')"
699
+ ]
700
+ },
701
+ {
702
+ "cell_type": "markdown",
703
+ "id": "cell-18-md",
704
+ "metadata": {},
705
+ "source": ["## 15. Evaluate: Baseline vs Trained"]
706
+ },
707
+ {
708
+ "cell_type": "code",
709
+ "id": "cell-18",
710
+ "metadata": {},
711
+ "outputs": [],
712
+ "execution_count": null,
713
+ "source": [
714
+ "from transformers import AutoModelForCausalLM\n",
715
+ "\n",
716
+ "N_EVAL = 15\n",
717
+ "EVAL_MAX_STEPS = 12\n",
718
+ "EVAL_SEED_BASE = 9000\n",
719
+ "\n",
720
+ "eval_model = AutoModelForCausalLM.from_pretrained(\n",
721
+ " OUTPUT_DIR, torch_dtype=torch.bfloat16, device_map='auto'\n",
722
+ ")\n",
723
+ "eval_model.eval()\n",
724
+ "\n",
725
+ "\n",
726
+ "def eval_trained(n=N_EVAL):\n",
727
+ " scores = []\n",
728
+ " with GenericEnvClient(base_url=ENV_URL).sync() as env:\n",
729
+ " for i in range(n):\n",
730
+ " result = env.reset(seed=EVAL_SEED_BASE + i)\n",
731
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
732
+ " task_brief = obs_dict.get('task_brief', '')\n",
733
+ " history, step, score, done = [], 0, 0.0, False\n",
734
+ "\n",
735
+ " while not done and step < EVAL_MAX_STEPS:\n",
736
+ " obs_text = _current_obs_text(obs_dict, step, task_brief)\n",
737
+ " msgs = build_messages(history, obs_text)\n",
738
+ " prompt = tokenizer.apply_chat_template(\n",
739
+ " msgs, add_generation_prompt=True, tokenize=False, enable_thinking=False\n",
740
+ " )\n",
741
+ " inputs = tokenizer([prompt], return_tensors='pt', truncation=True, max_length=4096)\n",
742
+ " inputs = {k: v.to(eval_model.device) for k, v in inputs.items()}\n",
743
+ " with torch.no_grad():\n",
744
+ " out_ids = eval_model.generate(\n",
745
+ " **inputs, max_new_tokens=256, do_sample=False,\n",
746
+ " pad_token_id=tokenizer.eos_token_id\n",
747
+ " )\n",
748
+ " completion = tokenizer.decode(\n",
749
+ " out_ids[0][inputs['input_ids'].shape[1]:], skip_special_tokens=True\n",
750
+ " )\n",
751
+ " parsed = extract_json_action(completion) or step_aware_fallback(step, EVAL_MAX_STEPS)\n",
752
+ " result = env.step({'action_type': parsed['action_type'], 'args': parsed.get('args', {})})\n",
753
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
754
+ " done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n",
755
+ " score = float(getattr(result, 'reward', obs_dict.get('reward', 0.0)))\n",
756
+ " history.append({'obs_text': obs_text, 'completion': completion, 'is_runbook': False})\n",
757
+ " step += 1\n",
758
+ "\n",
759
+ " scores.append(score)\n",
760
+ " print(f' Trained ep {i+1}/{n}: score={score:.3f}')\n",
761
+ " return scores\n",
762
+ "\n",
763
+ "\n",
764
+ "def eval_baseline(n=N_EVAL):\n",
765
+ " from inference import baseline_agent\n",
766
+ " scores = []\n",
767
+ " with GenericEnvClient(base_url=ENV_URL).sync() as env:\n",
768
+ " for i in range(n):\n",
769
+ " result = env.reset(seed=EVAL_SEED_BASE + i)\n",
770
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
771
+ " org_config, step, score, done = {}, 0, 0.0, False\n",
772
+ " while not done and step < EVAL_MAX_STEPS:\n",
773
+ " at, args = baseline_agent(obs_dict, org_config)\n",
774
+ " result = env.step({'action_type': at, 'args': args})\n",
775
+ " obs_dict = _obs_to_dict(result.observation if hasattr(result, 'observation') else result)\n",
776
+ " done = bool(getattr(result, 'done', obs_dict.get('done', False)))\n",
777
+ " score = float(getattr(result, 'reward', obs_dict.get('reward', 0.0)))\n",
778
+ " step += 1\n",
779
+ " scores.append(score)\n",
780
+ " print(f' Baseline ep {i+1}/{n}: score={score:.3f}')\n",
781
+ " return scores\n",
782
+ "\n",
783
+ "\n",
784
+ "print('--- Baseline ---')\n",
785
+ "baseline_scores = eval_baseline()\n",
786
+ "print('\\n--- Trained ---')\n",
787
+ "trained_scores = eval_trained()\n",
788
+ "\n",
789
+ "print(f'\\nBaseline avg : {sum(baseline_scores)/N_EVAL:.3f}')\n",
790
+ "print(f'Trained avg : {sum(trained_scores)/N_EVAL:.3f}')\n",
791
+ "print(f'Delta : {(sum(trained_scores)-sum(baseline_scores))/N_EVAL:+.3f}')"
792
+ ]
793
+ },
794
+ {
795
+ "cell_type": "markdown",
796
+ "id": "cell-19-md",
797
+ "metadata": {},
798
+ "source": ["## 16. Plot Results"]
799
+ },
800
+ {
801
+ "cell_type": "code",
802
+ "id": "cell-19",
803
+ "metadata": {},
804
+ "outputs": [],
805
+ "execution_count": null,
806
+ "source": [
807
+ "import matplotlib.pyplot as plt\n",
808
+ "import numpy as np\n",
809
+ "\n",
810
+ "fig, axes = plt.subplots(1, 2, figsize=(12, 4))\n",
811
+ "\n",
812
+ "ax, x, w = axes[0], np.arange(N_EVAL), 0.35\n",
813
+ "ax.bar(x - w/2, baseline_scores, w, label='Baseline', color='steelblue', alpha=0.8)\n",
814
+ "ax.bar(x + w/2, trained_scores, w, label='GRPO v3', color='coral', alpha=0.8)\n",
815
+ "ax.axhline(sum(baseline_scores)/N_EVAL, color='steelblue', ls='--', alpha=0.5, lw=1.5)\n",
816
+ "ax.axhline(sum(trained_scores)/N_EVAL, color='coral', ls='--', alpha=0.5, lw=1.5)\n",
817
+ "ax.set(xlabel='Eval episode', ylabel='Reward (0-1)',\n",
818
+ " title='Per-episode reward: Baseline vs GRPO v3', xticks=x, ylim=(0, 1.05))\n",
819
+ "ax.legend()\n",
820
+ "\n",
821
+ "ax2 = axes[1]\n",
822
+ "avgs = [sum(baseline_scores)/N_EVAL, sum(trained_scores)/N_EVAL]\n",
823
+ "bars = ax2.bar(['Baseline', 'GRPO v3'], avgs, color=['steelblue', 'coral'], alpha=0.85, width=0.5)\n",
824
+ "for bar, val in zip(bars, avgs):\n",
825
+ " ax2.text(bar.get_x() + bar.get_width()/2, val + 0.01, f'{val:.3f}',\n",
826
+ " ha='center', fontsize=13, fontweight='bold')\n",
827
+ "ax2.set(ylabel='Average reward (0-1)', title=f'Average over {N_EVAL} triage episodes',\n",
828
+ " ylim=(0, 1.05))\n",
829
+ "\n",
830
+ "plt.tight_layout()\n",
831
+ "plt.savefig('eval_results_v3.png', dpi=150, bbox_inches='tight')\n",
832
+ "plt.show()\n",
833
+ "print('Saved: eval_results_v3.png')"
834
+ ]
835
+ },
836
+ {
837
+ "cell_type": "markdown",
838
+ "id": "cell-20-md",
839
+ "metadata": {},
840
+ "source": ["## 17. Teardown"]
841
+ },
842
+ {
843
+ "cell_type": "code",
844
+ "id": "cell-20",
845
+ "metadata": {},
846
+ "outputs": [],
847
+ "execution_count": null,
848
+ "source": [
849
+ "server_proc.terminate()\n",
850
+ "print('Local PM-Ops server stopped')"
851
+ ]
852
+ }
853
+ ]
854
+ }