Add new regression and ensemble experiment configurations for V2-M and OrthoBridge

- Introduced multiple regression experiment configurations targeting vf_md, including:
  - cd_solo_reg_set.json: CD tower only regression setup.
  - img_solo_reg_set.json: Image tower only regression setup.
  - reg_head_epoch_sweep.json: Baseline regression sweeps at different epochs (50, 75, 100).
  - reg_head_set.json: Various regression setups including baseline and OrthoBridge configurations.
  - single_eye_reg.json: Single-eye regression setup for worst-eye aggregation analysis.

- Added ensemble configurations for OrthoBridge with different inner bridges:
  - ortho_alts_ensemble.json: Ensemble tests with ConcatBridge, PairwiseAdditiveBridge, and GatedAdditiveBridge.
  - ortho_alts_tritower.json: Tritower tests with the same inner bridges.

- Created V2-M specific configurations:
  - baseline_reg_nt50.json: Regression baseline with V2-M backbone.
  - geom_vec_gt.json and geom_vec_unet.json: Geometry vector injection experiments with V2-M.
  - single_l1_bridges.json: Single-eye ensemble experiments with various bridge types.
  - tritower_geom_gt.json: Tritower setup with GT contour-rasterized masks.

- Promoted existing experiments to higher repetitions for robustness.
This commit is contained in:
rpotter6298
2026-06-11 15:08:20 +02:00
parent 32a801a572
commit 280060db82
343 changed files with 8558 additions and 57747 deletions
+304
View File
@@ -0,0 +1,304 @@
"""Generic explainability primitives — GradCAM + region-of-interest analysis
+ column-shuffling permutation importance.
This module is dataset-agnostic. All PAPILA-specific knowledge (where the disc
contour lives, eye orientation conventions, clinical feature names, etc.) is
supplied by the profile through optional methods consumed via getattr — see
v4.classes.profiles.v4papila.ImageDataView and ClinicalDataView for examples.
Public surface
--------------
GradCAM — hooks-based class-activation maps via fwd/bwd hooks
overlay_gradcam — render a heatmap blended onto a PIL image
cam_region_stats — quantify CAM attention inside/outside a binary ROI
cam_region_patch — crop a square patch centered on the ROI centroid
permutation_importance — per-feature drop-in-score under column shuffling
"""
from __future__ import annotations
from typing import Callable, Sequence
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from matplotlib import cm
from PIL import Image
# ─────────────────────────────────────────────────────────────────────────────
# GradCAM
# ─────────────────────────────────────────────────────────────────────────────
class GradCAM:
"""Minimal GradCAM via forward/backward hooks.
Architecture-agnostic: the caller supplies a forward_fn that returns a
(1, num_classes) logits tensor, so this class doesn't need to know whether
the model takes (img,), (img, meta), or composed tower/bridge/head inputs.
Usage
-----
cam_module = GradCAM(target_layer)
cam, pred = cam_module.compute(
forward_fn=lambda: head(bridge([tower_img(x), tower_cd(c)])),
output_shape=(224, 224),
)
cam_module.remove()
"""
def __init__(self, target_layer: 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, _module, _inputs, output):
self._acts = output.detach()
def _save_grads(self, _module, _grad_in, grad_out):
self._grads = grad_out[0].detach()
def compute(
self,
forward_fn: Callable[[], torch.Tensor],
*,
output_shape: tuple[int, int],
target_class: int | None = None,
) -> tuple[np.ndarray, int]:
"""Run forward via forward_fn, backward against target_class, return CAM.
Parameters
----------
forward_fn
Zero-arg callable returning a (1, C) logits tensor. Caller is
responsible for placing model in eval mode and zeroing grads.
output_shape
(H, W) at which the CAM is bilinearly upsampled. Typically the
eval-crop image size, e.g. (224, 224).
target_class
Class index to backprop against. If None, uses the predicted class.
Returns
-------
(cam, pred)
cam : float32 ndarray of shape output_shape, normalised to [0, 1]
pred : int, the predicted class index
"""
with torch.enable_grad():
out = forward_fn()
pred = int(out.argmax(1).item())
tc = pred if target_class is None else target_class
out[0, tc].backward()
assert self._grads is not None and self._acts is not None, (
"GradCAM hooks did not fire — check that target_layer is in the "
"forward path of forward_fn()."
)
weights = self._grads.mean(dim=(2, 3), keepdim=True)
cam = F.relu((weights * self._acts).sum(dim=1, keepdim=True))
cam = F.interpolate(cam, output_shape, mode="bilinear", align_corners=False)
cam_np = cam.squeeze().cpu().numpy()
lo, hi = cam_np.min(), cam_np.max()
return ((cam_np - lo) / (hi - lo + 1e-8)).astype(np.float32), pred
def remove(self) -> None:
"""Detach the forward/backward hooks. Call when done with this instance."""
self._h1.remove()
self._h2.remove()
# ─────────────────────────────────────────────────────────────────────────────
# Visualisation
# ─────────────────────────────────────────────────────────────────────────────
def overlay_gradcam(pil: Image.Image, cam: np.ndarray, alpha: float = 0.45) -> Image.Image:
"""Blend a jet-colored heatmap of `cam` onto `pil` at alpha blending weight."""
cam_u8 = (np.clip(cam, 0.0, 1.0) * 255).astype(np.uint8)
cam_r = np.array(Image.fromarray(cam_u8).resize(pil.size, Image.BILINEAR)) / 255.0
colored = (cm.jet(cam_r)[:, :, :3] * 255).astype(np.uint8)
return Image.blend(pil.convert("RGB"), Image.fromarray(colored), alpha)
# ─────────────────────────────────────────────────────────────────────────────
# Region-of-interest analysis (generic — no PAPILA knowledge)
# ─────────────────────────────────────────────────────────────────────────────
def cam_region_stats(cam: np.ndarray, roi_mask: np.ndarray) -> dict[str, float]:
"""Quantify CAM attention restricted to / outside a binary ROI mask.
The CAM and the mask must be at the same resolution.
Returns
-------
dict with:
inside_mean : mean CAM intensity inside the ROI
outside_mean : mean CAM intensity outside the ROI
inside_fraction : sum(CAM inside) / sum(CAM total); fraction of total
attention that falls inside the ROI
roi_area_frac : ROI area / total area; how much of the image the ROI
covers — useful for normalising inside_fraction
"""
if cam.shape != roi_mask.shape:
raise ValueError(
f"cam shape {cam.shape} != roi_mask shape {roi_mask.shape}"
)
mask = roi_mask.astype(bool)
cam_sum = float(cam.sum())
inside_sum = float(cam[mask].sum()) if mask.any() else 0.0
outside_sum = cam_sum - inside_sum
n_inside = int(mask.sum())
n_outside = int(mask.size - n_inside)
return {
"inside_mean": inside_sum / max(1, n_inside),
"outside_mean": outside_sum / max(1, n_outside),
"inside_fraction": inside_sum / cam_sum if cam_sum > 0 else 0.0,
"roi_area_frac": n_inside / mask.size,
}
def cam_region_patch(
cam: np.ndarray,
roi_mask: np.ndarray,
*,
span: float = 5.0,
patch_size: int = 96,
) -> tuple[np.ndarray | None, float | None]:
"""Crop a square patch centered on the ROI centroid.
The crop side is `span × roi_radius` (in CAM pixels), where roi_radius is
derived from the mask area assuming a circular ROI. The output is resized
to (patch_size, patch_size).
Returns
-------
(patch, roi_radius_in_patch_pixels)
patch : float32 ndarray of shape (patch_size, patch_size), values in
[0, 1]. Padded with zeros if the centered crop window extends
outside the CAM.
roi_radius_in_patch_pixels : float, the ROI radius re-expressed in
output-patch pixels (useful for drawing ROI circles in figures).
Returns (None, None) if the mask is empty.
"""
mask = roi_mask.astype(bool)
if not mask.any():
return None, None
ys, xs = np.where(mask)
cy, cx = ys.mean(), xs.mean()
roi_r = float(np.sqrt(mask.sum() / np.pi))
half = max(1, int(round(span * roi_r / 2)))
h, w = cam.shape
y0, y1 = int(round(cy)) - half, int(round(cy)) + half
x0, x1 = int(round(cx)) - half, int(round(cx)) + half
pt = max(0, -y0)
pb = max(0, y1 - h)
pl = max(0, -x0)
pr = max(0, x1 - w)
cam_pad = np.pad(cam, ((pt, pb), (pl, pr)), constant_values=0.0)
window = cam_pad[y0 + pt: y1 + pt, x0 + pl: x1 + pl]
patch = np.array(
Image.fromarray((np.clip(window, 0, 1) * 255).astype(np.uint8))
.resize((patch_size, patch_size), Image.BILINEAR)
) / 255.0
roi_r_out = patch_size * roi_r / (2 * half)
return patch.astype(np.float32), roi_r_out
# ─────────────────────────────────────────────────────────────────────────────
# Permutation importance (column-shuffling)
# ─────────────────────────────────────────────────────────────────────────────
def permutation_importance(
*,
score_fn: Callable[[np.ndarray], float],
X: np.ndarray,
groups: Sequence[Sequence[int]] | None = None,
n_permutations: int = 30,
seed: int = 0,
feature_names: Sequence[str] | None = None,
) -> dict:
"""Per-feature drop-in-score from column shuffling.
Generic and feature-agnostic — the caller wires the model (or any
arbitrary scoring pipeline) into ``score_fn``. Higher score = better;
drop = baseline_score score(X_with_column_permuted).
Parameters
----------
score_fn
Callable taking the input matrix ``X`` (shape (N, F)) and returning
a scalar metric. Caller is responsible for any constants the metric
depends on (e.g. labels, held-out image embeddings).
X
Float ndarray of shape (N, F). The base matrix to permute.
groups
Optional list of column-index groups. If provided, each group is
permuted together (useful when several model dims encode one
conceptual feature, e.g. one-hot encodings of a categorical column).
Defaults to one group per column.
n_permutations
Number of independent shuffles per group; mean and std of the drop
are reported across these.
seed
Seed for the shuffling RNG.
feature_names
Optional human-readable names, one per group. If not provided, names
default to f"group_{i}" or f"col_{j}" when groups is None.
Returns
-------
dict with keys:
baseline : float, the score on unmodified X
mean_drop : ndarray of shape (G,), mean (baseline shuffled) per group
std_drop : ndarray of shape (G,), std of per-permutation drops
feature_names : list[str] of length G
groups : list[list[int]] of resolved column groups
"""
if X.ndim != 2:
raise ValueError(f"X must be 2-D (N, F); got shape {X.shape}")
n, f_dim = X.shape
if groups is None:
resolved_groups: list[list[int]] = [[j] for j in range(f_dim)]
else:
resolved_groups = [list(g) for g in groups]
all_cols = [c for g in resolved_groups for c in g]
if any(c < 0 or c >= f_dim for c in all_cols):
raise ValueError(
f"groups contain column indices outside [0, {f_dim})"
)
n_groups = len(resolved_groups)
if feature_names is None:
names = (
[f"col_{g[0]}" for g in resolved_groups] if groups is None
else [f"group_{i}" for i in range(n_groups)]
)
else:
names = list(feature_names)
if len(names) != n_groups:
raise ValueError(
f"feature_names has length {len(names)} but there are "
f"{n_groups} groups"
)
rng = np.random.default_rng(seed)
baseline = float(score_fn(X))
drops = np.empty((n_groups, n_permutations), dtype=float)
for gi, cols in enumerate(resolved_groups):
for p in range(n_permutations):
X_perm = X.copy()
perm = rng.permutation(n)
X_perm[:, cols] = X_perm[perm][:, cols]
drops[gi, p] = baseline - float(score_fn(X_perm))
return {
"baseline": baseline,
"mean_drop": drops.mean(axis=1),
"std_drop": drops.std(axis=1),
"feature_names": names,
"groups": resolved_groups,
}