Siddh12334 commited on
Commit
67601e4
Β·
1 Parent(s): 7a8a0f0

feat: bulletproof GRPO training script + Colab notebook

Browse files
training/ContextCorruption_GRPO.ipynb ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# ContextCorruption-Env β€” GRPO Training\n",
8
+ "> **OpenEnv Hackathon | Meta Γ— HuggingFace Γ— PyTorch**\n",
9
+ "\n",
10
+ "Fine-tunes **Qwen2-1.5B-Instruct** with GRPO to identify corrupted documents and answer questions correctly.\n",
11
+ "\n",
12
+ "**Reward signal (fully deterministic, no LLM judge):**\n",
13
+ "| Component | Weight |\n",
14
+ "|---|---|\n",
15
+ "| Answer correctness (exact match after normalisation) | +0.40 |\n",
16
+ "| Corruption detection recall | +0.30 |\n",
17
+ "| False-positive penalty | +0.20 |\n",
18
+ "| Confidence calibration | Β±0.10 |\n",
19
+ "| Efficiency bonus | +0.05 |\n",
20
+ "\n",
21
+ "**Random baseline:** avg reward β‰ˆ 0.13 β€” beat this to show improvement.\n",
22
+ "\n",
23
+ "---\n",
24
+ "⚠️ Requires **GPU runtime** (A100 recommended). Go to `Runtime β†’ Change runtime type β†’ GPU`."
25
+ ]
26
+ },
27
+ {
28
+ "cell_type": "markdown",
29
+ "metadata": {},
30
+ "source": [
31
+ "## 1. Install dependencies"
32
+ ]
33
+ },
34
+ {
35
+ "cell_type": "code",
36
+ "execution_count": null,
37
+ "metadata": {},
38
+ "outputs": [],
39
+ "source": [
40
+ "%%capture\n",
41
+ "!pip install openenv-core==0.2.3 unsloth trl transformers datasets wandb faker python-dotenv"
42
+ ]
43
+ },
44
+ {
45
+ "cell_type": "markdown",
46
+ "metadata": {},
47
+ "source": [
48
+ "## 2. Clone repo and generate facts"
49
+ ]
50
+ },
51
+ {
52
+ "cell_type": "code",
53
+ "execution_count": null,
54
+ "metadata": {},
55
+ "outputs": [],
56
+ "source": [
57
+ "import os\n",
58
+ "\n",
59
+ "REPO_URL = \"https://github.com/sas-dev5/context-corruption-env.git\"\n",
60
+ "\n",
61
+ "!git clone {REPO_URL}\n",
62
+ "%cd context-corruption-env\n",
63
+ "\n",
64
+ "# Generate facts.json (pulls NQ + PopQA)\n",
65
+ "!python -m data.loader"
66
+ ]
67
+ },
68
+ {
69
+ "cell_type": "markdown",
70
+ "metadata": {},
71
+ "source": [
72
+ "## 3. Authenticate WandB and HuggingFace"
73
+ ]
74
+ },
75
+ {
76
+ "cell_type": "code",
77
+ "execution_count": null,
78
+ "metadata": {},
79
+ "outputs": [],
80
+ "source": [
81
+ "import wandb\n",
82
+ "from huggingface_hub import login\n",
83
+ "\n",
84
+ "# Paste your keys here or set as Colab secrets\n",
85
+ "WANDB_API_KEY = os.getenv(\"WANDB_API_KEY\", \"\")\n",
86
+ "HF_TOKEN = os.getenv(\"HF_TOKEN\", \"\")\n",
87
+ "HF_HUB_MODEL_ID = \"\" # e.g. \"your-username/qwen-1.5b-context-corruption\" β€” leave blank to skip\n",
88
+ "\n",
89
+ "if WANDB_API_KEY:\n",
90
+ " wandb.login(key=WANDB_API_KEY)\n",
91
+ "else:\n",
92
+ " wandb.login() # interactive prompt\n",
93
+ "\n",
94
+ "if HF_TOKEN:\n",
95
+ " login(token=HF_TOKEN)\n",
96
+ "\n",
97
+ "os.environ[\"HF_HUB_MODEL_ID\"] = HF_HUB_MODEL_ID"
98
+ ]
99
+ },
100
+ {
101
+ "cell_type": "markdown",
102
+ "metadata": {},
103
+ "source": [
104
+ "## 4. Verify environment (smoke test)"
105
+ ]
106
+ },
107
+ {
108
+ "cell_type": "code",
109
+ "execution_count": null,
110
+ "metadata": {},
111
+ "outputs": [],
112
+ "source": [
113
+ "from environment.env import ContextCorruptionEnv\n",
114
+ "from environment.actions import ContextCorruptionAction, ActionType\n",
115
+ "\n",
116
+ "env = ContextCorruptionEnv(difficulty=2)\n",
117
+ "obs = env.reset()\n",
118
+ "assert len(obs.documents) == 8\n",
119
+ "obs = env.step(ContextCorruptionAction(action_type=ActionType.submit_answer, answer=\"test\", confidence=0.5))\n",
120
+ "assert obs.done and obs.reward is not None\n",
121
+ "print(f\"βœ… Smoke test passed | reward: {obs.reward:.4f}\")\n",
122
+ "print(f\" Question: {env.state.question}\")"
123
+ ]
124
+ },
125
+ {
126
+ "cell_type": "markdown",
127
+ "metadata": {},
128
+ "source": [
129
+ "## 5. Preview training dataset"
130
+ ]
131
+ },
132
+ {
133
+ "cell_type": "code",
134
+ "execution_count": null,
135
+ "metadata": {},
136
+ "outputs": [],
137
+ "source": [
138
+ "import sys\n",
139
+ "sys.path.insert(0, \".\")\n",
140
+ "from training.train_grpo import build_dataset, SYSTEM_PROMPT\n",
141
+ "\n",
142
+ "sample_ds = build_dataset(n_episodes=5, seed=0)\n",
143
+ "sample = sample_ds[0]\n",
144
+ "print(\"System:\", sample[\"messages\"][0][\"content\"][:200], \"...\")\n",
145
+ "print(\"\\nUser message (first 400 chars):\", sample[\"messages\"][1][\"content\"][:400], \"...\")\n",
146
+ "print(\"\\nGround truth:\", sample[\"ground_truth\"])\n",
147
+ "print(\"Corrupt doc IDs:\", sample[\"corrupt_ids\"])"
148
+ ]
149
+ },
150
+ {
151
+ "cell_type": "markdown",
152
+ "metadata": {},
153
+ "source": [
154
+ "## 6. Run GRPO training\n",
155
+ "\n",
156
+ "Expected time on A100: ~45–60 min for 3 epochs over 500 episodes."
157
+ ]
158
+ },
159
+ {
160
+ "cell_type": "code",
161
+ "execution_count": null,
162
+ "metadata": {},
163
+ "outputs": [],
164
+ "source": [
165
+ "from training.train_grpo import main\n",
166
+ "main()"
167
+ ]
168
+ },
169
+ {
170
+ "cell_type": "markdown",
171
+ "metadata": {},
172
+ "source": [
173
+ "## 7. View training curves"
174
+ ]
175
+ },
176
+ {
177
+ "cell_type": "code",
178
+ "execution_count": null,
179
+ "metadata": {},
180
+ "outputs": [],
181
+ "source": [
182
+ "from IPython.display import Image, display\n",
183
+ "\n",
184
+ "display(Image(\"assets/reward_curve.png\"))\n",
185
+ "display(Image(\"assets/loss_curve.png\"))"
186
+ ]
187
+ },
188
+ {
189
+ "cell_type": "markdown",
190
+ "metadata": {},
191
+ "source": [
192
+ "## 8. Evaluate trained model vs baseline"
193
+ ]
194
+ },
195
+ {
196
+ "cell_type": "code",
197
+ "execution_count": null,
198
+ "metadata": {},
199
+ "outputs": [],
200
+ "source": [
201
+ "import json, torch, re\n",
202
+ "from unsloth import FastLanguageModel\n",
203
+ "from training.train_grpo import (\n",
204
+ " MODEL_NAME, MAX_SEQ_LENGTH, OUTPUT_DIR,\n",
205
+ " build_dataset, SYSTEM_PROMPT, _parse_completion\n",
206
+ ")\n",
207
+ "from environment.reward import compute_reward\n",
208
+ "\n",
209
+ "model, tokenizer = FastLanguageModel.from_pretrained(\n",
210
+ " model_name=f\"{OUTPUT_DIR}-final\",\n",
211
+ " max_seq_length=MAX_SEQ_LENGTH,\n",
212
+ " load_in_4bit=True,\n",
213
+ ")\n",
214
+ "FastLanguageModel.for_inference(model)\n",
215
+ "\n",
216
+ "eval_ds = build_dataset(n_episodes=50, seed=999)\n",
217
+ "rewards = []\n",
218
+ "\n",
219
+ "for row in eval_ds:\n",
220
+ " prompt = tokenizer.apply_chat_template(\n",
221
+ " row[\"messages\"], tokenize=False, add_generation_prompt=True\n",
222
+ " )\n",
223
+ " inputs = tokenizer(prompt, return_tensors=\"pt\").to(\"cuda\")\n",
224
+ " with torch.no_grad():\n",
225
+ " out = model.generate(**inputs, max_new_tokens=256, temperature=0.1, do_sample=True)\n",
226
+ " completion = tokenizer.decode(out[0][inputs[\"input_ids\"].shape[1]:], skip_special_tokens=True)\n",
227
+ " parsed = _parse_completion(completion)\n",
228
+ " if parsed:\n",
229
+ " reward, _ = compute_reward(\n",
230
+ " parsed.get(\"answer\", \"\"), row[\"ground_truth\"],\n",
231
+ " [int(x) for x in parsed.get(\"suspicious_docs\", [])],\n",
232
+ " row[\"corrupt_ids\"], float(parsed.get(\"confidence\", 0.5)),\n",
233
+ " budget_used=1, max_budget=12\n",
234
+ " )\n",
235
+ " else:\n",
236
+ " reward = 0.0\n",
237
+ " rewards.append(reward)\n",
238
+ "\n",
239
+ "avg = sum(rewards) / len(rewards)\n",
240
+ "print(f\"\\n{'='*50}\")\n",
241
+ "print(f\"Trained model avg reward : {avg:.4f}\")\n",
242
+ "print(f\"Random baseline avg : 0.1302\")\n",
243
+ "print(f\"Improvement : {avg - 0.1302:+.4f}\")\n",
244
+ "print(f\"{'='*50}\")"
245
+ ]
246
+ },
247
+ {
248
+ "cell_type": "markdown",
249
+ "metadata": {},
250
+ "source": [
251
+ "## 9. Commit plots and results"
252
+ ]
253
+ },
254
+ {
255
+ "cell_type": "code",
256
+ "execution_count": null,
257
+ "metadata": {},
258
+ "outputs": [],
259
+ "source": [
260
+ "trained_avg = avg # from cell above\n",
261
+ "\n",
262
+ "results = {\n",
263
+ " \"baseline_avg_reward\": 0.1302,\n",
264
+ " \"trained_avg_reward\": round(trained_avg, 4),\n",
265
+ " \"improvement\": round(trained_avg - 0.1302, 4),\n",
266
+ " \"n_eval_episodes\": 50,\n",
267
+ " \"model\": \"Qwen2-1.5B-Instruct + LoRA r=16 GRPO\",\n",
268
+ "}\n",
269
+ "with open(\"eval/trained_results.json\", \"w\") as f:\n",
270
+ " json.dump(results, f, indent=2)\n",
271
+ "\n",
272
+ "!git config user.email \"colab@training\"\n",
273
+ "!git config user.name \"Colab Training Run\"\n",
274
+ "!git add assets/reward_curve.png assets/loss_curve.png eval/trained_results.json\n",
275
+ "!git commit -m \"results: add training curves and eval results\"\n",
276
+ "!git push origin main\n",
277
+ "print(\"Done β€” plots and results committed.\")"
278
+ ]
279
+ }
280
+ ],
281
+ "metadata": {
282
+ "accelerator": "GPU",
283
+ "colab": {
284
+ "gpuType": "A100",
285
+ "name": "ContextCorruption_GRPO.ipynb",
286
+ "provenance": []
287
+ },
288
+ "kernelspec": {
289
+ "display_name": "Python 3",
290
+ "language": "python",
291
+ "name": "python3"
292
+ },
293
+ "language_info": {
294
+ "name": "python",
295
+ "version": "3.11.0"
296
+ }
297
+ },
298
+ "nbformat": 4,
299
+ "nbformat_minor": 4
300
+ }
training/train_grpo.py CHANGED
@@ -1,96 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
 
 
 
 
 
