Spaces:
Runtime error
Runtime error
Upload 8 files
Browse files- .gitignore +3 -0
- app.py +492 -0
- check_submission_format.py +132 -0
- convert_to_submission.py +217 -0
- environment.yml +21 -0
- main.py +416 -0
- run_preprocess.bat +5 -0
- scoring.py +131 -0
.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.json
|
| 2 |
+
.env
|
| 3 |
+
__pycache__/
|
app.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gradio Interface for Clinical CRF Filling Pipeline
|
| 3 |
+
Deployable on HuggingFace Spaces (GPU or CPU)
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
Local: python app.py
|
| 7 |
+
HF: Set as main file in Space settings
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import json
|
| 12 |
+
import asyncio
|
| 13 |
+
import tempfile
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import gradio as gr
|
| 16 |
+
|
| 17 |
+
# Pipeline imports
|
| 18 |
+
from src.preprocess.wtts_builder import WTTSBuilder
|
| 19 |
+
from src.utils.data_loader import DataLoader
|
| 20 |
+
|
| 21 |
+
import google.generativeai as genai
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# CONFIG — reads from environment variables (set via HF Secrets or .env)
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
API_KEY = os.environ.get("GOOGLE_API_KEY", "")
|
| 27 |
+
DEFAULT_MODEL = os.environ.get("GEMINI_MODEL", "gemini-1.5-pro")
|
| 28 |
+
|
| 29 |
+
# RAG imports (optional — only if dependencies installed)
|
| 30 |
+
RAG_AVAILABLE = False
|
| 31 |
+
try:
|
| 32 |
+
from src.rag.embedder import WTTSEmbedder
|
| 33 |
+
from src.rag.rag_pipeline import RAGCRFExtractor
|
| 34 |
+
RAG_AVAILABLE = True
|
| 35 |
+
except ImportError:
|
| 36 |
+
pass
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# PROMPTS (same as main.py)
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
SKELETON_PROMPT = """
|
| 43 |
+
You are a Clinical Data Specialist.
|
| 44 |
+
Convert the Weighted Time Series (WTTS) below into a "Clinical Chronology Skeleton".
|
| 45 |
+
|
| 46 |
+
INPUT (WTTS):
|
| 47 |
+
{wtts_string}
|
| 48 |
+
|
| 49 |
+
INSTRUCTIONS:
|
| 50 |
+
1. Create a strict chronological timeline (Admission to Discharge).
|
| 51 |
+
2. IMPORTANT: You MUST retain the [S_xx] ID for every event you list.
|
| 52 |
+
3. Filter out "Routine" (Weight 0.1) events unless they indicate a status change.
|
| 53 |
+
4. Keep exact values (e.g., "BP 90/60", "Temp 102.5").
|
| 54 |
+
|
| 55 |
+
OUTPUT FORMAT:
|
| 56 |
+
[Date] [S_xx]: Event details
|
| 57 |
+
[Date] [S_xx]: Event details
|
| 58 |
+
...
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
EXTRACTION_PROMPT = """
|
| 62 |
+
You are a Clinical Coding Expert.
|
| 63 |
+
Review the Patient Skeleton and the Valid Options for the requested items.
|
| 64 |
+
|
| 65 |
+
PATIENT SKELETON:
|
| 66 |
+
{skeleton}
|
| 67 |
+
|
| 68 |
+
TASK:
|
| 69 |
+
For each Clinical Item listed below, determine the value AND the supporting Sentence ID.
|
| 70 |
+
1. **Value**: Must come strictly from the "Valid Options" provided.
|
| 71 |
+
2. **Evidence**: Must be the specific [S_xx] ID from the skeleton that proves the value.
|
| 72 |
+
|
| 73 |
+
ITEMS TO EXTRACT & THEIR OPTIONS:
|
| 74 |
+
{chunk_schema_json}
|
| 75 |
+
|
| 76 |
+
OUTPUT FORMAT (JSON Object):
|
| 77 |
+
{{
|
| 78 |
+
"item_name": {{
|
| 79 |
+
"value": "Selected Option",
|
| 80 |
+
"evidence": "S_xx",
|
| 81 |
+
"reasoning": "Brief explanation"
|
| 82 |
+
}},
|
| 83 |
+
...
|
| 84 |
+
}}
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ---------------------------------------------------------------------------
|
| 89 |
+
# LLM call
|
| 90 |
+
# ---------------------------------------------------------------------------
|
| 91 |
+
async def generate_async(prompt, model, max_retries=3, initial_delay=1):
|
| 92 |
+
loop = asyncio.get_event_loop()
|
| 93 |
+
for attempt in range(max_retries):
|
| 94 |
+
try:
|
| 95 |
+
response = await loop.run_in_executor(
|
| 96 |
+
None,
|
| 97 |
+
lambda: model.generate_content(
|
| 98 |
+
contents=prompt,
|
| 99 |
+
generation_config=genai.GenerationConfig(
|
| 100 |
+
response_mime_type="application/json"
|
| 101 |
+
),
|
| 102 |
+
),
|
| 103 |
+
)
|
| 104 |
+
try:
|
| 105 |
+
return json.loads(response.text)
|
| 106 |
+
except json.JSONDecodeError:
|
| 107 |
+
continue
|
| 108 |
+
except Exception as e:
|
| 109 |
+
error_message = str(e)
|
| 110 |
+
if "429" in error_message or "500" in error_message:
|
| 111 |
+
if attempt < max_retries - 1:
|
| 112 |
+
delay = initial_delay * (2 ** attempt)
|
| 113 |
+
await asyncio.sleep(delay)
|
| 114 |
+
else:
|
| 115 |
+
return {"error": f"Max retries reached - {error_message}"}
|
| 116 |
+
else:
|
| 117 |
+
return {"error": error_message}
|
| 118 |
+
return {"error": "Failed to generate valid JSON"}
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def chunk_data(data, size):
|
| 122 |
+
for i in range(0, len(data), size):
|
| 123 |
+
yield data[i:i + size]
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ---------------------------------------------------------------------------
|
| 127 |
+
# Core processing function
|
| 128 |
+
# ---------------------------------------------------------------------------
|
| 129 |
+
def process_clinical_text(
|
| 130 |
+
clinical_text: str,
|
| 131 |
+
api_key: str,
|
| 132 |
+
model_name: str,
|
| 133 |
+
use_rag: bool,
|
| 134 |
+
admission_time: str,
|
| 135 |
+
discharge_time: str,
|
| 136 |
+
progress=gr.Progress(),
|
| 137 |
+
):
|
| 138 |
+
"""Main Gradio handler — processes clinical text and returns CRF predictions."""
|
| 139 |
+
|
| 140 |
+
if not clinical_text.strip():
|
| 141 |
+
return "⚠️ Please paste clinical text or upload a file.", "", ""
|
| 142 |
+
|
| 143 |
+
if not api_key.strip():
|
| 144 |
+
return "⚠️ Please provide a Google API key.", "", ""
|
| 145 |
+
|
| 146 |
+
# Configure Gemini
|
| 147 |
+
genai.configure(api_key=api_key.strip())
|
| 148 |
+
model = genai.GenerativeModel(model_name)
|
| 149 |
+
|
| 150 |
+
# Build a synthetic patient data dict
|
| 151 |
+
patient_data = {
|
| 152 |
+
"document_id": "gradio_patient",
|
| 153 |
+
"admission_time": admission_time or "2026-01-01",
|
| 154 |
+
"discharge_time": discharge_time or "2026-01-15",
|
| 155 |
+
"notes": [
|
| 156 |
+
{
|
| 157 |
+
"timestamp": admission_time or "2026-01-01",
|
| 158 |
+
"text": clinical_text,
|
| 159 |
+
"source": "user_input",
|
| 160 |
+
}
|
| 161 |
+
],
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
builder = WTTSBuilder()
|
| 165 |
+
|
| 166 |
+
progress(0.1, desc="Building WTTS tuples...")
|
| 167 |
+
wtts_string = builder.build_wtts_string(patient_data)
|
| 168 |
+
|
| 169 |
+
if not wtts_string.strip():
|
| 170 |
+
return "⚠️ Could not extract any events from the text.", "", ""
|
| 171 |
+
|
| 172 |
+
# ---- RAG Pipeline ----
|
| 173 |
+
if use_rag and RAG_AVAILABLE:
|
| 174 |
+
progress(0.3, desc="RAG: Embedding tuples...")
|
| 175 |
+
embedder = WTTSEmbedder(model_name="all-MiniLM-L6-v2", device="cpu")
|
| 176 |
+
extractor = RAGCRFExtractor(
|
| 177 |
+
embedder=embedder,
|
| 178 |
+
generate_fn=generate_async,
|
| 179 |
+
top_k=15,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
# We don't have target_items/valid_options from the UI,
|
| 183 |
+
# so use a default set of common CRF items
|
| 184 |
+
target_items = _get_default_target_items()
|
| 185 |
+
valid_options = _get_default_valid_options()
|
| 186 |
+
|
| 187 |
+
semaphore = asyncio.Semaphore(3)
|
| 188 |
+
|
| 189 |
+
progress(0.5, desc="RAG: Retrieving & extracting...")
|
| 190 |
+
result = asyncio.run(
|
| 191 |
+
extractor.extract_patient(
|
| 192 |
+
patient_data, builder, target_items, valid_options, semaphore, model
|
| 193 |
+
)
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
if result and result.get("predictions"):
|
| 197 |
+
predictions = result["predictions"]
|
| 198 |
+
else:
|
| 199 |
+
return "⚠️ RAG extraction returned no results.", wtts_string, ""
|
| 200 |
+
|
| 201 |
+
# ---- Original Two-Pass Pipeline ----
|
| 202 |
+
else:
|
| 203 |
+
progress(0.3, desc="Pass 1: Generating skeleton...")
|
| 204 |
+
skeleton_input = SKELETON_PROMPT.format(wtts_string=wtts_string)
|
| 205 |
+
skeleton_resp = asyncio.run(generate_async(skeleton_input, model))
|
| 206 |
+
|
| 207 |
+
skeleton_text = str(skeleton_resp)
|
| 208 |
+
if isinstance(skeleton_resp, dict):
|
| 209 |
+
skeleton_text = json.dumps(skeleton_resp, indent=2)
|
| 210 |
+
|
| 211 |
+
target_items = _get_default_target_items()
|
| 212 |
+
valid_options = _get_default_valid_options()
|
| 213 |
+
|
| 214 |
+
progress(0.6, desc="Pass 2: Extracting CRF items...")
|
| 215 |
+
predictions = {}
|
| 216 |
+
item_chunks = list(chunk_data(target_items, 10))
|
| 217 |
+
|
| 218 |
+
for i, chunk_items in enumerate(item_chunks):
|
| 219 |
+
progress(0.6 + 0.3 * (i / max(len(item_chunks), 1)),
|
| 220 |
+
desc=f"Extracting batch {i+1}/{len(item_chunks)}...")
|
| 221 |
+
|
| 222 |
+
chunk_schema = {
|
| 223 |
+
item: valid_options.get(item, ["y", "n", "unknown"])
|
| 224 |
+
for item in chunk_items
|
| 225 |
+
}
|
| 226 |
+
extract_input = EXTRACTION_PROMPT.format(
|
| 227 |
+
skeleton=skeleton_text,
|
| 228 |
+
chunk_schema_json=json.dumps(chunk_schema),
|
| 229 |
+
)
|
| 230 |
+
chunk_resp = asyncio.run(generate_async(extract_input, model))
|
| 231 |
+
|
| 232 |
+
if isinstance(chunk_resp, dict) and "error" not in chunk_resp:
|
| 233 |
+
predictions.update(chunk_resp)
|
| 234 |
+
|
| 235 |
+
progress(0.95, desc="Formatting results...")
|
| 236 |
+
|
| 237 |
+
# Format predictions for display
|
| 238 |
+
results_md = _format_predictions_markdown(predictions)
|
| 239 |
+
predictions_json = json.dumps(predictions, indent=2)
|
| 240 |
+
|
| 241 |
+
progress(1.0, desc="Done!")
|
| 242 |
+
return results_md, wtts_string, predictions_json
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
# ---------------------------------------------------------------------------
|
| 246 |
+
# File upload handler
|
| 247 |
+
# ---------------------------------------------------------------------------
|
| 248 |
+
def load_from_file(file):
|
| 249 |
+
"""Read uploaded file (txt or parquet) and return the text content."""
|
| 250 |
+
if file is None:
|
| 251 |
+
return ""
|
| 252 |
+
|
| 253 |
+
filepath = file.name if hasattr(file, "name") else str(file)
|
| 254 |
+
|
| 255 |
+
if filepath.endswith(".parquet"):
|
| 256 |
+
df = pd.read_parquet(filepath)
|
| 257 |
+
text_col = next(
|
| 258 |
+
(c for c in df.columns if c.lower() in ["clinical_note", "text", "body"]),
|
| 259 |
+
df.columns[0],
|
| 260 |
+
)
|
| 261 |
+
return "\n\n---\n\n".join(df[text_col].dropna().astype(str).tolist())
|
| 262 |
+
|
| 263 |
+
elif filepath.endswith(".jsonl"):
|
| 264 |
+
texts = []
|
| 265 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 266 |
+
for line in f:
|
| 267 |
+
rec = json.loads(line.strip())
|
| 268 |
+
if "text" in rec:
|
| 269 |
+
texts.append(rec["text"])
|
| 270 |
+
return "\n\n---\n\n".join(texts) if texts else ""
|
| 271 |
+
|
| 272 |
+
else:
|
| 273 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 274 |
+
return f.read()
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
# ---------------------------------------------------------------------------
|
| 278 |
+
# Default CRF items (common dyspnea CRF items from the challenge)
|
| 279 |
+
# ---------------------------------------------------------------------------
|
| 280 |
+
def _get_default_target_items():
|
| 281 |
+
return [
|
| 282 |
+
"chronic pulmonary disease", "chronic respiratory failure",
|
| 283 |
+
"chronic cardiac failure", "chronic renal failure",
|
| 284 |
+
"presence of dyspnea", "improvement of dyspnea",
|
| 285 |
+
"heart rate", "blood pressure", "body temperature",
|
| 286 |
+
"respiratory rate", "spo2", "level of consciousness",
|
| 287 |
+
"hemoglobin", "platelets", "leukocytes", "c-reactive protein",
|
| 288 |
+
"creatinine", "troponin", "d-dimer",
|
| 289 |
+
"ecg, any abnormality", "chest rx, any abnormalities",
|
| 290 |
+
"brain ct scan, any abnormality",
|
| 291 |
+
"administration of diuretics", "administration of steroids",
|
| 292 |
+
"administration of bronchodilators",
|
| 293 |
+
"administration of oxygen/ventilation",
|
| 294 |
+
"heart failure", "pneumonia", "copd exacerbation",
|
| 295 |
+
"respiratory failure", "pulmonary embolism",
|
| 296 |
+
"acute coronary syndrome", "arrhythmia",
|
| 297 |
+
]
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def _get_default_valid_options():
|
| 301 |
+
return {item: ["y", "n", "unknown"] for item in _get_default_target_items()}
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
# ---------------------------------------------------------------------------
|
| 305 |
+
# Format predictions as Markdown table
|
| 306 |
+
# ---------------------------------------------------------------------------
|
| 307 |
+
def _format_predictions_markdown(predictions):
|
| 308 |
+
if not predictions:
|
| 309 |
+
return "No predictions generated."
|
| 310 |
+
|
| 311 |
+
lines = [
|
| 312 |
+
"## 📋 CRF Predictions\n",
|
| 313 |
+
"| # | CRF Item | Value | Evidence | Reasoning |",
|
| 314 |
+
"|---|----------|-------|----------|-----------|",
|
| 315 |
+
]
|
| 316 |
+
|
| 317 |
+
for i, (item, val) in enumerate(predictions.items(), 1):
|
| 318 |
+
if isinstance(val, dict):
|
| 319 |
+
value = val.get("value", "—")
|
| 320 |
+
evidence = val.get("evidence", "—")
|
| 321 |
+
reasoning = val.get("reasoning", "—")
|
| 322 |
+
else:
|
| 323 |
+
value = str(val)
|
| 324 |
+
evidence = "—"
|
| 325 |
+
reasoning = "—"
|
| 326 |
+
|
| 327 |
+
# Color code the value
|
| 328 |
+
if value.lower() == "y":
|
| 329 |
+
value = "✅ Yes"
|
| 330 |
+
elif value.lower() == "n":
|
| 331 |
+
value = "❌ No"
|
| 332 |
+
elif value.lower() == "unknown":
|
| 333 |
+
value = "❓ Unknown"
|
| 334 |
+
|
| 335 |
+
lines.append(f"| {i} | {item} | {value} | {evidence} | {reasoning} |")
|
| 336 |
+
|
| 337 |
+
return "\n".join(lines)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ---------------------------------------------------------------------------
|
| 341 |
+
# Gradio UI
|
| 342 |
+
# ---------------------------------------------------------------------------
|
| 343 |
+
def create_app():
|
| 344 |
+
with gr.Blocks(
|
| 345 |
+
title="Clinical CRF Filling — RAG Pipeline",
|
| 346 |
+
theme=gr.themes.Soft(
|
| 347 |
+
primary_hue="blue",
|
| 348 |
+
secondary_hue="cyan",
|
| 349 |
+
neutral_hue="slate",
|
| 350 |
+
),
|
| 351 |
+
css="""
|
| 352 |
+
.main-header { text-align: center; margin-bottom: 1rem; }
|
| 353 |
+
.results-box { min-height: 300px; }
|
| 354 |
+
footer { display: none !important; }
|
| 355 |
+
""",
|
| 356 |
+
) as app:
|
| 357 |
+
|
| 358 |
+
# Header
|
| 359 |
+
gr.HTML("""
|
| 360 |
+
<div class="main-header">
|
| 361 |
+
<h1>🏥 Clinical CRF Filling</h1>
|
| 362 |
+
<p style="color: #666; font-size: 1.1em;">
|
| 363 |
+
RAG-Enhanced Pipeline for CL4Health 2026 Challenge
|
| 364 |
+
</p>
|
| 365 |
+
</div>
|
| 366 |
+
""")
|
| 367 |
+
|
| 368 |
+
with gr.Row():
|
| 369 |
+
# ---- Left column: Input ----
|
| 370 |
+
with gr.Column(scale=1):
|
| 371 |
+
gr.Markdown("### 📝 Input")
|
| 372 |
+
|
| 373 |
+
api_key_input = gr.Textbox(
|
| 374 |
+
label="Google API Key",
|
| 375 |
+
type="password",
|
| 376 |
+
placeholder="Enter your Gemini API key...",
|
| 377 |
+
value=API_KEY,
|
| 378 |
+
)
|
| 379 |
+
|
| 380 |
+
model_dropdown = gr.Dropdown(
|
| 381 |
+
label="Model",
|
| 382 |
+
choices=[
|
| 383 |
+
"gemini-1.5-pro",
|
| 384 |
+
"gemini-1.5-flash",
|
| 385 |
+
"gemini-2.0-flash",
|
| 386 |
+
],
|
| 387 |
+
value=DEFAULT_MODEL,
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
use_rag_checkbox = gr.Checkbox(
|
| 391 |
+
label="🔍 Use RAG Pipeline",
|
| 392 |
+
value=RAG_AVAILABLE,
|
| 393 |
+
info="Retrieves relevant tuples per CRF item (requires sentence-transformers + faiss)",
|
| 394 |
+
interactive=RAG_AVAILABLE,
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
with gr.Row():
|
| 398 |
+
admission_input = gr.Textbox(
|
| 399 |
+
label="Admission Time",
|
| 400 |
+
placeholder="2026-01-01",
|
| 401 |
+
value="2026-01-01",
|
| 402 |
+
)
|
| 403 |
+
discharge_input = gr.Textbox(
|
| 404 |
+
label="Discharge Time",
|
| 405 |
+
placeholder="2026-01-15",
|
| 406 |
+
value="2026-01-15",
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
clinical_text_input = gr.Textbox(
|
| 410 |
+
label="Clinical Notes",
|
| 411 |
+
placeholder="Paste clinical text here...",
|
| 412 |
+
lines=12,
|
| 413 |
+
max_lines=30,
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
file_upload = gr.File(
|
| 417 |
+
label="Or Upload File (.txt, .parquet, .jsonl)",
|
| 418 |
+
file_types=[".txt", ".parquet", ".jsonl"],
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
submit_btn = gr.Button(
|
| 422 |
+
"🚀 Extract CRF Items",
|
| 423 |
+
variant="primary",
|
| 424 |
+
size="lg",
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
# ---- Right column: Output ----
|
| 428 |
+
with gr.Column(scale=1):
|
| 429 |
+
gr.Markdown("### 📊 Results")
|
| 430 |
+
|
| 431 |
+
results_output = gr.Markdown(
|
| 432 |
+
label="CRF Predictions",
|
| 433 |
+
elem_classes=["results-box"],
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
with gr.Accordion("🔧 WTTS Tuples (Debug)", open=False):
|
| 437 |
+
wtts_output = gr.Textbox(
|
| 438 |
+
label="Generated WTTS String",
|
| 439 |
+
lines=8,
|
| 440 |
+
interactive=False,
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
with gr.Accordion("📦 Raw JSON Output", open=False):
|
| 444 |
+
json_output = gr.Code(
|
| 445 |
+
label="Predictions JSON",
|
| 446 |
+
language="json",
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
# ---- Event handlers ----
|
| 450 |
+
file_upload.change(
|
| 451 |
+
fn=load_from_file,
|
| 452 |
+
inputs=[file_upload],
|
| 453 |
+
outputs=[clinical_text_input],
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
submit_btn.click(
|
| 457 |
+
fn=process_clinical_text,
|
| 458 |
+
inputs=[
|
| 459 |
+
clinical_text_input,
|
| 460 |
+
api_key_input,
|
| 461 |
+
model_dropdown,
|
| 462 |
+
use_rag_checkbox,
|
| 463 |
+
admission_input,
|
| 464 |
+
discharge_input,
|
| 465 |
+
],
|
| 466 |
+
outputs=[results_output, wtts_output, json_output],
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
# Footer
|
| 470 |
+
gr.Markdown("""
|
| 471 |
+
---
|
| 472 |
+
<center>
|
| 473 |
+
<small>
|
| 474 |
+
CL4Health 2026 • CRF Filling Challenge • MIMIC-III Dataset<br>
|
| 475 |
+
Built with WTTS + RAG Pipeline
|
| 476 |
+
</small>
|
| 477 |
+
</center>
|
| 478 |
+
""")
|
| 479 |
+
|
| 480 |
+
return app
|
| 481 |
+
|
| 482 |
+
|
| 483 |
+
# ---------------------------------------------------------------------------
|
| 484 |
+
# Launch
|
| 485 |
+
# ---------------------------------------------------------------------------
|
| 486 |
+
if __name__ == "__main__":
|
| 487 |
+
app = create_app()
|
| 488 |
+
app.launch(
|
| 489 |
+
server_name="0.0.0.0",
|
| 490 |
+
server_port=7860,
|
| 491 |
+
share=False,
|
| 492 |
+
)
|
check_submission_format.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Validate a Codabench result-only submission JSONL and produce a clean ZIP.
|
| 4 |
+
|
| 5 |
+
This script checks that the JSONL file follows the expected structure:
|
| 6 |
+
- Each line is a JSON object
|
| 7 |
+
- Required keys: "document_id" (string), "predictions" (list)
|
| 8 |
+
- Each element in "predictions" is a dict with keys: "item" (string), "prediction" (string)
|
| 9 |
+
|
| 10 |
+
If valid, it creates a ZIP with the JSONL file at the archive root, ready to upload.
|
| 11 |
+
|
| 12 |
+
Usage:
|
| 13 |
+
python check_submission_format.py <path/to/mock_data_dev_codabench.jsonl> [--out submission_clean.zip]
|
| 14 |
+
|
| 15 |
+
Exit codes:
|
| 16 |
+
0 on success, non-zero on validation failure or I/O error
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import sys
|
| 23 |
+
import zipfile
|
| 24 |
+
from typing import Any, Dict, List
|
| 25 |
+
|
| 26 |
+
REQUIRED_FILE_NAME = "mock_data_dev_codabench.jsonl"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def read_jsonl(path: str) -> List[Dict[str, Any]]:
|
| 30 |
+
data: List[Dict[str, Any]] = []
|
| 31 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 32 |
+
for idx, line in enumerate(f, start=1):
|
| 33 |
+
line = line.strip()
|
| 34 |
+
if not line:
|
| 35 |
+
continue
|
| 36 |
+
try:
|
| 37 |
+
obj = json.loads(line)
|
| 38 |
+
except json.JSONDecodeError as e:
|
| 39 |
+
raise ValueError(f"Line {idx}: invalid JSON: {e}")
|
| 40 |
+
if not isinstance(obj, dict):
|
| 41 |
+
raise ValueError(f"Line {idx}: JSON object must be a dict")
|
| 42 |
+
data.append(obj)
|
| 43 |
+
if not data:
|
| 44 |
+
raise ValueError("File contains no valid JSONL records")
|
| 45 |
+
return data
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def validate_record(rec: Dict[str, Any], index: int) -> None:
|
| 49 |
+
if "document_id" not in rec:
|
| 50 |
+
raise ValueError(f"Record {index}: missing 'document_id'")
|
| 51 |
+
if not isinstance(rec["document_id"], str) or not rec["document_id"].strip():
|
| 52 |
+
raise ValueError(f"Record {index}: 'document_id' must be a non-empty string")
|
| 53 |
+
|
| 54 |
+
if "predictions" not in rec:
|
| 55 |
+
raise ValueError(f"Record {index}: missing 'predictions'")
|
| 56 |
+
preds = rec["predictions"]
|
| 57 |
+
if not isinstance(preds, list):
|
| 58 |
+
raise ValueError(f"Record {index}: 'predictions' must be a list")
|
| 59 |
+
if len(preds) == 0:
|
| 60 |
+
raise ValueError(f"Record {index}: 'predictions' list must not be empty")
|
| 61 |
+
|
| 62 |
+
for j, p in enumerate(preds):
|
| 63 |
+
if not isinstance(p, dict):
|
| 64 |
+
raise ValueError(f"Record {index} prediction {j}: must be a dict")
|
| 65 |
+
if "item" not in p:
|
| 66 |
+
raise ValueError(f"Record {index} prediction {j}: missing 'item'")
|
| 67 |
+
if "prediction" not in p:
|
| 68 |
+
raise ValueError(f"Record {index} prediction {j}: missing 'prediction'")
|
| 69 |
+
if not isinstance(p["item"], str) or not p["item"].strip():
|
| 70 |
+
raise ValueError(f"Record {index} prediction {j}: 'item' must be a non-empty string")
|
| 71 |
+
if not isinstance(p["prediction"], str):
|
| 72 |
+
raise ValueError(f"Record {index} prediction {j}: 'prediction' must be a string")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def validate_jsonl_structure(records: List[Dict[str, Any]]) -> None:
|
| 76 |
+
for i, rec in enumerate(records):
|
| 77 |
+
validate_record(rec, i)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def make_clean_zip(jsonl_path: str, zip_out: str) -> str:
|
| 81 |
+
os.makedirs(os.path.dirname(zip_out) or ".", exist_ok=True)
|
| 82 |
+
with zipfile.ZipFile(zip_out, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
| 83 |
+
zf.write(jsonl_path, arcname=REQUIRED_FILE_NAME)
|
| 84 |
+
return zip_out
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def main() -> int:
|
| 88 |
+
parser = argparse.ArgumentParser(description="Validate and zip Codabench submission JSONL")
|
| 89 |
+
parser.add_argument("jsonl", help="Path to mock_data_dev_codabench.jsonl (or similarly structured file)")
|
| 90 |
+
parser.add_argument("--out", dest="out_zip", default="submission_clean.zip", help="Output ZIP file path")
|
| 91 |
+
args = parser.parse_args()
|
| 92 |
+
|
| 93 |
+
jsonl_path = os.path.abspath(args.jsonl)
|
| 94 |
+
if not os.path.exists(jsonl_path):
|
| 95 |
+
print(f"Error: file not found: {jsonl_path}", file=sys.stderr)
|
| 96 |
+
return 2
|
| 97 |
+
|
| 98 |
+
# Warn if the filename differs from REQUIRED_FILE_NAME
|
| 99 |
+
valid_name = True
|
| 100 |
+
base = os.path.basename(jsonl_path)
|
| 101 |
+
if base != REQUIRED_FILE_NAME:
|
| 102 |
+
valid_name = False
|
| 103 |
+
print(f"Warning: input filename is '{base}'. Codabench expects '{REQUIRED_FILE_NAME}'. Converting it for submission.\n", file=sys.stderr)
|
| 104 |
+
|
| 105 |
+
try:
|
| 106 |
+
records = read_jsonl(jsonl_path)
|
| 107 |
+
validate_jsonl_structure(records)
|
| 108 |
+
except Exception as e:
|
| 109 |
+
print(f"Validation failed: {e}", file=sys.stderr)
|
| 110 |
+
return 3
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
out_zip = os.path.abspath(args.out_zip)
|
| 114 |
+
if valid_name:
|
| 115 |
+
make_clean_zip(jsonl_path, out_zip)
|
| 116 |
+
else:
|
| 117 |
+
import tempfile
|
| 118 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 119 |
+
temp_jsonl_path = os.path.join(tmpdir, REQUIRED_FILE_NAME)
|
| 120 |
+
with open(temp_jsonl_path, "w", encoding="utf-8") as f_out, open(jsonl_path, "r", encoding="utf-8") as f_in:
|
| 121 |
+
f_out.write(f_in.read())
|
| 122 |
+
make_clean_zip(temp_jsonl_path, out_zip)
|
| 123 |
+
except Exception as e:
|
| 124 |
+
print(f"Failed to create ZIP: {e}", file=sys.stderr)
|
| 125 |
+
return 4
|
| 126 |
+
|
| 127 |
+
print("Validation passed. ZIP created:", out_zip)
|
| 128 |
+
return 0
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
if __name__ == "__main__":
|
| 132 |
+
sys.exit(main())
|
convert_to_submission.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Convert pipeline output to official CL4Health Codabench submission format.
|
| 3 |
+
|
| 4 |
+
Our pipeline outputs:
|
| 5 |
+
[{"patient_id": "1014081", "predictions": {"item_name": {"value": "y", ...}}, ...}, ...]
|
| 6 |
+
|
| 7 |
+
Codabench expects (one JSON per line in JSONL):
|
| 8 |
+
{"document_id": "1014081_en", "predictions": [{"item": "item_name", "prediction": "y"}, ...]}
|
| 9 |
+
|
| 10 |
+
This script:
|
| 11 |
+
1. Reads pipeline output (JSON) and ground truth (JSONL)
|
| 12 |
+
2. Maps predictions to match GT annotation order exactly
|
| 13 |
+
3. Writes a Codabench-compatible submission JSONL
|
| 14 |
+
4. Optionally runs local scoring and creates the upload ZIP
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python convert_to_submission.py
|
| 18 |
+
python convert_to_submission.py --pipeline_output results.json --language en --score --zip
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import argparse
|
| 24 |
+
import subprocess
|
| 25 |
+
import sys
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def load_ground_truth(gt_path: str) -> list:
|
| 29 |
+
"""Load dev_gt.jsonl to get document IDs and annotation item order."""
|
| 30 |
+
records = []
|
| 31 |
+
with open(gt_path, "r", encoding="utf-8") as f:
|
| 32 |
+
for line in f:
|
| 33 |
+
line = line.strip()
|
| 34 |
+
if line:
|
| 35 |
+
records.append(json.loads(line))
|
| 36 |
+
return records
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def load_pipeline_output(output_path: str) -> dict:
|
| 40 |
+
"""
|
| 41 |
+
Load our pipeline's output JSON.
|
| 42 |
+
Returns a dict mapping patient_id → predictions dict.
|
| 43 |
+
"""
|
| 44 |
+
with open(output_path, "r", encoding="utf-8") as f:
|
| 45 |
+
results = json.load(f)
|
| 46 |
+
|
| 47 |
+
lookup = {}
|
| 48 |
+
for r in results:
|
| 49 |
+
pid = str(r.get("patient_id", "unknown"))
|
| 50 |
+
preds = r.get("predictions", {})
|
| 51 |
+
# Flatten: handle both {"item": {"value": "y"}} and {"item": "y"}
|
| 52 |
+
flat = {}
|
| 53 |
+
for item_name, item_val in preds.items():
|
| 54 |
+
if isinstance(item_val, dict):
|
| 55 |
+
flat[item_name] = str(item_val.get("value", "unknown"))
|
| 56 |
+
else:
|
| 57 |
+
flat[item_name] = str(item_val)
|
| 58 |
+
lookup[pid] = flat
|
| 59 |
+
return lookup
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def convert(gt_records: list, pipeline_lookup: dict, language: str) -> list:
|
| 63 |
+
"""
|
| 64 |
+
Convert pipeline predictions to Codabench format.
|
| 65 |
+
|
| 66 |
+
For each GT patient:
|
| 67 |
+
- Create a submission record with document_id = "{id}_{language}"
|
| 68 |
+
- For each annotation item (in GT order), look up the prediction
|
| 69 |
+
- Default to "unknown" if no prediction exists
|
| 70 |
+
"""
|
| 71 |
+
submission = []
|
| 72 |
+
matched = 0
|
| 73 |
+
unmatched = 0
|
| 74 |
+
|
| 75 |
+
for gt_rec in gt_records:
|
| 76 |
+
doc_id = str(gt_rec["document_id"])
|
| 77 |
+
annotations = gt_rec.get("annotations", [])
|
| 78 |
+
|
| 79 |
+
# Look up our predictions for this patient
|
| 80 |
+
preds = pipeline_lookup.get(doc_id, {})
|
| 81 |
+
|
| 82 |
+
if preds:
|
| 83 |
+
matched += 1
|
| 84 |
+
else:
|
| 85 |
+
unmatched += 1
|
| 86 |
+
|
| 87 |
+
# Build predictions list in the SAME ORDER as GT annotations
|
| 88 |
+
pred_list = []
|
| 89 |
+
for ann in annotations:
|
| 90 |
+
item_name = ann["item"]
|
| 91 |
+
predicted_value = preds.get(item_name, "unknown")
|
| 92 |
+
|
| 93 |
+
# Normalize: strip whitespace, lowercase
|
| 94 |
+
predicted_value = predicted_value.strip().lower() if predicted_value else "unknown"
|
| 95 |
+
if not predicted_value:
|
| 96 |
+
predicted_value = "unknown"
|
| 97 |
+
|
| 98 |
+
pred_list.append({
|
| 99 |
+
"item": item_name,
|
| 100 |
+
"prediction": predicted_value,
|
| 101 |
+
})
|
| 102 |
+
|
| 103 |
+
submission.append({
|
| 104 |
+
"document_id": f"{doc_id}_{language}",
|
| 105 |
+
"predictions": pred_list,
|
| 106 |
+
})
|
| 107 |
+
|
| 108 |
+
print(f" Matched: {matched} patients")
|
| 109 |
+
print(f" Unmatched (defaulting to 'unknown'): {unmatched} patients")
|
| 110 |
+
return submission
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def write_submission_jsonl(submission: list, output_path: str):
|
| 114 |
+
"""Write one JSON object per line."""
|
| 115 |
+
os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
|
| 116 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 117 |
+
for record in submission:
|
| 118 |
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
| 119 |
+
print(f" Written to: {output_path}")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def main():
|
| 123 |
+
parser = argparse.ArgumentParser(
|
| 124 |
+
description="Convert pipeline output to Codabench submission format"
|
| 125 |
+
)
|
| 126 |
+
parser.add_argument(
|
| 127 |
+
"--pipeline_output",
|
| 128 |
+
default="data/processed/materialized_ehr/submission.json",
|
| 129 |
+
help="Path to pipeline output JSON",
|
| 130 |
+
)
|
| 131 |
+
parser.add_argument(
|
| 132 |
+
"--gt_file",
|
| 133 |
+
default="data/raw/dev_gt.jsonl",
|
| 134 |
+
help="Path to ground truth JSONL",
|
| 135 |
+
)
|
| 136 |
+
parser.add_argument(
|
| 137 |
+
"--language",
|
| 138 |
+
default="en",
|
| 139 |
+
choices=["en", "it"],
|
| 140 |
+
help="Language suffix for document IDs",
|
| 141 |
+
)
|
| 142 |
+
parser.add_argument(
|
| 143 |
+
"--output",
|
| 144 |
+
default="submission/mock_data_dev_codabench.jsonl",
|
| 145 |
+
help="Output submission JSONL path",
|
| 146 |
+
)
|
| 147 |
+
parser.add_argument(
|
| 148 |
+
"--score",
|
| 149 |
+
action="store_true",
|
| 150 |
+
help="Run local scoring after conversion",
|
| 151 |
+
)
|
| 152 |
+
parser.add_argument(
|
| 153 |
+
"--zip",
|
| 154 |
+
action="store_true",
|
| 155 |
+
help="Create Codabench ZIP after conversion",
|
| 156 |
+
)
|
| 157 |
+
args = parser.parse_args()
|
| 158 |
+
|
| 159 |
+
print("\n=== Converting Pipeline Output to Codabench Format ===")
|
| 160 |
+
|
| 161 |
+
# 1. Load data
|
| 162 |
+
print(f"\n1. Loading ground truth from: {args.gt_file}")
|
| 163 |
+
gt_records = load_ground_truth(args.gt_file)
|
| 164 |
+
print(f" {len(gt_records)} patients in GT")
|
| 165 |
+
|
| 166 |
+
print(f"\n2. Loading pipeline output from: {args.pipeline_output}")
|
| 167 |
+
pipeline_lookup = load_pipeline_output(args.pipeline_output)
|
| 168 |
+
print(f" {len(pipeline_lookup)} patients in pipeline output")
|
| 169 |
+
|
| 170 |
+
# 2. Convert
|
| 171 |
+
print(f"\n3. Converting to Codabench format (language={args.language})...")
|
| 172 |
+
submission = convert(gt_records, pipeline_lookup, args.language)
|
| 173 |
+
|
| 174 |
+
# 3. Write
|
| 175 |
+
print(f"\n4. Writing submission JSONL...")
|
| 176 |
+
write_submission_jsonl(submission, args.output)
|
| 177 |
+
|
| 178 |
+
# 4. Optionally score locally
|
| 179 |
+
if args.score:
|
| 180 |
+
print(f"\n5. Running local scoring...")
|
| 181 |
+
# The official scorer expects dev_gt.jsonl at development_data/dev_gt.jsonl
|
| 182 |
+
# Create symlink/copy if needed
|
| 183 |
+
dev_data_dir = "development_data"
|
| 184 |
+
dev_gt_target = os.path.join(dev_data_dir, "dev_gt.jsonl")
|
| 185 |
+
if not os.path.exists(dev_gt_target):
|
| 186 |
+
os.makedirs(dev_data_dir, exist_ok=True)
|
| 187 |
+
import shutil
|
| 188 |
+
shutil.copy2(args.gt_file, dev_gt_target)
|
| 189 |
+
print(f" Copied GT to {dev_gt_target}")
|
| 190 |
+
|
| 191 |
+
cmd = [
|
| 192 |
+
sys.executable, "scoring.py",
|
| 193 |
+
"--submission_path", args.output,
|
| 194 |
+
"--language", args.language,
|
| 195 |
+
]
|
| 196 |
+
print(f" Running: {' '.join(cmd)}")
|
| 197 |
+
subprocess.run(cmd, check=False)
|
| 198 |
+
|
| 199 |
+
# 5. Optionally create ZIP
|
| 200 |
+
if args.zip:
|
| 201 |
+
zip_output = os.path.join("submission", "submission_clean.zip")
|
| 202 |
+
print(f"\n6. Creating Codabench ZIP...")
|
| 203 |
+
cmd = [
|
| 204 |
+
sys.executable, "check_submission_format.py",
|
| 205 |
+
args.output,
|
| 206 |
+
"--out", zip_output,
|
| 207 |
+
]
|
| 208 |
+
print(f" Running: {' '.join(cmd)}")
|
| 209 |
+
subprocess.run(cmd, check=False)
|
| 210 |
+
print(f"\n Upload {zip_output} to Codabench:")
|
| 211 |
+
print(f" https://www.codabench.org/competitions/11984/#/participate-tab")
|
| 212 |
+
|
| 213 |
+
print("\n=== Done ===\n")
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
if __name__ == "__main__":
|
| 217 |
+
main()
|
environment.yml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: clinical_summ
|
| 2 |
+
channels:
|
| 3 |
+
- conda-forge
|
| 4 |
+
- defaults
|
| 5 |
+
dependencies:
|
| 6 |
+
- python=3.10
|
| 7 |
+
- pandas
|
| 8 |
+
- numpy
|
| 9 |
+
- pip
|
| 10 |
+
- pip:
|
| 11 |
+
- google-cloud-aiplatform
|
| 12 |
+
- google-cloud-bigquery
|
| 13 |
+
- tiktoken
|
| 14 |
+
- transformers
|
| 15 |
+
- tqdm
|
| 16 |
+
- python-dateutil
|
| 17 |
+
- pyarrow
|
| 18 |
+
- google-generativeai
|
| 19 |
+
- sentence-transformers
|
| 20 |
+
- faiss-cpu
|
| 21 |
+
|
main.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import argparse
|
| 4 |
+
import os
|
| 5 |
+
import math
|
| 6 |
+
|
| 7 |
+
# Imports from your specific file structure
|
| 8 |
+
from src.preprocess.wtts_builder import WTTSBuilder
|
| 9 |
+
from src.utils.data_loader import DataLoader
|
| 10 |
+
|
| 11 |
+
# RAG pipeline imports (lazy — only used when --use_rag is set)
|
| 12 |
+
RAG_AVAILABLE = False
|
| 13 |
+
try:
|
| 14 |
+
from src.rag.embedder import WTTSEmbedder
|
| 15 |
+
from src.rag.rag_pipeline import RAGCRFExtractor
|
| 16 |
+
RAG_AVAILABLE = True
|
| 17 |
+
except ImportError:
|
| 18 |
+
pass
|
| 19 |
+
|
| 20 |
+
import google.generativeai as genai
|
| 21 |
+
|
| 22 |
+
# --- CONFIG ---
|
| 23 |
+
API_KEY = "AIzaSyAkgKha4IxsCjRXbeirhyoygT9Qmr4qYzU"
|
| 24 |
+
|
| 25 |
+
# --- PROMPTS ---
|
| 26 |
+
|
| 27 |
+
SKELETON_PROMPT = """
|
| 28 |
+
You are a Clinical Data Specialist.
|
| 29 |
+
Convert the Weighted Time Series (WTTS) below into a "Clinical Chronology Skeleton".
|
| 30 |
+
|
| 31 |
+
INPUT (WTTS):
|
| 32 |
+
{wtts_string}
|
| 33 |
+
|
| 34 |
+
INSTRUCTIONS:
|
| 35 |
+
1. Create a strict chronological timeline (Admission to Discharge).
|
| 36 |
+
2. IMPORTANT: You MUST retain the [S_xx] ID for every event you list.
|
| 37 |
+
3. Filter out "Routine" (Weight 0.1) events unless they indicate a status change.
|
| 38 |
+
4. Keep exact values (e.g., "BP 90/60", "Temp 102.5").
|
| 39 |
+
|
| 40 |
+
OUTPUT FORMAT:
|
| 41 |
+
[Date] [S_xx]: Event details
|
| 42 |
+
[Date] [S_xx]: Event details
|
| 43 |
+
...
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
EXTRACTION_PROMPT = """
|
| 47 |
+
You are a Clinical Coding Expert.
|
| 48 |
+
Review the Patient Skeleton and the Valid Options for the requested items.
|
| 49 |
+
|
| 50 |
+
PATIENT SKELETON:
|
| 51 |
+
{skeleton}
|
| 52 |
+
|
| 53 |
+
TASK:
|
| 54 |
+
For each Clinical Item listed below, determine the value AND the supporting Sentence ID.
|
| 55 |
+
1. **Value**: Must come strictly from the "Valid Options" provided.
|
| 56 |
+
2. **Evidence**: Must be the specific [S_xx] ID from the skeleton that proves the value.
|
| 57 |
+
|
| 58 |
+
ITEMS TO EXTRACT & THEIR OPTIONS:
|
| 59 |
+
{chunk_schema_json}
|
| 60 |
+
|
| 61 |
+
OUTPUT FORMAT (JSON Object):
|
| 62 |
+
{{
|
| 63 |
+
"item_name": {{
|
| 64 |
+
"value": "Selected Option",
|
| 65 |
+
"evidence": "S_xx",
|
| 66 |
+
"reasoning": "Brief explanation"
|
| 67 |
+
}},
|
| 68 |
+
...
|
| 69 |
+
}}
|
| 70 |
+
"""
|
| 71 |
+
|
| 72 |
+
def chunk_data(data, size):
|
| 73 |
+
"""Yield successive n-sized chunks from list."""
|
| 74 |
+
for i in range(0, len(data), size):
|
| 75 |
+
yield data[i:i + size]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
async def generate_async(prompt, model, max_retries=3, initial_delay=1):
|
| 79 |
+
"""Call Gemini via google-generativeai SDK (async-safe)."""
|
| 80 |
+
loop = asyncio.get_event_loop()
|
| 81 |
+
for attempt in range(max_retries):
|
| 82 |
+
try:
|
| 83 |
+
response = await loop.run_in_executor(
|
| 84 |
+
None,
|
| 85 |
+
lambda: model.generate_content(
|
| 86 |
+
contents=prompt,
|
| 87 |
+
generation_config=genai.GenerationConfig(
|
| 88 |
+
response_mime_type="application/json"
|
| 89 |
+
),
|
| 90 |
+
)
|
| 91 |
+
)
|
| 92 |
+
try:
|
| 93 |
+
json_response = json.loads(response.text)
|
| 94 |
+
return json_response
|
| 95 |
+
except json.JSONDecodeError:
|
| 96 |
+
print(f"Generated content is not valid JSON. Retrying...")
|
| 97 |
+
continue
|
| 98 |
+
|
| 99 |
+
except Exception as e:
|
| 100 |
+
error_message = str(e)
|
| 101 |
+
if "429" in error_message or "500" in error_message:
|
| 102 |
+
if attempt < max_retries - 1:
|
| 103 |
+
delay = initial_delay * (2 ** attempt)
|
| 104 |
+
print(f"Rate limit / server error. Retrying in {delay}s...")
|
| 105 |
+
await asyncio.sleep(delay)
|
| 106 |
+
else:
|
| 107 |
+
print(f"Max retries reached.")
|
| 108 |
+
return {"error": f"Max retries reached - {error_message}"}
|
| 109 |
+
else:
|
| 110 |
+
print(f"Error in generate_async: {error_message}")
|
| 111 |
+
return {"error": error_message}
|
| 112 |
+
|
| 113 |
+
return {"error": "Failed to generate valid JSON after multiple attempts"}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
async def process_patient(model, builder, patient_data, target_items, valid_options, semaphore):
|
| 117 |
+
"""Executes the Two-Pass Pipeline for a single patient."""
|
| 118 |
+
async with semaphore:
|
| 119 |
+
pid = str(patient_data.get('document_id') or patient_data.get('patient_id')
|
| 120 |
+
or patient_data.get('hadm_id') or 'unknown')
|
| 121 |
+
|
| 122 |
+
try:
|
| 123 |
+
# --- PHASE 1: WTTS Construction ---
|
| 124 |
+
wtts_string = builder.build_wtts_string(patient_data)
|
| 125 |
+
|
| 126 |
+
# --- PHASE 2: Skeleton Generation (Pass 1) ---
|
| 127 |
+
skeleton_input = SKELETON_PROMPT.format(wtts_string=wtts_string)
|
| 128 |
+
skeleton_resp = await generate_async(skeleton_input, model)
|
| 129 |
+
|
| 130 |
+
skeleton_text = str(skeleton_resp)
|
| 131 |
+
if isinstance(skeleton_resp, dict):
|
| 132 |
+
skeleton_text = json.dumps(skeleton_resp)
|
| 133 |
+
|
| 134 |
+
# --- PHASE 3: Extraction (Pass 2) ---
|
| 135 |
+
final_predictions = {}
|
| 136 |
+
item_chunks = list(chunk_data(target_items, 10))
|
| 137 |
+
|
| 138 |
+
for chunk_items in item_chunks:
|
| 139 |
+
chunk_schema = {
|
| 140 |
+
item: valid_options.get(item, ["Yes", "No", "Unknown"])
|
| 141 |
+
for item in chunk_items
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
extract_input = EXTRACTION_PROMPT.format(
|
| 145 |
+
skeleton=skeleton_text,
|
| 146 |
+
chunk_schema_json=json.dumps(chunk_schema)
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
chunk_resp = await generate_async(extract_input, model)
|
| 150 |
+
|
| 151 |
+
if isinstance(chunk_resp, dict):
|
| 152 |
+
if 'error' in chunk_resp:
|
| 153 |
+
print(f" [WARN] LLM error for {pid}, chunk {chunk_items[:3]}...: {chunk_resp['error']}")
|
| 154 |
+
else:
|
| 155 |
+
final_predictions.update(chunk_resp)
|
| 156 |
+
|
| 157 |
+
return {
|
| 158 |
+
"patient_id": pid,
|
| 159 |
+
"skeleton_debug": skeleton_text[:500] + "...",
|
| 160 |
+
"predictions": final_predictions
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
except Exception as e:
|
| 164 |
+
print(f"Error processing {pid}: {e}")
|
| 165 |
+
return None
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ---------------------------------------------------------------------------
|
| 169 |
+
# EVALUATION -- Accuracy & F1 Scoring
|
| 170 |
+
# ---------------------------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
def _normalise(value):
|
| 173 |
+
"""Lowercase + strip for fair comparison."""
|
| 174 |
+
if value is None:
|
| 175 |
+
return ""
|
| 176 |
+
return str(value).strip().lower()
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def evaluate_predictions(results, gt_path):
|
| 180 |
+
"""
|
| 181 |
+
Compare pipeline results against dev_gt.jsonl.
|
| 182 |
+
Prints accuracy, macro-F1, per-item breakdown, and sample errors.
|
| 183 |
+
Returns (overall_dict, per_item_dict).
|
| 184 |
+
"""
|
| 185 |
+
# --- Load GT ---
|
| 186 |
+
gt = {}
|
| 187 |
+
with open(gt_path, 'r', encoding='utf-8') as f:
|
| 188 |
+
for line in f:
|
| 189 |
+
line = line.strip()
|
| 190 |
+
if not line:
|
| 191 |
+
continue
|
| 192 |
+
rec = json.loads(line)
|
| 193 |
+
doc_id = str(rec['document_id'])
|
| 194 |
+
gt[doc_id] = {a['item']: a['ground_truth'] for a in rec.get('annotations', [])}
|
| 195 |
+
|
| 196 |
+
# --- Build prediction lookup ---
|
| 197 |
+
preds = {}
|
| 198 |
+
for r in results:
|
| 199 |
+
doc_id = str(r.get('patient_id', 'unknown'))
|
| 200 |
+
items = {}
|
| 201 |
+
for item_name, item_val in r.get('predictions', {}).items():
|
| 202 |
+
if isinstance(item_val, dict):
|
| 203 |
+
items[item_name] = item_val.get('value', str(item_val))
|
| 204 |
+
else:
|
| 205 |
+
items[item_name] = str(item_val)
|
| 206 |
+
preds[doc_id] = items
|
| 207 |
+
|
| 208 |
+
# --- Collect all unique items ---
|
| 209 |
+
all_items = set()
|
| 210 |
+
for doc_items in gt.values():
|
| 211 |
+
all_items.update(doc_items.keys())
|
| 212 |
+
|
| 213 |
+
# --- Score ---
|
| 214 |
+
item_stats = {item: {'tp': 0, 'fp': 0, 'fn': 0, 'total': 0, 'correct': 0}
|
| 215 |
+
for item in all_items}
|
| 216 |
+
total_comparisons = 0
|
| 217 |
+
total_correct = 0
|
| 218 |
+
matched_patients = 0
|
| 219 |
+
errors = []
|
| 220 |
+
|
| 221 |
+
for doc_id, gt_items in gt.items():
|
| 222 |
+
pred_items = preds.get(doc_id, {})
|
| 223 |
+
if pred_items:
|
| 224 |
+
matched_patients += 1
|
| 225 |
+
|
| 226 |
+
for item_name, gt_val in gt_items.items():
|
| 227 |
+
gt_norm = _normalise(gt_val)
|
| 228 |
+
pred_val = pred_items.get(item_name)
|
| 229 |
+
pred_norm = _normalise(pred_val) if pred_val is not None else ""
|
| 230 |
+
|
| 231 |
+
total_comparisons += 1
|
| 232 |
+
item_stats[item_name]['total'] += 1
|
| 233 |
+
|
| 234 |
+
if gt_norm == pred_norm:
|
| 235 |
+
total_correct += 1
|
| 236 |
+
item_stats[item_name]['correct'] += 1
|
| 237 |
+
item_stats[item_name]['tp'] += 1
|
| 238 |
+
else:
|
| 239 |
+
item_stats[item_name]['fn'] += 1
|
| 240 |
+
if pred_norm:
|
| 241 |
+
item_stats[item_name]['fp'] += 1
|
| 242 |
+
errors.append((doc_id, item_name, gt_val,
|
| 243 |
+
pred_val if pred_val is not None else '<MISSING>'))
|
| 244 |
+
|
| 245 |
+
accuracy = total_correct / total_comparisons if total_comparisons > 0 else 0.0
|
| 246 |
+
|
| 247 |
+
# --- Per-item P/R/F1 ---
|
| 248 |
+
f1s = []
|
| 249 |
+
per_item = {}
|
| 250 |
+
for item_name in sorted(all_items):
|
| 251 |
+
s = item_stats[item_name]
|
| 252 |
+
tp, fp, fn = s['tp'], s['fp'], s['fn']
|
| 253 |
+
prec = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
| 254 |
+
rec = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 255 |
+
f1 = 2 * prec * rec / (prec + rec) if (prec + rec) > 0 else 0.0
|
| 256 |
+
item_acc = s['correct'] / s['total'] if s['total'] > 0 else 0.0
|
| 257 |
+
per_item[item_name] = {'accuracy': item_acc, 'precision': prec,
|
| 258 |
+
'recall': rec, 'f1': f1, 'total': s['total']}
|
| 259 |
+
f1s.append(f1)
|
| 260 |
+
|
| 261 |
+
macro_f1 = sum(f1s) / len(f1s) if f1s else 0.0
|
| 262 |
+
|
| 263 |
+
# --- Print report ---
|
| 264 |
+
print("\n" + "=" * 70)
|
| 265 |
+
print(" CL4Health CRF Filling -- Evaluation Report")
|
| 266 |
+
print("=" * 70)
|
| 267 |
+
print(f"\n GT Patients: {len(gt)}")
|
| 268 |
+
print(f" Pred Patients: {len(preds)}")
|
| 269 |
+
print(f" Matched Patients: {matched_patients}")
|
| 270 |
+
print(f"\n Total Comparisons: {total_comparisons}")
|
| 271 |
+
print(f" Total Correct: {total_correct}")
|
| 272 |
+
print(f"\n {'Accuracy':>20s}: {accuracy:.4f}")
|
| 273 |
+
print(f" {'Macro F1':>20s}: {macro_f1:.4f}")
|
| 274 |
+
|
| 275 |
+
# Top / bottom items
|
| 276 |
+
sorted_items = sorted(per_item.items(), key=lambda x: x[1]['f1'], reverse=True)
|
| 277 |
+
n_show = min(15, len(sorted_items))
|
| 278 |
+
|
| 279 |
+
print(f"\n Top {n_show} Items by F1:")
|
| 280 |
+
print(f" {'Item':<45s} {'Acc':>6s} {'P':>6s} {'R':>6s} {'F1':>6s}")
|
| 281 |
+
print(f" {'-'*45} {'---':>6s} {'---':>6s} {'---':>6s} {'---':>6s}")
|
| 282 |
+
for name, s in sorted_items[:n_show]:
|
| 283 |
+
print(f" {name:<45s} {s['accuracy']:>6.2f} {s['precision']:>6.2f} {s['recall']:>6.2f} {s['f1']:>6.2f}")
|
| 284 |
+
|
| 285 |
+
print(f"\n Bottom {n_show} Items by F1:")
|
| 286 |
+
print(f" {'Item':<45s} {'Acc':>6s} {'P':>6s} {'R':>6s} {'F1':>6s}")
|
| 287 |
+
print(f" {'-'*45} {'---':>6s} {'---':>6s} {'---':>6s} {'---':>6s}")
|
| 288 |
+
for name, s in sorted_items[-n_show:]:
|
| 289 |
+
print(f" {name:<45s} {s['accuracy']:>6.2f} {s['precision']:>6.2f} {s['recall']:>6.2f} {s['f1']:>6.2f}")
|
| 290 |
+
|
| 291 |
+
# Sample errors
|
| 292 |
+
if errors:
|
| 293 |
+
n_err = min(15, len(errors))
|
| 294 |
+
print(f"\n Sample Mismatches ({n_err} of {len(errors)}):")
|
| 295 |
+
print(f" {'DocID':<12s} {'Item':<40s} {'GT':<20s} {'Pred':<20s}")
|
| 296 |
+
print(f" {'-'*12} {'-'*40} {'-'*20} {'-'*20}")
|
| 297 |
+
for doc_id, item, gt_v, pred_v in errors[:n_err]:
|
| 298 |
+
print(f" {doc_id:<12s} {item:<40s} {str(gt_v):<20s} {str(pred_v):<20s}")
|
| 299 |
+
|
| 300 |
+
print("=" * 70)
|
| 301 |
+
|
| 302 |
+
return {'accuracy': round(accuracy, 4), 'macro_f1': round(macro_f1, 4)}, per_item
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
# ---------------------------------------------------------------------------
|
| 306 |
+
# MAIN
|
| 307 |
+
# ---------------------------------------------------------------------------
|
| 308 |
+
|
| 309 |
+
async def main():
|
| 310 |
+
parser = argparse.ArgumentParser()
|
| 311 |
+
parser.add_argument("--api_key", default=API_KEY,
|
| 312 |
+
help="Google AI Studio API key")
|
| 313 |
+
parser.add_argument("--model_name", default="gemini-1.5-pro",
|
| 314 |
+
help="Gemini model name")
|
| 315 |
+
parser.add_argument("--data_folders", nargs="+",
|
| 316 |
+
default=[
|
| 317 |
+
r"C:\Users\sai78\Desktop\Clinical_CRF_filling\data\raw\dyspnea-clinical-notes",
|
| 318 |
+
r"C:\Users\sai78\Desktop\Clinical_CRF_filling\data\raw\dyspnea-crf-development",
|
| 319 |
+
],
|
| 320 |
+
help="Directories containing .parquet shards (searched recursively)")
|
| 321 |
+
parser.add_argument("--gt_file",
|
| 322 |
+
default=r"C:\Users\sai78\Desktop\Clinical_CRF_filling\data\raw\dev_gt.jsonl")
|
| 323 |
+
parser.add_argument("--options_folder",
|
| 324 |
+
default=r"C:\Users\sai78\Desktop\Clinical_CRF_filling\data\raw\dyspnea-valid-options\dyspnea-valid-options\data")
|
| 325 |
+
parser.add_argument("--output_file",
|
| 326 |
+
default="data/processed/materialized_ehr/submission.json")
|
| 327 |
+
parser.add_argument("--skip_eval", action="store_true",
|
| 328 |
+
help="Skip evaluation after generating predictions")
|
| 329 |
+
parser.add_argument("--concurrency", type=int, default=5,
|
| 330 |
+
help="Max concurrent LLM calls (free tier: keep at 5)")
|
| 331 |
+
# --- RAG options ---
|
| 332 |
+
parser.add_argument("--use_rag", action="store_true",
|
| 333 |
+
help="Use RAG-guided extraction (retrieves relevant tuples per CRF item)")
|
| 334 |
+
parser.add_argument("--rag_top_k", type=int, default=15,
|
| 335 |
+
help="Number of WTTS tuples to retrieve per CRF item group (RAG mode)")
|
| 336 |
+
parser.add_argument("--rag_model", type=str, default="all-MiniLM-L6-v2",
|
| 337 |
+
help="SentenceTransformer model for embeddings (swap to clinical model on GPU)")
|
| 338 |
+
parser.add_argument("--rag_device", type=str, default="cpu",
|
| 339 |
+
help="Device for embedding model: 'cpu' or 'cuda'")
|
| 340 |
+
args = parser.parse_args()
|
| 341 |
+
|
| 342 |
+
# 1. Setup — Configure Gemini API
|
| 343 |
+
genai.configure(api_key=args.api_key)
|
| 344 |
+
model = genai.GenerativeModel(args.model_name)
|
| 345 |
+
print(f"Using model: {args.model_name} (Google AI Studio)")
|
| 346 |
+
|
| 347 |
+
# Limit concurrency (free tier = 15 RPM, so keep low)
|
| 348 |
+
semaphore = asyncio.Semaphore(args.concurrency)
|
| 349 |
+
|
| 350 |
+
# 2. Load Data
|
| 351 |
+
loader = DataLoader(data_folders=args.data_folders, gt_path=args.gt_file)
|
| 352 |
+
|
| 353 |
+
target_items = loader.get_target_schema()
|
| 354 |
+
valid_options = loader.load_valid_options(args.options_folder)
|
| 355 |
+
|
| 356 |
+
merged_data = loader.load_and_merge()
|
| 357 |
+
|
| 358 |
+
if not merged_data:
|
| 359 |
+
print("No data found. Exiting.")
|
| 360 |
+
return
|
| 361 |
+
|
| 362 |
+
# 3. Process
|
| 363 |
+
builder = WTTSBuilder()
|
| 364 |
+
print(f"Starting pipeline for {len(merged_data)} patients...")
|
| 365 |
+
print(f"Schema: {len(target_items)} items per patient.")
|
| 366 |
+
|
| 367 |
+
if args.use_rag:
|
| 368 |
+
# --- RAG Pipeline ---
|
| 369 |
+
if not RAG_AVAILABLE:
|
| 370 |
+
print("ERROR: RAG dependencies not installed. Run:")
|
| 371 |
+
print(" pip install sentence-transformers faiss-cpu")
|
| 372 |
+
return
|
| 373 |
+
|
| 374 |
+
print(f"\n [RAG MODE] Embedding model: {args.rag_model}")
|
| 375 |
+
print(f" [RAG MODE] Device: {args.rag_device}")
|
| 376 |
+
print(f" [RAG MODE] Top-k: {args.rag_top_k}\n")
|
| 377 |
+
|
| 378 |
+
embedder = WTTSEmbedder(model_name=args.rag_model, device=args.rag_device)
|
| 379 |
+
extractor = RAGCRFExtractor(
|
| 380 |
+
embedder=embedder,
|
| 381 |
+
generate_fn=generate_async,
|
| 382 |
+
top_k=args.rag_top_k,
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
tasks = [
|
| 386 |
+
extractor.extract_patient(
|
| 387 |
+
p, builder, target_items, valid_options, semaphore, model
|
| 388 |
+
)
|
| 389 |
+
for p in merged_data
|
| 390 |
+
]
|
| 391 |
+
else:
|
| 392 |
+
# --- Original Two-Pass Pipeline ---
|
| 393 |
+
tasks = [
|
| 394 |
+
process_patient(model, builder, p, target_items, valid_options, semaphore)
|
| 395 |
+
for p in merged_data
|
| 396 |
+
]
|
| 397 |
+
|
| 398 |
+
results = await asyncio.gather(*tasks)
|
| 399 |
+
results = [r for r in results if r is not None]
|
| 400 |
+
|
| 401 |
+
# 4. Save
|
| 402 |
+
os.makedirs(os.path.dirname(args.output_file), exist_ok=True)
|
| 403 |
+
with open(args.output_file, 'w') as f:
|
| 404 |
+
json.dump(results, f, indent=2)
|
| 405 |
+
|
| 406 |
+
print(f"\nDone! {len(results)} results saved to {args.output_file}")
|
| 407 |
+
|
| 408 |
+
# 5. Evaluate against GT
|
| 409 |
+
if not args.skip_eval:
|
| 410 |
+
print("\nRunning evaluation against ground truth...")
|
| 411 |
+
overall, _ = evaluate_predictions(results, args.gt_file)
|
| 412 |
+
print(f"\n >>> Final Accuracy: {overall['accuracy']:.4f} | Macro F1: {overall['macro_f1']:.4f}")
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
if __name__ == "__main__":
|
| 416 |
+
asyncio.run(main())
|
run_preprocess.bat
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
python -m src.preprocess.wtts_builder ^
|
| 2 |
+
--input_dir data/raw/dyspnea-clinical-notes ^
|
| 3 |
+
--gt_file data/raw/dev_gt.jsonl ^
|
| 4 |
+
--output_dir data/processed/materialized_ehr
|
| 5 |
+
pause
|
scoring.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
+
from sklearn.metrics import f1_score
|
| 5 |
+
import argparse
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def load_jsonl(path):
|
| 9 |
+
"""Load a JSONL file as a list of dicts."""
|
| 10 |
+
data = []
|
| 11 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 12 |
+
for line in f:
|
| 13 |
+
line = line.strip()
|
| 14 |
+
if not line:
|
| 15 |
+
continue
|
| 16 |
+
data.append(json.loads(line))
|
| 17 |
+
return data
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class Scorer:
|
| 21 |
+
def __init__(self, not_available_string: str, language:str):
|
| 22 |
+
self.not_available_string = not_available_string
|
| 23 |
+
self.return_value_for_zero_division = 0
|
| 24 |
+
if language not in ["en", "it"]:
|
| 25 |
+
raise ValueError(f"Unsupported language: {language}. Supported languages are 'en' and 'it'.")
|
| 26 |
+
self.language = language
|
| 27 |
+
|
| 28 |
+
def calculate_score(self, reference, submission):
|
| 29 |
+
scores = []
|
| 30 |
+
self.TP = 0
|
| 31 |
+
self.FP = 0
|
| 32 |
+
self.FN = 0
|
| 33 |
+
for ref_one_patient, sub_one_patient in zip(reference, submission):
|
| 34 |
+
sub_one_patient_id, lang = sub_one_patient["document_id"].split("_", 1)
|
| 35 |
+
if ref_one_patient["document_id"] != sub_one_patient_id:
|
| 36 |
+
raise ValueError(
|
| 37 |
+
f"Document ID mismatch: reference {ref_one_patient['document_id']} vs submission {sub_one_patient['document_id']}"
|
| 38 |
+
)
|
| 39 |
+
if lang != self.language:
|
| 40 |
+
raise ValueError(
|
| 41 |
+
f"Language mismatch: expected {self.language} but got {lang} in submission"
|
| 42 |
+
)
|
| 43 |
+
score_one_patient = self.calculate_score_one_patient(
|
| 44 |
+
ref_one_patient,
|
| 45 |
+
sub_one_patient,
|
| 46 |
+
)
|
| 47 |
+
scores.append(score_one_patient)
|
| 48 |
+
|
| 49 |
+
if not scores:
|
| 50 |
+
return 0.0
|
| 51 |
+
print(f"TP: {self.TP}, FP: {self.FP}, FN: {self.FN}")
|
| 52 |
+
|
| 53 |
+
return sum(scores) / len(scores)
|
| 54 |
+
|
| 55 |
+
def calculate_score_one_patient(self, reference_one_patient, submission_one_patient):
|
| 56 |
+
# Expected structure:
|
| 57 |
+
# reference_one_patient["annotations"] = [{"ground_truth": ...}, ...]
|
| 58 |
+
# submission_one_patient["predictions"] = [{"prediction": ...}, ...]
|
| 59 |
+
y_true = [item["ground_truth"] for item in reference_one_patient["annotations"]]
|
| 60 |
+
y_pred = [item["prediction"] for item in submission_one_patient["predictions"]]
|
| 61 |
+
|
| 62 |
+
for i, t, p in zip(range(len(y_true)), y_true, y_pred):
|
| 63 |
+
if t != self.not_available_string or p != self.not_available_string:
|
| 64 |
+
if t == p:
|
| 65 |
+
self.TP += 1
|
| 66 |
+
elif t == self.not_available_string and p != self.not_available_string:
|
| 67 |
+
self.FP += 1
|
| 68 |
+
elif t != p and p == self.not_available_string:
|
| 69 |
+
self.FN += 1
|
| 70 |
+
f1 = f1_score(
|
| 71 |
+
y_true,
|
| 72 |
+
y_pred,
|
| 73 |
+
average="macro",
|
| 74 |
+
)
|
| 75 |
+
return f1
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def main(your_submission_path: str, language: str, test_or_dev: str) -> None:
|
| 79 |
+
print("\n=== Scoring program starting ===")
|
| 80 |
+
output_dir = "your_sumbmission_scores"
|
| 81 |
+
|
| 82 |
+
if test_or_dev == "test":
|
| 83 |
+
ref_path = 'development_data/dev_gt.jsonl'
|
| 84 |
+
elif test_or_dev == "development":
|
| 85 |
+
ref_path = 'development_data/dev_gt.jsonl'
|
| 86 |
+
else:
|
| 87 |
+
raise ValueError("test_or_dev must be either 'test' or 'development'")
|
| 88 |
+
|
| 89 |
+
sub_path = your_submission_path
|
| 90 |
+
|
| 91 |
+
if not os.path.exists(ref_path):
|
| 92 |
+
raise FileNotFoundError(f"Reference file not found at {ref_path}")
|
| 93 |
+
if not os.path.exists(sub_path):
|
| 94 |
+
raise FileNotFoundError(f"Submission predictions not found at {sub_path}")
|
| 95 |
+
|
| 96 |
+
print(f"Loading reference from {ref_path}")
|
| 97 |
+
try:
|
| 98 |
+
reference = load_jsonl(ref_path)
|
| 99 |
+
except:
|
| 100 |
+
if test_or_dev == "test":
|
| 101 |
+
raise ValueError(f"Test data has not been released yet.")
|
| 102 |
+
|
| 103 |
+
print(f"Loading submission from {sub_path}")
|
| 104 |
+
submission = load_jsonl(sub_path)
|
| 105 |
+
|
| 106 |
+
scorer = Scorer(not_available_string="unknown", language=language)
|
| 107 |
+
score = scorer.calculate_score(reference, submission)
|
| 108 |
+
|
| 109 |
+
print(f"Final macro-F1 = {score}")
|
| 110 |
+
|
| 111 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 112 |
+
|
| 113 |
+
# Codabench reads scores.json (or scores.txt). Let's use JSON:
|
| 114 |
+
scores_path = os.path.join(output_dir, "scores.json")
|
| 115 |
+
with open(scores_path, "w", encoding="utf-8") as f:
|
| 116 |
+
json.dump({"f1_macro": float(score)}, f)
|
| 117 |
+
|
| 118 |
+
print(f"Scores written to {scores_path}")
|
| 119 |
+
print("=== Scoring program finished successfully ===\n")
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
if __name__ == "__main__":
|
| 123 |
+
# get from argparse the agumetns pred, ref, output, language
|
| 124 |
+
argparse = argparse.ArgumentParser(description="Score submission")
|
| 125 |
+
argparse.add_argument("--submission_path", type=str, help="Path to the submission JSONL")
|
| 126 |
+
argparse.add_argument("--language", type=str, help="Language of the submission (en or it)")
|
| 127 |
+
args = argparse.parse_args()
|
| 128 |
+
|
| 129 |
+
your_submission_path = args.submission_path
|
| 130 |
+
language = args.language
|
| 131 |
+
main(your_submission_path, language, test_or_dev="development")
|