File size: 9,683 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
"""
src/etl/extract.py
────────────────────────────────────────────────────────────────
Data extraction layer β€” the "E" in ETL.

Responsibilities
────────────────
  - Load MTSamples clinical notes from CSV
  - Load the ICD-10 reference table
  - Detect whether MIMIC-III data is available (optional)
  - Cache raw downloads so re-runs are fast

Design
──────
  Extractors are intentionally dumb: they load data and add a
  ``_source`` provenance column, but they do not clean, filter,
  or reshape anything.  All of that happens in transform.py.

  Every public function returns a DataFrame or raises a clear
  exception β€” never returns None.  Callers can always rely on
  the return type.

  MTSamples is freely available on Kaggle:
    https://www.kaggle.com/datasets/tboyle10/medicaltranscriptions
  Download ``mtsamples.csv`` and place it in ``data/raw/``.

  The ICD-10 code file (icd10_codes.csv) is included in the
  project under ``data/raw/`` β€” it is a static reference table
  that rarely changes.
────────────────────────────────────────────────────────────────
"""

from __future__ import annotations

from pathlib import Path

import pandas as pd

from src.utils.config import Paths
from src.utils.logger import get_logger

logger = get_logger(__name__)


# ── Column name aliases ────────────────────────────────────────────
# MTSamples columns vary slightly across Kaggle versions.
# We normalise to these names during extraction.
_MTSAMPLES_COLUMN_MAP: dict[str, str] = {
    "description":        "description",
    "medical_specialty":  "specialty",
    "sample_name":        "sample_name",
    "transcription":      "transcription",
    "keywords":           "keywords",
}

_ICD10_COLUMN_MAP: dict[str, str] = {
    "code":        "icd10_code",
    "description": "description",
    "category":    "category",
}


# ── MTSamples ─────────────────────────────────────────────────────

def load_mtsamples(
    path: Path | None = None,
    force_reload: bool = False,
) -> pd.DataFrame:
    """Load the MTSamples clinical notes dataset from CSV.

    MTSamples contains 4,999 de-identified medical transcriptions
    across 40 clinical specialties.  The raw CSV is expected at
    ``data/raw/mtsamples.csv``.

    Args:
        path: Override the default file location.  Useful in tests.
        force_reload: If True, bypass the cached version and re-read
            from disk.  Defaults to False.

    Returns:
        DataFrame with columns::

            description     : short note title
            specialty       : medical specialty (40 categories)
            sample_name     : note type (e.g. "Discharge Summary")
            transcription   : full clinical note text
            keywords        : comma-separated clinical keywords
            _source         : "mtsamples" (provenance marker)

    Raises:
        FileNotFoundError: If the CSV does not exist at the
            expected path.  The error message includes the
            download URL.

    Example::

        df = load_mtsamples()
        print(df.shape)          # (4999, 6)
        print(df["specialty"].nunique())  # 40
    """
    csv_path = path or Paths.mtsamples_csv

    if not csv_path.exists():
        raise FileNotFoundError(
            f"MTSamples CSV not found at: {csv_path}\n"
            "Download it from Kaggle:\n"
            "  https://www.kaggle.com/datasets/tboyle10/medicaltranscriptions\n"
            f"Then save it to: {Paths.raw}/"
        )

    logger.info("Loading MTSamples from %s", csv_path)
    df = pd.read_csv(csv_path, low_memory=False)

    # Rename columns to our standard names, ignoring any extras
    rename_map = {
        col: _MTSAMPLES_COLUMN_MAP[col]
        for col in df.columns
        if col in _MTSAMPLES_COLUMN_MAP
    }
    df = df.rename(columns=rename_map)

    # Keep only columns we use; add the rest as-is if not in our map
    expected = list(_MTSAMPLES_COLUMN_MAP.values())
    available = [c for c in expected if c in df.columns]
    df = df[available].copy()

    df["_source"] = "mtsamples"

    logger.info(
        "MTSamples loaded: %d notes, %d specialties",
        len(df),
        df["specialty"].nunique() if "specialty" in df.columns else 0,
    )
    return df


# ── ICD-10 reference table ─────────────────────────────────────────

