Browse Source

Implemented analysis section - coverage, curve fit, noise

Nicholas Schense 1 day ago
parent
commit
30ac86b93a

+ 891 - 0
alnn_rewrite.log

@@ -4843,3 +4843,894 @@ RuntimeError: Error(s) in loading state_dict for CNN3D:
 2026-07-15 10:24:47,259 - INFO - [Analyze Evaluations] Task completed.
 2026-07-15 10:24:47,261 - INFO - [SYSTEM] All tasks finished successfully!
 2026-07-15 11:53:52,291 - INFO - [SYSTEM] Application initialized and ready.
+2026-07-26 19:05:57,057 - INFO - [SYSTEM] Application initialized and ready.
+2026-07-26 19:05:57,058 - INFO - [SYSTEM] Successfully loaded config from ./outputs/exp1/config.toml
+2026-07-26 19:06:10,314 - INFO - [SYSTEM] Initiating Scenario: 2. Load, Evaluate (test+val), Noise, & Analyze
+2026-07-26 19:06:10,328 - INFO - [SYSTEM] Starting Task 1/10: Load Image and ADNIMERGE
+2026-07-26 19:06:10,463 - INFO - [Load Image and ADNIMERGE] Seeded RNGs with base seed 42
+2026-07-26 19:06:10,469 - INFO - [Load Image and ADNIMERGE] Loading Files
+2026-07-26 19:06:15,619 - INFO - [Load Image and ADNIMERGE] Dataloaders initalized
+2026-07-26 19:06:15,625 - INFO - [Load Image and ADNIMERGE] Task completed.
+2026-07-26 19:06:15,627 - INFO - [SYSTEM] Starting Task 2/10: Load Saved Models
+2026-07-26 19:06:15,628 - INFO - [Load Saved Models] Found 30 normal model(s) in normal_models/.
+2026-07-26 19:06:15,629 - INFO - [Load Saved Models] Found 30 bayesian model(s) in bayesian_models/.
+2026-07-26 19:06:15,630 - INFO - [Load Saved Models] Task completed.
+2026-07-26 19:06:15,631 - INFO - [SYSTEM] Starting Task 3/10: Evaluate Regular Models
+2026-07-26 19:06:15,897 - INFO - [Evaluate Regular Models] Evaluating 30 normal model(s) on 97 test samples, 1 noise level(s), 1 MC pass(es) each.
+2026-07-26 19:06:15,904 - INFO - [Evaluate Regular Models] Loading normal model 1/30: model_1.pt
+2026-07-26 19:06:17,346 - INFO - [Evaluate Regular Models] Loading normal model 2/30: model_2.pt
+2026-07-26 19:06:18,553 - INFO - [Evaluate Regular Models] Loading normal model 3/30: model_3.pt
+2026-07-26 19:06:19,746 - INFO - [Evaluate Regular Models] Loading normal model 4/30: model_4.pt
+2026-07-26 19:06:20,937 - INFO - [Evaluate Regular Models] Loading normal model 5/30: model_5.pt
+2026-07-26 19:06:22,122 - INFO - [Evaluate Regular Models] Loading normal model 6/30: model_6.pt
+2026-07-26 19:06:23,317 - INFO - [Evaluate Regular Models] Loading normal model 7/30: model_7.pt
+2026-07-26 19:06:24,506 - INFO - [Evaluate Regular Models] Loading normal model 8/30: model_8.pt
+2026-07-26 19:06:25,691 - INFO - [Evaluate Regular Models] Loading normal model 9/30: model_9.pt
+2026-07-26 19:06:26,875 - INFO - [Evaluate Regular Models] Loading normal model 10/30: model_10.pt
+2026-07-26 19:06:28,060 - INFO - [Evaluate Regular Models] Loading normal model 11/30: model_11.pt
+2026-07-26 19:06:29,249 - INFO - [Evaluate Regular Models] Loading normal model 12/30: model_12.pt
+2026-07-26 19:06:30,443 - INFO - [Evaluate Regular Models] Loading normal model 13/30: model_13.pt
+2026-07-26 19:06:31,632 - INFO - [Evaluate Regular Models] Loading normal model 14/30: model_14.pt
+2026-07-26 19:06:32,825 - INFO - [Evaluate Regular Models] Loading normal model 15/30: model_15.pt
+2026-07-26 19:06:34,010 - INFO - [Evaluate Regular Models] Loading normal model 16/30: model_16.pt
+2026-07-26 19:06:35,173 - INFO - [Evaluate Regular Models] Loading normal model 17/30: model_17.pt
+2026-07-26 19:06:36,369 - INFO - [Evaluate Regular Models] Loading normal model 18/30: model_18.pt
+2026-07-26 19:06:37,543 - INFO - [Evaluate Regular Models] Loading normal model 19/30: model_19.pt
+2026-07-26 19:06:38,732 - INFO - [Evaluate Regular Models] Loading normal model 20/30: model_20.pt
+2026-07-26 19:06:39,929 - INFO - [Evaluate Regular Models] Loading normal model 21/30: model_21.pt
+2026-07-26 19:06:41,115 - INFO - [Evaluate Regular Models] Loading normal model 22/30: model_22.pt
+2026-07-26 19:06:42,305 - INFO - [Evaluate Regular Models] Loading normal model 23/30: model_23.pt
+2026-07-26 19:06:43,500 - INFO - [Evaluate Regular Models] Loading normal model 24/30: model_24.pt
+2026-07-26 19:06:44,697 - INFO - [Evaluate Regular Models] Loading normal model 25/30: model_25.pt
+2026-07-26 19:06:45,896 - INFO - [Evaluate Regular Models] Loading normal model 26/30: model_26.pt
+2026-07-26 19:06:47,110 - INFO - [Evaluate Regular Models] Loading normal model 27/30: model_27.pt
+2026-07-26 19:06:48,305 - INFO - [Evaluate Regular Models] Loading normal model 28/30: model_28.pt
+2026-07-26 19:06:49,501 - INFO - [Evaluate Regular Models] Loading normal model 29/30: model_29.pt
+2026-07-26 19:06:50,699 - INFO - [Evaluate Regular Models] Loading normal model 30/30: model_30.pt
+2026-07-26 19:06:51,938 - INFO - [Evaluate Regular Models] Saved normal test evaluation to outputs/exp1/evaluations/normal.nc (models=30, samples=97, noise_levels=1).
+2026-07-26 19:06:51,939 - INFO - [Evaluate Regular Models] Task completed.
+2026-07-26 19:06:51,941 - INFO - [SYSTEM] Starting Task 4/10: Evaluate Bayesian Models
+2026-07-26 19:06:52,018 - INFO - [Evaluate Bayesian Models] Evaluating 30 bayesian model(s) on 97 test samples, 1 noise level(s), 30 MC pass(es) each.
+2026-07-26 19:06:52,025 - INFO - [Evaluate Bayesian Models] Loading bayesian model 1/30: model_1.pt
+2026-07-26 19:07:20,836 - INFO - [Evaluate Bayesian Models] Loading bayesian model 2/30: model_2.pt
+2026-07-26 19:07:49,761 - INFO - [Evaluate Bayesian Models] Loading bayesian model 3/30: model_3.pt
+2026-07-26 19:08:18,725 - INFO - [Evaluate Bayesian Models] Loading bayesian model 4/30: model_4.pt
+2026-07-26 19:08:47,655 - INFO - [Evaluate Bayesian Models] Loading bayesian model 5/30: model_5.pt
+2026-07-26 19:09:16,588 - INFO - [Evaluate Bayesian Models] Loading bayesian model 6/30: model_6.pt
+2026-07-26 19:09:45,521 - INFO - [Evaluate Bayesian Models] Loading bayesian model 7/30: model_7.pt
+2026-07-26 19:10:14,520 - INFO - [Evaluate Bayesian Models] Loading bayesian model 8/30: model_8.pt
+2026-07-26 19:10:43,509 - INFO - [Evaluate Bayesian Models] Loading bayesian model 9/30: model_9.pt
+2026-07-26 19:11:12,503 - INFO - [Evaluate Bayesian Models] Loading bayesian model 10/30: model_10.pt
+2026-07-26 19:11:41,451 - INFO - [Evaluate Bayesian Models] Loading bayesian model 11/30: model_11.pt
+2026-07-26 19:12:10,455 - INFO - [Evaluate Bayesian Models] Loading bayesian model 12/30: model_12.pt
+2026-07-26 19:12:39,453 - INFO - [Evaluate Bayesian Models] Loading bayesian model 13/30: model_13.pt
+2026-07-26 19:13:08,458 - INFO - [Evaluate Bayesian Models] Loading bayesian model 14/30: model_14.pt
+2026-07-26 19:13:37,438 - INFO - [Evaluate Bayesian Models] Loading bayesian model 15/30: model_15.pt
+2026-07-26 19:14:06,437 - INFO - [Evaluate Bayesian Models] Loading bayesian model 16/30: model_16.pt
+2026-07-26 19:14:35,478 - INFO - [Evaluate Bayesian Models] Loading bayesian model 17/30: model_17.pt
+2026-07-26 19:15:04,534 - INFO - [Evaluate Bayesian Models] Loading bayesian model 18/30: model_18.pt
+2026-07-26 19:15:33,620 - INFO - [Evaluate Bayesian Models] Loading bayesian model 19/30: model_19.pt
+2026-07-26 19:16:02,721 - INFO - [Evaluate Bayesian Models] Loading bayesian model 20/30: model_20.pt
+2026-07-26 19:16:31,815 - INFO - [Evaluate Bayesian Models] Loading bayesian model 21/30: model_21.pt
+2026-07-26 19:17:00,869 - INFO - [Evaluate Bayesian Models] Loading bayesian model 22/30: model_22.pt
+2026-07-26 19:17:30,007 - INFO - [Evaluate Bayesian Models] Loading bayesian model 23/30: model_23.pt
+2026-07-26 19:17:59,099 - INFO - [Evaluate Bayesian Models] Loading bayesian model 24/30: model_24.pt
+2026-07-26 19:18:28,155 - INFO - [Evaluate Bayesian Models] Loading bayesian model 25/30: model_25.pt
+2026-07-26 19:18:57,232 - INFO - [Evaluate Bayesian Models] Loading bayesian model 26/30: model_26.pt
+2026-07-26 19:19:26,222 - INFO - [Evaluate Bayesian Models] Loading bayesian model 27/30: model_27.pt
+2026-07-26 19:19:55,221 - INFO - [Evaluate Bayesian Models] Loading bayesian model 28/30: model_28.pt
+2026-07-26 19:20:24,279 - INFO - [Evaluate Bayesian Models] Loading bayesian model 29/30: model_29.pt
+2026-07-26 19:20:53,366 - INFO - [Evaluate Bayesian Models] Loading bayesian model 30/30: model_30.pt
+2026-07-26 19:21:22,526 - INFO - [Evaluate Bayesian Models] Saved bayesian test evaluation to outputs/exp1/evaluations/bayesian.nc (models=30, samples=97, noise_levels=1).
+2026-07-26 19:21:22,538 - INFO - [Evaluate Bayesian Models] Task completed.
+2026-07-26 19:21:22,543 - INFO - [SYSTEM] Starting Task 5/10: Evaluate Regular Models (Validation)
+2026-07-26 19:21:22,654 - INFO - [Evaluate Regular Models (Validation)] Evaluating 30 normal model(s) on 149 val samples, 1 noise level(s), 1 MC pass(es) each.
+2026-07-26 19:21:22,661 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 1/30: model_1.pt
+2026-07-26 19:21:24,315 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 2/30: model_2.pt
+2026-07-26 19:21:25,974 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 3/30: model_3.pt
+2026-07-26 19:21:27,633 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 4/30: model_4.pt
+2026-07-26 19:21:29,286 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 5/30: model_5.pt
+2026-07-26 19:21:30,932 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 6/30: model_6.pt
+2026-07-26 19:21:32,586 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 7/30: model_7.pt
+2026-07-26 19:21:34,236 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 8/30: model_8.pt
+2026-07-26 19:21:35,887 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 9/30: model_9.pt
+2026-07-26 19:21:37,532 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 10/30: model_10.pt
+2026-07-26 19:21:39,185 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 11/30: model_11.pt
+2026-07-26 19:21:40,830 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 12/30: model_12.pt
+2026-07-26 19:21:42,484 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 13/30: model_13.pt
+2026-07-26 19:21:44,143 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 14/30: model_14.pt
+2026-07-26 19:21:45,801 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 15/30: model_15.pt
+2026-07-26 19:21:47,461 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 16/30: model_16.pt
+2026-07-26 19:21:49,118 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 17/30: model_17.pt
+2026-07-26 19:21:50,754 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 18/30: model_18.pt
+2026-07-26 19:21:52,399 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 19/30: model_19.pt
+2026-07-26 19:21:54,050 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 20/30: model_20.pt
+2026-07-26 19:21:55,709 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 21/30: model_21.pt
+2026-07-26 19:21:57,349 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 22/30: model_22.pt
+2026-07-26 19:21:58,999 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 23/30: model_23.pt
+2026-07-26 19:22:00,649 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 24/30: model_24.pt
+2026-07-26 19:22:02,305 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 25/30: model_25.pt
+2026-07-26 19:22:03,957 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 26/30: model_26.pt
+2026-07-26 19:22:05,611 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 27/30: model_27.pt
+2026-07-26 19:22:07,244 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 28/30: model_28.pt
+2026-07-26 19:22:08,888 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 29/30: model_29.pt
+2026-07-26 19:22:10,533 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 30/30: model_30.pt
+2026-07-26 19:22:12,195 - INFO - [Evaluate Regular Models (Validation)] Saved normal val evaluation to outputs/exp1/evaluations/val_normal.nc (models=30, samples=149, noise_levels=1).
+2026-07-26 19:22:12,196 - INFO - [Evaluate Regular Models (Validation)] Task completed.
+2026-07-26 19:22:12,205 - INFO - [SYSTEM] Starting Task 6/10: Evaluate Bayesian Models (Validation)
+2026-07-26 19:22:12,318 - INFO - [Evaluate Bayesian Models (Validation)] Evaluating 30 bayesian model(s) on 149 val samples, 1 noise level(s), 30 MC pass(es) each.
+2026-07-26 19:22:12,326 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 1/30: model_1.pt
+2026-07-26 19:22:56,832 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 2/30: model_2.pt
+2026-07-26 19:23:44,487 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 3/30: model_3.pt
+2026-07-26 19:36:39,616 - ERROR - [SYSTEM] Stop requested! Terminating gracefully...
+2026-07-26 19:36:39,930 - ERROR - [SYSTEM] STOPPED: Pipeline execution stopped by user.
+2026-07-26 19:38:26,304 - INFO - [SYSTEM] Application initialized and ready.
+2026-07-26 19:38:26,305 - INFO - [SYSTEM] Successfully loaded config from ./outputs/exp1/config.toml
+2026-07-26 19:38:32,149 - INFO - [SYSTEM] Initiating Scenario: 2. Load, Evaluate (test+val), Noise, & Analyze
+2026-07-26 19:38:32,163 - INFO - [SYSTEM] Starting Task 1/10: Load Image and ADNIMERGE
+2026-07-26 19:38:32,307 - INFO - [Load Image and ADNIMERGE] Seeded RNGs with base seed 42
+2026-07-26 19:38:32,311 - INFO - [Load Image and ADNIMERGE] Loading Files
+2026-07-26 19:38:36,636 - INFO - [Load Image and ADNIMERGE] Dataloaders initalized
+2026-07-26 19:38:36,637 - INFO - [Load Image and ADNIMERGE] Task completed.
+2026-07-26 19:38:36,643 - INFO - [SYSTEM] Starting Task 2/10: Load Saved Models
+2026-07-26 19:38:36,645 - INFO - [Load Saved Models] Found 30 normal model(s) in normal_models/.
+2026-07-26 19:38:36,646 - INFO - [Load Saved Models] Found 30 bayesian model(s) in bayesian_models/.
+2026-07-26 19:38:36,647 - INFO - [Load Saved Models] Task completed.
+2026-07-26 19:38:36,648 - INFO - [SYSTEM] Starting Task 3/10: Evaluate Regular Models
+2026-07-26 19:38:36,919 - INFO - [Evaluate Regular Models] Evaluating 30 normal model(s) on 97 test samples, 1 noise level(s), 1 MC pass(es) each.
+2026-07-26 19:38:36,926 - INFO - [Evaluate Regular Models] Loading normal model 1/30: model_1.pt
+2026-07-26 19:38:38,362 - INFO - [Evaluate Regular Models] Loading normal model 2/30: model_2.pt
+2026-07-26 19:38:39,580 - INFO - [Evaluate Regular Models] Loading normal model 3/30: model_3.pt
+2026-07-26 19:38:40,778 - INFO - [Evaluate Regular Models] Loading normal model 4/30: model_4.pt
+2026-07-26 19:38:41,971 - INFO - [Evaluate Regular Models] Loading normal model 5/30: model_5.pt
+2026-07-26 19:38:43,151 - INFO - [Evaluate Regular Models] Loading normal model 6/30: model_6.pt
+2026-07-26 19:38:44,350 - INFO - [Evaluate Regular Models] Loading normal model 7/30: model_7.pt
+2026-07-26 19:38:45,555 - INFO - [Evaluate Regular Models] Loading normal model 8/30: model_8.pt
+2026-07-26 19:38:46,752 - INFO - [Evaluate Regular Models] Loading normal model 9/30: model_9.pt
+2026-07-26 19:38:47,954 - INFO - [Evaluate Regular Models] Loading normal model 10/30: model_10.pt
+2026-07-26 19:38:49,140 - INFO - [Evaluate Regular Models] Loading normal model 11/30: model_11.pt
+2026-07-26 19:38:50,334 - INFO - [Evaluate Regular Models] Loading normal model 12/30: model_12.pt
+2026-07-26 19:38:51,524 - INFO - [Evaluate Regular Models] Loading normal model 13/30: model_13.pt
+2026-07-26 19:38:52,718 - INFO - [Evaluate Regular Models] Loading normal model 14/30: model_14.pt
+2026-07-26 19:38:53,920 - INFO - [Evaluate Regular Models] Loading normal model 15/30: model_15.pt
+2026-07-26 19:38:55,118 - INFO - [Evaluate Regular Models] Loading normal model 16/30: model_16.pt
+2026-07-26 19:38:56,320 - INFO - [Evaluate Regular Models] Loading normal model 17/30: model_17.pt
+2026-07-26 19:38:57,517 - INFO - [Evaluate Regular Models] Loading normal model 18/30: model_18.pt
+2026-07-26 19:38:58,710 - INFO - [Evaluate Regular Models] Loading normal model 19/30: model_19.pt
+2026-07-26 19:38:59,910 - INFO - [Evaluate Regular Models] Loading normal model 20/30: model_20.pt
+2026-07-26 19:39:01,102 - INFO - [Evaluate Regular Models] Loading normal model 21/30: model_21.pt
+2026-07-26 19:39:02,296 - INFO - [Evaluate Regular Models] Loading normal model 22/30: model_22.pt
+2026-07-26 19:39:03,497 - INFO - [Evaluate Regular Models] Loading normal model 23/30: model_23.pt
+2026-07-26 19:39:04,707 - INFO - [Evaluate Regular Models] Loading normal model 24/30: model_24.pt
+2026-07-26 19:39:05,902 - INFO - [Evaluate Regular Models] Loading normal model 25/30: model_25.pt
+2026-07-26 19:39:07,108 - INFO - [Evaluate Regular Models] Loading normal model 26/30: model_26.pt
+2026-07-26 19:39:08,312 - INFO - [Evaluate Regular Models] Loading normal model 27/30: model_27.pt
+2026-07-26 19:39:09,510 - INFO - [Evaluate Regular Models] Loading normal model 28/30: model_28.pt
+2026-07-26 19:39:10,702 - INFO - [Evaluate Regular Models] Loading normal model 29/30: model_29.pt
+2026-07-26 19:39:11,904 - INFO - [Evaluate Regular Models] Loading normal model 30/30: model_30.pt
+2026-07-26 19:39:13,119 - INFO - [Evaluate Regular Models] Saved normal test evaluation to outputs/exp1/evaluations/normal.nc (models=30, samples=97, noise_levels=1).
+2026-07-26 19:39:13,120 - INFO - [Evaluate Regular Models] Task completed.
+2026-07-26 19:39:13,121 - INFO - [SYSTEM] Starting Task 4/10: Evaluate Bayesian Models
+2026-07-26 19:39:13,200 - INFO - [Evaluate Bayesian Models] Evaluating 30 bayesian model(s) on 97 test samples, 1 noise level(s), 20 MC pass(es) each.
+2026-07-26 19:39:13,208 - INFO - [Evaluate Bayesian Models] Loading bayesian model 1/30: model_1.pt
+2026-07-26 19:39:32,654 - INFO - [Evaluate Bayesian Models] Loading bayesian model 2/30: model_2.pt
+2026-07-26 19:39:52,160 - INFO - [Evaluate Bayesian Models] Loading bayesian model 3/30: model_3.pt
+2026-07-26 19:40:11,703 - INFO - [Evaluate Bayesian Models] Loading bayesian model 4/30: model_4.pt
+2026-07-26 19:40:31,162 - INFO - [Evaluate Bayesian Models] Loading bayesian model 5/30: model_5.pt
+2026-07-26 19:40:50,650 - INFO - [Evaluate Bayesian Models] Loading bayesian model 6/30: model_6.pt
+2026-07-26 19:41:10,087 - INFO - [Evaluate Bayesian Models] Loading bayesian model 7/30: model_7.pt
+2026-07-26 19:41:29,551 - INFO - [Evaluate Bayesian Models] Loading bayesian model 8/30: model_8.pt
+2026-07-26 19:41:49,042 - INFO - [Evaluate Bayesian Models] Loading bayesian model 9/30: model_9.pt
+2026-07-26 19:42:08,547 - INFO - [Evaluate Bayesian Models] Loading bayesian model 10/30: model_10.pt
+2026-07-26 19:42:28,069 - INFO - [Evaluate Bayesian Models] Loading bayesian model 11/30: model_11.pt
+2026-07-26 19:42:47,656 - INFO - [Evaluate Bayesian Models] Loading bayesian model 12/30: model_12.pt
+2026-07-26 19:43:07,207 - INFO - [Evaluate Bayesian Models] Loading bayesian model 13/30: model_13.pt
+2026-07-26 19:43:26,738 - INFO - [Evaluate Bayesian Models] Loading bayesian model 14/30: model_14.pt
+2026-07-26 19:43:46,257 - INFO - [Evaluate Bayesian Models] Loading bayesian model 15/30: model_15.pt
+2026-07-26 19:44:05,766 - INFO - [Evaluate Bayesian Models] Loading bayesian model 16/30: model_16.pt
+2026-07-26 19:44:25,258 - INFO - [Evaluate Bayesian Models] Loading bayesian model 17/30: model_17.pt
+2026-07-26 19:44:44,751 - INFO - [Evaluate Bayesian Models] Loading bayesian model 18/30: model_18.pt
+2026-07-26 19:45:04,257 - INFO - [Evaluate Bayesian Models] Loading bayesian model 19/30: model_19.pt
+2026-07-26 19:45:23,755 - INFO - [Evaluate Bayesian Models] Loading bayesian model 20/30: model_20.pt
+2026-07-26 19:45:43,267 - INFO - [Evaluate Bayesian Models] Loading bayesian model 21/30: model_21.pt
+2026-07-26 19:46:02,794 - INFO - [Evaluate Bayesian Models] Loading bayesian model 22/30: model_22.pt
+2026-07-26 19:46:22,251 - INFO - [Evaluate Bayesian Models] Loading bayesian model 23/30: model_23.pt
+2026-07-26 19:46:41,741 - INFO - [Evaluate Bayesian Models] Loading bayesian model 24/30: model_24.pt
+2026-07-26 19:47:01,218 - INFO - [Evaluate Bayesian Models] Loading bayesian model 25/30: model_25.pt
+2026-07-26 19:47:20,707 - INFO - [Evaluate Bayesian Models] Loading bayesian model 26/30: model_26.pt
+2026-07-26 19:47:40,151 - INFO - [Evaluate Bayesian Models] Loading bayesian model 27/30: model_27.pt
+2026-07-26 19:47:59,609 - INFO - [Evaluate Bayesian Models] Loading bayesian model 28/30: model_28.pt
+2026-07-26 19:48:19,085 - INFO - [Evaluate Bayesian Models] Loading bayesian model 29/30: model_29.pt
+2026-07-26 19:48:38,609 - INFO - [Evaluate Bayesian Models] Loading bayesian model 30/30: model_30.pt
+2026-07-26 19:48:58,670 - INFO - [Evaluate Bayesian Models] Saved bayesian test evaluation to outputs/exp1/evaluations/bayesian.nc (models=30, samples=97, noise_levels=1).
+2026-07-26 19:48:58,674 - INFO - [Evaluate Bayesian Models] Task completed.
+2026-07-26 19:48:58,676 - INFO - [SYSTEM] Starting Task 5/10: Evaluate Regular Models (Validation)
+2026-07-26 19:48:58,791 - INFO - [Evaluate Regular Models (Validation)] Evaluating 30 normal model(s) on 149 val samples, 1 noise level(s), 1 MC pass(es) each.
+2026-07-26 19:48:58,798 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 1/30: model_1.pt
+2026-07-26 19:49:00,741 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 2/30: model_2.pt
+2026-07-26 19:49:02,506 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 3/30: model_3.pt
+2026-07-26 19:49:04,275 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 4/30: model_4.pt
+2026-07-26 19:49:06,051 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 5/30: model_5.pt
+2026-07-26 19:49:07,818 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 6/30: model_6.pt
+2026-07-26 19:49:09,590 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 7/30: model_7.pt
+2026-07-26 19:49:11,351 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 8/30: model_8.pt
+2026-07-26 19:49:13,113 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 9/30: model_9.pt
+2026-07-26 19:49:14,868 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 10/30: model_10.pt
+2026-07-26 19:49:16,639 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 11/30: model_11.pt
+2026-07-26 19:49:18,408 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 12/30: model_12.pt
+2026-07-26 19:49:20,175 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 13/30: model_13.pt
+2026-07-26 19:49:21,959 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 14/30: model_14.pt
+2026-07-26 19:49:23,721 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 15/30: model_15.pt
+2026-07-26 19:49:25,496 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 16/30: model_16.pt
+2026-07-26 19:49:27,261 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 17/30: model_17.pt
+2026-07-26 19:49:29,026 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 18/30: model_18.pt
+2026-07-26 19:49:30,792 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 19/30: model_19.pt
+2026-07-26 19:49:32,558 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 20/30: model_20.pt
+2026-07-26 19:49:34,321 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 21/30: model_21.pt
+2026-07-26 19:49:36,091 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 22/30: model_22.pt
+2026-07-26 19:49:37,854 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 23/30: model_23.pt
+2026-07-26 19:49:39,617 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 24/30: model_24.pt
+2026-07-26 19:49:41,388 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 25/30: model_25.pt
+2026-07-26 19:49:43,152 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 26/30: model_26.pt
+2026-07-26 19:49:44,922 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 27/30: model_27.pt
+2026-07-26 19:49:46,680 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 28/30: model_28.pt
+2026-07-26 19:49:48,447 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 29/30: model_29.pt
+2026-07-26 19:49:50,202 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 30/30: model_30.pt
+2026-07-26 19:49:51,988 - INFO - [Evaluate Regular Models (Validation)] Saved normal val evaluation to outputs/exp1/evaluations/val_normal.nc (models=30, samples=149, noise_levels=1).
+2026-07-26 19:49:51,989 - INFO - [Evaluate Regular Models (Validation)] Task completed.
+2026-07-26 19:49:51,991 - INFO - [SYSTEM] Starting Task 6/10: Evaluate Bayesian Models (Validation)
+2026-07-26 19:49:52,112 - INFO - [Evaluate Bayesian Models (Validation)] Evaluating 30 bayesian model(s) on 149 val samples, 1 noise level(s), 20 MC pass(es) each.
+2026-07-26 19:49:52,122 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 1/30: model_1.pt
+2026-07-26 20:09:01,077 - INFO - [SYSTEM] Application initialized and ready.
+2026-07-26 20:09:01,078 - INFO - [SYSTEM] Successfully loaded config from ./outputs/exp1/config.toml
+2026-07-26 20:09:10,133 - INFO - [SYSTEM] Initiating Scenario: 2. Load, Evaluate (test+val), Noise, & Analyze
+2026-07-26 20:09:10,147 - INFO - [SYSTEM] Starting Task 1/10: Load Image and ADNIMERGE
+2026-07-26 20:09:10,299 - INFO - [Load Image and ADNIMERGE] Seeded RNGs with base seed 42
+2026-07-26 20:09:10,299 - INFO - [Load Image and ADNIMERGE] Loading Files
+2026-07-26 20:09:13,745 - INFO - [Load Image and ADNIMERGE] Dataloaders initalized
+2026-07-26 20:09:13,750 - INFO - [Load Image and ADNIMERGE] Task completed.
+2026-07-26 20:09:13,752 - INFO - [SYSTEM] Starting Task 2/10: Load Saved Models
+2026-07-26 20:09:13,753 - INFO - [Load Saved Models] Found 30 normal model(s) in normal_models/.
+2026-07-26 20:09:13,754 - INFO - [Load Saved Models] Found 30 bayesian model(s) in bayesian_models/.
+2026-07-26 20:09:13,755 - INFO - [Load Saved Models] Task completed.
+2026-07-26 20:09:13,757 - INFO - [SYSTEM] Starting Task 3/10: Evaluate Regular Models
+2026-07-26 20:09:14,021 - INFO - [Evaluate Regular Models] Evaluating 30 normal model(s) on 97 test samples, 1 noise level(s), 1 MC pass(es) each.
+2026-07-26 20:09:14,028 - INFO - [Evaluate Regular Models] Loading normal model 1/30: model_1.pt
+2026-07-26 20:09:15,384 - INFO - [Evaluate Regular Models] Loading normal model 2/30: model_2.pt
+2026-07-26 20:09:16,597 - INFO - [Evaluate Regular Models] Loading normal model 3/30: model_3.pt
+2026-07-26 20:09:17,829 - INFO - [Evaluate Regular Models] Loading normal model 4/30: model_4.pt
+2026-07-26 20:09:19,047 - INFO - [Evaluate Regular Models] Loading normal model 5/30: model_5.pt
+2026-07-26 20:09:20,255 - INFO - [Evaluate Regular Models] Loading normal model 6/30: model_6.pt
+2026-07-26 20:09:21,471 - INFO - [Evaluate Regular Models] Loading normal model 7/30: model_7.pt
+2026-07-26 20:09:22,697 - INFO - [Evaluate Regular Models] Loading normal model 8/30: model_8.pt
+2026-07-26 20:09:23,905 - INFO - [Evaluate Regular Models] Loading normal model 9/30: model_9.pt
+2026-07-26 20:09:25,126 - INFO - [Evaluate Regular Models] Loading normal model 10/30: model_10.pt
+2026-07-26 20:09:26,338 - INFO - [Evaluate Regular Models] Loading normal model 11/30: model_11.pt
+2026-07-26 20:09:27,570 - INFO - [Evaluate Regular Models] Loading normal model 12/30: model_12.pt
+2026-07-26 20:09:28,783 - INFO - [Evaluate Regular Models] Loading normal model 13/30: model_13.pt
+2026-07-26 20:09:30,001 - INFO - [Evaluate Regular Models] Loading normal model 14/30: model_14.pt
+2026-07-26 20:09:31,203 - INFO - [Evaluate Regular Models] Loading normal model 15/30: model_15.pt
+2026-07-26 20:09:32,409 - INFO - [Evaluate Regular Models] Loading normal model 16/30: model_16.pt
+2026-07-26 20:09:33,609 - INFO - [Evaluate Regular Models] Loading normal model 17/30: model_17.pt
+2026-07-26 20:09:34,809 - INFO - [Evaluate Regular Models] Loading normal model 18/30: model_18.pt
+2026-07-26 20:09:36,016 - INFO - [Evaluate Regular Models] Loading normal model 19/30: model_19.pt
+2026-07-26 20:09:37,209 - INFO - [Evaluate Regular Models] Loading normal model 20/30: model_20.pt
+2026-07-26 20:09:38,393 - INFO - [Evaluate Regular Models] Loading normal model 21/30: model_21.pt
+2026-07-26 20:09:39,579 - INFO - [Evaluate Regular Models] Loading normal model 22/30: model_22.pt
+2026-07-26 20:09:40,778 - INFO - [Evaluate Regular Models] Loading normal model 23/30: model_23.pt
+2026-07-26 20:09:41,995 - INFO - [Evaluate Regular Models] Loading normal model 24/30: model_24.pt
+2026-07-26 20:09:43,182 - INFO - [Evaluate Regular Models] Loading normal model 25/30: model_25.pt
+2026-07-26 20:09:44,375 - INFO - [Evaluate Regular Models] Loading normal model 26/30: model_26.pt
+2026-07-26 20:09:45,566 - INFO - [Evaluate Regular Models] Loading normal model 27/30: model_27.pt
+2026-07-26 20:09:46,754 - INFO - [Evaluate Regular Models] Loading normal model 28/30: model_28.pt
+2026-07-26 20:09:47,929 - INFO - [Evaluate Regular Models] Loading normal model 29/30: model_29.pt
+2026-07-26 20:09:49,135 - INFO - [Evaluate Regular Models] Loading normal model 30/30: model_30.pt
+2026-07-26 20:09:50,356 - INFO - [Evaluate Regular Models] Saved normal test evaluation to outputs/exp1/evaluations/normal.nc (models=30, samples=97, noise_levels=1).
+2026-07-26 20:09:50,361 - INFO - [Evaluate Regular Models] Task completed.
+2026-07-26 20:09:50,363 - INFO - [SYSTEM] Starting Task 4/10: Evaluate Bayesian Models
+2026-07-26 20:09:50,436 - INFO - [Evaluate Bayesian Models] Evaluating 30 bayesian model(s) on 97 test samples, 1 noise level(s), 20 MC pass(es) each.
+2026-07-26 20:09:50,444 - INFO - [Evaluate Bayesian Models] Loading bayesian model 1/30: model_1.pt
+2026-07-26 20:10:09,943 - INFO - [Evaluate Bayesian Models] Loading bayesian model 2/30: model_2.pt
+2026-07-26 20:10:29,448 - INFO - [Evaluate Bayesian Models] Loading bayesian model 3/30: model_3.pt
+2026-07-26 20:10:48,953 - INFO - [Evaluate Bayesian Models] Loading bayesian model 4/30: model_4.pt
+2026-07-26 20:11:08,475 - INFO - [Evaluate Bayesian Models] Loading bayesian model 5/30: model_5.pt
+2026-07-26 20:11:28,045 - INFO - [Evaluate Bayesian Models] Loading bayesian model 6/30: model_6.pt
+2026-07-26 20:11:47,667 - INFO - [Evaluate Bayesian Models] Loading bayesian model 7/30: model_7.pt
+2026-07-26 20:12:07,261 - INFO - [Evaluate Bayesian Models] Loading bayesian model 8/30: model_8.pt
+2026-07-26 20:12:26,817 - INFO - [Evaluate Bayesian Models] Loading bayesian model 9/30: model_9.pt
+2026-07-26 20:12:46,392 - INFO - [Evaluate Bayesian Models] Loading bayesian model 10/30: model_10.pt
+2026-07-26 20:13:05,960 - INFO - [Evaluate Bayesian Models] Loading bayesian model 11/30: model_11.pt
+2026-07-26 20:13:25,560 - INFO - [Evaluate Bayesian Models] Loading bayesian model 12/30: model_12.pt
+2026-07-26 20:13:45,102 - INFO - [Evaluate Bayesian Models] Loading bayesian model 13/30: model_13.pt
+2026-07-26 20:14:04,728 - INFO - [Evaluate Bayesian Models] Loading bayesian model 14/30: model_14.pt
+2026-07-26 20:14:24,348 - INFO - [Evaluate Bayesian Models] Loading bayesian model 15/30: model_15.pt
+2026-07-26 20:14:44,001 - INFO - [Evaluate Bayesian Models] Loading bayesian model 16/30: model_16.pt
+2026-07-26 20:15:03,615 - INFO - [Evaluate Bayesian Models] Loading bayesian model 17/30: model_17.pt
+2026-07-26 20:15:23,233 - INFO - [Evaluate Bayesian Models] Loading bayesian model 18/30: model_18.pt
+2026-07-26 20:15:42,849 - INFO - [Evaluate Bayesian Models] Loading bayesian model 19/30: model_19.pt
+2026-07-26 20:16:02,515 - INFO - [Evaluate Bayesian Models] Loading bayesian model 20/30: model_20.pt
+2026-07-26 20:16:22,193 - INFO - [Evaluate Bayesian Models] Loading bayesian model 21/30: model_21.pt
+2026-07-26 20:16:41,978 - INFO - [Evaluate Bayesian Models] Loading bayesian model 22/30: model_22.pt
+2026-07-26 20:17:01,767 - INFO - [Evaluate Bayesian Models] Loading bayesian model 23/30: model_23.pt
+2026-07-26 20:17:21,532 - INFO - [Evaluate Bayesian Models] Loading bayesian model 24/30: model_24.pt
+2026-07-26 20:17:41,242 - INFO - [Evaluate Bayesian Models] Loading bayesian model 25/30: model_25.pt
+2026-07-26 20:18:00,902 - INFO - [Evaluate Bayesian Models] Loading bayesian model 26/30: model_26.pt
+2026-07-26 20:18:20,606 - INFO - [Evaluate Bayesian Models] Loading bayesian model 27/30: model_27.pt
+2026-07-26 20:18:40,279 - INFO - [Evaluate Bayesian Models] Loading bayesian model 28/30: model_28.pt
+2026-07-26 20:18:59,965 - INFO - [Evaluate Bayesian Models] Loading bayesian model 29/30: model_29.pt
+2026-07-26 20:19:19,738 - INFO - [Evaluate Bayesian Models] Loading bayesian model 30/30: model_30.pt
+2026-07-26 20:19:39,484 - INFO - [Evaluate Bayesian Models] Saved bayesian test evaluation to outputs/exp1/evaluations/bayesian.nc (models=30, samples=97, noise_levels=1).
+2026-07-26 20:19:39,485 - INFO - [Evaluate Bayesian Models] Task completed.
+2026-07-26 20:19:39,486 - INFO - [SYSTEM] Starting Task 5/10: Evaluate Regular Models (Validation)
+2026-07-26 20:19:39,600 - INFO - [Evaluate Regular Models (Validation)] Evaluating 30 normal model(s) on 149 val samples, 1 noise level(s), 1 MC pass(es) each.
+2026-07-26 20:19:39,607 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 1/30: model_1.pt
+2026-07-26 20:19:41,264 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 2/30: model_2.pt
+2026-07-26 20:19:42,920 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 3/30: model_3.pt
+2026-07-26 20:19:44,572 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 4/30: model_4.pt
+2026-07-26 20:19:46,234 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 5/30: model_5.pt
+2026-07-26 20:19:47,887 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 6/30: model_6.pt
+2026-07-26 20:19:49,537 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 7/30: model_7.pt
+2026-07-26 20:19:51,182 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 8/30: model_8.pt
+2026-07-26 20:19:52,835 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 9/30: model_9.pt
+2026-07-26 20:19:54,482 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 10/30: model_10.pt
+2026-07-26 20:19:56,136 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 11/30: model_11.pt
+2026-07-26 20:19:57,794 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 12/30: model_12.pt
+2026-07-26 20:19:59,443 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 13/30: model_13.pt
+2026-07-26 20:20:01,100 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 14/30: model_14.pt
+2026-07-26 20:20:02,748 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 15/30: model_15.pt
+2026-07-26 20:20:04,393 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 16/30: model_16.pt
+2026-07-26 20:20:06,050 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 17/30: model_17.pt
+2026-07-26 20:20:07,702 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 18/30: model_18.pt
+2026-07-26 20:20:09,347 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 19/30: model_19.pt
+2026-07-26 20:20:11,002 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 20/30: model_20.pt
+2026-07-26 20:20:12,652 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 21/30: model_21.pt
+2026-07-26 20:20:14,300 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 22/30: model_22.pt
+2026-07-26 20:20:15,947 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 23/30: model_23.pt
+2026-07-26 20:20:17,599 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 24/30: model_24.pt
+2026-07-26 20:20:19,255 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 25/30: model_25.pt
+2026-07-26 20:20:20,920 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 26/30: model_26.pt
+2026-07-26 20:20:22,565 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 27/30: model_27.pt
+2026-07-26 20:20:24,213 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 28/30: model_28.pt
+2026-07-26 20:20:25,866 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 29/30: model_29.pt
+2026-07-26 20:20:27,513 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 30/30: model_30.pt
+2026-07-26 20:20:29,180 - INFO - [Evaluate Regular Models (Validation)] Saved normal val evaluation to outputs/exp1/evaluations/val_normal.nc (models=30, samples=149, noise_levels=1).
+2026-07-26 20:20:29,181 - INFO - [Evaluate Regular Models (Validation)] Task completed.
+2026-07-26 20:20:29,183 - INFO - [SYSTEM] Starting Task 6/10: Evaluate Bayesian Models (Validation)
+2026-07-26 20:20:29,310 - INFO - [Evaluate Bayesian Models (Validation)] Evaluating 30 bayesian model(s) on 149 val samples, 1 noise level(s), 20 MC pass(es) each.
+2026-07-26 20:20:29,318 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 1/30: model_1.pt
+2026-07-26 20:20:58,893 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 2/30: model_2.pt
+2026-07-26 20:21:28,592 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 3/30: model_3.pt
+2026-07-26 20:21:58,338 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 4/30: model_4.pt
+2026-07-26 20:22:28,031 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 5/30: model_5.pt
+2026-07-26 20:22:57,765 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 6/30: model_6.pt
+2026-07-26 20:23:27,460 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 7/30: model_7.pt
+2026-07-26 20:23:57,211 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 8/30: model_8.pt
+2026-07-26 20:24:26,962 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 9/30: model_9.pt
+2026-07-26 20:24:56,680 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 10/30: model_10.pt
+2026-07-26 20:25:26,332 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 11/30: model_11.pt
+2026-07-26 20:25:56,002 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 12/30: model_12.pt
+2026-07-26 20:26:25,644 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 13/30: model_13.pt
+2026-07-26 20:26:55,361 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 14/30: model_14.pt
+2026-07-26 20:27:25,109 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 15/30: model_15.pt
+2026-07-26 20:27:54,829 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 16/30: model_16.pt
+2026-07-26 20:28:24,492 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 17/30: model_17.pt
+2026-07-26 20:28:54,143 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 18/30: model_18.pt
+2026-07-26 20:29:23,871 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 19/30: model_19.pt
+2026-07-26 20:29:53,536 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 20/30: model_20.pt
+2026-07-26 20:30:23,159 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 21/30: model_21.pt
+2026-07-26 20:30:52,809 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 22/30: model_22.pt
+2026-07-26 20:31:22,460 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 23/30: model_23.pt
+2026-07-26 20:31:52,100 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 24/30: model_24.pt
+2026-07-26 20:32:21,637 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 25/30: model_25.pt
+2026-07-26 20:32:51,255 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 26/30: model_26.pt
+2026-07-26 20:33:20,904 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 27/30: model_27.pt
+2026-07-26 20:33:50,542 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 28/30: model_28.pt
+2026-07-26 20:34:20,202 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 29/30: model_29.pt
+2026-07-26 20:34:49,853 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 30/30: model_30.pt
+2026-07-26 20:35:19,569 - INFO - [Evaluate Bayesian Models (Validation)] Saved bayesian val evaluation to outputs/exp1/evaluations/val_bayesian.nc (models=30, samples=149, noise_levels=1).
+2026-07-26 20:35:19,570 - INFO - [Evaluate Bayesian Models (Validation)] Task completed.
+2026-07-26 20:35:19,571 - INFO - [SYSTEM] Starting Task 7/10: Evaluate Models on Noised Data
+2026-07-26 20:35:19,654 - INFO - [Evaluate Models on Noised Data] Evaluating 30 normal model(s) on 97 test samples, 13 noise level(s), 1 MC pass(es) each.
+2026-07-26 20:35:19,662 - INFO - [Evaluate Models on Noised Data] Loading normal model 1/30: model_1.pt
+2026-07-26 20:35:32,269 - INFO - [Evaluate Models on Noised Data] Loading normal model 2/30: model_2.pt
+2026-07-26 20:35:44,897 - INFO - [Evaluate Models on Noised Data] Loading normal model 3/30: model_3.pt
+2026-07-26 20:35:57,489 - INFO - [Evaluate Models on Noised Data] Loading normal model 4/30: model_4.pt
+2026-07-26 20:36:10,127 - INFO - [Evaluate Models on Noised Data] Loading normal model 5/30: model_5.pt
+2026-07-26 20:36:22,750 - INFO - [Evaluate Models on Noised Data] Loading normal model 6/30: model_6.pt
+2026-07-26 20:36:35,343 - INFO - [Evaluate Models on Noised Data] Loading normal model 7/30: model_7.pt
+2026-07-26 20:36:47,937 - INFO - [Evaluate Models on Noised Data] Loading normal model 8/30: model_8.pt
+2026-07-26 20:37:00,570 - INFO - [Evaluate Models on Noised Data] Loading normal model 9/30: model_9.pt
+2026-07-26 20:37:13,186 - INFO - [Evaluate Models on Noised Data] Loading normal model 10/30: model_10.pt
+2026-07-26 20:37:25,784 - INFO - [Evaluate Models on Noised Data] Loading normal model 11/30: model_11.pt
+2026-07-26 20:37:38,380 - INFO - [Evaluate Models on Noised Data] Loading normal model 12/30: model_12.pt
+2026-07-26 20:37:50,979 - INFO - [Evaluate Models on Noised Data] Loading normal model 13/30: model_13.pt
+2026-07-26 20:38:03,550 - INFO - [Evaluate Models on Noised Data] Loading normal model 14/30: model_14.pt
+2026-07-26 20:38:16,148 - INFO - [Evaluate Models on Noised Data] Loading normal model 15/30: model_15.pt
+2026-07-26 20:38:28,780 - INFO - [Evaluate Models on Noised Data] Loading normal model 16/30: model_16.pt
+2026-07-26 20:38:41,380 - INFO - [Evaluate Models on Noised Data] Loading normal model 17/30: model_17.pt
+2026-07-26 20:38:53,982 - INFO - [Evaluate Models on Noised Data] Loading normal model 18/30: model_18.pt
+2026-07-26 20:39:06,605 - INFO - [Evaluate Models on Noised Data] Loading normal model 19/30: model_19.pt
+2026-07-26 20:39:19,242 - INFO - [Evaluate Models on Noised Data] Loading normal model 20/30: model_20.pt
+2026-07-26 20:39:31,911 - INFO - [Evaluate Models on Noised Data] Loading normal model 21/30: model_21.pt
+2026-07-26 20:39:44,533 - INFO - [Evaluate Models on Noised Data] Loading normal model 22/30: model_22.pt
+2026-07-26 20:39:57,166 - INFO - [Evaluate Models on Noised Data] Loading normal model 23/30: model_23.pt
+2026-07-26 20:40:09,801 - INFO - [Evaluate Models on Noised Data] Loading normal model 24/30: model_24.pt
+2026-07-26 20:40:22,415 - INFO - [Evaluate Models on Noised Data] Loading normal model 25/30: model_25.pt
+2026-07-26 20:40:35,005 - INFO - [Evaluate Models on Noised Data] Loading normal model 26/30: model_26.pt
+2026-07-26 20:40:47,598 - INFO - [Evaluate Models on Noised Data] Loading normal model 27/30: model_27.pt
+2026-07-26 20:41:00,209 - INFO - [Evaluate Models on Noised Data] Loading normal model 28/30: model_28.pt
+2026-07-26 20:41:12,814 - INFO - [Evaluate Models on Noised Data] Loading normal model 29/30: model_29.pt
+2026-07-26 20:41:25,396 - INFO - [Evaluate Models on Noised Data] Loading normal model 30/30: model_30.pt
+2026-07-26 20:41:38,032 - INFO - [Evaluate Models on Noised Data] Saved normal test evaluation to outputs/exp1/evaluations/noisy_normal.nc (models=30, samples=97, noise_levels=13).
+2026-07-26 20:41:38,109 - INFO - [Evaluate Models on Noised Data] Evaluating 30 bayesian model(s) on 97 test samples, 13 noise level(s), 20 MC pass(es) each.
+2026-07-26 20:41:38,116 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 1/30: model_1.pt
+2026-07-26 20:45:49,154 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 2/30: model_2.pt
+2026-07-26 20:50:00,058 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 3/30: model_3.pt
+2026-07-26 20:54:10,435 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 4/30: model_4.pt
+2026-07-26 20:58:20,922 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 5/30: model_5.pt
+2026-07-26 21:02:31,301 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 6/30: model_6.pt
+2026-07-26 21:06:41,600 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 7/30: model_7.pt
+2026-07-26 21:10:51,530 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 8/30: model_8.pt
+2026-07-26 21:15:01,340 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 9/30: model_9.pt
+2026-07-26 21:19:12,200 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 10/30: model_10.pt
+2026-07-26 21:23:22,781 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 11/30: model_11.pt
+2026-07-26 21:27:32,933 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 12/30: model_12.pt
+2026-07-26 21:28:41,293 - ERROR - Pipeline failed: App is not running
+Traceback (most recent call last):
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/main.py", line 326, in run_background_pipeline
+    task_func(task_tracker, task_logger, config, pipeline_state)
+    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/tasks/evaluate.py", line 378, in evaluate_noisy_task
+    _evaluate_noisy(track, log, config, state, SPLIT_TEST)
+    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/tasks/evaluate.py", line 347, in _evaluate_noisy
+    _evaluate_ensemble(
+    ~~~~~~~~~~~~~~~~~~^
+        model_paths=paths,
+        ^^^^^^^^^^^^^^^^^^
+    ...<8 lines>...
+        split=split,
+        ^^^^^^^^^^^^
+    )
+    ^
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/tasks/evaluate.py", line 225, in _evaluate_ensemble
+    mc_probs = _run_mc_passes(
+        model=model,
+    ...<10 lines>...
+        noise_relative=noise_relative,
+    )
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/tasks/evaluate.py", line 171, in _run_mc_passes
+    batch_tracker.update(advance=1)
+    ~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/util/progress.py", line 70, in update
+    self.app.call_from_thread(_do_update)
+    ~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/.venv/lib64/python3.14/site-packages/textual/app.py", line 1819, in call_from_thread
+    raise RuntimeError("App is not running")
+RuntimeError: App is not running
+2026-07-26 21:28:41,294 - ERROR - [SYSTEM] ERROR: App is not running
+2026-07-26 21:29:22,266 - INFO - [SYSTEM] Application initialized and ready.
+2026-07-26 21:29:22,267 - INFO - [SYSTEM] Successfully loaded config from ./outputs/exp1/config.toml
+2026-07-26 21:29:25,063 - INFO - [SYSTEM] Initiating Scenario: 2. Load, Evaluate (test+val), Noise, & Analyze
+2026-07-26 21:29:25,077 - INFO - [SYSTEM] Starting Task 1/10: Load Image and ADNIMERGE
+2026-07-26 21:29:25,215 - INFO - [Load Image and ADNIMERGE] Seeded RNGs with base seed 42
+2026-07-26 21:29:25,216 - INFO - [Load Image and ADNIMERGE] Loading Files
+2026-07-26 21:29:28,608 - INFO - [Load Image and ADNIMERGE] Dataloaders initalized
+2026-07-26 21:29:28,619 - INFO - [Load Image and ADNIMERGE] Task completed.
+2026-07-26 21:29:28,624 - INFO - [SYSTEM] Starting Task 2/10: Load Saved Models
+2026-07-26 21:29:28,625 - INFO - [Load Saved Models] Found 30 normal model(s) in normal_models/.
+2026-07-26 21:29:28,626 - INFO - [Load Saved Models] Found 30 bayesian model(s) in bayesian_models/.
+2026-07-26 21:29:28,627 - INFO - [Load Saved Models] Task completed.
+2026-07-26 21:29:28,628 - INFO - [SYSTEM] Starting Task 3/10: Evaluate Regular Models
+2026-07-26 21:29:28,903 - INFO - [Evaluate Regular Models] Evaluating 30 normal model(s) on 97 test samples, 1 noise level(s), 1 MC pass(es) each.
+2026-07-26 21:29:28,910 - INFO - [Evaluate Regular Models] Loading normal model 1/30: model_1.pt
+2026-07-26 21:29:30,321 - INFO - [Evaluate Regular Models] Loading normal model 2/30: model_2.pt
+2026-07-26 21:29:31,544 - INFO - [Evaluate Regular Models] Loading normal model 3/30: model_3.pt
+2026-07-26 21:29:32,760 - INFO - [Evaluate Regular Models] Loading normal model 4/30: model_4.pt
+2026-07-26 21:29:33,970 - INFO - [Evaluate Regular Models] Loading normal model 5/30: model_5.pt
+2026-07-26 21:29:35,186 - INFO - [Evaluate Regular Models] Loading normal model 6/30: model_6.pt
+2026-07-26 21:29:36,397 - INFO - [Evaluate Regular Models] Loading normal model 7/30: model_7.pt
+2026-07-26 21:29:37,598 - INFO - [Evaluate Regular Models] Loading normal model 8/30: model_8.pt
+2026-07-26 21:29:38,816 - INFO - [Evaluate Regular Models] Loading normal model 9/30: model_9.pt
+2026-07-26 21:29:40,033 - INFO - [Evaluate Regular Models] Loading normal model 10/30: model_10.pt
+2026-07-26 21:29:41,244 - INFO - [Evaluate Regular Models] Loading normal model 11/30: model_11.pt
+2026-07-26 21:29:42,490 - INFO - [Evaluate Regular Models] Loading normal model 12/30: model_12.pt
+2026-07-26 21:29:43,705 - INFO - [Evaluate Regular Models] Loading normal model 13/30: model_13.pt
+2026-07-26 21:29:44,917 - INFO - [Evaluate Regular Models] Loading normal model 14/30: model_14.pt
+2026-07-26 21:29:46,115 - INFO - [Evaluate Regular Models] Loading normal model 15/30: model_15.pt
+2026-07-26 21:29:47,344 - INFO - [Evaluate Regular Models] Loading normal model 16/30: model_16.pt
+2026-07-26 21:29:48,569 - INFO - [Evaluate Regular Models] Loading normal model 17/30: model_17.pt
+2026-07-26 21:29:49,781 - INFO - [Evaluate Regular Models] Loading normal model 18/30: model_18.pt
+2026-07-26 21:29:51,005 - INFO - [Evaluate Regular Models] Loading normal model 19/30: model_19.pt
+2026-07-26 21:29:52,199 - INFO - [Evaluate Regular Models] Loading normal model 20/30: model_20.pt
+2026-07-26 21:29:53,408 - INFO - [Evaluate Regular Models] Loading normal model 21/30: model_21.pt
+2026-07-26 21:29:54,643 - INFO - [Evaluate Regular Models] Loading normal model 22/30: model_22.pt
+2026-07-26 21:29:56,028 - INFO - [Evaluate Regular Models] Loading normal model 23/30: model_23.pt
+2026-07-26 21:29:56,168 - ERROR - Pipeline failed: App is not running
+Traceback (most recent call last):
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/main.py", line 326, in run_background_pipeline
+    task_func(task_tracker, task_logger, config, pipeline_state)
+    ~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/tasks/evaluate.py", line 368, in evaluate_normal_task
+    _evaluate_clean(track, log, config, state, KIND_NORMAL, SPLIT_TEST)
+    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/tasks/evaluate.py", line 314, in _evaluate_clean
+    _evaluate_ensemble(
+    ~~~~~~~~~~~~~~~~~~^
+        model_paths=paths,
+        ^^^^^^^^^^^^^^^^^^
+    ...<8 lines>...
+        split=split,
+        ^^^^^^^^^^^^
+    )
+    ^
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/tasks/evaluate.py", line 225, in _evaluate_ensemble
+    mc_probs = _run_mc_passes(
+        model=model,
+    ...<10 lines>...
+        noise_relative=noise_relative,
+    )
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/tasks/evaluate.py", line 131, in _run_mc_passes
+    pass_tracker = parent_tracker.get_sub_tracker(label)
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/util/progress.py", line 28, in get_sub_tracker
+    sub.set_title(title)
+    ~~~~~~~~~~~~~^^^^^^^
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/util/progress.py", line 38, in set_title
+    self.app.call_from_thread(_update_title)
+    ~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
+  File "/home/nschense/Documents/Medphys_Research/alnn_rewrite/.venv/lib64/python3.14/site-packages/textual/app.py", line 1819, in call_from_thread
+    raise RuntimeError("App is not running")
+RuntimeError: App is not running
+2026-07-26 21:29:56,169 - ERROR - [SYSTEM] ERROR: App is not running
+2026-07-26 21:30:03,892 - INFO - [SYSTEM] Application initialized and ready.
+2026-07-26 21:30:03,893 - INFO - [SYSTEM] Successfully loaded config from ./outputs/exp1/config.toml
+2026-07-26 21:30:09,149 - INFO - [SYSTEM] Initiating Scenario: 2. Load, Evaluate (test+val), Noise, & Analyze
+2026-07-26 21:30:09,162 - INFO - [SYSTEM] Starting Task 1/10: Load Image and ADNIMERGE
+2026-07-26 21:30:09,307 - INFO - [Load Image and ADNIMERGE] Seeded RNGs with base seed 42
+2026-07-26 21:30:09,308 - INFO - [Load Image and ADNIMERGE] Loading Files
+2026-07-26 21:30:12,642 - INFO - [Load Image and ADNIMERGE] Dataloaders initalized
+2026-07-26 21:30:12,648 - INFO - [Load Image and ADNIMERGE] Task completed.
+2026-07-26 21:30:12,650 - INFO - [SYSTEM] Starting Task 2/10: Load Saved Models
+2026-07-26 21:30:12,651 - INFO - [Load Saved Models] Found 30 normal model(s) in normal_models/.
+2026-07-26 21:30:12,653 - INFO - [Load Saved Models] Found 30 bayesian model(s) in bayesian_models/.
+2026-07-26 21:30:12,653 - INFO - [Load Saved Models] Task completed.
+2026-07-26 21:30:12,655 - INFO - [SYSTEM] Starting Task 3/10: Evaluate Regular Models
+2026-07-26 21:30:12,932 - INFO - [Evaluate Regular Models] Evaluating 30 normal model(s) on 97 test samples, 1 noise level(s), 1 MC pass(es) each.
+2026-07-26 21:30:12,939 - INFO - [Evaluate Regular Models] Loading normal model 1/30: model_1.pt
+2026-07-26 21:30:14,364 - INFO - [Evaluate Regular Models] Loading normal model 2/30: model_2.pt
+2026-07-26 21:30:15,575 - INFO - [Evaluate Regular Models] Loading normal model 3/30: model_3.pt
+2026-07-26 21:30:16,767 - INFO - [Evaluate Regular Models] Loading normal model 4/30: model_4.pt
+2026-07-26 21:30:17,947 - INFO - [Evaluate Regular Models] Loading normal model 5/30: model_5.pt
+2026-07-26 21:30:19,126 - INFO - [Evaluate Regular Models] Loading normal model 6/30: model_6.pt
+2026-07-26 21:30:20,309 - INFO - [Evaluate Regular Models] Loading normal model 7/30: model_7.pt
+2026-07-26 21:30:21,489 - INFO - [Evaluate Regular Models] Loading normal model 8/30: model_8.pt
+2026-07-26 21:30:22,674 - INFO - [Evaluate Regular Models] Loading normal model 9/30: model_9.pt
+2026-07-26 21:30:23,861 - INFO - [Evaluate Regular Models] Loading normal model 10/30: model_10.pt
+2026-07-26 21:30:25,040 - INFO - [Evaluate Regular Models] Loading normal model 11/30: model_11.pt
+2026-07-26 21:30:26,228 - INFO - [Evaluate Regular Models] Loading normal model 12/30: model_12.pt
+2026-07-26 21:30:27,409 - INFO - [Evaluate Regular Models] Loading normal model 13/30: model_13.pt
+2026-07-26 21:30:28,599 - INFO - [Evaluate Regular Models] Loading normal model 14/30: model_14.pt
+2026-07-26 21:30:29,784 - INFO - [Evaluate Regular Models] Loading normal model 15/30: model_15.pt
+2026-07-26 21:30:30,968 - INFO - [Evaluate Regular Models] Loading normal model 16/30: model_16.pt
+2026-07-26 21:30:32,149 - INFO - [Evaluate Regular Models] Loading normal model 17/30: model_17.pt
+2026-07-26 21:30:33,329 - INFO - [Evaluate Regular Models] Loading normal model 18/30: model_18.pt
+2026-07-26 21:30:34,513 - INFO - [Evaluate Regular Models] Loading normal model 19/30: model_19.pt
+2026-07-26 21:30:35,699 - INFO - [Evaluate Regular Models] Loading normal model 20/30: model_20.pt
+2026-07-26 21:30:36,882 - INFO - [Evaluate Regular Models] Loading normal model 21/30: model_21.pt
+2026-07-26 21:30:38,076 - INFO - [Evaluate Regular Models] Loading normal model 22/30: model_22.pt
+2026-07-26 21:30:39,262 - INFO - [Evaluate Regular Models] Loading normal model 23/30: model_23.pt
+2026-07-26 21:30:40,449 - INFO - [Evaluate Regular Models] Loading normal model 24/30: model_24.pt
+2026-07-26 21:30:41,620 - INFO - [Evaluate Regular Models] Loading normal model 25/30: model_25.pt
+2026-07-26 21:30:42,794 - INFO - [Evaluate Regular Models] Loading normal model 26/30: model_26.pt
+2026-07-26 21:30:43,959 - INFO - [Evaluate Regular Models] Loading normal model 27/30: model_27.pt
+2026-07-26 21:30:45,127 - INFO - [Evaluate Regular Models] Loading normal model 28/30: model_28.pt
+2026-07-26 21:30:46,302 - INFO - [Evaluate Regular Models] Loading normal model 29/30: model_29.pt
+2026-07-26 21:30:47,470 - INFO - [Evaluate Regular Models] Loading normal model 30/30: model_30.pt
+2026-07-26 21:30:48,680 - INFO - [Evaluate Regular Models] Saved normal test evaluation to outputs/exp1/evaluations/normal.nc (models=30, samples=97, noise_levels=1).
+2026-07-26 21:30:48,681 - INFO - [Evaluate Regular Models] Task completed.
+2026-07-26 21:30:48,683 - INFO - [SYSTEM] Starting Task 4/10: Evaluate Bayesian Models
+2026-07-26 21:30:48,762 - INFO - [Evaluate Bayesian Models] Evaluating 30 bayesian model(s) on 97 test samples, 1 noise level(s), 20 MC pass(es) each.
+2026-07-26 21:30:48,770 - INFO - [Evaluate Bayesian Models] Loading bayesian model 1/30: model_1.pt
+2026-07-26 21:31:08,258 - INFO - [Evaluate Bayesian Models] Loading bayesian model 2/30: model_2.pt
+2026-07-26 21:31:27,760 - INFO - [Evaluate Bayesian Models] Loading bayesian model 3/30: model_3.pt
+2026-07-26 21:31:47,244 - INFO - [Evaluate Bayesian Models] Loading bayesian model 4/30: model_4.pt
+2026-07-26 21:32:06,726 - INFO - [Evaluate Bayesian Models] Loading bayesian model 5/30: model_5.pt
+2026-07-26 21:32:26,212 - INFO - [Evaluate Bayesian Models] Loading bayesian model 6/30: model_6.pt
+2026-07-26 21:32:45,670 - INFO - [Evaluate Bayesian Models] Loading bayesian model 7/30: model_7.pt
+2026-07-26 21:33:05,131 - INFO - [Evaluate Bayesian Models] Loading bayesian model 8/30: model_8.pt
+2026-07-26 21:33:24,641 - INFO - [Evaluate Bayesian Models] Loading bayesian model 9/30: model_9.pt
+2026-07-26 21:33:44,169 - INFO - [Evaluate Bayesian Models] Loading bayesian model 10/30: model_10.pt
+2026-07-26 21:34:03,687 - INFO - [Evaluate Bayesian Models] Loading bayesian model 11/30: model_11.pt
+2026-07-26 21:34:23,231 - INFO - [Evaluate Bayesian Models] Loading bayesian model 12/30: model_12.pt
+2026-07-26 21:34:42,710 - INFO - [Evaluate Bayesian Models] Loading bayesian model 13/30: model_13.pt
+2026-07-26 21:35:02,165 - INFO - [Evaluate Bayesian Models] Loading bayesian model 14/30: model_14.pt
+2026-07-26 21:35:21,624 - INFO - [Evaluate Bayesian Models] Loading bayesian model 15/30: model_15.pt
+2026-07-26 21:35:41,027 - INFO - [Evaluate Bayesian Models] Loading bayesian model 16/30: model_16.pt
+2026-07-26 21:36:00,502 - INFO - [Evaluate Bayesian Models] Loading bayesian model 17/30: model_17.pt
+2026-07-26 21:36:19,957 - INFO - [Evaluate Bayesian Models] Loading bayesian model 18/30: model_18.pt
+2026-07-26 21:36:39,437 - INFO - [Evaluate Bayesian Models] Loading bayesian model 19/30: model_19.pt
+2026-07-26 21:36:58,895 - INFO - [Evaluate Bayesian Models] Loading bayesian model 20/30: model_20.pt
+2026-07-26 21:37:18,381 - INFO - [Evaluate Bayesian Models] Loading bayesian model 21/30: model_21.pt
+2026-07-26 21:37:37,841 - INFO - [Evaluate Bayesian Models] Loading bayesian model 22/30: model_22.pt
+2026-07-26 21:37:57,293 - INFO - [Evaluate Bayesian Models] Loading bayesian model 23/30: model_23.pt
+2026-07-26 21:38:16,717 - INFO - [Evaluate Bayesian Models] Loading bayesian model 24/30: model_24.pt
+2026-07-26 21:38:36,164 - INFO - [Evaluate Bayesian Models] Loading bayesian model 25/30: model_25.pt
+2026-07-26 21:38:55,626 - INFO - [Evaluate Bayesian Models] Loading bayesian model 26/30: model_26.pt
+2026-07-26 21:39:15,089 - INFO - [Evaluate Bayesian Models] Loading bayesian model 27/30: model_27.pt
+2026-07-26 21:39:34,541 - INFO - [Evaluate Bayesian Models] Loading bayesian model 28/30: model_28.pt
+2026-07-26 21:39:54,016 - INFO - [Evaluate Bayesian Models] Loading bayesian model 29/30: model_29.pt
+2026-07-26 21:40:13,563 - INFO - [Evaluate Bayesian Models] Loading bayesian model 30/30: model_30.pt
+2026-07-26 21:40:33,116 - INFO - [Evaluate Bayesian Models] Saved bayesian test evaluation to outputs/exp1/evaluations/bayesian.nc (models=30, samples=97, noise_levels=1).
+2026-07-26 21:40:33,117 - INFO - [Evaluate Bayesian Models] Task completed.
+2026-07-26 21:40:33,119 - INFO - [SYSTEM] Starting Task 5/10: Evaluate Regular Models (Validation)
+2026-07-26 21:40:33,226 - INFO - [Evaluate Regular Models (Validation)] Evaluating 30 normal model(s) on 149 val samples, 1 noise level(s), 1 MC pass(es) each.
+2026-07-26 21:40:33,233 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 1/30: model_1.pt
+2026-07-26 21:40:34,882 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 2/30: model_2.pt
+2026-07-26 21:40:36,523 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 3/30: model_3.pt
+2026-07-26 21:40:38,176 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 4/30: model_4.pt
+2026-07-26 21:40:39,811 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 5/30: model_5.pt
+2026-07-26 21:40:41,453 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 6/30: model_6.pt
+2026-07-26 21:40:43,095 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 7/30: model_7.pt
+2026-07-26 21:40:44,729 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 8/30: model_8.pt
+2026-07-26 21:40:46,363 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 9/30: model_9.pt
+2026-07-26 21:40:48,002 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 10/30: model_10.pt
+2026-07-26 21:40:49,644 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 11/30: model_11.pt
+2026-07-26 21:40:51,283 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 12/30: model_12.pt
+2026-07-26 21:40:52,924 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 13/30: model_13.pt
+2026-07-26 21:40:54,565 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 14/30: model_14.pt
+2026-07-26 21:40:56,198 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 15/30: model_15.pt
+2026-07-26 21:40:57,842 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 16/30: model_16.pt
+2026-07-26 21:40:59,500 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 17/30: model_17.pt
+2026-07-26 21:41:01,130 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 18/30: model_18.pt
+2026-07-26 21:41:02,770 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 19/30: model_19.pt
+2026-07-26 21:41:04,400 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 20/30: model_20.pt
+2026-07-26 21:41:06,036 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 21/30: model_21.pt
+2026-07-26 21:41:07,667 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 22/30: model_22.pt
+2026-07-26 21:41:09,300 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 23/30: model_23.pt
+2026-07-26 21:41:10,930 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 24/30: model_24.pt
+2026-07-26 21:41:12,565 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 25/30: model_25.pt
+2026-07-26 21:41:14,204 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 26/30: model_26.pt
+2026-07-26 21:41:15,848 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 27/30: model_27.pt
+2026-07-26 21:41:17,492 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 28/30: model_28.pt
+2026-07-26 21:41:19,131 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 29/30: model_29.pt
+2026-07-26 21:41:20,765 - INFO - [Evaluate Regular Models (Validation)] Loading normal model 30/30: model_30.pt
+2026-07-26 21:41:22,416 - INFO - [Evaluate Regular Models (Validation)] Saved normal val evaluation to outputs/exp1/evaluations/val_normal.nc (models=30, samples=149, noise_levels=1).
+2026-07-26 21:41:22,416 - INFO - [Evaluate Regular Models (Validation)] Task completed.
+2026-07-26 21:41:22,418 - INFO - [SYSTEM] Starting Task 6/10: Evaluate Bayesian Models (Validation)
+2026-07-26 21:41:22,535 - INFO - [Evaluate Bayesian Models (Validation)] Evaluating 30 bayesian model(s) on 149 val samples, 1 noise level(s), 20 MC pass(es) each.
+2026-07-26 21:41:22,543 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 1/30: model_1.pt
+2026-07-26 21:41:51,939 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 2/30: model_2.pt
+2026-07-26 21:42:21,312 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 3/30: model_3.pt
+2026-07-26 21:42:50,691 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 4/30: model_4.pt
+2026-07-26 21:43:20,097 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 5/30: model_5.pt
+2026-07-26 21:43:49,458 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 6/30: model_6.pt
+2026-07-26 21:44:18,835 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 7/30: model_7.pt
+2026-07-26 21:44:48,226 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 8/30: model_8.pt
+2026-07-26 21:45:17,606 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 9/30: model_9.pt
+2026-07-26 21:45:46,980 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 10/30: model_10.pt
+2026-07-26 21:46:16,342 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 11/30: model_11.pt
+2026-07-26 21:46:45,707 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 12/30: model_12.pt
+2026-07-26 21:47:15,124 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 13/30: model_13.pt
+2026-07-26 21:47:44,534 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 14/30: model_14.pt
+2026-07-26 21:48:13,890 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 15/30: model_15.pt
+2026-07-26 21:48:43,260 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 16/30: model_16.pt
+2026-07-26 21:49:12,650 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 17/30: model_17.pt
+2026-07-26 21:49:42,043 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 18/30: model_18.pt
+2026-07-26 21:50:11,409 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 19/30: model_19.pt
+2026-07-26 21:50:40,774 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 20/30: model_20.pt
+2026-07-26 21:51:10,160 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 21/30: model_21.pt
+2026-07-26 21:51:39,542 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 22/30: model_22.pt
+2026-07-26 21:52:08,928 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 23/30: model_23.pt
+2026-07-26 21:52:38,412 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 24/30: model_24.pt
+2026-07-26 21:53:07,824 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 25/30: model_25.pt
+2026-07-26 21:53:37,228 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 26/30: model_26.pt
+2026-07-26 21:54:06,614 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 27/30: model_27.pt
+2026-07-26 21:54:36,019 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 28/30: model_28.pt
+2026-07-26 21:55:05,383 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 29/30: model_29.pt
+2026-07-26 21:55:34,728 - INFO - [Evaluate Bayesian Models (Validation)] Loading bayesian model 30/30: model_30.pt
+2026-07-26 21:56:04,133 - INFO - [Evaluate Bayesian Models (Validation)] Saved bayesian val evaluation to outputs/exp1/evaluations/val_bayesian.nc (models=30, samples=149, noise_levels=1).
+2026-07-26 21:56:04,134 - INFO - [Evaluate Bayesian Models (Validation)] Task completed.
+2026-07-26 21:56:04,135 - INFO - [SYSTEM] Starting Task 7/10: Evaluate Models on Noised Data
+2026-07-26 21:56:04,213 - INFO - [Evaluate Models on Noised Data] Evaluating 30 normal model(s) on 97 test samples, 13 noise level(s), 1 MC pass(es) each.
+2026-07-26 21:56:04,221 - INFO - [Evaluate Models on Noised Data] Loading normal model 1/30: model_1.pt
+2026-07-26 21:56:16,752 - INFO - [Evaluate Models on Noised Data] Loading normal model 2/30: model_2.pt
+2026-07-26 21:56:29,265 - INFO - [Evaluate Models on Noised Data] Loading normal model 3/30: model_3.pt
+2026-07-26 21:56:41,790 - INFO - [Evaluate Models on Noised Data] Loading normal model 4/30: model_4.pt
+2026-07-26 21:56:54,333 - INFO - [Evaluate Models on Noised Data] Loading normal model 5/30: model_5.pt
+2026-07-26 21:57:06,851 - INFO - [Evaluate Models on Noised Data] Loading normal model 6/30: model_6.pt
+2026-07-26 21:57:19,390 - INFO - [Evaluate Models on Noised Data] Loading normal model 7/30: model_7.pt
+2026-07-26 21:57:31,917 - INFO - [Evaluate Models on Noised Data] Loading normal model 8/30: model_8.pt
+2026-07-26 21:57:44,433 - INFO - [Evaluate Models on Noised Data] Loading normal model 9/30: model_9.pt
+2026-07-26 21:57:56,966 - INFO - [Evaluate Models on Noised Data] Loading normal model 10/30: model_10.pt
+2026-07-26 21:58:09,480 - INFO - [Evaluate Models on Noised Data] Loading normal model 11/30: model_11.pt
+2026-07-26 21:58:21,987 - INFO - [Evaluate Models on Noised Data] Loading normal model 12/30: model_12.pt
+2026-07-26 21:58:34,507 - INFO - [Evaluate Models on Noised Data] Loading normal model 13/30: model_13.pt
+2026-07-26 21:58:47,017 - INFO - [Evaluate Models on Noised Data] Loading normal model 14/30: model_14.pt
+2026-07-26 21:58:59,540 - INFO - [Evaluate Models on Noised Data] Loading normal model 15/30: model_15.pt
+2026-07-26 21:59:12,079 - INFO - [Evaluate Models on Noised Data] Loading normal model 16/30: model_16.pt
+2026-07-26 21:59:24,599 - INFO - [Evaluate Models on Noised Data] Loading normal model 17/30: model_17.pt
+2026-07-26 21:59:37,117 - INFO - [Evaluate Models on Noised Data] Loading normal model 18/30: model_18.pt
+2026-07-26 21:59:49,625 - INFO - [Evaluate Models on Noised Data] Loading normal model 19/30: model_19.pt
+2026-07-26 22:00:02,151 - INFO - [Evaluate Models on Noised Data] Loading normal model 20/30: model_20.pt
+2026-07-26 22:00:14,666 - INFO - [Evaluate Models on Noised Data] Loading normal model 21/30: model_21.pt
+2026-07-26 22:00:27,181 - INFO - [Evaluate Models on Noised Data] Loading normal model 22/30: model_22.pt
+2026-07-26 22:00:39,694 - INFO - [Evaluate Models on Noised Data] Loading normal model 23/30: model_23.pt
+2026-07-26 22:00:52,217 - INFO - [Evaluate Models on Noised Data] Loading normal model 24/30: model_24.pt
+2026-07-26 22:01:04,701 - INFO - [Evaluate Models on Noised Data] Loading normal model 25/30: model_25.pt
+2026-07-26 22:01:17,216 - INFO - [Evaluate Models on Noised Data] Loading normal model 26/30: model_26.pt
+2026-07-26 22:01:29,749 - INFO - [Evaluate Models on Noised Data] Loading normal model 27/30: model_27.pt
+2026-07-26 22:01:42,287 - INFO - [Evaluate Models on Noised Data] Loading normal model 28/30: model_28.pt
+2026-07-26 22:01:54,820 - INFO - [Evaluate Models on Noised Data] Loading normal model 29/30: model_29.pt
+2026-07-26 22:02:07,392 - INFO - [Evaluate Models on Noised Data] Loading normal model 30/30: model_30.pt
+2026-07-26 22:02:19,929 - INFO - [Evaluate Models on Noised Data] Saved normal test evaluation to outputs/exp1/evaluations/noisy_normal.nc (models=30, samples=97, noise_levels=13).
+2026-07-26 22:02:20,004 - INFO - [Evaluate Models on Noised Data] Evaluating 30 bayesian model(s) on 97 test samples, 13 noise level(s), 20 MC pass(es) each.
+2026-07-26 22:02:20,011 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 1/30: model_1.pt
+2026-07-26 22:06:28,700 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 2/30: model_2.pt
+2026-07-26 22:10:37,088 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 3/30: model_3.pt
+2026-07-26 22:14:45,447 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 4/30: model_4.pt
+2026-07-26 22:18:54,005 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 5/30: model_5.pt
+2026-07-26 22:23:02,465 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 6/30: model_6.pt
+2026-07-26 22:27:11,601 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 7/30: model_7.pt
+2026-07-26 22:31:20,867 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 8/30: model_8.pt
+2026-07-26 22:35:30,599 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 9/30: model_9.pt
+2026-07-26 22:39:40,346 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 10/30: model_10.pt
+2026-07-26 22:43:49,483 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 11/30: model_11.pt
+2026-07-26 22:47:58,410 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 12/30: model_12.pt
+2026-07-26 22:52:07,309 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 13/30: model_13.pt
+2026-07-26 22:56:16,703 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 14/30: model_14.pt
+2026-07-26 23:00:26,279 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 15/30: model_15.pt
+2026-07-26 23:04:36,108 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 16/30: model_16.pt
+2026-07-26 23:08:45,318 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 17/30: model_17.pt
+2026-07-26 23:12:54,852 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 18/30: model_18.pt
+2026-07-26 23:17:04,563 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 19/30: model_19.pt
+2026-07-26 23:21:14,581 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 20/30: model_20.pt
+2026-07-26 23:25:24,641 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 21/30: model_21.pt
+2026-07-26 23:29:34,434 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 22/30: model_22.pt
+2026-07-26 23:33:44,269 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 23/30: model_23.pt
+2026-07-26 23:37:53,862 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 24/30: model_24.pt
+2026-07-26 23:42:03,032 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 25/30: model_25.pt
+2026-07-26 23:46:12,283 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 26/30: model_26.pt
+2026-07-26 23:50:21,331 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 27/30: model_27.pt
+2026-07-26 23:54:30,682 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 28/30: model_28.pt
+2026-07-26 23:58:39,998 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 29/30: model_29.pt
+2026-07-27 00:02:49,246 - INFO - [Evaluate Models on Noised Data] Loading bayesian model 30/30: model_30.pt
+2026-07-27 00:06:58,432 - INFO - [Evaluate Models on Noised Data] Saved bayesian test evaluation to outputs/exp1/evaluations/noisy_bayesian.nc (models=30, samples=97, noise_levels=13).
+2026-07-27 00:06:58,433 - INFO - [Evaluate Models on Noised Data] Task completed.
+2026-07-27 00:06:58,435 - INFO - [SYSTEM] Starting Task 8/10: Evaluate Models on Noised Data (Validation)
+2026-07-27 00:06:58,541 - INFO - [Evaluate Models on Noised Data (Validation)] Evaluating 30 normal model(s) on 149 val samples, 13 noise level(s), 1 MC pass(es) each.
+2026-07-27 00:06:58,550 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 1/30: model_1.pt
+2026-07-27 00:07:17,570 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 2/30: model_2.pt
+2026-07-27 00:07:36,569 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 3/30: model_3.pt
+2026-07-27 00:07:55,540 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 4/30: model_4.pt
+2026-07-27 00:08:14,550 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 5/30: model_5.pt
+2026-07-27 00:08:33,559 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 6/30: model_6.pt
+2026-07-27 00:08:52,549 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 7/30: model_7.pt
+2026-07-27 00:09:11,549 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 8/30: model_8.pt
+2026-07-27 00:09:30,582 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 9/30: model_9.pt
+2026-07-27 00:09:49,518 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 10/30: model_10.pt
+2026-07-27 00:10:08,470 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 11/30: model_11.pt
+2026-07-27 00:10:27,459 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 12/30: model_12.pt
+2026-07-27 00:10:46,450 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 13/30: model_13.pt
+2026-07-27 00:11:05,434 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 14/30: model_14.pt
+2026-07-27 00:11:24,413 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 15/30: model_15.pt
+2026-07-27 00:11:43,391 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 16/30: model_16.pt
+2026-07-27 00:12:02,353 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 17/30: model_17.pt
+2026-07-27 00:12:21,362 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 18/30: model_18.pt
+2026-07-27 00:12:40,346 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 19/30: model_19.pt
+2026-07-27 00:12:59,316 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 20/30: model_20.pt
+2026-07-27 00:13:18,266 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 21/30: model_21.pt
+2026-07-27 00:13:37,229 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 22/30: model_22.pt
+2026-07-27 00:13:56,199 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 23/30: model_23.pt
+2026-07-27 00:14:15,130 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 24/30: model_24.pt
+2026-07-27 00:14:34,133 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 25/30: model_25.pt
+2026-07-27 00:14:53,068 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 26/30: model_26.pt
+2026-07-27 00:15:11,992 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 27/30: model_27.pt
+2026-07-27 00:15:30,963 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 28/30: model_28.pt
+2026-07-27 00:15:49,966 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 29/30: model_29.pt
+2026-07-27 00:16:08,911 - INFO - [Evaluate Models on Noised Data (Validation)] Loading normal model 30/30: model_30.pt
+2026-07-27 00:16:27,928 - INFO - [Evaluate Models on Noised Data (Validation)] Saved normal val evaluation to outputs/exp1/evaluations/val_noisy_normal.nc (models=30, samples=149, noise_levels=13).
+2026-07-27 00:16:28,034 - INFO - [Evaluate Models on Noised Data (Validation)] Evaluating 30 bayesian model(s) on 149 val samples, 13 noise level(s), 20 MC pass(es) each.
+2026-07-27 00:16:28,041 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 1/30: model_1.pt
+2026-07-27 00:22:45,767 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 2/30: model_2.pt
+2026-07-27 00:29:03,466 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 3/30: model_3.pt
+2026-07-27 00:35:21,431 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 4/30: model_4.pt
+2026-07-27 00:41:39,274 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 5/30: model_5.pt
+2026-07-27 00:47:57,182 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 6/30: model_6.pt
+2026-07-27 00:54:14,917 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 7/30: model_7.pt
+2026-07-27 01:00:32,270 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 8/30: model_8.pt
+2026-07-27 01:06:50,205 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 9/30: model_9.pt
+2026-07-27 01:13:07,953 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 10/30: model_10.pt
+2026-07-27 01:19:26,249 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 11/30: model_11.pt
+2026-07-27 01:25:44,385 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 12/30: model_12.pt
+2026-07-27 01:32:02,203 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 13/30: model_13.pt
+2026-07-27 01:38:19,974 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 14/30: model_14.pt
+2026-07-27 01:44:37,705 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 15/30: model_15.pt
+2026-07-27 01:50:55,216 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 16/30: model_16.pt
+2026-07-27 01:57:12,992 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 17/30: model_17.pt
+2026-07-27 02:03:30,447 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 18/30: model_18.pt
+2026-07-27 02:09:47,766 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 19/30: model_19.pt
+2026-07-27 02:16:05,146 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 20/30: model_20.pt
+2026-07-27 02:22:22,880 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 21/30: model_21.pt
+2026-07-27 02:28:40,602 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 22/30: model_22.pt
+2026-07-27 02:34:58,419 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 23/30: model_23.pt
+2026-07-27 02:41:16,000 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 24/30: model_24.pt
+2026-07-27 02:47:33,565 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 25/30: model_25.pt
+2026-07-27 02:53:51,021 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 26/30: model_26.pt
+2026-07-27 03:00:08,667 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 27/30: model_27.pt
+2026-07-27 03:06:26,116 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 28/30: model_28.pt
+2026-07-27 03:12:43,714 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 29/30: model_29.pt
+2026-07-27 03:19:01,400 - INFO - [Evaluate Models on Noised Data (Validation)] Loading bayesian model 30/30: model_30.pt
+2026-07-27 03:25:18,763 - INFO - [Evaluate Models on Noised Data (Validation)] Saved bayesian val evaluation to outputs/exp1/evaluations/val_noisy_bayesian.nc (models=30, samples=149, noise_levels=13).
+2026-07-27 03:25:18,765 - INFO - [Evaluate Models on Noised Data (Validation)] Task completed.
+2026-07-27 03:25:18,766 - INFO - [SYSTEM] Starting Task 9/10: Combine Split Evaluations
+2026-07-27 03:25:18,802 - INFO - [Combine Split Evaluations] combine_evaluations: wrote combined_normal.nc (246 samples from normal.nc, val_normal.nc).
+2026-07-27 03:25:18,841 - INFO - [Combine Split Evaluations] combine_evaluations: wrote combined_noisy_normal.nc (246 samples from noisy_normal.nc, val_noisy_normal.nc).
+2026-07-27 03:25:18,867 - INFO - [Combine Split Evaluations] combine_evaluations: wrote combined_bayesian.nc (246 samples from bayesian.nc, val_bayesian.nc).
+2026-07-27 03:25:18,903 - INFO - [Combine Split Evaluations] combine_evaluations: wrote combined_noisy_bayesian.nc (246 samples from noisy_bayesian.nc, val_noisy_bayesian.nc).
+2026-07-27 03:25:18,909 - INFO - [Combine Split Evaluations] Task completed.
+2026-07-27 03:25:18,910 - INFO - [SYSTEM] Starting Task 10/10: Analyze Evaluations
+2026-07-27 03:25:19,685 - INFO - generated new fontManager
+2026-07-27 03:25:19,962 - INFO - [Analyze Evaluations] Loaded evaluation 'bayesian' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-27 03:25:19,971 - INFO - [Analyze Evaluations] Loaded evaluation 'combined_bayesian' with dims {'model': 30, 'noise_level': 1, 'sample': 246, 'class_name': 2}.
+2026-07-27 03:25:19,975 - INFO - [Analyze Evaluations] Loaded evaluation 'combined_noisy_bayesian' with dims {'model': 30, 'noise_level': 13, 'sample': 246, 'class_name': 2}.
+2026-07-27 03:25:19,979 - INFO - [Analyze Evaluations] Loaded evaluation 'combined_noisy_normal' with dims {'model': 30, 'noise_level': 13, 'sample': 246, 'class_name': 2}.
+2026-07-27 03:25:19,982 - INFO - [Analyze Evaluations] Loaded evaluation 'combined_normal' with dims {'model': 30, 'noise_level': 1, 'sample': 246, 'class_name': 2}.
+2026-07-27 03:25:19,985 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_bayesian' with dims {'model': 30, 'noise_level': 13, 'sample': 97, 'class_name': 2}.
+2026-07-27 03:25:19,995 - INFO - [Analyze Evaluations] Loaded evaluation 'noisy_normal' with dims {'model': 30, 'noise_level': 13, 'sample': 97, 'class_name': 2}.
+2026-07-27 03:25:19,999 - INFO - [Analyze Evaluations] Loaded evaluation 'normal' with dims {'model': 30, 'noise_level': 1, 'sample': 97, 'class_name': 2}.
+2026-07-27 03:25:20,002 - INFO - [Analyze Evaluations] Loaded evaluation 'val_bayesian' with dims {'model': 30, 'noise_level': 1, 'sample': 149, 'class_name': 2}.
+2026-07-27 03:25:20,013 - INFO - [Analyze Evaluations] Loaded evaluation 'val_noisy_bayesian' with dims {'model': 30, 'noise_level': 13, 'sample': 149, 'class_name': 2}.
+2026-07-27 03:25:20,016 - INFO - [Analyze Evaluations] Loaded evaluation 'val_noisy_normal' with dims {'model': 30, 'noise_level': 13, 'sample': 149, 'class_name': 2}.
+2026-07-27 03:25:20,020 - INFO - [Analyze Evaluations] Loaded evaluation 'val_normal' with dims {'model': 30, 'noise_level': 1, 'sample': 149, 'class_name': 2}.
+2026-07-27 03:25:20,028 - INFO - [Analyze Evaluations] Running analysis: model_report — Per-model accuracy and uncertainty summary (PDF)
+2026-07-27 03:25:20,098 - INFO - [Analyze Evaluations] model_report: wrote model_report.pdf (2 family/families, 60 model(s)).
+2026-07-27 03:25:20,099 - INFO - [Analyze Evaluations] Running analysis: ensemble_size_accuracy — Accuracy vs. number of ensemble members
+2026-07-27 03:25:20,113 - INFO - [Analyze Evaluations] ensemble_size_accuracy: normal - sizes 1..30, up to 30 configs each.
+2026-07-27 03:25:20,137 - INFO - [Analyze Evaluations] ensemble_size_accuracy: bayesian - sizes 1..30, up to 30 configs each.
+2026-07-27 03:25:20,557 - INFO - [Analyze Evaluations] ensemble_size_accuracy: wrote ensemble_size_accuracy.png (2 family/families).
+2026-07-27 03:25:20,565 - INFO - [Analyze Evaluations] Running analysis: bootstrap_ensemble_ci — Bootstrap confidence intervals for ensemble accuracy
+2026-07-27 03:25:20,708 - INFO - [Analyze Evaluations] bootstrap_ensemble_ci: normal full-ensemble acc = 71.5% (95% CI 62.4-79.9%, n=246 scans / 129 patients).
+2026-07-27 03:25:20,747 - INFO - [Analyze Evaluations] bootstrap_ensemble_ci: bayesian full-ensemble acc = 80.5% (95% CI 72.3-87.9%, n=246 scans / 129 patients).
+2026-07-27 03:25:20,869 - INFO - [Analyze Evaluations] bootstrap_ensemble_ci: wrote bootstrap_ci.png (2 family/families).
+2026-07-27 03:25:20,876 - INFO - [Analyze Evaluations] Running analysis: coverage — Coverage curves: accuracy and F1 vs. retained fraction
+2026-07-27 03:25:21,442 - INFO - [Analyze Evaluations] coverage: wrote coverage_accuracy.png (5 measure panels).
+2026-07-27 03:25:21,957 - INFO - [Analyze Evaluations] coverage: wrote coverage_f1.png (5 measure panels).
+2026-07-27 03:25:21,966 - INFO - [Analyze Evaluations] coverage: mean_output_entropy / normal_ensemble — AURC=0.1189 (accuracy at full coverage 0.715).
+2026-07-27 03:25:21,967 - INFO - [Analyze Evaluations] coverage: mean_output_entropy / normal_single — AURC=0.1335 (accuracy at full coverage 0.725).
+2026-07-27 03:25:21,967 - INFO - [Analyze Evaluations] coverage: mean_output_entropy / bayesian_ensemble — AURC=0.0763 (accuracy at full coverage 0.805).
+2026-07-27 03:25:21,968 - INFO - [Analyze Evaluations] coverage: mean_output_entropy / bayesian_single — AURC=0.1594 (accuracy at full coverage 0.699).
+2026-07-27 03:25:21,968 - INFO - [Analyze Evaluations] coverage: mean_predictive_entropy / normal_ensemble — AURC=0.1096 (accuracy at full coverage 0.715).
+2026-07-27 03:25:21,969 - INFO - [Analyze Evaluations] coverage: mean_predictive_entropy / bayesian_ensemble — AURC=0.0923 (accuracy at full coverage 0.805).
+2026-07-27 03:25:21,969 - INFO - [Analyze Evaluations] coverage: ensemble_std / normal_ensemble — AURC=0.2589 (accuracy at full coverage 0.715).
+2026-07-27 03:25:21,970 - INFO - [Analyze Evaluations] coverage: ensemble_std / bayesian_ensemble — AURC=0.0979 (accuracy at full coverage 0.805).
+2026-07-27 03:25:21,970 - INFO - [Analyze Evaluations] coverage: ensemble_mutual_information / normal_ensemble — AURC=0.2778 (accuracy at full coverage 0.715).
+2026-07-27 03:25:21,970 - INFO - [Analyze Evaluations] coverage: ensemble_mutual_information / bayesian_ensemble — AURC=0.1134 (accuracy at full coverage 0.805).
+2026-07-27 03:25:21,971 - INFO - [Analyze Evaluations] coverage: mean_mutual_information / bayesian_ensemble — AURC=0.1170 (accuracy at full coverage 0.805).
+2026-07-27 03:25:21,971 - INFO - [Analyze Evaluations] coverage: mean_mutual_information / bayesian_single — AURC=0.2047 (accuracy at full coverage 0.699).
+2026-07-27 03:25:21,973 - INFO - [Analyze Evaluations] Running analysis: noise_performance — Accuracy and uncertainty vs. input noise
+2026-07-27 03:25:22,822 - INFO - [Analyze Evaluations] noise_performance: normal_ensemble — accuracy 0.715 at σ=0 → 0.589 at σ=8.
+2026-07-27 03:25:22,828 - INFO - [Analyze Evaluations] noise_performance: normal_single — accuracy 0.725 at σ=0 → 0.597 at σ=8.
+2026-07-27 03:25:22,829 - INFO - [Analyze Evaluations] noise_performance: bayesian_ensemble — accuracy 0.809 at σ=0 → 0.476 at σ=8.
+2026-07-27 03:25:22,830 - INFO - [Analyze Evaluations] noise_performance: bayesian_single — accuracy 0.686 at σ=0 → 0.492 at σ=8.
+2026-07-27 03:25:22,830 - INFO - [Analyze Evaluations] noise_performance: wrote noise_performance.png (7 panels).
+2026-07-27 03:25:22,831 - INFO - [Analyze Evaluations] Running analysis: noise_correlation — Uncertainty vs. accuracy across noise levels (with fit)
+2026-07-27 03:25:24,479 - INFO - [Analyze Evaluations] noise_correlation: wrote noise_correlation_mean_output_entropy.png.
+2026-07-27 03:25:24,485 - INFO - [Analyze Evaluations] noise_correlation: mean_output_entropy / normal_ensemble — exponential fit R²=0.747, Spearman ρ=-0.877.
+2026-07-27 03:25:24,486 - INFO - [Analyze Evaluations] noise_correlation: mean_output_entropy / normal_single — exponential fit R²=0.884, Spearman ρ=-0.926.
+2026-07-27 03:25:24,487 - INFO - [Analyze Evaluations] noise_correlation: mean_output_entropy / bayesian_ensemble — exponential fit R²=0.203, Spearman ρ=-0.488.
+2026-07-27 03:25:24,487 - INFO - [Analyze Evaluations] noise_correlation: mean_output_entropy / bayesian_single — exponential fit R²=0.395, Spearman ρ=-0.624.
+2026-07-27 03:25:24,813 - INFO - [Analyze Evaluations] noise_correlation: wrote noise_correlation_mean_predictive_entropy.png.
+2026-07-27 03:25:24,819 - INFO - [Analyze Evaluations] noise_correlation: mean_predictive_entropy / normal_ensemble — exponential fit R²=0.806, Spearman ρ=-0.907.
+2026-07-27 03:25:24,821 - INFO - [Analyze Evaluations] noise_correlation: mean_predictive_entropy / bayesian_ensemble — exponential fit R²=0.178, Spearman ρ=-0.435.
+2026-07-27 03:25:25,154 - INFO - [Analyze Evaluations] noise_correlation: wrote noise_correlation_ensemble_std.png.
+2026-07-27 03:25:25,160 - INFO - [Analyze Evaluations] noise_correlation: ensemble_std / normal_ensemble — exponential fit R²=0.044, Spearman ρ=-0.206.
+2026-07-27 03:25:25,162 - INFO - [Analyze Evaluations] noise_correlation: ensemble_std / bayesian_ensemble — exponential fit R²=0.389, Spearman ρ=-0.598.
+2026-07-27 03:25:25,641 - INFO - [Analyze Evaluations] noise_correlation: wrote noise_correlation_ensemble_mutual_information.png.
+2026-07-27 03:25:25,647 - INFO - [Analyze Evaluations] noise_correlation: ensemble_mutual_information / normal_ensemble — exponential fit R²=0.001, Spearman ρ=0.004.
+2026-07-27 03:25:25,648 - INFO - [Analyze Evaluations] noise_correlation: ensemble_mutual_information / bayesian_ensemble — exponential fit R²=0.393, Spearman ρ=-0.629.
+2026-07-27 03:25:25,990 - INFO - [Analyze Evaluations] noise_correlation: wrote noise_correlation_mean_mutual_information.png.
+2026-07-27 03:25:25,997 - INFO - [Analyze Evaluations] noise_correlation: mean_mutual_information / bayesian_ensemble — exponential fit R²=0.270, Spearman ρ=-0.507.
+2026-07-27 03:25:25,997 - INFO - [Analyze Evaluations] noise_correlation: mean_mutual_information / bayesian_single — exponential fit R²=0.494, Spearman ρ=-0.666.
+2026-07-27 03:25:26,000 - INFO - [Analyze Evaluations] Analysis artifacts written to outputs/exp1/analysis/.
+2026-07-27 03:25:26,003 - INFO - [Analyze Evaluations] Task completed.
+2026-07-27 03:25:26,005 - INFO - [SYSTEM] All tasks finished successfully!

