Add new regression and ensemble experiment configurations for V2-M and OrthoBridge
- Introduced multiple regression experiment configurations targeting vf_md, including: - cd_solo_reg_set.json: CD tower only regression setup. - img_solo_reg_set.json: Image tower only regression setup. - reg_head_epoch_sweep.json: Baseline regression sweeps at different epochs (50, 75, 100). - reg_head_set.json: Various regression setups including baseline and OrthoBridge configurations. - single_eye_reg.json: Single-eye regression setup for worst-eye aggregation analysis. - Added ensemble configurations for OrthoBridge with different inner bridges: - ortho_alts_ensemble.json: Ensemble tests with ConcatBridge, PairwiseAdditiveBridge, and GatedAdditiveBridge. - ortho_alts_tritower.json: Tritower tests with the same inner bridges. - Created V2-M specific configurations: - baseline_reg_nt50.json: Regression baseline with V2-M backbone. - geom_vec_gt.json and geom_vec_unet.json: Geometry vector injection experiments with V2-M. - single_l1_bridges.json: Single-eye ensemble experiments with various bridge types. - tritower_geom_gt.json: Tritower setup with GT contour-rasterized masks. - Promoted existing experiments to higher repetitions for robustness.
This commit is contained in:
@@ -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]
|
||||
|
||||
+13
-1
@@ -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,
|
||||
)
|
||||
|
||||
+89
-40
@@ -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():
|
||||
|
||||
+61
-18
@@ -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)), {}
|
||||
Reference in New Issue
Block a user