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.
@@ -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()}")
|
||||
|
||||
|
||||
@@ -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,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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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 nasal–temporal 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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))},
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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)), {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 474 KiB |
|
After Width: | Height: | Size: 292 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 134 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
|
||||
|
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 337 KiB |
|
After Width: | Height: | Size: 460 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
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
|
||||
|
|
After Width: | Height: | Size: 57 KiB |
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||