+ 134 - 0
analysis/__init__.py

@@ -0,0 +1,134 @@
+"""Analysis layer (pipeline step 7).
+
+Reads ONLY the evaluation netCDF files in ``<work_dir>/evaluations/`` and writes
+artifacts to ``<work_dir>/analysis/``. Because analyses are pure functions of the
+schema-locked datasets, a rerun is cheap and can only overwrite regeneratable
+output -- never models or evaluations.
+
+See ``ai/ANALYSIS_PLAN.md`` for the scientific definitions behind each analysis.
+
+Adding an analysis:
+    1. create ``analysis/plots/<name>.py`` with a ``@register``-decorated function
+       taking an :class:`~analysis.context.AnalysisContext`;
+    2. import it in ``analysis/plots/__init__.py``.
+"""
+
+import pathlib as pl
+from typing import Any, Dict, Optional
+
+import xarray as xr
+
+from evaluation import load_evaluation
+from util.ui_logger import PipelineLogger
+
+EVAL_SUBDIR = "evaluations"
+ANALYSIS_SUBDIR = "analysis"
+
+__all__ = [
+    "ANALYSIS_SUBDIR",
+    "EVAL_SUBDIR",
+    "get_analyses",
+    "load_evaluations",
+    "run_all",
+]
+
+
+def get_analyses() -> Dict[str, Any]:
+    """Every registered analysis, keyed by name.
+
+    Importing ``analysis.plots`` is what runs the ``@register`` decorators, and
+    it is done lazily (here and in :func:`run_all`) so that submodules can import
+    from this package without forming an import cycle.
+    """
+    from analysis import plots as _plots  # noqa: F401  (import for registration)
+    from analysis.registry import ANALYSES
+
+    return dict(ANALYSES)
+
+
+def load_evaluations(
+    eval_dir: pl.Path, log: PipelineLogger
+) -> Dict[str, xr.Dataset]:
+    """Load every ``*.nc`` in ``eval_dir``, keyed by file stem."""
+    datasets: Dict[str, xr.Dataset] = {}
+    for nc in sorted(eval_dir.glob("*.nc")):
+        try:
+            ds = load_evaluation(str(nc))
+        except Exception as exc:  # noqa: BLE001 - report and skip a bad file
+            log.error(f"Could not load evaluation '{nc.name}': {exc}")
+            continue
+        datasets[nc.stem] = ds
+        log.info(f"Loaded evaluation '{nc.stem}' with dims {dict(ds.sizes)}.")
+    return datasets
+
+
+def run_all(
+    work_dir: pl.Path,
+    log: PipelineLogger,
+    config: Optional[Dict[str, Any]] = None,
+    progress: Optional[Any] = None,
+) -> None:
+    """Run every registered analysis over ``work_dir``'s evaluations.
+
+    Analyses are independent: one failing is reported and does not prevent the
+    rest from running. Analyses declaring ``requires_noise`` are skipped with an
+    explanatory message when no noise sweep is present.
+    """
+    # Lazy imports: keeps this module free of submodule dependencies at import
+    # time, so `analysis.context` etc. can import from the package cleanly.
+    from analysis import plots as _plots  # noqa: F401  (import for registration)
+    from analysis.context import AnalysisContext
+    from analysis.registry import ANALYSES
+
+    eval_dir = work_dir / EVAL_SUBDIR
+    out_dir = work_dir / ANALYSIS_SUBDIR
+
+    if not eval_dir.is_dir():
+        log.error(
+            f"No evaluations directory at {eval_dir}. Run an evaluation scenario "
+            "first (or point the working directory at one that has evaluations)."
+        )
+        return
+
+    datasets = load_evaluations(eval_dir, log)
+    if not datasets:
+        log.error(f"No evaluation .nc files found in {eval_dir}; nothing to analyze.")
+        return
+
+    out_dir.mkdir(parents=True, exist_ok=True)
+    ctx = AnalysisContext(datasets, out_dir, log, config)
+
+    try:
+        if not ANALYSES:
+            log.error("No analyses are registered; nothing to do.")
+            return
+
+        if progress is not None:
+            progress.update(total=len(ANALYSES), advance=0)
+
+        for spec in ANALYSES.values():
+            if spec.requires_noise and not ctx.has_noise:
+                log.info(
+                    f"Skipping '{spec.name}': needs a noise sweep, but no "
+                    "multi-noise-level evaluation was found."
+                )
+                if progress is not None:
+                    progress.update(advance=1)
+                continue
+
+            marker = f" [{', '.join(spec.tags)}]" if spec.tags else ""
+            log.info(f"Running analysis: {spec.name}{marker} — {spec.title}")
+            try:
+                spec.fn(ctx)
+            except NotImplementedError:
+                log.info(f"'{spec.name}' is scaffolded but not implemented yet.")
+            except Exception as exc:  # noqa: BLE001 - keep other analyses running
+                log.error(f"Analysis '{spec.name}' failed: {exc}")
+                log.file_logger.error(f"Analysis '{spec.name}' failed", exc_info=True)
+            if progress is not None:
+                progress.update(advance=1)
+
+        log.info(f"Analysis artifacts written to {out_dir}/.")
+    finally:
+        for ds in datasets.values():
+            ds.close()

