IINOVAII's picture
Initial commit - final working code
f06a233
Raw
History Blame Contribute Delete
15.3 kB
"""
Task 3 (Hard) β€” Discharge Note Generation. v3.
v3 improvements over v2:
1. Hallucination check uses BOTH prescriptions AND emar_drug_set β€” a drug is
only hallucinated if absent from both sources.
2. follow_up structure penalty: if discharge_orders.discharge_planning_finalized
is True in the episode but the generated note omits "follow-up"/"follow up",
apply 0.05 structure penalty.
Score (all components clamped to [0, 1] before weighting):
0.30 Γ— diagnosis_coverage (contextual, anti-stuffing)
0.20 Γ— disposition_accuracy
0.20 Γ— medication_precision_recall (F1)
0.15 Γ— los_accuracy
0.10 Γ— structure_score
0.05 Γ— information_density
βˆ’ hallucination_penalty (subtracted after weighting, floor 0, max 0.15)
βˆ’ followup_structure_penalty (0.05 if planning finalized but follow-up omitted)
"""
from __future__ import annotations
import re
from collections import Counter
from typing import Tuple, Dict, Any, List, Set
# ─── Shared helpers ───────────────────────────────────────────────────────────
_STOPWORDS = {
"with", "without", "unspecified", "other", "acute", "chronic", "type",
"stage", "disease", "disorder", "condition", "history", "patient",
"admission", "hospital", "including", "related", "associated",
"secondary", "primary", "initial", "subsequent",
}
def _sentences(text: str) -> List[str]:
return [s.strip() for s in re.split(r"(?<=[.!?])\s+", text.strip()) if s.strip()]
def _words(text: str) -> List[str]:
return re.findall(r"\b[a-z]{3,}\b", text.lower())
# ─── 1. Diagnosis coverage ────────────────────────────────────────────────────
def _diagnosis_coverage(note: str, diagnoses: List[Dict]) -> float:
if not diagnoses:
return 0.5
top5 = [d for d in diagnoses if d.get("long_title")][:5]
if not top5:
return 0.5
sents = _sentences(note)
dx_keywords: List[List[str]] = []
for dx in top5:
title = str(dx.get("long_title", "")).lower()
kws = [w for w in re.findall(r"\b[a-z]{5,}\b", title) if w not in _STOPWORDS][:4]
dx_keywords.append(kws)
sent_matches: List[Set[int]] = []
for sent in sents:
sent_lower = sent.lower()
word_count = len(sent_lower.split())
if word_count < 5:
sent_matches.append(set())
continue
matched = set()
for i, kws in enumerate(dx_keywords):
if kws and any(kw in sent_lower for kw in kws):
matched.add(i)
sent_matches.append(matched)
valid_sent_matches: List[Set[int]] = []
for matched in sent_matches:
if len(matched) >= 3:
valid_sent_matches.append(set())
else:
valid_sent_matches.append(matched)
covered_indices: Set[int] = set()
for matched in valid_sent_matches:
covered_indices |= matched
base_coverage = len(covered_indices) / len(top5)
all_kws = {kw for kws in dx_keywords for kw in kws}
note_words = _words(note)
if note_words:
kw_density = sum(1 for w in note_words if w in all_kws) / len(note_words)
if kw_density > 0.08:
base_coverage *= 0.5
return round(base_coverage, 4)
# ─── 2. Disposition accuracy ─────────────────────────────────────────────────
_DISPO_SYNONYMS: Dict[str, List[str]] = {
"home_with_services": ["home health", "home with services", "home care"],
"snf": ["skilled nursing", "snf", "nursing facility", "long-term care"],
"rehab": ["rehabilitation", "rehab facility", "inpatient rehab"],
"hospice": ["hospice", "comfort care", "palliative"],
"expired": ["expired", "deceased", "passed away", "death", "died"],
"ama": ["against medical advice", "left ama", "against advice"],
"home": ["discharged home", "home with", "returned home", "discharge home"],
"other": ["transferred to", "transfer to"],
}
def _true_canonical(location: str) -> str:
loc = location.upper().strip()
if "HEALTH CARE" in loc or "HOME WITH" in loc: return "home_with_services"
if "SKILLED NURSING" in loc or "SNF" in loc or "LONG TERM" in loc: return "snf"
if "REHAB" in loc: return "rehab"
if "HOSPICE" in loc: return "hospice"
if "AGAINST ADVICE" in loc or "AMA" in loc: return "ama"
if "DIED" in loc or "EXPIRED" in loc or "DEAD" in loc: return "expired"
if "TRANSFER" in loc: return "other"
if "HOME" in loc or "SELF" in loc: return "home"
return "other"
def _disposition_mentioned(note: str, episode: Dict) -> float:
true_location = str(episode.get("discharge_location", "")).strip()
if not true_location:
return 0.5
note_lower = note.lower()
canonical = _true_canonical(true_location)
synonyms = _DISPO_SYNONYMS.get(canonical, [])
if any(syn in note_lower for syn in synonyms):
return 1.0
if "discharg" in note_lower:
return 0.3
return 0.0
# ─── 3. Medication F1 with emar_drug_set (v3) ─────────────────────────────────
def _extract_mentioned_drugs(
note: str,
known_drugs: List[str],
emar_drug_set: Set[str] = None,
) -> Tuple[Set[str], Set[str]]:
"""
Returns (true_positives, false_positives).
v3: a detected drug is NOT a false positive if it matches episode prescriptions
OR the emar_drug_set.
"""
note_lower = note.lower()
ep_stems: Set[str] = set()
for drug in known_drugs:
tokens = [t for t in str(drug).lower().split() if len(t) >= 4]
if tokens:
ep_stems.add(tokens[0])
emar_stems: Set[str] = set()
if emar_drug_set:
for drug in emar_drug_set:
tokens = [t for t in str(drug).lower().split() if len(t) >= 4]
if tokens:
emar_stems.add(tokens[0])
all_known_stems = ep_stems | emar_stems
true_positives = {stem for stem in ep_stems if stem in note_lower}
drug_suffixes = re.compile(
r"\b\w*(?:mab|nib|pril|sartan|olol|pam|lam|statin|mycin|cillin|"
r"oxacin|cycline|azole|prazole|tidine|triptan|vir|mide|zide|"
r"done|pine|xine|zine|dine|line|rine|mine|sine|vine|lone)\b",
re.IGNORECASE,
)
note_drug_tokens = {m.group().lower() for m in drug_suffixes.finditer(note)}
med_context = re.compile(
r"(?:medication|drug|prescribed|continued|started|taking|given|dose of|mg of)\s+([a-z]{4,})",
re.IGNORECASE,
)
for m in med_context.finditer(note):
note_drug_tokens.add(m.group(1).lower())
# False positives: detected tokens not matching ANY known drug source
false_positives = {
t for t in note_drug_tokens
if not any(
t.startswith(stem[:4]) or stem.startswith(t[:4])
for stem in all_known_stems
)
}
return true_positives, false_positives
def _medication_f1(
note: str,
medications: List[Dict],
emar_drug_set: Set[str] = None,
) -> Tuple[float, float]:
if not medications:
return 0.5, 0.0
known_drugs = [m.get("drug", "") for m in medications[:10] if m.get("drug")]
top5_drugs = known_drugs[:5]
tp, fp = _extract_mentioned_drugs(note, known_drugs, emar_drug_set)
recall = len(tp) / len(top5_drugs) if top5_drugs else 0.0
all_detected = len(tp) + len(fp)
precision = len(tp) / all_detected if all_detected > 0 else (1.0 if not tp else 0.0)
f1 = (2 * recall * precision / (recall + precision)) if (recall + precision) > 0 else 0.0
hallucination_rate = len(fp) / all_detected if all_detected > 0 else 0.0
return round(f1, 4), round(hallucination_rate, 4)
# ─── 4. LOS accuracy ──────────────────────────────────────────────────────────
def _los_accuracy(note: str, los_days: float) -> float:
note_lower = note.lower()
los_kws = ["day", "days", "admitted for", "hospital stay", "length of stay",
"los", "hospitalized for", "overnight", "week"]
has_context = any(kw in note_lower for kw in los_kws)
if not has_context:
return 0.0
numbers = [int(n) for n in re.findall(r"\b(\d{1,3})\b", note)]
los_r = round(los_days)
tolerance = max(1, round(los_r * 0.25))
if any(abs(n - los_r) <= tolerance for n in numbers):
return 1.0
return 0.3
# ─── 5. Structure score ───────────────────────────────────────────────────────
_REQUIRED_SECTIONS = [
("diagnosis", ["diagnosis", "diagnos", "presenting", "chief complaint", "admission dx"]),
("course", ["hospital course", "clinical course", "course of", "during admission",
"during hospitalization", "inpatient course"]),
("medications", ["medication", "medicines", "drugs", "prescri", "discharge med"]),
("disposition", ["discharg", "disposition", "transfer", "home", "facility"]),
("followup", ["follow", "follow-up", "appointment", "clinic", "return", "outpatient"]),
("warnings", ["call", "return to", "seek", "emergency", "warning", "symptom",
"chest pain", "shortness of breath", "fever", "worsening"]),
]
def _structure_score(note: str) -> float:
note_lower = note.lower()
sents = _sentences(note)
long_sents = [s.lower() for s in sents if len(s.split()) >= 5]
sections_present = 0
for _name, triggers in _REQUIRED_SECTIONS:
if any(any(t in s for t in triggers) for s in long_sents):
sections_present += 1
word_count = len(note.split())
if word_count < 100:
return 0.0
base = sections_present / len(_REQUIRED_SECTIONS)
import math
length_factor = min(1.0, math.log2(max(1, word_count / 100)) / math.log2(5))
return round(0.70 * base + 0.30 * length_factor, 4)
# ─── 6. Information density ───────────────────────────────────────────────────
def _information_density(note: str) -> float:
words = _words(note)
if not words:
return 0.0
window = 100
ttr_scores = []
for i in range(0, len(words), window):
chunk = words[i:i + window]
if len(chunk) >= 20:
ttr_scores.append(len(set(chunk)) / len(chunk))
mean_ttr = sum(ttr_scores) / len(ttr_scores) if ttr_scores else 0.5
sents = _sentences(note)
sent_tokens = [set(_words(s)) for s in sents if len(_words(s)) >= 5]
duplicate_pairs = 0
total_pairs = 0
for i in range(len(sent_tokens)):
for j in range(i + 1, len(sent_tokens)):
a, b = sent_tokens[i], sent_tokens[j]
union = a | b
if not union:
continue
overlap = len(a & b) / len(union)
total_pairs += 1
if overlap >= 0.70:
duplicate_pairs += 1
repeat_penalty = (duplicate_pairs / total_pairs) if total_pairs > 0 else 0.0
density = max(0.0, mean_ttr - repeat_penalty)
normalised = min(1.0, max(0.0, (density - 0.30) / 0.45))
return round(normalised, 4)
# ─── Grader class ─────────────────────────────────────────────────────────────
class NoteGrader:
"""
v3 grader for Task 3.
Scoring formula:
raw = 0.30Γ—dx + 0.20Γ—dispo + 0.20Γ—med_f1 + 0.15Γ—los + 0.10Γ—structure + 0.05Γ—density
penalty = hallucination_rate Γ— 0.15 (max 0.15 deduction)
followup_penalty = 0.05 if planning finalized but follow-up omitted
final = max(0.0, raw βˆ’ penalty βˆ’ followup_penalty)
"""
def grade(
self,
action: Any,
episode: Dict[str, Any],
) -> Tuple[float, Dict[str, float], bool, Dict[str, Any]]:
if action.task3 is None:
return 0.0, {"error_no_task3": -1.0}, True, {"error": "Action.task3 is missing"}
note = action.task3.discharge_note or ""
diagnoses = episode.get("diagnoses", [])
medications = episode.get("medications", [])
emar_drug_set = episode.get("_emar_drug_set", set())
los_days = float(episode.get("hospital_los_days", 0) or 0)
dx_cov = _diagnosis_coverage(note, diagnoses)
dispo = _disposition_mentioned(note, episode)
med_f1, halluc = _medication_f1(note, medications, emar_drug_set)
los_acc = _los_accuracy(note, los_days)
structure = _structure_score(note)
density = _information_density(note)
halluc_penalty = round(min(0.15, halluc * 0.15), 4)
# Follow-up structure penalty (v3)
followup_penalty = 0.0
do_raw = episode.get("discharge_orders") or {}
if do_raw.get("discharge_planning_finalized", False):
note_lower = note.lower()
if "follow-up" not in note_lower and "follow up" not in note_lower:
followup_penalty = 0.05
raw = (
0.30 * dx_cov
+ 0.20 * dispo
+ 0.20 * med_f1
+ 0.15 * los_acc
+ 0.10 * structure
+ 0.05 * density
)
final = round(
max(0.0, min(1.0, raw - halluc_penalty - followup_penalty)), 4
)
partial = {
"diagnosis_coverage": round(dx_cov, 4),
"disposition_score": round(dispo, 4),
"medication_f1": round(med_f1, 4),
"hallucination_rate": round(halluc, 4),
"hallucination_penalty": halluc_penalty,
"followup_structure_penalty": followup_penalty,
"los_accuracy": round(los_acc, 4),
"structure_score": round(structure, 4),
"information_density": round(density, 4),
}
info = {
"note_word_count": len(note.split()),
"hospital_los_days": round(los_days, 2),
"n_diagnoses": len(diagnoses),
"n_medications": len(medications),
"n_emar_drugs": len(emar_drug_set),
"discharge_location": episode.get("discharge_location", ""),
"discharge_planning_finalized": bool(
do_raw.get("discharge_planning_finalized", False)
),
}
return final, partial, True, info