Spaces:
Sleeping
Sleeping
| import re | |
| import json | |
| import uuid | |
| import random | |
| import torch | |
| import torch.nn.functional as F | |
| from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig | |
| from peft import LoraConfig, prepare_model_for_kbit_training | |
| from datasets import Dataset | |
| from trl import GRPOConfig, GRPOTrainer | |
| from server.simulation import MedchainSimulation | |
| from server.tasks import make_task_config | |
| from config import MODEL_ID, G, B, MAX_NEW_TOKENS, MAX_TURNS, MAX_ROUND_TURNS, MAX_STEPS, LR, SAVE_STEPS | |
| SYSTEM_PROMPT = """You are the central supply coordinator for a hospital network running 8 rounds per episode. | |
| Each round lasts 2 simulated days. After advance_round your conversation history is wiped β only the round brief carries over. | |
| βββ MANDATORY ROUND SEQUENCE βββ | |
| 1. read_inbox(filter="unread") β ALWAYS first; catches crises and recall alerts | |
| 2. view_requests() β see what wards are asking for (may be padded) | |
| 3. query_erp(table="pipeline_orders") β CHECK IN-TRANSIT ORDERS BEFORE ORDERING MORE | |
| 4. query_erp(table="inventory", location="central_pharmacy") β current on-hand stock | |
| [steps 3+4 can be called in parallel in a single turn] | |
| 5. query_ward_history / query_supplier β additional context if needed (1-2 calls max) | |
| [multiple query_ward_history calls can be parallelised across wards in one turn] | |
| 6. submit_po(...) [+ file_justification if expedited] β order ONLY the net gap (see PO rules below) | |
| 7. quarantine_lot(...) β ONLY if inbox contains a recall or cold-chain breach alert | |
| 8. submit_allocation_plan(plan_json=...) β REQUIRED every round; see rules below | |
| 9. advance_round() β LAST call; ends the round | |
| βββ REFERENCE β VALID IDs (use EXACTLY these strings) βββ | |
| Locations (for query_erp and submit_po destination_id): | |
| central_pharmacy ward_icu ward_er ward_general | |
| Suppliers (for query_supplier and submit_po): | |
| MEDLINE (lead 2d, cost 1.0Γ, all SKUs) | |
| BACKUP-B (lead 3d, cost 1.3Γ, all SKUs β fallback when MEDLINE disrupted) | |
| FASTMED (lead 1d, cost 1.8Γ, all SKUs β use only for life-critical emergencies) | |
| βββ PURCHASE ORDER RULES (critical for budget score) βββ | |
| β’ ALWAYS query pipeline_orders BEFORE placing any PO. Your context is wiped after each round β | |
| last round's orders are still in transit and will appear in pipeline_orders. | |
| β’ Derive consumption from query_ward_history β do NOT invent or memorise fixed quantities. | |
| β’ Compute for each SKU: net_qty = history_avg_consumption - in_transit_qty - surplus_on_hand. | |
| *** If net_qty β€ 0: DO NOT call submit_po. Calling submit_po with quantity β€ 0 is FORBIDDEN. *** | |
| *** If net_qty > 0: call submit_po ONCE with quantity = net_qty. ONE call per SKU total. *** | |
| β’ Sum need across all wards before calling submit_po β never submit per-ward separately. | |
| All stock routes through central_pharmacy regardless of destination. | |
| β’ Do NOT call view_requests() more than once per round. | |
| βββ WARD SKU REFERENCE βββ | |
| ward_icu : BLOOD-RBC BLOOD-PLT BLOOD-FFP IV-SAL-500 ANTIBIO-01 OXY-MASK SYR-10 GLOVE-001 | |
| ward_er : BLOOD-RBC BLOOD-PLT BLOOD-FFP IV-SAL-500 ANTIBIO-01 OXY-MASK SYR-10 GLOVE-001 GAUZE-01 | |
| ward_general : IV-SAL-500 ANTIBIO-01 SYR-10 GLOVE-001 MASK-001 GAUZE-01 | |
| βββ ALLOCATION PLAN RULES (critical for score) βββ | |
| β’ Include ALL THREE wards β ward_icu, ward_er, ward_general β every single round. | |
| β’ Include EVERY SKU listed above for each ward. Omitting a SKU is treated as 0 (stockout penalty). | |
| β’ Never allocate 0 for any SKU. Use ward_history average consumption when uncertain. | |
| β’ Start from request quantities, then discount by the inflation factor (wards pad 10-60%). | |
| ICU pads ~10%, ER pads ~20-50%, General pads ~25-60% β allocate closer to true need. | |
| β’ Blood products (BLOOD-RBC, BLOOD-PLT, BLOOD-FFP) are CRITICAL for ICU and ER β never stockout. | |
| β’ Allocation comes from central_pharmacy stock. Submit POs first if stock is low. | |
| βββ QUERY RULES βββ | |
| β’ query_erp: location and sku are optional β but NEVER pass null or empty string. | |
| Omit the field entirely when you want all rows: query_erp(table="inventory", location="central_pharmacy") | |
| β’ query_supplier: use only MEDLINE, BACKUP-B, or FASTMED β no other IDs exist. | |
| β’ file_justification ticket_id: use the PO ticket ID returned by submit_po response, not a guessed ID. | |
| βββ EVENTS TO WATCH FOR βββ | |
| β’ MCI (mass casualty): blood demand Γ2.8 at ICU+ER β pre-position via expedited FASTMED orders. | |
| β’ Supplier disruption: switch to BACKUP-B for urgent items; FASTMED for life-critical. | |
| β’ Product recall: quarantine_lot at every listed location using the EXACT lot_id string from the inbox message body β never invent or guess it. | |
| β’ Cold-chain breach: lot is auto-quarantined by the system; place emergency replenishment order β no quarantine_lot call needed.""" | |
| TOOL_SCHEMAS = [ | |
| {"type": "function", "function": { | |
| "name": "read_inbox", | |
| "description": "Read inbox messages. Always call first each round.", | |
| "parameters": {"type": "object", "properties": { | |
| "filter": {"type": "string", "enum": ["unread", "all", "flagged"]} | |
| }, "required": ["filter"]}, | |
| }}, | |
| {"type": "function", "function": { | |
| "name": "view_requests", | |
| "description": "View pending ward supply requests. Requests may be inflated.", | |
| "parameters": {"type": "object", "properties": {}}, | |
| }}, | |
| {"type": "function", "function": { | |
| "name": "query_ward_history", | |
| "description": "Query historical requests and allocations for a ward.", | |
| "parameters": {"type": "object", "properties": { | |
| "ward_id": {"type": "string"}, | |
| "product_id": {"type": "string"}, | |
| "n_rounds": {"type": "integer"}, | |
| }, "required": ["ward_id"]}, | |
| }}, | |
| {"type": "function", "function": { | |
| "name": "query_erp", | |
| "description": ( | |
| "Query ERP system for inventory, expiry, or pipeline orders. " | |
| "location must be one of: central_pharmacy, ward_icu, ward_er, ward_general. " | |
| "sku must be a valid SKU string. OMIT location or sku entirely to get all rows β " | |
| "never pass null or an empty string." | |
| ), | |
| "parameters": {"type": "object", "properties": { | |
| "table": {"type": "string", "enum": ["inventory", "expiry", "pipeline_orders"]}, | |
| "location": {"type": "string", "enum": ["central_pharmacy", "ward_icu", "ward_er", "ward_general"]}, | |
| "sku": {"type": "string"}, | |
| }, "required": ["table"]}, | |
| }}, | |
| {"type": "function", "function": { | |
| "name": "query_supplier", | |
| "description": "Query supplier lead time and disruption status. Valid supplier_id values: MEDLINE, BACKUP-B, FASTMED.", | |
| "parameters": {"type": "object", "properties": { | |
| "supplier_id": {"type": "string", "enum": ["MEDLINE", "BACKUP-B", "FASTMED"]}, | |
| }, "required": ["supplier_id"]}, | |
| }}, | |
| {"type": "function", "function": { | |
| "name": "submit_po", | |
| "description": "Submit a purchase order to a supplier. destination_id must be central_pharmacy (the central hub β stock is allocated from there to wards). Use FASTMED only for life-critical emergencies (1d lead, 1.8Γ cost).", | |
| "parameters": {"type": "object", "properties": { | |
| "supplier_id": {"type": "string", "enum": ["MEDLINE", "BACKUP-B", "FASTMED"]}, | |
| "product_id": {"type": "string"}, | |
| "destination_id": {"type": "string", "enum": ["central_pharmacy", "ward_icu", "ward_er", "ward_general"]}, | |
| "quantity": {"type": "integer"}, | |
| "priority": {"type": "string", "enum": ["standard", "expedited"]}, | |
| }, "required": ["supplier_id", "product_id", "destination_id", "quantity"]}, | |
| }}, | |
| {"type": "function", "function": { | |
| "name": "file_justification", | |
| "description": "File justification for an expedited purchase order.", | |
| "parameters": {"type": "object", "properties": { | |
| "ticket_id": {"type": "string"}, | |
| "reason": {"type": "string"}, | |
| }, "required": ["ticket_id", "reason"]}, | |
| }}, | |
| {"type": "function", "function": { | |
| "name": "quarantine_lot", | |
| "description": "Quarantine a lot due to recall or cold-chain breach.", | |
| "parameters": {"type": "object", "properties": { | |
| "location_id": {"type": "string"}, | |
| "sku": {"type": "string"}, | |
| "lot_id": {"type": "string"}, | |
| }, "required": ["location_id", "sku", "lot_id"]}, | |
| }}, | |
| {"type": "function", "function": { | |
| "name": "submit_allocation_plan", | |
| "description": "Submit stock allocation plan across all wards as JSON string {ward_id: {sku: qty}}.", | |
| "parameters": {"type": "object", "properties": { | |
| "plan_json": {"type": "string"}, | |
| }, "required": ["plan_json"]}, | |
| }}, | |
| {"type": "function", "function": { | |
| "name": "advance_round", | |
| "description": "Close the current round and advance to the next. Call after all actions are complete.", | |
| "parameters": {"type": "object", "properties": {}}, | |
| }}, | |
| ] | |
| # Integer params that need type coercion (native format gives all values as strings) | |
| _INT_PARAMS: dict[str, set[str]] = { | |
| tool["function"]["name"]: { | |
| k for k, v in tool["function"]["parameters"].get("properties", {}).items() | |
| if v.get("type") == "integer" | |
| } | |
| for tool in TOOL_SCHEMAS | |
| } | |
| def parse_tool_calls(text: str) -> list[dict]: | |
| """Parse Qwen3.5 native XML tool call format.""" | |
| results = [] | |
| for block in re.finditer(r'<tool_call>(.*?)</tool_call>', text, re.DOTALL): | |
| inner = block.group(1) | |
| fn = re.search(r'<function=([^>]+)>', inner) | |
| if not fn: | |
| continue | |
| name = fn.group(1).strip() | |
| args = {} | |
| for p in re.finditer(r'<parameter=([^>]+)>\n?(.*?)\n?</parameter>', inner, re.DOTALL): | |
| k, v = p.group(1).strip(), p.group(2).strip() | |
| if k in _INT_PARAMS.get(name, set()): | |
| try: | |
| v = int(v) | |
| except (ValueError, TypeError): | |
| pass | |
| args[k] = v | |
| results.append({"name": name, "arguments": args}) | |
| return results | |
| # Reward closure β rollout_func populates this, reward_func reads it | |
| _current_rewards: list[float] = [] | |
| def reward_func(completions, prompts, **kwargs) -> list[float]: | |
| return list(_current_rewards) | |
| def medchain_rollout(prompts, trainer) -> dict: | |
| """ | |
| Custom rollout with fresh context windows per round. | |
| Receives B=8 prompts from TRL. Creates G=4 episodes per seed = 32 total. | |
| Each episode resets its context window after every advance_round() call. | |
| All active episodes are batched into one forward pass per loop iteration. | |
| Returns 32 results; TRL groups consecutive G=4 as one GRPO group. | |
| """ | |
| global _current_rewards | |
| model = trainer.model | |
| tok = trainer.processing_class | |
| model.eval() | |
| # ββ Initialise 32 episodes (B=8 seeds Γ G=4 rollouts) ββββββββββββββββββ | |
| episodes = [] | |
| for prompt in prompts: | |
| # seed and difficulty embedded in the last user message by the dataset | |
| meta = prompt[-1]["content"] | |
| seed = int(re.search(r"seed=(\d+)", meta).group(1)) | |
| diff = re.search(r"diff=(\w+)", meta).group(1) | |
| for _ in range(G): | |
| sim = MedchainSimulation(make_task_config(seed=seed, difficulty=diff)) | |
| brief = sim.reset(seed=seed, episode_id=str(uuid.uuid4())) | |
| episodes.append({ | |
| "sim": sim, | |
| "messages": [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": brief}, | |
| ], | |
| "comp_ids": [], # per-turn cpu tensors | |
| "logprobs": [], # per-turn cpu tensors | |
| "first_prompt_ids": None, | |
| "done": False, | |
| "reward": 0.0, | |
| "round_turns": 0, | |
| }) | |
| print(f"[rollout] init {len(episodes)} episodes ({len(prompts)} seeds Γ G={G})") | |
| # ββ Batched fresh-window inference loop βββββββββββββββββββββββββββββββββ | |
| for turn in range(MAX_TURNS): | |
| active = [ep for ep in episodes if not ep["done"]] | |
| if not active: | |
| break | |
| if turn % 10 == 0: | |
| n_done = len(episodes) - len(active) | |
| vram = torch.cuda.memory_allocated() / 1e9 if torch.cuda.is_available() else 0 | |
| print(f"[rollout turn {turn:3d}] active={len(active)} done={n_done}/{len(episodes)} VRAM={vram:.2f}GB") | |
| tok.padding_side = "left" | |
| texts = [ | |
| tok.apply_chat_template( | |
| ep["messages"], | |
| tools=TOOL_SCHEMAS, | |
| tokenize=False, | |
| add_generation_prompt=True, | |
| chat_template_kwargs={"enable_thinking": False}, | |
| ) | |
| for ep in active | |
| ] | |
| inputs = tok(texts, return_tensors="pt", padding=True).to(model.device) | |
| padded_len = inputs["input_ids"].shape[1] | |
| with torch.no_grad(): | |
| gen = model.generate( | |
| **inputs, | |
| max_new_tokens=MAX_NEW_TOKENS, | |
| temperature=0.7, | |
| top_p=0.8, | |
| top_k=20, | |
| do_sample=True, | |
| return_dict_in_generate=True, | |
| output_scores=True, | |
| pad_token_id=tok.eos_token_id, | |
| ) | |
| for i, ep in enumerate(active): | |
| comp = gen.sequences[i, padded_len:].cpu() | |
| # Trim at first EOS | |
| eos_pos = (comp == tok.eos_token_id).nonzero() | |
| if len(eos_pos): | |
| comp = comp[: eos_pos[0].item() + 1] | |
| # Per-token logprobs from generation scores | |
| T = len(comp) | |
| if T > 0 and gen.scores: | |
| scores = torch.stack([gen.scores[t][i].cpu() for t in range(T)]) | |
| lp = F.log_softmax(scores.float(), dim=-1) | |
| token_lp = lp.gather(1, comp.unsqueeze(-1)).squeeze(-1) | |
| else: | |
| token_lp = torch.zeros(T) | |
| ep["comp_ids"].append(comp) | |
| ep["logprobs"].append(token_lp) | |
| # Store first-round prompt_ids for TRL's loss mask | |
| if ep["first_prompt_ids"] is None: | |
| raw = gen.sequences[i, :padded_len].cpu() | |
| non_pad = (raw != tok.pad_token_id).nonzero() | |
| ep["first_prompt_ids"] = raw[non_pad[0].item():] if len(non_pad) else raw | |
| # Decode and parse tool calls | |
| text = tok.decode(comp, skip_special_tokens=True) | |
| ep["messages"].append({"role": "assistant", "content": text}) | |
| tool_calls = parse_tool_calls(text) | |
| if not tool_calls: | |
| print(f" [ep{i}] turn={turn} round_turn={ep['round_turns']} β no tool call parsed text={text[:80]!r}") | |
| ep["messages"].append({ | |
| "role": "user", | |
| "content": "Use a tool. Call advance_round when done with this round.", | |
| }) | |
| else: | |
| for tc in tool_calls: | |
| name = tc.get("name", "") | |
| args = tc.get("arguments", {}) | |
| sim = ep["sim"] | |
| try: | |
| result = ( | |
| getattr(sim, name)(**args) | |
| if hasattr(sim, name) | |
| else f"ERROR: Unknown tool '{name}'" | |
| ) | |
| except Exception as e: | |
| result = f"ERROR: {e}" | |
| print(f" [ep{i}] TOOL ERROR {name}({args}): {e}") | |
| ep["messages"].append({"role": "user", "content": f"<tool_response>{result}</tool_response>"}) | |
| if name == "advance_round": | |
| if sim._done: | |
| ep["done"] = True | |
| ep["reward"] = sim._last_reward | |
| print(f" [ep{i}] DONE reward={ep['reward']:.4f}") | |
| else: | |
| ep["messages"] = [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": result}, | |
| ] | |
| ep["round_turns"] = 0 | |
| print(f" [ep{i}] advance_round β next round round_turns reset") | |
| break | |
| ep["round_turns"] += 1 | |
| # Force advance if model gets stuck within a round | |
| if not ep["done"] and ep["round_turns"] >= MAX_ROUND_TURNS: | |
| print(f" [ep{i}] FORCE advance_round (stuck {ep['round_turns']} turns in round)") | |
| result = ep["sim"].advance_round() | |
| if ep["sim"]._done: | |
| ep["done"] = True | |
| ep["reward"] = ep["sim"]._last_reward | |
| print(f" [ep{i}] DONE (forced) reward={ep['reward']:.4f}") | |
| else: | |
| ep["messages"] = [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": result}, | |
| ] | |
| ep["round_turns"] = 0 | |
| # ββ Package results for TRL βββββββββββββββββββββββββββββββββββββββββββββ | |
| _current_rewards = [ep["reward"] for ep in episodes] | |
| n_done = sum(1 for ep in episodes if ep["done"]) | |
| rewards = _current_rewards | |
| print(f"[rollout] done={n_done}/{len(episodes)} " | |
| f"reward mean={sum(rewards)/len(rewards):.4f} " | |
| f"min={min(rewards):.4f} max={max(rewards):.4f} " | |
| f"zeros={rewards.count(0.0)}/{len(rewards)}") | |
| return { | |
| "prompt_ids": [ | |
| ep["first_prompt_ids"] | |
| if ep["first_prompt_ids"] is not None | |
| else torch.tensor([], dtype=torch.long) | |
| for ep in episodes | |
| ], | |
| "completion_ids": [ | |
| torch.cat(ep["comp_ids"]) | |
| if ep["comp_ids"] | |
| else torch.tensor([], dtype=torch.long) | |
| for ep in episodes | |
| ], | |
| "logprobs": [ | |
| torch.cat(ep["logprobs"]) | |
| if ep["logprobs"] | |
| else torch.tensor([], dtype=torch.float) | |
| for ep in episodes | |
| ], | |
| } | |
| def load_model(): | |
| bnb_config = BitsAndBytesConfig( | |
| load_in_4bit=True, | |
| bnb_4bit_quant_type="nf4", | |
| bnb_4bit_use_double_quant=True, | |
| bnb_4bit_compute_dtype=torch.bfloat16, | |
| ) | |
| model = AutoModelForCausalLM.from_pretrained( | |
| MODEL_ID, quantization_config=bnb_config, device_map="auto" | |
| ) | |
| model = prepare_model_for_kbit_training(model) | |
| tokenizer = AutoTokenizer.from_pretrained(MODEL_ID) | |
| return model, tokenizer | |
| peft_config = LoraConfig( | |
| r=16, | |
| lora_alpha=16, | |
| target_modules="all-linear", | |
| lora_dropout=0.05, | |
| bias="none", | |
| task_type="CAUSAL_LM", | |
| ) | |
| # Do NOT call get_peft_model here β TRL applies it via peft_config | |
| # and uses disable_adapter() for the KL reference model. | |
| def build_dataset() -> Dataset: | |
| random.seed(42) | |
| seeds_list = list(range(50)) * 20 # 1000 rows; seeds 0-49 each Γ20 | |
| diffs_list = [random.choice(["light", "medium", "heavy"]) for _ in seeds_list] | |
| return Dataset.from_dict({ | |
| "prompt": [ | |
| [ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| # Embed seed/difficulty so rollout_func can extract them | |
| {"role": "user", "content": f"seed={s};diff={d}"}, | |
| ] | |
| for s, d in zip(seeds_list, diffs_list) | |
| ], | |
| }).shuffle(seed=42) | |
| def main(): | |
| import argparse | |
| import wandb | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument("--wandb-project", default="medchain-grpo") | |
| parser.add_argument("--max-steps", type=int, default=MAX_STEPS) | |
| parser.add_argument("--lr", type=float, default=LR) | |
| parser.add_argument("--save-steps", type=int, default=SAVE_STEPS) | |
| args = parser.parse_args() | |
| wandb.init(project=args.wandb_project) | |
| model, tokenizer = load_model() | |
| train_dataset = build_dataset() | |
| config = GRPOConfig( | |
| num_generations=G, # TRL groups consecutive G=4 results as one GRPO group | |
| per_device_train_batch_size=B, # B=8 seeds per step; rollout_func returns B*G=32 results | |
| max_completion_length=16384, # ceiling on concatenated completion_ids per episode | |
| learning_rate=args.lr, | |
| max_steps=args.max_steps, | |
| bf16=True, | |
| output_dir="checkpoints", | |
| save_steps=args.save_steps, | |
| logging_steps=1, | |
| report_to="wandb", | |
| ) | |
| trainer = GRPOTrainer( | |
| model=model, | |
| processing_class=tokenizer, | |
| reward_funcs=reward_func, | |
| args=config, | |
| train_dataset=train_dataset, | |
| peft_config=peft_config, | |
| rollout_func=medchain_rollout, | |
| ) | |
| trainer.train() | |
| if __name__ == "__main__": | |
| main() | |