CrisisWorldCortex / training /scripts /sft_warmstart_router.py
Angshuman28's picture
Upload folder using huggingface_hub
505b9f6 verified
Raw
History Blame Contribute Delete
12 kB
"""SFT warmstart for the Cortex brain-selector router.
Workstream B Phase 6b. Trains a LoRA adapter on the Phase 6a B3 corpus so
the router starts GRPO already able to emit valid JSON:
{"brain": "epi" | "logistics" | "governance"}
This intentionally uses a tiny raw Transformers + PEFT loop instead of
TRL's SFTTrainer. The objective is simple masked causal LM over the
assistant completion tokens, and avoiding TRL keeps the HF Jobs training
surface aligned with the no-TRL GRPO path in ``minimal_proof.py``.
Local preflight:
DRY_RUN=1 HF_TOKEN=dummy uv run python training/scripts/sft_warmstart_router.py
Live run:
HF_TOKEN=hf_xxx OUTPUT_REPO=Angshuman28/cortex-router-sft-warmstart \\
uv run python training/scripts/sft_warmstart_router.py
"""
from __future__ import annotations
import json
import os
import random
import re
import sys
import textwrap
import time
from typing import Any, Dict, List, Optional
def _env(name: str, default: Optional[str] = None, *, required: bool = False) -> str:
value = os.environ.get(name, default)
if required and not value:
raise SystemExit(f"[FATAL] env var {name} is required but unset")
return value or ""
DRY_RUN = _env("DRY_RUN", "0") not in ("0", "", "false", "False")
HF_TOKEN = _env("HF_TOKEN", required=not DRY_RUN)
MODEL_NAME = _env("MODEL_NAME", "Qwen/Qwen2.5-1.5B-Instruct")
B3_DATASET_REPO = _env("B3_DATASET_REPO", "Angshuman28/crisisworld-b3-corpus")
OUTPUT_REPO = _env("OUTPUT_REPO", "Angshuman28/cortex-router-sft-warmstart")
OUTPUT_DIR = _env("OUTPUT_DIR", "/tmp/cortex_router_sft_lora")
MAX_TRAIN_STEPS = int(_env("MAX_TRAIN_STEPS", "200"))
PER_DEVICE_BATCH = int(_env("PER_DEVICE_BATCH", "4"))
GRAD_ACCUM = int(_env("GRAD_ACCUM", "2"))
LR = float(_env("LR", "2e-5"))
LORA_RANK = int(_env("LORA_RANK", "16"))
LORA_ALPHA = int(_env("LORA_ALPHA", str(LORA_RANK * 2)))
LORA_DROPOUT = float(_env("LORA_DROPOUT", "0.05"))
MAX_SEQ_LEN = int(_env("MAX_SEQ_LEN", "1536"))
MAX_NEW_TOKENS = int(_env("MAX_NEW_TOKENS", "32"))
SEED = int(_env("SEED", "42"))
VALIDATION_ATTEMPTS = int(_env("VALIDATION_ATTEMPTS", "10"))
VALIDATION_MIN_RATE = float(_env("VALIDATION_MIN_RATE", "0.8"))
PUSH_TO_HUB = _env("PUSH_TO_HUB", "1") not in ("0", "", "false", "False")
def log(*args: object) -> None:
print("[router-sft]", *args, flush=True)
ROUTER_SYSTEM_PROMPT = textwrap.dedent(
"""
You are the Cortex brain selector. Read one CrisisWorld observation and
choose exactly one specialist brain to act next.
Brain options:
- epi: epidemiology, surveillance, case growth, and outbreak spread
- logistics: scarce resources, hospital beds, mobile units, allocation
- governance: legal constraints, compliance, escalation, restrictions
Respond with exactly one JSON object and no prose:
{"brain": "epi" | "logistics" | "governance"}
"""
).strip()
_BRAIN_ALIASES = {
"epi": "epi",
"epidemiology": "epi",
"logistics": "logistics",
"governance": "governance",
}
def parse_router_choice(raw_text: str) -> Optional[str]:
text = raw_text.strip()
text = re.sub(r"```(?:json)?\s*", "", text)
text = re.sub(r"```\s*$", "", text).strip()
candidates = [text]
start = text.find("{")
if start >= 0:
depth = 0
for index, char in enumerate(text[start:], start):
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
candidates.append(text[start : index + 1])
break
for candidate_text in candidates:
try:
candidate = json.loads(candidate_text)
except json.JSONDecodeError:
continue
if isinstance(candidate, dict):
brain = _BRAIN_ALIASES.get(str(candidate.get("brain", "")).strip().lower())
if brain is not None:
return brain
return None
def router_completion(brain: str) -> str:
canonical = _BRAIN_ALIASES.get(brain)
if canonical is None:
raise ValueError(f"unknown brain label {brain!r}")
return json.dumps({"brain": canonical}, separators=(",", ":"))
def render_prompt(tokenizer: Any, observation_text: str) -> str:
return tokenizer.apply_chat_template(
[
{"role": "system", "content": ROUTER_SYSTEM_PROMPT},
{"role": "user", "content": observation_text},
],
tokenize=False,
add_generation_prompt=True,
)
def preflight_dataset_access(dataset_repo: str, token: str) -> None:
log(f"preflight: checking dataset {dataset_repo}")
if DRY_RUN:
log("DRY_RUN=1 - skipping Hub dataset access check")
return
from huggingface_hub import HfApi
from huggingface_hub.utils import RepositoryNotFoundError
try:
HfApi().dataset_info(dataset_repo, token=token or None)
except RepositoryNotFoundError as exc:
raise SystemExit(f"[FATAL] dataset {dataset_repo} not found: {exc}") from exc
log("preflight: dataset accessible")
def make_collate(tokenizer: Any):
def collate(features: List[Dict[str, List[int]]]) -> Dict[str, Any]:
import torch
max_len = max(len(item["input_ids"]) for item in features)
input_ids, attention_mask, labels = [], [], []
for item in features:
pad = max_len - len(item["input_ids"])
input_ids.append(item["input_ids"] + [tokenizer.pad_token_id] * pad)
attention_mask.append([1] * len(item["input_ids"]) + [0] * pad)
labels.append(item["labels"] + [-100] * pad)
return {
"input_ids": torch.tensor(input_ids, dtype=torch.long),
"attention_mask": torch.tensor(attention_mask, dtype=torch.long),
"labels": torch.tensor(labels, dtype=torch.long),
}
return collate
def tokenize_rows(tokenizer: Any, rows: List[Dict[str, Any]]) -> List[Dict[str, List[int]]]:
tokenized: List[Dict[str, List[int]]] = []
eos = tokenizer.eos_token or ""
for row in rows:
prompt = render_prompt(tokenizer, row["observation_text"])
completion = router_completion(row["deterministic_brain_choice"]) + eos
prompt_ids = tokenizer(prompt, add_special_tokens=False)["input_ids"]
full_ids = tokenizer(
prompt + completion,
add_special_tokens=False,
truncation=True,
max_length=MAX_SEQ_LEN,
)["input_ids"]
labels = list(full_ids)
prompt_cutoff = min(len(prompt_ids), len(labels))
labels[:prompt_cutoff] = [-100] * prompt_cutoff
if all(label == -100 for label in labels):
continue
tokenized.append({"input_ids": full_ids, "labels": labels})
if not tokenized:
raise SystemExit("[FATAL] no tokenized training rows; check corpus schema")
return tokenized
def validate_router_json(
model: Any, tokenizer: Any, rows: List[Dict[str, Any]], device: Any
) -> float:
import torch
sample_rows = rows[: max(1, VALIDATION_ATTEMPTS)]
ok = 0
model.eval()
for row in sample_rows:
prompt = render_prompt(tokenizer, row["observation_text"])
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=MAX_SEQ_LEN).to(
device
)
prompt_len = inputs["input_ids"].shape[1]
with torch.no_grad():
out = model.generate(
**inputs,
do_sample=True,
temperature=0.2,
max_new_tokens=MAX_NEW_TOKENS,
pad_token_id=tokenizer.pad_token_id,
eos_token_id=tokenizer.eos_token_id,
)
text = tokenizer.decode(out[0][prompt_len:], skip_special_tokens=True)
ok += parse_router_choice(text) is not None
return ok / len(sample_rows)
def main() -> int:
log(f"MODEL_NAME={MODEL_NAME}")
log(f"B3_DATASET_REPO={B3_DATASET_REPO}")
log(f"OUTPUT_REPO={OUTPUT_REPO}")
log(f"MAX_TRAIN_STEPS={MAX_TRAIN_STEPS} LR={LR} LORA_RANK={LORA_RANK}")
preflight_dataset_access(B3_DATASET_REPO, HF_TOKEN)
if DRY_RUN:
log("DRY_RUN=1 - preflight only; not loading dataset/model or training")
return 0
import torch
from accelerate import Accelerator
from datasets import load_dataset
from huggingface_hub import HfApi
from peft import LoraConfig, TaskType, get_peft_model
from torch.utils.data import DataLoader
from transformers import AutoModelForCausalLM, AutoTokenizer
random.seed(SEED)
torch.manual_seed(SEED)
accelerator = Accelerator(gradient_accumulation_steps=GRAD_ACCUM)
log("loading dataset")
dsdict = load_dataset(B3_DATASET_REPO, token=HF_TOKEN)
train_split = dsdict["train"] if "train" in dsdict else dsdict[list(dsdict.keys())[0]]
rows = [dict(row) for row in train_split]
random.shuffle(rows)
required = {"observation_text", "deterministic_brain_choice"}
missing = required - set(train_split.column_names)
if missing:
raise SystemExit(f"[FATAL] dataset missing columns {sorted(missing)}")
log("loading tokenizer/model")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, token=HF_TOKEN)
if tokenizer.pad_token_id is None:
tokenizer.pad_token = tokenizer.eos_token
dtype = torch.bfloat16 if torch.cuda.is_available() else torch.float32
model = AutoModelForCausalLM.from_pretrained(MODEL_NAME, token=HF_TOKEN, torch_dtype=dtype)
model = get_peft_model(
model,
LoraConfig(
r=LORA_RANK,
lora_alpha=LORA_ALPHA,
lora_dropout=LORA_DROPOUT,
bias="none",
task_type=TaskType.CAUSAL_LM,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
),
)
optimizer = torch.optim.AdamW(model.parameters(), lr=LR)
tokenized = tokenize_rows(tokenizer, rows)
loader = DataLoader(
tokenized,
batch_size=PER_DEVICE_BATCH,
shuffle=True,
collate_fn=make_collate(tokenizer),
)
model, optimizer, loader = accelerator.prepare(model, optimizer, loader)
log(f"training rows={len(tokenized)}")
step = 0
while step < MAX_TRAIN_STEPS:
for batch in loader:
with accelerator.accumulate(model):
outputs = model(**batch)
loss = outputs.loss
accelerator.backward(loss)
optimizer.step()
optimizer.zero_grad()
if accelerator.sync_gradients:
step += 1
if step % max(MAX_TRAIN_STEPS // 20, 1) == 0:
log(f"step={step}/{MAX_TRAIN_STEPS} loss={float(loss.detach().cpu()):.4f}")
if step >= MAX_TRAIN_STEPS:
break
accelerator.wait_for_everyone()
if accelerator.is_main_process:
unwrapped = accelerator.unwrap_model(model)
rate = validate_router_json(unwrapped, tokenizer, rows, accelerator.device)
log(f"router JSON validation rate={rate:.0%}")
if rate < VALIDATION_MIN_RATE:
raise SystemExit(
f"[FATAL] router JSON validation {rate:.0%} < {VALIDATION_MIN_RATE:.0%}"
)
log(f"saving LoRA adapter to {OUTPUT_DIR}")
unwrapped.save_pretrained(OUTPUT_DIR)
tokenizer.save_pretrained(OUTPUT_DIR)
if PUSH_TO_HUB:
log(f"pushing to https://huggingface.co/{OUTPUT_REPO}")
api = HfApi()
api.create_repo(
OUTPUT_REPO, exist_ok=True, repo_type="model", private=False, token=HF_TOKEN
)
api.upload_folder(
folder_path=OUTPUT_DIR,
repo_id=OUTPUT_REPO,
repo_type="model",
token=HF_TOKEN,
)
log("done")
return 0
if __name__ == "__main__":
t0 = time.time()
rc = main()
log(f"elapsed={time.time() - t0:.1f}s")
sys.exit(rc)