Spaces:
Sleeping
Sleeping
| """SFT warm-start trainer for B1 / Cortex base models. | |
| Workstream B Phase 5d. TRL SFTTrainer over Unsloth-loaded base model | |
| (Qwen3-7B or Llama-3.1-8B), 1-2 epochs over a Phase-5c-collected | |
| trajectory dataset. Output is a LoRA adapter that teaches the JSON | |
| action schema before downstream GRPO refines strategy. | |
| Pipeline position: | |
| Base model (Qwen3-7B-Instruct or Llama-3.1-8B-Instruct) | |
| | | |
| v THIS SCRIPT (~30 min on a100-large, ~$1.25) | |
| | | |
| SFT-warmstarted LoRA on HF Hub | |
| | | |
| v Phase 5e: train_b1_grpo.py with BASE_MODEL=<this output> | |
| GRPO on env reward | |
| | | |
| v | |
| B1-trained / Cortex-router checkpoint | |
| Usage on HF Jobs: | |
| hf jobs run --hardware a100-large --secret HF_TOKEN \\ | |
| --env MODEL_NAME=unsloth/Qwen3-7B-Instruct-bnb-4bit \\ | |
| --env SFT_DATASET_REPO=Angshuman28/crisisworld-sft-trajectories \\ | |
| --env OUTPUT_REPO=Angshuman28/qwen3-7b-sft-warmstart \\ | |
| ghcr.io/astral-sh/uv:latest \\ | |
| bash -c "git clone https://huggingface.co/spaces/Angshuman28/CrisisWorldCortex /app && \\ | |
| cd /app && uv sync && uv run python training/scripts/sft_warmstart.py" | |
| Local dry-run (skip GPU/dataset checks via DRY_RUN=1): | |
| DRY_RUN=1 OUTPUT_REPO=local/test uv run python training/scripts/sft_warmstart.py | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| import textwrap | |
| import time | |
| from typing import 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 "" | |
| # ============================================================================ | |
| # Configuration (env-var driven) | |
| # ============================================================================ | |
| HF_TOKEN = _env("HF_TOKEN", required=True) | |
| MODEL_NAME = _env("MODEL_NAME", "unsloth/Qwen3-7B-Instruct-bnb-4bit") | |
| SFT_DATASET_REPO = _env("SFT_DATASET_REPO", "Angshuman28/crisisworld-sft-trajectories") | |
| OUTPUT_REPO = _env("OUTPUT_REPO", required=True) | |
| OUTPUT_DIR = _env("OUTPUT_DIR", "/tmp/sft_warmstart_lora") | |
| MAX_TRAIN_STEPS = int(_env("MAX_TRAIN_STEPS", "200")) | |
| LR = float(_env("LR", "2e-5")) | |
| LORA_RANK = int(_env("LORA_RANK", "32")) # M-FR-19: matches GRPO downstream | |
| NUM_EPOCHS = int(_env("NUM_EPOCHS", "2")) # M-FR-20: cap by steps too | |
| MAX_SEQ_LEN = int(_env("MAX_SEQ_LEN", "2560")) # prompt 2048 + completion 512 | |
| PER_DEVICE_BATCH = int(_env("PER_DEVICE_BATCH", "4")) | |
| GRAD_ACCUM = int(_env("GRAD_ACCUM", "2")) | |
| SEED = int(_env("SEED", "42")) | |
| GPU_MEM_UTIL = float(_env("GPU_MEM_UTIL", "0.6")) | |
| DRY_RUN = _env("DRY_RUN", "0") not in ("0", "", "false", "False") | |
| def log(*args: object) -> None: | |
| print("[sft-warmstart]", *args, flush=True) | |
| _SYSTEM_PROMPT_BODY = textwrap.dedent( | |
| """ | |
| You are an agent operating one outbreak-control simulator. You receive | |
| an observation each tick and must respond with EXACTLY ONE JSON object - | |
| no markdown fences, no prose around it, just the JSON. | |
| == ACTION TYPES (kind + required fields) == | |
| 1. {"kind": "no_op"} | |
| 2. {"kind": "deploy_resource", "region": "<id>", "resource_type": "<type>", "quantity": <int>} | |
| 3. {"kind": "request_data", "region": "<id>", "data_type": "case_survey" | "hospital_audit" | "compliance_check"} | |
| 4. {"kind": "restrict_movement", "region": "<id>", "severity": "none" | "light" | "moderate" | "strict"} | |
| 5. {"kind": "escalate", "to_authority": "regional" | "national"} | |
| 6. {"kind": "reallocate_budget", "from_resource": "<type>", "to_resource": "<type>", "amount": <int>} | |
| Respond with ONLY the JSON action object. No explanation, no surrounding | |
| text, no markdown. | |
| """ | |
| ).strip() | |
| # ============================================================================ | |
| # Pre-flight: gated-model + dataset checks | |
| # ============================================================================ | |
| def preflight_model_access(model_name: str, token: str) -> None: | |
| """Same fail-loud check as train_b1_grpo.py per Phase-A M-FR-3.""" | |
| log(f"preflight: checking model access {model_name}") | |
| from huggingface_hub import HfApi | |
| from huggingface_hub.utils import GatedRepoError, RepositoryNotFoundError | |
| try: | |
| info = HfApi().model_info(model_name, token=token) | |
| if getattr(info, "gated", False) and not getattr(info, "private", False): | |
| log(f"preflight: {model_name} is gated; access verified") | |
| except GatedRepoError as exc: | |
| raise SystemExit( | |
| f"[FATAL] {model_name} is gated and HF_TOKEN lacks access. " | |
| f"Visit https://huggingface.co/{model_name} and accept the license. " | |
| f"Original: {exc}" | |
| ) from exc | |
| except RepositoryNotFoundError as exc: | |
| raise SystemExit(f"[FATAL] {model_name} not found on HF Hub: {exc}") from exc | |
| log(f"preflight: {model_name} accessible") | |
| def preflight_dataset_access(dataset_repo: str, token: str) -> None: | |
| """Verify the SFT dataset exists. Schema check happens after load.""" | |
| log(f"preflight: checking dataset {dataset_repo}") | |
| from huggingface_hub import HfApi | |
| from huggingface_hub.utils import RepositoryNotFoundError | |
| try: | |
| HfApi().dataset_info(dataset_repo, token=token) | |
| except RepositoryNotFoundError as exc: | |
| raise SystemExit( | |
| f"[FATAL] dataset {dataset_repo} not found. Run Phase-5c " | |
| f"(collect_sft_data.py) first. Original: {exc}" | |
| ) from exc | |
| log(f"preflight: dataset {dataset_repo} accessible") | |
| # ============================================================================ | |
| # Main | |
| # ============================================================================ | |
| def main() -> int: | |
| log(f"MODEL_NAME={MODEL_NAME}") | |
| log(f"SFT_DATASET_REPO={SFT_DATASET_REPO}") | |
| log(f"OUTPUT_REPO={OUTPUT_REPO}") | |
| log(f"MAX_TRAIN_STEPS={MAX_TRAIN_STEPS} NUM_EPOCHS={NUM_EPOCHS} LR={LR}") | |
| log(f"LORA_RANK={LORA_RANK} MAX_SEQ_LEN={MAX_SEQ_LEN}") | |
| preflight_model_access(MODEL_NAME, HF_TOKEN) | |
| preflight_dataset_access(SFT_DATASET_REPO, HF_TOKEN) | |
| if DRY_RUN: | |
| log("DRY_RUN=1 — preflight only; not loading model or training") | |
| return 0 | |
| # Lazy imports — keeps preflight fast and avoids loading Unsloth/torch | |
| # on local machines that don't have GPU. | |
| from datasets import load_dataset | |
| from trl import SFTConfig, SFTTrainer | |
| from unsloth import FastLanguageModel | |
| # ---- Load dataset ---- | |
| log(f"loading dataset {SFT_DATASET_REPO}") | |
| dsdict = load_dataset(SFT_DATASET_REPO, token=HF_TOKEN) | |
| if "train" not in dsdict: | |
| raise SystemExit(f"[FATAL] dataset {SFT_DATASET_REPO} missing 'train' split") | |
| train_ds = dsdict["train"] | |
| eval_ds = dsdict.get("eval") | |
| log(f"dataset: train={len(train_ds)} eval={len(eval_ds) if eval_ds else 0}") | |
| required_cols = {"prompt", "completion"} | |
| missing = required_cols - set(train_ds.column_names) | |
| if missing: | |
| raise SystemExit( | |
| f"[FATAL] dataset {SFT_DATASET_REPO} missing columns: {missing}. " | |
| f"Got {train_ds.column_names}. Re-run Phase 5c." | |
| ) | |
| # ---- Load model + LoRA ---- | |
| log(f"loading model {MODEL_NAME} (LoRA rank={LORA_RANK})") | |
| model, tokenizer = FastLanguageModel.from_pretrained( | |
| model_name=MODEL_NAME, | |
| max_seq_length=MAX_SEQ_LEN, | |
| load_in_4bit=True, | |
| fast_inference=False, # SFT is forward-only at training time | |
| max_lora_rank=LORA_RANK, | |
| gpu_memory_utilization=GPU_MEM_UTIL, | |
| ) | |
| model = FastLanguageModel.get_peft_model( | |
| model, | |
| r=LORA_RANK, | |
| target_modules=[ | |
| "q_proj", | |
| "k_proj", | |
| "v_proj", | |
| "o_proj", | |
| "gate_proj", | |
| "up_proj", | |
| "down_proj", | |
| ], | |
| lora_alpha=LORA_RANK * 2, | |
| use_gradient_checkpointing="unsloth", | |
| random_state=SEED, | |
| ) | |
| log("model + LoRA ready") | |
| # ---- Format function: compose prompt + completion into trainable text ---- | |
| eos = tokenizer.eos_token or "<|endoftext|>" | |
| def formatting_func(example: dict) -> str: | |
| # Phase 5c stores the raw serialized observation in "prompt". | |
| # Render it through the target tokenizer here so SFT matches the | |
| # prompt shape used by train_b1_grpo.py. | |
| rendered_prompt = tokenizer.apply_chat_template( | |
| [ | |
| {"role": "system", "content": _SYSTEM_PROMPT_BODY}, | |
| {"role": "user", "content": example["prompt"]}, | |
| ], | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| ) | |
| return f"{rendered_prompt}{example['completion']}{eos}" | |
| # ---- Compute effective epoch budget ---- | |
| effective_batch = PER_DEVICE_BATCH * GRAD_ACCUM | |
| steps_per_epoch = max(len(train_ds) // effective_batch, 1) | |
| epoch_step_budget = NUM_EPOCHS * steps_per_epoch | |
| final_max_steps = min(MAX_TRAIN_STEPS, epoch_step_budget) | |
| log( | |
| f"steps_per_epoch={steps_per_epoch} epoch_budget={epoch_step_budget} " | |
| f"final_max_steps={final_max_steps}" | |
| ) | |
| # ---- SFT training ---- | |
| log("starting SFTTrainer") | |
| sft_args = SFTConfig( | |
| output_dir=OUTPUT_DIR, | |
| learning_rate=LR, | |
| per_device_train_batch_size=PER_DEVICE_BATCH, | |
| gradient_accumulation_steps=GRAD_ACCUM, | |
| max_steps=final_max_steps, | |
| save_steps=max(final_max_steps // 3, 1), | |
| logging_steps=max(final_max_steps // 60, 1), | |
| report_to="none", | |
| bf16=True, | |
| optim="adamw_8bit", | |
| seed=SEED, | |
| max_length=MAX_SEQ_LEN, | |
| warmup_ratio=0.05, | |
| weight_decay=0.01, | |
| lr_scheduler_type="cosine", | |
| ) | |
| trainer = SFTTrainer( | |
| model=model, | |
| processing_class=tokenizer, | |
| args=sft_args, | |
| train_dataset=train_ds, | |
| formatting_func=formatting_func, | |
| ) | |
| trainer.train() | |
| log("training done") | |
| # ---- Save + push ---- | |
| log(f"saving LoRA adapter to {OUTPUT_DIR}") | |
| model.save_pretrained(OUTPUT_DIR) | |
| tokenizer.save_pretrained(OUTPUT_DIR) | |
| log(f"pushing to https://huggingface.co/{OUTPUT_REPO}") | |
| from huggingface_hub import HfApi | |
| 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("push complete") | |
| return 0 | |
| if __name__ == "__main__": | |
| t0 = time.time() | |
| try: | |
| rc = main() | |
| except SystemExit: | |
| raise | |
| log(f"done in {time.time() - t0:.1f}s") | |
| sys.exit(rc) | |