Ruurd's picture
Deploy OpenAI classification and protected terminology streaming
c2b7494 verified
Raw History Blame Contribute Delete
4.43 kB
"""BM25 lexical retrieval over code descriptions."""
from __future__ import annotations
import re
from dataclasses import dataclass
import numpy as np
import pandas as pd
from rank_bm25 import BM25Okapi
def _tokenize(text: str) -> list[str]:
"""Simple whitespace + punctuation tokenizer for BM25 indexing."""
if not text or not isinstance(text, str):
return []
text = re.sub(r"[^\w\s]", " ", text.lower())
return [tok for tok in text.split() if len(tok) > 1]
@dataclass(slots=True)
class LexicalCandidate:
rank: int
code: str
description: str
vocabulary: str
score: float
def __lt__(self, other: object) -> bool:
if not isinstance(other, LexicalCandidate):
return NotImplemented
return self.score > other.score # higher = more relevant
def build_lexical_index(codes: pd.DataFrame) -> BM25Okapi:
"""Build a BM25 index from the code corpus descriptions.
Parameters
----------
codes : pd.DataFrame
Must contain ``description`` column with full description strings.
Returns
-------
BM25Okapi
The built tokenised index.
"""
doc_tokens: list[list[str]] = []
for desc in codes["description"].astype(str):
tokens = _tokenize(desc)
doc_tokens.append(tokens if tokens else [desc])
return BM25Okapi(doc_tokens)
def score_lexical(query: str, index: BM25Okapi, corpus_size: int) -> list[float]:
"""Return corpus-order BM25 scores without allocating result objects."""
query_tokens = _tokenize(query)
if not query_tokens:
return [0.0] * corpus_size
return [round(float(score), 6) for score in index.get_scores(query_tokens)]
def retrieve_lexical(
query: str,
codes: pd.DataFrame | list[dict],
index: BM25Okapi | None = None,
top_k: int | None = None,
) -> list[LexicalCandidate]:
"""Retrieve candidate codes ranked by BM25 lexical similarity.
Parameters
----------
query : str
The phenotype description / EDF summary to search against.
codes : pd.DataFrame or list[dict]
Code corpus containing ``description`` (mandatory) and optionally
``code``, ``vocabulary`` columns (DataFrame) or dicts with those keys.
index : BM25Okapi, optional
Pre-built index. If omitted one is built on-the-fly from *codes*.
top_k : int, optional
Maximum number of results to return. Defaults to the full corpus size.
Returns
-------
list[LexicalCandidate]
Ranked candidates sorted descending by BM25 score.
"""
if isinstance(codes, pd.DataFrame):
df = codes.copy()
keys = ("description", "code", "vocabulary")
for k in keys[1:]:
if k not in df.columns:
df[k] = "" # type: ignore[operator]
else:
df = pd.DataFrame(codes)
for k in ("code", "vocabulary"):
if k not in df.columns:
df[k] = ""
if index is None:
index = build_lexical_index(df)
query_tokens = _tokenize(query)
if not query_tokens:
# Fallback: rank all codes uniformly (no lexical signal)
return [
LexicalCandidate(
rank=0, code=r["code"], description=r["description"], vocabulary=r["vocabulary"], score=0.0
)
for i, r in df.iterrows()
]
scores = index.get_scores(query_tokens).tolist() # type: ignore[union-attr]
if top_k is not None:
top_indices = np.argpartition(scores, -top_k)[-top_k:]
top_scores = [scores[i] for i in sorted(top_indices, key=lambda x: -scores[x])]
results: list[LexicalCandidate] = [
LexicalCandidate(
rank=r + 1,
code=df.iloc[idx]["code"],
description=df.iloc[idx]["description"],
vocabulary=df.iloc[idx].get("vocabulary", ""),
score=round(float(s), 6),
)
for r, (idx, s) in enumerate(sorted(enumerate(top_scores), key=lambda x: -x[1]))
]
else:
results = [
LexicalCandidate(
rank=r + 1,
code=df.iloc[r]["code"],
description=df.iloc[r]["description"],
vocabulary=df.iloc[r].get("vocabulary", ""),
score=round(float(scores[r]), 6),
)
for r in range(len(df))
]
return results