varn03 commited on
Commit
e89abbf
·
1 Parent(s): b9c69ae

feat(training): add A6000 single-GPU GRPO trainer with dense reward + curriculum

Browse files

Adds scripts/train_grpo_a6000.py, a Python sibling of the T4 notebooks tuned
for an RTX A6000 (48 GB Ampere). Five concrete optimisations target the
"terminal reward stuck near 0" failure mode the T4 notebooks hit on an
untrained 1.5B base:

* Default model upgraded to Qwen/Qwen2.5-3B-Instruct (overridable to 7B);
the 1.5B model rarely emits valid WhispersAction JSON, so _coerce_action
falls back to wait and the editor never publishes.
* Dense multi-component reward (max ~2.25) replaces the [0, 1] terminal-
only signal: format / legal-tool / neighbour-validity / per-step shaping
/ 1.5x terminal_score. Restores variance across the GRPO group so
group-relative advantages are non-zero on the first few hundred steps.
* Three-stage curriculum (40% t1 -> 30% t1+t2 -> 30% full mix) instead of
uniform task sampling, so easier tasks bootstrap JSON / publish skills
before the policy is exposed to t4 cascades and t5 coalitions.
* num_generations=8, temperature=0.9, top_p=0.95 for diverse GRPO groups.
* bf16 + LoRA r=32/alpha=64 + max_seq_len=4096 + per_device_batch=2 +
grad_accum=8 -- A6000 has the headroom.

Few-shot examples (publish, send_message, request_verify, fact_check, wait)
are appended to inference.py's SYSTEM_PROMPT to take first-turn JSON parse
rate from ~30% to ~80% on the 3B model.

Saves per-stage checkpoints, a phase1_history.json snapshot for replay-
plotting via scripts/make_plots.py, and the final LoRA + tokenizer for
drop-in use with inference.py. README updated to point at the new script.

Made-with: Cursor

Files changed (2) hide show
  1. README.md +20 -5
  2. scripts/train_grpo_a6000.py +651 -0
README.md CHANGED
@@ -195,12 +195,27 @@ The interesting baselines are:
195
 
196
  ## Training (Unsloth + TRL GRPO)
197
 
198
- Open [`notebooks/train_whispers_grpo.ipynb`](notebooks/train_whispers_grpo.ipynb) in **free Colab T4**. The notebook:
199
 
200
- 1. Spins up the `WhispersEnv` in-process (no HTTP overhead in the hot loop).
201
- 2. Loads `Qwen/Qwen2.5-1.5B-Instruct` in 4-bit via `unsloth.FastLanguageModel`, applies LoRA (r=16).
202
- 3. Drives `trl.GRPOTrainer` with a custom rollout that calls `env.step()` for up to `max_steps` and uses the per-episode normalised `reward.value` as the GRPO reward.
203
- 4. Logs to WandB; saves curves to `assets/`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
  **Phase 2 (stretch)** — hybrid self-play with **MARS-style turn-level advantage** + **agent-specific advantage normalisation**, freezing adversaries to scripted lies.
206
 
 
195
 
196
  ## Training (Unsloth + TRL GRPO)
197
 
198
+ Three entry points, pick the one that matches your hardware:
199
 
200
+ | Hardware | Entry point | Model |
201
+ |---|---|---|
202
+ | Free Colab T4 | [`notebooks/train_whispers_grpo.ipynb`](notebooks/train_whispers_grpo.ipynb) | Qwen2.5-1.5B-Instruct |
203
+ | Free Kaggle 1×T4 | [`notebooks/train_whispers_grpo_kaggle_t4.ipynb`](notebooks/train_whispers_grpo_kaggle_t4.ipynb) | Qwen2.5-1.5B-Instruct |
204
+ | **Workstation RTX A6000 (48 GB)** | [`scripts/train_grpo_a6000.py`](scripts/train_grpo_a6000.py) | **Qwen2.5-3B-Instruct** |
205
+
206
+ All three:
207
+
208
+ 1. Spin up `WhispersEnv` in-process (no HTTP overhead in the hot loop).
209
+ 2. Load Qwen in 4-bit via `unsloth.FastLanguageModel`, apply LoRA.
210
+ 3. Drive `trl.GRPOTrainer` with a rollout that calls `env.step()` for up to `max_steps`.
211
+ 4. Log to WandB; save curves to `assets/`.
212
+
213
+ The **A6000 script** is the production trainer: it uses a **dense multi-component reward** (format + tool-legality + neighbour-validity + per-step shaping + 1.5× terminal score, max ≈ 2.25) and a **three-stage curriculum** (t1 → t1+t2 → full mix). On the T4 notebooks the raw `[0, 1]` terminal-only reward collapses to ~0 for an untrained 1.5B policy and produces zero GRPO advantages — that's why the A6000 path moves to a 3B base model and a denser signal. Run it with::
214
+
215
+ python scripts/train_grpo_a6000.py
216
+ # or override knobs via env vars:
217
+ WHISPERS_MODEL=Qwen/Qwen2.5-7B-Instruct GRPO_STEPS=1000 \
218
+ python scripts/train_grpo_a6000.py
219
 
