ahuggingface01 commited on
Commit
9ba48be
·
verified ·
1 Parent(s): 9a18432

Upload 8 files

Browse files
Files changed (8) hide show
  1. .gitignore +3 -0
  2. app.py +492 -0
  3. check_submission_format.py +132 -0
  4. convert_to_submission.py +217 -0
  5. environment.yml +21 -0
  6. main.py +416 -0
  7. run_preprocess.bat +5 -0
  8. 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")