Commit ·
e80ab65
1
Parent(s): 8006bcf
push
Browse files- README.md +1 -0
- train/README-GRPO.md +3 -2
- 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,
|
| 8 |
-
| `train/
|
|
|
|
| 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.")
|