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()