clinical-nlp-api / src /nlp /icd_mapper.py
Ayodeji Akande
Pin ruff config and fix the resulting lint findings
4dc0836
Raw
History Blame Contribute Delete
30.8 kB
"""
src/nlp/icd_mapper.py
────────────────────────────────────────────────────────────────
ICD-10 code mapping module.
Strategy β€” lookup-first, embedding fallback
────────────────────────────────────────────
This is the industry-standard approach for clinical coding:
Step 1 β€” Exact match
Direct dictionary lookup on the normalised entity text.
Fast and unambiguous. Most common clinical terms hit here.
Step 2 β€” Fuzzy match (rapidfuzz)
Levenshtein-based string matching against ICD-10 descriptions.
Catches spelling variants and minor OCR errors.
Returns a match when score >= ICD10Config.fuzzy_threshold (80).
Step 3 β€” Embedding match (sentence-transformers)
Encode both the entity and all ICD-10 descriptions into dense
vectors, then find the closest by cosine similarity.
Handles genuinely novel phrasing that fuzzy matching misses.
Returns a match when similarity >= ICD10Config.embedding_threshold (0.75).
This step is expensive (~100ms per entity) so it only runs
when steps 1 and 2 produce no confident result.
Step 4 β€” No match
Returns an empty list with a LOW_CONFIDENCE flag so the
caller can decide how to handle unknown entities.
Each step returns the top-k candidates, not just the best match.
This lets the dashboard show alternative codes and lets a human
reviewer choose the most appropriate one.
Confidence scores
─────────────────
Exact match β†’ 1.0
Fuzzy match β†’ fuzzy_score / 100.0
Embedding β†’ cosine similarity score (0.0–1.0)
────────────────────────────────────────────────────────────────
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
import pandas as pd
from src.utils.config import ICD10Config, Paths
from src.utils.logger import get_logger
logger = get_logger(__name__)
def _strip_parens(text: str) -> str:
"""Remove parenthetical qualifiers and collapse whitespace.
ICD-10 descriptions often carry a parenthetical qualifier, e.g.
"Essential (primary) hypertension" β€” about 8% of the ~74k codes.
Without stripping these, common, simply-phrased clinical terms
("essential hypertension") miss the exact match entirely and fall
through to fuzzy matching, which can rank an unrelated same-length
term (e.g. "Neonatal hypertension") above the correct one purely
because it scores closer on string length.
"""
stripped = re.sub(r"\s*\([^)]*\)", "", text)
stripped = re.sub(r"\s{2,}", " ", stripped)
return stripped.strip()
# Words that qualify/modify a clinical term without naming a distinct
# condition. Stripped out before computing "extra word" counts so that
# e.g. "Type 2 diabetes mellitus without complications" and "...with
# other specified complication" are correctly recognised as equally
# generic relative to the query "type 2 diabetes mellitus".
_GENERIC_QUALIFIER_WORDS: frozenset[str] = frozenset({
"unspecified", "specified", "other", "type", "without", "with",
"complication", "complications", "due", "to", "of", "in", "and",
"or", "the", "a", "an", "not", "elsewhere", "classified",
"organism", "agent", "cause", "origin", "site", "side",
})
def _content_words(text: str) -> set[str]:
"""Extract the clinically meaningful words from a description.
Strips generic qualifier words (see :data:`_GENERIC_QUALIFIER_WORDS`)
so that two phrasings of the same underlying condition compare equal
regardless of which specific qualifier each one uses.
"""
words = re.findall(r"[a-z0-9]+", text.lower())
return {w for w in words if w not in _GENERIC_QUALIFIER_WORDS and len(w) > 1}
def _is_unsafe_type_assertion(query: str, description: str) -> bool:
"""True if `description` asserts "type 1"/"type 2" but `query` doesn't.
`_content_words()` deliberately strips single-character tokens, so
"1" and "2" are invisible to the generic-vs-specific preference
logic above β€” "diabetes mellitus", "type 1 diabetes mellitus", and
"type 2 diabetes mellitus" all reduce to the same content-word set.
That means a bare, type-less query is equally "close" to both Type 1
and Type 2 ICD-10 entries, and whichever one a fuzzy/embedding
matcher ranks first is essentially arbitrary β€” asserting the wrong
type is a different diagnosis, not just a less precise one, so this
blocks that specific failure mode rather than relying on string
closeness to pick correctly.
"""
desc_lower = description.lower()
has_type1 = "type 1" in desc_lower or "type i " in desc_lower
has_type2 = "type 2" in desc_lower or "type ii" in desc_lower
if not (has_type1 or has_type2):
return False
query_lower = query.lower()
query_has_type1 = "type 1" in query_lower or "type i " in f"{query_lower} "
query_has_type2 = "type 2" in query_lower or "type ii" in query_lower
if has_type1 and query_has_type1:
return False
return not (has_type2 and query_has_type2)
def _is_external_cause_code(icd10_code: str) -> bool:
"""True if `icd10_code` is an ICD-10 "external causes" code (V00-Y99).
Chapter 20 codes describe HOW an injury happened (a fall, a
collision, an accident) and are only ever valid as a supplementary
code paired with an actual injury diagnosis β€” never a standalone
match for a SYMPTOM or DISEASE entity, which is all this mapper is
ever asked to map (see ``map_entities``). Fuzzy/embedding matching
can still rank one highly off an unrelated word that happens to
appear in its description (e.g. "stairs", from "worse with
stairs", matching "Fall on stairs" β€” fabricating a fall that was
never mentioned).
"""
if not icd10_code:
return False
return icd10_code[0].upper() in "VWXY"
# ── Output dataclass ──────────────────────────────────────────────
@dataclass
class ICD10Match:
"""A single ICD-10 code candidate for an extracted entity.
Attributes:
icd10_code : ICD-10-CM code (e.g. "I10").
description : Human-readable description.
confidence : Match confidence (0.0–1.0).
match_method : How the match was found:
"exact", "fuzzy", "embedding", or "none".
rank : Position in the top-k list (1 = best).
"""
icd10_code: str
description: str
confidence: float
match_method: str
rank: int = 1
def to_dict(self) -> dict:
"""Serialise to a plain dictionary for JSON responses."""
return {
"icd10_code": self.icd10_code,
"description": self.description,
"confidence": round(self.confidence, 3),
"match_method": self.match_method,
"rank": self.rank,
}
class ICD10Mapper:
"""Maps clinical entity text to ICD-10-CM codes.
Loads the ICD-10 reference table once at construction time.
The embedding index is built lazily on first use β€” it takes
a few seconds and is cached in memory for subsequent calls.
Args:
icd10_path : Path to the ICD-10 CSV file.
Defaults to the processed parquet if available,
falls back to the raw CSV.
top_k : Maximum number of candidate codes to return
per entity.
Example::
mapper = ICD10Mapper()
matches = mapper.map("hypertension")
for m in matches:
print(m.icd10_code, m.description, m.confidence)
# β†’ I10 Essential (primary) hypertension 1.0
"""
def __init__(
self,
icd10_path: Path | None = None,
top_k: int = ICD10Config.top_k,
) -> None:
self._top_k = top_k
self._df = self._load_reference_table(icd10_path)
self._lookup, self._stripped_lookup = self._build_lookup(self._df)
# Lowercased once up front β€” fuzz.token_set_ratio is case-sensitive,
# and the query is always lowercased before matching, so comparing
# against original-case descriptions silently degraded every score.
self._descriptions_lower = self._df["description"].str.lower().tolist()
self._embeddings = None # built lazily
self._embed_model = None # loaded lazily
# Set True only after a real load attempt fails (import error,
# or no local cache + no network). False until then β€” covers
# both "never attempted yet" and "loaded successfully".
self._embedding_unavailable = False
logger.info(
"ICD10Mapper ready: %d codes loaded", len(self._df)
)
@property
def embedding_available(self) -> bool:
"""Whether semantic (embedding-based) ICD-10 matching is usable.
``True`` until a real load attempt fails. Exact and fuzzy
matching are unaffected either way β€” this only governs the
fallback used for entity text that doesn't match any ICD-10
description lexically.
"""
return not self._embedding_unavailable
# ── Data loading ──────────────────────────────────────────────
def _load_reference_table(self, path: Path | None) -> pd.DataFrame:
"""Load the ICD-10 reference table from parquet or CSV.
The ETL pipeline saves a parquet copy to data/processed/
which is faster to load. Falls back to the raw CSV if
the processed version is not yet available.
Args:
path: Explicit path override, or None to auto-detect.
Returns:
DataFrame with columns ``icd10_code`` and ``description``.
Raises:
FileNotFoundError: If no ICD-10 file can be found.
"""
# Priority: explicit path > processed parquet > raw CSV
candidates = [
p for p in [
path,
Paths.processed / "icd10_codes.parquet",
Paths.icd10_csv,
]
if p is not None and p.exists()
]
if not candidates:
raise FileNotFoundError(
"ICD-10 reference file not found.\n"
"Run the ETL pipeline first:\n"
" python -m src.etl.pipeline"
)
chosen = candidates[0]
logger.debug("Loading ICD-10 from %s", chosen)
df = (
pd.read_parquet(chosen)
if chosen.suffix == ".parquet"
else pd.read_csv(chosen, dtype=str)
)
# Accept 'code' (from fetch_icd10.py) or 'icd10_code' (processed parquet)
if "code" in df.columns and "icd10_code" not in df.columns:
df = df.rename(columns={"code": "icd10_code"})
# Ensure we have the columns we need
required = {"icd10_code", "description"}
missing = required - set(df.columns)
if missing:
raise ValueError(
f"ICD-10 file is missing columns: {missing}. "
f"Got: {list(df.columns)}"
)
# Normalise: strip whitespace, uppercase codes
df["icd10_code"] = df["icd10_code"].str.strip().str.upper()
df["description"] = df["description"].str.strip()
df = df.dropna(subset=["icd10_code", "description"])
return df.reset_index(drop=True)
def _build_lookup(
self, df: pd.DataFrame
) -> tuple[dict[str, tuple[str, str]], dict[str, tuple[str, str]]]:
"""Build O(1) exact-match dictionaries from the ICD-10 table.
Builds two lookups:
- Primary: lowercase description β†’ (code, original_description).
Also indexes the code itself so "I10" β†’ hypertension works.
- Stripped: same, but with parenthetical qualifiers removed
(see :func:`_strip_parens`). Only populated when stripping
actually changes the key, and on collision keeps the
shortest original description (usually the more general /
primary code).
Args:
df: ICD-10 reference DataFrame.
Returns:
Tuple of ``(primary_lookup, stripped_lookup)``.
"""
lookup = {}
stripped_lookup: dict[str, tuple[str, str]] = {}
for _, row in df.iterrows():
code = row["icd10_code"]
desc = row["description"]
key = desc.lower().strip()
lookup[key] = (code, desc)
# Also index by code for reverse lookups
lookup[code.lower()] = (code, desc)
stripped_key = _strip_parens(key)
if stripped_key and stripped_key != key:
existing = stripped_lookup.get(stripped_key)
if existing is None or len(desc) < len(existing[1]):
stripped_lookup[stripped_key] = (code, desc)
return lookup, stripped_lookup
# ── Mapping methods ───────────────────────────────────────────
def map(self, entity_text: str) -> list[ICD10Match]:
"""Map an entity text string to ICD-10 candidates.
Tries exact β†’ fuzzy β†’ embedding in order, stopping at the
first method that returns a result above its threshold.
Args:
entity_text: Extracted entity text (e.g. "hypertension").
Cleaned text produces better results.
Returns:
List of :class:`ICD10Match` objects ordered by confidence.
Empty list if no match is found above any threshold.
"""
if not entity_text or not entity_text.strip():
return []
normalised = entity_text.lower().strip()
# Step 1: exact match β€” O(1), always try first
exact = self._exact_match(normalised)
exact = [m for m in exact if not _is_external_cause_code(m.icd10_code)]
if exact:
return exact
# Step 2: fuzzy match β€” ~10ms, good for spelling variants
fuzzy = self._fuzzy_match(normalised)
fuzzy = [
m for m in fuzzy
if not _is_unsafe_type_assertion(normalised, m.description)
and not _is_external_cause_code(m.icd10_code)
]
if fuzzy:
return fuzzy
# Step 3: embedding match β€” ~100ms, for novel phrasing
embedding = self._embedding_match(normalised)
embedding = [
m for m in embedding
if not _is_unsafe_type_assertion(normalised, m.description)
and not _is_external_cause_code(m.icd10_code)
]
return embedding
def map_entities(
self, entities: list
) -> dict[str, list[ICD10Match]]:
"""Map a list of Entity objects to ICD-10 candidates.
Convenience wrapper around map() that processes a list
of Entity objects from the NER pipeline.
Args:
entities: List of :class:`src.nlp.ner.Entity` objects.
Returns:
Dict mapping entity text β†’ list of ICD10Match objects.
"""
results = {}
for entity in entities:
# Only map disease and symptom entities β€” medications,
# procedures, and anatomy terms have their own code systems
if entity.label in ("DISEASE", "SYMPTOM"):
matches = self.map(entity.text)
if matches:
results[entity.text] = matches
return results
# ── Match implementations ─────────────────────────────────────
def _exact_match(self, text: str) -> list[ICD10Match]:
"""Direct dictionary lookup for exact description matches.
Tries the primary lookup first, then falls back to matching
against descriptions with parenthetical qualifiers stripped
(see :func:`_strip_parens`) β€” this lets common, simply-phrased
terms like "essential hypertension" match ``I10`` directly
instead of falling through to noisy fuzzy matching.
Args:
text: Lowercase, stripped entity text.
Returns:
List with one match at confidence=1.0, or empty list.
"""
result = self._lookup.get(text) or self._stripped_lookup.get(_strip_parens(text))
if result:
code, description = result
return [ICD10Match(
icd10_code = code,
description = description,
confidence = 1.0,
match_method = "exact",
rank = 1,
)]
return []
def _fuzzy_match(self, text: str) -> list[ICD10Match]:
"""Fuzzy string matching against ICD-10 descriptions.
Uses rapidfuzz's token_set_ratio, which scores a query as a
perfect match against any description that contains all of
the query's words plus extras β€” exactly the "base condition
plus complication/qualifier" pattern ICD-10 uses heavily
(e.g. "Type 2 diabetes mellitus {without complications,
with hyperglycemia, with foot ulcer, ...}"). That produces
many tied top-scoring candidates, so the actual selection
happens via two tiebreakers, applied in order:
1. Fewest "extra" clinically-meaningful words beyond the
query (see :func:`_content_words`) β€” prefers the
general/default code over a specific complication when
the query didn't mention one.
2. Shortest description β€” a final tiebreak between
phrasings that reduce to the same extra-word count
(e.g. "without complications" vs "with other specified
complication" both strip to zero extra words).
Without tiebreaker 1, "essential hypertension" or "unspecified
pneumonia" rank an unrelated same-length term above the
correct code purely because it scores closer on raw string
similarity. Without tiebreaker 2, "type 2 diabetes mellitus"
picks an arbitrary complication code instead of E11.9.
A full scan over all descriptions is used rather than a small
top-N candidate pool β€” ties at the top score can number in
the dozens (86 for the diabetes example above), and a small
pool risks truncating before reaching the correct candidate.
rapidfuzz is fast enough that this costs well under a second
even across 74k descriptions.
Args:
text: Lowercase, stripped entity text.
Returns:
Top-k matches above the fuzzy threshold, or empty list
(callers should fall through to the embedding match).
"""
try:
from rapidfuzz import fuzz, process
except ImportError:
logger.warning(
"rapidfuzz not installed β€” fuzzy matching disabled. "
"Run: pip install rapidfuzz"
)
return []
query_words = _content_words(text)
candidates = process.extract(
text,
self._descriptions_lower,
scorer = fuzz.token_set_ratio,
limit = len(self._descriptions_lower),
)
qualifying = []
for desc, score, idx in candidates:
if score < ICD10Config.fuzzy_threshold:
break # sorted descending β€” nothing further clears the bar
desc_words = _content_words(desc)
if query_words and not query_words.issubset(desc_words):
continue # candidate doesn't actually contain the query's terms
extra = len(desc_words - query_words)
qualifying.append((extra, len(desc), -score, idx, score))
if not qualifying:
return []
qualifying.sort()
# Only auto-resolve when the best candidate is a true generic/
# default equivalent (zero extra clinically-meaningful words).
# If even the closest candidate still requires adding a genuinely
# distinguishing word (e.g. bare "hypertension" only ever appears
# alongside "portal", "renal", "essential" etc. β€” ICD-10 has no
# plain "Hypertension, unspecified" code), guessing one of those
# specific variants would silently produce a wrong diagnosis code.
# Defer to the embedding step instead, same as if fuzzy found
# nothing at all.
if qualifying[0][0] != 0:
return []
matches = []
for _extra, _len, _neg_score, idx, score in qualifying[: self._top_k]:
row = self._df.iloc[idx]
matches.append(ICD10Match(
icd10_code = row["icd10_code"],
description = row["description"],
confidence = score / 100.0,
match_method = "fuzzy",
rank = len(matches) + 1,
))
return matches
def _embedding_cache_path(self) -> Path:
"""Return the on-disk cache path for the ICD-10 embedding index."""
return Paths.processed / "icd10_embeddings.pt"
def _load_or_build_embedding_index(self):
"""Load the cached ICD-10 embedding index from disk, or build it.
Encoding 74k descriptions through BERT on CPU takes 15-40+
minutes, so the result is cached to disk and only rebuilt if
the ICD-10 table size changes (e.g. a new CMS release).
Returns:
A tensor of shape (n_descriptions, embedding_dim).
"""
import torch
cache_path = self._embedding_cache_path()
if cache_path.exists():
cached = torch.load(cache_path)
if cached.shape[0] == len(self._df):
logger.info(
"Loaded cached ICD-10 embedding index from %s (%d vectors)",
cache_path, cached.shape[0],
)
return cached
logger.info(
"Cached embedding index size (%d) does not match current "
"ICD-10 table (%d) β€” rebuilding.",
cached.shape[0], len(self._df),
)
# Try downloading a precomputed copy from HF Hub before resorting
# to a 15-40+ minute CPU rebuild β€” e.g. a fresh deployment
# container that doesn't bundle data/processed/ locally (the
# file is too large to commit to git).
from src.utils.config import ModelConfig
hub_repo = ModelConfig.icd10_embeddings_hf_hub_fallback
if hub_repo:
try:
from huggingface_hub import hf_hub_download
logger.info(
"No local embedding cache β€” downloading precomputed "
"index from HF Hub (%s) instead of rebuilding from "
"scratch.", hub_repo,
)
downloaded_path = hf_hub_download(
repo_id = hub_repo,
repo_type = "dataset",
filename = "icd10_embeddings.pt",
)
cached = torch.load(downloaded_path)
if cached.shape[0] == len(self._df):
cache_path.parent.mkdir(parents=True, exist_ok=True)
torch.save(cached, cache_path)
logger.info(
"Loaded embedding index from HF Hub (%d vectors), "
"cached locally to %s", cached.shape[0], cache_path,
)
return cached
logger.warning(
"HF Hub embedding index size (%d) does not match "
"current ICD-10 table (%d) β€” rebuilding instead.",
cached.shape[0], len(self._df),
)
except Exception as exc:
logger.warning(
"Could not download embedding index from HF Hub "
"(%s) β€” rebuilding from scratch instead.", exc,
)
logger.info(
"Building ICD-10 embedding index (%d descriptions) β€” "
"one-time cost on CPU, can take 15-40+ minutes. "
"Progress bar below; result will be cached to disk.",
len(self._df),
)
embeddings = self._embed_model.encode(
self._df["description"].tolist(),
batch_size = 128,
show_progress_bar = True,
convert_to_tensor = True,
)
cache_path.parent.mkdir(parents=True, exist_ok=True)
torch.save(embeddings, cache_path)
logger.info("Embedding index ready and cached to %s βœ“", cache_path)
return embeddings
def _embedding_match(self, text: str) -> list[ICD10Match]:
"""Semantic similarity matching using sentence embeddings.
Encodes the entity text and all ICD-10 descriptions into
dense vectors, then finds the closest descriptions by
cosine similarity.
The embedding index is built once and cached both in memory
and on disk (see :meth:`_load_or_build_embedding_index`).
First call ever takes 15-40+ minutes; every call after that
(including in future notebook sessions) loads from disk in
under a second.
Args:
text: Lowercase, stripped entity text.
Returns:
Top-k matches above the embedding threshold, or empty list.
"""
# Once a real attempt has failed, don't keep retrying a known-
# broken import/load on every single call β€” fail fast instead.
if self._embedding_unavailable:
return []
try:
from sentence_transformers import SentenceTransformer, util
# Load the embedding model once and cache it.
# Try the local cache first β€” sentence-transformers otherwise
# always does a handful of network round-trips (HEAD requests
# for adapter_config.json etc.) even when the model is fully
# cached, which crashes the whole pipeline on any transient
# network blip. Only fall back to a live download if nothing
# is cached yet.
if self._embed_model is None:
from src.utils.config import ModelConfig, force_offline_hf_env
model_name = ModelConfig.embedding_model
try:
with force_offline_hf_env():
self._embed_model = SentenceTransformer(
model_name, local_files_only=True
)
logger.info(
"Loaded embedding model from local cache (offline): %s",
model_name,
)
except Exception:
logger.info(
"Embedding model not found in local cache β€” "
"downloading from Hugging Face Hub: %s (first call only)",
model_name,
)
self._embed_model = SentenceTransformer(model_name)
# Build (or load from disk cache) the ICD-10 embedding index.
# The cache is saved from whatever device originally built it
# (often CPU) -- move it onto the embedding model's device so
# cos_sim() below never sees a CPU/CUDA tensor mismatch.
if self._embeddings is None:
self._embeddings = self._load_or_build_embedding_index()
self._embeddings = self._embeddings.to(self._embed_model.device)
query_embedding = self._embed_model.encode(
text, convert_to_tensor=True
)
scores = util.cos_sim(query_embedding, self._embeddings)[0]
top_indices = scores.argsort(descending=True)[: self._top_k * 2]
matches = []
for idx in top_indices:
score = float(scores[idx])
if score < ICD10Config.embedding_threshold:
break
row = self._df.iloc[int(idx)]
matches.append(ICD10Match(
icd10_code = row["icd10_code"],
description = row["description"],
confidence = score,
match_method = "embedding",
rank = len(matches) + 1,
))
if len(matches) >= self._top_k:
break
return matches
except ImportError:
self._embedding_unavailable = True
logger.warning(
"sentence-transformers not installed β€” "
"embedding fallback disabled. "
"Run: pip install sentence-transformers"
)
return []
except Exception as exc:
# Never let an embedding-fallback failure crash the whole
# /notes/analyse request β€” exact/fuzzy matching still works.
# Mark unavailable so we don't keep retrying a known-broken
# load (e.g. resource contention during import) on every call.
self._embedding_unavailable = True
logger.warning(
"Embedding fallback failed and is now disabled for this "
"process (%s: %s). Exact/fuzzy ICD-10 matching is "
"unaffected; restart the process to retry.",
type(exc).__name__, exc,
)
return []
# ── Utility ───────────────────────────────────────────────────
def describe(self, icd10_code: str) -> str | None:
"""Return the description for an ICD-10 code.
Args:
icd10_code: ICD-10-CM code (case-insensitive).
Returns:
Description string, or None if the code is not found.
"""
result = self._lookup.get(icd10_code.upper().strip())
if result:
return result[1]
# Try the lower-case key
result = self._lookup.get(icd10_code.lower().strip())
return result[1] if result else None