Spaces:
Sleeping
Sleeping
| """ | |
| Narada GRPO Training Script | |
| Equivalent to narada_grpo.ipynb but runs as a single process. | |
| Required env vars: | |
| HF_TOKEN β HuggingFace write token | |
| HF_PUSH_REPO β where to push adapter, e.g. "KrishVenky/narada-detective-lora" | |
| Optional env vars: | |
| ENV_URL β Narada environment (default: HF Space) | |
| BASE_MODEL β base model ID (default: Qwen/Qwen3-1.7B) | |
| LORA_RANK β LoRA rank (default: 16) | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import json | |
| import math | |
| import os | |
| import re | |
| import sys | |
| import textwrap | |
| import threading | |
| import time | |
| from http.server import BaseHTTPRequestHandler, HTTPServer | |
| from typing import Any, Dict, List, Optional | |
| # Must come before transformers/torchao: torchao 0.17+ calls register_constant | |
| # which was added in PyTorch 2.7. Stub it so the import chain works on 2.6. | |
| import torch | |
| if not hasattr(torch.utils._pytree, "register_constant"): | |
| torch.utils._pytree.register_constant = lambda cls: cls | |
| from transformers import TrainerCallback, TrainerControl, TrainerState, TrainingArguments | |
| import nest_asyncio | |
| nest_asyncio.apply() | |
| import websockets | |
| from datasets import Dataset | |
| from unsloth import FastLanguageModel | |
| # ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| HF_TOKEN = os.environ["HF_TOKEN"] | |
| HF_PUSH_REPO = os.environ["HF_PUSH_REPO"] | |
| BASE_MODEL = os.environ.get("BASE_MODEL", "Qwen/Qwen3-1.7B") | |
| ENV_URL = os.environ.get("ENV_URL", "https://krishvenky-narada-env.hf.space") | |
| LORA_RANK = int(os.environ.get("LORA_RANK", "16")) | |
| ADAPTER_NAME = "narada-detective-lora" | |
| os.environ["HF_TOKEN"] = HF_TOKEN | |
| CURRICULUM = [ | |
| {"task": "monogenic", "steps": 80}, | |
| {"task": "oligogenic", "steps": 60}, | |
| {"task": "phenotype_mismatch", "steps": 60}, | |
| ] | |
| EVAL_SEEDS = [42, 7, 999, 1337, 2024] | |
| N_SEEDS_PER_TASK = 40 | |
| MAX_SEQ_LEN = 2048 | |
| MINI_BATCH_SIZE = 2 | |
| GRAD_ACCUM = 4 | |
| LR = 5e-6 | |
| WARMUP_STEPS = 20 | |
| # ββ System prompt βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| SYSTEM_PROMPT = textwrap.dedent(""" | |
| You are an expert clinical geneticist. Generate a DIAGNOSTIC PLAN: 3-5 JSON action blocks | |
| to navigate the gene-disease knowledge graph and identify the causal variant. | |
| Output each action block on its own line. End with flag_causal when you are confident. | |
| ACTIONS (one JSON per line, no other text): | |
| {"action_type": "hop", "node_id": "<id>", "reasoning": "<one sentence>"} | |
| {"action_type": "flag_causal", "variant_id": "VAR:...", "reasoning": "<one sentence>"} | |
| {"action_type": "backtrack", "reasoning": "<one sentence>"} | |
| {"action_type": "summarise_trail", "reasoning": "<one sentence>"} | |
| STRATEGY: | |
| 1. Navigate phenotype -> disease -> gene -> variant chains. | |
| 2. BRCA1/TP53 is a DECOY if phenotypes are cardiac/neurological -- skip it. | |
| 3. Oligogenic: flag ALL causal variants, not just the first one. | |
| 4. Flag before step 8 for a timing bonus. | |
| 5. ABSENT PHENOTYPES are strong rule-out signals -- use them. | |
| """).strip() | |
| # ββ Environment helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def format_obs(obs: Dict[str, Any]) -> str: | |
| lines = [ | |
| f"STEP {obs['step']}/{obs['max_steps']} | Task: {obs['task_type']}", | |
| "", | |
| "PATIENT PHENOTYPES (present):", | |
| ] | |
| for hid, name in zip(obs["patient_phenotypes"], obs["phenotype_names"]): | |
| lines.append(f" + {hid} -- {name}") | |
| absent_ids = obs.get("phenotypes_absent") or [] | |
| absent_names = obs.get("phenotype_absent_names") or [] | |
| if absent_ids: | |
| lines += ["", "ABSENT PHENOTYPES (rule-out signal):"] | |
| for hid, name in zip(absent_ids, absent_names): | |
| lines.append(f" - {hid} -- {name}") | |
| n = obs["current_node"] | |
| lines += [ | |
| "", | |
| f"CURRENT NODE: [{n['type'].upper()}] {n['name']} ({n['id']})", | |
| f" Neighbors ({len(n['connected_node_ids'])}): {', '.join(n['connected_node_ids'][:8])}", | |
| ] | |
| if obs.get("trail"): | |
| trail = [f"{t['name']}({t['id']})" for t in obs["trail"][-4:]] | |
| lines.append(f" Trail: {' -> '.join(trail)}") | |
| lines += ["", "CANDIDATE VARIANTS:"] | |
| for v in obs["candidate_variants"]: | |
| lines.append( | |
| f" {v['id']} | {v['gene']} | {v['variant_type']} " | |
| f"| path={v['pathogenicity_score']:.2f} | {v['clinical_significance']}" | |
| ) | |
| lines.append(f"\nStep reward: {obs['step_reward']:+.4f} | Cumulative: {obs['cumulative_reward']:.4f}") | |
| lines.append("Generate your diagnostic plan (3-5 JSON action blocks):") | |
| return "\n".join(lines) | |
| def parse_all_actions(text: str) -> List[Dict[str, Any]]: | |
| actions = [] | |
| for m in re.finditer(r'\{[^{}]*"action_type"[^{}]*\}', text, re.DOTALL): | |
| try: | |
| d = json.loads(m.group(0)) | |
| atype = str(d.get("action_type", "")).lower() | |
| if atype not in ("hop", "flag_causal", "backtrack", "summarise_trail", "request_lab"): | |
| continue | |
| actions.append({ | |
| "action_type": atype, | |
| "node_id": str(d["node_id"]) if d.get("node_id") else None, | |
| "variant_id": str(d["variant_id"]) if d.get("variant_id") else None, | |
| "reasoning": str(d.get("reasoning", ""))[:200], | |
| }) | |
| if atype == "flag_causal": | |
| break | |
| except Exception: | |
| continue | |
| return actions or [{"action_type": "summarise_trail", "reasoning": "fallback"}] | |
| def parse_action(text: str) -> Dict[str, Any]: | |
| m = re.search(r"\{.*\}", text, re.DOTALL) | |
| if not m: | |
| return {"action_type": "summarise_trail", "reasoning": "fallback"} | |
| try: | |
| d = json.loads(m.group(0)) | |
| atype = str(d.get("action_type", "summarise_trail")).lower() | |
| if atype not in ("hop", "flag_causal", "backtrack", "summarise_trail", "request_lab"): | |
| atype = "summarise_trail" | |
| return { | |
| "action_type": atype, | |
| "node_id": str(d["node_id"]) if d.get("node_id") else None, | |
| "variant_id": str(d["variant_id"]) if d.get("variant_id") else None, | |
| "reasoning": str(d.get("reasoning", ""))[:200], | |
| } | |
| except Exception: | |
| return {"action_type": "summarise_trail", "reasoning": "parse error"} | |
| async def run_episode_async( | |
| task_type: str, | |
| actions: List[Dict[str, Any]], | |
| seed: Optional[int] = None, | |
| ) -> float: | |
| ws_url = ENV_URL.replace("https://", "wss://").replace("http://", "ws://") + "/ws" | |
| async with websockets.connect(ws_url, open_timeout=30, ping_interval=20) as ws: | |
| reset_msg: Dict[str, Any] = {"type": "reset", "task_type": task_type} | |
| if seed is not None: | |
| reset_msg["seed"] = seed | |
| await ws.send(json.dumps(reset_msg)) | |
| raw = json.loads(await ws.recv()) | |
| if raw.get("type") == "error": | |
| return 0.1 | |
| obs = raw["data"]["observation"] | |
| last_reward = 0.1 | |
| for action in actions: | |
| if obs.get("done"): | |
| break | |
| await ws.send(json.dumps({"type": "step", "action": action})) | |
| raw = json.loads(await ws.recv()) | |
| if raw.get("type") == "error": | |
| break | |
| data = raw["data"] | |
| obs = data["observation"] | |
| last_reward = data["reward"] | |
| if obs.get("done"): | |
| return float(last_reward) | |
| return float(last_reward) | |
| async def collect_episode_async(task_type: str, seed: Optional[int] = None) -> List[Dict]: | |
| ws_url = ENV_URL.replace("https://", "wss://").replace("http://", "ws://") + "/ws" | |
| steps: List[Dict] = [] | |
| async with websockets.connect(ws_url, open_timeout=30, ping_interval=20) as ws: | |
| reset_msg: Dict[str, Any] = {"type": "reset", "task_type": task_type} | |
| if seed is not None: | |
| reset_msg["seed"] = seed | |
| await ws.send(json.dumps(reset_msg)) | |
| raw = json.loads(await ws.recv()) | |
| if raw.get("type") == "error": | |
| return steps | |
| obs = raw["data"]["observation"] | |
| steps.append({"prompt": format_obs(obs), "obs": obs, "task_type": task_type, "seed": seed}) | |
| return steps | |
| def run_episode(task_type: str, actions: List[Dict], seed: Optional[int] = None) -> float: | |
| return asyncio.get_event_loop().run_until_complete(run_episode_async(task_type, actions, seed)) | |
| def collect_episode(task_type: str, seed: Optional[int] = None) -> List[Dict]: | |
| return asyncio.get_event_loop().run_until_complete(collect_episode_async(task_type, seed)) | |
| # ββ Reward tracker callback βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class RewardTracker(TrainerCallback): | |
| def __init__(self, phase: str, log: List[Dict]): | |
| self.phase = phase | |
| self.log = log | |
| def on_log(self, args: TrainingArguments, state: TrainerState, | |
| control: TrainerControl, logs=None, **kwargs): | |
| if logs is None: | |
| return | |
| step = state.global_step | |
| reward = logs.get("reward", logs.get("train/reward", None)) | |
| if reward is not None: | |
| self.log.append({"phase": self.phase, "step": step, "reward": float(reward)}) | |
| # ββ Reward function βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def clamp(v: float, lo: float = 0.01, hi: float = 0.99) -> float: | |
| return max(lo, min(hi, v)) if math.isfinite(v) else 0.1 | |
| async def _eval_one_async(text: str, task: str, seed: Any) -> float: | |
| actions = parse_all_actions(text) | |
| try: | |
| return clamp(await run_episode_async(task, actions, seed=seed)) | |
| except Exception: | |
| return 0.1 | |
| def narada_reward(completions, prompts, task_type=None, seed=None, **kwargs): | |
| n = len(completions) | |
| tasks = task_type if task_type is not None else ["monogenic"] * n | |
| seeds = seed if seed is not None else [None] * n | |
| texts = [] | |
| for c in completions: | |
| if isinstance(c, list): | |
| texts.append(c[-1]["content"] if c else "") | |
| else: | |
| texts.append(str(c)) | |
| async def _batch(): | |
| return list(await asyncio.gather(*[ | |
| _eval_one_async(t, task, s) | |
| for t, task, s in zip(texts, tasks, seeds) | |
| ])) | |
| return asyncio.get_event_loop().run_until_complete(_batch()) | |
| # ββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main() -> None: | |
| # ββ Load model ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| os.environ["UNSLOTH_DISABLE_STATISTICS"] = "1" | |
| print(f"Loading {BASE_MODEL}...", flush=True) | |
| model, tokenizer = FastLanguageModel.from_pretrained( | |
| model_name = BASE_MODEL, | |
| max_seq_length= MAX_SEQ_LEN, | |
| dtype = None, | |
| load_in_4bit = True, | |
| token = HF_TOKEN, | |
| ) | |
| 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, | |
| lora_dropout = 0.0, | |
| bias = "none", | |
| use_gradient_checkpointing = "unsloth", | |
| random_state = 42, | |
| ) | |
| print(f"Model loaded. Trainable params: {sum(p.numel() for p in model.parameters() if p.requires_grad):,}", flush=True) | |
| # Disable Qwen3 thinking mode | |
| _orig = tokenizer.apply_chat_template | |
| def _no_think(*args, **kwargs): | |
| kwargs["enable_thinking"] = False | |
| return _orig(*args, **kwargs) | |
| tokenizer.apply_chat_template = _no_think | |
| print("Thinking mode disabled.", flush=True) | |
| # ββ Build dataset (parallel collection) ββββββββββββββββββββββββββββββββββ | |
| import random | |
| random.seed(42) | |
| train_seeds = random.sample(range(1, 10000), N_SEEDS_PER_TASK * 3) | |
| async def _collect_one(task: str, seed: int) -> Optional[Dict]: | |
| steps = await collect_episode_async(task, seed=seed) | |
| if not steps: | |
| return None | |
| return { | |
| "prompt": [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": steps[0]["prompt"]}, | |
| ], | |
| "task_type": task, | |
| "seed": seed, | |
| } | |
| async def _collect_all() -> List[Dict]: | |
| pairs = [] | |
| for i, phase in enumerate(CURRICULUM): | |
| task = phase["task"] | |
| for seed in train_seeds[i * N_SEEDS_PER_TASK : (i + 1) * N_SEEDS_PER_TASK]: | |
| pairs.append((task, seed)) | |
| results = await asyncio.gather(*[_collect_one(t, s) for t, s in pairs]) | |
| return [r for r in results if r is not None] | |
| print(f"Collecting {N_SEEDS_PER_TASK * len(CURRICULUM)} prompts in parallel...", flush=True) | |
| all_prompts = asyncio.get_event_loop().run_until_complete(_collect_all()) | |
| dataset = Dataset.from_list(all_prompts) | |
| print(f"Dataset: {len(dataset)} prompts", flush=True) | |
| # ββ GRPO config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| import shutil | |
| from trl import GRPOConfig, GRPOTrainer | |
| cache_path = "/tmp/unsloth_compiled_cache" | |
| if os.path.exists(cache_path): | |
| shutil.rmtree(cache_path) | |
| grpo_config = GRPOConfig( | |
| num_generations = 8, | |
| temperature = 1.1, | |
| top_p = 0.95, | |
| learning_rate = LR, | |
| per_device_train_batch_size = MINI_BATCH_SIZE, | |
| gradient_accumulation_steps = GRAD_ACCUM, | |
| warmup_steps = WARMUP_STEPS, | |
| max_grad_norm = 0.1, | |
| optim = "adamw_8bit", | |
| max_prompt_length = 1200, | |
| max_completion_length = 800, | |
| logging_steps = 5, | |
| output_dir = f"/tmp/{ADAPTER_NAME}", | |
| report_to = "none", | |
| ) | |
| # ββ Zero-shot baseline (before any training) βββββββββββββββββββββββββββββ | |
| print(f"\n{'='*60}", flush=True) | |
| print("BASELINE (zero-shot, untrained LoRA weights)", flush=True) | |
| print(f"{'='*60}", flush=True) | |
| FastLanguageModel.for_inference(model) | |
| baseline_results: Dict[str, float] = {} | |
| for phase in CURRICULUM: | |
| task = phase["task"] | |
| scores = [] | |
| for es in EVAL_SEEDS: | |
| steps = collect_episode(task, seed=es) | |
| if not steps: | |
| continue | |
| messages = [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": steps[0]["prompt"]}, | |
| ] | |
| inputs = tokenizer.apply_chat_template( | |
| messages, tokenize=True, add_generation_prompt=True, return_tensors="pt" | |
| ).to("cuda") | |
| with torch.no_grad(): | |
| out = model.generate(inputs, max_new_tokens=200, temperature=0.3) | |
| completion = tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True) | |
| scores.append(run_episode(task, [parse_action(completion)], seed=es)) | |
| avg = sum(scores) / len(scores) if scores else 0.0 | |
| baseline_results[task] = avg | |
| print(f"Baseline {task}: {avg:.4f} (n={len(scores)})", flush=True) | |
| FastLanguageModel.for_training(model) | |
| # ββ Curriculum training βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| eval_results: Dict[str, float] = {} | |
| reward_log: List[Dict] = [] | |
| step_offset = 0 | |
| for phase in CURRICULUM: | |
| task = phase["task"] | |
| n_steps = phase["steps"] | |
| phase_data = dataset.filter(lambda x: x["task_type"] == task) | |
| if len(phase_data) == 0: | |
| print(f"Skipping {task} β no data.", flush=True) | |
| continue | |
| print(f"\n{'='*60}", flush=True) | |
| print(f"Phase: {task} | {len(phase_data)} prompts | {n_steps} steps", flush=True) | |
| print(f"{'='*60}", flush=True) | |
| grpo_config.max_steps = n_steps | |
| tracker = RewardTracker(task, reward_log) | |
| tracker._step_offset = step_offset | |
| trainer = GRPOTrainer( | |
| model = model, | |
| processing_class = tokenizer, | |
| reward_funcs = narada_reward, | |
| args = grpo_config, | |
| train_dataset = phase_data, | |
| callbacks = [tracker], | |
| ) | |
| t0 = time.time() | |
| trainer.train() | |
| step_offset += n_steps | |
| print(f"Phase {task} done in {(time.time()-t0)/60:.1f} min", flush=True) | |
| # Eval | |
| FastLanguageModel.for_inference(model) | |
| scores = [] | |
| for es in EVAL_SEEDS: | |
| steps = collect_episode(task, seed=es) | |
| if not steps: | |
| continue | |
| messages = [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": steps[0]["prompt"]}, | |
| ] | |
| inputs = tokenizer.apply_chat_template( | |
| messages, tokenize=True, add_generation_prompt=True, return_tensors="pt" | |
| ).to("cuda") | |
| with torch.no_grad(): | |
| out = model.generate(inputs, max_new_tokens=200, temperature=0.3) | |
| completion = tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True) | |
| scores.append(run_episode(task, [parse_action(completion)], seed=es)) | |
| avg = sum(scores) / len(scores) if scores else 0.0 | |
| eval_results[task] = avg | |
| print(f"Eval {task}: {avg:.4f} (n={len(scores)})", flush=True) | |
| FastLanguageModel.for_training(model) | |
| # ββ Save & push adapter βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| model.save_pretrained(ADAPTER_NAME) | |
| tokenizer.save_pretrained(ADAPTER_NAME) | |
| print(f"\nAdapter saved locally to ./{ADAPTER_NAME}", flush=True) | |
| model.push_to_hub(HF_PUSH_REPO, token=HF_TOKEN) | |
| tokenizer.push_to_hub(HF_PUSH_REPO, token=HF_TOKEN) | |
| print(f"Pushed to https://huggingface.co/{HF_PUSH_REPO}", flush=True) | |
| # ββ Generate training curves ββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt | |
| colors = {"monogenic": "#2196F3", "oligogenic": "#FF9800", "phenotype_mismatch": "#E91E63"} | |
| # ββ Fig 1: reward over all steps (curriculum) βββββββββββββββββββββββββ | |
| fig, ax = plt.subplots(figsize=(10, 5)) | |
| global_step = 0 | |
| for phase in CURRICULUM: | |
| task = phase["task"] | |
| pts = [(e["step"] + global_step, e["reward"]) | |
| for e in reward_log if e["phase"] == task] | |
| if pts: | |
| xs, ys = zip(*pts) | |
| ax.plot(xs, ys, "o-", color=colors[task], label=task, linewidth=1.5, markersize=3) | |
| global_step += phase["steps"] | |
| ax.set_xlabel("Training step") | |
| ax.set_ylabel("Mean reward") | |
| ax.set_title("Narada GRPO β reward curve (curriculum order)") | |
| ax.legend() | |
| ax.grid(True, alpha=0.3) | |
| fig.tight_layout() | |
| fig.savefig("training_curve.png", dpi=150) | |
| plt.close(fig) | |
| # ββ Fig 2: per-task before/after bar chart βββββββββββββββββββββββββββββ | |
| tasks_list = [p["task"] for p in CURRICULUM] | |
| x = range(len(tasks_list)) | |
| w = 0.35 | |
| fig2, ax2 = plt.subplots(figsize=(9, 5)) | |
| ax2.bar([i - w/2 for i in x], | |
| [baseline_results.get(t, 0) for t in tasks_list], | |
| w, label="Zero-shot baseline", color="#90A4AE") | |
| ax2.bar([i + w/2 for i in x], | |
| [eval_results.get(t, 0) for t in tasks_list], | |
| w, label="After GRPO", color="#43A047") | |
| ax2.set_xticks(list(x)) | |
| ax2.set_xticklabels(tasks_list) | |
| ax2.set_ylabel("Avg reward (5 eval seeds)") | |
| ax2.set_title("Narada β zero-shot vs GRPO-trained (Qwen3-1.7B)") | |
| ax2.set_ylim(0, 1.0) | |
| ax2.legend() | |
| ax2.grid(True, axis="y", alpha=0.3) | |
| fig2.tight_layout() | |
| fig2.savefig("before_after.png", dpi=150) | |
| plt.close(fig2) | |
| print("Plots saved: training_curve.png, before_after.png", flush=True) | |
| # Upload plots to the adapter repo | |
| from huggingface_hub import HfApi | |
| api = HfApi(token=HF_TOKEN) | |
| for fname in ("training_curve.png", "before_after.png"): | |
| api.upload_file( | |
| path_or_fileobj=fname, | |
| path_in_repo=fname, | |
| repo_id=HF_PUSH_REPO, | |
| repo_type="model", | |
| commit_message=f"add {fname}", | |
| ) | |
| print(f"Plots uploaded to https://huggingface.co/{HF_PUSH_REPO}", flush=True) | |
| except Exception as e: | |
| print(f"Plot generation skipped: {e}", flush=True) | |
| print("\n=== TRAINING COMPLETE ===", flush=True) | |
| print(f"{'Task':<25} {'Baseline':>10} {'Trained':>10} {'Delta':>10}", flush=True) | |
| print("-" * 57, flush=True) | |
| for task in [p["task"] for p in CURRICULUM]: | |
| b = baseline_results.get(task, 0.0) | |
| t = eval_results.get(task, 0.0) | |
| print(f" {task:<23} {b:>10.4f} {t:>10.4f} {t-b:>+10.4f}", flush=True) | |
| class _HealthHandler(BaseHTTPRequestHandler): | |
| def do_GET(self): | |
| self.send_response(200) | |
| self.end_headers() | |
| self.wfile.write(b"Training in progress...") | |
| def log_message(self, *args): | |
| pass | |
| if __name__ == "__main__": | |
| threading.Thread( | |
| target=lambda: HTTPServer(("0.0.0.0", 7860), _HealthHandler).serve_forever(), | |
| daemon=True, | |
| ).start() | |
| main() | |