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
+101 -11
View File
@@ -12,10 +12,11 @@ from torchvision import models, transforms
_REPO_ROOT = Path(__file__).resolve().parents[3]
REFUGELIKE_BACKBONE_PATH = _REPO_ROOT / "models/v2/refuge/refugelike_backbone.pt"
REFUGE_DENSENET_PATH = _REPO_ROOT / "models/refuge/classifier/refuge_densenet_backbone.pt"
REFUGE_EFFICIENT_B0_PATH = _REPO_ROOT / "models/refuge/classifier/refuge_efficient_b0_backbone.pt"
REFUGE_EFFICIENT_B7_PATH = _REPO_ROOT / "models/refuge/classifier/refuge_efficient_b7_backbone.pt"
REFUGELIKE_BACKBONE_PATH = _REPO_ROOT / "models/v2/refuge/refugelike_backbone.pt"
REFUGE_DENSENET_PATH = _REPO_ROOT / "models/refuge/classifier/refuge_densenet_backbone.pt"
REFUGE_EFFICIENTNET_B0_PATH = _REPO_ROOT / "models/refuge/classifier/refuge_efficientnet_b0_backbone.pt"
REFUGE_EFFICIENTNET_B7_PATH = _REPO_ROOT / "models/refuge/classifier/refuge_efficientnet_b7_backbone.pt"
REFUGE_EFFICIENTNET_V2_M_PATH = _REPO_ROOT / "models/refuge/classifier/refuge_efficientnet_v2_m_backbone.pt"
@dataclass(frozen=True)
@@ -109,6 +110,30 @@ BACKBONES: Dict[str, BackboneSpec] = {
strip=_strip_efficientnet,
blocks=_blocks_efficientnet,
),
"efficientnet_b7": BackboneSpec(
ctor=models.efficientnet_b7,
weights_default=models.EfficientNet_B7_Weights.DEFAULT,
strip=_strip_efficientnet,
blocks=_blocks_efficientnet,
),
"efficientnet_v2_s": BackboneSpec(
ctor=models.efficientnet_v2_s,
weights_default=models.EfficientNet_V2_S_Weights.DEFAULT,
strip=_strip_efficientnet,
blocks=_blocks_efficientnet,
),
"efficientnet_v2_m": BackboneSpec(
ctor=models.efficientnet_v2_m,
weights_default=models.EfficientNet_V2_M_Weights.DEFAULT,
strip=_strip_efficientnet,
blocks=_blocks_efficientnet,
),
"efficientnet_v2_l": BackboneSpec(
ctor=models.efficientnet_v2_l,
weights_default=models.EfficientNet_V2_L_Weights.DEFAULT,
strip=_strip_efficientnet,
blocks=_blocks_efficientnet,
),
"resnet18": BackboneSpec(
ctor=models.resnet18,
weights_default=models.ResNet18_Weights.DEFAULT,
@@ -157,31 +182,94 @@ BACKBONES: Dict[str, BackboneSpec] = {
strip=_strip_densenet,
blocks=_blocks_densenet,
),
"refuge_efficient_b0": BackboneSpec(
"refuge_efficientnet_b0": BackboneSpec(
ctor=models.efficientnet_b0,
weights_default=None,
strip=_strip_efficientnet,
blocks=_blocks_efficientnet,
),
"refuge_efficient_b7": BackboneSpec(
"refuge_efficientnet_b7": BackboneSpec(
ctor=models.efficientnet_b7,
weights_default=None,
strip=_strip_efficientnet,
blocks=_blocks_efficientnet,
),
"refuge_efficientnet_v2_m": BackboneSpec(
ctor=models.efficientnet_v2_m,
weights_default=None,
strip=_strip_efficientnet,
blocks=_blocks_efficientnet,
),
}
_TIMM_CONVNEXTV2_VARIANTS = (
"convnextv2_atto",
"convnextv2_femto",
"convnextv2_pico",
"convnextv2_nano",
"convnextv2_tiny",
"convnextv2_base",
"convnextv2_large",
"convnextv2_huge",
)
def _is_timm_backbone(name: str) -> bool:
return name in _TIMM_CONVNEXTV2_VARIANTS
def _build_timm_backbone(name: str, freeze_ratio: float) -> tuple[nn.Module, int, list]:
"""Build a timm-sourced backbone (ConvNeXt-V2 family).
Uses num_classes=0 + global_pool="avg" so the model returns a pooled
(B, num_features) tensor directly — no head to strip. Freezable blocks
are [stem, stage_0, stage_1, stage_2, stage_3].
"""
try:
import timm
except ImportError as e:
raise ImportError(
f"backbone {name!r} requires the `timm` package "
"(pip install timm)"
) from e
m = timm.create_model(name, pretrained=True, num_classes=0, global_pool="avg")
out_dim = int(getattr(m, "num_features", 0))
if not out_dim:
raise RuntimeError(f"timm model {name!r} did not expose num_features")
blocks: list[nn.Module] = []
if hasattr(m, "stem"):
blocks.append(m.stem)
if hasattr(m, "stages"):
blocks.extend(list(m.stages))
if not blocks:
raise RuntimeError(
f"timm model {name!r} did not expose .stem / .stages — "
"extend _build_timm_backbone to support this architecture."
)
fr = max(0.0, min(1.0, float(freeze_ratio)))
n_freeze = int(math.floor(len(blocks) * fr))
for b in blocks[:n_freeze]:
for p in b.parameters():
p.requires_grad = False
return m, out_dim, blocks
def list_names() -> List[str]:
return list(BACKBONES.keys())
return list(BACKBONES.keys()) + list(_TIMM_CONVNEXTV2_VARIANTS)
def load_backbone_weights(key: str, model: nn.Module) -> None:
paths = {
"refugelike": REFUGELIKE_BACKBONE_PATH,
"refuge_densenet": REFUGE_DENSENET_PATH,
"refuge_efficient_b0": REFUGE_EFFICIENT_B0_PATH,
"refuge_efficient_b7": REFUGE_EFFICIENT_B7_PATH,
"refugelike": REFUGELIKE_BACKBONE_PATH,
"refuge_densenet": REFUGE_DENSENET_PATH,
"refuge_efficientnet_b0": REFUGE_EFFICIENTNET_B0_PATH,
"refuge_efficientnet_b7": REFUGE_EFFICIENTNET_B7_PATH,
"refuge_efficientnet_v2_m": REFUGE_EFFICIENTNET_V2_M_PATH,
}
path = paths.get(key)
if path is None:
@@ -202,6 +290,8 @@ def build_backbone(name: str, freeze_ratio: float = 0.0) -> tuple[nn.Module, int
freezable units — callers use it to dynamically adjust freeze_ratio later.
"""
key = (name or "").lower()
if _is_timm_backbone(key):
return _build_timm_backbone(key, freeze_ratio)
if key not in BACKBONES:
raise ValueError(f"Unknown backbone '{name}'. Available: {list_names()}")
+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,
}
+6 -1
View File
@@ -6,7 +6,7 @@ from typing import Tuple
from torchvision import transforms
from v4.classes.accessory.backbones import BACKBONES
from v4.classes.accessory.backbones import BACKBONES, _is_timm_backbone
IMAGENET_MEAN: Tuple[float, float, float] = (0.485, 0.456, 0.406)
IMAGENET_STD: Tuple[float, float, float] = (0.229, 0.224, 0.225)
@@ -79,6 +79,11 @@ class ImageTransformConfig:
def backbone_transform_config(backbone_name: str, augment: bool = True) -> ImageTransformConfig:
"""Build an ImageTransformConfig using the backbone's default normalisation stats."""
key = (backbone_name or "").lower()
if _is_timm_backbone(key):
# ConvNeXt-V2 and other timm models we currently expose are all
# pretrained with standard ImageNet stats at 224×224.
return ImageTransformConfig(crop_size=224, mean=IMAGENET_MEAN,
std=IMAGENET_STD, augment=augment)
if key not in BACKBONES:
raise ValueError(f"Unknown backbone '{backbone_name}'.")
spec = BACKBONES[key]