dataset.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import pathlib as pl
  2. import random
  3. import re
  4. from typing import Callable, Dict, Iterator, List, Tuple
  5. import nibabel as nib
  6. import pandas as pd
  7. import torch
  8. import torch.utils.data as data
  9. from jaxtyping import Float
  10. from torch.utils.data import DataLoader, Subset
  11. def _row_to_float_tensor(row: pd.DataFrame, *, image_id: int) -> torch.Tensor:
  12. values = row.drop(columns=["Image Data ID"]).iloc[0]
  13. numeric = pd.to_numeric(values, errors="coerce")
  14. if numeric.isna().any(): # pyright: ignore
  15. bad_columns = [column for column, value in numeric.items() if pd.isna(value)] # pyright: ignore
  16. raise ValueError(
  17. f"Non-numeric Excel values for Image Data ID {image_id}: {bad_columns}"
  18. )
  19. return torch.tensor(numeric.to_numpy(dtype="float32")) # pyright: ignore
  20. def xls_pre(df: pd.DataFrame) -> pd.DataFrame:
  21. """
  22. Preprocess the Excel DataFrame.
  23. This function can be customized to filter or modify the DataFrame as needed.
  24. """
  25. data: pd.DataFrame = df[["Image Data ID", "Sex", "Age (current)"]] # pyright: ignore
  26. data["Sex"] = data["Sex"].str.strip() # type: ignore
  27. data = data.replace({"M": 0, "F": 1}) # type: ignore
  28. data.set_index("Image Data ID") # type: ignore
  29. return data
  30. class ADNIDataset(data.Dataset): # type: ignore
  31. """
  32. A PyTorch Dataset class for loading
  33. and processing MRI and Excel data from the ADNI dataset.
  34. """
  35. def __init__(
  36. self,
  37. mri_data: Float[torch.Tensor, "n_samples channels width height depth"],
  38. xls_data: Float[torch.Tensor, "n_samples features"],
  39. expected_classes: Float[
  40. torch.Tensor, " classes"
  41. ], # leading space is necessary so pyright doesn't complain
  42. filename_ids: List[int],
  43. device: str = "cuda",
  44. ):
  45. """
  46. Args:
  47. mri_data (torch.Tensor): 5D tensor of MRI data with shape (n_samples, channels, width, height, depth).
  48. xls_data (torch.Tensor): 2D tensor of Excel data with shape (n_samples, features).
  49. """
  50. # Keep full datasets on CPU and move only active batches to accelerator.
  51. # This avoids loading the entire training corpus into VRAM.
  52. self.mri_data = mri_data.float().cpu()
  53. self.xls_data = xls_data.float().cpu()
  54. self.expected_classes = expected_classes.float().cpu()
  55. self.filename_ids = filename_ids
  56. def __len__(self) -> int:
  57. """
  58. Returns the number of samples in the dataset.
  59. """
  60. return self.mri_data.shape[0] # 0th dimension is the number of samples
  61. def __getitem__(
  62. self, idx: int
  63. ) -> Tuple[
  64. Float[torch.Tensor, "channels width height depth"],
  65. Float[torch.Tensor, " features"],
  66. Float[torch.Tensor, " classes"],
  67. int,
  68. ]:
  69. """
  70. Returns a sample from the dataset at the given index.
  71. Args:
  72. idx (int): Index of the sample to retrieve.
  73. Returns:
  74. tuple: A tuple containing the MRI data and Excel data for the sample.
  75. """
  76. # Slices the data on the 0th dimension, corresponding to the sample index
  77. mri_sample = self.mri_data[idx]
  78. xls_sample = self.xls_data[idx]
  79. # Assuming expected_classes is a tensor of classes, we return it as well
  80. expected_classes = self.expected_classes[idx]
  81. filename_id = self.filename_ids[idx]
  82. return mri_sample, xls_sample, expected_classes, filename_id
  83. def load_adni_data_from_file(
  84. mri_files: Iterator[pl.Path], # List of nibablel files
  85. xls_file: pl.Path, # Path to the Excel file
  86. device: str = "cuda",
  87. xls_preprocessor: Callable[[pd.DataFrame], pd.DataFrame] = lambda x: x,
  88. ) -> ADNIDataset:
  89. """
  90. Loads MRI and Excel data from the ADNI dataset.
  91. Args:
  92. mri_files (List[pl.Path]): List of paths to the MRI files.
  93. xls_file (pl.Path): Path to the Excel file.
  94. Returns:
  95. ADNIDataset: The loaded dataset.
  96. """
  97. # Load the Excel data
  98. xls_values = xls_preprocessor(pd.read_csv(xls_file)) # type: ignore
  99. # Load the MRI data
  100. mri_data_unstacked: List[torch.Tensor] = []
  101. expected_classes_unstacked: List[torch.Tensor] = []
  102. xls_data_unstacked: List[torch.Tensor] = []
  103. img_ids: List[int] = []
  104. for file in mri_files:
  105. filename = file.stem
  106. match re.search(r".+?(?=_I)_I(\d+).+", filename):
  107. case None:
  108. raise ValueError(
  109. f"Filename {filename} does not match expected pattern."
  110. )
  111. case m:
  112. img_id = int(m.group(1))
  113. file_mri_data = torch.from_numpy(nib.load(file).get_fdata()) # type: ignore # type checking does not work well with nibabel
  114. # Read the filename to determine the expected class
  115. file_expected_class = torch.tensor([0.0, 0.0]) # Default to a tensor of zeros
  116. if "AD" in filename:
  117. file_expected_class = torch.tensor([1.0, 0.0])
  118. elif "NL" in filename:
  119. file_expected_class = torch.tensor([0.0, 1.0])
  120. else:
  121. raise ValueError(
  122. f"Filename {filename} does not contain a valid class identifier (AD or CN)."
  123. )
  124. mri_data_unstacked.append(file_mri_data)
  125. expected_classes_unstacked.append(file_expected_class)
  126. # Extract the corresponding row from the Excel data using the img_id
  127. xls_row = xls_values.loc[xls_values["Image Data ID"] == img_id]
  128. if xls_row.empty:
  129. raise ValueError(
  130. f"No matching row found in Excel data for Image Data ID {img_id}."
  131. )
  132. elif len(xls_row) > 1:
  133. raise ValueError(
  134. f"Multiple rows found in Excel data for Image Data ID {img_id}."
  135. )
  136. file_xls_data = _row_to_float_tensor(xls_row, image_id=img_id)
  137. xls_data_unstacked.append(file_xls_data)
  138. img_ids.append(img_id)
  139. mri_data = torch.stack(mri_data_unstacked).unsqueeze(1)
  140. # Stack the list of tensors into a single tensor and unsqueeze along the channel dimension
  141. xls_data = torch.stack(
  142. xls_data_unstacked
  143. ) # Stack the list of tensors into a single tensor
  144. expected_classes = torch.stack(
  145. expected_classes_unstacked
  146. ) # Stack the list of expected classes into a single tensor
  147. return ADNIDataset(mri_data, xls_data, expected_classes, img_ids, device=device)
  148. def divide_dataset(
  149. dataset: ADNIDataset,
  150. ratios: Tuple[float, float, float],
  151. seed: int,
  152. ) -> List[data.Subset[ADNIDataset]]:
  153. """
  154. Divides the dataset into training, validation, and test sets.
  155. Args:
  156. dataset (ADNIDataset): The dataset to divide.
  157. train_ratio (float): The ratio of the training set.
  158. val_ratio (float): The ratio of the validation set.
  159. test_ratio (float): The ratio of the test set.
  160. Returns:
  161. Result[List[data.Subset[ADNIDataset]], str]: A Result object containing the subsets or an error message.
  162. """
  163. if sum(ratios) != 1.0:
  164. raise ValueError(f"Ratios must sum to 1.0, got {ratios}.")
  165. # Set the random seed for reproducibility
  166. gen = torch.Generator().manual_seed(seed)
  167. return data.random_split(dataset, ratios, generator=gen)
  168. def initalize_dataloaders(
  169. datasets: List[Subset[ADNIDataset]],
  170. batch_size: int = 64,
  171. ) -> List[DataLoader[ADNIDataset]]:
  172. """
  173. Initializes the DataLoader for the given datasets.
  174. Args:
  175. datasets (List[Subset[ADNIDataset]]): List of datasets to create DataLoaders for.
  176. batch_size (int): The batch size for the DataLoader.
  177. Returns:
  178. List[DataLoader[ADNIDataset]]: A list of DataLoaders for the datasets.
  179. """
  180. pin_memory = torch.cuda.is_available()
  181. return [
  182. DataLoader(
  183. dataset,
  184. batch_size=batch_size,
  185. shuffle=True,
  186. pin_memory=pin_memory,
  187. )
  188. for dataset in datasets
  189. ]
  190. def divide_dataset_by_patient_id(
  191. dataset: ADNIDataset,
  192. ptids: List[Tuple[int, str]],
  193. ratios: Tuple[float, float, float],
  194. seed: int,
  195. ) -> List[data.Subset[ADNIDataset]]:
  196. """
  197. Divides the dataset into training, validation, and test sets based on patient IDs.
  198. Ensures that all samples from the same patient are in the same set.
  199. Args:
  200. dataset (ADNIDataset): The dataset to divide.
  201. ptids (List[Tuple[int, str]]): A list of tuples containing image file IDs and their corresponding patient IDs.
  202. ratios (Tuple[float, float, float]): The ratios for training, validation, and test sets.
  203. seed (int): The random seed for reproducibility.
  204. Returns:
  205. List[data.Subset[ADNIDataset]]: A list of subsets for training, validation, and test sets.
  206. Notes:
  207. This split is grouped by PTID, so all images from the same patient are assigned
  208. to exactly one partition to avoid patient-level leakage across train/val/test.
  209. """
  210. if sum(ratios) != 1.0:
  211. raise ValueError(f"Ratios must sum to 1.0, got {ratios}.")
  212. if not ptids:
  213. raise ValueError("ptids list cannot be empty.")
  214. image_to_patient: Dict[int, str] = {}
  215. for image_id, patient_id in ptids:
  216. image_id_int = int(image_id)
  217. patient_id_str = str(patient_id).strip()
  218. if not patient_id_str or patient_id_str.lower() == "nan":
  219. raise ValueError(f"Invalid PTID for Image Data ID {image_id_int}.")
  220. if (
  221. image_id_int in image_to_patient
  222. and image_to_patient[image_id_int] != patient_id_str
  223. ):
  224. raise ValueError(
  225. f"Conflicting PTIDs for Image Data ID {image_id_int}: "
  226. f"{image_to_patient[image_id_int]} vs {patient_id_str}."
  227. )
  228. image_to_patient[image_id_int] = patient_id_str
  229. patient_to_indices: Dict[str, List[int]] = {}
  230. for idx, image_id in enumerate(dataset.filename_ids):
  231. if image_id not in image_to_patient:
  232. raise ValueError(
  233. f"Missing PTID mapping for dataset Image Data ID {image_id}."
  234. )
  235. patient_id = image_to_patient[image_id]
  236. if patient_id not in patient_to_indices:
  237. patient_to_indices[patient_id] = []
  238. patient_to_indices[patient_id].append(idx)
  239. shuffled_patients = list(patient_to_indices.keys())
  240. random.Random(seed).shuffle(shuffled_patients)
  241. train_cutoff = int(len(shuffled_patients) * ratios[0])
  242. val_cutoff = train_cutoff + int(len(shuffled_patients) * ratios[1])
  243. train_patients = shuffled_patients[:train_cutoff]
  244. val_patients = shuffled_patients[train_cutoff:val_cutoff]
  245. test_patients = shuffled_patients[val_cutoff:]
  246. train_patient_set = set(train_patients)
  247. val_patient_set = set(val_patients)
  248. test_patient_set = set(test_patients)
  249. if (
  250. train_patient_set & val_patient_set
  251. or train_patient_set & test_patient_set
  252. or val_patient_set & test_patient_set
  253. ):
  254. raise ValueError("Patient separation violated across train/val/test splits.")
  255. all_patients = set(patient_to_indices.keys())
  256. if train_patient_set | val_patient_set | test_patient_set != all_patients:
  257. raise ValueError("Not all patients were assigned to a split.")
  258. train_indices = [
  259. idx for patient_id in train_patients for idx in patient_to_indices[patient_id]
  260. ]
  261. val_indices = [
  262. idx for patient_id in val_patients for idx in patient_to_indices[patient_id]
  263. ]
  264. test_indices = [
  265. idx for patient_id in test_patients for idx in patient_to_indices[patient_id]
  266. ]
  267. train_index_set = set(train_indices)
  268. val_index_set = set(val_indices)
  269. test_index_set = set(test_indices)
  270. if (
  271. train_index_set & val_index_set
  272. or train_index_set & test_index_set
  273. or val_index_set & test_index_set
  274. ):
  275. raise ValueError("Sample index overlap detected across train/val/test splits.")
  276. all_split_indices = train_index_set | val_index_set | test_index_set
  277. expected_indices = set(range(len(dataset)))
  278. if all_split_indices != expected_indices:
  279. raise ValueError(
  280. "Split coverage check failed: not all dataset samples are assigned exactly once."
  281. )
  282. return [
  283. Subset(dataset, train_indices),
  284. Subset(dataset, val_indices),
  285. Subset(dataset, test_indices),
  286. ]