kfold.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. """Patient-grouped k-fold cross-validation driver (single process, sequential).
  2. Runs the ordinary train->evaluate pipeline once per fold, each on a different
  3. patient-grouped partition, writing per-fold artifacts under
  4. ``<work_dir>/folds/fold_<k>/``. It then concatenates every fold's held-out
  5. evaluation along the ``sample`` axis into out-of-fold (OOF) files at
  6. ``<work_dir>/evaluations/oof_*.nc`` -- a single evaluation covering EVERY sample,
  7. each predicted by the fold-ensemble that never trained on it. The existing
  8. analyses then run unchanged on the OOF files (via ``scen_analyze`` or the
  9. ``run_analysis`` step appended to ``scen_kfold``).
  10. Reuses the existing task functions verbatim: each fold just gets its own
  11. dataloaders in ``state`` and a per-fold ``work_dir`` in a shallow config copy.
  12. The full dataset is loaded ONCE and every fold is a set of Subset indices over
  13. it, so memory use matches a single run.
  14. """
  15. import pathlib as pl
  16. from typing import Any, Dict, List
  17. import numpy as np
  18. import xarray as xr
  19. import model.dataset as ds
  20. from evaluation import CLASS_NAMES, load_evaluation, save_evaluation
  21. from util.control import check_control_events
  22. from util.progress import ProgressTracker
  23. from util.ui_logger import PipelineLogger
  24. def _inner_tasks(include_noise: bool):
  25. """Per-fold pipeline (load_data is handled by the driver; noise optional).
  26. Sibling task modules are imported lazily so this module never needs the
  27. ``tasks`` package to be fully initialized at import time (``tasks/__init__``
  28. imports this module in order to register the k-fold task).
  29. """
  30. from . import evaluate, load_models, train_bayesian, train_normal
  31. tasks = [
  32. ("Train Regular", train_normal.train_normal_task),
  33. ("Train Bayesian", train_bayesian.train_bayesian_task),
  34. ("Load Models", load_models.load_models_task),
  35. ("Evaluate Regular", evaluate.evaluate_normal_task),
  36. ("Evaluate Bayesian", evaluate.evaluate_bayesian_task),
  37. ]
  38. if include_noise:
  39. tasks.append(("Evaluate Noisy", evaluate.evaluate_noisy_task))
  40. return tasks
  41. _FOLDS_SUBDIR = "folds"
  42. _EVAL_SUBDIR = "evaluations"
  43. def _kfold_cfg(config: Dict[str, Any]):
  44. cfg = config.get("kfold", {})
  45. k_folds = int(cfg.get("k_folds", 4))
  46. val_fraction = float(cfg.get("val_fraction", 0.15))
  47. include_noise = bool(cfg.get("include_noise", False))
  48. return k_folds, val_fraction, include_noise
  49. def _aggregate_oof(
  50. work_dir: pl.Path, fold_dirs: List[pl.Path], log: PipelineLogger
  51. ) -> None:
  52. """Concatenate each fold's held-out evaluations into OOF files."""
  53. out_dir = work_dir / _EVAL_SUBDIR
  54. out_dir.mkdir(parents=True, exist_ok=True)
  55. # Evaluation file names are identical across folds (normal.nc, bayesian.nc,
  56. # and noisy_* when enabled); discover them from the folds that produced them.
  57. stems = sorted(
  58. {p.stem for d in fold_dirs for p in (d / _EVAL_SUBDIR).glob("*.nc")}
  59. )
  60. for stem in stems:
  61. parts = [
  62. load_evaluation(str(d / _EVAL_SUBDIR / f"{stem}.nc"))
  63. for d in fold_dirs
  64. if (d / _EVAL_SUBDIR / f"{stem}.nc").exists()
  65. ]
  66. if not parts:
  67. continue
  68. # Folds hold disjoint samples; concatenating along `sample` yields full,
  69. # non-overlapping out-of-fold coverage. Reset the (per-fold) sample index.
  70. oof = xr.concat(parts, dim="sample")
  71. oof = oof.assign_coords(sample=np.arange(oof.sizes["sample"]))
  72. oof.attrs["n_folds"] = len(parts)
  73. oof.attrs["kfold"] = 1
  74. save_evaluation(oof, str(out_dir / f"oof_{stem}.nc"))
  75. for part in parts:
  76. part.close()
  77. log.info(
  78. f"Aggregated OOF: oof_{stem}.nc "
  79. f"({oof.sizes['sample']} samples from {len(parts)} folds)."
  80. )
  81. def run_kfold_task(
  82. track: ProgressTracker,
  83. log: PipelineLogger,
  84. config: Dict[str, Any],
  85. state: Dict[str, Any],
  86. ) -> None:
  87. events = state.get("events")
  88. stop_event = events.get("stop") if isinstance(events, dict) else None
  89. pause_event = events.get("pause") if isinstance(events, dict) else None
  90. from . import load_data # lazy, as in _inner_tasks
  91. base_seed = load_data.seed_pipeline(config, log)
  92. k_folds, val_fraction, include_noise = _kfold_cfg(config)
  93. dataset, ptids, image_to_ptid = load_data.load_full_dataset(config, log)
  94. state["image_to_ptid"] = image_to_ptid
  95. inner_tasks = _inner_tasks(include_noise)
  96. work_dir = pl.Path(config["work_dir"])
  97. fold_dirs: List[pl.Path] = []
  98. log.info(
  99. f"Starting {k_folds}-fold cross-validation "
  100. f"(val_fraction={val_fraction}, include_noise={include_noise})."
  101. )
  102. track.set_title("K-Fold Cross-Validation")
  103. track.update(total=k_folds, advance=0)
  104. for fold in range(k_folds):
  105. check_control_events(stop_event, pause_event)
  106. subsets = ds.kfold_split_by_patient_id(
  107. dataset, ptids, k_folds, fold, base_seed, val_fraction
  108. )
  109. train_loader, val_loader, test_loader = ds.initalize_dataloaders(
  110. subsets, batch_size=config["training"]["batch_size"], seed=base_seed
  111. )
  112. state["train_loader"] = train_loader
  113. state["val_loader"] = val_loader
  114. state["test_loader"] = test_loader
  115. fold_dir = work_dir / _FOLDS_SUBDIR / f"fold_{fold}"
  116. fold_dirs.append(fold_dir)
  117. # Per-fold config: same everything, but outputs go under the fold dir.
  118. fold_config = {**config, "work_dir": str(fold_dir)}
  119. log.info(
  120. f"===== Fold {fold + 1}/{k_folds} "
  121. f"(train={len(subsets[0])}, val={len(subsets[1])}, "
  122. f"test={len(subsets[2])} scans) ====="
  123. )
  124. for task_label, task_func in inner_tasks:
  125. check_control_events(stop_event, pause_event)
  126. fold_track = track.get_sub_tracker(
  127. f"Fold {fold + 1}/{k_folds}: {task_label}"
  128. )
  129. task_func(fold_track, log, fold_config, state)
  130. track.update(advance=1)
  131. log.info("All folds complete; aggregating out-of-fold evaluations.")
  132. _aggregate_oof(work_dir, fold_dirs, log)