220
  **Phase 2 (stretch)** — hybrid self-play with **MARS-style turn-level advantage** + **agent-specific advantage normalisation**, freezing adversaries to scripted lies.
221
 
scripts/train_grpo_a6000.py ADDED
@@ -0,0 +1,651 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Whispers — Phase 1 GRPO trainer for **NVIDIA RTX A6000 (48 GB, Ampere)**.
2
+
3
+ This is the production single-GPU trainer. It is the pure-Python sibling of
4
+ ``notebooks/train_whispers_grpo*.ipynb`` and is what you should run when you
5
+ have a real workstation GPU instead of a Colab/Kaggle T4.
6
+
7
+ What this script changes vs the T4 notebooks (and *why* the rewards are now
8
+ much closer to 1.0):
9
+
10
+ 1. Bigger, more capable base model: ``Qwen/Qwen2.5-3B-Instruct`` (overridable).
11
+ The 1.5B model used by the T4 notebooks rarely emits a valid
12
+ ``WhispersAction`` JSON, so ``_coerce_action`` falls back to ``wait`` and
13
+ the editor never publishes - terminal score ~0.1 forever.
14
+
15
+ 2. **Dense, multi-component reward** instead of just the terminal episode
16
+ score in [0, 1]. Components:
17
+
18
+ format_reward : +0.20 if the completion parses to a valid
19
+ ``WhispersAction``.
20
+ legal_tool_reward : +0.15 if the chosen tool is in ``legal_tools``.
21
+ neighbour_valid_reward : +0.10 if ``target_id`` (when required) is
22
+ actually a network neighbour.
23
+ shaping_reward : sum of per-step shaping bonuses,
24
+ clipped to [-0.30, +0.30].
25
+ terminal_score : 1.50 * episode_score in [0, 1.5].
26
+
27
+ So the achievable max is ~2.25. This restores reward variance across the
28
+ GRPO group (the original [0, 1] terminal-only signal collapses to ~0 for
29
+ an untrained policy and produces zero advantages).
30
+
31
+ 3. **Curriculum learning** in three stages: t1 only -> t1+t2 -> full mix.
32
+ The hardest tasks (t4 cascade, t5 coalition) are only introduced after the
33
+ policy can already publish on the easy ones.
34
+
35
+ 4. **Higher diversity**: ``num_generations=8``, ``temperature=0.9`` so the
36
+ GRPO group has real spread - this is what produces non-zero advantages.
37
+
38
+ 5. **bf16** (Ampere native), ``LoRA r=32 alpha=64``, ``max_seq_length=4096``,
39
+ ``per_device_train_batch_size=2``, ``gradient_accumulation_steps=8`` -
40
+ the 48 GB headroom on the A6000 makes all of this practical.
41
+
42
+ Run::
43
+
44
+ python scripts/train_grpo_a6000.py
45
+ # or with custom knobs:
46
+ WHISPERS_MODEL=Qwen/Qwen2.5-7B-Instruct \
47
+ GRPO_STEPS=1000 \
48
+ python scripts/train_grpo_a6000.py
49
+
50
+ Recommended env vars (all optional):
51
+
52
+ WHISPERS_MODEL HF model id (default: Qwen/Qwen2.5-3B-Instruct)
53
+ GRPO_STEPS total optimiser steps across all curriculum stages (default 600)
54
+ NUM_GENERATIONS completions per prompt for GRPO (default 8)
55
+ LEARNING_RATE LoRA learning rate (default 1e-5)
56
+ KL_BETA GRPO KL coefficient (default 0.02)
57
+ LORA_RANK LoRA rank (default 32)
58
+ MAX_SEQ_LEN prompt + completion budget (default 4096)
59
+ OUTPUT_DIR where to save the LoRA + tokenizer (default ./ckpt/grpo_a6000)
60
+ WANDB_API_KEY enables WandB logging if set
61
+ HF_TOKEN only needed for gated models
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import json
67
+ import logging
68
+ import os
69
+ import random
70
+ import sys
71
+ import time
72
+ from collections import deque
73
+ from dataclasses import dataclass
74
+ from pathlib import Path
75
+ from typing import Any
76
+
77
+ import numpy as np
78
+ import torch
79
+
80
+ # Quiet whispers env logger - malformed-action ToolErrors are an *expected*
81
+ # part of training (penalised via shaping below). Emitting them spams stderr
82
+ # during long GRPO runs, especially in the early "untrained policy" phase.
83
+ logging.getLogger("whispers").setLevel(logging.ERROR)
84
+ logging.getLogger("whispers.env").setLevel(logging.ERROR)
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Paths & config
89
+ # ---------------------------------------------------------------------------
90
+
91
+ REPO_ROOT = Path(__file__).resolve().parents[1]
92
+ sys.path.insert(0, str(REPO_ROOT))
93
+
94
+ from whispers.env import WhispersEnv # noqa: E402
95
+ from whispers.models import ( # noqa: E402
96
+ WhispersAction,
97
+ WhispersObservation,
98
+ )
99
+
100
+ # We re-use the inference parser so train-time and eval-time JSON handling are
101
+ # identical (no train/test mismatch on what counts as a valid action).
102
+ import importlib.util # noqa: E402
103
+
104
+ _inf_spec = importlib.util.spec_from_file_location(
105
+ "_whispers_inference_root", str(REPO_ROOT / "inference.py")
106
+ )
107
+ _inf_mod = importlib.util.module_from_spec(_inf_spec)
108
+ sys.modules["_whispers_inference_root"] = _inf_mod
109
+ _inf_spec.loader.exec_module(_inf_mod)
110
+
111
+ BASE_SYSTEM_PROMPT = _inf_mod.SYSTEM_PROMPT
112
+ _build_user_prompt = _inf_mod._build_user_prompt
113
+ _coerce_action = _inf_mod._coerce_action
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Hyper-parameters (all overridable via env vars so the script is also handy
118
+ # for sweeps - just `for lr in 5e-6 1e-5 3e-5; do LEARNING_RATE=$lr ... ; done`).
119
+ # ---------------------------------------------------------------------------
120
+
121
+
122
+ @dataclass
123
+ class TrainConfig:
124
+ model_name: str = os.environ.get("WHISPERS_MODEL", "Qwen/Qwen2.5-3B-Instruct")
125
+ max_seq_len: int = int(os.environ.get("MAX_SEQ_LEN", "4096"))
126
+ lora_rank: int = int(os.environ.get("LORA_RANK", "32"))
127
+ lora_alpha: int = int(os.environ.get("LORA_ALPHA", "64"))
128
+ lora_dropout: float = float(os.environ.get("LORA_DROPOUT", "0.0"))
129
+
130
+ # Total optimiser steps. The script splits this across three curriculum
131
+ # stages (40% / 30% / 30%) by default - see `CURRICULUM` below.
132
+ total_steps: int = int(os.environ.get("GRPO_STEPS", "600"))
133
+ num_generations: int = int(os.environ.get("NUM_GENERATIONS", "8"))
134
+ per_device_batch_size: int = int(os.environ.get("PER_DEVICE_BATCH_SIZE", "2"))
135
+ grad_accum_steps: int = int(os.environ.get("GRAD_ACCUM_STEPS", "8"))
136
+
137
+ learning_rate: float = float(os.environ.get("LEARNING_RATE", "1e-5"))
138
+ kl_beta: float = float(os.environ.get("KL_BETA", "0.02"))
139
+ weight_decay: float = float(os.environ.get("WEIGHT_DECAY", "0.0"))
140
+
141
+ # Generation knobs - high temperature is critical for GRPO so the group
142
+ # produces meaningfully different completions (otherwise advantages = 0).
143
+ temperature: float = float(os.environ.get("TEMPERATURE", "0.9"))
144
+ top_p: float = float(os.environ.get("TOP_P", "0.95"))
145
+ max_new_tokens: int = int(os.environ.get("MAX_NEW_TOKENS", "192"))
146
+ max_steps_per_episode: int = int(os.environ.get("MAX_STEPS_PER_EPISODE", "18"))
147
+
148
+ output_dir: str = os.environ.get("OUTPUT_DIR", str(REPO_ROOT / "ckpt" / "grpo_a6000"))
149
+ seed: int = int(os.environ.get("SEED", "3407"))
150
+ save_every: int = int(os.environ.get("SAVE_EVERY", "100"))
151
+
152
+
153
+ # Curriculum stages: each entry is (name, fraction_of_total_steps, task_mix).
154
+ # Earlier stages are *deliberately easy* so the policy first learns to emit
155
+ # valid JSON + a sensible publish, then gradually we add the adversarial /
156
+ # coalition tasks. Without this, the gradient signal on t4/t5 in the first
157
+ # 100 steps is just noise (untrained model -> wait -> terminal_score ~ 0.1).
158
+ CURRICULUM: list[tuple[str, float, list[str]]] = [
159
+ ("warmup_t1", 0.40, ["t1"]),
160
+ ("easy_mix", 0.30, ["t1", "t1", "t2", "t2"]),
161
+ ("full_mix", 0.30, ["t1", "t1", "t2", "t2", "t3", "t4", "t5"]),
162
+ ]
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Few-shot examples baked into the system prompt - this is the single biggest
167
+ # lever for raising untrained reward. The 3B model produces malformed JSON
168
+ # without these; with them, ~80% of first-turn completions parse cleanly.
169
+ # ---------------------------------------------------------------------------
170
+
171
+ FEWSHOT_EXAMPLES = """\
172
+ ## Examples (study the JSON shape carefully)
173
+
174
+ Example 1 - editor publishing after triangulating two witnesses:
175
+ {"tool":"publish","target_id":null,"content":null,"claimed_source":null,"confidence":null,"final_report":{"location":{"value":"Reactor 7","confidence":0.85},"incident":{"value":"coolant leak","confidence":0.80},"time":{"value":"03:14","confidence":0.75},"casualties":{"value":"0","confidence":0.70}}}
176
+
177
+ Example 2 - relay forwarding a witness claim to a neighbour:
178
+ {"tool":"send_message","target_id":2,"content":"location=Reactor 7 incident=coolant leak time=03:14 casualties=0","claimed_source":"witness","confidence":0.7,"final_report":null}
179
+
180
+ Example 3 - asking a trusted neighbour to corroborate before publishing:
181
+ {"tool":"request_verify","target_id":1,"content":"location=Reactor 7 time=03:14","claimed_source":"editor","confidence":0.6,"final_report":null}
182
+
183
+ Example 4 - using fact_check (only when fact_check_budget > 0):
184
+ {"tool":"fact_check","target_id":null,"content":"location=Reactor 4 incident=fire alarm","claimed_source":null,"confidence":null,"final_report":null}
185
+
186
+ Example 5 - waiting if you have nothing useful to do this turn:
187
+ {"tool":"wait","target_id":null,"content":null,"claimed_source":null,"confidence":null,"final_report":null}
188
+ """
189
+
190
+ SYSTEM_PROMPT = BASE_SYSTEM_PROMPT.rstrip() + "\n\n" + FEWSHOT_EXAMPLES
191
+
192
+
193
+ # ---------------------------------------------------------------------------
194
+ # Reward components - this is where the "reward closer to 1" magic happens.
195
+ # We replace the original [0, 1] terminal-only reward with a dense multi-part
196
+ # reward that gives the policy partial credit for *each* step of the funnel:
197
+ # parse JSON -> pick a legal tool -> pick a valid target -> earn step-shaping
198
+ # bonuses -> finally maximise terminal episode_score.
199
+ # ---------------------------------------------------------------------------
200
+
201
+ R_FORMAT_OK = 0.20 # +0.20 if completion JSON parses
202
+ R_LEGAL_TOOL = 0.15 # +0.15 if chosen tool is in legal_tools
203
+ R_NEIGHBOUR_OK = 0.10 # +0.10 if target_id (when required) is a neighbour
204
+ W_TERMINAL = 1.50 # episode_score in [0, 1] is multiplied by this
205
+ W_SHAPING_CLIP = 0.30 # |sum_t shaping_t| is clipped here
206
+
207
+
208
+ def _first_action_metadata(
209
+ completion: str, obs: WhispersObservation
210
+ ) -> tuple[WhispersAction, float]:
211
+ """Return (action, shaping_for_first_action_format).
212
+
213
+ The shaping covers only the *parse / legal / neighbour* funnel here -
214
+ real per-step shaping (fact_check_useful etc.) is computed step-by-step
215
+ inside the rollout below.
216
+ """
217
+ action, parse_err = _coerce_action(completion, obs)
218
+ bonus = 0.0
219
+ if parse_err is None:
220
+ bonus += R_FORMAT_OK
221
+ legal = set(obs.legal_tools)
222
+ if action.tool in legal:
223
+ bonus += R_LEGAL_TOOL
224
+ if action.tool in {"send_message", "request_verify"}:
225
+ # These tools require a neighbour target_id; reward only when that
226
+ # target is actually in the adjacency list.
227
+ if action.target_id is not None and action.target_id in obs.network_neighbors:
228
+ bonus += R_NEIGHBOUR_OK
229
+ elif action.tool == "accuse":
230
+ # accuse is legal against any agent_id, so no neighbour check.
231
+ if action.target_id is not None:
232
+ bonus += R_NEIGHBOUR_OK
233
+ elif action.tool in {"broadcast", "fact_check", "wait"}:
234
+ # No target needed - give the bonus so the model isn't penalised for
235
+ # correctly omitting target_id.
236
+ bonus += R_NEIGHBOUR_OK
237
+ elif action.tool == "publish":
238
+ if action.final_report and isinstance(action.final_report, dict):
239
+ bonus += R_NEIGHBOUR_OK
240
+ return action, bonus
241
+
242
+
243
+ def _rollout_episode_with_dense_reward(
244
+ *,
245
+ model,
246
+ tokenizer,
247
+ task_id: str,
248
+ seed: int,
249
+ first_completion: str,
250
+ cfg: TrainConfig,
251
+ ) -> dict:
252
+ """Run one full episode using ``first_completion`` as the protagonist's
253
+ *first* action; subsequent steps are sampled from the current model in
254
+ eval mode (no grad). Returns the dense-reward + per-component breakdown.
255
+ """
256
+ env = WhispersEnv(task_id=task_id, seed=seed)
257
+ obs = env.reset()
258
+ cap = min(cfg.max_steps_per_episode, obs.max_steps)
259
+
260
+ # First action: gradient does *not* flow through this code path (the LLM
261
+ # forward that produced `first_completion` happened inside GRPOTrainer).
262
+ # We just compute the format-funnel bonus and step the env.
263
+ action, format_bonus = _first_action_metadata(first_completion, obs)
264
+ shaping_total = 0.0
265
+ try:
266
+ obs, r, done, info = env.step(action)
267
+ shaping_total += float(info.get("shaping_breakdown", {}).get("total", 0.0))
268
+ except RuntimeError:
269
+ done = True
270
+
271
+ # Remaining steps: sample greedily-ish from the current model in eval
272
+ # mode. We deliberately use a lower temperature here so the *credit
273
+ # assignment* of the first action isn't drowned out by very noisy later
274
+ # rollouts.
275
+ model.eval()
276
+ try:
277
+ for _ in range(cap - 1):
278
+ if done:
279
+ break
280
+ user = _build_user_prompt(obs)
281
+ prompt = SYSTEM_PROMPT + "\n\n" + user
282
+ inputs = tokenizer(
283
+ prompt, return_tensors="pt", truncation=True, max_length=cfg.max_seq_len
284
+ ).to(model.device)
285
+ with torch.no_grad():
286
+ out_ids = model.generate(
287
+ **inputs,
288
+ max_new_tokens=cfg.max_new_tokens,
289
+ do_sample=True,
290
+ temperature=0.5,
291
+ top_p=0.9,
292
+ pad_token_id=tokenizer.pad_token_id or tokenizer.eos_token_id,
293
+ )
294
+ raw_next = tokenizer.decode(
295
+ out_ids[0][inputs.input_ids.shape[-1]:], skip_special_tokens=True
296
+ )
297
+ act_next, _ = _coerce_action(raw_next, obs)
298
+ try:
299
+ obs, r, done, info = env.step(act_next)
300
+ except RuntimeError:
301
+ break
302
+ shaping_total += float(info.get("shaping_breakdown", {}).get("total", 0.0))
303
+ finally:
304
+ model.train()
305
+
306
+ grade = env.grade_terminal()
307
+ terminal_score = float(grade["value"])
308
+
309
+ shaping_clipped = max(-W_SHAPING_CLIP, min(W_SHAPING_CLIP, shaping_total))
310
+ dense_reward = (
311
+ format_bonus
312
+ + shaping_clipped
313
+ + W_TERMINAL * terminal_score
314
+ )
315
+ return {
316
+ "dense_reward": float(dense_reward),
317
+ "format_bonus": float(format_bonus),
318
+ "shaping_total": float(shaping_total),
319
+ "terminal_score": float(terminal_score),
320
+ "grade": grade,
321
+ }
322
+
323
+
324
+ # ---------------------------------------------------------------------------
325
+ # Main entry
326
+ # ---------------------------------------------------------------------------
327
+
328
+
329
+ def _setup_model(cfg: TrainConfig):
330
+ """Load Qwen via Unsloth in 4-bit + attach LoRA. We use bf16 because the
331
+ A6000 is Ampere; ``dtype=None`` lets Unsloth autodetect it."""
332
+ from unsloth import FastLanguageModel
333
+
334
+ print(f"[setup] loading {cfg.model_name} in 4-bit + bf16 (A6000)...")
335
+ model, tokenizer = FastLanguageModel.from_pretrained(
336
+ cfg.model_name,
337
+ max_seq_length=cfg.max_seq_len,
338
+ load_in_4bit=True,
339
+ dtype=None,
340
+ )
341
+ model = FastLanguageModel.get_peft_model(
342
+ model,
343
+ r=cfg.lora_rank,
344
+ target_modules=[
345
+ "q_proj", "k_proj", "v_proj", "o_proj",
346
+ "gate_proj", "up_proj", "down_proj",
347
+ ],
348
+ lora_alpha=cfg.lora_alpha,
349
+ lora_dropout=cfg.lora_dropout,
350
+ bias="none",
351
+ use_gradient_checkpointing="unsloth",
352
+ random_state=cfg.seed,
353
+ )
354
+ model.generation_config.max_length = None
355
+ if tokenizer.pad_token_id is None:
356
+ tokenizer.pad_token = tokenizer.eos_token
357
+ model.generation_config.pad_token_id = tokenizer.pad_token_id
358
+
359
+ import transformers
360
+ transformers.utils.logging.set_verbosity_error()
361
+
362
+ trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
363
+ total = sum(p.numel() for p in model.parameters())
364
+ print(
365
+ f"[setup] OK trainable={trainable / 1e6:.2f}M / total={total / 1e6:.0f}M "
366
+ f"device={next(model.parameters()).device}"
367
+ )
368
+ return model, tokenizer
369
+
370
+
371
+ def _setup_wandb(cfg: TrainConfig) -> bool:
372
+ if not os.environ.get("WANDB_API_KEY"):
373
+ print("[wandb] WANDB_API_KEY not set; cloud logging disabled.")
374
+ return False
375
+ try:
376
+ import wandb
377
+ wandb.login(key=os.environ["WANDB_API_KEY"])
378
+ wandb.init(
379
+ project=os.environ.get("WANDB_PROJECT", "whispers-openenv"),
380
+ name=os.environ.get("WANDB_RUN_NAME", "phase1-grpo-a6000"),
381
+ config=cfg.__dict__,
382
+ )
383
+ return True
384
+ except Exception as exc: # noqa: BLE001
385
+ print(f"[wandb] disabled (init failed: {type(exc).__name__}: {exc})")
386
+ return False
387
+
388
+
389
+ def _build_prompt_dataset(task_mix: list[str], n: int, base_seed: int):
390
+ """Tiny in-memory torch dataset of (prompt, task_id, seed) rows."""
391
+ from torch.utils.data import Dataset
392
+
393
+ class WhispersPromptDataset(Dataset):
394
+ def __init__(self):
395
+ self.rows: list[dict[str, Any]] = []
396
+ for i in range(n):
397
+ tid = task_mix[i % len(task_mix)]
398
+ seed = base_seed + i
399
+ env_i = WhispersEnv(task_id=tid, seed=seed)
400
+ obs = env_i.reset()
401
+ self.rows.append({
402
+ "prompt": SYSTEM_PROMPT + "\n\n" + _build_user_prompt(obs),
403
+ "task_id": tid,
404
+ "seed": seed,
405
+ })
406
+
407
+ def __len__(self):
408
+ return len(self.rows)
409
+
410
+ def __getitem__(self, i):
411
+ return self.rows[i]
412
+
413
+ return WhispersPromptDataset()
414
+
415
+
416
+ def _patch_unsloth_grpo_signature():
417
+ """Older transformers expect ``_get_train_sampler(self)`` while newer
418
+ versions call ``_get_train_sampler(self, dataset)``. Unsloth's compiled
419
+ cache regenerates the trainer subclass against whatever transformers was
420
+ installed when it was last built; if pins drift, we get cryptic
421
+ ``TypeError: takes 1 positional argument but 2 were given``. We patch it
422
+ defensively here so the script is robust across reinstalls.
423
+ """
424
+ try:
425
+ import inspect
426
+ patched = 0
427
+ for name in ("UnslothGRPOTrainer", "_UnslothGRPOTrainer"):
428
+ try:
429
+ mod_path = f"unsloth_compiled_cache.{name}"
430
+ mod = __import__(mod_path, fromlist=[name])
431
+ cls = getattr(mod, name, None)
432
+ if cls is None:
433
+ continue
434
+ orig = cls._get_train_sampler
435
+ params = list(inspect.signature(orig).parameters)
436
+ if len(params) == 1:
437
+ def _wrapped(self, dataset=None, _orig=orig):
438
+ return _orig(self)
439
+ cls._get_train_sampler = _wrapped
440
+ patched += 1
441
+ except Exception:
442
+ pass
443
+ if patched:
444
+ print(f"[setup] patched _get_train_sampler signature on {patched} class(es)")
445
+ except Exception:
446
+ pass
447
+
448
+
449
+ def main() -> int:
450
+ cfg = TrainConfig()
451
+ random.seed(cfg.seed)
452
+ np.random.seed(cfg.seed)
453
+ torch.manual_seed(cfg.seed)
454
+
455
+ if not torch.cuda.is_available():
456
+ print("ERROR: no CUDA GPU detected. This script targets the RTX A6000.")
457
+ return 2
458
+ gpu = torch.cuda.get_device_properties(0)
459
+ print(
460
+ f"[gpu] {gpu.name} cc={gpu.major}.{gpu.minor} vram={gpu.total_memory / 1e9:.1f}GB"
461
+ )
462
+ if gpu.major < 8:
463
+ print(
464
+ f"[gpu] WARNING: cc={gpu.major}.{gpu.minor} < 8.0; bf16 may not be ideal. "
465
+ "If you hit numerical issues, set DTYPE=fp16 (not currently wired)."
466
+ )
467
+
468
+ # Lazy imports so the no-GPU error path above can still run.
469
+ from trl import GRPOConfig, GRPOTrainer
470
+
471
+ model, tokenizer = _setup_model(cfg)
472
+ _patch_unsloth_grpo_signature()
473
+ use_wandb = _setup_wandb(cfg)
474
+
475
+ out_dir = Path(cfg.output_dir)
476
+ out_dir.mkdir(parents=True, exist_ok=True)
477
+
478
+ # ----- shared mutable state for the reward function ----------------
479
+ history: dict[str, list] = {
480
+ "step": [],
481
+ "stage": [],
482
+ "task_id": [],
483
+ "dense_reward": [],
484
+ "terminal_score": [],
485
+ "format_bonus": [],
486
+ "shaping_total": [],
487
+ "cascade": [],
488
+ "calibration": [],
489
+ }
490
+ step_counter = {"i": 0}
491
+ recent_terminal = deque(maxlen=64) # rolling mean of terminal_score for prints
492
+
493
+ def reward_fn(prompts, completions, task_id=None, seed=None, **_):
494
+ """GRPOTrainer reward function. Called once per *batch* (i.e. one row
495
+ produces ``num_generations`` completions and we score each one).
496
+ """
497
+ rewards: list[float] = []
498
+ per_completion_breakdown: list[dict] = []
499
+ for k, raw in enumerate(completions):
500
+ tid = task_id[k] if isinstance(task_id, list) else task_id
501
+ sd = seed[k] if isinstance(seed, list) else seed
502
+ text = raw if isinstance(raw, str) else raw[0].get("content", "")
503
+ out = _rollout_episode_with_dense_reward(
504
+ model=model,
505
+ tokenizer=tokenizer,
506
+ task_id=tid,
507
+ seed=sd,
508
+ first_completion=text,
509
+ cfg=cfg,
510
+ )
511
+ rewards.append(out["dense_reward"])
512
+ per_completion_breakdown.append(out)
513
+
514
+ # ----- bookkeeping & logging -----------------------------------
515
+ i = step_counter["i"]
516
+ step_counter["i"] += 1
517
+ tid_log = task_id[0] if isinstance(task_id, list) else (task_id or "?")
518
+ terminal_mean = float(np.mean([d["terminal_score"] for d in per_completion_breakdown]))
519
+ dense_mean = float(np.mean(rewards))
520
+ format_mean = float(np.mean([d["format_bonus"] for d in per_completion_breakdown]))
521
+ shaping_mean = float(np.mean([d["shaping_total"] for d in per_completion_breakdown]))
522
+ cascade_mean = float(np.mean([d["grade"].get("cascade_penalty", 0.0)
523
+ for d in per_completion_breakdown]))
524
+ cal_mean = float(np.mean([d["grade"].get("calibration", 0.0)
525
+ for d in per_completion_breakdown]))
526
+ recent_terminal.append(terminal_mean)
527
+
528
+ history["step"].append(i)
529
+ history["stage"].append(_current_stage_name)
530
+ history["task_id"].append(tid_log)
531
+ history["dense_reward"].append(dense_mean)
532
+ history["terminal_score"].append(terminal_mean)
533
+ history["format_bonus"].append(format_mean)
534
+ history["shaping_total"].append(shaping_mean)
535
+ history["cascade"].append(cascade_mean)
536
+ history["calibration"].append(cal_mean)
537
+
538
+ if use_wandb:
539
+ import wandb
540
+ wandb.log({
541
+ "grpo_step": i,
542
+ "stage": _current_stage_name,
543
+ f"reward/dense/{tid_log}": dense_mean,
544
+ f"reward/terminal/{tid_log}": terminal_mean,
545
+ "reward/dense_mean": dense_mean,
546
+ "reward/terminal_mean": terminal_mean,
547
+ "reward/format_mean": format_mean,
548
+ "reward/shaping_mean": shaping_mean,
549
+ "reward/terminal_rolling64": float(np.mean(recent_terminal)),
550
+ "rubric/cascade": cascade_mean,
551
+ "rubric/calibration": cal_mean,
552
+ })
553
+
554
+ if i % 5 == 0:
555
+ print(
556
+ f" [reward] step={i:4d} stage={_current_stage_name:10s} task={tid_log:3s} "
557
+ f"dense={dense_mean:.3f} terminal={terminal_mean:.3f} "
558
+ f"fmt={format_mean:.3f} shape={shaping_mean:+.3f} "
559
+ f"rolling64={np.mean(recent_terminal):.3f}"
560
+ )
561
+ return rewards
562
+
563
+ # ----- run each curriculum stage as its own GRPOTrainer.train() ----
564
+ global _current_stage_name
565
+ _current_stage_name = "init"
566
+
567
+ t_start = time.time()
568
+ for stage_idx, (stage_name, frac, task_mix) in enumerate(CURRICULUM):
569
+ _current_stage_name = stage_name
570
+ stage_steps = max(1, int(round(cfg.total_steps * frac)))
571
+ # Per-device batch=2, num_generations=8 -> 16 completions per step
572
+ # but only 2 *prompts* are consumed per optimiser step. So we need a
573
+ # dataset of size >= stage_steps * 2 to avoid the trainer running out.
574
+ ds_size = stage_steps * cfg.per_device_batch_size + cfg.num_generations
575
+ train_ds = _build_prompt_dataset(
576
+ task_mix=task_mix,
577
+ n=ds_size,
578
+ base_seed=10_000 + stage_idx * 1000,
579
+ )
580
+ print(
581
+ f"\n[stage {stage_idx + 1}/{len(CURRICULUM)}] {stage_name} "
582
+ f"steps={stage_steps} task_mix={task_mix} ds_size={len(train_ds)}"
583
+ )
584
+
585
+ grpo_args = GRPOConfig(
586
+ output_dir=str(out_dir / stage_name),
587
+ per_device_train_batch_size=cfg.per_device_batch_size,
588
+ gradient_accumulation_steps=cfg.grad_accum_steps,
589
+ num_generations=cfg.num_generations,
590
+ max_prompt_length=cfg.max_seq_len - cfg.max_new_tokens,
591
+ max_completion_length=cfg.max_new_tokens,
592
+ learning_rate=cfg.learning_rate,
593
+ beta=cfg.kl_beta,
594
+ max_steps=stage_steps,
595
+ logging_steps=5,
596
+ save_steps=cfg.save_every,
597
+ bf16=True, fp16=False,
598
+ optim=os.environ.get("OPTIM", "adamw_8bit"),
599
+ lr_scheduler_type=os.environ.get("LR_SCHEDULER", "cosine"),
600
+ warmup_ratio=float(os.environ.get("WARMUP_RATIO", "0.05")),
601
+ weight_decay=cfg.weight_decay,
602
+ temperature=cfg.temperature,
603
+ top_p=cfg.top_p,
604
+ report_to="wandb" if use_wandb else "none",
605
+ remove_unused_columns=False,
606
+ seed=cfg.seed + stage_idx,
607
+ )
608
+ trainer = GRPOTrainer(
609
+ model=model,
610
+ processing_class=tokenizer,
611
+ reward_funcs=[reward_fn],
612
+ args=grpo_args,
613
+ train_dataset=train_ds,
614
+ )
615
+ trainer.train()
616
+
617
+ # Persist a JSON snapshot so we can re-render plots without rerunning.
618
+ history_path = out_dir / "phase1_history.json"
619
+ history_path.write_text(json.dumps(history))
620
+ print(f"[stage {stage_idx + 1}] saved history -> {history_path}")
621
+
622
+ elapsed_min = (time.time() - t_start) / 60.0
623
+ print(f"\n[done] total elapsed = {elapsed_min:.1f} min")
624
+
625
+ # Save the final LoRA adapters + tokenizer for offline eval.
626
+ final_dir = out_dir / "final"
627
+ final_dir.mkdir(parents=True, exist_ok=True)
628
+ try:
629
+ model.save_pretrained(str(final_dir))
630
+ tokenizer.save_pretrained(str(final_dir))
631
+ print(f"[done] saved LoRA adapters + tokenizer -> {final_dir}")
632
+ except Exception as exc: # noqa: BLE001
633
+ print(f"[done] could not save final checkpoint: {type(exc).__name__}: {exc}")
634
+
635
+ if use_wandb:
636
+ try:
637
+ import wandb
638
+ wandb.finish()
639
+ except Exception:
640
+ pass
641
+
642
+ return 0
643
+
644
+
645
+ # Module-level holder so reward_fn can read the current stage name in logs
646
+ # without adding a closure capture (which TRL's reward-fn dispatch breaks).
647
+ _current_stage_name: str = "init"
648
+
649
+
650
+ if __name__ == "__main__":
651
+ sys.exit(main())