Daisuke675 commited on
Commit
cbbc522
·
verified ·
1 Parent(s): 50e086e

Upload v24_grpo_chain_v6.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. v24_grpo_chain_v6.py +410 -0
v24_grpo_chain_v6.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import argparse, copy, json, math, random, re, subprocess, sys
3
+ from pathlib import Path
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+ from transformers import AutoTokenizer
8
+ from datasets import load_dataset
9
+
10
+ # scugnizz_core.py is a truncated copy of scugnizz-llama.py containing
11
+ # ScugnizzConfig / ScugnizzDecoder / preset_config / load_checkpoint.
12
+ from scugnizz_core import ScugnizzDecoder, preset_config, load_checkpoint
13
+
14
+
15
+ TOOL_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.S)
16
+ HEADER_RE = re.compile(r"<\|start_header_id\|>\s*(?:user|assistant|ipython|system)\s*<\|end_header_id\|>")
17
+
18
+ def extract_tool_calls(text):
19
+ text = text or ""
20
+ calls = []
21
+ for m in TOOL_RE.finditer(text):
22
+ raw = m.group(1)
23
+ try:
24
+ obj = json.loads(raw)
25
+ if isinstance(obj, dict) and isinstance(obj.get("name"), str):
26
+ calls.append((m.start(), m.end(), obj, True))
27
+ except Exception:
28
+ pass
29
+
30
+ # Fallback for truncated/malformed <tool_call> wrappers: recover a balanced JSON object.
31
+ for m in re.finditer(r'\{\s*"name"\s*:\s*"([^"\\]+)"\s*,\s*"arguments"\s*:\s*\{', text):
32
+ start = m.start()
33
+ if any(a <= start < b for a, b, _, _ in calls):
34
+ continue
35
+ depth = 0; in_str = False; esc = False
36
+ for k in range(start, len(text)):
37
+ c = text[k]
38
+ if in_str:
39
+ if esc: esc = False
40
+ elif c == "\\": esc = True
41
+ elif c == '"': in_str = False
42
+ else:
43
+ if c == '"': in_str = True
44
+ elif c == '{': depth += 1
45
+ elif c == '}':
46
+ depth -= 1
47
+ if depth == 0:
48
+ try:
49
+ obj = json.loads(text[start:k+1])
50
+ if isinstance(obj, dict) and isinstance(obj.get("name"), str):
51
+ calls.append((start, k+1, obj, False))
52
+ except Exception:
53
+ pass
54
+ break
55
+ calls.sort(key=lambda x: x[0])
56
+ return calls
57
+
58
+
59
+ def _arg_match(got, gold):
60
+ if not isinstance(got, dict) or not isinstance(gold, dict):
61
+ return 0.0
62
+ if got == gold:
63
+ return 1.5
64
+ if not got:
65
+ return 0.0
66
+ hits = 0
67
+ for k, v in gold.items():
68
+ if k in got and got[k] == v:
69
+ hits += 1
70
+ return 0.75 * (hits / max(1, len(gold))) if hits else 0.0
71
+
72
+
73
+ def reward_chain(text, expected):
74
+ """V6 reward: score the FIRST generated tool call, not any later echoed call.
75
+ expected may be a tool name (legacy) or the gold call dict (preferred).
76
+ """
77
+ text = text or ""
78
+ calls = extract_tool_calls(text)
79
+ if isinstance(expected, dict):
80
+ gold_name = expected.get("name", "")
81
+ gold_args = expected.get("arguments", {})
82
+ else:
83
+ gold_name = str(expected or "")
84
+ gold_args = {}
85
+
86
+ if not calls:
87
+ # Distinguish pure natural-language output from malformed tool syntax.
88
+ return -1.5 if "tool_call" in text else -1.0
89
+
90
+ pos, end, call, wrapped = calls[0]
91
+ name = call.get("name", "")
92
+ r = 0.0
93
+
94
+ # The target tool must be the FIRST generated action.
95
+ if name == gold_name:
96
+ r += 2.0
97
+ r += _arg_match(call.get("arguments"), gold_args)
98
+ else:
99
+ r -= 1.0
100
+
101
+ if not wrapped:
102
+ r -= 0.75
103
+
104
+ # Any earlier non-whitespace prose means the model did not enter tool mode cleanly.
105
+ prefix = text[:pos].strip()
106
+ if prefix:
107
+ r -= 0.5
108
+
109
+ # A second tool call is not a successful single action: it is usually the
110
+ # corruption/echo pattern observed in V5.
111
+ if len(calls) > 1:
112
+ r -= 0.75 * min(3, len(calls) - 1)
113
+
114
+ # Chat-template headers inside a sampled completion are contamination.
115
+ headers = len(HEADER_RE.findall(text))
116
+ if headers:
117
+ r -= 1.25 * min(3, headers)
118
+
119
+ # If the model emits a non-empty continuation after the tool call, tolerate
120
+ # the terminal token but penalize generated tool results / prose.
121
+ tail = text[end:].strip()
122
+ if tail:
123
+ tail = re.sub(r"^<\|eot_id\|>", "", tail).strip()
124
+ if tail:
125
+ r -= 0.50
126
+
127
+ return float(max(-3.0, min(3.0, r)))
128
+
129
+
130
+ def parse_tool_name(text):
131
+ calls = extract_tool_calls(text)
132
+ return calls[0][2].get("name") if calls else None
133
+
134
+ def make_prompt(tok, messages):
135
+ # messages already contain system/user/assistant/tool history.
136
+ # sft_data's ROLE_MAP maps tool -> tool and the Llama template renders
137
+ # that as the ipython header. We reproduce that exact representation.
138
+ msgs = []
139
+ for m in messages:
140
+ role = m["role"]
141
+ if role == "tool":
142
+ role = "ipython"
143
+ msgs.append({"role": role, "content": m["content"]})
144
+ return tok.apply_chat_template(
145
+ msgs, tokenize=False, add_generation_prompt=True
146
+ )
147
+
148
+
149
+ def extract_chain_rows(ds):
150
+ rows = []
151
+ for row in ds:
152
+ conv = row.get("conversations") or row.get("messages") or []
153
+ # Normalize role/value -> role/content.
154
+ norm = []
155
+ for m in conv:
156
+ role = m.get("role") or m.get("from")
157
+ content = m.get("content")
158
+ if content is None:
159
+ content = m.get("value", "")
160
+ if role in ("human", "user"):
161
+ role = "user"
162
+ elif role in ("gpt", "assistant", "reasoning", "tool_call"):
163
+ role = "assistant"
164
+ elif role == "tool":
165
+ role = "tool"
166
+ elif role == "system":
167
+ role = "system"
168
+ else:
169
+ continue
170
+ norm.append({"role": role, "content": content})
171
+
172
+ # Find every assistant tool call that is preceded by a tool response.
173
+ for i, m in enumerate(norm):
174
+ if m["role"] != "assistant" or "<tool_call>" not in m["content"]:
175
+ continue
176
+ if i == 0 or norm[i-1]["role"] != "tool":
177
+ continue
178
+ gold_calls = extract_tool_calls(m["content"])
179
+ if not gold_calls:
180
+ continue
181
+ expected = gold_calls[0][2]
182
+
183
+ # Prompt ends immediately after the previous tool response.
184
+ prefix = norm[:i]
185
+ if not prefix or prefix[-1]["role"] != "tool":
186
+ continue
187
+
188
+ # Keep only reasonably sized contexts; left-truncate if needed later.
189
+ rows.append((prefix, expected))
190
+
191
+ return rows
192
+
193
+
194
+ @torch.no_grad()
195
+ def sample_group(model, tok, prompt, n, max_new, temperature, top_p, device, block_size):
196
+ model.eval()
197
+ ids = tok.encode(prompt, add_special_tokens=False)
198
+ room = max(32, block_size - max_new)
199
+ ids = ids[-room:]
200
+ x = torch.tensor([ids], dtype=torch.long, device=device)
201
+ out = []
202
+
203
+ # Seed variation comes from multinomial sampling.
204
+ for _ in range(n):
205
+ past = None
206
+ cur = x
207
+ generated = []
208
+ for _step in range(max_new):
209
+ logits, _, past = model(cur, past_kvs=past, use_cache=True)
210
+ logits = logits[:, -1, :] / max(1e-5, temperature)
211
+ probs = F.softmax(logits, dim=-1)
212
+
213
+ if top_p < 1.0:
214
+ sp, si = torch.sort(probs, descending=True)
215
+ cs = torch.cumsum(sp, dim=-1)
216
+ mask = cs > top_p
217
+ mask[..., 0] = False
218
+ sp = sp.masked_fill(mask, 0)
219
+ sp = sp / sp.sum(dim=-1, keepdim=True)
220
+ nxt = si.gather(-1, torch.multinomial(sp, 1))
221
+ else:
222
+ nxt = torch.multinomial(probs, 1)
223
+
224
+ tid = int(nxt.item())
225
+ generated.append(tid)
226
+
227
+ stop_ids = set()
228
+ if tok.eos_token_id is not None:
229
+ stop_ids.add(int(tok.eos_token_id))
230
+ for s in ("<|eot_id|>", "<|eom_id|>"):
231
+ t = tok.convert_tokens_to_ids(s)
232
+ if isinstance(t, int) and t >= 0:
233
+ stop_ids.add(t)
234
+ if tid in stop_ids:
235
+ break
236
+ cur = nxt
237
+
238
+ out.append(generated)
239
+ return out, ids
240
+
241
+
242
+ def sequence_logprobs(model, full_ids, prompt_len, response_lens):
243
+ """Differentiable mean log p(sampled response tokens), excluding padding."""
244
+ logits, _ = model(full_ids)
245
+ lp = F.log_softmax(logits[:, :-1, :], dim=-1)
246
+ targets = full_ids[:, 1:]
247
+ start = max(0, prompt_len - 1)
248
+ chosen = lp[:, start:, :].gather(
249
+ -1, targets[:, start:].unsqueeze(-1)
250
+ ).squeeze(-1)
251
+ mask = torch.zeros_like(chosen)
252
+ for i, n in enumerate(response_lens):
253
+ mask[i, :int(n)] = 1.0
254
+ sums = (chosen * mask).sum(dim=-1)
255
+ means = sums / mask.sum(dim=-1).clamp_min(1.0)
256
+ return sums, means
257
+
258
+
259
+ def main():
260
+ ap = argparse.ArgumentParser()
261
+ ap.add_argument("--model-size", default="1.7b")
262
+ ap.add_argument("--tokenizer", required=True)
263
+ ap.add_argument("--checkpoint", required=True)
264
+ ap.add_argument("--dataset", required=True)
265
+ ap.add_argument("--max-steps", type=int, default=30)
266
+ ap.add_argument("--group-size", type=int, default=4)
267
+ ap.add_argument("--max-new", type=int, default=64)
268
+ ap.add_argument("--temperature", type=float, default=0.8)
269
+ ap.add_argument("--top-p", type=float, default=0.95)
270
+ ap.add_argument("--lr", type=float, default=5e-6)
271
+ ap.add_argument("--kl-beta", type=float, default=0.02)
272
+ ap.add_argument("--block-size", type=int, default=4096)
273
+ ap.add_argument("--save-every", type=int, default=10)
274
+ ap.add_argument("--out-dir", default="/tmp/V24-GRPO-chain-smoke")
275
+ args = ap.parse_args()
276
+
277
+ random.seed(42)
278
+ torch.manual_seed(42)
279
+ device = "cuda" if torch.cuda.is_available() else "cpu"
280
+ dtype = torch.bfloat16 if device == "cuda" else torch.float32
281
+ out = Path(args.out_dir)
282
+ out.mkdir(parents=True, exist_ok=True)
283
+
284
+ print("V24 GRPO CHAIN V6 STRUCTURAL REWARD", flush=True)
285
+ print(f"device={device} dtype={dtype}", flush=True)
286
+
287
+ tok = AutoTokenizer.from_pretrained(args.tokenizer)
288
+ if tok.pad_token is None:
289
+ tok.pad_token = tok.eos_token
290
+ if not getattr(tok, "chat_template", None):
291
+ raise RuntimeError("Tokenizer has no chat_template")
292
+
293
+ ds = load_dataset(args.dataset, split="train")
294
+ rows = extract_chain_rows(ds)
295
+ if len(rows) < args.group_size:
296
+ raise RuntimeError(f"Only {len(rows)} chain rows found")
297
+ random.shuffle(rows)
298
+ print(f"chain_rows={len(rows)}", flush=True)
299
+
300
+ cfg = preset_config(
301
+ args.model_size, len(tok), args.block_size, 0.0,
302
+ 0.8309193524478643, 0.0, gradient_checkpointing=False
303
+ )
304
+ model = ScugnizzDecoder(cfg).to(device=device, dtype=dtype)
305
+ ck = load_checkpoint(args.checkpoint, "cpu")
306
+ state = ck["model"] if isinstance(ck, dict) and "model" in ck else ck
307
+ model.load_state_dict(state, strict=True)
308
+ del ck
309
+ if device == "cuda":
310
+ torch.cuda.empty_cache()
311
+
312
+ # Frozen reference = exact V22-best before GRPO.
313
+ ref = copy.deepcopy(model).eval()
314
+ for p in ref.parameters():
315
+ p.requires_grad_(False)
316
+
317
+ opt = torch.optim.AdamW(model.parameters(), lr=args.lr, betas=(0.9, 0.95),
318
+ weight_decay=0.0)
319
+
320
+ step = 0
321
+ for step in range(1, args.max_steps + 1):
322
+ prefix, expected = rows[(step - 1) % len(rows)]
323
+ prompt = make_prompt(tok, prefix)
324
+
325
+ sampled, prompt_ids = sample_group(
326
+ model, tok, prompt, args.group_size, args.max_new,
327
+ args.temperature, args.top_p, device, args.block_size
328
+ )
329
+ prompt_len = len(prompt_ids)
330
+
331
+ texts = [tok.decode(x, skip_special_tokens=True) for x in sampled]
332
+ rewards = torch.tensor(
333
+ [reward_chain(t, expected) for t in texts],
334
+ device=device, dtype=torch.float32
335
+ )
336
+
337
+ # GRPO group-relative advantage.
338
+ adv = (rewards - rewards.mean()) / (rewards.std(unbiased=False) + 1e-4)
339
+
340
+ full = []
341
+ for gen in sampled:
342
+ ids = prompt_ids + gen
343
+ ids = ids[-args.block_size:]
344
+ full.append(ids)
345
+
346
+ # Pad group to a rectangular batch. Padding is outside the scored span.
347
+ maxlen = max(len(x) for x in full)
348
+ pad = tok.pad_token_id
349
+ batch = []
350
+ response_lens = [len(g) for g in sampled]
351
+ for ids in full:
352
+ batch.append(ids + [pad] * (maxlen - len(ids)))
353
+ x = torch.tensor(batch, dtype=torch.long, device=device)
354
+
355
+ # Recompute current-policy log-probs with gradients.
356
+ cur_sum, cur_mean = sequence_logprobs(
357
+ model, x, prompt_len, response_lens
358
+ )
359
+
360
+ with torch.no_grad():
361
+ ref_logits, _ = ref(x)
362
+ ref_lp = F.log_softmax(ref_logits[:, :-1, :], dim=-1)
363
+ targets = x[:, 1:]
364
+ start = max(0, prompt_len - 1)
365
+ ref_tok = ref_lp[:, start:, :].gather(
366
+ -1, targets[:, start:].unsqueeze(-1)
367
+ ).squeeze(-1)
368
+ ref_mask = torch.zeros_like(ref_tok)
369
+ for i, n in enumerate(response_lens):
370
+ ref_mask[i, :int(n)] = 1.0
371
+ ref_mean = (ref_tok * ref_mask).sum(dim=-1) / ref_mask.sum(dim=-1).clamp_min(1.0)
372
+ # Sampled-token KL proxy; gradients flow through cur_mean only.
373
+ kl = cur_mean - ref_mean
374
+
375
+ # Normalize by response length for stable small-batch updates.
376
+ policy_loss = -(adv.detach() * cur_mean).mean()
377
+ kl_loss = args.kl_beta * kl.mean()
378
+ loss = policy_loss + kl_loss
379
+
380
+ opt.zero_grad(set_to_none=True)
381
+ loss.backward()
382
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
383
+ opt.step()
384
+
385
+ print(
386
+ f"step={step:03d} expected={expected} "
387
+ f"reward_mean={rewards.mean().item():+.3f} "
388
+ f"reward_max={rewards.max().item():+.3f} "
389
+ f"adv_std={adv.std(unbiased=False).item():.3f} "
390
+ f"loss={loss.item():+.5f} kl={kl.mean().item():+.5f}",
391
+ flush=True
392
+ )
393
+ for j, (t, r) in enumerate(zip(texts, rewards.tolist())):
394
+ print(f" sample{j}: r={r:+.2f} {t[:180]!r}", flush=True)
395
+
396
+ if step % args.save_every == 0 or step == args.max_steps:
397
+ torch.save(
398
+ {"model": model.state_dict(), "step": step},
399
+ out / f"checkpoint_step_{step:03d}.pt"
400
+ )
401
+
402
+ torch.save({"model": model.state_dict(), "step": step},
403
+ out / "checkpoint_last.pt")
404
+ print(f"DONE out={out}", flush=True)
405
+
406
+
407
+ if __name__ == "__main__":
408
+ main()
409
+
410
+