CrisisWorldCortex / training /scripts /collect_sft_data.py
Angshuman28's picture
Upload folder using huggingface_hub
bc8c5b7 verified
Raw
History Blame Contribute Delete
15.6 kB
"""Collect SFT training trajectories for warm-starting B1 / Cortex policies.
Workstream B Phase 5c. Drives the deployed CrisisWorldCortex env with a
frontier teacher (default: Qwen 72B Instruct via HF Router/Novita) for
NUM_EPISODES * |TASKS| episodes, captures (observation, action) pairs
where the action parsed cleanly AND obs.reward >= MIN_REWARD_THRESHOLD
AND the env recorded accepted=True. Pushes the kept rows to an HF
dataset with train/eval splits.
The output dataset feeds Phase 5d (sft_warmstart.py), which teaches the
JSON action schema to a base model before GRPO refines strategy.
Local dry-run (no compute spend, just tests env reachability):
DRY_RUN=1 uv run python training/scripts/collect_sft_data.py
Live run (~$1-2 HF Router credits, ~30-45 min):
HF_TOKEN=hf_xxx OUTPUT_DATASET_REPO=Angshuman28/crisisworld-sft-trajectories \\
uv run python training/scripts/collect_sft_data.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
# ============================================================================
# Configuration (env-var driven)
# ============================================================================
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 ""
HF_TOKEN = _env("HF_TOKEN", required=True)
ENV_URL = _env("ENV_URL", "https://angshuman28-crisisworldcortex.hf.space")
MODEL_NAME = _env("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct:novita")
NUM_EPISODES = int(_env("NUM_EPISODES", "50"))
TASKS_CSV = _env("TASKS_CSV", "outbreak_easy,outbreak_medium,outbreak_hard")
MIN_REWARD_THRESHOLD = float(_env("MIN_REWARD_THRESHOLD", "0.5"))
OUTPUT_DATASET_REPO = _env("OUTPUT_DATASET_REPO", "Angshuman28/crisisworld-sft-trajectories")
EVAL_FRACTION = float(_env("EVAL_FRACTION", "0.2"))
EPISODE_TICKS = int(_env("EPISODE_TICKS", "12"))
SEED = int(_env("SEED", "42"))
DRY_RUN = _env("DRY_RUN", "0") not in ("0", "", "false", "False")
RUN_ID = _env("RUN_ID", "") # optional suffix for OUTPUT_DATASET_REPO collision avoidance
MAX_COMPLETION_TOKENS = int(_env("MAX_COMPLETION_TOKENS", "256"))
def log(*args: object) -> None:
print("[collect-sft]", *args, flush=True)
# ============================================================================
# Inlined helpers (duplicated from baselines/flat_agent.py per import-graph
# rule M-FR-13: training/* MUST NOT import baselines/*).
# Keep in sync with baselines/flat_agent.py if the helpers change.
# ============================================================================
_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()
def _action_summary(action: Any) -> str:
kind = action.kind
if kind == "deploy_resource":
return f"({action.region}, {action.resource_type}, qty={action.quantity})"
if kind == "request_data":
return f"({action.region}, {action.data_type})"
if kind == "restrict_movement":
return f"({action.region}, {action.severity})"
if kind == "escalate":
return f"({action.to_authority})"
if kind == "reallocate_budget":
return f"({action.from_resource} -> {action.to_resource}, amount={action.amount})"
return ""
def serialize_observation(obs: Any, last_reward: float = 0.0) -> str:
parts: List[str] = []
parts.append(
f"Tick {obs.tick} | Ticks remaining: {obs.ticks_remaining} | Last reward: {last_reward:.2f}"
)
r = obs.resources
parts.append(
"=== Resources ===\n"
f"test_kits={r.test_kits} hospital_beds_free={r.hospital_beds_free} "
f"mobile_units={r.mobile_units} vaccine_doses={r.vaccine_doses}"
)
region_lines = ["=== Regions ==="]
for region in obs.regions:
region_lines.append(
f"- {region.region}: cases_d_ago={region.reported_cases_d_ago} "
f"hospital_load={region.hospital_load:.2f} "
f"compliance_proxy={region.compliance_proxy:.2f}"
)
parts.append("\n".join(region_lines))
restr_lines = ["=== Active restrictions ==="]
if obs.active_restrictions:
for restr in obs.active_restrictions:
restr_lines.append(
f"- {restr.region}: severity={restr.severity} "
f"ticks_remaining={restr.ticks_remaining}"
)
else:
restr_lines.append("(none)")
parts.append("\n".join(restr_lines))
legal_lines = ["=== Legal constraints ==="]
if obs.legal_constraints:
for lc in obs.legal_constraints:
legal_lines.append(
f"- {lc.rule_id}: blocks {lc.blocked_action} (unlock via {lc.unlock_via})"
)
else:
legal_lines.append("(none)")
parts.append("\n".join(legal_lines))
log_lines = ["=== Recent actions (last 8) ==="]
if obs.recent_action_log:
for entry in obs.recent_action_log:
kind = entry.action.kind
extra = _action_summary(entry.action)
log_lines.append(f"- tick={entry.tick} {kind}{extra} accepted={entry.accepted}")
else:
log_lines.append("(none yet)")
parts.append("\n".join(log_lines))
return "\n\n".join(parts)
def parse_action_json(raw_text: str) -> Optional[Dict[str, Any]]:
"""Extract a JSON action dict from raw LLM output. Returns None on failure.
We parse to dict here (not OuterActionPayload) because this script
needs to capture the raw JSON string for the SFT dataset. The env
will Pydantic-validate when we submit it.
"""
if not raw_text or not raw_text.strip():
return None
text = raw_text.strip()
text = re.sub(r"```(?:json)?\s*", "", text)
text = re.sub(r"```\s*$", "", text)
text = text.strip()
try:
candidate = json.loads(text)
if isinstance(candidate, dict) and "kind" in candidate:
return candidate
except json.JSONDecodeError:
pass
start = text.find("{")
if start == -1:
return None
depth, end = 0, -1
for i, ch in enumerate(text[start:], start):
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i
break
if end == -1:
return None
try:
candidate = json.loads(text[start : end + 1])
if isinstance(candidate, dict) and "kind" in candidate:
return candidate
except json.JSONDecodeError:
return None
return None
# ============================================================================
# Pre-flight
# ============================================================================
def preflight_env_health(env_url: str) -> None:
"""Hit /health on the deployed env. Abort if not 200/healthy."""
log(f"preflight: checking {env_url}/health")
import urllib.request
try:
with urllib.request.urlopen(f"{env_url}/health", timeout=10) as resp:
body = resp.read().decode("utf-8")
if resp.status != 200 or "healthy" not in body.lower():
raise SystemExit(
f"[FATAL] env {env_url} unhealthy: status={resp.status} body={body!r}. "
f"Run `openenv push` to rebuild the Space first."
)
except SystemExit:
raise
except Exception as exc:
raise SystemExit(
f"[FATAL] env {env_url} unreachable: {exc}. "
f"Run `openenv push` to rebuild the Space first."
) from exc
log("preflight: env healthy")
def _sync_if_available(env: Any) -> Any:
"""OpenEnv 0.2.2+ exposes .sync(); 0.2.1 reset/step are already sync."""
sync = getattr(env, "sync", None)
return sync() if callable(sync) else env
# ============================================================================
# Main
# ============================================================================
def call_teacher(client: Any, system_prompt: str, user_prompt: str) -> str:
"""One chat-completion round-trip via HF Router. Returns the completion text."""
response = client.chat_completion(
model=MODEL_NAME,
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
max_tokens=MAX_COMPLETION_TOKENS,
temperature=0.1, # low for SFT data quality
)
# huggingface_hub returns OpenAI-shaped objects.
return response.choices[0].message.content or ""
def collect() -> int:
log(f"MODEL_NAME={MODEL_NAME}")
log(f"ENV_URL={ENV_URL}")
log(f"NUM_EPISODES={NUM_EPISODES} TASKS={TASKS_CSV}")
log(f"MIN_REWARD_THRESHOLD={MIN_REWARD_THRESHOLD} EVAL_FRACTION={EVAL_FRACTION}")
log(f"OUTPUT_DATASET_REPO={OUTPUT_DATASET_REPO}{('-' + RUN_ID) if RUN_ID else ''}")
preflight_env_health(ENV_URL)
if DRY_RUN:
log("DRY_RUN=1 — preflight only; not collecting or pushing")
return 0
# Lazy imports — keep the dry-run path fast.
from datasets import Dataset, DatasetDict
from huggingface_hub import HfApi, InferenceClient
from CrisisWorldCortex.client import CrisisworldcortexEnv
from CrisisWorldCortex.models import CrisisworldcortexAction
tasks = tuple(t.strip() for t in TASKS_CSV.split(",") if t.strip())
client = InferenceClient(token=HF_TOKEN)
rows: List[Dict[str, Any]] = []
parse_fail_count = 0
rejected_count = 0
low_reward_count = 0
kept_count = 0
for task in tasks:
for ep in range(NUM_EPISODES):
env = _sync_if_available(CrisisworldcortexEnv(base_url=ENV_URL))
try:
reset_result = env.reset(task_name=task, seed=ep, max_ticks=EPISODE_TICKS)
obs = (
reset_result.observation
if hasattr(reset_result, "observation")
else reset_result
)
except Exception as exc:
log(f"WARN env.reset failed task={task} ep={ep}: {exc}")
env.close()
continue
try:
last_reward = 0.0
for tick in range(EPISODE_TICKS):
user_prompt = serialize_observation(obs, last_reward)
try:
completion = call_teacher(client, _SYSTEM_PROMPT_BODY, user_prompt)
except Exception as exc:
log(f"WARN teacher call failed task={task} ep={ep} tick={tick}: {exc}")
break
action_dict = parse_action_json(completion)
if action_dict is None:
parse_fail_count += 1
break
# Submit to env (Pydantic validates here).
try:
result = env.step(
CrisisworldcortexAction.model_validate({"action": action_dict})
)
except Exception as exc:
log(f"WARN env.step rejected task={task} ep={ep} tick={tick}: {exc}")
parse_fail_count += 1
break
next_obs = result.observation if hasattr(result, "observation") else result
reward = next_obs.reward if next_obs.reward is not None else 0.0
accepted = bool(
next_obs.recent_action_log and next_obs.recent_action_log[-1].accepted
)
if not accepted:
rejected_count += 1
elif reward < MIN_REWARD_THRESHOLD:
low_reward_count += 1
else:
rows.append(
{
"prompt": user_prompt,
"completion": json.dumps(action_dict, separators=(",", ":")),
"task": task,
"seed": ep,
"tick": tick,
"reward": float(reward),
"accepted": True,
}
)
kept_count += 1
last_reward = float(reward)
obs = next_obs
if next_obs.done:
break
finally:
env.close()
if (ep + 1) % 5 == 0:
log(
f"[{task}] {ep + 1}/{NUM_EPISODES} kept={kept_count} "
f"rejected={rejected_count} low_reward={low_reward_count} "
f"parse_fail={parse_fail_count}"
)
log(
f"final tally: kept={kept_count} rejected={rejected_count} "
f"low_reward={low_reward_count} parse_fail={parse_fail_count}"
)
if kept_count < 50:
raise SystemExit(
f"[FATAL] only {kept_count} kept rows; need >=50 for usable SFT. "
f"Check teacher MODEL_NAME, MIN_REWARD_THRESHOLD, or env reachability."
)
# Per-task counts surface task balance.
by_task: Dict[str, int] = {}
for row in rows:
by_task[row["task"]] = by_task.get(row["task"], 0) + 1
for t, count in sorted(by_task.items()):
log(f" {t}: {count} rows ({count / kept_count:.0%})")
if count < kept_count * 0.05:
log(f" WARN: {t} is <5% of dataset — task balance skewed")
# Shuffle + split.
rng = random.Random(SEED)
rng.shuffle(rows)
eval_size = max(1, int(len(rows) * EVAL_FRACTION))
eval_rows = rows[:eval_size]
train_rows = rows[eval_size:]
log(f"split: train={len(train_rows)} eval={len(eval_rows)}")
target_repo = f"{OUTPUT_DATASET_REPO}-{RUN_ID}" if RUN_ID else OUTPUT_DATASET_REPO
def _to_ds(rs: List[Dict[str, Any]]) -> Any:
return Dataset.from_list(rs)
dsdict = DatasetDict({"train": _to_ds(train_rows), "eval": _to_ds(eval_rows)})
api = HfApi()
api.create_repo(target_repo, exist_ok=True, repo_type="dataset", private=False, token=HF_TOKEN)
dsdict.push_to_hub(target_repo, token=HF_TOKEN)
log(f"pushed https://huggingface.co/datasets/{target_repo}")
return 0
def main() -> int:
t0 = time.time()
rc = collect()
log(f"done in {time.time() - t0:.1f}s")
return rc
if __name__ == "__main__":
sys.exit(main())