| """ |
| Hierarchical TF-IDF Retriever for ICD-10 candidate generation. |
| |
| Three-stage retrieval: |
| 1. Chapter-level scoring (22 chapters β top-5) |
| 2. Category-level scoring (within selected chapters β top-20) |
| 3. Full-code scoring (within selected categories β top-K) |
| """ |
| import numpy as np |
| import pandas as pd |
| from sklearn.feature_extraction.text import TfidfVectorizer |
| from sklearn.metrics.pairwise import cosine_similarity |
|
|
| from utils.config import ICD10_CHAPTERS |
| from utils.preprocessing import get_icd_chapter, get_icd_category |
|
|
|
|
| class HierarchicalTFIDFRetriever: |
| """Three-level hierarchical ICD-10 code retriever using TF-IDF.""" |
|
|
| def __init__(self): |
| self.icd_metadata_df = None |
| self.chapter_tfidf = TfidfVectorizer(max_features=10000, sublinear_tf=True, ngram_range=(1, 2)) |
| self.category_tfidf = TfidfVectorizer(max_features=20000, sublinear_tf=True, ngram_range=(1, 2)) |
| self.code_tfidf = TfidfVectorizer(max_features=30000, sublinear_tf=True, ngram_range=(1, 2)) |
| self.chapter_vectors = None |
| self.category_vectors = None |
| self.code_vectors = None |
| self.chapter_df = None |
| self.category_df = None |
| self._fitted = False |
|
|
| def fit(self, bilingual_lookup: dict): |
| """Build the retriever from the bilingual lookup dictionary.""" |
| rows = [] |
| for code, info in bilingual_lookup.items(): |
| eng = info.get("english", "") |
| chi = info.get("chinese", "") |
| chapter_letter = get_icd_chapter(code) |
| chapter = ICD10_CHAPTERS.get(chapter_letter, "Unknown") |
| category = get_icd_category(code) |
| combined_desc = f"{eng} {chi}".strip() |
| rows.append({ |
| "code": code, |
| "chapter": chapter, |
| "category": category, |
| "english_description": eng, |
| "chinese_description": chi, |
| "combined_description": combined_desc, |
| }) |
|
|
| self.icd_metadata_df = pd.DataFrame(rows) |
|
|
| |
| self.chapter_df = ( |
| self.icd_metadata_df |
| .groupby("chapter")["combined_description"] |
| .apply(lambda x: " ".join(x)) |
| .reset_index() |
| ) |
| self.chapter_vectors = self.chapter_tfidf.fit_transform(self.chapter_df["combined_description"]) |
|
|
| |
| self.category_df = ( |
| self.icd_metadata_df |
| .groupby("category")["combined_description"] |
| .apply(lambda x: " ".join(x)) |
| .reset_index() |
| ) |
| self.category_vectors = self.category_tfidf.fit_transform(self.category_df["combined_description"]) |
|
|
| |
| self.code_vectors = self.code_tfidf.fit_transform(self.icd_metadata_df["combined_description"]) |
|
|
| self._fitted = True |
| return self |
|
|
| def retrieve(self, note_text: str, top_k: int = 100, |
| top_chapters: int = 5, top_categories: int = 20) -> list: |
| """ |
| Retrieve top-K ICD-10 candidate codes for a clinical note. |
| |
| Returns: list of (code, score) tuples sorted by relevance. |
| """ |
| if not self._fitted: |
| raise RuntimeError("Retriever not fitted. Call .fit() first.") |
|
|
| |
| note_chapter_vec = self.chapter_tfidf.transform([note_text]) |
| chapter_scores = cosine_similarity(note_chapter_vec, self.chapter_vectors).flatten() |
| top_chapter_idx = np.argsort(chapter_scores)[::-1][:top_chapters] |
| selected_chapters = set(self.chapter_df.iloc[top_chapter_idx]["chapter"].tolist()) |
|
|
| |
| chapter_to_cats = ( |
| self.icd_metadata_df[self.icd_metadata_df["chapter"].isin(selected_chapters)] |
| ["category"].unique() |
| ) |
| cat_mask = self.category_df["category"].isin(chapter_to_cats) |
| cat_indices = self.category_df[cat_mask].index.tolist() |
|
|
| if not cat_indices: |
| cat_indices = list(range(len(self.category_df))) |
|
|
| note_cat_vec = self.category_tfidf.transform([note_text]) |
| cat_scores = cosine_similarity(note_cat_vec, self.category_vectors[cat_indices]).flatten() |
| top_cat_local = np.argsort(cat_scores)[::-1][:top_categories] |
| top_cat_idx = [cat_indices[i] for i in top_cat_local] |
| selected_categories = set(self.category_df.iloc[top_cat_idx]["category"].tolist()) |
|
|
| |
| code_mask = self.icd_metadata_df["category"].isin(selected_categories) |
| code_indices = self.icd_metadata_df[code_mask].index.tolist() |
|
|
| if not code_indices: |
| code_indices = list(range(len(self.icd_metadata_df))) |
|
|
| note_code_vec = self.code_tfidf.transform([note_text]) |
| code_scores = cosine_similarity(note_code_vec, self.code_vectors[code_indices]).flatten() |
| top_code_local = np.argsort(code_scores)[::-1][:top_k] |
|
|
| results = [] |
| for i in top_code_local: |
| global_idx = code_indices[i] |
| row = self.icd_metadata_df.iloc[global_idx] |
| results.append((row["code"], float(code_scores[i]))) |
|
|
| return results |
|
|
| @property |
| def num_codes(self): |
| return len(self.icd_metadata_df) if self.icd_metadata_df is not None else 0 |
|
|