NurseCitizenDeveloper commited on
Commit
3d38e23
·
1 Parent(s): 00732f3

chore: Update workspace before push

Browse files
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.1.0"
10
- __all__ = ["TriageEnv", "PatientGenerator"]
 
 
 
 
 
 
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

  • SHA256: c77b4b852aabb13a79d62c0cc2c3863544c98d69111a2c966c6de63713f34dc5
  • Pointer size: 131 Bytes
  • Size of remote file: 121 kB
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()