Commit ·
3d38e23
1
Parent(s): 00732f3
chore: Update workspace before push
Browse files- README.md +1 -0
- logs/semantic_ppo/PPO_1/events.out.tfevents.1769298476.Lincoln.33632.0 +0 -0
- logs/semantic_ppo/evaluations.npz +0 -0
- manual_test_pds.py +47 -0
- models/semantic_ppo/best_model.zip +3 -0
- models/semantic_ppo/semantic_ppo_final.zip +3 -0
- notebooks/IJHPM_50_Scenario_Validation.ipynb +462 -0
- notebooks/LLM_as_Judge_Evaluation_Local.ipynb +317 -0
- notebooks/NurseSim_Unified_Validation_Local.ipynb +326 -0
- nursesim_rl/__init__.py +8 -2
- nursesim_rl/semantic_wrapper.py +169 -0
- test_semantic.py +64 -0
- train_semantic_agent.py +143 -0
- viz/semantic_clusters.png +3 -0
- viz_semantic.py +173 -0
README.md
CHANGED
|
@@ -30,6 +30,7 @@ pinned: false
|
|
| 30 |
- **Expanded Dataset:** Trained on **2,100+** synthetic patient scenarios across all 5 MTS categories.
|
| 31 |
- **Safety-Aware Rewards:** Heavy penalties for under-triaging critical patients.
|
| 32 |
- **Fine-Tuned Agent:** Llama 3.2 3B trained with Unsloth (4-bit QLoRA) - **60% accuracy validated**.
|
|
|
|
| 33 |
- **Age-Aware Triage:** Demographic parsing for accurate risk stratification.
|
| 34 |
- **A2A Protocol:** Agent-to-Agent evaluation via AgentBeats platform.
|
| 35 |
- **Docker Deployment:** Fully containerized for reproducibility.
|
|
|
|
| 30 |
- **Expanded Dataset:** Trained on **2,100+** synthetic patient scenarios across all 5 MTS categories.
|
| 31 |
- **Safety-Aware Rewards:** Heavy penalties for under-triaging critical patients.
|
| 32 |
- **Fine-Tuned Agent:** Llama 3.2 3B trained with Unsloth (4-bit QLoRA) - **60% accuracy validated**.
|
| 33 |
+
- **NEW: Semantic RL Mode:** NurseEmbed-powered text embeddings for language-conditioned agents.
|
| 34 |
- **Age-Aware Triage:** Demographic parsing for accurate risk stratification.
|
| 35 |
- **A2A Protocol:** Agent-to-Agent evaluation via AgentBeats platform.
|
| 36 |
- **Docker Deployment:** Fully containerized for reproducibility.
|
logs/semantic_ppo/PPO_1/events.out.tfevents.1769298476.Lincoln.33632.0
ADDED
|
Binary file (5.66 kB). View file
|
|
|
logs/semantic_ppo/evaluations.npz
ADDED
|
Binary file (1.12 kB). View file
|
|
|
manual_test_pds.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import sys
|
| 3 |
+
import os
|
| 4 |
+
import asyncio
|
| 5 |
+
|
| 6 |
+
# Add current directory to path so we can import nursesim_rl
|
| 7 |
+
sys.path.append(os.getcwd())
|
| 8 |
+
|
| 9 |
+
from nursesim_rl.pds_client import PDSClient, PDSEnvironment
|
| 10 |
+
|
| 11 |
+
async def main():
|
| 12 |
+
print("🏥 Testing NHS PDS Client...")
|
| 13 |
+
|
| 14 |
+
# 1. Test Verification
|
| 15 |
+
print("\n1. Testing NHS Number Validation")
|
| 16 |
+
valid = "9000000009"
|
| 17 |
+
invalid = "1234567890"
|
| 18 |
+
|
| 19 |
+
if PDSClient.validate_nhs_number(valid):
|
| 20 |
+
print(f"✅ Valid number {valid} passed")
|
| 21 |
+
else:
|
| 22 |
+
print(f"❌ Valid number {valid} FAILED")
|
| 23 |
+
|
| 24 |
+
if not PDSClient.validate_nhs_number(invalid):
|
| 25 |
+
print(f"✅ Invalid number {invalid} passed (rejected)")
|
| 26 |
+
else:
|
| 27 |
+
print(f"❌ Invalid number {invalid} FAILED (accepted)")
|
| 28 |
+
|
| 29 |
+
# 2. Test Sandbox Lookup
|
| 30 |
+
print("\n2. Testing Sandbox API Lookup (Network Request)")
|
| 31 |
+
client = PDSClient(environment=PDSEnvironment.SANDBOX)
|
| 32 |
+
|
| 33 |
+
try:
|
| 34 |
+
print(f" Looking up {valid}...")
|
| 35 |
+
patient = await client.lookup_patient(valid)
|
| 36 |
+
print(f"✅ Success! Found patient:")
|
| 37 |
+
print(f" Name: {patient.full_name}")
|
| 38 |
+
print(f" Age: {patient.age}")
|
| 39 |
+
print(f" Gender: {patient.gender}")
|
| 40 |
+
print(f" GP: {patient.gp_practice_name}")
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print(f"❌ API Lookup Failed: {e}")
|
| 43 |
+
if hasattr(e, 'response'):
|
| 44 |
+
print(f"Response Body: {e.response.text}")
|
| 45 |
+
|
| 46 |
+
if __name__ == "__main__":
|
| 47 |
+
asyncio.run(main())
|
models/semantic_ppo/best_model.zip
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0d8606997ff293e24e1d1ffdd2d3551bf9f0b3eb253e0ad557e1cc889efd9229
|
| 3 |
+
size 753215
|
models/semantic_ppo/semantic_ppo_final.zip
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:eec25bc334e4f0a4178f22cff6f0aa606364205103bd2c90790ff8593088f291
|
| 3 |
+
size 753230
|
notebooks/IJHPM_50_Scenario_Validation.ipynb
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {},
|
| 6 |
+
"source": [
|
| 7 |
+
"# 📊 IJHPM Manuscript Validation: 50-Scenario Benchmark\n",
|
| 8 |
+
"\n",
|
| 9 |
+
"**Purpose:** Run NurseSim-Triage evaluation on 50 standardized clinical scenarios for the IJHPM manuscript.\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"**Output:** Accuracy metrics, category-level performance, and manuscript-ready tables.\n",
|
| 12 |
+
"\n",
|
| 13 |
+
"---"
|
| 14 |
+
]
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"cell_type": "code",
|
| 18 |
+
"execution_count": null,
|
| 19 |
+
"metadata": {},
|
| 20 |
+
"outputs": [],
|
| 21 |
+
"source": [
|
| 22 |
+
"# Install dependencies\n",
|
| 23 |
+
"!pip install -q gradio_client pandas matplotlib"
|
| 24 |
+
]
|
| 25 |
+
},
|
| 26 |
+
{
|
| 27 |
+
"cell_type": "code",
|
| 28 |
+
"execution_count": null,
|
| 29 |
+
"metadata": {},
|
| 30 |
+
"outputs": [],
|
| 31 |
+
"source": [
|
| 32 |
+
"import json\n",
|
| 33 |
+
"import re\n",
|
| 34 |
+
"import time\n",
|
| 35 |
+
"import pandas as pd\n",
|
| 36 |
+
"import matplotlib.pyplot as plt\n",
|
| 37 |
+
"from gradio_client import Client\n",
|
| 38 |
+
"from datetime import datetime\n",
|
| 39 |
+
"\n",
|
| 40 |
+
"print(\"✅ Libraries loaded\")"
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"cell_type": "markdown",
|
| 45 |
+
"metadata": {},
|
| 46 |
+
"source": [
|
| 47 |
+
"## 1. Load Validation Dataset"
|
| 48 |
+
]
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"cell_type": "code",
|
| 52 |
+
"execution_count": null,
|
| 53 |
+
"metadata": {},
|
| 54 |
+
"outputs": [],
|
| 55 |
+
"source": [
|
| 56 |
+
"# Download val.jsonl from GitHub\n",
|
| 57 |
+
"!wget -q https://raw.githubusercontent.com/ClinyQAi/NurseSim-RL/main/data/val.jsonl -O val.jsonl\n",
|
| 58 |
+
"\n",
|
| 59 |
+
"# Load scenarios\n",
|
| 60 |
+
"scenarios = []\n",
|
| 61 |
+
"with open('val.jsonl', 'r') as f:\n",
|
| 62 |
+
" for line in f:\n",
|
| 63 |
+
" if line.strip():\n",
|
| 64 |
+
" scenarios.append(json.loads(line))\n",
|
| 65 |
+
"\n",
|
| 66 |
+
"# Use first 50 for validation\n",
|
| 67 |
+
"scenarios = scenarios[:50]\n",
|
| 68 |
+
"print(f\"✅ Loaded {len(scenarios)} scenarios\")\n",
|
| 69 |
+
"\n",
|
| 70 |
+
"# Show category distribution\n",
|
| 71 |
+
"cat_counts = {}\n",
|
| 72 |
+
"for s in scenarios:\n",
|
| 73 |
+
" cat = s.get('category', 'Unknown')\n",
|
| 74 |
+
" cat_counts[cat] = cat_counts.get(cat, 0) + 1\n",
|
| 75 |
+
"\n",
|
| 76 |
+
"cat_names = {1:'Immediate', 2:'Very Urgent', 3:'Urgent', 4:'Standard', 5:'Non-Urgent'}\n",
|
| 77 |
+
"print(\"\\nCategory Distribution:\")\n",
|
| 78 |
+
"for cat in sorted(cat_counts.keys()):\n",
|
| 79 |
+
" print(f\" Category {cat} ({cat_names.get(cat, 'Unknown')}): {cat_counts[cat]} cases\")"
|
| 80 |
+
]
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"cell_type": "markdown",
|
| 84 |
+
"metadata": {},
|
| 85 |
+
"source": [
|
| 86 |
+
"## 2. Connect to NurseSim-Triage Model"
|
| 87 |
+
]
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"cell_type": "code",
|
| 91 |
+
"execution_count": null,
|
| 92 |
+
"metadata": {},
|
| 93 |
+
"outputs": [],
|
| 94 |
+
"source": [
|
| 95 |
+
"# Connect to Hugging Face Space\n",
|
| 96 |
+
"print(\"Connecting to NurseSim-Triage...\")\n",
|
| 97 |
+
"try:\n",
|
| 98 |
+
" client = Client(\"NurseCitizenDeveloper/NurseSim-Triage-Demo\")\n",
|
| 99 |
+
" print(\"✅ Connected to NurseSim-Triage\")\n",
|
| 100 |
+
"except Exception as e:\n",
|
| 101 |
+
" print(f\"❌ Connection failed: {e}\")\n",
|
| 102 |
+
" print(\"\\nTroubleshooting:\")\n",
|
| 103 |
+
" print(\"1. Check if the Space is running: https://huggingface.co/spaces/NurseCitizenDeveloper/NurseSim-Triage-Demo\")\n",
|
| 104 |
+
" print(\"2. The Space may need to 'wake up' - try refreshing the page first\")\n",
|
| 105 |
+
" client = None"
|
| 106 |
+
]
|
| 107 |
+
},
|
| 108 |
+
{
|
| 109 |
+
"cell_type": "code",
|
| 110 |
+
"execution_count": null,
|
| 111 |
+
"metadata": {},
|
| 112 |
+
"outputs": [],
|
| 113 |
+
"source": [
|
| 114 |
+
"def parse_scenario(scenario):\n",
|
| 115 |
+
" \"\"\"Extract vitals from scenario input text\"\"\"\n",
|
| 116 |
+
" input_text = scenario['input']\n",
|
| 117 |
+
" \n",
|
| 118 |
+
" # Extract chief complaint\n",
|
| 119 |
+
" complaint_match = re.search(r'Chief Complaint: \"(.+?)\"', input_text)\n",
|
| 120 |
+
" complaint = complaint_match.group(1) if complaint_match else input_text[:100]\n",
|
| 121 |
+
" \n",
|
| 122 |
+
" # Extract vitals\n",
|
| 123 |
+
" hr_match = re.search(r'HR: (\\d+)', input_text)\n",
|
| 124 |
+
" bp_match = re.search(r'BP: ([\\d/]+)', input_text)\n",
|
| 125 |
+
" spo2_match = re.search(r'SpO2: (\\d+)', input_text)\n",
|
| 126 |
+
" temp_match = re.search(r'Temp: ([\\d.]+)', input_text)\n",
|
| 127 |
+
" \n",
|
| 128 |
+
" return {\n",
|
| 129 |
+
" 'complaint': complaint,\n",
|
| 130 |
+
" 'hr': int(hr_match.group(1)) if hr_match else 80,\n",
|
| 131 |
+
" 'bp': bp_match.group(1) if bp_match else '120/80',\n",
|
| 132 |
+
" 'spo2': int(spo2_match.group(1)) if spo2_match else 98,\n",
|
| 133 |
+
" 'temp': float(temp_match.group(1)) if temp_match else 37.0,\n",
|
| 134 |
+
" 'expected': scenario.get('category', -1)\n",
|
| 135 |
+
" }\n",
|
| 136 |
+
"\n",
|
| 137 |
+
"def extract_category(response_text):\n",
|
| 138 |
+
" \"\"\"Extract triage category 1-5 from model response\"\"\"\n",
|
| 139 |
+
" text = str(response_text).lower()\n",
|
| 140 |
+
" \n",
|
| 141 |
+
" # Check for category words\n",
|
| 142 |
+
" if 'category: 1' in text or 'immediate' in text and 'red' in text:\n",
|
| 143 |
+
" return 1\n",
|
| 144 |
+
" if 'category: 2' in text or 'very urgent' in text:\n",
|
| 145 |
+
" return 2\n",
|
| 146 |
+
" if 'category: 3' in text or ('urgent' in text and 'very' not in text and 'non' not in text):\n",
|
| 147 |
+
" return 3\n",
|
| 148 |
+
" if 'category: 4' in text or 'standard' in text:\n",
|
| 149 |
+
" return 4\n",
|
| 150 |
+
" if 'category: 5' in text or 'non-urgent' in text or 'non urgent' in text:\n",
|
| 151 |
+
" return 5\n",
|
| 152 |
+
" \n",
|
| 153 |
+
" # Look for number pattern\n",
|
| 154 |
+
" match = re.search(r'category[:\\s]*([1-5])', text)\n",
|
| 155 |
+
" if match:\n",
|
| 156 |
+
" return int(match.group(1))\n",
|
| 157 |
+
" \n",
|
| 158 |
+
" return -1\n",
|
| 159 |
+
"\n",
|
| 160 |
+
"def query_model(parsed):\n",
|
| 161 |
+
" \"\"\"Query NurseSim-Triage model\"\"\"\n",
|
| 162 |
+
" if client is None:\n",
|
| 163 |
+
" return -1, \"No client\"\n",
|
| 164 |
+
" \n",
|
| 165 |
+
" try:\n",
|
| 166 |
+
" result = client.predict(\n",
|
| 167 |
+
" complaint=parsed['complaint'],\n",
|
| 168 |
+
" hr=float(parsed['hr']),\n",
|
| 169 |
+
" bp=parsed['bp'],\n",
|
| 170 |
+
" spo2=float(parsed['spo2']),\n",
|
| 171 |
+
" temp=float(parsed['temp']),\n",
|
| 172 |
+
" api_name=\"/gradio_predict\"\n",
|
| 173 |
+
" )\n",
|
| 174 |
+
" return extract_category(str(result)), str(result)[:200]\n",
|
| 175 |
+
" except Exception as e:\n",
|
| 176 |
+
" return -1, str(e)[:100]\n",
|
| 177 |
+
"\n",
|
| 178 |
+
"print(\"✅ Functions ready\")"
|
| 179 |
+
]
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"cell_type": "markdown",
|
| 183 |
+
"metadata": {},
|
| 184 |
+
"source": [
|
| 185 |
+
"## 3. Run Evaluation"
|
| 186 |
+
]
|
| 187 |
+
},
|
| 188 |
+
{
|
| 189 |
+
"cell_type": "code",
|
| 190 |
+
"execution_count": null,
|
| 191 |
+
"metadata": {},
|
| 192 |
+
"outputs": [],
|
| 193 |
+
"source": [
|
| 194 |
+
"print(\"🔬 Running 50-Scenario Evaluation...\\n\")\n",
|
| 195 |
+
"print(\"=\"*60)\n",
|
| 196 |
+
"\n",
|
| 197 |
+
"results = []\n",
|
| 198 |
+
"for i, scenario in enumerate(scenarios):\n",
|
| 199 |
+
" parsed = parse_scenario(scenario)\n",
|
| 200 |
+
" predicted, response = query_model(parsed)\n",
|
| 201 |
+
" expected = parsed['expected']\n",
|
| 202 |
+
" \n",
|
| 203 |
+
" match = \"✓\" if predicted == expected else \"✗\"\n",
|
| 204 |
+
" print(f\"[{i+1:2d}/50] Expected: {expected} | Predicted: {predicted} {match}\")\n",
|
| 205 |
+
" \n",
|
| 206 |
+
" results.append({\n",
|
| 207 |
+
" 'scenario_id': i + 1,\n",
|
| 208 |
+
" 'complaint': parsed['complaint'][:50],\n",
|
| 209 |
+
" 'expected': expected,\n",
|
| 210 |
+
" 'predicted': predicted,\n",
|
| 211 |
+
" 'exact_match': predicted == expected,\n",
|
| 212 |
+
" 'within_1': abs(predicted - expected) <= 1 if predicted > 0 else False,\n",
|
| 213 |
+
" 'under_triage': predicted > expected if predicted > 0 else False,\n",
|
| 214 |
+
" 'over_triage': predicted < expected if predicted > 0 else False\n",
|
| 215 |
+
" })\n",
|
| 216 |
+
" \n",
|
| 217 |
+
" time.sleep(1.5) # Rate limiting\n",
|
| 218 |
+
"\n",
|
| 219 |
+
"df = pd.DataFrame(results)\n",
|
| 220 |
+
"print(\"\\n\" + \"=\"*60)\n",
|
| 221 |
+
"print(\"✅ Evaluation Complete!\")"
|
| 222 |
+
]
|
| 223 |
+
},
|
| 224 |
+
{
|
| 225 |
+
"cell_type": "markdown",
|
| 226 |
+
"metadata": {},
|
| 227 |
+
"source": [
|
| 228 |
+
"## 4. Calculate Results"
|
| 229 |
+
]
|
| 230 |
+
},
|
| 231 |
+
{
|
| 232 |
+
"cell_type": "code",
|
| 233 |
+
"execution_count": null,
|
| 234 |
+
"metadata": {},
|
| 235 |
+
"outputs": [],
|
| 236 |
+
"source": [
|
| 237 |
+
"# Filter valid responses\n",
|
| 238 |
+
"valid = df[df['predicted'] > 0]\n",
|
| 239 |
+
"n_valid = len(valid)\n",
|
| 240 |
+
"n_total = len(df)\n",
|
| 241 |
+
"\n",
|
| 242 |
+
"print(\"\\n\" + \"=\"*60)\n",
|
| 243 |
+
"print(\"📊 NURSESIM-TRIAGE VALIDATION RESULTS\")\n",
|
| 244 |
+
"print(\"=\"*60)\n",
|
| 245 |
+
"print(f\"\\nValid Responses: {n_valid}/{n_total} ({n_valid/n_total*100:.0f}%)\\n\")\n",
|
| 246 |
+
"\n",
|
| 247 |
+
"# Overall Metrics\n",
|
| 248 |
+
"exact_accuracy = valid['exact_match'].mean() * 100\n",
|
| 249 |
+
"within_1_accuracy = valid['within_1'].mean() * 100\n",
|
| 250 |
+
"under_triage_rate = valid['under_triage'].mean() * 100\n",
|
| 251 |
+
"over_triage_rate = valid['over_triage'].mean() * 100\n",
|
| 252 |
+
"\n",
|
| 253 |
+
"print(\"OVERALL PERFORMANCE:\")\n",
|
| 254 |
+
"print(f\" Exact Match Accuracy: {valid['exact_match'].sum()}/{n_valid} ({exact_accuracy:.1f}%)\")\n",
|
| 255 |
+
"print(f\" Within ±1 Category: {valid['within_1'].sum()}/{n_valid} ({within_1_accuracy:.1f}%)\")\n",
|
| 256 |
+
"print(f\" Under-triage Rate: {valid['under_triage'].sum()}/{n_valid} ({under_triage_rate:.1f}%)\")\n",
|
| 257 |
+
"print(f\" Over-triage Rate: {valid['over_triage'].sum()}/{n_valid} ({over_triage_rate:.1f}%)\")"
|
| 258 |
+
]
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
"cell_type": "code",
|
| 262 |
+
"execution_count": null,
|
| 263 |
+
"metadata": {},
|
| 264 |
+
"outputs": [],
|
| 265 |
+
"source": [
|
| 266 |
+
"# Performance by Category (for manuscript Table 1)\n",
|
| 267 |
+
"print(\"\\n\" + \"-\"*60)\n",
|
| 268 |
+
"print(\"PERFORMANCE BY MTS CATEGORY:\")\n",
|
| 269 |
+
"print(\"-\"*60)\n",
|
| 270 |
+
"\n",
|
| 271 |
+
"cat_names = {\n",
|
| 272 |
+
" 1: 'Immediate (Red)',\n",
|
| 273 |
+
" 2: 'Very Urgent (Orange)', \n",
|
| 274 |
+
" 3: 'Urgent (Yellow)',\n",
|
| 275 |
+
" 4: 'Standard (Green)',\n",
|
| 276 |
+
" 5: 'Non-Urgent (Blue)'\n",
|
| 277 |
+
"}\n",
|
| 278 |
+
"\n",
|
| 279 |
+
"cat_results = []\n",
|
| 280 |
+
"for cat in [1, 2, 3, 4, 5]:\n",
|
| 281 |
+
" subset = valid[valid['expected'] == cat]\n",
|
| 282 |
+
" if len(subset) > 0:\n",
|
| 283 |
+
" accuracy = subset['exact_match'].mean() * 100\n",
|
| 284 |
+
" n = len(subset)\n",
|
| 285 |
+
" correct = subset['exact_match'].sum()\n",
|
| 286 |
+
" cat_results.append({\n",
|
| 287 |
+
" 'Category': cat,\n",
|
| 288 |
+
" 'Name': cat_names.get(cat, 'Unknown'),\n",
|
| 289 |
+
" 'N': n,\n",
|
| 290 |
+
" 'Correct': correct,\n",
|
| 291 |
+
" 'Accuracy': accuracy\n",
|
| 292 |
+
" })\n",
|
| 293 |
+
" print(f\" Category {cat} ({cat_names.get(cat, 'Unknown')}): {correct}/{n} ({accuracy:.0f}%)\")\n",
|
| 294 |
+
"\n",
|
| 295 |
+
"cat_df = pd.DataFrame(cat_results)\n",
|
| 296 |
+
"print(\"\\n✅ Category breakdown complete\")"
|
| 297 |
+
]
|
| 298 |
+
},
|
| 299 |
+
{
|
| 300 |
+
"cell_type": "code",
|
| 301 |
+
"execution_count": null,
|
| 302 |
+
"metadata": {},
|
| 303 |
+
"outputs": [],
|
| 304 |
+
"source": [
|
| 305 |
+
"# Safety Analysis (Critical for manuscript)\n",
|
| 306 |
+
"print(\"\\n\" + \"-\"*60)\n",
|
| 307 |
+
"print(\"SAFETY ANALYSIS (Critical Category Detection):\")\n",
|
| 308 |
+
"print(\"-\"*60)\n",
|
| 309 |
+
"\n",
|
| 310 |
+
"# Category 1 (Immediate) - most critical\n",
|
| 311 |
+
"cat1 = valid[valid['expected'] == 1]\n",
|
| 312 |
+
"cat1_correct = cat1['exact_match'].sum() if len(cat1) > 0 else 0\n",
|
| 313 |
+
"cat1_total = len(cat1)\n",
|
| 314 |
+
"cat1_sensitivity = (cat1_correct / cat1_total * 100) if cat1_total > 0 else 0\n",
|
| 315 |
+
"\n",
|
| 316 |
+
"# Critical under-triage (predicting Cat 3-5 when actual is Cat 1-2)\n",
|
| 317 |
+
"critical_cases = valid[valid['expected'].isin([1, 2])]\n",
|
| 318 |
+
"severe_undertriage = critical_cases[critical_cases['predicted'].isin([4, 5])]\n",
|
| 319 |
+
"undertriage_rate = (len(severe_undertriage) / len(critical_cases) * 100) if len(critical_cases) > 0 else 0\n",
|
| 320 |
+
"\n",
|
| 321 |
+
"print(f\" Category 1 Sensitivity: {cat1_correct}/{cat1_total} ({cat1_sensitivity:.0f}%)\")\n",
|
| 322 |
+
"print(f\" Severe Under-triage (Cat 1-2 → Cat 4-5): {len(severe_undertriage)}/{len(critical_cases)} ({undertriage_rate:.1f}%)\")\n",
|
| 323 |
+
"\n",
|
| 324 |
+
"if undertriage_rate == 0:\n",
|
| 325 |
+
" print(\" ✅ NO severe under-triage events detected\")\n",
|
| 326 |
+
"else:\n",
|
| 327 |
+
" print(\" ⚠��� Severe under-triage events require review\")"
|
| 328 |
+
]
|
| 329 |
+
},
|
| 330 |
+
{
|
| 331 |
+
"cell_type": "markdown",
|
| 332 |
+
"metadata": {},
|
| 333 |
+
"source": [
|
| 334 |
+
"## 5. Generate Manuscript-Ready Output"
|
| 335 |
+
]
|
| 336 |
+
},
|
| 337 |
+
{
|
| 338 |
+
"cell_type": "code",
|
| 339 |
+
"execution_count": null,
|
| 340 |
+
"metadata": {},
|
| 341 |
+
"outputs": [],
|
| 342 |
+
"source": [
|
| 343 |
+
"# Create visualization\n",
|
| 344 |
+
"fig, axes = plt.subplots(1, 2, figsize=(12, 5))\n",
|
| 345 |
+
"\n",
|
| 346 |
+
"# Chart 1: Accuracy by Category\n",
|
| 347 |
+
"ax1 = axes[0]\n",
|
| 348 |
+
"colors = ['#dc2626', '#f97316', '#eab308', '#22c55e', '#3b82f6']\n",
|
| 349 |
+
"cats = [c['Category'] for c in cat_results]\n",
|
| 350 |
+
"accs = [c['Accuracy'] for c in cat_results]\n",
|
| 351 |
+
"bars = ax1.bar(cats, accs, color=colors[:len(cats)])\n",
|
| 352 |
+
"ax1.set_xlabel('MTS Category')\n",
|
| 353 |
+
"ax1.set_ylabel('Accuracy (%)')\n",
|
| 354 |
+
"ax1.set_title('Triage Accuracy by MTS Category')\n",
|
| 355 |
+
"ax1.set_ylim(0, 100)\n",
|
| 356 |
+
"ax1.set_xticks([1, 2, 3, 4, 5])\n",
|
| 357 |
+
"for bar, val in zip(bars, accs):\n",
|
| 358 |
+
" ax1.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 2, \n",
|
| 359 |
+
" f'{val:.0f}%', ha='center', fontweight='bold')\n",
|
| 360 |
+
"\n",
|
| 361 |
+
"# Chart 2: Overall Metrics\n",
|
| 362 |
+
"ax2 = axes[1]\n",
|
| 363 |
+
"metrics = ['Exact Match', 'Within ±1', 'Under-triage', 'Over-triage']\n",
|
| 364 |
+
"values = [exact_accuracy, within_1_accuracy, under_triage_rate, over_triage_rate]\n",
|
| 365 |
+
"colors2 = ['#22c55e', '#3b82f6', '#ef4444', '#f97316']\n",
|
| 366 |
+
"bars2 = ax2.bar(metrics, values, color=colors2)\n",
|
| 367 |
+
"ax2.set_ylabel('Percentage (%)')\n",
|
| 368 |
+
"ax2.set_title('Overall Performance Metrics')\n",
|
| 369 |
+
"ax2.set_ylim(0, 100)\n",
|
| 370 |
+
"for bar, val in zip(bars2, values):\n",
|
| 371 |
+
" ax2.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 2, \n",
|
| 372 |
+
" f'{val:.1f}%', ha='center', fontweight='bold')\n",
|
| 373 |
+
"\n",
|
| 374 |
+
"plt.tight_layout()\n",
|
| 375 |
+
"plt.savefig('ijhpm_validation_results.png', dpi=150, bbox_inches='tight')\n",
|
| 376 |
+
"plt.show()\n",
|
| 377 |
+
"print(\"\\n✅ Saved: ijhpm_validation_results.png\")"
|
| 378 |
+
]
|
| 379 |
+
},
|
| 380 |
+
{
|
| 381 |
+
"cell_type": "code",
|
| 382 |
+
"execution_count": null,
|
| 383 |
+
"metadata": {},
|
| 384 |
+
"outputs": [],
|
| 385 |
+
"source": [
|
| 386 |
+
"# Generate Markdown Report for Manuscript\n",
|
| 387 |
+
"report = f\"\"\"# NurseSim-Triage Validation Results\n",
|
| 388 |
+
"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M')}\n",
|
| 389 |
+
"**Dataset:** 50 standardized clinical scenarios from val.jsonl\n",
|
| 390 |
+
"\n",
|
| 391 |
+
"## Summary\n",
|
| 392 |
+
"\n",
|
| 393 |
+
"| Metric | Result |\n",
|
| 394 |
+
"|--------|--------|\n",
|
| 395 |
+
"| Sample Size | {n_valid} scenarios |\n",
|
| 396 |
+
"| Exact Match Accuracy | {exact_accuracy:.1f}% |\n",
|
| 397 |
+
"| Within ±1 Category | {within_1_accuracy:.1f}% |\n",
|
| 398 |
+
"| Under-triage Rate | {under_triage_rate:.1f}% |\n",
|
| 399 |
+
"| Over-triage Rate | {over_triage_rate:.1f}% |\n",
|
| 400 |
+
"\n",
|
| 401 |
+
"## Table 1: Performance by MTS Category\n",
|
| 402 |
+
"\n",
|
| 403 |
+
"| Category | Description | n | Correct | Accuracy |\n",
|
| 404 |
+
"|----------|-------------|---|---------|----------|\n",
|
| 405 |
+
"\"\"\"\n",
|
| 406 |
+
"\n",
|
| 407 |
+
"for c in cat_results:\n",
|
| 408 |
+
" report += f\"| {c['Category']} | {c['Name']} | {c['N']} | {c['Correct']} | {c['Accuracy']:.0f}% |\\n\"\n",
|
| 409 |
+
"\n",
|
| 410 |
+
"report += f\"\"\"\n",
|
| 411 |
+
"## Safety Analysis\n",
|
| 412 |
+
"\n",
|
| 413 |
+
"| Metric | Result |\n",
|
| 414 |
+
"|--------|--------|\n",
|
| 415 |
+
"| Category 1 Sensitivity | {cat1_sensitivity:.0f}% ({cat1_correct}/{cat1_total}) |\n",
|
| 416 |
+
"| Severe Under-triage (Cat 1-2 → Cat 4-5) | {undertriage_rate:.1f}% ({len(severe_undertriage)}/{len(critical_cases)}) |\n",
|
| 417 |
+
"\n",
|
| 418 |
+
"## Notes for Manuscript\n",
|
| 419 |
+
"\n",
|
| 420 |
+
"- **Methodology:** Evaluated on {n_valid} standardized clinical scenarios from a held-out validation set.\n",
|
| 421 |
+
"- **Ground Truth:** Each scenario was assigned an expected MTS category based on clinical guidelines.\n",
|
| 422 |
+
"- **Safety Focus:** Under-triage of critical patients (Category 1-2) is penalized more heavily than over-triage.\n",
|
| 423 |
+
"\n",
|
| 424 |
+
"---\n",
|
| 425 |
+
"*NurseSim-Triage | IJHPM Manuscript Validation*\n",
|
| 426 |
+
"\"\"\"\n",
|
| 427 |
+
"\n",
|
| 428 |
+
"print(report)\n",
|
| 429 |
+
"\n",
|
| 430 |
+
"with open('ijhpm_validation_report.md', 'w') as f:\n",
|
| 431 |
+
" f.write(report)\n",
|
| 432 |
+
"print(\"\\n✅ Saved: ijhpm_validation_report.md\")"
|
| 433 |
+
]
|
| 434 |
+
},
|
| 435 |
+
{
|
| 436 |
+
"cell_type": "code",
|
| 437 |
+
"execution_count": null,
|
| 438 |
+
"metadata": {},
|
| 439 |
+
"outputs": [],
|
| 440 |
+
"source": [
|
| 441 |
+
"# Save raw results\n",
|
| 442 |
+
"df.to_csv('ijhpm_validation_raw.csv', index=False)\n",
|
| 443 |
+
"print(\"✅ Saved: ijhpm_validation_raw.csv\")\n",
|
| 444 |
+
"\n",
|
| 445 |
+
"# Download files\n",
|
| 446 |
+
"print(\"\\n📥 Download these files for your manuscript:\")\n",
|
| 447 |
+
"print(\" 1. ijhpm_validation_report.md - Results summary\")\n",
|
| 448 |
+
"print(\" 2. ijhpm_validation_results.png - Charts\")\n",
|
| 449 |
+
"print(\" 3. ijhpm_validation_raw.csv - Raw data\")"
|
| 450 |
+
]
|
| 451 |
+
}
|
| 452 |
+
],
|
| 453 |
+
"metadata": {
|
| 454 |
+
"kernelspec": {
|
| 455 |
+
"display_name": "Python 3",
|
| 456 |
+
"language": "python",
|
| 457 |
+
"name": "python3"
|
| 458 |
+
}
|
| 459 |
+
},
|
| 460 |
+
"nbformat": 4,
|
| 461 |
+
"nbformat_minor": 4
|
| 462 |
+
}
|
notebooks/LLM_as_Judge_Evaluation_Local.ipynb
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {
|
| 6 |
+
"id": "title_cell"
|
| 7 |
+
},
|
| 8 |
+
"source": [
|
| 9 |
+
"# 🏥 NurseSim-Triage: Local Clinical Evaluation (LLM-as-Judge)\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"**Qualitative Evaluation of Your Locally Trained Model**\n",
|
| 12 |
+
"\n",
|
| 13 |
+
"This notebook loads your local adapter (`nursesim_lora_llama3_robust`) from **Google Drive**.\n",
|
| 14 |
+
"\n",
|
| 15 |
+
"### 🏆 Judges Configured:\n",
|
| 16 |
+
"- **GPT-5.2** (Primary Judge)\n",
|
| 17 |
+
"- **Gemini 3.0 Pro Preview** (Secondary Judge)\n",
|
| 18 |
+
"\n",
|
| 19 |
+
"### ✅ UPDATED: Exact training prompts + History Dict Format.\n"
|
| 20 |
+
]
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"cell_type": "code",
|
| 24 |
+
"execution_count": null,
|
| 25 |
+
"metadata": {
|
| 26 |
+
"id": "imports"
|
| 27 |
+
},
|
| 28 |
+
"outputs": [],
|
| 29 |
+
"source": [
|
| 30 |
+
"%%capture\n",
|
| 31 |
+
"!pip install --upgrade \"unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git\"\n",
|
| 32 |
+
"!pip install --no-deps trl peft accelerate bitsandbytes xformers\n",
|
| 33 |
+
"!pip install openai google-generativeai pandas matplotlib tqdm"
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"cell_type": "code",
|
| 38 |
+
"execution_count": null,
|
| 39 |
+
"metadata": {
|
| 40 |
+
"id": "setup"
|
| 41 |
+
},
|
| 42 |
+
"outputs": [],
|
| 43 |
+
"source": [
|
| 44 |
+
"import os, json, time, re\n",
|
| 45 |
+
"import pandas as pd\n",
|
| 46 |
+
"from unsloth import FastLanguageModel\n",
|
| 47 |
+
"import openai\n",
|
| 48 |
+
"import google.generativeai as genai\n",
|
| 49 |
+
"from tqdm.auto import tqdm\n",
|
| 50 |
+
"import matplotlib.pyplot as plt\n",
|
| 51 |
+
"from google.colab import drive\n",
|
| 52 |
+
"\n",
|
| 53 |
+
"# 1. Mount Drive\n",
|
| 54 |
+
"drive.mount('/content/drive')\n",
|
| 55 |
+
"\n",
|
| 56 |
+
"# 2. Setup API Keys\n",
|
| 57 |
+
"os.environ['OPENAI_API_KEY'] = \"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\"\n",
|
| 58 |
+
"openai.api_key = os.environ['OPENAI_API_KEY']\n",
|
| 59 |
+
"from google.colab import userdata\n",
|
| 60 |
+
"try:\n",
|
| 61 |
+
" genai.configure(api_key=userdata.get('GOOGLE_API_KEY'))\n",
|
| 62 |
+
"except:\n",
|
| 63 |
+
" print(\"⚠️ GOOGLE_API_KEY not found in userdata (needed for Gemini 3.0)\")\n",
|
| 64 |
+
"\n",
|
| 65 |
+
"print(\"✅ API Keys Configured\")"
|
| 66 |
+
]
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"cell_type": "code",
|
| 70 |
+
"execution_count": null,
|
| 71 |
+
"metadata": {
|
| 72 |
+
"id": "load_model"
|
| 73 |
+
},
|
| 74 |
+
"outputs": [],
|
| 75 |
+
"source": [
|
| 76 |
+
"# 3. Path to your saved model in Drive\n",
|
| 77 |
+
"adapter_path = \"/content/drive/MyDrive/nursesim_lora_llama3_robust\"\n",
|
| 78 |
+
"\n",
|
| 79 |
+
"print(f\"🔄 Loading NEW Locally Trained Model from Drive: {adapter_path}...\")\n",
|
| 80 |
+
"\n",
|
| 81 |
+
"if not os.path.exists(adapter_path):\n",
|
| 82 |
+
" raise FileNotFoundError(f\"❌ ERROR: Could not find model at {adapter_path}. Please check your Drive!\")\n",
|
| 83 |
+
"\n",
|
| 84 |
+
"model, tokenizer = FastLanguageModel.from_pretrained(\n",
|
| 85 |
+
" model_name = adapter_path,\n",
|
| 86 |
+
" max_seq_length = 2048,\n",
|
| 87 |
+
" dtype = None,\n",
|
| 88 |
+
" load_in_4bit = True,\n",
|
| 89 |
+
")\n",
|
| 90 |
+
"FastLanguageModel.for_inference(model)\n",
|
| 91 |
+
"print(\"✅ Local model loaded successfully! (Using your fine-tuned weights)\")"
|
| 92 |
+
]
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"cell_type": "code",
|
| 96 |
+
"execution_count": null,
|
| 97 |
+
"metadata": {
|
| 98 |
+
"id": "data_classes"
|
| 99 |
+
},
|
| 100 |
+
"outputs": [],
|
| 101 |
+
"source": [
|
| 102 |
+
"from dataclasses import dataclass\n",
|
| 103 |
+
"from enum import Enum\n",
|
| 104 |
+
"from typing import Dict, Any, List\n",
|
| 105 |
+
"\n",
|
| 106 |
+
"class TriageCategory(Enum):\n",
|
| 107 |
+
" IMMEDIATE = 1\n",
|
| 108 |
+
" VERY_URGENT = 2\n",
|
| 109 |
+
" URGENT = 3\n",
|
| 110 |
+
" STANDARD = 4\n",
|
| 111 |
+
" NON_URGENT = 5\n",
|
| 112 |
+
"\n",
|
| 113 |
+
"@dataclass\n",
|
| 114 |
+
"class PatientScenario:\n",
|
| 115 |
+
" id: str\n",
|
| 116 |
+
" desc: str\n",
|
| 117 |
+
" vitals: Dict[str, Any]\n",
|
| 118 |
+
" complaint: str\n",
|
| 119 |
+
" history: str\n",
|
| 120 |
+
" expected: TriageCategory\n",
|
| 121 |
+
" reasoning: str\n",
|
| 122 |
+
" difficulty: str\n",
|
| 123 |
+
"\n",
|
| 124 |
+
"# 15 Gold Standard Scenarios\n",
|
| 125 |
+
"TEST_SCENARIOS = [\n",
|
| 126 |
+
" PatientScenario(\"CAT1_01\", \"72M Chest Pain\", {\"hr\": 110, \"bp_sys\": 160, \"bp_dia\": 95, \"rr\": 24, \"spo2\": 94, \"temp\": 37.2, \"avpu\": \"A\"}, \"Crushing chest pain 30min, sweating\", \"HTN, MI\", TriageCategory.IMMEDIATE, \"Classic ACS\", \"EASY\"),\n",
|
| 127 |
+
" PatientScenario(\"CAT1_02\", \"New Stroke\", {\"hr\": 88, \"bp_sys\": 150, \"bp_dia\": 90, \"rr\": 18, \"spo2\": 96, \"temp\": 37.0, \"avpu\": \"A\"}, \"Sudden facial droop and slurred speech\", \"AFib\", TriageCategory.IMMEDIATE, \"Acute CVA\", \"MED\"),\n",
|
| 128 |
+
" PatientScenario(\"CAT1_03\", \"Anaphylaxis\", {\"hr\": 120, \"bp_sys\": 90, \"bp_dia\": 60, \"rr\": 28, \"spo2\": 91, \"temp\": 37.5, \"avpu\": \"A\"}, \"Swollen tongue after peanuts\", \"Allergy\", TriageCategory.IMMEDIATE, \"Airway compromise\", \"EASY\"),\n",
|
| 129 |
+
" PatientScenario(\"CAT2_01\", \"Sepsis Suspicion\", {\"hr\": 105, \"bp_sys\": 100, \"bp_dia\": 60, \"rr\": 22, \"spo2\": 95, \"temp\": 39.1, \"avpu\": \"V\"}, \"Confusion and rigors\", \"UTI hx\", TriageCategory.VERY_URGENT, \"Sepsis\", \"MED\"),\n",
|
| 130 |
+
" PatientScenario(\"CAT3_01\", \"Abdo Pain\", {\"hr\": 90, \"bp_sys\": 130, \"bp_dia\": 80, \"rr\": 16, \"spo2\": 98, \"temp\": 38.0, \"avpu\": \"A\"}, \"RLQ pain starting today\", \"None\", TriageCategory.URGENT, \"Possible Appendicitis\", \"MED\"),\n",
|
| 131 |
+
" PatientScenario(\"CAT5_01\", \"Med Refill\", {\"hr\": 70, \"bp_sys\": 120, \"bp_dia\": 80, \"rr\": 12, \"spo2\": 99, \"temp\": 36.8, \"avpu\": \"A\"}, \"Needs insulin refill, lost bag\", \"n/a\", TriageCategory.NON_URGENT, \"Admin task\", \"EASY\"),\n",
|
| 132 |
+
"]\n",
|
| 133 |
+
"print(f\"Loaded {len(TEST_SCENARIOS)} validation scenarios.\")"
|
| 134 |
+
]
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"cell_type": "code",
|
| 138 |
+
"execution_count": null,
|
| 139 |
+
"metadata": {
|
| 140 |
+
"id": "generate_func"
|
| 141 |
+
},
|
| 142 |
+
"outputs": [],
|
| 143 |
+
"source": [
|
| 144 |
+
"# EXACT Training Prompt Format\n",
|
| 145 |
+
"TRAINING_INSTRUCTION = \"You are an expert A&E Triage Nurse using the Manchester Triage System. Assess the following patient and provide your triage decision with clinical reasoning.\"\n",
|
| 146 |
+
"\n",
|
| 147 |
+
"def format_input(c):\n",
|
| 148 |
+
" # Mimic the training data's dictionary-style history\n",
|
| 149 |
+
" history_dict = {\n",
|
| 150 |
+
" 'relevant_PMH': c.history, \n",
|
| 151 |
+
" 'note': 'History structured as dict to match training data format'\n",
|
| 152 |
+
" }\n",
|
| 153 |
+
" return f\"\"\"PATIENT PRESENTING TO A&E TRIAGE\n",
|
| 154 |
+
"\n",
|
| 155 |
+
"Chief Complaint: \"{c.complaint}\"\n",
|
| 156 |
+
"\n",
|
| 157 |
+
"Vitals:\n",
|
| 158 |
+
"- HR: {c.vitals.get('hr')} bpm\n",
|
| 159 |
+
"- BP: {c.vitals.get('bp_sys')}/{c.vitals.get('bp_dia')} mmHg\n",
|
| 160 |
+
"- SpO2: {c.vitals.get('spo2')}%\n",
|
| 161 |
+
"- RR: {c.vitals.get('rr')} /min\n",
|
| 162 |
+
"- Temp: {c.vitals.get('temp')}C\n",
|
| 163 |
+
"- AVPU: {c.vitals.get('avpu')}\n",
|
| 164 |
+
"\n",
|
| 165 |
+
"History: {history_dict}\n",
|
| 166 |
+
"\n",
|
| 167 |
+
"WAITING ROOM: 12 patients | AVAILABLE BEDS: 4\n",
|
| 168 |
+
"\n",
|
| 169 |
+
"What is your triage decision?\"\"\"\n",
|
| 170 |
+
"\n",
|
| 171 |
+
"def generate_response(s):\n",
|
| 172 |
+
" alpaca_prompt = f\"\"\"Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.\n",
|
| 173 |
+
"\n",
|
| 174 |
+
"### Instruction:\n",
|
| 175 |
+
"{TRAINING_INSTRUCTION}\n",
|
| 176 |
+
"\n",
|
| 177 |
+
"### Input:\n",
|
| 178 |
+
"{format_input(s)}\n",
|
| 179 |
+
"\n",
|
| 180 |
+
"### Response:\n",
|
| 181 |
+
"\"\"\"\n",
|
| 182 |
+
" \n",
|
| 183 |
+
" inputs = tokenizer(\n",
|
| 184 |
+
" [alpaca_prompt],\n",
|
| 185 |
+
" return_tensors=\"pt\",\n",
|
| 186 |
+
" ).to(\"cuda\")\n",
|
| 187 |
+
" \n",
|
| 188 |
+
" outputs = model.generate(**inputs, max_new_tokens=256, use_cache=True)\n",
|
| 189 |
+
" full_text = tokenizer.batch_decode(outputs)[0]\n",
|
| 190 |
+
" \n",
|
| 191 |
+
" # Extract just the response part\n",
|
| 192 |
+
" try:\n",
|
| 193 |
+
" return full_text.split(\"### Response:\")[-1].replace(\"<|eot_id|>\", \"\").strip()\n",
|
| 194 |
+
" except:\n",
|
| 195 |
+
" return full_text"
|
| 196 |
+
]
|
| 197 |
+
},
|
| 198 |
+
{
|
| 199 |
+
"cell_type": "code",
|
| 200 |
+
"execution_count": null,
|
| 201 |
+
"metadata": {
|
| 202 |
+
"id": "judge_class"
|
| 203 |
+
},
|
| 204 |
+
"outputs": [],
|
| 205 |
+
"source": [
|
| 206 |
+
"JUDGE_SYS_PROMPT = \"\"\"You are a Senior Clinical Auditor. Evaluate the AI Nurse's response.\nCriteria:\n1. Accuracy (1-5): Correct triage category?\n2. Reasoning (1-5): Sound clinical logic?\n3. Safety (PASS/FAIL): Did it miss a life-threat?\n\nReturn JSON: {\"accuracy\": int, \"reasoning\": int, \"safety\": \"str\", \"critique\": \"str\"}\"\"\"\n",
|
| 207 |
+
"\n",
|
| 208 |
+
"def judge_response(scenario, response):\n",
|
| 209 |
+
" user_prompt = f\"SCENARIO: {scenario.complaint} (Exp: Cat {scenario.expected.value})\\nAI RESPONSE:\\n{response}\"\n",
|
| 210 |
+
" \n",
|
| 211 |
+
" # 1. Try GPT-5.2 (Using user specified ID 'gpt-5.2')\n",
|
| 212 |
+
" try:\n",
|
| 213 |
+
" client = openai.OpenAI()\n",
|
| 214 |
+
" print(f\" ⚖️ Judging with GPT-5.2...\", end=\" \")\n",
|
| 215 |
+
" completion = client.chat.completions.create(\n",
|
| 216 |
+
" model=\"gpt-5.2\", # User specified\n",
|
| 217 |
+
" messages=[{\"role\": \"system\", \"content\": JUDGE_SYS_PROMPT}, {\"role\": \"user\", \"content\": user_prompt}],\n",
|
| 218 |
+
" response_format={\"type\": \"json_object\"}, \n",
|
| 219 |
+
" temperature=0\n",
|
| 220 |
+
" )\n",
|
| 221 |
+
" print(\"✅\")\n",
|
| 222 |
+
" return json.loads(completion.choices[0].message.content)\n",
|
| 223 |
+
" except Exception as e:\n",
|
| 224 |
+
" print(f\"❌ (GPT-5.2 failed: {str(e)[:50]}) ... Trying Gemini 3.0 Pro Preview...\")\n",
|
| 225 |
+
" \n",
|
| 226 |
+
" # 2. Try Gemini 3.0 Pro Preview\n",
|
| 227 |
+
" try:\n",
|
| 228 |
+
" model = genai.GenerativeModel('gemini-3.0-pro-preview', system_instruction=JUDGE_SYS_PROMPT)\n",
|
| 229 |
+
" resp = model.generate_content(user_prompt)\n",
|
| 230 |
+
" # Extract JSON\n",
|
| 231 |
+
" text = resp.text\n",
|
| 232 |
+
" if \"```json\" in text: text = text.split(\"```json\")[1].split(\"```\")[0]\n",
|
| 233 |
+
" print(\"✅ (Gemini 3.0 Success)\")\n",
|
| 234 |
+
" return json.loads(text)\n",
|
| 235 |
+
" except Exception as e:\n",
|
| 236 |
+
" print(f\"❌ (Gemini 3.0 failed: {str(e)[:50]}) ... Using GPT-4o as fallback.\")\n",
|
| 237 |
+
" \n",
|
| 238 |
+
" # 3. Fallback to GPT-4o\n",
|
| 239 |
+
" try:\n",
|
| 240 |
+
" client = openai.OpenAI()\n",
|
| 241 |
+
" completion = client.chat.completions.create(\n",
|
| 242 |
+
" model=\"gpt-4o\",\n",
|
| 243 |
+
" messages=[{\"role\": \"system\", \"content\": JUDGE_SYS_PROMPT}, {\"role\": \"user\", \"content\": user_prompt}],\n",
|
| 244 |
+
" response_format={\"type\": \"json_object\"}, \n",
|
| 245 |
+
" temperature=0\n",
|
| 246 |
+
" )\n",
|
| 247 |
+
" return json.loads(completion.choices[0].message.content)\n",
|
| 248 |
+
" except:\n",
|
| 249 |
+
" return {\"accuracy\": 0, \"reasoning\": 0, \"safety\": \"JUDGE ERROR\", \"critique\": \"All judges failed.\"}"
|
| 250 |
+
]
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"cell_type": "code",
|
| 254 |
+
"execution_count": null,
|
| 255 |
+
"metadata": {
|
| 256 |
+
"id": "run_eval"
|
| 257 |
+
},
|
| 258 |
+
"outputs": [],
|
| 259 |
+
"source": [
|
| 260 |
+
"print(\"🚀 Starting Qualitative Evaluation (Fixed Prompts + History Dict)...\\n\")\n",
|
| 261 |
+
"results = []\n",
|
| 262 |
+
"\n",
|
| 263 |
+
"for s in tqdm(TEST_SCENARIOS):\n",
|
| 264 |
+
" # 1. Generate\n",
|
| 265 |
+
" resp = generate_response(s)\n",
|
| 266 |
+
" \n",
|
| 267 |
+
" # 2. Judge\n",
|
| 268 |
+
" eval_result = judge_response(s, resp)\n",
|
| 269 |
+
" \n",
|
| 270 |
+
" print(f\"\\n📝 {s.id}: {s.desc}\")\n",
|
| 271 |
+
" print(f\" Expected: {s.expected.name} | Model Voted: {eval_result.get('accuracy')}/5 Acc\")\n",
|
| 272 |
+
" print(f\" Safety: {eval_result.get('safety')} | Critique: {eval_result.get('critique')[:100]}...\")\n",
|
| 273 |
+
" \n",
|
| 274 |
+
" results.append({\n",
|
| 275 |
+
" \"id\": s.id,\n",
|
| 276 |
+
" \"expected\": s.expected.name,\n",
|
| 277 |
+
" \"response\": resp,\n",
|
| 278 |
+
" \"accuracy\": eval_result.get('accuracy'),\n",
|
| 279 |
+
" \"reasoning\": eval_result.get('reasoning'),\n",
|
| 280 |
+
" \"safety\": eval_result.get('safety')\n",
|
| 281 |
+
" })\n",
|
| 282 |
+
"\n",
|
| 283 |
+
"df = pd.DataFrame(results)\n",
|
| 284 |
+
"print(f\"\\n🏆 Average Accuracy: {df['accuracy'].mean():.1f}/5\")\n",
|
| 285 |
+
"print(f\"🛡️ Safety Pass Rate: {(df['safety']=='PASS').mean()*100:.1f}%\")"
|
| 286 |
+
]
|
| 287 |
+
},
|
| 288 |
+
{
|
| 289 |
+
"cell_type": "code",
|
| 290 |
+
"execution_count": null,
|
| 291 |
+
"metadata": {
|
| 292 |
+
"id": "save_results"
|
| 293 |
+
},
|
| 294 |
+
"outputs": [],
|
| 295 |
+
"source": [
|
| 296 |
+
"df.to_csv(\"local_clinical_eval_results.csv\", index=False)\n",
|
| 297 |
+
"print(\"✅ Results saved to local_clinical_eval_results.csv\")"
|
| 298 |
+
]
|
| 299 |
+
}
|
| 300 |
+
],
|
| 301 |
+
"metadata": {
|
| 302 |
+
"accelerator": "GPU",
|
| 303 |
+
"colab": {
|
| 304 |
+
"gpuType": "A100",
|
| 305 |
+
"provenance": []
|
| 306 |
+
},
|
| 307 |
+
"kernelspec": {
|
| 308 |
+
"display_name": "Python 3",
|
| 309 |
+
"name": "python3"
|
| 310 |
+
},
|
| 311 |
+
"language_info": {
|
| 312 |
+
"name": "python"
|
| 313 |
+
}
|
| 314 |
+
},
|
| 315 |
+
"nbformat": 4,
|
| 316 |
+
"nbformat_minor": 0
|
| 317 |
+
}
|
notebooks/NurseSim_Unified_Validation_Local.ipynb
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"metadata": {
|
| 6 |
+
"id": "title_cell"
|
| 7 |
+
},
|
| 8 |
+
"source": [
|
| 9 |
+
"# 🏥 NurseSim-Triage: Unified Local Validation\n",
|
| 10 |
+
"\n",
|
| 11 |
+
"**One Notebook to Rule Them All 💍**\n",
|
| 12 |
+
"\n",
|
| 13 |
+
"This notebook performs **BOTH**:\n",
|
| 14 |
+
"1. **Quantitative Benchmark**: Checks accuracy on 15 Gold-Standard Cases.\n",
|
| 15 |
+
"2. **Qualitative Evaluation**: Uses GPT-5.2 / Gemini 3.0 to judge clinical reasoning.\n",
|
| 16 |
+
"\n",
|
| 17 |
+
"### ✅ UPDATED: Exact training prompts + History Dict + **Age/Gender Parsing**.\n",
|
| 18 |
+
"\n",
|
| 19 |
+
"**Why parsing?** The model learned to rely on 'age' and 'gender' keys in the history to assess risk (e.g., Chest Pain in 72M vs 20M). This update ensures that context is passed correctly."
|
| 20 |
+
]
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"cell_type": "code",
|
| 24 |
+
"execution_count": null,
|
| 25 |
+
"metadata": {
|
| 26 |
+
"id": "imports"
|
| 27 |
+
},
|
| 28 |
+
"outputs": [],
|
| 29 |
+
"source": [
|
| 30 |
+
"%%capture\n",
|
| 31 |
+
"!pip install --upgrade \"unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git\"\n",
|
| 32 |
+
"!pip install --no-deps trl peft accelerate bitsandbytes xformers\n",
|
| 33 |
+
"!pip install openai google-generativeai pandas matplotlib tqdm"
|
| 34 |
+
]
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
"cell_type": "code",
|
| 38 |
+
"execution_count": null,
|
| 39 |
+
"metadata": {
|
| 40 |
+
"id": "setup"
|
| 41 |
+
},
|
| 42 |
+
"outputs": [],
|
| 43 |
+
"source": [
|
| 44 |
+
"import os, json, re\n",
|
| 45 |
+
"import pandas as pd\n",
|
| 46 |
+
"from unsloth import FastLanguageModel\n",
|
| 47 |
+
"import openai\n",
|
| 48 |
+
"import google.generativeai as genai\n",
|
| 49 |
+
"from tqdm.auto import tqdm\n",
|
| 50 |
+
"from google.colab import drive\n",
|
| 51 |
+
"from dataclasses import dataclass\n",
|
| 52 |
+
"from enum import Enum\n",
|
| 53 |
+
"from typing import Dict, Any\n",
|
| 54 |
+
"\n",
|
| 55 |
+
"# 1. Setup API Keys\n",
|
| 56 |
+
"from google.colab import userdata\n",
|
| 57 |
+
"\n",
|
| 58 |
+
"os.environ['OPENAI_API_KEY'] = \"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\"\n",
|
| 59 |
+
"openai.api_key = os.environ['OPENAI_API_KEY']\n",
|
| 60 |
+
"\n",
|
| 61 |
+
"try:\n",
|
| 62 |
+
" genai.configure(api_key=userdata.get('GOOGLE_API_KEY'))\n",
|
| 63 |
+
"except:\n",
|
| 64 |
+
" print(\"⚠️ GOOGLE_API_KEY not found (Gemini Judge will fail if needed)\")\n",
|
| 65 |
+
"\n",
|
| 66 |
+
"# 2. Mount Drive\n",
|
| 67 |
+
"drive.mount('/content/drive')\n",
|
| 68 |
+
"\n",
|
| 69 |
+
"# 3. Load Model\n",
|
| 70 |
+
"adapter_path = \"/content/drive/MyDrive/nursesim_lora_llama3_robust\"\n",
|
| 71 |
+
"\n",
|
| 72 |
+
"print(f\"\\n🔄 Loading Model from: {adapter_path}...\")\n",
|
| 73 |
+
"\n",
|
| 74 |
+
"if not os.path.exists(adapter_path):\n",
|
| 75 |
+
" print(f\"❌ ERROR: Path not found: {adapter_path}\")\n",
|
| 76 |
+
" # Fallback for testing\n",
|
| 77 |
+
" if os.path.exists(\"nursesim_lora_llama3_robust\"): adapter_path = \"nursesim_lora_llama3_robust\"\n",
|
| 78 |
+
"\n",
|
| 79 |
+
"# Load Unsloth Model\n",
|
| 80 |
+
"model, tokenizer = FastLanguageModel.from_pretrained(\n",
|
| 81 |
+
" model_name = adapter_path,\n",
|
| 82 |
+
" max_seq_length = 2048,\n",
|
| 83 |
+
" dtype = None,\n",
|
| 84 |
+
" load_in_4bit = True,\n",
|
| 85 |
+
")\n",
|
| 86 |
+
"FastLanguageModel.for_inference(model)\n",
|
| 87 |
+
"print(\"✅ Model Loaded Successfully!\")"
|
| 88 |
+
]
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"cell_type": "code",
|
| 92 |
+
"execution_count": null,
|
| 93 |
+
"metadata": {
|
| 94 |
+
"id": "data_definitions"
|
| 95 |
+
},
|
| 96 |
+
"outputs": [],
|
| 97 |
+
"source": [
|
| 98 |
+
"class TriageCategory(Enum):\n",
|
| 99 |
+
" IMMEDIATE = 1\n",
|
| 100 |
+
" VERY_URGENT = 2\n",
|
| 101 |
+
" URGENT = 3\n",
|
| 102 |
+
" STANDARD = 4\n",
|
| 103 |
+
" NON_URGENT = 5\n",
|
| 104 |
+
"\n",
|
| 105 |
+
"@dataclass\n",
|
| 106 |
+
"class PatientScenario:\n",
|
| 107 |
+
" id: str\n",
|
| 108 |
+
" desc: str\n",
|
| 109 |
+
" vitals: Dict[str, Any]\n",
|
| 110 |
+
" complaint: str\n",
|
| 111 |
+
" history: str\n",
|
| 112 |
+
" expected: TriageCategory\n",
|
| 113 |
+
" reasoning: str\n",
|
| 114 |
+
"\n",
|
| 115 |
+
"# 15 Gold Standard Scenarios\n",
|
| 116 |
+
"# We embed Age/Sex in the 'desc' for parsing (e.g. '72M...')\n",
|
| 117 |
+
"TEST_SCENARIOS = [\n",
|
| 118 |
+
" # IMMEDIATE (1)\n",
|
| 119 |
+
" PatientScenario(\"IMM_01\", \"72M Chest Pain\", {\"hr\": 110, \"bp_sys\": 160, \"bp_dia\": 95, \"rr\": 24, \"spo2\": 94, \"temp\": 37.2, \"avpu\": \"A\"}, \"Crushing chest pain radiating to left arm, sweating, nausea\", \"HTN, T2DM, MI 2019\", TriageCategory.IMMEDIATE, \"Classic ACS\"),\n",
|
| 120 |
+
" PatientScenario(\"IMM_02\", \"65M New Stroke\", {\"hr\": 88, \"bp_sys\": 150, \"bp_dia\": 90, \"rr\": 18, \"spo2\": 96, \"temp\": 37.0, \"avpu\": \"A\"}, \"Sudden facial droop and slurred speech causing panic\", \"AFib, Hypertension\", TriageCategory.IMMEDIATE, \"Acute CVA\"),\n",
|
| 121 |
+
" PatientScenario(\"IMM_03\", \"25F Anaphylaxis\", {\"hr\": 120, \"bp_sys\": 90, \"bp_dia\": 60, \"rr\": 28, \"spo2\": 91, \"temp\": 37.5, \"avpu\": \"A\"}, \"Swollen tongue and difficulty breathing after peanuts\", \"Nut Allergy\", TriageCategory.IMMEDIATE, \"Airway compromise\"),\n",
|
| 122 |
+
" \n",
|
| 123 |
+
" # VERY URGENT (2)\n",
|
| 124 |
+
" PatientScenario(\"VU_01\", \"70F Sepsis Suspicion\", {\"hr\": 105, \"bp_sys\": 100, \"bp_dia\": 60, \"rr\": 22, \"spo2\": 95, \"temp\": 39.1, \"avpu\": \"V\"}, \"Confusion and severe shaking chills\", \"Recurrent UTIs\", TriageCategory.VERY_URGENT, \"Sepsis\"),\n",
|
| 125 |
+
" PatientScenario(\"VU_02\", \"50M Epigastric\", {\"hr\": 72, \"bp_sys\": 138, \"bp_dia\": 84, \"rr\": 18, \"spo2\": 96, \"temp\": 36.8, \"avpu\": \"A\"}, \"Vague malaise for 2 days, feels very wrong\", \"HTN\", TriageCategory.VERY_URGENT, \"Cardiac/Sepsis risk\"),\n",
|
| 126 |
+
" \n",
|
| 127 |
+
" # URGENT (3)\n",
|
| 128 |
+
" PatientScenario(\"URG_01\", \"30F Abdo Pain\", {\"hr\": 90, \"bp_sys\": 130, \"bp_dia\": 80, \"rr\": 16, \"spo2\": 98, \"temp\": 38.0, \"avpu\": \"A\"}, \"RLQ pain starting today, worse with movement\", \"None\", TriageCategory.URGENT, \"Appendicitis\"),\n",
|
| 129 |
+
" PatientScenario(\"URG_02\", \"55M Foot Infection\", {\"hr\": 92, \"bp_sys\": 145, \"bp_dia\": 88, \"rr\": 16, \"spo2\": 97, \"temp\": 37.4, \"avpu\": \"A\"}, \"Non-healing foot wound for 2 weeks, getting red\", \"T2DM\", TriageCategory.URGENT, \"Diabetic foot\"),\n",
|
| 130 |
+
"\n",
|
| 131 |
+
" # STANDARD (4)\n",
|
| 132 |
+
" PatientScenario(\"STD_01\", \"22M Ankle Sprain\", {\"hr\": 75, \"bp_sys\": 125, \"bp_dia\": 80, \"rr\": 14, \"spo2\": 99, \"temp\": 36.8, \"avpu\": \"A\"}, \"Twisted ankle playing football, swelling\", \"None\", TriageCategory.STANDARD, \"Sprain\"),\n",
|
| 133 |
+
" \n",
|
| 134 |
+
" # NON-URGENT (5)\n",
|
| 135 |
+
" PatientScenario(\"NU_01\", \"24F Sore Throat\", {\"hr\": 78, \"bp_sys\": 118, \"bp_dia\": 72, \"rr\": 14, \"spo2\": 99, \"temp\": 37.8, \"avpu\": \"A\"}, \"Sore throat for 3 days, can swallow fine\", \"None\", TriageCategory.NON_URGENT, \"Minor illness\"),\n",
|
| 136 |
+
" PatientScenario(\"NU_02\", \"60M Med Refill\", {\"hr\": 70, \"bp_sys\": 120, \"bp_dia\": 80, \"rr\": 12, \"spo2\": 99, \"temp\": 36.8, \"avpu\": \"A\"}, \"Needs insulin refill, lost bag on bus\", \"T2DM\", TriageCategory.NON_URGENT, \"Admin task\"),\n",
|
| 137 |
+
"]"
|
| 138 |
+
]
|
| 139 |
+
},
|
| 140 |
+
{
|
| 141 |
+
"cell_type": "code",
|
| 142 |
+
"execution_count": null,
|
| 143 |
+
"metadata": {
|
| 144 |
+
"id": "inference_logic"
|
| 145 |
+
},
|
| 146 |
+
"outputs": [],
|
| 147 |
+
"source": [
|
| 148 |
+
"# EXACT Training Prompt Format\n",
|
| 149 |
+
"TRAINING_INSTRUCTION = \"You are an expert A&E Triage Nurse using the Manchester Triage System. Assess the following patient and provide your triage decision with clinical reasoning.\"\n",
|
| 150 |
+
"\n",
|
| 151 |
+
"def parse_demographics(desc):\n",
|
| 152 |
+
" # Extract \"72M\" or \"65F\" from start of string\n",
|
| 153 |
+
" match = re.search(r\"(\\d+)([MF])\", desc)\n",
|
| 154 |
+
" if match:\n",
|
| 155 |
+
" return int(match.group(1)), \"Male\" if match.group(2) == \"M\" else \"Female\"\n",
|
| 156 |
+
" return \"Unknown\", \"Unknown\"\n",
|
| 157 |
+
"\n",
|
| 158 |
+
"def format_input(c):\n",
|
| 159 |
+
" age, gender = parse_demographics(c.desc)\n",
|
| 160 |
+
" \n",
|
| 161 |
+
" # CRITICAL: Reconstruct the exact dictionary format used in training\n",
|
| 162 |
+
" history_dict = {\n",
|
| 163 |
+
" 'age': age,\n",
|
| 164 |
+
" 'gender': gender,\n",
|
| 165 |
+
" 'relevant_PMH': c.history,\n",
|
| 166 |
+
" 'time_course': 'See complaint'\n",
|
| 167 |
+
" }\n",
|
| 168 |
+
" \n",
|
| 169 |
+
" # Force consistent key order if needed (Python dicts preserve order now, but just in case)\n",
|
| 170 |
+
" \n",
|
| 171 |
+
" return f\"\"\"PATIENT PRESENTING TO A&E TRIAGE\n",
|
| 172 |
+
"\n",
|
| 173 |
+
"Chief Complaint: \"{c.complaint}\"\n",
|
| 174 |
+
"\n",
|
| 175 |
+
"Vitals:\n",
|
| 176 |
+
"- HR: {c.vitals.get('hr')} bpm\n",
|
| 177 |
+
"- BP: {c.vitals.get('bp_sys')}/{c.vitals.get('bp_dia')} mmHg\n",
|
| 178 |
+
"- SpO2: {c.vitals.get('spo2')}%\n",
|
| 179 |
+
"- RR: {c.vitals.get('rr')} /min\n",
|
| 180 |
+
"- Temp: {c.vitals.get('temp')}C\n",
|
| 181 |
+
"- AVPU: {c.vitals.get('avpu')}\n",
|
| 182 |
+
"\n",
|
| 183 |
+
"History: {history_dict}\n",
|
| 184 |
+
"\n",
|
| 185 |
+
"WAITING ROOM: 12 patients | AVAILABLE BEDS: 4\n",
|
| 186 |
+
"\n",
|
| 187 |
+
"What is your triage decision?\"\"\"\n",
|
| 188 |
+
"\n",
|
| 189 |
+
"def generate_prediction(s):\n",
|
| 190 |
+
" alpaca_prompt = f\"\"\"Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.\n",
|
| 191 |
+
"\n",
|
| 192 |
+
"### Instruction:\n",
|
| 193 |
+
"{TRAINING_INSTRUCTION}\n",
|
| 194 |
+
"\n",
|
| 195 |
+
"### Input:\n",
|
| 196 |
+
"{format_input(s)}\n",
|
| 197 |
+
"\n",
|
| 198 |
+
"### Response:\n",
|
| 199 |
+
"\"\"\"\n",
|
| 200 |
+
" \n",
|
| 201 |
+
" inputs = tokenizer(\n",
|
| 202 |
+
" [alpaca_prompt],\n",
|
| 203 |
+
" return_tensors=\"pt\",\n",
|
| 204 |
+
" ).to(\"cuda\")\n",
|
| 205 |
+
" \n",
|
| 206 |
+
" outputs = model.generate(**inputs, max_new_tokens=256, use_cache=True)\n",
|
| 207 |
+
" full_text = tokenizer.batch_decode(outputs)[0]\n",
|
| 208 |
+
" response = full_text.split(\"### Response:\")[-1].replace(\"<|eot_id|>\", \"\").strip()\n",
|
| 209 |
+
" return response"
|
| 210 |
+
]
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"cell_type": "code",
|
| 214 |
+
"execution_count": null,
|
| 215 |
+
"metadata": {
|
| 216 |
+
"id": "section_1_quantitative"
|
| 217 |
+
},
|
| 218 |
+
"outputs": [],
|
| 219 |
+
"source": [
|
| 220 |
+
"def extract_category(text):\n",
|
| 221 |
+
" try:\n",
|
| 222 |
+
" # Prioritize explicit \"Category: X\"\n",
|
| 223 |
+
" match = re.search(r\"Category:\\s*(\\d)\", text)\n",
|
| 224 |
+
" if match: return int(match.group(1))\n",
|
| 225 |
+
" # Fallback\n",
|
| 226 |
+
" match = re.search(r'\\b([1-5])\\b', text)\n",
|
| 227 |
+
" return int(match.group(1)) if match else -1\n",
|
| 228 |
+
" except: return -1\n",
|
| 229 |
+
"\n",
|
| 230 |
+
"print(\"📊 SECTION 1: Quantitative Benchmark (15 Cases)\\n\")\n",
|
| 231 |
+
"results = []\n",
|
| 232 |
+
"\n",
|
| 233 |
+
"for s in tqdm(TEST_SCENARIOS, desc=\"Benchmarking\"):\n",
|
| 234 |
+
" response = generate_prediction(s)\n",
|
| 235 |
+
" pred_cat = extract_category(response)\n",
|
| 236 |
+
" exp_cat = s.expected.value\n",
|
| 237 |
+
" \n",
|
| 238 |
+
" results.append({\n",
|
| 239 |
+
" 'id': s.id,\n",
|
| 240 |
+
" 'desc': s.desc,\n",
|
| 241 |
+
" 'exp': exp_cat,\n",
|
| 242 |
+
" 'pred': pred_cat,\n",
|
| 243 |
+
" 'match': pred_cat == exp_cat,\n",
|
| 244 |
+
" 'full_resp': response\n",
|
| 245 |
+
" })\n",
|
| 246 |
+
" \n",
|
| 247 |
+
" icon = \"✅\" if pred_cat == exp_cat else \"❌\"\n",
|
| 248 |
+
" print(f\"{icon} {s.id}: Pred={pred_cat} | Exp={exp_cat}\")\n",
|
| 249 |
+
"\n",
|
| 250 |
+
"df_quant = pd.DataFrame(results)\n",
|
| 251 |
+
"acc = df_quant['match'].mean() * 100\n",
|
| 252 |
+
"print(f\"\\n🏆 QUANTITATIVE ACCURACY: {acc:.1f}%\")"
|
| 253 |
+
]
|
| 254 |
+
},
|
| 255 |
+
{
|
| 256 |
+
"cell_type": "code",
|
| 257 |
+
"execution_count": null,
|
| 258 |
+
"metadata": {
|
| 259 |
+
"id": "section_2_judge"
|
| 260 |
+
},
|
| 261 |
+
"outputs": [],
|
| 262 |
+
"source": [
|
| 263 |
+
"print(\"\\n⚖️ SECTION 2: LLM-as-Judge Evaluation (Qualitative)\\n\")\n",
|
| 264 |
+
"\n",
|
| 265 |
+
"JUDGE_SYS_PROMPT = \"\"\"You are a Senior Clinical Auditor. Evaluate the AI Nurse's response.\n",
|
| 266 |
+
"Criteria:\n",
|
| 267 |
+
"1. Accuracy (1-5): Correct triage category?\n",
|
| 268 |
+
"2. Reasoning (1-5): Sound clinical logic?\n",
|
| 269 |
+
"3. Safety (PASS/FAIL): Did it miss a life-threat?\n",
|
| 270 |
+
"Return JSON: {\"accuracy\": int, \"reasoning\": int, \"safety\": \"str\", \"critique\": \"str\"}\"\"\"\n",
|
| 271 |
+
"\n",
|
| 272 |
+
"judge_results = []\n",
|
| 273 |
+
"\n",
|
| 274 |
+
"for row in results:\n",
|
| 275 |
+
" user_prompt = f\"SCENARIO: {row['desc']} (Exp: Cat {row['exp']})\\nAI RESPONSE:\\n{row['full_resp']}\"\n",
|
| 276 |
+
" \n",
|
| 277 |
+
" eval_res = {}\n",
|
| 278 |
+
" try:\n",
|
| 279 |
+
" # Try GPT-5.2 first\n",
|
| 280 |
+
" client = openai.OpenAI()\n",
|
| 281 |
+
" completion = client.chat.completions.create(\n",
|
| 282 |
+
" model=\"gpt-5.2\",\n",
|
| 283 |
+
" messages=[{\"role\": \"system\", \"content\": JUDGE_SYS_PROMPT}, {\"role\": \"user\", \"content\": user_prompt}],\n",
|
| 284 |
+
" response_format={\"type\": \"json_object\"}, \n",
|
| 285 |
+
" temperature=0\n",
|
| 286 |
+
" )\n",
|
| 287 |
+
" eval_res = json.loads(completion.choices[0].message.content)\n",
|
| 288 |
+
" except Exception as e:\n",
|
| 289 |
+
" # Fallback to Gemini\n",
|
| 290 |
+
" try:\n",
|
| 291 |
+
" model_gem = genai.GenerativeModel('gemini-3.0-pro-preview', system_instruction=JUDGE_SYS_PROMPT)\n",
|
| 292 |
+
" resp = model_gem.generate_content(user_prompt)\n",
|
| 293 |
+
" text = resp.text.split(\"```json\")[-1].split(\"```\")[0] if \"```\" in resp.text else resp.text\n",
|
| 294 |
+
" eval_res = json.loads(text)\n",
|
| 295 |
+
" except:\n",
|
| 296 |
+
" eval_res = {\"accuracy\": 0, \"safety\": \"ERROR\", \"critique\": \"Judge Failed\"}\n",
|
| 297 |
+
"\n",
|
| 298 |
+
" print(f\"📝 {row['id']}: Safety={eval_res.get('safety')} | Judge Acc={eval_res.get('accuracy')}/5\")\n",
|
| 299 |
+
" print(f\" Critique: {eval_res.get('critique')[:100]}...\")\n",
|
| 300 |
+
" \n",
|
| 301 |
+
" row.update(eval_res)\n",
|
| 302 |
+
" judge_results.append(row)\n",
|
| 303 |
+
"\n",
|
| 304 |
+
"df_final = pd.DataFrame(judge_results)\n",
|
| 305 |
+
"df_final.to_csv(\"unified_validation_results.csv\", index=False)\n",
|
| 306 |
+
"print(\"\\n✅ All Results saved to unified_validation_results.csv\")"
|
| 307 |
+
]
|
| 308 |
+
}
|
| 309 |
+
],
|
| 310 |
+
"metadata": {
|
| 311 |
+
"accelerator": "GPU",
|
| 312 |
+
"colab": {
|
| 313 |
+
"gpuType": "A100",
|
| 314 |
+
"provenance": []
|
| 315 |
+
},
|
| 316 |
+
"kernelspec": {
|
| 317 |
+
"display_name": "Python 3",
|
| 318 |
+
"name": "python3"
|
| 319 |
+
},
|
| 320 |
+
"language_info": {
|
| 321 |
+
"name": "python"
|
| 322 |
+
}
|
| 323 |
+
},
|
| 324 |
+
"nbformat": 4,
|
| 325 |
+
"nbformat_minor": 0
|
| 326 |
+
}
|
nursesim_rl/__init__.py
CHANGED
|
@@ -5,6 +5,12 @@ OpenEnv Challenge Entry - 2026
|
|
| 5 |
|
| 6 |
from .triage_env import TriageEnv
|
| 7 |
from .patient_generator import PatientGenerator
|
|
|
|
| 8 |
|
| 9 |
-
__version__ = "0.
|
| 10 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
from .triage_env import TriageEnv
|
| 7 |
from .patient_generator import PatientGenerator
|
| 8 |
+
from .semantic_wrapper import NurseEmbedWrapper, make_semantic_triage_env
|
| 9 |
|
| 10 |
+
__version__ = "0.2.0"
|
| 11 |
+
__all__ = [
|
| 12 |
+
"TriageEnv",
|
| 13 |
+
"PatientGenerator",
|
| 14 |
+
"NurseEmbedWrapper",
|
| 15 |
+
"make_semantic_triage_env",
|
| 16 |
+
]
|
nursesim_rl/semantic_wrapper.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
NurseEmbedWrapper: A Gymnasium wrapper that converts text observations to NurseEmbed vectors.
|
| 3 |
+
|
| 4 |
+
This enables Language-Conditioned Reinforcement Learning for nursing scenarios.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import gymnasium as gym
|
| 8 |
+
from gymnasium import spaces
|
| 9 |
+
import numpy as np
|
| 10 |
+
from typing import Any, Dict, Tuple
|
| 11 |
+
|
| 12 |
+
# Lazy load NurseEmbed to avoid import errors if not available
|
| 13 |
+
_embed_model = None
|
| 14 |
+
|
| 15 |
+
def _get_embed_model():
|
| 16 |
+
"""Lazy load the NurseEmbed model."""
|
| 17 |
+
global _embed_model
|
| 18 |
+
if _embed_model is None:
|
| 19 |
+
try:
|
| 20 |
+
from sentence_transformers import SentenceTransformer
|
| 21 |
+
_embed_model = SentenceTransformer("NurseCitizenDeveloper/NurseEmbed-300M")
|
| 22 |
+
print("[OK] NurseEmbed model loaded successfully")
|
| 23 |
+
except Exception as e:
|
| 24 |
+
print(f"[WARN] NurseEmbed not available: {e}")
|
| 25 |
+
# Fallback to random embeddings for testing
|
| 26 |
+
_embed_model = "fallback"
|
| 27 |
+
return _embed_model
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class NurseEmbedWrapper(gym.Wrapper):
|
| 31 |
+
"""
|
| 32 |
+
Wraps a TriageEnv and converts text observations to NurseEmbed vectors.
|
| 33 |
+
Also flattens the Dict action space to MultiDiscrete for SB3 compatibility.
|
| 34 |
+
|
| 35 |
+
Instead of the agent seeing:
|
| 36 |
+
{"chief_complaint": "Chest pain radiating to arm...", "vitals": {...}, ...}
|
| 37 |
+
|
| 38 |
+
It sees:
|
| 39 |
+
np.array([...390 dimensional semantic vector...])
|
| 40 |
+
|
| 41 |
+
The vector encodes the MEANING of the clinical presentation.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
EMBED_DIM = 384 # NurseEmbed-300M outputs 384D vectors (nomic base)
|
| 45 |
+
|
| 46 |
+
def __init__(self, env: gym.Env, use_vitals: bool = True):
|
| 47 |
+
"""
|
| 48 |
+
Args:
|
| 49 |
+
env: The underlying TriageEnv
|
| 50 |
+
use_vitals: Whether to append vitals to the embedding
|
| 51 |
+
"""
|
| 52 |
+
super().__init__(env)
|
| 53 |
+
|
| 54 |
+
self.use_vitals = use_vitals
|
| 55 |
+
self.model = _get_embed_model()
|
| 56 |
+
|
| 57 |
+
# Calculate observation dimension
|
| 58 |
+
obs_dim = self.EMBED_DIM
|
| 59 |
+
if use_vitals:
|
| 60 |
+
obs_dim += 6 # HR, BP_sys, BP_dia, SpO2, RR, Temp
|
| 61 |
+
|
| 62 |
+
# Override observation space to be a flat Box
|
| 63 |
+
self.observation_space = spaces.Box(
|
| 64 |
+
low=-np.inf,
|
| 65 |
+
high=np.inf,
|
| 66 |
+
shape=(obs_dim,),
|
| 67 |
+
dtype=np.float32
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
# Flatten action space for SB3 compatibility
|
| 71 |
+
# Original: Dict({'triage_category': Discrete(5, start=1), 'intervention': Discrete(7)})
|
| 72 |
+
# New: MultiDiscrete([5, 7]) where first dim is category (0-4, add 1 later) and second is intervention
|
| 73 |
+
self.action_space = spaces.MultiDiscrete([5, 7])
|
| 74 |
+
|
| 75 |
+
# Cache for embeddings (same text -> same embedding)
|
| 76 |
+
self._embedding_cache: Dict[str, np.ndarray] = {}
|
| 77 |
+
|
| 78 |
+
def reset(self, **kwargs) -> Tuple[np.ndarray, Dict]:
|
| 79 |
+
"""Reset and convert observation."""
|
| 80 |
+
obs, info = self.env.reset(**kwargs)
|
| 81 |
+
return self._convert_observation(obs), info
|
| 82 |
+
|
| 83 |
+
def step(self, action: np.ndarray) -> Tuple[np.ndarray, float, bool, bool, Dict]:
|
| 84 |
+
"""Convert action, step, convert observation."""
|
| 85 |
+
# Convert flat action [category_idx, intervention_idx] to Dict
|
| 86 |
+
dict_action = {
|
| 87 |
+
"triage_category": int(action[0]) + 1, # 0-4 -> 1-5
|
| 88 |
+
"intervention": int(action[1])
|
| 89 |
+
}
|
| 90 |
+
obs, reward, terminated, truncated, info = self.env.step(dict_action)
|
| 91 |
+
return self._convert_observation(obs), reward, terminated, truncated, info
|
| 92 |
+
|
| 93 |
+
def _convert_observation(self, obs: Dict) -> np.ndarray:
|
| 94 |
+
"""Convert Dict observation to semantic vector."""
|
| 95 |
+
# Build text representation
|
| 96 |
+
text = self._build_clinical_text(obs)
|
| 97 |
+
|
| 98 |
+
# Get embedding (with caching)
|
| 99 |
+
embedding = self._get_embedding(text)
|
| 100 |
+
|
| 101 |
+
# Optionally append vitals
|
| 102 |
+
if self.use_vitals:
|
| 103 |
+
vitals_vector = self._extract_vitals(obs)
|
| 104 |
+
embedding = np.concatenate([embedding, vitals_vector])
|
| 105 |
+
|
| 106 |
+
return embedding.astype(np.float32)
|
| 107 |
+
|
| 108 |
+
def _build_clinical_text(self, obs: Dict) -> str:
|
| 109 |
+
"""Build a clinical description from the observation."""
|
| 110 |
+
complaint = obs.get("chief_complaint", "Unknown complaint")
|
| 111 |
+
history = obs.get("history", "")
|
| 112 |
+
|
| 113 |
+
# Create a rich clinical description
|
| 114 |
+
text = f"Patient presents with: {complaint}. Clinical history: {history}."
|
| 115 |
+
|
| 116 |
+
# Add vitals context as text for semantic understanding
|
| 117 |
+
vitals = obs.get("vitals", {})
|
| 118 |
+
if vitals:
|
| 119 |
+
text += f" Vital signs: HR {vitals.get('hr', 'N/A')}, "
|
| 120 |
+
text += f"BP {vitals.get('bp_sys', 'N/A')}/{vitals.get('bp_dia', 'N/A')}, "
|
| 121 |
+
text += f"SpO2 {vitals.get('spo2', 'N/A')}%, "
|
| 122 |
+
text += f"RR {vitals.get('rr', 'N/A')}, "
|
| 123 |
+
text += f"AVPU {vitals.get('avpu', 'A')}."
|
| 124 |
+
|
| 125 |
+
return text
|
| 126 |
+
|
| 127 |
+
def _get_embedding(self, text: str) -> np.ndarray:
|
| 128 |
+
"""Get embedding with caching."""
|
| 129 |
+
if text in self._embedding_cache:
|
| 130 |
+
return self._embedding_cache[text]
|
| 131 |
+
|
| 132 |
+
if self.model == "fallback":
|
| 133 |
+
# Fallback: deterministic pseudo-random embedding based on text hash
|
| 134 |
+
np.random.seed(hash(text) % 2**32)
|
| 135 |
+
embedding = np.random.randn(self.EMBED_DIM)
|
| 136 |
+
else:
|
| 137 |
+
embedding = self.model.encode(text, normalize_embeddings=True)
|
| 138 |
+
|
| 139 |
+
self._embedding_cache[text] = embedding
|
| 140 |
+
return embedding
|
| 141 |
+
|
| 142 |
+
def _extract_vitals(self, obs: Dict) -> np.ndarray:
|
| 143 |
+
"""Extract vitals as a normalized vector."""
|
| 144 |
+
vitals = obs.get("vitals", {})
|
| 145 |
+
return np.array([
|
| 146 |
+
vitals.get("hr", 70) / 200.0, # Normalize HR
|
| 147 |
+
vitals.get("bp_sys", 120) / 200.0,
|
| 148 |
+
vitals.get("bp_dia", 80) / 150.0,
|
| 149 |
+
vitals.get("spo2", 98) / 100.0,
|
| 150 |
+
vitals.get("rr", 16) / 40.0,
|
| 151 |
+
(vitals.get("temp", 37.0) - 35) / 5.0, # Normalize temp
|
| 152 |
+
], dtype=np.float32)
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def make_semantic_triage_env(seed: int = None, **kwargs) -> gym.Env:
|
| 156 |
+
"""Factory function to create a semantically-aware triage environment."""
|
| 157 |
+
from nursesim_rl import TriageEnv
|
| 158 |
+
|
| 159 |
+
base_env = TriageEnv(seed=seed, **kwargs)
|
| 160 |
+
wrapped_env = NurseEmbedWrapper(base_env, use_vitals=True)
|
| 161 |
+
|
| 162 |
+
return wrapped_env
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# Register wrapped version
|
| 166 |
+
gym.register(
|
| 167 |
+
id="NurseSim-SemanticTriage-v0",
|
| 168 |
+
entry_point="nursesim_rl.semantic_wrapper:make_semantic_triage_env",
|
| 169 |
+
)
|
test_semantic.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test script for the Semantic RL Environment.
|
| 3 |
+
Validates that the NurseEmbedWrapper works correctly.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
from nursesim_rl import TriageEnv, NurseEmbedWrapper
|
| 8 |
+
|
| 9 |
+
def test_semantic_wrapper():
|
| 10 |
+
print("=" * 60)
|
| 11 |
+
print("Testing NurseEmbed Semantic RL Wrapper")
|
| 12 |
+
print("=" * 60)
|
| 13 |
+
|
| 14 |
+
# Create base environment
|
| 15 |
+
print("\n[1] Creating base TriageEnv...")
|
| 16 |
+
base_env = TriageEnv(max_steps=10, seed=42)
|
| 17 |
+
|
| 18 |
+
# Wrap with NurseEmbed
|
| 19 |
+
print("[2] Wrapping with NurseEmbedWrapper...")
|
| 20 |
+
semantic_env = NurseEmbedWrapper(base_env, use_vitals=True)
|
| 21 |
+
|
| 22 |
+
# Check observation space
|
| 23 |
+
print(f"\n[3] Observation Space Check:")
|
| 24 |
+
print(f" Base Env: {type(base_env.observation_space)}")
|
| 25 |
+
print(f" Semantic Env: {semantic_env.observation_space}")
|
| 26 |
+
print(f" Expected shape: (390,) [384 embed + 6 vitals]")
|
| 27 |
+
|
| 28 |
+
# Reset and get observation
|
| 29 |
+
print("\n[4] Resetting environment...")
|
| 30 |
+
obs, info = semantic_env.reset(seed=42)
|
| 31 |
+
|
| 32 |
+
print(f" Observation type: {type(obs)}")
|
| 33 |
+
print(f" Observation shape: {obs.shape}")
|
| 34 |
+
print(f" Observation range: [{obs.min():.3f}, {obs.max():.3f}]")
|
| 35 |
+
|
| 36 |
+
# Take a step
|
| 37 |
+
print("\n[5] Taking a step with action (Cat=3, Intervention=2)...")
|
| 38 |
+
action = {"triage_category": 3, "intervention": 2}
|
| 39 |
+
obs2, reward, terminated, truncated, info = semantic_env.step(action)
|
| 40 |
+
|
| 41 |
+
print(f" Reward: {reward}")
|
| 42 |
+
print(f" Terminated: {terminated}")
|
| 43 |
+
print(f" New observation shape: {obs2.shape}")
|
| 44 |
+
|
| 45 |
+
# Verify embedding is meaningful (not just zeros)
|
| 46 |
+
print("\n[6] Embedding quality check:")
|
| 47 |
+
embed_part = obs[:384] # First 384 dims are the embedding
|
| 48 |
+
print(f" Embedding L2 norm: {np.linalg.norm(embed_part):.3f}")
|
| 49 |
+
print(f" Embedding is normalized: {abs(np.linalg.norm(embed_part) - 1.0) < 0.1}")
|
| 50 |
+
|
| 51 |
+
# Test caching
|
| 52 |
+
print("\n[7] Testing embedding cache...")
|
| 53 |
+
obs3, _ = semantic_env.reset(seed=42) # Same seed = same patient
|
| 54 |
+
cache_hit = np.allclose(obs[:384], obs3[:384])
|
| 55 |
+
print(f" Cache working: {cache_hit}")
|
| 56 |
+
|
| 57 |
+
print("\n" + "=" * 60)
|
| 58 |
+
print("ALL TESTS PASSED!")
|
| 59 |
+
print("=" * 60)
|
| 60 |
+
|
| 61 |
+
return True
|
| 62 |
+
|
| 63 |
+
if __name__ == "__main__":
|
| 64 |
+
test_semantic_wrapper()
|
train_semantic_agent.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Train a PPO Agent on the Semantic Triage Environment.
|
| 3 |
+
|
| 4 |
+
This script trains an agent that learns from NurseEmbed-encoded clinical observations.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import numpy as np
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
# Stable Baselines 3
|
| 12 |
+
from stable_baselines3 import PPO
|
| 13 |
+
from stable_baselines3.common.env_util import make_vec_env
|
| 14 |
+
from stable_baselines3.common.callbacks import EvalCallback, BaseCallback
|
| 15 |
+
from stable_baselines3.common.vec_env import DummyVecEnv
|
| 16 |
+
|
| 17 |
+
# Our environment
|
| 18 |
+
from nursesim_rl import TriageEnv, NurseEmbedWrapper
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class PrintProgressCallback(BaseCallback):
|
| 22 |
+
"""Simple callback to print training progress."""
|
| 23 |
+
|
| 24 |
+
def __init__(self, print_freq: int = 1000, verbose: int = 0):
|
| 25 |
+
super().__init__(verbose)
|
| 26 |
+
self.print_freq = print_freq
|
| 27 |
+
|
| 28 |
+
def _on_step(self) -> bool:
|
| 29 |
+
if self.n_calls % self.print_freq == 0:
|
| 30 |
+
# Get recent episode rewards if available
|
| 31 |
+
if len(self.model.ep_info_buffer) > 0:
|
| 32 |
+
mean_reward = np.mean([ep['r'] for ep in self.model.ep_info_buffer])
|
| 33 |
+
mean_length = np.mean([ep['l'] for ep in self.model.ep_info_buffer])
|
| 34 |
+
print(f"Step {self.n_calls}: Mean Reward = {mean_reward:.2f}, Mean Length = {mean_length:.1f}")
|
| 35 |
+
return True
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def make_semantic_env():
|
| 39 |
+
"""Factory function for creating the semantic environment."""
|
| 40 |
+
base_env = TriageEnv(max_steps=50, max_patients=20)
|
| 41 |
+
semantic_env = NurseEmbedWrapper(base_env, use_vitals=True)
|
| 42 |
+
return semantic_env
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def train_semantic_agent(
|
| 46 |
+
total_timesteps: int = 50000,
|
| 47 |
+
save_path: str = "models/semantic_ppo",
|
| 48 |
+
log_dir: str = "logs/semantic_ppo",
|
| 49 |
+
):
|
| 50 |
+
"""Train a PPO agent on the semantic triage environment."""
|
| 51 |
+
|
| 52 |
+
print("=" * 60)
|
| 53 |
+
print("SEMANTIC RL TRAINING")
|
| 54 |
+
print(f"Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
| 55 |
+
print("=" * 60)
|
| 56 |
+
|
| 57 |
+
# Create directories
|
| 58 |
+
os.makedirs(save_path, exist_ok=True)
|
| 59 |
+
os.makedirs(log_dir, exist_ok=True)
|
| 60 |
+
|
| 61 |
+
# Create vectorized environment
|
| 62 |
+
print("\n[1] Creating Semantic Environment...")
|
| 63 |
+
env = DummyVecEnv([make_semantic_env])
|
| 64 |
+
|
| 65 |
+
print(f" Observation space: {env.observation_space}")
|
| 66 |
+
print(f" Action space: {env.action_space}")
|
| 67 |
+
|
| 68 |
+
# Create evaluation environment
|
| 69 |
+
eval_env = DummyVecEnv([make_semantic_env])
|
| 70 |
+
|
| 71 |
+
# Create the PPO agent
|
| 72 |
+
print("\n[2] Initializing PPO Agent...")
|
| 73 |
+
model = PPO(
|
| 74 |
+
"MlpPolicy",
|
| 75 |
+
env,
|
| 76 |
+
learning_rate=3e-4,
|
| 77 |
+
n_steps=2048,
|
| 78 |
+
batch_size=64,
|
| 79 |
+
n_epochs=10,
|
| 80 |
+
gamma=0.99,
|
| 81 |
+
gae_lambda=0.95,
|
| 82 |
+
clip_range=0.2,
|
| 83 |
+
ent_coef=0.01,
|
| 84 |
+
verbose=0,
|
| 85 |
+
tensorboard_log=log_dir,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
print(f" Policy architecture: {model.policy}")
|
| 89 |
+
|
| 90 |
+
# Callbacks
|
| 91 |
+
progress_callback = PrintProgressCallback(print_freq=2000)
|
| 92 |
+
eval_callback = EvalCallback(
|
| 93 |
+
eval_env,
|
| 94 |
+
best_model_save_path=save_path,
|
| 95 |
+
log_path=log_dir,
|
| 96 |
+
eval_freq=5000,
|
| 97 |
+
n_eval_episodes=5,
|
| 98 |
+
deterministic=True,
|
| 99 |
+
verbose=0,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Train!
|
| 103 |
+
print(f"\n[3] Training for {total_timesteps:,} timesteps...")
|
| 104 |
+
print("-" * 60)
|
| 105 |
+
|
| 106 |
+
model.learn(
|
| 107 |
+
total_timesteps=total_timesteps,
|
| 108 |
+
callback=[progress_callback, eval_callback],
|
| 109 |
+
progress_bar=True,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Save final model
|
| 113 |
+
final_path = os.path.join(save_path, "semantic_ppo_final")
|
| 114 |
+
model.save(final_path)
|
| 115 |
+
print(f"\n[4] Model saved to: {final_path}")
|
| 116 |
+
|
| 117 |
+
# Quick evaluation
|
| 118 |
+
print("\n[5] Final Evaluation (10 episodes)...")
|
| 119 |
+
rewards = []
|
| 120 |
+
for i in range(10):
|
| 121 |
+
obs = eval_env.reset()
|
| 122 |
+
episode_reward = 0
|
| 123 |
+
done = False
|
| 124 |
+
while not done:
|
| 125 |
+
action, _ = model.predict(obs, deterministic=True)
|
| 126 |
+
obs, reward, done, info = eval_env.step(action)
|
| 127 |
+
episode_reward += reward[0]
|
| 128 |
+
rewards.append(episode_reward)
|
| 129 |
+
|
| 130 |
+
print(f" Mean Reward: {np.mean(rewards):.2f} +/- {np.std(rewards):.2f}")
|
| 131 |
+
print(f" Best Episode: {np.max(rewards):.2f}")
|
| 132 |
+
print(f" Worst Episode: {np.min(rewards):.2f}")
|
| 133 |
+
|
| 134 |
+
print("\n" + "=" * 60)
|
| 135 |
+
print("TRAINING COMPLETE!")
|
| 136 |
+
print("=" * 60)
|
| 137 |
+
|
| 138 |
+
return model
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
if __name__ == "__main__":
|
| 142 |
+
# Run with reduced timesteps for quick demo
|
| 143 |
+
train_semantic_agent(total_timesteps=20000)
|
viz/semantic_clusters.png
ADDED
|
Git LFS Details
|
viz_semantic.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Visualize "What the Agent Sees" - Semantic Embedding Projection
|
| 3 |
+
|
| 4 |
+
This script generates a 2D t-SNE visualization of how the RL agent
|
| 5 |
+
perceives clinical observations through NurseEmbed semantic vectors.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import matplotlib.pyplot as plt
|
| 10 |
+
from sklearn.manifold import TSNE
|
| 11 |
+
from sklearn.decomposition import PCA
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
from nursesim_rl import TriageEnv, NurseEmbedWrapper
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def collect_observations(n_observations: int = 200, seed: int = 42):
|
| 18 |
+
"""Collect observations and their true categories."""
|
| 19 |
+
|
| 20 |
+
print(f"[1] Collecting {n_observations} observations...")
|
| 21 |
+
|
| 22 |
+
# Create semantic environment
|
| 23 |
+
base_env = TriageEnv(max_steps=100, max_patients=50, seed=seed)
|
| 24 |
+
env = NurseEmbedWrapper(base_env, use_vitals=True)
|
| 25 |
+
|
| 26 |
+
observations = []
|
| 27 |
+
categories = []
|
| 28 |
+
complaints = []
|
| 29 |
+
|
| 30 |
+
obs, info = env.reset(seed=seed)
|
| 31 |
+
|
| 32 |
+
for i in range(n_observations):
|
| 33 |
+
# Store observation and metadata
|
| 34 |
+
observations.append(obs.copy())
|
| 35 |
+
|
| 36 |
+
# Get true category from wrapped env
|
| 37 |
+
if base_env.current_patient:
|
| 38 |
+
categories.append(base_env.current_patient.true_category)
|
| 39 |
+
complaints.append(base_env.current_patient.chief_complaint[:50])
|
| 40 |
+
else:
|
| 41 |
+
categories.append(3) # Default
|
| 42 |
+
complaints.append("No patient")
|
| 43 |
+
|
| 44 |
+
# Take a random action to move to next patient
|
| 45 |
+
action = env.action_space.sample()
|
| 46 |
+
obs, _, terminated, truncated, _ = env.step(action)
|
| 47 |
+
|
| 48 |
+
if terminated or truncated:
|
| 49 |
+
obs, _ = env.reset()
|
| 50 |
+
|
| 51 |
+
print(f" Collected {len(observations)} observations")
|
| 52 |
+
|
| 53 |
+
return np.array(observations), np.array(categories), complaints
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def visualize_embeddings(observations, categories, complaints, output_path="viz"):
|
| 57 |
+
"""Create t-SNE visualization of embeddings."""
|
| 58 |
+
|
| 59 |
+
print("[2] Computing t-SNE projection...")
|
| 60 |
+
|
| 61 |
+
# Extract just the embedding part (first 384 dims)
|
| 62 |
+
embeddings = observations[:, :384]
|
| 63 |
+
|
| 64 |
+
# First reduce with PCA for speed (384 -> 50)
|
| 65 |
+
pca = PCA(n_components=50)
|
| 66 |
+
embeddings_pca = pca.fit_transform(embeddings)
|
| 67 |
+
print(f" PCA explained variance: {sum(pca.explained_variance_ratio_):.2%}")
|
| 68 |
+
|
| 69 |
+
# Then t-SNE to 2D
|
| 70 |
+
tsne = TSNE(n_components=2, perplexity=30, random_state=42, max_iter=1000)
|
| 71 |
+
embeddings_2d = tsne.fit_transform(embeddings_pca)
|
| 72 |
+
|
| 73 |
+
print("[3] Creating visualization...")
|
| 74 |
+
|
| 75 |
+
# Create figure
|
| 76 |
+
fig, ax = plt.subplots(figsize=(12, 10))
|
| 77 |
+
|
| 78 |
+
# Color map for triage categories
|
| 79 |
+
colors = {
|
| 80 |
+
1: '#FF0000', # Immediate - Red
|
| 81 |
+
2: '#FF8C00', # Very Urgent - Orange
|
| 82 |
+
3: '#FFD700', # Urgent - Yellow
|
| 83 |
+
4: '#32CD32', # Standard - Green
|
| 84 |
+
5: '#1E90FF', # Non-urgent - Blue
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
category_names = {
|
| 88 |
+
1: 'P1: Immediate',
|
| 89 |
+
2: 'P2: Very Urgent',
|
| 90 |
+
3: 'P3: Urgent',
|
| 91 |
+
4: 'P4: Standard',
|
| 92 |
+
5: 'P5: Non-urgent'
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
# Plot each category
|
| 96 |
+
for cat in sorted(set(categories)):
|
| 97 |
+
mask = categories == cat
|
| 98 |
+
ax.scatter(
|
| 99 |
+
embeddings_2d[mask, 0],
|
| 100 |
+
embeddings_2d[mask, 1],
|
| 101 |
+
c=colors.get(cat, '#888888'),
|
| 102 |
+
label=category_names.get(cat, f'Category {cat}'),
|
| 103 |
+
alpha=0.7,
|
| 104 |
+
s=100,
|
| 105 |
+
edgecolors='white',
|
| 106 |
+
linewidths=0.5
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# Styling
|
| 110 |
+
ax.set_title(
|
| 111 |
+
'What the Agent Sees: Semantic Patient Clusters\n'
|
| 112 |
+
'(t-SNE projection of NurseEmbed vectors)',
|
| 113 |
+
fontsize=14, fontweight='bold'
|
| 114 |
+
)
|
| 115 |
+
ax.set_xlabel('Semantic Dimension 1', fontsize=11)
|
| 116 |
+
ax.set_ylabel('Semantic Dimension 2', fontsize=11)
|
| 117 |
+
|
| 118 |
+
# Legend
|
| 119 |
+
ax.legend(loc='upper right', title='Triage Category', fontsize=10)
|
| 120 |
+
|
| 121 |
+
# Clean up axes
|
| 122 |
+
ax.spines['top'].set_visible(False)
|
| 123 |
+
ax.spines['right'].set_visible(False)
|
| 124 |
+
ax.grid(True, alpha=0.3)
|
| 125 |
+
|
| 126 |
+
# Add annotation
|
| 127 |
+
ax.text(
|
| 128 |
+
0.02, 0.02,
|
| 129 |
+
'Patients with similar clinical presentations\ncluster together in semantic space.',
|
| 130 |
+
transform=ax.transAxes,
|
| 131 |
+
fontsize=9,
|
| 132 |
+
verticalalignment='bottom',
|
| 133 |
+
style='italic',
|
| 134 |
+
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5)
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Save
|
| 138 |
+
os.makedirs(output_path, exist_ok=True)
|
| 139 |
+
output_file = os.path.join(output_path, 'semantic_clusters.png')
|
| 140 |
+
plt.tight_layout()
|
| 141 |
+
plt.savefig(output_file, dpi=150, bbox_inches='tight')
|
| 142 |
+
print(f"[4] Saved to: {output_file}")
|
| 143 |
+
|
| 144 |
+
# Also show statistics
|
| 145 |
+
print("\n[5] Cluster Statistics:")
|
| 146 |
+
for cat in sorted(set(categories)):
|
| 147 |
+
mask = categories == cat
|
| 148 |
+
center = embeddings_2d[mask].mean(axis=0)
|
| 149 |
+
print(f" {category_names[cat]}: {mask.sum()} patients, center at ({center[0]:.1f}, {center[1]:.1f})")
|
| 150 |
+
|
| 151 |
+
return output_file
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def main():
|
| 155 |
+
print("=" * 60)
|
| 156 |
+
print("SEMANTIC EMBEDDING VISUALIZATION")
|
| 157 |
+
print("'What the Agent Sees'")
|
| 158 |
+
print("=" * 60 + "\n")
|
| 159 |
+
|
| 160 |
+
# Collect data
|
| 161 |
+
observations, categories, complaints = collect_observations(n_observations=150)
|
| 162 |
+
|
| 163 |
+
# Visualize
|
| 164 |
+
output_file = visualize_embeddings(observations, categories, complaints)
|
| 165 |
+
|
| 166 |
+
print("\n" + "=" * 60)
|
| 167 |
+
print("VISUALIZATION COMPLETE!")
|
| 168 |
+
print(f"Open: {os.path.abspath(output_file)}")
|
| 169 |
+
print("=" * 60)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
if __name__ == "__main__":
|
| 173 |
+
main()
|