v4 update

This commit is contained in:
rpotter6298
2026-04-20 18:01:31 +02:00
parent 13290575d5
commit 4dea45df78
71 changed files with 8316 additions and 4112 deletions
View File
+273
View File
@@ -0,0 +1,273 @@
"""stages/fusion — fusion stage runner: trains a bridge + associated head stages."""
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 (
encode_embedding, get_out_dim, resolve_input_dims, phase_for_epoch,
)
def collect_probs(
bridge,
primary_head,
stage_cfg: dict,
towers: dict,
stage_models: dict,
cfg_stages: list[dict],
loader,
device,
num_classes: int,
) -> tuple[np.ndarray, np.ndarray]:
"""Eval pass for one fusion stage; returns (y_true, softmax_probs)."""
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 = [], []
with torch.no_grad():
for batch in loader:
y = batch.get("label")
if not torch.is_tensor(y):
continue
if is_bilateral:
side_embs = {
side: encode_embedding(src, batch, side, towers, stage_models, cfg_stages, device)
for side, src in inputs.items()
}
z = bridge(side_embs)
else:
embs = [
encode_embedding(n, batch, None, towers, stage_models, cfg_stages, device)
for n in inputs
]
z = bridge(embs)
logits = primary_head(z)
y_all.append(to_label_tensor(y, device).cpu().numpy())
p_all.append(F.softmax(logits, dim=1).cpu().numpy())
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)
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,
) -> tuple[dict, dict]:
"""Train one fusion stage + its associated head stages.
Returns (updated_stage_models, metrics_dict).
"""
nan = float("nan")
name = stage_cfg["name"]
level = stage_cfg["level"]
epochs = stage_cfg["epochs"]
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)
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)
return stage_models, {}
bs = cfg["training"]["batch_size"]
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)
# ── Bridge ───────────────────────────────────────────────────────────────
input_dims = resolve_input_dims(inputs, towers, stage_models)
bmod = importlib.import_module(stage_cfg["module"])
bridge = getattr(bmod, stage_cfg["class"])(input_dims, **stage_cfg.get("args", {})).to(device)
# ── Head stages ──────────────────────────────────────────────────────────
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"))
head_models[hs["name"]] = h_cls(h_dim, num_classes).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)]
if primary_hs_cfg is None:
print(f" WARNING: no primary head for stage {name!r}; skipping.", flush=True)
return stage_models, {}
primary_head = head_models[primary_hs_cfg["name"]]
# ── Freeze prior stages ───────────────────────────────────────────────────
for m in stage_models.values():
for p in m.parameters():
p.requires_grad_(False)
m.eval()
# ── Optimizer ────────────────────────────────────────────────────────────
train_towers = stage_cfg.get("train_towers", False)
opt_params = (
([p for t in towers.values() for p in t.parameters()] if train_towers else []) +
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 = stage_cfg.get("warmup", {})
wt = 0 if is_bilateral else warmup_cfg.get("tower_epochs", 0)
wf = 0 if is_bilateral else warmup_cfg.get("fused_epochs", 0)
bcd_prob = cfg["training"].get("bcd_prob", 0.5)
# ── Epoch loop ────────────────────────────────────────────────────────────
for epoch in range(epochs):
bridge.train()
for h in head_models.values():
h.train()
for t in towers.values():
if train_towers:
t.train()
else:
t.eval()
if not is_bilateral:
phase = phase_for_epoch(epoch, wt, wf)
if hasattr(bridge, "set_phase"):
bridge.set_phase(phase)
for t in towers.values():
if hasattr(t, "set_phase"):
t.set_phase(phase)
else:
phase = "fusion"
total_loss = total_correct = total_n = 0
for batch in 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
if is_bilateral:
side_embs = {
side: encode_embedding(src, batch, side, towers, stage_models, cfg_stages, device)
for side, src in inputs.items()
}
local_embs = {name: bridge(side_embs)}
else:
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"]: head_models[hs["name"]](local_embs[hs["input"]])
for hs in head_stage_cfgs
if hs["input"] in local_embs
}
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)
for hs in bcd_head_cfgs if hs["name"] in head_logits]
if not losses:
continue
loss = sum(losses) / len(losses)
opt.zero_grad(); loss.backward(); opt.step()
total_loss += loss.item() * len(y_t)
total_n += len(y_t)
continue
else:
if bcd_head_cfgs and _random() < bcd_prob:
logits = head_logits.get(choice(bcd_head_cfgs)["name"])
else:
logits = head_logits.get(primary_hs_cfg["name"])
if logits is None:
continue
loss = F.cross_entropy(logits, y_t)
opt.zero_grad(); loss.backward(); 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, primary_head, stage_cfg, towers,
stage_models, cfg_stages, val_loader, device, num_classes)
_, val_auc, _ = score_arrays(y_v, p_v, num_classes) if y_v.size else (nan, nan, nan)
print(
f" fold{fold+1} [{name}] ep{epoch+1:03d}/{epochs} [{phase:14s}]"
f" loss={tr_loss:.4f} acc={tr_acc:.3f} val_auc={val_auc:.4f}",
flush=True,
)
# ── Final eval ────────────────────────────────────────────────────────────
y_val, p_val = collect_probs(bridge, primary_head, stage_cfg, towers,
stage_models, cfg_stages, val_loader, device, num_classes)
val_acc, val_auc, val_n = (score_arrays(y_val, p_val, num_classes)
if y_val.size else (nan, nan, nan))
ext = compute_extended_metrics(y_val, p_val, num_classes) if y_val.size else {}
val_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])
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)
test_acc, test_auc, test_n = (score_arrays(y_te, p_te, num_classes)
if y_te.size else (nan, nan, nan))
updated = dict(stage_models)
updated[name] = bridge
updated.update(head_models)
metrics = {
f"{name}_val_auc": val_auc,
f"{name}_val_acc": val_acc,
f"{name}_val_n": val_n,
f"{name}_val_kappa": ext.get("kappa", nan),
f"{name}_val_mcc": ext.get("mcc", nan),
f"{name}_val_f1": ext.get("macro_f1", nan),
f"{name}_val_threshold": val_threshold,
f"{name}_test_auc": test_auc,
f"{name}_test_acc": test_acc,
f"{name}_test_n": test_n,
}
return updated, metrics
+66
View File
@@ -0,0 +1,66 @@
"""stages/helpers — shared utilities for stage runners."""
from __future__ import annotations
import torch
def get_out_dim(name: str, towers: dict, stage_models: dict) -> int:
if name in towers:
return towers[name].out_dim
if name in stage_models:
return stage_models[name].out_dim
raise KeyError(f"No out_dim for input {name!r}")
def resolve_input_dims(inputs, towers: dict, stage_models: dict):
"""Return list[int] for list inputs, dict[str, int] for dict inputs."""
if isinstance(inputs, list):
return [get_out_dim(n, towers, stage_models) for n in inputs]
return {k: get_out_dim(v, towers, stage_models) for k, v in inputs.items()}
def encode_embedding(
name: str,
batch: dict,
side: str | None,
towers: dict,
stage_models: dict,
cfg_stages: list[dict],
device,
) -> torch.Tensor:
"""Return the embedding for a named tower or prior frozen fusion stage.
For bilateral batches, *side* selects which side dict entry to use.
Fusion stages are re-encoded recursively from their own inputs.
Runs under torch.no_grad() — only used for frozen passes.
"""
if name in towers:
t = batch[name]
if side is not None and isinstance(t, dict):
t = t[side]
with torch.no_grad():
return towers[name](t.to(device))
s_cfg = next(s for s in cfg_stages if s["name"] == name)
model = stage_models[name]
inputs = s_cfg["inputs"]
if isinstance(inputs, list):
embeddings = [
encode_embedding(n, batch, side, towers, stage_models, cfg_stages, device)
for n in inputs
]
with torch.no_grad():
return model(embeddings)
raise NotImplementedError(
f"Stage {name!r} uses dict inputs and cannot be used as a fusion input."
)
def phase_for_epoch(epoch: int, warmup_tower: int, warmup_fused: int) -> str:
if epoch < warmup_tower:
return "tower_warmup"
if epoch < warmup_tower + warmup_fused:
return "fused_warmup"
return "main"
+73
View File
@@ -0,0 +1,73 @@
"""stages/warm — warm stage runner: pre-trains a single tower with a temporary probe."""
from __future__ import annotations
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
def run(
stage_cfg: dict,
towers: dict,
data,
split,
label_filter,
cfg: dict,
num_classes: int,
device,
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"]
if n_epochs == 0:
return
s_train = data.build_shells(split.train, level=level, label_filter=label_filter)
bs = cfg["training"]["batch_size"]
loader = _make_loader(
s_train, {tower_name: towers[tower_name]},
batch_size=bs, shuffle=False,
sampler=_balanced_sampler(s_train),
)
for n, t in towers.items():
for p in t.parameters():
p.requires_grad_(n == tower_name)
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"],
)
towers[tower_name].train()
for epoch in range(n_epochs):
total_loss = total_correct = total_n = 0
for batch in 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 = probe(towers[tower_name](x.to(device)))
loss = F.cross_entropy(logits, y_t)
opt.zero_grad(); loss.backward(); opt.step()
total_loss += loss.item() * len(y_t)
total_correct += int((logits.argmax(1) == y_t).sum())
total_n += len(y_t)
print(
f" fold{fold+1} [warm/{tower_name}] ep{epoch+1:03d}/{n_epochs}"
f" loss={total_loss/total_n:.4f} acc={total_correct/total_n:.3f}",
flush=True,
)
for t in towers.values():
for p in t.parameters():
p.requires_grad_(True)