+ 193 - 0
analysis/context.py

@@ -0,0 +1,193 @@
+"""Shared state and data access for analyses.
+
+``AnalysisContext`` owns the loaded evaluation datasets and answers the questions
+every analysis needs -- which file represents a family, which members belong to
+it, how to slice a noise level -- so no analysis re-derives that logic and two
+analyses can never silently disagree about what "the normal ensemble" means.
+
+The ``Series`` dataclass names the family x configuration combinations that all
+analyses iterate, together with their visual encoding.
+"""
+
+import pathlib as pl
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional, Tuple
+
+import numpy as np
+import xarray as xr
+
+from analysis.sources import (
+    FAMILY_LABELS,
+    baseline_noise_index,
+    ordered_families,
+    select_clean_sources,
+    select_noise_sources,
+)
+from evaluation import CLASS_NAMES, MODEL_KIND_BAYESIAN, MODEL_KIND_NORMAL
+from util.ui_logger import PipelineLogger
+
+#: Ensemble configurations analysed side by side.
+CONFIG_ENSEMBLE = "ensemble"
+CONFIG_SINGLE = "single"
+CONFIG_ORDER = [CONFIG_ENSEMBLE, CONFIG_SINGLE]
+
+CONFIG_LABELS = {
+    CONFIG_ENSEMBLE: "ensemble",
+    CONFIG_SINGLE: "single",
+}
+
+#: Family -> hue (dataviz categorical slots, CVD-safe in this fixed order).
+FAMILY_COLORS = {
+    MODEL_KIND_NORMAL: "#003f5c",
+    MODEL_KIND_BAYESIAN: "#ffa600",
+}
+
+#: Configuration -> line style. Identity is never carried by colour alone.
+CONFIG_LINESTYLES = {
+    CONFIG_ENSEMBLE: "-",
+    CONFIG_SINGLE: "--",
+}
+
+
+@dataclass(frozen=True)
+class Series:
+    """One family x configuration combination, with its visual encoding."""
+
+    family: str
+    config: str
+
+    @property
+    def key(self) -> str:
+        return f"{self.family}_{self.config}"
+
+    @property
+    def label(self) -> str:
+        return f"{FAMILY_LABELS.get(self.family, self.family)} — {CONFIG_LABELS[self.config]}"
+
+    @property
+    def color(self) -> str:
+        return FAMILY_COLORS.get(self.family, "#666666")
+
+    @property
+    def linestyle(self) -> str:
+        return CONFIG_LINESTYLES[self.config]
+
+    @property
+    def is_ensemble(self) -> bool:
+        return self.config == CONFIG_ENSEMBLE
+
+
+class AnalysisContext:
+    """Loaded evaluations plus the selection logic analyses share."""
+
+    def __init__(
+        self,
+        datasets: Dict[str, xr.Dataset],
+        out_dir: pl.Path,
+        log: PipelineLogger,
+        config: Optional[Dict[str, Any]] = None,
+    ) -> None:
+        self.datasets = datasets
+        self.out_dir = out_dir
+        self.log = log
+        self.config: Dict[str, Any] = config or {}
+        self._clean = select_clean_sources(datasets)
+        self._noise = select_noise_sources(datasets)
+
+    # -- source selection --------------------------------------------------
+    @property
+    def families(self) -> List[str]:
+        """Families present in the clean evaluations, in canonical order."""
+        return ordered_families(self._clean.keys())
+
+    @property
+    def noise_families(self) -> List[str]:
+        """Families that have a noise sweep available."""
+        return ordered_families(self._noise.keys())
+
+    def clean_source(self, family: str) -> Optional[Tuple[str, xr.Dataset]]:
+        """Best baseline evaluation for ``family`` as ``(stem, dataset)``."""
+        return self._clean.get(family)
+
+    def noise_source(self, family: str) -> Optional[Tuple[str, xr.Dataset]]:
+        """Best noise-sweep evaluation for ``family`` as ``(stem, dataset)``."""
+        return self._noise.get(family)
+
+    @property
+    def has_noise(self) -> bool:
+        return bool(self._noise)
+
+    def series(self, families: Optional[List[str]] = None) -> List[Series]:
+        """All family x configuration series, in canonical order."""
+        chosen = families if families is not None else self.families
+        return [
+            Series(family=family, config=config)
+            for family in ordered_families(chosen)
+            for config in CONFIG_ORDER
+        ]
+
+    # -- data access -------------------------------------------------------
+    def member_positions(self, family: str, ds: xr.Dataset) -> List[int]:
+        """Indices along the ``model`` axis belonging to ``family``."""
+        kinds = [str(k) for k in np.atleast_1d(ds["model_kind"].values)]
+        return [i for i, k in enumerate(kinds) if k == family]
+
+    def probs(
+        self, family: str, ds: xr.Dataset, noise_index: Optional[int] = None
+    ) -> np.ndarray:
+        """Member probabilities ``(M, S, C)`` for ``family`` at one noise level.
+
+        ``noise_index`` defaults to the clean baseline.
+        """
+        if noise_index is None:
+            noise_index = baseline_noise_index(ds)
+        values = np.asarray(
+            ds["prob"].isel(noise_level=noise_index).values, dtype=float
+        )
+        return values[self.member_positions(family, ds)]
+
+    def member_stat(
+        self,
+        name: str,
+        family: str,
+        ds: xr.Dataset,
+        noise_index: Optional[int] = None,
+    ) -> np.ndarray:
+        """A stored per-member statistic ``(M, S)``.
+
+        ``name`` is a schema variable such as ``predictive_entropy`` or
+        ``mutual_information``.
+        """
+        if noise_index is None:
+            noise_index = baseline_noise_index(ds)
+        values = np.asarray(
+            ds[name].isel(noise_level=noise_index).values, dtype=float
+        )
+        return values[self.member_positions(family, ds)]
+
+    @staticmethod
+    def true_index(ds: xr.Dataset) -> np.ndarray:
+        """Ground-truth class indices ``(S,)`` aligned with ``CLASS_NAMES``."""
+        return np.array(
+            [
+                CLASS_NAMES.index(str(t))
+                for t in np.atleast_1d(ds["true_class"].values)
+            ],
+            dtype=int,
+        )
+
+    @staticmethod
+    def noise_levels(ds: xr.Dataset) -> np.ndarray:
+        return np.asarray(ds["noise_level"].values, dtype=float)
+
+    @staticmethod
+    def ptids(ds: xr.Dataset) -> np.ndarray:
+        return np.asarray(ds["ptid"].values).astype(str)
+
+    def base_seed(self) -> int:
+        """Experiment seed recorded in the evaluations (0 when absent)."""
+        for ds in self.datasets.values():
+            seed = ds.attrs.get("seed")
+            if seed is not None:
+                return int(seed)
+        return 0

