#!/usr/bin/env python3 """ Post-hoc explainability for a full saved run (all folds). Phase 1 — MD permutation feature importance (bar chart + CSV, per fold). Phase 2 — GradCAM overlays on val/holdout patients (per fold). Phase 3 — Fusion event analysis loaded entirely from saved prediction files (no re-inference needed). Runs on both train and val splits. Usage: python scripts/output_analysis/explainability/explain_run.py \ --run-dir analysis_data/v2.3_single_binary_nocrop/binary/single \ [--checkpoint best_single.pt | best_holdout_single.pt] \ [--split holdout] [--image-dir Papila/FundusImages] \ [--clinical-dir Papila/ClinicalData] \ [--n-permutations 30] \ [--seed 0] \ [--alpha 0.45] """ from __future__ import annotations import argparse import csv import json import sys from pathlib import Path from types import SimpleNamespace import matplotlib matplotlib.use("Agg") import matplotlib.cm as cm import matplotlib.patches as mpatches import matplotlib.pyplot as plt import numpy as np import pandas as pd import torch import torch.nn.functional as F from PIL import Image REPO_ROOT = Path(__file__).resolve().parents[3] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) from classes.v2.data_bundle import DataBundle from classes.v2.papila_builders import build_papila_data from classes.v2.profiles.papila import build_papila_profile from classes.v2.split_manager import PatientFirstSplitManager from classes.v2.loader_factory import filter_bilateral_samples, make_loader from classes.v2.metrics import _score_arrays from classes.v2.models import SingleEyeHT from classes.v2.transforms import build_eval_transform # --------------------------------------------------------------------------- # Label display helpers # --------------------------------------------------------------------------- BINARY_LABELS = {0: "Normal", 1: "Glaucoma"} MULTICLASS_LABELS = {0: "Normal", 1: "Glaucoma", 2: "Suspect"} def label_name(label: int, eval_mode: str) -> str: mapping = BINARY_LABELS if eval_mode == "binary" else MULTICLASS_LABELS return mapping.get(int(label), str(label)) # --------------------------------------------------------------------------- # GradCAM # --------------------------------------------------------------------------- class GradCAM: """Minimal GradCAM using forward/backward hooks. No extra dependencies.""" def __init__(self, target_layer: torch.nn.Module) -> None: self._acts: torch.Tensor | None = None self._grads: torch.Tensor | None = None self._h1 = target_layer.register_forward_hook(self._save_acts) self._h2 = target_layer.register_full_backward_hook(self._save_grads) def _save_acts(self, _m, _i, output): self._acts = output.detach() def _save_grads(self, _m, _gi, grad_output): self._grads = grad_output[0].detach() def compute( self, img: torch.Tensor, meta: torch.Tensor, model: torch.nn.Module, target_class: int | None = None, ) -> tuple[np.ndarray, int]: model.eval() with torch.enable_grad(): out = model(img, meta) pred = int(out.argmax(1).item()) tc = pred if target_class is None else target_class model.zero_grad() out[0, tc].backward() if self._acts is None or self._grads is None: raise RuntimeError("GradCAM hooks did not fire — check target_layer.") weights = self._grads.mean(dim=(2, 3), keepdim=True) cam = F.relu((weights * self._acts).sum(dim=1, keepdim=True)) cam = F.interpolate(cam, img.shape[-2:], mode="bilinear", align_corners=False) cam_np = cam.squeeze().cpu().numpy() lo, hi = cam_np.min(), cam_np.max() cam_np = (cam_np - lo) / (hi - lo + 1e-8) return cam_np, pred def remove(self) -> None: self._h1.remove() self._h2.remove() def get_gradcam_layer(model: SingleEyeHT, backbone: str) -> torch.nn.Module: bb = model.img_tower.backbone key = backbone.lower() if key in ("refugelike",) or "resnet" in key: return bb.layer4[-1] if "efficientnet" in key or "refuge_efficient" in key: return bb.features[-1] if "densenet" in key or key == "refuge_densenet": return bb.features.denseblock4 if "mobilenet" in key: return bb.features[-1] if "vgg" in key: return bb.features[-1] raise ValueError(f"Unknown backbone for GradCAM target layer: {backbone!r}") def overlay_gradcam( original_pil: "Image.Image", cam: np.ndarray, alpha: float = 0.45 ) -> "Image.Image": cam_u8 = (cam * 255).astype(np.uint8) cam_resized = ( np.array(Image.fromarray(cam_u8).resize(original_pil.size, Image.BILINEAR)) / 255.0 ) colored = (cm.jet(cam_resized)[:, :, :3] * 255).astype(np.uint8) return Image.blend(original_pil.convert("RGB"), Image.fromarray(colored), alpha) # --------------------------------------------------------------------------- # Feature index map # --------------------------------------------------------------------------- def build_feature_index_map(data: DataBundle) -> dict[str, dict]: n_scalar = len(data.scalar_cols) cat_expanded = sum(len(m) for m in data.cat_maps.values()) feature_map: dict[str, dict] = {} for i, col in enumerate(data.scalar_cols): missing_dim = n_scalar + cat_expanded + i feature_map[col] = {"value_dims": [i], "missing_dims": [missing_dim]} cat_offset = n_scalar for col in data.cat_cols: n_cats = len(data.cat_maps[col]) dims = list(range(cat_offset, cat_offset + n_cats)) feature_map[col] = {"value_dims": dims, "missing_dims": []} cat_offset += n_cats return feature_map # --------------------------------------------------------------------------- # Phase 1 — MD permutation importance # --------------------------------------------------------------------------- def run_permutation_importance( model: SingleEyeHT, loader, data: DataBundle, num_classes: int, device: torch.device, n_permutations: int, seed: int, out_dir: Path, ) -> None: print("\n[Phase 1] MD permutation importance ...", flush=True) img1_feats_list, img2_feats_list = [], [] md1_list, md2_list, label_list = [], [], [] model.eval() with torch.no_grad(): for batch in loader: img1 = batch["image_1"].to(device) img2 = batch["image_2"].to(device) md1 = batch["matrix_1"].to(device) md2 = batch["matrix_2"].to(device) labels = batch["label_1"] img1_feats_list.append(model.img_tower(img1)) img2_feats_list.append(model.img_tower(img2)) md1_list.append(md1) md2_list.append(md2) if isinstance(labels, torch.Tensor): label_list.append(labels) else: label_list.append(torch.tensor(labels, dtype=torch.long)) img1_feats = torch.cat(img1_feats_list) img2_feats = torch.cat(img2_feats_list) md1_tensor = torch.cat(md1_list) md2_tensor = torch.cat(md2_list) y_true = torch.cat(label_list).numpy() N = len(y_true) if N == 0: print(" [Phase 1] No samples — skipping.", flush=True) return with torch.no_grad(): md1_feats = model.md_tower(md1_tensor) md2_feats = model.md_tower(md2_tensor) fused1, _, _ = model.bridge(img1_feats, md1_feats) fused2, _, _ = model.bridge(img2_feats, md2_feats) probs_baseline = ( 0.5 * (torch.softmax(fused1, dim=1) + torch.softmax(fused2, dim=1)) ).cpu().numpy() _, baseline_auc, _ = _score_arrays(y_true, probs_baseline, num_classes) print(f" Baseline AUC: {baseline_auc:.4f} (N={N})", flush=True) feat_map = build_feature_index_map(data) rng = np.random.default_rng(seed) results = [] for feat_name, dims in feat_map.items(): all_dims = dims["value_dims"] + dims["missing_dims"] drops = [] for _ in range(n_permutations): perm1 = md1_tensor.clone() perm2 = md2_tensor.clone() perm_idx = torch.from_numpy(rng.permutation(N)).to(device) perm1[:, all_dims] = perm1[perm_idx][:, all_dims] perm2[:, all_dims] = perm2[perm_idx][:, all_dims] with torch.no_grad(): md1_p = model.md_tower(perm1) md2_p = model.md_tower(perm2) fused1_p, _, _ = model.bridge(img1_feats, md1_p) fused2_p, _, _ = model.bridge(img2_feats, md2_p) probs_p = ( 0.5 * (torch.softmax(fused1_p, dim=1) + torch.softmax(fused2_p, dim=1)) ).cpu().numpy() _, auc_p, _ = _score_arrays(y_true, probs_p, num_classes) drops.append(baseline_auc - auc_p) mean_drop = float(np.mean(drops)) std_drop = float(np.std(drops)) results.append({"feature": feat_name, "importance": mean_drop, "std": std_drop}) print(f" {feat_name:30s} Δ AUC = {mean_drop:+.4f} ± {std_drop:.4f}", flush=True) results.sort(key=lambda r: r["importance"], reverse=True) # total MD ablation print(" Running total MD ablation ...", flush=True) total_drops = [] for _ in range(n_permutations): perm_idx = torch.from_numpy(rng.permutation(N)).to(device) perm1_all = md1_tensor[perm_idx] perm2_all = md2_tensor[perm_idx] with torch.no_grad(): md1_all = model.md_tower(perm1_all) md2_all = model.md_tower(perm2_all) f1, _, _ = model.bridge(img1_feats, md1_all) f2, _, _ = model.bridge(img2_feats, md2_all) probs_all = ( 0.5 * (torch.softmax(f1, dim=1) + torch.softmax(f2, dim=1)) ).cpu().numpy() _, auc_all, _ = _score_arrays(y_true, probs_all, num_classes) total_drops.append(baseline_auc - auc_all) total_mean = float(np.mean(total_drops)) total_std = float(np.std(total_drops)) print(f" Total MD ablation Δ AUC = {total_mean:+.4f} ± {total_std:.4f}", flush=True) # Gaussian noise ablation print(" Running Gaussian noise ablation ...", flush=True) noise_drops = [] for _ in range(n_permutations): noise1 = torch.randn_like(md1_tensor) noise2 = torch.randn_like(md2_tensor) with torch.no_grad(): md1_noise = model.md_tower(noise1) md2_noise = model.md_tower(noise2) f1, _, _ = model.bridge(img1_feats, md1_noise) f2, _, _ = model.bridge(img2_feats, md2_noise) probs_noise = ( 0.5 * (torch.softmax(f1, dim=1) + torch.softmax(f2, dim=1)) ).cpu().numpy() _, auc_noise, _ = _score_arrays(y_true, probs_noise, num_classes) noise_drops.append(baseline_auc - auc_noise) noise_mean = float(np.mean(noise_drops)) noise_std = float(np.std(noise_drops)) print(f" Gaussian noise ablation Δ AUC = {noise_mean:+.4f} ± {noise_std:.4f}", flush=True) print( f" [interpretation] permutation Δ={total_mean:+.4f} noise Δ={noise_mean:+.4f} " f"informational gain = {total_mean - noise_mean:+.4f}", flush=True, ) csv_path = out_dir / "md_permutation_importance.csv" with csv_path.open("w", newline="") as f: writer = csv.DictWriter(f, fieldnames=["feature", "importance", "std"]) writer.writeheader() writer.writerows(results) writer.writerow({"feature": "TOTAL_MD_ABLATION", "importance": total_mean, "std": total_std}) writer.writerow({"feature": "GAUSSIAN_NOISE_ABLATION", "importance": noise_mean, "std": noise_std}) names = [r["feature"] for r in results] imps = [r["importance"] for r in results] stds = [r["std"] for r in results] colors = ["#e05c5c" if v >= 0 else "#5c9ee0" for v in imps] fig, ax = plt.subplots(figsize=(9, max(4, (len(names) + 3) * 0.45))) y_pos = np.arange(len(names)) ax.barh(y_pos, imps, xerr=stds, color=colors, ecolor="grey", capsize=3, height=0.6) ax.axhline(len(names) - 0.25, color="grey", linewidth=0.6, linestyle="--") ax.barh(len(names) + 0.5, total_mean, xerr=total_std, color="#c45ce0" if total_mean >= 0 else "#5c9ee0", ecolor="grey", capsize=3, height=0.6) ax.barh(len(names) + 1.5, noise_mean, xerr=noise_std, color="#e08c2a" if noise_mean >= 0 else "#5c9ee0", ecolor="grey", capsize=3, height=0.6) ax.set_yticks(list(y_pos) + [len(names) + 0.5, len(names) + 1.5]) ax.set_yticklabels(names + ["ALL MD (permute)", "ALL MD (noise)"], fontsize=9) ax.invert_yaxis() ax.axvline(0, color="black", linewidth=0.8) ax.set_xlabel("Mean AUC drop (baseline − permuted)", fontsize=10) ax.set_title( f"MD Tower — Permutation Feature Importance\n" f"baseline AUC={baseline_auc:.4f} N={N} repeats={n_permutations}", fontsize=11, ) fig.tight_layout() fig.savefig(out_dir / "md_permutation_importance.png", dpi=150) plt.close(fig) print(f" Saved → {out_dir / 'md_permutation_importance.png'}", flush=True) # --------------------------------------------------------------------------- # Phase 2 — GradCAM overlays # --------------------------------------------------------------------------- def run_gradcam( model: SingleEyeHT, loader, data: DataBundle, eval_df, eval_mode: str, backbone: str, device: torch.device, alpha: float, out_dir: Path, ) -> None: print("\n[Phase 2] GradCAM overlays ...", flush=True) gradcam_dir = out_dir / "gradcam" gradcam_dir.mkdir(exist_ok=True) manifest_path = gradcam_dir / "gradcam_manifest.csv" skip_overlays = manifest_path.exists() overlay_grid_items = [] if skip_overlays: print(" Overlays already exist — skipping computation, loading from disk.", flush=True) manifest_df = pd.read_csv(manifest_path) for _, row in manifest_df.iterrows(): pid = str(row["pid"]) od_path = gradcam_dir / f"gradcam_od_{pid}.png" os_path = gradcam_dir / f"gradcam_os_{pid}.png" od_ov = Image.open(od_path).convert("RGB") if od_path.exists() else None os_ov = Image.open(os_path).convert("RGB") if os_path.exists() else None overlay_grid_items.append((od_ov, os_ov, str(row["short_lbl"]), bool(row["correct"]))) else: target_layer = get_gradcam_layer(model, backbone) gcam = GradCAM(target_layer) manifest_rows = [] model.eval() for batch in loader: img_od = batch["image_1"].to(device) img_os = batch["image_2"].to(device) meta_od = batch["matrix_1"].to(device) meta_os = batch["matrix_2"].to(device) lbl_raw = batch["label_1"][0] label = int(lbl_raw.item() if isinstance(lbl_raw, torch.Tensor) else lbl_raw) pid = batch["id_1"][0] cam_od, pred = gcam.compute(img_od, meta_od, model) cam_os, _ = gcam.compute(img_os, meta_os, model) with torch.no_grad(): out_od = model(img_od, meta_od) conf = float(torch.softmax(out_od, dim=1)[0, pred].item()) row_od = eval_df[ (eval_df["Patient ID"] == int(pid)) & (eval_df["eyeID"] == "OD") ] row_os = eval_df[ (eval_df["Patient ID"] == int(pid)) & (eval_df["eyeID"] == "OS") ] orig_od = ( Image.open(data.get_image_path(row_od.iloc[0])).convert("RGB") if len(row_od) else None ) orig_os = ( Image.open(data.get_image_path(row_os.iloc[0])).convert("RGB") if len(row_os) else None ) true_name = label_name(label, eval_mode) pred_name = label_name(pred, eval_mode) correct = label == pred title = ( f"Patient {pid} | True: {true_name} | Pred: {pred_name} " f"| conf={conf:.2f} {'✓' if correct else '✗'}" ) fig, axes = plt.subplots(2, 2, figsize=(10, 9)) fig.suptitle(title, fontsize=11, fontweight="bold", color="green" if correct else "red") if orig_od is not None: axes[0, 0].imshow(orig_od) axes[0, 0].set_title("OD — original", fontsize=9) axes[0, 1].imshow(overlay_gradcam(orig_od, cam_od, alpha)) axes[0, 1].set_title("OD — GradCAM", fontsize=9) else: axes[0, 0].set_title("OD — (missing)", fontsize=9) axes[0, 0].axis("off") axes[0, 1].axis("off") if orig_os is not None: axes[1, 0].imshow(orig_os) axes[1, 0].set_title("OS — original", fontsize=9) axes[1, 1].imshow(overlay_gradcam(orig_os, cam_os, alpha)) axes[1, 1].set_title("OS — GradCAM", fontsize=9) else: axes[1, 0].set_title("OS — (missing)", fontsize=9) axes[1, 0].axis("off") axes[1, 1].axis("off") fig.tight_layout() out_path = gradcam_dir / f"patient_{pid}_OD_OS.png" fig.savefig(out_path, dpi=120) plt.close(fig) print(f" Patient {pid}: {true_name} → {pred_name} ({conf:.2f}) → {out_path.name}", flush=True) od_overlay = overlay_gradcam(orig_od, cam_od, alpha) if orig_od else None os_overlay = overlay_gradcam(orig_os, cam_os, alpha) if orig_os else None if od_overlay is not None: od_overlay.save(gradcam_dir / f"gradcam_od_{pid}.png") if os_overlay is not None: os_overlay.save(gradcam_dir / f"gradcam_os_{pid}.png") short_lbl = f"P{pid} {true_name[:3]}→{pred_name[:3]} {'✓' if correct else '✗'}" overlay_grid_items.append((od_overlay, os_overlay, short_lbl, correct)) manifest_rows.append({"pid": pid, "short_lbl": short_lbl, "correct": correct}) gcam.remove() pd.DataFrame(manifest_rows).to_csv(manifest_path, index=False) # Always regenerate the summary grid n = len(overlay_grid_items) if n == 0: print(" [Phase 2] No patients to visualise.", flush=True) return fig, axes = plt.subplots(n, 2, figsize=(8, n * 3.2 + 1.5)) if n == 1: axes = axes[np.newaxis, :] fig.suptitle("GradCAM Summary Grid — all holdout patients", fontsize=12) for i, (od_ov, os_ov, lbl, correct) in enumerate(overlay_grid_items): color = "green" if correct else "red" for j in range(2): axes[i, j].axis("off") if od_ov is not None: axes[i, 0].imshow(od_ov) axes[i, 0].set_title(f"{lbl}\nOD", fontsize=7, color=color) if os_ov is not None: axes[i, 1].imshow(os_ov) axes[i, 1].set_title(f"{lbl}\nOS", fontsize=7, color=color) fig.tight_layout(rect=[0, 0, 1, 0.97]) grid_path = out_dir / "gradcam_summary_grid.png" fig.savefig(grid_path, dpi=120) plt.close(fig) print(f" Summary grid → {grid_path}", flush=True) # --------------------------------------------------------------------------- # Phase 3 — Fusion event analysis (disk-based, no re-inference) # --------------------------------------------------------------------------- _EVENT_LABELS = [ "full correction\n(both wrong→fused right)", "img assist\n(img wrong, md right→right)", "md assist\n(md wrong, img right→right)", "full error\n(both right→fused wrong)", "img drag\n(img wrong, md right→wrong)", "md drag\n(md wrong, img right→wrong)", ] _EVENT_KEYS = ["full_correction", "img_assist", "md_assist", "full_error", "img_drag", "md_drag"] _EVENT_COLORS = ["#2ca02c", "#98df8a", "#b5d46e", "#d62728", "#ff9896", "#ffbf9b"] def _fusion_event_stats( y_true: np.ndarray, pf: np.ndarray, pi: np.ndarray, pm: np.ndarray, split_name: str, out_dir: Path, component_labels: tuple[str, str] = ("img", "md"), ) -> dict: """Compute, save, and plot fusion events for one split. Returns summary dict.""" N = len(y_true) if N == 0: print(f" [{split_name}] No samples — skipping.", flush=True) return {} a, b = component_labels event_labels = [ "full correction\n(both wrong→fused right)", f"{a} assist\n({a} wrong, {b} right→right)", f"{b} assist\n({b} wrong, {a} right→right)", "full error\n(both right→fused wrong)", f"{a} drag\n({a} wrong, {b} right→wrong)", f"{b} drag\n({b} wrong, {a} right→wrong)", ] pred_f = pf.argmax(axis=1) pred_i = pi.argmax(axis=1) pred_m = pm.argmax(axis=1) conf_f = np.take_along_axis(pf, pred_f[:, None], axis=1).squeeze(1) conf_i = np.take_along_axis(pi, pred_i[:, None], axis=1).squeeze(1) conf_m = np.take_along_axis(pm, pred_m[:, None], axis=1).squeeze(1) conf_delta = conf_f - 0.5 * (conf_i + conf_m) f_ok = pred_f == y_true i_ok = pred_i == y_true m_ok = pred_m == y_true full_correction = f_ok & ~i_ok & ~m_ok img_assist = f_ok & ~i_ok & m_ok md_assist = f_ok & i_ok & ~m_ok full_error = ~f_ok & i_ok & m_ok img_drag = ~f_ok & ~i_ok & m_ok md_drag = ~f_ok & i_ok & ~m_ok concordant_ok = f_ok & i_ok & m_ok concordant_bad = ~f_ok & ~i_ok & ~m_ok event_masks = [full_correction, img_assist, md_assist, full_error, img_drag, md_drag] counts = [int(m.sum()) for m in event_masks] print(f"\n [{split_name}] N={N}", flush=True) for label, count in zip(event_labels, counts): print(f" {label.replace(chr(10), ' '):55s}: {count}", flush=True) n_corr, n_err = counts[0], counts[3] print(f" full correction/error ratio: {n_corr}/{n_err}", flush=True) print(f" conf_delta mean={conf_delta.mean():+.4f} median={np.median(conf_delta):+.4f}", flush=True) # CSV event_type = np.where(concordant_ok, "concordant_correct", np.where(concordant_bad, "concordant_wrong", "other")).astype(object) for mask, key in zip(event_masks, _EVENT_KEYS): event_type[mask] = key rows = [ { "patient_idx": idx, "y_true": int(y_true[idx]), "pred_fused": int(pred_f[idx]), "pred_img": int(pred_i[idx]), "pred_md": int(pred_m[idx]), "conf_fused": float(conf_f[idx]), "conf_img": float(conf_i[idx]), "conf_md": float(conf_m[idx]), "conf_delta": float(conf_delta[idx]), "event_type": event_type[idx], } for idx in range(N) ] csv_path = out_dir / f"fusion_events_{split_name}.csv" with csv_path.open("w", newline="") as f: writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) writer.writeheader() writer.writerows(rows) # Plot fig, axes = plt.subplots(1, 3, figsize=(15, 4)) fig.suptitle(f"Fusion Events — {split_name} (N={N})", fontsize=11) for bar_x, bar_counts, bar_colors in ( (0, counts[:3], _EVENT_COLORS[:3]), (1, counts[3:], _EVENT_COLORS[3:]), ): bot = 0 for c, col in zip(bar_counts, bar_colors): axes[0].bar(bar_x, c, bottom=bot, color=col, width=0.5) if c > 0: axes[0].text(bar_x, bot + c / 2, str(c), ha="center", va="center", fontsize=9, fontweight="bold") bot += c axes[0].set_xticks([0, 1]) axes[0].set_xticklabels(["Positive\nevents", "Negative\nevents"]) axes[0].set_ylabel("Count") patches = [mpatches.Patch(color=c, label=l.replace("\n", " ")) for c, l in zip(_EVENT_COLORS, event_labels)] axes[0].legend(handles=patches, fontsize=6, loc="upper right") box_data = [conf_delta[m] for m in event_masks if m.sum() > 0] box_labels = [l.split("\n")[0] for m, l in zip(event_masks, event_labels) if m.sum() > 0] box_cols = [c for m, c in zip(event_masks, _EVENT_COLORS) if m.sum() > 0] if box_data: bp = axes[1].boxplot(box_data, patch_artist=True, widths=0.5) for patch, color in zip(bp["boxes"], box_cols): patch.set_facecolor(color) axes[1].set_xticks(range(1, len(box_labels) + 1)) axes[1].set_xticklabels(box_labels, rotation=35, ha="right", fontsize=7) axes[1].axhline(0, color="black", linewidth=0.8, linestyle="--") axes[1].set_ylabel(f"conf_delta\n(fused − avg({a}, {b}))") axes[1].set_title("Confidence delta by event type") for mask, color, label in zip(event_masks, _EVENT_COLORS, event_labels): if mask.sum() > 0: axes[2].scatter(conf_i[mask], conf_m[mask], c=color, label=label.split("\n")[0], alpha=0.85, s=45, edgecolors="none") if concordant_ok.sum() > 0: axes[2].scatter(conf_i[concordant_ok], conf_m[concordant_ok], c="lightgrey", alpha=0.4, s=20, edgecolors="none", label="concordant correct") if concordant_bad.sum() > 0: axes[2].scatter(conf_i[concordant_bad], conf_m[concordant_bad], c="darkgrey", alpha=0.4, s=20, edgecolors="none", label="concordant wrong") axes[2].plot([0, 1], [0, 1], "k--", linewidth=0.5, alpha=0.4) axes[2].set_xlabel(f"conf_{a}") axes[2].set_ylabel(f"conf_{b}") axes[2].set_title("Tower confidence space\ncoloured by fusion event") axes[2].legend(fontsize=6, loc="lower right") fig.tight_layout() fig.savefig(out_dir / f"fusion_events_{split_name}.png", dpi=150) plt.close(fig) print(f" Saved → {out_dir / f'fusion_events_{split_name}.png'}", flush=True) return { "split": split_name, "N": N, **{key: cnt for key, cnt in zip(_EVENT_KEYS, counts)}, "conf_delta_mean": float(conf_delta.mean()), "conf_delta_median": float(np.median(conf_delta)), } def _load_best_epoch_idx(fold_dir: Path, n_epochs: int) -> int: """Return 0-based array index of the best single checkpoint. Val/train prediction arrays skip md_warmup epochs but include all other phases (tower_warmup, fused_warmup, main). The array index is therefore the row position within epoch_log *after* filtering out md_warmup rows. """ log_path = fold_dir / "epoch_log.csv" if not log_path.exists(): return n_epochs - 1 try: log = pd.read_csv(log_path) if "is_best_single" not in log.columns or "phase_single" not in log.columns: return n_epochs - 1 # Keep only phases that produce predictions (everything except md_warmup) pred_log = log[log["phase_single"] != "md_warmup"].reset_index(drop=True) best_rows = pred_log[pred_log["is_best_single"] == True] if not best_rows.empty: return int(best_rows.index[-1]) except Exception: pass return n_epochs - 1 def _aggregate_by_patient( patient_ids: np.ndarray, y_true: np.ndarray, *prob_arrays: np.ndarray, ) -> tuple: """Average per-eye probs to patient level. Returns (y, *averaged_probs).""" unique_pids = np.unique(patient_ids) y_bilat = np.array([y_true[patient_ids == pid][0] for pid in unique_pids]) averaged = tuple( np.array([arr[patient_ids == pid].mean(axis=0) for pid in unique_pids]) for arr in prob_arrays ) return (y_bilat,) + averaged def run_fusion_event_analysis(fold_dir: Path, out_dir: Path) -> list[dict]: """ Load pre-saved predictions from disk and run fusion event analysis for both the train and val splits at the best checkpoint epoch. Returns list of summary dicts (one per split). """ print("\n[Phase 3] Fusion event analysis (from saved predictions) ...", flush=True) summaries = [] # ---- val: separate OD/OS per-epoch files, already patient-level ---- od_f = fold_dir / "val_probs_fused_od_epochs.npy" os_f = fold_dir / "val_probs_fused_os_epochs.npy" od_i = fold_dir / "val_probs_img_od_epochs.npy" os_i = fold_dir / "val_probs_img_os_epochs.npy" od_m = fold_dir / "val_probs_md_od_epochs.npy" os_m = fold_dir / "val_probs_md_os_epochs.npy" y_f = fold_dir / "val_y_true_epochs.npy" tower_mode = fold_dir.parent.name # "single", "ensemble", "bilateral", … is_ensemble_like = tower_mode in ("ensemble", "bilateral") if all(p.exists() for p in [od_f, os_f, od_i, os_i, od_m, os_m, y_f]): n_epochs = np.load(od_f).shape[0] epoch_idx = _load_best_epoch_idx(fold_dir, n_epochs) print(f" Val best epoch index: {epoch_idx}", flush=True) y_val = np.load(y_f)[epoch_idx] pf_od = np.load(od_f)[epoch_idx] pf_os = np.load(os_f)[epoch_idx] pi_od = np.load(od_i)[epoch_idx] pi_os = np.load(os_i)[epoch_idx] pm_od = np.load(od_m)[epoch_idx] pm_os = np.load(os_m)[epoch_idx] # Per-eye bridge (ensemble/bilateral only — redundant in single mode) if is_ensemble_like: summaries.append(_fusion_event_stats(y_val, pf_od, pi_od, pm_od, "val_OD", out_dir)) summaries.append(_fusion_event_stats(y_val, pf_os, pi_os, pm_os, "val_OS", out_dir)) # Ensemble: OD+OS averaged (bilateral patient-level) pf_ens = 0.5 * (pf_od + pf_os) pi_ens = 0.5 * (pi_od + pi_os) pm_ens = 0.5 * (pm_od + pm_os) summaries.append(_fusion_event_stats(y_val, pf_ens, pi_ens, pm_ens, "val", out_dir)) # Fused head (if available): learned bilateral combination vs per-eye bridge outputs fused_head_f = fold_dir / "probs_fused_head.npy" if fused_head_f.exists(): pf_head = np.load(fused_head_f) summaries.append(_fusion_event_stats( y_val, pf_head, pf_od, pf_os, "val_fused_head", out_dir, component_labels=("OD", "OS"))) else: print(" Val epoch files not found — skipping val.", flush=True) # ---- train: eye-level per-epoch files, aggregate to patient level ---- tr_pf_f = fold_dir / "train_probs_fused.npy" tr_pi_f = fold_dir / "train_probs_img.npy" tr_pm_f = fold_dir / "train_probs_md.npy" tr_y_f = fold_dir / "train_y_true.npy" tr_id_f = fold_dir / "train_patient_ids.npy" if all(p.exists() for p in [tr_pf_f, tr_pi_f, tr_pm_f, tr_y_f, tr_id_f]): n_epochs = np.load(tr_pf_f).shape[0] epoch_idx = _load_best_epoch_idx(fold_dir, n_epochs) print(f" Train best epoch index: {epoch_idx}", flush=True) pf_eyes = np.load(tr_pf_f)[epoch_idx] pi_eyes = np.load(tr_pi_f)[epoch_idx] pm_eyes = np.load(tr_pm_f)[epoch_idx] y_eyes = np.load(tr_y_f) pids = np.load(tr_id_f, allow_pickle=True).astype(str) y_tr, pf_tr, pi_tr, pm_tr = _aggregate_by_patient(pids, y_eyes, pf_eyes, pi_eyes, pm_eyes) summaries.append(_fusion_event_stats(y_tr, pf_tr, pi_tr, pm_tr, "train", out_dir)) else: print(" Train epoch files not found — skipping train.", flush=True) return summaries # --------------------------------------------------------------------------- # Cross-fold MD importance summary plot # --------------------------------------------------------------------------- def _plot_run_md_importance_summary(run_dir: Path, folds: list) -> None: """Aggregate per-fold MD permutation importance CSVs into a run-level summary plot.""" all_dfs = [] for fold_idx, fold_dir, _ in folds: csv_path = fold_dir / "explainability" / "md_permutation_importance.csv" if csv_path.exists(): df = pd.read_csv(csv_path) df["fold"] = fold_idx all_dfs.append(df) if not all_dfs: print(" [MD summary] No per-fold importance CSVs found — skipping.", flush=True) return combined = pd.concat(all_dfs, ignore_index=True) _SPECIAL = {"TOTAL_MD_ABLATION", "GAUSSIAN_NOISE_ABLATION"} feature_rows = combined[~combined["feature"].isin(_SPECIAL)] special_rows = combined[combined["feature"].isin(_SPECIAL)] agg = ( feature_rows.groupby("feature")["importance"] .agg(["mean", "std"]) .reset_index() .rename(columns={"mean": "mean_importance", "std": "std_importance"}) .sort_values("mean_importance", ascending=False) .reset_index(drop=True) ) special_agg = ( special_rows.groupby("feature")["importance"] .agg(["mean", "std"]) .reset_index() ) names = agg["feature"].tolist() imps = agg["mean_importance"].tolist() stds = agg["std_importance"].fillna(0).tolist() colors = ["#e05c5c" if v >= 0 else "#5c9ee0" for v in imps] fig, ax = plt.subplots(figsize=(9, max(4, (len(names) + 3) * 0.45))) y_pos = np.arange(len(names)) ax.barh(y_pos, imps, xerr=stds, color=colors, ecolor="grey", capsize=3, height=0.6) ax.axhline(len(names) - 0.25, color="grey", linewidth=0.6, linestyle="--") special_label_map = { "TOTAL_MD_ABLATION": "ALL MD (permute)", "GAUSSIAN_NOISE_ABLATION": "ALL MD (noise)", } special_colors = { "TOTAL_MD_ABLATION": "#c45ce0", "GAUSSIAN_NOISE_ABLATION": "#e08c2a", } extra_ytick_pos = [] extra_ytick_labels = [] for i, feat in enumerate(["TOTAL_MD_ABLATION", "GAUSSIAN_NOISE_ABLATION"]): row = special_agg[special_agg["feature"] == feat] if row.empty: continue offset = len(names) + 0.5 + i val, err = float(row["mean"].iloc[0]), float(row["std"].iloc[0]) ax.barh(offset, val, xerr=err, color=special_colors[feat] if val >= 0 else "#5c9ee0", ecolor="grey", capsize=3, height=0.6) extra_ytick_pos.append(offset) extra_ytick_labels.append(special_label_map[feat]) ax.set_yticks(list(y_pos) + extra_ytick_pos) ax.set_yticklabels(names + extra_ytick_labels, fontsize=9) ax.invert_yaxis() ax.axvline(0, color="black", linewidth=0.8) ax.set_xlabel("Mean AUC drop (baseline − permuted)", fontsize=10) ax.set_title( f"MD Tower — Permutation Feature Importance ({len(all_dfs)}-fold summary)\n" f"error bars = std across folds", fontsize=11, ) fig.tight_layout() out_path = run_dir / "explainability_md_importance_summary.png" fig.savefig(out_path, dpi=150) plt.close(fig) print(f" MD importance summary → {out_path}", flush=True) agg.to_csv(run_dir / "explainability_md_importance_summary.csv", index=False) # --------------------------------------------------------------------------- # Cross-fold fusion summary plot # --------------------------------------------------------------------------- def _plot_cross_fold_fusion_summary(df_sum: pd.DataFrame, run_dir: Path) -> None: """Generate aggregate fusion event plots from the cross-fold summary DataFrame. Loads per-fold fusion_events_*.csv files to get sample-level data for the conf_delta and tower-confidence-space panels. """ for split_name, grp in df_sum.groupby("split"): grp = grp.sort_values("fold").reset_index(drop=True) n_folds = len(grp) fold_ids = grp["fold"].values # For the fused_head split the two comparators are OD/OS bridges, not img/md towers comp_a, comp_b = ("OD", "OS") if "fused_head" in split_name else ("img", "md") summary_event_labels = [ "full correction\n(both wrong→fused right)", f"{comp_a} assist\n({comp_a} wrong, {comp_b} right→right)", f"{comp_b} assist\n({comp_b} wrong, {comp_a} right→right)", "full error\n(both right→fused wrong)", f"{comp_a} drag\n({comp_a} wrong, {comp_b} right→wrong)", f"{comp_b} drag\n({comp_b} wrong, {comp_a} right→wrong)", ] # Load all per-fold CSVs for this split to get sample-level data sample_dfs = [] for fold_idx in fold_ids: csv_path = (run_dir / f"fold{fold_idx}" / "explainability" / f"fusion_events_{split_name}.csv") if csv_path.exists(): sample_dfs.append(pd.read_csv(csv_path)) sample_df = pd.concat(sample_dfs, ignore_index=True) if sample_dfs else pd.DataFrame() fig, axes = plt.subplots(1, 5, figsize=(25, 4)) fig.suptitle(f"Fusion Events — {split_name} (all {n_folds} folds combined)", fontsize=11) # Panel 1: stacked bar of totals (positive vs negative) for bar_x, keys, colors in ( (0, _EVENT_KEYS[:3], _EVENT_COLORS[:3]), (1, _EVENT_KEYS[3:], _EVENT_COLORS[3:]), ): bot = 0 for key, col in zip(keys, colors): c = int(grp[key].sum()) axes[0].bar(bar_x, c, bottom=bot, color=col, width=0.5) if c > 0: axes[0].text(bar_x, bot + c / 2, str(c), ha="center", va="center", fontsize=9, fontweight="bold") bot += c axes[0].set_xticks([0, 1]) axes[0].set_xticklabels(["Positive\nevents", "Negative\nevents"]) axes[0].set_ylabel("Count (all folds)") patches = [mpatches.Patch(color=c, label=l.replace("\n", " ")) for c, l in zip(_EVENT_COLORS, summary_event_labels)] axes[0].legend(handles=patches, fontsize=6, loc="upper right") # Panel 2: per-fold stacked bar (fold variance) x = np.arange(n_folds) pos_bot = np.zeros(n_folds) neg_bot = np.zeros(n_folds) for key, color in zip(_EVENT_KEYS[:3], _EVENT_COLORS[:3]): vals = grp[key].values.astype(float) axes[1].bar(x, vals, bottom=pos_bot, color=color, width=0.6) pos_bot += vals for key, color in zip(_EVENT_KEYS[3:], _EVENT_COLORS[3:]): vals = grp[key].values.astype(float) axes[1].bar(x + 0.65, vals, bottom=neg_bot, color=color, width=0.6) neg_bot += vals axes[1].set_xticks(x + 0.325) axes[1].set_xticklabels([f"fold {f}" for f in fold_ids], fontsize=8) axes[1].set_ylabel("Count") axes[1].set_title("Per-fold breakdown\n(left=positive, right=negative)") # Panel 3: conf_delta_mean per fold (bar + mean line) cd = grp["conf_delta_mean"].values bar_colors = ["#2ca02c" if v >= 0 else "#d62728" for v in cd] axes[2].bar(x, cd, color=bar_colors, width=0.6, alpha=0.8) axes[2].axhline(cd.mean(), color="black", linewidth=1.2, linestyle="--", label=f"mean={cd.mean():+.4f}") axes[2].axhline(0, color="grey", linewidth=0.7) axes[2].set_xticks(x) axes[2].set_xticklabels([f"fold {f}" for f in fold_ids], fontsize=8) axes[2].set_ylabel(f"conf_delta mean\n(fused − avg({comp_a}, {comp_b}))") axes[2].set_title("Confidence delta per fold") axes[2].legend(fontsize=8) # Panel 4: conf_delta boxplot by event type (all folds combined) if not sample_df.empty and "event_type" in sample_df.columns: key_order = [k for k in _EVENT_KEYS if k in sample_df["event_type"].values] box_data = [sample_df.loc[sample_df["event_type"] == k, "conf_delta"].values for k in key_order] box_labels = [l.split("\n")[0] for k, l in zip(_EVENT_KEYS, summary_event_labels) if k in key_order] box_cols = [c for k, c in zip(_EVENT_KEYS, _EVENT_COLORS) if k in key_order] if box_data: bp = axes[3].boxplot(box_data, patch_artist=True, widths=0.5) for patch, color in zip(bp["boxes"], box_cols): patch.set_facecolor(color) axes[3].set_xticks(range(1, len(box_labels) + 1)) axes[3].set_xticklabels(box_labels, rotation=35, ha="right", fontsize=7) axes[3].axhline(0, color="black", linewidth=0.8, linestyle="--") axes[3].set_ylabel(f"conf_delta\n(fused − avg({comp_a}, {comp_b}))") axes[3].set_title("Confidence delta by event type\n(all folds)") # Panel 5: tower confidence space scatter (all folds combined) if not sample_df.empty: event_color_map = dict(zip(_EVENT_KEYS, _EVENT_COLORS)) for key, color in zip(_EVENT_KEYS, _EVENT_COLORS): sub = sample_df[sample_df["event_type"] == key] if len(sub): label = next(l.split("\n")[0] for k, l in zip(_EVENT_KEYS, summary_event_labels) if k == key) axes[4].scatter(sub["conf_img"], sub["conf_md"], c=color, label=label, alpha=0.7, s=30, edgecolors="none") for conc_key, conc_color, conc_label in [ ("concordant_correct", "lightgrey", "concordant correct"), ("concordant_wrong", "darkgrey", "concordant wrong"), ]: sub = sample_df[sample_df["event_type"] == conc_key] if len(sub): axes[4].scatter(sub["conf_img"], sub["conf_md"], c=conc_color, alpha=0.3, s=15, edgecolors="none", label=conc_label) axes[4].plot([0, 1], [0, 1], "k--", linewidth=0.5, alpha=0.4) axes[4].set_xlabel(f"conf_{comp_a}") axes[4].set_ylabel(f"conf_{comp_b}") axes[4].legend(fontsize=6, loc="lower right") axes[4].set_title("Tower confidence space\n(all folds)") fig.tight_layout() out_path = run_dir / f"explainability_fusion_summary_{split_name}.png" fig.savefig(out_path, dpi=150) plt.close(fig) print(f" Summary plot → {out_path}", flush=True) # --------------------------------------------------------------------------- # Fold discovery # --------------------------------------------------------------------------- def find_folds(run_dir: Path, checkpoint: str) -> list[tuple[int, Path, Path]]: """Return sorted list of (fold_idx, fold_dir, ckpt_path) for existing folds.""" folds = [] for fold_dir in sorted(run_dir.glob("fold*")): if not fold_dir.is_dir(): continue try: fold_idx = int(fold_dir.name.replace("fold", "")) except ValueError: continue ckpt = fold_dir / checkpoint if not ckpt.exists(): print(f" [WARN] Checkpoint not found: {ckpt} — skipping fold {fold_idx}", flush=True) continue folds.append((fold_idx, fold_dir, ckpt)) return folds # --------------------------------------------------------------------------- # Argument parsing # --------------------------------------------------------------------------- def parse_args(): ap = argparse.ArgumentParser( description="Post-hoc explainability for all folds of a saved run." ) ap.add_argument( "--run-dir", type=Path, required=True, help="Path to the tower-mode run directory, e.g. " "analysis_data/v2.3_single_binary_nocrop/binary/single", ) ap.add_argument( "--checkpoint", default="best_single.pt", help="Checkpoint filename inside each fold_dir (default: best_single.pt)", ) ap.add_argument( "--split", choices=["holdout", "val"], default="holdout", help="Which patient set to use for Phases 1 & 2 (default: holdout, falls back to val)", ) ap.add_argument("--image-dir", default="Papila/FundusImages") ap.add_argument("--clinical-dir", default="Papila/ClinicalData") ap.add_argument("--label-col", default="Diagnosis") ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"]) ap.add_argument("--fold-seed", type=int, default=42) ap.add_argument("--holdout-seed", type=int, default=123) ap.add_argument("--holdout-per-class", type=int, default=5) ap.add_argument("--n-splits", type=int, default=5) ap.add_argument("--n-permutations", type=int, default=30) ap.add_argument("--seed", type=int, default=0) ap.add_argument("--alpha", type=float, default=0.45) ap.add_argument("--batch-size", type=int, default=1) ap.add_argument("--no-phase1", action="store_true", help="Skip MD importance") ap.add_argument("--no-phase2", action="store_true", help="Skip GradCAM") ap.add_argument("--no-phase3", action="store_true", help="Skip fusion event analysis") return ap.parse_args() # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main(): args = parse_args() run_dir = args.run_dir.resolve() if not run_dir.is_dir(): sys.exit(f"[ERROR] run_dir does not exist: {run_dir}") summary_path = run_dir / "summary.json" if not summary_path.exists(): sys.exit(f"[ERROR] summary.json not found: {summary_path}") summary = json.loads(summary_path.read_text()) backbone = summary["backbone"] eval_mode = summary["eval_mode"] tower_mode = summary.get("tower_mode", "single") print(f"[explain_run] backbone={backbone} eval_mode={eval_mode} tower_mode={tower_mode}") if tower_mode not in ("single", "ensemble"): sys.exit( f"[ERROR] explain_run supports single/ensemble tower modes, got: {tower_mode!r}" ) folds = find_folds(run_dir, args.checkpoint) if not folds: sys.exit(f"[ERROR] No fold directories with checkpoint '{args.checkpoint}' found in {run_dir}") print(f"[explain_run] Found {len(folds)} fold(s): {[f[0] for f in folds]}") device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print(f"[explain_run] device={device}", flush=True) # Build data bundle once (shared across folds) print("[explain_run] Loading clinical data ...", flush=True) data = build_papila_data( image_dir=args.image_dir, clinical_dir=args.clinical_dir, label_col=args.label_col, cat_cols=args.cat_cols, n_splits=args.n_splits, random_seed=args.fold_seed, ) df_mode = data.df.copy() if eval_mode == "binary": df_mode = df_mode[df_mode[args.label_col].isin([0, 1])].reset_index(drop=True) num_classes = 2 if eval_mode == "binary" else int(df_mode[args.label_col].nunique()) # Reconstruct all splits once splitter = PatientFirstSplitManager( patient_col="Patient ID", label_col=args.label_col ) split_args = SimpleNamespace( eval_mode=eval_mode, holdout_per_class=args.holdout_per_class, holdout_seed=args.holdout_seed, n_splits=args.n_splits, fold_seed=args.fold_seed, ) clinical_ns = SimpleNamespace(df=df_mode, label_col=args.label_col) plans = splitter.build_plans(clinical=clinical_ns, args=split_args, profile=None) profile_patient = build_papila_profile( patient_col="Patient ID", label_col=args.label_col, sample_mode="patient" ) eval_transform = build_eval_transform(backbone) all_phase3_summaries = [] for fold_idx, fold_dir, ckpt_path in folds: print(f"\n{'='*60}", flush=True) print(f"[explain_run] === Fold {fold_idx} ===", flush=True) out_dir = fold_dir / "explainability" out_dir.mkdir(exist_ok=True) # Phase 3 needs no model — run first so the model load can be skipped # if only phase3 is requested if not args.no_phase3: p3_summaries = run_fusion_event_analysis(fold_dir=fold_dir, out_dir=out_dir) for s in p3_summaries: s["fold"] = fold_idx all_phase3_summaries.extend(p3_summaries) if args.no_phase1 and args.no_phase2: continue # ---- Phases 1 & 2 require model + loader ---- if fold_idx >= len(plans): print(f" [WARN] fold_idx={fold_idx} >= n_plans={len(plans)} — skipping Phases 1/2", flush=True) continue split = plans[fold_idx] if args.split == "holdout" and split.holdout is not None and not split.holdout.empty: eval_df = split.holdout split_name = "holdout" else: if args.split == "holdout": print(" [WARN] No holdout set; falling back to val.", flush=True) eval_df = split.val split_name = "val" print(f" Phases 1/2 using {split_name}: {eval_df['Patient ID'].nunique()} patients", flush=True) samples = filter_bilateral_samples( profile_patient.build_samples(df=eval_df, clinical=data) ) if not samples: print(f" [WARN] No bilateral samples for fold {fold_idx} — skipping Phases 1/2", flush=True) continue loader = make_loader( samples, profile_patient.slot_descriptors(), image_transform=eval_transform, image_preprocessor=None, batch_size=args.batch_size, shuffle=False, num_workers=0, ) print(f" Loading model from {ckpt_path} ...", flush=True) model = SingleEyeHT( backbone=backbone, freeze_ratio=0.0, augment=False, clinical_data=data, num_classes=num_classes, ).to(device) state = torch.load(ckpt_path, map_location=device) model.load_state_dict(state) model.eval() if not args.no_phase1: run_permutation_importance( model=model, loader=loader, data=data, num_classes=num_classes, device=device, n_permutations=args.n_permutations, seed=args.seed, out_dir=out_dir, ) if not args.no_phase2: run_gradcam( model=model, loader=loader, data=data, eval_df=eval_df, eval_mode=eval_mode, backbone=backbone, device=device, alpha=args.alpha, out_dir=out_dir, ) # free GPU memory between folds del model torch.cuda.empty_cache() # ---- Cross-fold MD importance summary ---- if not args.no_phase1: print(f"\n{'='*60}", flush=True) print("[explain_run] === Cross-fold MD importance summary ===", flush=True) _plot_run_md_importance_summary(run_dir, folds) # ---- Cross-fold Phase 3 summary ---- if all_phase3_summaries and not args.no_phase3: print(f"\n{'='*60}", flush=True) print("[explain_run] === Cross-fold fusion event summary ===", flush=True) df_sum = pd.DataFrame(all_phase3_summaries) for split_name, grp in df_sum.groupby("split"): print(f"\n [{split_name}]", flush=True) for col in _EVENT_KEYS: vals = grp[col].values print(f" {col:20s} mean={vals.mean():.1f} total={vals.sum()}", flush=True) cd = grp["conf_delta_mean"].values print(f" conf_delta_mean mean={cd.mean():+.4f} std={cd.std():.4f}", flush=True) summary_csv = run_dir / "explainability_fusion_summary.csv" df_sum.to_csv(summary_csv, index=False) print(f"\n Cross-fold summary → {summary_csv}", flush=True) _plot_cross_fold_fusion_summary(df_sum, run_dir) print("\n[explain_run] Done.", flush=True) if __name__ == "__main__": main()