def load_icd10_codes(path: Path | None = None) -> pd.DataFrame:
    """Load the ICD-10 diagnostic code reference table.

    The ICD-10 CSV is included in the repository under
    ``data/raw/icd10_codes.csv``.  It contains all billable
    ICD-10-CM codes with descriptions.

    Args:
        path: Override the default file location.  Useful in tests.

    Returns:
        DataFrame with columns::

            icd10_code  : ICD-10-CM code (e.g. "I10", "J18.9")
            description : Human-readable description
            category    : Broad category (e.g. "Circulatory System")
            _source     : "icd10_cms" (provenance marker)

    Raises:
        FileNotFoundError: If the CSV is missing.

    Example::

        icd = load_icd10_codes()
        icd[icd["icd10_code"] == "I10"]
        # icd10_code | description            | category
        # I10        | Essential hypertension | Circulatory System
    """
    csv_path = path or Paths.icd10_csv

    if not csv_path.exists():
        raise FileNotFoundError(
            f"ICD-10 codes file not found at: {csv_path}\n"
            "This file should be included in the repository.\n"
            "Check that you have the full project download."
        )

    logger.info("Loading ICD-10 reference table from %s", csv_path)
    df = pd.read_csv(csv_path, dtype=str, low_memory=False)

    rename_map = {
        col: _ICD10_COLUMN_MAP[col]
        for col in df.columns
        if col in _ICD10_COLUMN_MAP
    }
    df = df.rename(columns=rename_map)

    # Ensure the code column is clean
    if "icd10_code" in df.columns:
        df["icd10_code"] = df["icd10_code"].str.strip().str.upper()

    df["_source"] = "icd10_cms"

    logger.info("ICD-10 table loaded: %d codes", len(df))
    return df


# ── MIMIC-III (optional) ───────────────────────────────────────────

def load_mimic_notes(
    path: Path | None = None,
) -> pd.DataFrame | None:
    """Load MIMIC-III discharge summaries if available.

    MIMIC-III requires credentialed access via PhysioNet:
      https://physionet.org/content/mimiciii/

    This function gracefully returns None if the data is not
    present rather than raising an error β€” MIMIC-III is optional.
    The pipeline uses MTSamples when MIMIC-III is absent.

    Args:
        path: Path to ``NOTEEVENTS.csv`` from MIMIC-III.
            Defaults to ``data/raw/mimic_notes.csv``.

    Returns:
        DataFrame with columns ``transcription``, ``specialty``,
        ``_source`` if the file exists; None otherwise.

    Example::

        notes = load_mimic_notes()
        if notes is None:
            print("MIMIC-III not available, using MTSamples")
    """
    default_path = Paths.raw / "mimic_notes.csv"
    csv_path     = path or default_path

    if not csv_path.exists():
        logger.info(
            "MIMIC-III notes not found at %s β€” using MTSamples only. "
            "See https://physionet.org/content/mimiciii/ for access.",
            csv_path,
        )
        return None

    logger.info("Loading MIMIC-III notes from %s", csv_path)
    df = pd.read_csv(
        csv_path,
        usecols=["TEXT", "CATEGORY"],
        low_memory=False,
    )

    df = df.rename(columns={"TEXT": "transcription", "CATEGORY": "specialty"})
    df["description"] = "MIMIC-III discharge note"
    df["_source"]     = "mimic3"

    # Keep only discharge summaries for comparability with MTSamples
    df = df[df["specialty"].str.lower() == "discharge summary"].copy()

    logger.info("MIMIC-III notes loaded: %d discharge summaries", len(df))
    return df


# ── Auto-detect best available source ─────────────────────────────

def load_clinical_notes() -> pd.DataFrame:
    """Load clinical notes from the best available source.

    Tries sources in order of preference:
      1. MTSamples (primary β€” free, always available once downloaded)
      2. MIMIC-III (optional β€” richer, requires credentialed access)

    If both are available, MTSamples is used by default.
    Set ``DATA_SOURCE=mimic`` in your ``.env`` to prefer MIMIC-III.

    Returns:
        DataFrame with at minimum the columns ``transcription``,
        ``specialty``, and ``_source``.

    Raises:
        FileNotFoundError: If no data source is available.
    """
    import os
    preferred = os.getenv("DATA_SOURCE", "mtsamples").lower()

    if preferred == "mimic":
        mimic_df = load_mimic_notes()
        if mimic_df is not None:
            return mimic_df
        logger.warning(
            "DATA_SOURCE=mimic but MIMIC-III not found. "
            "Falling back to MTSamples."
        )

    return load_mtsamples()