+ 212 - 0
analysis/measures.py

@@ -0,0 +1,212 @@
+"""Uncertainty measures derived from ensemble member probabilities.
+
+Definitions follow ai/ANALYSIS_PLAN.md section 2.
+
+All measures take member probabilities ``probs`` of shape ``(M, S, C)`` -- M
+ensemble members, S samples, C classes -- and return a per-sample uncertainty
+``(S,)`` where **higher means more uncertain**. A ``single``-model configuration
+passes ``M = 1``.
+
+Key identity that keeps the two families consistent
+---------------------------------------------------
+The stored ``predictive_entropy`` of member *m* equals ``H(probs[m])``, because
+``prob`` is already the MC-mean for a Bayesian member and the plain softmax for a
+deterministic one. So the ensemble decomposition
+
+    H(mean_m p_m)          = total       (mean_output_entropy)
+    mean_m H(p_m)          = aleatoric   (mean_predictive_entropy)
+    total - aleatoric      = epistemic   (ensemble_mutual_information)
+
+holds for BOTH families, computed from ``prob`` alone. ``mean_mutual_information``
+is different: it averages each member's *within-member* MC epistemic term, which
+is identically zero for deterministic models.
+"""
+
+from dataclasses import dataclass
+from typing import Callable, Dict, List, Optional
+
+import numpy as np
+
+from analysis.context import CONFIG_SINGLE
+from evaluation import MODEL_KIND_BAYESIAN
+
+#: Numerical floor for logarithms, matching bayesian_torch's convention.
+EPS = 1e-15
+
+
+# ---------------------------------------------------------------------------
+# Primitive
+# ---------------------------------------------------------------------------
+def entropy(prob: np.ndarray) -> np.ndarray:
+    """Shannon entropy along the last axis: ``-sum(p * log(p))``.
+
+    Args:
+        prob: Probabilities with classes on the last axis, e.g. ``(..., C)``.
+
+    Returns:
+        Entropy with the class axis removed. Natural log, so a uniform binary
+        distribution gives ``ln 2 ~= 0.693``.
+    """
+    p = np.asarray(prob, dtype=float)
+    return -np.sum(p * np.log(p + EPS), axis=-1)
+
+
+def predicted_class(probs: np.ndarray) -> np.ndarray:
+    """Ensemble prediction ``(S,)``: argmax of the mean member probability."""
+    return np.asarray(probs, dtype=float).mean(axis=0).argmax(axis=1)
+
+
+# ---------------------------------------------------------------------------
+# Measures -- each returns (S,), higher = more uncertain
+# ---------------------------------------------------------------------------
+def mean_output_entropy(probs: np.ndarray, **_: object) -> np.ndarray:
+    """Total uncertainty: entropy of the ensemble mean output, ``H(p_bar)``."""
+    return entropy(np.asarray(probs, dtype=float).mean(axis=0))
+
+
+def mean_predictive_entropy(probs: np.ndarray, **_: object) -> np.ndarray:
+    """Aleatoric: mean of the members' own entropies, ``mean_m H(p_m)``.
+
+    Equal to :func:`mean_output_entropy` when ``M == 1`` (hence skipped for the
+    ``single`` configuration -- see :data:`MEASURES`).
+    """
+    return entropy(np.asarray(probs, dtype=float)).mean(axis=0)
+
+
+def ensemble_std(probs: np.ndarray, **_: object) -> np.ndarray:
+    """Member disagreement: ``std_m(p_m[y_hat])`` on the ensemble-predicted class.
+
+    Measures how much the members disagree about the probability they assign to
+    the class the ensemble ultimately predicts. Identically 0 for a single model;
+    the applicability matrix excludes it there.
+    """
+    values = np.asarray(probs, dtype=float)
+    y_hat = predicted_class(values)  # (S,)
+    # Take each member's probability for the ensemble-predicted class -> (M, S).
+    indices = np.broadcast_to(
+        y_hat[None, :, None], (values.shape[0], values.shape[1], 1)
+    )
+    member_prob = np.take_along_axis(values, indices, axis=2)[:, :, 0]
+    return member_prob.std(axis=0)
+
+
+def ensemble_mutual_information(probs: np.ndarray, **_: object) -> np.ndarray:
+    """Epistemic from disagreement: total minus aleatoric (Jensen-Shannon).
+
+    ``H(p_bar) - mean_m H(p_m)`` -- non-negative by Jensen's inequality, and
+    identically zero when ``M == 1``.
+    """
+    return mean_output_entropy(probs) - mean_predictive_entropy(probs)
+
+
+def mean_mutual_information(
+    probs: np.ndarray, member_mi: Optional[np.ndarray] = None, **_: object
+) -> np.ndarray:
+    """Within-member MC epistemic: ``mean_m MI_m`` from the stored statistic.
+
+    Args:
+        probs: Unused; kept for a uniform measure signature.
+        member_mi: Stored ``mutual_information`` of shape ``(M, S)``.
+
+    Bayesian only -- identically zero for deterministic members (K = 1).
+    """
+    if member_mi is None:
+        raise ValueError(
+            "mean_mutual_information needs the stored per-member "
+            "`mutual_information` (M, S); pass member_mi=..."
+        )
+    return np.asarray(member_mi, dtype=float).mean(axis=0)
+
+
+# ---------------------------------------------------------------------------
+# Registry + applicability
+# ---------------------------------------------------------------------------
+MeasureFn = Callable[..., np.ndarray]
+
+
+@dataclass(frozen=True)
+class Measure:
+    """An uncertainty measure and where it is scientifically meaningful."""
+
+    name: str
+    label: str
+    fn: MeasureFn
+    #: Needs more than one member (excludes the ``single`` configuration).
+    requires_ensemble: bool = False
+    #: Restricted to these families (empty = all).
+    families: tuple[str, ...] = ()
+    #: Needs the stored per-member ``mutual_information`` variable.
+    needs_member_mi: bool = False
+    #: Redundant with ``mean_output_entropy`` when M == 1.
+    duplicate_when_single: bool = False
+
+    def applies_to(self, family: str, config: str) -> bool:
+        """Whether this measure is meaningful for a family x configuration."""
+        if self.families and family not in self.families:
+            return False
+        if config == CONFIG_SINGLE and (
+            self.requires_ensemble or self.duplicate_when_single
+        ):
+            return False
+        return True
+
+
+#: All uncertainty measures, in presentation order.
+MEASURES: Dict[str, Measure] = {
+    "mean_output_entropy": Measure(
+        name="mean_output_entropy",
+        label="Mean-output entropy (total)",
+        fn=mean_output_entropy,
+    ),
+    "mean_predictive_entropy": Measure(
+        name="mean_predictive_entropy",
+        label="Mean predictive entropy (aleatoric)",
+        fn=mean_predictive_entropy,
+        duplicate_when_single=True,
+    ),
+    "ensemble_std": Measure(
+        name="ensemble_std",
+        label="Ensemble std. dev. (disagreement)",
+        fn=ensemble_std,
+        requires_ensemble=True,
+    ),
+    "ensemble_mutual_information": Measure(
+        name="ensemble_mutual_information",
+        label="Ensemble mutual information (epistemic)",
+        fn=ensemble_mutual_information,
+        requires_ensemble=True,
+    ),
+    "mean_mutual_information": Measure(
+        name="mean_mutual_information",
+        label="Mean MC mutual information (epistemic)",
+        fn=mean_mutual_information,
+        families=(MODEL_KIND_BAYESIAN,),
+        needs_member_mi=True,
+    ),
+}
+
+
+def measures_for(family: str, config: str) -> List[Measure]:
+    """Measures that are meaningful for one family x configuration."""
+    return [m for m in MEASURES.values() if m.applies_to(family, config)]
+
+
+def compute(
+    measure: Measure,
+    probs: np.ndarray,
+    member_mi: Optional[np.ndarray] = None,
+) -> np.ndarray:
+    """Evaluate ``measure`` on member probabilities, supplying what it needs.
+
+    Args:
+        measure: The measure to evaluate.
+        probs: Member probabilities ``(M, S, C)``.
+        member_mi: Stored per-member ``mutual_information`` ``(M, S)``; required
+            only by measures with ``needs_member_mi``.
+
+    Returns:
+        Per-sample uncertainty ``(S,)``, higher = more uncertain.
+    """
+    if measure.needs_member_mi:
+        return measure.fn(probs, member_mi=member_mi)
+    return measure.fn(probs)

