Spaces:
Running
Running
File size: 12,529 Bytes
79b0bef | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 | """
src/utils/config.py
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Central configuration for the Clinical NLP Pipeline.
All paths, model names, and tuneable parameters live here.
Nothing else in the codebase should hardcode a path or model name β
import from this module instead.
Design notes
ββββββββββββ
- Settings are read from environment variables where appropriate,
with sensible defaults so the project works out of the box.
- Model names are strings, not imports, so swapping a model is a
one-line change that does not require touching pipeline code.
- The Paths class uses pathlib throughout; no os.path anywhere.
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
"""
from __future__ import annotations
import os
from contextlib import contextmanager
from pathlib import Path
from dotenv import load_dotenv
load_dotenv() # populate os.getenv() from .env before any class is defined
@contextmanager
def force_offline_hf_env():
"""Temporarily force huggingface_hub/transformers into fully offline mode.
``local_files_only=True`` on an individual ``from_pretrained()`` call
does not stop every internal network call some library versions
make (e.g. commit-history or discussion metadata lookups). Those
extra calls can hang indefinitely on a flaky network rather than
failing fast β even though the model is fully cached locally β
which previously caused multi-minute hangs on model load.
``HF_HUB_OFFLINE`` / ``TRANSFORMERS_OFFLINE`` are huggingface_hub's
documented mechanism for guaranteeing zero network calls; they're
stronger than the per-call flag. Use this to wrap *only* the
local-cache load attempt, so a genuine fallback download (model not
yet cached) still works normally once the env is restored.
Example::
with force_offline_hf_env():
try:
return SentenceTransformer(name, local_files_only=True)
except Exception:
pass
return SentenceTransformer(name) # real download, env restored
"""
keys = ("HF_HUB_OFFLINE", "TRANSFORMERS_OFFLINE")
previous = {k: os.environ.get(k) for k in keys}
for k in keys:
os.environ[k] = "1"
try:
yield
finally:
for k, v in previous.items():
if v is None:
os.environ.pop(k, None)
else:
os.environ[k] = v
# ββ Project root βββββββββββββββββββββββββββββββββββββββββββββββββ
# Resolves correctly whether you run from the repo root, a
# subdirectory, or inside a Docker container.
ROOT = Path(__file__).resolve().parents[2]
class Paths:
"""Filesystem layout for the project.
All directories are relative to the repository root so the
project is portable across machines and containers.
"""
# Source data lands here before any processing
raw: Path = ROOT / "data" / "raw"
# Cleaned and featurised outputs
processed: Path = ROOT / "data" / "processed"
# Saved model checkpoints and artefacts
models: Path = ROOT / "data" / "models"
# ICD-10 reference files
icd10_csv: Path = raw / "icd10_codes.csv"
# MTSamples clinical notes
mtsamples_csv: Path = raw / "mtsamples.csv"
# SQLite database (used when DATABASE_URL is not set)
sqlite_db: Path = ROOT / "clinical_nlp.db"
@classmethod
def ensure_all(cls) -> None:
"""Create every data directory if it does not already exist.
Safe to call multiple times β uses exist_ok=True throughout.
"""
for attr in ("raw", "processed", "models"):
getattr(cls, attr).mkdir(parents=True, exist_ok=True)
class ModelConfig:
"""Names and sources for every model used in the pipeline.
Changing a model is a one-line edit here; nothing downstream
needs to change. All names are valid HuggingFace model IDs
or spaCy model names.
"""
# ββ NER ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# "hybrid" runs en_ner_bc5cdr_md (DISEASE/MEDICATION) + en_core_sci_lg
# (PROCEDURE/ANATOMY/SYMPTOM) and merges, fine model taking priority.
# Single-model alternatives: "en_ner_bc5cdr_md" or "en_core_sci_lg"
ner_model: str = os.getenv("NER_MODEL", "hybrid")
# ββ Classifier ββββββββββββββββββββββββββββββββββββββββββββββββ
# Bio_ClinicalBERT β BERT pre-trained on MIMIC-III notes.
# Best publicly available model for clinical text classification.
classifier_model: str = os.getenv(
"CLASSIFIER_MODEL",
"emilyalsentzer/Bio_ClinicalBERT",
)
# ββ Embeddings (ICD-10 fallback matching) βββββββββββββββββββββ
# Sentence-transformer variant of BioBERT fine-tuned on
# medical NLI tasks β strong semantic similarity on clinical text.
embedding_model: str = os.getenv(
"EMBEDDING_MODEL",
"pritamdeka/BioBERT-mnli-snli-scinli-scitail-mednli-stsb",
)
# Directory where fine-tuned weights are saved after training
fine_tuned_dir: Path = Paths.models / "severity_classifier"
# HF Hub repo holding a copy of the fine-tuned classifier weights β
# used as a fallback when fine_tuned_dir doesn't exist locally (e.g.
# a fresh deployment container that doesn't bundle data/models/,
# since the trained weights are too large to commit to git). Without
# this, ClinicalClassifier.load() would silently fall back to the
# UNTRAINED base classifier_model and produce garbage predictions
# with no error. Empty string disables the fallback.
classifier_hf_hub_fallback: str = os.getenv(
"CLASSIFIER_HF_HUB_FALLBACK",
"ayodeji21/clinical-severity-classifier",
)
# HF Hub dataset repo holding the precomputed ICD-10 embedding index
# β used as a fallback when the local disk cache
# (data/processed/icd10_embeddings.pt) doesn't exist, avoiding a
# 15-40+ minute rebuild from scratch on a fresh deployment. Empty
# string disables the fallback (falls through to local rebuild).
icd10_embeddings_hf_hub_fallback: str = os.getenv(
"ICD10_EMBEDDINGS_HF_HUB_FALLBACK",
"ayodeji21/clinical-icd10-embeddings",
)
class ClassifierConfig:
"""Parameters for the severity classification task.
Designed to be task-agnostic: swap `task` and `labels` to
switch from severity to readmission risk (or anything else)
without touching the model code.
"""
# Current active task β change to "readmission" for Phase 2
task: str = os.getenv("CLASSIFIER_TASK", "severity")
# Severity labels (Phase 1)
severity_labels: list[str] = ["routine", "urgent", "critical"]
# Readmission labels (Phase 2 β needs MIMIC-III)
readmission_labels: list[str] = ["not_readmitted", "readmitted"]
@classmethod
def active_labels(cls, task: str | None = None) -> list[str]:
"""Return the label set for the given (or globally configured) task.
Args:
task: Task name to look up. Defaults to ``cls.task`` (the
global default) when not provided, so existing callers
that rely on the env-configured default keep working.
Pass explicitly to respect a per-instance task override
(e.g. from ``ClinicalClassifier(task=...)``) rather than
silently falling back to the global default.
Returns:
List of string class labels for the active task.
Raises:
ValueError: If `task` is not a recognised task name.
"""
task = task or cls.task
label_map = {
"severity": cls.severity_labels,
"readmission": cls.readmission_labels,
}
if task not in label_map:
raise ValueError(
f"Unknown classifier task '{task}'. "
f"Choose from: {list(label_map.keys())}"
)
return label_map[task]
class TrainingConfig:
"""Hyperparameters for fine-tuning Bio_ClinicalBERT.
These defaults are tuned for MTSamples (~5k notes) on a CPU.
Reduce batch_size if you run out of memory; increase epochs
if validation loss is still falling at the end of training.
"""
epochs: int = int(os.getenv("TRAIN_EPOCHS", "3"))
batch_size: int = int(os.getenv("TRAIN_BATCH_SIZE", "16"))
learning_rate: float = float(os.getenv("TRAIN_LR", "2e-5"))
max_length: int = int(os.getenv("TRAIN_MAX_LENGTH", "512"))
val_split: float = float(os.getenv("TRAIN_VAL_SPLIT", "0.15"))
test_split: float = float(os.getenv("TRAIN_TEST_SPLIT", "0.10"))
random_seed: int = int(os.getenv("TRAIN_SEED", "42"))
class ICD10Config:
"""Settings for the ICD-10 code mapping module."""
# Minimum fuzzy-match score (0β100) to accept a lookup result.
# Below this threshold the embedding fallback is used instead.
fuzzy_threshold: int = int(os.getenv("ICD10_FUZZY_THRESHOLD", "80"))
# Minimum cosine similarity (0.0β1.0) to accept an embedding match.
# Results below this are returned with a low-confidence flag.
embedding_threshold: float = float(
os.getenv("ICD10_EMBEDDING_THRESHOLD", "0.75")
)
# Maximum number of candidate ICD-10 codes returned per entity
top_k: int = int(os.getenv("ICD10_TOP_K", "3"))
class APIConfig:
"""FastAPI application settings."""
host: str = os.getenv("API_HOST", "0.0.0.0")
port: int = int(os.getenv("API_PORT", "8000"))
title: str = "Clinical NLP Pipeline API"
version: str = "1.0.0"
description: str = (
"Named entity recognition, ICD-10 mapping, and severity "
"classification for clinical text."
)
# Set to False in production β disables /docs and /redoc
debug: bool = os.getenv("API_DEBUG", "true").lower() == "true"
# Eager-load NER/ICD-10/classifier models at startup rather than on
# first request. Set to "false" on memory-constrained hosts (e.g.
# Render's free 512Mi tier) -- the hybrid NER pipeline + classifier
# can OOM-kill the whole process at boot, which no try/except can
# catch since it's an OS-level kill, not a Python exception.
warm_up_models: bool = os.getenv("WARM_UP_MODELS", "true").lower() == "true"
class DatabaseConfig:
"""Database connection settings.
SQLite is used when DATABASE_URL is not set β zero configuration
required for local development. Set DATABASE_URL to a PostgreSQL
connection string (e.g. Supabase) for staging and production.
"""
url: str = os.getenv(
"DATABASE_URL",
f"sqlite:///{Paths.sqlite_db}",
)
# SQLAlchemy connection pool settings.
# These are ignored for SQLite (which has no connection pool).
pool_size: int = int(os.getenv("DB_POOL_SIZE", "5"))
max_overflow: int = int(os.getenv("DB_MAX_OVERFLOW", "10"))
pool_timeout: int = int(os.getenv("DB_POOL_TIMEOUT", "30"))
@classmethod
def is_sqlite(cls) -> bool:
"""Return True if the configured database is SQLite."""
return cls.url.startswith("sqlite")
# ββ Convenience re-exports ββββββββββββββββββββββββββββββββββββββββ
# Import just `settings` anywhere in the codebase for quick access.
class Settings:
"""Single entry point for all configuration sections.
Usage::
from src.utils.config import settings
model = settings.model.ner_model
db = settings.db.url
"""
paths: Paths = Paths
model: ModelConfig = ModelConfig
classifier: ClassifierConfig = ClassifierConfig
training: TrainingConfig = TrainingConfig
icd10: ICD10Config = ICD10Config
api: APIConfig = APIConfig
db: DatabaseConfig = DatabaseConfig
settings = Settings()
|