Spaces:
Sleeping
Sleeping
Deploy OpenAI classification and protected terminology streaming
Browse files- Dockerfile +9 -0
- README.md +5 -6
- backend/app.py +180 -0
- backend/requirements.txt +9 -0
- src/__init__.py +0 -0
- src/data/__init__.py +0 -0
- src/data/code_systems.py +253 -0
- src/data/loaders.py +181 -0
- src/data/preprocessing.py +79 -0
- src/evaluation/__init__.py +1 -0
- src/evaluation/metrics.py +510 -0
- src/llm/__init__.py +1 -0
- src/llm/classify.py +531 -0
- src/llm/client.py +376 -0
- src/llm/decisions.py +111 -0
- src/llm/jpt_runtime.py +111 -0
- src/llm/local.py +147 -0
- src/llm/model_paths.py +28 -0
- src/llm/prompts.py +79 -0
- src/llm/rank.py +139 -0
- src/mapping/__init__.py +1 -0
- src/mapping/codemapper.py +295 -0
- src/retrieval/__init__.py +1 -0
- src/retrieval/embeddings.py +548 -0
- src/retrieval/hybrid.py +203 -0
- src/retrieval/lexical.py +141 -0
- src/uncertainty/__init__.py +1 -0
- src/uncertainty/selection.py +76 -0
- src/utils/__init__.py +0 -0
- src/utils/config.py +135 -0
- src/utils/logging.py +32 -0
Dockerfile
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY backend/requirements.txt /app/requirements.txt
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY src /app/src
|
| 6 |
+
COPY backend /app/backend
|
| 7 |
+
ENV PORT=7860
|
| 8 |
+
EXPOSE 7860
|
| 9 |
+
CMD ["sh", "-c", "exec uvicorn backend.app:app --host 0.0.0.0 --port ${PORT}"]
|
README.md
CHANGED
|
@@ -1,10 +1,9 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 🚀
|
| 4 |
-
colorFrom: green
|
| 5 |
-
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Clinical Codelist API
|
|
|
|
|
|
|
|
|
|
| 3 |
sdk: docker
|
| 4 |
+
app_port: 7860
|
| 5 |
---
|
| 6 |
|
| 7 |
+
Token-protected classification and GPT review through OpenAI, plus streaming access to a private terminology dataset.
|
| 8 |
+
|
| 9 |
+
Configure OPENAI_API_KEY, PIPELINE_API_TOKEN and HF_TOKEN as Space secrets. Configure PIPELINE_HF_DATASET and PIPELINE_ALLOWED_ORIGINS as variables. No terminology vectors are stored in the Space.
|
backend/app.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small classification API for the static browser app. No embedding dependencies.
|
| 2 |
+
|
| 3 |
+
Run: python -m uvicorn backend.app:app --host 127.0.0.1 --port 8000
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
from dataclasses import asdict, fields
|
| 8 |
+
import hmac
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
import math
|
| 12 |
+
import os
|
| 13 |
+
import re
|
| 14 |
+
from typing import Annotated, Literal
|
| 15 |
+
|
| 16 |
+
import httpx
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
from fastapi import Depends, FastAPI, Header, HTTPException
|
| 19 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 20 |
+
from fastapi.responses import StreamingResponse
|
| 21 |
+
from starlette.background import BackgroundTask
|
| 22 |
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 23 |
+
|
| 24 |
+
from src.data.loaders import EventDefinitionForm
|
| 25 |
+
from src.llm.classify import llm_classify
|
| 26 |
+
from src.llm.client import LLMCallError, default_model_for
|
| 27 |
+
|
| 28 |
+
load_dotenv()
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
app = FastAPI(title="Semantic Harmonization classification API", docs_url=None, redoc_url=None)
|
| 31 |
+
app.add_middleware(
|
| 32 |
+
CORSMiddleware,
|
| 33 |
+
allow_origins=[origin.strip() for origin in os.getenv(
|
| 34 |
+
"PIPELINE_ALLOWED_ORIGINS", "http://127.0.0.1:5173,http://localhost:5173,https://ruurdkuiper.github.io"
|
| 35 |
+
).split(",") if origin.strip()],
|
| 36 |
+
allow_methods=["GET", "POST"], allow_headers=["Authorization", "Content-Type"],
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def authorize(authorization: Annotated[str | None, Header()] = None):
|
| 41 |
+
token = os.getenv("PIPELINE_API_TOKEN", "")
|
| 42 |
+
if not token:
|
| 43 |
+
raise HTTPException(503, "The classification service needs an access token configured by its administrator.")
|
| 44 |
+
supplied = (authorization or "").removeprefix("Bearer ")
|
| 45 |
+
if not hmac.compare_digest(supplied.encode(), token.encode()):
|
| 46 |
+
raise HTTPException(401, "Enter a valid classification service access token.")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class Candidate(BaseModel):
|
| 50 |
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
| 51 |
+
code: str = Field(min_length=1, max_length=128)
|
| 52 |
+
vocabulary: Literal["ICD10CM", "ICD9CM", "ICPC", "MDR", "RCD2", "SNOMEDCT_US"]
|
| 53 |
+
description: str = Field(min_length=1, max_length=4096)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class Batch(BaseModel):
|
| 57 |
+
model_config = ConfigDict(extra="forbid")
|
| 58 |
+
edf: dict
|
| 59 |
+
candidates: list[Candidate] = Field(min_length=1, max_length=20)
|
| 60 |
+
use_possible_category: bool = False
|
| 61 |
+
|
| 62 |
+
@model_validator(mode="after")
|
| 63 |
+
def validate_edf(self):
|
| 64 |
+
allowed = {field.name: field for field in fields(EventDefinitionForm) if field.name != "name"}
|
| 65 |
+
if not set(self.edf) <= set(allowed):
|
| 66 |
+
raise ValueError("Unknown EDF field")
|
| 67 |
+
if len(json.dumps(self.edf)) > 65_536:
|
| 68 |
+
raise ValueError("EDF is too large")
|
| 69 |
+
for key, value in self.edf.items():
|
| 70 |
+
# Accept cleared YAML fields, matching the browser's normalization.
|
| 71 |
+
is_list = key in {"inclusion_criteria", "exclusion_criteria", "narrow_decision_rules", "possible_decision_rules",
|
| 72 |
+
"exclude_decision_rules", "synonyms", "candidate_anchor_codes", "ambiguities", "references"}
|
| 73 |
+
if value is None:
|
| 74 |
+
self.edf[key] = [] if is_list else ""
|
| 75 |
+
elif is_list and (not isinstance(value, list) or any(not isinstance(item, str) for item in value)):
|
| 76 |
+
raise ValueError(f"{key} must be a list of text values")
|
| 77 |
+
elif not is_list and not isinstance(value, str):
|
| 78 |
+
raise ValueError(f"{key} must be text")
|
| 79 |
+
if not self.edf.get("preferred_name", "").strip():
|
| 80 |
+
raise ValueError("preferred_name is required")
|
| 81 |
+
if len({(row.code, row.vocabulary) for row in self.candidates}) != len(self.candidates):
|
| 82 |
+
raise ValueError("Duplicate candidates in batch")
|
| 83 |
+
return self
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
@app.get("/health")
|
| 87 |
+
def health():
|
| 88 |
+
return {"status": "ok", "retrieval": "browser-minilm"}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
_ASSET_PATH = re.compile(
|
| 92 |
+
r"(?:manifest\.json|(?:ICD10CM|ICD9CM|ICPC|MDR|RCD2|SNOMEDCT_US)-[0-9a-f]{20}/"
|
| 93 |
+
r"(?:lexical\.json\.gz|[0-9]{7}\.(?:json\.gz|f32)))\Z"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@app.get("/api/assets/{asset_path:path}", dependencies=[Depends(authorize)])
|
| 98 |
+
def terminology_asset(asset_path: str):
|
| 99 |
+
"""Stream private HF dataset assets to authorized browsers without storing them."""
|
| 100 |
+
if not _ASSET_PATH.fullmatch(asset_path):
|
| 101 |
+
raise HTTPException(404, "Unknown terminology asset.")
|
| 102 |
+
repo = os.getenv("PIPELINE_HF_DATASET", "")
|
| 103 |
+
revision = os.getenv("PIPELINE_HF_REVISION", "main")
|
| 104 |
+
if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo) or not re.fullmatch(r"[A-Za-z0-9_-]+", revision):
|
| 105 |
+
raise HTTPException(503, "The Hugging Face terminology dataset is not configured.")
|
| 106 |
+
if not os.getenv("HF_TOKEN"):
|
| 107 |
+
raise HTTPException(503, "Private terminology downloads need a read-only HF_TOKEN configured in the Space secrets.")
|
| 108 |
+
headers = {"Accept-Encoding": "identity"}
|
| 109 |
+
if os.getenv("HF_TOKEN"):
|
| 110 |
+
headers["Authorization"] = "Bearer " + os.environ["HF_TOKEN"]
|
| 111 |
+
# HTTPX strips Authorization on cross-origin CDN redirects. Neither the HF
|
| 112 |
+
# token nor provider keys are forwarded to the browser.
|
| 113 |
+
client = httpx.Client(timeout=60, follow_redirects=True)
|
| 114 |
+
try:
|
| 115 |
+
response = client.send(client.build_request("GET",
|
| 116 |
+
f"https://huggingface.co/datasets/{repo}/resolve/{revision}/{asset_path}", headers=headers), stream=True)
|
| 117 |
+
except httpx.HTTPError:
|
| 118 |
+
client.close()
|
| 119 |
+
raise HTTPException(502, "The terminology download could not be reached. Please retry.") from None
|
| 120 |
+
if response.status_code != 200:
|
| 121 |
+
status = response.status_code
|
| 122 |
+
response.close()
|
| 123 |
+
client.close()
|
| 124 |
+
raise HTTPException(404 if status == 404 else 502,
|
| 125 |
+
"The terminology asset is unavailable. The administrator should check the dataset access settings.")
|
| 126 |
+
|
| 127 |
+
def close():
|
| 128 |
+
response.close()
|
| 129 |
+
client.close()
|
| 130 |
+
|
| 131 |
+
def chunks():
|
| 132 |
+
try:
|
| 133 |
+
yield from response.iter_raw(chunk_size=65536)
|
| 134 |
+
finally:
|
| 135 |
+
close()
|
| 136 |
+
|
| 137 |
+
# Private terminology data must not be cached by shared HTTP proxies.
|
| 138 |
+
# The authorized browser maintains its own content-addressed IndexedDB cache.
|
| 139 |
+
return StreamingResponse(chunks(), media_type="application/json" if asset_path == "manifest.json" else "application/octet-stream",
|
| 140 |
+
headers={"Cache-Control": "private, no-store", "X-Content-Type-Options": "nosniff"},
|
| 141 |
+
background=BackgroundTask(close))
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def classify_batch(batch: Batch, review: bool):
|
| 145 |
+
provider = "openai" if review else os.getenv("PIPELINE_CLASSIFIER_PROVIDER", "openai")
|
| 146 |
+
if provider not in {"jev", "openai", "anthropic", "google"}:
|
| 147 |
+
raise HTTPException(503, "The classification provider is not configured correctly.")
|
| 148 |
+
model = os.getenv("PIPELINE_REVIEW_MODEL" if review else "PIPELINE_CLASSIFIER_MODEL") or default_model_for(provider)
|
| 149 |
+
try:
|
| 150 |
+
records = llm_classify(
|
| 151 |
+
batch.candidates, EventDefinitionForm(name="browser-edf", **batch.edf),
|
| 152 |
+
provider=provider, model=model, max_retries=3, batch_size=10 if review else 20,
|
| 153 |
+
use_possible_category=batch.use_possible_category, reasoning_effort="medium", adaptive_stopping=None,
|
| 154 |
+
)
|
| 155 |
+
except (LLMCallError, ValueError, RuntimeError):
|
| 156 |
+
logger.exception("Classification request failed")
|
| 157 |
+
# Provider responses may contain sensitive diagnostic text. Keep it server-side.
|
| 158 |
+
raise HTTPException(502, "The model request failed. Retry the batch or ask the service administrator to check its configuration.") from None
|
| 159 |
+
expected = {(row.code, row.vocabulary) for row in batch.candidates}
|
| 160 |
+
labels = {"Narrow", "Exclude", "Possible"} if batch.use_possible_category else {"Narrow", "Exclude"}
|
| 161 |
+
keys = [(row.code, row.vocabulary) for row in records]
|
| 162 |
+
if len(keys) != len(expected) or set(keys) != expected or any(
|
| 163 |
+
row.label not in labels or not math.isfinite(row.confidence) or not 0 <= row.confidence <= 1
|
| 164 |
+
or row.explanation == "No classification returned by LLM; routed for manual review."
|
| 165 |
+
for row in records
|
| 166 |
+
):
|
| 167 |
+
raise HTTPException(502, "The model returned an incomplete or invalid batch. Retry to continue.")
|
| 168 |
+
return {"decisions": [asdict(row) for row in records], "provider": provider, "model": model}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
@app.post("/api/classify", dependencies=[Depends(authorize)])
|
| 172 |
+
def classify(batch: Batch):
|
| 173 |
+
return classify_batch(batch, review=False)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@app.post("/api/review", dependencies=[Depends(authorize)])
|
| 177 |
+
def review(batch: Batch):
|
| 178 |
+
if len(batch.candidates) > 10:
|
| 179 |
+
raise HTTPException(422, "Review accepts at most ten candidates per batch.")
|
| 180 |
+
return classify_batch(batch, review=True)
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115,<1
|
| 2 |
+
uvicorn>=0.30,<1
|
| 3 |
+
openai>=1.50,<3
|
| 4 |
+
anthropic>=0.40,<1
|
| 5 |
+
google-genai>=1,<3
|
| 6 |
+
pandas>=2,<4
|
| 7 |
+
pyyaml>=6,<7
|
| 8 |
+
python-dotenv>=1,<2
|
| 9 |
+
httpx>=0.27,<1
|
src/__init__.py
ADDED
|
File without changes
|
src/data/__init__.py
ADDED
|
File without changes
|
src/data/code_systems.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Loaders for full clinical coding-system reference lists (ICD10, SNOMED CT,
|
| 2 |
+
MedDRA, ...), used as the retrieval universe for concept retrieval.
|
| 3 |
+
|
| 4 |
+
Unlike the phenotype-specific AESI review exports (`load_aesi_dataset`), these
|
| 5 |
+
represent the *complete* set of codes in a vocabulary, independent of any
|
| 6 |
+
phenotype. Since they can be large (tens of thousands of rows) and slow to
|
| 7 |
+
load/parse from their native format, the processed corpus for each vocabulary
|
| 8 |
+
is cached to disk as Parquet so the source file only needs to be parsed once.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import csv
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
import pandas as pd
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
DEFAULT_CODE_SYSTEM_PATHS = {
|
| 20 |
+
"ICD10CM": "data/codes/csv/ICD10CM@2026-codes.csv",
|
| 21 |
+
"ICD9CM": "data/codes/csv/ICD9CM@2014-codes.csv",
|
| 22 |
+
"ICPC": "data/codes/csv/ICPC@1993-codes.csv",
|
| 23 |
+
"MDR": "data/codes/csv/MDR@28.0-codes.csv",
|
| 24 |
+
"RCD2": "data/codes/csv/RCD2@20200401-codes.csv",
|
| 25 |
+
"SNOMEDCT_US": "data/codes/csv/SNOMEDCT_US@2025_09_01-codes definitive.csv",
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _load_codelist_csv(
|
| 30 |
+
path: str | Path,
|
| 31 |
+
*,
|
| 32 |
+
vocabulary: str,
|
| 33 |
+
delimiter: str = ",",
|
| 34 |
+
) -> pd.DataFrame:
|
| 35 |
+
"""Load a code list CSV into the canonical retrieval schema.
|
| 36 |
+
|
| 37 |
+
The source files in ``data/codes`` are plain text CSV exports, but the
|
| 38 |
+
description field may contain commas wrapped in quotes. We therefore rely
|
| 39 |
+
on the CSV parser rather than manual splitting so those commas stay inside
|
| 40 |
+
the description instead of being treated as extra columns.
|
| 41 |
+
"""
|
| 42 |
+
path = Path(path)
|
| 43 |
+
if not path.exists():
|
| 44 |
+
raise FileNotFoundError(f"Full {vocabulary} code list not found: {path}")
|
| 45 |
+
|
| 46 |
+
raw = pd.read_csv(
|
| 47 |
+
path,
|
| 48 |
+
dtype=str,
|
| 49 |
+
sep=delimiter,
|
| 50 |
+
engine="python",
|
| 51 |
+
quotechar='"',
|
| 52 |
+
escapechar="\\",
|
| 53 |
+
on_bad_lines="skip",
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
if raw.empty:
|
| 57 |
+
raise ValueError(f"No rows loaded from {path}")
|
| 58 |
+
|
| 59 |
+
# Normalize the first two columns into code/description regardless of the
|
| 60 |
+
# exact header names used in the export.
|
| 61 |
+
columns = list(raw.columns)
|
| 62 |
+
if len(columns) < 2:
|
| 63 |
+
raise ValueError(f"Unexpected {vocabulary} CSV schema in {path}: columns={columns}")
|
| 64 |
+
|
| 65 |
+
code_col = columns[0]
|
| 66 |
+
desc_col = columns[1]
|
| 67 |
+
df = raw[[code_col, desc_col]].rename(columns={code_col: "code", desc_col: "description"})
|
| 68 |
+
df = df.dropna(subset=["code", "description"]).copy()
|
| 69 |
+
df["code"] = df["code"].astype(str).str.strip().str.strip('"')
|
| 70 |
+
df["description"] = df["description"].astype(str).str.strip().str.strip('"')
|
| 71 |
+
df["vocabulary"] = vocabulary
|
| 72 |
+
df = df[df["code"] != ""]
|
| 73 |
+
df = df[df["description"] != ""]
|
| 74 |
+
df = df.drop_duplicates(subset=["code", "vocabulary"], keep="first").reset_index(drop=True)
|
| 75 |
+
return df
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _load_two_column_text(
|
| 79 |
+
path: str | Path,
|
| 80 |
+
*,
|
| 81 |
+
vocabulary: str,
|
| 82 |
+
delimiter: str,
|
| 83 |
+
) -> pd.DataFrame:
|
| 84 |
+
"""Load a very loose delimited export by splitting only on the first separator.
|
| 85 |
+
|
| 86 |
+
Some vendor exports include trailing separators or extra empty columns. This
|
| 87 |
+
parser keeps the first field as the code and the remainder of the line as the
|
| 88 |
+
description so embedded punctuation in the description is preserved.
|
| 89 |
+
"""
|
| 90 |
+
path = Path(path)
|
| 91 |
+
if not path.exists():
|
| 92 |
+
raise FileNotFoundError(f"Full {vocabulary} code list not found: {path}")
|
| 93 |
+
|
| 94 |
+
rows: list[tuple[str, str]] = []
|
| 95 |
+
for line in path.read_text().splitlines():
|
| 96 |
+
line = line.strip()
|
| 97 |
+
if not line or line.lower().startswith("code"):
|
| 98 |
+
continue
|
| 99 |
+
if delimiter not in line:
|
| 100 |
+
continue
|
| 101 |
+
code, description = line.split(delimiter, 1)
|
| 102 |
+
code = code.strip().strip('"')
|
| 103 |
+
description = description.strip().strip(";").strip(delimiter).strip().strip('"')
|
| 104 |
+
if code and description:
|
| 105 |
+
rows.append((code, description))
|
| 106 |
+
|
| 107 |
+
if not rows:
|
| 108 |
+
raise ValueError(f"No rows loaded from {path}")
|
| 109 |
+
|
| 110 |
+
df = pd.DataFrame(rows, columns=["code", "description"])
|
| 111 |
+
df["vocabulary"] = vocabulary
|
| 112 |
+
df = df.drop_duplicates(subset=["code", "vocabulary"], keep="first").reset_index(drop=True)
|
| 113 |
+
return df
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def load_icd10cm_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["ICD10CM"]) -> pd.DataFrame:
|
| 117 |
+
return _load_codelist_csv(csv_path, vocabulary="ICD10CM", delimiter=",")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def load_icd9cm_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["ICD9CM"]) -> pd.DataFrame:
|
| 121 |
+
return _load_codelist_csv(csv_path, vocabulary="ICD9CM", delimiter=",")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def load_icpc_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["ICPC"]) -> pd.DataFrame:
|
| 125 |
+
return _load_codelist_csv(csv_path, vocabulary="ICPC", delimiter=",")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def load_mdr_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["MDR"]) -> pd.DataFrame:
|
| 129 |
+
return _load_codelist_csv(csv_path, vocabulary="MDR", delimiter=",")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def load_rcd2_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["RCD2"]) -> pd.DataFrame:
|
| 133 |
+
return _load_two_column_text(csv_path, vocabulary="RCD2", delimiter=",")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def load_snomedct_full(csv_path: str | Path = DEFAULT_CODE_SYSTEM_PATHS["SNOMEDCT_US"]) -> pd.DataFrame:
|
| 137 |
+
path = Path(csv_path)
|
| 138 |
+
if not path.exists():
|
| 139 |
+
raise FileNotFoundError(f"Full SNOMEDCT_US code list not found: {path}")
|
| 140 |
+
|
| 141 |
+
# Excel can wrap each original comma-delimited record in a semicolon-
|
| 142 |
+
# delimited sheet. Decode the outer quoting before the original CSV quoting.
|
| 143 |
+
# Terms containing semicolons may occupy more than one outer cell, so join
|
| 144 |
+
# those cells back together rather than truncating the term at the first one.
|
| 145 |
+
with path.open(encoding="utf-8-sig", newline="") as handle:
|
| 146 |
+
reader = csv.reader(handle, delimiter=";")
|
| 147 |
+
header = next(reader, [])
|
| 148 |
+
inner_header = next(csv.reader([header[0]]), []) if header else []
|
| 149 |
+
wrapped = (
|
| 150 |
+
len(header) > 1
|
| 151 |
+
and len(inner_header) == 2
|
| 152 |
+
and inner_header[0].strip().lower() == "code"
|
| 153 |
+
and inner_header[1].strip().lower() in {"term", "description"}
|
| 154 |
+
and not any(cell.strip() for cell in header[1:])
|
| 155 |
+
)
|
| 156 |
+
if wrapped:
|
| 157 |
+
rows = []
|
| 158 |
+
for row in reader:
|
| 159 |
+
if not row or not any(cell.strip() for cell in row):
|
| 160 |
+
continue
|
| 161 |
+
record = ";".join(row).rstrip(";")
|
| 162 |
+
try:
|
| 163 |
+
fields = next(csv.reader([record], strict=True))
|
| 164 |
+
except csv.Error as exc:
|
| 165 |
+
raise ValueError(
|
| 166 |
+
f"Malformed nested SNOMED CSV at source line {reader.line_num}"
|
| 167 |
+
) from exc
|
| 168 |
+
if len(fields) != 2:
|
| 169 |
+
raise ValueError(
|
| 170 |
+
f"Expected code and term at SNOMED source line {reader.line_num}"
|
| 171 |
+
)
|
| 172 |
+
code, description = (value.strip() for value in fields)
|
| 173 |
+
if code and description:
|
| 174 |
+
rows.append((code, description))
|
| 175 |
+
if not rows:
|
| 176 |
+
raise ValueError(f"No rows loaded from {path}")
|
| 177 |
+
frame = pd.DataFrame(rows, columns=["code", "description"])
|
| 178 |
+
frame["vocabulary"] = "SNOMEDCT_US"
|
| 179 |
+
return frame.drop_duplicates(subset=["code", "vocabulary"], keep="first").reset_index(drop=True)
|
| 180 |
+
|
| 181 |
+
return _load_two_column_text(csv_path, vocabulary="SNOMEDCT_US", delimiter=",")
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
CODE_SYSTEM_LOADERS = {
|
| 185 |
+
"ICD10CM": load_icd10cm_full,
|
| 186 |
+
"ICD9CM": load_icd9cm_full,
|
| 187 |
+
"ICPC": load_icpc_full,
|
| 188 |
+
"MDR": load_mdr_full,
|
| 189 |
+
"RCD2": load_rcd2_full,
|
| 190 |
+
"SNOMEDCT_US": load_snomedct_full,
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def load_code_system_corpus(
|
| 195 |
+
vocabularies: list[str],
|
| 196 |
+
source_paths: dict[str, str] | None = None,
|
| 197 |
+
cache_dir: str | Path = "data/codes/parquet/snomed_definitive_20261030",
|
| 198 |
+
) -> pd.DataFrame:
|
| 199 |
+
"""Load (and cache) the full reference code corpus for the given vocabularies.
|
| 200 |
+
|
| 201 |
+
Each vocabulary is parsed from its source file at most once; the processed
|
| 202 |
+
result (``code``, ``description``, ``vocabulary``) is cached to
|
| 203 |
+
``<cache_dir>/codes_<vocabulary>.parquet`` and reused on subsequent calls,
|
| 204 |
+
so the (potentially slow) source loader only runs once per vocabulary.
|
| 205 |
+
|
| 206 |
+
Parameters
|
| 207 |
+
----------
|
| 208 |
+
vocabularies : list[str]
|
| 209 |
+
Coding systems to include, e.g. ``["ICD10CM"]``.
|
| 210 |
+
source_paths : dict[str, str], optional
|
| 211 |
+
Override source file path per vocabulary (defaults to each loader's
|
| 212 |
+
built-in default path).
|
| 213 |
+
cache_dir : str or Path
|
| 214 |
+
Directory to store/read the cached per-vocabulary Parquet files.
|
| 215 |
+
|
| 216 |
+
Returns
|
| 217 |
+
-------
|
| 218 |
+
pd.DataFrame
|
| 219 |
+
Concatenated corpus across all requested vocabularies with columns
|
| 220 |
+
``code``, ``description``, ``vocabulary``.
|
| 221 |
+
"""
|
| 222 |
+
if not vocabularies:
|
| 223 |
+
raise ValueError("At least one vocabulary must be specified.")
|
| 224 |
+
|
| 225 |
+
source_paths = source_paths or {}
|
| 226 |
+
cache_dir = Path(cache_dir)
|
| 227 |
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
| 228 |
+
|
| 229 |
+
frames: list[pd.DataFrame] = []
|
| 230 |
+
for vocab in vocabularies:
|
| 231 |
+
if vocab not in CODE_SYSTEM_LOADERS:
|
| 232 |
+
raise ValueError(
|
| 233 |
+
f"No loader registered for coding system '{vocab}'. "
|
| 234 |
+
f"Available: {sorted(CODE_SYSTEM_LOADERS)}"
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
cache_path = cache_dir / f"codes_{vocab}.parquet"
|
| 238 |
+
source_path = Path(source_paths.get(vocab, DEFAULT_CODE_SYSTEM_PATHS[vocab]))
|
| 239 |
+
cache_is_fresh = (
|
| 240 |
+
cache_path.exists()
|
| 241 |
+
and source_path.exists()
|
| 242 |
+
and cache_path.stat().st_mtime_ns >= source_path.stat().st_mtime_ns
|
| 243 |
+
)
|
| 244 |
+
if cache_is_fresh:
|
| 245 |
+
frames.append(pd.read_parquet(cache_path))
|
| 246 |
+
continue
|
| 247 |
+
|
| 248 |
+
loader = CODE_SYSTEM_LOADERS[vocab]
|
| 249 |
+
df = loader(source_path)
|
| 250 |
+
df.to_parquet(cache_path, index=False)
|
| 251 |
+
frames.append(df)
|
| 252 |
+
|
| 253 |
+
return pd.concat(frames, ignore_index=True) if len(frames) > 1 else frames[0]
|
src/data/loaders.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
import yaml
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class EventDefinitionForm:
|
| 12 |
+
"""Structured phenotype definition (EDF) providing semantic context for
|
| 13 |
+
LLM-assisted concept retrieval and code classification."""
|
| 14 |
+
|
| 15 |
+
name: str
|
| 16 |
+
preferred_name: str
|
| 17 |
+
definition: str = ""
|
| 18 |
+
inclusion_criteria: list[str] = field(default_factory=list)
|
| 19 |
+
narrow_definition: str = ""
|
| 20 |
+
exclusion_criteria: list[str] = field(default_factory=list)
|
| 21 |
+
narrow_decision_rules: list[str] = field(default_factory=list)
|
| 22 |
+
possible_decision_rules: list[str] = field(default_factory=list)
|
| 23 |
+
exclude_decision_rules: list[str] = field(default_factory=list)
|
| 24 |
+
synonyms: list[str] = field(default_factory=list)
|
| 25 |
+
anatomical_location: str = ""
|
| 26 |
+
population_restrictions: str = ""
|
| 27 |
+
temporal_characteristics: str = ""
|
| 28 |
+
etiology: str = ""
|
| 29 |
+
morphology: str = ""
|
| 30 |
+
diagnostic_certainty: str = ""
|
| 31 |
+
administrative_context: str = ""
|
| 32 |
+
candidate_anchor_codes: list[str] = field(default_factory=list)
|
| 33 |
+
ambiguities: list[str] = field(default_factory=list)
|
| 34 |
+
references: list[str] = field(default_factory=list)
|
| 35 |
+
|
| 36 |
+
def to_prompt_context(self, include_possible: bool = True) -> str:
|
| 37 |
+
"""Render the EDF as structured text suitable for retrieval or an LLM prompt.
|
| 38 |
+
|
| 39 |
+
Possible rules are never discarded. In binary mode they define cases
|
| 40 |
+
that are insufficient for Narrow and therefore map to Exclude.
|
| 41 |
+
"""
|
| 42 |
+
lines = [f"Preferred name: {self.preferred_name}"]
|
| 43 |
+
if self.definition:
|
| 44 |
+
lines.append(f"Definition: {self.definition}")
|
| 45 |
+
if self.narrow_definition:
|
| 46 |
+
lines.append(f"Narrow definition: {self.narrow_definition}")
|
| 47 |
+
if self.inclusion_criteria:
|
| 48 |
+
lines.append("Inclusion criteria:")
|
| 49 |
+
lines += [f" - {c}" for c in self.inclusion_criteria]
|
| 50 |
+
if self.exclusion_criteria:
|
| 51 |
+
lines.append("Exclusion criteria:")
|
| 52 |
+
lines += [f" - {c}" for c in self.exclusion_criteria]
|
| 53 |
+
if self.narrow_decision_rules:
|
| 54 |
+
lines.append("Criteria for Narrow classification:")
|
| 55 |
+
lines += [f" - {c}" for c in self.narrow_decision_rules]
|
| 56 |
+
if self.possible_decision_rules:
|
| 57 |
+
if include_possible:
|
| 58 |
+
lines.append("Criteria for Possible classification (when enabled):")
|
| 59 |
+
else:
|
| 60 |
+
lines.append(
|
| 61 |
+
"Criteria that are insufficient for Narrow classification "
|
| 62 |
+
"(classify as Exclude because Possible is disabled):"
|
| 63 |
+
)
|
| 64 |
+
lines += [f" - {c}" for c in self.possible_decision_rules]
|
| 65 |
+
if self.exclude_decision_rules:
|
| 66 |
+
lines.append("Criteria for Exclude classification:")
|
| 67 |
+
lines += [f" - {c}" for c in self.exclude_decision_rules]
|
| 68 |
+
if self.synonyms:
|
| 69 |
+
lines.append(f"Synonyms: {', '.join(self.synonyms)}")
|
| 70 |
+
if self.anatomical_location:
|
| 71 |
+
lines.append(f"Anatomical location: {self.anatomical_location}")
|
| 72 |
+
if self.population_restrictions:
|
| 73 |
+
lines.append(f"Population restrictions: {self.population_restrictions}")
|
| 74 |
+
if self.temporal_characteristics:
|
| 75 |
+
lines.append(f"Temporal characteristics: {self.temporal_characteristics}")
|
| 76 |
+
if self.etiology:
|
| 77 |
+
lines.append(f"Etiology: {self.etiology}")
|
| 78 |
+
if self.morphology:
|
| 79 |
+
lines.append(f"Morphology/pathology: {self.morphology}")
|
| 80 |
+
if self.diagnostic_certainty:
|
| 81 |
+
lines.append(f"Diagnostic certainty: {self.diagnostic_certainty}")
|
| 82 |
+
if self.administrative_context:
|
| 83 |
+
lines.append(f"Administrative/historical context: {self.administrative_context}")
|
| 84 |
+
if self.candidate_anchor_codes:
|
| 85 |
+
lines.append("Candidate anchor codes:")
|
| 86 |
+
lines += [f" - {c}" for c in self.candidate_anchor_codes]
|
| 87 |
+
if self.ambiguities:
|
| 88 |
+
lines.append("Ambiguities:")
|
| 89 |
+
lines += [f" - {c}" for c in self.ambiguities]
|
| 90 |
+
if self.references:
|
| 91 |
+
lines.append("References:")
|
| 92 |
+
lines += [f" - {c}" for c in self.references]
|
| 93 |
+
return "\n".join(lines)
|
| 94 |
+
|
| 95 |
+
def to_lexical_query(self) -> str:
|
| 96 |
+
"""Render the phenotype name and its synonyms for lexical matching."""
|
| 97 |
+
terms = [self.preferred_name.strip(), *(term.strip() for term in self.synonyms)]
|
| 98 |
+
return " ".join(dict.fromkeys(term for term in terms if term))
|
| 99 |
+
|
| 100 |
+
def to_embedding_query(self) -> str:
|
| 101 |
+
"""Render the EDF subset used for semantic embedding matching."""
|
| 102 |
+
lines = [f"Preferred name: {self.preferred_name}"]
|
| 103 |
+
if self.narrow_definition:
|
| 104 |
+
lines.append(f"Narrow definition: {self.narrow_definition}")
|
| 105 |
+
if self.synonyms:
|
| 106 |
+
lines.append(f"Synonyms: {', '.join(self.synonyms)}")
|
| 107 |
+
if self.narrow_decision_rules:
|
| 108 |
+
lines.append("Narrow decision rules:")
|
| 109 |
+
lines += [f" - {c}" for c in self.narrow_decision_rules]
|
| 110 |
+
if self.candidate_anchor_codes:
|
| 111 |
+
lines.append("Candidate anchor codes:")
|
| 112 |
+
lines += [f" - {c}" for c in self.candidate_anchor_codes]
|
| 113 |
+
if self.morphology:
|
| 114 |
+
lines.append(f"Morphology/pathology: {self.morphology}")
|
| 115 |
+
if self.anatomical_location:
|
| 116 |
+
lines.append(f"Anatomical location: {self.anatomical_location}")
|
| 117 |
+
return "\n".join(lines)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def load_edf(name: str, edf_dir: str | Path = "data/edfs/yamls") -> EventDefinitionForm:
|
| 121 |
+
"""Load an Event Definition Form by phenotype name from a YAML file."""
|
| 122 |
+
path = Path(edf_dir) / f"{name}.yaml"
|
| 123 |
+
if not path.exists():
|
| 124 |
+
raise FileNotFoundError(f"EDF not found for phenotype '{name}': {path}")
|
| 125 |
+
raw = yaml.safe_load(path.read_text()) or {}
|
| 126 |
+
return EventDefinitionForm(name=name, **raw)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def load_aesi_dataset(csv_path: str | Path) -> tuple[pd.DataFrame, pd.DataFrame]:
|
| 130 |
+
"""Load a raw AESI (Adverse Event of Special Interest) review export and
|
| 131 |
+
split it into a candidate code corpus and expert-curated gold labels.
|
| 132 |
+
|
| 133 |
+
These CSV exports (e.g. ``C_MYOCARD_AESI_filtered.csv``) contain the full
|
| 134 |
+
set of candidate codes considered during expert phenotype curation, with
|
| 135 |
+
one row per (code, vocabulary) pair and a ``tags`` column holding the
|
| 136 |
+
reviewer's final classification (``narrow``/``possible``/``exclude``).
|
| 137 |
+
|
| 138 |
+
Parameters
|
| 139 |
+
----------
|
| 140 |
+
csv_path : str or Path
|
| 141 |
+
Path to the raw AESI CSV export.
|
| 142 |
+
|
| 143 |
+
Returns
|
| 144 |
+
-------
|
| 145 |
+
tuple[pd.DataFrame, pd.DataFrame]
|
| 146 |
+
``(codes, gold_labels)`` where ``codes`` has columns
|
| 147 |
+
``code``, ``description``, ``vocabulary`` and ``gold_labels`` has
|
| 148 |
+
columns ``code``, ``code_name``, ``vocabulary``, ``label``
|
| 149 |
+
(Narrow/Possible/Exclude).
|
| 150 |
+
"""
|
| 151 |
+
path = Path(csv_path)
|
| 152 |
+
if not path.exists():
|
| 153 |
+
raise FileNotFoundError(f"AESI dataset not found: {path}")
|
| 154 |
+
|
| 155 |
+
raw = pd.read_csv(path, dtype=str)
|
| 156 |
+
required = {"code", "code_name", "coding_system", "tags"}
|
| 157 |
+
missing = required - set(raw.columns)
|
| 158 |
+
if missing:
|
| 159 |
+
raise ValueError(f"AESI dataset missing required columns: {missing}")
|
| 160 |
+
|
| 161 |
+
df = raw.dropna(subset=["tags"]).copy()
|
| 162 |
+
df["label"] = df["tags"].str.strip().str.title()
|
| 163 |
+
|
| 164 |
+
valid_labels = {"Narrow", "Possible", "Exclude"}
|
| 165 |
+
invalid = set(df["label"]) - valid_labels
|
| 166 |
+
if invalid:
|
| 167 |
+
raise ValueError(f"AESI dataset contains invalid tag values: {invalid}")
|
| 168 |
+
|
| 169 |
+
codes = (
|
| 170 |
+
df[["code", "code_name", "coding_system"]]
|
| 171 |
+
.rename(columns={"code_name": "description", "coding_system": "vocabulary"})
|
| 172 |
+
.drop_duplicates(subset=["code", "vocabulary"], keep="first")
|
| 173 |
+
.reset_index(drop=True)
|
| 174 |
+
)
|
| 175 |
+
gold_labels = (
|
| 176 |
+
df[["code", "code_name", "coding_system", "label"]]
|
| 177 |
+
.rename(columns={"coding_system": "vocabulary"})
|
| 178 |
+
.drop_duplicates(subset=["code", "vocabulary"], keep="first")
|
| 179 |
+
.reset_index(drop=True)
|
| 180 |
+
)
|
| 181 |
+
return codes, gold_labels
|
src/data/preprocessing.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
import unicodedata
|
| 5 |
+
|
| 6 |
+
import pandas as pd
|
| 7 |
+
|
| 8 |
+
_WHITESPACE_RE = re.compile(r"\s+")
|
| 9 |
+
_PUNCT_RE = re.compile(r"[^\w\s]")
|
| 10 |
+
_CODE_RANGE_RE = re.compile(
|
| 11 |
+
r"^[A-Z]*\d+[A-Z0-9.]*\s*[-\u2010-\u2015\u2212]\s*[A-Z]*\d+[A-Z0-9.]*$",
|
| 12 |
+
re.IGNORECASE,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def normalize_text(text: str) -> str:
|
| 17 |
+
"""Lowercase, strip accents/punctuation, and collapse whitespace for
|
| 18 |
+
lexical matching and deduplication."""
|
| 19 |
+
if not isinstance(text, str):
|
| 20 |
+
return ""
|
| 21 |
+
text = unicodedata.normalize("NFKD", text)
|
| 22 |
+
text = "".join(ch for ch in text if not unicodedata.combining(ch))
|
| 23 |
+
text = text.lower()
|
| 24 |
+
text = _PUNCT_RE.sub(" ", text)
|
| 25 |
+
text = _WHITESPACE_RE.sub(" ", text).strip()
|
| 26 |
+
return text
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def standardize_code(code: str, vocabulary: str) -> str:
|
| 30 |
+
"""Standardize a code's surface form per vocabulary conventions
|
| 31 |
+
(uppercase, no internal whitespace; ICD-style codes keep their dot)."""
|
| 32 |
+
if not isinstance(code, str):
|
| 33 |
+
return ""
|
| 34 |
+
code = code.strip().upper()
|
| 35 |
+
code = re.sub(r"\s+", "", code)
|
| 36 |
+
vocabulary = (vocabulary or "").strip().upper()
|
| 37 |
+
if vocabulary in {"ICD10", "ICD10CM"} and "." not in code and len(code) > 3:
|
| 38 |
+
code = f"{code[:3]}.{code[3:]}"
|
| 39 |
+
return code
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def is_code_range(code: object) -> bool:
|
| 43 |
+
"""Return whether *code* is a dictionary grouping range, not a billable code.
|
| 44 |
+
|
| 45 |
+
Examples include ``N17-N19``, ``N17–N19``, and ``001-009.99``. Both
|
| 46 |
+
endpoints must contain a digit, which avoids treating leading-hyphen ICPC
|
| 47 |
+
process codes such as ``-30`` or ordinary hyphenated text as a range.
|
| 48 |
+
"""
|
| 49 |
+
return isinstance(code, str) and _CODE_RANGE_RE.fullmatch(code.strip()) is not None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def filter_code_ranges(df: pd.DataFrame) -> pd.DataFrame:
|
| 53 |
+
"""Remove non-codable dictionary range rows from a terminology corpus."""
|
| 54 |
+
if df.empty or "code" not in df.columns:
|
| 55 |
+
return df.copy()
|
| 56 |
+
range_mask = df["code"].map(is_code_range)
|
| 57 |
+
return df.loc[~range_mask].reset_index(drop=True)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def deduplicate_codes(df: pd.DataFrame) -> pd.DataFrame:
|
| 61 |
+
"""Remove duplicate (code, vocabulary) pairs, keeping the first occurrence."""
|
| 62 |
+
if df.empty:
|
| 63 |
+
return df
|
| 64 |
+
working = df.copy()
|
| 65 |
+
working["_code_key"] = working.apply(
|
| 66 |
+
lambda r: standardize_code(r["code"], r.get("vocabulary", "")), axis=1
|
| 67 |
+
)
|
| 68 |
+
working = working.drop_duplicates(subset=["_code_key", "vocabulary"], keep="first")
|
| 69 |
+
return working.drop(columns=["_code_key"]).reset_index(drop=True)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def preprocess_corpus(df: pd.DataFrame) -> pd.DataFrame:
|
| 73 |
+
"""Filter ranges, standardize codes/descriptions, and drop duplicates."""
|
| 74 |
+
df = filter_code_ranges(df)
|
| 75 |
+
df = deduplicate_codes(df)
|
| 76 |
+
df = df.copy()
|
| 77 |
+
df["code"] = [standardize_code(c, v) for c, v in zip(df["code"], df["vocabulary"])]
|
| 78 |
+
df["normalized_description"] = df["description"].map(normalize_text)
|
| 79 |
+
return df
|
src/evaluation/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation metrics against gold-standard phenotype codelists."""
|
src/evaluation/metrics.py
ADDED
|
@@ -0,0 +1,510 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation metrics for concept retrieval and code classification against
|
| 2 |
+
expert-curated gold-standard phenotype codelists."""
|
| 3 |
+
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from dataclasses import asdict, dataclass
|
| 7 |
+
import json
|
| 8 |
+
|
| 9 |
+
import pandas as pd
|
| 10 |
+
|
| 11 |
+
from src.data.preprocessing import standardize_code
|
| 12 |
+
|
| 13 |
+
LABELS = ("Narrow", "Possible", "Exclude")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(slots=True)
|
| 17 |
+
class RetrievalMetrics:
|
| 18 |
+
true_positives: int
|
| 19 |
+
false_positives: int
|
| 20 |
+
false_negatives: int
|
| 21 |
+
sensitivity: float
|
| 22 |
+
precision: float
|
| 23 |
+
f1: float
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass(slots=True)
|
| 27 |
+
class ClassificationMetrics:
|
| 28 |
+
accuracy: float
|
| 29 |
+
cohens_kappa: float
|
| 30 |
+
true_negatives: int
|
| 31 |
+
sensitivity: float
|
| 32 |
+
precision: float
|
| 33 |
+
f1: float
|
| 34 |
+
macro_sensitivity: float
|
| 35 |
+
macro_precision: float
|
| 36 |
+
macro_f1: float
|
| 37 |
+
per_label: dict[str, dict[str, float]]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass(slots=True)
|
| 41 |
+
class NarrowLossBreakdown:
|
| 42 |
+
"""Where available gold-Narrow codes were lost end to end."""
|
| 43 |
+
|
| 44 |
+
gold_narrow_available: int
|
| 45 |
+
correctly_classified: int
|
| 46 |
+
misclassified: int
|
| 47 |
+
not_classified: int
|
| 48 |
+
total_missed: int
|
| 49 |
+
end_to_end_recall: float
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@dataclass(slots=True)
|
| 53 |
+
class GPTReviewEvaluation:
|
| 54 |
+
"""Gold-standard impact of the final low-confidence GPT review."""
|
| 55 |
+
|
| 56 |
+
candidates_reviewed: int
|
| 57 |
+
labels_changed: int
|
| 58 |
+
corrected_changes: int
|
| 59 |
+
harmful_changes: int
|
| 60 |
+
unchanged: int
|
| 61 |
+
correct_before: int
|
| 62 |
+
correct_after: int
|
| 63 |
+
net_correct_change: int
|
| 64 |
+
reviewed_accuracy_before: float
|
| 65 |
+
reviewed_accuracy_after: float
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _standardized_key(code: str, vocabulary: str) -> tuple[str, str]:
|
| 69 |
+
return standardize_code(code, vocabulary), (vocabulary or "").strip().upper()
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def filter_records_by_vocabulary(records: list[dict], vocabularies: list[str]) -> list[dict]:
|
| 73 |
+
"""Keep only records whose ``vocabulary`` is in *vocabularies* (case-insensitive)."""
|
| 74 |
+
vocab_set = {v.strip().upper() for v in vocabularies}
|
| 75 |
+
return [r for r in records if (r.get("vocabulary") or "").strip().upper() in vocab_set]
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def filter_gold_by_vocabulary(gold_labels: pd.DataFrame, vocabularies: list[str]) -> pd.DataFrame:
|
| 79 |
+
"""Keep only gold-label rows whose ``vocabulary`` is in *vocabularies* (case-insensitive)."""
|
| 80 |
+
vocab_set = {v.strip().upper() for v in vocabularies}
|
| 81 |
+
mask = gold_labels["vocabulary"].str.strip().str.upper().isin(vocab_set)
|
| 82 |
+
return gold_labels[mask].reset_index(drop=True)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def filter_gold_by_available_codes(
|
| 86 |
+
gold_labels: pd.DataFrame,
|
| 87 |
+
available_codes: pd.DataFrame,
|
| 88 |
+
) -> pd.DataFrame:
|
| 89 |
+
"""Restrict gold labels to codes present in the loaded terminology corpus.
|
| 90 |
+
|
| 91 |
+
This prevents codes from older or otherwise unavailable terminology
|
| 92 |
+
versions from being counted as false negatives.
|
| 93 |
+
"""
|
| 94 |
+
required = {"code", "vocabulary"}
|
| 95 |
+
missing = required - set(available_codes.columns)
|
| 96 |
+
if missing:
|
| 97 |
+
raise ValueError(f"Available code corpus missing required columns: {missing}")
|
| 98 |
+
|
| 99 |
+
available_keys = {
|
| 100 |
+
_standardized_key(row["code"], row["vocabulary"])
|
| 101 |
+
for _, row in available_codes.iterrows()
|
| 102 |
+
}
|
| 103 |
+
mask = gold_labels.apply(
|
| 104 |
+
lambda row: _standardized_key(row["code"], row["vocabulary"]) in available_keys,
|
| 105 |
+
axis=1,
|
| 106 |
+
)
|
| 107 |
+
return gold_labels[mask].reset_index(drop=True)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def narrow_loss_breakdown(
|
| 111 |
+
classified: list[dict],
|
| 112 |
+
gold_labels: pd.DataFrame,
|
| 113 |
+
) -> NarrowLossBreakdown:
|
| 114 |
+
"""Separate gold-Narrow losses before and during LLM classification.
|
| 115 |
+
|
| 116 |
+
``not_classified`` covers gold-Narrow codes that never reached the model
|
| 117 |
+
(for example because adaptive stopping or a hard top-k limit ended the
|
| 118 |
+
scan). ``misclassified`` covers codes that did reach the model but were
|
| 119 |
+
assigned a label other than Narrow.
|
| 120 |
+
"""
|
| 121 |
+
gold_narrow_keys = {
|
| 122 |
+
_standardized_key(row["code"], row["vocabulary"])
|
| 123 |
+
for _, row in gold_labels.iterrows()
|
| 124 |
+
if str(row["label"]).strip().title() == "Narrow"
|
| 125 |
+
}
|
| 126 |
+
predictions_by_key = {
|
| 127 |
+
_standardized_key(item["code"], item["vocabulary"]):
|
| 128 |
+
str(item.get("label", "")).strip().title()
|
| 129 |
+
for item in classified
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
correctly_classified = sum(
|
| 133 |
+
predictions_by_key.get(key) == "Narrow" for key in gold_narrow_keys
|
| 134 |
+
)
|
| 135 |
+
misclassified = sum(
|
| 136 |
+
key in predictions_by_key and predictions_by_key[key] != "Narrow"
|
| 137 |
+
for key in gold_narrow_keys
|
| 138 |
+
)
|
| 139 |
+
not_classified = len(gold_narrow_keys - predictions_by_key.keys())
|
| 140 |
+
total_missed = misclassified + not_classified
|
| 141 |
+
recall = correctly_classified / len(gold_narrow_keys) if gold_narrow_keys else 0.0
|
| 142 |
+
|
| 143 |
+
return NarrowLossBreakdown(
|
| 144 |
+
gold_narrow_available=len(gold_narrow_keys),
|
| 145 |
+
correctly_classified=correctly_classified,
|
| 146 |
+
misclassified=misclassified,
|
| 147 |
+
not_classified=not_classified,
|
| 148 |
+
total_missed=total_missed,
|
| 149 |
+
end_to_end_recall=round(recall, 4),
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def evaluate_gpt_review(
|
| 154 |
+
stage2_classified: list[dict],
|
| 155 |
+
final_classified: list[dict],
|
| 156 |
+
gold_labels: pd.DataFrame,
|
| 157 |
+
*,
|
| 158 |
+
confidence_threshold: float,
|
| 159 |
+
max_candidates: int | None = None,
|
| 160 |
+
include_possible: bool = False,
|
| 161 |
+
) -> GPTReviewEvaluation:
|
| 162 |
+
"""Measure whether GPT's reviewed labels improved stage-2 decisions."""
|
| 163 |
+
gold_by_key = {
|
| 164 |
+
_standardized_key(row["code"], row["vocabulary"]): (
|
| 165 |
+
row["label"]
|
| 166 |
+
if include_possible or row["label"] != "Possible"
|
| 167 |
+
else "Exclude"
|
| 168 |
+
)
|
| 169 |
+
for _, row in gold_labels.iterrows()
|
| 170 |
+
}
|
| 171 |
+
final_by_key = {
|
| 172 |
+
_standardized_key(item["code"], item["vocabulary"]):
|
| 173 |
+
str(item.get("label", "")).strip().title()
|
| 174 |
+
for item in final_classified
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
eligible = [
|
| 178 |
+
item
|
| 179 |
+
for item in stage2_classified
|
| 180 |
+
if float(item.get("confidence", 0.0)) < confidence_threshold
|
| 181 |
+
]
|
| 182 |
+
eligible.sort(key=lambda item: float(item.get("confidence", 0.0)))
|
| 183 |
+
selected = eligible[:max_candidates] if max_candidates is not None else eligible
|
| 184 |
+
selected_keys = {
|
| 185 |
+
_standardized_key(item["code"], item["vocabulary"]) for item in selected
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
reviewed = 0
|
| 189 |
+
changed = 0
|
| 190 |
+
corrected = 0
|
| 191 |
+
harmful = 0
|
| 192 |
+
correct_before = 0
|
| 193 |
+
correct_after = 0
|
| 194 |
+
for original in stage2_classified:
|
| 195 |
+
key = _standardized_key(original["code"], original["vocabulary"])
|
| 196 |
+
if key not in selected_keys:
|
| 197 |
+
continue
|
| 198 |
+
if key not in final_by_key:
|
| 199 |
+
continue
|
| 200 |
+
reviewed += 1
|
| 201 |
+
original_label = str(original.get("label", "")).strip().title()
|
| 202 |
+
final_label = final_by_key[key]
|
| 203 |
+
if not include_possible:
|
| 204 |
+
original_label = "Exclude" if original_label == "Possible" else original_label
|
| 205 |
+
final_label = "Exclude" if final_label == "Possible" else final_label
|
| 206 |
+
gold_label = gold_by_key.get(key, "Exclude")
|
| 207 |
+
was_correct = original_label == gold_label
|
| 208 |
+
is_correct = final_label == gold_label
|
| 209 |
+
correct_before += was_correct
|
| 210 |
+
correct_after += is_correct
|
| 211 |
+
if original_label != final_label:
|
| 212 |
+
changed += 1
|
| 213 |
+
corrected += is_correct and not was_correct
|
| 214 |
+
harmful += was_correct and not is_correct
|
| 215 |
+
|
| 216 |
+
return GPTReviewEvaluation(
|
| 217 |
+
candidates_reviewed=reviewed,
|
| 218 |
+
labels_changed=changed,
|
| 219 |
+
corrected_changes=corrected,
|
| 220 |
+
harmful_changes=harmful,
|
| 221 |
+
unchanged=reviewed - changed,
|
| 222 |
+
correct_before=correct_before,
|
| 223 |
+
correct_after=correct_after,
|
| 224 |
+
net_correct_change=correct_after - correct_before,
|
| 225 |
+
reviewed_accuracy_before=round(correct_before / reviewed, 4) if reviewed else 0.0,
|
| 226 |
+
reviewed_accuracy_after=round(correct_after / reviewed, 4) if reviewed else 0.0,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def to_predicted_codelist(classified: list[dict]) -> pd.DataFrame:
|
| 231 |
+
"""Render final classifications in the same schema as the ground-truth AESI
|
| 232 |
+
export (``coding_system``, ``code``, ``code_name``, ``concept``,
|
| 233 |
+
``concept_name``, ``tags``) so predicted and reference codelists are
|
| 234 |
+
directly comparable/exportable side by side.
|
| 235 |
+
|
| 236 |
+
Parameters
|
| 237 |
+
----------
|
| 238 |
+
classified : list[dict]
|
| 239 |
+
Each dict must contain ``code``, ``vocabulary``, ``description``, and
|
| 240 |
+
``label`` keys (as produced by :func:`src.llm.classify.llm_classify`).
|
| 241 |
+
|
| 242 |
+
Returns
|
| 243 |
+
-------
|
| 244 |
+
pd.DataFrame
|
| 245 |
+
AESI-compatible columns. When classification annotations are supplied,
|
| 246 |
+
confidence, explanation, manual-review flag, and review reason are
|
| 247 |
+
appended. ``concept`` is blank because the corpus has no UMLS mapping.
|
| 248 |
+
"""
|
| 249 |
+
base_columns = ["coding_system", "code", "code_name", "concept", "concept_name", "tags"]
|
| 250 |
+
annotation_columns = ["confidence", "explanation", "manual_review", "review_reason"]
|
| 251 |
+
include_annotations = any(any(column in item for column in annotation_columns) for item in classified)
|
| 252 |
+
columns = base_columns + annotation_columns if include_annotations else base_columns
|
| 253 |
+
mapping_columns = ["decision_origin", "mapping_provenance", "prior_label", "prior_confidence"]
|
| 254 |
+
include_mapping = any(c.get("decision_origin") == "codemapper" for c in classified)
|
| 255 |
+
if include_mapping:
|
| 256 |
+
columns += mapping_columns
|
| 257 |
+
return pd.DataFrame(
|
| 258 |
+
[
|
| 259 |
+
{
|
| 260 |
+
"coding_system": c["vocabulary"],
|
| 261 |
+
"code": c["code"],
|
| 262 |
+
"code_name": c.get("description", ""),
|
| 263 |
+
"concept": "",
|
| 264 |
+
"concept_name": c.get("description", ""),
|
| 265 |
+
"tags": (c.get("label") or "").lower(),
|
| 266 |
+
**(
|
| 267 |
+
{
|
| 268 |
+
"confidence": c.get("confidence", ""),
|
| 269 |
+
"explanation": c.get("explanation", ""),
|
| 270 |
+
"manual_review": bool(c.get("manual_review", False) or c.get("decision_origin") == "codemapper"),
|
| 271 |
+
"review_reason": c.get("review_reason") or (
|
| 272 |
+
"included by CodeMapper mapping; EDF scope not independently assessed"
|
| 273 |
+
if c.get("decision_origin") == "codemapper" else ""
|
| 274 |
+
),
|
| 275 |
+
}
|
| 276 |
+
if include_annotations
|
| 277 |
+
else {}
|
| 278 |
+
),
|
| 279 |
+
**({"decision_origin": c.get("decision_origin", "llm"),
|
| 280 |
+
"mapping_provenance": json.dumps(c.get("mapping_provenance", [])),
|
| 281 |
+
"prior_label": c.get("prior_label"),
|
| 282 |
+
"prior_confidence": c.get("prior_confidence")} if include_mapping else {}),
|
| 283 |
+
}
|
| 284 |
+
for c in classified
|
| 285 |
+
],
|
| 286 |
+
columns=columns,
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def evaluate_retrieval(
|
| 291 |
+
retrieved_codes: list[dict],
|
| 292 |
+
gold_labels: pd.DataFrame,
|
| 293 |
+
narrow_only_as_positive: bool = False,
|
| 294 |
+
) -> RetrievalMetrics:
|
| 295 |
+
"""Evaluate concept retrieval as an information-retrieval task.
|
| 296 |
+
|
| 297 |
+
By default, true positives are retrieved codes labeled 'Narrow' or
|
| 298 |
+
'Possible' in the gold standard. With ``narrow_only_as_positive=True``, only
|
| 299 |
+
'Narrow' is positive.
|
| 300 |
+
False positives are retrieved codes labeled 'Exclude' in the gold standard,
|
| 301 |
+
or codes absent from it entirely.
|
| 302 |
+
False negatives are positive gold-standard codes that were not retrieved.
|
| 303 |
+
|
| 304 |
+
Parameters
|
| 305 |
+
----------
|
| 306 |
+
retrieved_codes : list[dict]
|
| 307 |
+
Each dict must contain ``code`` and ``vocabulary`` keys.
|
| 308 |
+
gold_labels : pd.DataFrame
|
| 309 |
+
Reference standard with ``code``, ``vocabulary``, ``label`` columns.
|
| 310 |
+
|
| 311 |
+
Returns
|
| 312 |
+
-------
|
| 313 |
+
RetrievalMetrics
|
| 314 |
+
"""
|
| 315 |
+
gold_by_key = {
|
| 316 |
+
_standardized_key(row["code"], row["vocabulary"]): row["label"]
|
| 317 |
+
for _, row in gold_labels.iterrows()
|
| 318 |
+
}
|
| 319 |
+
positive_labels = {"Narrow"} if narrow_only_as_positive else {"Narrow", "Possible"}
|
| 320 |
+
positive_keys = {k for k, label in gold_by_key.items() if label in positive_labels}
|
| 321 |
+
|
| 322 |
+
retrieved_keys = {
|
| 323 |
+
_standardized_key(r["code"], r["vocabulary"]) for r in retrieved_codes
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
tp = len(retrieved_keys & positive_keys)
|
| 327 |
+
fp = len(retrieved_keys - positive_keys)
|
| 328 |
+
fn = len(positive_keys - retrieved_keys)
|
| 329 |
+
|
| 330 |
+
sensitivity = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 331 |
+
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
| 332 |
+
f1 = (
|
| 333 |
+
2 * precision * sensitivity / (precision + sensitivity)
|
| 334 |
+
if (precision + sensitivity) > 0
|
| 335 |
+
else 0.0
|
| 336 |
+
)
|
| 337 |
+
|
| 338 |
+
return RetrievalMetrics(
|
| 339 |
+
true_positives=tp,
|
| 340 |
+
false_positives=fp,
|
| 341 |
+
false_negatives=fn,
|
| 342 |
+
sensitivity=round(sensitivity, 4),
|
| 343 |
+
precision=round(precision, 4),
|
| 344 |
+
f1=round(f1, 4),
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _cohens_kappa(confusion: dict[tuple[str, str], int], labels: tuple[str, ...], n: int) -> float:
|
| 349 |
+
if n == 0:
|
| 350 |
+
return 0.0
|
| 351 |
+
observed_agreement = sum(confusion.get((lbl, lbl), 0) for lbl in labels) / n
|
| 352 |
+
|
| 353 |
+
row_totals = {lbl: sum(confusion.get((lbl, o), 0) for o in labels) for lbl in labels}
|
| 354 |
+
col_totals = {lbl: sum(confusion.get((o, lbl), 0) for o in labels) for lbl in labels}
|
| 355 |
+
expected_agreement = sum((row_totals[lbl] * col_totals[lbl]) / n for lbl in labels) / n
|
| 356 |
+
|
| 357 |
+
if expected_agreement >= 1.0:
|
| 358 |
+
return 1.0
|
| 359 |
+
return (observed_agreement - expected_agreement) / (1 - expected_agreement)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def evaluate_classification(
|
| 363 |
+
classified: list[dict],
|
| 364 |
+
gold_labels: pd.DataFrame,
|
| 365 |
+
include_possible: bool = True,
|
| 366 |
+
) -> ClassificationMetrics:
|
| 367 |
+
"""Evaluate code classification (Narrow/Possible/Exclude) against gold labels.
|
| 368 |
+
|
| 369 |
+
Only codes present in both the classified set and gold standard are compared
|
| 370 |
+
(codes absent from either side cannot be scored for classification agreement).
|
| 371 |
+
|
| 372 |
+
Parameters
|
| 373 |
+
----------
|
| 374 |
+
classified : list[dict]
|
| 375 |
+
Each dict must contain ``code``, ``vocabulary``, ``label`` keys.
|
| 376 |
+
gold_labels : pd.DataFrame
|
| 377 |
+
Reference standard with ``code``, ``vocabulary``, ``label`` columns.
|
| 378 |
+
|
| 379 |
+
Returns
|
| 380 |
+
-------
|
| 381 |
+
ClassificationMetrics
|
| 382 |
+
"""
|
| 383 |
+
labels = LABELS if include_possible else ("Narrow", "Exclude")
|
| 384 |
+
gold_by_key = {
|
| 385 |
+
_standardized_key(row["code"], row["vocabulary"]): (
|
| 386 |
+
row["label"] if include_possible or row["label"] != "Possible" else "Exclude"
|
| 387 |
+
)
|
| 388 |
+
for _, row in gold_labels.iterrows()
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
confusion: dict[tuple[str, str], int] = {}
|
| 392 |
+
correct = 0
|
| 393 |
+
total = 0
|
| 394 |
+
per_label_counts = {lbl: {"tp": 0, "tn": 0, "fp": 0, "fn": 0} for lbl in labels}
|
| 395 |
+
|
| 396 |
+
for item in classified:
|
| 397 |
+
key = _standardized_key(item["code"], item["vocabulary"])
|
| 398 |
+
gold_label = gold_by_key.get(key)
|
| 399 |
+
pred_label = item["label"]
|
| 400 |
+
if not include_possible and pred_label == "Possible":
|
| 401 |
+
pred_label = "Exclude"
|
| 402 |
+
if gold_label is None:
|
| 403 |
+
# A retrieved code absent from the curated positive codelist is an
|
| 404 |
+
# implicit Exclude. Apply this consistently to both correct
|
| 405 |
+
# Exclude predictions and false-positive Narrow/Possible results.
|
| 406 |
+
gold_label = "Exclude"
|
| 407 |
+
|
| 408 |
+
total += 1
|
| 409 |
+
confusion[(gold_label, pred_label)] = confusion.get((gold_label, pred_label), 0) + 1
|
| 410 |
+
if pred_label == gold_label:
|
| 411 |
+
correct += 1
|
| 412 |
+
for label in labels:
|
| 413 |
+
gold_positive = gold_label == label
|
| 414 |
+
predicted_positive = pred_label == label
|
| 415 |
+
if gold_positive and predicted_positive:
|
| 416 |
+
per_label_counts[label]["tp"] += 1
|
| 417 |
+
elif not gold_positive and not predicted_positive:
|
| 418 |
+
per_label_counts[label]["tn"] += 1
|
| 419 |
+
elif predicted_positive:
|
| 420 |
+
per_label_counts[label]["fp"] += 1
|
| 421 |
+
else:
|
| 422 |
+
per_label_counts[label]["fn"] += 1
|
| 423 |
+
|
| 424 |
+
accuracy = correct / total if total > 0 else 0.0
|
| 425 |
+
kappa = _cohens_kappa(confusion, labels, total)
|
| 426 |
+
|
| 427 |
+
per_label: dict[str, dict[str, float]] = {}
|
| 428 |
+
for lbl in labels:
|
| 429 |
+
tp = per_label_counts[lbl]["tp"]
|
| 430 |
+
tn = per_label_counts[lbl]["tn"]
|
| 431 |
+
fp = per_label_counts[lbl]["fp"]
|
| 432 |
+
fn = per_label_counts[lbl]["fn"]
|
| 433 |
+
precision = tp / (tp + fp) if (tp + fp) > 0 else 0.0
|
| 434 |
+
recall = tp / (tp + fn) if (tp + fn) > 0 else 0.0
|
| 435 |
+
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0
|
| 436 |
+
per_label[lbl] = {
|
| 437 |
+
"tp": tp,
|
| 438 |
+
"tn": tn,
|
| 439 |
+
"fp": fp,
|
| 440 |
+
"fn": fn,
|
| 441 |
+
"precision": round(precision, 4),
|
| 442 |
+
"recall": round(recall, 4),
|
| 443 |
+
"f1": round(f1, 4),
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
macro_labels = [
|
| 447 |
+
label
|
| 448 |
+
for label, counts in per_label_counts.items()
|
| 449 |
+
if counts["tp"] + counts["fn"] > 0
|
| 450 |
+
] or list(labels)
|
| 451 |
+
|
| 452 |
+
return ClassificationMetrics(
|
| 453 |
+
accuracy=round(accuracy, 4),
|
| 454 |
+
cohens_kappa=round(kappa, 4),
|
| 455 |
+
true_negatives=per_label["Narrow"]["tn"],
|
| 456 |
+
sensitivity=per_label["Narrow"]["recall"],
|
| 457 |
+
precision=per_label["Narrow"]["precision"],
|
| 458 |
+
f1=per_label["Narrow"]["f1"],
|
| 459 |
+
macro_sensitivity=round(sum(per_label[label]["recall"] for label in macro_labels) / len(macro_labels), 4),
|
| 460 |
+
macro_precision=round(sum(per_label[label]["precision"] for label in macro_labels) / len(macro_labels), 4),
|
| 461 |
+
macro_f1=round(sum(per_label[label]["f1"] for label in macro_labels) / len(macro_labels), 4),
|
| 462 |
+
per_label=per_label,
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def evaluate(
|
| 467 |
+
classified: list[dict],
|
| 468 |
+
gold_labels: pd.DataFrame,
|
| 469 |
+
narrow_only_as_positive: bool = False,
|
| 470 |
+
include_possible: bool = True,
|
| 471 |
+
) -> dict:
|
| 472 |
+
"""Compute both retrieval and classification metrics for the full pipeline output.
|
| 473 |
+
|
| 474 |
+
Parameters
|
| 475 |
+
----------
|
| 476 |
+
classified : list[dict]
|
| 477 |
+
Final classified candidates, each with ``code``, ``vocabulary``, ``label`` keys.
|
| 478 |
+
gold_labels : pd.DataFrame
|
| 479 |
+
Reference standard with ``code``, ``vocabulary``, ``label`` columns.
|
| 480 |
+
|
| 481 |
+
Returns
|
| 482 |
+
-------
|
| 483 |
+
dict
|
| 484 |
+
Dictionary with 'retrieval' and 'classification' metric sub-dictionaries.
|
| 485 |
+
"""
|
| 486 |
+
retrieval_metrics = evaluate_retrieval(
|
| 487 |
+
classified,
|
| 488 |
+
gold_labels,
|
| 489 |
+
narrow_only_as_positive=narrow_only_as_positive,
|
| 490 |
+
)
|
| 491 |
+
classification_metrics = evaluate_classification(
|
| 492 |
+
classified,
|
| 493 |
+
gold_labels,
|
| 494 |
+
include_possible=include_possible,
|
| 495 |
+
)
|
| 496 |
+
return {
|
| 497 |
+
"retrieval": asdict(retrieval_metrics),
|
| 498 |
+
"classification": {
|
| 499 |
+
"accuracy": classification_metrics.accuracy,
|
| 500 |
+
"cohens_kappa": classification_metrics.cohens_kappa,
|
| 501 |
+
"true_negatives": classification_metrics.true_negatives,
|
| 502 |
+
"sensitivity": classification_metrics.sensitivity,
|
| 503 |
+
"precision": classification_metrics.precision,
|
| 504 |
+
"f1": classification_metrics.f1,
|
| 505 |
+
"macro_sensitivity": classification_metrics.macro_sensitivity,
|
| 506 |
+
"macro_precision": classification_metrics.macro_precision,
|
| 507 |
+
"macro_f1": classification_metrics.macro_f1,
|
| 508 |
+
"per_label": classification_metrics.per_label,
|
| 509 |
+
},
|
| 510 |
+
}
|
src/llm/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""LLM-based ranking and classification of candidate codes."""
|
src/llm/classify.py
ADDED
|
@@ -0,0 +1,531 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM classification into Narrow/Exclude with an optional Possible category."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
from collections.abc import Callable, Sequence
|
| 7 |
+
import json
|
| 8 |
+
from typing import Protocol
|
| 9 |
+
|
| 10 |
+
from src.data.loaders import EventDefinitionForm
|
| 11 |
+
from src.llm.client import call_jev_decisions, call_llm_json, resolve_provider
|
| 12 |
+
from src.llm.prompts import build_classify_prompt, classify_system_prompt
|
| 13 |
+
from src.utils.logging import get_logger
|
| 14 |
+
|
| 15 |
+
logger = get_logger(__name__)
|
| 16 |
+
|
| 17 |
+
VALID_LABELS = {"Narrow", "Possible", "Exclude"}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class ClassificationCandidate(Protocol):
|
| 21 |
+
"""Minimal candidate interface consumed by the classifier."""
|
| 22 |
+
|
| 23 |
+
code: str
|
| 24 |
+
description: str
|
| 25 |
+
vocabulary: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass(frozen=True, slots=True)
|
| 29 |
+
class AdaptiveStoppingConfig:
|
| 30 |
+
"""Stop classification after Narrow results remain sparse for several batches."""
|
| 31 |
+
|
| 32 |
+
sparse_narrow_threshold: int = 1
|
| 33 |
+
consecutive_sparse_batches: int = 3
|
| 34 |
+
minimum_batches: int = 3
|
| 35 |
+
|
| 36 |
+
def __post_init__(self) -> None:
|
| 37 |
+
if self.sparse_narrow_threshold < 0:
|
| 38 |
+
raise ValueError("sparse_narrow_threshold must be at least 0")
|
| 39 |
+
if self.consecutive_sparse_batches < 1:
|
| 40 |
+
raise ValueError("consecutive_sparse_batches must be at least 1")
|
| 41 |
+
if self.minimum_batches < 1:
|
| 42 |
+
raise ValueError("minimum_batches must be at least 1")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass(frozen=True, slots=True)
|
| 46 |
+
class ClassificationRunInfo:
|
| 47 |
+
"""Summary of how much of the ranked candidate list was classified."""
|
| 48 |
+
|
| 49 |
+
candidates_available: int
|
| 50 |
+
candidates_classified: int
|
| 51 |
+
batches_completed: int
|
| 52 |
+
narrow_counts_by_batch: tuple[int, ...]
|
| 53 |
+
stopped_early: bool
|
| 54 |
+
stop_reason: str
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@dataclass(slots=True)
|
| 58 |
+
class ClassifiedCandidate:
|
| 59 |
+
code: str
|
| 60 |
+
description: str
|
| 61 |
+
vocabulary: str
|
| 62 |
+
label: str
|
| 63 |
+
confidence: float
|
| 64 |
+
explanation: str
|
| 65 |
+
relevance_score: float = 0.0
|
| 66 |
+
retrieval_score: float = 0.0
|
| 67 |
+
decision_origin: str = "llm"
|
| 68 |
+
mapping_provenance: list[dict] = field(default_factory=list)
|
| 69 |
+
prior_label: str | None = None
|
| 70 |
+
prior_confidence: float | None = None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@dataclass(frozen=True, slots=True)
|
| 74 |
+
class GPTReviewRunInfo:
|
| 75 |
+
"""Summary of final-stage GPT adjudication of uncertain classifications."""
|
| 76 |
+
|
| 77 |
+
confidence_threshold: float
|
| 78 |
+
max_candidates: int | None
|
| 79 |
+
eligible_candidates: int
|
| 80 |
+
candidates_reviewed: int
|
| 81 |
+
labels_changed: int
|
| 82 |
+
narrow_to_exclude: int
|
| 83 |
+
exclude_to_narrow: int
|
| 84 |
+
reasoning_effort: str
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def select_gpt_review_candidates(
|
| 88 |
+
classified: Sequence[ClassifiedCandidate],
|
| 89 |
+
*,
|
| 90 |
+
confidence_threshold: float,
|
| 91 |
+
max_candidates: int | None = None,
|
| 92 |
+
) -> list[ClassifiedCandidate]:
|
| 93 |
+
"""Select the lowest-confidence eligible decisions for GPT review."""
|
| 94 |
+
if not 0.0 <= confidence_threshold <= 1.0:
|
| 95 |
+
raise ValueError("confidence_threshold must be between 0 and 1")
|
| 96 |
+
if max_candidates is not None and max_candidates < 1:
|
| 97 |
+
raise ValueError("max_candidates must be at least 1 when provided")
|
| 98 |
+
|
| 99 |
+
eligible = [item for item in classified if item.confidence < confidence_threshold]
|
| 100 |
+
eligible.sort(key=lambda item: item.confidence)
|
| 101 |
+
return eligible[:max_candidates] if max_candidates is not None else eligible
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def gpt_review_low_confidence(
|
| 105 |
+
classified: Sequence[ClassifiedCandidate],
|
| 106 |
+
edf: EventDefinitionForm,
|
| 107 |
+
*,
|
| 108 |
+
confidence_threshold: float = 0.7,
|
| 109 |
+
max_candidates: int | None = None,
|
| 110 |
+
model: str | None = None,
|
| 111 |
+
max_retries: int = 3,
|
| 112 |
+
batch_size: int = 10,
|
| 113 |
+
progress_callback: Callable[[int, int], None] | None = None,
|
| 114 |
+
use_possible_category: bool = False,
|
| 115 |
+
reasoning_effort: str = "medium",
|
| 116 |
+
provider: str = "openai",
|
| 117 |
+
) -> tuple[list[ClassifiedCandidate], GPTReviewRunInfo]:
|
| 118 |
+
"""Use GPT to adjudicate only classifications below the confidence cutoff.
|
| 119 |
+
|
| 120 |
+
Results at or above the threshold are preserved exactly. Reviewed results
|
| 121 |
+
replace the corresponding stage-2 labels while retaining their original
|
| 122 |
+
list positions and recording the prior label/confidence in the explanation.
|
| 123 |
+
"""
|
| 124 |
+
eligible_count = sum(item.confidence < confidence_threshold for item in classified)
|
| 125 |
+
uncertain = select_gpt_review_candidates(
|
| 126 |
+
classified,
|
| 127 |
+
confidence_threshold=confidence_threshold,
|
| 128 |
+
max_candidates=max_candidates,
|
| 129 |
+
)
|
| 130 |
+
if not uncertain:
|
| 131 |
+
return list(classified), GPTReviewRunInfo(
|
| 132 |
+
confidence_threshold=confidence_threshold,
|
| 133 |
+
max_candidates=max_candidates,
|
| 134 |
+
eligible_candidates=eligible_count,
|
| 135 |
+
candidates_reviewed=0,
|
| 136 |
+
labels_changed=0,
|
| 137 |
+
narrow_to_exclude=0,
|
| 138 |
+
exclude_to_narrow=0,
|
| 139 |
+
reasoning_effort=reasoning_effort,
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
reviewed = llm_classify(
|
| 143 |
+
uncertain,
|
| 144 |
+
edf,
|
| 145 |
+
provider=provider,
|
| 146 |
+
model=model,
|
| 147 |
+
max_retries=max_retries,
|
| 148 |
+
batch_size=batch_size,
|
| 149 |
+
progress_callback=progress_callback,
|
| 150 |
+
use_possible_category=use_possible_category,
|
| 151 |
+
adaptive_stopping=None,
|
| 152 |
+
reasoning_effort=reasoning_effort,
|
| 153 |
+
)
|
| 154 |
+
reviewed_by_key = {(item.code, item.vocabulary): item for item in reviewed}
|
| 155 |
+
|
| 156 |
+
merged: list[ClassifiedCandidate] = []
|
| 157 |
+
labels_changed = 0
|
| 158 |
+
narrow_to_exclude = 0
|
| 159 |
+
exclude_to_narrow = 0
|
| 160 |
+
for original in classified:
|
| 161 |
+
replacement = reviewed_by_key.get((original.code, original.vocabulary))
|
| 162 |
+
if replacement is None:
|
| 163 |
+
merged.append(original)
|
| 164 |
+
continue
|
| 165 |
+
|
| 166 |
+
if replacement.label != original.label:
|
| 167 |
+
labels_changed += 1
|
| 168 |
+
if original.label == "Narrow" and replacement.label == "Exclude":
|
| 169 |
+
narrow_to_exclude += 1
|
| 170 |
+
elif original.label == "Exclude" and replacement.label == "Narrow":
|
| 171 |
+
exclude_to_narrow += 1
|
| 172 |
+
merged.append(
|
| 173 |
+
ClassifiedCandidate(
|
| 174 |
+
code=original.code,
|
| 175 |
+
description=original.description,
|
| 176 |
+
vocabulary=original.vocabulary,
|
| 177 |
+
label=replacement.label,
|
| 178 |
+
confidence=replacement.confidence,
|
| 179 |
+
explanation=(
|
| 180 |
+
"GPT review of low-confidence stage-2 result "
|
| 181 |
+
f"(previous label={original.label}, confidence={original.confidence:.2f}). "
|
| 182 |
+
+ replacement.explanation
|
| 183 |
+
),
|
| 184 |
+
relevance_score=original.relevance_score,
|
| 185 |
+
retrieval_score=original.retrieval_score,
|
| 186 |
+
)
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
return merged, GPTReviewRunInfo(
|
| 190 |
+
confidence_threshold=confidence_threshold,
|
| 191 |
+
max_candidates=max_candidates,
|
| 192 |
+
eligible_candidates=eligible_count,
|
| 193 |
+
candidates_reviewed=len(uncertain),
|
| 194 |
+
labels_changed=labels_changed,
|
| 195 |
+
narrow_to_exclude=narrow_to_exclude,
|
| 196 |
+
exclude_to_narrow=exclude_to_narrow,
|
| 197 |
+
reasoning_effort=reasoning_effort,
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def llm_classify(
|
| 202 |
+
candidates: Sequence[ClassificationCandidate],
|
| 203 |
+
edf: EventDefinitionForm,
|
| 204 |
+
provider: str = "auto",
|
| 205 |
+
model: str | None = None,
|
| 206 |
+
max_retries: int = 3,
|
| 207 |
+
batch_size: int = 10,
|
| 208 |
+
progress_callback: Callable[[int, int], None] | None = None,
|
| 209 |
+
use_possible_category: bool = False,
|
| 210 |
+
adaptive_stopping: AdaptiveStoppingConfig | None = None,
|
| 211 |
+
run_info_callback: Callable[[ClassificationRunInfo], None] | None = None,
|
| 212 |
+
reasoning_effort: str | None = None,
|
| 213 |
+
thinking_budget: int | None = None,
|
| 214 |
+
) -> list[ClassifiedCandidate]:
|
| 215 |
+
"""Classify candidates as Narrow/Exclude, optionally allowing Possible.
|
| 216 |
+
|
| 217 |
+
Parameters
|
| 218 |
+
----------
|
| 219 |
+
candidates : sequence
|
| 220 |
+
Similarity-ranked hybrid or LLM-ranked candidates.
|
| 221 |
+
edf : EventDefinitionForm
|
| 222 |
+
Structured phenotype definition providing semantic context.
|
| 223 |
+
provider : str
|
| 224 |
+
"auto", "anthropic", or "openai".
|
| 225 |
+
model : str, optional
|
| 226 |
+
Model identifier. Defaults to a provider-specific default.
|
| 227 |
+
max_retries : int
|
| 228 |
+
Number of LLM call attempts before failing.
|
| 229 |
+
batch_size : int
|
| 230 |
+
Maximum number of candidates to include in a single LLM request.
|
| 231 |
+
progress_callback : callable, optional
|
| 232 |
+
Callback invoked after each completed batch as ``callback(completed, total)``.
|
| 233 |
+
use_possible_category : bool
|
| 234 |
+
Enable three-way Narrow/Possible/Exclude labeling. Defaults to False.
|
| 235 |
+
adaptive_stopping : AdaptiveStoppingConfig, optional
|
| 236 |
+
Stop after Narrow results remain at or below the configured threshold
|
| 237 |
+
for the configured number of consecutive batches.
|
| 238 |
+
run_info_callback : callable, optional
|
| 239 |
+
Invoked once with the completed/stopped run summary.
|
| 240 |
+
reasoning_effort : str, optional
|
| 241 |
+
OpenAI reasoning effort, such as ``"none"`` or ``"medium"``.
|
| 242 |
+
|
| 243 |
+
Returns
|
| 244 |
+
-------
|
| 245 |
+
list[ClassifiedCandidate]
|
| 246 |
+
"""
|
| 247 |
+
if not candidates:
|
| 248 |
+
if run_info_callback is not None:
|
| 249 |
+
run_info_callback(
|
| 250 |
+
ClassificationRunInfo(0, 0, 0, (), False, "No candidates to classify.")
|
| 251 |
+
)
|
| 252 |
+
return []
|
| 253 |
+
|
| 254 |
+
if batch_size < 1:
|
| 255 |
+
raise ValueError("batch_size must be at least 1")
|
| 256 |
+
|
| 257 |
+
resolved_provider = resolve_provider(provider)
|
| 258 |
+
if resolved_provider in {"jev", "openai_decisions", "laya", "jpt"}:
|
| 259 |
+
from src.llm.decisions import DEFAULT_DECISION_MODELS
|
| 260 |
+
return _jev_classify(
|
| 261 |
+
candidates,
|
| 262 |
+
edf,
|
| 263 |
+
model=model or DEFAULT_DECISION_MODELS[resolved_provider],
|
| 264 |
+
max_retries=max_retries,
|
| 265 |
+
batch_size=batch_size,
|
| 266 |
+
progress_callback=progress_callback,
|
| 267 |
+
use_possible_category=use_possible_category,
|
| 268 |
+
adaptive_stopping=adaptive_stopping,
|
| 269 |
+
run_info_callback=run_info_callback,
|
| 270 |
+
provider=resolved_provider,
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
results: list[ClassifiedCandidate] = []
|
| 274 |
+
narrow_counts: list[int] = []
|
| 275 |
+
sparse_streak = 0
|
| 276 |
+
|
| 277 |
+
total = len(candidates)
|
| 278 |
+
for start in range(0, total, batch_size):
|
| 279 |
+
batch = candidates[start : start + batch_size]
|
| 280 |
+
batch_result_start = len(results)
|
| 281 |
+
lookup = {(c.code, c.vocabulary): c for c in batch}
|
| 282 |
+
payload = [
|
| 283 |
+
{"code": c.code, "vocabulary": c.vocabulary, "description": c.description}
|
| 284 |
+
for c in batch
|
| 285 |
+
]
|
| 286 |
+
prompt = build_classify_prompt(edf, payload, use_possible_category=use_possible_category)
|
| 287 |
+
response = call_llm_json(
|
| 288 |
+
system_prompt=classify_system_prompt(use_possible_category),
|
| 289 |
+
user_prompt=prompt,
|
| 290 |
+
provider=resolved_provider,
|
| 291 |
+
model=model,
|
| 292 |
+
max_retries=max_retries,
|
| 293 |
+
reasoning_effort=reasoning_effort,
|
| 294 |
+
**({'thinking_budget':thinking_budget} if thinking_budget is not None else {}),
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
classifications_raw = response.get("classifications", [])
|
| 298 |
+
batch_seen: set[tuple[str, str]] = set()
|
| 299 |
+
for item in classifications_raw:
|
| 300 |
+
key = (item.get("code", ""), item.get("vocabulary", ""))
|
| 301 |
+
source = lookup.get(key)
|
| 302 |
+
if source is None or key in batch_seen:
|
| 303 |
+
continue
|
| 304 |
+
fallback_label = "Possible" if use_possible_category else "Exclude"
|
| 305 |
+
valid_labels = VALID_LABELS if use_possible_category else {"Narrow", "Exclude"}
|
| 306 |
+
label = item.get("label", fallback_label)
|
| 307 |
+
if label not in valid_labels:
|
| 308 |
+
logger.warning(
|
| 309 |
+
"LLM returned invalid label %r for code %s; defaulting to %r",
|
| 310 |
+
label,
|
| 311 |
+
key,
|
| 312 |
+
fallback_label,
|
| 313 |
+
)
|
| 314 |
+
label = fallback_label
|
| 315 |
+
batch_seen.add(key)
|
| 316 |
+
results.append(
|
| 317 |
+
ClassifiedCandidate(
|
| 318 |
+
code=source.code,
|
| 319 |
+
description=source.description,
|
| 320 |
+
vocabulary=source.vocabulary,
|
| 321 |
+
label=label,
|
| 322 |
+
confidence=float(item.get("confidence", 0.0)),
|
| 323 |
+
explanation=str(item.get("explanation", "")),
|
| 324 |
+
relevance_score=float(getattr(source, "relevance_score", 0.0)),
|
| 325 |
+
retrieval_score=float(
|
| 326 |
+
getattr(source, "retrieval_score", getattr(source, "score", 0.0))
|
| 327 |
+
),
|
| 328 |
+
)
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
# Keep every candidate in the result. A missing response receives the
|
| 332 |
+
# least-committal enabled label and zero confidence so review selection
|
| 333 |
+
# can annotate it without filtering or altering any other result.
|
| 334 |
+
for c in batch:
|
| 335 |
+
key = (c.code, c.vocabulary)
|
| 336 |
+
if key not in batch_seen:
|
| 337 |
+
results.append(
|
| 338 |
+
ClassifiedCandidate(
|
| 339 |
+
code=c.code,
|
| 340 |
+
description=c.description,
|
| 341 |
+
vocabulary=c.vocabulary,
|
| 342 |
+
label="Possible" if use_possible_category else "Exclude",
|
| 343 |
+
confidence=0.0,
|
| 344 |
+
explanation="No classification returned by LLM; routed for manual review.",
|
| 345 |
+
relevance_score=float(getattr(c, "relevance_score", 0.0)),
|
| 346 |
+
retrieval_score=float(
|
| 347 |
+
getattr(c, "retrieval_score", getattr(c, "score", 0.0))
|
| 348 |
+
),
|
| 349 |
+
)
|
| 350 |
+
)
|
| 351 |
+
|
| 352 |
+
if progress_callback is not None:
|
| 353 |
+
progress_callback(min(start + len(batch), total), total)
|
| 354 |
+
|
| 355 |
+
narrow_count = sum(
|
| 356 |
+
item.label == "Narrow" for item in results[batch_result_start:]
|
| 357 |
+
)
|
| 358 |
+
narrow_counts.append(narrow_count)
|
| 359 |
+
sparse_streak = (
|
| 360 |
+
sparse_streak + 1
|
| 361 |
+
if adaptive_stopping is not None
|
| 362 |
+
and narrow_count <= adaptive_stopping.sparse_narrow_threshold
|
| 363 |
+
else 0
|
| 364 |
+
)
|
| 365 |
+
if _adaptive_stop_reached(adaptive_stopping, len(narrow_counts), sparse_streak):
|
| 366 |
+
break
|
| 367 |
+
|
| 368 |
+
_report_run_info(
|
| 369 |
+
run_info_callback,
|
| 370 |
+
total=total,
|
| 371 |
+
results=results,
|
| 372 |
+
narrow_counts=narrow_counts,
|
| 373 |
+
adaptive_stopping=adaptive_stopping,
|
| 374 |
+
)
|
| 375 |
+
return results
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def _jev_classify(
|
| 379 |
+
candidates: Sequence[ClassificationCandidate],
|
| 380 |
+
edf: EventDefinitionForm,
|
| 381 |
+
model: str,
|
| 382 |
+
max_retries: int,
|
| 383 |
+
batch_size: int,
|
| 384 |
+
progress_callback: Callable[[int, int], None] | None,
|
| 385 |
+
use_possible_category: bool,
|
| 386 |
+
adaptive_stopping: AdaptiveStoppingConfig | None,
|
| 387 |
+
run_info_callback: Callable[[ClassificationRunInfo], None] | None,
|
| 388 |
+
provider: str = "jev",
|
| 389 |
+
) -> list[ClassifiedCandidate]:
|
| 390 |
+
"""Classify candidates with Jev typed choice questions."""
|
| 391 |
+
effective_batch_size = min(batch_size, 20)
|
| 392 |
+
labels = {
|
| 393 |
+
"Narrow": "The code is sufficiently specific to the phenotype and meets the Narrow criteria.",
|
| 394 |
+
"Exclude": "The code is irrelevant, insufficiently specific, historical, or explicitly excluded.",
|
| 395 |
+
}
|
| 396 |
+
if use_possible_category:
|
| 397 |
+
labels = {
|
| 398 |
+
"Narrow": labels["Narrow"],
|
| 399 |
+
"Possible": "The code is related but not sufficiently specific in every context.",
|
| 400 |
+
"Exclude": labels["Exclude"],
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
results: list[ClassifiedCandidate] = []
|
| 404 |
+
narrow_counts: list[int] = []
|
| 405 |
+
sparse_streak = 0
|
| 406 |
+
total = len(candidates)
|
| 407 |
+
state = "Phenotype definition:\n" + edf.to_prompt_context(
|
| 408 |
+
include_possible=use_possible_category
|
| 409 |
+
)
|
| 410 |
+
for start in range(0, total, effective_batch_size):
|
| 411 |
+
batch = candidates[start : start + effective_batch_size]
|
| 412 |
+
batch_result_start = len(results)
|
| 413 |
+
questions = {
|
| 414 |
+
f"candidate_{offset}": {
|
| 415 |
+
"type": "choice",
|
| 416 |
+
"instructions": (
|
| 417 |
+
"Classify this clinical terminology candidate for the phenotype: "
|
| 418 |
+
f"code={candidate.code}; vocabulary={candidate.vocabulary}; "
|
| 419 |
+
f"description={candidate.description}"
|
| 420 |
+
),
|
| 421 |
+
"criteria": labels,
|
| 422 |
+
}
|
| 423 |
+
for offset, candidate in enumerate(batch)
|
| 424 |
+
}
|
| 425 |
+
if provider == "jev":
|
| 426 |
+
decision_call = call_jev_decisions
|
| 427 |
+
else:
|
| 428 |
+
from src.llm.decisions import call_decisions
|
| 429 |
+
decision_call = lambda **kwargs: call_decisions(provider=provider, **kwargs)
|
| 430 |
+
response = decision_call(
|
| 431 |
+
state=state,
|
| 432 |
+
questions=questions,
|
| 433 |
+
model=model,
|
| 434 |
+
max_retries=max_retries,
|
| 435 |
+
)
|
| 436 |
+
answers = response.get("answers", {})
|
| 437 |
+
for offset, candidate in enumerate(batch):
|
| 438 |
+
answer = answers.get(f"candidate_{offset}", {})
|
| 439 |
+
probabilities = answer.get("probabilities", {})
|
| 440 |
+
fallback_label = "Possible" if use_possible_category else "Exclude"
|
| 441 |
+
label = answer.get("choice", fallback_label)
|
| 442 |
+
if label not in labels:
|
| 443 |
+
label = fallback_label
|
| 444 |
+
confidence = answer.get("confidence")
|
| 445 |
+
if confidence is None:
|
| 446 |
+
confidence = max((float(value) for value in probabilities.values()), default=0.0)
|
| 447 |
+
results.append(
|
| 448 |
+
ClassifiedCandidate(
|
| 449 |
+
code=candidate.code,
|
| 450 |
+
description=candidate.description,
|
| 451 |
+
vocabulary=candidate.vocabulary,
|
| 452 |
+
label=label,
|
| 453 |
+
confidence=float(confidence),
|
| 454 |
+
explanation=f"{provider} choice probabilities: " + json.dumps(probabilities, sort_keys=True)
|
| 455 |
+
+ "; confidence source: " + answer.get("confidence_source", "provider" if answer.get("confidence") is not None else "max_probability"),
|
| 456 |
+
relevance_score=float(getattr(candidate, "relevance_score", 0.0)),
|
| 457 |
+
retrieval_score=float(
|
| 458 |
+
getattr(
|
| 459 |
+
candidate,
|
| 460 |
+
"retrieval_score",
|
| 461 |
+
getattr(candidate, "score", 0.0),
|
| 462 |
+
)
|
| 463 |
+
),
|
| 464 |
+
)
|
| 465 |
+
)
|
| 466 |
+
if progress_callback is not None:
|
| 467 |
+
progress_callback(min(start + len(batch), total), total)
|
| 468 |
+
narrow_count = sum(
|
| 469 |
+
item.label == "Narrow" for item in results[batch_result_start:]
|
| 470 |
+
)
|
| 471 |
+
narrow_counts.append(narrow_count)
|
| 472 |
+
sparse_streak = (
|
| 473 |
+
sparse_streak + 1
|
| 474 |
+
if adaptive_stopping is not None
|
| 475 |
+
and narrow_count <= adaptive_stopping.sparse_narrow_threshold
|
| 476 |
+
else 0
|
| 477 |
+
)
|
| 478 |
+
if _adaptive_stop_reached(adaptive_stopping, len(narrow_counts), sparse_streak):
|
| 479 |
+
break
|
| 480 |
+
|
| 481 |
+
_report_run_info(
|
| 482 |
+
run_info_callback,
|
| 483 |
+
total=total,
|
| 484 |
+
results=results,
|
| 485 |
+
narrow_counts=narrow_counts,
|
| 486 |
+
adaptive_stopping=adaptive_stopping,
|
| 487 |
+
)
|
| 488 |
+
return results
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def _adaptive_stop_reached(
|
| 492 |
+
config: AdaptiveStoppingConfig | None,
|
| 493 |
+
batches_completed: int,
|
| 494 |
+
sparse_streak: int,
|
| 495 |
+
) -> bool:
|
| 496 |
+
return bool(
|
| 497 |
+
config is not None
|
| 498 |
+
and batches_completed >= config.minimum_batches
|
| 499 |
+
and sparse_streak >= config.consecutive_sparse_batches
|
| 500 |
+
)
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def _report_run_info(
|
| 504 |
+
callback: Callable[[ClassificationRunInfo], None] | None,
|
| 505 |
+
*,
|
| 506 |
+
total: int,
|
| 507 |
+
results: list[ClassifiedCandidate],
|
| 508 |
+
narrow_counts: list[int],
|
| 509 |
+
adaptive_stopping: AdaptiveStoppingConfig | None,
|
| 510 |
+
) -> None:
|
| 511 |
+
if callback is None:
|
| 512 |
+
return
|
| 513 |
+
stopped_early = len(results) < total
|
| 514 |
+
if stopped_early and adaptive_stopping is not None:
|
| 515 |
+
reason = (
|
| 516 |
+
f"Stopped after {adaptive_stopping.consecutive_sparse_batches} consecutive "
|
| 517 |
+
f"batches with at most {adaptive_stopping.sparse_narrow_threshold} Narrow "
|
| 518 |
+
"result(s) per batch."
|
| 519 |
+
)
|
| 520 |
+
else:
|
| 521 |
+
reason = "All available candidates were classified."
|
| 522 |
+
callback(
|
| 523 |
+
ClassificationRunInfo(
|
| 524 |
+
candidates_available=total,
|
| 525 |
+
candidates_classified=len(results),
|
| 526 |
+
batches_completed=len(narrow_counts),
|
| 527 |
+
narrow_counts_by_batch=tuple(narrow_counts),
|
| 528 |
+
stopped_early=stopped_early,
|
| 529 |
+
stop_reason=reason,
|
| 530 |
+
)
|
| 531 |
+
)
|
src/llm/client.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared model clients for generative JSON calls and Jev typed decisions.
|
| 2 |
+
|
| 3 |
+
Automatic provider selection preserves the original Anthropic-then-OpenAI
|
| 4 |
+
precedence; Google is selected explicitly by experiments that require it.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import time
|
| 13 |
+
import uuid
|
| 14 |
+
from typing import Any
|
| 15 |
+
from urllib.error import HTTPError, URLError
|
| 16 |
+
from urllib.request import Request, urlopen
|
| 17 |
+
|
| 18 |
+
from src.utils.logging import get_logger
|
| 19 |
+
|
| 20 |
+
logger = get_logger(__name__)
|
| 21 |
+
|
| 22 |
+
_JSON_BLOCK_RE = re.compile(r"\{.*\}", re.DOTALL)
|
| 23 |
+
|
| 24 |
+
DEFAULT_ANTHROPIC_MODEL = "claude-haiku-4-8"
|
| 25 |
+
DEFAULT_OPENAI_MODEL = "gpt-6-luna"
|
| 26 |
+
DEFAULT_GOOGLE_MODEL = "gemini-3-flash-preview"
|
| 27 |
+
DEFAULT_JEV_MODEL = "jev-latest"
|
| 28 |
+
DEFAULT_JEV_API_URL = "https://api.typesafe.ai/v1/systemone"
|
| 29 |
+
|
| 30 |
+
_PLACEHOLDER_KEY_VALUES = {"", "your-api-key-here"}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class LLMCallError(RuntimeError):
|
| 34 |
+
"""Raised when the LLM call fails or returns unparsable output after retries."""
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _has_real_key(env_var: str) -> bool:
|
| 38 |
+
"""Return True if *env_var* is set to a non-empty, non-placeholder value.
|
| 39 |
+
|
| 40 |
+
Guards against `.env` files copied from `.env.example` where a key is
|
| 41 |
+
present but still holds the literal placeholder text.
|
| 42 |
+
"""
|
| 43 |
+
value = os.environ.get(env_var, "").strip()
|
| 44 |
+
return value.lower() not in _PLACEHOLDER_KEY_VALUES
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def resolve_provider(provider: str = "auto") -> str:
|
| 48 |
+
"""Resolve the LLM provider to use, based on explicit choice or available API keys.
|
| 49 |
+
|
| 50 |
+
Parameters
|
| 51 |
+
----------
|
| 52 |
+
provider : str
|
| 53 |
+
One of ``"auto"``, ``"anthropic"``, ``"openai"``, ``"google"``, or ``"jev"``.
|
| 54 |
+
|
| 55 |
+
Returns
|
| 56 |
+
-------
|
| 57 |
+
str
|
| 58 |
+
The resolved provider name.
|
| 59 |
+
"""
|
| 60 |
+
provider = (provider or "auto").lower()
|
| 61 |
+
if provider in {"anthropic", "openai", "google", "jev", "openai_decisions", "laya", "jpt", "mlx"}:
|
| 62 |
+
return provider
|
| 63 |
+
if provider != "auto":
|
| 64 |
+
raise LLMCallError(
|
| 65 |
+
f"Unknown LLM provider: {provider!r}. "
|
| 66 |
+
"Use 'auto', 'anthropic', 'openai', 'google', or 'jev'."
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
if _has_real_key("ANTHROPIC_API_KEY"):
|
| 70 |
+
return "anthropic"
|
| 71 |
+
if _has_real_key("OPENAI_API_KEY"):
|
| 72 |
+
return "openai"
|
| 73 |
+
if _has_real_key("JEV_API_KEY"):
|
| 74 |
+
return "jev"
|
| 75 |
+
raise LLMCallError(
|
| 76 |
+
"No LLM API key found. Set ANTHROPIC_API_KEY and/or OPENAI_API_KEY in your .env file."
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def default_model_for(provider: str) -> str:
|
| 81 |
+
"""Return the default model identifier for the given provider."""
|
| 82 |
+
if provider == "anthropic":
|
| 83 |
+
return DEFAULT_ANTHROPIC_MODEL
|
| 84 |
+
if provider == "google":
|
| 85 |
+
return DEFAULT_GOOGLE_MODEL
|
| 86 |
+
if provider == "jev":
|
| 87 |
+
return DEFAULT_JEV_MODEL
|
| 88 |
+
if provider in {"laya", "jpt", "mlx"}:
|
| 89 |
+
return {"laya": "multilingual", "jpt": "kirp/jpt-4b",
|
| 90 |
+
"mlx": "mlx-community/Qwen3.5-9B-8bit"}[provider]
|
| 91 |
+
return DEFAULT_OPENAI_MODEL
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _get_anthropic_client():
|
| 95 |
+
"""Lazily construct the Anthropic client so importing this module does not
|
| 96 |
+
require an API key to be set (e.g. for unit tests that mock calls)."""
|
| 97 |
+
import anthropic
|
| 98 |
+
|
| 99 |
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
| 100 |
+
if not api_key:
|
| 101 |
+
raise LLMCallError("ANTHROPIC_API_KEY environment variable is not set.")
|
| 102 |
+
return anthropic.Anthropic(api_key=api_key)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _get_openai_client():
|
| 106 |
+
"""Lazily construct the OpenAI client so importing this module does not
|
| 107 |
+
require an API key to be set (e.g. for unit tests that mock calls)."""
|
| 108 |
+
import openai
|
| 109 |
+
|
| 110 |
+
api_key = os.environ.get("OPENAI_API_KEY")
|
| 111 |
+
if not api_key:
|
| 112 |
+
raise LLMCallError("OPENAI_API_KEY environment variable is not set.")
|
| 113 |
+
return openai.OpenAI(api_key=api_key)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _get_google_client():
|
| 117 |
+
"""Lazily construct the Google Gen AI client."""
|
| 118 |
+
from google import genai
|
| 119 |
+
|
| 120 |
+
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY")
|
| 121 |
+
if not api_key:
|
| 122 |
+
raise LLMCallError("GOOGLE_API_KEY or GEMINI_API_KEY environment variable is not set.")
|
| 123 |
+
return genai.Client(api_key=api_key)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def extract_json(text: str) -> dict[str, Any]:
|
| 127 |
+
"""Extract and parse the first JSON object found in *text*.
|
| 128 |
+
|
| 129 |
+
Handles cases where the model wraps JSON in markdown code fences or adds
|
| 130 |
+
surrounding commentary despite instructions not to.
|
| 131 |
+
"""
|
| 132 |
+
text = text.strip()
|
| 133 |
+
try:
|
| 134 |
+
return json.loads(text)
|
| 135 |
+
except json.JSONDecodeError:
|
| 136 |
+
pass
|
| 137 |
+
|
| 138 |
+
match = _JSON_BLOCK_RE.search(text)
|
| 139 |
+
if match:
|
| 140 |
+
return json.loads(match.group(0))
|
| 141 |
+
|
| 142 |
+
raise ValueError(f"Could not extract JSON from LLM response: {text[:200]!r}")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _call_anthropic(system_prompt: str, user_prompt: str, model: str, max_tokens: int) -> str:
|
| 146 |
+
client = _get_anthropic_client()
|
| 147 |
+
response = client.messages.create(
|
| 148 |
+
model=model,
|
| 149 |
+
max_tokens=max_tokens,
|
| 150 |
+
system=system_prompt,
|
| 151 |
+
messages=[{"role": "user", "content": user_prompt}],
|
| 152 |
+
)
|
| 153 |
+
return "".join(
|
| 154 |
+
block.text for block in response.content if getattr(block, "type", None) == "text"
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
_REASONING_MODEL_PREFIXES = ("gpt-5", "gpt-6", "o1", "o3", "o4")
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _is_reasoning_model(model: str) -> bool:
|
| 162 |
+
"""Whether *model* is an OpenAI reasoning model (GPT-5/6 and o-series).
|
| 163 |
+
|
| 164 |
+
Reasoning models spend part of their completion token budget on hidden
|
| 165 |
+
reasoning tokens before producing visible output, and support a
|
| 166 |
+
``reasoning_effort`` parameter to control that trade-off. Non-reasoning
|
| 167 |
+
models (e.g. gpt-4o) don't accept that parameter, so it must only be sent
|
| 168 |
+
for models in this family.
|
| 169 |
+
"""
|
| 170 |
+
return model.lower().startswith(_REASONING_MODEL_PREFIXES)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _call_openai(
|
| 174 |
+
system_prompt: str,
|
| 175 |
+
user_prompt: str,
|
| 176 |
+
model: str,
|
| 177 |
+
max_tokens: int,
|
| 178 |
+
reasoning_effort: str | None = None,
|
| 179 |
+
) -> str:
|
| 180 |
+
client = _get_openai_client()
|
| 181 |
+
kwargs: dict[str, Any] = {}
|
| 182 |
+
if reasoning_effort and _is_reasoning_model(model):
|
| 183 |
+
kwargs["reasoning_effort"] = reasoning_effort
|
| 184 |
+
|
| 185 |
+
response = client.chat.completions.create(
|
| 186 |
+
model=model,
|
| 187 |
+
max_completion_tokens=max_tokens,
|
| 188 |
+
response_format={"type": "json_object"},
|
| 189 |
+
messages=[
|
| 190 |
+
{"role": "system", "content": system_prompt},
|
| 191 |
+
{"role": "user", "content": user_prompt},
|
| 192 |
+
],
|
| 193 |
+
**kwargs,
|
| 194 |
+
)
|
| 195 |
+
choice = response.choices[0]
|
| 196 |
+
content = choice.message.content or ""
|
| 197 |
+
if not content.strip():
|
| 198 |
+
# Most commonly caused by a reasoning model spending its entire
|
| 199 |
+
# max_completion_tokens budget on hidden reasoning, leaving nothing
|
| 200 |
+
# for the visible answer (finish_reason == "length" in that case).
|
| 201 |
+
raise ValueError(
|
| 202 |
+
f"Empty response content from model '{model}' "
|
| 203 |
+
f"(finish_reason={choice.finish_reason!r}, usage={response.usage!r}). "
|
| 204 |
+
"Consider increasing max_tokens or lowering reasoning_effort."
|
| 205 |
+
)
|
| 206 |
+
return content
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def _call_google(system_prompt: str, user_prompt: str, model: str, max_tokens: int) -> str:
|
| 210 |
+
from google.genai import types
|
| 211 |
+
|
| 212 |
+
client = _get_google_client()
|
| 213 |
+
response = client.models.generate_content(
|
| 214 |
+
model=model,
|
| 215 |
+
contents=user_prompt,
|
| 216 |
+
config=types.GenerateContentConfig(
|
| 217 |
+
system_instruction=system_prompt,
|
| 218 |
+
response_mime_type="application/json",
|
| 219 |
+
max_output_tokens=max_tokens,
|
| 220 |
+
),
|
| 221 |
+
)
|
| 222 |
+
text = response.text or ""
|
| 223 |
+
if not text.strip():
|
| 224 |
+
raise ValueError(f"Empty response content from Google model '{model}'.")
|
| 225 |
+
return text
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def call_jev_decisions(
|
| 229 |
+
state: str,
|
| 230 |
+
questions: dict[str, dict[str, Any]],
|
| 231 |
+
model: str = DEFAULT_JEV_MODEL,
|
| 232 |
+
max_retries: int = 3,
|
| 233 |
+
) -> dict[str, Any]:
|
| 234 |
+
"""Submit typed questions to Jev and return its structured response.
|
| 235 |
+
|
| 236 |
+
Jev is not a generative chat model, so it does not use
|
| 237 |
+
:func:`call_llm_json`. A request shares one phenotype state across up to 20
|
| 238 |
+
candidate-classification questions and receives declared labels plus
|
| 239 |
+
probability distributions.
|
| 240 |
+
"""
|
| 241 |
+
api_key = (
|
| 242 |
+
os.environ.get("TYPESAFE_API_KEY", "").strip()
|
| 243 |
+
or os.environ.get("JEV_API_KEY", "").strip()
|
| 244 |
+
)
|
| 245 |
+
if not api_key:
|
| 246 |
+
raise LLMCallError(
|
| 247 |
+
"TYPESAFE_API_KEY or JEV_API_KEY environment variable is not set."
|
| 248 |
+
)
|
| 249 |
+
if not questions or len(questions) > 20:
|
| 250 |
+
raise ValueError("Jev requests must contain between 1 and 20 questions.")
|
| 251 |
+
|
| 252 |
+
url = os.environ.get("JEV_API_URL", DEFAULT_JEV_API_URL).strip() or DEFAULT_JEV_API_URL
|
| 253 |
+
payload = json.dumps(
|
| 254 |
+
{"model": model, "state": state, "questions": questions},
|
| 255 |
+
separators=(",", ":"),
|
| 256 |
+
).encode("utf-8")
|
| 257 |
+
idempotency_key = str(uuid.uuid5(uuid.NAMESPACE_URL, payload.decode("utf-8")))
|
| 258 |
+
retryable_statuses = {429, 502, 503, 504, 529}
|
| 259 |
+
last_error: Exception | None = None
|
| 260 |
+
|
| 261 |
+
for attempt in range(1, max_retries + 1):
|
| 262 |
+
request = Request(
|
| 263 |
+
url,
|
| 264 |
+
data=payload,
|
| 265 |
+
method="POST",
|
| 266 |
+
headers={
|
| 267 |
+
"Authorization": f"Bearer {api_key}",
|
| 268 |
+
"Content-Type": "application/json",
|
| 269 |
+
"Accept": "application/json",
|
| 270 |
+
"Idempotency-Key": idempotency_key,
|
| 271 |
+
},
|
| 272 |
+
)
|
| 273 |
+
try:
|
| 274 |
+
with urlopen(request, timeout=60) as response:
|
| 275 |
+
result = json.loads(response.read().decode("utf-8"))
|
| 276 |
+
if not isinstance(result.get("answers"), dict):
|
| 277 |
+
raise ValueError("Jev response is missing an 'answers' object.")
|
| 278 |
+
return result
|
| 279 |
+
except HTTPError as exc:
|
| 280 |
+
try:
|
| 281 |
+
error_body = exc.read().decode("utf-8", errors="replace")[:500]
|
| 282 |
+
except Exception: # noqa: BLE001 - preserve the original HTTP status
|
| 283 |
+
error_body = ""
|
| 284 |
+
last_error = RuntimeError(
|
| 285 |
+
f"Jev HTTP {exc.code} from {url}"
|
| 286 |
+
+ (f": {error_body}" if error_body else "")
|
| 287 |
+
)
|
| 288 |
+
if exc.code not in retryable_statuses or attempt >= max_retries:
|
| 289 |
+
break
|
| 290 |
+
retry_after = exc.headers.get("Retry-After") if exc.headers else None
|
| 291 |
+
delay = min(float(retry_after), 30.0) if retry_after else min(2 ** (attempt - 1), 8)
|
| 292 |
+
time.sleep(delay)
|
| 293 |
+
except (URLError, TimeoutError, ValueError, json.JSONDecodeError) as exc:
|
| 294 |
+
last_error = exc
|
| 295 |
+
if attempt < max_retries:
|
| 296 |
+
time.sleep(min(2 ** (attempt - 1), 8))
|
| 297 |
+
|
| 298 |
+
raise LLMCallError(
|
| 299 |
+
f"Jev decision call failed after {max_retries} attempts: {last_error}"
|
| 300 |
+
) from last_error
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def call_llm_json(
|
| 304 |
+
system_prompt: str,
|
| 305 |
+
user_prompt: str,
|
| 306 |
+
provider: str = "auto",
|
| 307 |
+
model: str | None = None,
|
| 308 |
+
max_retries: int = 3,
|
| 309 |
+
max_tokens: int = 8192,
|
| 310 |
+
reasoning_effort: str | None = None,
|
| 311 |
+
thinking_budget: int | None = None,
|
| 312 |
+
) -> dict[str, Any]:
|
| 313 |
+
"""Call the configured LLM provider and parse a JSON object from the response,
|
| 314 |
+
retrying on transient failures or malformed JSON output.
|
| 315 |
+
|
| 316 |
+
Parameters
|
| 317 |
+
----------
|
| 318 |
+
system_prompt : str
|
| 319 |
+
System prompt establishing the LLM's role and output format.
|
| 320 |
+
user_prompt : str
|
| 321 |
+
The task-specific user prompt.
|
| 322 |
+
provider : str
|
| 323 |
+
``"auto"``, ``"anthropic"``, ``"openai"``, or ``"google"``. ``"auto"`` selects
|
| 324 |
+
Anthropic if ``ANTHROPIC_API_KEY`` is set, otherwise OpenAI.
|
| 325 |
+
model : str, optional
|
| 326 |
+
Model identifier. Defaults to a sensible default for the resolved provider.
|
| 327 |
+
max_retries : int
|
| 328 |
+
Number of attempts before raising ``LLMCallError``.
|
| 329 |
+
max_tokens : int
|
| 330 |
+
Maximum completion tokens to generate. For OpenAI reasoning models
|
| 331 |
+
(gpt-5/o1/o3/o4), this budget is shared with hidden reasoning tokens,
|
| 332 |
+
so it should be generous enough to leave room for the visible answer.
|
| 333 |
+
reasoning_effort : str, optional
|
| 334 |
+
Passed as ``reasoning_effort`` to OpenAI reasoning models only (e.g.
|
| 335 |
+
``"low"``/``"medium"``/``"high"``); ignored for Anthropic and for
|
| 336 |
+
non-reasoning OpenAI models.
|
| 337 |
+
|
| 338 |
+
Returns
|
| 339 |
+
-------
|
| 340 |
+
dict
|
| 341 |
+
Parsed JSON response body.
|
| 342 |
+
"""
|
| 343 |
+
resolved_provider = resolve_provider(provider)
|
| 344 |
+
if resolved_provider in {"jev", "openai_decisions", "laya", "jpt"}:
|
| 345 |
+
raise LLMCallError(
|
| 346 |
+
"Jev uses typed decisions rather than generative JSON. "
|
| 347 |
+
"Call call_jev_decisions(), or use llm_classify(provider='jev')."
|
| 348 |
+
)
|
| 349 |
+
resolved_model = model or default_model_for(resolved_provider)
|
| 350 |
+
|
| 351 |
+
last_error: Exception | None = None
|
| 352 |
+
for attempt in range(1, max_retries + 1):
|
| 353 |
+
try:
|
| 354 |
+
if resolved_provider == "anthropic":
|
| 355 |
+
text = _call_anthropic(system_prompt, user_prompt, resolved_model, max_tokens)
|
| 356 |
+
elif resolved_provider == "openai":
|
| 357 |
+
text = _call_openai(
|
| 358 |
+
system_prompt, user_prompt, resolved_model, max_tokens, reasoning_effort
|
| 359 |
+
)
|
| 360 |
+
elif resolved_provider == "mlx":
|
| 361 |
+
from src.llm.local import call_mlx_json
|
| 362 |
+
return call_mlx_json(system_prompt, user_prompt, resolved_model, max_tokens, reasoning_effort,
|
| 363 |
+
thinking_budget if thinking_budget is not None else 1024)
|
| 364 |
+
else:
|
| 365 |
+
text = _call_google(system_prompt, user_prompt, resolved_model, max_tokens)
|
| 366 |
+
return extract_json(text)
|
| 367 |
+
except Exception as exc: # noqa: BLE001 - broad by design, retried below
|
| 368 |
+
last_error = exc
|
| 369 |
+
logger.warning(
|
| 370 |
+
"LLM call attempt %d/%d (provider=%s) failed: %s",
|
| 371 |
+
attempt, max_retries, resolved_provider, exc,
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
raise LLMCallError(
|
| 375 |
+
f"LLM call failed after {max_retries} attempts (provider={resolved_provider}): {last_error}"
|
| 376 |
+
) from last_error
|
src/llm/decisions.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapters for typed decision providers using the shared Jev question schema.
|
| 2 |
+
|
| 3 |
+
Keep provider confidence separate from option probabilities. A fallback to the
|
| 4 |
+
maximum option probability is recorded explicitly; it cannot be below 0.5 for
|
| 5 |
+
a normalized binary choice. No domain calibration is inferred from that value.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from functools import lru_cache
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
from urllib.request import Request, urlopen
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from src.llm.client import LLMCallError, _get_openai_client, call_jev_decisions
|
| 16 |
+
|
| 17 |
+
DECISION_PROVIDERS = frozenset({'jev', 'openai_decisions', 'laya', 'jpt'})
|
| 18 |
+
DEFAULT_DECISION_MODELS = {
|
| 19 |
+
'jev': 'jev-latest', 'openai_decisions': 'gpt-6-luna',
|
| 20 |
+
'laya': 'multilingual', 'jpt': 'kirp/jpt-4b',
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def normalize_answers(response, names):
|
| 25 |
+
"""Validate and normalize typed answers without inventing missing decisions."""
|
| 26 |
+
raw = response.get('answers')
|
| 27 |
+
if isinstance(raw, list):
|
| 28 |
+
keys = [answer.get('name') for answer in raw]
|
| 29 |
+
if len(set(keys)) != len(keys):
|
| 30 |
+
raise LLMCallError('Decision provider returned duplicate answer names.')
|
| 31 |
+
raw = {answer['name']: answer for answer in raw}
|
| 32 |
+
if not isinstance(raw, dict) or any(name not in raw for name in names):
|
| 33 |
+
raise LLMCallError('Decision provider omitted one or more requested answers.')
|
| 34 |
+
answers = {}
|
| 35 |
+
for name in names:
|
| 36 |
+
answer = dict(raw[name])
|
| 37 |
+
if answer.get('type') == 'refusal' or not isinstance(answer.get('choice'), str):
|
| 38 |
+
raise LLMCallError(f'Decision provider did not answer question {name}.')
|
| 39 |
+
probabilities = answer.get('probabilities', {})
|
| 40 |
+
if isinstance(probabilities, list):
|
| 41 |
+
probabilities = {item['value']: item['probability'] for item in probabilities}
|
| 42 |
+
probabilities = {key: float(value) for key, value in probabilities.items()}
|
| 43 |
+
if not probabilities or any(not 0 <= p <= 1 for p in probabilities.values()):
|
| 44 |
+
raise LLMCallError(f'Invalid choice probabilities for question {name}.')
|
| 45 |
+
if abs(sum(probabilities.values())-1) > .02 or answer['choice'] not in probabilities:
|
| 46 |
+
raise LLMCallError(f'Invalid choice distribution for question {name}.')
|
| 47 |
+
if isinstance(names, dict) and set(probabilities) != set(names[name]['criteria']):
|
| 48 |
+
raise LLMCallError(f'Unexpected choices for question {name}.')
|
| 49 |
+
confidence = answer.get('confidence')
|
| 50 |
+
answer['confidence_source'] = 'provider' if confidence is not None else 'max_probability'
|
| 51 |
+
confidence = float(confidence) if confidence is not None else max(probabilities.values())
|
| 52 |
+
if not 0 <= confidence <= 1:
|
| 53 |
+
raise LLMCallError(f'Invalid confidence for question {name}.')
|
| 54 |
+
answers[name] = {**answer, 'confidence': confidence, 'probabilities': probabilities}
|
| 55 |
+
return {**response, 'answers': answers}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def call_openai_decisions(state, questions, model='gpt-6-luna', max_retries=3):
|
| 59 |
+
"""POST /v1/decisions, including SDKs predating the typed resource."""
|
| 60 |
+
client = _get_openai_client().with_options(max_retries=max_retries-1)
|
| 61 |
+
payload = {'model': model, 'input': state, 'questions': [
|
| 62 |
+
{'name': name, 'type': 'choice', 'instructions': question['instructions'],
|
| 63 |
+
'choices': [{'value': label, 'description': description}
|
| 64 |
+
for label, description in question['criteria'].items()]}
|
| 65 |
+
for name, question in questions.items()
|
| 66 |
+
]}
|
| 67 |
+
response = client.post('/decisions', cast_to=dict[str, Any], body=payload)
|
| 68 |
+
return normalize_answers(response, questions)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@lru_cache(maxsize=1)
|
| 72 |
+
def _laya_router():
|
| 73 |
+
try:
|
| 74 |
+
from laya import Router
|
| 75 |
+
except ImportError as exc:
|
| 76 |
+
raise LLMCallError('Install requirements-decision-models.txt to use Laya.') from exc
|
| 77 |
+
from src.llm.model_paths import prepared_model_path
|
| 78 |
+
path=prepared_model_path('convaiinnovations/laya')
|
| 79 |
+
return Router(models={'multilingual':(path,'multilingual')})
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def call_decisions(state, questions, provider='jev', model=None, max_retries=3):
|
| 83 |
+
"""Dispatch an existing typed choice request to the selected backend."""
|
| 84 |
+
if provider not in DECISION_PROVIDERS:
|
| 85 |
+
raise ValueError(f'Unsupported decision provider: {provider}')
|
| 86 |
+
model = model or DEFAULT_DECISION_MODELS[provider]
|
| 87 |
+
if provider == 'jev':
|
| 88 |
+
return call_jev_decisions(state, questions, model=model, max_retries=max_retries)
|
| 89 |
+
if provider == 'openai_decisions':
|
| 90 |
+
return call_openai_decisions(state, questions, model=model, max_retries=max_retries)
|
| 91 |
+
if provider == 'laya':
|
| 92 |
+
# Explicit multilingual checkpoint avoids the English router's shorter
|
| 93 |
+
# input limit. The model/card documents 8192-token inference support.
|
| 94 |
+
result = _laya_router().predict(state, questions, model=model, max_len=8192)
|
| 95 |
+
return normalize_answers(result, questions)
|
| 96 |
+
if not os.environ.get('JPT_API_URL'):
|
| 97 |
+
from src.llm.jpt_runtime import predict_jpt
|
| 98 |
+
return normalize_answers(predict_jpt(state, questions, model), questions)
|
| 99 |
+
url = os.environ.get('JPT_API_URL', 'http://127.0.0.1:8080/v1/systemone')
|
| 100 |
+
payload = json.dumps({'state': state, 'questions': questions}).encode()
|
| 101 |
+
headers = {'Content-Type': 'application/json'}
|
| 102 |
+
token = os.environ.get('JPT_API_KEY')
|
| 103 |
+
if token:
|
| 104 |
+
headers['Authorization'] = f'Bearer {token}'
|
| 105 |
+
request = Request(url, data=payload, headers=headers, method='POST')
|
| 106 |
+
try:
|
| 107 |
+
with urlopen(request, timeout=300) as response:
|
| 108 |
+
result = json.load(response)
|
| 109 |
+
except Exception as exc:
|
| 110 |
+
raise LLMCallError('JPT decision endpoint failed. Start llm2jev before running.') from exc
|
| 111 |
+
return normalize_answers(result, questions)
|
src/llm/jpt_runtime.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JPT-4B through its published llm2jev scoring interface on a local GPU."""
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
import platform
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AppleJPT:
|
| 7 |
+
"""Published llm2jev prompts and probabilities with MLX model execution."""
|
| 8 |
+
def __init__(self, model_name):
|
| 9 |
+
from mlx_vlm import load
|
| 10 |
+
from src.llm.model_paths import prepared_model_path
|
| 11 |
+
print(f'[JPT] Loading {model_name} with MLX (original BF16 weights)',flush=True)
|
| 12 |
+
self.model,self.processor=load(prepared_model_path(model_name))
|
| 13 |
+
self.prefix_tokens=[];self.prefix_cache=None
|
| 14 |
+
|
| 15 |
+
def warm(self,prefix,images):
|
| 16 |
+
if images:raise ValueError('The study uses text-only questions.')
|
| 17 |
+
import mlx.core as mx
|
| 18 |
+
# Leave a boundary margin so tokenization cannot merge the final prefix
|
| 19 |
+
# token with the question. Every scoring request verifies this prefix.
|
| 20 |
+
tokens=self.processor.tokenizer.encode(prefix,add_special_tokens=False)[:-2]
|
| 21 |
+
if tokens==self.prefix_tokens:return
|
| 22 |
+
self.prefix_tokens=tokens;lm=self.model.language_model
|
| 23 |
+
self.prefix_cache=lm.make_cache()
|
| 24 |
+
if tokens:
|
| 25 |
+
lm(mx.array([tokens]),cache=self.prefix_cache,skip_logits=True)
|
| 26 |
+
mx.eval([c.state for c in self.prefix_cache])
|
| 27 |
+
|
| 28 |
+
def score(self,text,images,ids):
|
| 29 |
+
if images:raise ValueError('The study uses text-only questions.')
|
| 30 |
+
rows,count=self._score_texts([text],ids)
|
| 31 |
+
return rows[0],count
|
| 32 |
+
|
| 33 |
+
def _score_texts(self,texts,ids):
|
| 34 |
+
import mlx.core as mx
|
| 35 |
+
tokens=[self.processor.tokenizer.encode(text,add_special_tokens=False) for text in texts]
|
| 36 |
+
lm=self.model.language_model
|
| 37 |
+
cache=lm.make_cache();suffixes=tokens
|
| 38 |
+
if self.prefix_tokens and all(t[:len(self.prefix_tokens)]==self.prefix_tokens for t in tokens):
|
| 39 |
+
from mlx.utils import tree_map
|
| 40 |
+
for fresh,saved in zip(cache,self.prefix_cache):
|
| 41 |
+
snapshot=tree_map(lambda x:mx.repeat(x,len(texts),axis=0) if isinstance(x,mx.array) else x,
|
| 42 |
+
saved.prefix_cache_snapshot())
|
| 43 |
+
fresh.prefix_cache_restore(snapshot)
|
| 44 |
+
suffixes=[t[len(self.prefix_tokens):] for t in tokens]
|
| 45 |
+
lengths=[len(s) for s in suffixes];width=max(lengths)
|
| 46 |
+
# Future padding cannot affect earlier positions in this causal model.
|
| 47 |
+
# Select each row's final real token before projecting to vocabulary.
|
| 48 |
+
padded=[s+[0]*(width-len(s)) for s in suffixes]
|
| 49 |
+
out=lm(mx.array(padded),cache=cache,skip_logits=True,return_hidden=True)
|
| 50 |
+
hidden=out.hidden_states[-1][mx.arange(len(texts)),mx.array(lengths)-1][:,None,:]
|
| 51 |
+
logits=lm.speculative_logits_from_hidden(hidden)[:,0].astype(mx.float32)
|
| 52 |
+
values=(logits-mx.logsumexp(logits,axis=-1,keepdims=True))[:,mx.array(ids)].tolist()
|
| 53 |
+
return values,sum(map(len,tokens))
|
| 54 |
+
|
| 55 |
+
def score_many(self,texts,ids):
|
| 56 |
+
rows=[];count=0
|
| 57 |
+
for i in range(0,len(texts),4):
|
| 58 |
+
batch,n=self._score_texts(texts[i:i+4],ids);rows.extend(batch);count+=n
|
| 59 |
+
print(f'[JPT] {min(i+4,len(texts))}/{len(texts)} questions scored',flush=True)
|
| 60 |
+
return rows,count
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@lru_cache(maxsize=1)
|
| 64 |
+
def load_jpt(model_name):
|
| 65 |
+
from llm2jev.engine import LLM2Jev
|
| 66 |
+
if platform.system()=='Darwin' and platform.machine()=='arm64':
|
| 67 |
+
backend=AppleJPT(model_name)
|
| 68 |
+
return LLM2Jev(backend.processor,backend,temperature=1.036,workers=1)
|
| 69 |
+
import torch
|
| 70 |
+
from llm2jev.backends import HF
|
| 71 |
+
|
| 72 |
+
class LastTokenHF(HF):
|
| 73 |
+
def __init__(self, model, device=None, **kwargs):
|
| 74 |
+
# Loading safetensors directly onto MPS starts concurrent transfer
|
| 75 |
+
# threads. Load on CPU and transfer serially to avoid a native
|
| 76 |
+
# allocator/Metal crash in that loading path on this Mac.
|
| 77 |
+
super().__init__(model, device='cpu' if device=='mps' else device, **kwargs)
|
| 78 |
+
if device=='mps':self.model.to(device)
|
| 79 |
+
# Restrict the LM projection to the last position. The upstream HF
|
| 80 |
+
# reference projects every EDF token, wasting substantial GPU memory.
|
| 81 |
+
def score(self, text, images, ids):
|
| 82 |
+
if images:
|
| 83 |
+
raise ValueError('This experiment uses text-only JPT inputs.')
|
| 84 |
+
rows, count = self.score_many([text], ids)
|
| 85 |
+
return rows[0], count
|
| 86 |
+
|
| 87 |
+
def score_many(self, texts, ids):
|
| 88 |
+
self.tok.padding_side = 'left'
|
| 89 |
+
rows = []; count = 0
|
| 90 |
+
for start in range(0, len(texts), 4):
|
| 91 |
+
inputs = self.tok(texts[start:start+4],padding=True,return_tensors='pt',
|
| 92 |
+
add_special_tokens=False)
|
| 93 |
+
count += int(inputs['attention_mask'].sum())
|
| 94 |
+
inputs = {k:v.to(self.model.device) for k,v in inputs.items()}
|
| 95 |
+
with self.lock, torch.inference_mode():
|
| 96 |
+
logits = self.model(**inputs,logits_to_keep=1).logits[:,-1].float().log_softmax(-1)
|
| 97 |
+
rows.extend(logits[:,ids].cpu().tolist())
|
| 98 |
+
return rows,count
|
| 99 |
+
|
| 100 |
+
device = 'mps' if torch.backends.mps.is_available() else 'cuda' if torch.cuda.is_available() else 'cpu'
|
| 101 |
+
print(f'[JPT] Loading {model_name} on {device}',flush=True)
|
| 102 |
+
from src.llm.model_paths import prepared_model_path
|
| 103 |
+
backend = LastTokenHF(prepared_model_path(model_name),device=device,dtype='bfloat16')
|
| 104 |
+
return LLM2Jev(backend.processor or backend.tok,backend,temperature=1.036,workers=1)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def predict_jpt(state, questions, model_name):
|
| 108 |
+
answers, usage = load_jpt(model_name).run(state, questions)
|
| 109 |
+
return {'answers':answers,'model':model_name,'usage':usage,
|
| 110 |
+
'runtime':'llm2jev/mlx' if platform.system()=='Darwin' and platform.machine()=='arm64' else 'llm2jev/hf',
|
| 111 |
+
'temperature':1.036}
|
src/llm/local.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local MLX reviewer for Apple silicon; no API calls or model substitution."""
|
| 2 |
+
from functools import lru_cache
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
import re
|
| 8 |
+
from time import perf_counter
|
| 9 |
+
from uuid import uuid4
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def visible_response(text):
|
| 13 |
+
"""Exclude reasoning, including a thinking block preopened by the template."""
|
| 14 |
+
text = re.sub(r'<think>.*?</think>', '', text, flags=re.S)
|
| 15 |
+
if '</think>' in text:
|
| 16 |
+
text = text.split('</think>',1)[1]
|
| 17 |
+
if '<think>' in text:
|
| 18 |
+
text = text.split('<think>',1)[0]
|
| 19 |
+
return text.strip()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def first_response(text):
|
| 23 |
+
"""Read a complete JSON envelope, allowing subsequent prose or repeated output.
|
| 24 |
+
|
| 25 |
+
Never repair incomplete JSON or extract a nested classification from a broken
|
| 26 |
+
envelope. Generation and classification settings remain unchanged.
|
| 27 |
+
"""
|
| 28 |
+
text=visible_response(text)
|
| 29 |
+
start=text.find('{')
|
| 30 |
+
if start<0:raise ValueError('No complete JSON envelope in local response')
|
| 31 |
+
result,_=json.JSONDecoder().raw_decode(text[start:])
|
| 32 |
+
if not isinstance(result,dict):raise ValueError('Local response must be a JSON object')
|
| 33 |
+
if not isinstance(result.get('classifications'),list):
|
| 34 |
+
raise ValueError('Local response must contain a classifications array')
|
| 35 |
+
return result
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def candidate_parts(user_prompt):
|
| 39 |
+
marker='Classify each of the following candidate codes:\n'
|
| 40 |
+
if marker not in user_prompt:return None
|
| 41 |
+
prefix,body=user_prompt.split(marker,1)
|
| 42 |
+
lines,suffix=body.split('\n\nReturn a JSON object',1)
|
| 43 |
+
candidates=lines.splitlines()
|
| 44 |
+
if not candidates or any(not line.startswith('- code: ') for line in candidates):return None
|
| 45 |
+
return prefix+marker,candidates,'\n\nReturn a JSON object'+suffix
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def validate_candidates(response,user_prompt):
|
| 49 |
+
parts=candidate_parts(user_prompt)
|
| 50 |
+
if parts is None:return
|
| 51 |
+
expected=[]
|
| 52 |
+
for line in parts[1]:
|
| 53 |
+
code,vocabulary,_=line.removeprefix('- code: ').split(' | ',2)
|
| 54 |
+
expected.append((code,vocabulary.removeprefix('vocabulary: ')))
|
| 55 |
+
actual=[(r.get('code'),r.get('vocabulary')) for r in response['classifications']]
|
| 56 |
+
if len(actual)!=len(expected) or set(actual)!=set(expected):
|
| 57 |
+
raise ValueError('Local answer does not cover every requested code exactly once')
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@lru_cache(maxsize=1)
|
| 61 |
+
def load_mlx_reviewer(model_name):
|
| 62 |
+
from mlx_vlm import load
|
| 63 |
+
from src.llm.model_paths import prepared_model_path
|
| 64 |
+
return load(prepared_model_path(model_name))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def call_mlx_json(system_prompt, user_prompt, model_name, max_tokens=8192, reasoning_effort='medium',
|
| 68 |
+
thinking_budget=1024):
|
| 69 |
+
from mlx_vlm import stream_generate
|
| 70 |
+
model, processor = load_mlx_reviewer(model_name)
|
| 71 |
+
messages = [{'role':'system','content':system_prompt},{'role':'user','content':user_prompt}]
|
| 72 |
+
prompt = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True,
|
| 73 |
+
enable_thinking=reasoning_effort not in (None,'none'))
|
| 74 |
+
folder=Path(os.environ.get('LOCAL_RESPONSE_DIR',Path(__file__).resolve().parents[2]/'results/local_responses'))
|
| 75 |
+
folder.mkdir(parents=True,exist_ok=True)
|
| 76 |
+
prompt_hash=hashlib.sha256(prompt.encode()).hexdigest()
|
| 77 |
+
failure=folder/'failed'/f'{prompt_hash}.json'
|
| 78 |
+
replay=json.loads(failure.read_text()) if failure.exists() else None
|
| 79 |
+
if replay and (replay.get('model'),replay.get('max_tokens'),replay.get('thinking_budget'))!=(model_name,max_tokens,thinking_budget):
|
| 80 |
+
replay=None
|
| 81 |
+
if replay:
|
| 82 |
+
print('[Qwen reviewer] Reusing audited failed output; subdividing its candidate batch.',flush=True)
|
| 83 |
+
chunks = []; started = perf_counter(); reported = 0; generated_tokens=0
|
| 84 |
+
finish_reason='model_stop_or_limit'
|
| 85 |
+
outputs=() if replay else stream_generate(model, processor, prompt=prompt, max_tokens=max_tokens,
|
| 86 |
+
temperature=.6, top_p=.95, top_k=20, seed=42,
|
| 87 |
+
enable_thinking=reasoning_effort not in (None,'none'),
|
| 88 |
+
thinking_budget=thinking_budget)
|
| 89 |
+
for output in outputs:
|
| 90 |
+
chunks.append(output.text)
|
| 91 |
+
generated_tokens=output.generation_tokens
|
| 92 |
+
if output.generation_tokens >= reported+256:
|
| 93 |
+
reported = output.generation_tokens
|
| 94 |
+
print(f'[Qwen reviewer] {reported} tokens generated, {perf_counter()-started:.1f}s',flush=True)
|
| 95 |
+
current=''.join(chunks)
|
| 96 |
+
if '</think>' in current or reasoning_effort in (None,'none'):
|
| 97 |
+
try:
|
| 98 |
+
parsed=first_response(current)
|
| 99 |
+
validate_candidates(parsed,user_prompt)
|
| 100 |
+
except json.JSONDecodeError as exc:
|
| 101 |
+
# An unescaped control character inside a JSON string cannot
|
| 102 |
+
# become valid by generating more tokens. Other incomplete
|
| 103 |
+
# prefixes remain eligible to finish normally.
|
| 104 |
+
if exc.msg.startswith('Invalid control character'):
|
| 105 |
+
finish_reason='invalid_json_syntax'
|
| 106 |
+
if hasattr(outputs,'close'):outputs.close()
|
| 107 |
+
break
|
| 108 |
+
except (ValueError,TypeError):pass
|
| 109 |
+
else:
|
| 110 |
+
finish_reason='complete_json'
|
| 111 |
+
if hasattr(outputs,'close'):outputs.close()
|
| 112 |
+
break
|
| 113 |
+
text = replay['raw_response'] if replay else ''.join(chunks)
|
| 114 |
+
path=folder/(uuid4().hex+'.json')
|
| 115 |
+
audit={'model':model_name,'raw_response':text,'generation_tokens':generated_tokens,
|
| 116 |
+
'max_tokens':max_tokens,'thinking_budget':thinking_budget,'seconds':perf_counter()-started,
|
| 117 |
+
'prompt_sha256':prompt_hash,'replayed_failed_response':bool(replay),'finish_reason':finish_reason,
|
| 118 |
+
'implementation_sha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest()}
|
| 119 |
+
try:
|
| 120 |
+
result=first_response(text)
|
| 121 |
+
validate_candidates(result,user_prompt)
|
| 122 |
+
audit['status']='parsed'
|
| 123 |
+
return result
|
| 124 |
+
except (ValueError,TypeError) as exc:
|
| 125 |
+
audit.update(status='parse_error',error=str(exc))
|
| 126 |
+
failure.parent.mkdir(exist_ok=True)
|
| 127 |
+
failure.write_text(json.dumps(audit,ensure_ascii=False,indent=2))
|
| 128 |
+
print(f'[Qwen reviewer] Raw failed response saved: {path}',flush=True)
|
| 129 |
+
parts=candidate_parts(user_prompt)
|
| 130 |
+
if parts and len(parts[1])>1:
|
| 131 |
+
prefix,lines,suffix=parts;middle=len(lines)//2
|
| 132 |
+
audit['recovery']='subdivide_batch'
|
| 133 |
+
# Persist the failure before recovery, including if recovery is interrupted.
|
| 134 |
+
path.write_text(json.dumps(audit,ensure_ascii=False,indent=2))
|
| 135 |
+
print(f'[Qwen reviewer] Retrying failed batch as {middle} + {len(lines)-middle} candidates.',flush=True)
|
| 136 |
+
recovered=[]
|
| 137 |
+
for subset in (lines[:middle],lines[middle:]):
|
| 138 |
+
answer=call_mlx_json(system_prompt,prefix+'\n'.join(subset)+suffix,model_name,
|
| 139 |
+
max_tokens,reasoning_effort,thinking_budget)
|
| 140 |
+
recovered.extend(answer['classifications'])
|
| 141 |
+
result={'classifications':recovered}
|
| 142 |
+
validate_candidates(result,user_prompt)
|
| 143 |
+
audit['status']='recovered_by_subdivision'
|
| 144 |
+
return result
|
| 145 |
+
raise
|
| 146 |
+
finally:
|
| 147 |
+
path.write_text(json.dumps(audit,ensure_ascii=False,indent=2))
|
src/llm/model_paths.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Use prepared local snapshots when available, retaining exact model revisions."""
|
| 2 |
+
import json
|
| 3 |
+
import gc
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def prepared_model_path(repo):
|
| 9 |
+
root=Path(__file__).resolve().parents[2]
|
| 10 |
+
receipt=root/'.model-cache/model_revisions.json'
|
| 11 |
+
if receipt.exists():
|
| 12 |
+
info=json.loads(receipt.read_text()).get(repo)
|
| 13 |
+
if info and Path(info['path']).is_dir():return info['path']
|
| 14 |
+
return repo
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def release_local_models():
|
| 18 |
+
"""Release cached weights before another local model or phase is loaded."""
|
| 19 |
+
for module_name,function in (('src.llm.decisions','_laya_router'),
|
| 20 |
+
('src.llm.jpt_runtime','load_jpt'),
|
| 21 |
+
('src.llm.local','load_mlx_reviewer')):
|
| 22 |
+
module=sys.modules.get(module_name)
|
| 23 |
+
if module is not None:getattr(module,function).cache_clear()
|
| 24 |
+
gc.collect()
|
| 25 |
+
torch=sys.modules.get('torch')
|
| 26 |
+
if torch is not None and torch.backends.mps.is_available():torch.mps.empty_cache()
|
| 27 |
+
mx=sys.modules.get('mlx.core')
|
| 28 |
+
if mx is not None:mx.clear_cache()
|
src/llm/prompts.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reusable prompt templates for LLM-assisted ranking and classification."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from src.data.loaders import EventDefinitionForm
|
| 6 |
+
|
| 7 |
+
RANK_SYSTEM_PROMPT = (
|
| 8 |
+
"You are a clinical terminology expert supporting semantic harmonization for "
|
| 9 |
+
"real-world evidence studies. You review candidate medical codes retrieved for "
|
| 10 |
+
"a phenotype and re-rank them by clinical relevance, using the structured "
|
| 11 |
+
"phenotype definition (Event Definition Form) provided by the user. "
|
| 12 |
+
"You must respond only with valid JSON, no additional commentary."
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
CLASSIFY_SYSTEM_PROMPT = (
|
| 16 |
+
"You are a clinical terminology expert supporting semantic harmonization for "
|
| 17 |
+
"real-world evidence studies. You review candidate medical codes for a phenotype "
|
| 18 |
+
"and classify each one as 'Narrow' (highly specific to the phenotype), "
|
| 19 |
+
"'Possible' (related but insufficiently specific, or specific in some but not "
|
| 20 |
+
"all contexts), or 'Exclude' (not relevant, or explicitly an exclusion "
|
| 21 |
+
"criterion). Base your decision on the structured phenotype definition (Event "
|
| 22 |
+
"Definition Form) provided by the user. For every code, provide a brief "
|
| 23 |
+
"explanation followed by the label and a confidence score between 0 and 1. "
|
| 24 |
+
"You must respond only with valid JSON, no additional commentary."
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def classify_system_prompt(use_possible_category: bool = False) -> str:
|
| 29 |
+
"""Return classification instructions for binary or three-way labeling."""
|
| 30 |
+
if use_possible_category:
|
| 31 |
+
return CLASSIFY_SYSTEM_PROMPT
|
| 32 |
+
return (
|
| 33 |
+
"You are a clinical terminology expert supporting semantic harmonization "
|
| 34 |
+
"for real-world evidence studies. Classify every candidate as 'Narrow' "
|
| 35 |
+
"when it is sufficiently specific to the phenotype, or 'Exclude' otherwise. "
|
| 36 |
+
"Do not return a 'Possible' label. Any EDF rule describing a Possible, "
|
| 37 |
+
"ambiguous, or insufficiently specific case must be treated as Exclude in "
|
| 38 |
+
"this binary mode. Base decisions on the supplied Event Definition Form. "
|
| 39 |
+
"For every code, request the output fields in this order: code, vocabulary, "
|
| 40 |
+
"explanation, label, confidence. Confidence must be from 0 to 1. Respond "
|
| 41 |
+
"only with valid JSON."
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def build_rank_prompt(edf: EventDefinitionForm, candidates: list[dict]) -> str:
|
| 46 |
+
"""Build the user prompt for LLM-based re-ranking of candidate codes."""
|
| 47 |
+
candidate_lines = "\n".join(
|
| 48 |
+
f"- code: {c['code']} | vocabulary: {c['vocabulary']} | description: {c['description']}"
|
| 49 |
+
for c in candidates
|
| 50 |
+
)
|
| 51 |
+
return (
|
| 52 |
+
f"Phenotype definition:\n{edf.to_prompt_context()}\n\n"
|
| 53 |
+
f"Candidate codes to rank by clinical relevance to this phenotype "
|
| 54 |
+
f"(most relevant first):\n{candidate_lines}\n\n"
|
| 55 |
+
"Return a JSON object with a single key 'ranked_codes', containing a list of "
|
| 56 |
+
"objects with keys 'code', 'vocabulary', and 'relevance_score' (float 0-1, "
|
| 57 |
+
"higher = more relevant), sorted descending by relevance_score."
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def build_classify_prompt(
|
| 62 |
+
edf: EventDefinitionForm,
|
| 63 |
+
candidates: list[dict],
|
| 64 |
+
use_possible_category: bool = False,
|
| 65 |
+
) -> str:
|
| 66 |
+
"""Build the user prompt for LLM-based classification of candidate codes."""
|
| 67 |
+
candidate_lines = "\n".join(
|
| 68 |
+
f"- code: {c['code']} | vocabulary: {c['vocabulary']} | description: {c['description']}"
|
| 69 |
+
for c in candidates
|
| 70 |
+
)
|
| 71 |
+
labels = "'Narrow', 'Possible', or 'Exclude'" if use_possible_category else "'Narrow' or 'Exclude'"
|
| 72 |
+
return (
|
| 73 |
+
f"Phenotype definition:\n{edf.to_prompt_context(include_possible=use_possible_category)}\n\n"
|
| 74 |
+
f"Classify each of the following candidate codes:\n{candidate_lines}\n\n"
|
| 75 |
+
"Return a JSON object with a single key 'classifications', containing a "
|
| 76 |
+
"list of objects using this requested key order: 'code', 'vocabulary', "
|
| 77 |
+
"'explanation' (brief string), 'label' "
|
| 78 |
+
f"(one of {labels}), and 'confidence' (float 0-1)."
|
| 79 |
+
)
|
src/llm/rank.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM-based re-ranking of candidate codes using full EDF semantic context."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
from src.data.loaders import EventDefinitionForm
|
| 8 |
+
from src.llm.client import call_llm_json
|
| 9 |
+
from src.llm.prompts import RANK_SYSTEM_PROMPT, build_rank_prompt
|
| 10 |
+
from src.retrieval.hybrid import HybridCandidate
|
| 11 |
+
from src.utils.logging import get_logger
|
| 12 |
+
|
| 13 |
+
logger = get_logger(__name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(slots=True)
|
| 17 |
+
class RankedCandidate:
|
| 18 |
+
rank: int
|
| 19 |
+
code: str
|
| 20 |
+
description: str
|
| 21 |
+
vocabulary: str
|
| 22 |
+
relevance_score: float
|
| 23 |
+
retrieval_score: float
|
| 24 |
+
|
| 25 |
+
def __lt__(self, other: object) -> bool:
|
| 26 |
+
if not isinstance(other, RankedCandidate):
|
| 27 |
+
return NotImplemented
|
| 28 |
+
return self.relevance_score > other.relevance_score
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def skip_rank(candidates: list[HybridCandidate]) -> list[RankedCandidate]:
|
| 32 |
+
"""Wrap hybrid retrieval candidates into ``RankedCandidate`` objects without
|
| 33 |
+
calling the LLM, preserving their original hybrid retrieval order.
|
| 34 |
+
|
| 35 |
+
The LLM ranking step (:func:`llm_rank`) is currently disabled in the
|
| 36 |
+
pipeline/UI (an extra LLM call that duplicates work also done by
|
| 37 |
+
classification) but is kept available for future use. This helper lets
|
| 38 |
+
downstream code (``llm_classify``) keep consuming ``RankedCandidate``
|
| 39 |
+
objects uniformly regardless of whether ranking actually ran.
|
| 40 |
+
"""
|
| 41 |
+
results = [
|
| 42 |
+
RankedCandidate(
|
| 43 |
+
rank=c.rank,
|
| 44 |
+
code=c.code,
|
| 45 |
+
description=c.description,
|
| 46 |
+
vocabulary=c.vocabulary,
|
| 47 |
+
relevance_score=0.0,
|
| 48 |
+
retrieval_score=c.score,
|
| 49 |
+
)
|
| 50 |
+
for c in candidates
|
| 51 |
+
]
|
| 52 |
+
return results
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def llm_rank(
|
| 56 |
+
candidates: list[HybridCandidate],
|
| 57 |
+
edf: EventDefinitionForm,
|
| 58 |
+
provider: str = "auto",
|
| 59 |
+
model: str | None = None,
|
| 60 |
+
max_retries: int = 3,
|
| 61 |
+
) -> list[RankedCandidate]:
|
| 62 |
+
"""Re-rank hybrid retrieval candidates using an LLM given the full EDF context.
|
| 63 |
+
|
| 64 |
+
Parameters
|
| 65 |
+
----------
|
| 66 |
+
candidates : list[HybridCandidate]
|
| 67 |
+
Candidates produced by :func:`src.retrieval.hybrid.hybrid_retrieval`.
|
| 68 |
+
edf : EventDefinitionForm
|
| 69 |
+
Structured phenotype definition providing semantic context.
|
| 70 |
+
provider : str
|
| 71 |
+
"auto", "anthropic", or "openai".
|
| 72 |
+
model : str, optional
|
| 73 |
+
Model identifier. Defaults to a provider-specific default.
|
| 74 |
+
max_retries : int
|
| 75 |
+
Number of LLM call attempts before failing.
|
| 76 |
+
|
| 77 |
+
Returns
|
| 78 |
+
-------
|
| 79 |
+
list[RankedCandidate]
|
| 80 |
+
Candidates ordered by descending LLM-assigned relevance score.
|
| 81 |
+
"""
|
| 82 |
+
if not candidates:
|
| 83 |
+
return []
|
| 84 |
+
|
| 85 |
+
lookup = {(c.code, c.vocabulary): c for c in candidates}
|
| 86 |
+
payload = [
|
| 87 |
+
{"code": c.code, "vocabulary": c.vocabulary, "description": c.description}
|
| 88 |
+
for c in candidates
|
| 89 |
+
]
|
| 90 |
+
prompt = build_rank_prompt(edf, payload)
|
| 91 |
+
response = call_llm_json(
|
| 92 |
+
system_prompt=RANK_SYSTEM_PROMPT,
|
| 93 |
+
user_prompt=prompt,
|
| 94 |
+
provider=provider,
|
| 95 |
+
model=model,
|
| 96 |
+
max_retries=max_retries,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
ranked_raw = response.get("ranked_codes", [])
|
| 100 |
+
results: list[RankedCandidate] = []
|
| 101 |
+
seen: set[tuple[str, str]] = set()
|
| 102 |
+
|
| 103 |
+
for item in ranked_raw:
|
| 104 |
+
key = (item.get("code", ""), item.get("vocabulary", ""))
|
| 105 |
+
source = lookup.get(key)
|
| 106 |
+
if source is None:
|
| 107 |
+
continue
|
| 108 |
+
seen.add(key)
|
| 109 |
+
results.append(
|
| 110 |
+
RankedCandidate(
|
| 111 |
+
rank=0,
|
| 112 |
+
code=source.code,
|
| 113 |
+
description=source.description,
|
| 114 |
+
vocabulary=source.vocabulary,
|
| 115 |
+
relevance_score=float(item.get("relevance_score", 0.0)),
|
| 116 |
+
retrieval_score=source.score,
|
| 117 |
+
)
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# Append any candidates the LLM omitted, ordered by original retrieval score,
|
| 121 |
+
# so no candidate is silently dropped from downstream classification.
|
| 122 |
+
for c in candidates:
|
| 123 |
+
key = (c.code, c.vocabulary)
|
| 124 |
+
if key not in seen:
|
| 125 |
+
results.append(
|
| 126 |
+
RankedCandidate(
|
| 127 |
+
rank=0,
|
| 128 |
+
code=c.code,
|
| 129 |
+
description=c.description,
|
| 130 |
+
vocabulary=c.vocabulary,
|
| 131 |
+
relevance_score=0.0,
|
| 132 |
+
retrieval_score=c.score,
|
| 133 |
+
)
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
results.sort(key=lambda r: (-r.relevance_score, -r.retrieval_score))
|
| 137 |
+
for r, item in enumerate(results):
|
| 138 |
+
item.rank = r + 1
|
| 139 |
+
return results
|
src/mapping/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Optional terminology mapping stages."""
|
src/mapping/codemapper.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-round cross-vocabulary CUI expansion, with caching and paced sessions."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from dataclasses import replace
|
| 5 |
+
try:
|
| 6 |
+
import fcntl
|
| 7 |
+
except ImportError: # Keep the disabled optional stage importable on Windows.
|
| 8 |
+
fcntl = None
|
| 9 |
+
import hashlib
|
| 10 |
+
from http.cookiejar import CookieJar
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import os
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
import time
|
| 16 |
+
from urllib.error import HTTPError, URLError
|
| 17 |
+
from urllib.parse import urlencode
|
| 18 |
+
from urllib.request import HTTPCookieProcessor, Request, build_opener
|
| 19 |
+
|
| 20 |
+
import pandas as pd
|
| 21 |
+
|
| 22 |
+
from src.data.preprocessing import standardize_code
|
| 23 |
+
from src.llm.classify import ClassifiedCandidate
|
| 24 |
+
from src.utils.config import CodeMapperConfig
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class CodeMapperError(RuntimeError):
|
| 28 |
+
"""Sanitized failure: never include credentials, cookies, or server bodies."""
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def normalize_code_versions(response,vocabulary):
|
| 32 |
+
"""Select the pinned release from the current all-version API response."""
|
| 33 |
+
_,separator,version=vocabulary.partition('@')
|
| 34 |
+
if not separator:raise CodeMapperError('CodeMapper requires a pinned vocabulary version')
|
| 35 |
+
result={}
|
| 36 |
+
for code,value in response.items():
|
| 37 |
+
if isinstance(value,list):
|
| 38 |
+
matching=[item for item in value if isinstance(item,dict) and str(item.get('version'))==version]
|
| 39 |
+
if len(matching)>1:raise CodeMapperError('Ambiguous CodeMapper code version response')
|
| 40 |
+
if matching:result[code]=matching[0]
|
| 41 |
+
elif isinstance(value,dict):
|
| 42 |
+
result[code]=value # Older cached/API responses were already release-specific.
|
| 43 |
+
else:raise CodeMapperError('Unexpected CodeMapper code version response')
|
| 44 |
+
return result
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def normalize_concept_versions(response,targets):
|
| 48 |
+
"""Project series-level codes to requested releases, never to latest by default."""
|
| 49 |
+
versions={target.partition('@')[0]:target.partition('@')[2] for target in targets}
|
| 50 |
+
result=[]
|
| 51 |
+
for concept in response:
|
| 52 |
+
sources=[]
|
| 53 |
+
for source in concept.get('sourceConcepts',[]):
|
| 54 |
+
if 'codingSystem' in source:
|
| 55 |
+
if source['codingSystem'] in targets:sources.append(source)
|
| 56 |
+
continue
|
| 57 |
+
series=source.get('vocId');version=versions.get(series)
|
| 58 |
+
if version is None:continue
|
| 59 |
+
matching=[item for item in source.get('versions',[]) if str(item.get('version'))==version]
|
| 60 |
+
if matching:
|
| 61 |
+
sources.append({**source,'codingSystem':series+'@'+version,
|
| 62 |
+
'preferredTerm':matching[0].get('term',source.get('preferredTerm'))})
|
| 63 |
+
result.append({**concept,'sourceConcepts':sources})
|
| 64 |
+
return result
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class CodeMapperClient:
|
| 68 |
+
def __init__(self, config: CodeMapperConfig):
|
| 69 |
+
self.config = config
|
| 70 |
+
self.cache_dir = Path(config.cache_dir)
|
| 71 |
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 72 |
+
self.opener = build_opener(HTTPCookieProcessor(CookieJar()))
|
| 73 |
+
self.logged_in = False
|
| 74 |
+
self.calls: list[dict] = []
|
| 75 |
+
self.cache_hits = 0
|
| 76 |
+
|
| 77 |
+
def _pace(self):
|
| 78 |
+
if fcntl is None:
|
| 79 |
+
raise CodeMapperError("Shared CodeMapper pacing currently requires macOS or Linux")
|
| 80 |
+
# Share the limiter across client instances/processes using this cache.
|
| 81 |
+
with (self.cache_dir / "rate.lock").open("a+") as handle:
|
| 82 |
+
fcntl.flock(handle, fcntl.LOCK_EX)
|
| 83 |
+
handle.seek(0)
|
| 84 |
+
previous = float(handle.read() or 0)
|
| 85 |
+
delay = previous + 60 / self.config.requests_per_minute - time.time()
|
| 86 |
+
if delay > 0:
|
| 87 |
+
time.sleep(delay)
|
| 88 |
+
handle.seek(0)
|
| 89 |
+
handle.truncate()
|
| 90 |
+
handle.write(str(time.time()))
|
| 91 |
+
handle.flush()
|
| 92 |
+
|
| 93 |
+
def _request(self, path, method="POST", form=None):
|
| 94 |
+
fields = [(key, item) for key, value in (form or {}).items()
|
| 95 |
+
for item in (value if isinstance(value, list) else [value])]
|
| 96 |
+
request = Request(self.config.base_url.rstrip("/") + path,
|
| 97 |
+
data=urlencode(fields).encode() if form is not None else None,
|
| 98 |
+
method=method, headers={"Accept": "application/json"})
|
| 99 |
+
for attempt in range(self.config.max_retries):
|
| 100 |
+
self._pace()
|
| 101 |
+
logging.getLogger(__name__).info('CodeMapper %s %s (attempt %s)',method,path,attempt+1)
|
| 102 |
+
started = time.perf_counter()
|
| 103 |
+
try:
|
| 104 |
+
with self.opener.open(request, timeout=self.config.timeout_seconds) as response:
|
| 105 |
+
raw = response.read()
|
| 106 |
+
status = response.status
|
| 107 |
+
self.calls.append({"path": path, "status": status,
|
| 108 |
+
"seconds": round(time.perf_counter() - started, 4),
|
| 109 |
+
"bytes": len(raw)})
|
| 110 |
+
return json.loads(raw) if raw else None
|
| 111 |
+
except HTTPError as exc:
|
| 112 |
+
self.calls.append({"path": path, "status": exc.code,
|
| 113 |
+
"seconds": round(time.perf_counter() - started, 4)})
|
| 114 |
+
if exc.code not in {429, 500, 502, 503, 504}:
|
| 115 |
+
raise CodeMapperError(f"CodeMapper {path}: HTTP {exc.code}") from None
|
| 116 |
+
try:
|
| 117 |
+
retry_after = float(exc.headers.get("Retry-After", 0))
|
| 118 |
+
except ValueError:
|
| 119 |
+
retry_after = 0
|
| 120 |
+
if attempt + 1 < self.config.max_retries:
|
| 121 |
+
time.sleep(max(retry_after, 2 ** attempt))
|
| 122 |
+
except (URLError, TimeoutError, OSError):
|
| 123 |
+
if attempt + 1 < self.config.max_retries:
|
| 124 |
+
time.sleep(2 ** attempt)
|
| 125 |
+
except (ValueError, UnicodeDecodeError):
|
| 126 |
+
raise CodeMapperError(f"CodeMapper {path}: invalid JSON") from None
|
| 127 |
+
raise CodeMapperError(f"CodeMapper {path}: failed after retries")
|
| 128 |
+
|
| 129 |
+
def _login(self):
|
| 130 |
+
if self.logged_in:
|
| 131 |
+
return
|
| 132 |
+
username, password = os.getenv("CODEMAPPER_USERNAME"), os.getenv("CODEMAPPER_PASSWORD")
|
| 133 |
+
if not username or not password:
|
| 134 |
+
raise CodeMapperError("Set CODEMAPPER_USERNAME and CODEMAPPER_PASSWORD in .env")
|
| 135 |
+
response = self._request("/session", form={"username": username, "password": password})
|
| 136 |
+
if not isinstance(response, dict) or response.get("success") is not True:
|
| 137 |
+
raise CodeMapperError("CodeMapper login unsuccessful")
|
| 138 |
+
self.logged_in = True
|
| 139 |
+
|
| 140 |
+
def close(self):
|
| 141 |
+
if self.logged_in:
|
| 142 |
+
try:
|
| 143 |
+
self._request("/session", "DELETE")
|
| 144 |
+
finally:
|
| 145 |
+
self.logged_in = False
|
| 146 |
+
|
| 147 |
+
def _cache_path(self, kind, identity):
|
| 148 |
+
key = hashlib.sha256(json.dumps([self.config.base_url, kind, identity],
|
| 149 |
+
sort_keys=True).encode()).hexdigest()
|
| 150 |
+
return self.cache_dir / f"{key}.json"
|
| 151 |
+
|
| 152 |
+
def _load(self, kind, identity):
|
| 153 |
+
path = self._cache_path(kind, identity)
|
| 154 |
+
if path.exists():
|
| 155 |
+
self.cache_hits += 1
|
| 156 |
+
return json.loads(path.read_text())
|
| 157 |
+
return None
|
| 158 |
+
|
| 159 |
+
def _save(self, kind, identity, value):
|
| 160 |
+
path = self._cache_path(kind, identity)
|
| 161 |
+
temporary = path.with_suffix(f".{os.getpid()}.tmp")
|
| 162 |
+
temporary.write_text(json.dumps(value))
|
| 163 |
+
temporary.replace(path)
|
| 164 |
+
|
| 165 |
+
def cuis_for_codes(self, vocabulary, codes):
|
| 166 |
+
result, missing = {}, []
|
| 167 |
+
for code in sorted(set(codes)):
|
| 168 |
+
cached = self._load("code_cuis", [vocabulary, code])
|
| 169 |
+
if cached is None:
|
| 170 |
+
missing.append(code)
|
| 171 |
+
elif cached:
|
| 172 |
+
result[code] = cached
|
| 173 |
+
for start in range(0, len(missing), self.config.batch_size):
|
| 174 |
+
batch = missing[start:start + self.config.batch_size]
|
| 175 |
+
self._login()
|
| 176 |
+
response = self._request("/legacy/code-mapper/cuis-for-codes",
|
| 177 |
+
form={"vocId": vocabulary.partition('@')[0], "codes": batch})
|
| 178 |
+
if not isinstance(response, dict):
|
| 179 |
+
raise CodeMapperError("Unexpected code-to-CUI response")
|
| 180 |
+
response=normalize_code_versions(response,vocabulary)
|
| 181 |
+
for code in batch:
|
| 182 |
+
value = response.get(code, {})
|
| 183 |
+
self._save("code_cuis", [vocabulary, code], value)
|
| 184 |
+
if value:
|
| 185 |
+
result[code] = value
|
| 186 |
+
return result
|
| 187 |
+
|
| 188 |
+
def concepts(self, cuis, targets):
|
| 189 |
+
result, missing = [], []
|
| 190 |
+
targets = sorted(set(targets))
|
| 191 |
+
for cui in sorted(set(cuis)):
|
| 192 |
+
cached = self._load("concept", [targets, cui])
|
| 193 |
+
if cached is None:
|
| 194 |
+
missing.append(cui)
|
| 195 |
+
else:
|
| 196 |
+
result.extend(cached)
|
| 197 |
+
for start in range(0, len(missing), self.config.batch_size):
|
| 198 |
+
batch = missing[start:start + self.config.batch_size]
|
| 199 |
+
self._login()
|
| 200 |
+
response = self._request("/legacy/code-mapper/umls-concepts",
|
| 201 |
+
form={"cuis": batch, "vocIds": sorted({target.partition('@')[0] for target in targets})})
|
| 202 |
+
if not isinstance(response, list):
|
| 203 |
+
raise CodeMapperError("Unexpected concept response")
|
| 204 |
+
response=normalize_concept_versions(response,targets)
|
| 205 |
+
for cui in batch:
|
| 206 |
+
values = [c for c in response if c.get("cui") == cui]
|
| 207 |
+
self._save("concept", [targets, cui], values)
|
| 208 |
+
result.extend(values)
|
| 209 |
+
return result
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def expand_narrow_matches(classified: list[ClassifiedCandidate], corpus: pd.DataFrame,
|
| 213 |
+
vocabularies: list[str], config: CodeMapperConfig,
|
| 214 |
+
*, client=None):
|
| 215 |
+
"""Promote/add direct cross-vocabulary matches from a frozen Narrow seed set.
|
| 216 |
+
|
| 217 |
+
Only codes in the loaded/preprocessed corpus are eligible. Same-vocabulary
|
| 218 |
+
links and descendants are excluded. No new additions become seeds this round.
|
| 219 |
+
Mapping confidence is 0 (unassessed), not an invented model probability.
|
| 220 |
+
"""
|
| 221 |
+
seeds = [c for c in classified if c.label == "Narrow" and c.vocabulary in vocabularies]
|
| 222 |
+
versions = {v: f"{v}@{config.vocabulary_versions[v]}" for v in vocabularies}
|
| 223 |
+
reverse = {version: voc for voc, version in versions.items()}
|
| 224 |
+
by_cui = {}
|
| 225 |
+
client = client or CodeMapperClient(config)
|
| 226 |
+
started = time.perf_counter()
|
| 227 |
+
seed_counts, unmapped = {}, []
|
| 228 |
+
try:
|
| 229 |
+
for voc in vocabularies:
|
| 230 |
+
group = [c for c in seeds if c.vocabulary == voc]
|
| 231 |
+
if not group:
|
| 232 |
+
continue
|
| 233 |
+
seed_counts[voc] = len(group)
|
| 234 |
+
response = client.cuis_for_codes(versions[voc], [c.code for c in group])
|
| 235 |
+
for seed in group:
|
| 236 |
+
cuis = response.get(seed.code, {}).get("cuis", [])
|
| 237 |
+
if not cuis:
|
| 238 |
+
unmapped.append({"code": seed.code, "vocabulary": voc})
|
| 239 |
+
for cui in cuis:
|
| 240 |
+
by_cui.setdefault(cui, []).append(seed)
|
| 241 |
+
concepts = client.concepts(by_cui, list(versions.values())) if by_cui else []
|
| 242 |
+
finally:
|
| 243 |
+
client.close()
|
| 244 |
+
available = {(standardize_code(c, v), v): (c, d)
|
| 245 |
+
for c, v, d in corpus[["code", "vocabulary", "description"]].itertuples(index=False, name=None)}
|
| 246 |
+
links, unavailable = {}, {}
|
| 247 |
+
for concept in concepts:
|
| 248 |
+
for source in concept.get("sourceConcepts", []):
|
| 249 |
+
voc = reverse.get(source["codingSystem"])
|
| 250 |
+
if voc is None:
|
| 251 |
+
continue
|
| 252 |
+
key = (standardize_code(source["id"], voc), voc)
|
| 253 |
+
provenance = [{"seed_code": s.code, "seed_vocabulary": s.vocabulary,
|
| 254 |
+
"seed_label": s.label, "cui": concept["cui"],
|
| 255 |
+
"source_version": versions[s.vocabulary],
|
| 256 |
+
"target_version": source["codingSystem"]}
|
| 257 |
+
for s in by_cui.get(concept["cui"], []) if s.vocabulary != voc]
|
| 258 |
+
if not provenance:
|
| 259 |
+
continue
|
| 260 |
+
target = links if key in available else unavailable
|
| 261 |
+
target.setdefault(key, []).extend(provenance)
|
| 262 |
+
merged = list(classified)
|
| 263 |
+
positions = {(standardize_code(c.code, c.vocabulary), c.vocabulary): i
|
| 264 |
+
for i, c in enumerate(merged)}
|
| 265 |
+
added, promoted, already_narrow = [], [], 0
|
| 266 |
+
for key, provenance in sorted(links.items()):
|
| 267 |
+
index = positions.get(key)
|
| 268 |
+
previous = merged[index] if index is not None else None
|
| 269 |
+
if previous is not None and previous.label == "Narrow":
|
| 270 |
+
already_narrow += 1
|
| 271 |
+
continue
|
| 272 |
+
code, description = available[key]
|
| 273 |
+
item = ClassifiedCandidate(code, description, key[1], "Narrow", 0.0,
|
| 274 |
+
"Included by direct cross-vocabulary CodeMapper CUI mapping; "
|
| 275 |
+
"not independently classified for EDF scope.",
|
| 276 |
+
decision_origin="codemapper", mapping_provenance=provenance,
|
| 277 |
+
prior_label=previous.label if previous else None,
|
| 278 |
+
prior_confidence=previous.confidence if previous else None)
|
| 279 |
+
if previous is None:
|
| 280 |
+
merged.append(item)
|
| 281 |
+
added.append({"code": code, "vocabulary": key[1]})
|
| 282 |
+
else:
|
| 283 |
+
merged[index] = replace(item, retrieval_score=previous.retrieval_score)
|
| 284 |
+
promoted.append({"code": code, "vocabulary": key[1], "prior_label": previous.label})
|
| 285 |
+
info = {"enabled": True, "rounds": 1, "descendants_used": False,
|
| 286 |
+
"direct_links": [{"code": available[key][0], "vocabulary": key[1],
|
| 287 |
+
"provenance": provenance} for key, provenance in sorted(links.items())],
|
| 288 |
+
"seeds": len(seeds), "seed_counts": seed_counts, "unique_cuis": len(by_cui),
|
| 289 |
+
"unmapped_seeds": unmapped, "new_codes": added, "promoted_codes": promoted,
|
| 290 |
+
"already_narrow": already_narrow,
|
| 291 |
+
"unavailable_codes": [{"code": c, "vocabulary": v, "provenance": p}
|
| 292 |
+
for (c, v), p in sorted(unavailable.items())],
|
| 293 |
+
"calls": client.calls, "cache_hits": client.cache_hits,
|
| 294 |
+
"seconds": round(time.perf_counter() - started, 3)}
|
| 295 |
+
return merged, info
|
src/retrieval/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Retrieval components: lexical, embedding, and hybrid candidate generation."""
|
src/retrieval/embeddings.py
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Embedding-based retrieval using local or API-backed encoders."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
import re
|
| 10 |
+
from typing import Protocol, Sequence
|
| 11 |
+
from urllib.error import URLError
|
| 12 |
+
from urllib.request import urlopen
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import pandas as pd
|
| 16 |
+
|
| 17 |
+
DEFAULT_LOCAL_EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
|
| 18 |
+
DEFAULT_OPENAI_EMBEDDING_MODEL = "text-embedding-3-large"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _create_local_embedding_model(model_name: str):
|
| 22 |
+
"""Import the optional local stack only when that backend is selected."""
|
| 23 |
+
from sentence_transformers import SentenceTransformer
|
| 24 |
+
|
| 25 |
+
return SentenceTransformer(model_name)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class EmbeddingEncoder(Protocol):
|
| 29 |
+
"""Common interface for local and API-backed embedding models."""
|
| 30 |
+
|
| 31 |
+
def encode(self, sentences, **kwargs): ...
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class OpenAIEmbeddingModel:
|
| 35 |
+
"""SentenceTransformer-compatible adapter for OpenAI embeddings."""
|
| 36 |
+
|
| 37 |
+
def __init__(
|
| 38 |
+
self,
|
| 39 |
+
model_name: str = DEFAULT_OPENAI_EMBEDDING_MODEL,
|
| 40 |
+
*,
|
| 41 |
+
dimensions: int | None = 3072,
|
| 42 |
+
batch_size: int = 512,
|
| 43 |
+
) -> None:
|
| 44 |
+
if dimensions is not None and dimensions < 1:
|
| 45 |
+
raise ValueError("Embedding dimensions must be at least 1")
|
| 46 |
+
if batch_size < 1:
|
| 47 |
+
raise ValueError("Embedding batch size must be at least 1")
|
| 48 |
+
self.model_name = model_name
|
| 49 |
+
self.dimensions = dimensions
|
| 50 |
+
self.batch_size = batch_size
|
| 51 |
+
self._client = None
|
| 52 |
+
|
| 53 |
+
def _get_client(self):
|
| 54 |
+
if self._client is None:
|
| 55 |
+
import openai
|
| 56 |
+
|
| 57 |
+
self._client = openai.OpenAI()
|
| 58 |
+
return self._client
|
| 59 |
+
|
| 60 |
+
def encode(
|
| 61 |
+
self,
|
| 62 |
+
sentences,
|
| 63 |
+
*,
|
| 64 |
+
normalize_embeddings: bool = True,
|
| 65 |
+
show_progress_bar: bool = False,
|
| 66 |
+
**_kwargs,
|
| 67 |
+
) -> np.ndarray:
|
| 68 |
+
del show_progress_bar
|
| 69 |
+
single = isinstance(sentences, str)
|
| 70 |
+
inputs = [sentences] if single else [str(item) for item in sentences]
|
| 71 |
+
if not inputs:
|
| 72 |
+
width = self.dimensions or 0
|
| 73 |
+
return np.empty((0, width), dtype=np.float32)
|
| 74 |
+
|
| 75 |
+
# Convert each response to float32 immediately. Keeping hundreds of
|
| 76 |
+
# thousands of embeddings as Python float lists can require many times
|
| 77 |
+
# more memory than the final NumPy matrix.
|
| 78 |
+
chunks: list[np.ndarray] = []
|
| 79 |
+
client = self._get_client()
|
| 80 |
+
for start in range(0, len(inputs), self.batch_size):
|
| 81 |
+
request = {
|
| 82 |
+
"model": self.model_name,
|
| 83 |
+
"input": inputs[start : start + self.batch_size],
|
| 84 |
+
"encoding_format": "float",
|
| 85 |
+
}
|
| 86 |
+
if self.dimensions is not None:
|
| 87 |
+
request["dimensions"] = self.dimensions
|
| 88 |
+
response = client.embeddings.create(**request)
|
| 89 |
+
chunks.append(
|
| 90 |
+
np.asarray(
|
| 91 |
+
[
|
| 92 |
+
item.embedding
|
| 93 |
+
for item in sorted(response.data, key=lambda item: item.index)
|
| 94 |
+
],
|
| 95 |
+
dtype=np.float32,
|
| 96 |
+
)
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
result = np.vstack(chunks)
|
| 100 |
+
if normalize_embeddings:
|
| 101 |
+
norms = np.linalg.norm(result, axis=1, keepdims=True)
|
| 102 |
+
result = result / np.maximum(norms, 1e-12)
|
| 103 |
+
return result[0] if single else result
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def create_embedding_model(
|
| 107 |
+
provider: str,
|
| 108 |
+
model_name: str,
|
| 109 |
+
*,
|
| 110 |
+
dimensions: int | None = None,
|
| 111 |
+
batch_size: int = 512,
|
| 112 |
+
) -> EmbeddingEncoder:
|
| 113 |
+
"""Construct a local SentenceTransformer or OpenAI embedding adapter."""
|
| 114 |
+
provider = provider.strip().lower()
|
| 115 |
+
if provider == "local":
|
| 116 |
+
return _create_local_embedding_model(model_name)
|
| 117 |
+
if provider == "openai":
|
| 118 |
+
return OpenAIEmbeddingModel(
|
| 119 |
+
model_name,
|
| 120 |
+
dimensions=dimensions,
|
| 121 |
+
batch_size=batch_size,
|
| 122 |
+
)
|
| 123 |
+
raise ValueError(f"Unsupported embedding provider: {provider!r}")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def embedding_cache_key(
|
| 127 |
+
provider: str,
|
| 128 |
+
model_name: str,
|
| 129 |
+
dimensions: int | None = None,
|
| 130 |
+
) -> str:
|
| 131 |
+
"""Return a filesystem-safe cache identity without mixing providers/models."""
|
| 132 |
+
model_key = re.sub(r"[^A-Za-z0-9_.-]+", "_", model_name).strip("_")
|
| 133 |
+
if provider.strip().lower() == "local":
|
| 134 |
+
return model_key
|
| 135 |
+
dimension_key = f"_{dimensions}" if dimensions is not None else ""
|
| 136 |
+
return f"{provider.strip().lower()}_{model_key}{dimension_key}"
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@dataclass(slots=True)
|
| 140 |
+
class EmbeddingCandidate:
|
| 141 |
+
rank: int
|
| 142 |
+
code: str
|
| 143 |
+
description: str
|
| 144 |
+
vocabulary: str
|
| 145 |
+
score: float
|
| 146 |
+
|
| 147 |
+
def __lt__(self, other: object) -> bool:
|
| 148 |
+
if not isinstance(other, EmbeddingCandidate):
|
| 149 |
+
return NotImplemented
|
| 150 |
+
return self.score > other.score
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@dataclass
|
| 154 |
+
class EmbeddingIndex:
|
| 155 |
+
"""Wraps embedding model + corpus embeddings for fast cosine-sim search."""
|
| 156 |
+
|
| 157 |
+
model: EmbeddingEncoder | None
|
| 158 |
+
descriptions: list[str]
|
| 159 |
+
codes: list[str]
|
| 160 |
+
vocabularies: list[str]
|
| 161 |
+
embeddings: np.ndarray # shape (n, dim)
|
| 162 |
+
|
| 163 |
+
@classmethod
|
| 164 |
+
def from_codes(
|
| 165 |
+
cls,
|
| 166 |
+
codes: pd.DataFrame,
|
| 167 |
+
model_name: str = DEFAULT_LOCAL_EMBEDDING_MODEL,
|
| 168 |
+
model: EmbeddingEncoder | None = None,
|
| 169 |
+
show_progress_bar: bool = False,
|
| 170 |
+
) -> "EmbeddingIndex":
|
| 171 |
+
df = codes.copy()
|
| 172 |
+
for col in ("code", "vocabulary"):
|
| 173 |
+
if col not in df.columns:
|
| 174 |
+
df[col] = ""
|
| 175 |
+
embedding_model = model or _create_local_embedding_model(model_name)
|
| 176 |
+
descs = df["description"].fillna("").astype(str).tolist()
|
| 177 |
+
embs = embedding_model.encode(
|
| 178 |
+
descs,
|
| 179 |
+
show_progress_bar=show_progress_bar,
|
| 180 |
+
normalize_embeddings=True,
|
| 181 |
+
)
|
| 182 |
+
return cls(
|
| 183 |
+
model=embedding_model,
|
| 184 |
+
descriptions=descs,
|
| 185 |
+
codes=df["code"].astype(str).tolist(),
|
| 186 |
+
vocabularies=df["vocabulary"].astype(str).tolist(),
|
| 187 |
+
embeddings=np.asarray(embs, dtype=np.float32),
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
def save(self, path: str | Path) -> None:
|
| 191 |
+
"""Persist the corpus embeddings + metadata to disk (npz) so they can
|
| 192 |
+
be reloaded without re-running the embedding model."""
|
| 193 |
+
path = Path(path)
|
| 194 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 195 |
+
np.savez_compressed(
|
| 196 |
+
path,
|
| 197 |
+
embeddings=np.asarray(self.embeddings, dtype=np.float32),
|
| 198 |
+
codes=np.array(self.codes, dtype=object),
|
| 199 |
+
descriptions=np.array(self.descriptions, dtype=object),
|
| 200 |
+
vocabularies=np.array(self.vocabularies, dtype=object),
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
@classmethod
|
| 204 |
+
def load(
|
| 205 |
+
cls,
|
| 206 |
+
path: str | Path,
|
| 207 |
+
model_name: str = DEFAULT_LOCAL_EMBEDDING_MODEL,
|
| 208 |
+
model: EmbeddingEncoder | None = None,
|
| 209 |
+
load_model: bool = True,
|
| 210 |
+
) -> "EmbeddingIndex":
|
| 211 |
+
"""Load a previously-saved embedding index from disk."""
|
| 212 |
+
with np.load(path, allow_pickle=True) as data:
|
| 213 |
+
return cls(
|
| 214 |
+
model=model or (_create_local_embedding_model(model_name) if load_model else None),
|
| 215 |
+
descriptions=list(data["descriptions"]),
|
| 216 |
+
codes=list(data["codes"]),
|
| 217 |
+
vocabularies=list(data["vocabularies"]),
|
| 218 |
+
embeddings=np.asarray(data["embeddings"], dtype=np.float32),
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
@classmethod
|
| 222 |
+
def from_cache_or_build(
|
| 223 |
+
cls,
|
| 224 |
+
codes: pd.DataFrame,
|
| 225 |
+
cache_path: str | Path,
|
| 226 |
+
model_name: str = DEFAULT_LOCAL_EMBEDDING_MODEL,
|
| 227 |
+
model: EmbeddingEncoder | None = None,
|
| 228 |
+
load_model: bool = True,
|
| 229 |
+
show_progress_bar: bool = False,
|
| 230 |
+
status_callback: Callable[[str], None] | None = None,
|
| 231 |
+
) -> "EmbeddingIndex":
|
| 232 |
+
"""Load a cached embedding index if present, otherwise build it from
|
| 233 |
+
*codes* and cache the result for subsequent runs.
|
| 234 |
+
|
| 235 |
+
This is the key mechanism that avoids re-embedding large reference
|
| 236 |
+
code systems (e.g. the ~74k-row full ICD-10-CM corpus) on every run.
|
| 237 |
+
"""
|
| 238 |
+
cache_path = Path(cache_path)
|
| 239 |
+
if cache_path.exists():
|
| 240 |
+
cached = cls.load(
|
| 241 |
+
cache_path,
|
| 242 |
+
model_name=model_name,
|
| 243 |
+
model=model,
|
| 244 |
+
load_model=load_model,
|
| 245 |
+
)
|
| 246 |
+
expected_codes = codes.get("code", pd.Series(dtype=str)).astype(str).tolist()
|
| 247 |
+
expected_vocabularies = (
|
| 248 |
+
codes.get("vocabulary", pd.Series(dtype=str)).astype(str).tolist()
|
| 249 |
+
)
|
| 250 |
+
expected_descriptions = codes["description"].fillna("").astype(str).tolist()
|
| 251 |
+
if (
|
| 252 |
+
cached.codes == expected_codes
|
| 253 |
+
and cached.vocabularies == expected_vocabularies
|
| 254 |
+
and cached.descriptions == expected_descriptions
|
| 255 |
+
):
|
| 256 |
+
return cached
|
| 257 |
+
subset_positions = _ordered_subset_positions(
|
| 258 |
+
cached.codes,
|
| 259 |
+
cached.vocabularies,
|
| 260 |
+
expected_codes,
|
| 261 |
+
expected_vocabularies,
|
| 262 |
+
)
|
| 263 |
+
if subset_positions is not None and all(
|
| 264 |
+
cached.descriptions[position] == description
|
| 265 |
+
for position, description in zip(subset_positions, expected_descriptions)
|
| 266 |
+
):
|
| 267 |
+
# Corpus preprocessing can intentionally remove non-codable
|
| 268 |
+
# dictionary rows (for example ICD range headers). Reuse the
|
| 269 |
+
# matching cached vectors instead of re-embedding every code.
|
| 270 |
+
subset = cls(
|
| 271 |
+
model=cached.model,
|
| 272 |
+
descriptions=expected_descriptions,
|
| 273 |
+
codes=expected_codes,
|
| 274 |
+
vocabularies=expected_vocabularies,
|
| 275 |
+
embeddings=np.asarray(cached.embeddings[subset_positions], dtype=np.float32),
|
| 276 |
+
)
|
| 277 |
+
subset.save(cache_path)
|
| 278 |
+
return subset
|
| 279 |
+
if status_callback is not None:
|
| 280 |
+
status_callback(
|
| 281 |
+
f"Creating embeddings for {len(codes):,} descriptions; no matching saved index. "
|
| 282 |
+
"The first run can take several minutes."
|
| 283 |
+
)
|
| 284 |
+
index = cls.from_codes(
|
| 285 |
+
codes,
|
| 286 |
+
model_name=model_name,
|
| 287 |
+
model=model,
|
| 288 |
+
show_progress_bar=show_progress_bar,
|
| 289 |
+
)
|
| 290 |
+
index.save(cache_path)
|
| 291 |
+
return index
|
| 292 |
+
|
| 293 |
+
@classmethod
|
| 294 |
+
def combine(cls, indexes: Sequence["EmbeddingIndex"]) -> "EmbeddingIndex":
|
| 295 |
+
"""Concatenate multiple embedding indexes into a single searchable index."""
|
| 296 |
+
if not indexes:
|
| 297 |
+
raise ValueError("At least one embedding index must be provided.")
|
| 298 |
+
|
| 299 |
+
descriptions: list[str] = []
|
| 300 |
+
codes: list[str] = []
|
| 301 |
+
vocabularies: list[str] = []
|
| 302 |
+
embeddings: list[np.ndarray] = []
|
| 303 |
+
|
| 304 |
+
for index in indexes:
|
| 305 |
+
descriptions.extend(index.descriptions)
|
| 306 |
+
codes.extend(index.codes)
|
| 307 |
+
vocabularies.extend(index.vocabularies)
|
| 308 |
+
embeddings.append(np.asarray(index.embeddings))
|
| 309 |
+
|
| 310 |
+
return cls(
|
| 311 |
+
model=indexes[0].model,
|
| 312 |
+
descriptions=descriptions,
|
| 313 |
+
codes=codes,
|
| 314 |
+
vocabularies=vocabularies,
|
| 315 |
+
embeddings=(embeddings[0] if len(embeddings) == 1 else np.vstack(embeddings)).astype(np.float32, copy=False)
|
| 316 |
+
if embeddings
|
| 317 |
+
else np.empty((0, 0), dtype=np.float32),
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
@classmethod
|
| 321 |
+
def load_for_vocabularies(
|
| 322 |
+
cls,
|
| 323 |
+
codes: pd.DataFrame,
|
| 324 |
+
vocabularies: Sequence[str],
|
| 325 |
+
cache_dir: str | Path,
|
| 326 |
+
model_name: str = DEFAULT_LOCAL_EMBEDDING_MODEL,
|
| 327 |
+
model: EmbeddingEncoder | None = None,
|
| 328 |
+
provider: str = "local",
|
| 329 |
+
dimensions: int | None = None,
|
| 330 |
+
progress_callback: Callable[[int, int], None] | None = None,
|
| 331 |
+
status_callback: Callable[[str], None] | None = None,
|
| 332 |
+
) -> "EmbeddingIndex":
|
| 333 |
+
"""Load per-vocabulary embedding caches and concatenate them.
|
| 334 |
+
|
| 335 |
+
Each vocabulary is cached independently by vocabulary, provider,
|
| 336 |
+
model, and output dimensions.
|
| 337 |
+
Selected vocabularies are then combined in memory for retrieval.
|
| 338 |
+
"""
|
| 339 |
+
cache_dir = Path(cache_dir)
|
| 340 |
+
indexes: list[EmbeddingIndex] = []
|
| 341 |
+
total = len(vocabularies)
|
| 342 |
+
if progress_callback is not None:
|
| 343 |
+
progress_callback(0, total)
|
| 344 |
+
for position, vocabulary in enumerate(vocabularies, start=1):
|
| 345 |
+
vocab_df = codes[
|
| 346 |
+
codes["vocabulary"].astype(str).str.upper() == vocabulary.upper()
|
| 347 |
+
].reset_index(drop=True)
|
| 348 |
+
if vocab_df.empty:
|
| 349 |
+
continue
|
| 350 |
+
cache_identity = embedding_cache_key(provider, model_name, dimensions)
|
| 351 |
+
cache_path = cache_dir / f"embeddings_{vocabulary}_{cache_identity}.npz"
|
| 352 |
+
if status_callback is not None:
|
| 353 |
+
status_callback(f"Preparing embeddings for {vocabulary} ({len(vocab_df):,} descriptions)...")
|
| 354 |
+
# Existing release assets contain the legacy local MiniLM indexes.
|
| 355 |
+
# OpenAI indexes are private, paid-to-generate local artifacts and
|
| 356 |
+
# must not be mistaken for downloadable release assets.
|
| 357 |
+
if provider.strip().lower() == "local":
|
| 358 |
+
_ensure_embedding_asset(cache_path)
|
| 359 |
+
if status_callback is not None:
|
| 360 |
+
status_callback(
|
| 361 |
+
f"Loading and checking saved embeddings for {vocabulary}..."
|
| 362 |
+
if cache_path.exists()
|
| 363 |
+
else f"Creating embeddings for {vocabulary} ({len(vocab_df):,} descriptions). "
|
| 364 |
+
"The first run can take several minutes."
|
| 365 |
+
)
|
| 366 |
+
indexes.append(
|
| 367 |
+
cls.from_cache_or_build(
|
| 368 |
+
vocab_df,
|
| 369 |
+
cache_path=cache_path,
|
| 370 |
+
model_name=model_name,
|
| 371 |
+
model=model,
|
| 372 |
+
load_model=False,
|
| 373 |
+
show_progress_bar=progress_callback is not None,
|
| 374 |
+
status_callback=(
|
| 375 |
+
(lambda message, vocabulary=vocabulary: status_callback(f"{vocabulary}: {message}"))
|
| 376 |
+
if status_callback is not None else None
|
| 377 |
+
),
|
| 378 |
+
)
|
| 379 |
+
)
|
| 380 |
+
if progress_callback is not None:
|
| 381 |
+
progress_callback(position, total)
|
| 382 |
+
if status_callback is not None:
|
| 383 |
+
status_callback("Combining terminology embeddings...")
|
| 384 |
+
matrices = [np.asarray(index.embeddings, dtype=np.float32) for index in indexes]
|
| 385 |
+
return cls(
|
| 386 |
+
model=None,
|
| 387 |
+
descriptions=[d for index in indexes for d in index.descriptions],
|
| 388 |
+
codes=[c for index in indexes for c in index.codes],
|
| 389 |
+
vocabularies=[v for index in indexes for v in index.vocabularies],
|
| 390 |
+
embeddings=(matrices[0] if len(matrices) == 1 else np.vstack(matrices))
|
| 391 |
+
if indexes
|
| 392 |
+
else np.empty((0, 0), dtype=np.float32),
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
def retrieve(
|
| 396 |
+
self,
|
| 397 |
+
query: str,
|
| 398 |
+
top_k: int | None = None,
|
| 399 |
+
model: EmbeddingEncoder | None = None,
|
| 400 |
+
) -> list[EmbeddingCandidate]:
|
| 401 |
+
query_model = model or self.model
|
| 402 |
+
if query_model is None:
|
| 403 |
+
raise ValueError("An embedding model is required to embed the query.")
|
| 404 |
+
q_emb = query_model.encode(query, show_progress_bar=False, normalize_embeddings=True)
|
| 405 |
+
scores = self.embeddings @ q_emb # cosine similarity (both normalised)
|
| 406 |
+
if top_k is not None and len(scores) > top_k:
|
| 407 |
+
top_indices = np.argpartition(scores, -top_k)[-top_k:]
|
| 408 |
+
sorted_local = sorted(top_indices, key=lambda i: -scores[i])[:top_k]
|
| 409 |
+
else:
|
| 410 |
+
sorted_local = np.argsort(-scores)
|
| 411 |
+
|
| 412 |
+
return [
|
| 413 |
+
EmbeddingCandidate(
|
| 414 |
+
rank=r + 1,
|
| 415 |
+
code=self.codes[idx],
|
| 416 |
+
description=self.descriptions[idx],
|
| 417 |
+
vocabulary=self.vocabularies[idx],
|
| 418 |
+
score=float(round(scores[idx], 6)),
|
| 419 |
+
)
|
| 420 |
+
for r, idx in enumerate(sorted_local)
|
| 421 |
+
]
|
| 422 |
+
|
| 423 |
+
def score_all(
|
| 424 |
+
self,
|
| 425 |
+
query: str,
|
| 426 |
+
model: EmbeddingEncoder | None = None,
|
| 427 |
+
) -> list[float]:
|
| 428 |
+
"""Return corpus-order cosine scores without ranking or object creation.
|
| 429 |
+
|
| 430 |
+
Hybrid retrieval needs every embedding score for global min-max
|
| 431 |
+
normalization, but it does not need the embedding-only ranking. This
|
| 432 |
+
path preserves the same six-decimal scores as :meth:`retrieve` while
|
| 433 |
+
avoiding a full sort and hundreds of thousands of temporary
|
| 434 |
+
``EmbeddingCandidate`` objects.
|
| 435 |
+
"""
|
| 436 |
+
query_model = model or self.model
|
| 437 |
+
if query_model is None:
|
| 438 |
+
raise ValueError("An embedding model is required to embed the query.")
|
| 439 |
+
q_emb = query_model.encode(query, show_progress_bar=False, normalize_embeddings=True)
|
| 440 |
+
scores = self.embeddings @ q_emb
|
| 441 |
+
return [float(round(score, 6)) for score in scores]
|
| 442 |
+
|
| 443 |
+
|
| 444 |
+
def _ordered_subset_positions(
|
| 445 |
+
cached_codes: Sequence[str],
|
| 446 |
+
cached_vocabularies: Sequence[str],
|
| 447 |
+
expected_codes: Sequence[str],
|
| 448 |
+
expected_vocabularies: Sequence[str],
|
| 449 |
+
) -> list[int] | None:
|
| 450 |
+
"""Locate an order-preserving expected corpus inside a cached corpus."""
|
| 451 |
+
if (
|
| 452 |
+
len(expected_codes) != len(expected_vocabularies)
|
| 453 |
+
or len(expected_codes) > len(cached_codes)
|
| 454 |
+
):
|
| 455 |
+
return None
|
| 456 |
+
positions: list[int] = []
|
| 457 |
+
expected_index = 0
|
| 458 |
+
for cached_index, (code, vocabulary) in enumerate(
|
| 459 |
+
zip(cached_codes, cached_vocabularies)
|
| 460 |
+
):
|
| 461 |
+
if expected_index >= len(expected_codes):
|
| 462 |
+
break
|
| 463 |
+
if (
|
| 464 |
+
code == expected_codes[expected_index]
|
| 465 |
+
and vocabulary == expected_vocabularies[expected_index]
|
| 466 |
+
):
|
| 467 |
+
positions.append(cached_index)
|
| 468 |
+
expected_index += 1
|
| 469 |
+
return positions if expected_index == len(expected_codes) else None
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def _release_asset_url(asset_name: str) -> str | None:
|
| 473 |
+
owner = os.getenv("GITHUB_REPOSITORY_OWNER")
|
| 474 |
+
repo = os.getenv("GITHUB_REPOSITORY_NAME")
|
| 475 |
+
tag = os.getenv("GITHUB_RELEASE_TAG")
|
| 476 |
+
if not owner or not repo or not tag:
|
| 477 |
+
return None
|
| 478 |
+
return f"https://github.com/{owner}/{repo}/releases/download/{tag}/{asset_name}"
|
| 479 |
+
|
| 480 |
+
|
| 481 |
+
def _ensure_embedding_asset(cache_path: Path) -> None:
|
| 482 |
+
"""Ensure an embedding asset exists locally, downloading it from a GitHub Release if needed."""
|
| 483 |
+
if cache_path.exists():
|
| 484 |
+
return
|
| 485 |
+
|
| 486 |
+
asset_name = cache_path.name
|
| 487 |
+
url = _release_asset_url(asset_name)
|
| 488 |
+
if url is None:
|
| 489 |
+
return
|
| 490 |
+
|
| 491 |
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
| 492 |
+
try:
|
| 493 |
+
with urlopen(url) as response, cache_path.open("wb") as fh:
|
| 494 |
+
fh.write(response.read())
|
| 495 |
+
except URLError:
|
| 496 |
+
if cache_path.exists():
|
| 497 |
+
cache_path.unlink(missing_ok=True)
|
| 498 |
+
raise
|
| 499 |
+
|
| 500 |
+
|
| 501 |
+
def retrieve_embeddings(
|
| 502 |
+
query: str,
|
| 503 |
+
codes: pd.DataFrame,
|
| 504 |
+
model_name: str = DEFAULT_LOCAL_EMBEDDING_MODEL,
|
| 505 |
+
top_k: int | None = None,
|
| 506 |
+
) -> list[EmbeddingCandidate]:
|
| 507 |
+
"""Build an embedding index on *codes* and retrieve candidates for *query*.
|
| 508 |
+
|
| 509 |
+
Parameters
|
| 510 |
+
----------
|
| 511 |
+
query : str
|
| 512 |
+
Phenotype / EDF description to search.
|
| 513 |
+
codes : pd.DataFrame
|
| 514 |
+
Corpus with a ``description`` column (and optionally ``code``/``vocabulary``).
|
| 515 |
+
model_name : str
|
| 516 |
+
sentence-transformers model identifier.
|
| 517 |
+
top_k : int, optional
|
| 518 |
+
Number of results to return.
|
| 519 |
+
|
| 520 |
+
Returns
|
| 521 |
+
-------
|
| 522 |
+
list[EmbeddingCandidate]
|
| 523 |
+
"""
|
| 524 |
+
df = codes.copy()
|
| 525 |
+
for col in ("code", "vocabulary"):
|
| 526 |
+
if col not in df.columns:
|
| 527 |
+
df[col] = ""
|
| 528 |
+
|
| 529 |
+
descs = df["description"].fillna("").astype(str).tolist()
|
| 530 |
+
model = _create_local_embedding_model(model_name)
|
| 531 |
+
embs = model.encode(descs, show_progress_bar=False, normalize_embeddings=True)
|
| 532 |
+
q_emb = model.encode(query, show_progress_bar=False, normalize_embeddings=True)
|
| 533 |
+
scores = embs @ q_emb
|
| 534 |
+
|
| 535 |
+
sorted_idx = np.argsort(-scores)
|
| 536 |
+
if top_k is not None:
|
| 537 |
+
sorted_idx = sorted_idx[:top_k]
|
| 538 |
+
|
| 539 |
+
return [
|
| 540 |
+
EmbeddingCandidate(
|
| 541 |
+
rank=r + 1,
|
| 542 |
+
code=df.iloc[idx]["code"],
|
| 543 |
+
description=descs[idx],
|
| 544 |
+
vocabulary=str(df.iloc[idx].get("vocabulary", "")),
|
| 545 |
+
score=float(round(scores[idx], 6)),
|
| 546 |
+
)
|
| 547 |
+
for r, idx in enumerate(sorted_idx)
|
| 548 |
+
]
|
src/retrieval/hybrid.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hybrid retrieval combining lexical (BM25) and embedding (semantic) scores."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
import pandas as pd
|
| 8 |
+
|
| 9 |
+
from src.retrieval.embeddings import EmbeddingEncoder, EmbeddingIndex, retrieve_embeddings
|
| 10 |
+
from src.retrieval.lexical import build_lexical_index, score_lexical
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(slots=True)
|
| 14 |
+
class HybridCandidate:
|
| 15 |
+
rank: int
|
| 16 |
+
code: str
|
| 17 |
+
description: str
|
| 18 |
+
vocabulary: str
|
| 19 |
+
lexical_score: float
|
| 20 |
+
embedding_score: float
|
| 21 |
+
score: float
|
| 22 |
+
|
| 23 |
+
def __lt__(self, other: object) -> bool:
|
| 24 |
+
if not isinstance(other, HybridCandidate):
|
| 25 |
+
return NotImplemented
|
| 26 |
+
return self.score > other.score
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _min_max_normalize(values: list[float]) -> list[float]:
|
| 30 |
+
if not values:
|
| 31 |
+
return values
|
| 32 |
+
lo, hi = min(values), max(values)
|
| 33 |
+
if hi - lo < 1e-12:
|
| 34 |
+
return [0.0 for _ in values]
|
| 35 |
+
return [(v - lo) / (hi - lo) for v in values]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def hybrid_retrieval(
|
| 39 |
+
query: str,
|
| 40 |
+
codes: pd.DataFrame,
|
| 41 |
+
lexical_weight: float = 0.5,
|
| 42 |
+
embedding_weight: float = 0.5,
|
| 43 |
+
embedding_model: str = "sentence-transformers/all-MiniLM-L6-v2",
|
| 44 |
+
top_k: int | None = 25,
|
| 45 |
+
per_vocabulary_top_k: bool = True,
|
| 46 |
+
embedding_index: EmbeddingIndex | None = None,
|
| 47 |
+
query_model: EmbeddingEncoder | None = None,
|
| 48 |
+
lexical_query: str | None = None,
|
| 49 |
+
embedding_query: str | None = None,
|
| 50 |
+
) -> list[HybridCandidate]:
|
| 51 |
+
"""Combine BM25 lexical retrieval with embedding-based semantic retrieval.
|
| 52 |
+
|
| 53 |
+
Both signals are min-max normalized to [0, 1] and combined with the given
|
| 54 |
+
weights before ranking. The combined ranking is truncated to *top_k*.
|
| 55 |
+
|
| 56 |
+
Parameters
|
| 57 |
+
----------
|
| 58 |
+
query : str
|
| 59 |
+
Phenotype / EDF text used as the search query.
|
| 60 |
+
codes : pd.DataFrame
|
| 61 |
+
Candidate code corpus with ``code``, ``description``, ``vocabulary`` columns.
|
| 62 |
+
lexical_weight, embedding_weight : float
|
| 63 |
+
Relative weighting of each retrieval signal. Need not sum to 1.
|
| 64 |
+
embedding_model : str
|
| 65 |
+
sentence-transformers model identifier used when *embedding_index* is None.
|
| 66 |
+
top_k : int, optional
|
| 67 |
+
Number of ranked results to return per vocabulary. ``None`` returns the
|
| 68 |
+
full corpus.
|
| 69 |
+
per_vocabulary_top_k : bool
|
| 70 |
+
If ``True`` (default), apply ``top_k`` separately within each selected
|
| 71 |
+
vocabulary. If ``False``, apply ``top_k`` after combining all selected
|
| 72 |
+
vocabularies.
|
| 73 |
+
embedding_index : EmbeddingIndex, optional
|
| 74 |
+
Pre-built embedding index to reuse across multiple queries.
|
| 75 |
+
query_model : EmbeddingEncoder, optional
|
| 76 |
+
Cached local or API-backed encoder used to embed the EDF/query text.
|
| 77 |
+
lexical_query : str, optional
|
| 78 |
+
Text used for the BM25 lexical score. If omitted, *query* is used.
|
| 79 |
+
embedding_query : str, optional
|
| 80 |
+
Text used for the embedding score. If omitted, *query* is used.
|
| 81 |
+
|
| 82 |
+
Returns
|
| 83 |
+
-------
|
| 84 |
+
list[HybridCandidate]
|
| 85 |
+
"""
|
| 86 |
+
if codes.empty:
|
| 87 |
+
return []
|
| 88 |
+
|
| 89 |
+
df = codes.reset_index(drop=True).copy()
|
| 90 |
+
for col in ("code", "vocabulary"):
|
| 91 |
+
if col not in df.columns:
|
| 92 |
+
df[col] = ""
|
| 93 |
+
|
| 94 |
+
lexical_query = lexical_query or query
|
| 95 |
+
embedding_query = embedding_query or query
|
| 96 |
+
embedding_scores_by_code: dict[tuple[str, str], float] | None = None
|
| 97 |
+
if embedding_index is not None:
|
| 98 |
+
# Score the combined corpus exactly once. Previously this full matrix
|
| 99 |
+
# multiplication, full sort, and candidate-object construction ran
|
| 100 |
+
# once per vocabulary even though every pass produced identical raw
|
| 101 |
+
# scores. The corpus is deduplicated on (code, vocabulary), so the map
|
| 102 |
+
# is identical to the maps produced by the former repeated searches.
|
| 103 |
+
all_embedding_scores = embedding_index.score_all(embedding_query, model=query_model)
|
| 104 |
+
embedding_scores_by_code = {
|
| 105 |
+
(code, vocabulary): score
|
| 106 |
+
for code, vocabulary, score in zip(
|
| 107 |
+
embedding_index.codes,
|
| 108 |
+
embedding_index.vocabularies,
|
| 109 |
+
all_embedding_scores,
|
| 110 |
+
)
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
per_vocab_candidates: list[
|
| 114 |
+
tuple[str, list[tuple[str, str, str, float, float]]]
|
| 115 |
+
] = []
|
| 116 |
+
for vocabulary, vocab_df in df.groupby("vocabulary", sort=False):
|
| 117 |
+
lexical_index = build_lexical_index(vocab_df)
|
| 118 |
+
lexical_scores = score_lexical(lexical_query, lexical_index, len(vocab_df))
|
| 119 |
+
|
| 120 |
+
if embedding_scores_by_code is None:
|
| 121 |
+
embedding_results = retrieve_embeddings(embedding_query, vocab_df, model_name=embedding_model, top_k=None)
|
| 122 |
+
vocabulary_embedding_scores = {
|
| 123 |
+
(c.code, c.vocabulary): c.score for c in embedding_results
|
| 124 |
+
}
|
| 125 |
+
else:
|
| 126 |
+
vocabulary_embedding_scores = embedding_scores_by_code
|
| 127 |
+
|
| 128 |
+
codes_values = vocab_df["code"].tolist()
|
| 129 |
+
description_values = vocab_df["description"].tolist()
|
| 130 |
+
vocabulary_values = vocab_df["vocabulary"].tolist()
|
| 131 |
+
vocab_candidates = [
|
| 132 |
+
(
|
| 133 |
+
code,
|
| 134 |
+
description,
|
| 135 |
+
candidate_vocabulary,
|
| 136 |
+
lexical_score,
|
| 137 |
+
vocabulary_embedding_scores.get((code, candidate_vocabulary), 0.0),
|
| 138 |
+
)
|
| 139 |
+
for code, description, candidate_vocabulary, lexical_score in zip(
|
| 140 |
+
codes_values,
|
| 141 |
+
description_values,
|
| 142 |
+
vocabulary_values,
|
| 143 |
+
lexical_scores,
|
| 144 |
+
)
|
| 145 |
+
]
|
| 146 |
+
per_vocab_candidates.append((vocabulary, vocab_candidates))
|
| 147 |
+
|
| 148 |
+
all_lexical = [
|
| 149 |
+
lexical
|
| 150 |
+
for _, vocab_candidates in per_vocab_candidates
|
| 151 |
+
for _, _, _, lexical, _ in vocab_candidates
|
| 152 |
+
]
|
| 153 |
+
all_embedding = [
|
| 154 |
+
embedding
|
| 155 |
+
for _, vocab_candidates in per_vocab_candidates
|
| 156 |
+
for _, _, _, _, embedding in vocab_candidates
|
| 157 |
+
]
|
| 158 |
+
norm_all_lexical = _min_max_normalize(all_lexical)
|
| 159 |
+
norm_all_embedding = _min_max_normalize(all_embedding)
|
| 160 |
+
|
| 161 |
+
# Keep lightweight tuples until after top-k selection. Constructing a
|
| 162 |
+
# dataclass (and formerly a Pandas Series) for all 620k rows dominated the
|
| 163 |
+
# runtime even when only a handful of results were requested.
|
| 164 |
+
selected: list[tuple[str, str, str, float, float, float]] = []
|
| 165 |
+
flat_idx = 0
|
| 166 |
+
for _, vocab_candidates in per_vocab_candidates:
|
| 167 |
+
scored: list[tuple[str, str, str, float, float, float]] = []
|
| 168 |
+
for idx, (code, description, vocabulary, _, _) in enumerate(vocab_candidates):
|
| 169 |
+
norm_lexical = norm_all_lexical[flat_idx + idx]
|
| 170 |
+
norm_embedding = norm_all_embedding[flat_idx + idx]
|
| 171 |
+
score = lexical_weight * norm_lexical + embedding_weight * norm_embedding
|
| 172 |
+
scored.append(
|
| 173 |
+
(
|
| 174 |
+
code,
|
| 175 |
+
description,
|
| 176 |
+
vocabulary,
|
| 177 |
+
round(norm_lexical, 6),
|
| 178 |
+
round(norm_embedding, 6),
|
| 179 |
+
round(score, 6),
|
| 180 |
+
)
|
| 181 |
+
)
|
| 182 |
+
if per_vocabulary_top_k and top_k is not None:
|
| 183 |
+
scored.sort(key=lambda candidate: -candidate[5])
|
| 184 |
+
scored = scored[:top_k]
|
| 185 |
+
selected.extend(scored)
|
| 186 |
+
flat_idx += len(vocab_candidates)
|
| 187 |
+
|
| 188 |
+
selected.sort(key=lambda candidate: -candidate[5])
|
| 189 |
+
if not per_vocabulary_top_k and top_k is not None:
|
| 190 |
+
selected = selected[:top_k]
|
| 191 |
+
return [
|
| 192 |
+
HybridCandidate(
|
| 193 |
+
rank=rank,
|
| 194 |
+
code=code,
|
| 195 |
+
description=description,
|
| 196 |
+
vocabulary=vocabulary,
|
| 197 |
+
lexical_score=lexical_score,
|
| 198 |
+
embedding_score=embedding_score,
|
| 199 |
+
score=score,
|
| 200 |
+
)
|
| 201 |
+
for rank, (code, description, vocabulary, lexical_score, embedding_score, score)
|
| 202 |
+
in enumerate(selected, start=1)
|
| 203 |
+
]
|
src/retrieval/lexical.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BM25 lexical retrieval over code descriptions."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import re
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pandas as pd
|
| 10 |
+
from rank_bm25 import BM25Okapi
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _tokenize(text: str) -> list[str]:
|
| 14 |
+
"""Simple whitespace + punctuation tokenizer for BM25 indexing."""
|
| 15 |
+
if not text or not isinstance(text, str):
|
| 16 |
+
return []
|
| 17 |
+
text = re.sub(r"[^\w\s]", " ", text.lower())
|
| 18 |
+
return [tok for tok in text.split() if len(tok) > 1]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(slots=True)
|
| 22 |
+
class LexicalCandidate:
|
| 23 |
+
rank: int
|
| 24 |
+
code: str
|
| 25 |
+
description: str
|
| 26 |
+
vocabulary: str
|
| 27 |
+
score: float
|
| 28 |
+
|
| 29 |
+
def __lt__(self, other: object) -> bool:
|
| 30 |
+
if not isinstance(other, LexicalCandidate):
|
| 31 |
+
return NotImplemented
|
| 32 |
+
return self.score > other.score # higher = more relevant
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def build_lexical_index(codes: pd.DataFrame) -> BM25Okapi:
|
| 36 |
+
"""Build a BM25 index from the code corpus descriptions.
|
| 37 |
+
|
| 38 |
+
Parameters
|
| 39 |
+
----------
|
| 40 |
+
codes : pd.DataFrame
|
| 41 |
+
Must contain ``description`` column with full description strings.
|
| 42 |
+
|
| 43 |
+
Returns
|
| 44 |
+
-------
|
| 45 |
+
BM25Okapi
|
| 46 |
+
The built tokenised index.
|
| 47 |
+
"""
|
| 48 |
+
doc_tokens: list[list[str]] = []
|
| 49 |
+
for desc in codes["description"].astype(str):
|
| 50 |
+
tokens = _tokenize(desc)
|
| 51 |
+
doc_tokens.append(tokens if tokens else [desc])
|
| 52 |
+
|
| 53 |
+
return BM25Okapi(doc_tokens)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def score_lexical(query: str, index: BM25Okapi, corpus_size: int) -> list[float]:
|
| 57 |
+
"""Return corpus-order BM25 scores without allocating result objects."""
|
| 58 |
+
query_tokens = _tokenize(query)
|
| 59 |
+
if not query_tokens:
|
| 60 |
+
return [0.0] * corpus_size
|
| 61 |
+
return [round(float(score), 6) for score in index.get_scores(query_tokens)]
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def retrieve_lexical(
|
| 65 |
+
query: str,
|
| 66 |
+
codes: pd.DataFrame | list[dict],
|
| 67 |
+
index: BM25Okapi | None = None,
|
| 68 |
+
top_k: int | None = None,
|
| 69 |
+
) -> list[LexicalCandidate]:
|
| 70 |
+
"""Retrieve candidate codes ranked by BM25 lexical similarity.
|
| 71 |
+
|
| 72 |
+
Parameters
|
| 73 |
+
----------
|
| 74 |
+
query : str
|
| 75 |
+
The phenotype description / EDF summary to search against.
|
| 76 |
+
codes : pd.DataFrame or list[dict]
|
| 77 |
+
Code corpus containing ``description`` (mandatory) and optionally
|
| 78 |
+
``code``, ``vocabulary`` columns (DataFrame) or dicts with those keys.
|
| 79 |
+
index : BM25Okapi, optional
|
| 80 |
+
Pre-built index. If omitted one is built on-the-fly from *codes*.
|
| 81 |
+
top_k : int, optional
|
| 82 |
+
Maximum number of results to return. Defaults to the full corpus size.
|
| 83 |
+
|
| 84 |
+
Returns
|
| 85 |
+
-------
|
| 86 |
+
list[LexicalCandidate]
|
| 87 |
+
Ranked candidates sorted descending by BM25 score.
|
| 88 |
+
"""
|
| 89 |
+
if isinstance(codes, pd.DataFrame):
|
| 90 |
+
df = codes.copy()
|
| 91 |
+
keys = ("description", "code", "vocabulary")
|
| 92 |
+
for k in keys[1:]:
|
| 93 |
+
if k not in df.columns:
|
| 94 |
+
df[k] = "" # type: ignore[operator]
|
| 95 |
+
else:
|
| 96 |
+
df = pd.DataFrame(codes)
|
| 97 |
+
for k in ("code", "vocabulary"):
|
| 98 |
+
if k not in df.columns:
|
| 99 |
+
df[k] = ""
|
| 100 |
+
|
| 101 |
+
if index is None:
|
| 102 |
+
index = build_lexical_index(df)
|
| 103 |
+
|
| 104 |
+
query_tokens = _tokenize(query)
|
| 105 |
+
if not query_tokens:
|
| 106 |
+
# Fallback: rank all codes uniformly (no lexical signal)
|
| 107 |
+
return [
|
| 108 |
+
LexicalCandidate(
|
| 109 |
+
rank=0, code=r["code"], description=r["description"], vocabulary=r["vocabulary"], score=0.0
|
| 110 |
+
)
|
| 111 |
+
for i, r in df.iterrows()
|
| 112 |
+
]
|
| 113 |
+
|
| 114 |
+
scores = index.get_scores(query_tokens).tolist() # type: ignore[union-attr]
|
| 115 |
+
|
| 116 |
+
if top_k is not None:
|
| 117 |
+
top_indices = np.argpartition(scores, -top_k)[-top_k:]
|
| 118 |
+
top_scores = [scores[i] for i in sorted(top_indices, key=lambda x: -scores[x])]
|
| 119 |
+
results: list[LexicalCandidate] = [
|
| 120 |
+
LexicalCandidate(
|
| 121 |
+
rank=r + 1,
|
| 122 |
+
code=df.iloc[idx]["code"],
|
| 123 |
+
description=df.iloc[idx]["description"],
|
| 124 |
+
vocabulary=df.iloc[idx].get("vocabulary", ""),
|
| 125 |
+
score=round(float(s), 6),
|
| 126 |
+
)
|
| 127 |
+
for r, (idx, s) in enumerate(sorted(enumerate(top_scores), key=lambda x: -x[1]))
|
| 128 |
+
]
|
| 129 |
+
else:
|
| 130 |
+
results = [
|
| 131 |
+
LexicalCandidate(
|
| 132 |
+
rank=r + 1,
|
| 133 |
+
code=df.iloc[r]["code"],
|
| 134 |
+
description=df.iloc[r]["description"],
|
| 135 |
+
vocabulary=df.iloc[r].get("vocabulary", ""),
|
| 136 |
+
score=round(float(scores[r]), 6),
|
| 137 |
+
)
|
| 138 |
+
for r in range(len(df))
|
| 139 |
+
]
|
| 140 |
+
|
| 141 |
+
return results
|
src/uncertainty/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Uncertainty-based selection of candidates for human review."""
|
src/uncertainty/selection.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Select uncertain classifications requiring targeted human review."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
|
| 7 |
+
from src.llm.classify import ClassifiedCandidate
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass(slots=True)
|
| 11 |
+
class ReviewItem:
|
| 12 |
+
code: str
|
| 13 |
+
description: str
|
| 14 |
+
vocabulary: str
|
| 15 |
+
label: str
|
| 16 |
+
confidence: float
|
| 17 |
+
explanation: str
|
| 18 |
+
reason: str
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def select_uncertain(
|
| 22 |
+
classified: list[ClassifiedCandidate],
|
| 23 |
+
confidence_threshold: float = 0.7,
|
| 24 |
+
possible_requires_review: bool = False,
|
| 25 |
+
) -> list[ReviewItem]:
|
| 26 |
+
"""Select classified candidates that require human validation.
|
| 27 |
+
|
| 28 |
+
A candidate is flagged for review when:
|
| 29 |
+
- its LLM confidence is below *confidence_threshold*, or
|
| 30 |
+
- it was classified as 'Possible' and *possible_requires_review* is True.
|
| 31 |
+
|
| 32 |
+
Parameters
|
| 33 |
+
----------
|
| 34 |
+
classified : list[ClassifiedCandidate]
|
| 35 |
+
Output of :func:`src.llm.classify.llm_classify`.
|
| 36 |
+
confidence_threshold : float
|
| 37 |
+
Minimum confidence below which a candidate is routed for review.
|
| 38 |
+
possible_requires_review : bool
|
| 39 |
+
Whether 'Possible' classifications are always routed for review,
|
| 40 |
+
regardless of confidence.
|
| 41 |
+
|
| 42 |
+
Returns
|
| 43 |
+
-------
|
| 44 |
+
list[ReviewItem]
|
| 45 |
+
Items requiring human review, each annotated with the reason.
|
| 46 |
+
"""
|
| 47 |
+
review_items: list[ReviewItem] = []
|
| 48 |
+
for c in classified:
|
| 49 |
+
reasons: list[str] = []
|
| 50 |
+
if c.decision_origin == "codemapper":
|
| 51 |
+
reasons.append("included by CodeMapper mapping; EDF scope not independently assessed")
|
| 52 |
+
if c.confidence < confidence_threshold:
|
| 53 |
+
reasons.append(f"confidence {c.confidence:.2f} below threshold {confidence_threshold:.2f}")
|
| 54 |
+
if possible_requires_review and c.label == "Possible":
|
| 55 |
+
reasons.append("classified as 'Possible'")
|
| 56 |
+
|
| 57 |
+
if reasons:
|
| 58 |
+
review_items.append(
|
| 59 |
+
ReviewItem(
|
| 60 |
+
code=c.code,
|
| 61 |
+
description=c.description,
|
| 62 |
+
vocabulary=c.vocabulary,
|
| 63 |
+
label=c.label,
|
| 64 |
+
confidence=c.confidence,
|
| 65 |
+
explanation=c.explanation,
|
| 66 |
+
reason="; ".join(reasons),
|
| 67 |
+
)
|
| 68 |
+
)
|
| 69 |
+
return review_items
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def review_rate(classified: list[ClassifiedCandidate], review_items: list[ReviewItem]) -> float:
|
| 73 |
+
"""Fraction of classified candidates flagged for human review."""
|
| 74 |
+
if not classified:
|
| 75 |
+
return 0.0
|
| 76 |
+
return len(review_items) / len(classified)
|
src/utils/__init__.py
ADDED
|
File without changes
|
src/utils/config.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import yaml
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@dataclass
|
| 10 |
+
class PathsConfig:
|
| 11 |
+
edf_dir: str = "data/edfs/yamls"
|
| 12 |
+
codes_parquet_dir: str = "data/codes/parquet/snomed_definitive_20261030"
|
| 13 |
+
embeddings_dir: str = "data/codes/embeddings/snomed_definitive_20261030"
|
| 14 |
+
results_dir: str = "results"
|
| 15 |
+
aesi_datasets: dict[str, str] = field(default_factory=dict)
|
| 16 |
+
code_systems: dict[str, str] = field(
|
| 17 |
+
default_factory=lambda: {
|
| 18 |
+
"ICD10CM": "data/codes/csv/ICD10CM@2026-codes.csv",
|
| 19 |
+
"ICD9CM": "data/codes/csv/ICD9CM@2014-codes.csv",
|
| 20 |
+
"ICPC": "data/codes/csv/ICPC@1993-codes.csv",
|
| 21 |
+
"MDR": "data/codes/csv/MDR@28.0-codes.csv",
|
| 22 |
+
"RCD2": "data/codes/csv/RCD2@20200401-codes.csv",
|
| 23 |
+
"SNOMEDCT_US": "data/codes/csv/SNOMEDCT_US@2025_09_01-codes definitive.csv",
|
| 24 |
+
}
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@dataclass
|
| 29 |
+
class RetrievalConfig:
|
| 30 |
+
lexical_weight: float = 0.1
|
| 31 |
+
embedding_weight: float = 0.9
|
| 32 |
+
embedding_provider: str = "openai"
|
| 33 |
+
embedding_model: str = "text-embedding-3-large"
|
| 34 |
+
embedding_dimensions: int | None = 3072
|
| 35 |
+
embedding_batch_size: int = 512
|
| 36 |
+
top_k: int = 50
|
| 37 |
+
use_top_k_limit: bool = False
|
| 38 |
+
per_vocabulary_top_k: bool = True
|
| 39 |
+
vocabularies: list[str] = field(
|
| 40 |
+
default_factory=lambda: ["ICD10CM", "ICD9CM", "ICPC", "MDR", "RCD2", "SNOMEDCT_US"]
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class LLMConfig:
|
| 46 |
+
provider: str = "jev" # Stage 2 defaults to Jev; other providers remain available.
|
| 47 |
+
anthropic_model: str = "claude-opus-4-8"
|
| 48 |
+
openai_model: str = "gpt-6-luna"
|
| 49 |
+
google_model: str = "gemini-3-flash-preview"
|
| 50 |
+
jev_model: str = "jev-latest"
|
| 51 |
+
openai_decisions_model: str = "gpt-6-luna"
|
| 52 |
+
laya_model: str = "multilingual"
|
| 53 |
+
jpt_model: str = "kirp/jpt-4b"
|
| 54 |
+
mlx_model: str = "mlx-community/Qwen3.5-9B-8bit"
|
| 55 |
+
rank_effort: str = "low"
|
| 56 |
+
classify_effort: str = "medium"
|
| 57 |
+
max_retries: int = 3
|
| 58 |
+
use_possible_category: bool = False
|
| 59 |
+
adaptive_stopping_enabled: bool = True
|
| 60 |
+
sparse_narrow_threshold: int = 0
|
| 61 |
+
consecutive_sparse_batches: int = 10
|
| 62 |
+
minimum_batches: int = 3
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@dataclass
|
| 66 |
+
class UncertaintyConfig:
|
| 67 |
+
review_provider: str = "openai"
|
| 68 |
+
review_model: str | None = None
|
| 69 |
+
confidence_threshold: float = 0.7
|
| 70 |
+
gpt_review_max_candidates: int = 200
|
| 71 |
+
possible_requires_review: bool = False
|
| 72 |
+
gpt_review_enabled: bool = True
|
| 73 |
+
gpt_review_batch_size: int = 10
|
| 74 |
+
gpt_review_reasoning_enabled: bool = True
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@dataclass
|
| 78 |
+
class EvaluationConfig:
|
| 79 |
+
narrow_only_as_positive: bool = True
|
| 80 |
+
compare_vocabularies: list[str] = field(
|
| 81 |
+
default_factory=lambda: ["ICD10CM", "ICD9CM", "ICPC", "MDR", "RCD2", "SNOMEDCT_US"]
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass
|
| 86 |
+
class CodeMapperConfig:
|
| 87 |
+
enabled: bool = False
|
| 88 |
+
base_url: str = "https://app.vac4eu.org/codemapper-testing/api"
|
| 89 |
+
cache_dir: str = "results/codemapper/cache"
|
| 90 |
+
batch_size: int = 100
|
| 91 |
+
requests_per_minute: float = 6
|
| 92 |
+
timeout_seconds: float = 30
|
| 93 |
+
max_retries: int = 3
|
| 94 |
+
vocabulary_versions: dict[str, str] = field(default_factory=lambda: {
|
| 95 |
+
"ICD10CM": "2026", "ICD9CM": "2014", "ICPC": "1993",
|
| 96 |
+
"MDR": "28.0", "RCD2": "20200401", "SNOMEDCT_US": "2025_09_01",
|
| 97 |
+
})
|
| 98 |
+
|
| 99 |
+
def __post_init__(self):
|
| 100 |
+
if self.batch_size < 1 or self.requests_per_minute <= 0:
|
| 101 |
+
raise ValueError("CodeMapper batch size and request rate must be positive")
|
| 102 |
+
if self.timeout_seconds <= 0 or self.max_retries < 1:
|
| 103 |
+
raise ValueError("CodeMapper timeout and retry count must be positive")
|
| 104 |
+
if not self.base_url.startswith("https://"):
|
| 105 |
+
raise ValueError("CodeMapper requires an HTTPS base URL")
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@dataclass
|
| 109 |
+
class PipelineConfig:
|
| 110 |
+
phenotype: str = "myocarditis"
|
| 111 |
+
verbose: bool = True
|
| 112 |
+
paths: PathsConfig = field(default_factory=PathsConfig)
|
| 113 |
+
retrieval: RetrievalConfig = field(default_factory=RetrievalConfig)
|
| 114 |
+
llm: LLMConfig = field(default_factory=LLMConfig)
|
| 115 |
+
uncertainty: UncertaintyConfig = field(default_factory=UncertaintyConfig)
|
| 116 |
+
evaluation: EvaluationConfig = field(default_factory=EvaluationConfig)
|
| 117 |
+
codemapper: CodeMapperConfig = field(default_factory=CodeMapperConfig)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def load_config(path: str | Path) -> PipelineConfig:
|
| 121 |
+
"""Load a YAML config file into a PipelineConfig, falling back to defaults
|
| 122 |
+
for any keys not present in the file."""
|
| 123 |
+
path = Path(path)
|
| 124 |
+
raw = yaml.safe_load(path.read_text()) or {}
|
| 125 |
+
|
| 126 |
+
return PipelineConfig(
|
| 127 |
+
phenotype=raw.get("phenotype", PipelineConfig.phenotype),
|
| 128 |
+
verbose=raw.get("verbose", PipelineConfig.verbose),
|
| 129 |
+
paths=PathsConfig(**raw.get("paths", {})),
|
| 130 |
+
retrieval=RetrievalConfig(**raw.get("retrieval", {})),
|
| 131 |
+
llm=LLMConfig(**raw.get("llm", {})),
|
| 132 |
+
uncertainty=UncertaintyConfig(**raw.get("uncertainty", {})),
|
| 133 |
+
evaluation=EvaluationConfig(**raw.get("evaluation", {})),
|
| 134 |
+
codemapper=CodeMapperConfig(**raw.get("codemapper", {})),
|
| 135 |
+
)
|
src/utils/logging.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
_CONFIGURED = False
|
| 5 |
+
|
| 6 |
+
# Silence noisy third-party libraries (sentence-transformers/httpx/huggingface_hub
|
| 7 |
+
# log INFO-level HTTP requests and an "unauthenticated requests" warning on every
|
| 8 |
+
# model load) — none of this is actionable for local, cached model usage.
|
| 9 |
+
_QUIET_LOGGERS = ("sentence_transformers", "httpx", "huggingface_hub")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_logger(name: str) -> logging.Logger:
|
| 13 |
+
global _CONFIGURED
|
| 14 |
+
if not _CONFIGURED:
|
| 15 |
+
logging.basicConfig(
|
| 16 |
+
level=logging.INFO,
|
| 17 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 18 |
+
stream=sys.stdout,
|
| 19 |
+
)
|
| 20 |
+
for quiet_logger in _QUIET_LOGGERS:
|
| 21 |
+
logging.getLogger(quiet_logger).setLevel(logging.ERROR)
|
| 22 |
+
try:
|
| 23 |
+
# huggingface_hub emits its "unauthenticated requests" notice through
|
| 24 |
+
# its own verbosity control rather than a plain logger level, so the
|
| 25 |
+
# logger.setLevel above alone doesn't always suppress it.
|
| 26 |
+
from huggingface_hub.utils import logging as hf_logging
|
| 27 |
+
|
| 28 |
+
hf_logging.set_verbosity_error()
|
| 29 |
+
except ImportError:
|
| 30 |
+
pass
|
| 31 |
+
_CONFIGURED = True
|
| 32 |
+
return logging.getLogger(name)
|