+ 274 - 0
analysis/metrics.py

@@ -0,0 +1,274 @@
+"""Performance metrics and curve fitting.
+
+Definitions follow ai/ANALYSIS_PLAN.md section 3.
+
+Pure numpy/scipy: no plotting, no file I/O, no xarray. This is the part that has
+to be numerically correct, so it is kept independently testable.
+"""
+
+from dataclasses import dataclass, field
+from typing import Any, Callable, Dict, List, Sequence, Tuple
+
+import numpy as np
+
+from evaluation import CLASS_NAMES
+
+# numpy>=2 renamed trapz -> trapezoid; support both without a static reference to
+# whichever name is missing in the installed version.
+_trapezoid: Callable[..., Any] = getattr(np, "trapezoid", None) or getattr(np, "trapz")
+
+#: Positive class for binary F1 (disease detection is the meaningful direction).
+POSITIVE_CLASS = "AD"
+POSITIVE_INDEX = CLASS_NAMES.index(POSITIVE_CLASS)
+
+#: Default coverage grid: keep the most-confident 5%, 10%, ... 100%.
+DEFAULT_COVERAGE_GRID: Tuple[float, ...] = tuple(
+    round(0.05 * i, 2) for i in range(1, 21)
+)
+
+#: A coverage point with fewer retained samples than this is reported as NaN.
+MIN_COVERAGE_SAMPLES = 10
+
+
+# ---------------------------------------------------------------------------
+# Classification metrics
+# ---------------------------------------------------------------------------
+def accuracy(pred: np.ndarray, true: np.ndarray) -> float:
+    """Fraction of correct predictions; ``nan`` for an empty input."""
+    pred = np.asarray(pred)
+    true = np.asarray(true)
+    if pred.size == 0:
+        return float("nan")
+    return float(np.mean(pred == true))
+
+
+def f1_score(
+    pred: np.ndarray, true: np.ndarray, positive: int = POSITIVE_INDEX
+) -> float:
+    """Binary F1 for the ``positive`` class: ``2TP / (2TP + FP + FN)``.
+
+    Returns ``nan`` when F1 is genuinely undefined -- no true positives *and* no
+    predicted positives -- rather than the misleading 0.0 some implementations
+    report. A subset containing only true negatives is "no evidence", not
+    "perfectly wrong".
+    """
+    pred = np.asarray(pred)
+    true = np.asarray(true)
+    if pred.size == 0:
+        return float("nan")
+
+    predicted_positive = pred == positive
+    actually_positive = true == positive
+    tp = int(np.sum(predicted_positive & actually_positive))
+    fp = int(np.sum(predicted_positive & ~actually_positive))
+    fn = int(np.sum(~predicted_positive & actually_positive))
+
+    denominator = 2 * tp + fp + fn
+    if denominator == 0:  # no positives predicted and none present
+        return float("nan")
+    return float(2 * tp / denominator)
+
+
+def macro_f1(pred: np.ndarray, true: np.ndarray) -> float:
+    """Unweighted mean of the per-class F1 scores (alternative to binary F1)."""
+    scores = [
+        f1_score(pred, true, positive=index)
+        for index in range(len(CLASS_NAMES))
+    ]
+    finite = [s for s in scores if np.isfinite(s)]
+    return float(np.mean(finite)) if finite else float("nan")
+
+
+# ---------------------------------------------------------------------------
+# Coverage / risk-coverage
+# ---------------------------------------------------------------------------
+@dataclass
+class CoverageCurve:
+    """Accuracy and F1 as a function of retained (most-confident) fraction."""
+
+    coverage: List[float] = field(default_factory=list)
+    accuracy: List[float] = field(default_factory=list)
+    f1: List[float] = field(default_factory=list)
+    n_retained: List[int] = field(default_factory=list)
+    aurc: float = float("nan")
+
+
+def coverage_curve(
+    uncertainty: np.ndarray,
+    pred: np.ndarray,
+    true: np.ndarray,
+    grid: Sequence[float] = DEFAULT_COVERAGE_GRID,
+    min_samples: int = MIN_COVERAGE_SAMPLES,
+) -> CoverageCurve:
+    """Accuracy/F1 after discarding the most uncertain predictions.
+
+    Samples are ranked by ``uncertainty`` ascending (most confident first) with a
+    stable sort, so ties resolve deterministically. For each fraction in ``grid``
+    the leading portion is retained and scored.
+
+    Args:
+        uncertainty: Per-sample uncertainty ``(S,)``; higher = less confident.
+        pred: Predicted class indices ``(S,)``.
+        true: Ground-truth class indices ``(S,)``.
+        grid: Retained fractions in (0, 1].
+        min_samples: Points retaining fewer samples than this report ``nan``.
+
+    Returns:
+        A :class:`CoverageCurve`, including its AURC summary.
+    """
+    uncertainty = np.asarray(uncertainty, dtype=float)
+    pred = np.asarray(pred)
+    true = np.asarray(true)
+    n_samples = uncertainty.size
+
+    # Ascending uncertainty = most confident first. A stable sort makes ties
+    # resolve by original sample order, so the curve is reproducible.
+    order = np.argsort(uncertainty, kind="stable")
+
+    curve = CoverageCurve()
+    for fraction in grid:
+        n_keep = int(np.clip(round(float(fraction) * n_samples), 0, n_samples))
+        retained = order[:n_keep]
+        if n_keep < min_samples:
+            acc = float("nan")
+            f1 = float("nan")
+        else:
+            acc = accuracy(pred[retained], true[retained])
+            f1 = f1_score(pred[retained], true[retained])
+        curve.coverage.append(float(fraction))
+        curve.accuracy.append(acc)
+        curve.f1.append(f1)
+        curve.n_retained.append(n_keep)
+
+    curve.aurc = aurc(curve.coverage, curve.accuracy)
+    return curve
+
+
+def aurc(coverage: Sequence[float], accuracy_values: Sequence[float]) -> float:
+    """Area under the risk-coverage curve (risk = 1 - accuracy); lower is better.
+
+    Integrated with the trapezoid rule over the finite points and normalised by
+    the covered range, so curves whose low-coverage end was suppressed by the
+    ``min_samples`` guard remain comparable.
+    """
+    cov = np.asarray(coverage, dtype=float)
+    acc = np.asarray(accuracy_values, dtype=float)
+    finite = np.isfinite(acc)
+    if finite.sum() < 2:
+        return float("nan")
+
+    cov = cov[finite]
+    acc = acc[finite]
+    span = float(cov[-1] - cov[0])
+    if span <= 0:
+        return float("nan")
+    return float(_trapezoid(1.0 - acc, cov) / span)
+
+
+# ---------------------------------------------------------------------------
+# Binning + curve fitting (noise correlation)
+# ---------------------------------------------------------------------------
+def quantile_bins(
+    uncertainty: np.ndarray, n_bins: int
+) -> List[np.ndarray]:
+    """Split sample indices into ``n_bins`` equal-count uncertainty bins.
+
+    Equal-count (quantile) bins rather than equal-width, so every point in the
+    scatter carries a comparable number of samples.
+    """
+    order = np.argsort(np.asarray(uncertainty, dtype=float), kind="stable")
+    if order.size == 0 or n_bins < 1:
+        return []
+    return [chunk for chunk in np.array_split(order, n_bins) if chunk.size > 0]
+
+
+@dataclass
+class FitResult:
+    """Outcome of fitting accuracy as a function of uncertainty."""
+
+    model: str = "none"
+    params: Dict[str, float] = field(default_factory=dict)
+    r_squared: float = float("nan")
+    spearman: float = float("nan")
+    #: Points used, for plotting the fitted line.
+    x_fit: List[float] = field(default_factory=list)
+    y_fit: List[float] = field(default_factory=list)
+
+
+def fit_accuracy_vs_uncertainty(
+    uncertainty: np.ndarray,
+    accuracy_values: np.ndarray,
+    model: str = "exponential",
+) -> FitResult:
+    """Fit accuracy as a decreasing function of uncertainty.
+
+    ``exponential``: ``acc = a + b * exp(-k * u)`` -- bounded and monotone, the
+    expected shape. Falls back to a linear fit if the optimiser fails to
+    converge. Reports R^2 and Spearman rho; rho is the robust headline because it
+    does not assume the functional form.
+    """
+    from scipy.optimize import curve_fit  # local: keeps import cost off startup
+    from scipy.stats import spearmanr
+
+    x = np.asarray(uncertainty, dtype=float)
+    y = np.asarray(accuracy_values, dtype=float)
+    finite = np.isfinite(x) & np.isfinite(y)
+    x, y = x[finite], y[finite]
+
+    result = FitResult()
+    if x.size < 3:
+        return result  # not enough points to say anything
+
+    if x.size >= 3 and np.ptp(x) > 0:
+        rho = float(np.asarray(spearmanr(x, y)).ravel()[0])
+        result.spearman = rho if np.isfinite(rho) else float("nan")
+
+    def _exponential(u: np.ndarray, a: float, b: float, k: float) -> np.ndarray:
+        return a + b * np.exp(-k * u)
+
+    fitted: np.ndarray | None = None
+    if model == "exponential" and x.size >= 4 and np.ptp(x) > 0:
+        try:
+            scale = float(np.mean(x)) or 1.0
+            guess = (float(np.min(y)), float(np.ptp(y)) or 0.1, 1.0 / scale)
+            params, _ = curve_fit(_exponential, x, y, p0=guess, maxfev=10000)
+            fitted = _exponential(x, *params)
+            result.model = "exponential"
+            result.params = {
+                "a": float(params[0]),
+                "b": float(params[1]),
+                "k": float(params[2]),
+            }
+        except Exception:  # noqa: BLE001 - fall back to a linear fit
+            fitted = None
+
+    if fitted is None and np.ptp(x) > 0:
+        slope, intercept = np.polyfit(x, y, 1)
+        fitted = slope * x + intercept
+        result.model = "linear"
+        result.params = {"slope": float(slope), "intercept": float(intercept)}
+
+    if fitted is None:
+        return result
+
+    result.r_squared = r_squared(y, fitted)
+    # A smooth line for plotting, evaluated on a dense grid over the fitted range.
+    dense = np.linspace(float(x.min()), float(x.max()), 100)
+    if result.model == "exponential":
+        curve = _exponential(dense, *result.params.values())
+    else:
+        curve = result.params["slope"] * dense + result.params["intercept"]
+    result.x_fit = [float(v) for v in dense]
+    result.y_fit = [float(v) for v in curve]
+    return result
+
+
+def r_squared(observed: np.ndarray, predicted: np.ndarray) -> float:
+    """Coefficient of determination between observed and fitted values."""
+    obs = np.asarray(observed, dtype=float)
+    pred = np.asarray(predicted, dtype=float)
+    ss_tot = float(np.sum((obs - obs.mean()) ** 2))
+    if ss_tot <= 0:
+        return float("nan")
+    ss_res = float(np.sum((obs - pred) ** 2))
+    return float(1.0 - ss_res / ss_tot)

+ 16 - 0
analysis/plots/__init__.py

@@ -0,0 +1,16 @@
+"""Individual analyses.
+
+Importing this package runs each module's ``@register`` decorator, which is what
+populates :data:`analysis.registry.ANALYSES`. Import order is run order, so the
+cheap summaries come first and the heavier noise analyses last.
+
+To add an analysis: create a module here with a ``@register``-decorated function
+taking an :class:`~analysis.context.AnalysisContext`, then import it below.
+"""
+
+from analysis.plots import model_report  # noqa: F401
+from analysis.plots import ensemble_size  # noqa: F401
+from analysis.plots import bootstrap_ci  # noqa: F401
+from analysis.plots import coverage  # noqa: F401
+from analysis.plots import noise_performance  # noqa: F401
+from analysis.plots import noise_correlation  # noqa: F401

+ 137 - 0
analysis/plots/bootstrap_ci.py