2
  import wandb
3
 
4
- # Fill in after HF Space is live
5
- ENV_URL = os.getenv("ENV_URL", "http://localhost:8000")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
- SYSTEM_PROMPT = """You are an epistemic agent. You will be given a question and a set of documents.
8
- Your goal is to identify the correct answer and flag any documents that contain corrupted or false information.
9
 
10
- You have three tools:
11
- - read_doc(doc_id): Spend 1 budget unit to focus on a specific document (content already visible).
12
- - flag_suspicious(doc_id): Mark a document as potentially corrupted.
13
- - submit_answer(answer, confidence): End the episode with your final answer and a confidence score (0.0–1.0).
14
 
15
- Strategy:
16
- - Cross-reference claims across documents. Corrupted documents will contradict the majority or your parametric knowledge.
17
- - When documents conflict, trust your parametric knowledge and flag the outlier.
18
- - Use your budget wisely β€” you have 12 actions total. Flag only when confident, as false positives are penalised.
19
- - Submit as soon as you are confident; unused budget gives a small efficiency bonus."""
20
 
 
 
 
 
21
 
22
- def build_prompt(obs_dict: dict) -> str:
 
 
 
 
 
 
 
 
 
 
23
  docs_text = "\n\n".join(
24
- f"[Doc {d['id']}] {d['title']}\n{d['content']}"
25
- for d in obs_dict["documents"]
26
- )
27
- flagged = obs_dict.get("flagged_ids", [])
28
- return (
29
- f"Question: {obs_dict['question']}\n\n"
30
- f"Documents:\n{docs_text}\n\n"
31
- f"Flagged so far: {flagged}\n"
32
- f"Budget remaining: {obs_dict['budget_remaining']}\n"
33
- f"Turn: {obs_dict['turn']}\n\n"
34
- "What is your next action? Respond with a JSON action object:\n"
35
- '{"action_type": "submit_answer", "answer": "...", "confidence": 0.9}\n'
36
- "or\n"
37
- '{"action_type": "flag_suspicious", "doc_id": 2}'
38
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  def main():
 
 
 
 
 
 
 
 
 
42
  from unsloth import FastLanguageModel
43
  from trl import GRPOTrainer, GRPOConfig
44
 
45
- wandb.init(project="context-corruption-env", name="qwen-1.5b-grpo-run1")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
- # Load model with Unsloth
48
  model, tokenizer = FastLanguageModel.from_pretrained(
49
- model_name="unsloth/Qwen2-1.5B-Instruct",
50
- max_seq_length=2048,
51
- load_in_4bit=True,
52
  )
53
  model = FastLanguageModel.get_peft_model(
54
  model,
55
- r=16,
56
- target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
57
  lora_dropout=0.0,
58
  use_gradient_checkpointing="unsloth",
59
  )
60
 
61
- # Environment factory for GRPO β€” one env instance per parallel generation
62
- from environment.env import ContextCorruptionEnv
63
-
64
- def env_factory():
65
- return ContextCorruptionEnv()
66
 
67
  config = GRPOConfig(
68
- num_train_epochs=3,
69
- per_device_train_batch_size=4,
70
- gradient_accumulation_steps=4,
71
- learning_rate=5e-5,
72
- max_completion_length=512,
73
- num_generations=8,
 
74
  report_to="wandb",
75
- logging_steps=10,
76
- save_steps=50,
77
- output_dir="checkpoints/grpo-qwen-1.5b",
 
 
 
 
 
 
 
 
 
 
78
  )
79
 
80
  trainer = GRPOTrainer(
81
  model=model,
82
  args=config,
83
  processing_class=tokenizer,
84
- environment_factory=env_factory,
 
 
85
  )
86
 
 
87
  trainer.train()
88
- wandb.finish()
89
 
90
- # Save model
91
- model.save_pretrained("checkpoints/grpo-qwen-1.5b-final")
92
- tokenizer.save_pretrained("checkpoints/grpo-qwen-1.5b-final")
93
- print("Training complete. Model saved to checkpoints/grpo-qwen-1.5b-final")
 
 
 
 
 
 
 
 
 
 
94
 
95
 
96
  if __name__ == "__main__":
 
1
+ """
2
+ GRPO fine-tuning of Qwen2-1.5B-Instruct on ContextCorruption-Env.
3
+
4
+ Architecture:
5
+ - Single-turn formulation: model sees question + all 8 docs, responds with
6
+ JSON {"answer": "...", "suspicious_docs": [0, 3], "confidence": 0.85}
7
+ - Two reward signals: correctness (from compute_reward) + format (valid JSON)
8
+ - WandB logs metrics + sample completions every LOGGING_STEPS
9
+ - Pushes final model to HF Hub after training
10
+
11
+ Usage (on GPU machine / HF Space):
12
+ pip install -r requirements.txt
13
+ WANDB_API_KEY=... HF_TOKEN=... python -m training.train_grpo
14
+ """
15
+
16
+ import json
17
  import os
18
+ import random
19
+ import re
20
+ import sys
21
+ from pathlib import Path
22
+
23
  import wandb
24
 
25
+ # ── Config ────────────────────────────────────────────────────────────────────
26
+ MODEL_NAME = "unsloth/Qwen2-1.5B-Instruct"
27
+ MAX_SEQ_LENGTH = 2048
28
+ LOAD_IN_4BIT = True
29
+ LORA_R = 16
30
+ LORA_TARGET_MODULES = ["q_proj", "k_proj", "v_proj", "o_proj"]
31
+
32
+ NUM_TRAIN_EPOCHS = 3
33
+ BATCH_SIZE = 4
34
+ GRAD_ACCUM = 4
35
+ LR = 5e-5
36
+ MAX_COMPLETION_LENGTH = 512
37
+ NUM_GENERATIONS = 8
38
+ LOGGING_STEPS = 10
39
+ SAVE_STEPS = 50
40
+ SEED = 42
41
 
42
+ N_TRAIN_EPISODES = 500
43
+ N_EVAL_EPISODES = 50
44
 
45
+ WANDB_PROJECT = "context-corruption-env"
46
+ WANDB_RUN_NAME = "qwen-1.5b-grpo-run1"
47
+ OUTPUT_DIR = "checkpoints/grpo-qwen-1.5b"
48
+ HF_HUB_MODEL_ID = os.getenv("HF_HUB_MODEL_ID", "") # set to push model after training
49
 
50
+ SYSTEM_PROMPT = """\
51
+ You are an epistemic agent. You will receive a factual question and a set of reference documents.
52
+ Some documents may contain deliberately corrupted information (wrong numbers, wrong names, inverted facts).
 
 
53
 
54
+ Your task:
55
+ 1. Answer the question correctly using your knowledge and the documents.
56
+ 2. Identify which document IDs contain false or corrupted information.
57
+ 3. Report your confidence (0.0–1.0).
58
 
59
+ Respond ONLY with valid JSON on a single line:
60
+ {"answer": "<your answer>", "suspicious_docs": [<doc ids>], "confidence": <0.0-1.0>}
61
+
62
+ Examples:
63
+ {"answer": "Paris", "suspicious_docs": [2, 5], "confidence": 0.95}
64
+ {"answer": "1969", "suspicious_docs": [], "confidence": 0.8}"""
65
+
66
+
67
+ # ── Dataset builder ────────────────────────────────────────────────────────────
68
+
69
+ def _format_user_message(question: str, docs: list[dict]) -> str:
70
  docs_text = "\n\n".join(
71
+ f"[Doc {d['id']}] {d['title']}\n{d['content']}" for d in docs
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  )
73
+ return f"Question: {question}\n\nDocuments:\n{docs_text}"
74
+
75
+
76
+ def build_dataset(n_episodes: int, seed: int = SEED) -> "datasets.Dataset":
77
+ from datasets import Dataset
78
+ from data.generator import generate_documents
79
+
80
+ random.seed(seed)
81
+ facts_path = Path(__file__).parent.parent / "data" / "facts.json"
82
+ if not facts_path.exists():
83
+ raise FileNotFoundError(
84
+ "data/facts.json not found. Run: python -m data.loader"
85
+ )
86
+ facts = json.loads(facts_path.read_text(encoding="utf-8"))
87
+
88
+ rows = []
89
+ for _ in range(n_episodes):
90
+ fact = random.choice(facts)
91
+ n_corrupt = random.choice([1, 2, 3, 4])
92
+ corrupt_ids = random.sample(range(8), n_corrupt)
93
+ try:
94
+ docs = generate_documents(fact, num_docs=8, corrupt_positions=corrupt_ids)
95
+ except Exception:
96
+ docs = [
97
+ {"id": i, "title": f"Doc {i}", "content": fact["answer"],
98
+ "is_corrupt": i in corrupt_ids}
99
+ for i in range(8)
100
+ ]
101
+ rows.append({
102
+ "messages": [
103
+ {"role": "system", "content": SYSTEM_PROMPT},
104
+ {"role": "user", "content": _format_user_message(fact["question"], docs)},
105
+ ],
106
+ "ground_truth": fact["answer"],
107
+ "corrupt_ids": corrupt_ids,
108
+ })
109
+
110
+ return Dataset.from_list(rows)
111
+
112
+
113
+ # ── Reward functions ───────────────────────────────────────────────────────────
114
+
115
+ def _parse_completion(text: str) -> dict | None:
116
+ """Extract first JSON object from completion text."""
117
+ # Strip any <think>...</think> blocks (chain-of-thought models)
118
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
119
+ # Try direct parse first
120
+ try:
121
+ return json.loads(text)
122
+ except json.JSONDecodeError:
123
+ pass
124
+ # Find first {...} block
125
+ match = re.search(r"\{[^{}]*\}", text, re.DOTALL)
126
+ if match:
127
+ try:
128
+ return json.loads(match.group())
129
+ except json.JSONDecodeError:
130
+ pass
131
+ return None
132
+
133
 
134
+ def format_reward(prompts, completions, **kwargs) -> list[float]:
135
+ """Small bonus for structurally valid responses β€” teaches the output format."""
136
+ rewards = []
137
+ for completion in completions:
138
+ parsed = _parse_completion(completion)
139
+ if parsed is None:
140
+ rewards.append(-0.1)
141
+ continue
142
+ has_answer = isinstance(parsed.get("answer"), str) and parsed["answer"].strip()
143
+ has_docs = isinstance(parsed.get("suspicious_docs"), list)
144
+ has_conf = isinstance(parsed.get("confidence"), (int, float))
145
+ rewards.append(0.1 if (has_answer and has_docs and has_conf) else 0.0)
146
+ return rewards
147
+
148
+
149
+ def correctness_reward(prompts, completions, ground_truth, corrupt_ids, **kwargs) -> list[float]:
150
+ """Main reward: calls compute_reward() from environment/reward.py."""
151
+ from environment.reward import compute_reward
152
+
153
+ rewards = []
154
+ for completion, gt, cids in zip(completions, ground_truth, corrupt_ids):
155
+ parsed = _parse_completion(completion)
156
+ if parsed is None:
157
+ rewards.append(0.0)
158
+ continue
159
+ answer = str(parsed.get("answer", "")).strip()
160
+ flagged = [int(x) for x in parsed.get("suspicious_docs", [])
161
+ if isinstance(x, (int, float))]
162
+ confidence = float(parsed.get("confidence", 0.5))
163
+ confidence = max(0.0, min(1.0, confidence))
164
+ cids_list = list(cids) if not isinstance(cids, list) else cids
165
+ reward, _ = compute_reward(
166
+ submitted_answer=answer,
167
+ ground_truth_answer=gt,
168
+ flagged_ids=flagged,
169
+ corrupt_ids=cids_list,
170
+ confidence=confidence,
171
+ budget_used=1,
172
+ max_budget=12,
173
+ )
174
+ rewards.append(float(reward))
175
+ return rewards
176
+
177
+
178
+ # ── Plot saving ────────────────────────────────────────────────────────────────
179
+
180
+ def save_training_plots(run_id: str):
181
+ """Download reward + loss curves from WandB and save to assets/."""
182
+ try:
183
+ import matplotlib
184
+ matplotlib.use("Agg")
185
+ import matplotlib.pyplot as plt
186
+ api = wandb.Api()
187
+ run = api.run(f"{WANDB_PROJECT}/{run_id}")
188
+ history = run.history(keys=["train/reward", "train/loss"], pandas=True)
189
+ assets = Path(__file__).parent.parent / "assets"
190
+ assets.mkdir(exist_ok=True)
191
+
192
+ if "train/reward" in history.columns:
193
+ fig, ax = plt.subplots(figsize=(8, 4))
194
+ ax.plot(history["_step"], history["train/reward"])
195
+ ax.set_xlabel("Training step")
196
+ ax.set_ylabel("Mean episode reward")
197
+ ax.set_title("GRPO Training Reward β€” Qwen2-1.5B")
198
+ ax.grid(True, alpha=0.3)
199
+ fig.tight_layout()
200
+ fig.savefig(assets / "reward_curve.png", dpi=150)
201
+ plt.close(fig)
202
+ print(f"Saved reward_curve.png")
203
+
204
+ if "train/loss" in history.columns:
205
+ fig, ax = plt.subplots(figsize=(8, 4))
206
+ ax.plot(history["_step"], history["train/loss"])
207
+ ax.set_xlabel("Training step")
208
+ ax.set_ylabel("GRPO loss")
209
+ ax.set_title("GRPO Training Loss β€” Qwen2-1.5B")
210
+ ax.grid(True, alpha=0.3)
211
+ fig.tight_layout()
212
+ fig.savefig(assets / "loss_curve.png", dpi=150)
213
+ plt.close(fig)
214
+ print(f"Saved loss_curve.png")
215
+ except Exception as e:
216
+ print(f"[warn] Could not save plots: {e}")
217
+
218
+
219
+ # ── Main ───────────────────────────────────────────────────────────────────────
220
 
221
  def main():
222
+ # Guard: must have GPU
223
+ try:
224
+ import torch
225
+ if not torch.cuda.is_available():
226
+ print("[error] No GPU detected. Training requires CUDA. Exiting.")
227
+ sys.exit(1)
228
+ except ImportError:
229
+ pass
230
+
231
  from unsloth import FastLanguageModel
232
  from trl import GRPOTrainer, GRPOConfig
233
 
234
+ run = wandb.init(
235
+ project=WANDB_PROJECT,
236
+ name=WANDB_RUN_NAME,
237
+ config={
238
+ "model": MODEL_NAME,
239
+ "lora_r": LORA_R,
240
+ "epochs": NUM_TRAIN_EPOCHS,
241
+ "batch_size": BATCH_SIZE,
242
+ "grad_accum": GRAD_ACCUM,
243
+ "lr": LR,
244
+ "num_generations": NUM_GENERATIONS,
245
+ "n_train_episodes": N_TRAIN_EPISODES,
246
+ "seed": SEED,
247
+ },
248
+ )
249
+
250
+ print("Building training dataset...")
251
+ train_dataset = build_dataset(N_TRAIN_EPISODES, seed=SEED)
252
+ eval_dataset = build_dataset(N_EVAL_EPISODES, seed=SEED + 1)
253
+ print(f"Train: {len(train_dataset)} episodes | Eval: {len(eval_dataset)} episodes")
254
 
255
+ print("Loading model with Unsloth...")
256
  model, tokenizer = FastLanguageModel.from_pretrained(
257
+ model_name=MODEL_NAME,
258
+ max_seq_length=MAX_SEQ_LENGTH,
259
+ load_in_4bit=LOAD_IN_4BIT,
260
  )
261
  model = FastLanguageModel.get_peft_model(
262
  model,
263
+ r=LORA_R,
264
+ target_modules=LORA_TARGET_MODULES,
265
  lora_dropout=0.0,
266
  use_gradient_checkpointing="unsloth",
267
  )
268
 
269
+ push_to_hub = bool(HF_HUB_MODEL_ID and os.getenv("HF_TOKEN"))
 
 
 
 
270
 
271
  config = GRPOConfig(
272
+ output_dir=OUTPUT_DIR,
273
+ num_train_epochs=NUM_TRAIN_EPOCHS,
274
+ per_device_train_batch_size=BATCH_SIZE,
275
+ gradient_accumulation_steps=GRAD_ACCUM,
276
+ learning_rate=LR,
277
+ max_completion_length=MAX_COMPLETION_LENGTH,
278
+ num_generations=NUM_GENERATIONS,
279
  report_to="wandb",
280
+ logging_steps=LOGGING_STEPS,
281
+ save_steps=SAVE_STEPS,
282
+ save_total_limit=2,
283
+ seed=SEED,
284
+ # Deployment logs: log completions to WandB every logging step
285
+ log_completions=True,
286
+ num_completions_to_print=2,
287
+ # Push to HF Hub if token provided
288
+ push_to_hub=push_to_hub,
289
+ hub_model_id=HF_HUB_MODEL_ID if push_to_hub else None,
290
+ hub_strategy="end",
291
+ bf16=True,
292
+ remove_unused_columns=False,
293
  )
294
 
295
  trainer = GRPOTrainer(
296
  model=model,
297
  args=config,
298
  processing_class=tokenizer,
299
+ train_dataset=train_dataset,
300
+ eval_dataset=eval_dataset,
301
+ reward_funcs=[correctness_reward, format_reward],
302
  )
303
 
304
+ print("Starting GRPO training...")
305
  trainer.train()
 
306
 
307
+ print("Saving final model...")
308
+ model.save_pretrained(f"{OUTPUT_DIR}-final")
309
+ tokenizer.save_pretrained(f"{OUTPUT_DIR}-final")
310
+
311
+ if push_to_hub:
312
+ model.push_to_hub(HF_HUB_MODEL_ID)
313
+ tokenizer.push_to_hub(HF_HUB_MODEL_ID)
314
+ print(f"Model pushed to HF Hub: {HF_HUB_MODEL_ID}")
315
+
316
+ print("Saving training plots...")
317
+ save_training_plots(run.id)
318
+
319
+ wandb.finish()
320
+ print("Training complete.")
321
 
322
 
323
  if __name__ == "__main__":