kaveh's picture
experiments: two-phase training protocol
90ed5f2
Raw History Blame Contribute Delete
15.2 kB
"""
Train one baseline / ablation variant with the shared protocol (experiments/configs.py).
python -m experiments.train --variant s2f_full
python -m experiments.train --variant unet --seed 1
python -m experiments.train --variant s2f_no_gan --resume # continue an interrupted run
python -m experiments.train --variant unet --epochs 1 --max-batches 5 # smoke test (1 epoch per phase)
Two phases (see experiments/configs.py). Outputs in experiments/runs/<variant>_seed<seed>/:
config.json exact configuration used
phase1/, phase2/ per phase: history.json (every loss term, val loss / WFM-RME / pixel r, lr, time),
best_checkpoint.pth (lowest validation loss of that phase), last_checkpoint.pth
(full state for --resume), visualizations/ (first validation batch every 10 epochs)
best_checkpoint.pth final model = best phase-2 generator (written when the run is complete)
history.json both phases concatenated (written when the run is complete)
The training loop follows training/s2f_trainer.py (same losses, label convention and update order);
it additionally skips the discriminator when lambda_gan = 0, records every loss term, and seeds all RNGs.
"""
import argparse
import json
import math
import os
import random
import shutil
import time
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from experiments.ablation_models import build_discriminator, build_generator, n_params
from experiments.configs import get_config
from experiments.layout import DEFAULT_DATASET_ROOT, RUNS_DIR, S2F_ROOT
from experiments.losses import REC_LOSSES, PerImageWFMRMELoss
from experiments.manifest_data import make_loader
from models.s2f_model import create_settings_channels
from utils.substrate_settings import compute_settings_normalization
CONFIG_PATH = os.path.join(S2F_ROOT, "config", "substrate_settings.json")
def seed_everything(seed):
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)
def cosine_with_warmup(optimizer, warmup_steps, total_steps):
"""Same schedule as diffusers.optimization.get_cosine_schedule_with_warmup (num_cycles=0.5)."""
def f(step):
if step < warmup_steps:
return step / max(1, warmup_steps)
progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
return max(0.0, 0.5 * (1.0 + math.cos(math.pi * progress)))
return torch.optim.lr_scheduler.LambdaLR(optimizer, f)
def add_settings(x, batch, cfg, norm, device):
if cfg["substrate_mode"] == "none":
return x
meta = batch[4]
return torch.cat([x, create_settings_channels(meta, norm, device, x.shape, config_path=CONFIG_PATH)], dim=1)
def save_vis(G, val_loader, cfg, norm, device, path):
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
batch = next(iter(val_loader))
x = add_settings(batch[0].to(device), batch, cfg, norm, device)
with torch.no_grad():
p = (G(x) + 1) / 2
n = min(2, x.size(0))
fig, ax = plt.subplots(3, n, figsize=(4 * n, 12), squeeze=False)
for i in range(n):
ax[0, i].imshow(x[i, 0].cpu(), cmap="gray"); ax[1, i].imshow(p[i, 0].cpu(), cmap="jet", vmin=0, vmax=1)
ax[2, i].imshow(batch[1][i, 0], cmap="jet", vmin=0, vmax=1)
for r in range(3):
ax[r, i].axis("off")
fig.tight_layout(); fig.savefig(path, dpi=100); plt.close(fig)
def pixel_r(p, y):
"""Mean per-image Pearson correlation between predicted and true maps (monitoring only)."""
p, y = p.flatten(1), y.flatten(1)
p, y = p - p.mean(1, keepdim=True), y - y.mean(1, keepdim=True)
return ((p * y).sum(1) / (p.norm(dim=1) * y.norm(dim=1) + 1e-12)).mean()
def _done(phase_dir, epochs):
h = os.path.join(phase_dir, "history.json")
return (os.path.exists(h) and len(json.load(open(h))) >= epochs
and os.path.exists(os.path.join(phase_dir, "best_checkpoint.pth")))
def run_phase(G, ph, phase_dir, cfg, train_loader, val_loader, norm, device, resume, max_batches):
"""One training phase. Fresh discriminator, optimisers and schedule (as in the original staged training);
the generator continues from its current weights. Checkpoint selection: lowest validation loss of this phase."""
os.makedirs(os.path.join(phase_dir, "visualizations"), exist_ok=True)
use_gan = cfg["lambda_gan"] > 0
D = build_discriminator(cfg["in_channels"]).to(device) if use_gan else None
g_opt = torch.optim.Adam(G.parameters(), lr=ph["g_lr"], betas=(cfg["beta1"], cfg["beta2"]))
d_opt = torch.optim.Adam(D.parameters(), lr=ph["d_lr"], betas=(cfg["beta1"], cfg["beta2"])) if use_gan else None
n_batches = min(len(train_loader), max_batches) if max_batches else len(train_loader)
steps = n_batches * ph["epochs"]
g_sch = cosine_with_warmup(g_opt, int(steps * cfg["warmup_frac"]), steps)
d_sch = cosine_with_warmup(d_opt, int(steps * cfg["warmup_frac"]), steps) if use_gan else None
rec_loss, rme, bce = REC_LOSSES[ph["rec_loss"]](), PerImageWFMRMELoss(), nn.BCEWithLogitsLoss()
hist, best, start = [], float("inf"), 0
last_path = os.path.join(phase_dir, "last_checkpoint.pth")
if resume and os.path.exists(last_path):
ck = torch.load(last_path, map_location="cpu", weights_only=False)
G.load_state_dict(ck["generator_state_dict"]); g_opt.load_state_dict(ck["g_optimizer"]); g_sch.load_state_dict(ck["g_scheduler"])
if use_gan:
D.load_state_dict(ck["discriminator_state_dict"]); d_opt.load_state_dict(ck["d_optimizer"]); d_sch.load_state_dict(ck["d_scheduler"])
hist, best, start = ck["history"], ck["best_val_loss"], ck["epoch"] + 1
print(f"[{ph['name']}] resumed at epoch {start}")
print(f"[{ph['name']}] {ph['epochs']} epochs, loss {ph['rec_loss']} x{ph['lambda_rec']:g}, "
f"GAN x{cfg['lambda_gan']:g}, force x{ph['lambda_force']:g}, lr {ph['g_lr']:g}", flush=True)
for epoch in range(start, ph["epochs"]):
t0 = time.time(); G.train(); D.train() if use_gan else None
acc = dict(g=0.0, rec=0.0, gan=0.0, force=0.0, d=0.0); nb = 0
for bi, batch in enumerate(train_loader):
if max_batches and bi >= max_batches:
break
x = add_settings(batch[0].to(device), batch, cfg, norm, device)
y = batch[1].to(device)
fake = G(x); p = (fake + 1) / 2
l_rec = rec_loss(p, y)
l_force = F.mse_loss(p.flatten(1).mean(1), y.flatten(1).mean(1))
l_gan = torch.zeros((), device=device)
if use_gan:
pf = D(torch.cat([x, fake], 1)); l_gan = bce(pf, torch.ones_like(pf))
l_g = ph["lambda_rec"] * l_rec + cfg["lambda_gan"] * l_gan + ph["lambda_force"] * l_force
g_opt.zero_grad(); l_g.backward(); g_opt.step()
l_d = torch.zeros(())
if use_gan:
pr = D(torch.cat([x, y * 2 - 1], 1)); pf = D(torch.cat([x, fake.detach()], 1))
l_d = 0.5 * (bce(pr, torch.ones_like(pr)) + bce(pf, torch.zeros_like(pf)))
d_opt.zero_grad(); l_d.backward(); d_opt.step(); d_sch.step()
g_sch.step()
for k, v in zip(acc, [l_g, l_rec, l_gan, l_force, l_d]):
acc[k] += float(v.detach())
nb += 1
G.eval(); v = dict(rec=0.0, rme=0.0, mse=0.0, r=0.0); nv = 0
with torch.no_grad():
for bi, batch in enumerate(val_loader):
if max_batches and bi >= max_batches:
break
x = add_settings(batch[0].to(device), batch, cfg, norm, device); y = batch[1].to(device)
p = (G(x) + 1) / 2
v["rec"] += float(rec_loss(p, y)); v["rme"] += float(rme(p, y))
v["mse"] += float(F.mse_loss(p, y)); v["r"] += float(pixel_r(p, y)); nv += 1
h = {"phase": ph["name"], "epoch": epoch, **{f"train_{k}": a / max(nb, 1) for k, a in acc.items()},
**{f"val_{k}": a / max(nv, 1) for k, a in v.items()},
"g_lr": g_opt.param_groups[0]["lr"], "epoch_seconds": time.time() - t0}
hist.append(h)
is_best = h["val_rec"] < best
best = min(best, h["val_rec"])
print(f"[{ph['name']}] epoch {epoch:3d} G {h['train_g']:.4f} (rec {h['train_rec']:.4f} gan {h['train_gan']:.4f} "
f"force {h['train_force']:.5f}) D {h['train_d']:.4f} val_rec {h['val_rec']:.4f} "
f"val_r {h['val_r']:.3f} val_rme {h['val_rme']:.3f} {'*' if is_best else ' '} {h['epoch_seconds']:.0f}s", flush=True)
if is_best:
torch.save({"epoch": epoch, "phase": ph["name"], "generator_state_dict": G.state_dict(), "config": cfg,
"val_loss": h["val_rec"], "history": hist}, os.path.join(phase_dir, "best_checkpoint.pth"))
state = {"epoch": epoch, "generator_state_dict": G.state_dict(), "g_optimizer": g_opt.state_dict(),
"g_scheduler": g_sch.state_dict(), "history": hist, "best_val_loss": best, "config": cfg}
if use_gan:
state.update(discriminator_state_dict=D.state_dict(), d_optimizer=d_opt.state_dict(), d_scheduler=d_sch.state_dict())
torch.save(state, last_path)
json.dump(hist, open(os.path.join(phase_dir, "history.json"), "w"), indent=1)
if epoch % 10 == 0 or epoch == ph["epochs"] - 1:
save_vis(G, val_loader, cfg, norm, device, os.path.join(phase_dir, "visualizations", f"epoch_{epoch:03d}.png"))
return hist
def train(cfg, run_dir, dataset_root=DEFAULT_DATASET_ROOT, device=None, resume=False, max_batches=None,
num_workers=2, allow_cpu=False):
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
if str(device).startswith("cpu") and not allow_cpu:
raise SystemExit("CUDA is not available - refusing to train on CPU (pass --allow-cpu for a smoke test)")
if str(device).startswith("cuda"):
torch.backends.cudnn.benchmark = True
print(f"GPU: {torch.cuda.get_device_name(torch.device(device))} "
f"(CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES', 'all')})")
os.makedirs(run_dir, exist_ok=True)
seed_everything(cfg["seed"])
train_loader = make_loader(cfg["train_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
augment=cfg["augment"], shuffle=True, substrate_mode=cfg["substrate_mode"],
input_mode=cfg["input_mode"], seed=cfg["seed"], num_workers=num_workers)
if num_workers > 0: # check that worker processes work on this machine
try:
next(iter(train_loader))
except RuntimeError as e:
print(f"DataLoader workers failed ({str(e).splitlines()[0][:120]}); falling back to num_workers=0")
train_loader = make_loader(cfg["train_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
augment=cfg["augment"], shuffle=True, substrate_mode=cfg["substrate_mode"],
input_mode=cfg["input_mode"], seed=cfg["seed"], num_workers=0)
seed_everything(cfg["seed"])
val_loader = make_loader(cfg["val_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
substrate_mode=cfg["substrate_mode"], input_mode=cfg["input_mode"])
norm = compute_settings_normalization(config_path=CONFIG_PATH)
G = build_generator(**cfg).to(device)
n_d = n_params(build_discriminator(cfg["in_channels"])) if cfg["lambda_gan"] > 0 else 0
cfg = dict(cfg, n_params_generator=n_params(G), n_params_discriminator=n_d,
n_train=len(train_loader.dataset), n_val=len(val_loader.dataset), device=str(device),
torch_version=torch.__version__)
json.dump(cfg, open(os.path.join(run_dir, "config.json"), "w"), indent=2)
print(f"[{cfg['variant']}] G {cfg['n_params_generator']/1e6:.2f} M params, D {n_d/1e6:.2f} M; "
f"train {cfg['n_train']}, val {cfg['n_val']}, device {device}", flush=True)
all_hist = []
for ph in cfg["phases"]:
phase_dir = os.path.join(run_dir, ph["name"])
shared = cfg.get("share_phase1_with")
if ph["name"] == "phase1" and shared and not _done(phase_dir, ph["epochs"]):
src = os.path.join(os.path.dirname(run_dir), f"{shared}_seed{cfg['seed']}", "phase1")
if _done(src, ph["epochs"]):
print(f"[phase1] identical to {shared} (same seed, data and phase-1 settings) - copying {src}")
shutil.copytree(src, phase_dir, dirs_exist_ok=True)
else:
print(f"[phase1] {shared} phase 1 not finished yet - training phase 1 for this run")
if _done(phase_dir, ph["epochs"]) and (resume or shared):
print(f"[{ph['name']}] already finished - skipping")
hist = json.load(open(os.path.join(phase_dir, "history.json")))
else:
hist = run_phase(G, ph, phase_dir, cfg, train_loader, val_loader, norm, device, resume, max_batches)
all_hist += hist
best_ck = torch.load(os.path.join(phase_dir, "best_checkpoint.pth"), map_location="cpu", weights_only=False)
G.load_state_dict(best_ck["generator_state_dict"]) # next phase starts from this phase's best
best_ck["config"] = cfg
torch.save(best_ck, os.path.join(run_dir, "best_checkpoint.pth")) # final model = best of the last phase
json.dump(all_hist, open(os.path.join(run_dir, "history.json"), "w"), indent=1) # written last = run finished
print(f"done. final model: {best_ck['phase']} epoch {best_ck['epoch']}, val loss {best_ck['val_loss']:.4f}; in {run_dir}")
return run_dir
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--variant", required=True)
ap.add_argument("--seed", type=int, default=None)
ap.add_argument("--epochs", type=int, default=None)
ap.add_argument("--g-lr", type=float, default=None); ap.add_argument("--d-lr", type=float, default=None)
ap.add_argument("--batch-size", type=int, default=None)
ap.add_argument("--dataset-root", default=DEFAULT_DATASET_ROOT)
ap.add_argument("--run-dir", default=None)
ap.add_argument("--device", default=None)
ap.add_argument("--resume", action="store_true")
ap.add_argument("--max-batches", type=int, default=None, help="limit batches per epoch (smoke test only)")
ap.add_argument("--num-workers", type=int, default=2, help="DataLoader worker processes for augmentation")
ap.add_argument("--allow-cpu", action="store_true", help="allow training without CUDA (smoke tests)")
a = ap.parse_args()
cfg = get_config(a.variant, seed=a.seed, epochs=a.epochs, g_lr=a.g_lr, d_lr=a.d_lr, batch_size=a.batch_size)
run_dir = a.run_dir or os.path.join(RUNS_DIR, f"{a.variant}_seed{cfg['seed']}")
train(cfg, run_dir, a.dataset_root, a.device, a.resume, a.max_batches, a.num_workers, a.allow_cpu)
if __name__ == "__main__":
main()