@@ -0,0 +1,137 @@
+"""Confidence intervals for full-ensemble accuracy.
+
+Patient-clustered percentile bootstrap: whole PATIENTS are resampled with
+replacement, not individual scans, because scans from one patient are correlated
+and a per-scan bootstrap would understate the interval. Holds the trained models
+fixed, so it captures test-set sampling noise only -- an honest lower bound on
+total uncertainty (it excludes training/initialisation variability).
+"""
+
+from typing import Any, Dict
+
+import numpy as np
+
+from analysis.plotting import panel_grid, save_figure, save_json, style_axes
+from analysis.sources import FAMILY_LABELS, ordered_families
+from analysis.context import AnalysisContext, FAMILY_COLORS
+from analysis.registry import register
+
+BOOTSTRAP_ITERS = 2000
+CI_LEVEL = 0.95
+
+
+def _bootstrap_accuracy_ci(
+    correct: np.ndarray,
+    ptids: np.ndarray,
+    iters: int,
+    ci_level: float,
+    rng: np.random.Generator,
+) -> Dict[str, Any]:
+    """Patient-clustered percentile bootstrap CI for a mean-accuracy statistic.
+
+    Reduces to an ordinary bootstrap when every patient has exactly one scan.
+    """
+    point = float(np.mean(correct)) if correct.size else 0.0
+    _, inverse = np.unique(ptids, return_inverse=True)
+    n_patients = int(inverse.max()) + 1 if inverse.size else 0
+    groups = [np.where(inverse == p)[0] for p in range(n_patients)]
+
+    boot = np.empty(iters, dtype=float)
+    for b in range(iters):
+        chosen = rng.integers(0, n_patients, size=n_patients)
+        boot[b] = correct[np.concatenate([groups[c] for c in chosen])].mean()
+
+    tail = (1.0 - ci_level) / 2.0
+    return {
+        "point": point,
+        "ci_low": float(np.quantile(boot, tail)),
+        "ci_high": float(np.quantile(boot, 1.0 - tail)),
+        "n_samples": int(correct.size),
+        "n_patients": n_patients,
+    }
+
+
+@register(
+    "bootstrap_ensemble_ci",
+    title="Bootstrap confidence intervals for ensemble accuracy",
+)
+def bootstrap_ensemble_ci(ctx: AnalysisContext) -> None:
+    base_seed = ctx.base_seed()
+
+    results: Dict[str, Dict[str, Any]] = {}
+    for index, family in enumerate(ctx.families):
+        selected = ctx.clean_source(family)
+        if selected is None:
+            continue
+        stem, ds = selected
+        probs = ctx.probs(family, ds)
+        if probs.shape[0] < 1:
+            continue
+        # Full-ensemble prediction: argmax of the mean over ALL members.
+        correct = (
+            probs.mean(axis=0).argmax(axis=1) == ctx.true_index(ds)
+        ).astype(float)
+        rng = np.random.default_rng([base_seed, 1000 + index])
+        ci = _bootstrap_accuracy_ci(
+            correct, ctx.ptids(ds), BOOTSTRAP_ITERS, CI_LEVEL, rng
+        )
+        ci["source"] = stem
+        results[family] = ci
+        ctx.log.info(
+            f"bootstrap_ensemble_ci: {family} full-ensemble acc = "
+            f"{ci['point'] * 100:.1f}% (95% CI {ci['ci_low'] * 100:.1f}-"
+            f"{ci['ci_high'] * 100:.1f}%, n={ci['n_samples']} scans / "
+            f"{ci['n_patients']} patients)."
+        )
+
+    if not results:
+        ctx.log.error("bootstrap_ensemble_ci: nothing to compute.")
+        return
+
+    ordered = ordered_families(results.keys())
+    fig, axes = panel_grid(
+        1, n_cols=1, panel_size=(7.0, 1.4 + 0.8 * len(ordered))
+    )
+    ax = axes[0]
+    for row, family in enumerate(ordered):
+        ci = results[family]
+        color = FAMILY_COLORS.get(family, "#666666")
+        ax.plot(
+            [ci["ci_low"], ci["ci_high"]],
+            [row, row],
+            color=color,
+            linewidth=2.5,
+            solid_capstyle="round",
+        )
+        ax.plot([ci["point"]], [row], "o", color=color, markersize=8)
+        ax.annotate(
+            f"{ci['point'] * 100:.1f}%  "
+            f"[{ci['ci_low'] * 100:.1f}, {ci['ci_high'] * 100:.1f}]",
+            (ci["point"], row),
+            xytext=(0, 10),
+            textcoords="offset points",
+            ha="center",
+            va="bottom",
+            fontsize=9,
+            color="#222222",
+        )
+
+    ax.set_yticks(range(len(ordered)))
+    ax.set_yticklabels([FAMILY_LABELS.get(k, k) for k in ordered])
+    ax.invert_yaxis()  # first family on top
+    ax.set_xlabel("Full-ensemble accuracy (95% patient-clustered bootstrap CI)")
+    ax.set_title("Ensemble accuracy with bootstrap confidence intervals")
+    ax.margins(x=0.18, y=0.35)
+    style_axes(ax, grid_axis="x")
+    ax.spines["left"].set_visible(False)
+    ax.tick_params(left=False)
+
+    png = save_figure(fig, ctx.out_dir, "bootstrap_ci")
+    save_json(
+        {"iters": BOOTSTRAP_ITERS, "ci_level": CI_LEVEL, "families": results},
+        ctx.out_dir,
+        "bootstrap_ci",
+    )
+    ctx.log.info(
+        f"bootstrap_ensemble_ci: wrote {png.name} ({len(results)} family/families)."
+    )

+ 182 - 0
analysis/plots/coverage.py

@@ -0,0 +1,182 @@
+"""Coverage (selective-prediction) curves.
+
+Question: if the most uncertain predictions are refused, how much does quality
+improve? A useful uncertainty measure buys accuracy when you discard.
+
+Method (see ai/ANALYSIS_PLAN.md 3.1)
+-----------------------------------
+Rank samples by uncertainty ascending (most confident first, stable sort), retain
+the leading fraction c for each c in the coverage grid, and score accuracy and F1
+on the retained subset. Repeated for every family x configuration x applicable
+uncertainty measure, at the clean baseline noise level.
+
+The ``single`` configuration evaluates each member on its own and averages the
+resulting curves across members (+/-1 std band), so it is a fair "one model"
+baseline rather than an arbitrary pick.
+
+A *flat* curve is a real result: it means the measure carries no information
+about correctness. A curve rising to the left is what a well-behaved uncertainty
+measure looks like.
+
+Outputs: ``coverage_accuracy.png``, ``coverage_f1.png`` (one panel per measure,
+one line per family x configuration) and ``coverage.json`` (every curve + AURC).
+"""
+
+import warnings
+from typing import Any, Dict, List
+
+import numpy as np
+
+import analysis.measures as ms
+import analysis.metrics as mt
+from analysis.context import CONFIG_SINGLE, AnalysisContext, Series
+from analysis.plotting import add_legend, panel_grid, save_figure, save_json, style_axes
+from analysis.registry import register
+
+
+def _series_curves(
+    ctx: AnalysisContext, series: Series, measure: ms.Measure
+) -> Dict[str, Any] | None:
+    """Coverage curve(s) for one series and measure, aggregated over members."""
+    selected = ctx.clean_source(series.family)
+    if selected is None:
+        return None
+    stem, ds = selected
+
+    probs = ctx.probs(series.family, ds)  # (M, S, C)
+    if probs.shape[0] == 0:
+        return None
+    member_mi = (
+        ctx.member_stat("mutual_information", series.family, ds)
+        if measure.needs_member_mi
+        else None
+    )
+    true_idx = ctx.true_index(ds)
+
+    # ensemble -> one curve from all members; single -> one curve per member.
+    if series.config == CONFIG_SINGLE:
+        groups = [
+            (probs[m : m + 1], None if member_mi is None else member_mi[m : m + 1])
+            for m in range(probs.shape[0])
+        ]
+    else:
+        groups = [(probs, member_mi)]
+
+    curves: List[mt.CoverageCurve] = []
+    for group_probs, group_mi in groups:
+        uncertainty = ms.compute(measure, group_probs, member_mi=group_mi)
+        pred = ms.predicted_class(group_probs)
+        curves.append(mt.coverage_curve(uncertainty, pred, true_idx))
+
+    accuracy = np.array([c.accuracy for c in curves], dtype=float)
+    f1 = np.array([c.f1 for c in curves], dtype=float)
+    aurc_values = np.array([c.aurc for c in curves], dtype=float)
+
+    # Low-coverage points can be NaN for every member (the min_samples guard);
+    # averaging an all-NaN column legitimately yields NaN, so silence the notice.
+    with warnings.catch_warnings():
+        warnings.simplefilter("ignore", RuntimeWarning)
+        return {
+            "source": stem,
+            "coverage": curves[0].coverage,
+            "n_retained": curves[0].n_retained,
+            "n_curves": len(curves),
+            "accuracy": np.nanmean(accuracy, axis=0).tolist(),
+            "accuracy_std": np.nanstd(accuracy, axis=0).tolist(),
+            "f1": np.nanmean(f1, axis=0).tolist(),
+            "f1_std": np.nanstd(f1, axis=0).tolist(),
+            "aurc": float(np.nanmean(aurc_values)),
+        }
+
+
+def _plot(
+    ctx: AnalysisContext,
+    results: Dict[str, Dict[str, Dict[str, Any]]],
+    metric: str,
+    std_key: str,
+    ylabel: str,
+    stem: str,
+) -> None:
+    """One panel per measure; one line per series."""
+    panels = [name for name, per_series in results.items() if per_series]
+    if not panels:
+        return
+
+    fig, axes = panel_grid(len(panels), n_cols=2)
+    for ax, measure_name in zip(axes, panels):
+        for series_key, payload in results[measure_name].items():
+            series = payload["_series"]
+            x = np.asarray(payload["coverage"], dtype=float) * 100.0
+            y = np.asarray(payload[metric], dtype=float)
+            spread = np.asarray(payload[std_key], dtype=float)
+            if payload["n_curves"] > 1:
+                ax.fill_between(
+                    x, y - spread, y + spread,
+                    color=series.color, alpha=0.15, linewidth=0,
+                )
+            ax.plot(
+                x, y,
+                color=series.color, linestyle=series.linestyle,
+                linewidth=2, marker="o", markersize=3, label=series.label,
+            )
+        ax.set_title(ms.MEASURES[measure_name].label, fontsize=10)
+        ax.set_xlabel("Coverage: most-confident samples retained (%)")
+        ax.set_ylabel(ylabel)
+        style_axes(ax)
+
+    fig.suptitle(f"Coverage curves — {ylabel} vs. retained fraction")
+    add_legend(fig, axes)
+    png = save_figure(fig, ctx.out_dir, stem)
+    ctx.log.info(f"coverage: wrote {png.name} ({len(panels)} measure panels).")
+
+
+@register(
+    "coverage",
+    title="Coverage curves: accuracy and F1 vs. retained fraction",
+)
+def coverage(ctx: AnalysisContext) -> None:
+    # results[measure][series_key] = curve payload
+    results: Dict[str, Dict[str, Dict[str, Any]]] = {
+        name: {} for name in ms.MEASURES
+    }
+
+    for series in ctx.series():
+        for measure in ms.measures_for(series.family, series.config):
+            payload = _series_curves(ctx, series, measure)
+            if payload is None:
+                continue
+            payload["_series"] = series
+            results[measure.name][series.key] = payload
+
+    if not any(results.values()):
+        ctx.log.error("coverage: no usable evaluations.")
+        return
+
+    _plot(ctx, results, "accuracy", "accuracy_std", "Accuracy", "coverage_accuracy")
+    _plot(ctx, results, "f1", "f1_std", "F1 (AD)", "coverage_f1")
+
+    serializable = {
+        measure: {
+            key: {k: v for k, v in payload.items() if k != "_series"}
+            for key, payload in per_series.items()
+        }
+        for measure, per_series in results.items()
+        if per_series
+    }
+    save_json(
+        {
+            "positive_class": mt.POSITIVE_CLASS,
+            "grid": list(mt.DEFAULT_COVERAGE_GRID),
+            "min_samples": mt.MIN_COVERAGE_SAMPLES,
+            "measures": serializable,
+        },
+        ctx.out_dir,
+        "coverage",
+    )
+
+    for measure, per_series in serializable.items():
+        for key, payload in per_series.items():
+            ctx.log.info(
+                f"coverage: {measure} / {key} — AURC={payload['aurc']:.4f} "
+                f"(accuracy at full coverage {payload['accuracy'][-1]:.3f})."
+            )

+ 137 - 0
analysis/plots/ensemble_size.py

@@ -0,0 +1,137 @@
+"""Accuracy as a function of ensemble size.
+
+For every size 1..M, samples distinct member subsets, scores each as an ensemble
+(argmax of the mean member probability), and reports the mean accuracy with a
++/-1 std band across the sampled configurations. Answers "how many models do we
+actually need?".
+"""
+
+import itertools
+import math
+from typing import Any, Dict, List, Tuple
+
+import numpy as np
+from matplotlib.ticker import MaxNLocator
+
+from analysis.plotting import panel_grid, save_figure, save_json, style_axes
+from analysis.sources import FAMILY_LABELS, ordered_families
+from analysis.context import AnalysisContext, FAMILY_COLORS
+from analysis.registry import register
+
+#: Member-subset configurations sampled per ensemble size (all of them when the
+#: number of distinct subsets is smaller).
+TARGET_CONFIGS = 30
+
+
+def _sample_subsets(
+    n_models: int, size: int, target: int, rng: np.random.Generator
+) -> List[List[int]]:
+    """Distinct member-index subsets of ``size``.
+
+    Enumerates exhaustively when ``C(n_models, size) <= target`` (e.g. size 1 or
+    size M), otherwise draws ``target`` distinct random subsets.
+    """
+    if math.comb(n_models, size) <= target:
+        return [list(combo) for combo in itertools.combinations(range(n_models), size)]
+
+    seen: set[Tuple[int, ...]] = set()
+    subsets: List[List[int]] = []
+    while len(subsets) < target:
+        pick = tuple(
+            sorted(int(i) for i in rng.choice(n_models, size=size, replace=False))
+        )
+        if pick not in seen:
+            seen.add(pick)
+            subsets.append(list(pick))
+    return subsets
+
+
+def _size_curve(
+    probs: np.ndarray, true_idx: np.ndarray, target: int, rng: np.random.Generator
+) -> Dict[str, Any]:
+    """Mean/std accuracy over sampled member subsets, for every size 1..M."""
+    n_models = probs.shape[0]
+    sizes: List[int] = []
+    means: List[float] = []
+    stds: List[float] = []
+    n_configs: List[int] = []
+    for size in range(1, n_models + 1):
+        subsets = _sample_subsets(n_models, size, target, rng)
+        accuracies = [
+            float(np.mean(probs[subset].mean(axis=0).argmax(axis=1) == true_idx))
+            for subset in subsets
+        ]
+        acc = np.asarray(accuracies, dtype=float)
+        sizes.append(size)
+        means.append(float(acc.mean()))
+        stds.append(float(acc.std()))
+        n_configs.append(len(subsets))
+    return {"sizes": sizes, "mean": means, "std": stds, "n_configs": n_configs}
+
+
+@register(
+    "ensemble_size_accuracy",
+    title="Accuracy vs. number of ensemble members",
+)
+def ensemble_size_accuracy(ctx: AnalysisContext) -> None:
+    base_seed = ctx.base_seed()
+
+    curves: Dict[str, Dict[str, Any]] = {}
+    for index, family in enumerate(ctx.families):
+        selected = ctx.clean_source(family)
+        if selected is None:
+            continue
+        stem, ds = selected
+        probs = ctx.probs(family, ds)
+        if probs.shape[0] < 1:
+            continue
+        # Distinct, reproducible RNG stream per family for subset sampling.
+        rng = np.random.default_rng([base_seed, index])
+        curve = _size_curve(probs, ctx.true_index(ds), TARGET_CONFIGS, rng)
+        curve["source"] = stem
+        curves[family] = curve
+        ctx.log.info(
+            f"ensemble_size_accuracy: {family} - sizes 1..{probs.shape[0]}, "
+            f"up to {TARGET_CONFIGS} configs each."
+        )
+
+    if not curves:
+        ctx.log.error("ensemble_size_accuracy: no usable probabilities.")
+        return
+
+    fig, axes = panel_grid(1, n_cols=1, panel_size=(7.0, 4.3))
+    ax = axes[0]
+    for family in ordered_families(curves.keys()):
+        curve = curves[family]
+        color = FAMILY_COLORS.get(family, "#666666")
+        x = np.asarray(curve["sizes"])
+        mean = np.asarray(curve["mean"])
+        std = np.asarray(curve["std"])
+        ax.fill_between(x, mean - std, mean + std, color=color, alpha=0.18, linewidth=0)
+        ax.plot(
+            x,
+            mean,
+            color=color,
+            marker="o",
+            markersize=4,
+            linewidth=2,
+            label=FAMILY_LABELS.get(family, family),
+        )
+
+    ax.set_xlabel("Ensemble size (number of models)")
+    ax.set_ylabel("Accuracy")
+    ax.set_title("Ensemble accuracy vs. number of models")
+    ax.xaxis.set_major_locator(MaxNLocator(integer=True))
+    ax.margins(x=0.02)
+    style_axes(ax)
+    ax.legend(frameon=False, loc="lower right")
+
+    png = save_figure(fig, ctx.out_dir, "ensemble_size_accuracy")
+    save_json(
+        {"target_configs": TARGET_CONFIGS, "families": curves},
+        ctx.out_dir,
+        "ensemble_size_accuracy",
+    )
+    ctx.log.info(
+        f"ensemble_size_accuracy: wrote {png.name} ({len(curves)} family/families)."
+    )

+ 108 - 0
analysis/plots/model_report.py

@@ -0,0 +1,108 @@
+"""Per-model summary report (PDF, via Typst).
+
+A compact table of every ensemble member's accuracy and mean uncertainty, one
+section per model family, plus the ensemble headline. Data is injected into the
+Typst template as a JSON string through ``sys.inputs``.
+"""
+
+import datetime
+import json
+import pathlib as pl
+from typing import Any, Dict, List
+
+import numpy as np
+import typst
+
+from analysis.sources import FAMILY_LABELS, baseline_noise_index
+from analysis.context import AnalysisContext
+from analysis.registry import register
+
+_TEMPLATE = pl.Path(__file__).resolve().parent.parent / "templates" / "model_report.typ"
+
+
+def _family_block(ctx: AnalysisContext, family: str) -> Dict[str, Any] | None:
+    """Per-member and ensemble metrics for one family at the clean baseline."""
+    selected = ctx.clean_source(family)
+    if selected is None:
+        return None
+    stem, ds = selected
+
+    baseline = baseline_noise_index(ds)
+    positions = ctx.member_positions(family, ds)
+    correct = np.asarray(ds["correct"].isel(noise_level=baseline).values, dtype=float)
+    entropy = ctx.member_stat("predictive_entropy", family, ds, baseline)
+    mut_info = ctx.member_stat("mutual_information", family, ds, baseline)
+    labels = np.atleast_1d(ds["model"].values)
+
+    models: List[Dict[str, Any]] = []
+    accuracies: List[float] = []
+    for row, position in enumerate(positions):
+        acc = float(np.mean(correct[position]))
+        accuracies.append(acc)
+        models.append(
+            {
+                "index": int(labels[position]) + 1,  # 1-based for display
+                "accuracy": acc,
+                "mean_entropy": float(np.mean(entropy[row])),
+                "mean_mi": float(np.mean(mut_info[row])),
+            }
+        )
+
+    acc_arr = np.asarray(accuracies, dtype=float)
+    return {
+        "name": FAMILY_LABELS.get(family, family),
+        "kind": family,
+        "source": stem,
+        "split": str(ds.attrs.get("split", "unknown")),
+        "noise_sigma": float(ctx.noise_levels(ds)[baseline]),
+        "n_models": len(models),
+        "n_samples": int(ds.sizes["sample"]),
+        "n_mc": int(ds.attrs.get("n_mc", 1)),
+        "accuracy_mean": float(acc_arr.mean()) if acc_arr.size else 0.0,
+        "accuracy_std": float(acc_arr.std()) if acc_arr.size else 0.0,
+        "accuracy_best": float(acc_arr.max()) if acc_arr.size else 0.0,
+        "models": models,
+    }
+
+
+@register(
+    "model_report",
+    title="Per-model accuracy and uncertainty summary (PDF)",
+)
+def model_report(ctx: AnalysisContext) -> None:
+    families = [
+        block
+        for block in (_family_block(ctx, family) for family in ctx.families)
+        if block is not None
+    ]
+    if not families:
+        ctx.log.error("model_report: no model families found in evaluations.")
+        return
+
+    meta = next(iter(ctx.datasets.values())).attrs
+    seed = meta.get("seed")
+    payload = {
+        "title": "Model Evaluation Report",
+        "generated": datetime.datetime.now(datetime.timezone.utc).strftime(
+            "%Y-%m-%d %H:%M UTC"
+        ),
+        "work_dir": str(ctx.out_dir.parent),
+        "schema_version": str(meta.get("schema_version", "?")),
+        "seed": str(seed) if seed is not None else "n/a",
+        "git_commit": str(meta.get("git_commit", "unknown"))[:10],
+        "families": families,
+    }
+    payload_json = json.dumps(payload)
+
+    # The template parses this with `json(bytes(sys.inputs.at("data")))`.
+    pdf_bytes = typst.compile(
+        str(_TEMPLATE), sys_inputs={"data": payload_json}, format="pdf"
+    )
+    (ctx.out_dir / "model_report.pdf").write_bytes(pdf_bytes)
+    (ctx.out_dir / "model_report.json").write_text(payload_json)
+
+    total_models = sum(f["n_models"] for f in families)
+    ctx.log.info(
+        f"model_report: wrote model_report.pdf "
+        f"({len(families)} family/families, {total_models} model(s))."
+    )

+ 232 - 0
analysis/plots/noise_correlation.py

@@ -0,0 +1,232 @@
+"""Uncertainty vs. accuracy across noise conditions, with a fitted curve.
+
+Question: is uncertainty a *transferable* predictor of error -- does a given
+uncertainty value imply the same accuracy regardless of the noise condition?
+
+Method (see ai/ANALYSIS_PLAN.md 3.3)
+-----------------------------------
+For each noise level sigma, split its samples into ``N_BINS`` equal-count
+(quantile) uncertainty bins and plot one point per bin: mean uncertainty in the
+bin (x) against accuracy in the bin (y), coloured by sigma. This gives
+sigma x bins points -- enough to fit -- rather than the single point per sigma a
+naive reading would produce.
+
+Fits ``acc = a + b * exp(-k * u)`` (bounded, monotone) with a linear fallback,
+and reports R^2 plus Spearman rho. Rho is the robust headline because it makes no
+assumption about the functional form.
+
+Interpretation
+--------------
+If points from every sigma collapse onto one curve, the measure is calibrated
+*across conditions*: uncertainty alone predicts accuracy. If they separate by
+sigma, the measure is condition-dependent and cannot be used as a standalone
+confidence signal -- an important negative result if it appears.
+
+Outputs: ``noise_correlation_<measure>.png`` (one panel per family x
+configuration) and ``noise_correlation.json`` (fit parameters, R^2, rho).
+"""
+
+from typing import Any, Dict, List
+
+import numpy as np
+from matplotlib import cm
+from matplotlib.colors import Normalize
+
+import analysis.measures as ms
+import analysis.metrics as mt
+from analysis.context import CONFIG_SINGLE, AnalysisContext, Series
+from analysis.sources import noise_axis_label
+from analysis.plotting import panel_grid, save_figure, save_json, style_axes
+from analysis.registry import register
+
+#: Equal-count uncertainty bins per noise level.
+N_BINS = 10
+
+#: Minimum samples in a bin for its accuracy to be trustworthy.
+MIN_BIN_SAMPLES = 5
+
+
+def _series_points(
+    ctx: AnalysisContext, series: Series, measure: ms.Measure
+) -> Dict[str, Any] | None:
+    """Binned (uncertainty, accuracy, sigma) points plus the fit, for one series."""
+    selected = ctx.noise_source(series.family)
+    if selected is None:
+        return None
+    stem, ds = selected
+
+    levels = ctx.noise_levels(ds)
+    true_idx = ctx.true_index(ds)
+
+    x_values: List[float] = []
+    y_values: List[float] = []
+    sigma_values: List[float] = []
+
+    for level_index, sigma in enumerate(levels):
+        probs = ctx.probs(series.family, ds, level_index)
+        member_mi = (
+            ctx.member_stat("mutual_information", series.family, ds, level_index)
+            if measure.needs_member_mi
+            else None
+        )
+
+        # `single` pools every (member, sample) observation at this noise level,
+        # so it yields the same number of points as `ensemble` while using all
+        # members' predictions.
+        if series.config == CONFIG_SINGLE:
+            uncertainty = np.concatenate(
+                [
+                    ms.compute(
+                        measure,
+                        probs[m : m + 1],
+                        member_mi=None if member_mi is None else member_mi[m : m + 1],
+                    )
+                    for m in range(probs.shape[0])
+                ]
+            )
+            correct = np.concatenate(
+                [
+                    ms.predicted_class(probs[m : m + 1]) == true_idx
+                    for m in range(probs.shape[0])
+                ]
+            )
+        else:
+            uncertainty = ms.compute(measure, probs, member_mi=member_mi)
+            correct = ms.predicted_class(probs) == true_idx
+
+        for indices in mt.quantile_bins(uncertainty, N_BINS):
+            if indices.size < MIN_BIN_SAMPLES:
+                continue
+            x_values.append(float(np.mean(uncertainty[indices])))
+            y_values.append(float(np.mean(correct[indices])))
+            sigma_values.append(float(sigma))
+
+    if len(x_values) < 3:
+        return None
+
+    fit = mt.fit_accuracy_vs_uncertainty(
+        np.asarray(x_values), np.asarray(y_values)
+    )
+    return {
+        "source": stem,
+        "noise_axis": noise_axis_label(ds),
+        "uncertainty": x_values,
+        "accuracy": y_values,
+        "noise_level": sigma_values,
+        "n_bins": N_BINS,
+        "fit": {
+            "model": fit.model,
+            "params": fit.params,
+            "r_squared": fit.r_squared,
+            "spearman": fit.spearman,
+        },
+        "_fit_line": (fit.x_fit, fit.y_fit),
+    }
+
+
+def _plot_measure(
+    ctx: AnalysisContext,
+    measure: ms.Measure,
+    per_series: Dict[str, Dict[str, Any]],
+) -> None:
+    """One panel per series; points coloured by noise level, with the fit drawn."""
+    keys = list(per_series)
+    fig, axes = panel_grid(len(keys), n_cols=2, panel_size=(5.0, 3.8))
+
+    all_sigmas = sorted(
+        {s for payload in per_series.values() for s in payload["noise_level"]}
+    )
+    norm = Normalize(vmin=min(all_sigmas), vmax=max(all_sigmas))
+    colormap = cm.viridis
+
+    scatter = None
+    for ax, key in zip(axes, keys):
+        payload = per_series[key]
+        series: Series = payload["_series"]
+        scatter = ax.scatter(
+            payload["uncertainty"],
+            payload["accuracy"],
+            c=payload["noise_level"],
+            cmap=colormap,
+            norm=norm,
+            s=26,
+            edgecolor="white",
+            linewidth=0.4,
+            zorder=3,
+        )
+        x_fit, y_fit = payload["_fit_line"]
+        if x_fit:
+            ax.plot(x_fit, y_fit, color="#444444", linewidth=1.6, zorder=2)
+
+        fit = payload["fit"]
+        ax.annotate(
+            f"{fit['model']}  R²={fit['r_squared']:.3f}\nρ={fit['spearman']:.3f}",
+            xy=(0.97, 0.95),
+            xycoords="axes fraction",
+            ha="right",
+            va="top",
+            fontsize=8,
+            color="#333333",
+        )
+        ax.set_title(series.label, fontsize=10)
+        ax.set_xlabel(f"{measure.label}")
+        ax.set_ylabel("Accuracy (per bin)")
+        style_axes(ax)
+
+    if scatter is not None:
+        label = next(iter(per_series.values()))["noise_axis"]
+        fig.colorbar(scatter, ax=axes, label=label, shrink=0.85)
+    fig.suptitle(f"Accuracy vs. {measure.label.lower()} across noise levels")
+    stem = f"noise_correlation_{measure.name}"
+    png = save_figure(fig, ctx.out_dir, stem)
+    ctx.log.info(f"noise_correlation: wrote {png.name}.")
+
+
+@register(
+    "noise_correlation",
+    title="Uncertainty vs. accuracy across noise levels (with fit)",
+    requires_noise=True,
+)
+def noise_correlation(ctx: AnalysisContext) -> None:
+    results: Dict[str, Dict[str, Dict[str, Any]]] = {}
+
+    for series in ctx.series(ctx.noise_families):
+        for measure in ms.measures_for(series.family, series.config):
+            payload = _series_points(ctx, series, measure)
+            if payload is None:
+                continue
+            payload["_series"] = series
+            results.setdefault(measure.name, {})[series.key] = payload
+
+    if not results:
+        ctx.log.error("noise_correlation: no noise-sweep evaluations available.")
+        return
+
+    for measure_name, per_series in results.items():
+        _plot_measure(ctx, ms.MEASURES[measure_name], per_series)
+        for key, payload in per_series.items():
+            fit = payload["fit"]
+            ctx.log.info(
+                f"noise_correlation: {measure_name} / {key} — {fit['model']} fit "
+                f"R²={fit['r_squared']:.3f}, Spearman ρ={fit['spearman']:.3f}."
+            )
+
+    save_json(
+        {
+            "n_bins": N_BINS,
+            "min_bin_samples": MIN_BIN_SAMPLES,
+            "measures": {
+                measure: {
+                    key: {
+                        k: v
+                        for k, v in payload.items()
+                        if k not in ("_series", "_fit_line")
+                    }
+                    for key, payload in per_series.items()
+                }
+                for measure, per_series in results.items()
+            },
+        },
+        ctx.out_dir,
+        "noise_correlation",
+    )

