KrishVenky commited on
Commit
f7fbd5e
Β·
verified Β·
1 Parent(s): 15c3ae7

Upload 2 files

Browse files
Files changed (2) hide show
  1. Dockerfile +28 -0
  2. train.py +407 -0
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM pytorch/pytorch:2.3.0-cuda12.1-cudnn8-devel
2
+
3
+ # System deps
4
+ RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
5
+
6
+ # Python deps β€” unsloth last so it can detect the CUDA version at build time
7
+ RUN pip install --no-cache-dir \
8
+ "trl>=0.9.0" \
9
+ "peft>=0.10.0" \
10
+ "accelerate>=0.30.0" \
11
+ "bitsandbytes>=0.43.0" \
12
+ "websockets>=12.0" \
13
+ "pydantic>=2.7.0" \
14
+ "openai>=1.30.0" \
15
+ "nest_asyncio>=1.6.0" \
16
+ "datasets>=2.18.0" \
17
+ "transformers>=4.40.0" \
18
+ "huggingface_hub>=0.22.0"
19
+
20
+ RUN pip install --no-cache-dir \
21
+ "unsloth[cu121-torch230] @ git+https://github.com/unslothai/unsloth.git"
22
+
23
+ WORKDIR /app
24
+ COPY train.py .
25
+
26
+ # Required: HF_TOKEN, HF_PUSH_REPO
27
+ # Optional: BASE_MODEL, ENV_URL, LORA_RANK
28
+ CMD ["python", "train.py"]
train.py ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Narada GRPO Training Script
3
+ Equivalent to narada_grpo.ipynb but runs as a single process.
4
+
5
+ Required env vars:
6
+ HF_TOKEN β€” HuggingFace write token
7
+ HF_PUSH_REPO β€” where to push adapter, e.g. "KrishVenky/narada-detective-lora"
8
+
9
+ Optional env vars:
10
+ ENV_URL β€” Narada environment (default: HF Space)
11
+ BASE_MODEL β€” base model ID (default: Qwen/Qwen3-1.7B)
12
+ LORA_RANK β€” LoRA rank (default: 16)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import json
19
+ import math
20
+ import os
21
+ import re
22
+ import sys
23
+ import textwrap
24
+ import time
25
+ from typing import Any, Dict, List, Optional
26
+
27
+ import nest_asyncio
28
+ nest_asyncio.apply()
29
+
30
+ import websockets
31
+ import torch
32
+ from datasets import Dataset
33
+ from unsloth import FastLanguageModel
34
+
35
+ # ── Config ────────────────────────────────────────────────────────────────────
36
+
37
+ HF_TOKEN = os.environ["HF_TOKEN"]
38
+ HF_PUSH_REPO = os.environ["HF_PUSH_REPO"]
39
+ BASE_MODEL = os.environ.get("BASE_MODEL", "Qwen/Qwen3-1.7B")
40
+ ENV_URL = os.environ.get("ENV_URL", "https://krishvenky-narada-env.hf.space")
41
+ LORA_RANK = int(os.environ.get("LORA_RANK", "16"))
42
+ ADAPTER_NAME = "narada-detective-lora"
43
+
44
+ os.environ["HF_TOKEN"] = HF_TOKEN
45
+
46
+ CURRICULUM = [
47
+ {"task": "monogenic", "steps": 80},
48
+ {"task": "oligogenic", "steps": 60},
49
+ {"task": "phenotype_mismatch", "steps": 60},
50
+ ]
51
+ EVAL_SEEDS = [42, 7, 999, 1337, 2024]
52
+ N_SEEDS_PER_TASK = 40
53
+ MAX_SEQ_LEN = 2048
54
+ MINI_BATCH_SIZE = 2
55
+ GRAD_ACCUM = 4
56
+ LR = 5e-6
57
+ WARMUP_STEPS = 20
58
+
59
+ # ── System prompt ─────────────────────────────────────────────────────────────
60
+
61
+ SYSTEM_PROMPT = textwrap.dedent("""
62
+ You are an expert clinical geneticist. Generate a DIAGNOSTIC PLAN: 3-5 JSON action blocks
63
+ to navigate the gene-disease knowledge graph and identify the causal variant.
64
+
65
+ Output each action block on its own line. End with flag_causal when you are confident.
66
+
67
+ ACTIONS (one JSON per line, no other text):
68
+ {"action_type": "hop", "node_id": "<id>", "reasoning": "<one sentence>"}
69
+ {"action_type": "flag_causal", "variant_id": "VAR:...", "reasoning": "<one sentence>"}
70
+ {"action_type": "backtrack", "reasoning": "<one sentence>"}
71
+ {"action_type": "summarise_trail", "reasoning": "<one sentence>"}
72
+
73
+ STRATEGY:
74
+ 1. Navigate phenotype -> disease -> gene -> variant chains.
75
+ 2. BRCA1/TP53 is a DECOY if phenotypes are cardiac/neurological -- skip it.
76
+ 3. Oligogenic: flag ALL causal variants, not just the first one.
77
+ 4. Flag before step 8 for a timing bonus.
78
+ 5. ABSENT PHENOTYPES are strong rule-out signals -- use them.
79
+ """).strip()
80
+
81
+ # ── Environment helpers ───────────────────────────────────────────────────────
82
+
83
+ def format_obs(obs: Dict[str, Any]) -> str:
84
+ lines = [
85
+ f"STEP {obs['step']}/{obs['max_steps']} | Task: {obs['task_type']}",
86
+ "",
87
+ "PATIENT PHENOTYPES (present):",
88
+ ]
89
+ for hid, name in zip(obs["patient_phenotypes"], obs["phenotype_names"]):
90
+ lines.append(f" + {hid} -- {name}")
91
+
92
+ absent_ids = obs.get("phenotypes_absent") or []
93
+ absent_names = obs.get("phenotype_absent_names") or []
94
+ if absent_ids:
95
+ lines += ["", "ABSENT PHENOTYPES (rule-out signal):"]
96
+ for hid, name in zip(absent_ids, absent_names):
97
+ lines.append(f" - {hid} -- {name}")
98
+
99
+ n = obs["current_node"]
100
+ lines += [
101
+ "",
102
+ f"CURRENT NODE: [{n['type'].upper()}] {n['name']} ({n['id']})",
103
+ f" Neighbors ({len(n['connected_node_ids'])}): {', '.join(n['connected_node_ids'][:8])}",
104
+ ]
105
+ if obs.get("trail"):
106
+ trail = [f"{t['name']}({t['id']})" for t in obs["trail"][-4:]]
107
+ lines.append(f" Trail: {' -> '.join(trail)}")
108
+
109
+ lines += ["", "CANDIDATE VARIANTS:"]
110
+ for v in obs["candidate_variants"]:
111
+ lines.append(
112
+ f" {v['id']} | {v['gene']} | {v['variant_type']} "
113
+ f"| path={v['pathogenicity_score']:.2f} | {v['clinical_significance']}"
114
+ )
115
+ lines.append(f"\nStep reward: {obs['step_reward']:+.4f} | Cumulative: {obs['cumulative_reward']:.4f}")
116
+ lines.append("Generate your diagnostic plan (3-5 JSON action blocks):")
117
+ return "\n".join(lines)
118
+
119
+
120
+ def parse_all_actions(text: str) -> List[Dict[str, Any]]:
121
+ actions = []
122
+ for m in re.finditer(r'\{[^{}]*"action_type"[^{}]*\}', text, re.DOTALL):
123
+ try:
124
+ d = json.loads(m.group(0))
125
+ atype = str(d.get("action_type", "")).lower()
126
+ if atype not in ("hop", "flag_causal", "backtrack", "summarise_trail", "request_lab"):
127
+ continue
128
+ actions.append({
129
+ "action_type": atype,
130
+ "node_id": str(d["node_id"]) if d.get("node_id") else None,
131
+ "variant_id": str(d["variant_id"]) if d.get("variant_id") else None,
132
+ "reasoning": str(d.get("reasoning", ""))[:200],
133
+ })
134
+ if atype == "flag_causal":
135
+ break
136
+ except Exception:
137
+ continue
138
+ return actions or [{"action_type": "summarise_trail", "reasoning": "fallback"}]
139
+
140
+
141
+ def parse_action(text: str) -> Dict[str, Any]:
142
+ m = re.search(r"\{.*\}", text, re.DOTALL)
143
+ if not m:
144
+ return {"action_type": "summarise_trail", "reasoning": "fallback"}
145
+ try:
146
+ d = json.loads(m.group(0))
147
+ atype = str(d.get("action_type", "summarise_trail")).lower()
148
+ if atype not in ("hop", "flag_causal", "backtrack", "summarise_trail", "request_lab"):
149
+ atype = "summarise_trail"
150
+ return {
151
+ "action_type": atype,
152
+ "node_id": str(d["node_id"]) if d.get("node_id") else None,
153
+ "variant_id": str(d["variant_id"]) if d.get("variant_id") else None,
154
+ "reasoning": str(d.get("reasoning", ""))[:200],
155
+ }
156
+ except Exception:
157
+ return {"action_type": "summarise_trail", "reasoning": "parse error"}
158
+
159
+
160
+ async def run_episode_async(
161
+ task_type: str,
162
+ actions: List[Dict[str, Any]],
163
+ seed: Optional[int] = None,
164
+ ) -> float:
165
+ ws_url = ENV_URL.replace("https://", "wss://").replace("http://", "ws://") + "/ws"
166
+ async with websockets.connect(ws_url, open_timeout=30, ping_interval=20) as ws:
167
+ reset_msg: Dict[str, Any] = {"type": "reset", "task_type": task_type}
168
+ if seed is not None:
169
+ reset_msg["seed"] = seed
170
+ await ws.send(json.dumps(reset_msg))
171
+ raw = json.loads(await ws.recv())
172
+ if raw.get("type") == "error":
173
+ return 0.1
174
+
175
+ obs = raw["data"]["observation"]
176
+ last_reward = 0.1
177
+
178
+ for action in actions:
179
+ if obs.get("done"):
180
+ break
181
+ await ws.send(json.dumps({"type": "step", "action": action}))
182
+ raw = json.loads(await ws.recv())
183
+ if raw.get("type") == "error":
184
+ break
185
+ data = raw["data"]
186
+ obs = data["observation"]
187
+ last_reward = data["reward"]
188
+ if obs.get("done"):
189
+ return float(last_reward)
190
+
191
+ return float(last_reward)
192
+
193
+
194
+ async def collect_episode_async(task_type: str, seed: Optional[int] = None) -> List[Dict]:
195
+ ws_url = ENV_URL.replace("https://", "wss://").replace("http://", "ws://") + "/ws"
196
+ steps: List[Dict] = []
197
+ async with websockets.connect(ws_url, open_timeout=30, ping_interval=20) as ws:
198
+ reset_msg: Dict[str, Any] = {"type": "reset", "task_type": task_type}
199
+ if seed is not None:
200
+ reset_msg["seed"] = seed
201
+ await ws.send(json.dumps(reset_msg))
202
+ raw = json.loads(await ws.recv())
203
+ if raw.get("type") == "error":
204
+ return steps
205
+ obs = raw["data"]["observation"]
206
+ steps.append({"prompt": format_obs(obs), "obs": obs, "task_type": task_type, "seed": seed})
207
+ return steps
208
+
209
+
210
+ def run_episode(task_type: str, actions: List[Dict], seed: Optional[int] = None) -> float:
211
+ return asyncio.get_event_loop().run_until_complete(run_episode_async(task_type, actions, seed))
212
+
213
+
214
+ def collect_episode(task_type: str, seed: Optional[int] = None) -> List[Dict]:
215
+ return asyncio.get_event_loop().run_until_complete(collect_episode_async(task_type, seed))
216
+
217
+
218
+ # ── Reward function ───────────────────────────────────────────────────────────
219
+
220
+ def clamp(v: float, lo: float = 0.01, hi: float = 0.99) -> float:
221
+ return max(lo, min(hi, v)) if math.isfinite(v) else 0.1
222
+
223
+
224
+ async def _eval_one_async(text: str, task: str, seed: Any) -> float:
225
+ actions = parse_all_actions(text)
226
+ try:
227
+ return clamp(await run_episode_async(task, actions, seed=seed))
228
+ except Exception:
229
+ return 0.1
230
+
231
+
232
+ def narada_reward(completions, prompts, task_type=None, seed=None, **kwargs):
233
+ n = len(completions)
234
+ tasks = task_type if task_type is not None else ["monogenic"] * n
235
+ seeds = seed if seed is not None else [None] * n
236
+
237
+ texts = []
238
+ for c in completions:
239
+ if isinstance(c, list):
240
+ texts.append(c[-1]["content"] if c else "")
241
+ else:
242
+ texts.append(str(c))
243
+
244
+ async def _batch():
245
+ return list(await asyncio.gather(*[
246
+ _eval_one_async(t, task, s)
247
+ for t, task, s in zip(texts, tasks, seeds)
248
+ ]))
249
+
250
+ return asyncio.get_event_loop().run_until_complete(_batch())
251
+
252
+
253
+ # ── Main ────────────────────────────────────────────────��─────────────────────
254
+
255
+ def main() -> None:
256
+ # ── Load model ────────────────────────────────────────────────────────────
257
+ os.environ["UNSLOTH_DISABLE_STATISTICS"] = "1"
258
+
259
+ print(f"Loading {BASE_MODEL}...", flush=True)
260
+ model, tokenizer = FastLanguageModel.from_pretrained(
261
+ model_name = BASE_MODEL,
262
+ max_seq_length= MAX_SEQ_LEN,
263
+ dtype = None,
264
+ load_in_4bit = True,
265
+ token = HF_TOKEN,
266
+ )
267
+ model = FastLanguageModel.get_peft_model(
268
+ model,
269
+ r = LORA_RANK,
270
+ target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
271
+ "gate_proj", "up_proj", "down_proj"],
272
+ lora_alpha = LORA_RANK * 2,
273
+ lora_dropout = 0.0,
274
+ bias = "none",
275
+ use_gradient_checkpointing = "unsloth",
276
+ random_state = 42,
277
+ )
278
+ print(f"Model loaded. Trainable params: {sum(p.numel() for p in model.parameters() if p.requires_grad):,}", flush=True)
279
+
280
+ # Disable Qwen3 thinking mode
281
+ _orig = tokenizer.apply_chat_template
282
+ def _no_think(*args, **kwargs):
283
+ kwargs["enable_thinking"] = False
284
+ return _orig(*args, **kwargs)
285
+ tokenizer.apply_chat_template = _no_think
286
+ print("Thinking mode disabled.", flush=True)
287
+
288
+ # ── Build dataset ─────────────────────────────────────────────────────────
289
+ import random
290
+ random.seed(42)
291
+ train_seeds = random.sample(range(1, 10000), N_SEEDS_PER_TASK * 3)
292
+
293
+ all_prompts = []
294
+ for i, phase in enumerate(CURRICULUM):
295
+ task = phase["task"]
296
+ seeds = train_seeds[i * N_SEEDS_PER_TASK : (i + 1) * N_SEEDS_PER_TASK]
297
+ print(f"Collecting {N_SEEDS_PER_TASK} prompts for task={task}...", flush=True)
298
+ for seed in seeds:
299
+ steps = collect_episode(task, seed=seed)
300
+ if steps:
301
+ all_prompts.append({
302
+ "prompt": [
303
+ {"role": "system", "content": SYSTEM_PROMPT},
304
+ {"role": "user", "content": steps[0]["prompt"]},
305
+ ],
306
+ "task_type": task,
307
+ "seed": seed,
308
+ })
309
+
310
+ dataset = Dataset.from_list(all_prompts)
311
+ print(f"Dataset: {len(dataset)} prompts", flush=True)
312
+
313
+ # ── GRPO config ───────────────────────────────────────────────────────────
314
+ import shutil
315
+ from trl import GRPOConfig, GRPOTrainer
316
+
317
+ cache_path = "/tmp/unsloth_compiled_cache"
318
+ if os.path.exists(cache_path):
319
+ shutil.rmtree(cache_path)
320
+
321
+ grpo_config = GRPOConfig(
322
+ num_generations = 8,
323
+ temperature = 1.1,
324
+ top_p = 0.95,
325
+ learning_rate = LR,
326
+ per_device_train_batch_size = MINI_BATCH_SIZE,
327
+ gradient_accumulation_steps = GRAD_ACCUM,
328
+ warmup_steps = WARMUP_STEPS,
329
+ max_grad_norm = 0.1,
330
+ optim = "adamw_8bit",
331
+ max_prompt_length = 1200,
332
+ max_completion_length = 800,
333
+ logging_steps = 5,
334
+ output_dir = f"/tmp/{ADAPTER_NAME}",
335
+ report_to = "none",
336
+ )
337
+
338
+ # ── Curriculum training ───────────────────────────────────────────────────
339
+ eval_results: Dict[str, float] = {}
340
+
341
+ for phase in CURRICULUM:
342
+ task = phase["task"]
343
+ n_steps = phase["steps"]
344
+
345
+ phase_data = dataset.filter(lambda x: x["task_type"] == task)
346
+ if len(phase_data) == 0:
347
+ print(f"Skipping {task} β€” no data.", flush=True)
348
+ continue
349
+
350
+ print(f"\n{'='*60}", flush=True)
351
+ print(f"Phase: {task} | {len(phase_data)} prompts | {n_steps} steps", flush=True)
352
+ print(f"{'='*60}", flush=True)
353
+
354
+ grpo_config.max_steps = n_steps
355
+
356
+ trainer = GRPOTrainer(
357
+ model = model,
358
+ processing_class = tokenizer,
359
+ reward_funcs = narada_reward,
360
+ args = grpo_config,
361
+ train_dataset = phase_data,
362
+ )
363
+
364
+ t0 = time.time()
365
+ trainer.train()
366
+ print(f"Phase {task} done in {(time.time()-t0)/60:.1f} min", flush=True)
367
+
368
+ # Eval
369
+ FastLanguageModel.for_inference(model)
370
+ scores = []
371
+ for es in EVAL_SEEDS:
372
+ steps = collect_episode(task, seed=es)
373
+ if not steps:
374
+ continue
375
+ messages = [
376
+ {"role": "system", "content": SYSTEM_PROMPT},
377
+ {"role": "user", "content": steps[0]["prompt"]},
378
+ ]
379
+ inputs = tokenizer.apply_chat_template(
380
+ messages, tokenize=True, add_generation_prompt=True, return_tensors="pt"
381
+ ).to("cuda")
382
+ with torch.no_grad():
383
+ out = model.generate(inputs, max_new_tokens=200, temperature=0.3)
384
+ completion = tokenizer.decode(out[0][inputs.shape[1]:], skip_special_tokens=True)
385
+ scores.append(run_episode(task, [parse_action(completion)], seed=es))
386
+
387
+ avg = sum(scores) / len(scores) if scores else 0.0
388
+ eval_results[task] = avg
389
+ print(f"Eval {task}: {avg:.4f} (n={len(scores)})", flush=True)
390
+ FastLanguageModel.for_training(model)
391
+
392
+ # ── Save & push ───────────────────────────────────────────────────────────
393
+ model.save_pretrained(ADAPTER_NAME)
394
+ tokenizer.save_pretrained(ADAPTER_NAME)
395
+ print(f"\nAdapter saved locally to ./{ADAPTER_NAME}", flush=True)
396
+
397
+ model.push_to_hub(HF_PUSH_REPO, token=HF_TOKEN)
398
+ tokenizer.push_to_hub(HF_PUSH_REPO, token=HF_TOKEN)
399
+ print(f"Pushed to https://huggingface.co/{HF_PUSH_REPO}", flush=True)
400
+
401
+ print("\n=== TRAINING COMPLETE ===", flush=True)
402
+ for task, score in eval_results.items():
403
+ print(f" {task:25s}: {score:.4f}", flush=True)
404
+
405
+
406
+ if __name__ == "__main__":
407
+ main()