Daisuke675 commited on
Commit
42e8152
·
verified ·
1 Parent(s): d9c9ab4

Upload v24_grpo_chain_v4.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. v24_grpo_chain_v4.py +364 -0
v24_grpo_chain_v4.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import argparse, json, math, os, random, re
2
+ from pathlib import Path
3
+
4
+ import torch
5
+ import torch.nn.functional as F
6
+ from datasets import load_dataset
7
+ from transformers import AutoTokenizer
8
+
9
+ from scugnizz_core import ScugnizzDecoder, preset_config, load_checkpoint
10
+
11
+
12
+ def parse_args():
13
+ p = argparse.ArgumentParser()
14
+ p.add_argument("--model-size", default="1.7b")
15
+ p.add_argument("--tokenizer", required=True)
16
+ p.add_argument("--checkpoint", required=True)
17
+ p.add_argument("--dataset", required=True)
18
+ p.add_argument("--max-steps", type=int, default=30)
19
+ p.add_argument("--group-size", type=int, default=4)
20
+ p.add_argument("--max-new", type=int, default=64)
21
+ p.add_argument("--temperature", type=float, default=0.8)
22
+ p.add_argument("--top-p", type=float, default=0.95)
23
+ p.add_argument("--lr", type=float, default=1e-6)
24
+ p.add_argument("--kl-beta", type=float, default=0.10)
25
+ p.add_argument("--block-size", type=int, default=4096)
26
+ p.add_argument("--save-every", type=int, default=10)
27
+ p.add_argument("--out-dir", default="/tmp/V24-GRPO-chain-v3")
28
+ return p.parse_args()
29
+
30
+
31
+ def make_prompt(tok, messages):
32
+ # Use the exact V22 tokenizer chat template instead of reconstructing
33
+ # headers manually. This keeps the GRPO prompt aligned with SFT.
34
+ msgs = []
35
+ for m in messages:
36
+ role = m.get("role", "")
37
+ content = str(m.get("content", ""))
38
+ if role in ("system", "user", "assistant", "ipython", "tool"):
39
+ if role == "tool":
40
+ role = "ipython"
41
+ msgs.append({"role": role, "content": content})
42
+
43
+ try:
44
+ return tok.apply_chat_template(
45
+ msgs,
46
+ tokenize=False,
47
+ add_generation_prompt=True,
48
+ )
49
+ except Exception as exc:
50
+ raise RuntimeError(
51
+ "Tokenizer chat_template could not render the V22 chain prompt. "
52
+ f"Roles={[m['role'] for m in msgs]!r}"
53
+ ) from exc
54
+
55
+
56
+ def extract_chain_rows(ds):
57
+ rows = []
58
+ for r in ds:
59
+ msgs = r.get("messages") or []
60
+ expected = r.get("expected_second_tool")
61
+ expected_args = r.get("expected_second_args") or {}
62
+ if not expected or not isinstance(msgs, list):
63
+ continue
64
+
65
+ # Require: assistant first tool_call -> ipython/tool response.
66
+ first = None
67
+ response = None
68
+ for i, m in enumerate(msgs):
69
+ if m.get("role") == "assistant" and "<tool_call>" in str(m.get("content") or ""):
70
+ if i + 1 < len(msgs) and msgs[i + 1].get("role") in ("ipython", "tool"):
71
+ first = i
72
+ response = i + 1
73
+ break
74
+ if first is None:
75
+ continue
76
+
77
+ prefix = msgs[: response + 1]
78
+ rows.append((prefix, str(expected), expected_args))
79
+ return rows
80
+
81
+
82
+ def extract_tool_call(text):
83
+ # Primary V22 format: explicit <tool_call>{...}</tool_call>.
84
+ m = re.search(r"<tool_call>\\s*(\\{.*?\\})\\s*</tool_call>", text, flags=re.S)
85
+ if m:
86
+ try:
87
+ obj = json.loads(m.group(1))
88
+ if isinstance(obj, dict):
89
+ return obj
90
+ except Exception:
91
+ pass
92
+
93
+ # Robust fallback: recover a complete JSON tool object even if the model
94
+ # polluted the opening tag with extra BPE tokens/whitespace.
95
+ for m in re.finditer(r'\\{\\s*"name"\\s*:\\s*"([^"]+)"\\s*,\\s*"arguments"\\s*:\\s*\\{', text):
96
+ start = m.start()
97
+ depth = 0
98
+ in_str = False
99
+ esc = False
100
+ for k in range(start, len(text)):
101
+ c = text[k]
102
+ if in_str:
103
+ if esc:
104
+ esc = False
105
+ elif c == "\\\\":
106
+ esc = True
107
+ elif c == '"':
108
+ in_str = False
109
+ else:
110
+ if c == '"':
111
+ in_str = True
112
+ elif c == "{":
113
+ depth += 1
114
+ elif c == "}":
115
+ depth -= 1
116
+ if depth == 0:
117
+ try:
118
+ obj = json.loads(text[start:k+1])
119
+ if isinstance(obj, dict):
120
+ return obj
121
+ except Exception:
122
+ break
123
+ return None
124
+
125
+
126
+ def reward_chain(text, expected_name, expected_args):
127
+ call = extract_tool_call(text)
128
+
129
+ # Plain answers are neutral: do not punish the model merely for failing
130
+ # to emit a tool call. This prevents four identical -0.5 rewards.
131
+ if call is None:
132
+ return 0.0
133
+
134
+ name = call.get("name")
135
+ args = call.get("arguments")
136
+
137
+ # Valid tool structure gives a small positive base.
138
+ score = 0.25
139
+
140
+ if name == expected_name:
141
+ score += 2.0
142
+ elif isinstance(name, str) and name:
143
+ score -= 0.25
144
+
145
+ if isinstance(args, dict):
146
+ if expected_args and args == expected_args:
147
+ score += 1.0
148
+ elif expected_args:
149
+ hits = sum(args.get(k) == v for k, v in expected_args.items())
150
+ if hits:
151
+ score += 0.50 * hits / len(expected_args)
152
+ else:
153
+ score -= 0.50
154
+
155
+ # Header contamination is a mild penalty, not a complete rejection:
156
+ # valid tool calls should retain most of their structural reward.
157
+ contamination_tokens = (
158
+ "<|start_header_id|>",
159
+ "<|end_header_id|>",
160
+ "<|assistant|>",
161
+ "<|user|>",
162
+ "<|system|>",
163
+ "<|ipython|>",
164
+ )
165
+ if any(x in text for x in contamination_tokens):
166
+ score -= 0.25
167
+
168
+ return float(score)
169
+
170
+
171
+ @torch.no_grad()
172
+ def sample_group(model, tok, prompt_ids, group_size, max_new, temperature, top_p):
173
+ device = next(model.parameters()).device
174
+ outs = []
175
+ for _ in range(group_size):
176
+ ids = prompt_ids.clone().to(device)
177
+ for _ in range(max_new):
178
+ x = ids[:, -4096:]
179
+ logits = model(x)
180
+ if isinstance(logits, tuple):
181
+ logits = logits[0]
182
+ if hasattr(logits, "logits"):
183
+ logits = logits.logits
184
+ next_logits = logits[:, -1, :] / max(temperature, 1e-6)
185
+ probs = F.softmax(next_logits, dim=-1)
186
+ if top_p < 1.0:
187
+ sorted_probs, sorted_idx = torch.sort(probs, descending=True)
188
+ cum = torch.cumsum(sorted_probs, dim=-1)
189
+ mask = cum - sorted_probs > top_p
190
+ sorted_probs[mask] = 0
191
+ sorted_probs = sorted_probs / sorted_probs.sum(dim=-1, keepdim=True)
192
+ nxt = sorted_idx.gather(-1, torch.multinomial(sorted_probs, 1))
193
+ else:
194
+ nxt = torch.multinomial(probs, 1)
195
+ ids = torch.cat([ids, nxt], dim=1)
196
+ if nxt.item() == tok.eos_token_id:
197
+ break
198
+ outs.append(ids[:, prompt_ids.shape[1]:].clone())
199
+ return outs
200
+
201
+
202
+ def seq_logprob(model, prompt_ids, response_ids, block_size):
203
+ device = next(model.parameters()).device
204
+ full = torch.cat([prompt_ids, response_ids], dim=1)
205
+ full = full[:, -block_size:]
206
+ # Keep response-token indexing aligned after any left truncation.
207
+ plen = max(full.shape[1] - response_ids.shape[1], 0)
208
+ logits = model(full)
209
+ if isinstance(logits, tuple):
210
+ logits = logits[0]
211
+ if hasattr(logits, "logits"):
212
+ logits = logits.logits
213
+ start = max(plen - 1, 0)
214
+ target = full[:, start + 1:]
215
+ pred = logits[:, start:-1, :]
216
+ if target.numel() == 0:
217
+ return torch.zeros((), device=device)
218
+ lp = F.log_softmax(pred.float(), dim=-1)
219
+ tok_lp = lp.gather(-1, target.unsqueeze(-1)).squeeze(-1)
220
+ return tok_lp.mean()
221
+
222
+
223
+ def seq_logprob_and_forward_kl(model, ref, prompt_ids, response_ids, block_size):
224
+ device = next(model.parameters()).device
225
+ full = torch.cat([prompt_ids, response_ids], dim=1)
226
+ if full.shape[1] > block_size:
227
+ full = full[:, -block_size:]
228
+ plen = max(full.shape[1] - response_ids.shape[1], 0)
229
+
230
+ logits = model(full)
231
+ if isinstance(logits, tuple):
232
+ logits = logits[0]
233
+ if hasattr(logits, "logits"):
234
+ logits = logits.logits
235
+
236
+ with torch.no_grad():
237
+ ref_logits = ref(full)
238
+ if isinstance(ref_logits, tuple):
239
+ ref_logits = ref_logits[0]
240
+ if hasattr(ref_logits, "logits"):
241
+ ref_logits = ref_logits.logits
242
+
243
+ start = max(plen - 1, 0)
244
+ target = full[:, start + 1:]
245
+ pred = logits[:, start:-1, :]
246
+ ref_pred = ref_logits[:, start:-1, :]
247
+
248
+ if target.numel() == 0:
249
+ z = torch.zeros((), device=device)
250
+ return z, z
251
+
252
+ logp = F.log_softmax(pred.float(), dim=-1)
253
+ with torch.no_grad():
254
+ logq = F.log_softmax(ref_pred.float(), dim=-1)
255
+ q = logq.exp()
256
+
257
+ tok_logp = logp.gather(-1, target.unsqueeze(-1)).squeeze(-1)
258
+ token_kl = (q * (logq - logp)).sum(dim=-1)
259
+ return tok_logp.mean(), token_kl.mean()
260
+
261
+
262
+ def save_state(model, optimizer, step, out_dir):
263
+ Path(out_dir).mkdir(parents=True, exist_ok=True)
264
+ state = {
265
+ "step": step,
266
+ "model": model.state_dict(),
267
+ "optimizer": optimizer.state_dict(),
268
+ }
269
+ torch.save(state, Path(out_dir) / f"checkpoint_step_{step:03d}.pt")
270
+ torch.save(state, Path(out_dir) / "checkpoint_last.pt")
271
+
272
+
273
+ def main():
274
+ args = parse_args()
275
+ random.seed(20260915)
276
+ torch.manual_seed(20260915)
277
+
278
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
279
+ dtype = torch.bfloat16 if device.type == "cuda" and torch.cuda.is_bf16_supported() else torch.float32
280
+ print(f"device={device} dtype={dtype}")
281
+
282
+ tok = AutoTokenizer.from_pretrained(args.tokenizer)
283
+ ds = load_dataset(args.dataset, split="train")
284
+ rows = extract_chain_rows(ds)
285
+ print(f"chain_rows={len(rows)} lr={args.lr:g} kl_beta={args.kl_beta:g}")
286
+ if len(rows) < args.group_size:
287
+ raise RuntimeError("Too few valid sequential chain rows")
288
+
289
+ cfg = preset_config(args.model_size, vocab_size=len(tok), block_size=args.block_size, dropout=0.0, pcs_a=0.8309193524, pcs_b=0.0)
290
+ model = ScugnizzDecoder(cfg).to(device=device, dtype=dtype)
291
+ state = load_checkpoint(args.checkpoint, "cpu")
292
+ if isinstance(state, dict) and "model" in state:
293
+ state = state["model"]
294
+ model.load_state_dict(state, strict=True)
295
+ model.train()
296
+
297
+ # Frozen V22 reference for a simple KL-style anchor.
298
+ ref = ScugnizzDecoder(cfg).to(device=device, dtype=dtype)
299
+ ref.load_state_dict(state, strict=True)
300
+ ref.eval()
301
+ for p in ref.parameters():
302
+ p.requires_grad_(False)
303
+
304
+ opt = torch.optim.AdamW(model.parameters(), lr=args.lr)
305
+ step = 0
306
+ for step in range(1, args.max_steps + 1):
307
+ prefix, expected, expected_args = rows[(step - 1) % len(rows)]
308
+ prompt = make_prompt(tok, prefix)
309
+ prompt_ids = tok(prompt, return_tensors="pt", add_special_tokens=False).input_ids.to(device)
310
+
311
+ # Sample a group from the state immediately after the first tool response.
312
+ responses = sample_group(
313
+ model, tok, prompt_ids, args.group_size, args.max_new,
314
+ args.temperature, args.top_p
315
+ )
316
+ texts = [tok.decode(x[0], skip_special_tokens=False) for x in responses]
317
+ rewards = torch.tensor(
318
+ [reward_chain(t, expected, expected_args) for t in texts],
319
+ device=device, dtype=torch.float32
320
+ )
321
+
322
+ mean_r = rewards.mean()
323
+ std_r = rewards.std(unbiased=False)
324
+ adv = (rewards - mean_r) / (std_r + 1e-6)
325
+
326
+ # Recompute differentiable sequence log-probs and a true sampled
327
+ # forward KL against the frozen V22 reference.
328
+ logps = []
329
+ kls = []
330
+ for rids in responses:
331
+ lp, k = seq_logprob_and_forward_kl(
332
+ model, ref, prompt_ids, rids.to(device), args.block_size
333
+ )
334
+ logps.append(lp)
335
+ kls.append(k)
336
+ logps = torch.stack(logps)
337
+ kls = torch.stack(kls)
338
+ kl = kls.mean()
339
+ loss = -(adv.detach() * logps).mean() + args.kl_beta * kl
340
+
341
+ opt.zero_grad(set_to_none=True)
342
+ if loss.requires_grad and torch.isfinite(loss) and torch.isfinite(kl):
343
+ loss.backward()
344
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
345
+ opt.step()
346
+
347
+ print(
348
+ f"step={step:03d} expected={expected} "
349
+ f"reward_mean={mean_r.item():+.3f} reward_max={rewards.max().item():+.3f} "
350
+ f"adv_std={adv.std(unbiased=False).item():.3f} "
351
+ f"loss={loss.item():+.5f} kl={kl.item():+.5f}"
352
+ )
353
+ for i, (t, r) in enumerate(zip(texts, rewards.tolist())):
354
+ print(f" sample[{i}] reward={r:+.2f} {t[:500]!r}")
355
+
356
+ if step % args.save_every == 0:
357
+ save_state(model, opt, step, args.out_dir)
358
+
359
+ save_state(model, opt, step, args.out_dir)
360
+ print("DONE")
361
+
362
+
363
+ if __name__ == "__main__":
364
+ main()