File size: 15,223 Bytes
1d4bac8
 
 
 
 
90ed5f2
 
1d4bac8
90ed5f2
1d4bac8
90ed5f2
 
 
 
 
1d4bac8
 
 
 
 
 
 
 
90ed5f2
1d4bac8
 
 
 
 
 
 
 
 
 
90ed5f2
1d4bac8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90ed5f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d4bac8
 
90ed5f2
 
 
 
1d4bac8
 
90ed5f2
1d4bac8
90ed5f2
1d4bac8
 
 
 
 
 
90ed5f2
 
 
1d4bac8
90ed5f2
1d4bac8
 
 
 
 
 
 
 
 
 
 
 
 
90ed5f2
1d4bac8
 
 
 
 
 
 
 
90ed5f2
1d4bac8
90ed5f2
1d4bac8
 
 
 
 
 
90ed5f2
 
 
 
1d4bac8
 
 
 
90ed5f2
1d4bac8
90ed5f2
1d4bac8
90ed5f2
 
1d4bac8
 
 
 
 
90ed5f2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d4bac8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
"""
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()