HarshitShri026 commited on
Commit
e80ab65
·
1 Parent(s): 8006bcf
Files changed (3) hide show
  1. README.md +1 -0
  2. train/README-GRPO.md +3 -2
  3. train/kaggle_grpo_league.py +900 -0
README.md CHANGED
@@ -173,6 +173,7 @@ cyber_selfplay/
173
  │ ├── _bootstrap.py # sys.path bootstrap shared by all scripts
174
  │ ├── README-GRPO.md # SFT+GRPO pipeline (Kaggle/Space) + theme alignment
175
  │ ├── kaggle_grpo.py # one-cell SFT+GRPO (Kaggle; primary tuned path)
 
176
  │ ├── grpo_space.py # same pipeline for HF Space / Docker
177
  │ ├── pfsp.py # PFSP opponent sampling
178
  │ ├── psro_meta.py # Replicator meta-solver
 
173
  │ ├── _bootstrap.py # sys.path bootstrap shared by all scripts
174
  │ ├── README-GRPO.md # SFT+GRPO pipeline (Kaggle/Space) + theme alignment
175
  │ ├── kaggle_grpo.py # one-cell SFT+GRPO (Kaggle; primary tuned path)
176
+ │ ├── kaggle_grpo_league.py # SFT + PFSP/PSRO league + GRPO per round (Kaggle)
177
  │ ├── grpo_space.py # same pipeline for HF Space / Docker
178
  │ ├── pfsp.py # PFSP opponent sampling
179
  │ ├── psro_meta.py # Replicator meta-solver
train/README-GRPO.md CHANGED
@@ -4,8 +4,9 @@ This document is the **reference** for the **SFT + GRPO** track: supervised warm
4
 
5
  | File | When to use |
6
  | --- | --- |
7
- | `train/kaggle_grpo.py` | Kaggle: one cell, Unsloth + TRL, plots, per-step logs, optional Hub push |
8
- | `train/grpo_space.py` | Hugging Face Space / Docker: same loop, `ENV`-driven configuration |
 
9
 
10
  The **same** repository also ships **league / PFSP / PSRO** utilities (`train/pfsp.py`, `train/psro_meta.py`, `run_demo.py`, `colab_trl_selfplay.py` with league flags) for **population-based** training. Those modules are **independent** entry points: you can run SFT+GRPO alone, run league demos alone, or later **compose** a GRPO checkpoint into a league pool.
11
 
 
4
 
5
  | File | When to use |
6
  | --- | --- |
7
+ | `train/kaggle_grpo.py` | Kaggle: one cell, SFT + single GRPO phase (fastest default) |
8
+ | `train/kaggle_grpo_league.py` | Kaggle: **SFT + league rounds** — **PFSP** / **PSRO** / **mix** opponent pick (`OPP_SAMPLE_MODE`), **PSRO** replicator on heuristic payoffs, **mini-GRPO** per round, aligned Red preamble; `HF_SPACE_CLONE` env to point at your Space |
9
+ | `train/grpo_space.py` | Hugging Face Space / Docker: `ENV`-driven configuration |
10
 
11
  The **same** repository also ships **league / PFSP / PSRO** utilities (`train/pfsp.py`, `train/psro_meta.py`, `run_demo.py`, `colab_trl_selfplay.py` with league flags) for **population-based** training. Those modules are **independent** entry points: you can run SFT+GRPO alone, run league demos alone, or later **compose** a GRPO checkpoint into a league pool.
12
 
