kfold.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  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. from . import evaluate, load_data, load_models, train_bayesian, train_normal
  25. # Inner per-fold pipeline (load_data is handled by the driver; noise optional).
  26. _BASE_INNER_TASKS = [
  27. ("Train Regular", train_normal.train_normal_task),
  28. ("Train Bayesian", train_bayesian.train_bayesian_task),
  29. ("Load Models", load_models.load_models_task),
  30. ("Evaluate Regular", evaluate.evaluate_normal_task),
  31. ("Evaluate Bayesian", evaluate.evaluate_bayesian_task),
  32. ]
  33. _FOLDS_SUBDIR = "folds"
  34. _EVAL_SUBDIR = "evaluations"
  35. def _kfold_cfg(config: Dict[str, Any]):
  36. cfg = config.get("kfold", {})
  37. k_folds = int(cfg.get("k_folds", 4))
  38. val_fraction = float(cfg.get("val_fraction", 0.15))
  39. include_noise = bool(cfg.get("include_noise", False))
  40. return k_folds, val_fraction, include_noise
  41. def _aggregate_oof(
  42. work_dir: pl.Path, fold_dirs: List[pl.Path], log: PipelineLogger
  43. ) -> None:
  44. """Concatenate each fold's held-out evaluations into OOF files."""
  45. out_dir = work_dir / _EVAL_SUBDIR
  46. out_dir.mkdir(parents=True, exist_ok=True)
  47. # Evaluation file names are identical across folds (normal.nc, bayesian.nc,
  48. # and noisy_* when enabled); discover them from the folds that produced them.
  49. stems = sorted(
  50. {p.stem for d in fold_dirs for p in (d / _EVAL_SUBDIR).glob("*.nc")}
  51. )
  52. for stem in stems:
  53. parts = [
  54. load_evaluation(str(d / _EVAL_SUBDIR / f"{stem}.nc"))
  55. for d in fold_dirs
  56. if (d / _EVAL_SUBDIR / f"{stem}.nc").exists()
  57. ]
  58. if not parts:
  59. continue
  60. # Folds hold disjoint samples; concatenating along `sample` yields full,
  61. # non-overlapping out-of-fold coverage. Reset the (per-fold) sample index.
  62. oof = xr.concat(parts, dim="sample")
  63. oof = oof.assign_coords(sample=np.arange(oof.sizes["sample"]))
  64. oof.attrs["n_folds"] = len(parts)
  65. oof.attrs["kfold"] = 1
  66. save_evaluation(oof, str(out_dir / f"oof_{stem}.nc"))
  67. for part in parts:
  68. part.close()
  69. log.info(
  70. f"Aggregated OOF: oof_{stem}.nc "
  71. f"({oof.sizes['sample']} samples from {len(parts)} folds)."
  72. )
  73. def run_kfold_task(
  74. track: ProgressTracker,
  75. log: PipelineLogger,
  76. config: Dict[str, Any],
  77. state: Dict[str, Any],
  78. ) -> None:
  79. events = state.get("events")
  80. stop_event = events.get("stop") if isinstance(events, dict) else None
  81. pause_event = events.get("pause") if isinstance(events, dict) else None
  82. base_seed = load_data.seed_pipeline(config, log)
  83. k_folds, val_fraction, include_noise = _kfold_cfg(config)
  84. dataset, ptids, image_to_ptid = load_data.load_full_dataset(config, log)
  85. state["image_to_ptid"] = image_to_ptid
  86. inner_tasks = list(_BASE_INNER_TASKS)
  87. if include_noise:
  88. inner_tasks.append(("Evaluate Noisy", evaluate.evaluate_noisy_task))
  89. work_dir = pl.Path(config["work_dir"])
  90. fold_dirs: List[pl.Path] = []
  91. log.info(
  92. f"Starting {k_folds}-fold cross-validation "
  93. f"(val_fraction={val_fraction}, include_noise={include_noise})."
  94. )
  95. track.set_title("K-Fold Cross-Validation")
  96. track.update(total=k_folds, advance=0)
  97. for fold in range(k_folds):
  98. check_control_events(stop_event, pause_event)
  99. subsets = ds.kfold_split_by_patient_id(
  100. dataset, ptids, k_folds, fold, base_seed, val_fraction
  101. )
  102. train_loader, val_loader, test_loader = ds.initalize_dataloaders(
  103. subsets, batch_size=config["training"]["batch_size"], seed=base_seed
  104. )
  105. state["train_loader"] = train_loader
  106. state["val_loader"] = val_loader
  107. state["test_loader"] = test_loader
  108. fold_dir = work_dir / _FOLDS_SUBDIR / f"fold_{fold}"
  109. fold_dirs.append(fold_dir)
  110. # Per-fold config: same everything, but outputs go under the fold dir.
  111. fold_config = {**config, "work_dir": str(fold_dir)}
  112. log.info(
  113. f"===== Fold {fold + 1}/{k_folds} "
  114. f"(train={len(subsets[0])}, val={len(subsets[1])}, "
  115. f"test={len(subsets[2])} scans) ====="
  116. )
  117. for task_label, task_func in inner_tasks:
  118. check_control_events(stop_event, pause_event)
  119. fold_track = track.get_sub_tracker(
  120. f"Fold {fold + 1}/{k_folds}: {task_label}"
  121. )
  122. task_func(fold_track, log, fold_config, state)
  123. track.update(advance=1)
  124. log.info("All folds complete; aggregating out-of-fold evaluations.")
  125. _aggregate_oof(work_dir, fold_dirs, log)