feat(training): add A6000 single-GPU GRPO trainer with dense reward + curriculum
Browse filesAdds 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
- README.md +20 -5
- scripts/train_grpo_a6000.py +651 -0
|
@@ -195,12 +195,27 @@ The interesting baselines are:
|
|
| 195 |
|
| 196 |
## Training (Unsloth + TRL GRPO)
|
| 197 |
|
| 198 |
-
|
| 199 |
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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())
|