evaluate_models.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. # pyright: basic
  2. from __future__ import annotations
  3. import argparse
  4. import sys
  5. from pathlib import Path
  6. from typing import Any
  7. import pandas as pd
  8. import torch
  9. from tqdm.auto import tqdm
  10. if __package__ in (None, ""):
  11. sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
  12. from analysis.analysis_modules import (
  13. run_calibration,
  14. run_longitudinal,
  15. run_performance,
  16. run_physician,
  17. )
  18. from analysis.data_access import load_backend_evaluation, load_clinical_table
  19. from analysis.dataset_summary import run_dataset_summary
  20. from analysis.defaults import (
  21. DEFAULT_BACKENDS,
  22. DEFAULT_BAYESIAN_MC_PASSES,
  23. DEFAULT_CALIBRATION_BINS,
  24. DEFAULT_DECISION_THRESHOLD,
  25. DEFAULT_POSITIVE_CLASS_INDEX,
  26. noise_factor_grid,
  27. threshold_grid,
  28. )
  29. from analysis.holdout_evaluation import ensure_backend_netcdf
  30. from analysis.longitudinal_audit import run_longitudinal_breakdown_audit
  31. from analysis.noise_analysis import run_noise_analysis
  32. from analysis.noise_correlation import run_noise_accuracy_uncertainty_analysis
  33. from analysis.runtime import backend_dir, init_runtime_paths, load_config, write_json
  34. else:
  35. from .analysis_modules import (
  36. run_calibration,
  37. run_longitudinal,
  38. run_performance,
  39. run_physician,
  40. )
  41. from .data_access import load_backend_evaluation, load_clinical_table
  42. from .dataset_summary import run_dataset_summary
  43. from .defaults import (
  44. DEFAULT_BACKENDS,
  45. DEFAULT_BAYESIAN_MC_PASSES,
  46. DEFAULT_CALIBRATION_BINS,
  47. DEFAULT_DECISION_THRESHOLD,
  48. DEFAULT_POSITIVE_CLASS_INDEX,
  49. noise_factor_grid,
  50. threshold_grid,
  51. )
  52. from .holdout_evaluation import ensure_backend_netcdf
  53. from .longitudinal_audit import run_longitudinal_breakdown_audit
  54. from .noise_analysis import run_noise_analysis
  55. from .noise_correlation import run_noise_accuracy_uncertainty_analysis
  56. from .runtime import backend_dir, init_runtime_paths, load_config, write_json
  57. def _plot_description(filename: str) -> str:
  58. descriptions = {
  59. "performance_threshold_accuracy.png": "Accuracy as the decision threshold varies.",
  60. "performance_threshold_f1.png": "F1 score as the decision threshold varies.",
  61. "performance_threshold_accuracy_f1.png": "Accuracy and F1 shown side-by-side as the decision threshold varies.",
  62. "performance_uncertainty_cutoff_accuracy.png": "Accuracy while progressively restricting to higher-confidence and uncertainty-metric subsets.",
  63. "performance_uncertainty_cutoff_f1.png": "F1 score while progressively restricting to higher-confidence and uncertainty-metric subsets.",
  64. "performance_uncertainty_cutoff_accuracy_f1.png": "Accuracy and F1 shown side-by-side across uncertainty-cutoff restriction levels.",
  65. "performance_uncertainty_percentile_cutoff_accuracy.png": "Accuracy from least to most restricted percentile-wise subset selection.",
  66. "performance_uncertainty_percentile_cutoff_f1.png": "F1 score from least to most restricted percentile-wise subset selection.",
  67. "performance_uncertainty_percentile_cutoff_accuracy_f1.png": "Accuracy and F1 shown side-by-side across percentile-floor restriction levels.",
  68. "calibration_reliability.png": "Reliability diagram comparing predicted probability to empirical outcome frequency.",
  69. "physician_confidence_boxplot.png": "Confidence grouped by physician confidence ratings.",
  70. "physician_std_boxplot.png": "Standard deviation grouped by physician confidence ratings.",
  71. "physician_predictive_entropy_boxplot.png": "Predictive uncertainty grouped by physician confidence ratings.",
  72. "longitudinal_cohort_confidence.png": "Longitudinal cohort comparison using confidence.",
  73. "longitudinal_cohort_std.png": "Longitudinal cohort comparison using standard deviation.",
  74. "longitudinal_cohort_predictive_entropy.png": "Longitudinal cohort comparison using predictive uncertainty.",
  75. "noise_sensitivity_accuracy.png": "Accuracy trend across increasing Gaussian noise factors.",
  76. "noise_sensitivity_f1.png": "F1 trend across increasing Gaussian noise factors.",
  77. "noise_sensitivity_accuracy_f1.png": "Accuracy and F1 shown side-by-side across increasing Gaussian noise factors.",
  78. "noise_confidence.png": "Confidence trend across increasing Gaussian noise factors.",
  79. "noise_standard_deviation.png": "Standard deviation trend across increasing Gaussian noise factors.",
  80. "noise_confidence_standard_deviation.png": "Confidence and standard deviation shown side-by-side across increasing Gaussian noise factors.",
  81. "noise_predictive_uncertainty.png": "Predictive uncertainty trend across increasing Gaussian noise factors.",
  82. "noise_confidence_predictive_uncertainty.png": "Confidence and predictive uncertainty shown side-by-side across increasing Gaussian noise factors.",
  83. "noise_accuracy_uncertainty_2d.png": "2D uncertainty-vs-accuracy relationship with linear fit (noise factor encoded by color).",
  84. "ensemble_noise_examples.png": "Representative noisy image slices across selected Gaussian noise factors.",
  85. "bayesian_noise_examples.png": "Representative noisy image slices across selected Gaussian noise factors.",
  86. "ensemble_clean_scan_example.png": "Example clean scan image with no added noise.",
  87. "bayesian_clean_scan_example.png": "Example clean scan image with no added noise.",
  88. }
  89. return descriptions.get(filename, "Generated analysis plot.")
  90. def _write_backend_plot_report(backend: str, out_dir: Path) -> Path:
  91. plots_dir = out_dir / "plots"
  92. images = sorted(plots_dir.rglob("*.png")) if plots_dir.exists() else []
  93. report_path = out_dir / "plots_report.md"
  94. lines = [
  95. f"# {backend.title()} Analysis Plot Report",
  96. "",
  97. "This document lists generated analysis plots with brief descriptions.",
  98. "",
  99. ]
  100. if not images:
  101. lines.append("No plot images were generated for this backend run.")
  102. else:
  103. for image_path in images:
  104. rel = image_path.relative_to(out_dir).as_posix()
  105. title = image_path.stem.replace("_", " ").title()
  106. lines.append(f"## {title}")
  107. lines.append(_plot_description(image_path.name))
  108. lines.append("")
  109. lines.append(f"![{title}]({rel})")
  110. lines.append("")
  111. report_path.write_text("\n".join(lines), encoding="utf-8")
  112. return report_path
  113. def _print_device_info(config: dict[str, Any]) -> None:
  114. device = torch.device(str(config["training"]["device"]))
  115. print(f"Analysis device: {device}")
  116. if device.type == "cuda":
  117. if not torch.cuda.is_available():
  118. print("CUDA is not available in this environment.")
  119. return
  120. index = device.index if device.index is not None else torch.cuda.current_device()
  121. props = torch.cuda.get_device_properties(index)
  122. print(f"GPU index: {index}")
  123. print(f"GPU name: {torch.cuda.get_device_name(index)}")
  124. print(f"GPU capability: {props.major}.{props.minor}")
  125. print(f"GPU total memory: {props.total_memory / (1024 ** 3):.2f} GiB")
  126. print(f"CUDA runtime: {torch.version.cuda or 'unknown'}")
  127. print(f"Visible CUDA devices: {torch.cuda.device_count()}")
  128. elif device.type == "mps":
  129. print("Apple Metal Performance Shaders backend selected.")
  130. else:
  131. print("CPU backend selected.")
  132. def _parse_args() -> argparse.Namespace:
  133. parser = argparse.ArgumentParser(
  134. description=(
  135. "Run modular evaluation analyses for ensemble and bayesian models. "
  136. "All outputs are written to alnn_rewrite/analysis_output."
  137. )
  138. )
  139. parser.add_argument(
  140. "--backend",
  141. nargs="+",
  142. choices=["ensemble", "bayesian"],
  143. default=DEFAULT_BACKENDS,
  144. help="Backends to evaluate.",
  145. )
  146. parser.add_argument(
  147. "--run-name",
  148. default=None,
  149. help="Optional run directory name under analysis_output.",
  150. )
  151. parser.add_argument(
  152. "--skip-noise",
  153. action="store_true",
  154. help="Skip Gaussian noise sensitivity analysis.",
  155. )
  156. parser.add_argument(
  157. "--longitudinal-breakdown-only",
  158. action="store_true",
  159. help=(
  160. "Run only longitudinal cohort breakdown audit from existing model "
  161. "evaluation outputs (no full analysis rerun)."
  162. ),
  163. )
  164. parser.add_argument(
  165. "--noise-correlation-only",
  166. action="store_true",
  167. help=(
  168. "Run only the noise uncertainty-vs-accuracy correlation/regression "
  169. "analysis from an existing noise_sensitivity.csv per backend."
  170. ),
  171. )
  172. parser.add_argument(
  173. "--dataset-summary-only",
  174. action="store_true",
  175. help=(
  176. "Generate only dataset composition summary documentation "
  177. "(overall and train/validation/test class breakdown)."
  178. ),
  179. )
  180. args = parser.parse_args()
  181. only_modes = [
  182. bool(args.longitudinal_breakdown_only),
  183. bool(args.noise_correlation_only),
  184. bool(args.dataset_summary_only),
  185. ]
  186. if sum(only_modes) > 1:
  187. parser.error(
  188. "Only one of --longitudinal-breakdown-only, "
  189. "--noise-correlation-only, and --dataset-summary-only may be used at once."
  190. )
  191. return args
  192. def _run_longitudinal_breakdown_only(
  193. config: dict[str, Any],
  194. backend: str,
  195. clinical_df: pd.DataFrame,
  196. out_dir: Path,
  197. ) -> dict[str, Any]:
  198. evaluation = load_backend_evaluation(
  199. config=config,
  200. backend=backend,
  201. class_index=DEFAULT_POSITIVE_CLASS_INDEX,
  202. )
  203. summary = run_longitudinal_breakdown_audit(
  204. evaluation=evaluation,
  205. clinical_df=clinical_df,
  206. output_dir=out_dir,
  207. )
  208. write_json(out_dir / "longitudinal_breakdown_backend_summary.json", summary)
  209. return summary
  210. def _run_noise_correlation_only(
  211. backend: str,
  212. out_dir: Path,
  213. ) -> dict[str, Any]:
  214. noise_table_path = out_dir / "noise_sensitivity.csv"
  215. if not noise_table_path.exists():
  216. raise FileNotFoundError(
  217. f"Expected existing noise table for --noise-correlation-only: {noise_table_path}"
  218. )
  219. noise_df = pd.read_csv(noise_table_path)
  220. summary = run_noise_accuracy_uncertainty_analysis(
  221. noise_df=noise_df,
  222. backend=backend,
  223. output_dir=out_dir,
  224. )
  225. write_json(out_dir / "noise_accuracy_uncertainty_backend_summary.json", summary)
  226. return summary
  227. def _run_backend(
  228. config: dict[str, Any],
  229. root_dir: Path,
  230. backend: str,
  231. clinical_df: pd.DataFrame,
  232. skip_noise: bool,
  233. out_dir: Path,
  234. ) -> dict[str, Any]:
  235. netcdf_path = ensure_backend_netcdf(
  236. config=config,
  237. root_dir=root_dir,
  238. backend=backend,
  239. bayesian_mc_passes=DEFAULT_BAYESIAN_MC_PASSES,
  240. )
  241. evaluation = load_backend_evaluation(
  242. config=config,
  243. backend=backend,
  244. class_index=DEFAULT_POSITIVE_CLASS_INDEX,
  245. )
  246. thresholds = threshold_grid()
  247. noise_factors = noise_factor_grid()
  248. summary: dict[str, Any] = {
  249. "backend": backend,
  250. "netcdf": str(netcdf_path),
  251. "source_file": str(evaluation.source_file),
  252. "uncertainty_metric": evaluation.uncertainty_metric,
  253. }
  254. n_stages = 4 + (0 if skip_noise else 2)
  255. stage_bar = tqdm(
  256. total=n_stages,
  257. desc=f"[{backend}] analysis stages",
  258. unit="stage",
  259. leave=False,
  260. )
  261. try:
  262. stage_bar.set_postfix_str("performance")
  263. summary["performance"] = run_performance(
  264. evaluation=evaluation,
  265. output_dir=out_dir,
  266. thresholds=thresholds,
  267. )
  268. stage_bar.update(1)
  269. stage_bar.set_postfix_str("calibration")
  270. summary["calibration"] = run_calibration(
  271. evaluation=evaluation,
  272. output_dir=out_dir,
  273. bins=DEFAULT_CALIBRATION_BINS,
  274. )
  275. stage_bar.update(1)
  276. stage_bar.set_postfix_str("physician")
  277. summary["physician"] = run_physician(
  278. evaluation=evaluation,
  279. clinical_df=clinical_df,
  280. output_dir=out_dir,
  281. )
  282. stage_bar.update(1)
  283. stage_bar.set_postfix_str("longitudinal")
  284. summary["longitudinal"] = run_longitudinal(
  285. evaluation=evaluation,
  286. clinical_df=clinical_df,
  287. output_dir=out_dir,
  288. )
  289. stage_bar.update(1)
  290. if skip_noise:
  291. summary["noise"] = {"skipped": True, "reason": "--skip-noise supplied"}
  292. summary["noise_accuracy_uncertainty"] = {
  293. "skipped": True,
  294. "reason": "Noise analysis skipped, so no noise table available.",
  295. }
  296. else:
  297. try:
  298. stage_bar.set_postfix_str("noise")
  299. summary["noise"] = run_noise_analysis(
  300. config=config,
  301. root_dir=root_dir,
  302. backend=backend,
  303. output_dir=out_dir,
  304. class_index=DEFAULT_POSITIVE_CLASS_INDEX,
  305. noise_sigmas=noise_factors,
  306. threshold=DEFAULT_DECISION_THRESHOLD,
  307. calibration_bins=DEFAULT_CALIBRATION_BINS,
  308. bayesian_mc_passes=DEFAULT_BAYESIAN_MC_PASSES,
  309. )
  310. stage_bar.update(1)
  311. stage_bar.set_postfix_str("noise-correlation")
  312. noise_table_path = Path(str(summary["noise"]["table"]))
  313. noise_df = pd.read_csv(noise_table_path)
  314. summary["noise_accuracy_uncertainty"] = (
  315. run_noise_accuracy_uncertainty_analysis(
  316. noise_df=noise_df,
  317. backend=backend,
  318. output_dir=out_dir,
  319. )
  320. )
  321. stage_bar.update(1)
  322. except Exception as exc:
  323. summary["noise"] = {
  324. "skipped": True,
  325. "reason": f"Noise analysis failed: {exc}",
  326. }
  327. summary["noise_accuracy_uncertainty"] = {
  328. "skipped": True,
  329. "reason": f"Noise relationship analysis failed: {exc}",
  330. }
  331. stage_bar.update(2)
  332. finally:
  333. stage_bar.close()
  334. report_path = _write_backend_plot_report(backend=backend, out_dir=out_dir)
  335. summary["plots_report"] = str(report_path)
  336. write_json(out_dir / "backend_summary.json", summary)
  337. return summary
  338. def main() -> None:
  339. args = _parse_args()
  340. analysis_dir = Path(__file__).resolve().parent
  341. paths = init_runtime_paths(analysis_dir=analysis_dir, run_name=args.run_name)
  342. config = load_config(paths.root_dir)
  343. _print_device_info(config)
  344. clinical_df = load_clinical_table(config=config, root_dir=paths.root_dir)
  345. manifest: dict[str, Any] = {
  346. "run_dir": str(paths.run_dir),
  347. "output_root": str(paths.output_root),
  348. "mode": (
  349. "dataset_summary_only"
  350. if bool(args.dataset_summary_only)
  351. else (
  352. "longitudinal_breakdown_only"
  353. if bool(args.longitudinal_breakdown_only)
  354. else (
  355. "noise_correlation_only"
  356. if bool(args.noise_correlation_only)
  357. else "full"
  358. )
  359. )
  360. ),
  361. "positive_class_index": DEFAULT_POSITIVE_CLASS_INDEX,
  362. "threshold_sweep": {
  363. "values": [float(v) for v in threshold_grid().tolist()],
  364. },
  365. "calibration_bins": DEFAULT_CALIBRATION_BINS,
  366. "noise_factors": noise_factor_grid(),
  367. "bayesian_mc_passes": DEFAULT_BAYESIAN_MC_PASSES,
  368. "decision_threshold": DEFAULT_DECISION_THRESHOLD,
  369. "backends": {},
  370. }
  371. if args.dataset_summary_only:
  372. manifest["dataset_summary"] = run_dataset_summary(
  373. config=config,
  374. root_dir=paths.root_dir,
  375. output_dir=paths.run_dir,
  376. positive_class_index=DEFAULT_POSITIVE_CLASS_INDEX,
  377. )
  378. write_json(paths.run_dir / "run_manifest.json", manifest)
  379. print(f"Dataset summary complete. Results saved to {paths.run_dir}")
  380. return
  381. backend_iter = tqdm(args.backend, desc="Backends", unit="backend")
  382. for backend in backend_iter:
  383. out_dir = backend_dir(paths, backend)
  384. backend_iter.set_postfix_str(backend)
  385. if args.longitudinal_breakdown_only:
  386. manifest["backends"][backend] = _run_longitudinal_breakdown_only(
  387. config=config,
  388. backend=backend,
  389. clinical_df=clinical_df,
  390. out_dir=out_dir,
  391. )
  392. elif args.noise_correlation_only:
  393. manifest["backends"][backend] = _run_noise_correlation_only(
  394. backend=backend,
  395. out_dir=out_dir,
  396. )
  397. else:
  398. manifest["backends"][backend] = _run_backend(
  399. config=config,
  400. root_dir=paths.root_dir,
  401. backend=backend,
  402. clinical_df=clinical_df,
  403. skip_noise=bool(args.skip_noise),
  404. out_dir=out_dir,
  405. )
  406. write_json(paths.run_dir / "run_manifest.json", manifest)
  407. print(f"Analysis complete. Results saved to {paths.run_dir}")
  408. if __name__ == "__main__":
  409. main()