+ 197 - 0
analysis/plots/noise_performance.py

@@ -0,0 +1,197 @@
+"""Performance and uncertainty vs. input noise.
+
+Question: how do accuracy and each uncertainty measure respond as Gaussian input
+noise increases? This is the core robustness result.
+
+Method (see ai/ANALYSIS_PLAN.md 3.2)
+-----------------------------------
+Uses a noise-sweep evaluation (``noisy_*`` / ``combined_noisy_*``). For every
+noise level sigma, computes accuracy, F1 and the mean (+/-1 std across samples)
+of each applicable uncertainty measure, for every family x configuration. The
+``single`` configuration averages over members.
+
+The scientifically interesting comparison is the *rate* of change: the epistemic
+terms (``ensemble_mutual_information``, ``mean_mutual_information``) should rise
+faster than the aleatoric term (``mean_predictive_entropy``) as inputs move out
+of distribution. If they do not, the uncertainty is not tracking what it should.
+
+Outputs: ``noise_performance.png`` (accuracy and F1 panels, then one panel per
+uncertainty measure, all against sigma) and ``noise_performance.json``.
+"""
+
+from typing import Any, Dict, List, Tuple
+
+import numpy as np
+
+import analysis.measures as ms
+import analysis.metrics as mt
+from analysis.context import CONFIG_SINGLE, AnalysisContext, Series
+from analysis.sources import noise_axis_label
+from analysis.plotting import add_legend, panel_grid, save_figure, save_json, style_axes
+from analysis.registry import register
+
+
+def _member_groups(
+    probs: np.ndarray, member_mi: np.ndarray | None, config: str
+) -> List[Tuple[np.ndarray, np.ndarray | None]]:
+    """Split into the units scored independently: 1 ensemble, or M singles."""
+    if config == CONFIG_SINGLE:
+        return [
+            (probs[m : m + 1], None if member_mi is None else member_mi[m : m + 1])
+            for m in range(probs.shape[0])
+        ]
+    return [(probs, member_mi)]
+
+
+def _series_response(
+    ctx: AnalysisContext, series: Series
+) -> Dict[str, Any] | None:
+    """Accuracy, F1 and every applicable measure across the noise sweep."""
+    selected = ctx.noise_source(series.family)
+    if selected is None:
+        return None
+    stem, ds = selected
+
+    levels = ctx.noise_levels(ds)
+    true_idx = ctx.true_index(ds)
+    applicable = ms.measures_for(series.family, series.config)
+
+    accuracy: List[float] = []
+    accuracy_std: List[float] = []
+    f1: List[float] = []
+    f1_std: List[float] = []
+    measure_mean: Dict[str, List[float]] = {m.name: [] for m in applicable}
+    measure_std: Dict[str, List[float]] = {m.name: [] for m in applicable}
+
+    needs_mi = any(m.needs_member_mi for m in applicable)
+    for level_index in range(len(levels)):
+        probs = ctx.probs(series.family, ds, level_index)
+        member_mi = (
+            ctx.member_stat("mutual_information", series.family, ds, level_index)
+            if needs_mi
+            else None
+        )
+        groups = _member_groups(probs, member_mi, series.config)
+
+        # Performance: score each unit, then average over units.
+        unit_acc = [
+            mt.accuracy(ms.predicted_class(g), true_idx) for g, _ in groups
+        ]
+        unit_f1 = [mt.f1_score(ms.predicted_class(g), true_idx) for g, _ in groups]
+        accuracy.append(float(np.nanmean(unit_acc)))
+        accuracy_std.append(float(np.nanstd(unit_acc)))
+        f1.append(float(np.nanmean(unit_f1)))
+        f1_std.append(float(np.nanstd(unit_f1)))
+
+        # Uncertainty: mean over samples for each unit, then averaged over units;
+        # the band is the spread across samples (pooled over units).
+        for measure in applicable:
+            per_sample = np.concatenate(
+                [ms.compute(measure, g, member_mi=gm) for g, gm in groups]
+            )
+            measure_mean[measure.name].append(float(np.mean(per_sample)))
+            measure_std[measure.name].append(float(np.std(per_sample)))
+
+    return {
+        "source": stem,
+        "noise_axis": noise_axis_label(ds),
+        "noise_levels": levels.tolist(),
+        "accuracy": accuracy,
+        "accuracy_std": accuracy_std,
+        "f1": f1,
+        "f1_std": f1_std,
+        "measures": {
+            name: {"mean": measure_mean[name], "std": measure_std[name]}
+            for name in measure_mean
+        },
+    }
+
+
+@register(
+    "noise_performance",
+    title="Accuracy and uncertainty vs. input noise",
+    requires_noise=True,
+)
+def noise_performance(ctx: AnalysisContext) -> None:
+    responses: Dict[str, Dict[str, Any]] = {}
+    for series in ctx.series(ctx.noise_families):
+        payload = _series_response(ctx, series)
+        if payload is not None:
+            payload["_series"] = series
+            responses[series.key] = payload
+
+    if not responses:
+        ctx.log.error("noise_performance: no noise-sweep evaluations available.")
+        return
+
+    # Panels: accuracy, F1, then every measure that any series produced.
+    measure_panels = [
+        name
+        for name in ms.MEASURES
+        if any(name in p["measures"] for p in responses.values())
+    ]
+    panel_keys = ["accuracy", "f1"] + measure_panels
+    fig, axes = panel_grid(len(panel_keys), n_cols=2)
+
+    for ax, key in zip(axes, panel_keys):
+        is_performance = key in ("accuracy", "f1")
+        for payload in responses.values():
+            series: Series = payload["_series"]
+            x = np.asarray(payload["noise_levels"], dtype=float)
+            if is_performance:
+                y = np.asarray(payload[key], dtype=float)
+                spread = np.asarray(payload[f"{key}_std"], dtype=float)
+                show_band = series.config == CONFIG_SINGLE
+            else:
+                if key not in payload["measures"]:
+                    continue
+                y = np.asarray(payload["measures"][key]["mean"], dtype=float)
+                spread = np.asarray(payload["measures"][key]["std"], dtype=float)
+                show_band = True
+            if show_band:
+                ax.fill_between(
+                    x, y - spread, y + spread,
+                    color=series.color, alpha=0.13, linewidth=0,
+                )
+            ax.plot(
+                x, y,
+                color=series.color, linestyle=series.linestyle,
+                linewidth=2, marker="o", markersize=4, label=series.label,
+            )
+
+        if key == "accuracy":
+            ax.set_title("Accuracy", fontsize=10)
+            ax.set_ylabel("Accuracy")
+        elif key == "f1":
+            ax.set_title(f"F1 ({mt.POSITIVE_CLASS})", fontsize=10)
+            ax.set_ylabel("F1")
+        else:
+            ax.set_title(ms.MEASURES[key].label, fontsize=10)
+            ax.set_ylabel("Uncertainty (nats)")
+        ax.set_xlabel(next(iter(responses.values()))["noise_axis"])
+        style_axes(ax)
+
+    fig.suptitle("Performance and uncertainty vs. input noise")
+    add_legend(fig, axes)
+    png = save_figure(fig, ctx.out_dir, "noise_performance")
+
+    save_json(
+        {
+            "positive_class": mt.POSITIVE_CLASS,
+            "series": {
+                key: {k: v for k, v in payload.items() if k != "_series"}
+                for key, payload in responses.items()
+            },
+        },
+        ctx.out_dir,
+        "noise_performance",
+    )
+
+    for key, payload in responses.items():
+        levels = payload["noise_levels"]
+        acc = payload["accuracy"]
+        ctx.log.info(
+            f"noise_performance: {key} — accuracy {acc[0]:.3f} at σ={levels[0]:g} "
+            f"→ {acc[-1]:.3f} at σ={levels[-1]:g}."
+        )
+    ctx.log.info(f"noise_performance: wrote {png.name} ({len(panel_keys)} panels).")

+ 110 - 0
analysis/plotting.py

@@ -0,0 +1,110 @@
+"""Shared figure styling and output helpers.
+
+Centralising this keeps every analysis visually consistent (same palette, same
+grid weight, same file formats) and means an analysis body contains only its own
+logic, not boilerplate.
+"""
+
+import json
+import math
+import pathlib as pl
+from typing import Any, Dict, List, Literal, Sequence, Tuple
+
+import matplotlib
+
+matplotlib.use("Agg")  # headless: analysis runs in a background worker thread
+
+import matplotlib.pyplot as plt  # noqa: E402
+from matplotlib.axes import Axes  # noqa: E402
+from matplotlib.figure import Figure  # noqa: E402
+
+#: Raster resolution for the .png copies.
+DPI = 150
+
+def style_axes(
+    ax: Axes, *, grid_axis: Literal["both", "x", "y"] = "both"
+) -> None:
+    """Apply the shared axis style: recessive grid, no top/right spines."""
+    ax.grid(True, axis=grid_axis, color="#000000", alpha=0.08, linewidth=0.7)
+    for spine in ("top", "right"):
+        ax.spines[spine].set_visible(False)
+
+
+def panel_grid(
+    n_panels: int,
+    *,
+    n_cols: int = 2,
+    panel_size: Tuple[float, float] = (5.0, 3.4),
+) -> Tuple[Figure, List[Axes]]:
+    """Create a grid of panels sized to the panel count.
+
+    Returns the figure and a flat list of exactly ``n_panels`` axes; any surplus
+    axes in the final row are removed so no empty frames are shown.
+    """
+    n_panels = max(1, n_panels)
+    n_cols = max(1, min(n_cols, n_panels))
+    n_rows = math.ceil(n_panels / n_cols)
+    # constrained layout keeps axis labels from colliding with the titles of the
+    # row beneath them once several panels are stacked.
+    fig, axes = plt.subplots(
+        n_rows,
+        n_cols,
+        figsize=(panel_size[0] * n_cols, panel_size[1] * n_rows),
+        squeeze=False,
+        layout="constrained",
+    )
+    flat = [ax for row in axes for ax in row]
+    for extra in flat[n_panels:]:
+        extra.remove()
+    return fig, flat[:n_panels]
+
+
+def add_legend(fig: Figure, axes: Sequence[Axes], *, ncol: int = 4) -> None:
+    """Attach one shared, de-duplicated legend below the figure.
+
+    A legend is always present when more than one series is drawn, so identity is
+    never carried by colour alone.
+    """
+    handles: List[Any] = []
+    labels: List[str] = []
+    for ax in axes:
+        for handle, label in zip(*ax.get_legend_handles_labels()):
+            if label not in labels:
+                handles.append(handle)
+                labels.append(label)
+    if len(labels) < 2:
+        return
+    fig.legend(
+        handles,
+        labels,
+        loc="outside lower center" if fig.get_layout_engine() else "lower center",
+        ncol=min(ncol, len(labels)),
+        frameon=False,
+    )
+
+
+def save_figure(fig: Figure, out_dir: pl.Path, stem: str) -> pl.Path:
+    """Write ``stem.png`` (raster) and ``stem.pdf`` (vector); close the figure."""
+    out_dir.mkdir(parents=True, exist_ok=True)
+    png_path = out_dir / f"{stem}.png"
+    fig.savefig(png_path, dpi=DPI, bbox_inches="tight")
+    fig.savefig(out_dir / f"{stem}.pdf", bbox_inches="tight")
+    plt.close(fig)
+    return png_path
+
+
+def save_json(payload: Dict[str, Any], out_dir: pl.Path, stem: str) -> pl.Path:
+    """Persist the numbers behind a figure so plots can be rebuilt or reused."""
+    out_dir.mkdir(parents=True, exist_ok=True)
+    path = out_dir / f"{stem}.json"
+    path.write_text(json.dumps(payload, indent=2, default=_json_default))
+    return path
+
+
+def _json_default(value: Any) -> Any:
+    """Fallback encoder for numpy scalars/arrays."""
+    if hasattr(value, "tolist"):
+        return value.tolist()
+    if hasattr(value, "item"):
+        return value.item()
+    return str(value)

+ 66 - 0
analysis/registry.py

@@ -0,0 +1,66 @@
+"""Analysis registry.
+
+An *analysis* is a pure function of the loaded evaluation datasets that writes
+artifacts into ``<work_dir>/analysis/``. Analyses declare their data
+requirements so the runner can skip them with a clear message instead of failing
+when a working directory lacks, say, a noise sweep.
+
+Adding an analysis is: one new module under ``analysis/plots/`` containing a
+``@register``-decorated function, plus an import in ``analysis/plots/__init__``.
+"""
+
+from dataclasses import dataclass, field
+from typing import Callable, Dict, List, TYPE_CHECKING
+
+if TYPE_CHECKING:  # avoid a circular import at runtime
+    from analysis.context import AnalysisContext
+
+AnalysisFn = Callable[["AnalysisContext"], None]
+
+
+@dataclass(frozen=True)
+class AnalysisSpec:
+    """Metadata describing one registered analysis."""
+
+    name: str
+    title: str
+    fn: AnalysisFn
+    #: Requires an evaluation with more than one noise level (a noise sweep).
+    requires_noise: bool = False
+    #: Free-form notes shown in logs (e.g. "scaffold").
+    tags: List[str] = field(default_factory=list)
+
+
+#: Registered analyses, in registration order (which is the run order).
+ANALYSES: Dict[str, AnalysisSpec] = {}
+
+
+def register(
+    name: str,
+    *,
+    title: str,
+    requires_noise: bool = False,
+    tags: List[str] | None = None,
+) -> Callable[[AnalysisFn], AnalysisFn]:
+    """Decorator registering an analysis function under ``name``.
+
+    Args:
+        name: Unique id; also the log label and typical output filename stem.
+        title: Human-readable description.
+        requires_noise: Skip this analysis when no noise-sweep file is available.
+        tags: Optional markers (e.g. ``["scaffold"]``).
+    """
+
+    def _decorate(fn: AnalysisFn) -> AnalysisFn:
+        if name in ANALYSES:
+            raise ValueError(f"Analysis {name!r} is already registered.")
+        ANALYSES[name] = AnalysisSpec(
+            name=name,
+            title=title,
+            fn=fn,
+            requires_noise=requires_noise,
+            tags=list(tags or []),
+        )
+        return fn
+
+    return _decorate

+ 114 - 0
analysis/sources.py

@@ -0,0 +1,114 @@
+"""Choosing which evaluation file represents which model family.
+
+A single work_dir holds several evaluation files describing the *same* ensembles:
+``normal.nc`` (test), ``val_normal.nc``, ``combined_normal.nc`` (pooled),
+``noisy_normal.nc`` (noise sweep), ``oof_normal.nc`` (k-fold). Analyses must pick
+exactly one per family per purpose, or a family would be counted twice and the
+numbers would silently disagree between analyses.
+
+Two selections exist:
+  * **clean** -- the largest available sample set at the baseline noise level,
+    used for accuracy/coverage style analyses.
+  * **noise** -- the noise sweep (more than one noise level), used by the
+    noise-response analyses.
+"""
+
+from typing import Dict, Iterable, List, Tuple
+
+import numpy as np
+import xarray as xr
+
+from evaluation import MODEL_KIND_BAYESIAN, MODEL_KIND_NORMAL
+
+#: Canonical display order for model families.
+FAMILY_ORDER: List[str] = [MODEL_KIND_NORMAL, MODEL_KIND_BAYESIAN]
+
+#: Human-readable family labels.
+FAMILY_LABELS: Dict[str, str] = {
+    MODEL_KIND_NORMAL: "Normal (deterministic)",
+    MODEL_KIND_BAYESIAN: "Bayesian (MC)",
+}
+
+
+def ordered_families(kinds: Iterable[str]) -> List[str]:
+    """Sort family kinds into the canonical Normal-then-Bayesian order."""
+    order = {kind: i for i, kind in enumerate(FAMILY_ORDER)}
+    return sorted(kinds, key=lambda k: order.get(k, len(order)))
+
+
+def baseline_noise_index(ds: xr.Dataset) -> int:
+    """Index of the noise level closest to the clean baseline (sigma = 0)."""
+    levels = np.asarray(ds["noise_level"].values, dtype=float)
+    return int(np.abs(levels).argmin())
+
+
+def has_noise_sweep(ds: xr.Dataset) -> bool:
+    """True when the dataset spans more than one noise level."""
+    return int(ds.sizes.get("noise_level", 1)) > 1
+
+
+def noise_axis_label(ds: xr.Dataset) -> str:
+    """Axis label for the noise level, reflecting how sigma was applied.
+
+    Relative noise (the default) scales sigma by each image's own intensity
+    standard deviation, so the units differ from absolute noise and the axis must
+    say which it is.
+    """
+    if int(ds.attrs.get("noise_relative", 0)):
+        return "Noise σ (× image std)"
+    return "Noise σ (raw voxel units)"
+
+
+def _coverage_rank(stem: str, ds: xr.Dataset) -> int:
+    """Rank a file by how many evaluation samples it covers (lower is better)."""
+    split = str(ds.attrs.get("split", ""))
+    if stem.startswith(("combined_", "oof_")) or "+" in split:
+        return 0  # pooled: test+val or k-fold out-of-fold -- the most samples
+    if split == "test":
+        return 1
+    if split == "val":
+        return 2
+    return 3  # unlabeled (files written before the split attribute existed)
+
+
+def source_rank(stem: str, ds: xr.Dataset) -> Tuple[int, int, str]:
+    """Sort key for the *clean* selection: clean before noisy, then by coverage."""
+    return (1 if "noisy" in stem else 0, _coverage_rank(stem, ds), stem)
+
+
+def noise_source_rank(stem: str, ds: xr.Dataset) -> Tuple[int, str]:
+    """Sort key for the *noise* selection: widest coverage first."""
+    return (_coverage_rank(stem, ds), stem)
+
+
+def _families_in(ds: xr.Dataset) -> List[str]:
+    if "model_kind" not in ds.coords:
+        return []
+    return sorted({str(k) for k in np.atleast_1d(ds["model_kind"].values)})
+
+
+def select_clean_sources(
+    datasets: Dict[str, xr.Dataset]
+) -> Dict[str, Tuple[str, xr.Dataset]]:
+    """Best clean (baseline) evaluation per family: ``{family: (stem, ds)}``."""
+    chosen: Dict[str, Tuple[str, xr.Dataset]] = {}
+    for stem in sorted(datasets, key=lambda s: source_rank(s, datasets[s])):
+        for family in _families_in(datasets[stem]):
+            chosen.setdefault(family, (stem, datasets[stem]))
+    return chosen
+
+
+def select_noise_sources(
+    datasets: Dict[str, xr.Dataset]
+) -> Dict[str, Tuple[str, xr.Dataset]]:
+    """Best noise-sweep evaluation per family: ``{family: (stem, ds)}``.
+
+    Only datasets with more than one noise level qualify; families without a
+    sweep are simply absent from the result.
+    """
+    chosen: Dict[str, Tuple[str, xr.Dataset]] = {}
+    candidates = {s: d for s, d in datasets.items() if has_noise_sweep(d)}
+    for stem in sorted(candidates, key=lambda s: noise_source_rank(s, candidates[s])):
+        for family in _families_in(candidates[stem]):
+            chosen.setdefault(family, (stem, candidates[stem]))
+    return chosen

+ 0 - 0
tasks/templates/model_report.typ → analysis/templates/model_report.typ


+ 16 - 3
config.toml

@@ -37,9 +37,22 @@ deterministic = false # force deterministic cuDNN kernels (slower, exact reprodu
 
 
 [evaluation]
-# Gaussian noise standard deviations applied to images during noisy evaluation
-# (step 6). 0.0 is the clean baseline and should be kept first.
-noise_levels = [0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 3.0, 5.0]
+# Gaussian noise applied to images during noisy evaluation (step 6).
+#
+# With noise_relative = true (below) each sigma is a FRACTION OF THAT IMAGE'S own
+# intensity standard deviation: sigma = 1.0 means the added noise is as strong as
+# the image's signal variation (SNR ~ 1), and sigma = 8.0 is essentially pure
+# noise. This scaling is required here because the PET volumes are unnormalised
+# (std in the thousands) -- an absolute sigma of 0.2 perturbs them by ~0.002% and
+# does nothing measurable.
+#
+# The grid is dense below 1.0 (where degradation begins) and spreads out above it
+# so the curve is followed all the way into the unusable regime, giving the fit
+# both plateaus (clean accuracy and chance level) plus the transition between.
+# 0.0 is the clean baseline and must stay first; values must be unique.
+noise_levels = [0.0, 0.05, 0.1, 0.2, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 3.0, 5.0, 8.0]
+# false => sigma is an absolute value in raw voxel units (not recommended here).
+noise_relative = true
 # Monte-Carlo forward passes used to estimate Bayesian predictive/model
 # uncertainty (step 5/6). Ignored for the deterministic ensemble.
 mc_passes = 30

+ 10 - 1
evaluation/schema.py

@@ -373,7 +373,16 @@ class EvaluationAccumulator:
             raise ValueError(
                 "image_ids, ptids, and true_classes must all have the same length."
             )
-        self._noise_index = {round(float(n), 12): i for i, n in enumerate(self._noise_levels)}
+        # Duplicate levels would collapse in this lookup and silently leave one
+        # slice of the output array unwritten (i.e. all zeros), so reject them.
+        keys = [round(float(n), 12) for n in self._noise_levels]
+        duplicates = {k for k in keys if keys.count(k) > 1}
+        if duplicates:
+            raise ValueError(
+                f"noise_levels contains duplicate value(s) {sorted(duplicates)}: "
+                f"{self._noise_levels}. Each level must appear exactly once."
+            )
+        self._noise_index = {k: i for i, k in enumerate(keys)}
 
         # keyed by model index -> dict with kind and per-noise arrays
         self._models: Dict[int, Dict[str, Any]] = {}

+ 3 - 3
tasks/__init__.py

@@ -22,7 +22,7 @@ Pipeline stages (see ai/ARCHITECTURE.md):
 import os
 from typing import Any, Callable, Dict, List, Tuple, TypedDict
 
-from . import analysis
+from . import analyze
 from . import evaluate
 from . import load_data
 from . import load_models
