Add distributed server implementation and protocol definitions

- Introduced `protocol.py` for shared data models used in server/client communication, including request and response schemas for registration, job submission, and status updates.
- Implemented `server.py` to manage a SQLite job queue and client registry, handling job polling, status updates, and job completion.
- Created a cheat sheet for server usage, detailing commands for starting the server, submitting jobs, and monitoring clients.
- Added several experiment configuration files for various training setups, including geometry vector injections and baseline ensembles.
This commit is contained in:
rpotter6298
2026-04-28 08:24:25 +02:00
parent 4dea45df78
commit 512ebd13b2
42 changed files with 4468 additions and 612 deletions
+63 -20
View File
@@ -11,7 +11,8 @@ import torch.nn.functional as F
from v4.classes.dataset import LoaderShell, to_label_tensor
from v4.classes.metrics import score_arrays, compute_extended_metrics, tune_binary_threshold
from v4.classes.stages.helpers import (
encode_embedding, get_out_dim, resolve_input_dims, phase_for_epoch,
class_weights_from_shell, encode_embedding, get_out_dim, resolve_input_dims,
phase_for_epoch,
)
@@ -25,15 +26,15 @@ def collect_probs(
loader,
device,
num_classes: int,
) -> tuple[np.ndarray, np.ndarray]:
"""Eval pass for one fusion stage; returns (y_true, softmax_probs)."""
) -> tuple[np.ndarray, np.ndarray, list, np.ndarray]:
"""Eval pass for one fusion stage; returns (y_true, softmax_probs, entity_ids, embeddings)."""
from v4.classes.dataset import to_label_tensor
bridge.eval(); primary_head.eval()
for t in towers.values():
t.eval()
inputs = stage_cfg["inputs"]
is_bilateral = isinstance(inputs, dict)
y_all, p_all = [], []
y_all, p_all, ids_all, z_all = [], [], [], []
with torch.no_grad():
for batch in loader:
@@ -57,10 +58,14 @@ def collect_probs(
logits = primary_head(z)
y_all.append(to_label_tensor(y, device).cpu().numpy())
p_all.append(F.softmax(logits, dim=1).cpu().numpy())
z_all.append(z.cpu().numpy())
ids_all.extend(batch.get("entity_id", []))
if not y_all:
return np.zeros(0, dtype=np.int64), np.zeros((0, num_classes), dtype=np.float32)
return np.concatenate(y_all), np.concatenate(p_all, axis=0)
return (np.zeros(0, dtype=np.int64), np.zeros((0, num_classes), dtype=np.float32),
[], np.zeros((0, 0), dtype=np.float32))
return (np.concatenate(y_all), np.concatenate(p_all, axis=0),
ids_all, np.concatenate(z_all, axis=0))
def run(
@@ -88,9 +93,13 @@ def run(
inputs = stage_cfg["inputs"]
is_bilateral = isinstance(inputs, dict)
s_train = data.build_shells(split.train, level=level, label_filter=label_filter)
s_val = data.build_shells(split.val, level=level, label_filter=label_filter)
s_test = (data.build_shells(split.test, level=level, label_filter=label_filter)
eye_filter = stage_cfg.get("eye_filter", None)
s_train = data.build_shells(split.train, level=level, label_filter=label_filter,
eye_filter=eye_filter)
s_val = data.build_shells(split.val, level=level, label_filter=label_filter,
eye_filter=eye_filter)
s_test = (data.build_shells(split.test, level=level, label_filter=label_filter,
eye_filter=eye_filter)
if split.test is not None else LoaderShell(entries=[]))
if not s_val.entries:
@@ -116,7 +125,11 @@ def run(
h_dim = get_out_dim(hs["input"], towers, {**stage_models, name: bridge})
h_mod = importlib.import_module(hs.get("module", "v4.classes.heads.classifier"))
h_cls = getattr(h_mod, hs.get("class", "ClassificationHead"))
head_models[hs["name"]] = h_cls(h_dim, num_classes).to(device)
existing = stage_models.get(hs["name"])
head_models[hs["name"]] = (
existing.to(device) if existing is not None
else h_cls(h_dim, num_classes, **hs.get("args", {})).to(device)
)
primary_hs_cfg = next((hs for hs in head_stage_cfgs if not hs.get("bcd", False)), None)
bcd_head_cfgs = [hs for hs in head_stage_cfgs if hs.get("bcd", False)]
@@ -132,11 +145,23 @@ def run(
for p in m.parameters():
p.requires_grad_(False)
m.eval()
for h in head_models.values():
for p in h.parameters():
p.requires_grad_(True)
# ── Optimizer ────────────────────────────────────────────────────────────
train_towers = stage_cfg.get("train_towers", False)
if train_towers:
# Only train towers that are direct inputs to this stage (not all towers globally).
# For nt_od with inputs ["img_od", "cd_od"] this trains only those two; other
# eye's towers remain untouched.
direct_inputs = list(inputs.values()) if isinstance(inputs, dict) else inputs
tower_params = [p for n in direct_inputs if n in towers
for p in towers[n].parameters()]
else:
tower_params = []
opt_params = (
([p for t in towers.values() for p in t.parameters()] if train_towers else []) +
tower_params +
list(bridge.parameters()) +
[p for h in head_models.values() for p in h.parameters()]
)
@@ -147,6 +172,17 @@ def run(
wf = 0 if is_bilateral else warmup_cfg.get("fused_epochs", 0)
bcd_prob = cfg["training"].get("bcd_prob", 0.5)
cw = class_weights_from_shell(
s_train, num_classes, device,
enabled=cfg["training"].get("class_weighted", False),
)
if cw is not None:
print(
f" fold{fold+1} [{name}] class weights: "
+ ", ".join(f"{i}={w:.3f}" for i, w in enumerate(cw.tolist())),
flush=True,
)
# ── Epoch loop ────────────────────────────────────────────────────────────
for epoch in range(epochs):
bridge.train()
@@ -200,7 +236,7 @@ def run(
if is_bilateral or phase == "fused_warmup":
logits = head_logits.get(primary_hs_cfg["name"])
elif phase == "tower_warmup" and bcd_head_cfgs:
losses = [F.cross_entropy(head_logits[hs["name"]], y_t)
losses = [F.cross_entropy(head_logits[hs["name"]], y_t, weight=cw)
for hs in bcd_head_cfgs if hs["name"] in head_logits]
if not losses:
continue
@@ -217,7 +253,7 @@ def run(
if logits is None:
continue
loss = F.cross_entropy(logits, y_t)
loss = F.cross_entropy(logits, y_t, weight=cw)
opt.zero_grad(); loss.backward(); opt.step()
total_correct += int((logits.argmax(1) == y_t).sum())
total_loss += loss.item() * len(y_t)
@@ -226,8 +262,8 @@ def run(
tr_loss = total_loss / total_n if total_n else nan
tr_acc = total_correct / total_n if total_n else nan
y_v, p_v = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, val_loader, device, num_classes)
y_v, p_v, _, _ = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, val_loader, device, num_classes)
_, val_auc, _ = score_arrays(y_v, p_v, num_classes) if y_v.size else (nan, nan, nan)
print(
f" fold{fold+1} [{name}] ep{epoch+1:03d}/{epochs} [{phase:14s}]"
@@ -236,8 +272,8 @@ def run(
)
# ── Final eval ────────────────────────────────────────────────────────────
y_val, p_val = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, val_loader, device, num_classes)
y_val, p_val, ids_val, z_val = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, val_loader, device, num_classes)
val_acc, val_auc, val_n = (score_arrays(y_val, p_val, num_classes)
if y_val.size else (nan, nan, nan))
ext = compute_extended_metrics(y_val, p_val, num_classes) if y_val.size else {}
@@ -247,10 +283,11 @@ def run(
and num_classes == 2 and y_val.size >= 2):
val_threshold = tune_binary_threshold(y_val, p_val[:, 1])
y_te = p_te = ids_te = z_te = None
test_auc = test_acc = test_n = nan
if test_loader is not None:
y_te, p_te = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, test_loader, device, num_classes)
y_te, p_te, ids_te, z_te = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, test_loader, device, num_classes)
test_acc, test_auc, test_n = (score_arrays(y_te, p_te, num_classes)
if y_te.size else (nan, nan, nan))
@@ -270,4 +307,10 @@ def run(
f"{name}_test_acc": test_acc,
f"{name}_test_n": test_n,
}
return updated, metrics
pred_data = {
name: {
"val_y": y_val, "val_p": p_val, "val_ids": ids_val, "val_z": z_val,
"test_y": y_te, "test_p": p_te, "test_ids": ids_te, "test_z": z_te,
}
}
return updated, metrics, pred_data
+23
View File
@@ -1,9 +1,32 @@
"""stages/helpers — shared utilities for stage runners."""
from __future__ import annotations
from collections import Counter
import torch
def class_weights_from_shell(
shell, num_classes: int, device, *, enabled: bool = True
) -> torch.Tensor | None:
"""Return inverse-frequency CE weights normalised to mean 1, or None.
weights[i] = (N_total / num_classes) / N_class_i → rare classes weighted higher.
Mean(weights) ≈ 1 so overall loss magnitude is unchanged.
Classes absent from the shell get weight 1.0 (no division-by-zero).
"""
if not enabled or shell is None or not shell.entries:
return None
counts = Counter(int(e.label) for e in shell.entries)
n_total = sum(counts.values())
weights = []
for c in range(num_classes):
n_c = counts.get(c, 0)
weights.append(1.0 if n_c == 0 else n_total / (num_classes * n_c))
return torch.tensor(weights, dtype=torch.float32, device=device)
def get_out_dim(name: str, towers: dict, stage_models: dict) -> int:
if name in towers:
return towers[name].out_dim
+394
View File
@@ -0,0 +1,394 @@
"""stages/parallel — runs multiple same-type sub-stages in a shared epoch loop.
Used to train bilateral pairs (OD + OS) simultaneously rather than sequentially.
Sub-stages must all be the same type: either all 'warm' or all 'fusion'.
"""
from __future__ import annotations
import importlib
from random import choice, random as _random
import numpy as np
import torch
import torch.nn.functional as F
from v4.classes.dataset import LoaderShell, to_label_tensor
from v4.classes.metrics import score_arrays, compute_extended_metrics, tune_binary_threshold
from v4.classes.stages.helpers import (
class_weights_from_shell, get_out_dim, resolve_input_dims, phase_for_epoch,
)
from v4.classes.stages.fusion import collect_probs
def run(
stage_cfg: dict,
cfg: dict,
towers: dict,
stage_models: dict,
data,
split,
label_filter,
num_classes: int,
device,
fold: int,
cfg_stages: list[dict],
_make_loader,
_balanced_sampler,
) -> tuple[dict, dict, dict]:
sub_cfgs = stage_cfg["stages"]
sub_types = {s["type"] for s in sub_cfgs}
if sub_types == {"warm"}:
return _parallel_warm(sub_cfgs, cfg, towers, stage_models, data, split,
label_filter, num_classes, device, fold,
cfg_stages, _make_loader, _balanced_sampler)
elif sub_types == {"fusion"}:
return _parallel_fusion(sub_cfgs, cfg, towers, stage_models, data, split,
label_filter, num_classes, device, fold,
cfg_stages, _make_loader)
else:
raise ValueError(
f"parallel stage sub-stages must all be the same type (warm or fusion), "
f"got {sub_types}"
)
# ── Parallel warm ─────────────────────────────────────────────────────────────
def _parallel_warm(
sub_cfgs, cfg, towers, stage_models, data, split,
label_filter, num_classes, device, fold, cfg_stages, _make_loader, _balanced_sampler,
):
bs = cfg["training"]["batch_size"]
contexts = []
for sc in sub_cfgs:
tower_name = sc["tower"]
level = sc["level"]
shell_filter = sc.get("shell_filter", {})
n_epochs = sc.get("epochs", 0)
s_train = data.build_shells(split.train, level=level, label_filter=label_filter,
**shell_filter)
loader = _make_loader(s_train, {tower_name: towers[tower_name]},
batch_size=bs, shuffle=False,
sampler=_balanced_sampler(s_train))
head_name = sc.get("head_name")
if head_name:
head_cfg = next((s for s in cfg_stages if s.get("name") == head_name), None)
if head_cfg is None:
raise ValueError(f"warm stage requested head_name={head_name!r}, but no such head exists")
h_mod = importlib.import_module(head_cfg.get("module", "v4.classes.heads.classifier"))
h_cls = getattr(h_mod, head_cfg.get("class", "ClassificationHead"))
probe = stage_models.get(head_name)
if probe is None:
probe = h_cls(towers[tower_name].out_dim, num_classes, **head_cfg.get("args", {}))
probe = probe.to(device)
else:
probe = torch.nn.Linear(towers[tower_name].out_dim, num_classes).to(device)
opt = torch.optim.Adam(
list(towers[tower_name].parameters()) + list(probe.parameters()),
lr=cfg["training"]["lr"],
)
cw = class_weights_from_shell(
s_train, num_classes, device,
enabled=cfg["training"].get("class_weighted", False),
)
contexts.append({
"name": tower_name, "n_epochs": n_epochs,
"loader": loader, "probe": probe, "opt": opt,
"head_name": head_name, "class_weights": cw,
})
active = {c["name"] for c in contexts if c["n_epochs"] > 0}
for name, t in towers.items():
for p in t.parameters():
p.requires_grad_(name in active)
max_epochs = max((c["n_epochs"] for c in contexts), default=0)
for epoch in range(max_epochs):
for ctx in contexts:
if epoch >= ctx["n_epochs"]:
continue
tower_name = ctx["name"]
towers[tower_name].train()
total_loss = total_correct = total_n = 0
for batch in ctx["loader"]:
y = batch.get("label")
x = batch.get(tower_name)
if not torch.is_tensor(y) or not torch.is_tensor(x):
continue
y_t = to_label_tensor(y, device)
logits = ctx["probe"](towers[tower_name](x.to(device)))
loss = F.cross_entropy(logits, y_t, weight=ctx["class_weights"])
ctx["opt"].zero_grad(); loss.backward(); ctx["opt"].step()
total_loss += loss.item() * len(y_t)
total_correct += int((logits.argmax(1) == y_t).sum())
total_n += len(y_t)
if total_n:
print(
f" fold{fold+1} [warm/{tower_name}]"
f" ep{epoch+1:03d}/{ctx['n_epochs']}"
f" loss={total_loss/total_n:.4f} acc={total_correct/total_n:.3f}",
flush=True,
)
for t in towers.values():
for p in t.parameters():
p.requires_grad_(True)
updated = dict(stage_models)
for ctx in contexts:
if ctx.get("head_name"):
updated[ctx["head_name"]] = ctx["probe"]
return updated, {}, {}
# ── Parallel fusion ───────────────────────────────────────────────────────────
def _parallel_fusion(
sub_cfgs, cfg, towers, stage_models, data, split,
label_filter, num_classes, device, fold, cfg_stages, _make_loader,
):
nan = float("nan")
bs = cfg["training"]["batch_size"]
bcd_prob = cfg["training"].get("bcd_prob", 0.5)
# Freeze all prior stage models once, before building any bridges.
for m in stage_models.values():
for p in m.parameters():
p.requires_grad_(False)
m.eval()
# ── Per-sub-stage setup ───────────────────────────────────────────────────
contexts = []
for sc in sub_cfgs:
name = sc["name"]
level = sc["level"]
inputs = sc["inputs"]
epochs = sc["epochs"]
eye_filter = sc.get("eye_filter", None)
s_train = data.build_shells(split.train, level=level, label_filter=label_filter,
eye_filter=eye_filter)
s_val = data.build_shells(split.val, level=level, label_filter=label_filter,
eye_filter=eye_filter)
s_test = (data.build_shells(split.test, level=level, label_filter=label_filter,
eye_filter=eye_filter)
if split.test is not None else LoaderShell(entries=[]))
if not s_val.entries:
print(f" fold{fold+1}: no val samples for stage {name!r}, skipping.", flush=True)
continue
input_dims = resolve_input_dims(inputs, towers, stage_models)
bmod = importlib.import_module(sc["module"])
bridge = getattr(bmod, sc["class"])(input_dims, **sc.get("args", {})).to(device)
# Head stages for this sub-stage.
head_stage_cfgs = [s for s in cfg_stages if s["type"] == "head"
and s.get("train_with") == name]
head_models: dict[str, torch.nn.Module] = {}
for hs in head_stage_cfgs:
h_dim = get_out_dim(hs["input"], towers, {**stage_models, name: bridge})
h_mod = importlib.import_module(hs.get("module", "v4.classes.heads.classifier"))
h_cls = getattr(h_mod, hs.get("class", "ClassificationHead"))
existing = stage_models.get(hs["name"])
head_models[hs["name"]] = (
existing.to(device) if existing is not None
else h_cls(h_dim, num_classes, **hs.get("args", {})).to(device)
)
for h in head_models.values():
for p in h.parameters():
p.requires_grad_(True)
primary_hs_cfg = next((hs for hs in head_stage_cfgs if not hs.get("bcd", False)), None)
bcd_head_cfgs = [hs for hs in head_stage_cfgs if hs.get("bcd", False)]
if primary_hs_cfg is None:
print(f" WARNING: no primary head for stage {name!r}; skipping.", flush=True)
continue
primary_head = head_models[primary_hs_cfg["name"]]
train_towers = sc.get("train_towers", False)
direct_inputs = inputs if isinstance(inputs, list) else list(inputs.values())
tower_params = ([p for n in direct_inputs if n in towers
for p in towers[n].parameters()]
if train_towers else [])
opt_params = (tower_params + list(bridge.parameters()) +
[p for h in head_models.values() for p in h.parameters()])
opt = torch.optim.Adam(opt_params, lr=cfg["training"]["lr"])
warmup_cfg = sc.get("warmup", {})
wt = warmup_cfg.get("tower_epochs", 0)
wf = warmup_cfg.get("fused_epochs", 0)
train_loader = _make_loader(s_train, towers, batch_size=bs, shuffle=True)
val_loader = _make_loader(s_val, towers, batch_size=bs, shuffle=False)
test_loader = (_make_loader(s_test, towers, batch_size=bs, shuffle=False)
if s_test.entries else None)
cw = class_weights_from_shell(
s_train, num_classes, device,
enabled=cfg["training"].get("class_weighted", False),
)
contexts.append({
"name": name, "inputs": inputs, "epochs": epochs,
"bridge": bridge, "head_models": head_models,
"primary_head": primary_head, "primary_hs_cfg": primary_hs_cfg,
"bcd_head_cfgs": bcd_head_cfgs,
"opt": opt, "wt": wt, "wf": wf,
"train_towers": train_towers, "direct_inputs": direct_inputs,
"train_loader": train_loader, "val_loader": val_loader,
"test_loader": test_loader, "sc": sc,
"class_weights": cw,
})
if not contexts:
return stage_models, {}, {}
max_epochs = max(c["epochs"] for c in contexts)
# ── Shared epoch loop ─────────────────────────────────────────────────────
for epoch in range(max_epochs):
for ctx in contexts:
if epoch >= ctx["epochs"]:
continue
name = ctx["name"]
bridge = ctx["bridge"]
inputs = ctx["inputs"]
wt, wf = ctx["wt"], ctx["wf"]
phase = phase_for_epoch(epoch, wt, wf)
bridge.train()
for h in ctx["head_models"].values():
h.train()
if ctx["train_towers"]:
for n in ctx["direct_inputs"]:
if n in towers:
towers[n].train()
else:
for n in ctx["direct_inputs"]:
if n in towers:
towers[n].eval()
if hasattr(bridge, "set_phase"):
bridge.set_phase(phase)
total_loss = total_correct = total_n = 0
for batch in ctx["train_loader"]:
y = batch.get("label")
if not torch.is_tensor(y):
continue
y_t = to_label_tensor(y, device)
if y_t.numel() == 0:
continue
local_embs = {n: towers[n](batch[n].to(device)) for n in inputs
if n in batch and torch.is_tensor(batch[n])}
if len(local_embs) != len(inputs):
continue
local_embs[name] = bridge(list(local_embs[n] for n in inputs))
head_logits = {
hs["name"]: ctx["head_models"][hs["name"]](local_embs[hs["input"]])
for hs in ([ctx["primary_hs_cfg"]] + ctx["bcd_head_cfgs"])
if hs["input"] in local_embs
}
if phase == "fused_warmup":
logits = head_logits.get(ctx["primary_hs_cfg"]["name"])
elif phase == "tower_warmup" and ctx["bcd_head_cfgs"]:
losses = [F.cross_entropy(head_logits[hs["name"]], y_t,
weight=ctx["class_weights"])
for hs in ctx["bcd_head_cfgs"] if hs["name"] in head_logits]
if not losses:
continue
loss = sum(losses) / len(losses)
ctx["opt"].zero_grad(); loss.backward(); ctx["opt"].step()
total_loss += loss.item() * len(y_t)
total_n += len(y_t)
continue
else:
if ctx["bcd_head_cfgs"] and _random() < bcd_prob:
logits = head_logits.get(choice(ctx["bcd_head_cfgs"])["name"])
else:
logits = head_logits.get(ctx["primary_hs_cfg"]["name"])
if logits is None:
continue
loss = F.cross_entropy(logits, y_t, weight=ctx["class_weights"])
ctx["opt"].zero_grad(); loss.backward(); ctx["opt"].step()
total_correct += int((logits.argmax(1) == y_t).sum())
total_loss += loss.item() * len(y_t)
total_n += len(y_t)
tr_loss = total_loss / total_n if total_n else nan
tr_acc = total_correct / total_n if total_n else nan
y_v, p_v, _, _ = collect_probs(bridge, ctx["primary_head"], ctx["sc"], towers,
stage_models, cfg_stages, ctx["val_loader"],
device, num_classes)
_, val_auc, _ = score_arrays(y_v, p_v, num_classes) if y_v.size else (nan, nan, nan)
print(
f" fold{fold+1} [{name}] ep{epoch+1:03d}/{ctx['epochs']} [{phase:14s}]"
f" loss={tr_loss:.4f} acc={tr_acc:.3f} val_auc={val_auc:.4f}",
flush=True,
)
# ── Final eval + collect results ──────────────────────────────────────────
updated = dict(stage_models)
all_metrics: dict = {}
all_preds: dict = {}
for ctx in contexts:
name = ctx["name"]
bridge = ctx["bridge"]
primary_head = ctx["primary_head"]
updated[name] = bridge
updated.update(ctx["head_models"])
y_val, p_val, ids_val, z_val = collect_probs(bridge, primary_head, ctx["sc"], towers,
stage_models, cfg_stages, ctx["val_loader"],
device, num_classes)
val_acc, val_auc, val_n = (score_arrays(y_val, p_val, num_classes)
if y_val.size else (nan, nan, nan))
ext = compute_extended_metrics(y_val, p_val, num_classes) if y_val.size else {}
val_threshold = 0.5
if (cfg["training"].get("tune_binary_threshold")
and num_classes == 2 and y_val.size >= 2):
val_threshold = tune_binary_threshold(y_val, p_val[:, 1])
y_te = p_te = ids_te = z_te = None
test_auc = test_acc = test_n = nan
if ctx["test_loader"] is not None:
y_te, p_te, ids_te, z_te = collect_probs(bridge, primary_head, ctx["sc"], towers,
stage_models, cfg_stages, ctx["test_loader"],
device, num_classes)
test_acc, test_auc, test_n = (score_arrays(y_te, p_te, num_classes)
if y_te.size else (nan, nan, nan))
all_metrics.update({
f"{name}_val_auc": val_auc,
f"{name}_val_acc": val_acc,
f"{name}_val_n": val_n,
f"{name}_val_kappa": ext.get("kappa", nan),
f"{name}_val_mcc": ext.get("mcc", nan),
f"{name}_val_f1": ext.get("macro_f1", nan),
f"{name}_val_threshold": val_threshold,
f"{name}_test_auc": test_auc,
f"{name}_test_acc": test_acc,
f"{name}_test_n": test_n,
})
all_preds[name] = {
"val_y": y_val, "val_p": p_val, "val_ids": ids_val, "val_z": z_val,
"test_y": y_te, "test_p": p_te, "test_ids": ids_te, "test_z": z_te,
}
return updated, all_metrics, all_preds
+47 -11
View File
@@ -1,11 +1,13 @@
"""stages/warm — warm stage runner: pre-trains a single tower with a temporary probe."""
"""stages/warm — warm stage runner: pre-trains a single tower and optional real head."""
from __future__ import annotations
import importlib
import torch
import torch.nn.functional as F
from v4.classes.dataset import to_label_tensor
from v4.classes.stages.helpers import phase_for_epoch
from v4.classes.stages.helpers import class_weights_from_shell
def run(
@@ -20,16 +22,27 @@ def run(
fold: int,
_make_loader,
_balanced_sampler,
) -> None:
"""Pre-train one tower using a temporary linear probe (probe discarded after)."""
tower_name = stage_cfg["tower"]
n_epochs = stage_cfg.get("epochs", 0)
level = stage_cfg["level"]
stage_models: dict | None = None,
cfg_stages: list[dict] | None = None,
) -> dict:
"""Pre-train one tower.
If ``head_name`` is set on the stage config, train that real downstream head
and return it in ``stage_models``. Otherwise, fall back to a temporary linear
probe for backward-compatible representation warmup.
"""
tower_name = stage_cfg["tower"]
n_epochs = stage_cfg.get("epochs", 0)
level = stage_cfg["level"]
shell_filter = stage_cfg.get("shell_filter", {})
stage_models = dict(stage_models or {})
cfg_stages = list(cfg_stages or [])
if n_epochs == 0:
return
return stage_models
s_train = data.build_shells(split.train, level=level, label_filter=label_filter)
s_train = data.build_shells(split.train, level=level, label_filter=label_filter,
**shell_filter)
bs = cfg["training"]["batch_size"]
loader = _make_loader(
s_train, {tower_name: towers[tower_name]},
@@ -41,13 +54,32 @@ def run(
for p in t.parameters():
p.requires_grad_(n == tower_name)
probe = torch.nn.Linear(towers[tower_name].out_dim, num_classes).to(device)
head_name = stage_cfg.get("head_name")
if head_name:
head_cfg = next((s for s in cfg_stages if s.get("name") == head_name), None)
if head_cfg is None:
raise ValueError(f"warm stage requested head_name={head_name!r}, but no such head exists")
h_mod = importlib.import_module(head_cfg.get("module", "v4.classes.heads.classifier"))
h_cls = getattr(h_mod, head_cfg.get("class", "ClassificationHead"))
probe = stage_models.get(head_name)
if probe is None:
probe = h_cls(towers[tower_name].out_dim, num_classes, **head_cfg.get("args", {}))
probe = probe.to(device)
else:
probe = torch.nn.Linear(towers[tower_name].out_dim, num_classes).to(device)
opt = torch.optim.Adam(
list(towers[tower_name].parameters()) + list(probe.parameters()),
lr=cfg["training"]["lr"],
)
cw = class_weights_from_shell(
s_train, num_classes, device,
enabled=cfg["training"].get("class_weighted", False),
)
towers[tower_name].train()
probe.train()
for epoch in range(n_epochs):
total_loss = total_correct = total_n = 0
for batch in loader:
@@ -57,7 +89,7 @@ def run(
continue
y_t = to_label_tensor(y, device)
logits = probe(towers[tower_name](x.to(device)))
loss = F.cross_entropy(logits, y_t)
loss = F.cross_entropy(logits, y_t, weight=cw)
opt.zero_grad(); loss.backward(); opt.step()
total_loss += loss.item() * len(y_t)
total_correct += int((logits.argmax(1) == y_t).sum())
@@ -71,3 +103,7 @@ def run(
for t in towers.values():
for p in t.parameters():
p.requires_grad_(True)
if head_name:
stage_models[head_name] = probe
return stage_models