logging rework temp save
This commit is contained in:
@@ -20,6 +20,13 @@ def _default_slot_descriptors(patient_col: str, label_col: str) -> dict[str, Slo
|
||||
required=True,
|
||||
shape_hint="scalar",
|
||||
),
|
||||
"eye_id_1": SlotDescriptor(
|
||||
key="eye_id_1",
|
||||
kind="id",
|
||||
description="Eye side identifier (OD/OS)",
|
||||
required=False,
|
||||
shape_hint="scalar",
|
||||
),
|
||||
"label_1": SlotDescriptor(
|
||||
key="label_1",
|
||||
kind="label",
|
||||
@@ -53,6 +60,7 @@ def _row_to_sample(
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id_1": row[patient_col],
|
||||
"eye_id_1": str(row.get("eyeID", "")),
|
||||
"label_1": row[label_col],
|
||||
"image_1": clinical.get_image_path(row) if hasattr(clinical, "get_image_path") else None,
|
||||
"matrix_1": clinical.vectorize_row(row) if hasattr(clinical, "vectorize_row") else None,
|
||||
|
||||
+209
-32
@@ -34,6 +34,7 @@ class SingleEyeHT(nn.Module):
|
||||
num_classes: int,
|
||||
md_hidden_dim: int = 128,
|
||||
fusion_dim: int = 256,
|
||||
bridge_mode: str = "fused",
|
||||
):
|
||||
super().__init__()
|
||||
self.img_tower = ImageTower(
|
||||
@@ -52,7 +53,7 @@ class SingleEyeHT(nn.Module):
|
||||
meta_dim=self.md_tower.out_dim,
|
||||
num_classes=num_classes,
|
||||
fusion_dim=fusion_dim,
|
||||
mode="fused",
|
||||
mode=bridge_mode,
|
||||
use_se=False,
|
||||
)
|
||||
|
||||
@@ -61,8 +62,8 @@ class SingleEyeHT(nn.Module):
|
||||
return self.img_tower.transform
|
||||
|
||||
def forward(self, x: torch.Tensor, meta: torch.Tensor) -> torch.Tensor:
|
||||
img_feats = self.img_tower(x)
|
||||
md_feats = self.md_tower(meta)
|
||||
img_feats = None if self.bridge.mode == "metadata_only" else self.img_tower(x)
|
||||
md_feats = None if self.bridge.mode == "image_only" else self.md_tower(meta)
|
||||
out_f, _, _ = self.bridge(img_feats, md_feats)
|
||||
return out_f
|
||||
|
||||
@@ -214,11 +215,15 @@ def _set_requires_grad(module: nn.Module, enabled: bool) -> None:
|
||||
|
||||
|
||||
def _set_single_phase(model: SingleEyeHT, phase: str) -> None:
|
||||
bridge_mode = model.bridge.mode
|
||||
# Ablation modes have no fusion bridge; fused_warmup is meaningless — treat as tower_warmup
|
||||
if bridge_mode in ("image_only", "metadata_only") and phase == "fused_warmup":
|
||||
phase = "tower_warmup"
|
||||
if phase == "tower_warmup":
|
||||
_set_requires_grad(model.img_tower, True)
|
||||
_set_requires_grad(model.md_tower, True)
|
||||
_set_requires_grad(model.bridge.classifier_img, True)
|
||||
_set_requires_grad(model.bridge.classifier_md, True)
|
||||
_set_requires_grad(model.img_tower, bridge_mode != "metadata_only")
|
||||
_set_requires_grad(model.md_tower, bridge_mode != "image_only")
|
||||
_set_requires_grad(model.bridge.classifier_img, bridge_mode != "metadata_only")
|
||||
_set_requires_grad(model.bridge.classifier_md, bridge_mode != "image_only")
|
||||
_set_requires_grad(model.bridge.W_img, False)
|
||||
_set_requires_grad(model.bridge.W_md, False)
|
||||
_set_requires_grad(model.bridge.classifier_fused, False)
|
||||
@@ -282,19 +287,33 @@ def train_single_epoch(
|
||||
x = x.to(device)
|
||||
m = m.to(device)
|
||||
y = _to_label_tensor(y, device)
|
||||
img_feats = model.img_tower(x)
|
||||
md_feats = model.md_tower(m)
|
||||
bridge_mode = model.bridge.mode
|
||||
img_feats = None if bridge_mode == "metadata_only" else model.img_tower(x)
|
||||
md_feats = None if bridge_mode == "image_only" else model.md_tower(m)
|
||||
|
||||
if phase == "tower_warmup":
|
||||
logits_i = model.bridge.classifier_img(img_feats)
|
||||
logits_m = model.bridge.classifier_md(md_feats)
|
||||
loss = 0.5 * (F.cross_entropy(logits_i, y) + F.cross_entropy(logits_m, y))
|
||||
logits = 0.5 * (F.softmax(logits_i, dim=1) + F.softmax(logits_m, dim=1))
|
||||
if bridge_mode == "metadata_only":
|
||||
logits = model.bridge.classifier_md(md_feats)
|
||||
loss = F.cross_entropy(logits, y)
|
||||
elif bridge_mode == "image_only":
|
||||
logits = model.bridge.classifier_img(img_feats)
|
||||
loss = F.cross_entropy(logits, y)
|
||||
else:
|
||||
logits_i = model.bridge.classifier_img(img_feats)
|
||||
logits_m = model.bridge.classifier_md(md_feats)
|
||||
loss = 0.5 * (F.cross_entropy(logits_i, y) + F.cross_entropy(logits_m, y))
|
||||
logits = 0.5 * (F.softmax(logits_i, dim=1) + F.softmax(logits_m, dim=1))
|
||||
elif phase == "fused_warmup":
|
||||
logits, _, _ = model.bridge(img_feats, md_feats)
|
||||
loss = F.cross_entropy(logits, y)
|
||||
else:
|
||||
if random() < bcd_prob:
|
||||
if bridge_mode == "metadata_only":
|
||||
logits = model.bridge.classifier_md(md_feats)
|
||||
loss = F.cross_entropy(logits, y)
|
||||
elif bridge_mode == "image_only":
|
||||
logits = model.bridge.classifier_img(img_feats)
|
||||
loss = F.cross_entropy(logits, y)
|
||||
elif random() < bcd_prob:
|
||||
if random() < 0.5:
|
||||
logits = model.bridge.classifier_img(img_feats)
|
||||
else:
|
||||
@@ -447,6 +466,79 @@ def collect_probs_classic(
|
||||
return np.concatenate(y_chunks), np.concatenate(p_chunks, axis=0)
|
||||
|
||||
|
||||
def collect_probs_ensemble_pereye(
|
||||
model: "SingleEyeHT",
|
||||
loader: DataLoader,
|
||||
device: torch.device,
|
||||
*,
|
||||
return_ids: bool = False,
|
||||
):
|
||||
"""
|
||||
Per-patient, per-eye probs for all 3 heads from a bilateral loader (ensemble mode).
|
||||
|
||||
OD corresponds to image_1/matrix_1; OS to image_2/matrix_2.
|
||||
Arrays are in patient order (not interleaved at sample level).
|
||||
|
||||
Returns:
|
||||
(y, pf_od, pi_od, pm_od, pf_os, pi_os, pm_os)
|
||||
or, when return_ids=True:
|
||||
(y, pf_od, pi_od, pm_od, pf_os, pi_os, pm_os, patient_ids)
|
||||
|
||||
Patient-level averaged ensemble probs can be recovered as:
|
||||
p_en = 0.5 * (pf_od + pf_os)
|
||||
"""
|
||||
model.eval()
|
||||
y_chunks: list = []
|
||||
pf_od_c, pi_od_c, pm_od_c = [], [], []
|
||||
pf_os_c, pi_os_c, pm_os_c = [], [], []
|
||||
id_chunks: list[str] = []
|
||||
|
||||
with torch.no_grad():
|
||||
for batch in loader:
|
||||
x1 = batch.get("image_1"); m1 = batch.get("matrix_1")
|
||||
x2 = batch.get("image_2"); m2 = batch.get("matrix_2")
|
||||
y = batch.get("label_1")
|
||||
if not (torch.is_tensor(x1) and torch.is_tensor(m1) and
|
||||
torch.is_tensor(x2) and torch.is_tensor(m2)):
|
||||
continue
|
||||
y_t = _to_label_tensor(y, device)
|
||||
|
||||
def _fwd(x, m):
|
||||
img_feats = None if model.bridge.mode == "metadata_only" else model.img_tower(x.to(device))
|
||||
md_feats = None if model.bridge.mode == "image_only" else model.md_tower(m.to(device))
|
||||
out_f, out_i, out_m = model.bridge(img_feats, md_feats)
|
||||
pf = F.softmax(out_f, dim=1)
|
||||
pi = F.softmax(out_i, dim=1) if out_i is not None else pf
|
||||
pm = F.softmax(out_m, dim=1) if out_m is not None else pf
|
||||
return pf, pi, pm
|
||||
|
||||
pf_od, pi_od, pm_od = _fwd(x1, m1)
|
||||
pf_os, pi_os, pm_os = _fwd(x2, m2)
|
||||
|
||||
y_chunks.append(y_t.cpu().numpy())
|
||||
pf_od_c.append(pf_od.cpu().numpy()); pi_od_c.append(pi_od.cpu().numpy()); pm_od_c.append(pm_od.cpu().numpy())
|
||||
pf_os_c.append(pf_os.cpu().numpy()); pi_os_c.append(pi_os.cpu().numpy()); pm_os_c.append(pm_os.cpu().numpy())
|
||||
|
||||
if return_ids:
|
||||
ids = batch.get("id_1", [""] * len(y_t))
|
||||
if torch.is_tensor(ids):
|
||||
ids = ids.tolist()
|
||||
id_chunks.extend([str(i) for i in ids])
|
||||
|
||||
if not y_chunks:
|
||||
z = np.zeros((0, 0), dtype=np.float32)
|
||||
empty_i = np.array([], dtype=np.int64)
|
||||
base = (empty_i, z, z, z, z, z, z)
|
||||
return base + (np.array([], dtype=object),) if return_ids else base
|
||||
|
||||
y = np.concatenate(y_chunks)
|
||||
pf_od = np.concatenate(pf_od_c, axis=0); pi_od = np.concatenate(pi_od_c, axis=0); pm_od = np.concatenate(pm_od_c, axis=0)
|
||||
pf_os = np.concatenate(pf_os_c, axis=0); pi_os = np.concatenate(pi_os_c, axis=0); pm_os = np.concatenate(pm_os_c, axis=0)
|
||||
if return_ids:
|
||||
return y, pf_od, pi_od, pm_od, pf_os, pi_os, pm_os, np.array(id_chunks, dtype=object)
|
||||
return y, pf_od, pi_od, pm_od, pf_os, pi_os, pm_os
|
||||
|
||||
|
||||
def collect_probs_ensemble(
|
||||
model: SingleEyeHT,
|
||||
loader: DataLoader,
|
||||
@@ -532,15 +624,22 @@ def collect_probs_single_components(
|
||||
device: torch.device,
|
||||
*,
|
||||
aggregate_patient: bool,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
||||
return_logits: bool = False,
|
||||
):
|
||||
"""
|
||||
Collect fused/img/md probabilities for SingleEyeHT.
|
||||
Collect fused/img/md probabilities (and optionally raw logits) for SingleEyeHT.
|
||||
- aggregate_patient=False: eye-level (OD/OS as independent samples)
|
||||
- aggregate_patient=True : patient-level (average OD/OS per head)
|
||||
- return_logits=False: returns (y, probs_f, probs_i, probs_m)
|
||||
- return_logits=True: returns (y, probs_f, probs_i, probs_m,
|
||||
logits_f, logits_i, logits_m)
|
||||
Note: logits are averaged across eyes when aggregate_patient=True,
|
||||
which is equivalent to averaging in logit space (before softmax).
|
||||
"""
|
||||
model.eval()
|
||||
y_chunks = []
|
||||
pf_chunks, pi_chunks, pm_chunks = [], [], []
|
||||
lf_chunks, li_chunks, lm_chunks = [], [], []
|
||||
with torch.no_grad():
|
||||
for batch in loader:
|
||||
x1 = batch.get("image_1"); m1 = batch.get("matrix_1")
|
||||
@@ -550,40 +649,118 @@ def collect_probs_single_components(
|
||||
continue
|
||||
y_t = _to_label_tensor(y, device)
|
||||
|
||||
def _per_eye_probs(x, m):
|
||||
img_feats = model.img_tower(x.to(device))
|
||||
md_feats = model.md_tower(m.to(device))
|
||||
def _per_eye(x, m):
|
||||
img_feats = None if model.bridge.mode == "metadata_only" else model.img_tower(x.to(device))
|
||||
md_feats = None if model.bridge.mode == "image_only" else model.md_tower(m.to(device))
|
||||
out_f, out_i, out_m = model.bridge(img_feats, md_feats)
|
||||
return (
|
||||
F.softmax(out_f, dim=1),
|
||||
F.softmax(out_i, dim=1),
|
||||
F.softmax(out_m, dim=1),
|
||||
)
|
||||
pf = F.softmax(out_f, dim=1)
|
||||
pi = F.softmax(out_i, dim=1) if out_i is not None else pf
|
||||
pm = F.softmax(out_m, dim=1) if out_m is not None else pf
|
||||
lf = out_f
|
||||
li = out_i if out_i is not None else out_f
|
||||
lm = out_m if out_m is not None else out_f
|
||||
return pf, pi, pm, lf, li, lm
|
||||
|
||||
pf_od, pi_od, pm_od = _per_eye_probs(x1, m1)
|
||||
pf_os, pi_os, pm_os = _per_eye_probs(x2, m2)
|
||||
pf_od, pi_od, pm_od, lf_od, li_od, lm_od = _per_eye(x1, m1)
|
||||
pf_os, pi_os, pm_os, lf_os, li_os, lm_os = _per_eye(x2, m2)
|
||||
|
||||
if aggregate_patient:
|
||||
y_chunks.append(y_t.cpu().numpy())
|
||||
pf_chunks.append((0.5 * (pf_od + pf_os)).cpu().numpy())
|
||||
pi_chunks.append((0.5 * (pi_od + pi_os)).cpu().numpy())
|
||||
pm_chunks.append((0.5 * (pm_od + pm_os)).cpu().numpy())
|
||||
lf_chunks.append((0.5 * (lf_od + lf_os)).cpu().numpy())
|
||||
li_chunks.append((0.5 * (li_od + li_os)).cpu().numpy())
|
||||
lm_chunks.append((0.5 * (lm_od + lm_os)).cpu().numpy())
|
||||
else:
|
||||
y_np = y_t.cpu().numpy()
|
||||
y_chunks += [y_np, y_np]
|
||||
pf_chunks += [pf_od.cpu().numpy(), pf_os.cpu().numpy()]
|
||||
pi_chunks += [pi_od.cpu().numpy(), pi_os.cpu().numpy()]
|
||||
pm_chunks += [pm_od.cpu().numpy(), pm_os.cpu().numpy()]
|
||||
lf_chunks += [lf_od.cpu().numpy(), lf_os.cpu().numpy()]
|
||||
li_chunks += [li_od.cpu().numpy(), li_os.cpu().numpy()]
|
||||
lm_chunks += [lm_od.cpu().numpy(), lm_os.cpu().numpy()]
|
||||
|
||||
if not y_chunks:
|
||||
z = np.zeros((0, 0), dtype=np.float32)
|
||||
if return_logits:
|
||||
return np.array([], dtype=np.int64), z, z, z, z, z, z
|
||||
return np.array([], dtype=np.int64), z, z, z
|
||||
return (
|
||||
np.concatenate(y_chunks),
|
||||
np.concatenate(pf_chunks, axis=0),
|
||||
np.concatenate(pi_chunks, axis=0),
|
||||
np.concatenate(pm_chunks, axis=0),
|
||||
)
|
||||
|
||||
y = np.concatenate(y_chunks)
|
||||
pf = np.concatenate(pf_chunks, axis=0)
|
||||
pi = np.concatenate(pi_chunks, axis=0)
|
||||
pm = np.concatenate(pm_chunks, axis=0)
|
||||
if return_logits:
|
||||
lf = np.concatenate(lf_chunks, axis=0)
|
||||
li = np.concatenate(li_chunks, axis=0)
|
||||
lm = np.concatenate(lm_chunks, axis=0)
|
||||
return y, pf, pi, pm, lf, li, lm
|
||||
return y, pf, pi, pm
|
||||
|
||||
|
||||
def collect_probs_eye_level(
|
||||
model: "SingleEyeHT",
|
||||
loader: DataLoader,
|
||||
device: torch.device,
|
||||
*,
|
||||
return_ids: bool = False,
|
||||
):
|
||||
"""
|
||||
Collect fused/img/md probabilities from a single-eye loader (image_1/matrix_1 only).
|
||||
Used for eval-mode passes over the training set.
|
||||
|
||||
Returns (y, probs_f, probs_i, probs_m) or, when return_ids=True,
|
||||
(y, probs_f, probs_i, probs_m, sample_ids) where sample_ids is an
|
||||
array of strings like "2OD", "4OS".
|
||||
"""
|
||||
model.eval()
|
||||
y_chunks, pf_chunks, pi_chunks, pm_chunks, id_chunks = [], [], [], [], []
|
||||
with torch.no_grad():
|
||||
for batch in loader:
|
||||
x = batch.get("image_1")
|
||||
m = batch.get("matrix_1")
|
||||
y = batch.get("label_1")
|
||||
if not (torch.is_tensor(x) and torch.is_tensor(m)):
|
||||
continue
|
||||
y_t = _to_label_tensor(y, device)
|
||||
img_feats = None if model.bridge.mode == "metadata_only" else model.img_tower(x.to(device))
|
||||
md_feats = None if model.bridge.mode == "image_only" else model.md_tower(m.to(device))
|
||||
out_f, out_i, out_m = model.bridge(img_feats, md_feats)
|
||||
pf = F.softmax(out_f, dim=1)
|
||||
pi = F.softmax(out_i, dim=1) if out_i is not None else pf
|
||||
pm = F.softmax(out_m, dim=1) if out_m is not None else pf
|
||||
y_chunks.append(y_t.cpu().numpy())
|
||||
pf_chunks.append(pf.cpu().numpy())
|
||||
pi_chunks.append(pi.cpu().numpy())
|
||||
pm_chunks.append(pm.cpu().numpy())
|
||||
if return_ids:
|
||||
ids = batch.get("id_1", [""] * len(y_t))
|
||||
eyes = batch.get("eye_id_1", [""] * len(y_t))
|
||||
# ids/eyes may be tensors (int) or lists of strings
|
||||
if torch.is_tensor(ids):
|
||||
ids = ids.tolist()
|
||||
if torch.is_tensor(eyes):
|
||||
eyes = eyes.tolist()
|
||||
id_chunks.extend(
|
||||
[f"{pid}{eye}" for pid, eye in zip(ids, eyes)]
|
||||
)
|
||||
|
||||
if not y_chunks:
|
||||
z = np.zeros((0, 0), dtype=np.float32)
|
||||
empty_ids = np.array([], dtype=object)
|
||||
if return_ids:
|
||||
return np.array([], dtype=np.int64), z, z, z, empty_ids
|
||||
return np.array([], dtype=np.int64), z, z, z
|
||||
|
||||
y = np.concatenate(y_chunks)
|
||||
pf = np.concatenate(pf_chunks, axis=0)
|
||||
pi = np.concatenate(pi_chunks, axis=0)
|
||||
pm = np.concatenate(pm_chunks, axis=0)
|
||||
if return_ids:
|
||||
return y, pf, pi, pm, np.array(id_chunks, dtype=object)
|
||||
return y, pf, pi, pm
|
||||
|
||||
|
||||
def collect_probs_bilateral_components(
|
||||
|
||||
@@ -50,14 +50,15 @@ def _correct_iop(raw_iop: float, pachy: float) -> float:
|
||||
|
||||
|
||||
def _apply_iop_and_drop_md(df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""Add IOP_raw/IOP_corr and drop VF_MD if present (in-place safe)."""
|
||||
"""Add IOP_raw/IOP_corr and drop source IOP columns + VF_MD if present (in-place safe)."""
|
||||
df["IOP_raw"] = df.apply(_pick_iop, axis=1)
|
||||
pachy = df.get("Pachymetry", pd.Series(np.nan, index=df.index))
|
||||
df["IOP_corr"] = [
|
||||
_correct_iop(r, p) for r, p in zip(df["IOP_raw"].values, pachy.values)
|
||||
]
|
||||
if "VF_MD" in df.columns:
|
||||
df.drop(columns=["VF_MD"], inplace=True)
|
||||
drop_cols = [c for c in ("Pneumatic", "Perkins", "VF_MD") if c in df.columns]
|
||||
if drop_cols:
|
||||
df.drop(columns=drop_cols, inplace=True)
|
||||
return df
|
||||
|
||||
|
||||
|
||||
@@ -109,3 +109,23 @@ class FoldArtifacts:
|
||||
probs_bilat: Optional[np.ndarray]
|
||||
y_true_fused: Optional[np.ndarray] = None
|
||||
probs_fused: Optional[np.ndarray] = None
|
||||
probs_ensemble_img: Optional[np.ndarray] = None
|
||||
probs_ensemble_md: Optional[np.ndarray] = None
|
||||
probs_classic_img: Optional[np.ndarray] = None
|
||||
probs_classic_md: Optional[np.ndarray] = None
|
||||
# per-eye (pre-averaged) versions for ensemble mode
|
||||
y_true_ensemble_pereye: Optional[np.ndarray] = None
|
||||
probs_ensemble_pereye: Optional[np.ndarray] = None
|
||||
probs_ensemble_img_pereye: Optional[np.ndarray] = None
|
||||
probs_ensemble_md_pereye: Optional[np.ndarray] = None
|
||||
# raw logits (before softmax) — patient-level
|
||||
logits_ensemble: Optional[np.ndarray] = None
|
||||
logits_ensemble_img: Optional[np.ndarray] = None
|
||||
logits_ensemble_md: Optional[np.ndarray] = None
|
||||
logits_classic: Optional[np.ndarray] = None
|
||||
logits_classic_img: Optional[np.ndarray] = None
|
||||
logits_classic_md: Optional[np.ndarray] = None
|
||||
# raw logits — per-eye
|
||||
logits_ensemble_pereye: Optional[np.ndarray] = None
|
||||
logits_ensemble_img_pereye: Optional[np.ndarray] = None
|
||||
logits_ensemble_md_pereye: Optional[np.ndarray] = None
|
||||
|
||||
+407
-9
@@ -39,6 +39,8 @@ from classes.v2.models import (
|
||||
collect_probs_bilateral_components,
|
||||
collect_probs_classic,
|
||||
collect_probs_ensemble,
|
||||
collect_probs_ensemble_pereye,
|
||||
collect_probs_eye_level,
|
||||
collect_probs_fused,
|
||||
collect_probs_single_components,
|
||||
train_bilateral_epoch,
|
||||
@@ -59,6 +61,98 @@ from classes.v2.utils import (
|
||||
from classes.v2.hypertower_logger import HypertowerLogger
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fusion-event helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _fusion_events(
|
||||
y: np.ndarray,
|
||||
pf: np.ndarray,
|
||||
pi: np.ndarray,
|
||||
pm: np.ndarray,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Count fusion corrections and errors.
|
||||
- correction: fused correct, both img and md wrong
|
||||
- error: fused wrong, both img and md correct
|
||||
Returns (n_corrections, n_errors).
|
||||
"""
|
||||
pred_f = pf.argmax(1); pred_i = pi.argmax(1); pred_m = pm.argmax(1)
|
||||
corr = int(((pred_f == y) & (pred_i != y) & (pred_m != y)).sum())
|
||||
err = int(((pred_f != y) & (pred_i == y) & (pred_m == y)).sum())
|
||||
return corr, err
|
||||
|
||||
|
||||
def _cm_cells(y: np.ndarray, p: np.ndarray, num_classes: int) -> dict[str, int]:
|
||||
"""
|
||||
Return confusion matrix cells as a flat dict.
|
||||
Binary: keys tn/fp/fn/tp
|
||||
Multiclass: keys cm_{i}_{j} for true class i, predicted class j
|
||||
Returns empty dict if arrays are empty or wrong shape.
|
||||
"""
|
||||
if not y.size or p.ndim < 2 or p.shape[1] != num_classes:
|
||||
return {}
|
||||
pred = p.argmax(1)
|
||||
if num_classes == 2:
|
||||
tn = int(((pred == 0) & (y == 0)).sum())
|
||||
fp = int(((pred == 1) & (y == 0)).sum())
|
||||
fn = int(((pred == 0) & (y == 1)).sum())
|
||||
tp = int(((pred == 1) & (y == 1)).sum())
|
||||
return {"tn": tn, "fp": fp, "fn": fn, "tp": tp}
|
||||
# multiclass: full NxN matrix
|
||||
out: dict[str, int] = {}
|
||||
for i in range(num_classes):
|
||||
for j in range(num_classes):
|
||||
out[f"cm_{i}_{j}"] = int(((y == i) & (pred == j)).sum())
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-sample prediction logging
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _save_predictions_csv(
|
||||
fold_dir: Path,
|
||||
eval_mode: str,
|
||||
y_true: np.ndarray,
|
||||
heads: dict, # {"fused": probs_array, "img": probs_array, "md": probs_array, ...}
|
||||
suffix: str = "", # e.g. "_pereye"
|
||||
) -> None:
|
||||
"""
|
||||
Save a per-sample CSV with predicted class, per-class probabilities,
|
||||
and TP/FP/TN/FN (binary) or correct flag (multiclass) for every head.
|
||||
"""
|
||||
N = len(y_true)
|
||||
num_classes = next(p.shape[1] for p in heads.values() if p is not None)
|
||||
rows = []
|
||||
for i in range(N):
|
||||
true = int(y_true[i])
|
||||
row: dict = {"idx": i, "y_true": true}
|
||||
for head_name, probs in heads.items():
|
||||
if probs is None:
|
||||
continue
|
||||
pred = int(probs[i].argmax())
|
||||
row[f"pred_{head_name}"] = pred
|
||||
for c in range(num_classes):
|
||||
row[f"prob_{head_name}_c{c}"] = float(probs[i, c])
|
||||
if eval_mode == "binary":
|
||||
row[f"tp_{head_name}"] = int(pred == 1 and true == 1)
|
||||
row[f"fp_{head_name}"] = int(pred == 1 and true == 0)
|
||||
row[f"tn_{head_name}"] = int(pred == 0 and true == 0)
|
||||
row[f"fn_{head_name}"] = int(pred == 0 and true == 1)
|
||||
else:
|
||||
row[f"correct_{head_name}"] = int(pred == true)
|
||||
rows.append(row)
|
||||
|
||||
if not rows:
|
||||
return
|
||||
csv_path = fold_dir / f"predictions{suffix}.csv"
|
||||
with csv_path.open("w", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# V2HyperTower
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -156,6 +250,9 @@ class V2HyperTower:
|
||||
help="MDTower hidden dimension.")
|
||||
ap.add_argument("--fusion-dim", type=int, default=256,
|
||||
help="Bridge/BilateralBridge fusion dimension.")
|
||||
ap.add_argument("--bridge-mode", default="fused",
|
||||
choices=["fused", "image_only", "metadata_only"],
|
||||
help="Bridge fusion mode: fused (default), image_only, or metadata_only.")
|
||||
# Mixed patients
|
||||
ap.add_argument(
|
||||
"--exclude-mixed-patients",
|
||||
@@ -332,10 +429,73 @@ class V2HyperTower:
|
||||
np.save(fold_dir / "y_true.npy", artifacts.y_true_ensemble)
|
||||
if artifacts.probs_ensemble is not None:
|
||||
np.save(fold_dir / "probs_fused.npy", artifacts.probs_ensemble)
|
||||
if artifacts.probs_ensemble_img is not None:
|
||||
np.save(fold_dir / "probs_img.npy", artifacts.probs_ensemble_img)
|
||||
if artifacts.probs_ensemble_md is not None:
|
||||
np.save(fold_dir / "probs_md.npy", artifacts.probs_ensemble_md)
|
||||
if artifacts.probs_classic is not None:
|
||||
np.save(fold_dir / "probs_classic.npy", artifacts.probs_classic)
|
||||
if artifacts.probs_classic_img is not None:
|
||||
np.save(fold_dir / "probs_classic_img.npy", artifacts.probs_classic_img)
|
||||
if artifacts.probs_classic_md is not None:
|
||||
np.save(fold_dir / "probs_classic_md.npy", artifacts.probs_classic_md)
|
||||
if artifacts.y_true_ensemble_pereye is not None:
|
||||
np.save(fold_dir / "y_true_pereye.npy", artifacts.y_true_ensemble_pereye)
|
||||
if artifacts.probs_ensemble_pereye is not None:
|
||||
np.save(fold_dir / "probs_fused_pereye.npy", artifacts.probs_ensemble_pereye)
|
||||
if artifacts.probs_ensemble_img_pereye is not None:
|
||||
np.save(fold_dir / "probs_img_pereye.npy", artifacts.probs_ensemble_img_pereye)
|
||||
if artifacts.probs_ensemble_md_pereye is not None:
|
||||
np.save(fold_dir / "probs_md_pereye.npy", artifacts.probs_ensemble_md_pereye)
|
||||
if artifacts.logits_ensemble is not None:
|
||||
np.save(fold_dir / "logits_fused.npy", artifacts.logits_ensemble)
|
||||
if artifacts.logits_ensemble_img is not None:
|
||||
np.save(fold_dir / "logits_img.npy", artifacts.logits_ensemble_img)
|
||||
if artifacts.logits_ensemble_md is not None:
|
||||
np.save(fold_dir / "logits_md.npy", artifacts.logits_ensemble_md)
|
||||
if artifacts.logits_classic is not None:
|
||||
np.save(fold_dir / "logits_classic.npy", artifacts.logits_classic)
|
||||
if artifacts.logits_classic_img is not None:
|
||||
np.save(fold_dir / "logits_classic_img.npy", artifacts.logits_classic_img)
|
||||
if artifacts.logits_classic_md is not None:
|
||||
np.save(fold_dir / "logits_classic_md.npy", artifacts.logits_classic_md)
|
||||
if artifacts.logits_ensemble_pereye is not None:
|
||||
np.save(fold_dir / "logits_fused_pereye.npy", artifacts.logits_ensemble_pereye)
|
||||
if artifacts.logits_ensemble_img_pereye is not None:
|
||||
np.save(fold_dir / "logits_img_pereye.npy", artifacts.logits_ensemble_img_pereye)
|
||||
if artifacts.logits_ensemble_md_pereye is not None:
|
||||
np.save(fold_dir / "logits_md_pereye.npy", artifacts.logits_ensemble_md_pereye)
|
||||
if artifacts.probs_bilat is not None:
|
||||
np.save(fold_dir / "probs_bilat.npy", artifacts.probs_bilat)
|
||||
if artifacts.probs_fused is not None:
|
||||
np.save(fold_dir / "probs_fused_head.npy", artifacts.probs_fused)
|
||||
# y_true is shared across all heads for the same fold
|
||||
if artifacts.y_true_bilat is not None and artifacts.y_true_ensemble is None:
|
||||
np.save(fold_dir / "y_true.npy", artifacts.y_true_bilat)
|
||||
# per-sample prediction CSVs
|
||||
if artifacts.y_true_ensemble is not None:
|
||||
_save_predictions_csv(
|
||||
fold_dir, mode, artifacts.y_true_ensemble,
|
||||
{"fused": artifacts.probs_ensemble,
|
||||
"img": artifacts.probs_ensemble_img,
|
||||
"md": artifacts.probs_ensemble_md},
|
||||
)
|
||||
if artifacts.y_true_ensemble_pereye is not None:
|
||||
_save_predictions_csv(
|
||||
fold_dir, mode, artifacts.y_true_ensemble_pereye,
|
||||
{"fused": artifacts.probs_ensemble_pereye,
|
||||
"img": artifacts.probs_ensemble_img_pereye,
|
||||
"md": artifacts.probs_ensemble_md_pereye},
|
||||
suffix="_pereye",
|
||||
)
|
||||
if artifacts.y_true_classic is not None:
|
||||
_save_predictions_csv(
|
||||
fold_dir, mode, artifacts.y_true_classic,
|
||||
{"fused": artifacts.probs_classic,
|
||||
"img": artifacts.probs_classic_img,
|
||||
"md": artifacts.probs_classic_md},
|
||||
suffix="_classic",
|
||||
)
|
||||
|
||||
fold_csv = tm_dir / "fold_results.csv"
|
||||
csv_fields = list(FoldResult.__dataclass_fields__.keys())
|
||||
@@ -509,6 +669,7 @@ class V2HyperTower:
|
||||
augment=args.augment, clinical_data=data,
|
||||
num_classes=num_classes,
|
||||
md_hidden_dim=args.md_hidden_dim, fusion_dim=args.fusion_dim,
|
||||
bridge_mode=getattr(args, "bridge_mode", "fused"),
|
||||
).to(device)
|
||||
if run_bilat:
|
||||
bilateral = BilateralHT(
|
||||
@@ -525,6 +686,7 @@ class V2HyperTower:
|
||||
# ---- loaders ---------------------------------------------------
|
||||
use_balanced = bool(getattr(args, "balanced_sampling", False))
|
||||
train_single_loader = None
|
||||
train_eval_loader = None # non-shuffled, no sampler — for per-epoch train logging
|
||||
train_bilat_loader = None
|
||||
if run_single:
|
||||
single_sampler = build_balanced_sampler(eye_train) if use_balanced else None
|
||||
@@ -536,6 +698,13 @@ class V2HyperTower:
|
||||
sampler=single_sampler,
|
||||
**loader_kw,
|
||||
)
|
||||
train_eval_loader = make_loader(
|
||||
eye_train, slots_eye,
|
||||
image_transform=build_eval_transform(args.backbone),
|
||||
image_preprocessor=image_preprocessor,
|
||||
shuffle=False,
|
||||
**loader_kw,
|
||||
)
|
||||
if run_bilat:
|
||||
bilat_sampler = build_balanced_sampler(bilat_train) if use_balanced else None
|
||||
train_bilat_loader = make_loader(
|
||||
@@ -593,18 +762,68 @@ class V2HyperTower:
|
||||
"main_epoch_single", "main_epoch_bilat",
|
||||
"single_active", "bilat_active",
|
||||
"single_train_loss", "single_train_acc",
|
||||
# val — fused head (existing)
|
||||
"classic_val_auc", "classic_val_acc", "classic_val_n",
|
||||
"ensemble_val_auc", "ensemble_val_acc", "ensemble_val_n",
|
||||
"bilat_train_loss", "bilat_train_acc",
|
||||
"bilat_val_auc", "bilat_val_acc", "bilat_val_n",
|
||||
# val — img/md heads + fusion events
|
||||
"classic_val_auc_img", "classic_val_acc_img",
|
||||
"classic_val_auc_md", "classic_val_acc_md",
|
||||
"classic_val_fe_corr", "classic_val_fe_err",
|
||||
"ensemble_val_auc_img", "ensemble_val_acc_img",
|
||||
"ensemble_val_auc_md", "ensemble_val_acc_md",
|
||||
"ensemble_val_fe_corr", "ensemble_val_fe_err",
|
||||
"bilat_val_auc_img", "bilat_val_acc_img",
|
||||
"bilat_val_auc_md", "bilat_val_acc_md",
|
||||
"bilat_val_fe_corr", "bilat_val_fe_err",
|
||||
# holdout — fused head (existing)
|
||||
"classic_holdout_auc", "classic_holdout_acc",
|
||||
"ensemble_holdout_auc", "ensemble_holdout_acc",
|
||||
"bilat_holdout_auc", "bilat_holdout_acc",
|
||||
# holdout — img/md heads + fusion events
|
||||
"classic_holdout_auc_img", "classic_holdout_acc_img",
|
||||
"classic_holdout_auc_md", "classic_holdout_acc_md",
|
||||
"classic_holdout_fe_corr", "classic_holdout_fe_err",
|
||||
"ensemble_holdout_auc_img", "ensemble_holdout_acc_img",
|
||||
"ensemble_holdout_auc_md", "ensemble_holdout_acc_md",
|
||||
"ensemble_holdout_fe_corr", "ensemble_holdout_fe_err",
|
||||
# train-set eval pass (eval mode, all 3 heads)
|
||||
"train_auc_fused", "train_acc_fused",
|
||||
"train_auc_img", "train_acc_img",
|
||||
"train_auc_md", "train_acc_md",
|
||||
"train_fe_corr", "train_fe_err",
|
||||
"train_n",
|
||||
"is_best_single", "is_best_bilat",
|
||||
"is_best_holdout_single", "is_best_holdout_bilat",
|
||||
]
|
||||
# CM columns — named by num_classes so binary and multiclass both work
|
||||
if num_classes == 2:
|
||||
_cm_keys = ["tn", "fp", "fn", "tp"]
|
||||
else:
|
||||
_cm_keys = [f"cm_{i}_{j}" for i in range(num_classes) for j in range(num_classes)]
|
||||
for _split in ("classic_val", "ensemble_val", "classic_holdout", "ensemble_holdout", "train"):
|
||||
for _head in ("fused", "img", "md"):
|
||||
for _k in _cm_keys:
|
||||
epoch_fields.append(f"{_split}_{_head}_{_k}")
|
||||
fold_logger = HypertowerLogger(run_dir=fold_dir)
|
||||
|
||||
# per-epoch accumulation for npy tensors
|
||||
_epoch_train_pf: list[np.ndarray] = []
|
||||
_epoch_train_pi: list[np.ndarray] = []
|
||||
_epoch_train_pm: list[np.ndarray] = []
|
||||
_epoch_train_ids: list[np.ndarray] = []
|
||||
_epoch_train_y: list[np.ndarray] = []
|
||||
# per-eye val accumulators (ensemble mode: OD and OS separate)
|
||||
_epoch_val_pf_od: list[np.ndarray] = []
|
||||
_epoch_val_pi_od: list[np.ndarray] = []
|
||||
_epoch_val_pm_od: list[np.ndarray] = []
|
||||
_epoch_val_pf_os: list[np.ndarray] = []
|
||||
_epoch_val_pi_os: list[np.ndarray] = []
|
||||
_epoch_val_pm_os: list[np.ndarray] = []
|
||||
_epoch_val_y: list[np.ndarray] = []
|
||||
_epoch_val_ids: list[np.ndarray] = []
|
||||
|
||||
# ---- best-epoch trackers ---------------------------------------
|
||||
best_single_auc = -1.0
|
||||
best_bilat_auc = -1.0
|
||||
@@ -704,9 +923,16 @@ class V2HyperTower:
|
||||
en_n = 0
|
||||
en_acc_img = en_acc_md = en_auc_img = en_auc_md = nan
|
||||
elif run_single and tower_mode == "ensemble":
|
||||
y_en, p_en, p_en_img, p_en_md = collect_probs_single_components(
|
||||
single, val_loader, device, aggregate_patient=True
|
||||
(y_en,
|
||||
_p_en_f_od, _p_en_i_od, _p_en_m_od,
|
||||
_p_en_f_os, _p_en_i_os, _p_en_m_os,
|
||||
_en_pat_ids) = collect_probs_ensemble_pereye(
|
||||
single, val_loader, device, return_ids=True
|
||||
)
|
||||
# patient-level averages (used for metrics, same as before)
|
||||
p_en = 0.5 * (_p_en_f_od + _p_en_f_os)
|
||||
p_en_img = 0.5 * (_p_en_i_od + _p_en_i_os)
|
||||
p_en_md = 0.5 * (_p_en_m_od + _p_en_m_os)
|
||||
en_acc, en_auc, en_n = _score_arrays(y_en, p_en, num_classes)
|
||||
en_acc_img = float((p_en_img.argmax(1) == y_en).mean()) if y_en.size else nan
|
||||
en_acc_md = float((p_en_md.argmax(1) == y_en).mean()) if y_en.size else nan
|
||||
@@ -742,23 +968,47 @@ class V2HyperTower:
|
||||
bi_acc_img = bi_acc_md = bi_auc_img = bi_auc_md = nan
|
||||
|
||||
# --- holdout evaluation ------------------------------------
|
||||
# defaults (overwritten below when holdout_loader is not None)
|
||||
_z2 = np.zeros((0, num_classes), dtype=np.float32)
|
||||
_e2 = np.array([], dtype=np.int64)
|
||||
y_cl_h = y_en_h = _e2
|
||||
p_cl_h = p_cl_h_img = p_cl_h_md = _z2
|
||||
p_en_h = p_en_h_img = p_en_h_md = _z2
|
||||
|
||||
if holdout_loader is not None:
|
||||
if run_single and tower_mode == "single":
|
||||
y_cl_h, p_cl_h, _, _ = collect_probs_single_components(
|
||||
y_cl_h, p_cl_h, p_cl_h_img, p_cl_h_md = collect_probs_single_components(
|
||||
single, holdout_loader, device, aggregate_patient=False
|
||||
)
|
||||
_, cl_auc_h, _ = _score_arrays(y_cl_h, p_cl_h, num_classes)
|
||||
cl_acc_h = float((p_cl_h.argmax(1) == y_cl_h).mean()) if y_cl_h.size else nan
|
||||
_, cl_auc_h_img, _ = _score_arrays(y_cl_h, p_cl_h_img, num_classes)
|
||||
cl_acc_h_img = float((p_cl_h_img.argmax(1) == y_cl_h).mean()) if y_cl_h.size else nan
|
||||
_, cl_auc_h_md, _ = _score_arrays(y_cl_h, p_cl_h_md, num_classes)
|
||||
cl_acc_h_md = float((p_cl_h_md.argmax(1) == y_cl_h).mean()) if y_cl_h.size else nan
|
||||
cl_fe_h_corr, cl_fe_h_err = _fusion_events(y_cl_h, p_cl_h, p_cl_h_img, p_cl_h_md)
|
||||
en_auc_h = en_acc_h = nan
|
||||
en_auc_h_img = en_acc_h_img = en_auc_h_md = en_acc_h_md = nan
|
||||
en_fe_h_corr = en_fe_h_err = 0
|
||||
elif run_single and tower_mode == "ensemble":
|
||||
y_en_h, p_en_h, _, _ = collect_probs_single_components(
|
||||
y_en_h, p_en_h, p_en_h_img, p_en_h_md = collect_probs_single_components(
|
||||
single, holdout_loader, device, aggregate_patient=True
|
||||
)
|
||||
_, en_auc_h, _ = _score_arrays(y_en_h, p_en_h, num_classes)
|
||||
en_acc_h = float((p_en_h.argmax(1) == y_en_h).mean()) if y_en_h.size else nan
|
||||
_, en_auc_h_img, _ = _score_arrays(y_en_h, p_en_h_img, num_classes)
|
||||
en_acc_h_img = float((p_en_h_img.argmax(1) == y_en_h).mean()) if y_en_h.size else nan
|
||||
_, en_auc_h_md, _ = _score_arrays(y_en_h, p_en_h_md, num_classes)
|
||||
en_acc_h_md = float((p_en_h_md.argmax(1) == y_en_h).mean()) if y_en_h.size else nan
|
||||
en_fe_h_corr, en_fe_h_err = _fusion_events(y_en_h, p_en_h, p_en_h_img, p_en_h_md)
|
||||
cl_auc_h = cl_acc_h = nan
|
||||
cl_auc_h_img = cl_acc_h_img = cl_auc_h_md = cl_acc_h_md = nan
|
||||
cl_fe_h_corr = cl_fe_h_err = 0
|
||||
else:
|
||||
cl_auc_h = cl_acc_h = en_auc_h = en_acc_h = nan
|
||||
cl_auc_h_img = cl_acc_h_img = cl_auc_h_md = cl_acc_h_md = nan
|
||||
en_auc_h_img = en_acc_h_img = en_auc_h_md = en_acc_h_md = nan
|
||||
cl_fe_h_corr = cl_fe_h_err = en_fe_h_corr = en_fe_h_err = 0
|
||||
if run_bilat:
|
||||
y_bi_h, p_bi_h, _, _ = collect_probs_bilateral_components(
|
||||
bilateral, holdout_loader, device
|
||||
@@ -769,6 +1019,59 @@ class V2HyperTower:
|
||||
bi_auc_h = bi_acc_h = nan
|
||||
else:
|
||||
cl_auc_h = cl_acc_h = en_auc_h = en_acc_h = bi_auc_h = bi_acc_h = nan
|
||||
cl_auc_h_img = cl_acc_h_img = cl_auc_h_md = cl_acc_h_md = nan
|
||||
en_auc_h_img = en_acc_h_img = en_auc_h_md = en_acc_h_md = nan
|
||||
cl_fe_h_corr = cl_fe_h_err = en_fe_h_corr = en_fe_h_err = 0
|
||||
|
||||
# --- fusion-event helpers for val sets ----------------------
|
||||
cl_fe_corr, cl_fe_err = _fusion_events(y_cl, p_cl, p_cl_img, p_cl_md) if y_cl.size else (0, 0)
|
||||
en_fe_corr, en_fe_err = _fusion_events(y_en, p_en, p_en_img, p_en_md) if y_en.size else (0, 0)
|
||||
bi_fe_corr, bi_fe_err = (0, 0) # bilateral components not separated the same way
|
||||
|
||||
# --- train eval pass (eval mode, all 3 heads) ----------------
|
||||
tr_auc_f = tr_acc_f = tr_auc_i = tr_acc_i = tr_auc_m = tr_acc_m = nan
|
||||
tr_fe_corr = tr_fe_err = tr_n = 0
|
||||
y_tr = np.array([], dtype=np.int64)
|
||||
p_tr_f = p_tr_i = p_tr_m = np.zeros((0, num_classes), dtype=np.float32)
|
||||
if run_single and train_eval_loader is not None:
|
||||
y_tr, p_tr_f, p_tr_i, p_tr_m, tr_ids = collect_probs_eye_level(
|
||||
single, train_eval_loader, device, return_ids=True
|
||||
)
|
||||
if y_tr.size:
|
||||
_, tr_auc_f, _ = _score_arrays(y_tr, p_tr_f, num_classes)
|
||||
tr_acc_f = float((p_tr_f.argmax(1) == y_tr).mean())
|
||||
_, tr_auc_i, _ = _score_arrays(y_tr, p_tr_i, num_classes)
|
||||
tr_acc_i = float((p_tr_i.argmax(1) == y_tr).mean())
|
||||
_, tr_auc_m, _ = _score_arrays(y_tr, p_tr_m, num_classes)
|
||||
tr_acc_m = float((p_tr_m.argmax(1) == y_tr).mean())
|
||||
tr_fe_corr, tr_fe_err = _fusion_events(y_tr, p_tr_f, p_tr_i, p_tr_m)
|
||||
tr_n = int(y_tr.size)
|
||||
# accumulate for npy tensors
|
||||
_epoch_train_pf.append(p_tr_f)
|
||||
_epoch_train_pi.append(p_tr_i)
|
||||
_epoch_train_pm.append(p_tr_m)
|
||||
_epoch_train_ids.append(tr_ids)
|
||||
_epoch_train_y.append(y_tr)
|
||||
|
||||
# accumulate val for npy tensors
|
||||
if run_single and tower_mode == "ensemble" and y_en.size:
|
||||
_epoch_val_pf_od.append(_p_en_f_od)
|
||||
_epoch_val_pi_od.append(_p_en_i_od)
|
||||
_epoch_val_pm_od.append(_p_en_m_od)
|
||||
_epoch_val_pf_os.append(_p_en_f_os)
|
||||
_epoch_val_pi_os.append(_p_en_i_os)
|
||||
_epoch_val_pm_os.append(_p_en_m_os)
|
||||
_epoch_val_y.append(y_en)
|
||||
_epoch_val_ids.append(_en_pat_ids)
|
||||
elif run_single and tower_mode == "single" and y_cl.size:
|
||||
# single mode: no per-eye split, reuse same array for both slots
|
||||
_epoch_val_pf_od.append(p_cl)
|
||||
_epoch_val_pi_od.append(p_cl_img)
|
||||
_epoch_val_pm_od.append(p_cl_md)
|
||||
_epoch_val_pf_os.append(p_cl)
|
||||
_epoch_val_pi_os.append(p_cl_img)
|
||||
_epoch_val_pm_os.append(p_cl_md)
|
||||
_epoch_val_y.append(y_cl)
|
||||
|
||||
# Best-epoch checks (restricted to main phase).
|
||||
target_single_auc = cl_auc if tower_mode == "single" else en_auc
|
||||
@@ -830,23 +1133,65 @@ class V2HyperTower:
|
||||
best_epoch_holdout_bilat = epoch + 1
|
||||
best_holdout_bilat_state = copy.deepcopy(bilateral.state_dict())
|
||||
|
||||
# --- confusion matrix cells per split × head -------------------
|
||||
def _prefixed_cm(prefix: str, y: np.ndarray, pf: np.ndarray,
|
||||
pi: np.ndarray, pm: np.ndarray) -> dict:
|
||||
out: dict = {}
|
||||
for head, p in (("fused", pf), ("img", pi), ("md", pm)):
|
||||
for k, v in _cm_cells(y, p, num_classes).items():
|
||||
out[f"{prefix}_{head}_{k}"] = v
|
||||
return out
|
||||
|
||||
cm_row: dict = {}
|
||||
cm_row.update(_prefixed_cm("classic_val", y_cl, p_cl, p_cl_img, p_cl_md))
|
||||
cm_row.update(_prefixed_cm("ensemble_val", y_en, p_en, p_en_img, p_en_md))
|
||||
cm_row.update(_prefixed_cm("classic_holdout", y_cl_h, p_cl_h, p_cl_h_img, p_cl_h_md))
|
||||
cm_row.update(_prefixed_cm("ensemble_holdout", y_en_h, p_en_h, p_en_h_img, p_en_h_md))
|
||||
cm_row.update(_prefixed_cm("train", y_tr, p_tr_f, p_tr_i, p_tr_m))
|
||||
|
||||
fold_logger.write_epoch_row({
|
||||
"fold": fold, "epoch": epoch + 1,
|
||||
"phase_single": phase_single, "phase_bilat": phase_bilat,
|
||||
"main_epoch_single": main_epoch_single, "main_epoch_bilat": main_epoch_bilat,
|
||||
"single_active": int(single_active), "bilat_active": int(bilat_active),
|
||||
"single_train_loss": _f(sl_loss), "single_train_acc": _f(sl_acc),
|
||||
# val — fused
|
||||
"classic_val_auc": _f(cl_auc), "classic_val_acc": _f(cl_acc), "classic_val_n": cl_n,
|
||||
"ensemble_val_auc": _f(en_auc), "ensemble_val_acc": _f(en_acc), "ensemble_val_n": en_n,
|
||||
"bilat_train_loss": _f(bl_loss), "bilat_train_acc": _f(bl_acc),
|
||||
"bilat_val_auc": _f(bi_auc), "bilat_val_acc": _f(bi_acc), "bilat_val_n": bi_n,
|
||||
# val — img/md + fusion events
|
||||
"classic_val_auc_img": _f(cl_auc_img), "classic_val_acc_img": _f(cl_acc_img),
|
||||
"classic_val_auc_md": _f(cl_auc_md), "classic_val_acc_md": _f(cl_acc_md),
|
||||
"classic_val_fe_corr": cl_fe_corr, "classic_val_fe_err": cl_fe_err,
|
||||
"ensemble_val_auc_img": _f(en_auc_img), "ensemble_val_acc_img": _f(en_acc_img),
|
||||
"ensemble_val_auc_md": _f(en_auc_md), "ensemble_val_acc_md": _f(en_acc_md),
|
||||
"ensemble_val_fe_corr": en_fe_corr, "ensemble_val_fe_err": en_fe_err,
|
||||
"bilat_val_auc_img": _f(bi_auc_img), "bilat_val_acc_img": _f(bi_acc_img),
|
||||
"bilat_val_auc_md": _f(bi_auc_md), "bilat_val_acc_md": _f(bi_acc_md),
|
||||
"bilat_val_fe_corr": bi_fe_corr, "bilat_val_fe_err": bi_fe_err,
|
||||
# holdout — fused
|
||||
"classic_holdout_auc": _f(cl_auc_h), "classic_holdout_acc": _f(cl_acc_h),
|
||||
"ensemble_holdout_auc": _f(en_auc_h), "ensemble_holdout_acc": _f(en_acc_h),
|
||||
"bilat_holdout_auc": _f(bi_auc_h), "bilat_holdout_acc": _f(bi_acc_h),
|
||||
# holdout — img/md + fusion events
|
||||
"classic_holdout_auc_img": _f(cl_auc_h_img), "classic_holdout_acc_img": _f(cl_acc_h_img),
|
||||
"classic_holdout_auc_md": _f(cl_auc_h_md), "classic_holdout_acc_md": _f(cl_acc_h_md),
|
||||
"classic_holdout_fe_corr": cl_fe_h_corr, "classic_holdout_fe_err": cl_fe_h_err,
|
||||
"ensemble_holdout_auc_img": _f(en_auc_h_img), "ensemble_holdout_acc_img": _f(en_acc_h_img),
|
||||
"ensemble_holdout_auc_md": _f(en_auc_h_md), "ensemble_holdout_acc_md": _f(en_acc_h_md),
|
||||
"ensemble_holdout_fe_corr": en_fe_h_corr, "ensemble_holdout_fe_err": en_fe_h_err,
|
||||
# train eval pass
|
||||
"train_auc_fused": _f(tr_auc_f), "train_acc_fused": _f(tr_acc_f),
|
||||
"train_auc_img": _f(tr_auc_i), "train_acc_img": _f(tr_acc_i),
|
||||
"train_auc_md": _f(tr_auc_m), "train_acc_md": _f(tr_acc_m),
|
||||
"train_fe_corr": tr_fe_corr, "train_fe_err": tr_fe_err,
|
||||
"train_n": tr_n,
|
||||
"is_best_single": int(is_best_single),
|
||||
"is_best_bilat": int(is_best_bilat),
|
||||
"is_best_holdout_single": int(is_best_holdout_single),
|
||||
"is_best_holdout_bilat": int(is_best_holdout_bilat),
|
||||
**cm_row,
|
||||
}, optional_cols=epoch_fields)
|
||||
|
||||
if args.log_every > 0 and (epoch + 1) % args.log_every == 0:
|
||||
@@ -898,6 +1243,27 @@ class V2HyperTower:
|
||||
if best_holdout_bilat_state is not None:
|
||||
torch.save(best_holdout_bilat_state, fold_dir / "best_holdout_bilateral.pt")
|
||||
|
||||
# ---- Save per-epoch per-patient npy tensors -------------------------
|
||||
if _epoch_train_pf:
|
||||
# Use the order from the first epoch (consistent since loader is non-shuffled)
|
||||
ids_ref = _epoch_train_ids[0]
|
||||
y_ref = _epoch_train_y[0]
|
||||
np.save(fold_dir / "train_patient_ids.npy", ids_ref)
|
||||
np.save(fold_dir / "train_y_true.npy", y_ref)
|
||||
np.save(fold_dir / "train_probs_fused.npy", np.stack(_epoch_train_pf)) # (n_ep, n_pts, n_cls)
|
||||
np.save(fold_dir / "train_probs_img.npy", np.stack(_epoch_train_pi))
|
||||
np.save(fold_dir / "train_probs_md.npy", np.stack(_epoch_train_pm))
|
||||
if _epoch_val_pf_od:
|
||||
np.save(fold_dir / "val_y_true_epochs.npy", np.stack(_epoch_val_y)) # (n_ep, N)
|
||||
np.save(fold_dir / "val_probs_fused_od_epochs.npy", np.stack(_epoch_val_pf_od)) # (n_ep, N, C)
|
||||
np.save(fold_dir / "val_probs_img_od_epochs.npy", np.stack(_epoch_val_pi_od))
|
||||
np.save(fold_dir / "val_probs_md_od_epochs.npy", np.stack(_epoch_val_pm_od))
|
||||
np.save(fold_dir / "val_probs_fused_os_epochs.npy", np.stack(_epoch_val_pf_os))
|
||||
np.save(fold_dir / "val_probs_img_os_epochs.npy", np.stack(_epoch_val_pi_os))
|
||||
np.save(fold_dir / "val_probs_md_os_epochs.npy", np.stack(_epoch_val_pm_os))
|
||||
if _epoch_val_ids: # only ensemble mode populates this
|
||||
np.save(fold_dir / "val_patient_ids.npy", _epoch_val_ids[0])
|
||||
|
||||
# ---- Phase 2: fused head training (ensemble + --fused-head only) ----
|
||||
best_fused_auc = -1.0
|
||||
best_fused_state: Optional[dict] = None
|
||||
@@ -999,15 +1365,30 @@ class V2HyperTower:
|
||||
single.load_state_dict(best_single_state)
|
||||
if run_bilat and best_bilat_state is not None:
|
||||
bilateral.load_state_dict(best_bilat_state)
|
||||
y_en_pe_best = p_en_pe_best = p_en_pe_best_img = p_en_pe_best_md = None
|
||||
l_en_best = l_en_best_img = l_en_best_md = None
|
||||
l_cl_best = l_cl_best_img = l_cl_best_md = None
|
||||
l_en_pe_best = l_en_pe_best_img = l_en_pe_best_md = None
|
||||
if run_single and tower_mode == "single":
|
||||
y_cl_best, p_cl_best = collect_probs_classic(single, val_loader, device)
|
||||
y_en_best = p_en_best = None
|
||||
y_cl_best, p_cl_best, p_cl_best_img, p_cl_best_md, \
|
||||
l_cl_best, l_cl_best_img, l_cl_best_md = collect_probs_single_components(
|
||||
single, val_loader, device, aggregate_patient=False, return_logits=True
|
||||
)
|
||||
y_en_best = p_en_best = p_en_best_img = p_en_best_md = None
|
||||
elif run_single and tower_mode == "ensemble":
|
||||
y_en_best, p_en_best = collect_probs_ensemble(single, val_loader, device)
|
||||
y_cl_best = p_cl_best = None
|
||||
y_en_best, p_en_best, p_en_best_img, p_en_best_md, \
|
||||
l_en_best, l_en_best_img, l_en_best_md = collect_probs_single_components(
|
||||
single, val_loader, device, aggregate_patient=True, return_logits=True
|
||||
)
|
||||
y_en_pe_best, p_en_pe_best, p_en_pe_best_img, p_en_pe_best_md, \
|
||||
l_en_pe_best, l_en_pe_best_img, l_en_pe_best_md = collect_probs_single_components(
|
||||
single, val_loader, device, aggregate_patient=False, return_logits=True
|
||||
)
|
||||
y_cl_best = p_cl_best = p_cl_best_img = p_cl_best_md = None
|
||||
else:
|
||||
y_cl_best = y_en_best = None
|
||||
p_cl_best = p_en_best = None
|
||||
p_cl_best = p_en_best = p_en_best_img = p_en_best_md = None
|
||||
p_cl_best_img = p_cl_best_md = None
|
||||
if run_bilat:
|
||||
y_bi_best, p_bi_best = collect_probs_bilateral(bilateral, val_loader, device)
|
||||
else:
|
||||
@@ -1077,6 +1458,23 @@ class V2HyperTower:
|
||||
y_true_ensemble=y_en_best, probs_ensemble=p_en_best,
|
||||
y_true_bilat=y_bi_best, probs_bilat=p_bi_best,
|
||||
y_true_fused=y_fu_best, probs_fused=p_fu_best,
|
||||
probs_ensemble_img=p_en_best_img,
|
||||
probs_ensemble_md=p_en_best_md,
|
||||
probs_classic_img=p_cl_best_img,
|
||||
probs_classic_md=p_cl_best_md,
|
||||
y_true_ensemble_pereye=y_en_pe_best,
|
||||
probs_ensemble_pereye=p_en_pe_best,
|
||||
probs_ensemble_img_pereye=p_en_pe_best_img,
|
||||
probs_ensemble_md_pereye=p_en_pe_best_md,
|
||||
logits_ensemble=l_en_best,
|
||||
logits_ensemble_img=l_en_best_img,
|
||||
logits_ensemble_md=l_en_best_md,
|
||||
logits_classic=l_cl_best,
|
||||
logits_classic_img=l_cl_best_img,
|
||||
logits_classic_md=l_cl_best_md,
|
||||
logits_ensemble_pereye=l_en_pe_best,
|
||||
logits_ensemble_img_pereye=l_en_pe_best_img,
|
||||
logits_ensemble_md_pereye=l_en_pe_best_md,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user