train/kaggle_grpo_league.py ADDED
@@ -0,0 +1,900 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # CyberSelfPlay — Kaggle: SFT + League (PFSP/PSRO) + GRPO (full merge, single cell)
3
+ #
4
+ # Phase 1: same SFT as kaggle_grpo.py
5
+ # Phase 2: for each league round (LEAGUE_ROUNDS):
6
+ # • PFSP sample Red archetype (R-easy / R-mid / R-hard) from a pool
7
+ # • build GRPO prompts with that Red’s rollout (exploit count range)
8
+ # • compute_rewards uses the SAME profile (aligned env preamble)
9
+ # • mini-GRPO: GRPO_STEPS_PER_ROUND steps, same LoRA
10
+ # • update pool win-rates; PSRO: replicator on heuristic-eval payoffs
11
+ #
12
+ # Slower than kaggle_grpo.py — reduce LEAGUE_ROUNDS or GRPO_STEPS_PER_ROUND.
13
+ # If train.pfsp import fails, fallbacks are inlined.
14
+ # =============================================================================
15
+
16
+ # ---------- 1) Install Unsloth + TRL + OpenEnv deps ----------
17
+ get_ipython().system('pip install -q "unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git"')
18
+ get_ipython().system('pip install -q --upgrade git+https://github.com/huggingface/trl.git')
19
+ get_ipython().system('pip install -q sympy scipy fastapi uvicorn datasets pydantic openenv-core huggingface_hub matplotlib')
20
+
21
+ # ---------- 2) Clone your deployed Hugging Face Space (edit URL if you fork) ----------
22
+ import os, sys, shutil
23
+ HF_SPACE_CLONE = os.environ.get(
24
+ "HF_SPACE_CLONE",
25
+ "https://huggingface.co/spaces/HarshitShri026/cyberselfplay-env",
26
+ )
27
+ os.chdir("/kaggle/working")
28
+ shutil.rmtree("/kaggle/working/cyberselfplay-env", ignore_errors=True)
29
+ get_ipython().system("git clone " + HF_SPACE_CLONE)
30
+ os.chdir("/kaggle/working/cyberselfplay-env")
31
+ sys.path.insert(0, "/kaggle/working/cyberselfplay-env")
32
+ get_ipython().system("pip install -q -e .")
33
+
34
+ # ---------- 3) Wipe any prior checkpoint ----------
35
+ shutil.rmtree("/kaggle/working/cyberselfplay-env/outputs_cyber", ignore_errors=True)
36
+
37
+ # ---------- 4) Silence noisy warnings ----------
38
+ import warnings, logging
39
+ warnings.filterwarnings("ignore", category=FutureWarning)
40
+ warnings.filterwarnings("ignore", category=UserWarning)
41
+ logging.getLogger("transformers").setLevel(logging.ERROR)
42
+ logging.getLogger("trl").setLevel(logging.WARNING)
43
+
44
+ # ---------- 5) Imports (Unsloth FIRST) ----------
45
+ import unsloth
46
+ import torch, json, random, re
47
+ from pathlib import Path
48
+ from datasets import Dataset
49
+ from unsloth import FastLanguageModel
50
+ from trl import SFTTrainer, SFTConfig, GRPOConfig, GRPOTrainer
51
+
52
+ from cyber_selfplay_env.environment import CyberSelfPlayEnvironment
53
+ from cyber_selfplay_env.models import CyberAction
54
+ from cyber_selfplay_env.tools_blue import BLUE_TOOLS
55
+
56
+ try:
57
+ from train.pfsp import OpponentStats, sample_opponent, pfsp_weight
58
+ from train.psro_meta import replicator_update, normalize as psro_normalize
59
+ except Exception: # noqa: BLE001
60
+ from dataclasses import dataclass
61
+
62
+ @dataclass
63
+ class OpponentStats:
64
+ name: str
65
+ win_rate_vs_learner: float
66
+ games: int = 0
67
+
68
+ def pfsp_weight(w: float) -> float:
69
+ w = min(1.0, max(0.0, w))
70
+ return w * (1.0 - w)
71
+
72
+ def sample_opponent(opponents):
73
+ pool = list(opponents)
74
+ weights = [pfsp_weight(o.win_rate_vs_learner) for o in pool]
75
+ t = sum(weights)
76
+ if t <= 0:
77
+ return random.choice(pool)
78
+ return random.choices(pool, weights=weights, k=1)[0]
79
+
80
+ def psro_normalize(xs):
81
+ s = sum(max(0.0, x) for x in xs)
82
+ if s <= 0:
83
+ return [1.0 / len(xs)] * len(xs)
84
+ return [max(0.0, x) / s for x in xs]
85
+
86
+ def replicator_update(payoff_row, current, eta=0.2):
87
+ u_bar = sum(p * u for p, u in zip(current, payoff_row))
88
+ nxt = [p * (1.0 + eta * (u - u_bar)) for p, u in zip(current, payoff_row)]
89
+ return psro_normalize(nxt)
90
+
91
+ # ---------- 6) Env vars ----------
92
+ os.environ["TRANSFORMERS_VERBOSITY"] = "error"
93
+ os.environ["TOKENIZERS_PARALLELISM"] = "false"
94
+ os.environ["HF_HUB_ENABLE_HF_TRANSFER"] = "1"
95
+ os.environ["WANDB_DISABLED"] = "true"
96
+ os.environ["BITSANDBYTES_NOWELCOME"] = "1"
97
+ if torch.cuda.device_count() > 1:
98
+ os.environ["CUDA_VISIBLE_DEVICES"] = "0"
99
+ print("Kaggle T4 x2 detected -- using GPU 0 only (Unsloth single-GPU).")
100
+
101
+ # ---------- 7) Precision ----------
102
+ has_cuda = torch.cuda.is_available()
103
+ use_bf16 = has_cuda and torch.cuda.is_bf16_supported()
104
+ use_fp16 = has_cuda and not use_bf16
105
+ print(f"CUDA: {has_cuda} | bf16: {use_bf16} | fp16: {use_fp16}")
106
+ print(f"GPU: {torch.cuda.get_device_name(0) if has_cuda else 'none'}")
107
+
108
+ # ---------- 8) Model load ----------
109
+ MAX_SEQ_LEN = 1024
110
+ LORA_RANK = 16
111
+ BASE_MODEL = "unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit"
112
+
113
+ model, tokenizer = FastLanguageModel.from_pretrained(
114
+ model_name=BASE_MODEL,
115
+ max_seq_length=MAX_SEQ_LEN,
116
+ dtype=None,
117
+ load_in_4bit=True,
118
+ )
119
+ tokenizer.model_max_length = MAX_SEQ_LEN
120
+ if tokenizer.pad_token is None:
121
+ tokenizer.pad_token = tokenizer.eos_token
122
+
123
+ model = FastLanguageModel.get_peft_model(
124
+ model,
125
+ r=LORA_RANK,
126
+ target_modules=["q_proj","k_proj","v_proj","o_proj","gate_proj","up_proj","down_proj"],
127
+ lora_alpha=LORA_RANK,
128
+ use_gradient_checkpointing="unsloth",
129
+ )
130
+
131
+ # Clear `generation_config.max_length` so it does NOT collide with `max_new_tokens`
132
+ # at every `model.generate(...)` call (silences the spammy transformers warning).
133
+ if getattr(model, "generation_config", None) is not None:
134
+ model.generation_config.max_length = None
135
+ model.generation_config.max_new_tokens = None
136
+
137
+ IS_LLAMA = "llama" in BASE_MODEL.lower()
138
+ IS_QWEN = "qwen" in BASE_MODEL.lower()
139
+
140
+ # ---------- 9) Prompt builder (single source of truth) + JSON parser ----------
141
+ # Both SFT data and GRPO inference go through tokenizer.apply_chat_template
142
+ # so the formats are byte-identical. NO hand-written prompt strings anywhere.
143
+ VALID_BLUE = list(BLUE_TOOLS)
144
+
145
+ SYSTEM_MSG = (
146
+ "You are the Blue defender in a cyber self-play game.\n"
147
+ "You MUST output ONLY a valid JSON object.\n"
148
+ "No explanation. No extra text. No prefixes/suffixes.\n"
149
+ "Output exactly ONE JSON object and stop.\n\n"
150
+ f"Schema: {{\"actor\":\"blue\",\"tool_name\":<one of {sorted(VALID_BLUE)}>,"
151
+ "\"target\":\"host-XX\",\"params\":{},\"rationale\":\"short\"}}"
152
+ )
153
+
154
+ def _user_msg(obs_dict: dict) -> str:
155
+ return (
156
+ f"Observation:\n{json.dumps(obs_dict, ensure_ascii=True)}\n\n"
157
+ "Reply with ONE JSON line ending with '}'. Nothing else."
158
+ )
159
+
160
+ def obs_to_prompt(obs_dict: dict) -> str:
161
+ """Inference prompt. Same chat template SFT used, with assistant header appended."""
162
+ return tokenizer.apply_chat_template(
163
+ [
164
+ {"role": "system", "content": SYSTEM_MSG},
165
+ {"role": "user", "content": _user_msg(obs_dict)},
166
+ ],
167
+ tokenize=False,
168
+ add_generation_prompt=True,
169
+ )
170
+
171
+ # JSON_RE = re.compile(r"\{[^{}]*\}", re.DOTALL)
172
+
173
+ JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
174
+
175
+ def parse_action(text: str):
176
+ matches = JSON_RE.findall(text)
177
+ if not matches:
178
+ return None, False
179
+
180
+ for m in matches:
181
+ try:
182
+ a = json.loads(m)
183
+ if a.get("tool_name") in VALID_BLUE:
184
+ a["actor"] = "blue"
185
+ a.setdefault("target", "host-00")
186
+ a.setdefault("params", {})
187
+ a.setdefault("rationale", "grpo")
188
+ return a, True
189
+ except:
190
+ continue
191
+
192
+ return None, False
193
+
194
+ # =============================================================================
195
+ # PHASE 1 -- SFT warm-start (imitate heuristic Blue policy)
196
+ # =============================================================================
197
+
198
+ # ---------- 10) Heuristic Blue policy (lifted from train/colab_trl_selfplay.py) ----------
199
+ # Real catalog of `required_tool` values used by the env's instruction system
200
+ # (see cyber_selfplay_env/simulator.py::_build_instructions). Picking from this
201
+ # list means execute_instruction has a real chance (1/8) of matching.
202
+ INSTRUCTION_TOOLS = [
203
+ "triage_alerts", "isolate_host", "deploy_patch", "rotate_secrets",
204
+ "run_forensics", "restore_backup", "harden_policy", "publish_ioc_blocklist",
205
+ ]
206
+
207
+ RATIONALE = {
208
+ "query_siem": "scan telemetry",
209
+ "triage_alerts": "investigate alert",
210
+ "isolate_host": "contain breach",
211
+ "disable_account": "lock compromised user",
212
+ "rotate_secrets": "remove persistence",
213
+ "deploy_patch": "harden vulnerable host",
214
+ "harden_policy": "tighten controls",
215
+ "restore_backup": "recover service",
216
+ "run_forensics": "investigate host",
217
+ "publish_ioc_blocklist": "block known IOCs",
218
+ "execute_instruction": "follow playbook",
219
+ "checkpoint_plan": "track progress",
220
+ "reconcile_state": "stabilize state",
221
+ }
222
+
223
+ EPSILON_RANDOM = 0.40 # 40% pure exploration in SFT data => corpus covers all
224
+ # 13 tools roughly evenly, killing the "default action"
225
+ # bias that triggers post-SFT mode collapse.
226
+
227
+ def heuristic_blue_action(public_state: dict, telemetry: list, t_step: int) -> dict:
228
+ """State-conditional, diverse Blue policy.
229
+
230
+ The previous version only ever fired ~3 branches and ~80% of the data
231
+ became `execute_instruction`, which caused SFT mode collapse. This version
232
+ builds a *weighted candidate set* from the current observation and samples
233
+ one, plus a 20% epsilon-random arm. Result: the SFT corpus exercises all
234
+ 13 Blue tools with realistic targets, so the model learns the schema
235
+ BROADLY and GRPO has real diversity to do credit assignment on.
236
+ """
237
+ detections = telemetry or public_state.get("detections", []) or []
238
+ known = int(public_state.get("known_incident_count", 0) or 0)
239
+ instr = public_state.get("instruction_progress", {}) or {}
240
+ instr_pending = isinstance(instr, dict) and instr.get("completed", 0) < instr.get("total", 1)
241
+
242
+ rand_host = lambda: f"host-{random.randint(0, 5):02d}"
243
+
244
+ # 20% pure exploration: random valid tool with a syntactically correct payload.
245
+ if random.random() < EPSILON_RANDOM:
246
+ tool = random.choice(VALID_BLUE)
247
+ if tool == "execute_instruction":
248
+ target, params = "", {"required_tool": random.choice(INSTRUCTION_TOOLS)}
249
+ elif tool == "disable_account":
250
+ target, params = f"user-{random.randint(0, 3):02d}", {}
251
+ else:
252
+ target, params = rand_host(), {}
253
+ return {"actor": "blue", "tool_name": tool, "target": target,
254
+ "params": params, "rationale": RATIONALE.get(tool, "explore")}
255
+
256
+ # Build (tool, target, params, weight) candidates from the observation.
257
+ cands: list[tuple[str, str, dict, int]] = []
258
+
259
+ # Always-valid baseline (covers tools that always parse but pay little).
260
+ cands += [
261
+ ("query_siem", rand_host(), {}, 1),
262
+ ("checkpoint_plan", rand_host(), {}, 1),
263
+ ("reconcile_state", rand_host(), {}, 1),
264
+ ("deploy_patch", rand_host(), {}, 1),
265
+ ("harden_policy", rand_host(), {}, 1),
266
+ ("publish_ioc_blocklist", rand_host(), {}, 1),
267
+ ("disable_account", f"user-{random.randint(0, 3):02d}", {}, 1),
268
+ ]
269
+
270
+ if detections:
271
+ cands += [("triage_alerts", rand_host(), {}, 3)] # +2.0 reward when valid
272
+
273
+ if known > 0:
274
+ cands += [
275
+ ("isolate_host", rand_host(), {}, 3), # +3.0
276
+ ("rotate_secrets", rand_host(), {}, 2), # +4.0
277
+ ("run_forensics", rand_host(), {}, 2),
278
+ ("restore_backup", rand_host(), {}, 1),
279
+ ]
280
+
281
+ if instr_pending:
282
+ # weight=1 (was 2) -- execute_instruction was over-represented and caused
283
+ # the model to memorize it as the "safe default" action.
284
+ cands += [(
285
+ "execute_instruction", "",
286
+ {"required_tool": random.choice(INSTRUCTION_TOOLS)},
287
+ 1, # +1.2 if right, -1.0 if wrong
288
+ )]
289
+
290
+ if t_step > 0 and t_step % 10 == 0:
291
+ cands += [("checkpoint_plan", rand_host(), {}, 4)] # +2.0 on multiples of 10
292
+
293
+ # Weighted random sample
294
+ pool = [(tool, tgt, params) for tool, tgt, params, w in cands for _ in range(w)]
295
+ tool, target, params = random.choice(pool)
296
+ return {"actor": "blue", "tool_name": tool, "target": target,
297
+ "params": params, "rationale": RATIONALE.get(tool, "respond")}
298
+
299
+ # ---------- 11) Collect SFT dataset from real env rollouts ----------
300
+ # Uses TRL "messages" format -> chat template applied automatically AND
301
+ # prompt tokens are masked from the loss (only JSON tokens contribute).
302
+ print("\n===== Phase 1: collecting SFT data from heuristic policy =====")
303
+ N_SFT_EPISODES = 50 # ~50 episodes x ~20 steps = ~1000 (obs, action) pairs
304
+ RED_TOOLS = ["recon_network", "attempt_exploit", "lateral_move", "exfiltrate_data"]
305
+ random.seed(42)
306
+
307
+ sft_pairs = []
308
+ for ep in range(N_SFT_EPISODES):
309
+ env = CyberSelfPlayEnvironment()
310
+ obs = env.reset()
311
+ for t in range(40):
312
+ if obs.done: break
313
+ red_act = CyberAction(actor="red",
314
+ tool_name=RED_TOOLS[t % len(RED_TOOLS)],
315
+ target=f"host-{t % 6:02d}", params={})
316
+ red_obs = env.step(red_act)
317
+ if red_obs.done: break
318
+ blue_action_dict = heuristic_blue_action(
319
+ red_obs.public_state, red_obs.telemetry, t,
320
+ )
321
+ obs_payload = {
322
+ "public_state": red_obs.public_state,
323
+ "telemetry": red_obs.telemetry,
324
+ "incident_summary": red_obs.incident_summary,
325
+ }
326
+ sft_pairs.append({
327
+ "messages": [
328
+ {"role": "system", "content": SYSTEM_MSG},
329
+ {"role": "user", "content": _user_msg(obs_payload)},
330
+ {"role": "assistant", "content": json.dumps(blue_action_dict, ensure_ascii=True)},
331
+ ]
332
+ })
333
+ obs = env.step(CyberAction(**blue_action_dict))
334
+ print(f"Collected {len(sft_pairs)} SFT examples in messages format.")
335
+
336
+ # Print the action distribution -- if any single tool > 50%, SFT will collapse.
337
+ from collections import Counter
338
+ _dist = Counter(json.loads(p["messages"][-1]["content"])["tool_name"] for p in sft_pairs)
339
+ print("\n[SFT] Blue tool distribution in training data:")
340
+ for tool, n in _dist.most_common():
341
+ pct = 100 * n / len(sft_pairs)
342
+ bar = "#" * int(pct / 2)
343
+ print(f" {tool:<22s} {n:>4d} {pct:5.1f}% {bar}")
344
+ top_pct = max(_dist.values()) / len(sft_pairs)
345
+ if top_pct > 0.5:
346
+ print(f"\nWARNING: top tool is {top_pct:.0%} of data -- expect mode collapse.")
347
+ else:
348
+ print(f"\n[SFT] Diversity OK -- top tool is only {top_pct:.0%} of corpus.\n")
349
+
350
+ sft_dataset = Dataset.from_list(sft_pairs)
351
+
352
+ # Pre-apply the chat template -> Unsloth's SFTTrainer wants plain text input.
353
+ def _msgs_to_text(example):
354
+ return {"text": tokenizer.apply_chat_template(
355
+ example["messages"],
356
+ tokenize=False,
357
+ add_generation_prompt=False,
358
+ )}
359
+
360
+ sft_dataset = sft_dataset.map(_msgs_to_text, remove_columns=["messages"])
361
+ print("Sample SFT text:\n", sft_dataset[0]["text"][:400], "...\n")
362
+
363
+ # ---------- 12) SFT phase ----------
364
+ print("\n===== Phase 1: SFT (imitation learning, BFD-packed) =====")
365
+ sft_args = SFTConfig(
366
+ output_dir = "/kaggle/working/outputs_cyber/sft",
367
+ learning_rate = 2e-4,
368
+ per_device_train_batch_size = 4,
369
+ gradient_accumulation_steps = 4,
370
+ num_train_epochs = 1,
371
+ logging_steps = 1,
372
+ warmup_steps = 5,
373
+ optim = "adamw_8bit",
374
+ bf16 = use_bf16,
375
+ fp16 = use_fp16,
376
+ save_strategy = "no",
377
+ report_to = "none",
378
+ max_length = MAX_SEQ_LEN,
379
+ dataset_text_field = "text",
380
+ packing = True,
381
+ packing_strategy = "bfd",
382
+ )
383
+ sft_trainer = SFTTrainer(
384
+ model = model,
385
+ tokenizer = tokenizer,
386
+ args = sft_args,
387
+ train_dataset = sft_dataset,
388
+ )
389
+ sft_trainer.train()
390
+ print("SFT done.")
391
+
392
+ # ---------- 13) Sanity check after SFT (parses should be ~100%) ----------
393
+ # Use GREEDY decoding -- this is the truest signal of what SFT actually learned.
394
+ # Vary the env trajectory length per sample so you see different observations
395
+ # (and therefore different outputs), not 8 copies of the same one.
396
+ print("\n===== Post-SFT sanity check (greedy decoding, varied env states) =====")
397
+ FastLanguageModel.for_inference(model)
398
+ ok_count, n_check = 0, 8
399
+ example_prompt, example_gen = None, None
400
+ RED_T = ["recon_network", "attempt_exploit", "lateral_move", "exfiltrate_data"]
401
+ for i in range(n_check):
402
+ env_t = CyberSelfPlayEnvironment(); env_t.reset()
403
+ for t in range(random.randint(1, 6)):
404
+ env_t.step(CyberAction(actor="red",
405
+ tool_name=RED_T[t % len(RED_T)],
406
+ target=f"host-{random.randint(0,5):02d}"))
407
+ o = env_t.step(CyberAction(actor="red", tool_name="recon_network",
408
+ target=f"host-{random.randint(0,5):02d}"))
409
+ p = obs_to_prompt({"public_state": o.public_state, "telemetry": o.telemetry,
410
+ "incident_summary": o.incident_summary})
411
+ inp = tokenizer(p, return_tensors="pt").to(model.device)
412
+ out = model.generate(
413
+ **inp,
414
+ max_new_tokens=128,
415
+ do_sample=False,
416
+ pad_token_id=tokenizer.pad_token_id,
417
+ eos_token_id=tokenizer.eos_token_id,
418
+ )
419
+ gen = tokenizer.decode(out[0][inp.input_ids.shape[1]:], skip_special_tokens=True)
420
+ _, ok = parse_action(gen)
421
+ ok_count += int(ok)
422
+ print(f"sample {i+1} (parses={ok}): {gen.strip()[:200]}")
423
+ if example_prompt is None:
424
+ example_prompt, example_gen = p, gen
425
+
426
+ parse_rate = ok_count / n_check
427
+ print(f"\nSFT parse rate: {ok_count}/{n_check} = {parse_rate:.0%}")
428
+
429
+ if parse_rate < 0.5:
430
+ # Print one full prompt + generation so the user can SEE what the model is doing.
431
+ print("\n--- DEBUG: full inference prompt the model is being given ---")
432
+ print(example_prompt)
433
+ print("--- DEBUG: full model output ---")
434
+ print(example_gen)
435
+ print("--- DEBUG: example SFT training text ---")
436
+ print(sft_dataset[0]["text"][:1500])
437
+ print("--- end debug ---\n")
438
+ raise RuntimeError(
439
+ f"SFT parse rate too low ({parse_rate:.0%}). "
440
+ "GRPO will not learn -- aborting.\n"
441
+ "Fixes: (a) increase N_SFT_EPISODES (50 -> 100), "
442
+ "(b) increase num_train_epochs (4 -> 6), "
443
+ "(c) try a stronger BASE_MODEL (Qwen2.5-Coder-1.5B-Instruct-bnb-4bit)."
444
+ )
445
+ FastLanguageModel.for_training(model)
446
+
447
+ # =============================================================================
448
+ # PHASE 2 -- League (PFSP + PSRO) + GRPO (per-round)
449
+ # =============================================================================
450
+ from collections import Counter
451
+ from statistics import mean
452
+ from transformers import TrainerCallback
453
+ from IPython.display import clear_output
454
+ import matplotlib.pyplot as plt
455
+
456
+ # --- Tunables (Kaggle T4) ---
457
+ LEAGUE_ROUNDS = 2 # one PFSP/PSRO + mini-GRPO per round (must be >= 1)
458
+ GRPO_STEPS_PER_ROUND = 50
459
+ N_GRPO_PROMPTS = 64
460
+ PSRO_EVAL_EPISODES = 4 # heuristic episodes per Red profile for PSRO replicator
461
+ PSRO_ETA = 0.2
462
+ # How to pick the Red for each round: "pfsp" (f(w)=w(1-w)), "psro" (META_PROBS), "mix" (0.5/0.5)
463
+ OPP_SAMPLE_MODE = "pfsp"
464
+ assert LEAGUE_ROUNDS >= 1, "LEAGUE_ROUNDS must be >= 1 for the league+GRPO script."
465
+
466
+ # Red archetypes: (min, max) exploit pre-steps before final recon
467
+ RED_PROFILES = {
468
+ "R-easy": (0, 1), # light red pressure
469
+ "R-mid": (0, 4), # default spread
470
+ "R-hard": (3, 8), # heavy exploit preamble
471
+ }
472
+
473
+ def env_after_red_preamble(red_name: str):
474
+ """New env, random preamble following RED_PROFILES[red_name], then recon. Returns (env, obs before Blue)."""
475
+ e = CyberSelfPlayEnvironment()
476
+ e.reset()
477
+ lo, hi = RED_PROFILES[red_name]
478
+ n = random.randint(lo, hi)
479
+ for _ in range(n):
480
+ e.step(CyberAction(
481
+ actor="red", tool_name="attempt_exploit",
482
+ target=f"host-{random.randint(0, 5):02d}", params={},
483
+ ))
484
+ o = e.step(CyberAction(
485
+ actor="red", tool_name="recon_network",
486
+ target=f"host-{random.randint(0, 5):02d}", params={},
487
+ ))
488
+ return e, o
489
+
490
+ # PFSP pool (same as train_blue_vs_pool _seed_pool idea)
491
+ LEAGUE_POOL: list = [
492
+ OpponentStats("R-easy", 0.20, 10),
493
+ OpponentStats("R-mid", 0.50, 10),
494
+ OpponentStats("R-hard", 0.80, 10),
495
+ ]
496
+ META_PROBS = [1.0 / 3, 1.0 / 3, 1.0 / 3] # PSRO: replicator on heuristic-eval payoffs
497
+
498
+ # Set each league round; compute_rewards reads it for aligned Red preamble
499
+ LEAGUE_CURRENT_RED = "R-mid"
500
+
501
+ def _heuristic_payoff_vector() -> list[float]:
502
+ """Mean Blue return (heuristic policy) vs each red profile — for PSRO replicator."""
503
+ out = []
504
+ for name in ("R-easy", "R-mid", "R-hard"):
505
+ s = 0.0
506
+ for _ in range(PSRO_EVAL_EPISODES):
507
+ e, o = env_after_red_preamble(name)
508
+ a = heuristic_blue_action(o.public_state, o.telemetry, 0)
509
+ o2 = e.step(CyberAction(**a))
510
+ s += float(o2.reward or 0.0)
511
+ out.append(s / max(1, PSRO_EVAL_EPISODES))
512
+ return out
513
+
514
+ def _update_winrate(opp: OpponentStats, mean_r: float, n_g: int) -> None:
515
+ blue_won = 1.0 if mean_r > 0.0 else 0.0
516
+ new_g = opp.games + n_g
517
+ blended = (opp.win_rate_vs_learner * opp.games + (1.0 - blue_won) * n_g) / max(1, new_g)
518
+ opp.win_rate_vs_learner = float(min(1.0, max(0.0, blended)))
519
+ opp.games = new_g
520
+
521
+ INVALID_PENALTY = -1.0
522
+ DUP_TOOL_PENALTY = -0.5
523
+ INSTR_TOOL_PENALTY = -0.2
524
+ _REWARD_CALLS = {"n": 0}
525
+ _PER_STEP: list = [] # per compute_rewards call; reset at each sub-trainer
526
+ _PER_ROUND_STEP_BASE = 0 # for logging steps across mini-GRPOs
527
+
528
+ def compute_rewards(prompts, completions, **kwargs):
529
+ n = len(completions)
530
+ base = [0.0] * n
531
+ tools = [None] * n
532
+ parsed_ok = [False] * n
533
+ debug_text = None
534
+
535
+ for i, comp in enumerate(completions):
536
+ text = comp if isinstance(comp, str) else comp.get("content", "")
537
+ if debug_text is None:
538
+ debug_text = text
539
+ action, ok = parse_action(text)
540
+ if not ok:
541
+ base[i] = INVALID_PENALTY
542
+ continue
543
+ e, _o = env_after_red_preamble(LEAGUE_CURRENT_RED)
544
+ try:
545
+ obs = e.step(CyberAction(**action))
546
+ base[i] = float(obs.reward or 0.0)
547
+ tools[i] = action.get("tool_name")
548
+ parsed_ok[i] = True
549
+ except Exception:
550
+ base[i] = INVALID_PENALTY
551
+
552
+ counts = Counter(t for t in tools if t)
553
+ n_valid = sum(1 for t in tools if t)
554
+ rewards = []
555
+ for i in range(n):
556
+ r = base[i]
557
+ if parsed_ok[i] and tools[i]:
558
+ share = counts[tools[i]] / max(1, n_valid)
559
+ if share > 0.5:
560
+ r += DUP_TOOL_PENALTY * (share - 0.5) * 2
561
+ if tools[i] == "execute_instruction":
562
+ r += INSTR_TOOL_PENALTY
563
+ rewards.append(r)
564
+
565
+ _REWARD_CALLS["n"] += 1
566
+ _PER_STEP.append({
567
+ "call": _REWARD_CALLS["n"],
568
+ "rewards": rewards, "tools": tools, "parsed": sum(parsed_ok), "n": n,
569
+ "tool_dist": dict(counts),
570
+ })
571
+ if _REWARD_CALLS["n"] % 5 == 1 and debug_text:
572
+ top = ", ".join(f"{k}={v}" for k, v in counts.most_common(3))
573
+ print(f"[rewards] red={LEAGUE_CURRENT_RED} call={_REWARD_CALLS['n']:3d} "
574
+ f"parsed={sum(parsed_ok)}/{n} r_mean={sum(rewards)/n:+.2f} top={{{top}}}", flush=True)
575
+ return rewards
576
+
577
+ def build_grpo_prompts(n: int) -> list:
578
+ p = []
579
+ for _ in range(n):
580
+ _e, o = env_after_red_preamble(LEAGUE_CURRENT_RED)
581
+ p.append({"prompt": obs_to_prompt({
582
+ "public_state": o.public_state, "telemetry": o.telemetry,
583
+ "incident_summary": o.incident_summary,
584
+ })})
585
+ return p
586
+
587
+ # Logging dirs (per-round subfolders; CURVES_DIR used by Hub upload)
588
+ OUT_DIR = Path("/kaggle/working/outputs_cyber")
589
+ CURVES_DIR = OUT_DIR / "curves"
590
+ (OUT_DIR / "league").mkdir(parents=True, exist_ok=True)
591
+ CURVES_DIR.mkdir(parents=True, exist_ok=True)
592
+ MAIN_LOG = OUT_DIR / "train_metrics.log"
593
+ LEAGUE_JSONL = OUT_DIR / "league_state.jsonl"
594
+ JSONL_FILE = OUT_DIR / "per_step_rewards.jsonl"
595
+
596
+
597
+ def pick_red_opponent():
598
+ """Pick one of R-easy / R-mid / R-hard: PFSP, PSRO (meta-probs), or 50-50 mix."""
599
+ pool = LEAGUE_POOL
600
+ if OPP_SAMPLE_MODE == "psro":
601
+ j = random.choices(range(3), weights=META_PROBS, k=1)[0]
602
+ return pool[j]
603
+ if OPP_SAMPLE_MODE == "mix":
604
+ pf = [pfsp_weight(o.win_rate_vs_learner) for o in pool]
605
+ s0 = sum(pf)
606
+ pf = [x / s0 for x in pf] if s0 > 0 else [1 / 3, 1 / 3, 1 / 3]
607
+ bl = [0.5 * pf[i] + 0.5 * META_PROBS[i] for i in range(3)]
608
+ s1 = sum(bl)
609
+ w = [x / s1 for x in bl]
610
+ j = random.choices(range(3), weights=w, k=1)[0]
611
+ return pool[j]
612
+ return sample_opponent(pool)
613
+
614
+ class LivePlotCallback(TrainerCallback):
615
+ def __init__(self, round_idx: int, red_name: str):
616
+ self.h = []
617
+ self.r = round_idx
618
+ self.red_name = red_name
619
+ self._per_step_i = 0
620
+ self.curves = OUT_DIR / "curves" / f"round_{round_idx}_{red_name}"
621
+ self.curves.mkdir(parents=True, exist_ok=True)
622
+ self.latest = self.curves / "latest.png"
623
+ self.log_file = MAIN_LOG
624
+ if round_idx == 0:
625
+ self.log_file.write_text("step\tloss\treward\tround\tred\tr_min\tr_max\treward_std\tkl\n")
626
+ JSONL_FILE.write_text("")
627
+
628
+ def on_log(self, args, state, control, logs=None, **kw):
629
+ if not logs or "reward" not in logs:
630
+ return
631
+ gstep = _PER_ROUND_STEP_BASE + state.global_step
632
+ recent = _PER_STEP[self._per_step_i :]
633
+ if not recent:
634
+ return
635
+ self._per_step_i = len(_PER_STEP)
636
+ flat_rewards = [r for c in recent for r in c["rewards"]]
637
+ flat_tools = [t for c in recent for t in c["tools"] if t]
638
+ uq = len(set(flat_tools))
639
+ rmin = min(flat_rewards) if flat_rewards else 0.0
640
+ rmax = max(flat_rewards) if flat_rewards else 0.0
641
+ loss = logs.get("loss", logs.get("train_loss", 0.0))
642
+ row = {**logs, "step": gstep, "round": self.r, "red": self.red_name,
643
+ "reward_min": rmin, "reward_max": rmax, "n_unique_tools": uq,
644
+ "individuals": flat_rewards}
645
+ self.h.append(row)
646
+ print(
647
+ f"[R{self.r} {self.red_name}] step {gstep:4d} loss={loss:+.4f} "
648
+ f"rew={logs.get('reward',0):+.2f} min/max={rmin:+.2f}/{rmax:+.2f} uq={uq}",
649
+ flush=True,
650
+ )
651
+ with self.log_file.open("a") as f:
652
+ f.write(f"{gstep}\t{loss:.6f}\t{logs.get('reward',0):.6f}\t{self.r}\t{self.red_name}\t"
653
+ f"{rmin:.6f}\t{rmax:.6f}\t{logs.get('reward_std',0):.6f}\t{logs.get('kl',0):.6f}\n")
654
+ with JSONL_FILE.open("a") as f:
655
+ f.write(json.dumps({
656
+ "step": gstep, "round": self.r, "red": self.red_name,
657
+ "rewards": flat_rewards, "tools": flat_tools,
658
+ }) + "\n")
659
+ with LEAGUE_JSONL.open("a") as f:
660
+ f.write(json.dumps({
661
+ "round": self.r, "red": self.red_name, "global_step": gstep,
662
+ "pool": [(o.name, o.win_rate_vs_learner) for o in LEAGUE_POOL],
663
+ "meta_probs": list(META_PROBS),
664
+ }) + "\n")
665
+ clear_output(wait=True)
666
+ fig, ax = plt.subplots(1, 2, figsize=(12, 4))
667
+ steps = [x["step"] for x in self.h]
668
+ ax[0].plot(steps, [x.get("reward", 0) for x in self.h], "b-")
669
+ ax[0].set_title(f"round {self.r} {self.red_name} mean reward")
670
+ ax[0].grid(True, alpha=0.3)
671
+ ax[1].plot(steps, [x.get("reward_std", 0) for x in self.h], color="orange")
672
+ ax[1].set_title("reward_std"); ax[1].grid(True, alpha=0.3)
673
+ plt.tight_layout()
674
+ fig.savefig(self.curves / f"step_{state.global_step:04d}.png", dpi=100)
675
+ fig.savefig(self.latest, dpi=100)
676
+ plt.show()
677
+
678
+ # ---------- 18) League + GRPO loop ----------
679
+ all_hist: list = []
680
+ print("\n===== Phase 2: League (PFSP) + mini-GRPO + PSRO replicator =====")
681
+ for r in range(LEAGUE_ROUNDS):
682
+ opp = sample_opponent(LEAGUE_POOL)
683
+ LEAGUE_CURRENT_RED = opp.name
684
+ print(f"\n--- League round {r+1}/{LEAGUE_ROUNDS}: [{OPP_SAMPLE_MODE}] Red = {LEAGUE_CURRENT_RED} "
685
+ f"(pool w~={opp.win_rate_vs_learner:.2f}) ---")
686
+
687
+ _REWARD_CALLS["n"] = 0
688
+ _PER_STEP.clear()
689
+ train_dataset = Dataset.from_list(build_grpo_prompts(N_GRPO_PROMPTS))
690
+ print(f" Built {len(train_dataset)} GRPO prompts for {LEAGUE_CURRENT_RED}.")
691
+
692
+ training_args = GRPOConfig(
693
+ output_dir = f"/kaggle/working/outputs_cyber/grpo_r{r}_{opp.name}",
694
+ learning_rate = 5e-6,
695
+ per_device_train_batch_size= 2,
696
+ gradient_accumulation_steps= 4,
697
+ num_generations = 8,
698
+ max_completion_length = 128,
699
+ max_steps = GRPO_STEPS_PER_ROUND,
700
+ logging_steps = 2,
701
+ warmup_steps = 6,
702
+ optim = "adamw_8bit",
703
+ bf16 = use_bf16,
704
+ fp16 = use_fp16,
705
+ use_cpu = not has_cuda,
706
+ report_to = "none",
707
+ temperature = 1.0,
708
+ top_p = 0.95,
709
+ beta = 0.02,
710
+ max_grad_norm = 1.0,
711
+ )
712
+
713
+ trainer = GRPOTrainer(
714
+ model = model,
715
+ reward_funcs = [compute_rewards],
716
+ args = training_args,
717
+ train_dataset = train_dataset,
718
+ callbacks = [LivePlotCallback(r, opp.name)],
719
+ )
720
+ trainer.train()
721
+ all_hist.append({"round": r, "red": opp.name, "log_history": list(trainer.state.log_history or [])})
722
+ if trainer.state.log_history:
723
+ last_r = [x.get("reward", 0) for x in trainer.state.log_history if "reward" in x]
724
+ round_mean = mean(last_r) if last_r else 0.0
725
+ else:
726
+ round_mean = 0.0
727
+ _update_winrate(opp, round_mean, max(1, GRPO_STEPS_PER_ROUND // 2))
728
+
729
+ payoff = _heuristic_payoff_vector()
730
+ META_PROBS = replicator_update(payoff, META_PROBS, PSRO_ETA) # type: ignore[assignment]
731
+ print(f" Heuristic payoffs (easy/mid/hard) = {[round(x,3) for x in payoff]}")
732
+ print(f" PSRO meta-probs = {[round(p,3) for p in META_PROBS]}")
733
+ print(f" Pool win-rates: {[(o.name, round(o.win_rate_vs_learner,2)) for o in LEAGUE_POOL]}")
734
+
735
+ _PER_ROUND_STEP_BASE += GRPO_STEPS_PER_ROUND
736
+ (OUT_DIR / f"log_history_r{r}_{opp.name}.json").write_text(
737
+ json.dumps(trainer.state.log_history, indent=2)
738
+ )
739
+ FastLanguageModel.for_training(model) # Unsloth mode after for_inference if any
740
+
741
+ (OUT_DIR / "league_log.json").write_text(json.dumps(all_hist, indent=2))
742
+ comb = []
743
+ for h in all_hist:
744
+ for row in h.get("log_history") or []:
745
+ comb.append({**row, "league_round": h.get("round"), "round_red": h.get("red")})
746
+ (OUT_DIR / "log_history_combined.json").write_text(json.dumps(comb, indent=2))
747
+ print(f"\nWrote {OUT_DIR / 'league_log.json'}, {OUT_DIR / 'log_history_combined.json'}, {LEAGUE_JSONL}")
748
+ # `trainer` / `training_args` refer to the last league round (used in §19 + save)
749
+
750
+ # ---------- 19) Final summary plot + raw metrics dump (last round + optional combined) ----------
751
+ OUT = Path(training_args.output_dir)
752
+ OUT.mkdir(parents=True, exist_ok=True)
753
+ history = list(trainer.state.log_history or [])
754
+ (OUT / "log_history.json").write_text(json.dumps(history, indent=2))
755
+ history_combined = comb # all rounds, with league_round in each row
756
+
757
+ def _series(key):
758
+ xs, ys = [], []
759
+ for r in history:
760
+ if key in r and "step" in r:
761
+ xs.append(r["step"]); ys.append(r[key])
762
+ return xs, ys
763
+
764
+ fig, axes = plt.subplots(2, 2, figsize=(13, 9))
765
+ fig.suptitle("CyberSelfPlay SFT+GRPO -- training curves", fontsize=14)
766
+
767
+ ax = axes[0, 0]
768
+ rx, ry = _series("reward"); sx, sy = _series("reward_std")
769
+ if rx: ax.plot(rx, ry, color="#2563eb", linewidth=2, label="env reward")
770
+ if sx and len(ry) == len(sy):
771
+ ax.fill_between(sx, [m-s for m,s in zip(ry,sy)], [m+s for m,s in zip(ry,sy)],
772
+ color="#2563eb", alpha=0.15, label="±1 std")
773
+ ax.axhline(0, color="gray", linestyle=":", alpha=0.5)
774
+ ax.set_title("Env reward progression"); ax.set_xlabel("step")
775
+ ax.legend(fontsize=8); ax.grid(alpha=0.3)
776
+
777
+ ax = axes[0, 1]
778
+ kx, ky = _series("kl")
779
+ if kx: ax.plot(kx, ky, color="#9333ea", linewidth=2)
780
+ ax.set_title("KL divergence"); ax.set_xlabel("step"); ax.grid(alpha=0.3)
781
+
782
+ ax = axes[1, 0]
783
+ lx, ly = _series("loss")
784
+ if not lx: lx, ly = _series("train_loss")
785
+ if lx: ax.plot(lx, ly, color="#be123c", linewidth=2)
786
+ ax.set_title("Training loss"); ax.set_xlabel("step"); ax.grid(alpha=0.3)
787
+
788
+ ax = axes[1, 1]
789
+ for k, color, ls in [("completions/mean_length", "#0891b2", "-"),
790
+ ("completions/min_length", "#64748b", "--"),
791
+ ("completions/max_length", "#64748b", ":")]:
792
+ xs, ys = _series(k)
793
+ if xs: ax.plot(xs, ys, color=color, linestyle=ls, label=k.split("/")[-1])
794
+ ax.set_title("Completion length"); ax.set_xlabel("step")
795
+ ax.legend(fontsize=8); ax.grid(alpha=0.3)
796
+
797
+ fig.tight_layout(rect=[0, 0, 1, 0.96])
798
+ fig.savefig(OUT / "training_curves.png", dpi=120, bbox_inches="tight")
799
+ plt.show()
800
+ print(f"Saved -> {OUT/'training_curves.png'} and {OUT/'log_history.json'}")
801
+
802
+ # Combined plot over all league rounds
803
+ def _series_c(rows, key):
804
+ xs, ys = [], []
805
+ for row in rows:
806
+ if key in row and "step" in row:
807
+ xs.append(row["step"])
808
+ ys.append(row[key])
809
+ return xs, ys
810
+
811
+ if history_combined:
812
+ fig2, ax2 = plt.subplots(1, 1, figsize=(10, 4))
813
+ cx, cy = _series_c(history_combined, "reward")
814
+ if cx:
815
+ ax2.plot(cx, cy, color="#1d4ed8", linewidth=1.5)
816
+ ax2.axhline(0, color="gray", ls=":", alpha=0.5)
817
+ ax2.set_title("Mean reward (all league rounds, TRL log steps, combined export)")
818
+ ax2.set_xlabel("step"); ax2.grid(alpha=0.3)
819
+ fig2.tight_layout()
820
+ fig2.savefig(OUT_DIR / "training_curves_all_rounds.png", dpi=120, bbox_inches="tight")
821
+ plt.show()
822
+ print(f"Saved -> {OUT_DIR / 'training_curves_all_rounds.png'}")
823
+
824
+ # ---------- 20) Save adapter ----------
825
+ SAVE_DIR = "/kaggle/working/outputs_cyber/cyber-blue-grpo-lora"
826
+ trainer.save_model(SAVE_DIR)
827
+ tokenizer.save_pretrained(SAVE_DIR)
828
+ print(f"\nSaved LoRA adapter to {SAVE_DIR}")
829
+
830
+ # ---------- 21) (Optional) Push to HF Hub ----------
831
+ PUSH_TO_HUB = True
832
+ HF_TARGET_REPO = "HarshitShri026/cyber-blue-grpo"
833
+
834
+ try:
835
+ from kaggle_secrets import UserSecretsClient
836
+ HF_TOKEN = UserSecretsClient().get_secret("HF_TOKEN")
837
+ except Exception:
838
+ HF_TOKEN = ""
839
+
840
+ if PUSH_TO_HUB and HF_TOKEN:
841
+ from huggingface_hub import HfApi, login
842
+ login(token=HF_TOKEN)
843
+ api = HfApi()
844
+ api.create_repo(repo_id=HF_TARGET_REPO, repo_type="model", exist_ok=True)
845
+ api.upload_folder(repo_id=HF_TARGET_REPO, folder_path=SAVE_DIR, repo_type="model")
846
+ # Top-level training artifacts.
847
+ for fname in (
848
+ "training_curves.png",
849
+ "training_curves_all_rounds.png",
850
+ "log_history.json",
851
+ "log_history_combined.json",
852
+ "league_log.json",
853
+ "train_metrics.log",
854
+ "per_step_rewards.jsonl",
855
+ ):
856
+ fpath = OUT_DIR / fname if fname in (
857
+ "training_curves_all_rounds.png",
858
+ "log_history_combined.json",
859
+ "league_log.json",
860
+ "train_metrics.log",
861
+ "per_step_rewards.jsonl",
862
+ ) else OUT / fname
863
+ if fpath.exists():
864
+ api.upload_file(path_or_fileobj=str(fpath), path_in_repo=fname,
865
+ repo_id=HF_TARGET_REPO, repo_type="model")
866
+ # Per-step PNGs (one image per logging step) -> uploaded under curves/.
867
+ if CURVES_DIR.exists():
868
+ api.upload_folder(repo_id=HF_TARGET_REPO, folder_path=str(CURVES_DIR),
869
+ path_in_repo="curves", repo_type="model")
870
+ print(f"Uploaded -> https://huggingface.co/{HF_TARGET_REPO}")
871
+ print(f" per-step curves -> https://huggingface.co/{HF_TARGET_REPO}/tree/main/curves")
872
+ else:
873
+ print("[push] skipped -- set HF_TOKEN as a Kaggle Secret to enable.")
874
+
875
+ # ---------- 22) Final sanity check (varied env states) ----------
876
+ print("\n===== Post-GRPO sanity check =====")
877
+ FastLanguageModel.for_inference(model)
878
+ for i in range(3):
879
+ env_t = CyberSelfPlayEnvironment(); env_t.reset()
880
+ for t in range(random.randint(1, 6)):
881
+ env_t.step(CyberAction(actor="red",
882
+ tool_name=RED_T[t % len(RED_T)],
883
+ target=f"host-{random.randint(0,5):02d}"))
884
+ o = env_t.step(CyberAction(actor="red", tool_name="recon_network",
885
+ target=f"host-{random.randint(0,5):02d}"))
886
+ p = obs_to_prompt({"public_state": o.public_state, "telemetry": o.telemetry,
887
+ "incident_summary": o.incident_summary})
888
+ inp = tokenizer(p, return_tensors="pt").to(model.device)
889
+ out = model.generate(
890
+ **inp,
891
+ max_new_tokens=128,
892
+ do_sample=False,
893
+ pad_token_id=tokenizer.pad_token_id,
894
+ eos_token_id=tokenizer.eos_token_id,
895
+ )
896
+ gen = tokenizer.decode(out[0][inp.input_ids.shape[1]:], skip_special_tokens=True)
897
+ action, ok = parse_action(gen)
898
+ print(f"\n--- sample {i+1} (parse_ok={ok}) ---")
899
+ print(gen.strip()); print("Parsed:", action)
900
+ print("\nDone.")