Spaces:
Sleeping
Sleeping
| """Pair-relevance classifier with two layered tiers. | |
| Tier order: | |
| 1. **LightGBM classifier** (`app/classifier_model.py`) — primary path. Trained | |
| offline on the public split with engineered features (region overlap, | |
| modality, contrast, laterality, date deltas, description text features, | |
| plus the heuristic's own prediction as a feature). Deterministic, fast, | |
| no external API dependency. | |
| 2. **Heuristic only** (`app/parser.py`, `app/heuristic.py`) — fallback when | |
| the LightGBM model isn't available (e.g., model file missing on a deploy | |
| or feature-schema drift detected at load time). | |
| 3. All-False fallback — handled at the FastAPI layer in `app/main.py`. | |
| Public entry points: | |
| predict_cases(cases) -> list[Prediction] # sync wrapper | |
| predict_cases_async(cases) -> list[Prediction] | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import logging | |
| from typing import Iterable | |
| from app.classifier_model import is_available as _classifier_available, predict_batch as _classifier_predict | |
| from app.heuristic import classify_pair | |
| from app.parser import parse_description | |
| from app.schemas import Case, Prediction | |
| logger = logging.getLogger(__name__) | |
| def _predict_via_classifier(cases: list[Case]) -> list[Prediction]: | |
| """Tier 1: LightGBM classifier on every (curr, prior) pair.""" | |
| pairs = [] | |
| keys = [] | |
| for c in cases: | |
| ct = parse_description(c.current_study.study_description) | |
| for p in c.prior_studies: | |
| pt = parse_description(p.study_description) | |
| pairs.append(( | |
| c.current_study.study_description, | |
| p.study_description, | |
| c.current_study.study_date, | |
| p.study_date, | |
| ct, | |
| pt, | |
| )) | |
| keys.append((c.case_id, p.study_id)) | |
| preds = _classifier_predict(pairs) | |
| return [ | |
| Prediction(case_id=k[0], study_id=k[1], predicted_is_relevant=p) | |
| for k, p in zip(keys, preds) | |
| ] | |
| def _predict_via_heuristic(cases: list[Case]) -> list[Prediction]: | |
| """Tier 2: heuristic-only fallback. Used when the classifier model is | |
| missing or its feature schema doesn't match the current code.""" | |
| predictions: list[Prediction] = [] | |
| for c in cases: | |
| curr_tags = parse_description(c.current_study.study_description) | |
| for p in c.prior_studies: | |
| prior_tags = parse_description(p.study_description) | |
| r = classify_pair(curr_tags, prior_tags) | |
| predictions.append( | |
| Prediction( | |
| case_id=c.case_id, | |
| study_id=p.study_id, | |
| predicted_is_relevant=r.predicted, | |
| ) | |
| ) | |
| return predictions | |
| async def predict_cases_async(cases: Iterable[Case], request_id: str = "-") -> list[Prediction]: | |
| cases = list(cases) | |
| total_priors = sum(len(c.prior_studies) for c in cases) | |
| if _classifier_available(): | |
| predictions = _predict_via_classifier(cases) | |
| tier = "classifier" | |
| else: | |
| predictions = _predict_via_heuristic(cases) | |
| tier = "heuristic" | |
| logger.info( | |
| "request_id=%s predict cases=%d priors=%d tier=%s", | |
| request_id, len(cases), total_priors, tier, | |
| ) | |
| return predictions | |
| def predict_cases(cases: Iterable[Case]) -> list[Prediction]: | |
| """Sync wrapper for use from non-async contexts (e.g., the eval harness).""" | |
| try: | |
| loop = asyncio.get_event_loop() | |
| if loop.is_running(): | |
| # We're inside an event loop already (FastAPI handler) — caller | |
| # should have used the async entry point. Fall back gracefully. | |
| raise RuntimeError("predict_cases called from within a running loop; use predict_cases_async") | |
| except RuntimeError: | |
| # No running loop, OK to use asyncio.run | |
| pass | |
| return asyncio.run(predict_cases_async(cases)) | |