dataset.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. import math
  2. import pathlib as pl
  3. import random
  4. import re
  5. from typing import Callable, Dict, Iterator, List, Tuple, cast
  6. import nibabel as nib
  7. import pandas as pd
  8. import torch
  9. import torch.utils.data as data
  10. from jaxtyping import Float
  11. from torch.utils.data import DataLoader, Subset
  12. from util.seeding import torch_generator
  13. # Tokens that appear in MRI filenames to indicate the diagnostic class.
  14. # On disk the cognitively-normal class is spelled "NL" (not "CN").
  15. CLASS_TOKENS: Dict[str, torch.Tensor] = {
  16. "AD": torch.tensor([1.0, 0.0]),
  17. "NL": torch.tensor([0.0, 1.0]),
  18. }
  19. def _row_to_float_tensor(row: pd.DataFrame, *, image_id: int) -> torch.Tensor:
  20. values = row.drop(columns=["Image Data ID"]).iloc[0]
  21. numeric = pd.to_numeric(values, errors="coerce")
  22. if numeric.isna().any(): # pyright: ignore
  23. bad_columns = [column for column, value in numeric.items() if pd.isna(value)] # pyright: ignore
  24. raise ValueError(
  25. f"Non-numeric Excel values for Image Data ID {image_id}: {bad_columns}"
  26. )
  27. return torch.tensor(numeric.to_numpy(dtype="float32")) # pyright: ignore
  28. def xls_pre(df: pd.DataFrame) -> pd.DataFrame:
  29. """
  30. Preprocess the Excel DataFrame.
  31. This function can be customized to filter or modify the DataFrame as needed.
  32. Returns a DataFrame with columns [Image Data ID, Sex, Age (current)] where
  33. Sex is encoded 0/1. "Image Data ID" is deliberately kept as a column (not an
  34. index) because downstream lookups filter on it directly
  35. (``xls_values["Image Data ID"] == img_id``).
  36. """
  37. # Work on an explicit copy so column assignment writes back reliably instead
  38. # of raising SettingWithCopyWarning against a view of ``df``.
  39. #
  40. # The ``cast``s are needed because pandas ships no type stubs: basedpyright
  41. # widens ``.copy()``/``.replace()``/``__getitem__`` to
  42. # ``DataFrame | Series | Unknown``, which then has no ``.str`` accessor and
  43. # isn't assignable to the declared return type. ``cast`` asserts the concrete
  44. # pandas type at each step without any runtime effect.
  45. data = cast(pd.DataFrame, df[["Image Data ID", "Sex", "Age (current)"]].copy())
  46. # ``.str`` is pandas' vectorized string accessor: it applies a str method
  47. # element-wise over every value in the Series. Here it strips surrounding
  48. # whitespace from each Sex entry (e.g. " M " -> "M") before encoding.
  49. sex_col = cast(pd.Series, data["Sex"])
  50. data["Sex"] = sex_col.str.strip()
  51. data = cast(pd.DataFrame, data.replace({"M": 0, "F": 1}))
  52. return data
  53. class ADNIDataset(data.Dataset): # type: ignore
  54. """
  55. A PyTorch Dataset class for loading
  56. and processing MRI and Excel data from the ADNI dataset.
  57. """
  58. def __init__(
  59. self,
  60. mri_data: Float[torch.Tensor, "n_samples channels width height depth"],
  61. xls_data: Float[torch.Tensor, "n_samples features"],
  62. expected_classes: Float[
  63. torch.Tensor, " classes"
  64. ], # leading space is necessary so pyright doesn't complain
  65. filename_ids: List[int],
  66. device: str = "cuda",
  67. ):
  68. """
  69. Args:
  70. mri_data (torch.Tensor): 5D tensor of MRI data with shape (n_samples, channels, width, height, depth).
  71. xls_data (torch.Tensor): 2D tensor of Excel data with shape (n_samples, features).
  72. """
  73. # Keep full datasets on CPU and move only active batches to accelerator.
  74. # This avoids loading the entire training corpus into VRAM.
  75. self.mri_data = mri_data.float().cpu()
  76. self.xls_data = xls_data.float().cpu()
  77. self.expected_classes = expected_classes.float().cpu()
  78. self.filename_ids = filename_ids
  79. def __len__(self) -> int:
  80. """
  81. Returns the number of samples in the dataset.
  82. """
  83. return self.mri_data.shape[0] # 0th dimension is the number of samples
  84. def __getitem__(
  85. self, idx: int
  86. ) -> Tuple[
  87. Float[torch.Tensor, "channels width height depth"],
  88. Float[torch.Tensor, " features"],
  89. Float[torch.Tensor, " classes"],
  90. int,
  91. ]:
  92. """
  93. Returns a sample from the dataset at the given index.
  94. Args:
  95. idx (int): Index of the sample to retrieve.
  96. Returns:
  97. tuple: A tuple containing the MRI data and Excel data for the sample.
  98. """
  99. # Slices the data on the 0th dimension, corresponding to the sample index
  100. mri_sample = self.mri_data[idx]
  101. xls_sample = self.xls_data[idx]
  102. # Assuming expected_classes is a tensor of classes, we return it as well
  103. expected_classes = self.expected_classes[idx]
  104. filename_id = self.filename_ids[idx]
  105. return mri_sample, xls_sample, expected_classes, filename_id
  106. def load_adni_data_from_file(
  107. mri_files: Iterator[pl.Path], # List of nibablel files
  108. xls_file: pl.Path, # Path to the Excel file
  109. device: str = "cuda",
  110. xls_preprocessor: Callable[[pd.DataFrame], pd.DataFrame] = lambda x: x,
  111. ) -> ADNIDataset:
  112. """
  113. Loads MRI and Excel data from the ADNI dataset.
  114. Args:
  115. mri_files (List[pl.Path]): List of paths to the MRI files.
  116. xls_file (pl.Path): Path to the Excel file.
  117. Returns:
  118. ADNIDataset: The loaded dataset.
  119. """
  120. # Load the Excel data
  121. xls_values = xls_preprocessor(pd.read_csv(xls_file)) # type: ignore
  122. # Load the MRI data
  123. mri_data_unstacked: List[torch.Tensor] = []
  124. expected_classes_unstacked: List[torch.Tensor] = []
  125. xls_data_unstacked: List[torch.Tensor] = []
  126. img_ids: List[int] = []
  127. for file in mri_files:
  128. filename = file.stem
  129. match re.search(r".+?(?=_I)_I(\d+).+", filename):
  130. case None:
  131. raise ValueError(
  132. f"Filename {filename} does not match expected pattern."
  133. )
  134. case m:
  135. img_id = int(m.group(1))
  136. file_mri_data = torch.from_numpy(nib.load(file).get_fdata()) # type: ignore # type checking does not work well with nibabel
  137. # Read the filename to determine the expected class. On disk the classes
  138. # are spelled "AD" and "NL"; see CLASS_TOKENS.
  139. file_expected_class: torch.Tensor | None = None
  140. for token, one_hot in CLASS_TOKENS.items():
  141. if token in filename:
  142. file_expected_class = one_hot.clone()
  143. break
  144. if file_expected_class is None:
  145. raise ValueError(
  146. f"Filename {filename} does not contain a valid class identifier "
  147. f"({' or '.join(CLASS_TOKENS)})."
  148. )
  149. mri_data_unstacked.append(file_mri_data)
  150. expected_classes_unstacked.append(file_expected_class)
  151. # Extract the corresponding row from the Excel data using the img_id
  152. xls_row = xls_values.loc[xls_values["Image Data ID"] == img_id]
  153. if xls_row.empty:
  154. raise ValueError(
  155. f"No matching row found in Excel data for Image Data ID {img_id}."
  156. )
  157. elif len(xls_row) > 1:
  158. raise ValueError(
  159. f"Multiple rows found in Excel data for Image Data ID {img_id}."
  160. )
  161. file_xls_data = _row_to_float_tensor(xls_row, image_id=img_id)
  162. xls_data_unstacked.append(file_xls_data)
  163. img_ids.append(img_id)
  164. mri_data = torch.stack(mri_data_unstacked).unsqueeze(1)
  165. # Stack the list of tensors into a single tensor and unsqueeze along the channel dimension
  166. xls_data = torch.stack(
  167. xls_data_unstacked
  168. ) # Stack the list of tensors into a single tensor
  169. expected_classes = torch.stack(
  170. expected_classes_unstacked
  171. ) # Stack the list of expected classes into a single tensor
  172. return ADNIDataset(mri_data, xls_data, expected_classes, img_ids, device=device)
  173. def divide_dataset(
  174. dataset: ADNIDataset,
  175. ratios: Tuple[float, float, float],
  176. seed: int,
  177. ) -> List[data.Subset[ADNIDataset]]:
  178. """
  179. Divides the dataset into training, validation, and test sets.
  180. Args:
  181. dataset (ADNIDataset): The dataset to divide.
  182. train_ratio (float): The ratio of the training set.
  183. val_ratio (float): The ratio of the validation set.
  184. test_ratio (float): The ratio of the test set.
  185. Returns:
  186. Result[List[data.Subset[ADNIDataset]], str]: A Result object containing the subsets or an error message.
  187. """
  188. if not math.isclose(sum(ratios), 1.0, rel_tol=1e-9, abs_tol=1e-9):
  189. raise ValueError(f"Ratios must sum to 1.0, got {ratios} (sum={sum(ratios)}).")
  190. # Set the random seed for reproducibility
  191. gen = torch.Generator().manual_seed(seed)
  192. return data.random_split(dataset, ratios, generator=gen)
  193. def initalize_dataloaders(
  194. datasets: List[Subset[ADNIDataset]],
  195. batch_size: int = 64,
  196. seed: int | None = None,
  197. ) -> List[DataLoader[ADNIDataset]]:
  198. """
  199. Initializes the DataLoaders for the [train, val, test] datasets.
  200. The three splits are configured differently:
  201. - train: shuffled (for SGD) with a seeded generator for reproducibility,
  202. and ``drop_last=True`` so a size-1 final batch cannot crash BatchNorm1d.
  203. - val / test: NOT shuffled, so sample order is stable. Stable order is
  204. required for evaluation, where saved model outputs must align with a
  205. fixed sample axis (see the netCDF evaluation schema).
  206. Args:
  207. datasets: Subsets in ``[train, val, test]`` order.
  208. batch_size: The batch size for the DataLoaders.
  209. seed: Seed for the training shuffle generator. ``None`` leaves shuffling
  210. to global RNG state.
  211. Returns:
  212. List[DataLoader[ADNIDataset]]: DataLoaders in the same order as ``datasets``.
  213. """
  214. loader_configs = [
  215. {"shuffle": True, "drop_last": True}, # train
  216. {"shuffle": False, "drop_last": False}, # val
  217. {"shuffle": False, "drop_last": False}, # test
  218. ]
  219. if len(datasets) != len(loader_configs):
  220. raise ValueError(
  221. f"Expected 3 datasets ([train, val, test]), got {len(datasets)}."
  222. )
  223. pin_memory = torch.cuda.is_available()
  224. loaders: List[DataLoader[ADNIDataset]] = []
  225. for dataset, cfg in zip(datasets, loader_configs):
  226. generator = torch_generator(seed) if cfg["shuffle"] else None
  227. loaders.append(
  228. DataLoader(
  229. dataset,
  230. batch_size=batch_size,
  231. shuffle=cfg["shuffle"],
  232. drop_last=cfg["drop_last"],
  233. pin_memory=pin_memory,
  234. generator=generator,
  235. )
  236. )
  237. return loaders
  238. def _patient_index(
  239. dataset: ADNIDataset, ptids: List[Tuple[int, str]]
  240. ) -> Dict[str, List[int]]:
  241. """Map patient id -> dataset sample indices, validating PTID consistency.
  242. Shared by the single-split and k-fold partitioners so both enforce the same
  243. patient-grouping and leakage checks.
  244. """
  245. if not ptids:
  246. raise ValueError("ptids list cannot be empty.")
  247. image_to_patient: Dict[int, str] = {}
  248. for image_id, patient_id in ptids:
  249. image_id_int = int(image_id)
  250. patient_id_str = str(patient_id).strip()
  251. if not patient_id_str or patient_id_str.lower() == "nan":
  252. raise ValueError(f"Invalid PTID for Image Data ID {image_id_int}.")
  253. if (
  254. image_id_int in image_to_patient
  255. and image_to_patient[image_id_int] != patient_id_str
  256. ):
  257. raise ValueError(
  258. f"Conflicting PTIDs for Image Data ID {image_id_int}: "
  259. f"{image_to_patient[image_id_int]} vs {patient_id_str}."
  260. )
  261. image_to_patient[image_id_int] = patient_id_str
  262. patient_to_indices: Dict[str, List[int]] = {}
  263. for idx, image_id in enumerate(dataset.filename_ids):
  264. if image_id not in image_to_patient:
  265. raise ValueError(
  266. f"Missing PTID mapping for dataset Image Data ID {image_id}."
  267. )
  268. patient_to_indices.setdefault(image_to_patient[image_id], []).append(idx)
  269. return patient_to_indices
  270. def divide_dataset_by_patient_id(
  271. dataset: ADNIDataset,
  272. ptids: List[Tuple[int, str]],
  273. ratios: Tuple[float, float, float],
  274. seed: int,
  275. ) -> List[data.Subset[ADNIDataset]]:
  276. """
  277. Divides the dataset into training, validation, and test sets based on patient IDs.
  278. Ensures that all samples from the same patient are in the same set.
  279. Args:
  280. dataset (ADNIDataset): The dataset to divide.
  281. ptids (List[Tuple[int, str]]): A list of tuples containing image file IDs and their corresponding patient IDs.
  282. ratios (Tuple[float, float, float]): The ratios for training, validation, and test sets.
  283. seed (int): The random seed for reproducibility.
  284. Returns:
  285. List[data.Subset[ADNIDataset]]: A list of subsets for training, validation, and test sets.
  286. Notes:
  287. This split is grouped by PTID, so all images from the same patient are assigned
  288. to exactly one partition to avoid patient-level leakage across train/val/test.
  289. """
  290. if not math.isclose(sum(ratios), 1.0, rel_tol=1e-9, abs_tol=1e-9):
  291. raise ValueError(f"Ratios must sum to 1.0, got {ratios} (sum={sum(ratios)}).")
  292. patient_to_indices = _patient_index(dataset, ptids)
  293. shuffled_patients = list(patient_to_indices.keys())
  294. random.Random(seed).shuffle(shuffled_patients)
  295. train_cutoff = int(len(shuffled_patients) * ratios[0])
  296. val_cutoff = train_cutoff + int(len(shuffled_patients) * ratios[1])
  297. train_patients = shuffled_patients[:train_cutoff]
  298. val_patients = shuffled_patients[train_cutoff:val_cutoff]
  299. test_patients = shuffled_patients[val_cutoff:]
  300. train_patient_set = set(train_patients)
  301. val_patient_set = set(val_patients)
  302. test_patient_set = set(test_patients)
  303. if (
  304. train_patient_set & val_patient_set
  305. or train_patient_set & test_patient_set
  306. or val_patient_set & test_patient_set
  307. ):
  308. raise ValueError("Patient separation violated across train/val/test splits.")
  309. all_patients = set(patient_to_indices.keys())
  310. if train_patient_set | val_patient_set | test_patient_set != all_patients:
  311. raise ValueError("Not all patients were assigned to a split.")
  312. train_indices = [
  313. idx for patient_id in train_patients for idx in patient_to_indices[patient_id]
  314. ]
  315. val_indices = [
  316. idx for patient_id in val_patients for idx in patient_to_indices[patient_id]
  317. ]
  318. test_indices = [
  319. idx for patient_id in test_patients for idx in patient_to_indices[patient_id]
  320. ]
  321. train_index_set = set(train_indices)
  322. val_index_set = set(val_indices)
  323. test_index_set = set(test_indices)
  324. if (
  325. train_index_set & val_index_set
  326. or train_index_set & test_index_set
  327. or val_index_set & test_index_set
  328. ):
  329. raise ValueError("Sample index overlap detected across train/val/test splits.")
  330. all_split_indices = train_index_set | val_index_set | test_index_set
  331. expected_indices = set(range(len(dataset)))
  332. if all_split_indices != expected_indices:
  333. raise ValueError(
  334. "Split coverage check failed: not all dataset samples are assigned exactly once."
  335. )
  336. return [
  337. Subset(dataset, train_indices),
  338. Subset(dataset, val_indices),
  339. Subset(dataset, test_indices),
  340. ]
  341. def kfold_split_by_patient_id(
  342. dataset: ADNIDataset,
  343. ptids: List[Tuple[int, str]],
  344. k_folds: int,
  345. fold: int,
  346. seed: int,
  347. val_fraction: float = 0.15,
  348. ) -> List[data.Subset[ADNIDataset]]:
  349. """Patient-grouped k-fold partition for a single fold.
  350. Patients are shuffled once (deterministically from ``seed``) and dealt
  351. round-robin into ``k_folds`` folds, so every patient is in exactly one fold
  352. regardless of which fold is requested. For the requested ``fold``:
  353. - test = that fold's patients (the held-out slice),
  354. - the remaining patients are split into val/train by ``val_fraction``.
  355. All splits are patient-disjoint (no leakage), and across all folds every
  356. sample is a test point exactly once.
  357. Args:
  358. dataset: The dataset to partition.
  359. ptids: (Image Data ID, PTID) pairs covering the dataset.
  360. k_folds: Number of folds (>= 2).
  361. fold: Which fold to hold out as test (0 <= fold < k_folds).
  362. seed: Seed for the (shared across folds) patient shuffle.
  363. val_fraction: Fraction of the NON-test patients used for validation.
  364. Returns:
  365. ``[train, val, test]`` subsets.
  366. """
  367. if k_folds < 2:
  368. raise ValueError(f"k_folds must be >= 2, got {k_folds}.")
  369. if not 0 <= fold < k_folds:
  370. raise ValueError(f"fold must be in [0, {k_folds}), got {fold}.")
  371. if not 0.0 <= val_fraction < 1.0:
  372. raise ValueError(f"val_fraction must be in [0.0, 1.0), got {val_fraction}.")
  373. patient_to_indices = _patient_index(dataset, ptids)
  374. patients = list(patient_to_indices.keys())
  375. if len(patients) < k_folds:
  376. raise ValueError(
  377. f"Cannot make {k_folds} folds from {len(patients)} patients."
  378. )
  379. # Deterministic shuffle shared across all folds, then round-robin deal so
  380. # fold sizes are balanced and the same partition is reproduced for every fold.
  381. random.Random(seed).shuffle(patients)
  382. fold_patients = [patients[i::k_folds] for i in range(k_folds)]
  383. test_patients = fold_patients[fold]
  384. rest = [p for f in range(k_folds) if f != fold for p in fold_patients[f]]
  385. n_val = int(round(len(rest) * val_fraction))
  386. val_patients = rest[:n_val]
  387. train_patients = rest[n_val:]
  388. def _indices(group: List[str]) -> List[int]:
  389. return [idx for patient in group for idx in patient_to_indices[patient]]
  390. train_indices = _indices(train_patients)
  391. val_indices = _indices(val_patients)
  392. test_indices = _indices(test_patients)
  393. # Safety: patient-disjoint and full, exact coverage of the dataset.
  394. train_set, val_set, test_set = (
  395. set(train_indices),
  396. set(val_indices),
  397. set(test_indices),
  398. )
  399. if train_set & val_set or train_set & test_set or val_set & test_set:
  400. raise ValueError("Sample index overlap detected across k-fold splits.")
  401. if train_set | val_set | test_set != set(range(len(dataset))):
  402. raise ValueError(
  403. "k-fold coverage check failed: not all samples assigned exactly once."
  404. )
  405. return [
  406. Subset(dataset, train_indices),
  407. Subset(dataset, val_indices),
  408. Subset(dataset, test_indices),
  409. ]