Files
hypertower/classes/frontend.py
T
2026-02-24 10:39:48 +01:00

1403 lines
61 KiB
Python
Executable File

#!/usr/bin/env python3
"""Tkinter front-end for scripts/run_multifold.py."""
from __future__ import annotations
import argparse
import contextlib
import csv
import io
import json
import os
import sys
import signal
import subprocess
import threading
import time
import shutil
import re
from pathlib import Path
from typing import Dict, Optional
from types import SimpleNamespace
import numpy as np
import torch
import torch.nn.functional as F
from sklearn.metrics import roc_auc_score, roc_curve, auc
import matplotlib.pyplot as plt
import tkinter as tk
from tkinter import filedialog, messagebox
from classes.hypertower import HyperTower
from classes.backbones import list_names as list_backbones
from classes import build_papila_clinical
BACKBONES = [
"efficientnet_b0",
"resnet50",
"densenet121",
"refugelike",
"refuge_densenet",
"refuge_efficient_b0",
"refuge_efficient_b7",
]
FUSION_MODES = ["fused", "image_only", "metadata_only", "vote"]
EVAL_MODES = ["multiclass", "binary"]
class Multifold:
"""Core multifold runner extracted from scripts/run_multifold."""
def __init__(self, args: argparse.Namespace) -> None:
self.args = args
@staticmethod
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(description="Run k-fold CV and emit per-fold logs, npy, and ROC plots.")
ap.add_argument("--image-dir", default="Papila/FundusImages")
ap.add_argument("--clinical-dir", default="Papila/ClinicalData")
ap.add_argument("--label-col", default="Diagnosis")
ap.add_argument("--cat-cols", nargs="*", default=["Gender", "Phakic/Pseudophakic"])
ap.add_argument("--backbone", type=str, default="efficientnet_b0", choices=list_backbones())
ap.add_argument("--freeze-ratio", type=float, default=0.0)
ap.add_argument("--fusion-mode", default="fused", choices=["fused","image_only","metadata_only","vote"])
ap.add_argument("--epochs", type=int, default=5)
ap.add_argument("--batch-size", type=int, default=8)
ap.add_argument("--lr", type=float, default=1e-4)
ap.add_argument("--num-classes", type=int, default=3)
ap.add_argument("--n-splits", type=int, default=5)
ap.add_argument("--fold-seed", type=int, default=42,
help="Random seed for patient-level splits")
ap.add_argument("--holdout-per-class", type=int, default=0,
help="Reserve this many samples per class for a monitoring holdout (0 disables)")
ap.add_argument("--holdout-seed", type=int, default=123,
help="Random seed used when sampling the holdout subset")
ap.add_argument("--img-crop-manifest", type=Path, default=None,
help="Optional manifest for UNet cropper (enables disc-centric crops)")
ap.add_argument("--img-crop-weights", type=Path, default=None,
help="UNet checkpoint weights for cropping")
ap.add_argument("--img-crop-normalize", choices=["none", "imagenet", "per_image"], default="per_image")
ap.add_argument("--img-crop-threshold", type=float, default=0.5)
ap.add_argument("--img-crop-scale", type=float, default=2.5)
ap.add_argument("--img-crop-size", type=int, default=224)
ap.add_argument("--img-crop-cache", type=Path, default=Path("analysis_data/hypertower_crops"))
ap.add_argument("--img-crop-tta", action="store_true")
ap.add_argument("--img-crop-gt", action="store_true",
help="Use ground-truth masks/contours from manifest for cropping instead of UNet")
ap.add_argument("--no-img-augment", dest="img_augment", action="store_false",
help="Disable random image augmentations for the image tower")
ap.set_defaults(img_augment=True)
ap.add_argument("--img-geometry-features", action="store_true",
help="Append disc/cup geometry features to the image tower (requires cropping)")
ap.add_argument("--shortname", default="multi")
ap.add_argument("--plot-head", default="fused", choices=["image","fused","metadata"],
help="Which head to plot/aggregate.")
ap.add_argument("--class-names", nargs="*", default=None)
ap.add_argument("--no-se", dest="use_se", action="store_false",
help="Disable SE attention in the bridge (default: enabled)")
ap.set_defaults(use_se=True)
ap.add_argument("--se-reduction", type=int, default=16, choices=[8,16,32],
help="SE bottleneck: fusion_dim // reduction (default 16)")
ap.add_argument("--se-pre-norm", dest="se_pre_norm", action="store_true",
help="Enable LayerNorm on branches before the multiply (default)")
ap.add_argument("--no-se-pre-norm", dest="se_pre_norm", action="store_false",
help="Disable LayerNorm on branches before the multiply")
ap.set_defaults(se_pre_norm=True)
ap.add_argument("--se-where", choices=["bridge","tower","both","none"], default="bridge",
help="Where to apply SE: bridge (default), tower, both, or none")
ap.add_argument("--se-reduction-tower", type=int, default=16, choices=[8,16,32],
help="SE bottleneck for tower vectors (default 16)")
ap.add_argument("--se-pre-norm-tower", dest="se_pre_norm_tower", action="store_true",
help="Enable LayerNorm on tower vectors before SE (default)")
ap.add_argument("--no-se-pre-norm-tower", dest="se_pre_norm_tower", action="store_false",
help="Disable LayerNorm on tower vectors before SE (default: enabled)")
ap.set_defaults(se_pre_norm_tower=True)
ap.add_argument("--eval_mode", choices=["multiclass","binary"], default="multiclass",
help="Multiclass (3 classes) or binary (Healthy vs Glaucoma; drops Suspect).")
ap.add_argument("--warmup-tower-epochs", type=int, default=2)
ap.add_argument("--warmup-fused-epochs", type=int, default=3)
ap.add_argument("--gradual-thaw", action="store_true",
help="Enable gradual backbone thawing schedule (image/metadata towers)")
ap.add_argument("--thaw-phase-duration", type=int, default=5,
help="Epochs per thaw phase (default 5)")
ap.add_argument("--thaw-ratio", type=float, default=0.33,
help="Fraction of blocks to unfreeze each phase (default 0.33)")
ap.add_argument("--thaw-target", choices=["image","metadata","both"], default="image",
help="Which tower(s) to apply gradual thaw to (default image)")
ap.add_argument("--thaw-start-epoch", type=int, default=-1,
help="Epoch to start thawing (default: warmup_tower_epochs)")
ap.add_argument("--initial-freeze", action="store_true",
help="Before thaw start, force backbone(s) fully frozen (default off)")
ap.add_argument("--early-stop", action="store_true",
help="Enable early stopping")
ap.add_argument("--early-metric", default=None,
help="Metric key to monitor (e.g., eval_loss, auc_fused, acc_fused).")
ap.add_argument("--early-mode", choices=["auto","min","max"], default="auto")
ap.add_argument("--early-monitor-holdout", action="store_true",
help="Monitor the holdout metric for early stopping/checkpointing (requires holdout set).")
ap.add_argument("--early-patience", type=int, default=7)
ap.add_argument("--early-min-delta", type=float, default=0.0)
ap.add_argument("--checkpoint-best", action="store_true",
help="Save best weights to disk during training")
ap.add_argument("--focal-gamma", type=float, default=0.0,
help="Focal loss exponent (0 disables focal loss)")
ap.add_argument("--balanced-sampler", action="store_true",
help="Use a class-balanced bootstrapped sampler for the training loader")
ap.add_argument("--run-id", default=None, help=argparse.SUPPRESS)
return ap
@staticmethod
def _serialize_arg(value):
if isinstance(value, Path):
return str(value)
if isinstance(value, (list, tuple)):
return [Multifold._serialize_arg(v) for v in value]
return value
def _export_run_settings(self, run_dir: Path) -> None:
data = {key: self._serialize_arg(value) for key, value in vars(self.args).items()}
try:
with open(run_dir / "cli_args.json", "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
except Exception as exc: # pragma: no cover
print(f"[run_multifold] Failed to write cli_args.json: {exc}")
def run(self, callback=None) -> None:
if callback:
sink = _StreamCallback(callback)
with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
self._run_impl()
else:
self._run_impl()
# ---- Helper functions copied from run_multifold ----------------
@staticmethod
def eval_collect_logits(ht: HyperTower):
device = ht.device
ht.img_tower.eval(); ht.md_tower.eval()
if getattr(ht, "mode", "fused") != "vote":
ht.bridge.eval()
y_all = []
pf, pi, pm = [], [], []
for batch in ht.test_loader:
if len(batch) == 4:
imgs, metas, geometry, labels = batch
else:
imgs, metas, labels = batch
geometry = None
imgs = imgs.to(device)
metas = metas.to(device)
labels = labels.to(device)
if geometry is not None and geometry.numel() > 0:
geometry = geometry.to(device)
else:
geometry = None
if ht.mode == "vote":
img_feats = ht.img_tower(imgs, geometry)
md_feats = ht.md_tower(metas)
out_img = ht.head_img(img_feats)
out_md = ht.head_md(md_feats)
out_fused = ht.vote(out_img, out_md)
else:
img_feats = ht.img_tower(imgs, geometry)
md_feats = ht.md_tower(metas)
outputs = ht.bridge(img_feats, md_feats)
if isinstance(outputs, tuple):
out_fused, out_img, out_md = outputs
else:
out_fused, out_img, out_md = outputs, None, None
y_all.append(labels.detach().cpu().numpy())
pf.append(F.softmax(out_fused, dim=1).detach().cpu().numpy())
if out_img is not None:
pi.append(F.softmax(out_img, dim=1).detach().cpu().numpy())
if out_md is not None:
pm.append(F.softmax(out_md, dim=1).detach().cpu().numpy())
y_true = np.concatenate(y_all, axis=0)
pf = np.concatenate(pf, axis=0) if pf else None
pi = np.concatenate(pi, axis=0) if pi else None
pm = np.concatenate(pm, axis=0) if pm else None
return y_true, pf, pi, pm
@staticmethod
def auc_for(y, p):
y = np.asarray(y)
if p is None:
return float("nan")
if p.ndim == 1 or p.shape[1] == 1:
return roc_auc_score(y, p.ravel())
if p.shape[1] == 2:
return roc_auc_score(y, p[:, 1])
return roc_auc_score(y, p, multi_class="ovr", average="macro")
@staticmethod
def per_class_roc(y, p):
if p is None:
return {}
K = p.shape[1]
out = {}
for k in range(K):
y_bin = (y == k).astype(np.uint8)
fpr, tpr, _ = roc_curve(y_bin, p[:, k])
out[k] = (fpr, tpr, auc(fpr, tpr) if len(fpr) > 1 else np.nan)
return out
@staticmethod
def plot_mean_sd(per_fold_curves, out_png, class_names=None, title="Mean OVR ROC (±1 SD)"):
if not per_fold_curves:
return
fpr_grid = np.linspace(0, 1, 501)
fig = plt.figure(figsize=(10, 8)); ax = fig.add_subplot(111)
ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1)
keys = sorted({k for d in per_fold_curves for k in d.keys()})
if class_names is not None and len(class_names) == len(keys):
name_map = {k: class_names[i] for i, k in enumerate(keys)}
else:
name_map = {k: f"class {k}" for k in keys}
for k in keys:
tprs, aucs = [], []
for d in per_fold_curves:
if k not in d:
continue
fpr, tpr, a = d[k]
tprs.append(np.interp(fpr_grid, fpr, tpr))
aucs.append(a)
if not tprs:
continue
tprs = np.vstack(tprs)
mean = tprs.mean(axis=0); std = tprs.std(axis=0)
auc_mean = np.nanmean(aucs); auc_std = np.nanstd(aucs)
label = f"{name_map[k]} (AUC {auc_mean:.3f}±{auc_std:.3f})"
ax.plot(fpr_grid, mean, linewidth=2, label=label)
ax.fill_between(fpr_grid, np.maximum(mean - std, 0), np.minimum(mean + std, 1), alpha=0.15)
ax.set_xlabel("False Positive Rate"); ax.set_ylabel("True Positive Rate")
ax.set_title(title); ax.legend(loc="lower right"); fig.tight_layout()
fig.savefig(out_png, dpi=160); plt.close(fig)
@staticmethod
def plot_overlays(per_fold_curves, out_png, title="Per-fold OVR ROC overlays"):
if not per_fold_curves:
return
fig = plt.figure(figsize=(10, 8)); ax = fig.add_subplot(111)
ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1)
for d in per_fold_curves:
for _, (fpr, tpr, _) in d.items():
ax.plot(fpr, tpr, alpha=0.25, linewidth=1)
ax.set_xlabel("False Positive Rate"); ax.set_ylabel("True Positive Rate")
ax.set_title(title); fig.tight_layout(); fig.savefig(out_png, dpi=160); plt.close(fig)
@staticmethod
def plot_per_class_overlays(per_fold_curves, out_dir: Path, class_names=None, head_name: str = "fused"):
if not per_fold_curves:
return
keys = sorted({k for d in per_fold_curves for k in d.keys()})
if class_names is not None and len(class_names) == len(keys):
name_map = {k: class_names[i] for i, k in enumerate(keys)}
else:
name_map = {k: f"class_{k}" for k in keys}
out_dir.mkdir(parents=True, exist_ok=True)
for k in keys:
per_fold = []
for fold_idx, d in enumerate(per_fold_curves, start=1):
if k not in d:
continue
fpr, tpr, auc_val = d[k]
per_fold.append((fold_idx, fpr, tpr, auc_val))
if not per_fold:
continue
fig = plt.figure(figsize=(10, 8)); ax = fig.add_subplot(111)
ax.plot([0, 1], [0, 1], linestyle="--", linewidth=1, color="grey")
for fold_idx, fpr, tpr, auc_val in per_fold:
label = f"Fold {fold_idx} (AUC {auc_val:.3f})"
ax.plot(fpr, tpr, linewidth=1.5, label=label)
ax.set_xlabel("False Positive Rate"); ax.set_ylabel("True Positive Rate")
ax.set_title(f"{head_name} head — {name_map[k]} ROC per fold")
ax.legend(loc="lower right", frameon=True)
fig.tight_layout()
safe_name = name_map[k].replace(" ", "_")
fig.savefig(out_dir / f"roc_{head_name}_{safe_name}_perfold.png", dpi=160)
plt.close(fig)
@staticmethod
def move_if_exists(src: Path, dest: Path):
if src.exists():
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dest))
@staticmethod
def move_dir_overwrite(src: Path, dest: Path):
"""Move directory, replacing destination if it already exists."""
if not src.exists():
return
if dest.exists():
shutil.rmtree(dest)
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(src), str(dest))
@staticmethod
def load_holdout_roc_curves(dir_path: Path, head: str):
"""
Load the holdout ROC JSON for a given head from a directory like
foldX_roc_curves_holdout_best, returning the per-class curve map
expected by plotting helpers.
"""
if not dir_path.exists() or not dir_path.is_dir():
return {}
head_name = {"image": "image", "metadata": "metadata"}.get(head, "fused")
best_path = None
best_epoch = -1
for path in dir_path.glob(f"epoch*_holdout_{head_name}.json"):
m = re.match(r"epoch(\\d+)_", path.stem)
if not m:
continue
try:
epoch_idx = int(m.group(1))
except Exception:
continue
if epoch_idx > best_epoch:
best_epoch = epoch_idx
best_path = path
if best_path is None:
return {}
try:
data = json.loads(best_path.read_text())
except Exception:
return {}
per_class = data.get("per_class") or {}
curves = {}
for k, vals in per_class.items():
if not isinstance(vals, dict):
continue
fpr = vals.get("fpr"); tpr = vals.get("tpr"); auc_val = vals.get("auc")
if fpr is None or tpr is None or auc_val is None:
continue
try:
idx = int(k)
except Exception:
continue
curves[idx] = (np.array(fpr, dtype=float), np.array(tpr, dtype=float), float(auc_val))
return curves
def _run_impl(self) -> None:
args = self.args
args.num_classes = 2 if args.eval_mode == "binary" else 3
if getattr(args, "run_id", None):
run_id = str(args.run_id)
else:
ts = time.strftime("%Y%m%d_%H%M%S")
run_id = f"{args.shortname}_{ts}" if args.shortname else ts
run_dir = Path("analysis_data") / args.shortname / run_id
(run_dir / "plots").mkdir(parents=True, exist_ok=True)
base_models_dir = Path("models") / args.shortname / run_id
base_models_dir.mkdir(parents=True, exist_ok=True)
self._export_run_settings(run_dir)
clinical = build_papila_clinical(
args.image_dir,
args.clinical_dir,
args.label_col,
args.cat_cols,
n_splits=args.n_splits,
random_seed=args.fold_seed,
)
holdout_df = None
if args.holdout_per_class > 0:
df_full = clinical.df.copy()
if args.eval_mode == "binary":
df_full = df_full[df_full[args.label_col].isin([0, 1])].reset_index(drop=True)
rng = np.random.default_rng(args.holdout_seed)
holdout_indices = []
for label, group in df_full.groupby(args.label_col):
n = min(args.holdout_per_class, len(group))
if n <= 0:
continue
selected = rng.choice(group.index.to_numpy(), size=n, replace=False)
holdout_indices.extend(selected.tolist())
if holdout_indices:
holdout_indices = sorted(set(holdout_indices))
holdout_df = df_full.loc[holdout_indices].reset_index(drop=True)
train_df = df_full.drop(index=holdout_indices).reset_index(drop=True)
clinical.frames = [train_df.copy()]
clinical.df = train_df.copy()
clinical._infer_or_validate_feature_types()
clinical._compute_numeric_stats()
clinical._build_cat_maps()
clinical._compute_feature_dim()
clinical._build_kfold_indices()
holdout_path = run_dir / "holdout.csv"
holdout_df.to_csv(holdout_path, index=False)
print(f"[run_multifold] Reserved holdout set of {len(holdout_df)} samples (saved to {holdout_path})")
fold_macro_aucs = []
per_fold_ovr_curves_for_plot_head = []
holdout_per_fold_ovr_curves_for_plot_head = []
def _default_monitor():
if args.early_metric:
mode = getattr(args, "early_mode", "auto")
if mode == "auto":
mode = "min" if "loss" in args.early_metric.lower() else "max"
return args.early_metric, mode
if args.fusion_mode == "image_only":
return "auc_img", "max"
if args.fusion_mode == "metadata_only":
return "auc_md", "max"
return "auc_fused", "max"
monitor_name, monitor_mode = _default_monitor()
fold_summaries = []
best_metric_values = []
for fold in range(args.n_splits):
print(f"\n=== Fold {fold+1}/{args.n_splits} ===")
if args.early_metric:
early_metric = args.early_metric
else:
if args.fusion_mode == "image_only":
early_metric = "auc_img"
elif args.fusion_mode == "metadata_only":
early_metric = "auc_md"
else:
early_metric = "auc_fused"
ht_args = SimpleNamespace(
image_dir=args.image_dir,
clinical_dir=args.clinical_dir,
label_col=args.label_col,
cat_cols=args.cat_cols,
batch_size=args.batch_size,
epochs=args.epochs,
lr=args.lr,
num_classes=args.num_classes,
img_augment=args.img_augment,
focal_gamma=args.focal_gamma,
eval_mode=args.eval_mode,
fold=fold,
run_dir=str(run_dir),
models_dir=str((base_models_dir / f"fold{fold}").resolve()),
backbone=args.backbone,
freeze_ratio=args.freeze_ratio,
fusion_mode=args.fusion_mode,
use_se=args.use_se,
se_reduction=args.se_reduction,
se_pre_norm=args.se_pre_norm,
se_where=args.se_where,
se_reduction_tower=args.se_reduction_tower,
se_pre_norm_tower=args.se_pre_norm_tower,
warmup_tower_epochs=args.warmup_tower_epochs,
warmup_fused_epochs=args.warmup_fused_epochs,
gradual_thaw=args.gradual_thaw,
thaw_phase_duration=args.thaw_phase_duration,
thaw_ratio=args.thaw_ratio,
thaw_target=args.thaw_target,
thaw_start_epoch=args.thaw_start_epoch,
initial_freeze=args.initial_freeze,
bcd_prob=0.5,
bcd_p0=0.20,
bcd_min=0.05,
bcd_max=0.30,
bcd_k=0.4,
bcd_metric="auc",
bcd_alpha_batch=0.2,
bcd_alpha_tower=0.3,
bcd_explore_floor=0.15,
aux_img=0.05,
aux_md=0.05,
aux_detach=True,
ema_alpha=0.9,
entropy_ema=0.7,
early_stop=args.early_stop,
early_metric=early_metric,
early_mode=args.early_mode,
early_patience=args.early_patience,
early_min_delta=args.early_min_delta,
checkpoint_best=args.checkpoint_best,
holdout_df=holdout_df,
img_crop_manifest=args.img_crop_manifest,
img_crop_weights=args.img_crop_weights,
img_crop_normalize=args.img_crop_normalize,
img_crop_threshold=args.img_crop_threshold,
img_crop_scale=args.img_crop_scale,
img_crop_size=args.img_crop_size,
img_crop_cache=args.img_crop_cache,
img_crop_tta=args.img_crop_tta,
img_crop_gt=args.img_crop_gt,
)
(base_models_dir / f"fold{fold}").mkdir(parents=True, exist_ok=True)
ht = HyperTower(clinical, ht_args)
ht.train()
epoch_log_path = run_dir / "epoch_log.csv"
best_row_data = None
best_value = None
if epoch_log_path.exists():
try:
with epoch_log_path.open("r", newline="", encoding="utf-8") as fp:
reader = csv.DictReader(fp)
for row in reader:
val_raw = row.get(monitor_name)
try:
val = float(val_raw)
except (TypeError, ValueError):
continue
if best_value is None:
best_value = val
best_row_data = dict(row)
else:
if monitor_mode == "max":
if val > best_value:
best_value = val
best_row_data = dict(row)
else:
if val < best_value:
best_value = val
best_row_data = dict(row)
except OSError as exc: # pragma: no cover
print(f"[run_multifold] Warning: failed to read {epoch_log_path} ({exc}); skipping best-metric parse.")
def _coerce_types(row: dict | None) -> dict | None:
if row is None:
return None
out = {}
for key, value in row.items():
if value is None or value == "":
out[key] = None
continue
try:
out[key] = float(value)
if key == "epoch":
out[key] = int(float(value))
except ValueError:
out[key] = value
return out
best_row_converted = _coerce_types(best_row_data)
if isinstance(best_row_converted, dict) and "epoch" in best_row_converted:
try:
best_epoch = int(best_row_converted["epoch"])
except Exception:
best_epoch = None
else:
best_epoch = None
if best_value is not None:
best_metric_values.append(best_value)
fold_summaries.append({
"fold": fold,
"best_metric_value": best_value,
"best_epoch": best_epoch,
"monitor": monitor_name,
"warmup_tower_epochs": int(getattr(ht, "warmup_tower_epochs", getattr(args, "warmup_tower_epochs", 2))),
"warmup_fused_epochs": int(getattr(ht, "warmup_fused_epochs", getattr(args, "warmup_fused_epochs", 3))),
"main_epochs": int(getattr(ht, "epochs", args.epochs)),
"total_epochs": int(getattr(ht, "total_epochs", args.epochs)),
"stats": best_row_converted,
})
self.move_if_exists(run_dir / "train.log", run_dir / f"fold{fold}_train.log")
self.move_if_exists(epoch_log_path, run_dir / f"fold{fold}_epoch_log.csv")
roc_src = run_dir / "roc_curves"
if roc_src.exists() and roc_src.is_dir():
self.move_dir_overwrite(roc_src, run_dir / f"fold{fold}_roc_curves")
roc_best_src = run_dir / "roc_curves_best"
if roc_best_src.exists() and roc_best_src.is_dir():
self.move_dir_overwrite(roc_best_src, run_dir / f"fold{fold}_roc_curves_best")
roc_holdout_src = run_dir / "roc_curves_holdout_best"
if roc_holdout_src.exists() and roc_holdout_src.is_dir():
self.move_dir_overwrite(roc_holdout_src, run_dir / f"fold{fold}_roc_curves_holdout_best")
holdout_curves = self.load_holdout_roc_curves(run_dir / f"fold{fold}_roc_curves_holdout_best", args.plot_head)
if holdout_curves:
holdout_per_fold_ovr_curves_for_plot_head.append(holdout_curves)
# Reload the recorded best checkpoint so downstream metrics/plots use the same epoch as the summary.
best_snapshot = Path(ht_args.models_dir) / "model_best.pt"
if best_snapshot.exists():
try:
try:
state = torch.load(best_snapshot, map_location=ht.device, weights_only=False)
except TypeError:
state = torch.load(best_snapshot, map_location=ht.device)
ht._restore_from_state(state)
except Exception as exc: # pragma: no cover
print(f"[run_multifold] Warning: failed to reload best checkpoint for fold {fold}: {exc}")
train_df, test_df = clinical.get_split_dfs(fold)
y_true, p_fused, p_img, p_md = self.eval_collect_logits(ht)
if args.eval_mode == "binary":
keep = np.isin(y_true, [0, 1])
if keep.sum() == 0:
raise RuntimeError("No binary samples left after filtering.")
y_true = y_true[keep]
if p_fused is not None: p_fused = p_fused[keep]
if p_img is not None: p_img = p_img[keep]
if p_md is not None: p_md = p_md[keep]
def _slice2(p):
if p is None:
return None
if p.ndim == 2 and p.shape[1] >= 2:
return p[:, :2]
return p
p_fused = _slice2(p_fused)
p_img = _slice2(p_img)
p_md = _slice2(p_md)
np.save(run_dir / f"fold{fold}_y_true.npy", y_true)
if p_img is not None: np.save(run_dir / f"fold{fold}_probs_img.npy", p_img)
if p_fused is not None: np.save(run_dir / f"fold{fold}_probs_fused.npy", p_fused)
if p_md is not None: np.save(run_dir / f"fold{fold}_probs_md.npy", p_md)
if args.plot_head == "image":
p_plot = p_img
elif args.plot_head == "metadata":
p_plot = p_md
else:
p_plot = p_fused
fold_auc = self.auc_for(y_true, p_plot) if p_plot is not None else float("nan")
fold_macro_aucs.append(fold_auc)
per_fold_ovr_curves_for_plot_head.append(self.per_class_roc(y_true, p_plot))
auc_mean = float(np.nanmean(fold_macro_aucs)) if fold_macro_aucs else float("nan")
auc_std = float(np.nanstd(fold_macro_aucs)) if fold_macro_aucs else float("nan")
class_names = args.class_names if args.class_names else (
["Healthy", "Glaucoma"] if args.eval_mode == "binary" else ["Healthy","Glaucoma","Suspect"]
)
self.plot_per_class_overlays(
per_fold_ovr_curves_for_plot_head,
out_dir=run_dir / "plots",
class_names=class_names,
head_name=args.plot_head,
)
self.plot_mean_sd(
per_fold_ovr_curves_for_plot_head,
out_png=run_dir / "plots" / f"roc_{args.plot_head}_mean_ovr.png",
class_names=class_names,
title=f"Mean OVR ROC (±1 SD) — {args.plot_head} head"
)
if holdout_per_fold_ovr_curves_for_plot_head:
holdout_plots_dir = run_dir / "plots" / "holdout"
holdout_plots_dir.mkdir(parents=True, exist_ok=True)
self.plot_per_class_overlays(
holdout_per_fold_ovr_curves_for_plot_head,
out_dir=holdout_plots_dir,
class_names=class_names,
head_name=f"{args.plot_head}_holdout",
)
self.plot_mean_sd(
holdout_per_fold_ovr_curves_for_plot_head,
out_png=holdout_plots_dir / f"roc_{args.plot_head}_holdout_mean_ovr.png",
class_names=class_names,
title=f"Holdout Mean OVR ROC (±1 SD) — {args.plot_head} head",
)
if best_metric_values:
best_metric_mean = float(np.mean(best_metric_values))
best_metric_std = float(np.std(best_metric_values, ddof=0))
else:
best_metric_mean = None
best_metric_std = None
warmup_tower_used = (
fold_summaries[0].get("warmup_tower_epochs")
if fold_summaries else getattr(args, "warmup_tower_epochs", None)
)
warmup_fused_used = (
fold_summaries[0].get("warmup_fused_epochs")
if fold_summaries else getattr(args, "warmup_fused_epochs", None)
)
total_epochs_used = (
fold_summaries[0].get("total_epochs")
if fold_summaries else args.epochs
)
summary = {
"run_id": run_id,
"backbone": args.backbone,
"freeze_ratio": args.freeze_ratio,
"fusion_mode": args.fusion_mode,
"epochs": args.epochs,
"warmup_tower_epochs": warmup_tower_used,
"warmup_fused_epochs": warmup_fused_used,
"total_epochs": total_epochs_used,
"batch_size": args.batch_size,
"lr": args.lr,
"num_classes": args.num_classes,
"eval_mode": args.eval_mode,
"n_splits": args.n_splits,
"focal_gamma": args.focal_gamma,
"balanced_sampler": bool(args.balanced_sampler),
"se": {
"enabled": bool(args.use_se),
"reduction": int(args.se_reduction),
"pre_norm": bool(args.se_pre_norm),
},
"best_metric": monitor_name,
"best_metric_mode": monitor_mode,
"best_metric_mean": best_metric_mean,
"best_metric_std": best_metric_std,
"fold_metrics": fold_summaries,
}
with open(run_dir / "summary.json", "w") as jf:
json.dump(summary, jf, indent=2)
print(f"Summary written to {run_dir / 'summary.json'}")
class _StreamCallback(io.TextIOBase):
def __init__(self, callback):
self.callback = callback
def write(self, s):
if self.callback and s:
self.callback(s)
return len(s)
def flush(self):
pass
class MultifoldRunner:
"""Wrapper used by the GUI to execute Multifold runs."""
def run(self, cli_args: list[str], callback=None) -> None:
parser = Multifold.build_parser()
if callback:
sink = _StreamCallback(callback)
with contextlib.redirect_stdout(sink), contextlib.redirect_stderr(sink):
args = parser.parse_args(cli_args)
else:
args = parser.parse_args(cli_args)
multifold = Multifold(args)
multifold.run(callback)
class MultifoldFrontend(tk.Tk):
"""Simple Tkinter GUI for configuring and launching run_multifold.py."""
def __init__(self) -> None:
super().__init__()
self.title("run_multifold.py")
self.vars: Dict[str, tk.Variable] = {}
self._configure_scale()
self.geometry(self.window_geometry)
self._build_form()
self._build_output()
self.status_var = tk.StringVar(value="Idle")
status_frame = tk.Frame(self)
status_frame.pack(fill="x", padx=6, pady=(0, 6))
tk.Label(status_frame, text="Status:", font=self.base_font).pack(side="left")
tk.Label(status_frame, textvariable=self.status_var, anchor="w", font=self.base_font).pack(side="left", fill="x")
self.force_cpu = False
self.after(0, self.on_device_change)
self.repo_root = Path(__file__).resolve().parents[1]
self.current_process: Optional[subprocess.Popen] = None
self.current_thread: Optional[threading.Thread] = None
self.current_run_dir: Optional[Path] = None
# ---- UI helpers -------------------------------------------------
def _configure_scale(self) -> None:
try:
screen_w = self.winfo_screenwidth()
screen_h = self.winfo_screenheight()
except Exception:
screen_w, screen_h = 1920, 1080
scale = screen_w / 1920.0
scale = max(0.8, min(scale, 1.6))
base_size = max(10, int(10 * scale))
self.base_font = ("TkDefaultFont", base_size)
self.bold_font = ("TkDefaultFont", max(base_size, 11), "bold")
self.entry_font = ("TkDefaultFont", max(9, base_size))
self.button_font = ("TkDefaultFont", max(9, base_size - 1))
self.mono_font = ("Courier", max(9, base_size))
self.text_height = max(18, int(24 * scale))
width = int(720 * scale)
height = int(860 * scale)
self.window_geometry = f"{width}x{height}"
try:
self.tk.call("tk", "scaling", scale)
except Exception:
pass
def _entry(self, parent: tk.Widget, label: str, default: str = "") -> tk.Entry:
frame = tk.Frame(parent)
frame.pack(fill="x", padx=4, pady=2)
tk.Label(frame, text=label, width=22, anchor="w", font=self.base_font).pack(side="left")
var = self.vars.get(label)
if not isinstance(var, tk.StringVar):
var = tk.StringVar(value=default)
entry = tk.Entry(frame, textvariable=var, font=self.entry_font)
entry.pack(side="left", fill="x", expand=True)
self.vars[label] = var
return entry
def _browse_entry(self, parent: tk.Widget, label: str, default: str = "", is_dir: bool = True) -> None:
entry = self._entry(parent, label, default)
def choose() -> None:
path = filedialog.askdirectory() if is_dir else filedialog.askopenfilename()
if path:
entry.delete(0, tk.END)
entry.insert(0, path)
tk.Button(entry.master, text="Browse", command=choose, font=self.button_font).pack(side="left", padx=4)
def _checkbox(self, parent: tk.Widget, label: str, default: bool = False) -> None:
var = self.vars.get(label)
if not isinstance(var, tk.BooleanVar):
var = tk.BooleanVar(value=default)
tk.Checkbutton(parent, text=label, variable=var, font=self.base_font).pack(anchor="w", padx=6)
self.vars[label] = var
def _option_menu(self, parent: tk.Widget, label: str, options: list[str], default: str) -> None:
frame = tk.Frame(parent)
frame.pack(fill="x", padx=4, pady=2)
tk.Label(frame, text=label, width=22, anchor="w", font=self.base_font).pack(side="left")
var = self.vars.get(label)
if not isinstance(var, tk.StringVar):
var = tk.StringVar(value=default)
menu = tk.OptionMenu(frame, var, *options)
menu.configure(font=self.base_font)
menu["menu"].configure(font=self.base_font)
menu.pack(side="left", fill="x", expand=True)
self.vars[label] = var
def _on_eval_mode_change(self, *_args) -> None:
mode_var = self.vars.get("Evaluation mode")
num_var = self.vars.get("Number of classes")
if isinstance(mode_var, tk.StringVar) and isinstance(num_var, tk.StringVar):
num_var.set("2" if mode_var.get() == "binary" else "3")
def _build_popup_section(self, title: str, builder, parent: Optional[tk.Widget] = None) -> None:
container = parent if parent is not None else self
frame = tk.Frame(container)
frame.pack(fill="x", padx=6, pady=2)
tk.Button(frame, text=title, command=lambda: self._open_popup(title, builder), font=self.button_font).pack(anchor="w")
def _open_popup(self, title: str, builder) -> None:
win = tk.Toplevel(self)
win.title(title)
win.transient(self)
content = tk.Frame(win, padx=8, pady=8)
content.pack(fill="both", expand=True)
builder(content)
tk.Button(content, text="Close", command=win.destroy, font=self.button_font).pack(pady=(8, 0))
def _build_crop_settings(self, parent: tk.Widget) -> None:
self._browse_entry(parent, "Cropping mask manifest", "manifest.csv", is_dir=False)
self._browse_entry(parent, "Cropping weights (optional)", "", is_dir=False)
self._option_menu(parent, "Crop normalization", ["none", "imagenet", "per_image"], "per_image")
self._entry(parent, "Crop threshold", "0.5")
self._entry(parent, "Crop scale", "2.5")
self._entry(parent, "Crop size", "224")
self._entry(parent, "Crop cache directory", "analysis_data/hypertower_crops")
self._checkbox(parent, "Use ground truth masks", True)
self._checkbox(parent, "Use crop TTA", False)
self._checkbox(parent, "Append geometry features", False)
def _build_se_settings(self, parent: tk.Widget) -> None:
self._checkbox(parent, "Enable SE", True)
self._entry(parent, "SE reduction (bridge)", "16")
self._checkbox(parent, "SE pre-norm (bridge)", True)
self._option_menu(parent, "SE location", ["bridge", "tower", "both", "none"], "bridge")
self._entry(parent, "SE reduction (tower)", "16")
self._checkbox(parent, "SE pre-norm (tower)", True)
def _build_warmup_settings(self, parent: tk.Widget) -> None:
self._entry(parent, "Tower warmup epochs", "2")
self._entry(parent, "Fused warmup epochs", "3")
self._checkbox(parent, "Enable gradual thaw", False)
self._entry(parent, "Thaw phase duration", "5")
self._entry(parent, "Thaw ratio", "0.33")
self._option_menu(parent, "Thaw target", ["image", "metadata", "both"], "image")
self._entry(parent, "Thaw start epoch", "-1")
self._checkbox(parent, "Initial freeze before thaw", False)
def _build_early_stop_settings(self, parent: tk.Widget) -> None:
self._entry(parent, "Early metric", "")
self._option_menu(parent, "Early mode", ["auto", "min", "max"], "auto")
self._entry(parent, "Early patience", "7")
self._entry(parent, "Early min delta", "0.0")
self._checkbox(parent, "Monitor holdout for early stop", False)
self._checkbox(parent, "Save best checkpoint", False)
# ---- Layout -----------------------------------------------------
def _build_form(self) -> None:
form = tk.Frame(self)
form.pack(fill="both", expand=False)
self._entry(form, "Run name", "gui_run")
self._browse_entry(form, "Fundus image directory", "Papila/FundusImages")
self._browse_entry(form, "Clinical data directory", "Papila/ClinicalData")
self._entry(form, "Diagnosis column", "Diagnosis")
self._entry(form, "Categorical columns", "Gender,Phakic/Pseudophakic")
self._option_menu(form, "Backbone architecture", BACKBONES, BACKBONES[0])
self._option_menu(form, "Fusion mode", FUSION_MODES, "image_only")
self._option_menu(form, "Evaluation mode", EVAL_MODES, "binary")
self._entry(form, "Epochs", "40")
self._entry(form, "Batch size", "8")
self._entry(form, "Learning rate", "5e-5")
self._entry(form, "Number of classes", "2")
self._entry(form, "Number of folds", "5")
self._entry(form, "Fold seed", "42")
self._entry(form, "Holdout per class", "8")
self._entry(form, "Holdout seed", "123")
eval_var = self.vars.get("Evaluation mode")
if isinstance(eval_var, tk.StringVar):
eval_var.trace_add("write", self._on_eval_mode_change)
self._on_eval_mode_change()
tk.Label(form, text="Options", font=self.bold_font).pack(anchor="w", padx=6, pady=(8, 0))
self._checkbox(form, "Disable image augmentation", False)
self._checkbox(form, "Use balanced sampler", False)
self._checkbox(form, "Enable early stopping", False)
self._checkbox(form, "Use image cropping", True)
self._checkbox(form, "Use focal loss", False)
self._entry(form, "Focal gamma", "0.0")
dummy = tk.Frame(self)
self._build_crop_settings(dummy)
self._build_se_settings(dummy)
self._build_warmup_settings(dummy)
self._build_early_stop_settings(dummy)
dummy.destroy()
self._build_popup_section("Cropping Settings", self._build_crop_settings, parent=form)
self._build_popup_section("Squeeze-and-Excitation Settings", self._build_se_settings, parent=form)
self._build_popup_section("Warmup / Thaw Settings", self._build_warmup_settings, parent=form)
self._build_popup_section("Early Stop Settings", self._build_early_stop_settings, parent=form)
device_frame = tk.Frame(form)
device_frame.pack(fill="x", pady=4, padx=6)
tk.Label(device_frame, text="Compute Device", font=self.bold_font).pack(anchor="w")
self.device_var = tk.StringVar(value="gpu")
tk.Radiobutton(device_frame, text="GPU", variable=self.device_var, value="gpu",
command=self.on_device_change, font=self.base_font).pack(anchor="w")
tk.Radiobutton(device_frame, text="CPU", variable=self.device_var, value="cpu",
command=self.on_device_change, font=self.base_font).pack(anchor="w")
btn_frame = tk.Frame(form)
btn_frame.pack(fill="x", pady=8)
self.run_button = tk.Button(btn_frame, text="Run", command=self.run_command, font=self.button_font)
self.run_button.pack(side="left", padx=4)
self.stop_button = tk.Button(btn_frame, text="Stop", command=self.stop_command, font=self.button_font, state="disabled")
self.stop_button.pack(side="left", padx=4)
tk.Button(btn_frame, text="Preview Command", command=self.preview_command, font=self.button_font).pack(side="left", padx=4)
tk.Button(btn_frame, text="Export Settings", command=self.export_settings, font=self.button_font).pack(side="right", padx=4)
tk.Button(btn_frame, text="Import Settings", command=self.import_settings, font=self.button_font).pack(side="right", padx=4)
def _build_output(self) -> None:
tk.Label(self, text="Command / Output", font=self.bold_font).pack(anchor="w", padx=6)
self.output = tk.Text(self, height=self.text_height, font=self.mono_font)
self.output.pack(fill="both", expand=True, padx=6, pady=(0, 6))
self.output.configure(state="disabled")
# ---- Run management helpers ------------------------------------
def _compute_run_id_and_dir(self) -> tuple[str, Path]:
shortname = ""
short_var = self.vars.get("Run name")
if isinstance(short_var, tk.StringVar):
shortname = short_var.get().strip()
timestamp = time.strftime("%Y%m%d_%H%M%S")
run_id = f"{shortname}_{timestamp}" if shortname else timestamp
base = Path("analysis_data")
if shortname:
base = base / shortname
run_dir = base / run_id
return run_id, run_dir
def _auto_export_settings(self, run_dir: Path) -> None:
data = {}
for key, var in self.vars.items():
try:
data[key] = var.get()
except Exception:
pass
try:
run_dir.mkdir(parents=True, exist_ok=True)
with open(run_dir / "gui_settings.json", "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
self.append_output(f"[GUI] Settings saved to {run_dir / 'gui_settings.json'}\n")
except Exception as exc: # pragma: no cover
self.append_output(f"[GUI] Failed to save GUI settings: {exc}\n")
def _compose_command(self, include_run_id: bool = False) -> tuple[list[str], Optional[str], Optional[Path]]:
cli_args = self.build_cli_args()
run_id = None
run_dir = None
if include_run_id:
run_id, run_dir = self._compute_run_id_and_dir()
cli_args = cli_args + ["--run-id", run_id]
cmd = [sys.executable, "-u", "scripts/run_multifold.py", *cli_args]
return cmd, run_id, run_dir
def _on_process_finished(self, exit_code: Optional[int], error: Optional[Exception]) -> None:
self.current_process = None
self.current_thread = None
self.current_run_dir = None
self.stop_button.config(state="disabled")
self.run_button.config(state="normal")
if error is not None:
self.append_output(f"\n[GUI] Error: {error}\n")
self.set_status("Error")
return
if exit_code is None:
self.append_output("\nProcess finished.\n")
self.set_status("Finished")
return
self.append_output(f"\nProcess finished with exit code {exit_code}\n")
self.set_status("Finished (exit 0)" if exit_code == 0 else f"Finished (exit {exit_code})")
def _force_terminate_if_running(self) -> None:
proc = self.current_process
if proc is None or proc.poll() is not None:
return
self.append_output("[GUI] Process still running after interrupt; terminating...\n")
try:
proc.terminate()
except Exception as exc: # pragma: no cover
self.append_output(f"[GUI] Failed to terminate process: {exc}\n")
self.after(4000, self._kill_process)
def _kill_process(self) -> None:
proc = self.current_process
if proc is None or proc.poll() is not None:
return
self.append_output("[GUI] Forcing process kill.\n")
try:
proc.kill()
except Exception as exc: # pragma: no cover
self.append_output(f"[GUI] Failed to kill process: {exc}\n")
def stop_command(self) -> None:
proc = self.current_process
if proc is None or proc.poll() is not None:
self.stop_button.config(state="disabled")
return
self.append_output("\n[GUI] Sending interrupt signal...\n")
self.set_status("Stopping...")
try:
if os.name == "nt":
ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", signal.SIGINT)
proc.send_signal(ctrl_break)
else:
proc.send_signal(signal.SIGINT)
except Exception as exc: # pragma: no cover
self.append_output(f"[GUI] Failed to send interrupt: {exc}\n")
self.stop_button.config(state="disabled")
self.after(4000, self._force_terminate_if_running)
# ---- Output helpers ---------------------------------------------
def append_output(self, text: str) -> None:
self.after(0, self._append_output, text)
def _append_output(self, text: str) -> None:
self.output.configure(state="normal")
self.output.insert(tk.END, text)
self.output.see(tk.END)
self.output.configure(state="disabled")
def set_status(self, text: str) -> None:
self.after(0, self.status_var.set, text)
def preview_command(self) -> None:
cmd_list, run_id, run_dir = self._compose_command(include_run_id=True)
cmd = " ".join(cmd_list)
self.output.configure(state="normal")
self.output.delete("1.0", tk.END)
self.output.insert(tk.END, cmd + "\n")
if run_dir is not None:
self.output.insert(tk.END, f"# output directory: {run_dir}\n")
if run_id is not None:
self.output.insert(tk.END, f"# run id: {run_id}\n")
self.output.configure(state="disabled")
# ---- Command execution ------------------------------------------
def run_command(self) -> None:
if self.current_process and self.current_process.poll() is None:
messagebox.showwarning("Run in progress", "A run is already in progress.")
return
cmd_list, run_id, run_dir = self._compose_command(include_run_id=True)
self.output.configure(state="normal")
self.output.delete("1.0", tk.END)
self.output.insert(tk.END, "Running: " + " ".join(cmd_list) + "\n\n")
if run_dir is not None:
self.output.insert(tk.END, f"# output directory: {run_dir}\n\n")
self.output.configure(state="disabled")
self.set_status("Running")
self.run_button.config(state="disabled")
self.stop_button.config(state="normal")
env = os.environ.copy()
if self.force_cpu:
env["CUDA_VISIBLE_DEVICES"] = "-1"
self.current_run_dir = run_dir
if run_dir is not None:
self._auto_export_settings(run_dir)
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) if os.name == "nt" else 0
def worker() -> None:
exit_code: Optional[int] = None
error: Optional[Exception] = None
try:
proc = subprocess.Popen(
cmd_list,
cwd=self.repo_root,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True,
env=env,
creationflags=creationflags,
)
self.current_process = proc
assert proc.stdout is not None
for line in proc.stdout:
if not line:
break
self.append_output(line)
proc.stdout.close()
exit_code = proc.wait()
except Exception as exc: # pragma: no cover
error = exc
finally:
self.after(0, lambda: self._on_process_finished(exit_code, error))
self.current_thread = threading.Thread(target=worker, daemon=True)
self.current_thread.start()
# ---- Command builder --------------------------------------------
def build_cli_args(self) -> list[str]:
args_list: list[str] = []
def add(flag: str, var_name: str, allow_empty: bool = False) -> None:
var = self.vars.get(var_name)
if isinstance(var, tk.StringVar):
value = var.get().strip()
if value or allow_empty:
args_list.extend([flag, value])
add("--shortname", "Run name")
add("--image-dir", "Fundus image directory")
add("--clinical-dir", "Clinical data directory")
add("--label-col", "Diagnosis column")
cats = self.vars["Categorical columns"].get().strip()
if cats:
args_list.extend(["--cat-cols", *[c.strip() for c in cats.split(",") if c.strip()]])
add("--backbone", "Backbone architecture")
add("--fusion-mode", "Fusion mode")
add("--epochs", "Epochs")
add("--batch-size", "Batch size")
add("--lr", "Learning rate")
add("--num-classes", "Number of classes")
add("--n-splits", "Number of folds")
add("--fold-seed", "Fold seed")
add("--holdout-per-class", "Holdout per class")
add("--holdout-seed", "Holdout seed")
eval_mode = self.vars["Evaluation mode"].get()
if eval_mode:
args_list.extend(["--eval_mode", eval_mode])
use_crop_var = self.vars.get("Use image cropping")
if isinstance(use_crop_var, tk.BooleanVar) and use_crop_var.get():
add("--img-crop-manifest", "Cropping mask manifest")
weights = self.vars["Cropping weights (optional)"].get().strip()
if weights:
args_list.extend(["--img-crop-weights", weights])
norm_var = self.vars.get("Crop normalization")
if isinstance(norm_var, tk.StringVar):
norm = norm_var.get().strip()
if norm:
args_list.extend(["--img-crop-normalize", norm])
add("--img-crop-threshold", "Crop threshold")
add("--img-crop-cache", "Crop cache directory")
add("--img-crop-scale", "Crop scale")
add("--img-crop-size", "Crop size")
if self.vars["Use ground truth masks"].get():
args_list.append("--img-crop-gt")
if self.vars["Use crop TTA"].get():
args_list.append("--img-crop-tta")
if self.vars["Append geometry features"].get():
args_list.append("--img-geometry-features")
if self.vars["Disable image augmentation"].get():
args_list.append("--no-img-augment")
if self.vars["Use balanced sampler"].get():
args_list.append("--balanced-sampler")
if self.vars["Enable early stopping"].get():
args_list.append("--early-stop")
if self.vars.get("Use focal loss") and self.vars["Use focal loss"].get():
focal_gamma = self.vars["Focal gamma"].get().strip()
if focal_gamma:
args_list.extend(["--focal-gamma", focal_gamma])
if not self.vars["Enable SE"].get():
args_list.append("--no-se")
else:
add("--se-reduction", "SE reduction (bridge)")
if not self.vars["SE pre-norm (bridge)"].get():
args_list.append("--no-se-pre-norm")
se_where = self.vars["SE location"].get().strip()
if se_where:
args_list.extend(["--se-where", se_where])
add("--se-reduction-tower", "SE reduction (tower)")
if not self.vars["SE pre-norm (tower)"].get():
args_list.append("--no-se-pre-norm-tower")
add("--warmup-tower-epochs", "Tower warmup epochs")
add("--warmup-fused-epochs", "Fused warmup epochs")
if self.vars["Enable gradual thaw"].get():
args_list.append("--gradual-thaw")
add("--thaw-phase-duration", "Thaw phase duration")
add("--thaw-ratio", "Thaw ratio")
thaw_target = self.vars["Thaw target"].get().strip()
if thaw_target:
args_list.extend(["--thaw-target", thaw_target])
add("--thaw-start-epoch", "Thaw start epoch")
if self.vars["Initial freeze before thaw"].get():
args_list.append("--initial-freeze")
early_metric = self.vars["Early metric"].get().strip()
if early_metric:
args_list.extend(["--early-metric", early_metric])
early_mode = self.vars["Early mode"].get().strip()
if early_mode:
args_list.extend(["--early-mode", early_mode])
add("--early-patience", "Early patience")
add("--early-min-delta", "Early min delta")
if self.vars["Monitor holdout for early stop"].get():
args_list.append("--early-monitor-holdout")
if self.vars["Save best checkpoint"].get():
args_list.append("--checkpoint-best")
return args_list
def build_command(self) -> list[str]:
return [sys.executable, "-u", "scripts/run_multifold.py", *self.build_cli_args()]
def on_device_change(self) -> None:
choice = getattr(self, "device_var", None)
if choice is None:
return
choice = self.device_var.get()
if choice == "cpu":
self.force_cpu = True
self.append_output("CPU selected. Forcing CPU usage.\n")
self.set_status("CPU selected")
else:
if not torch.cuda.is_available():
self.append_output("GPU selected but CUDA is not available. Falling back to CPU.\n")
self.device_var.set("cpu")
self.force_cpu = True
self.set_status("GPU unavailable; CPU selected")
return
try:
x = torch.rand((2048,), device="cuda")
y = torch.rand((2048,), device="cuda")
_ = (x * y).sum().item()
self.append_output("GPU support confirmed.\n")
self.force_cpu = False
self.set_status("GPU selected")
except Exception as exc:
self.append_output(f"GPU self-test failed ({exc}). Falling back to CPU.\n")
self.device_var.set("cpu")
self.force_cpu = True
self.set_status("GPU test failed; CPU selected")
# ---- Settings import / export ----------------------------------
def export_settings(self) -> None:
path = filedialog.asksaveasfilename(
title="Export Settings",
defaultextension=".json",
filetypes=[("JSON", "*.json"), ("All files", "*.*")],
)
if not path:
return
data = {}
for key, var in self.vars.items():
try:
data[key] = var.get()
except Exception:
pass
try:
with open(path, "w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2)
self.set_status(f"Settings exported to {path}")
except Exception as exc: # pragma: no cover
messagebox.showerror("Export failed", str(exc))
self.set_status("Export failed")
def import_settings(self) -> None:
path = filedialog.askopenfilename(
title="Import Settings",
filetypes=[("JSON", "*.json"), ("All files", "*.*")],
)
if not path:
return
try:
with open(path, "r", encoding="utf-8") as fh:
data = json.load(fh)
except Exception as exc:
messagebox.showerror("Import failed", str(exc))
self.set_status("Import failed")
return
for key, value in data.items():
var = self.vars.get(key)
if var is None:
continue
try:
if isinstance(var, tk.BooleanVar):
var.set(bool(value))
else:
var.set(str(value))
except Exception:
continue
self.set_status(f"Settings imported from {path}")
self.on_device_change()
def launch_frontend() -> None:
app = MultifoldFrontend()
app.mainloop()
if __name__ == "__main__":
launch_frontend()