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]
+13 -1
View File
@@ -208,10 +208,22 @@ class HTDataset(Dataset):
def __getitem__(self, idx: int) -> dict[str, Any]:
entry = self.entries[idx]
sample = {
sample: dict[str, Any] = {
"label": torch.tensor(entry.label, dtype=torch.long),
"entity_id": entry.entity_id,
}
# Any per-entry auxiliary fields the profile attached via meta
# (e.g. vf_md for regression heads) flow into the batch dict
# alongside `label` and tower inputs.
for k, v in entry.meta.items():
if k in sample:
continue # don't overwrite primary fields
if isinstance(v, torch.Tensor):
sample[k] = v
elif isinstance(v, (int, float)):
sample[k] = torch.tensor(v, dtype=torch.float32)
else:
sample[k] = v
for name, tower in self.towers.items():
sample[name] = tower.get_sample(entry)
return sample
+165
View File
@@ -0,0 +1,165 @@
"""regression — RegressionHead for continuous-target tasks.
Self-contained head: implements the four opt-in hooks expected by the v4
stage runners (`target_key`, `compute_loss`, `to_probs`, `score`), so no
runner-side classification logic interferes.
Usage in a stage config:
{
"name": "nt_head",
"type": "head",
"input": "nt",
"train_with": "nt",
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": {
"target_key": "vf_md",
"dropout": 0.3,
"loss": "mse"
}
}
"""
from __future__ import annotations
from typing import Any
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
_LOSS_FNS = {
"mse": F.mse_loss,
"l1": F.l1_loss,
"huber": F.huber_loss,
}
class RegressionHead(nn.Module):
"""Minimal regression head: ReLU → Dropout → Linear(in_dim → 1).
Parameters
----------
in_dim : input embedding dimension (set by the stage runner)
num_classes : ignored — present only so the runner's `h_cls(in_dim, num_classes, **args)`
invocation keeps working. The head always outputs 1 scalar.
dropout : dropout fraction before the final linear
target_key : which batch field to read as the ground-truth value
(must match a field populated by the dataset; default 'vf_md')
loss : 'mse' | 'l1' | 'huber'
"""
def __init__(
self,
in_dim: int,
num_classes: int = 1,
dropout: float = 0.3,
target_key: str = "vf_md",
loss: str = "mse",
):
super().__init__()
del num_classes # explicitly ignored; always single-output
self.target_key = target_key
if loss not in _LOSS_FNS:
raise ValueError(f"unknown loss {loss!r}; choose from {list(_LOSS_FNS)}")
self._loss_name = loss
self._loss_fn = _LOSS_FNS[loss]
self.net = nn.Sequential(
nn.ReLU(),
nn.Dropout(dropout),
nn.Linear(in_dim, 1),
)
# ── nn.Module ──────────────────────────────────────────────────────────────
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.net(x).squeeze(-1) # (B,) — per-sample predicted scalar
# ── opt-in hooks consumed by the stage runners ─────────────────────────────
def compute_loss(self, logits: torch.Tensor, batch: dict) -> torch.Tensor:
"""Run regression loss against this head's target field in the batch."""
target = batch[self.target_key]
if not torch.is_tensor(target):
target = torch.as_tensor(target)
target = target.float().to(logits.device)
# logits is (B,), target is (B,) — match shapes for any loss fn
return self._loss_fn(logits, target)
def to_probs(self, logits: torch.Tensor) -> np.ndarray:
"""For regression there is no 'probability' — return raw predictions."""
return logits.detach().cpu().numpy()
def score(self, y_true: np.ndarray, predictions: np.ndarray) -> dict:
"""Compute regression metrics. Returns dict with 'primary' = neg-MSE.
We report negative MSE as 'primary' so that 'higher is better' matches
the convention of AUC — easier for monitoring code that expects to
maximise the primary metric. MSE itself is also stored separately.
"""
y = np.asarray(y_true, dtype=np.float32).ravel()
pred = np.asarray(predictions, dtype=np.float32).ravel()
n = y.size
if n == 0:
return {"primary": float("nan"),
"primary_name": "neg_mse",
"neg_mse": float("nan"),
"mse": float("nan"),
"mae": float("nan"),
"r2": float("nan"),
"spearman": float("nan"),
"n": 0}
diff = pred - y
mse = float(np.mean(diff ** 2))
mae = float(np.mean(np.abs(diff)))
# R²: 1 - SSres / SStot; falls back to NaN if SStot = 0
ss_res = float(np.sum(diff ** 2))
ss_tot = float(np.sum((y - y.mean()) ** 2))
r2 = float(1.0 - ss_res / ss_tot) if ss_tot > 1e-12 else float("nan")
# Spearman rank correlation (no scipy dependency — manual rank-Pearson)
spearman = _spearmanr(y, pred)
return {
"primary": -mse, # neg-MSE so 'higher is better'
"primary_name": "neg_mse",
"neg_mse": -mse, # mirror under its primary_name (matches AUC pattern)
"mse": mse,
"mae": mae,
"r2": r2,
"spearman": spearman,
"n": int(n),
}
def _spearmanr(a: np.ndarray, b: np.ndarray) -> float:
"""Simple Spearman correlation — Pearson on ranks; handles ties via average ranks."""
if a.size < 2:
return float("nan")
ra = _rankdata(a)
rb = _rankdata(b)
ra -= ra.mean(); rb -= rb.mean()
den = float(np.sqrt((ra ** 2).sum() * (rb ** 2).sum()))
if den < 1e-12:
return float("nan")
return float((ra * rb).sum() / den)
def _rankdata(x: np.ndarray) -> np.ndarray:
"""Assign average ranks; equivalent to scipy.stats.rankdata."""
order = np.argsort(x, kind="mergesort")
ranks = np.empty_like(order, dtype=np.float64)
ranks[order] = np.arange(1, len(x) + 1)
# Average ties
sorted_x = x[order]
i = 0
while i < len(x):
j = i
while j + 1 < len(x) and sorted_x[j + 1] == sorted_x[i]:
j += 1
if j > i:
avg = (ranks[order[i:j + 1]].mean())
ranks[order[i:j + 1]] = avg
i = j + 1
return ranks
+2 -1
View File
@@ -667,7 +667,8 @@ def build_seg_map_loader(source: str, **kwargs):
if source == "gt":
if "contour_dir" not in kwargs:
raise ValueError("build_seg_map_loader source='gt' requires contour_dir")
return GTSegMapLoader(**kwargs)
gt_keys = {"contour_dir", "channels", "mask_size", "target_size", "crop_to_disc"}
return GTSegMapLoader(**{k: v for k, v in kwargs.items() if k in gt_keys})
if source == "unet":
if "weights_path" not in kwargs:
raise ValueError("build_seg_map_loader source='unet' requires weights_path")
+260 -7
View File
@@ -155,7 +155,12 @@ def _apply_iop_and_drop_md(
df["IOP_corr"] = [
_correct_iop(r, p) for r, p in zip(df["IOP_raw"].values, pachy.values)
]
drop = [c for c in ("Pneumatic", "Perkins", "VF_MD") if c in df.columns]
# Drop the raw IOP source columns (Pneumatic, Perkins) since their info
# is already absorbed into IOP_corr. VF_MD is intentionally NOT dropped
# here — it stays available as a target for auxiliary/regression tasks
# (PapilaBundle exposes it via .vf_md_target / .get_vf_md()). It must
# however be added to `exclude_cols` so it is never used as a feature.
drop = [c for c in ("Pneumatic", "Perkins") if c in df.columns]
if drop_raw:
drop.append("IOP_raw")
if drop:
@@ -248,6 +253,39 @@ class ClinicalDataView:
n_cat = sum(len(m) for m in self.cat_maps.values())
return n_scalar + n_cat + n_scalar # scalars + one-hots + missing flags
# ── Optional explainability hooks ────────────────────────────────────────
#
# feature_names + feature_groups describe how the encoded feature_dim
# vector maps back to the original column space. They're consumed by
# v4.classes.accessory.explainability.permutation_importance via the
# caller (F8); profiles without these methods skip per-column importance.
@cached_property
def feature_names(self) -> list[str]:
"""Human-readable name for each original column (used as group label)."""
return list(self.scalar_cols) + list(self.cat_cols)
@cached_property
def feature_groups(self) -> list[list[int]]:
"""Encoded-vector indices grouped by original column.
Each entry is the set of model-input dimensions that encode one
conceptual feature: a scalar groups its value + missing-flag dim,
a categorical groups all of its one-hot dims.
Order matches ``feature_names``.
"""
n_scalar = len(self.scalar_cols)
n_cat_total = sum(len(m) for m in self.cat_maps.values())
groups: list[list[int]] = []
for i in range(n_scalar):
groups.append([i, n_scalar + n_cat_total + i])
offset = n_scalar
for col in self.cat_cols:
k = len(self.cat_maps[col])
groups.append(list(range(offset, offset + k)))
offset += k
return groups
def vectorize_entity(self, *ids) -> np.ndarray:
"""Return the feature vector for an entity identified by positional ids.
@@ -442,6 +480,104 @@ class ImageDataView:
from v4.classes.profiles.fundus_images import build_seg_map_loader as _build
return _build(source, **self._resolve_paths(kwargs))
# ── Optional explainability hooks ────────────────────────────────────────
#
# These methods are consumed by v4.classes.accessory.explainability via
# getattr — they're optional on the data view, so a different profile
# without disc annotations can simply not define them and GradCAM/overlay
# will still work (region-of-interest analysis is skipped).
#
# Eval-crop convention: PAPILA training resizes the short side to 256
# then center-crops 224×224. The methods below mirror that so the disc
# mask and the display image stay aligned with what the image tower sees.
_EVAL_RESIZE = 256
_EVAL_CROP = 224
_DEFAULT_CONTOUR_DIR = "Papila/ExpertsSegmentations/Contours"
def eval_image_pil(self, *ids) -> Image.Image:
"""Load image and apply the 256-resize + 224-center-crop used at eval time."""
from torchvision.transforms import functional as TF
from torchvision.transforms import InterpolationMode
pil = self.load_image(*ids).convert("RGB")
pil = TF.resize(pil, self._EVAL_RESIZE, interpolation=InterpolationMode.BILINEAR)
return TF.center_crop(pil, [self._EVAL_CROP, self._EVAL_CROP])
def build_roi_mask(
self,
*ids,
target_h: int,
target_w: int,
expert: int = 1,
contour_dir: str | None = None,
) -> np.ndarray | None:
"""Rasterize the expert disc contour for (pid, eye), aligned to the eval crop.
Returns a binary ndarray of shape (target_h, target_w), or None if no
contour file exists for this eye / the file is malformed.
Alignment: the contour polygon is rasterized at the original image
resolution, then resized + center-cropped to match the same eval
transform the image tower uses, then resized to (target_h, target_w).
"""
from PIL import ImageDraw
from torchvision.transforms import functional as TF
from torchvision.transforms import InterpolationMode
if len(ids) < 2:
raise TypeError(
"build_roi_mask requires (patient_id, eye) — got %r" % (ids,)
)
pid, eye = int(ids[0]), str(ids[1])
repo_root = Path(__file__).resolve().parents[3]
dir_ = Path(contour_dir or self._DEFAULT_CONTOUR_DIR)
if not dir_.is_absolute():
dir_ = repo_root / dir_
contour_path = dir_ / f"RET{pid:03d}{eye}_disc_exp{expert}.txt"
if not contour_path.exists():
return None
try:
arr = np.loadtxt(str(contour_path), dtype=np.float32)
except Exception:
return None
if arr.ndim == 1:
arr = arr.reshape(-1, 2)
if arr.shape[0] < 3:
return None
# Original image size for polygon canvas
try:
with Image.open(self.get_image_path(*ids)) as im:
orig_w, orig_h = im.size
except Exception:
return None
canvas = Image.new("L", (orig_w, orig_h), 0)
ImageDraw.Draw(canvas).polygon([tuple(pt) for pt in arr[:, :2]], fill=1)
# Match the eval transform exactly (resize → center-crop)
canvas = TF.resize(canvas, self._EVAL_RESIZE, interpolation=InterpolationMode.NEAREST)
canvas = TF.center_crop(canvas, [self._EVAL_CROP, self._EVAL_CROP])
if (target_h, target_w) != (self._EVAL_CROP, self._EVAL_CROP):
canvas = canvas.resize((target_w, target_h), Image.NEAREST)
return np.array(canvas, dtype=bool)
def orient_for_display(self, image, side: str):
"""Mirror OS to align its nasaltemporal axis with the OD convention.
Accepts a PIL.Image or a numpy array. OD is passed through unchanged.
"""
if side != self.SIDE_B: # SIDE_A == OD, unchanged
return image
if isinstance(image, Image.Image):
from PIL import ImageOps
return ImageOps.mirror(image)
if isinstance(image, np.ndarray):
return np.ascontiguousarray(np.fliplr(image))
raise TypeError(f"orient_for_display: unsupported type {type(image).__name__}")
# ---------------------------------------------------------------------------
# PapilaBundle — the v4 DataBundle returned by build_data
@@ -457,10 +593,11 @@ class PapilaBundle:
def __init__(
self,
bundle: DataBundle,
image_dir: str,
preprocessor: Callable | None = None,
image_cache: CachedImageLoader | None = None,
bundle: DataBundle,
image_dir: str,
preprocessor: Callable | None = None,
image_cache: CachedImageLoader | None = None,
vf_md_targets: dict[tuple, float] | None = None,
):
self._bundle = bundle
self._image_dir = image_dir
@@ -485,6 +622,25 @@ class PapilaBundle:
image_cache=image_cache,
)
# ── VF_MD target table (for regression / auxiliary heads) ────────────
# Provided by build_data — captured from raw frames before the master
# df dropped VF_MD via exclude_cols. Keys are (pid, eyeID), values are
# raw floats (NaN where the measurement was missing).
self._vf_md_raw: dict[tuple, float] = dict(vf_md_targets or {})
# ── Imputation distribution + pre-sampled imputed table ──────────────
# Strategy: in PAPILA, the few healthy patients with measured MD are
# likely biased toward being slightly worse than the typical healthy
# eye (the test is usually administered when there's some suspicion).
# We correct for this by centering the imputation distribution on the
# TOP QUARTILE mean of the measured-healthy MD values (i.e. the
# healthiest of the measured healthies), while using the std of the
# full measured-healthy sample. Missing values are then drawn from
# N(top_q_mean, std²) once at bundle construction, with a fixed seed,
# so the same patient always gets the same imputed value.
self._impute_mean, self._impute_std = self._compute_impute_params()
self._vf_md_imputed: dict[tuple, float] = self._sample_imputations(seed=42)
# ── Entity-id metadata (for orchestrator logging) ────────────────────────
@property
@@ -526,6 +682,76 @@ class PapilaBundle:
out.append((pid, eye, self.image.get_image_path(pid, eye)))
return out
# ── VF_MD target accessor (for regression / auxiliary heads) ─────────────
def get_vf_md(self, pid: int, eye: str, *, impute: bool = True) -> float:
"""Return VF_MD for one eye.
impute=True → look up the precomputed imputed value (raw if measured,
distribution sample if missing). This is what training
should use — deterministic, fixed-seed, same value every
call across the run.
impute=False → return the raw value (NaN if unmeasured).
"""
key = (int(pid), str(eye))
if impute:
return self._vf_md_imputed.get(key, self._impute_mean)
return self._vf_md_raw.get(key, float("nan"))
@property
def has_vf_md(self) -> bool:
return bool(self._vf_md_raw)
@property
def impute_params(self) -> dict:
"""Inspection: which mean/std were used to draw imputations."""
return {"mean": self._impute_mean, "std": self._impute_std}
# ── Internal helpers for imputation -------------------------------------
def _compute_impute_params(self) -> tuple[float, float]:
"""Mean = top-quartile mean of measured healthy MDs; std = std of all.
Falls back to (0.0, 0.0) if there are too few measured-healthy samples
to fit a sensible distribution.
"""
# Build (pid, eye) → diagnosis from the underlying bundle df.
diag_lookup: dict[tuple, int] = {}
label_col = self._bundle.label_col
pc = self._bundle.patient_col
if label_col in self._bundle.df.columns:
for _, row in self._bundle.df.iterrows():
key = (int(row[pc]), str(row.get("eyeID", "OD")))
diag_lookup[key] = int(row[label_col])
measured_healthy = [
v for key, v in self._vf_md_raw.items()
if v == v and diag_lookup.get(key, -1) == 0 # not NaN and healthy
]
if len(measured_healthy) < 4:
return 0.0, 0.0
arr = np.asarray(measured_healthy, dtype=np.float64)
q3 = float(np.percentile(arr, 75))
top = arr[arr >= q3]
mean = float(top.mean()) if top.size else float(arr.mean())
std = float(arr.std(ddof=1)) if arr.size > 1 else 0.0
return mean, std
def _sample_imputations(self, *, seed: int) -> dict[tuple, float]:
"""One-shot pre-sample: every (pid, eye) gets a fixed value for the run."""
rng = np.random.default_rng(seed)
out: dict[tuple, float] = {}
for key in sorted(self._vf_md_raw.keys()):
v = self._vf_md_raw[key]
if v == v: # measured
out[key] = float(v)
elif self._impute_std > 0:
out[key] = float(rng.normal(self._impute_mean, self._impute_std))
else:
out[key] = self._impute_mean
return out
# ── Backward-compat delegates ────────────────────────────────────────────
@property
@@ -587,7 +813,8 @@ class PapilaBundle:
pid = int(row[pc])
label = int(row[lc])
side = str(row.get("eyeID", "OD"))
entries.append(ShellEntry(entity_id=(pid, side), label=label))
meta = {"vf_md": self.get_vf_md(pid, side)} if self.has_vf_md else {}
entries.append(ShellEntry(entity_id=(pid, side), label=label, meta=meta))
elif level == "patient":
for pid, grp in df.groupby(pc):
@@ -597,7 +824,13 @@ class PapilaBundle:
continue
label_mode = grp[lc].mode()
label = int(label_mode.iloc[0]) if not label_mode.empty else int(grp[lc].iloc[0])
entries.append(ShellEntry(entity_id=(int(pid),), label=label))
meta = {}
if self.has_vf_md:
# Patient-level target: mean of the two eyes' imputed MDs
meta["vf_md"] = 0.5 * (
self.get_vf_md(int(pid), "OD") + self.get_vf_md(int(pid), "OS")
)
entries.append(ShellEntry(entity_id=(int(pid),), label=label, meta=meta))
else:
raise ValueError(f"Unknown shell level: {level!r}. Choose 'eye' or 'patient'.")
@@ -659,6 +892,11 @@ def build_data(args: dict) -> PapilaBundle:
random_seed = int(args.get("random_seed", 42))
use_cache = bool(args.get("in_memory_cache", False))
# Always exclude VF_MD from feature vectorization — it's a target/label
# column (used by regression heads), never an input.
if "VF_MD" not in exclude_cols:
exclude_cols = exclude_cols + ["VF_MD"]
effective_cat = [c for c in cat_cols if c not in exclude_cols]
bundle = DataBundle(
@@ -694,6 +932,20 @@ def build_data(args: dict) -> PapilaBundle:
bundle.frames[i], converter=converter, drop_raw=iop_drop_raw
)
# Capture VF_MD per (pid, eyeID) from the raw frames BEFORE the master df
# is refreshed (which would drop VF_MD via exclude_cols).
vf_md_targets: dict[tuple, float] = {}
for frame in bundle.frames:
if "VF_MD" not in frame.columns:
continue
for _, row in frame.iterrows():
pid = int(row["Patient ID"])
eye = str(row.get("eyeID", "OD"))
v = row["VF_MD"]
vf_md_targets[(pid, eye)] = (
float(v) if not pd.isna(v) else float("nan")
)
bundle._refresh_master_df(exclude_cols=exclude_cols or None)
bundle._infer_or_validate_feature_types(exclude_cols=exclude_cols or None)
bundle._compute_numeric_stats()
@@ -706,4 +958,5 @@ def build_data(args: dict) -> PapilaBundle:
bundle=bundle,
image_dir=image_dir,
image_cache=image_cache,
vf_md_targets=vf_md_targets,
)
+89 -40
View File
@@ -12,7 +12,7 @@ from v4.classes.dataset import LoaderShell, to_label_tensor
from v4.classes.metrics import score_arrays, compute_extended_metrics, tune_binary_threshold
from v4.classes.stages.helpers import (
class_weights_from_shell, encode_embedding, get_out_dim, resolve_input_dims,
phase_for_epoch,
phase_for_epoch, head_compute_loss, head_to_probs, head_score, head_target_key,
)
@@ -27,18 +27,23 @@ def collect_probs(
device,
num_classes: int,
) -> tuple[np.ndarray, np.ndarray, list, np.ndarray]:
"""Eval pass for one fusion stage; returns (y_true, softmax_probs, entity_ids, embeddings)."""
from v4.classes.dataset import to_label_tensor
"""Eval pass for one fusion stage; returns (y_true, predictions, entity_ids, embeddings).
For classification heads, `predictions` is the softmax over classes (B, C).
For regression heads (or any head with a `to_probs` method), it's whatever
that method returns — typically per-sample scalar predictions.
"""
bridge.eval(); primary_head.eval()
for t in towers.values():
t.eval()
inputs = stage_cfg["inputs"]
is_bilateral = isinstance(inputs, dict)
target_key = head_target_key(primary_head)
y_all, p_all, ids_all, z_all = [], [], [], []
with torch.no_grad():
for batch in loader:
y = batch.get("label")
y = batch.get(target_key, batch.get("label"))
if not torch.is_tensor(y):
continue
@@ -56,13 +61,13 @@ def collect_probs(
z = bridge(embs)
logits = primary_head(z)
y_all.append(to_label_tensor(y, device).cpu().numpy())
p_all.append(F.softmax(logits, dim=1).cpu().numpy())
y_all.append(y.detach().cpu().numpy())
p_all.append(head_to_probs(primary_head, logits))
z_all.append(z.cpu().numpy())
ids_all.extend(batch.get("entity_id", []))
if not y_all:
return (np.zeros(0, dtype=np.int64), np.zeros((0, num_classes), dtype=np.float32),
return (np.zeros(0, dtype=np.float32), np.zeros((0,), dtype=np.float32),
[], np.zeros((0, 0), dtype=np.float32))
return (np.concatenate(y_all), np.concatenate(p_all, axis=0),
ids_all, np.concatenate(z_all, axis=0))
@@ -170,7 +175,10 @@ def run(
warmup_cfg = stage_cfg.get("warmup", {})
wt = 0 if is_bilateral else warmup_cfg.get("tower_epochs", 0)
wf = 0 if is_bilateral else warmup_cfg.get("fused_epochs", 0)
bcd_prob = cfg["training"].get("bcd_prob", 0.5)
bcd_prob = cfg["training"].get("bcd_prob", 0.5)
tower_loss_mode = cfg["training"].get("tower_loss_mode", "bcd")
if tower_loss_mode not in ("bcd", "all_losses"):
raise ValueError(f"tower_loss_mode must be 'bcd' or 'all_losses'; got {tower_loss_mode!r}")
cw = class_weights_from_shell(
s_train, num_classes, device,
@@ -205,6 +213,7 @@ def run(
phase = "fusion"
total_loss = total_correct = total_n = 0
is_class = not hasattr(primary_head, "target_key")
for batch in train_loader:
y = batch.get("label")
@@ -234,10 +243,15 @@ def run(
}
if is_bilateral or phase == "fused_warmup":
logits = head_logits.get(primary_hs_cfg["name"])
logits = head_logits.get(primary_hs_cfg["name"])
chosen_head = head_models.get(primary_hs_cfg["name"])
elif phase == "tower_warmup" and bcd_head_cfgs:
losses = [F.cross_entropy(head_logits[hs["name"]], y_t, weight=cw)
for hs in bcd_head_cfgs if hs["name"] in head_logits]
losses = [
head_compute_loss(head_models[hs["name"]],
head_logits[hs["name"]], batch, y_t,
class_weights=cw)
for hs in bcd_head_cfgs if hs["name"] in head_logits
]
if not losses:
continue
loss = sum(losses) / len(losses)
@@ -247,19 +261,42 @@ def run(
total_loss += loss.item() * len(y_t)
total_n += len(y_t)
continue
elif tower_loss_mode == "all_losses" and bcd_head_cfgs:
# All-losses (v3 phase 3 control): sum primary + every aux head
# loss every step. Effective LR is implicitly N× single-head BCD
# — matches v3 semantics so the comparison is apples-to-apples.
all_head_names = ([primary_hs_cfg["name"]]
+ [hs["name"] for hs in bcd_head_cfgs])
losses = [
head_compute_loss(head_models[n], head_logits[n], batch, y_t,
class_weights=cw)
for n in all_head_names if n in head_logits
]
if not losses:
continue
loss = sum(losses)
if hasattr(bridge, "modify_loss"):
loss = bridge.modify_loss(loss)
opt.zero_grad(); loss.backward(); opt.step()
total_loss += loss.item() * len(y_t)
total_n += len(y_t)
continue
else:
if bcd_head_cfgs and _random() < bcd_prob:
logits = head_logits.get(choice(bcd_head_cfgs)["name"])
chosen_hs = choice(bcd_head_cfgs)
else:
logits = head_logits.get(primary_hs_cfg["name"])
chosen_hs = primary_hs_cfg
logits = head_logits.get(chosen_hs["name"])
chosen_head = head_models.get(chosen_hs["name"])
if logits is None:
continue
loss = F.cross_entropy(logits, y_t, weight=cw)
loss = head_compute_loss(chosen_head, logits, batch, y_t, class_weights=cw)
if hasattr(bridge, "modify_loss"):
loss = bridge.modify_loss(loss)
opt.zero_grad(); loss.backward(); opt.step()
total_correct += int((logits.argmax(1) == y_t).sum())
if logits.dim() >= 2:
total_correct += int((logits.argmax(1) == y_t).sum())
total_loss += loss.item() * len(y_t)
total_n += len(y_t)
@@ -268,49 +305,61 @@ def run(
y_v, p_v, _, _ = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, val_loader, device, num_classes)
_, val_auc, _ = score_arrays(y_v, p_v, num_classes) if y_v.size else (nan, nan, nan)
print(
f" fold{fold+1} [{name}] ep{epoch+1:03d}/{epochs} [{phase:14s}]"
f" loss={tr_loss:.4f} acc={tr_acc:.3f} val_auc={val_auc:.4f}",
flush=True,
)
epoch_scores = head_score(primary_head, y_v, p_v, num_classes)
val_metric = epoch_scores.get("primary", nan)
metric_name = epoch_scores.get("primary_name", "auc")
if is_class:
print(
f" fold{fold+1} [{name}] ep{epoch+1:03d}/{epochs} [{phase:14s}]"
f" loss={tr_loss:.4f} acc={tr_acc:.3f} val_{metric_name}={val_metric:.4f}",
flush=True,
)
else:
print(
f" fold{fold+1} [{name}] ep{epoch+1:03d}/{epochs} [{phase:14s}]"
f" loss={tr_loss:.4f} val_{metric_name}={val_metric:.4f}",
flush=True,
)
# ── Final eval ────────────────────────────────────────────────────────────
y_val, p_val, ids_val, z_val = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, val_loader, device, num_classes)
val_acc, val_auc, val_n = (score_arrays(y_val, p_val, num_classes)
if y_val.size else (nan, nan, nan))
ext = compute_extended_metrics(y_val, p_val, num_classes) if y_val.size else {}
val_scores = head_score(primary_head, y_val, p_val, num_classes)
val_threshold = 0.5
if (cfg["training"].get("tune_binary_threshold")
and num_classes == 2 and y_val.size >= 2):
and num_classes == 2 and y_val.size >= 2
and p_val.ndim == 2 and p_val.shape[1] == 2):
val_threshold = tune_binary_threshold(y_val, p_val[:, 1])
y_te = p_te = ids_te = z_te = None
test_auc = test_acc = test_n = nan
test_scores: dict = {}
if test_loader is not None:
y_te, p_te, ids_te, z_te = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, test_loader, device, num_classes)
test_acc, test_auc, test_n = (score_arrays(y_te, p_te, num_classes)
if y_te.size else (nan, nan, nan))
test_scores = head_score(primary_head, y_te, p_te, num_classes)
updated = dict(stage_models)
updated[name] = bridge
updated.update(head_models)
metrics = {
f"{name}_val_auc": val_auc,
f"{name}_val_acc": val_acc,
f"{name}_val_n": val_n,
f"{name}_val_kappa": ext.get("kappa", nan),
f"{name}_val_mcc": ext.get("mcc", nan),
f"{name}_val_f1": ext.get("macro_f1", nan),
f"{name}_val_threshold": val_threshold,
f"{name}_test_auc": test_auc,
f"{name}_test_acc": test_acc,
f"{name}_test_n": test_n,
}
# Metrics: prefix every score-dict key with stage name + split, plus a fixed
# `_val_primary` / `_test_primary` slot that the summary writer can read
# without knowing the metric's name.
metrics: dict = {f"{name}_val_threshold": val_threshold}
for k, v in val_scores.items():
if k == "primary_name":
continue
if isinstance(v, (int, float)):
metrics[f"{name}_val_{k}"] = float(v)
metrics[f"{name}_val_primary_name"] = val_scores.get("primary_name", "auc")
for k, v in test_scores.items():
if k == "primary_name":
continue
if isinstance(v, (int, float)):
metrics[f"{name}_test_{k}"] = float(v)
metrics[f"{name}_test_primary_name"] = test_scores.get("primary_name",
metrics[f"{name}_val_primary_name"])
pred_data = {
name: {
"val_y": y_val, "val_p": p_val, "val_ids": ids_val, "val_z": z_val,
+74
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
from collections import Counter
import numpy as np
import torch
import torch.nn.functional as F
def class_weights_from_shell(
@@ -87,3 +89,75 @@ def phase_for_epoch(epoch: int, warmup_tower: int, warmup_fused: int) -> str:
if epoch < warmup_tower + warmup_fused:
return "fused_warmup"
return "main"
# ─────────────────────────────────────────────────────────────────────────────
# Head dispatch helpers — opt-in hooks that let new head types (regression,
# ordinal, etc.) plug in without touching the runners. Heads that don't
# implement these methods fall back to the classification defaults.
# ─────────────────────────────────────────────────────────────────────────────
def head_target_key(head) -> str:
"""Which batch field this head consumes as ground truth (default: 'label')."""
return getattr(head, "target_key", "label")
def head_compute_loss(
head, logits: torch.Tensor, batch: dict, y_t: torch.Tensor,
*, class_weights: torch.Tensor | None = None,
) -> torch.Tensor:
"""Compute one head's training loss.
If the head provides `compute_loss(logits, batch)`, that wins — the head
is responsible for reading its own target from batch and applying whatever
loss function it wants. Otherwise we fall back to weighted CE against y_t
(the standard classification path).
"""
if hasattr(head, "compute_loss"):
return head.compute_loss(logits, batch)
return F.cross_entropy(logits, y_t, weight=class_weights)
def head_to_probs(head, logits: torch.Tensor) -> np.ndarray:
"""Convert raw head outputs to a per-sample numpy array.
For classification heads, this is the softmax over class logits.
For regression heads, this is just the raw predicted scalar(s).
Heads override `to_probs(logits)` to define their own conversion.
"""
if hasattr(head, "to_probs"):
return head.to_probs(logits)
return F.softmax(logits, dim=1).cpu().numpy()
def head_score(head, y_true: np.ndarray, predictions: np.ndarray,
num_classes: int) -> dict:
"""Compute evaluation metrics for one head.
Returns a dict with at minimum:
- 'primary' : the headline metric value (float)
- 'primary_name' : how to label it (e.g. 'auc', 'mse')
- 'n' : sample count
Classification heads fall through to the existing score_arrays-based
metric set. Regression heads override `score(y_true, predictions)` and
return their own dict (which may include the standard fields plus extras
like 'mae', 'r2', 'spearman').
"""
if hasattr(head, "score"):
return head.score(y_true, predictions)
# Default: classification scoring
from v4.classes.metrics import score_arrays, compute_extended_metrics
if not y_true.size:
return {"primary": float("nan"), "primary_name": "auc",
"auc": float("nan"), "acc": float("nan"), "n": 0}
acc, auc, n = score_arrays(y_true, predictions, num_classes)
ext = compute_extended_metrics(y_true, predictions, num_classes)
return {
"primary": float(auc),
"primary_name": "auc",
"auc": float(auc),
"acc": float(acc),
"n": int(n),
**{k: float(v) for k, v in ext.items() if isinstance(v, (int, float))},
}
+94 -41
View File
@@ -16,6 +16,7 @@ from v4.classes.dataset import LoaderShell, to_label_tensor
from v4.classes.metrics import score_arrays, compute_extended_metrics, tune_binary_threshold
from v4.classes.stages.helpers import (
class_weights_from_shell, get_out_dim, resolve_input_dims, phase_for_epoch,
head_compute_loss, head_to_probs, head_score, head_target_key,
)
from v4.classes.stages.fusion import collect_probs
@@ -120,18 +121,31 @@ def _parallel_warm(
continue
y_t = to_label_tensor(y, device)
logits = ctx["probe"](towers[tower_name](x.to(device)))
loss = F.cross_entropy(logits, y_t, weight=ctx["class_weights"])
loss = head_compute_loss(ctx["probe"], logits, batch, y_t,
class_weights=ctx["class_weights"])
ctx["opt"].zero_grad(); loss.backward(); ctx["opt"].step()
total_loss += loss.item() * len(y_t)
total_correct += int((logits.argmax(1) == y_t).sum())
if logits.dim() >= 2:
total_correct += int((logits.argmax(1) == y_t).sum())
is_class = True
else:
is_class = False
total_n += len(y_t)
if total_n:
print(
f" fold{fold+1} [warm/{tower_name}]"
f" ep{epoch+1:03d}/{ctx['n_epochs']}"
f" loss={total_loss/total_n:.4f} acc={total_correct/total_n:.3f}",
flush=True,
)
if is_class:
print(
f" fold{fold+1} [warm/{tower_name}]"
f" ep{epoch+1:03d}/{ctx['n_epochs']}"
f" loss={total_loss/total_n:.4f} acc={total_correct/total_n:.3f}",
flush=True,
)
else:
print(
f" fold{fold+1} [warm/{tower_name}]"
f" ep{epoch+1:03d}/{ctx['n_epochs']}"
f" loss={total_loss/total_n:.4f}",
flush=True,
)
for t in towers.values():
for p in t.parameters():
@@ -153,7 +167,10 @@ def _parallel_fusion(
):
nan = float("nan")
bs = cfg["training"]["batch_size"]
bcd_prob = cfg["training"].get("bcd_prob", 0.5)
bcd_prob = cfg["training"].get("bcd_prob", 0.5)
tower_loss_mode = cfg["training"].get("tower_loss_mode", "bcd")
if tower_loss_mode not in ("bcd", "all_losses"):
raise ValueError(f"tower_loss_mode must be 'bcd' or 'all_losses'; got {tower_loss_mode!r}")
# Freeze all prior stage models once, before building any bridges.
for m in stage_models.values():
@@ -302,10 +319,14 @@ def _parallel_fusion(
if phase == "fused_warmup":
logits = head_logits.get(ctx["primary_hs_cfg"]["name"])
chosen_head = ctx["head_models"].get(ctx["primary_hs_cfg"]["name"])
elif phase == "tower_warmup" and ctx["bcd_head_cfgs"]:
losses = [F.cross_entropy(head_logits[hs["name"]], y_t,
weight=ctx["class_weights"])
for hs in ctx["bcd_head_cfgs"] if hs["name"] in head_logits]
losses = [
head_compute_loss(ctx["head_models"][hs["name"]],
head_logits[hs["name"]], batch, y_t,
class_weights=ctx["class_weights"])
for hs in ctx["bcd_head_cfgs"] if hs["name"] in head_logits
]
if not losses:
continue
loss = sum(losses) / len(losses)
@@ -315,19 +336,40 @@ def _parallel_fusion(
total_loss += loss.item() * len(y_t)
total_n += len(y_t)
continue
elif tower_loss_mode == "all_losses" and ctx["bcd_head_cfgs"]:
all_head_names = ([ctx["primary_hs_cfg"]["name"]]
+ [hs["name"] for hs in ctx["bcd_head_cfgs"]])
losses = [
head_compute_loss(ctx["head_models"][n], head_logits[n],
batch, y_t, class_weights=ctx["class_weights"])
for n in all_head_names if n in head_logits
]
if not losses:
continue
loss = sum(losses)
if hasattr(bridge, "modify_loss"):
loss = bridge.modify_loss(loss)
ctx["opt"].zero_grad(); loss.backward(); ctx["opt"].step()
total_loss += loss.item() * len(y_t)
total_n += len(y_t)
continue
else:
if ctx["bcd_head_cfgs"] and _random() < bcd_prob:
logits = head_logits.get(choice(ctx["bcd_head_cfgs"])["name"])
chosen_hs = choice(ctx["bcd_head_cfgs"])
else:
logits = head_logits.get(ctx["primary_hs_cfg"]["name"])
chosen_hs = ctx["primary_hs_cfg"]
logits = head_logits.get(chosen_hs["name"])
chosen_head = ctx["head_models"].get(chosen_hs["name"])
if logits is None:
continue
loss = F.cross_entropy(logits, y_t, weight=ctx["class_weights"])
loss = head_compute_loss(chosen_head, logits, batch, y_t,
class_weights=ctx["class_weights"])
if hasattr(bridge, "modify_loss"):
loss = bridge.modify_loss(loss)
ctx["opt"].zero_grad(); loss.backward(); ctx["opt"].step()
total_correct += int((logits.argmax(1) == y_t).sum())
if logits.dim() >= 2:
total_correct += int((logits.argmax(1) == y_t).sum())
total_loss += loss.item() * len(y_t)
total_n += len(y_t)
@@ -337,12 +379,22 @@ def _parallel_fusion(
y_v, p_v, _, _ = collect_probs(bridge, ctx["primary_head"], ctx["sc"], towers,
stage_models, cfg_stages, ctx["val_loader"],
device, num_classes)
_, val_auc, _ = score_arrays(y_v, p_v, num_classes) if y_v.size else (nan, nan, nan)
print(
f" fold{fold+1} [{name}] ep{epoch+1:03d}/{ctx['epochs']} [{phase:14s}]"
f" loss={tr_loss:.4f} acc={tr_acc:.3f} val_auc={val_auc:.4f}",
flush=True,
)
epoch_scores = head_score(ctx["primary_head"], y_v, p_v, num_classes)
val_metric = epoch_scores.get("primary", nan)
metric_name = epoch_scores.get("primary_name", "auc")
is_class = not hasattr(ctx["primary_head"], "target_key")
if is_class:
print(
f" fold{fold+1} [{name}] ep{epoch+1:03d}/{ctx['epochs']} [{phase:14s}]"
f" loss={tr_loss:.4f} acc={tr_acc:.3f} val_{metric_name}={val_metric:.4f}",
flush=True,
)
else:
print(
f" fold{fold+1} [{name}] ep{epoch+1:03d}/{ctx['epochs']} [{phase:14s}]"
f" loss={tr_loss:.4f} val_{metric_name}={val_metric:.4f}",
flush=True,
)
# ── Final eval + collect results ──────────────────────────────────────────
updated = dict(stage_models)
@@ -360,36 +412,37 @@ def _parallel_fusion(
y_val, p_val, ids_val, z_val = collect_probs(bridge, primary_head, ctx["sc"], towers,
stage_models, cfg_stages, ctx["val_loader"],
device, num_classes)
val_acc, val_auc, val_n = (score_arrays(y_val, p_val, num_classes)
if y_val.size else (nan, nan, nan))
ext = compute_extended_metrics(y_val, p_val, num_classes) if y_val.size else {}
val_scores = head_score(primary_head, y_val, p_val, num_classes)
val_threshold = 0.5
if (cfg["training"].get("tune_binary_threshold")
and num_classes == 2 and y_val.size >= 2):
and num_classes == 2 and y_val.size >= 2
and p_val.ndim == 2 and p_val.shape[1] == 2):
val_threshold = tune_binary_threshold(y_val, p_val[:, 1])
y_te = p_te = ids_te = z_te = None
test_auc = test_acc = test_n = nan
test_scores: dict = {}
if ctx["test_loader"] is not None:
y_te, p_te, ids_te, z_te = collect_probs(bridge, primary_head, ctx["sc"], towers,
stage_models, cfg_stages, ctx["test_loader"],
device, num_classes)
test_acc, test_auc, test_n = (score_arrays(y_te, p_te, num_classes)
if y_te.size else (nan, nan, nan))
test_scores = head_score(primary_head, y_te, p_te, num_classes)
all_metrics.update({
f"{name}_val_auc": val_auc,
f"{name}_val_acc": val_acc,
f"{name}_val_n": val_n,
f"{name}_val_kappa": ext.get("kappa", nan),
f"{name}_val_mcc": ext.get("mcc", nan),
f"{name}_val_f1": ext.get("macro_f1", nan),
f"{name}_val_threshold": val_threshold,
f"{name}_test_auc": test_auc,
f"{name}_test_acc": test_acc,
f"{name}_test_n": test_n,
})
per_stage_metrics: dict = {f"{name}_val_threshold": val_threshold}
for k, v in val_scores.items():
if k == "primary_name":
continue
if isinstance(v, (int, float)):
per_stage_metrics[f"{name}_val_{k}"] = float(v)
per_stage_metrics[f"{name}_val_primary_name"] = val_scores.get("primary_name", "auc")
for k, v in test_scores.items():
if k == "primary_name":
continue
if isinstance(v, (int, float)):
per_stage_metrics[f"{name}_test_{k}"] = float(v)
per_stage_metrics[f"{name}_test_primary_name"] = test_scores.get(
"primary_name", per_stage_metrics[f"{name}_val_primary_name"])
all_metrics.update(per_stage_metrics)
all_preds[name] = {
"val_y": y_val, "val_p": p_val, "val_ids": ids_val, "val_z": z_val,
"test_y": y_te, "test_p": p_te, "test_ids": ids_te, "test_z": z_te,
+19 -8
View File
@@ -7,7 +7,7 @@ import torch
import torch.nn.functional as F
from v4.classes.dataset import to_label_tensor
from v4.classes.stages.helpers import class_weights_from_shell
from v4.classes.stages.helpers import class_weights_from_shell, head_compute_loss
def run(
@@ -80,6 +80,7 @@ def run(
towers[tower_name].train()
probe.train()
is_classification = True
for epoch in range(n_epochs):
total_loss = total_correct = total_n = 0
for batch in loader:
@@ -89,16 +90,26 @@ def run(
continue
y_t = to_label_tensor(y, device)
logits = probe(towers[tower_name](x.to(device)))
loss = F.cross_entropy(logits, y_t, weight=cw)
loss = head_compute_loss(probe, logits, batch, y_t, class_weights=cw)
opt.zero_grad(); loss.backward(); opt.step()
total_loss += loss.item() * len(y_t)
total_correct += int((logits.argmax(1) == y_t).sum())
if logits.dim() >= 2:
total_correct += int((logits.argmax(1) == y_t).sum())
else:
is_classification = False
total_n += len(y_t)
print(
f" fold{fold+1} [warm/{tower_name}] ep{epoch+1:03d}/{n_epochs}"
f" loss={total_loss/total_n:.4f} acc={total_correct/total_n:.3f}",
flush=True,
)
if is_classification:
print(
f" fold{fold+1} [warm/{tower_name}] ep{epoch+1:03d}/{n_epochs}"
f" loss={total_loss/total_n:.4f} acc={total_correct/total_n:.3f}",
flush=True,
)
else:
print(
f" fold{fold+1} [warm/{tower_name}] ep{epoch+1:03d}/{n_epochs}"
f" loss={total_loss/total_n:.4f}",
flush=True,
)
for t in towers.values():
for p in t.parameters():
+61 -18
View File
@@ -21,6 +21,7 @@ from pathlib import Path
from typing import Any
import numpy as np
import torch
from torch.utils.data import DataLoader
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -148,7 +149,13 @@ def _balanced_sampler(shell: LoaderShell):
# Fold runner
# ---------------------------------------------------------------------------
def run_fold(fold: int, splits, cfg: dict, data, num_classes: int, device) -> dict:
def run_fold(fold: int, splits, cfg: dict, data, num_classes: int, device):
"""Train + evaluate one fold.
Returns (fold_result, fold_preds, towers, stage_models). The latter two are
handy for opt-in artefact saving (e.g. checkpoint dumps for explainability
runs) without forcing the orchestrator to know about every saved tensor.
"""
seed_everything(cfg["seed"] + fold * 100)
split = splits[fold]
label_filter = cfg.get("label_filter", None)
@@ -210,7 +217,7 @@ def run_fold(fold: int, splits, cfg: dict, data, num_classes: int, device) -> di
# head stages are handled inside fusion.run
return fold_result, fold_preds
return fold_result, fold_preds, towers, stage_models
# ---------------------------------------------------------------------------
@@ -264,6 +271,7 @@ def main():
eval_stage = cfg.get("eval_stage", "hb")
save_predictions = cfg.get("save_predictions", False)
save_features = cfg.get("save_features", False)
save_checkpoints = cfg.get("save_checkpoints", False)
fold_results = []
eval_stage_preds = [] # list[dict] — one per fold, only for eval_stage
all_phase_preds: dict[str, list[dict]] = {} # phase → list[dict] across folds
@@ -274,8 +282,21 @@ def main():
n_train = split.train[group_col].nunique() if group_col else len(split.train)
print(f"\n── fold {fold+1}/{cfg.get('folds', 5)} train_groups={n_train} ──",
flush=True)
result, fold_preds = run_fold(fold, splits, cfg, data, num_classes, device)
result, fold_preds, towers, stage_models = run_fold(
fold, splits, cfg, data, num_classes, device,
)
fold_results.append(result)
if save_checkpoints:
ckpt_dir = out_dir / "checkpoints" / f"fold{fold}"
ckpt_dir.mkdir(parents=True, exist_ok=True)
for name, mod in towers.items():
torch.save(mod.state_dict(), ckpt_dir / f"tower_{name}.pt")
for name, mod in stage_models.items():
# Skip non-Module entries (defensive); only nn.Modules have state_dict
if hasattr(mod, "state_dict"):
torch.save(mod.state_dict(), ckpt_dir / f"stage_{name}.pt")
print(f" Checkpoints saved: {ckpt_dir}", flush=True)
if save_predictions and eval_stage in fold_preds:
eval_stage_preds.append(fold_preds[eval_stage])
if save_features:
@@ -283,26 +304,46 @@ def main():
if pdata.get("val_z") is None:
continue
all_phase_preds.setdefault(ph, []).append(pdata)
# Look up the eval stage's primary metric name (set by the stage runner).
primary_name = result.get(f"{eval_stage}_val_primary_name", "auc")
val_primary = result.get(f"{eval_stage}_val_{primary_name}", float("nan"))
test_primary = result.get(f"{eval_stage}_test_{primary_name}", float("nan"))
print(
f" fold{fold+1} DONE"
f" val_auc={result.get(f'{eval_stage}_val_auc', float('nan')):.4f}"
f" test_auc={result.get(f'{eval_stage}_test_auc', float('nan')):.4f}",
f" val_{primary_name}={val_primary:.4f}"
f" test_{primary_name}={test_primary:.4f}",
flush=True,
)
if fold_results:
val_aucs = [r.get(f"{eval_stage}_val_auc", float("nan")) for r in fold_results]
test_aucs = [r.get(f"{eval_stage}_test_auc", float("nan")) for r in fold_results]
val_aucs = [v for v in val_aucs if not np.isnan(v)]
test_aucs = [v for v in test_aucs if not np.isnan(v)]
# Resolve primary metric name from first valid fold result.
primary_name = next(
(r.get(f"{eval_stage}_val_primary_name", "auc") for r in fold_results
if r.get(f"{eval_stage}_val_primary_name") is not None),
"auc",
)
val_primaries = [r.get(f"{eval_stage}_val_{primary_name}", float("nan"))
for r in fold_results]
test_primaries = [r.get(f"{eval_stage}_test_{primary_name}", float("nan"))
for r in fold_results]
val_primaries = [v for v in val_primaries if not np.isnan(v)]
test_primaries = [v for v in test_primaries if not np.isnan(v)]
summary = {
"run_name": cfg["run_name"],
"eval_stage": eval_stage,
"config": cfg,
"mean_val_auc": float(np.mean(val_aucs)) if val_aucs else float("nan"),
"std_val_auc": float(np.std(val_aucs)) if val_aucs else float("nan"),
"mean_test_auc": float(np.mean(test_aucs)) if test_aucs else float("nan"),
"std_test_auc": float(np.std(test_aucs)) if test_aucs else float("nan"),
"run_name": cfg["run_name"],
"eval_stage": eval_stage,
"primary_metric": primary_name,
"config": cfg,
# Canonical primary-metric stats
f"mean_val_{primary_name}": float(np.mean(val_primaries)) if val_primaries else float("nan"),
f"std_val_{primary_name}": float(np.std(val_primaries)) if val_primaries else float("nan"),
f"mean_test_{primary_name}": float(np.mean(test_primaries)) if test_primaries else float("nan"),
f"std_test_{primary_name}": float(np.std(test_primaries)) if test_primaries else float("nan"),
# Backward-compat aliases so existing analysis tooling (summarize_run.py,
# compare_grid.py) still reads correctly for classification runs.
"mean_val_auc": float(np.mean(val_primaries)) if val_primaries else float("nan"),
"std_val_auc": float(np.std(val_primaries)) if val_primaries else float("nan"),
"mean_test_auc": float(np.mean(test_primaries)) if test_primaries else float("nan"),
"std_test_auc": float(np.std(test_primaries)) if test_primaries else float("nan"),
"elapsed_s": round(time.time() - t0, 1),
"fold_results": fold_results,
}
@@ -310,8 +351,10 @@ def main():
summary_path = out_dir / "summary.json"
summary_path.write_text(json.dumps(summary, indent=2))
print(f"\n{'='*60}", flush=True)
print(f"Val AUC: {summary['mean_val_auc']:.4f} ± {summary['std_val_auc']:.4f}", flush=True)
print(f"Test AUC: {summary['mean_test_auc']:.4f} ± {summary['std_test_auc']:.4f}", flush=True)
print(f"Val {primary_name}: {summary[f'mean_val_{primary_name}']:.4f} ± "
f"{summary[f'std_val_{primary_name}']:.4f}", flush=True)
print(f"Test {primary_name}: {summary[f'mean_test_{primary_name}']:.4f} ± "
f"{summary[f'std_test_{primary_name}']:.4f}", flush=True)
print(f"Saved: {summary_path}", flush=True)
if save_predictions and eval_stage_preds:
-40
View File
@@ -1,40 +0,0 @@
"""htbase — HTBase: abstract base for all v4 vehicle classes."""
from __future__ import annotations
from abc import ABC, abstractmethod
import torch
import torch.nn as nn
class HTBase(nn.Module, ABC):
"""Shared interface for all HyperTower vehicles.
Subclasses must implement ``encode`` and ``forward``.
``transform`` walks ``self.towers`` (if present) and returns the transform
from the first tower that exposes one used by data loaders.
``forward`` contract: returns ``(logits, aux_dict)`` where
``aux_dict`` maps a name or index to per-component logits.
HTMono returns an empty dict to keep the signature uniform.
"""
@abstractmethod
def encode(self, inputs) -> torch.Tensor:
"""Return the pre-classifier embedding."""
@abstractmethod
def forward(self, inputs) -> tuple[torch.Tensor, dict]:
"""Return (logits, aux_dict)."""
@property
def transform(self):
towers = getattr(self, "towers", None) or {}
for t in (towers.values() if hasattr(towers, "values") else []):
if hasattr(t, "transform"):
return t.transform
encoder = getattr(self, "encoder", None)
if encoder is not None:
return getattr(encoder, "transform", None)
return None
@@ -1,61 +0,0 @@
"""htfusion — HTFusion: N named towers fused through a FusionBridge."""
from __future__ import annotations
import torch
import torch.nn as nn
from v4.classes.bridges.fusion_bridge import FusionBridge
from v4.classes.vehicles.htbase import HTBase
class HTFusion(HTBase):
"""General N-tower fusion vehicle.
Each named encoder is registered as a submodule; the FusionBridge
projects and Hadamard-fuses their embeddings.
Parameters
----------
towers : ordered dict ``{name: encoder}``. Each encoder must
expose ``.out_dim``.
num_classes : output classes
fusion_dim : bridge projection dimensionality
dropout : bridge dropout
use_se : SE gate on the fused vector
Forward contract
----------------
``forward(embeddings)`` takes a ``dict[str, Tensor]`` of pre-computed
per-tower embeddings and returns ``(logits_fused, aux_dict)`` where
``aux_dict`` maps each tower name to its auxiliary head logits.
"""
def __init__(
self,
towers: dict[str, nn.Module],
num_classes: int,
fusion_dim: int = 256,
dropout: float = 0.5,
use_se: bool = False,
):
super().__init__()
self.towers = nn.ModuleDict(towers)
self.bridge = FusionBridge(
tower_dims=[t.out_dim for t in self.towers.values()],
num_classes=num_classes,
fusion_dim=fusion_dim,
dropout=dropout,
use_se=use_se,
)
def encode(self, embeddings: dict[str, torch.Tensor]) -> torch.Tensor:
"""Return z_fused (pre-classifier) from a dict of per-tower embeddings."""
return self.bridge.encode([embeddings[name] for name in self.towers])
def forward(
self,
embeddings: dict[str, torch.Tensor],
) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
ordered = [embeddings[name] for name in self.towers]
logits, aux = self.bridge.fuse(ordered)
return logits, {name: aux[i] for i, name in enumerate(self.towers)}
@@ -1,67 +0,0 @@
"""htlateral — HTLateral: shared encoder over N same-type inputs."""
from __future__ import annotations
import torch
import torch.nn as nn
from v4.classes.vehicles.htbase import HTBase
class HTLateral(HTBase):
"""N same-type inputs through a shared encoder, jointly compressed, then classified.
All inputs share the same encoder weights (one forward pass per input).
The joint MLP compresses the concatenated embeddings before classification.
Aux heads provide per-input logits before the joint MLP useful for
BCD-style training.
Parameters
----------
encoder : shared encoder module with ``.out_dim``
input_names : ordered slot names (e.g. ``["od", "os"]``)
num_classes : output classes
fusion_dim : joint MLP hidden dim
dropout : dropout in MLP and classifier
"""
def __init__(
self,
encoder: nn.Module,
input_names: list[str],
num_classes: int,
fusion_dim: int = 256,
dropout: float = 0.5,
):
super().__init__()
self.encoder = encoder
self.input_names = list(input_names)
n = len(input_names)
in_dim: int = encoder.out_dim # type: ignore[assignment]
self.joint = nn.Sequential(
nn.Linear(n * in_dim, fusion_dim), nn.LayerNorm(fusion_dim),
nn.ReLU(), nn.Dropout(dropout), nn.Linear(fusion_dim, in_dim),
)
self.aux_heads = nn.ModuleList([
nn.Linear(in_dim, num_classes) for _ in range(n)
])
self.head = nn.Sequential(
nn.ReLU(), nn.Dropout(dropout), nn.Linear(in_dim, num_classes),
)
def encode(self, inputs: dict[str, torch.Tensor]) -> torch.Tensor:
"""Return joint embedding (post-MLP, pre-classifier)."""
zs = [self.encoder(inputs[name]) for name in self.input_names]
return self.joint(torch.cat(zs, dim=1))
def forward(
self,
inputs: dict[str, torch.Tensor],
) -> tuple[torch.Tensor, dict[str, torch.Tensor]]:
zs = [self.encoder(inputs[name]) for name in self.input_names]
z_joint = self.joint(torch.cat(zs, dim=1))
logits = self.head(z_joint)
aux = {name: head(z)
for name, head, z in zip(self.input_names, self.aux_heads, zs)}
return logits, aux
-44
View File
@@ -1,44 +0,0 @@
"""htmono — HTMono: single tower + ClassificationHead, no bridge."""
from __future__ import annotations
import torch
import torch.nn as nn
from v4.classes.heads.classifier import ClassificationHead
from v4.classes.vehicles.htbase import HTBase
class HTMono(HTBase):
"""Single-tower vehicle: tower embedding fed directly into a ClassificationHead.
No bridge or projection the tower's output goes straight to
ReLU Dropout Linear. Returns ``(logits, {})`` from ``forward``
to match the HTFusion / HTLateral interface.
Parameters
----------
tower : encoder module with ``.out_dim``
num_classes : output classes
dropout : dropout before the output linear layer
"""
def __init__(
self,
tower: nn.Module,
num_classes: int,
dropout: float = 0.5,
):
super().__init__()
self.tower = tower
self.head = ClassificationHead(tower.out_dim, num_classes, dropout) # type: ignore[arg-type]
def encode(self, inputs) -> torch.Tensor:
"""Return tower embedding (pre-classifier)."""
if isinstance(inputs, dict):
# single-entry dict from HTDataset eye-level pass
(z,) = inputs.values()
return self.tower(z) if torch.is_tensor(z) else self.tower(*z.values())
return self.tower(inputs)
def forward(self, inputs) -> tuple[torch.Tensor, dict]:
return self.head(self.encode(inputs)), {}
+102
View File
@@ -0,0 +1,102 @@
{
"_notes": [
"CD tower only, regression head predicting vf_md.",
"Mirrors ensemble_fused architecture (eye-level fusion → patient-level hb → head)",
"with the img tower removed, so neg_mse is directly comparable to baseline_reg_nt50."
],
"run_name": "v4/cd_solo_reg",
"num_classes": 2,
"label_filter": [0, 1, 2],
"split_identity_level": 1,
"eval_stage": "hb",
"save_predictions": true,
"seed": 1234,
"folds": 5,
"fold_seed": 100,
"output_root": "v4/results",
"out_dir_tags": ["binary"],
"data": {
"module": "v4.classes.profiles.v4papila",
"args": {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": true,
"exclude_cols": ["Axial_Length"],
"in_memory_cache": true
}
},
"towers": [
{
"name": "cd",
"module": "v4.classes.towers.clinical_tower",
"class": "ClinicalEncoder",
"data_source": "matrix",
"args": { "hidden_dim": 128 }
}
],
"stages": [
{
"name": "cd_warm",
"type": "warm",
"tower": "cd",
"head_name": "cd_aux",
"level": "eye",
"epochs": 40
},
{
"name": "cd_aux",
"type": "head",
"input": "cd",
"train_with": "cd_fuse",
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
{
"name": "cd_fuse",
"type": "fusion",
"module": "v4.classes.bridges.mono_bridge",
"class": "MonoBridge",
"inputs": ["cd"],
"level": "eye",
"epochs": 50,
"train_towers": true,
"args": { "use_ln": false }
},
{
"name": "cd_fuse_head",
"type": "head",
"input": "cd_fuse",
"train_with": "cd_fuse",
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
{
"name": "hb",
"type": "fusion",
"module": "v4.classes.bridges.hyperbridge",
"class": "HyperBridge",
"inputs": { "a": "cd_fuse", "b": "cd_fuse" },
"level": "patient",
"epochs": 10,
"args": { "hidden_dim": 256, "mode": "embedding_mlp" }
},
{
"name": "hb_head",
"type": "head",
"input": "hb",
"train_with": "hb",
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
}
],
"training": {
"lr": 1e-4,
"batch_size": 8,
"tune_binary_threshold": true
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"_notes": [
"Clinical-only at patient level (bilateral). Mirrors clinical_solo.json but",
"adds the hb stage so cd_fuse(OD) and cd_fuse(OS) are aggregated at",
"patient level. Pairs with single-eye clinical_solo for the Figure 4",
"bilateral comparison."
],
"run_name": "v4/clinical_solo_bilateral",
"num_classes": 2,
"label_filter": [0, 1],
"split_identity_level": 1,
"eval_stage": "hb",
"save_predictions": true,
"seed": 1234,
"folds": 5,
"fold_seed": 100,
"output_root": "v4/results",
"out_dir_tags": ["binary"],
"data": {
"module": "v4.classes.profiles.v4papila",
"args": {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": true,
"exclude_cols": ["Axial_Length"],
"in_memory_cache": false
}
},
"towers": [
{
"name": "cd",
"module": "v4.classes.towers.clinical_tower",
"class": "ClinicalEncoder",
"data_source": "matrix",
"args": { "hidden_dim": 128 }
}
],
"stages": [
{
"name": "cd_warm",
"type": "warm",
"tower": "cd",
"head_name": "cd_aux",
"level": "eye",
"epochs": 40
},
{
"name": "cd_aux",
"type": "head",
"input": "cd",
"train_with": "cd_fuse"
},
{
"name": "cd_fuse",
"type": "fusion",
"module": "v4.classes.bridges.mono_bridge",
"class": "MonoBridge",
"inputs": ["cd"],
"level": "eye",
"epochs": 36,
"train_towers": true,
"args": { "use_ln": false }
},
{
"name": "hb",
"type": "fusion",
"module": "v4.classes.bridges.hyperbridge",
"class": "HyperBridge",
"inputs": { "a": "cd_fuse", "b": "cd_fuse" },
"level": "patient",
"epochs": 10,
"args": { "hidden_dim": 256, "mode": "embedding_mlp" }
},
{
"name": "hb_head",
"type": "head",
"input": "hb",
"train_with": "hb",
"args": { "dropout": 0.3 }
}
],
"training": {
"lr": 1e-4,
"batch_size": 16,
"tune_binary_threshold": true
}
}
+102
View File
@@ -0,0 +1,102 @@
{
"_notes": [
"Single-eye variant of ensemble_fused: stops at nt (eye-level fusion),",
"drops the bilateral hb stage. Each eye is classified independently.",
"For comparing patient-level bilateral (hb) vs single-eye (nt) — the v3",
"single/bilateral standardization in the new modular architecture."
],
"run_name": "v4/ensemble_fused_single",
"num_classes": 2,
"label_filter": [0, 1],
"split_identity_level": 1,
"eval_stage": "nt",
"save_predictions": true,
"seed": 1234,
"folds": 5,
"fold_seed": 100,
"output_root": "v4/results",
"out_dir_tags": ["binary"],
"data": {
"module": "v4.classes.profiles.v4papila",
"args": {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": true,
"exclude_cols": ["Axial_Length"],
"in_memory_cache": true
}
},
"towers": [
{
"name": "img",
"module": "v4.classes.towers.image_tower",
"class": "ImageEncoder",
"data_source": "image",
"args": {
"backbone": "refugelike",
"freeze_ratio": 0.0,
"augment": true
}
},
{
"name": "cd",
"module": "v4.classes.towers.clinical_tower",
"class": "ClinicalEncoder",
"data_source": "matrix",
"args": { "hidden_dim": 128 }
}
],
"stages": [
{
"name": "cd_warm",
"type": "warm",
"tower": "cd",
"head_name": "cd_aux",
"level": "eye",
"epochs": 40
},
{
"name": "img_aux",
"type": "head",
"input": "img",
"train_with": "nt",
"bcd": true
},
{
"name": "cd_aux",
"type": "head",
"input": "cd",
"train_with": "nt",
"bcd": true
},
{
"name": "nt",
"type": "fusion",
"module": "v4.classes.bridges.fusion_bridge",
"class": "FusionBridge",
"inputs": ["img", "cd"],
"level": "eye",
"epochs": 36,
"train_towers": true,
"warmup": {
"tower_epochs": 3,
"fused_epochs": 3
},
"args": { "fusion_dim": 256 }
},
{
"name": "nt_head",
"type": "head",
"input": "nt",
"train_with": "nt"
}
],
"training": {
"lr": 1e-4,
"batch_size": 8,
"bcd_prob": 0.5,
"tune_binary_threshold": true
}
}
+92
View File
@@ -0,0 +1,92 @@
{
"_notes": [
"Single-rep checkpointed run of the production bilateral img+cd ensemble",
"at the refuge_efficientnet_v2_m backbone. Per-fold tower and stage_models",
"state_dicts are saved under {output_root}/{run_name}/binary/checkpoints/",
"fold{N}/ for downstream explainability (Grad-CAM, attribution, etc.).",
"",
"Same architecture as ensemble_fused.json — only changes are:",
" • img.args.backbone = refuge_efficientnet_v2_m",
" • save_checkpoints = true",
" • run_name = experiments/explainability/ensemble_v2m_ckpt"
],
"run_name": "experiments/explainability/ensemble_v2m_ckpt",
"num_classes": 2,
"label_filter": [0, 1],
"split_identity_level": 1,
"eval_stage": "hb",
"save_predictions": true,
"save_checkpoints": true,
"seed": 1234,
"folds": 5,
"fold_seed": 100,
"output_root": "v4/results",
"out_dir_tags": ["binary"],
"data": {
"module": "v4.classes.profiles.v4papila",
"args": {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": true,
"exclude_cols": ["Axial_Length"],
"in_memory_cache": true
}
},
"towers": [
{
"name": "img",
"module": "v4.classes.towers.image_tower",
"class": "ImageEncoder",
"data_source": "image",
"args": {
"backbone": "refuge_efficientnet_v2_m",
"freeze_ratio": 0.0,
"augment": true
}
},
{
"name": "cd",
"module": "v4.classes.towers.clinical_tower",
"class": "ClinicalEncoder",
"data_source": "matrix",
"args": { "hidden_dim": 128 }
}
],
"stages": [
{ "name": "cd_warm", "type": "warm", "tower": "cd", "head_name": "cd_aux", "level": "eye", "epochs": 40 },
{ "name": "img_aux", "type": "head", "input": "img", "train_with": "nt", "bcd": true },
{ "name": "cd_aux", "type": "head", "input": "cd", "train_with": "nt", "bcd": true },
{
"name": "nt",
"type": "fusion",
"module": "v4.classes.bridges.fusion_bridge",
"class": "FusionBridge",
"inputs": ["img", "cd"],
"level": "eye",
"epochs": 36,
"train_towers": true,
"warmup": { "tower_epochs": 3, "fused_epochs": 3 },
"args": { "fusion_dim": 256 }
},
{ "name": "nt_head", "type": "head", "input": "nt", "train_with": "nt" },
{
"name": "hb",
"type": "fusion",
"module": "v4.classes.bridges.hyperbridge",
"class": "HyperBridge",
"inputs": { "a": "nt", "b": "nt" },
"level": "patient",
"epochs": 10,
"args": { "hidden_dim": 256, "mode": "embedding_mlp" }
},
{ "name": "hb_head", "type": "head", "input": "hb", "train_with": "hb", "args": { "dropout": 0.3 } }
],
"training": {
"lr": 1e-4,
"batch_size": 8,
"bcd_prob": 0.5,
"tune_binary_threshold": true
}
}
+84
View File
@@ -0,0 +1,84 @@
{
"_notes": [
"Image tower only, binary classification. Mirrors ensemble_fused architecture",
"(eye-level fusion → patient-level hb → head) with the cd tower removed,",
"so test AUC is directly comparable to baseline_ensemble."
],
"run_name": "v4/img_solo",
"num_classes": 2,
"label_filter": [0, 1],
"split_identity_level": 1,
"eval_stage": "hb",
"save_predictions": true,
"seed": 1234,
"folds": 5,
"fold_seed": 100,
"output_root": "v4/results",
"out_dir_tags": ["binary"],
"data": {
"module": "v4.classes.profiles.v4papila",
"args": {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": true,
"exclude_cols": ["Axial_Length"],
"in_memory_cache": true
}
},
"towers": [
{
"name": "img",
"module": "v4.classes.towers.image_tower",
"class": "ImageEncoder",
"data_source": "image",
"args": {
"backbone": "refugelike",
"freeze_ratio": 0.0,
"augment": true
}
}
],
"stages": [
{
"name": "img_fuse",
"type": "fusion",
"module": "v4.classes.bridges.mono_bridge",
"class": "MonoBridge",
"inputs": ["img"],
"level": "eye",
"epochs": 36,
"train_towers": true,
"args": { "use_ln": false }
},
{
"name": "img_fuse_head",
"type": "head",
"input": "img_fuse",
"train_with": "img_fuse"
},
{
"name": "hb",
"type": "fusion",
"module": "v4.classes.bridges.hyperbridge",
"class": "HyperBridge",
"inputs": { "a": "img_fuse", "b": "img_fuse" },
"level": "patient",
"epochs": 10,
"args": { "hidden_dim": 256, "mode": "embedding_mlp" }
},
{
"name": "hb_head",
"type": "head",
"input": "hb",
"train_with": "hb",
"args": { "dropout": 0.3 }
}
],
"training": {
"lr": 1e-4,
"batch_size": 8,
"tune_binary_threshold": true
}
}
+89
View File
@@ -0,0 +1,89 @@
{
"_notes": [
"Image tower only, regression head predicting vf_md.",
"Mirrors ensemble_fused architecture (eye-level fusion → patient-level hb → head)",
"with the cd tower removed, so neg_mse is directly comparable to baseline_reg_nt50."
],
"run_name": "v4/img_solo_reg",
"num_classes": 2,
"label_filter": [0, 1, 2],
"split_identity_level": 1,
"eval_stage": "hb",
"save_predictions": true,
"seed": 1234,
"folds": 5,
"fold_seed": 100,
"output_root": "v4/results",
"out_dir_tags": ["binary"],
"data": {
"module": "v4.classes.profiles.v4papila",
"args": {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": true,
"exclude_cols": ["Axial_Length"],
"in_memory_cache": true
}
},
"towers": [
{
"name": "img",
"module": "v4.classes.towers.image_tower",
"class": "ImageEncoder",
"data_source": "image",
"args": {
"backbone": "refugelike",
"freeze_ratio": 0.0,
"augment": true
}
}
],
"stages": [
{
"name": "img_fuse",
"type": "fusion",
"module": "v4.classes.bridges.mono_bridge",
"class": "MonoBridge",
"inputs": ["img"],
"level": "eye",
"epochs": 50,
"train_towers": true,
"args": { "use_ln": false }
},
{
"name": "img_fuse_head",
"type": "head",
"input": "img_fuse",
"train_with": "img_fuse",
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
{
"name": "hb",
"type": "fusion",
"module": "v4.classes.bridges.hyperbridge",
"class": "HyperBridge",
"inputs": { "a": "img_fuse", "b": "img_fuse" },
"level": "patient",
"epochs": 10,
"args": { "hidden_dim": 256, "mode": "embedding_mlp" }
},
{
"name": "hb_head",
"type": "head",
"input": "hb",
"train_with": "hb",
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
}
],
"training": {
"lr": 1e-4,
"batch_size": 8,
"tune_binary_threshold": true
}
}
+115
View File
@@ -0,0 +1,115 @@
{
"_notes": [
"Smoke test: convnextv2_tiny backbone, single fold, few epochs.",
"Goal is to verify the new ConvNeXt-V2 path works end-to-end (load weights,",
"forward, backward, save summary). Not for measuring quality."
],
"run_name": "smoke/convnextv2_tiny",
"num_classes": 2,
"label_filter": [
0,
1,
2
],
"split_identity_level": 1,
"eval_stage": "hb",
"save_predictions": false,
"seed": 1234,
"folds": 3,
"fold_seed": 100,
"output_root": "v4/results",
"out_dir_tags": [
"binary"
],
"data": {
"module": "v4.classes.profiles.v4papila",
"args": {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": true,
"exclude_cols": [
"Axial_Length"
],
"in_memory_cache": true
}
},
"towers": [
{
"name": "img",
"module": "v4.classes.towers.image_tower",
"class": "ImageEncoder",
"data_source": "image",
"args": {
"backbone": "convnextv2_tiny",
"freeze_ratio": 0.6,
"augment": true
}
}
],
"stages": [
{
"name": "img_fuse",
"type": "fusion",
"module": "v4.classes.bridges.mono_bridge",
"class": "MonoBridge",
"inputs": [
"img"
],
"level": "eye",
"epochs": 2,
"train_towers": true,
"args": {
"use_ln": false
}
},
{
"name": "img_fuse_head",
"type": "head",
"input": "img_fuse",
"train_with": "img_fuse",
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": {
"dropout": 0.3,
"target_key": "vf_md",
"loss": "mse"
}
},
{
"name": "hb",
"type": "fusion",
"module": "v4.classes.bridges.hyperbridge",
"class": "HyperBridge",
"inputs": {
"a": "img_fuse",
"b": "img_fuse"
},
"level": "patient",
"epochs": 2,
"args": {
"hidden_dim": 256,
"mode": "embedding_mlp"
}
},
{
"name": "hb_head",
"type": "head",
"input": "hb",
"train_with": "hb",
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": {
"dropout": 0.3,
"target_key": "vf_md",
"loss": "mse"
}
}
],
"training": {
"lr": 1e-4,
"batch_size": 8,
"tune_binary_threshold": true
}
}
+67
View File
@@ -0,0 +1,67 @@
{
"_notes": [
"Single-eye img-only variant. Eye-level MonoBridge fusion, no patient-level hb.",
"Each eye is classified independently. For comparing single vs bilateral",
"in the img-only architecture."
],
"run_name": "v4/img_solo_single",
"num_classes": 2,
"label_filter": [0, 1],
"split_identity_level": 1,
"eval_stage": "img_fuse",
"save_predictions": true,
"seed": 1234,
"folds": 5,
"fold_seed": 100,
"output_root": "v4/results",
"out_dir_tags": ["binary"],
"data": {
"module": "v4.classes.profiles.v4papila",
"args": {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": true,
"exclude_cols": ["Axial_Length"],
"in_memory_cache": true
}
},
"towers": [
{
"name": "img",
"module": "v4.classes.towers.image_tower",
"class": "ImageEncoder",
"data_source": "image",
"args": {
"backbone": "refugelike",
"freeze_ratio": 0.0,
"augment": true
}
}
],
"stages": [
{
"name": "img_fuse",
"type": "fusion",
"module": "v4.classes.bridges.mono_bridge",
"class": "MonoBridge",
"inputs": ["img"],
"level": "eye",
"epochs": 36,
"train_towers": true,
"args": { "use_ln": false }
},
{
"name": "img_fuse_head",
"type": "head",
"input": "img_fuse",
"train_with": "img_fuse"
}
],
"training": {
"lr": 1e-4,
"batch_size": 8,
"tune_binary_threshold": true
}
}
+23
View File
@@ -30,6 +30,9 @@ batch.json format:
"stage_overrides": { // optional patched by stage name
"nt": { "epochs": 40 }
},
"tower_overrides": { // optional patched by tower name
"img": { "args": { "backbone": "convnextv2_tiny" } }
},
"reps": 10, // optional overrides --reps
"priority": 0 // optional
}
@@ -83,6 +86,22 @@ def apply_stage_overrides(stages: list[dict], stage_overrides: dict) -> list[dic
return stages
def apply_tower_overrides(towers: list[dict], tower_overrides: dict) -> list[dict]:
"""Patch individual towers by name without replacing the entire list.
Useful for backbone swaps and other per-tower arg tweaks:
"tower_overrides": { "img": { "args": { "backbone": "convnextv2_tiny" } } }
"""
towers = copy.deepcopy(towers)
for tower in towers:
name = tower.get("name")
if name in tower_overrides:
merged = deep_merge(tower, tower_overrides[name])
tower.clear()
tower.update(merged)
return towers
def build_config(base_cfg: dict, entry: dict, rep: int, seed: int, fold_seed: int,
output_root: str) -> dict:
"""Produce the final merged config for one rep of one batch entry."""
@@ -95,6 +114,10 @@ def build_config(base_cfg: dict, entry: dict, rep: int, seed: int, fold_seed: in
if "stage_overrides" in entry and "stages" in cfg:
cfg["stages"] = apply_stage_overrides(cfg["stages"], entry["stage_overrides"])
# Patch individual towers by name (backbone swaps, arg tweaks)
if "tower_overrides" in entry and "towers" in cfg:
cfg["towers"] = apply_tower_overrides(cfg["towers"], entry["tower_overrides"])
# Stamp run_name, model seed, split seed, output_root.
base_run_name = entry["run_name"]
cfg["run_name"] = f"{base_run_name}/rep{rep:02d}"
+9 -1
View File
@@ -292,9 +292,17 @@ def _run_job(job: JobSpec, server: _Server,
proc.wait()
tailer.join(timeout=5)
heartbeat.join(timeout=5)
log_file.unlink(missing_ok=True)
success = proc.returncode == 0
if success:
log_file.unlink(missing_ok=True)
else:
failed_path = log_file.with_suffix(".failed.log")
try:
log_file.replace(failed_path)
print(f"[client] preserved failure log at {failed_path}", flush=True)
except Exception:
pass
if not success:
try:
Binary file not shown.
+189
View File
@@ -0,0 +1,189 @@
"""F1 — System architecture diagram.
Bilateral multimodal fusion architecture. Modelled on v3's architecture_fused_head
but adapted for v4 + manuscript terminology:
* "OD HyperTower" / "OS HyperTower" -> "OD Fusion" / "OS Fusion"
* Bridge boxes show their math explicitly (image projection, clinical
projection, fusion operation), no longer abbreviated "Bridge"
* Title drops the HyperTower brand
Re-run anytime:
python -m v4.figures.F1_architecture
"""
from __future__ import annotations
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import FancyBboxPatch
OUT = Path(__file__).parent / "output" / "F1_architecture.png"
# ── Palette (matches v3 plot_architecture.py for visual consistency) ────────
C_IMG = "#4e8d3a" # green — image / CNN
C_MD = "#4c72b0" # blue — clinical network
C_BRIDGE = "#c44e52" # red — fusion bridge
C_HEAD = "#d4a017" # gold — patient-level head
C_OUT = "#8c6bb1" # purple — output classes
C_INPUT = "#a0a0a0" # grey — raw inputs
C_BG = "#e8e8e8"
C_ARROW = "#444444"
FONT = "DejaVu Sans"
# ── Primitives ───────────────────────────────────────────────────────────────
def _box(ax, cx, cy, w, h, color, text="", fontsize=9, text_color="white",
bold=False, alpha=0.92, radius=0.12, lw=1.5):
patch = FancyBboxPatch(
(cx - w / 2, cy - h / 2), w, h,
boxstyle=f"round,pad=0,rounding_size={radius}",
facecolor=color, edgecolor="white", linewidth=lw, alpha=alpha, zorder=3,
transform=ax.transData,
)
ax.add_patch(patch)
if text:
ax.text(cx, cy, text, ha="center", va="center",
fontsize=fontsize, color=text_color,
fontweight="bold" if bold else "normal",
fontfamily=FONT, zorder=4)
return patch
def _arrow(ax, x0, y0, x1, y1, lw=1.4, color=C_ARROW, style="-|>"):
ax.annotate("", xy=(x1, y1), xytext=(x0, y0),
arrowprops=dict(arrowstyle=style, color=color, lw=lw),
zorder=2)
def _text(ax, x, y, s, fontsize=9, color="#333", ha="center", va="center", bold=False):
ax.text(x, y, s, ha=ha, va=va, fontsize=fontsize, color=color,
fontfamily=FONT, fontweight="bold" if bold else "normal", zorder=5)
def _bracket(ax, x, y0, y1, text="", pad=0.20, fontsize=9, badge_color="#555"):
mid = (y0 + y1) / 2
ax.plot([x, x + pad, x + pad, x], [y1, y1, y0, y0],
color=badge_color, lw=1.4, solid_capstyle="round", zorder=2)
if text:
ax.text(x + pad * 1.4, mid, text, ha="left", va="center",
fontsize=fontsize, color="white", fontfamily=FONT, fontweight="bold",
zorder=6,
bbox=dict(facecolor=badge_color, edgecolor="none", pad=3.5,
boxstyle="round,pad=0.3"))
def _draw_output(ax, x, y, classes=("Glaucoma", "Normal")):
bw, bh, gap = 1.10, 0.38, 0.08
n = len(classes)
total = n * bh + (n - 1) * gap
y_top = y + total / 2 - bh / 2
for i, cls in enumerate(classes):
cy = y_top - i * (bh + gap)
_box(ax, x + bw / 2, cy, bw, bh, C_OUT, cls, fontsize=8.5, radius=0.08)
_arrow(ax, x, y, x, cy, lw=1.1, style="-|>")
_text(ax, x + bw / 2, y - total / 2 - 0.20, "Softmax",
fontsize=7.5, color=C_OUT)
def _draw_eye_fusion(ax, x_left, y_img, y_md, eye_label):
"""One eye's row: Image Network box + Clinical Network box -> Fusion bridge box.
Returns (x_right_of_bridge, y_bridge_center).
"""
bw_img, bh_img = 1.45, 0.72
bw_md, bh_md = 1.48, 0.66
bw_br, bh_br = 1.75, 1.30
# Image network box
_box(ax, x_left + bw_img / 2, y_img, bw_img, bh_img, C_IMG,
f"{eye_label}\nImage Network", fontsize=8.5, radius=0.08)
# Clinical network box
_box(ax, x_left + bw_md / 2, y_md, bw_md, bh_md, C_MD,
f"{eye_label}\nClinical Network", fontsize=8.5, radius=0.08)
# Fusion bridge — with math detail (replaces compact "Bridge" label)
br_x = x_left + max(bw_img, bw_md) + 1.40
cy_br = (y_img + y_md) / 2
_arrow(ax, x_left + bw_img, y_img, br_x - bw_br / 2, cy_br, lw=1.2, style="-|>")
_arrow(ax, x_left + bw_md, y_md, br_x - bw_br / 2, cy_br, lw=1.2, style="-|>")
_box(ax, br_x, cy_br, bw_br, bh_br, C_BRIDGE,
"Fusion Bridge\nFC(img → 256)\nFC(md → 256)\nHadamard product",
fontsize=8, radius=0.10)
return br_x + bw_br / 2, cy_br
# ── Main figure ──────────────────────────────────────────────────────────────
def main() -> None:
W, H = 13.0, 7.8
fig, ax = plt.subplots(figsize=(W, H))
ax.set_xlim(0, W); ax.set_ylim(0, H); ax.axis("off")
ax.set_facecolor(C_BG); fig.patch.set_facecolor(C_BG)
ax.set_title("Bilateral Multimodal Fusion Architecture",
fontsize=13, fontweight="bold", fontfamily=FONT, pad=10, color="#222")
x_left = 3.2
inp_cx = 1.85
inp_w = 0.95
inp_h = 0.55
# OD (top)
od_y_img, od_y_md = 6.10, 4.80
br_od_x, cy_od = _draw_eye_fusion(ax, x_left, od_y_img, od_y_md, "OD")
_text(ax, 0.50, (od_y_img + od_y_md) / 2, "OD\n(Right Eye)",
fontsize=9.5, color="#444", bold=True)
_box(ax, inp_cx, od_y_img, inp_w, inp_h, C_INPUT, "Fundus\nImage",
fontsize=8.5, radius=0.08, alpha=0.78, text_color="#333")
_box(ax, inp_cx, od_y_md, inp_w, inp_h, C_INPUT, "Clinical\nData",
fontsize=8.5, radius=0.08, alpha=0.78, text_color="#333")
_arrow(ax, inp_cx + inp_w / 2, od_y_img, x_left, od_y_img, lw=1.2, style="-|>")
_arrow(ax, inp_cx + inp_w / 2, od_y_md, x_left, od_y_md, lw=1.2, style="-|>")
# OS (bottom)
os_y_img, os_y_md = 2.95, 1.65
br_os_x, cy_os = _draw_eye_fusion(ax, x_left, os_y_img, os_y_md, "OS")
_text(ax, 0.50, (os_y_img + os_y_md) / 2, "OS\n(Left Eye)",
fontsize=9.5, color="#444", bold=True)
_box(ax, inp_cx, os_y_img, inp_w, inp_h, C_INPUT, "Fundus\nImage",
fontsize=8.5, radius=0.08, alpha=0.78, text_color="#333")
_box(ax, inp_cx, os_y_md, inp_w, inp_h, C_INPUT, "Clinical\nData",
fontsize=8.5, radius=0.08, alpha=0.78, text_color="#333")
_arrow(ax, inp_cx + inp_w / 2, os_y_img, x_left, os_y_img, lw=1.2, style="-|>")
_arrow(ax, inp_cx + inp_w / 2, os_y_md, x_left, os_y_md, lw=1.2, style="-|>")
# Side brackets — re-labelled "OD Fusion" / "OS Fusion"
_bracket(ax, x=br_od_x + 0.05,
y0=od_y_md - 0.50, y1=od_y_img + 0.50,
text="OD Fusion", pad=0.22, fontsize=9, badge_color="#555")
_bracket(ax, x=br_os_x + 0.05,
y0=os_y_md - 0.50, y1=os_y_img + 0.50,
text="OS Fusion", pad=0.22, fontsize=9, badge_color="#555")
# Patient-level head (Fused Head)
head_y = (cy_od + cy_os) / 2
head_x = max(br_od_x, br_os_x) + 2.55
head_w, head_h = 1.95, 1.30
_arrow(ax, br_od_x + 0.05, cy_od, head_x - head_w / 2, head_y, lw=1.4, style="-|>")
_arrow(ax, br_os_x + 0.05, cy_os, head_x - head_w / 2, head_y, lw=1.4, style="-|>")
_box(ax, head_x, head_y, head_w, head_h, C_HEAD,
"Patient Head\ncat(z_OD, z_OS)\n→ FC(256) → FC(2)",
fontsize=8.5, radius=0.10)
# Output nodes
out_x = head_x + head_w / 2 + 0.55
_arrow(ax, head_x + head_w / 2, head_y, out_x, head_y, lw=1.5, style="-|>")
_draw_output(ax, out_x, head_y)
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
main()
@@ -0,0 +1,221 @@
"""F2 — Backbone selection panel.
Box plot in the style of v3/figures/phase2_analysis.png (black-bordered boxes,
red median lines, baseline median reference). Three left-to-right sections:
Block 1 (blue) Basic backbones (img-only, single-eye, ImageNet pretraining):
VGG16, MobileNetV2, DenseNet121, InceptionV3, ResNet50
Sourced from v3 phase 1 / phase 2 fold AUCs. Will be refined with v4
10x5 runs later; means should not move much.
Block 2 (blue) ResNet50 preprocessing/CV variations:
leaky CV, GT crop, U-Net crop (all 2.5x scale; 1.1x dropped from labels)
Sourced from v3 phase 2 'classic_test_auc' (single-mode image-only).
Block 3 (orange) Baseline reference:
"Baseline (fine-tuned ResNet50)" what we previously called refugelike.
Sourced from v3 phase 2 imageonly_refugelike_proper.
Each non-baseline box is labelled with a Wilcoxon two-sided p-value comparing
its fold AUCs to the baseline.
Re-run anytime:
python -m v4.figures.F2_papila_replication_and_single_mode
"""
from __future__ import annotations
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.stats import wilcoxon
from v4.figures.util.loaders import REPO_ROOT
OUT = Path(__file__).parent / "output" / "F2_backbones.png"
# ── Colors / styling (mirrors v3 phase2_analysis) ────────────────────────────
C_VAR = "#4c72b0" # blue — non-baseline boxes (basic backbones + variants)
C_BASE = "#dd8452" # orange — baseline reference box
C_MEDIAN = "#c44e52" # red — median line inside boxes
ALPHA = 0.82
V3_PHASE1_DIR = REPO_ROOT / "v3" / "results" / "phase1"
V3_PHASE2_DIR = REPO_ROOT / "v3" / "results" / "phase2"
def _wilcoxon_p(a: np.ndarray, b: np.ndarray) -> float:
diffs = a - b
if len(diffs) < 5 or np.all(diffs == 0):
return float("nan")
try:
return float(wilcoxon(diffs, alternative="two-sided").pvalue)
except Exception:
return float("nan")
def _load_phase1_fold_aucs(subdir: str) -> np.ndarray:
fp = V3_PHASE1_DIR / subdir / "fold_metrics.csv"
if not fp.exists():
return np.array([])
df = pd.read_csv(fp)
return df["auc"].dropna().astype(float).values
def _load_phase2_classic_aucs(run_name: str) -> np.ndarray:
"""Collect classic_test_auc across all rep×fold for a phase 2 run folder."""
root = V3_PHASE2_DIR / run_name
if not root.exists():
return np.array([])
out: list[float] = []
for rep in sorted(root.glob("rep*")):
fp = rep / "binary" / "single" / "fold_results.csv"
if not fp.exists(): continue
df = pd.read_csv(fp)
if "classic_test_auc" not in df.columns: continue
out.extend(df["classic_test_auc"].dropna().astype(float).tolist())
return np.array(out)
# ── Per-section data definitions ─────────────────────────────────────────────
# Each entry: (label, loader_fn, *args)
BASIC_BACKBONES = [
("VGG16", _load_phase1_fold_aucs, "cnn_vgg16"),
("MobileNetV2", _load_phase1_fold_aucs, "cnn_mobilenet_v2"),
("DenseNet121", _load_phase1_fold_aucs, "cnn_densenet121"),
("InceptionV3", _load_phase1_fold_aucs, "cnn_inception_v3"),
# Use phase 2 ResNet50 (50 fold AUCs) for tighter statistics on the
# backbone that we sweep variations of in block 2.
("ResNet50", _load_phase2_classic_aucs, "imageonly_resnet50_proper"),
]
RESNET_VARIATIONS = [
("leaky CV", _load_phase2_classic_aucs, "imageonly_resnet50_leaky"),
("GT crop", _load_phase2_classic_aucs, "imageonly_resnet50_gtcrop_2.5"),
("U-Net crop", _load_phase2_classic_aucs, "imageonly_resnet50_unetcrop_2.5"),
]
BASELINE_LABEL = "baseline\n(fine-tuned ResNet50)"
BASELINE_DATA = (_load_phase2_classic_aucs, "imageonly_refugelike_proper")
def render() -> None:
# Load everything
block1 = [(lbl, fn(arg)) for lbl, fn, arg in BASIC_BACKBONES]
block2 = [(lbl, fn(arg)) for lbl, fn, arg in RESNET_VARIATIONS]
base_fn, base_arg = BASELINE_DATA
base_aucs = base_fn(base_arg)
print("Block 1 — Basic backbones:")
for lbl, a in block1:
print(f" {lbl:<14s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}" if len(a) else f" {lbl:<14s} no data")
print("Block 2 — ResNet50 variations:")
for lbl, a in block2:
print(f" {lbl:<14s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}" if len(a) else f" {lbl:<14s} no data")
print(f"Block 3 — Baseline: n={len(base_aucs)} "
f"mean={base_aucs.mean():.3f}±{base_aucs.std():.3f}" if len(base_aucs) else "Block 3 — no baseline data")
# Lay out positions
gap = 0.7
pos: list[float] = []
p = 0.0
for _ in block1:
pos.append(p); p += 1.0
section1_right = p - 1.0
p += gap
section2_left = p
for _ in block2:
pos.append(p); p += 1.0
section2_right = p - 1.0
p += gap
section3_left = p
pos.append(p)
section3_right = p
total_w = p + 0.6
fig, ax = plt.subplots(figsize=(13, 5.8))
fig.suptitle("Backbone Selection", fontsize=13, fontweight="bold")
box_w = 0.55
boxprops_kw = dict(linewidth=1.2, edgecolor="black")
medianprops = dict(color=C_MEDIAN, linewidth=2)
whiskerprops = dict(color="black", linewidth=1.0)
capprops = dict(color="black", linewidth=1.0)
flierprops = dict(marker="o", markersize=3, alpha=0.55,
markerfacecolor="#888", markeredgecolor="#444")
all_aucs: list[np.ndarray] = []
all_labels: list[str] = []
all_colors: list[str] = []
for lbl, a in block1 + block2:
all_labels.append(lbl); all_aucs.append(a); all_colors.append(C_VAR)
all_labels.append(BASELINE_LABEL); all_aucs.append(base_aucs); all_colors.append(C_BASE)
# Draw boxes
for x, aucs, color in zip(pos, all_aucs, all_colors):
if not len(aucs): continue
bp = ax.boxplot(
aucs, positions=[x], widths=box_w, patch_artist=True, manage_ticks=False,
boxprops=dict(facecolor=color, alpha=ALPHA, **boxprops_kw),
medianprops=medianprops,
whiskerprops=whiskerprops,
capprops=capprops,
flierprops=flierprops,
)
# Baseline median reference line spanning the variant blocks
if len(base_aucs):
ax.axhline(np.median(base_aucs),
color=C_BASE, linewidth=1.2, linestyle="--", alpha=0.55,
label="Baseline median")
# Dividers between sections (vertical light lines)
div1 = (section1_right + section2_left) / 2
div2 = (section2_right + section3_left) / 2
for d in (div1, div2):
ax.axvline(d, color="#aaa", linewidth=0.7, alpha=0.65, linestyle="-")
# Section labels just above each block
y_band = 1.02
section_centers = [
((pos[0] + section1_right) / 2, "Basic backbones (img-only, single)"),
((section2_left + section2_right) / 2, "ResNet50 variations"),
((section3_left + section3_right) / 2, "Baseline"),
]
for cx, txt in section_centers:
ax.text(cx, y_band, txt, ha="center", va="bottom",
fontsize=10, color="#333", fontweight="bold",
transform=ax.get_xaxis_transform())
# X-tick labels (with p-values vs baseline beneath each variant box)
tick_labels = []
for lbl, aucs, color in zip(all_labels, all_aucs, all_colors):
if color == C_BASE or not len(aucs) or not len(base_aucs):
tick_labels.append(lbl); continue
n = min(len(aucs), len(base_aucs))
p_val = _wilcoxon_p(aucs[:n], base_aucs[:n])
ps = f"p={p_val:.3f}" if not np.isnan(p_val) else "p=n/a"
tick_labels.append(f"{lbl}\n{ps}")
ax.set_xticks(pos)
ax.set_xticklabels(tick_labels, fontsize=9.5)
ax.set_xlim(-0.6, section3_right + 0.7)
ax.set_ylim(0.55, 1.0)
ax.set_ylabel("Test AUC", fontsize=11)
ax.grid(axis="y", alpha=0.3, linestyle="--")
ax.legend(loc="lower left", fontsize=9, framealpha=0.92)
fig.tight_layout()
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+298
View File
@@ -0,0 +1,298 @@
"""F3 — Single-mode comparison via confidence strips.
Four panels showing per-eye predicted P(Glaucoma) coloured by VF-MD severity
(when known), in the style of v3/figures/explainability/confidence_strips_comparison.png.
Panels (left right):
Clinical only (cd_solo_single)
Image only (img_solo_single_refugelike)
Hadamard fusion (ensemble_single_refugelike baseline)
Concat fusion (phase3_v4/single_bcd_concat)
Each panel shows test predictions pooled across all reps × folds. Points are
jittered around their true-class column and coloured by VF-MD severity tier
(early / moderate / severe / unknown for glaucoma rows; grey for healthy).
Re-run anytime:
python -m v4.figures.F3_hyperfeature_ablation
"""
from __future__ import annotations
import warnings
warnings.filterwarnings("ignore")
from pathlib import Path
import h5py
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
from v4.figures.util.loaders import REPO_ROOT, RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "F3_single_mode_strips.png"
# ── Palette (matches v3 confidence_strips) ───────────────────────────────────
C_NORMAL = "#78909C"
C_EARLY = "#29B6F6"
C_MODERATE = "#FFB300"
C_SEVERE = "#E53935"
C_UNKNOWN = "#BDBDBD"
SEV_LABELS = {
"normal": "Normal",
"early": "Glaucoma — early (VF_MD > 6)",
"moderate": "Glaucoma — moderate (12 to 6)",
"severe": "Glaucoma — severe (VF_MD < 12)",
"unknown": "Glaucoma — VF_MD not recorded",
}
SEV_COLORS = {
"normal": C_NORMAL,
"early": C_EARLY,
"moderate": C_MODERATE,
"severe": C_SEVERE,
"unknown": C_UNKNOWN,
}
SEV_ORDER = ["normal", "unknown", "early", "moderate", "severe"]
SEV_ALPHA = {"normal": 0.40, "unknown": 0.35, "early": 0.55, "moderate": 0.70, "severe": 0.85}
SEV_SIZE = {"normal": 6, "unknown": 6, "early": 8, "moderate": 10, "severe": 12}
# ── Per-panel definitions: (label, results dir, eval_stage) ──────────────────
# Top row: single-modality reference runs
TOP_ROW = [
("Clinical only", RESULTS_ROOT / "phase2_v4" / "cd_solo_single", "cd_fuse"),
("Image only", RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refugelike", "img_fuse"),
]
# Bottom row: L1 fusion bridge variants (eye-level img+cd ensembles)
BOTTOM_ROW = [
("Concat fusion", RESULTS_ROOT / "phase3_v4" / "single_bcd_concat", "nt"),
("Pairwise fusion", RESULTS_ROOT / "phase3_v4" / "single_bcd_pairwise", "nt"),
("Gated fusion", RESULTS_ROOT / "phase3_v4" / "single_bcd_gated", "nt"),
("Hadamard fusion", RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refugelike", "nt"),
]
CLINICAL_DIR = REPO_ROOT / "Papila" / "ClinicalData"
# ── VFI loader (patient-level worst-eye severity, matches v3) ────────────────
def load_vfi() -> pd.DataFrame:
od = pd.read_excel(CLINICAL_DIR / "patient_data_od.xlsx", header=1)
os_= pd.read_excel(CLINICAL_DIR / "patient_data_os.xlsx", header=1)
def _clean(df):
df = df.copy()
if "Patient ID" not in df.columns and "ID" in df.columns:
df.rename(columns={"ID": "Patient ID"}, inplace=True)
df["Patient ID"] = df["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
df["Diagnosis"] = pd.to_numeric(df["Diagnosis"], errors="coerce")
df["VF_MD"] = pd.to_numeric(df["VF_MD"], errors="coerce")
# PAPILA: 0=Normal, 1=Glaucoma, 2=Suspect — keep only binary subjects
df = df[df["Diagnosis"].isin([0, 1])].copy()
return df[["Patient ID", "Diagnosis", "VF_MD"]]
both = pd.concat([_clean(od), _clean(os_)], ignore_index=True)
diag = both.groupby("Patient ID")["Diagnosis"].agg(lambda x: x.mode().iloc[0]).reset_index()
vf = both.groupby("Patient ID")["VF_MD"].min().reset_index()
out = diag.merge(vf, on="Patient ID").rename(
columns={"Patient ID": "patient_id", "Diagnosis": "diagnosis", "VF_MD": "vf_md"}
)
def _sev(row):
if int(row["diagnosis"]) == 0: return "normal"
v = row["vf_md"]
if pd.isna(v): return "unknown"
if v > -6: return "early"
if v > -12: return "moderate"
return "severe"
out["severity"] = out.apply(_sev, axis=1)
return out
# ── Prediction pooler ────────────────────────────────────────────────────────
def collect_predictions(run_dir: Path, eval_stage: str) -> pd.DataFrame:
"""Pool test rows across reps × folds. Returns DataFrame with
patient_id, y_true, prob_glaucoma, rep, fold.
Applies softmax to the 2-class logits."""
if not run_dir.exists():
return pd.DataFrame()
rows: list[dict] = []
for rep in sorted(run_dir.glob("rep*")):
fp = next(iter(rep.rglob("predictions.h5")), None)
if fp is None: continue
with h5py.File(fp, "r") as f:
if eval_stage not in f: continue
grp = f[eval_stage]
logits = grp["logits"][:]
y_true = grp["y_true"][:].astype(int)
split = grp["split"][:]
eid0 = grp["entity_id_0"][:]
n_folds, n_epochs, n_samples, n_heads, n_outputs = logits.shape
if n_outputs != 2: continue
ep, head = n_epochs - 1, n_heads - 1
for fold in range(n_folds):
labels = np.array([s.decode() if isinstance(s, bytes) else str(s) for s in split[fold]])
test_mask = (labels == "test")
if not test_mask.any(): continue
lg = logits[fold, ep, test_mask, head, :] # (n_test, 2)
# softmax
e = np.exp(lg - lg.max(axis=1, keepdims=True))
p = e / e.sum(axis=1, keepdims=True)
for k, idx in enumerate(np.where(test_mask)[0]):
rows.append({
"rep": rep.name,
"fold": fold,
"patient_id": int(eid0[idx]),
"y_true": int(y_true[idx]),
"prob_glaucoma": float(p[k, 1]),
})
return pd.DataFrame(rows)
# ── Panel render ─────────────────────────────────────────────────────────────
C_NORMAL_VIOLIN = "#4c72b0"
C_GLAUCOMA_VIOLIN = "#c44e52"
def _draw_panel(ax, df: pd.DataFrame, vfi: pd.DataFrame, label: str):
df = df.merge(vfi[["patient_id", "severity"]], on="patient_id", how="left")
df["severity"] = df["severity"].fillna("unknown")
rng = np.random.default_rng(42)
x_pos = {0: 0.0, 1: 1.0}
jitter_scale = 0.18
# Violin density behind everything (per true class)
data_by_class = [df.loc[df["y_true"] == cls, "prob_glaucoma"].values for cls in [0, 1]]
if all(len(d) > 0 for d in data_by_class):
vp = ax.violinplot(data_by_class, positions=[0, 1], widths=0.7,
showmedians=False, showextrema=False)
for body, color in zip(vp["bodies"], [C_NORMAL_VIOLIN, C_GLAUCOMA_VIOLIN]):
body.set_facecolor(color); body.set_alpha(0.30)
body.set_edgecolor("none"); body.set_zorder(2)
for sev in SEV_ORDER:
mask = df["severity"] == sev
if not mask.any(): continue
sub = df[mask]
jitter = rng.uniform(-jitter_scale, jitter_scale, len(sub))
x = np.array([x_pos[int(v)] for v in sub["y_true"]]) + jitter
ax.scatter(x, sub["prob_glaucoma"].values,
c=SEV_COLORS[sev], s=SEV_SIZE[sev],
alpha=SEV_ALPHA[sev], linewidths=0, zorder=3)
# Median lines + TN/TP rate labels per class
xtick_labels = []
for cls, xc in x_pos.items():
vals = df.loc[df["y_true"] == cls, "prob_glaucoma"]
if not len(vals):
xtick_labels.append("Normal" if cls == 0 else "Glaucoma"); continue
med = float(np.median(vals))
ax.plot([xc - jitter_scale - 0.04, xc + jitter_scale + 0.04],
[med, med], color="#222", lw=2.0, zorder=5)
if cls == 0:
rate = (vals <= 0.5).mean() * 100
xtick_labels.append(f"Normal\nTN {rate:.0f}%")
else:
rate = (vals > 0.5).mean() * 100
xtick_labels.append(f"Glaucoma\nTP {rate:.0f}%")
ax.axhline(0.5, color="#888", lw=1.2, ls="--", alpha=0.7, zorder=2)
ax.set_xticks([0, 1]); ax.set_xticklabels(xtick_labels, fontsize=10)
ax.set_ylim(-0.04, 1.04); ax.set_xlim(-0.55, 1.55)
ax.set_title(label, fontsize=11, fontweight="bold")
ax.grid(axis="y", alpha=0.3, zorder=1)
# AUC across reps×folds (per-fold AUC averaged)
fold_aucs = []
for _, g in df.groupby(["rep", "fold"]):
if g["y_true"].nunique() < 2: continue
try: fold_aucs.append(roc_auc_score(g["y_true"], g["prob_glaucoma"]))
except Exception: pass
if fold_aucs:
ax.text(0.66, 0.0,
f"AUC = {np.mean(fold_aucs):.3f} ± {np.std(fold_aucs):.3f}",
transform=ax.transAxes, ha="right", va="bottom",
fontsize=9, color="#333",
bbox=dict(facecolor="white", alpha=0.7, edgecolor="none", pad=2))
def render() -> None:
vfi = load_vfi()
top_dfs = [(lbl, collect_predictions(p, s)) for lbl, p, s in TOP_ROW]
bottom_dfs = [(lbl, collect_predictions(p, s)) for lbl, p, s in BOTTOM_ROW]
for lbl, df in top_dfs + bottom_dfs:
if len(df):
print(f" {lbl:<18s} n_rows={len(df):>5d} (pid={df['patient_id'].nunique()}, reps={df['rep'].nunique()})")
else:
print(f" {lbl:<18s} no data")
n_cols = len(BOTTOM_ROW)
fig = plt.figure(figsize=(4.4 * n_cols, 12))
fig.patch.set_facecolor("#e8e8e8")
fig.suptitle("L1 Fusion Comparison", fontsize=14, fontweight="bold")
gs = fig.add_gridspec(2, n_cols, hspace=0.30, wspace=0.15)
# Top row: 2 reference panels at same width as bottom panels, centered.
# In a 4-column bottom grid, that's columns 1 and 2.
n_top = len(top_dfs)
top_offset = (n_cols - n_top) // 2 # leading empty columns
top_axes = []
for i, (lbl, df) in enumerate(top_dfs):
ax = fig.add_subplot(gs[0, top_offset + i])
top_axes.append(ax)
ax.set_facecolor("#e8e8e8")
if len(df):
_draw_panel(ax, df, vfi, lbl)
else:
ax.text(0.5, 0.5, "(pending)", ha="center", va="center",
fontsize=12, color="#888", transform=ax.transAxes)
ax.set_xticks([]); ax.set_yticks([])
ax.set_title(lbl, fontsize=11, fontweight="bold")
# Bottom row: 4 fusion variants
bottom_axes = []
sharey = None
for i, (lbl, df) in enumerate(bottom_dfs):
ax = fig.add_subplot(gs[1, i], sharey=sharey)
sharey = sharey or ax
bottom_axes.append(ax)
ax.set_facecolor("#e8e8e8")
if len(df):
_draw_panel(ax, df, vfi, lbl)
else:
ax.text(0.5, 0.5, "(pending)", ha="center", va="center",
fontsize=12, color="#888", transform=ax.transAxes)
ax.set_xticks([]); ax.set_yticks([])
ax.set_title(lbl, fontsize=11, fontweight="bold")
top_axes[0].set_ylabel("Predicted P(Glaucoma)", fontsize=11)
bottom_axes[0].set_ylabel("Predicted P(Glaucoma)", fontsize=11)
for ax in bottom_axes[1:]:
ax.set_yticklabels([])
legend_patches = [mpatches.Patch(color=SEV_COLORS[s], label=SEV_LABELS[s])
for s in ["normal", "early", "moderate", "severe", "unknown"]]
fig.legend(handles=legend_patches, fontsize=9,
loc="lower center", ncol=len(legend_patches),
framealpha=0.75, bbox_to_anchor=(0.5, -0.01))
fig.tight_layout(rect=[0, 0.06, 1, 0.97])
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+345
View File
@@ -0,0 +1,345 @@
"""F4 — Bilateral lift via confidence strips.
Six-panel grid in the same v3-style as F3. Rows are aggregation mode,
columns are tower configuration:
Clinical Image Fusion (img+cd)
Single | cd_solo_single | img_solo_single | ensemble_single (Hadamard L1)
Bilateral| cd_solo_bilat | img_solo_bilat | baseline_ensemble (L2 concat default)
All refugelike. Single-eye panels eval at the appropriate eye-level fusion
stage; bilateral panels eval at hb. Points are coloured by VF-MD severity.
Each panel shows per-patient or per-eye P(Glaucoma) with TN / TP rates and
AUC printed in.
Re-run anytime:
python -m v4.figures.F4_bilateral
"""
from __future__ import annotations
import warnings
warnings.filterwarnings("ignore")
from pathlib import Path
import h5py
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
from v4.figures.util.loaders import REPO_ROOT, RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "F4_bilateral.png"
# Palette (matches F3) ───────────────────────────────────────────────────────
C_NORMAL = "#78909C"
C_EARLY = "#29B6F6"
C_MODERATE = "#FFB300"
C_SEVERE = "#E53935"
C_UNKNOWN = "#BDBDBD"
SEV_COLORS = {
"normal": C_NORMAL,
"early": C_EARLY,
"moderate": C_MODERATE,
"severe": C_SEVERE,
"unknown": C_UNKNOWN,
}
SEV_LABELS = {
"normal": "Normal",
"early": "Glaucoma — early (VF_MD > 6)",
"moderate": "Glaucoma — moderate (12 to 6)",
"severe": "Glaucoma — severe (VF_MD < 12)",
"unknown": "Glaucoma — VF_MD not recorded",
}
SEV_ORDER = ["normal", "unknown", "early", "moderate", "severe"]
SEV_ALPHA = {
"normal": 0.40,
"unknown": 0.35,
"early": 0.55,
"moderate": 0.70,
"severe": 0.85,
}
SEV_SIZE = {"normal": 6, "unknown": 6, "early": 8, "moderate": 10, "severe": 12}
# Panel grid: [row][col] = (label, run_dir, eval_stage)
GRID = [
[
("Single · Clinical", RESULTS_ROOT / "phase2_v4" / "cd_solo_single", "cd_fuse"),
(
"Single · Image",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refugelike",
"img_fuse",
),
(
"Single · Fusion",
RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refugelike",
"nt",
),
],
[
(
"Bilateral · Clinical",
RESULTS_ROOT / "phase4_v4" / "cd_solo_bilateral",
"hb",
),
(
"Bilateral · Image",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_bilateral_refugelike",
"hb",
),
("Bilateral · Fusion", RESULTS_ROOT / "tri_v1" / "baseline_ensemble", "hb"),
],
]
CLINICAL_DIR = REPO_ROOT / "Papila" / "ClinicalData"
# ── Same helpers as F3 ───────────────────────────────────────────────────────
def load_vfi() -> pd.DataFrame:
od = pd.read_excel(CLINICAL_DIR / "patient_data_od.xlsx", header=1)
os_ = pd.read_excel(CLINICAL_DIR / "patient_data_os.xlsx", header=1)
def _clean(df):
df = df.copy()
if "Patient ID" not in df.columns and "ID" in df.columns:
df.rename(columns={"ID": "Patient ID"}, inplace=True)
df["Patient ID"] = (
df["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
)
df["Diagnosis"] = pd.to_numeric(df["Diagnosis"], errors="coerce")
df["VF_MD"] = pd.to_numeric(df["VF_MD"], errors="coerce")
df = df[df["Diagnosis"].isin([0, 1])].copy()
return df[["Patient ID", "Diagnosis", "VF_MD"]]
both = pd.concat([_clean(od), _clean(os_)], ignore_index=True)
diag = (
both.groupby("Patient ID")["Diagnosis"]
.agg(lambda x: x.mode().iloc[0])
.reset_index()
)
vf = both.groupby("Patient ID")["VF_MD"].min().reset_index()
out = diag.merge(vf, on="Patient ID").rename(
columns={"Patient ID": "patient_id", "Diagnosis": "diagnosis", "VF_MD": "vf_md"}
)
def _sev(row):
if int(row["diagnosis"]) == 0:
return "normal"
v = row["vf_md"]
if pd.isna(v):
return "unknown"
if v > -6:
return "early"
if v > -12:
return "moderate"
return "severe"
out["severity"] = out.apply(_sev, axis=1)
return out
def collect_predictions(run_dir: Path, eval_stage: str) -> pd.DataFrame:
if not run_dir.exists():
return pd.DataFrame()
rows: list[dict] = []
for rep in sorted(run_dir.glob("rep*")):
fp = next(iter(rep.rglob("predictions.h5")), None)
if fp is None:
continue
with h5py.File(fp, "r") as f:
if eval_stage not in f:
continue
grp = f[eval_stage]
logits = grp["logits"][:]
y_true = grp["y_true"][:].astype(int)
split = grp["split"][:]
eid0 = grp["entity_id_0"][:]
n_folds, n_epochs, n_samples, n_heads, n_outputs = logits.shape
if n_outputs != 2:
continue
ep, head = n_epochs - 1, n_heads - 1
for fold in range(n_folds):
labels = np.array(
[
s.decode() if isinstance(s, bytes) else str(s)
for s in split[fold]
]
)
test_mask = labels == "test"
if not test_mask.any():
continue
lg = logits[fold, ep, test_mask, head, :]
e = np.exp(lg - lg.max(axis=1, keepdims=True))
p = e / e.sum(axis=1, keepdims=True)
for k, idx in enumerate(np.where(test_mask)[0]):
rows.append(
{
"rep": rep.name,
"fold": fold,
"patient_id": int(eid0[idx]),
"y_true": int(y_true[idx]),
"prob_glaucoma": float(p[k, 1]),
}
)
return pd.DataFrame(rows)
C_NORMAL_VIOLIN = "#4c72b0"
C_GLAUCOMA_VIOLIN = "#c44e52"
def _draw_panel(ax, df: pd.DataFrame, vfi: pd.DataFrame, label: str):
df = df.merge(vfi[["patient_id", "severity"]], on="patient_id", how="left")
df["severity"] = df["severity"].fillna("unknown")
rng = np.random.default_rng(42)
x_pos = {0: 0.0, 1: 1.0}
jitter_scale = 0.18
# Violin density behind everything (per true class)
data_by_class = [
df.loc[df["y_true"] == cls, "prob_glaucoma"].values for cls in [0, 1]
]
if all(len(d) > 0 for d in data_by_class):
vp = ax.violinplot(
data_by_class,
positions=[0, 1],
widths=0.7,
showmedians=False,
showextrema=False,
)
for body, color in zip(vp["bodies"], [C_NORMAL_VIOLIN, C_GLAUCOMA_VIOLIN]):
body.set_facecolor(color)
body.set_alpha(0.30)
body.set_edgecolor("none")
body.set_zorder(2)
for sev in SEV_ORDER:
mask = df["severity"] == sev
if not mask.any():
continue
sub = df[mask]
jitter = rng.uniform(-jitter_scale, jitter_scale, len(sub))
x = np.array([x_pos[int(v)] for v in sub["y_true"]]) + jitter
ax.scatter(
x,
sub["prob_glaucoma"].values,
c=SEV_COLORS[sev],
s=SEV_SIZE[sev],
alpha=SEV_ALPHA[sev],
linewidths=0,
zorder=3,
)
xtick_labels = []
for cls, xc in x_pos.items():
vals = df.loc[df["y_true"] == cls, "prob_glaucoma"]
if not len(vals):
xtick_labels.append("Normal" if cls == 0 else "Glaucoma")
continue
med = float(np.median(vals))
ax.plot(
[xc - jitter_scale - 0.04, xc + jitter_scale + 0.04],
[med, med],
color="#222",
lw=2.0,
zorder=5,
)
if cls == 0:
rate = (vals <= 0.5).mean() * 100
xtick_labels.append(f"Normal\nTN {rate:.0f}%")
else:
rate = (vals > 0.5).mean() * 100
xtick_labels.append(f"Glaucoma\nTP {rate:.0f}%")
ax.axhline(0.5, color="#888", lw=1.2, ls="--", alpha=0.7, zorder=2)
ax.set_xticks([0, 1])
ax.set_xticklabels(xtick_labels, fontsize=9)
ax.set_ylim(-0.04, 1.04)
ax.set_xlim(-0.55, 1.55)
ax.set_title(label, fontsize=10.5, fontweight="bold")
ax.grid(axis="y", alpha=0.3, zorder=1)
fold_aucs = []
for _, g in df.groupby(["rep", "fold"]):
if g["y_true"].nunique() < 2:
continue
try:
fold_aucs.append(roc_auc_score(g["y_true"], g["prob_glaucoma"]))
except Exception:
pass
if fold_aucs:
ax.text(
0.66,
0.0,
f"AUC = {np.mean(fold_aucs):.3f} ± {np.std(fold_aucs):.3f}",
transform=ax.transAxes,
ha="right",
va="bottom",
fontsize=8.5,
color="#333",
bbox=dict(facecolor="white", alpha=0.7, edgecolor="none", pad=2),
)
def render() -> None:
vfi = load_vfi()
fig, axes = plt.subplots(2, 3, figsize=(13, 11), sharey=True)
fig.patch.set_facecolor("#e8e8e8")
fig.suptitle("Single → Bilateral Aggregation Lift", fontsize=13, fontweight="bold")
for ri, row in enumerate(GRID):
for ci, (lbl, path, stage) in enumerate(row):
ax = axes[ri, ci]
ax.set_facecolor("#e8e8e8")
df = collect_predictions(path, stage)
if len(df):
_draw_panel(ax, df, vfi, lbl)
print(f" [{ri},{ci}] {lbl:<22s} n={len(df):>5d}")
else:
ax.text(
0.5,
0.5,
"(pending)",
ha="center",
va="center",
fontsize=12,
color="#888",
transform=ax.transAxes,
)
ax.set_xticks([])
ax.set_yticks([])
ax.set_title(lbl, fontsize=10.5, fontweight="bold")
print(f" [{ri},{ci}] {lbl:<22s} no data yet")
for ri in range(2):
axes[ri, 0].set_ylabel("Predicted P(Glaucoma)", fontsize=10.5)
legend_patches = [
mpatches.Patch(color=SEV_COLORS[s], label=SEV_LABELS[s])
for s in ["normal", "early", "moderate", "severe", "unknown"]
]
fig.legend(
handles=legend_patches,
fontsize=9,
loc="lower center",
ncol=len(legend_patches),
framealpha=0.75,
bbox_to_anchor=(0.5, -0.005),
)
fig.tight_layout(rect=[0, 0.04, 1, 0.97])
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+311
View File
@@ -0,0 +1,311 @@
"""F6 — Regression VF_MD with severity grouping.
Four sub-panels:
(a) Predicted vs Actual MD scatter (pooled across all test eyes)
(b) Three one-vs-rest ROC curves severe, moderate, low using the
regression head's continuous output as the score
(c) 3-tier confusion matrix at tuned thresholds (HAP truth boundaries,
val-tuned prediction thresholds)
(d) Per-class TP / FN / FP / TN and sens / spec / PPV / NPV
Re-run anytime predictions.h5 changes:
python -m v4.figures.F6_regression
"""
from __future__ import annotations
from pathlib import Path
import h5py
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import roc_curve, roc_auc_score
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "F6_regression.png"
RUN_DIR = RESULTS_ROOT / "reg_head" / "baseline_reg_nt50"
# Prediction-side bin boundaries
NP_THRESH = -1.097 # mean of measured-healthy MD
SEV_PRED = -9.14 # midpoint of HAP -12 and mean predicted MD for severe truth
# Actual (HAP / Mills) clinical boundaries
HAP_SEV = -12.0
HAP_MOD = -6.0
LABELS = ["severe", "moderate", "low"]
# Severity colors (consistent across figures)
C_SEVERE = "#E53935"
C_MODERATE = "#FFB300"
C_LOW = "#3B6FB5"
def _decode(arr):
return np.array(
[s.decode("utf-8") if isinstance(s, bytes) else str(s) for s in arr]
)
def _collect():
actuals, preds = [], []
for fp in sorted(RUN_DIR.rglob("predictions.h5")):
with h5py.File(fp, "r") as f:
if "hb" not in f:
continue
grp = f["hb"]
logits = grp["logits"][:]
y_true = grp["y_true"][:].astype(float)
split = grp["split"][:]
n_folds, n_epochs, _, n_heads, _ = logits.shape
ep, head, out = n_epochs - 1, n_heads - 1, 0
for fold in range(n_folds):
labels = _decode(split[fold])
m = (
(labels == "test")
& np.isfinite(y_true)
& np.isfinite(logits[fold, ep, :, head, out])
)
actuals.append(y_true[m])
preds.append(logits[fold, ep, m, head, out].astype(float))
if not actuals:
return None, None
return np.concatenate(actuals), np.concatenate(preds)
def _bin(values, sev, np_th):
bins = np.full(values.shape, 2, dtype=int)
bins[values <= np_th] = 1
bins[values <= sev] = 0
return bins
def render() -> None:
if not RUN_DIR.exists() or not any(RUN_DIR.rglob("predictions.h5")):
print(f"[F6] no predictions.h5 yet under {RUN_DIR}. Run after data lands.")
return
a, p = _collect()
if a is None:
print("[F6] no usable predictions")
return
print(f"[F6] pooled n={a.size}")
fig = plt.figure(figsize=(14, 10.5))
gs = fig.add_gridspec(
2, 2, hspace=0.40, wspace=0.30, left=0.07, right=0.96, top=0.92, bottom=0.07
)
ax_sc = fig.add_subplot(gs[0, 0])
ax_rc = fig.add_subplot(gs[0, 1])
ax_cm = fig.add_subplot(gs[1, 0])
ax_tb = fig.add_subplot(gs[1, 1])
ax_tb.axis("off")
# ── (a) scatter ──────────────────────────────────────────────────────────
ax_sc.scatter(a, p, s=10, alpha=0.4, color="#2563eb", edgecolor="none")
lo, hi = -30, 6
ax_sc.plot([lo, hi], [lo, hi], ls="--", color="#9ca3af", lw=1, label="ideal y=x")
ax_sc.axvline(HAP_SEV, ls=":", color="#dc2626", lw=0.8, alpha=0.5)
ax_sc.axvline(HAP_MOD, ls=":", color="#dc2626", lw=0.8, alpha=0.5)
ax_sc.set_xlim(lo, hi)
ax_sc.set_ylim(lo, hi)
ax_sc.set_xlabel("Actual VF_MD (dB)")
ax_sc.set_ylabel("Predicted VF_MD (dB)")
ax_sc.set_title(f"(a) Predicted vs Actual MD (n={a.size})", fontsize=11)
r = np.corrcoef(a, p)[0, 1]
mae = float(np.mean(np.abs(p - a)))
ax_sc.text(
0.04,
0.95,
f"r = {r:.3f}\nMAE = {mae:.2f} dB",
transform=ax_sc.transAxes,
ha="left",
va="top",
fontsize=10,
bbox=dict(facecolor="white", alpha=0.85, edgecolor="#d1d5db"),
)
# ── (b) three one-vs-rest ROCs ───────────────────────────────────────────
# Severe vs rest: score = -p (more negative pred → more severe)
# Low vs rest: score = +p (more positive pred → more "low" / no-problem)
# Moderate vs rest: score = -|p - midpoint of moderate range|
# (closer to midpoint → more moderate-like)
mod_midpoint = 0.5 * (HAP_SEV + HAP_MOD) # -9 dB
truth_severe = (a <= HAP_SEV).astype(int)
truth_low = (a > HAP_MOD).astype(int)
truth_moderate = ((a > HAP_SEV) & (a <= HAP_MOD)).astype(int)
series = [
("Severe (≤ 12 dB) vs rest", truth_severe, -p, C_SEVERE),
(
"Moderate (12..6) vs rest",
truth_moderate,
-np.abs(p - mod_midpoint),
C_MODERATE,
),
("Low (> 6 dB) vs rest", truth_low, p, C_LOW),
]
for label, ybin, score, color in series:
if len(np.unique(ybin)) < 2:
continue
fpr, tpr, _ = roc_curve(ybin, score)
auc = roc_auc_score(ybin, score)
ax_rc.plot(fpr, tpr, color=color, lw=1.8, label=f"{label} (AUC = {auc:.3f})")
ax_rc.plot([0, 1], [0, 1], ls="--", color="#9ca3af", lw=0.8)
ax_rc.set_xlim(0, 1)
ax_rc.set_ylim(0, 1.02)
ax_rc.set_xlabel("False positive rate")
ax_rc.set_ylabel("True positive rate")
ax_rc.set_title("(b) One-vs-rest ROC per severity tier", fontsize=11)
ax_rc.legend(loc="lower right", fontsize=9, framealpha=0.95)
ax_rc.grid(alpha=0.25, linestyle="--")
# ── (c) confusion matrix ─────────────────────────────────────────────────
t_act = _bin(a, HAP_SEV, HAP_MOD)
t_pred = _bin(p, SEV_PRED, NP_THRESH)
cm = np.zeros((3, 3), dtype=int)
for x, y in zip(t_act, t_pred):
cm[x, y] += 1
cm_pct = cm / np.maximum(cm.sum(axis=1, keepdims=True), 1)
ax_cm.imshow(cm_pct, cmap="Blues", vmin=0, vmax=1, aspect="equal")
for i in range(3):
for j in range(3):
text_color = "white" if cm_pct[i, j] > 0.55 else "black"
ax_cm.text(
j,
i,
f"{cm[i,j]}\n({cm_pct[i,j]*100:.0f}%)",
ha="center",
va="center",
fontsize=10,
color=text_color,
)
ax_cm.set_xticks(range(3))
ax_cm.set_xticklabels(LABELS, fontsize=10)
ax_cm.set_yticks(range(3))
ax_cm.set_yticklabels(LABELS, fontsize=10)
ax_cm.set_xlabel("Predicted", fontsize=10)
ax_cm.set_ylabel("Actual", fontsize=10)
ax_cm.set_title("(c) 3-tier confusion", fontsize=11)
# ── (d) per-class stats — sens / spec / PPV / NPV only ─────────────────
# We deliberately drop TP/FN/FP/TN here because in a 3-tier setting a
# "false negative" for severe could land in moderate (clinically
# different from landing in low). The confusion matrix in panel (c)
# already shows that distinction; sens/spec/PPV/NPV summarise the
# one-vs-rest performance without the blanket-count obfuscation.
ax_tb.set_title("(d) Per-class statistics", fontsize=11)
ax_tb.set_xlim(0, 10)
ax_tb.set_ylim(0, 5)
headers = ["class", "n", "sens", "spec", "PPV", "NPV"]
# Make the class column wider than the numeric columns to avoid clipping.
col_widths = np.array([2.4, 1.1, 1.4, 1.4, 1.4, 1.4])
col_widths *= 10.0 / col_widths.sum() # normalise to total width 10
col_edges = np.concatenate([[0], np.cumsum(col_widths)])
col_x = (col_edges[:-1] + col_edges[1:]) / 2 # column centers
row_y = [3.5, 2.5, 1.5, 0.5] # 1 header + 3 data rows
rows = []
for c in range(3):
ac = t_act == c
pc = t_pred == c
tp = int(np.sum(ac & pc))
fn = int(np.sum(ac & ~pc))
fp = int(np.sum(~ac & pc))
tn = int(np.sum(~ac & ~pc))
sens = tp / max(tp + fn, 1)
spec = tn / max(tn + fp, 1)
ppv = tp / max(tp + fp, 1)
npv = tn / max(tn + fn, 1)
rows.append(
[
LABELS[c],
int(ac.sum()),
f"{sens:.3f}",
f"{spec:.3f}",
f"{ppv:.3f}",
f"{npv:.3f}",
]
)
# Header band
ax_tb.add_patch(
plt.Rectangle(
(0, 3.05), 10, 0.9, facecolor="#dbeafe", edgecolor="none", zorder=1
)
)
for x, h in zip(col_x, headers):
ax_tb.text(
x,
row_y[0],
h,
ha="center",
va="center",
fontsize=11,
fontweight="bold",
color="#1e3a8a",
zorder=2,
)
# Data rows with zebra shading
row_colors = ["#f8fafc", "#eef2f6", "#f8fafc"]
severity_color = {"severe": C_SEVERE, "moderate": C_MODERATE, "low": C_LOW}
for ri, row in enumerate(rows):
ax_tb.add_patch(
plt.Rectangle(
(0, row_y[ri + 1] - 0.45),
10,
0.9,
facecolor=row_colors[ri],
edgecolor="none",
zorder=1,
)
)
for ci, val in enumerate(row):
txt_color = "#222"
weight = "normal"
if ci == 0:
txt_color = severity_color.get(val, "#222")
weight = "bold"
ax_tb.text(
col_x[ci],
row_y[ri + 1],
str(val),
ha="center",
va="center",
fontsize=11,
fontweight=weight,
color=txt_color,
zorder=2,
)
# Subtle horizontal grid lines
for y in [
row_y[0] - 0.45,
row_y[0] + 0.45,
row_y[1] - 0.45,
row_y[2] - 0.45,
row_y[3] - 0.45,
]:
ax_tb.plot([0, 10], [y, y], color="#cbd5e1", lw=0.6, zorder=1.5)
fig.suptitle(
"Regression predicting VF_MD with severity grouping",
fontsize=13,
fontweight="bold",
y=0.97,
)
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
File diff suppressed because it is too large Load Diff
+211
View File
@@ -0,0 +1,211 @@
"""F5 — Adding geometry as a third information source.
Box plot in the F2 style (black-bordered boxes, red median lines, baseline
median reference, Wilcoxon p-values). Two sections separated by a divider:
Section A Vector injection (compact 5-dim structured features)
baseline (no geom) | image+clinical ensemble, no geometry stream
unet vector | + 5-dim geometry features from UNet seg (auto)
gt vector | + 5-dim geometry features from GT contours (human)
Section B geometry network (a parallel CNN on segmentation maps)
solo | geometry network alone, no img/cd
unet fusion | tritower img+cd+geom, UNet seg (auto)
gt fusion | tritower img+cd+geom, GT contours (human)
Baseline reference for both sections = "no geometry" ensemble. The figure
shows that:
* geometry features carry signal alone (solo > chance)
* a compact vector of GT-derived features modestly helps (+0.014)
* UNet-derived features (auto) don't help meaningfully
* a full geometry network doesn't help beyond what the img backbone has
Re-run after data lands:
python -m v4.figures.S1_geometry
"""
from __future__ import annotations
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.stats import wilcoxon
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "S1_geometry.png"
# ── Style (mirrors F2) ───────────────────────────────────────────────────────
C_VAR = "#4c72b0" # blue — variant boxes
C_BASE = "#dd8452" # orange — baseline reference box
C_MEDIAN = "#c44e52" # red — median line
ALPHA = 0.82
def _wilcoxon_p(a: np.ndarray, b: np.ndarray) -> float:
diffs = a - b
if len(diffs) < 5 or np.all(diffs == 0):
return float("nan")
try:
return float(wilcoxon(diffs, alternative="two-sided").pvalue)
except Exception:
return float("nan")
def load_fold_aucs(run_dir: Path) -> np.ndarray:
"""Aggregate test AUC across all rep × fold, picking the run's eval_stage."""
import json
if not run_dir.exists():
return np.array([])
out: list[float] = []
for rep in sorted(run_dir.glob("rep*")):
s = next(iter(rep.rglob("summary.json")), None)
if s is None: continue
d = json.loads(s.read_text())
eval_stage = d.get("eval_stage", "hb")
key = f"{eval_stage}_test_auc"
for fr in d.get("fold_results", []):
v = fr.get(key)
if v is not None and np.isfinite(v):
out.append(float(v))
return np.array(out)
# ── Per-section data definitions ─────────────────────────────────────────────
# Baseline (used in both sections as reference)
BASELINE_LABEL = "baseline\n(no geometry)"
BASELINE_RUN = RESULTS_ROOT / "ensemble_fused" / "no_geom"
# Section A — Vector injection variants (image+clinical ensemble, +EPC geom)
VECTOR_VARIANTS = [
("U-Net vector", RESULTS_ROOT / "tri_v1" / "geom_vec_unet"), # currently 3 reps; 10-rep bump queued
("GT vector", RESULTS_ROOT / "ensemble_fused" / "geom_gt"),
]
# Section B — network variants (CNN over segmentation maps)
NETWORK_VARIANTS = [
("solo (geom network alone)", RESULTS_ROOT / "tri_v1" / "baseline_solo"),
("U-Net fusion", RESULTS_ROOT / "tri_v1" / "baseline_tri"),
("GT fusion", RESULTS_ROOT / "phase6_v4" / "tritower_geom_gt"),
]
def render() -> None:
base_aucs = load_fold_aucs(BASELINE_RUN)
vec_data = [(lbl, load_fold_aucs(p)) for lbl, p in VECTOR_VARIANTS]
network_data = [(lbl, load_fold_aucs(p)) for lbl, p in NETWORK_VARIANTS]
print(f"Baseline (no geometry): n={len(base_aucs):>3d} "
f"mean={base_aucs.mean():.3f}±{base_aucs.std():.3f}"
if len(base_aucs) else "Baseline: no data")
print("Vector injection variants:")
for lbl, a in vec_data:
print(f" {lbl:<28s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}"
if len(a) else f" {lbl:<28s} pending")
print("Network variants:")
for lbl, a in network_data:
print(f" {lbl:<28s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}"
if len(a) else f" {lbl:<28s} pending")
# Layout positions
box_w = 0.55
inner_gap = 0.50
section_gap = 0.95
# Section A: baseline | unet vector | gt vector
section_a_labels = [BASELINE_LABEL] + [l for l, _ in vec_data]
section_a_data = [base_aucs] + [a for _, a in vec_data]
section_a_colors = [C_BASE] + [C_VAR] * len(vec_data)
# Section B: solo | unet fusion | gt fusion
section_b_labels = [l for l, _ in network_data]
section_b_data = [a for _, a in network_data]
section_b_colors = [C_VAR] * len(network_data)
positions: list[float] = []
p = 0.0
for _ in section_a_labels:
positions.append(p); p += box_w + inner_gap
section_a_right = positions[-1] + box_w / 2
p = positions[-1] + box_w + section_gap
section_b_left = p
for _ in section_b_labels:
positions.append(p); p += box_w + inner_gap
all_labels = section_a_labels + section_b_labels
all_data = section_a_data + section_b_data
all_colors = section_a_colors + section_b_colors
fig, ax = plt.subplots(figsize=(12.5, 5.8))
fig.suptitle("Geometry Integration", fontsize=13, fontweight="bold")
boxprops_kw = dict(linewidth=1.2, edgecolor="black")
medianprops = dict(color=C_MEDIAN, linewidth=2)
whiskerprops = dict(color="black", linewidth=1.0)
capprops = dict(color="black", linewidth=1.0)
flierprops = dict(marker="o", markersize=3, alpha=0.55,
markerfacecolor="#888", markeredgecolor="#444")
for x, aucs, color in zip(positions, all_data, all_colors):
if not len(aucs):
continue
ax.boxplot(
aucs, positions=[x], widths=box_w, patch_artist=True, manage_ticks=False,
boxprops=dict(facecolor=color, alpha=ALPHA, **boxprops_kw),
medianprops=medianprops,
whiskerprops=whiskerprops,
capprops=capprops,
flierprops=flierprops,
)
# Baseline median reference line across the whole plot
if len(base_aucs):
ax.axhline(np.median(base_aucs), color=C_BASE,
linewidth=1.2, linestyle="--", alpha=0.55,
label="Baseline median (no geometry)")
# Section dividers
div_x = (section_a_right + section_b_left - box_w / 2) / 2
ax.axvline(div_x, color="#aaa", linewidth=0.7, alpha=0.6, linestyle="-")
# Section headers
sec_a_cx = (positions[0] + positions[len(section_a_labels) - 1]) / 2
sec_b_cx = (positions[len(section_a_labels)] + positions[-1]) / 2
ax.text(sec_a_cx, 1.02, "Vector injection (5-dim structured features)",
ha="center", va="bottom", fontsize=11, fontweight="bold", color="#333",
transform=ax.get_xaxis_transform())
ax.text(sec_b_cx, 1.02, "Geometry network (CNN over segmentation map)",
ha="center", va="bottom", fontsize=11, fontweight="bold", color="#333",
transform=ax.get_xaxis_transform())
# X-tick labels with Wilcoxon p-values vs baseline for non-baseline boxes
tick_lbls = []
for lbl, aucs in zip(all_labels, all_data):
if lbl == BASELINE_LABEL or not len(aucs) or not len(base_aucs):
tick_lbls.append(lbl); continue
n = min(len(aucs), len(base_aucs))
p_val = _wilcoxon_p(aucs[:n], base_aucs[:n])
ps = f"p={p_val:.3f}" if not np.isnan(p_val) else "p=n/a"
tick_lbls.append(f"{lbl}\n{ps}")
ax.set_xticks(positions)
ax.set_xticklabels(tick_lbls, fontsize=9.5)
ax.set_xlim(positions[0] - box_w, positions[-1] + box_w + 0.3)
ax.set_ylim(0.55, 1.0)
ax.set_ylabel("Test AUC", fontsize=11)
ax.grid(axis="y", alpha=0.3, linestyle="--")
ax.legend(loc="lower left", fontsize=9, framealpha=0.92)
fig.tight_layout()
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+297
View File
@@ -0,0 +1,297 @@
"""V2M_F3 — Single-mode comparison via confidence strips (refuge V2-M backbone).
V2-M counterpart to F3. Panels use the refuge_efficientnet_v2_m image-tower
backbone wherever the image stream is present. cd_solo_single is shared
(no image backbone), so the clinical floor is the same as in F3.
Panels (left right):
Clinical only (cd_solo_single backbone-independent)
Image only (refuge_v2m_baseline/img_solo_single_refuge_v2m)
Concat fusion (v2m_variants/single_bcd_concat_v2m)
Pairwise fusion (v2m_variants/single_bcd_pairwise_v2m)
Gated fusion (v2m_variants/single_bcd_gated_v2m)
Hadamard fusion (refuge_v2m_baseline/ensemble_single_refuge_v2m baseline)
Re-run anytime:
python -m v4.figures.V2M_F3_hyperfeature_ablation
"""
from __future__ import annotations
import warnings
warnings.filterwarnings("ignore")
from pathlib import Path
import h5py
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
from v4.figures.util.loaders import REPO_ROOT, RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "V2M_F3_single_mode_strips.png"
# ── Palette (matches v3 confidence_strips) ───────────────────────────────────
C_NORMAL = "#78909C"
C_EARLY = "#29B6F6"
C_MODERATE = "#FFB300"
C_SEVERE = "#E53935"
C_UNKNOWN = "#BDBDBD"
SEV_LABELS = {
"normal": "Normal",
"early": "Glaucoma — early (VF_MD > 6)",
"moderate": "Glaucoma — moderate (12 to 6)",
"severe": "Glaucoma — severe (VF_MD < 12)",
"unknown": "Glaucoma — VF_MD not recorded",
}
SEV_COLORS = {
"normal": C_NORMAL,
"early": C_EARLY,
"moderate": C_MODERATE,
"severe": C_SEVERE,
"unknown": C_UNKNOWN,
}
SEV_ORDER = ["normal", "unknown", "early", "moderate", "severe"]
SEV_ALPHA = {"normal": 0.40, "unknown": 0.35, "early": 0.55, "moderate": 0.70, "severe": 0.85}
SEV_SIZE = {"normal": 6, "unknown": 6, "early": 8, "moderate": 10, "severe": 12}
# ── Per-panel definitions: (label, results dir, eval_stage) ──────────────────
# Top row: single-modality reference runs (cd_solo is backbone-independent)
TOP_ROW = [
("Clinical only", RESULTS_ROOT / "phase2_v4" / "cd_solo_single", "cd_fuse"),
("Image only", RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refuge_v2m", "img_fuse"),
]
# Bottom row: L1 fusion bridge variants at refuge V2-M (eye-level img+cd ensembles)
BOTTOM_ROW = [
("Concat fusion", RESULTS_ROOT / "v2m_variants" / "single_bcd_concat_v2m", "nt"),
("Pairwise fusion", RESULTS_ROOT / "v2m_variants" / "single_bcd_pairwise_v2m", "nt"),
("Gated fusion", RESULTS_ROOT / "v2m_variants" / "single_bcd_gated_v2m", "nt"),
("Hadamard fusion", RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refuge_v2m", "nt"),
]
CLINICAL_DIR = REPO_ROOT / "Papila" / "ClinicalData"
# ── VFI loader (patient-level worst-eye severity, matches v3) ────────────────
def load_vfi() -> pd.DataFrame:
od = pd.read_excel(CLINICAL_DIR / "patient_data_od.xlsx", header=1)
os_= pd.read_excel(CLINICAL_DIR / "patient_data_os.xlsx", header=1)
def _clean(df):
df = df.copy()
if "Patient ID" not in df.columns and "ID" in df.columns:
df.rename(columns={"ID": "Patient ID"}, inplace=True)
df["Patient ID"] = df["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
df["Diagnosis"] = pd.to_numeric(df["Diagnosis"], errors="coerce")
df["VF_MD"] = pd.to_numeric(df["VF_MD"], errors="coerce")
# PAPILA: 0=Normal, 1=Glaucoma, 2=Suspect — keep only binary subjects
df = df[df["Diagnosis"].isin([0, 1])].copy()
return df[["Patient ID", "Diagnosis", "VF_MD"]]
both = pd.concat([_clean(od), _clean(os_)], ignore_index=True)
diag = both.groupby("Patient ID")["Diagnosis"].agg(lambda x: x.mode().iloc[0]).reset_index()
vf = both.groupby("Patient ID")["VF_MD"].min().reset_index()
out = diag.merge(vf, on="Patient ID").rename(
columns={"Patient ID": "patient_id", "Diagnosis": "diagnosis", "VF_MD": "vf_md"}
)
def _sev(row):
if int(row["diagnosis"]) == 0: return "normal"
v = row["vf_md"]
if pd.isna(v): return "unknown"
if v > -6: return "early"
if v > -12: return "moderate"
return "severe"
out["severity"] = out.apply(_sev, axis=1)
return out
# ── Prediction pooler ────────────────────────────────────────────────────────
def collect_predictions(run_dir: Path, eval_stage: str) -> pd.DataFrame:
"""Pool test rows across reps × folds. Returns DataFrame with
patient_id, y_true, prob_glaucoma, rep, fold.
Applies softmax to the 2-class logits."""
if not run_dir.exists():
return pd.DataFrame()
rows: list[dict] = []
for rep in sorted(run_dir.glob("rep*")):
fp = next(iter(rep.rglob("predictions.h5")), None)
if fp is None: continue
with h5py.File(fp, "r") as f:
if eval_stage not in f: continue
grp = f[eval_stage]
logits = grp["logits"][:]
y_true = grp["y_true"][:].astype(int)
split = grp["split"][:]
eid0 = grp["entity_id_0"][:]
n_folds, n_epochs, n_samples, n_heads, n_outputs = logits.shape
if n_outputs != 2: continue
ep, head = n_epochs - 1, n_heads - 1
for fold in range(n_folds):
labels = np.array([s.decode() if isinstance(s, bytes) else str(s) for s in split[fold]])
test_mask = (labels == "test")
if not test_mask.any(): continue
lg = logits[fold, ep, test_mask, head, :] # (n_test, 2)
# softmax
e = np.exp(lg - lg.max(axis=1, keepdims=True))
p = e / e.sum(axis=1, keepdims=True)
for k, idx in enumerate(np.where(test_mask)[0]):
rows.append({
"rep": rep.name,
"fold": fold,
"patient_id": int(eid0[idx]),
"y_true": int(y_true[idx]),
"prob_glaucoma": float(p[k, 1]),
})
return pd.DataFrame(rows)
# ── Panel render ─────────────────────────────────────────────────────────────
C_NORMAL_VIOLIN = "#4c72b0"
C_GLAUCOMA_VIOLIN = "#c44e52"
def _draw_panel(ax, df: pd.DataFrame, vfi: pd.DataFrame, label: str):
df = df.merge(vfi[["patient_id", "severity"]], on="patient_id", how="left")
df["severity"] = df["severity"].fillna("unknown")
rng = np.random.default_rng(42)
x_pos = {0: 0.0, 1: 1.0}
jitter_scale = 0.18
# Violin density behind everything (per true class)
data_by_class = [df.loc[df["y_true"] == cls, "prob_glaucoma"].values for cls in [0, 1]]
if all(len(d) > 0 for d in data_by_class):
vp = ax.violinplot(data_by_class, positions=[0, 1], widths=0.7,
showmedians=False, showextrema=False)
for body, color in zip(vp["bodies"], [C_NORMAL_VIOLIN, C_GLAUCOMA_VIOLIN]):
body.set_facecolor(color); body.set_alpha(0.30)
body.set_edgecolor("none"); body.set_zorder(2)
for sev in SEV_ORDER:
mask = df["severity"] == sev
if not mask.any(): continue
sub = df[mask]
jitter = rng.uniform(-jitter_scale, jitter_scale, len(sub))
x = np.array([x_pos[int(v)] for v in sub["y_true"]]) + jitter
ax.scatter(x, sub["prob_glaucoma"].values,
c=SEV_COLORS[sev], s=SEV_SIZE[sev],
alpha=SEV_ALPHA[sev], linewidths=0, zorder=3)
# Median lines + TN/TP rate labels per class
xtick_labels = []
for cls, xc in x_pos.items():
vals = df.loc[df["y_true"] == cls, "prob_glaucoma"]
if not len(vals):
xtick_labels.append("Normal" if cls == 0 else "Glaucoma"); continue
med = float(np.median(vals))
ax.plot([xc - jitter_scale - 0.04, xc + jitter_scale + 0.04],
[med, med], color="#222", lw=2.0, zorder=5)
if cls == 0:
rate = (vals <= 0.5).mean() * 100
xtick_labels.append(f"Normal\nTN {rate:.0f}%")
else:
rate = (vals > 0.5).mean() * 100
xtick_labels.append(f"Glaucoma\nTP {rate:.0f}%")
ax.axhline(0.5, color="#888", lw=1.2, ls="--", alpha=0.7, zorder=2)
ax.set_xticks([0, 1]); ax.set_xticklabels(xtick_labels, fontsize=10)
ax.set_ylim(-0.04, 1.04); ax.set_xlim(-0.55, 1.55)
ax.set_title(label, fontsize=11, fontweight="bold")
ax.grid(axis="y", alpha=0.3, zorder=1)
# AUC across reps×folds (per-fold AUC averaged)
fold_aucs = []
for _, g in df.groupby(["rep", "fold"]):
if g["y_true"].nunique() < 2: continue
try: fold_aucs.append(roc_auc_score(g["y_true"], g["prob_glaucoma"]))
except Exception: pass
if fold_aucs:
ax.text(0.66, 0.0,
f"AUC = {np.mean(fold_aucs):.3f} ± {np.std(fold_aucs):.3f}",
transform=ax.transAxes, ha="right", va="bottom",
fontsize=9, color="#333",
bbox=dict(facecolor="white", alpha=0.7, edgecolor="none", pad=2))
def render() -> None:
vfi = load_vfi()
top_dfs = [(lbl, collect_predictions(p, s)) for lbl, p, s in TOP_ROW]
bottom_dfs = [(lbl, collect_predictions(p, s)) for lbl, p, s in BOTTOM_ROW]
for lbl, df in top_dfs + bottom_dfs:
if len(df):
print(f" {lbl:<18s} n_rows={len(df):>5d} (pid={df['patient_id'].nunique()}, reps={df['rep'].nunique()})")
else:
print(f" {lbl:<18s} no data")
n_cols = len(BOTTOM_ROW)
fig = plt.figure(figsize=(4.4 * n_cols, 12))
fig.patch.set_facecolor("#e8e8e8")
fig.suptitle("L1 Fusion Comparison — refuge V2-M backbone", fontsize=14, fontweight="bold")
gs = fig.add_gridspec(2, n_cols, hspace=0.30, wspace=0.15)
# Top row: 2 reference panels at same width as bottom panels, centered.
# In a 4-column bottom grid, that's columns 1 and 2.
n_top = len(top_dfs)
top_offset = (n_cols - n_top) // 2 # leading empty columns
top_axes = []
for i, (lbl, df) in enumerate(top_dfs):
ax = fig.add_subplot(gs[0, top_offset + i])
top_axes.append(ax)
ax.set_facecolor("#e8e8e8")
if len(df):
_draw_panel(ax, df, vfi, lbl)
else:
ax.text(0.5, 0.5, "(pending)", ha="center", va="center",
fontsize=12, color="#888", transform=ax.transAxes)
ax.set_xticks([]); ax.set_yticks([])
ax.set_title(lbl, fontsize=11, fontweight="bold")
# Bottom row: 4 fusion variants
bottom_axes = []
sharey = None
for i, (lbl, df) in enumerate(bottom_dfs):
ax = fig.add_subplot(gs[1, i], sharey=sharey)
sharey = sharey or ax
bottom_axes.append(ax)
ax.set_facecolor("#e8e8e8")
if len(df):
_draw_panel(ax, df, vfi, lbl)
else:
ax.text(0.5, 0.5, "(pending)", ha="center", va="center",
fontsize=12, color="#888", transform=ax.transAxes)
ax.set_xticks([]); ax.set_yticks([])
ax.set_title(lbl, fontsize=11, fontweight="bold")
top_axes[0].set_ylabel("Predicted P(Glaucoma)", fontsize=11)
bottom_axes[0].set_ylabel("Predicted P(Glaucoma)", fontsize=11)
for ax in bottom_axes[1:]:
ax.set_yticklabels([])
legend_patches = [mpatches.Patch(color=SEV_COLORS[s], label=SEV_LABELS[s])
for s in ["normal", "early", "moderate", "severe", "unknown"]]
fig.legend(handles=legend_patches, fontsize=9,
loc="lower center", ncol=len(legend_patches),
framealpha=0.75, bbox_to_anchor=(0.5, -0.01))
fig.tight_layout(rect=[0, 0.06, 1, 0.97])
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+343
View File
@@ -0,0 +1,343 @@
"""V2M_F4 — Bilateral lift via confidence strips (refuge V2-M backbone).
V2-M counterpart to F4. Same 2x3 grid; image and fusion cells use refuge
V2-M runs. Clinical-only cells are backbone-independent so use the existing
phase2_v4/cd_solo_single and phase4_v4/cd_solo_bilateral runs.
Re-run anytime:
python -m v4.figures.V2M_F4_bilateral
"""
from __future__ import annotations
import warnings
warnings.filterwarnings("ignore")
from pathlib import Path
import h5py
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
from v4.figures.util.loaders import REPO_ROOT, RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "V2M_F4_bilateral.png"
# Palette (matches F3) ───────────────────────────────────────────────────────
C_NORMAL = "#78909C"
C_EARLY = "#29B6F6"
C_MODERATE = "#FFB300"
C_SEVERE = "#E53935"
C_UNKNOWN = "#BDBDBD"
SEV_COLORS = {
"normal": C_NORMAL,
"early": C_EARLY,
"moderate": C_MODERATE,
"severe": C_SEVERE,
"unknown": C_UNKNOWN,
}
SEV_LABELS = {
"normal": "Normal",
"early": "Glaucoma — early (VF_MD > 6)",
"moderate": "Glaucoma — moderate (12 to 6)",
"severe": "Glaucoma — severe (VF_MD < 12)",
"unknown": "Glaucoma — VF_MD not recorded",
}
SEV_ORDER = ["normal", "unknown", "early", "moderate", "severe"]
SEV_ALPHA = {
"normal": 0.40,
"unknown": 0.35,
"early": 0.55,
"moderate": 0.70,
"severe": 0.85,
}
SEV_SIZE = {"normal": 6, "unknown": 6, "early": 8, "moderate": 10, "severe": 12}
# Panel grid: [row][col] = (label, run_dir, eval_stage)
# All image / fusion cells use refuge V2-M backbone.
GRID = [
[
("Single · Clinical", RESULTS_ROOT / "phase2_v4" / "cd_solo_single", "cd_fuse"),
(
"Single · Image",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refuge_v2m",
"img_fuse",
),
(
"Single · Fusion",
RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refuge_v2m",
"nt",
),
],
[
(
"Bilateral · Clinical",
RESULTS_ROOT / "phase4_v4" / "cd_solo_bilateral",
"hb",
),
(
"Bilateral · Image",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo",
"hb",
),
(
"Bilateral · Fusion",
RESULTS_ROOT / "efficientnet" / "refuge_efficientnetv2_m",
"hb",
),
],
]
CLINICAL_DIR = REPO_ROOT / "Papila" / "ClinicalData"
# ── Same helpers as F3 ───────────────────────────────────────────────────────
def load_vfi() -> pd.DataFrame:
od = pd.read_excel(CLINICAL_DIR / "patient_data_od.xlsx", header=1)
os_ = pd.read_excel(CLINICAL_DIR / "patient_data_os.xlsx", header=1)
def _clean(df):
df = df.copy()
if "Patient ID" not in df.columns and "ID" in df.columns:
df.rename(columns={"ID": "Patient ID"}, inplace=True)
df["Patient ID"] = (
df["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
)
df["Diagnosis"] = pd.to_numeric(df["Diagnosis"], errors="coerce")
df["VF_MD"] = pd.to_numeric(df["VF_MD"], errors="coerce")
df = df[df["Diagnosis"].isin([0, 1])].copy()
return df[["Patient ID", "Diagnosis", "VF_MD"]]
both = pd.concat([_clean(od), _clean(os_)], ignore_index=True)
diag = (
both.groupby("Patient ID")["Diagnosis"]
.agg(lambda x: x.mode().iloc[0])
.reset_index()
)
vf = both.groupby("Patient ID")["VF_MD"].min().reset_index()
out = diag.merge(vf, on="Patient ID").rename(
columns={"Patient ID": "patient_id", "Diagnosis": "diagnosis", "VF_MD": "vf_md"}
)
def _sev(row):
if int(row["diagnosis"]) == 0:
return "normal"
v = row["vf_md"]
if pd.isna(v):
return "unknown"
if v > -6:
return "early"
if v > -12:
return "moderate"
return "severe"
out["severity"] = out.apply(_sev, axis=1)
return out
def collect_predictions(run_dir: Path, eval_stage: str) -> pd.DataFrame:
if not run_dir.exists():
return pd.DataFrame()
rows: list[dict] = []
for rep in sorted(run_dir.glob("rep*")):
fp = next(iter(rep.rglob("predictions.h5")), None)
if fp is None:
continue
with h5py.File(fp, "r") as f:
if eval_stage not in f:
continue
grp = f[eval_stage]
logits = grp["logits"][:]
y_true = grp["y_true"][:].astype(int)
split = grp["split"][:]
eid0 = grp["entity_id_0"][:]
n_folds, n_epochs, n_samples, n_heads, n_outputs = logits.shape
if n_outputs != 2:
continue
ep, head = n_epochs - 1, n_heads - 1
for fold in range(n_folds):
labels = np.array(
[
s.decode() if isinstance(s, bytes) else str(s)
for s in split[fold]
]
)
test_mask = labels == "test"
if not test_mask.any():
continue
lg = logits[fold, ep, test_mask, head, :]
e = np.exp(lg - lg.max(axis=1, keepdims=True))
p = e / e.sum(axis=1, keepdims=True)
for k, idx in enumerate(np.where(test_mask)[0]):
rows.append(
{
"rep": rep.name,
"fold": fold,
"patient_id": int(eid0[idx]),
"y_true": int(y_true[idx]),
"prob_glaucoma": float(p[k, 1]),
}
)
return pd.DataFrame(rows)
C_NORMAL_VIOLIN = "#4c72b0"
C_GLAUCOMA_VIOLIN = "#c44e52"
def _draw_panel(ax, df: pd.DataFrame, vfi: pd.DataFrame, label: str):
df = df.merge(vfi[["patient_id", "severity"]], on="patient_id", how="left")
df["severity"] = df["severity"].fillna("unknown")
rng = np.random.default_rng(42)
x_pos = {0: 0.0, 1: 1.0}
jitter_scale = 0.18
# Violin density behind everything (per true class)
data_by_class = [
df.loc[df["y_true"] == cls, "prob_glaucoma"].values for cls in [0, 1]
]
if all(len(d) > 0 for d in data_by_class):
vp = ax.violinplot(
data_by_class,
positions=[0, 1],
widths=0.7,
showmedians=False,
showextrema=False,
)
for body, color in zip(vp["bodies"], [C_NORMAL_VIOLIN, C_GLAUCOMA_VIOLIN]):
body.set_facecolor(color)
body.set_alpha(0.30)
body.set_edgecolor("none")
body.set_zorder(2)
for sev in SEV_ORDER:
mask = df["severity"] == sev
if not mask.any():
continue
sub = df[mask]
jitter = rng.uniform(-jitter_scale, jitter_scale, len(sub))
x = np.array([x_pos[int(v)] for v in sub["y_true"]]) + jitter
ax.scatter(
x,
sub["prob_glaucoma"].values,
c=SEV_COLORS[sev],
s=SEV_SIZE[sev],
alpha=SEV_ALPHA[sev],
linewidths=0,
zorder=3,
)
xtick_labels = []
for cls, xc in x_pos.items():
vals = df.loc[df["y_true"] == cls, "prob_glaucoma"]
if not len(vals):
xtick_labels.append("Normal" if cls == 0 else "Glaucoma")
continue
med = float(np.median(vals))
ax.plot(
[xc - jitter_scale - 0.04, xc + jitter_scale + 0.04],
[med, med],
color="#222",
lw=2.0,
zorder=5,
)
if cls == 0:
rate = (vals <= 0.5).mean() * 100
xtick_labels.append(f"Normal\nTN {rate:.0f}%")
else:
rate = (vals > 0.5).mean() * 100
xtick_labels.append(f"Glaucoma\nTP {rate:.0f}%")
ax.axhline(0.5, color="#888", lw=1.2, ls="--", alpha=0.7, zorder=2)
ax.set_xticks([0, 1])
ax.set_xticklabels(xtick_labels, fontsize=9)
ax.set_ylim(-0.04, 1.04)
ax.set_xlim(-0.55, 1.55)
ax.set_title(label, fontsize=10.5, fontweight="bold")
ax.grid(axis="y", alpha=0.3, zorder=1)
fold_aucs = []
for _, g in df.groupby(["rep", "fold"]):
if g["y_true"].nunique() < 2:
continue
try:
fold_aucs.append(roc_auc_score(g["y_true"], g["prob_glaucoma"]))
except Exception:
pass
if fold_aucs:
ax.text(
0.66,
0.0,
f"AUC = {np.mean(fold_aucs):.3f} ± {np.std(fold_aucs):.3f}",
transform=ax.transAxes,
ha="right",
va="bottom",
fontsize=8.5,
color="#333",
bbox=dict(facecolor="white", alpha=0.7, edgecolor="none", pad=2),
)
def render() -> None:
vfi = load_vfi()
fig, axes = plt.subplots(2, 3, figsize=(13, 11), sharey=True)
fig.patch.set_facecolor("#e8e8e8")
fig.suptitle("Single → Bilateral Aggregation Lift — refuge V2-M backbone",
fontsize=13, fontweight="bold")
for ri, row in enumerate(GRID):
for ci, (lbl, path, stage) in enumerate(row):
ax = axes[ri, ci]
ax.set_facecolor("#e8e8e8")
df = collect_predictions(path, stage)
if len(df):
_draw_panel(ax, df, vfi, lbl)
print(f" [{ri},{ci}] {lbl:<22s} n={len(df):>5d}")
else:
ax.text(
0.5,
0.5,
"(pending)",
ha="center",
va="center",
fontsize=12,
color="#888",
transform=ax.transAxes,
)
ax.set_xticks([])
ax.set_yticks([])
ax.set_title(lbl, fontsize=10.5, fontweight="bold")
print(f" [{ri},{ci}] {lbl:<22s} no data yet")
for ri in range(2):
axes[ri, 0].set_ylabel("Predicted P(Glaucoma)", fontsize=10.5)
legend_patches = [
mpatches.Patch(color=SEV_COLORS[s], label=SEV_LABELS[s])
for s in ["normal", "early", "moderate", "severe", "unknown"]
]
fig.legend(
handles=legend_patches,
fontsize=9,
loc="lower center",
ncol=len(legend_patches),
framealpha=0.75,
bbox_to_anchor=(0.5, -0.005),
)
fig.tight_layout(rect=[0, 0.04, 1, 0.97])
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+307
View File
@@ -0,0 +1,307 @@
"""V2M_F6 — Regression VF_MD with severity grouping (refuge V2-M backbone).
V2-M counterpart to F6. Reads predictions from the refuge V2-M variant of
baseline_reg_nt50; otherwise identical layout to F6 so panels can be
compared side-by-side.
Re-run anytime predictions.h5 changes:
python -m v4.figures.V2M_F6_regression
"""
from __future__ import annotations
from pathlib import Path
import h5py
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from sklearn.metrics import roc_curve, roc_auc_score
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "V2M_F6_regression.png"
RUN_DIR = RESULTS_ROOT / "v2m_variants" / "baseline_reg_nt50_v2m"
# Prediction-side bin boundaries
NP_THRESH = -1.097 # mean of measured-healthy MD
SEV_PRED = -9.14 # midpoint of HAP -12 and mean predicted MD for severe truth
# Actual (HAP / Mills) clinical boundaries
HAP_SEV = -12.0
HAP_MOD = -6.0
LABELS = ["severe", "moderate", "low"]
# Severity colors (consistent across figures)
C_SEVERE = "#E53935"
C_MODERATE = "#FFB300"
C_LOW = "#3B6FB5"
def _decode(arr):
return np.array(
[s.decode("utf-8") if isinstance(s, bytes) else str(s) for s in arr]
)
def _collect():
actuals, preds = [], []
for fp in sorted(RUN_DIR.rglob("predictions.h5")):
with h5py.File(fp, "r") as f:
if "hb" not in f:
continue
grp = f["hb"]
logits = grp["logits"][:]
y_true = grp["y_true"][:].astype(float)
split = grp["split"][:]
n_folds, n_epochs, _, n_heads, _ = logits.shape
ep, head, out = n_epochs - 1, n_heads - 1, 0
for fold in range(n_folds):
labels = _decode(split[fold])
m = (
(labels == "test")
& np.isfinite(y_true)
& np.isfinite(logits[fold, ep, :, head, out])
)
actuals.append(y_true[m])
preds.append(logits[fold, ep, m, head, out].astype(float))
if not actuals:
return None, None
return np.concatenate(actuals), np.concatenate(preds)
def _bin(values, sev, np_th):
bins = np.full(values.shape, 2, dtype=int)
bins[values <= np_th] = 1
bins[values <= sev] = 0
return bins
def render() -> None:
if not RUN_DIR.exists() or not any(RUN_DIR.rglob("predictions.h5")):
print(f"[F6] no predictions.h5 yet under {RUN_DIR}. Run after data lands.")
return
a, p = _collect()
if a is None:
print("[F6] no usable predictions")
return
print(f"[F6] pooled n={a.size}")
fig = plt.figure(figsize=(14, 10.5))
gs = fig.add_gridspec(
2, 2, hspace=0.40, wspace=0.30, left=0.07, right=0.96, top=0.92, bottom=0.07
)
ax_sc = fig.add_subplot(gs[0, 0])
ax_rc = fig.add_subplot(gs[0, 1])
ax_cm = fig.add_subplot(gs[1, 0])
ax_tb = fig.add_subplot(gs[1, 1])
ax_tb.axis("off")
# ── (a) scatter ──────────────────────────────────────────────────────────
ax_sc.scatter(a, p, s=10, alpha=0.4, color="#2563eb", edgecolor="none")
lo, hi = -30, 6
ax_sc.plot([lo, hi], [lo, hi], ls="--", color="#9ca3af", lw=1, label="ideal y=x")
ax_sc.axvline(HAP_SEV, ls=":", color="#dc2626", lw=0.8, alpha=0.5)
ax_sc.axvline(HAP_MOD, ls=":", color="#dc2626", lw=0.8, alpha=0.5)
ax_sc.set_xlim(lo, hi)
ax_sc.set_ylim(lo, hi)
ax_sc.set_xlabel("Actual VF_MD (dB)")
ax_sc.set_ylabel("Predicted VF_MD (dB)")
ax_sc.set_title(f"(a) Predicted vs Actual MD (n={a.size})", fontsize=11)
r = np.corrcoef(a, p)[0, 1]
mae = float(np.mean(np.abs(p - a)))
ax_sc.text(
0.04,
0.95,
f"r = {r:.3f}\nMAE = {mae:.2f} dB",
transform=ax_sc.transAxes,
ha="left",
va="top",
fontsize=10,
bbox=dict(facecolor="white", alpha=0.85, edgecolor="#d1d5db"),
)
# ── (b) three one-vs-rest ROCs ───────────────────────────────────────────
# Severe vs rest: score = -p (more negative pred → more severe)
# Low vs rest: score = +p (more positive pred → more "low" / no-problem)
# Moderate vs rest: score = -|p - midpoint of moderate range|
# (closer to midpoint → more moderate-like)
mod_midpoint = 0.5 * (HAP_SEV + HAP_MOD) # -9 dB
truth_severe = (a <= HAP_SEV).astype(int)
truth_low = (a > HAP_MOD).astype(int)
truth_moderate = ((a > HAP_SEV) & (a <= HAP_MOD)).astype(int)
series = [
("Severe (≤ 12 dB) vs rest", truth_severe, -p, C_SEVERE),
(
"Moderate (12..6) vs rest",
truth_moderate,
-np.abs(p - mod_midpoint),
C_MODERATE,
),
("Low (> 6 dB) vs rest", truth_low, p, C_LOW),
]
for label, ybin, score, color in series:
if len(np.unique(ybin)) < 2:
continue
fpr, tpr, _ = roc_curve(ybin, score)
auc = roc_auc_score(ybin, score)
ax_rc.plot(fpr, tpr, color=color, lw=1.8, label=f"{label} (AUC = {auc:.3f})")
ax_rc.plot([0, 1], [0, 1], ls="--", color="#9ca3af", lw=0.8)
ax_rc.set_xlim(0, 1)
ax_rc.set_ylim(0, 1.02)
ax_rc.set_xlabel("False positive rate")
ax_rc.set_ylabel("True positive rate")
ax_rc.set_title("(b) One-vs-rest ROC per severity tier", fontsize=11)
ax_rc.legend(loc="lower right", fontsize=9, framealpha=0.95)
ax_rc.grid(alpha=0.25, linestyle="--")
# ── (c) confusion matrix ─────────────────────────────────────────────────
t_act = _bin(a, HAP_SEV, HAP_MOD)
t_pred = _bin(p, SEV_PRED, NP_THRESH)
cm = np.zeros((3, 3), dtype=int)
for x, y in zip(t_act, t_pred):
cm[x, y] += 1
cm_pct = cm / np.maximum(cm.sum(axis=1, keepdims=True), 1)
ax_cm.imshow(cm_pct, cmap="Blues", vmin=0, vmax=1, aspect="equal")
for i in range(3):
for j in range(3):
text_color = "white" if cm_pct[i, j] > 0.55 else "black"
ax_cm.text(
j,
i,
f"{cm[i,j]}\n({cm_pct[i,j]*100:.0f}%)",
ha="center",
va="center",
fontsize=10,
color=text_color,
)
ax_cm.set_xticks(range(3))
ax_cm.set_xticklabels(LABELS, fontsize=10)
ax_cm.set_yticks(range(3))
ax_cm.set_yticklabels(LABELS, fontsize=10)
ax_cm.set_xlabel("Predicted", fontsize=10)
ax_cm.set_ylabel("Actual", fontsize=10)
ax_cm.set_title("(c) 3-tier confusion", fontsize=11)
# ── (d) per-class stats — sens / spec / PPV / NPV only ─────────────────
# We deliberately drop TP/FN/FP/TN here because in a 3-tier setting a
# "false negative" for severe could land in moderate (clinically
# different from landing in low). The confusion matrix in panel (c)
# already shows that distinction; sens/spec/PPV/NPV summarise the
# one-vs-rest performance without the blanket-count obfuscation.
ax_tb.set_title("(d) Per-class statistics", fontsize=11)
ax_tb.set_xlim(0, 10)
ax_tb.set_ylim(0, 5)
headers = ["class", "n", "sens", "spec", "PPV", "NPV"]
# Make the class column wider than the numeric columns to avoid clipping.
col_widths = np.array([2.4, 1.1, 1.4, 1.4, 1.4, 1.4])
col_widths *= 10.0 / col_widths.sum() # normalise to total width 10
col_edges = np.concatenate([[0], np.cumsum(col_widths)])
col_x = (col_edges[:-1] + col_edges[1:]) / 2 # column centers
row_y = [3.5, 2.5, 1.5, 0.5] # 1 header + 3 data rows
rows = []
for c in range(3):
ac = t_act == c
pc = t_pred == c
tp = int(np.sum(ac & pc))
fn = int(np.sum(ac & ~pc))
fp = int(np.sum(~ac & pc))
tn = int(np.sum(~ac & ~pc))
sens = tp / max(tp + fn, 1)
spec = tn / max(tn + fp, 1)
ppv = tp / max(tp + fp, 1)
npv = tn / max(tn + fn, 1)
rows.append(
[
LABELS[c],
int(ac.sum()),
f"{sens:.3f}",
f"{spec:.3f}",
f"{ppv:.3f}",
f"{npv:.3f}",
]
)
# Header band
ax_tb.add_patch(
plt.Rectangle(
(0, 3.05), 10, 0.9, facecolor="#dbeafe", edgecolor="none", zorder=1
)
)
for x, h in zip(col_x, headers):
ax_tb.text(
x,
row_y[0],
h,
ha="center",
va="center",
fontsize=11,
fontweight="bold",
color="#1e3a8a",
zorder=2,
)
# Data rows with zebra shading
row_colors = ["#f8fafc", "#eef2f6", "#f8fafc"]
severity_color = {"severe": C_SEVERE, "moderate": C_MODERATE, "low": C_LOW}
for ri, row in enumerate(rows):
ax_tb.add_patch(
plt.Rectangle(
(0, row_y[ri + 1] - 0.45),
10,
0.9,
facecolor=row_colors[ri],
edgecolor="none",
zorder=1,
)
)
for ci, val in enumerate(row):
txt_color = "#222"
weight = "normal"
if ci == 0:
txt_color = severity_color.get(val, "#222")
weight = "bold"
ax_tb.text(
col_x[ci],
row_y[ri + 1],
str(val),
ha="center",
va="center",
fontsize=11,
fontweight=weight,
color=txt_color,
zorder=2,
)
# Subtle horizontal grid lines
for y in [
row_y[0] - 0.45,
row_y[0] + 0.45,
row_y[1] - 0.45,
row_y[2] - 0.45,
row_y[3] - 0.45,
]:
ax_tb.plot([0, 10], [y, y], color="#cbd5e1", lw=0.6, zorder=1.5)
fig.suptitle(
"Regression predicting VF_MD with severity grouping — refuge V2-M backbone",
fontsize=13,
fontweight="bold",
y=0.97,
)
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+198
View File
@@ -0,0 +1,198 @@
"""V2M_S1 — Adding geometry as a third information source (refuge V2-M backbone).
V2-M counterpart to S1. Section structure unchanged; runs swapped for V2-M
variants where the image stream is present. "solo" still uses the existing
geometry-only run since that path doesn't use the image backbone.
Re-run after data lands:
python -m v4.figures.V2M_S1_geometry
"""
from __future__ import annotations
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from scipy.stats import wilcoxon
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "V2M_S1_geometry.png"
# ── Style (mirrors F2) ───────────────────────────────────────────────────────
C_VAR = "#4c72b0" # blue — variant boxes
C_BASE = "#dd8452" # orange — baseline reference box
C_MEDIAN = "#c44e52" # red — median line
ALPHA = 0.82
def _wilcoxon_p(a: np.ndarray, b: np.ndarray) -> float:
diffs = a - b
if len(diffs) < 5 or np.all(diffs == 0):
return float("nan")
try:
return float(wilcoxon(diffs, alternative="two-sided").pvalue)
except Exception:
return float("nan")
def load_fold_aucs(run_dir: Path) -> np.ndarray:
"""Aggregate test AUC across all rep × fold, picking the run's eval_stage."""
import json
if not run_dir.exists():
return np.array([])
out: list[float] = []
for rep in sorted(run_dir.glob("rep*")):
s = next(iter(rep.rglob("summary.json")), None)
if s is None: continue
d = json.loads(s.read_text())
eval_stage = d.get("eval_stage", "hb")
key = f"{eval_stage}_test_auc"
for fr in d.get("fold_results", []):
v = fr.get(key)
if v is not None and np.isfinite(v):
out.append(float(v))
return np.array(out)
# ── Per-section data definitions ─────────────────────────────────────────────
# Baseline (used in both sections as reference) — refuge V2-M ensemble, no geometry
BASELINE_LABEL = "baseline\n(no geometry)"
BASELINE_RUN = RESULTS_ROOT / "efficientnet" / "refuge_efficientnetv2_m"
# Section A — Vector injection variants at refuge V2-M
VECTOR_VARIANTS = [
("U-Net vector", RESULTS_ROOT / "v2m_variants" / "ensemble_geom_vec_unet_v2m"),
("GT vector", RESULTS_ROOT / "v2m_variants" / "ensemble_geom_vec_gt_v2m"),
]
# Section B — network variants (CNN over segmentation maps) at refuge V2-M
NETWORK_VARIANTS = [
# Geometry-only network does not use the image backbone, so refugelike data
# is the same as V2-M would be.
("solo (geom network alone)", RESULTS_ROOT / "tri_v1" / "baseline_solo"),
("U-Net fusion", RESULTS_ROOT / "refuge_v2m_baseline" / "tritower"),
("GT fusion", RESULTS_ROOT / "v2m_variants" / "tritower_geom_gt_v2m"),
]
def render() -> None:
base_aucs = load_fold_aucs(BASELINE_RUN)
vec_data = [(lbl, load_fold_aucs(p)) for lbl, p in VECTOR_VARIANTS]
network_data = [(lbl, load_fold_aucs(p)) for lbl, p in NETWORK_VARIANTS]
print(f"Baseline (no geometry): n={len(base_aucs):>3d} "
f"mean={base_aucs.mean():.3f}±{base_aucs.std():.3f}"
if len(base_aucs) else "Baseline: no data")
print("Vector injection variants:")
for lbl, a in vec_data:
print(f" {lbl:<28s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}"
if len(a) else f" {lbl:<28s} pending")
print("Network variants:")
for lbl, a in network_data:
print(f" {lbl:<28s} n={len(a):>3d} mean={a.mean():.3f}±{a.std():.3f}"
if len(a) else f" {lbl:<28s} pending")
# Layout positions
box_w = 0.55
inner_gap = 0.50
section_gap = 0.95
# Section A: baseline | unet vector | gt vector
section_a_labels = [BASELINE_LABEL] + [l for l, _ in vec_data]
section_a_data = [base_aucs] + [a for _, a in vec_data]
section_a_colors = [C_BASE] + [C_VAR] * len(vec_data)
# Section B: solo | unet fusion | gt fusion
section_b_labels = [l for l, _ in network_data]
section_b_data = [a for _, a in network_data]
section_b_colors = [C_VAR] * len(network_data)
positions: list[float] = []
p = 0.0
for _ in section_a_labels:
positions.append(p); p += box_w + inner_gap
section_a_right = positions[-1] + box_w / 2
p = positions[-1] + box_w + section_gap
section_b_left = p
for _ in section_b_labels:
positions.append(p); p += box_w + inner_gap
all_labels = section_a_labels + section_b_labels
all_data = section_a_data + section_b_data
all_colors = section_a_colors + section_b_colors
fig, ax = plt.subplots(figsize=(12.5, 5.8))
fig.suptitle("Geometry Integration — refuge V2-M backbone",
fontsize=13, fontweight="bold")
boxprops_kw = dict(linewidth=1.2, edgecolor="black")
medianprops = dict(color=C_MEDIAN, linewidth=2)
whiskerprops = dict(color="black", linewidth=1.0)
capprops = dict(color="black", linewidth=1.0)
flierprops = dict(marker="o", markersize=3, alpha=0.55,
markerfacecolor="#888", markeredgecolor="#444")
for x, aucs, color in zip(positions, all_data, all_colors):
if not len(aucs):
continue
ax.boxplot(
aucs, positions=[x], widths=box_w, patch_artist=True, manage_ticks=False,
boxprops=dict(facecolor=color, alpha=ALPHA, **boxprops_kw),
medianprops=medianprops,
whiskerprops=whiskerprops,
capprops=capprops,
flierprops=flierprops,
)
# Baseline median reference line across the whole plot
if len(base_aucs):
ax.axhline(np.median(base_aucs), color=C_BASE,
linewidth=1.2, linestyle="--", alpha=0.55,
label="Baseline median (no geometry)")
# Section dividers
div_x = (section_a_right + section_b_left - box_w / 2) / 2
ax.axvline(div_x, color="#aaa", linewidth=0.7, alpha=0.6, linestyle="-")
# Section headers
sec_a_cx = (positions[0] + positions[len(section_a_labels) - 1]) / 2
sec_b_cx = (positions[len(section_a_labels)] + positions[-1]) / 2
ax.text(sec_a_cx, 1.02, "Vector injection (5-dim structured features)",
ha="center", va="bottom", fontsize=11, fontweight="bold", color="#333",
transform=ax.get_xaxis_transform())
ax.text(sec_b_cx, 1.02, "Geometry network (CNN over segmentation map)",
ha="center", va="bottom", fontsize=11, fontweight="bold", color="#333",
transform=ax.get_xaxis_transform())
# X-tick labels with Wilcoxon p-values vs baseline for non-baseline boxes
tick_lbls = []
for lbl, aucs in zip(all_labels, all_data):
if lbl == BASELINE_LABEL or not len(aucs) or not len(base_aucs):
tick_lbls.append(lbl); continue
n = min(len(aucs), len(base_aucs))
p_val = _wilcoxon_p(aucs[:n], base_aucs[:n])
ps = f"p={p_val:.3f}" if not np.isnan(p_val) else "p=n/a"
tick_lbls.append(f"{lbl}\n{ps}")
ax.set_xticks(positions)
ax.set_xticklabels(tick_lbls, fontsize=9.5)
ax.set_xlim(positions[0] - box_w, positions[-1] + box_w + 0.3)
ax.set_ylim(0.55, 1.0)
ax.set_ylabel("Test AUC", fontsize=11)
ax.grid(axis="y", alpha=0.3, linestyle="--")
ax.legend(loc="lower left", fontsize=9, framealpha=0.92)
fig.tight_layout()
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
+177
View File
@@ -0,0 +1,177 @@
"""F7 — Backbone upgrade: refugelike → refuge V2-M.
Four architecture configurations ordered from least to most complex, each
shown as a paired box plot (refugelike vs refuge_efficientnet_v2_m). Same
F2-style: black-bordered boxes, red median lines, baseline median dashed
reference. Within each group a Wilcoxon p-value compares V2-M to refugelike.
Configs (left right, increasing architectural complexity):
1. Single-eye img+cd ensemble (no bilateral aggregation)
2. Bilateral img only (bilateral hb, single tower)
3. Bilateral img+cd ensemble (production architecture)
4. Bilateral tritower (img+cd+geom)
Re-run anytime:
python -m v4.figures.X1_v2m_punch
"""
from __future__ import annotations
import json
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
from scipy.stats import wilcoxon
from v4.figures.util.loaders import RESULTS_ROOT
OUT = Path(__file__).parent / "output" / "X1_v2m_backbone.png"
# ── Style ───────────────────────────────────────────────────────────────────
C_REFUGELIKE = "#dd8452" # orange — the older fundus-pretrained baseline
C_REFUGE_V2M = "#4c72b0" # blue — the upgraded fundus-pretrained backbone
C_MEDIAN = "#c44e52" # red — median line
ALPHA = 0.82
# (config_label, refugelike_run_path, refuge_v2m_run_path)
CONFIGS = [
(
"Single\nimg only",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refugelike",
RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refuge_v2m",
),
(
"Single ensemble\n(img+cd)",
RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refugelike",
RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refuge_v2m",
),
(
"Bilateral ensemble\n(img+cd)",
RESULTS_ROOT / "tri_v1" / "baseline_ensemble",
RESULTS_ROOT / "efficientnet" / "refuge_efficientnetv2_m",
),
(
"3-way fusion\n(img+cd+geom)",
RESULTS_ROOT / "tri_v1" / "baseline_tri",
RESULTS_ROOT / "refuge_v2m_baseline" / "tritower",
),
]
def _wilcoxon_p(a: np.ndarray, b: np.ndarray) -> float:
diffs = a - b
if len(diffs) < 5 or np.all(diffs == 0):
return float("nan")
try:
return float(wilcoxon(diffs, alternative="two-sided").pvalue)
except Exception:
return float("nan")
def load_fold_aucs(run_dir: Path) -> np.ndarray:
if not run_dir.exists():
return np.array([])
out: list[float] = []
for rep in sorted(run_dir.glob("rep*")):
s = next(iter(rep.rglob("summary.json")), None)
if s is None: continue
d = json.loads(s.read_text())
eval_stage = d.get("eval_stage", "hb")
key = f"{eval_stage}_test_auc"
for fr in d.get("fold_results", []):
v = fr.get(key)
if v is not None and np.isfinite(v):
out.append(float(v))
return np.array(out)
def render() -> None:
data = []
for label, refg_path, v2m_path in CONFIGS:
refg = load_fold_aucs(refg_path)
v2m = load_fold_aucs(v2m_path)
data.append((label, refg, v2m))
print(f" {label.replace(chr(10), ' '):<32s} refg n={len(refg):>3d} {refg.mean():.3f}±{refg.std():.3f} "
f"v2m n={len(v2m):>3d} {v2m.mean():.3f}±{v2m.std():.3f}"
if (len(refg) and len(v2m)) else f" {label} pending")
# Layout: 4 groups of 2 boxes
box_w = 0.46
pair_gap = 0.10
group_gap = 0.85
group_width = 2 * box_w + pair_gap
positions: list[tuple[float, float]] = []
p = 0.0
for _ in CONFIGS:
positions.append((p, p + box_w + pair_gap))
p += group_width + group_gap
fig, ax = plt.subplots(figsize=(12.5, 5.8))
fig.suptitle("Backbone Upgrade — refugelike → refuge V2-M",
fontsize=13, fontweight="bold")
boxprops_kw = dict(linewidth=1.2, edgecolor="black")
medianprops = dict(color=C_MEDIAN, linewidth=2)
whiskerprops = dict(color="black", linewidth=1.0)
capprops = dict(color="black", linewidth=1.0)
flierprops = dict(marker="o", markersize=3, alpha=0.55,
markerfacecolor="#888", markeredgecolor="#444")
for (label, refg, v2m), (xr, xv) in zip(data, positions):
if len(refg):
ax.boxplot(refg, positions=[xr], widths=box_w, patch_artist=True,
manage_ticks=False,
boxprops=dict(facecolor=C_REFUGELIKE, alpha=ALPHA, **boxprops_kw),
medianprops=medianprops, whiskerprops=whiskerprops,
capprops=capprops, flierprops=flierprops)
if len(v2m):
ax.boxplot(v2m, positions=[xv], widths=box_w, patch_artist=True,
manage_ticks=False,
boxprops=dict(facecolor=C_REFUGE_V2M, alpha=ALPHA, **boxprops_kw),
medianprops=medianprops, whiskerprops=whiskerprops,
capprops=capprops, flierprops=flierprops)
# Group tick labels (config name + Wilcoxon p between paired boxes)
tick_x = [(xr + xv) / 2 for xr, xv in positions]
tick_lb = []
for (label, refg, v2m), _ in zip(data, positions):
if len(refg) and len(v2m):
n = min(len(refg), len(v2m))
p_val = _wilcoxon_p(v2m[:n], refg[:n])
ps = f"p={p_val:.3f}" if not np.isnan(p_val) else "p=n/a"
tick_lb.append(f"{label}\n{ps}")
else:
tick_lb.append(label)
ax.set_xticks(tick_x)
ax.set_xticklabels(tick_lb, fontsize=9.5)
# Legend
legend_handles = [
mpatches.Patch(facecolor=C_REFUGELIKE, edgecolor="black",
alpha=ALPHA, label="refugelike (ResNet50 + REFUGE)"),
mpatches.Patch(facecolor=C_REFUGE_V2M, edgecolor="black",
alpha=ALPHA, label="refuge V2-M (EfficientNetV2-M + REFUGE)"),
]
ax.legend(handles=legend_handles, loc="lower right", fontsize=9, framealpha=0.92)
# Limits and grid
xmin = positions[0][0] - box_w
xmax = positions[-1][1] + box_w
ax.set_xlim(xmin - 0.3, xmax + 0.3)
ax.set_ylim(0.55, 1.0)
ax.set_ylabel("Test AUC (10 reps × 5 folds)", fontsize=11)
ax.grid(axis="y", alpha=0.3, linestyle="--")
fig.tight_layout()
OUT.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(OUT, dpi=180, bbox_inches="tight")
plt.close(fig)
print(f"saved {OUT}")
if __name__ == "__main__":
render()
Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+10
View File
@@ -0,0 +1,10 @@
feature,mean_importance,std_importance
Age,0.03184101084778838,0.017362350323790826
IOP_corr,0.0209298093602393,0.01619384191432414
Gender,0.010245143460808846,0.020271910566046904
Phakic/Pseudophakic,0.005718439848904106,0.019878985551523384
eyeID,0.002907359675067821,0.0068517202428395665
Pachymetry,0.0027483776870354105,0.008864856763204554
dioptre_2,8.49432781653429e-05,0.0010216320594945336
astigmatism,-0.0012830897957007016,0.0044473592790778
dioptre_1,-0.002925119731183013,0.006220349074293031
1 feature mean_importance std_importance
2 Age 0.03184101084778838 0.017362350323790826
3 IOP_corr 0.0209298093602393 0.01619384191432414
4 Gender 0.010245143460808846 0.020271910566046904
5 Phakic/Pseudophakic 0.005718439848904106 0.019878985551523384
6 eyeID 0.002907359675067821 0.0068517202428395665
7 Pachymetry 0.0027483776870354105 0.008864856763204554
8 dioptre_2 8.49432781653429e-05 0.0010216320594945336
9 astigmatism -0.0012830897957007016 0.0044473592790778
10 dioptre_1 -0.002925119731183013 0.006220349074293031
Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

@@ -0,0 +1,10 @@
feature,mean_importance,std_importance
Age,0.03184101084778838,0.017362350323790826
IOP_corr,0.0209298093602393,0.01619384191432414
Gender,0.010245143460808846,0.020271910566046904
Phakic/Pseudophakic,0.005718439848904106,0.019878985551523384
eyeID,0.002907359675067821,0.0068517202428395665
Pachymetry,0.0027483776870354105,0.008864856763204554
dioptre_2,8.49432781653429e-05,0.0010216320594945336
astigmatism,-0.0012830897957007016,0.0044473592790778
dioptre_1,-0.002925119731183013,0.006220349074293031
1 feature mean_importance std_importance
2 Age 0.03184101084778838 0.017362350323790826
3 IOP_corr 0.0209298093602393 0.01619384191432414
4 Gender 0.010245143460808846 0.020271910566046904
5 Phakic/Pseudophakic 0.005718439848904106 0.019878985551523384
6 eyeID 0.002907359675067821 0.0068517202428395665
7 Pachymetry 0.0027483776870354105 0.008864856763204554
8 dioptre_2 8.49432781653429e-05 0.0010216320594945336
9 astigmatism -0.0012830897957007016 0.0044473592790778
10 dioptre_1 -0.002925119731183013 0.006220349074293031
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../.."
PYTHON_BIN="${PYTHON_BIN:-/home/rpotter/miniconda3/envs/fundus_imaging/bin/python}"
export MPLCONFIGDIR="${MPLCONFIGDIR:-/tmp/mplconfig}"
FUSION_SPLIT="${FUSION_SPLIT:-test}"
GRADCAM_GRID="${GRADCAM_GRID:-16}"
GRADCAM_ALPHA="${GRADCAM_ALPHA:-0.45}"
FUSION_SOURCE="${FUSION_SOURCE:-v3}"
GRADCAM_SOURCE="${GRADCAM_SOURCE:-v3}"
echo "Running F8a fusion event panel from ${FUSION_SOURCE} ${FUSION_SPLIT} predictions..."
"${PYTHON_BIN}" -m v4.figures.F8_explainability \
--only-fusion \
--fusion-source "${FUSION_SOURCE}" \
--fusion-split "${FUSION_SPLIT}"
echo "Running oriented GradCAM outputs from ${GRADCAM_SOURCE}..."
"${PYTHON_BIN}" -m v4.figures.F8_explainability \
--only-gradcam \
--gradcam-source "${GRADCAM_SOURCE}" \
--n-grid "${GRADCAM_GRID}" \
--alpha "${GRADCAM_ALPHA}"
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/../.."
export MPLCONFIGDIR="${MPLCONFIGDIR:-/tmp/mplconfig}"
/home/rpotter/miniconda3/envs/fundus_imaging/bin/python -m v4.figures.F8_explainability \
--only-gradcam \
--n-grid 16 \
--alpha 0.45
View File
+55
View File
@@ -0,0 +1,55 @@
"""Shared loaders/aggregators for v4 figure scripts."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
import numpy as np
REPO_ROOT = Path(__file__).resolve().parents[3]
RESULTS_ROOT = REPO_ROOT / "v4" / "results" / "experiments"
def summarise_run(run_path: Path, primary_hint: Optional[str] = None) -> Optional[dict]:
"""Aggregate val/test primary-metric mean & std across reps for one run folder.
Returns dict with n, val_mean, val_std, test_mean, test_std, metric_name, or None if no reps."""
val, test = [], []
metric_name = primary_hint
for rep in sorted(run_path.glob("rep*")):
s = next(iter(rep.rglob("summary.json")), None)
if not s:
continue
d = json.loads(s.read_text())
pm = d.get("primary_metric") or metric_name or "auc"
metric_name = metric_name or pm
v = d.get(f"mean_val_{pm}")
t = d.get(f"mean_test_{pm}")
if v is None or t is None or not np.isfinite(v) or not np.isfinite(t):
continue
val.append(float(v)); test.append(float(t))
if not val:
return None
return {
"n": len(val),
"metric": metric_name or "auc",
"val_mean": float(np.mean(val)),
"val_std": float(np.std(val)),
"test_mean": float(np.mean(test)),
"test_std": float(np.std(test)),
"val_arr": np.array(val),
"test_arr": np.array(test),
}
def summarise_many(name_to_path: dict[str, Path], primary_hint: Optional[str] = None) -> dict[str, Optional[dict]]:
"""Apply summarise_run to a dict of labelled run folders."""
return {label: summarise_run(p, primary_hint) for label, p in name_to_path.items()}
def fmt_status(s: Optional[dict]) -> str:
if s is None:
return "pending"
return f"n={s['n']:>2d} test={s['test_mean']:.4f}±{s['test_std']:.4f}"
+173
View File
@@ -0,0 +1,173 @@
"""Post-hoc regression calibration on predictions.h5 files.
For each fold of a regression run, fit a linear calibration
actual_md a · predicted_md + b
on the *val* split, then apply (a, b) to that fold's *test* predictions
and report metrics before vs after calibration. No retraining required.
Usage:
python -m v4.scripts.analysis.calibrate_regression \\
v4/results/experiments/reg_head/baseline_reg_nt50
Pass a single predictions.h5 file or a folder; the script finds every
predictions.h5 under it and produces a per-rep + aggregate report.
"""
from __future__ import annotations
import argparse
import math
from pathlib import Path
import h5py
import numpy as np
BIN_LABELS = ["<=-10", "-9..-5", "-4..-1", ">=1"]
def _severity_bin(values: np.ndarray) -> np.ndarray:
bins = np.full(values.shape, -1, dtype=int)
bins[values <= -9.5] = 0
bins[(values > -9.5) & (values <= -4.5)] = 1
bins[(values > -4.5) & (values <= 0.0)] = 2
bins[values > 0.0] = 3
return bins
def _decode(arr) -> np.ndarray:
return np.array([s.decode("utf-8") if isinstance(s, bytes) else str(s) for s in arr])
def _metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict:
res = y_pred - y_true
mse = float(np.mean(res ** 2))
abs_res = np.abs(res)
out = {
"n": int(y_true.size),
"mse": mse,
"rmse": math.sqrt(mse),
"mae": float(np.mean(abs_res)),
"bias": float(np.mean(res)),
"within_1": float(np.mean(abs_res <= 1.0)),
"within_3": float(np.mean(abs_res <= 3.0)),
"within_5": float(np.mean(abs_res <= 5.0)),
"r": float(np.corrcoef(y_true, y_pred)[0, 1])
if y_true.size > 1 and np.std(y_pred) > 0 else float("nan"),
}
bins_true = _severity_bin(y_true)
bins_pred = _severity_bin(y_pred)
valid = (bins_true >= 0) & (bins_pred >= 0)
out["bin_exact"] = float(np.mean(bins_true[valid] == bins_pred[valid])) if valid.any() else float("nan")
out["bin_adjacent"] = float(np.mean(np.abs(bins_true[valid] - bins_pred[valid]) <= 1)) if valid.any() else float("nan")
return out
def _fit_linear(y_true: np.ndarray, y_pred: np.ndarray) -> tuple[float, float]:
"""Fit y_true = a * y_pred + b via OLS. Returns (a, b)."""
if y_pred.size < 2 or np.std(y_pred) == 0:
return 1.0, 0.0
a, b = np.polyfit(y_pred, y_true, 1)
return float(a), float(b)
def calibrate_one(path: Path) -> dict:
"""Run val→test linear calibration on one predictions.h5. Returns aggregate metrics."""
with h5py.File(path, "r") as f:
# Pick the last phase that has a head (heuristic: highest stage)
phases = sorted(k for k in f.keys() if isinstance(f[k], h5py.Group) and "logits" in f[k])
if not phases:
return {"path": str(path), "skipped": "no logits"}
# Prefer 'hb' if present
phase = "hb" if "hb" in phases else phases[-1]
grp = f[phase]
logits = grp["logits"][:] # (folds, epochs, samples, heads, outputs)
y_true = grp["y_true"][:].astype(float)
split = grp["split"][:]
n_folds, n_epochs, n_samples, n_heads, _ = logits.shape
# Use the last epoch and head 0, output 0
ep, head, out = n_epochs - 1, n_heads - 1, 0
raw_test: list[np.ndarray] = []
cal_test: list[np.ndarray] = []
truth_test: list[np.ndarray] = []
slopes: list[float] = []
intercepts: list[float] = []
for fold in range(n_folds):
labels = _decode(split[fold])
v_mask = (labels == "val") & np.isfinite(y_true) & np.isfinite(logits[fold, ep, :, head, out])
t_mask = (labels == "test") & np.isfinite(y_true) & np.isfinite(logits[fold, ep, :, head, out])
if not v_mask.any() or not t_mask.any():
continue
v_pred = logits[fold, ep, v_mask, head, out].astype(float)
v_true = y_true[v_mask]
a, b = _fit_linear(v_true, v_pred)
slopes.append(a); intercepts.append(b)
t_pred = logits[fold, ep, t_mask, head, out].astype(float)
t_true = y_true[t_mask]
raw_test.append(t_pred)
cal_test.append(a * t_pred + b)
truth_test.append(t_true)
if not raw_test:
return {"path": str(path), "skipped": "no val/test rows"}
truth = np.concatenate(truth_test)
raw = _metrics(truth, np.concatenate(raw_test))
cal = _metrics(truth, np.concatenate(cal_test))
return {
"path": str(path),
"slopes": slopes,
"intercepts": intercepts,
"raw": raw,
"cal": cal,
}
def _fmt(row: dict, label: str) -> str:
return (f" {label}: mse={row['mse']:.3f} mae={row['mae']:.3f} bias={row['bias']:+.3f} "
f"r={row['r']:.3f} bin_exact={row['bin_exact']:.3f} bin_adj={row['bin_adjacent']:.3f} "
f"within_1={row['within_1']:.2f} within_3={row['within_3']:.2f}")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("path", type=Path, help="predictions.h5 file or directory")
args = ap.parse_args()
if args.path.is_file():
files = [args.path]
else:
files = sorted(args.path.rglob("predictions.h5"))
if not files:
raise SystemExit(f"No predictions.h5 found under {args.path}")
# Aggregate raw vs cal across all reps
agg_raw_truth, agg_raw_pred = [], []
agg_cal_truth, agg_cal_pred = [], []
all_slopes, all_intercepts = [], []
for fp in files:
res = calibrate_one(fp)
if res.get("skipped"):
print(f"\n{fp.parent.parent.name}: SKIPPED ({res['skipped']})")
continue
rel = fp.relative_to(args.path) if args.path.is_dir() else fp.name
print(f"\n{rel}")
print(f" fold-wise slopes: {', '.join(f'{a:.2f}' for a in res['slopes'])}")
print(f" fold-wise intercepts: {', '.join(f'{b:+.2f}' for b in res['intercepts'])}")
print(_fmt(res["raw"], "raw "))
print(_fmt(res["cal"], "cal "))
all_slopes.extend(res["slopes"])
all_intercepts.extend(res["intercepts"])
if all_slopes:
print(
f"\nCalibration parameters across all reps × folds (n={len(all_slopes)}):\n"
f" slope: mean={np.mean(all_slopes):.3f} ± {np.std(all_slopes):.3f} "
f"min={np.min(all_slopes):.2f} max={np.max(all_slopes):.2f}\n"
f" intercept: mean={np.mean(all_intercepts):+.3f} ± {np.std(all_intercepts):.3f}"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,349 @@
"""Plot regression predictions against actual values from v4 predictions.h5.
Usage:
python -m v4.scripts.analysis.plot_regression_predictions \
v4/results/experiments/reg_head/baseline_reg
python -m v4.scripts.analysis.plot_regression_predictions \
v4/results/experiments/reg_head/baseline_reg/rep00/binary/predictions.h5 \
--split val --phase hb --head hb_head --glaucoma-only
"""
from __future__ import annotations
import argparse
import math
from pathlib import Path
import h5py
import matplotlib.pyplot as plt
import numpy as np
def _decode_array(arr: np.ndarray) -> np.ndarray:
return np.array([v.decode() if isinstance(v, bytes) else str(v) for v in arr])
def _head_names(grp: h5py.Group) -> list[str]:
return [v.decode() if isinstance(v, bytes) else str(v) for v in grp["head_names"][:]]
def _find_prediction_files(path: Path) -> list[Path]:
if path.is_file():
if path.name != "predictions.h5":
raise SystemExit(f"Expected predictions.h5 file, got: {path}")
return [path]
if not path.is_dir():
raise SystemExit(f"Not a file or directory: {path}")
files = sorted(path.rglob("predictions.h5"))
if not files:
raise SystemExit(f"No predictions.h5 files found under: {path}")
return files
def _choose_phase(f: h5py.File, phase: str | None) -> str:
phases = sorted(k for k in f.keys() if isinstance(f[k], h5py.Group) and "logits" in f[k])
if not phases:
raise ValueError("No phase groups containing a logits dataset were found")
if phase is None:
return phases[0]
if phase not in phases:
raise ValueError(f"Phase {phase!r} not found. Available phases: {', '.join(phases)}")
return phase
def _choose_head(grp: h5py.Group, head: str | None) -> tuple[str, int]:
names = _head_names(grp)
if not names:
raise ValueError("No head names found")
if head is None:
return names[0], 0
if head not in names:
raise ValueError(f"Head {head!r} not found. Available heads: {', '.join(names)}")
return head, names.index(head)
def _collect_points(
path: Path,
*,
phase: str | None,
head: str | None,
split: str,
epoch: int,
output_index: int,
glaucoma_only: bool,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, str, str]:
with h5py.File(path, "r") as f:
phase_name = _choose_phase(f, phase)
grp = f[phase_name]
head_name, head_idx = _choose_head(grp, head)
logits = grp["logits"]
n_folds, n_epochs, n_samples, _, n_outputs = logits.shape
epoch_idx = epoch if epoch >= 0 else n_epochs + epoch
if not 0 <= epoch_idx < n_epochs:
raise ValueError(f"Epoch {epoch} is out of range for {n_epochs} epochs")
if not 0 <= output_index < n_outputs:
raise ValueError(f"Output index {output_index} is out of range for {n_outputs} outputs")
y_true_all = grp["y_true"][:].astype(float)
split_all = grp["split"][:]
actuals: list[np.ndarray] = []
preds: list[np.ndarray] = []
folds: list[np.ndarray] = []
for fold in range(n_folds):
labels = _decode_array(split_all[fold])
mask = np.ones(n_samples, dtype=bool) if split == "all" else labels == split
if glaucoma_only:
mask &= y_true_all != 0
y_pred = logits[fold, epoch_idx, :, head_idx, output_index].astype(float)
finite = mask & np.isfinite(y_true_all) & np.isfinite(y_pred)
if not finite.any():
continue
actuals.append(y_true_all[finite])
preds.append(y_pred[finite])
folds.append(np.full(int(finite.sum()), fold, dtype=int))
if not actuals:
suffix = " and y_true != 0" if glaucoma_only else ""
raise ValueError(f"No finite rows found for split={split!r}{suffix}")
return (
np.concatenate(actuals),
np.concatenate(preds),
np.concatenate(folds),
phase_name,
head_name,
)
def _metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, float]:
residual = y_pred - y_true
mse = float(np.mean(residual ** 2))
abs_residual = np.abs(residual)
out = {
"n": float(y_true.size),
"mse": mse,
"rmse": math.sqrt(mse),
"mae": float(np.mean(abs_residual)),
"bias": float(np.mean(residual)),
"within_1": float(np.mean(abs_residual <= 1.0)),
"within_3": float(np.mean(abs_residual <= 3.0)),
"within_5": float(np.mean(abs_residual <= 5.0)),
}
if y_true.size > 1 and np.std(y_true) > 0 and np.std(y_pred) > 0:
out["r"] = float(np.corrcoef(y_true, y_pred)[0, 1])
else:
out["r"] = float("nan")
return out
BIN_LABELS = ["<=-10", "-9..-5", "-4..-1", ">=1"]
def _severity_bin(values: np.ndarray) -> np.ndarray:
"""Map continuous values onto glaucoma severity bins.
Actual labels use integer bins: <=-10, -9..-5, -4..-1, >=1.
Predictions are continuous, so boundaries are placed halfway between
adjacent integer bins: -9.5, -4.5, and 0.0.
"""
bins = np.full(values.shape, -1, dtype=int)
bins[values <= -9.5] = 0
bins[(values > -9.5) & (values <= -4.5)] = 1
bins[(values > -4.5) & (values <= 0.0)] = 2
bins[values > 0.0] = 3
return bins
def _bin_summary_lines(y_true: np.ndarray, y_pred: np.ndarray) -> list[str]:
actual_bins = _severity_bin(y_true)
pred_bins = _severity_bin(y_pred)
valid = (actual_bins >= 0) & (pred_bins >= 0)
if not valid.any():
return ["Bin summary: no rows matched the configured severity bins"]
actual_bins = actual_bins[valid]
pred_bins = pred_bins[valid]
confusion = np.zeros((len(BIN_LABELS), len(BIN_LABELS)), dtype=int)
for actual, pred in zip(actual_bins, pred_bins):
confusion[actual, pred] += 1
exact = float(np.mean(actual_bins == pred_bins))
adjacent = float(np.mean(np.abs(actual_bins - pred_bins) <= 1))
lines = [
f"Bin summary: exact={exact:.3f} within_adjacent={adjacent:.3f}",
" actual/pred " + " ".join(f"{label:>8s}" for label in BIN_LABELS),
]
for idx, label in enumerate(BIN_LABELS):
row = confusion[idx]
n = int(row.sum())
row_text = " ".join(f"{v:8d}" for v in row)
lines.append(f" {label:>11s} n={n:3d} {row_text}")
lines.append(" per-actual-bin:")
for idx, label in enumerate(BIN_LABELS):
mask = actual_bins == idx
if not mask.any():
continue
stats = _metrics(y_true[valid][mask], y_pred[valid][mask])
lines.append(
f" {label:>7s} n={stats['n']:.0f} "
f"mean_pred={float(np.mean(y_pred[valid][mask])):.2f} "
f"bias={stats['bias']:.2f} mae={stats['mae']:.2f} rmse={stats['rmse']:.2f} "
f"within_3={stats['within_3']:.3f} within_5={stats['within_5']:.3f}"
)
return lines
def _plot_one(
y_true: np.ndarray,
y_pred: np.ndarray,
folds: np.ndarray,
*,
title: str,
output: Path,
dpi: int,
) -> None:
stats = _metrics(y_true, y_pred)
residual = y_pred - y_true
lo = float(np.nanmin([y_true.min(), y_pred.min()]))
hi = float(np.nanmax([y_true.max(), y_pred.max()]))
pad = max((hi - lo) * 0.05, 1.0)
lo -= pad
hi += pad
fig, (ax_scatter, ax_resid) = plt.subplots(
1,
2,
figsize=(11.5, 5.0),
gridspec_kw={"width_ratios": [1.4, 1.0]},
constrained_layout=True,
)
scatter = ax_scatter.scatter(
y_true,
y_pred,
c=folds,
cmap="tab10",
s=34,
alpha=0.72,
linewidths=0,
)
ax_scatter.plot([lo, hi], [lo, hi], color="black", linewidth=1.2, linestyle="--", label="ideal")
if y_true.size > 1:
slope, intercept = np.polyfit(y_true, y_pred, deg=1)
ax_scatter.plot(
[lo, hi],
[slope * lo + intercept, slope * hi + intercept],
color="#b03a2e",
linewidth=1.4,
label=f"fit: y={slope:.2f}x{intercept:+.2f}",
)
ax_scatter.set_xlim(lo, hi)
ax_scatter.set_ylim(lo, hi)
ax_scatter.set_aspect("equal", adjustable="box")
ax_scatter.set_xlabel("Actual")
ax_scatter.set_ylabel("Predicted")
ax_scatter.set_title(title)
ax_scatter.grid(True, color="#e6e6e6", linewidth=0.8)
ax_scatter.legend(loc="upper left", frameon=False)
cbar = fig.colorbar(scatter, ax=ax_scatter, fraction=0.046, pad=0.04)
cbar.set_label("Fold")
text = (
f"n={stats['n']:.0f}\n"
f"MSE={stats['mse']:.3f}\n"
f"RMSE={stats['rmse']:.3f}\n"
f"MAE={stats['mae']:.3f}\n"
f"bias={stats['bias']:.3f}\n"
f"r={stats['r']:.3f}\n"
f"±1={stats['within_1']:.3f}\n"
f"±3={stats['within_3']:.3f}\n"
f"±5={stats['within_5']:.3f}"
)
ax_scatter.text(
0.98,
0.02,
text,
transform=ax_scatter.transAxes,
ha="right",
va="bottom",
fontsize=9,
bbox={"boxstyle": "round,pad=0.35", "facecolor": "white", "edgecolor": "#cccccc", "alpha": 0.92},
)
bins = min(30, max(8, int(np.sqrt(residual.size))))
ax_resid.hist(residual, bins=bins, color="#4c78a8", alpha=0.85, edgecolor="white")
ax_resid.axvline(0, color="black", linewidth=1.1, linestyle="--")
ax_resid.axvline(stats["bias"], color="#b03a2e", linewidth=1.4, label="mean residual")
ax_resid.set_xlabel("Predicted - actual")
ax_resid.set_ylabel("Count")
ax_resid.set_title("Residuals")
ax_resid.grid(True, axis="y", color="#e6e6e6", linewidth=0.8)
ax_resid.legend(loc="upper right", frameon=False)
output.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output, dpi=dpi)
plt.close(fig)
def default_output_path(path: Path, phase: str, head: str, split: str, label: str) -> Path:
stem = f"regression_predictions_{phase}_{head}_{split}"
if label:
stem += f"_{label}"
stem += ".png"
return path.with_name(stem)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("path", type=Path, help="A predictions.h5 file or a directory containing predictions.h5 files")
ap.add_argument("--phase", default=None, help="Phase group to plot; defaults to the first logits phase")
ap.add_argument("--head", default=None, help="Head name to plot; defaults to the first head")
ap.add_argument("--split", default="test", help="Split label to plot: test, val, train, or all")
ap.add_argument("--epoch", type=int, default=-1, help="Epoch index to plot; negative values count from the end")
ap.add_argument("--output-index", type=int, default=0, help="Regression output index within logits' final axis")
ap.add_argument("--glaucoma-only", action="store_true", help="Only plot rows where actual y_true is non-zero")
ap.add_argument("--out-dir", type=Path, default=None, help="Optional directory for all output PNGs")
ap.add_argument("--bin-summary", action="store_true", help="Print severity-bin accuracy and confusion matrix")
ap.add_argument("--dpi", type=int, default=160, help="Output PNG resolution")
args = ap.parse_args()
filter_label = "glaucoma_only" if args.glaucoma_only else ""
for pred_path in _find_prediction_files(args.path):
y_true, y_pred, folds, phase, head = _collect_points(
pred_path,
phase=args.phase,
head=args.head,
split=args.split,
epoch=args.epoch,
output_index=args.output_index,
glaucoma_only=args.glaucoma_only,
)
rel_title = pred_path.parent.as_posix()
filter_text = ", glaucoma only" if args.glaucoma_only else ""
title = f"{rel_title}\nphase={phase}, head={head}, split={args.split}, epoch={args.epoch}{filter_text}"
if args.out_dir is None:
out_path = default_output_path(pred_path, phase, head, args.split, filter_label)
else:
rep_name = "_".join(pred_path.parent.parts[-3:])
suffix = f"_{filter_label}" if filter_label else ""
out_path = args.out_dir / f"{rep_name}_{phase}_{head}_{args.split}{suffix}.png"
_plot_one(y_true, y_pred, folds, title=title, output=out_path, dpi=args.dpi)
stats = _metrics(y_true, y_pred)
print(
f"{out_path} n={stats['n']:.0f} "
f"mse={stats['mse']:.4f} rmse={stats['rmse']:.4f} "
f"mae={stats['mae']:.4f} r={stats['r']:.4f} "
f"within_1={stats['within_1']:.3f} "
f"within_3={stats['within_3']:.3f} "
f"within_5={stats['within_5']:.3f}"
)
if args.bin_summary:
print("\n".join(_bin_summary_lines(y_true, y_pred)))
if __name__ == "__main__":
main()
+188
View File
@@ -0,0 +1,188 @@
"""3-bin severity confusion matrix for vf_md regression heads.
Reads predictions.h5 files from a regression run, bins both actual and predicted
MD into 3 severity tiers (severe / moderate / no-problem), and reports:
* confusion matrix (counts and per-row %)
* exact-bin & adjacent-bin accuracy
* per-bin recall
* binary "no-problem vs disease" sensitivity/specificity at the 4.5 dB boundary
* optional saved heatmap PNG
Bin boundaries (placed halfway between integer bins, matching plot_regression_predictions):
severe : vf_md <= -9.5
moderate : -9.5 < vf_md <= -4.5
no-problem : vf_md > -4.5
Usage:
python -m v4.scripts.analysis.severity_confusion \\
v4/results/experiments/reg_head/baseline_reg_nt50 \\
--save-fig analysis/figures/regression_severity_confusion.png
"""
from __future__ import annotations
import argparse
from pathlib import Path
import h5py
import numpy as np
LABELS = ["severe (≤−10)", "moderate (9..5)", "no-problem (≥−4)"]
def severity_3bin(values: np.ndarray) -> np.ndarray:
bins = np.full(values.shape, -1, dtype=int)
bins[values <= -9.5] = 0
bins[(values > -9.5) & (values <= -4.5)] = 1
bins[values > -4.5] = 2
return bins
def _decode(arr) -> np.ndarray:
return np.array([s.decode("utf-8") if isinstance(s, bytes) else str(s) for s in arr])
def collect_test_predictions(path: Path) -> tuple[np.ndarray, np.ndarray] | None:
with h5py.File(path, "r") as f:
if "hb" not in f:
return None
grp = f["hb"]
logits = grp["logits"][:]
y_true = grp["y_true"][:].astype(float)
split = grp["split"][:]
n_folds, n_epochs, _, n_heads, _ = logits.shape
ep, head, out = n_epochs - 1, n_heads - 1, 0
actuals: list[np.ndarray] = []
preds: list[np.ndarray] = []
for fold in range(n_folds):
labels = _decode(split[fold])
mask = (labels == "test") & np.isfinite(y_true) & np.isfinite(logits[fold, ep, :, head, out])
if not mask.any():
continue
actuals.append(y_true[mask])
preds.append(logits[fold, ep, mask, head, out].astype(float))
if not actuals:
return None
return np.concatenate(actuals), np.concatenate(preds)
def report(actuals: np.ndarray, preds: np.ndarray) -> dict:
ab = severity_3bin(actuals)
pb = severity_3bin(preds)
valid = (ab >= 0) & (pb >= 0)
ab, pb = ab[valid], pb[valid]
cm = np.zeros((3, 3), dtype=int)
for x, y in zip(ab, pb):
cm[x, y] += 1
# Binary disease vs no-problem (bins 0+1 vs bin 2)
actual_disease = ab <= 1
pred_disease = pb <= 1
tp = int(np.sum(actual_disease & pred_disease))
tn = int(np.sum(~actual_disease & ~pred_disease))
fp = int(np.sum(~actual_disease & pred_disease))
fn = int(np.sum(actual_disease & ~pred_disease))
sens = tp / max(tp + fn, 1)
spec = tn / max(tn + fp, 1)
return {
"confusion": cm,
"n_test": int(ab.size),
"exact_acc": float(np.mean(ab == pb)),
"adjacent_acc": float(np.mean(np.abs(ab - pb) <= 1)),
"recall_per_bin": [float(np.mean(pb[ab == i] == i)) if (ab == i).any() else float("nan")
for i in range(3)],
"n_per_bin": [int((ab == i).sum()) for i in range(3)],
"binary_sens": sens,
"binary_spec": spec,
"binary_tp": tp,
"binary_fp": fp,
"binary_fn": fn,
"binary_tn": tn,
}
def print_report(r: dict) -> None:
cm = r["confusion"]
print(f"\nn_test (pooled across reps × folds): {r['n_test']}")
print(f"\nConfusion matrix (rows = actual, cols = predicted):")
print(f"{'actual \\ pred':<22s} {LABELS[0]:>16s} {LABELS[1]:>20s} {LABELS[2]:>18s} n")
for i in range(3):
row = cm[i]
print(f"{LABELS[i]:<22s} {row[0]:>16d} {row[1]:>20d} {row[2]:>18d} {row.sum()}")
print(f"\nExact-bin accuracy: {r['exact_acc']:.3f}")
print(f"Adjacent-bin accuracy: {r['adjacent_acc']:.3f}")
print("\nPer-bin recall:")
for i in range(3):
n = r["n_per_bin"][i]
rec = r["recall_per_bin"][i]
print(f" {LABELS[i]:<22s} n={n:>4d} recall={rec:.3f}")
print(f"\nBinary disease (severe+moderate) vs no-problem, threshold = 4.5 dB:")
print(f" sensitivity (correctly flag disease): {r['binary_sens']:.3f} ({r['binary_tp']}/{r['binary_tp']+r['binary_fn']})")
print(f" specificity (correctly clear healthy): {r['binary_spec']:.3f} ({r['binary_tn']}/{r['binary_tn']+r['binary_fp']})")
def save_heatmap(r: dict, path: Path) -> None:
import matplotlib.pyplot as plt
cm = r["confusion"]
cm_pct = cm / np.maximum(cm.sum(axis=1, keepdims=True), 1)
fig, ax = plt.subplots(figsize=(6.5, 5.5))
im = ax.imshow(cm_pct, cmap="Blues", vmin=0, vmax=1, aspect="equal")
for i in range(3):
for j in range(3):
ax.text(j, i, f"{cm[i,j]}\n({cm_pct[i,j]*100:.1f}%)",
ha="center", va="center",
color="white" if cm_pct[i,j] > 0.5 else "black",
fontsize=10)
ax.set_xticks(range(3)); ax.set_xticklabels(LABELS, rotation=20, ha="right")
ax.set_yticks(range(3)); ax.set_yticklabels(LABELS)
ax.set_xlabel("Predicted")
ax.set_ylabel("Actual")
ax.set_title(f"VF-MD severity confusion (n={r['n_test']})\n"
f"exact={r['exact_acc']:.3f} adjacent={r['adjacent_acc']:.3f} "
f"sens={r['binary_sens']:.3f} spec={r['binary_spec']:.3f}")
fig.colorbar(im, ax=ax, label="Row-normalised fraction")
fig.tight_layout()
path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(path, dpi=150, bbox_inches="tight")
plt.close(fig)
print(f"\nSaved heatmap: {path}")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("path", type=Path, help="A predictions.h5 file or a directory containing them")
ap.add_argument("--save-fig", type=Path, default=None, help="Optional path to save the confusion-matrix heatmap PNG")
args = ap.parse_args()
if args.path.is_file():
files = [args.path]
else:
files = sorted(args.path.rglob("predictions.h5"))
if not files:
raise SystemExit(f"No predictions.h5 under {args.path}")
all_actual: list[np.ndarray] = []
all_pred: list[np.ndarray] = []
for fp in files:
res = collect_test_predictions(fp)
if res is None:
print(f" skipped (no hb predictions): {fp}")
continue
a, p = res
all_actual.append(a); all_pred.append(p)
if not all_actual:
raise SystemExit("No usable predictions found")
actuals = np.concatenate(all_actual)
preds = np.concatenate(all_pred)
print(f"Pooled across {len(all_actual)} predictions.h5 files")
r = report(actuals, preds)
print_report(r)
if args.save_fig:
save_heatmap(r, args.save_fig)
if __name__ == "__main__":
main()
+17 -9
View File
@@ -39,14 +39,20 @@ def find_rep_summaries(run_dir: Path) -> list[tuple[int, Path]]:
def load_rep(summary_path: Path) -> dict:
"""Extract the fields we summarise from one rep's summary.json."""
"""Extract the fields we summarise from one rep's summary.json.
Uses `primary_metric` if present (for regression runs e.g. neg_mse),
falling back to AUC for legacy classification runs.
"""
d = json.loads(summary_path.read_text())
pm = d.get("primary_metric", "auc")
return {
"val_mean": float(d.get("mean_val_auc", float("nan"))),
"val_std": float(d.get("std_val_auc", float("nan"))),
"test_mean": float(d.get("mean_test_auc", float("nan"))),
"test_std": float(d.get("std_test_auc", float("nan"))),
"elapsed_s": float(d.get("elapsed_s", float("nan"))),
"primary": pm,
"val_mean": float(d.get(f"mean_val_{pm}", d.get("mean_val_auc", float("nan")))),
"val_std": float(d.get(f"std_val_{pm}", d.get("std_val_auc", float("nan")))),
"test_mean": float(d.get(f"mean_test_{pm}", d.get("mean_test_auc", float("nan")))),
"test_std": float(d.get(f"std_test_{pm}", d.get("std_test_auc", float("nan")))),
"elapsed_s": float(d.get("elapsed_s", float("nan"))),
"eval_stage": d.get("eval_stage", "?"),
}
@@ -63,6 +69,7 @@ def summarise(run_dir: Path) -> dict:
"run": str(run_dir),
"n_reps": len(rows),
"eval_stage": rows[0][1]["eval_stage"],
"primary": rows[0][1]["primary"],
"val_mean": float(np.mean(val)),
"val_std": float(np.std(val)),
"val_min": float(np.min(val)),
@@ -83,12 +90,13 @@ def render(s: dict, per_rep: bool = False) -> str:
if s["n_reps"] == 0:
return f"Run: {s['run']}\n no reps with summary.json found."
metric = s.get("primary", "auc")
lines = [
f"Run: {s['run']}",
f"Reps: {s['n_reps']} (eval_stage={s['eval_stage']})",
f"Val AUC: {s['val_mean']:.4f} ± {s['val_std']:.4f} "
f"Reps: {s['n_reps']} (eval_stage={s['eval_stage']}, metric={metric})",
f"Val {metric}: {s['val_mean']:.4f} ± {s['val_std']:.4f} "
f"[min={s['val_min']:.4f} max={s['val_max']:.4f}]",
f"Test AUC: {s['test_mean']:.4f} ± {s['test_std']:.4f} "
f"Test {metric}: {s['test_mean']:.4f} ± {s['test_std']:.4f} "
f"[min={s['test_min']:.4f} max={s['test_max']:.4f}]",
]
if s.get("elapsed_total_s") is not None:
+324
View File
@@ -0,0 +1,324 @@
"""fetch_tcga_brca — programmatic download of TCGA-BRCA multimodal data.
Two-phase fetch:
Phase 1 TABULAR (cBioPortal bulk distribution):
- Clinical fields (~90 columns: stage, grade, treatment, vital status, etc.)
- RPPA protein expression (~200 proteins)
- mRNA expression (~20,000 genes; optional)
- Mutations (MAF)
- All pre-joined by sample ID and cleaned by Broad/MSK curation
- One tarball, ~200 MB compressed, fast download
- Source: https://cbioportal-datahub.s3.amazonaws.com/
- Curated study: brca_tcga_pan_can_atlas_2018
Phase 2 PATHOLOGY IMAGES (GDC API):
- Diagnostic image thumbnails (small, JPG-like, ~MBs each manageable)
- Or full SVS slide images (gigapixel, ~100s of MB each heavy)
- Uses GDC's REST API to build a manifest, then downloads files
- Source: https://api.gdc.cancer.gov/
Usage:
python -m v4.scripts.data.fetch_tcga_brca --out data/tcga_brca
python -m v4.scripts.data.fetch_tcga_brca --out data/tcga_brca --skip-images
python -m v4.scripts.data.fetch_tcga_brca --out data/tcga_brca --images diagnostic --limit 50
All TCGA-BRCA data downloaded here is in GDC's *open-access* tier — no DUA,
no controlled-access approval needed. Standard NIH attribution required for
publications.
"""
from __future__ import annotations
import argparse
import json
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
CBIOPORTAL_STUDY = "brca_tcga_pan_can_atlas_2018"
# cBioPortal datahub stores files in a GitHub repo with LFS. The S3 bucket
# is no longer publicly accessible, so we pull individual files from GitHub.
# Large data files are stored via LFS (different endpoint); small meta/text
# files are regular git blobs. We try LFS first, fall back to raw.
CBIOPORTAL_LFS_BASE = (
f"https://media.githubusercontent.com/media/cBioPortal/datahub/master/public/{CBIOPORTAL_STUDY}"
)
CBIOPORTAL_RAW_BASE = (
f"https://raw.githubusercontent.com/cBioPortal/datahub/master/public/{CBIOPORTAL_STUDY}"
)
# Curated file list for the BRCA Pan-Cancer Atlas 2018 study.
CBIOPORTAL_FILES_ESSENTIAL = [
"data_clinical_patient.txt", # ~90 clinical fields per patient
"data_clinical_sample.txt", # sample-level annotations
"data_rppa.txt", # RPPA protein expression (~200 proteins)
"data_rppa_zscores.txt", # RPPA z-scored against normal samples
"meta_clinical_patient.txt",
"meta_clinical_sample.txt",
"meta_rppa.txt",
"meta_study.txt",
]
CBIOPORTAL_FILES_OPTIONAL = [
"data_protein_quantification.txt", # mass-spec proteomics (CPTAC) — richer than RPPA
"data_phosphoprotein_quantification.txt", # phosphoproteomics
"data_protein_quantification_zscores.txt",
"data_mutations.txt", # MAF — somatic mutations
"data_cna.txt", # copy-number alterations (gistic)
"data_mrna_seq_v2_rsem.txt", # RNA-seq counts (LARGE, ~150 MB)
"data_mrna_seq_v2_rsem_zscores_ref_normal_samples.txt",
]
GDC_API_FILES = "https://api.gdc.cancer.gov/files"
GDC_API_DATA = "https://api.gdc.cancer.gov/data"
USER_AGENT = "hypertower-data-fetch/1.0 (research; python urllib)"
# ---------------------------------------------------------------------------
# Phase 1: cBioPortal tabular bundle
# ---------------------------------------------------------------------------
def fetch_cbioportal(out_dir: Path, include_optional: bool = False) -> Path:
"""Download cBioPortal TCGA-BRCA Pan-Cancer Atlas files via GitHub LFS."""
study_dir = out_dir / "cbioportal" / CBIOPORTAL_STUDY
study_dir.mkdir(parents=True, exist_ok=True)
files = list(CBIOPORTAL_FILES_ESSENTIAL)
if include_optional:
files += CBIOPORTAL_FILES_OPTIONAL
print(f"[cBioPortal] downloading {len(files)} files from datahub")
print(f"{study_dir}")
failed = []
for fname in files:
dest = study_dir / fname
if dest.exists() and dest.stat().st_size > 0:
print(f" · {fname} (already present, {dest.stat().st_size/1e6:.2f} MB)")
continue
# Try LFS first (for large data files), then raw (for small meta files).
last_err = None
for url in (f"{CBIOPORTAL_LFS_BASE}/{fname}",
f"{CBIOPORTAL_RAW_BASE}/{fname}"):
try:
print(f"{fname}")
_stream_download(url, dest)
print(f" {dest.stat().st_size/1e6:.2f} MB")
last_err = None
break
except (urllib.error.HTTPError, urllib.error.URLError) as e:
last_err = e
if last_err is not None:
print(f" failed: {last_err}")
failed.append(fname)
print(f"\n[cBioPortal] {len(files) - len(failed)}/{len(files)} files retrieved.")
if failed:
print(f"[cBioPortal] failed files: {failed}")
print(f"\nFiles under {study_dir}:")
for f in sorted(study_dir.iterdir()):
if f.is_file():
size_mb = f.stat().st_size / 1e6
print(f" {f.name:60s} {size_mb:>8.2f} MB")
return study_dir
# ---------------------------------------------------------------------------
# Phase 2: GDC pathology images
# ---------------------------------------------------------------------------
# Image-type aliases for convenience. "Diagnostic Slide" is the larger SVS;
# "Tissue Slide" is similar. Diagnostic image thumbnails are not always
# listed as a separate type — they're embedded inside the slide files.
_IMAGE_TYPE_FILTERS = {
"diagnostic": "Diagnostic Slide",
"tissue": "Tissue Slide",
}
def build_image_manifest(image_type: str = "diagnostic",
limit: int | None = None,
max_size_mb: float | None = None,
out_path: Path | None = None) -> list[dict]:
"""Query GDC API for TCGA-BRCA pathology images, return file metadata.
Returns a list of dicts: file_id, file_name, file_size, patient_id, sample_id.
"""
filt_type = _IMAGE_TYPE_FILTERS.get(image_type, image_type)
filters = {
"op": "and",
"content": [
{"op": "in", "content": {"field": "cases.project.project_id",
"value": ["TCGA-BRCA"]}},
{"op": "in", "content": {"field": "data_format", "value": ["SVS"]}},
{"op": "in", "content": {"field": "experimental_strategy",
"value": [filt_type]}},
{"op": "in", "content": {"field": "access", "value": ["open"]}},
],
}
# Request more than `limit` so we can filter by size client-side first.
page_size = max(limit or 1000, 1000)
params = {
"filters": json.dumps(filters),
"fields": ("file_id,file_name,file_size,experimental_strategy,"
"cases.submitter_id,cases.samples.submitter_id"),
"format": "JSON",
"size": str(page_size),
}
url = f"{GDC_API_FILES}?{urllib.parse.urlencode(params)}"
print(f"[GDC] querying for image manifest "
f"(type={image_type}, max_size={max_size_mb}MB, limit={limit})...")
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
raw_hits = data.get("data", {}).get("hits", [])
total = data.get("data", {}).get("pagination", {}).get("total", len(raw_hits))
print(f"[GDC] GDC reports {total} total matching files; fetched {len(raw_hits)}")
# Flatten + filter
hits = []
for h in raw_hits:
case = (h.get("cases") or [{}])[0]
sample = ((case.get("samples") or [{}])[0])
size_mb = h.get("file_size", 0) / 1e6
if max_size_mb is not None and size_mb > max_size_mb:
continue
hits.append({
"file_id": h["file_id"],
"file_name": h["file_name"],
"file_size": h.get("file_size", 0),
"experimental_strategy": h.get("experimental_strategy"),
"patient_id": case.get("submitter_id"),
"sample_id": sample.get("submitter_id"),
})
if limit is not None:
hits = hits[:limit]
print(f"[GDC] {len(hits)} files in manifest after filter "
f"({sum(h['file_size'] for h in hits)/1e9:.2f} GB total)")
if out_path is not None:
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(json.dumps(hits, indent=2))
print(f"[GDC] manifest saved → {out_path}")
return hits
def download_images(manifest: list[dict], out_dir: Path) -> None:
"""Download images from a GDC manifest. Files are SVS (gigapixel)."""
import time
out_dir.mkdir(parents=True, exist_ok=True)
n = len(manifest)
if n == 0:
return
total_bytes = sum(item.get("file_size", 0) for item in manifest)
print(f"[GDC] downloading {n} files ({total_bytes/1e9:.2f} GB total) → {out_dir}")
done_bytes = 0
t0 = time.time()
for i, item in enumerate(manifest, 1):
fid = item["file_id"]
name = item["file_name"]
sz = item.get("file_size", 0)
dest = out_dir / name
if dest.exists() and dest.stat().st_size == sz:
print(f" [{i:>3d}/{n}] {name} (already complete, skip)")
done_bytes += sz
continue
elif dest.exists():
dest.unlink() # partial / wrong size, redo
url = f"{GDC_API_DATA}/{fid}"
print(f" [{i:>3d}/{n}] {name} ({sz/1e6:.1f} MB) "
f"[total so far {done_bytes/1e9:.2f}/{total_bytes/1e9:.2f} GB, "
f"elapsed {(time.time()-t0)/60:.1f} min]")
try:
_stream_download(url, dest)
done_bytes += sz
except (urllib.error.URLError, urllib.error.HTTPError) as e:
print(f" failed: {e}")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _stream_download(url: str, dest: Path, chunk_size: int = 1 << 16) -> None:
"""Stream-download a URL to a destination path, with a progress indicator."""
dest.parent.mkdir(parents=True, exist_ok=True)
tmp = dest.with_suffix(dest.suffix + ".part")
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req) as resp:
total = int(resp.headers.get("Content-Length", 0))
got = 0
last_pct = -1
with open(tmp, "wb") as f:
while True:
chunk = resp.read(chunk_size)
if not chunk:
break
f.write(chunk)
got += len(chunk)
if total > 0:
pct = int(got * 100 / total)
if pct >= last_pct + 5:
print(f" ... {pct}% ({got/1e6:.1f}/{total/1e6:.1f} MB)", flush=True)
last_pct = pct
tmp.rename(dest)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", type=Path, default=Path("data/tcga_brca"),
help="Output directory (default: data/tcga_brca)")
ap.add_argument("--skip-tabular", action="store_true",
help="Skip the cBioPortal tabular file downloads")
ap.add_argument("--include-optional", action="store_true",
help="Also fetch optional larger files (mutations, CNA, RNA-seq)")
ap.add_argument("--skip-images", action="store_true",
help="Skip the GDC image download (manifest only is still built)")
ap.add_argument("--images", choices=["diagnostic", "tissue"], default="diagnostic",
help="Image type to fetch — diagnostic (H&E, ~1.5 GB each) or "
"tissue (~200 MB each). Default: diagnostic")
ap.add_argument("--limit", type=int, default=None,
help="Cap number of images downloaded (after size filter)")
ap.add_argument("--max-size-mb", type=float, default=None,
help="Skip files larger than this many MB (useful for sampling smaller slides)")
ap.add_argument("--manifest-only", action="store_true",
help="Build the GDC image manifest JSON but don't download images")
args = ap.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
if not args.skip_tabular:
fetch_cbioportal(args.out, include_optional=args.include_optional)
if args.skip_images:
return
manifest = build_image_manifest(
image_type=args.images,
limit=args.limit,
max_size_mb=args.max_size_mb,
out_path=args.out / "images" / args.images / f"manifest.json",
)
if args.manifest_only:
print("[GDC] manifest-only mode, skipping downloads.")
return
download_images(manifest, args.out / "images" / args.images)
if __name__ == "__main__":
main()
-65
View File
@@ -1,65 +0,0 @@
"""Compare fold assignments between v3 PatientFirstSplitManager and v4 SplitManager."""
import sys
from pathlib import Path
from types import SimpleNamespace
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from v3.classes.split_manager import PatientFirstSplitManager
from v4.classes.split_manager import SplitManager
from v4.classes.profiles.v4papila import build_data
args = {
"image_dir": "Papila/FundusImages",
"clinical_dir": "Papila/ClinicalData",
"label_col": "Diagnosis",
"iop_corr_method": "ratio",
"iop_drop_raw": True,
"exclude_cols": ["Axial_Length"],
}
# Resolve relative paths
root = Path(__file__).resolve().parents[2]
args["image_dir"] = str(root / args["image_dir"])
args["clinical_dir"] = str(root / args["clinical_dir"])
data = build_data(args)
label_col = data.label_col
patient_col = data.patient_col
df_mode = data.df[data.df[label_col].isin([0, 1])].reset_index(drop=True)
# ── v3 splits ────────────────────────────────────────────────────────────────
split_mgr_v3 = PatientFirstSplitManager(patient_col=patient_col, label_col=label_col)
split_args_v3 = SimpleNamespace(eval_mode="binary", n_splits=5, fold_seed=100)
clinical_ns = SimpleNamespace(df=df_mode, label_col=label_col)
splits_v3 = split_mgr_v3.build_plans(clinical=clinical_ns, args=split_args_v3, profile=None)
# ── v4 splits ────────────────────────────────────────────────────────────────
splits_v4 = SplitManager(group_col=patient_col).build_plans(
df_mode, label_col=label_col, n_splits=5, seed=100,
)
# ── Compare ──────────────────────────────────────────────────────────────────
print(f"{'Fold':<6} {'Set':<6} {'v3 patients':<8} {'v4 patients':<8} {'Match'}")
print("-" * 50)
all_match = True
for fold in range(5):
s3, s4 = splits_v3[fold], splits_v4[fold]
for label, df3, df4 in [
("train", s3.train, s4.train),
("val", s3.val, s4.val),
("test", s3.test, s4.test),
]:
ids3 = set(df3[patient_col].unique()) if df3 is not None else set()
ids4 = set(df4[patient_col].unique()) if df4 is not None else set()
match = ids3 == ids4
if not match:
all_match = False
print(f"{fold+1:<6} {label:<6} {len(ids3):<8} {len(ids4):<8} {'' if match else '✗ DIFF'}")
if not match:
print(f" only in v3: {sorted(ids3 - ids4)[:10]}")
print(f" only in v4: {sorted(ids4 - ids3)[:10]}")
print()
print("All folds match!" if all_match else "SPLITS DIFFER — fold assignments changed.")
@@ -0,0 +1,230 @@
[
{
"_note": "Bridge attention sweep — gated × mobilenet_v2 (ImageNet, weakest off-the-shelf). Per-sample sigmoid gates expose how much each tower contributes. Tests the hypothesis 'as image tower strengthens, bridge downweights clinical'.",
"run_name": "experiments/bridge_attention/gated_mobilenet_v2",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "mobilenet_v2", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.gated_bridge",
"class": "GatedAdditiveBridge",
"args": { "fusion_dim": 256 }
}
}
},
{
"_note": "Bridge attention sweep — gated × resnet50 (ImageNet weights, NOT refuge-pretrained).",
"run_name": "experiments/bridge_attention/gated_resnet50",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "resnet50", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.gated_bridge",
"class": "GatedAdditiveBridge",
"args": { "fusion_dim": 256 }
}
}
},
{
"_note": "Bridge attention sweep — gated × efficientnet_b0 (ImageNet).",
"run_name": "experiments/bridge_attention/gated_efficientnet_b0",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "efficientnet_b0", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.gated_bridge",
"class": "GatedAdditiveBridge",
"args": { "fusion_dim": 256 }
}
}
},
{
"_note": "Bridge attention sweep — gated × efficientnet_v2_m (ImageNet, strongest off-the-shelf).",
"run_name": "experiments/bridge_attention/gated_efficientnet_v2_m",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.gated_bridge",
"class": "GatedAdditiveBridge",
"args": { "fusion_dim": 256 }
}
}
},
{
"_note": "Bridge attention sweep — gated × refugelike (resnet50 + REFUGE pretrain). Pairs with refugelike fundus-domain prior; tests whether REFUGE-pretrained image tower shifts the bridge's attention compared to its ImageNet-only counterpart.",
"run_name": "experiments/bridge_attention/gated_refugelike",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "refugelike", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.gated_bridge",
"class": "GatedAdditiveBridge",
"args": { "fusion_dim": 256 }
}
}
},
{
"_note": "Bridge attention sweep — gated × refuge_efficientnet_v2_m (V2-M + REFUGE pretrain, headline production backbone).",
"run_name": "experiments/bridge_attention/gated_refuge_efficientnet_v2_m",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.gated_bridge",
"class": "GatedAdditiveBridge",
"args": { "fusion_dim": 256 }
}
}
},
{
"_note": "Bridge attention sweep — ortho_w0.1 × mobilenet_v2 (ImageNet). Orthogonality penalty pushes streams to encode different info; per-stream variance-explained is the attention readout.",
"run_name": "experiments/bridge_attention/ortho_mobilenet_v2",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "mobilenet_v2", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.fusion_bridge",
"inner_class": "FusionBridge",
"inner_args": { "fusion_dim": 256 }
}
}
}
},
{
"_note": "Bridge attention sweep — ortho_w0.1 × resnet50 (ImageNet).",
"run_name": "experiments/bridge_attention/ortho_resnet50",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "resnet50", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.fusion_bridge",
"inner_class": "FusionBridge",
"inner_args": { "fusion_dim": 256 }
}
}
}
},
{
"_note": "Bridge attention sweep — ortho_w0.1 × efficientnet_b0 (ImageNet).",
"run_name": "experiments/bridge_attention/ortho_efficientnet_b0",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "efficientnet_b0", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.fusion_bridge",
"inner_class": "FusionBridge",
"inner_args": { "fusion_dim": 256 }
}
}
}
},
{
"_note": "Bridge attention sweep — ortho_w0.1 × efficientnet_v2_m (ImageNet).",
"run_name": "experiments/bridge_attention/ortho_efficientnet_v2_m",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.fusion_bridge",
"inner_class": "FusionBridge",
"inner_args": { "fusion_dim": 256 }
}
}
}
},
{
"_note": "Bridge attention sweep — ortho_w0.1 × refugelike (resnet50 + REFUGE pretrain).",
"run_name": "experiments/bridge_attention/ortho_refugelike",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "refugelike", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.fusion_bridge",
"inner_class": "FusionBridge",
"inner_args": { "fusion_dim": 256 }
}
}
}
},
{
"_note": "Bridge attention sweep — ortho_w0.1 × refuge_efficientnet_v2_m (V2-M + REFUGE pretrain, headline production backbone).",
"run_name": "experiments/bridge_attention/ortho_refuge_efficientnet_v2_m",
"reps": 10,
"overrides": { "save_checkpoints": true, "save_predictions": true },
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.fusion_bridge",
"inner_class": "FusionBridge",
"inner_args": { "fusion_dim": 256 }
}
}
}
}
]
@@ -0,0 +1,15 @@
[
{
"_note": "ConvNeXt-V2-Tiny vs the resnet50/refugelike img backbone. Otherwise identical to experiments/tri_v1/baseline_ensemble — same ensemble_fused base, binary classification, hb eval.",
"run_name": "experiments/convnext/baseline_convnextv2_tiny",
"reps": 10,
"tower_overrides": {
"img": {
"args": {
"backbone": "convnextv2_tiny",
"freeze_ratio": 0.0
}
}
}
}
]
@@ -0,0 +1,46 @@
[
{
"_note": "Hypothesis 1 — ImageNet pretraining is OOD for fundus; freezing low-level filters helps. Keep tiny but freeze 60% of stages (stem + first 2 of 4 stages).",
"run_name": "experiments/convnext/baseline_convnextv2_tiny_freeze60",
"reps": 3,
"tower_overrides": {
"img": {
"args": {
"backbone": "convnextv2_tiny",
"freeze_ratio": 0.6
}
}
}
},
{
"_note": "Hypothesis 2 — model too big for 330 patients; try the smallest variant.",
"run_name": "experiments/convnext/baseline_convnextv2_atto",
"reps": 3,
"tower_overrides": {
"img": {
"args": {
"backbone": "convnextv2_atto",
"freeze_ratio": 0.0
}
}
}
},
{
"_note": "Hypothesis 3 — LR too high for the bigger model from ImageNet init; halve LR with the tiny backbone unfrozen.",
"run_name": "experiments/convnext/baseline_convnextv2_tiny_lr5e5",
"reps": 3,
"overrides": {
"training": { "lr": 5e-5 }
},
"tower_overrides": {
"img": {
"args": {
"backbone": "convnextv2_tiny",
"freeze_ratio": 0.0
}
}
}
}
]
@@ -0,0 +1,29 @@
[
{
"_note": "EfficientNet-B7 (ImageNet-pretrained, torchvision) vs the refugelike resnet50 img backbone. Otherwise identical to experiments/tri_v1/baseline_ensemble.",
"run_name": "experiments/efficientnet/baseline_efficientnet_b7",
"reps": 3,
"tower_overrides": {
"img": {
"args": {
"backbone": "efficientnet_b7",
"freeze_ratio": 0.0
}
}
}
},
{
"_note": "Same architecture, freeze the first 40% of B7 blocks (keep low-level ImageNet filters fixed since fundus is out-of-distribution).",
"run_name": "experiments/efficientnet/baseline_efficientnet_b7_freeze40",
"reps": 3,
"tower_overrides": {
"img": {
"args": {
"backbone": "efficientnet_b7",
"freeze_ratio": 0.4
}
}
}
}
]
@@ -0,0 +1,43 @@
[
{
"_note": "EfficientNetV2-S (ImageNet, torchvision). 20M params, 1280-dim output. Direct comparison to B7 unfrozen (0.9015 test AUC).",
"run_name": "experiments/efficientnet/baseline_efficientnetv2_s",
"reps": 3,
"tower_overrides": {
"img": {
"args": {
"backbone": "efficientnet_v2_s",
"freeze_ratio": 0.0
}
}
}
},
{
"_note": "EfficientNetV2-S + freeze 0.4. Applies the lesson from refugelike_freeze_sweep — anchoring low-level filters from ImageNet pretraining.",
"run_name": "experiments/efficientnet/baseline_efficientnetv2_s_freeze40",
"reps": 3,
"tower_overrides": {
"img": {
"args": {
"backbone": "efficientnet_v2_s",
"freeze_ratio": 0.4
}
}
}
},
{
"_note": "EfficientNetV2-M (53M params). Mid-size variant; tests whether extra capacity helps or overfits on PAPILA.",
"run_name": "experiments/efficientnet/baseline_efficientnetv2_m",
"reps": 3,
"tower_overrides": {
"img": {
"args": {
"backbone": "efficientnet_v2_m",
"freeze_ratio": 0.0
}
}
}
}
]
@@ -0,0 +1,15 @@
[
{
"_note": "REFUGE-pretrained EfficientNetV2-M (whole-image, no UNet/disc crop) vs ImageNet-pretrained V2-M (0.9029) and refugelike resnet50 (0.8958). Tests whether fundus-domain pretraining beats ImageNet for V2-M.",
"run_name": "experiments/efficientnet/refuge_efficientnetv2_m",
"reps": 10,
"tower_overrides": {
"img": {
"args": {
"backbone": "refuge_efficientnet_v2_m",
"freeze_ratio": 0.0
}
}
}
}
]
@@ -0,0 +1,7 @@
[
{
"_note": "10-rep checkpointed run of the production bilateral img+cd ensemble at refuge_efficientnet_v2_m. Per-fold tower and stage_models state_dicts saved under each rep's checkpoints/foldN/ directory. Used for F8 explainability — reconstructing per-tower predictions at the nt (eye) and hb (patient) levels so we can compare img-tower-only, cd-tower-only, eye-fusion, and patient-fusion outputs.",
"run_name": "experiments/explainability/ensemble_v2m_ckpt",
"reps": 10
}
]
@@ -0,0 +1,28 @@
[
{
"_note": "refugelike (resnet50 + REFUGE fundus pretraining), freeze stem only (1/5 blocks). Tests whether even minimal anchoring helps stability.",
"run_name": "experiments/freeze_sweep/refugelike_freeze20",
"reps": 3,
"tower_overrides": {
"img": { "args": { "backbone": "refugelike", "freeze_ratio": 0.2 } }
}
},
{
"_note": "Freeze stem + layer1 (2/5 blocks). Keeps low-level conv filters fixed, lets layers 2-4 + fc adapt. Bumped to 10 reps to confirm the 0.9105 result vs the 10-rep baseline_ensemble at 0.8958.",
"run_name": "experiments/freeze_sweep/refugelike_freeze40",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "refugelike", "freeze_ratio": 0.4 } }
}
},
{
"_note": "Freeze stem + layer1 + layer2 (3/5 blocks). Only the deep semantic layers adapt — most aggressive practical setting before model loses capacity.",
"run_name": "experiments/freeze_sweep/refugelike_freeze60",
"reps": 3,
"tower_overrides": {
"img": { "args": { "backbone": "refugelike", "freeze_ratio": 0.6 } }
}
}
]
@@ -0,0 +1,7 @@
[
{
"_note": "Single-eye cd-only at refugelike (no img tower). 10 reps. For Figure 2 cd column. Pairs with img_solo_single_refugelike and ensemble_single_refugelike to complete the single-mode tower-ablation cascade.",
"run_name": "experiments/phase2_v4/cd_solo_single",
"reps": 10
}
]
@@ -0,0 +1,37 @@
[
{
"_note": "PAPILA paper backbone replication — VGG16, single-eye img-only, ImageNet-pretrained (no fundus pretraining). For Figure 2 anchor row.",
"run_name": "experiments/phase2_v4/papila_backbones/vgg16",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "vgg16", "freeze_ratio": 0.0 } }
}
},
{
"_note": "DenseNet121 ImageNet-pretrained, no fundus.",
"run_name": "experiments/phase2_v4/papila_backbones/densenet121",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "densenet121", "freeze_ratio": 0.0 } }
}
},
{
"_note": "MobileNetV2 ImageNet-pretrained, no fundus.",
"run_name": "experiments/phase2_v4/papila_backbones/mobilenet_v2",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "mobilenet_v2", "freeze_ratio": 0.0 } }
}
},
{
"_note": "InceptionV3 ImageNet-pretrained, no fundus. (Note: 299x299 native; pipeline uses 224 — note in figure caption.)",
"run_name": "experiments/phase2_v4/papila_backbones/inception_v3",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "inception_v3", "freeze_ratio": 0.0 } }
}
}
]
@@ -0,0 +1,27 @@
[
{
"_note": "Single-eye img+cd ensemble at refugelike with PairwiseAdditiveBridge at nt (eye-level fusion). For F3 confidence-strip panel expansion.",
"run_name": "experiments/phase3_v4/single_bcd_pairwise",
"reps": 10,
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.pairwise_bridge",
"class": "PairwiseAdditiveBridge",
"args": { "fusion_dim": 256 }
}
}
},
{
"_note": "Single-eye img+cd ensemble at refugelike with GatedAdditiveBridge at nt.",
"run_name": "experiments/phase3_v4/single_bcd_gated",
"reps": 10,
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.gated_bridge",
"class": "GatedAdditiveBridge",
"args": { "fusion_dim": 256 }
}
}
}
]
@@ -0,0 +1,39 @@
[
{
"_note": "Single-eye + all_losses + Hadamard FusionBridge. Pairs with the in-flight ensemble_single_refugelike (single+BCD+Hadamard) to isolate the BCD-vs-all-losses effect. v3 phase 3 originally showed BCD generalizes better; this re-establishes it in v4.",
"run_name": "experiments/phase3_v4/single_all_losses_hadamard",
"reps": 10,
"overrides": {
"training": { "tower_loss_mode": "all_losses" }
}
},
{
"_note": "Single-eye + BCD + ConcatBridge. Pairs with in-flight baseline (single+BCD+Hadamard) to isolate the bridge effect. Justifies why we use FusionBridge (Hadamard) as default.",
"run_name": "experiments/phase3_v4/single_bcd_concat",
"reps": 10,
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.concat_bridge",
"class": "ConcatBridge",
"args": { "fusion_dim": 256 }
}
}
},
{
"_note": "Single-eye + all_losses + ConcatBridge. Fourth cell of the BCD-vs-all-losses × Hadamard-vs-Concat 2x2.",
"run_name": "experiments/phase3_v4/single_all_losses_concat",
"reps": 10,
"overrides": {
"training": { "tower_loss_mode": "all_losses" }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.concat_bridge",
"class": "ConcatBridge",
"args": { "fusion_dim": 256 }
}
}
}
]
@@ -0,0 +1,7 @@
[
{
"_note": "Bilateral cd-only at refugelike. 10 reps. For Figure 4 bilateral cd column. Pairs with phase2_v4/cd_solo_single (single-eye) to show whether the bilateral hb fusion improves a pure-clinical model.",
"run_name": "experiments/phase4_v4/cd_solo_bilateral",
"reps": 10
}
]
@@ -0,0 +1,12 @@
[
{
"_note": "Bilateral img+cd ensemble at refugelike with HyperBridge mode = classic_bridge (Hadamard per-side projection + product) instead of the default embedding_mlp (concat + linear). For Figure 4 inset showing the bilateral-fusion bridge choice doesn't materially change the patient-level result.",
"run_name": "experiments/phase4_v4/ensemble_hb_classic_bridge",
"reps": 10,
"stage_overrides": {
"hb": {
"args": { "hidden_dim": 256, "mode": "classic_bridge" }
}
}
}
]
@@ -0,0 +1,10 @@
[
{
"_note": "Tritower (img+cd+geom) with the geom tower fed GT contour-rasterized masks instead of UNet predictions. Completes the geometry panel by disentangling 'GT signal is what matters' from 'vector form is what matters'. 10 reps at refugelike.",
"run_name": "experiments/phase6_v4/tritower_geom_gt",
"reps": 10,
"tower_overrides": {
"geom": { "args": { "seg_source": "gt" } }
}
}
]
@@ -0,0 +1,7 @@
[
{
"_note": "Geometry-vector injection from UNet-derived segmentation, bumped to 10 reps to round out the refugelike geometry panel alongside ensemble_fused/no_geom (0.8979, n=10), ensemble_fused/geom_gt (0.9121, n=10), tri_v1/baseline_tri (0.8932, n=10), and tri_v1/baseline_solo (0.6833, n=10). Use the existing ensemble_fused_geom_unet.json base config which already wires up the EPC geometry_vectors channel.",
"run_name": "experiments/phase6_v4/ensemble_geom_vec_unet",
"reps": 10
}
]
@@ -0,0 +1,7 @@
[
{
"_note": "Clinical-only floor reference under the new baseline framework. No img backbone change since there is no img tower.",
"run_name": "experiments/refuge_v2m_baseline/cd_solo",
"reps": 3
}
]
@@ -0,0 +1,149 @@
[
{
"_note": "Ortho-wrapped Hadamard at w=0.1 — best ortho weight from prior sweep. Bumped to 10 reps after 3-rep showed +0.009 vs anchor.",
"run_name": "experiments/refuge_v2m_baseline/ensemble_ortho_w0.1",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.fusion_bridge",
"inner_class": "FusionBridge",
"inner_args": { "fusion_dim": 256 }
}
}
}
},
{
"_note": "ConcatBridge alternative to Hadamard.",
"run_name": "experiments/refuge_v2m_baseline/ensemble_concat",
"reps": 3,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.concat_bridge",
"class": "ConcatBridge",
"args": { "fusion_dim": 256 }
}
}
},
{
"_note": "PairwiseAdditiveBridge — for N=2 streams this reduces to ≈ FusionBridge additive=True. Bumped to 10 reps after 3-rep showed +0.017 vs anchor (but with a suspicious val/test gap).",
"run_name": "experiments/refuge_v2m_baseline/ensemble_pairwise",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.pairwise_bridge",
"class": "PairwiseAdditiveBridge",
"args": { "fusion_dim": 256 }
}
}
},
{
"_note": "GatedAdditiveBridge — per-sample sigmoid gates over each stream.",
"run_name": "experiments/refuge_v2m_baseline/ensemble_gated",
"reps": 3,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.gated_bridge",
"class": "GatedAdditiveBridge",
"args": { "fusion_dim": 256 }
}
}
},
{
"_note": "Ortho w=0.1 wrapping ConcatBridge inner.",
"run_name": "experiments/refuge_v2m_baseline/ensemble_ortho_concat_w0.1",
"reps": 3,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.concat_bridge",
"inner_class": "ConcatBridge",
"inner_args": { "fusion_dim": 256 }
}
}
}
},
{
"_note": "Ortho w=0.1 wrapping PairwiseAdditiveBridge inner.",
"run_name": "experiments/refuge_v2m_baseline/ensemble_ortho_pairwise_w0.1",
"reps": 3,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.pairwise_bridge",
"inner_class": "PairwiseAdditiveBridge",
"inner_args": { "fusion_dim": 256 }
}
}
}
},
{
"_note": "Ortho w=0.1 wrapping GatedAdditiveBridge inner.",
"run_name": "experiments/refuge_v2m_baseline/ensemble_ortho_gated_w0.1",
"reps": 3,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.gated_bridge",
"inner_class": "GatedAdditiveBridge",
"inner_args": { "fusion_dim": 256 }
}
}
}
},
{
"_note": "Bottleneck (fusion_dim=8) — tests whether the new backbone still survives aggressive compression.",
"run_name": "experiments/refuge_v2m_baseline/ensemble_bottleneck",
"reps": 3,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": { "args": { "fusion_dim": 8 } }
}
}
]
@@ -0,0 +1,19 @@
[
{
"_note": "Geometry-vector injection (GT contours, EPC) added to img+cd ensemble at refuge V2-M backbone. Replaces refugelike's ensemble_fused/geom_gt (0.9121) at the new backbone. For the geometry panel.",
"run_name": "experiments/refuge_v2m_baseline/ensemble_geom_vec_gt",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
}
},
{
"_note": "Geometry-vector injection via UNet-derived geometry (not GT). Same architecture; uses the per-fold-finetuned UNet segmenter EPC channel. Tests whether GT vs predicted segmentation matters under the new backbone.",
"run_name": "experiments/refuge_v2m_baseline/ensemble_geom_vec_unet",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
}
}
]
@@ -0,0 +1,10 @@
[
{
"_note": "Image-only (no cd) at the new refuge_efficientnet_v2_m baseline. 10 reps — definite test, establishes how much cd contributes to the ensemble.",
"run_name": "experiments/refuge_v2m_baseline/img_solo",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
}
}
]
@@ -0,0 +1,7 @@
[
{
"_note": "Bilateral img-only with refugelike — missing corner of the img_solo single-vs-bilateral × refugelike-vs-V2M grid. (Bilateral V2-M already exists at 0.8923.)",
"run_name": "experiments/refuge_v2m_baseline/img_solo_bilateral_refugelike",
"reps": 10
}
]
@@ -0,0 +1,16 @@
[
{
"_note": "Single-eye ensemble (img+cd, eval at nt) under the original refugelike backbone. Pairs with tri_v1/baseline_ensemble (bilateral, 0.8958 ± 0.017 at 10 reps) to standardize the v3 single-vs-bilateral comparison.",
"run_name": "experiments/refuge_v2m_baseline/ensemble_single_refugelike",
"reps": 10
},
{
"_note": "Single-eye ensemble under the new refuge V2-M backbone. Pairs with efficientnet/refuge_efficientnetv2_m (bilateral, 0.9132 ± 0.019 at 10 reps).",
"run_name": "experiments/refuge_v2m_baseline/ensemble_single_refuge_v2m",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
}
}
]
@@ -0,0 +1,16 @@
[
{
"_note": "Single-eye img-only (eval at img_fuse) under the original refugelike backbone. Goes with img_solo_bilateral_refugelike below to fill the 2x2 grid.",
"run_name": "experiments/refuge_v2m_baseline/img_solo_single_refugelike",
"reps": 10
},
{
"_note": "Single-eye img-only under the new refuge V2-M backbone. Pairs with refuge_v2m_baseline/img_solo (bilateral, 0.8923 ± 0.018 at 10 reps).",
"run_name": "experiments/refuge_v2m_baseline/img_solo_single_refuge_v2m",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
}
}
]
@@ -0,0 +1,44 @@
[
{
"_note": "Tritower (img + cd + geom) at the new img backbone. 10 reps — definite test, establishes whether the geom tower adds value over img+cd.",
"run_name": "experiments/refuge_v2m_baseline/tritower",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
}
},
{
"_note": "Tritower + OrthoBridge w=0.1 (best ortho weight from earlier sweep) wrapping Hadamard inner. Bumped to 10 reps after 3-rep showed 0.9059 with tight std — wanted to confirm against plain tritower's 0.9040.",
"run_name": "experiments/refuge_v2m_baseline/tritower_ortho_w0.1",
"reps": 10,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.fusion_bridge",
"inner_class": "FusionBridge",
"inner_args": { "fusion_dim": 256 }
}
}
}
},
{
"_note": "Tritower + bottleneck (fusion_dim=8) — tests whether the strong img backbone can survive aggressive bottlenecking.",
"run_name": "experiments/refuge_v2m_baseline/tritower_bottleneck",
"reps": 3,
"tower_overrides": {
"img": { "args": { "backbone": "refuge_efficientnet_v2_m", "freeze_ratio": 0.0 } }
},
"stage_overrides": {
"nt": { "args": { "fusion_dim": 8 } }
}
}
]
@@ -0,0 +1,7 @@
[
{
"_note": "CD tower only, regression. Ablation vs baseline_reg_nt50 to isolate the contribution of clinical data.",
"run_name": "experiments/reg_head/cd_solo_reg",
"reps": 3
}
]
@@ -0,0 +1,7 @@
[
{
"_note": "Image tower only, regression. Ablation vs baseline_reg_nt50 to isolate the contribution of fundus images.",
"run_name": "experiments/reg_head/img_solo_reg",
"reps": 3
}
]
@@ -0,0 +1,97 @@
[
{
"_note": "baseline_reg at nt=50 (vs default 36). All heads regression on vf_md, label_filter expanded to [0,1,2]. Bumped to 10 reps for final regression-head reporting + 3-bin severity confusion matrix.",
"run_name": "experiments/reg_head/baseline_reg_nt50",
"reps": 10,
"overrides": {
"label_filter": [0, 1, 2]
},
"stage_overrides": {
"nt": { "epochs": 50 },
"img_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"cd_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"nt_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"hb_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
}
}
},
{
"_note": "baseline_reg at nt=75",
"run_name": "experiments/reg_head/baseline_reg_nt75",
"reps": 3,
"overrides": {
"label_filter": [0, 1, 2]
},
"stage_overrides": {
"nt": { "epochs": 75 },
"img_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"cd_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"nt_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"hb_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
}
}
},
{
"_note": "baseline_reg at nt=100",
"run_name": "experiments/reg_head/baseline_reg_nt100",
"reps": 3,
"overrides": {
"label_filter": [0, 1, 2]
},
"stage_overrides": {
"nt": { "epochs": 100 },
"img_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"cd_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"nt_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"hb_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
}
}
}
]
@@ -0,0 +1,116 @@
[
{
"_note": "1) Baseline classification — sanity check that the runner refactor didn't break anything. Should match baseline_ensemble at 0.896.",
"run_name": "experiments/reg_head/baseline_class",
"reps": 3
},
{
"_note": "2) Same architecture as baseline, but all heads (img_aux, cd_aux, nt_head, hb_head) are regression heads targeting vf_md. label_filter expanded to include suspect patients (label=2) since regression handles continuous targets naturally.",
"run_name": "experiments/reg_head/baseline_reg",
"reps": 3,
"overrides": {
"label_filter": [0, 1, 2]
},
"stage_overrides": {
"img_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"cd_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"nt_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"hb_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
}
}
},
{
"_note": "3) OrthoBridge (w=0.1) wrapping Hadamard inner + regression heads",
"run_name": "experiments/reg_head/ortho_reg",
"reps": 3,
"overrides": {
"label_filter": [0, 1, 2]
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.ortho_bridge",
"class": "OrthoBridge",
"args": {
"fusion_dim": 256,
"ortho_weight": 0.1,
"inner_module": "v4.classes.bridges.fusion_bridge",
"inner_class": "FusionBridge",
"inner_args": { "fusion_dim": 256 }
}
},
"img_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"cd_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"nt_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"hb_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
}
}
},
{
"_note": "4) PairwiseAdditiveBridge + regression heads",
"run_name": "experiments/reg_head/pairwise_reg",
"reps": 3,
"overrides": {
"label_filter": [0, 1, 2]
},
"stage_overrides": {
"nt": {
"module": "v4.classes.bridges.pairwise_bridge",
"class": "PairwiseAdditiveBridge",
"args": { "fusion_dim": 256 }
},
"img_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"cd_aux": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"nt_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
},
"hb_head": {
"module": "v4.classes.heads.regression",
"class": "RegressionHead",
"args": { "dropout": 0.3, "target_key": "vf_md", "loss": "mse" }
}
}
}
]

Some files were not shown because too many files have changed in this diff Show More