@@ -88,7 +88,7 @@ PIPELINE_TASKS: Dict[str, TaskEntry] = {
     },
     "run_analysis": {
         "task_name": "Analyze Evaluations",
-        "task_func": analysis.run_analysis_task,
+        "task_func": analyze.run_analysis_task,
     },
     "run_kfold": {
         "task_name": "K-Fold Cross-Validation",
@@ -126,7 +126,7 @@ ARTIFACT_CATEGORIES: Dict[str, ArtifactCategory] = {
         "desc": "per-fold k-fold models & evaluations",
     },
     "analysis": {
-        "dirs": (analysis.ANALYSIS_SUBDIR,),
+        "dirs": (analyze.ANALYSIS_SUBDIR,),
         "protected": False,
         "desc": "analysis artifacts (regeneratable)",
     },

+ 0 - 544
tasks/analysis.py

@@ -1,544 +0,0 @@
-"""Step 7: analysis over saved evaluation netCDF files.
-
-Analysis is deliberately decoupled from models and data: it reads ONLY the
-evaluation ``.nc`` files in ``<work_dir>/evaluations/`` and writes artifacts to
-``<work_dir>/analysis/``. That makes an analysis rerun cheap and safe -- the only
-outputs it can clobber are the easily-regeneratable files under ``analysis/``.
-
-To add an analysis, register a callable in :data:`ANALYSES`. Each receives the
-loaded evaluation datasets (keyed by file stem, e.g. ``"normal"``,
-``"noisy_bayesian"``), an output directory, and a logger. Because every analysis
-is a pure function of the schema-locked datasets, new plots (coverage,
-accuracy-vs-noise, uncertainty-vs-noise, calibration, ...) drop in here without
-touching the rest of the pipeline.
-"""
-
-import datetime
-import itertools
-import json
-import math
-import pathlib as pl
-from typing import Any, Callable, Dict, List, Tuple
-
-import matplotlib
-
-matplotlib.use("Agg")  # headless; analysis runs in a background worker thread
-
-import matplotlib.pyplot as plt  # noqa: E402
-import numpy as np  # noqa: E402
-import typst  # noqa: E402
-import xarray as xr  # noqa: E402
-from matplotlib.ticker import MaxNLocator  # noqa: E402
-
-from evaluation import (  # noqa: E402
-    CLASS_NAMES,
-    MODEL_KIND_BAYESIAN,
-    MODEL_KIND_NORMAL,
-    load_evaluation,
-)
-from util.progress import ProgressTracker  # noqa: E402
-from util.ui_logger import PipelineLogger  # noqa: E402
-
-# Categorical colors (dataviz palette slots 1 & 2, CVD-safe in fixed order).
-_FAMILY_COLORS = {
-    MODEL_KIND_NORMAL: "#003f5c",  # blue
-    MODEL_KIND_BAYESIAN: "#ffa600",  # orange
-}
-
-# Number of random member-subset configurations sampled per ensemble size
-# (fewer only when the total number of distinct subsets is smaller).
-_TARGET_CONFIGS = 30
-
-# Bootstrap CI settings for full-ensemble accuracy.
-_BOOTSTRAP_ITERS = 2000
-_CI_LEVEL = 0.95
-
-EVAL_SUBDIR = "evaluations"
-ANALYSIS_SUBDIR = "analysis"
-
-# Analysis registry: name -> function(datasets, out_dir, log) -> None.
-# Each analysis is a pure function of the schema-locked evaluation datasets.
-AnalysisFn = Callable[[Dict[str, xr.Dataset], pl.Path, PipelineLogger], None]
-
-_TEMPLATE_DIR = pl.Path(__file__).parent / "templates"
-_MODEL_REPORT_TEMPLATE = _TEMPLATE_DIR / "model_report.typ"
-
-# Display labels + ordering for known model families.
-_FAMILY_LABELS = {
-    MODEL_KIND_NORMAL: "Normal (deterministic)",
-    MODEL_KIND_BAYESIAN: "Bayesian (MC)",
-}
-_FAMILY_ORDER = [MODEL_KIND_NORMAL, MODEL_KIND_BAYESIAN]
-
-
-def _baseline_noise_index(ds: xr.Dataset) -> int:
-    """Index of the noise level closest to the clean baseline (sigma = 0)."""
-    levels = np.asarray(ds["noise_level"].values, dtype=float)
-    return int(np.abs(levels).argmin())
-
-
-def _family_metrics(kind: str, source: str, ds: xr.Dataset) -> Dict[str, Any]:
-    """Per-model + ensemble metrics for one model family at the clean baseline."""
-    baseline = _baseline_noise_index(ds)
-    kinds = [str(k) for k in np.atleast_1d(ds["model_kind"].values)]
-    positions = [i for i, k in enumerate(kinds) if k == kind]
-
-    correct = ds["correct"].isel(noise_level=baseline).values  # (model, sample)
-    entropy = ds["predictive_entropy"].isel(noise_level=baseline).values
-    mut_info = ds["mutual_information"].isel(noise_level=baseline).values
-    labels = np.atleast_1d(ds["model"].values)
-
-    models: List[Dict[str, Any]] = []
-    accuracies: List[float] = []
-    for pos in positions:
-        acc = float(np.mean(correct[pos]))
-        accuracies.append(acc)
-        models.append(
-            {
-                "index": int(labels[pos]) + 1,  # 1-based for display
-                "accuracy": acc,
-                "mean_entropy": float(np.mean(entropy[pos])),
-                "mean_mi": float(np.mean(mut_info[pos])),
-            }
-        )
-
-    acc_arr = np.asarray(accuracies, dtype=float)
-    return {
-        "name": _FAMILY_LABELS.get(kind, kind),
-        "kind": kind,
-        "source": source,
-        "split": str(ds.attrs.get("split", "unknown")),
-        "noise_sigma": float(np.asarray(ds["noise_level"].values, dtype=float)[baseline]),
-        "n_models": len(models),
-        "n_samples": int(ds.sizes["sample"]),
-        "n_mc": int(ds.attrs.get("n_mc", 1)),
-        "accuracy_mean": float(acc_arr.mean()) if acc_arr.size else 0.0,
-        "accuracy_std": float(acc_arr.std()) if acc_arr.size else 0.0,
-        "accuracy_best": float(acc_arr.max()) if acc_arr.size else 0.0,
-        "models": models,
-    }
-
-
-def _source_rank(stem: str, ds: xr.Dataset) -> Tuple[int, int, str]:
-    """Sort key deciding which evaluation file represents a model family.
-
-    Prefers, in order: clean over noisy; then the widest sample coverage --
-    pooled files (``combined_*`` = test+val, ``oof_*`` = k-fold out-of-fold)
-    over a single split, and test over val when only single splits exist.
-    """
-    split = str(ds.attrs.get("split", ""))
-    if stem.startswith(("combined_", "oof_")) or "+" in split:
-        coverage = 0  # pooled: the most evaluation samples
-    elif split == "test":
-        coverage = 1
-    elif split == "val":
-        coverage = 2
-    else:
-        coverage = 3  # unlabeled (pre-split-attr files)
-    return ("noisy" in stem, coverage, stem)
-
-
-def _family_sources(
-    datasets: Dict[str, xr.Dataset]
-) -> Dict[str, Tuple[str, xr.Dataset]]:
-    """Pick one evaluation dataset per model family.
-
-    A family appears in several files (``normal.nc``, ``val_normal.nc``,
-    ``combined_normal.nc``, ``noisy_normal.nc``) that all describe the same
-    ensemble; only the best-ranked one is analyzed so a family is never counted
-    twice. See :func:`_source_rank` for the preference order.
-    """
-    chosen: Dict[str, Tuple[str, xr.Dataset]] = {}
-    for stem in sorted(datasets, key=lambda s: _source_rank(s, datasets[s])):
-        ds = datasets[stem]
-        if "model_kind" not in ds.coords:
-            continue
-        for kind in {str(k) for k in np.atleast_1d(ds["model_kind"].values)}:
-            chosen.setdefault(kind, (stem, ds))
-    return chosen
-
-
-def _ordered_families(kinds: Any) -> List[str]:
-    """Sort family kinds into the canonical Normal-then-Bayesian order."""
-    order = {kind: i for i, kind in enumerate(_FAMILY_ORDER)}
-    return sorted(kinds, key=lambda k: order.get(k, len(order)))
-
-
-def _collect_families(datasets: Dict[str, xr.Dataset]) -> List[Dict[str, Any]]:
-    """One metrics block per model family, in canonical order."""
-    sources = _family_sources(datasets)
-    families = [
-        _family_metrics(kind, source, ds) for kind, (source, ds) in sources.items()
-    ]
-    families.sort(key=lambda f: _ordered_families(sources.keys()).index(f["kind"]))
-    return families
-
-
-def model_report(
-    datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
-) -> None:
-    """Compile a short per-model PDF report (accuracy + uncertainty) via Typst."""
-    families = _collect_families(datasets)
-    if not families:
-        log.error("model_report: no model families found in evaluations; skipping.")
-        return
-
-    meta = next(iter(datasets.values())).attrs
-    seed = meta.get("seed")
-    payload = {
-        "title": "Model Evaluation Report",
-        "generated": datetime.datetime.now(datetime.timezone.utc).strftime(
-            "%Y-%m-%d %H:%M UTC"
-        ),
-        "work_dir": str(out_dir.parent),
-        "schema_version": str(meta.get("schema_version", "?")),
-        "seed": str(seed) if seed is not None else "n/a",
-        "git_commit": str(meta.get("git_commit", "unknown"))[:10],
-        "families": families,
-    }
-    payload_json = json.dumps(payload)
-
-    # The template parses this JSON via `json(bytes(sys.inputs.data))`.
-    pdf_bytes = typst.compile(
-        str(_MODEL_REPORT_TEMPLATE),
-        sys_inputs={"data": payload_json},
-        format="pdf",
-    )
-    (out_dir / "model_report.pdf").write_bytes(pdf_bytes)
-    # Keep the JSON alongside for debugging / reuse (regeneratable).
-    (out_dir / "model_report.json").write_text(payload_json)
-
-    total_models = sum(f["n_models"] for f in families)
-    log.info(
-        f"model_report: wrote model_report.pdf "
-        f"({len(families)} family/families, {total_models} model(s))."
-    )
-
-
-def _baseline_probs(kind: str, ds: xr.Dataset) -> Tuple[np.ndarray, np.ndarray]:
-    """Return ``(probs (M, S, C), true_idx (S,))`` for ``kind``'s members at the
-    clean-baseline noise level."""
-    baseline = _baseline_noise_index(ds)
-    kinds = [str(k) for k in np.atleast_1d(ds["model_kind"].values)]
-    positions = [i for i, k in enumerate(kinds) if k == kind]
-    probs = np.asarray(ds["prob"].isel(noise_level=baseline).values, dtype=float)
-    probs = probs[positions]  # (M, S, C)
-    true_idx = np.array(
-        [CLASS_NAMES.index(str(t)) for t in np.atleast_1d(ds["true_class"].values)],
-        dtype=int,
-    )
-    return probs, true_idx
-
-
-def _sample_subsets(
-    n_models: int, size: int, target: int, rng: np.random.Generator
-) -> List[List[int]]:
-    """Distinct member-index subsets of ``size``.
-
-    Returns every subset when the number of distinct combinations is <= ``target``
-    (e.g. size == 1, size == M, or size near M); otherwise ``target`` random
-    distinct subsets.
-    """
-    total = math.comb(n_models, size)
-    if total <= target:
-        return [list(combo) for combo in itertools.combinations(range(n_models), size)]
-
-    seen: set[Tuple[int, ...]] = set()
-    subsets: List[List[int]] = []
-    while len(subsets) < target:
-        pick = tuple(
-            sorted(int(i) for i in rng.choice(n_models, size=size, replace=False))
-        )
-        if pick not in seen:
-            seen.add(pick)
-            subsets.append(list(pick))
-    return subsets
-
-
-def _ensemble_size_curve(
-    probs: np.ndarray, true_idx: np.ndarray, target: int, rng: np.random.Generator
-) -> Dict[str, Any]:
-    """Sampled-configuration accuracy for every ensemble size ``1..M``.
-
-    An ensemble prediction is the argmax of the mean of its members' output
-    probabilities. For each size we average accuracy over sampled member subsets
-    and report the spread (std) as the uncertainty estimate.
-    """
-    n_models = probs.shape[0]
-    sizes: List[int] = []
-    means: List[float] = []
-    stds: List[float] = []
-    n_configs: List[int] = []
-    for size in range(1, n_models + 1):
-        subsets = _sample_subsets(n_models, size, target, rng)
-        accuracies = []
-        for subset in subsets:
-            ensemble_prob = probs[subset].mean(axis=0)  # (S, C)
-            predictions = ensemble_prob.argmax(axis=1)
-            accuracies.append(float(np.mean(predictions == true_idx)))
-        acc = np.asarray(accuracies, dtype=float)
-        sizes.append(size)
-        means.append(float(acc.mean()))
-        stds.append(float(acc.std()))
-        n_configs.append(len(subsets))
-    return {"sizes": sizes, "mean": means, "std": stds, "n_configs": n_configs}
-
-
-def ensemble_size_accuracy(
-    datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
-) -> None:
-    """Plot ensemble accuracy vs. number of member models, with an uncertainty
-    band from sampling different member configurations, for each family."""
-    sources = _family_sources(datasets)
-    if not sources:
-        log.error("ensemble_size_accuracy: no model families found; skipping.")
-        return
-
-    base_seed = int(next(iter(datasets.values())).attrs.get("seed", 0) or 0)
-
-    curves: Dict[str, Dict[str, Any]] = {}
-    for fam_index, kind in enumerate(_ordered_families(sources.keys())):
-        source, ds = sources[kind]
-        probs, true_idx = _baseline_probs(kind, ds)
-        if probs.shape[0] < 1:
-            continue
-        # Distinct, reproducible RNG stream per family for subset sampling.
-        rng = np.random.default_rng([base_seed, fam_index])
-        curve = _ensemble_size_curve(probs, true_idx, _TARGET_CONFIGS, rng)
-        curve["source"] = source
-        curves[kind] = curve
-        log.info(
-            f"ensemble_size_accuracy: {kind} - sizes 1..{probs.shape[0]}, "
-            f"up to {_TARGET_CONFIGS} configs each."
-        )
-
-    if not curves:
-        log.error("ensemble_size_accuracy: no usable probabilities; skipping.")
-        return
-
-    fig, ax = plt.subplots(figsize=(7.0, 4.3))
-    for kind in _ordered_families(curves.keys()):
-        curve = curves[kind]
-        color = _FAMILY_COLORS.get(kind, "#666666")
-        x = np.asarray(curve["sizes"])
-        mean = np.asarray(curve["mean"])
-        std = np.asarray(curve["std"])
-        ax.fill_between(x, mean - std, mean + std, color=color, alpha=0.18, linewidth=0)
-        ax.plot(
-            x, mean, color=color, marker="o", markersize=4, linewidth=2,
-            label=_FAMILY_LABELS.get(kind, kind),
-        )
-
-    ax.set_xlabel("Ensemble size (number of models)")
-    ax.set_ylabel("Test accuracy")
-    ax.set_title("Ensemble accuracy vs. number of models")
-    ax.xaxis.set_major_locator(MaxNLocator(integer=True))
-    ax.margins(x=0.02)
-    ax.grid(True, color="#000000", alpha=0.08, linewidth=0.7)
-    for spine in ("top", "right"):
-        ax.spines[spine].set_visible(False)
-    ax.legend(frameon=False, loc="lower right")
-    fig.tight_layout()
-
-    png_path = out_dir / "ensemble_size_accuracy.png"
-    fig.savefig(png_path, dpi=150)
-    fig.savefig(out_dir / "ensemble_size_accuracy.pdf")
-    plt.close(fig)
-
-    # Persist the underlying curve data for reuse / regeneration.
-    (out_dir / "ensemble_size_accuracy.json").write_text(
-        json.dumps({"target_configs": _TARGET_CONFIGS, "families": curves})
-    )
-    log.info(
-        f"ensemble_size_accuracy: wrote {png_path.name} "
-        f"({len(curves)} family/families)."
-    )
-
-
-def _full_ensemble_correct(kind: str, ds: xr.Dataset) -> Tuple[np.ndarray, np.ndarray]:
-    """Per-sample correctness (0/1) of the FULL ensemble at baseline, plus ptids.
-
-    The ensemble prediction is the argmax of the mean of ALL members' output
-    probabilities -- i.e. the rightmost point of the ensemble-size curve.
-    """
-    probs, true_idx = _baseline_probs(kind, ds)  # (M, S, C), (S,)
-    ensemble_pred = probs.mean(axis=0).argmax(axis=1)  # (S,)
-    correct = (ensemble_pred == true_idx).astype(float)
-    ptids = np.asarray(ds["ptid"].values).astype(str)
-    return correct, ptids
-
-
-def _bootstrap_accuracy_ci(
-    correct: np.ndarray,
-    ptids: np.ndarray,
-    iters: int,
-    ci_level: float,
-    rng: np.random.Generator,
-) -> Dict[str, Any]:
-    """Patient-clustered percentile bootstrap CI for a mean-accuracy statistic.
-
-    Resamples whole PATIENTS with replacement (not individual scans), because
-    scans from one patient are correlated; a per-scan bootstrap would understate
-    the interval. Reduces to an ordinary bootstrap when every patient has one scan.
-    """
-    point = float(np.mean(correct)) if correct.size else 0.0
-    _, inverse = np.unique(ptids, return_inverse=True)
-    n_patients = int(inverse.max()) + 1 if inverse.size else 0
-    groups = [np.where(inverse == p)[0] for p in range(n_patients)]
-
-    boot = np.empty(iters, dtype=float)
-    for b in range(iters):
-        chosen = rng.integers(0, n_patients, size=n_patients)
-        idx = np.concatenate([groups[c] for c in chosen])
-        boot[b] = correct[idx].mean()
-
-    tail = (1.0 - ci_level) / 2.0
-    return {
-        "point": point,
-        "ci_low": float(np.quantile(boot, tail)),
-        "ci_high": float(np.quantile(boot, 1.0 - tail)),
-        "n_samples": int(correct.size),
-        "n_patients": n_patients,
-    }
-
-
-def bootstrap_ensemble_ci(
-    datasets: Dict[str, xr.Dataset], out_dir: pl.Path, log: PipelineLogger
-) -> None:
-    """Patient-clustered bootstrap CI for each family's full-ensemble test
-    accuracy; writes a JSON summary and a compact point-range plot."""
-    sources = _family_sources(datasets)
-    if not sources:
-        log.error("bootstrap_ensemble_ci: no model families found; skipping.")
-        return
-
-    base_seed = int(next(iter(datasets.values())).attrs.get("seed", 0) or 0)
-
-    results: Dict[str, Dict[str, Any]] = {}
-    for fam_index, kind in enumerate(_ordered_families(sources.keys())):
-        source, ds = sources[kind]
-        correct, ptids = _full_ensemble_correct(kind, ds)
-        if correct.size == 0:
-            continue
-        rng = np.random.default_rng([base_seed, 1000 + fam_index])
-        ci = _bootstrap_accuracy_ci(correct, ptids, _BOOTSTRAP_ITERS, _CI_LEVEL, rng)
-        ci["source"] = source
-        results[kind] = ci
-        log.info(
-            f"bootstrap_ensemble_ci: {kind} full-ensemble acc = "
-            f"{ci['point'] * 100:.1f}% (95% CI "
-            f"{ci['ci_low'] * 100:.1f}-{ci['ci_high'] * 100:.1f}%, "
-            f"n={ci['n_samples']} scans / {ci['n_patients']} patients)."
-        )
-
-    if not results:
-        log.error("bootstrap_ensemble_ci: nothing to compute; skipping.")
-        return
-
-    ordered = _ordered_families(results.keys())
-    fig, ax = plt.subplots(figsize=(7.0, 1.4 + 0.8 * len(ordered)))
-    for row, kind in enumerate(ordered):
-        ci = results[kind]
-        color = _FAMILY_COLORS.get(kind, "#666666")
-        ax.plot(
-            [ci["ci_low"], ci["ci_high"]], [row, row],
-            color=color, linewidth=2.5, solid_capstyle="round",
-        )
-        ax.plot([ci["point"]], [row], "o", color=color, markersize=8)
-        ax.annotate(
-            f"{ci['point'] * 100:.1f}%  "
-            f"[{ci['ci_low'] * 100:.1f}, {ci['ci_high'] * 100:.1f}]",
-            (ci["point"], row), xytext=(0, 10), textcoords="offset points",
-            ha="center", va="bottom", fontsize=9, color="#222222",
-        )
-    ax.set_yticks(range(len(ordered)))
-    ax.set_yticklabels([_FAMILY_LABELS.get(k, k) for k in ordered])
-    ax.invert_yaxis()  # first family on top
-    ax.set_xlabel("Full-ensemble test accuracy (95% patient-clustered bootstrap CI)")
-    ax.set_title("Ensemble accuracy with bootstrap confidence intervals")
-    ax.margins(x=0.18, y=0.35)
-    ax.grid(True, axis="x", color="#000000", alpha=0.08, linewidth=0.7)
-    for spine in ("top", "right", "left"):
-        ax.spines[spine].set_visible(False)
-    ax.tick_params(left=False)
-    fig.tight_layout()
-    fig.savefig(out_dir / "bootstrap_ci.png", dpi=150)
-    fig.savefig(out_dir / "bootstrap_ci.pdf")
-    plt.close(fig)
-
-    (out_dir / "bootstrap_ci.json").write_text(
-        json.dumps(
-            {"iters": _BOOTSTRAP_ITERS, "ci_level": _CI_LEVEL, "families": results}
-        )
-    )
-    log.info(
-        f"bootstrap_ensemble_ci: wrote bootstrap_ci.png ({len(results)} family/families)."
-    )
-
-
-ANALYSES: Dict[str, AnalysisFn] = {
-    "model_report": model_report,
-    "ensemble_size_accuracy": ensemble_size_accuracy,
-    "bootstrap_ensemble_ci": bootstrap_ensemble_ci,
-}
-
-
-def _load_evaluations(
-    eval_dir: pl.Path, log: PipelineLogger
-) -> Dict[str, xr.Dataset]:
-    """Load every ``*.nc`` in ``eval_dir``, keyed by file stem."""
-    datasets: Dict[str, xr.Dataset] = {}
-    for nc in sorted(eval_dir.glob("*.nc")):
-        try:
-            ds = load_evaluation(str(nc))
-        except Exception as exc:  # noqa: BLE001 - report and skip a bad file
-            log.error(f"Could not load evaluation '{nc.name}': {exc}")
-            continue
-        datasets[nc.stem] = ds
-        log.info(f"Loaded evaluation '{nc.stem}' with dims {dict(ds.sizes)}.")
-    return datasets
-
-
-def run_analysis_task(
-    track: ProgressTracker,
-    log: PipelineLogger,
-    config: Dict[str, Any],
-    state: Dict[str, Any],
-) -> None:
-    work_dir = pl.Path(config["work_dir"])
-    eval_dir = work_dir / EVAL_SUBDIR
-    out_dir = work_dir / ANALYSIS_SUBDIR
-
-    if not eval_dir.is_dir():
-        log.error(
-            f"No evaluations directory at {eval_dir}. Run an evaluation scenario "
-            "first (or point the working directory at one that has evaluations)."
-        )
-        return
-
-    datasets = _load_evaluations(eval_dir, log)
-    if not datasets:
-        log.error(f"No evaluation .nc files found in {eval_dir}; nothing to analyze.")
-        return
-
-    out_dir.mkdir(parents=True, exist_ok=True)
-
-    try:
-        if not ANALYSES:
-            log.info(
-                f"[NOT IMPLEMENTED] No analyses registered yet (step 7). "
-                f"Loaded {len(datasets)} evaluation file(s): "
-                f"{', '.join(sorted(datasets))}. Register functions in "
-                f"tasks.analysis.ANALYSES to produce artifacts under {out_dir}/."
-            )
-            return
-
-        track.update(total=len(ANALYSES), advance=0)
-        for name, analysis_fn in ANALYSES.items():
-            log.info(f"Running analysis: {name}")
-            analysis_fn(datasets, out_dir, log)
-            track.update(advance=1)
-        log.info(f"Analysis artifacts written to {out_dir}/.")
-    finally:
-        for ds in datasets.values():
-            ds.close()

+ 32 - 0
tasks/analyze.py

@@ -0,0 +1,32 @@
+"""Pipeline task wrapper for the analysis stage (step 7).
+
+The analysis logic lives in the top-level :mod:`analysis` package (mirroring
+:mod:`evaluation`); this module is only the thin pipeline-task adapter. It is
+named ``analyze`` rather than ``analysis`` so it does not shadow that package.
+"""
+
+import pathlib as pl
+from typing import Any, Dict
+
+import analysis
+from util.progress import ProgressTracker
+from util.ui_logger import PipelineLogger
+
+# Re-exported so the task registry can describe the artifacts this stage writes.
+ANALYSIS_SUBDIR = analysis.ANALYSIS_SUBDIR
+EVAL_SUBDIR = analysis.EVAL_SUBDIR
+
+
+def run_analysis_task(
+    track: ProgressTracker,
+    log: PipelineLogger,
+    config: Dict[str, Any],
+    state: Dict[str, Any],
+) -> None:
+    """Run every registered analysis over the working directory's evaluations."""
+    analysis.run_all(
+        work_dir=pl.Path(config["work_dir"]),
+        log=log,
+        config=config,
+        progress=track,
+    )

+ 27 - 1
tasks/evaluate.py

@@ -108,12 +108,22 @@ def _run_mc_passes(
     stop_event,
     pause_event,
     label: str,
+    noise_relative: bool = True,
 ) -> np.ndarray:
     """Run ``n_passes`` forward passes over the test set, returning ``(K, S, C)``.
 
     For a deterministic model use ``n_passes == 1``. Bayesian models sample fresh
     weights each pass (their reparameterization layers stay stochastic in eval
     mode), producing the Monte-Carlo ensemble the uncertainty measures need.
+
+    Args:
+        noise_relative: Scale the Gaussian noise by each image's own intensity
+            standard deviation, so ``noise_level`` means "fraction of image std".
+            Required for this dataset: the PET volumes are unnormalised (std in
+            the thousands), so an absolute sigma of order 1 is numerically
+            invisible. Per-image (not per-batch) scaling keeps the corruption
+            level identical for every sample regardless of its intensity range,
+            and independent of batch composition.
     """
     n_samples = len(loader.dataset)
     mc = np.zeros((n_passes, n_samples, n_classes), dtype=np.float32)
@@ -144,7 +154,15 @@ def _run_mc_passes(
                     noise = torch.randn(
                         mri.shape, generator=noise_gen, device=device, dtype=mri.dtype
                     )
-                    mri = mri + noise * noise_level
+                    if noise_relative:
+                        # Per-image std over the voxel dims -> sigma is a fraction
+                        # of that image's own intensity spread.
+                        scale = mri.std(
+                            dim=tuple(range(1, mri.ndim)), keepdim=True
+                        )
+                    else:
+                        scale = 1.0
+                    mri = mri + noise * (noise_level * scale)
 
                 outputs = model((mri, xls))
                 bs = int(outputs.shape[0])
@@ -176,6 +194,10 @@ def _evaluate_ensemble(
     base_seed = int(config["data"]["seed"])
     stop_event, pause_event = _events(state)
 
+    noise_relative = bool(
+        config.get("evaluation", {}).get("noise_relative", True)
+    )
+
     loader = state[_SPLIT_LOADERS[split]]
     image_to_ptid: Dict[int, str] = state.get("image_to_ptid", {})
 
@@ -212,6 +234,7 @@ def _evaluate_ensemble(
                 stop_event=stop_event,
                 pause_event=pause_event,
                 label=f"model {model_index + 1} | sigma={noise}",
+                noise_relative=noise_relative,
             )
             accumulator.add(
                 model_index=model_index,
@@ -230,6 +253,9 @@ def _evaluate_ensemble(
             "n_mc": n_passes,
             "ensemble_kind": kind,
             "split": split,
+            # 1 => noise_level is a fraction of each image's intensity std;
+            # 0 => it is an absolute value in raw voxel units.
+            "noise_relative": int(noise_relative),
             "config": _config_snapshot(config),
         }
     )