logging rework temp save

This commit is contained in:
rpotter6298
2026-03-04 09:40:09 +01:00
parent 080b5999fa
commit d36b9508ff
41 changed files with 2182 additions and 383 deletions
+8
View File
@@ -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
View File
@@ -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(
+4 -3
View File
@@ -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
+20
View File
@@ -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
View File
@@ -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,
)
# ------------------------------------------------------------------