kaveh commited on
Commit
90ed5f2
·
1 Parent(s): 5004189

experiments: two-phase training protocol

Browse files
.gitignore CHANGED
@@ -13,4 +13,4 @@ S2FApp/ckp/spheroid/ckp_spheroid_GN.pth
13
  S2FApp/*.docx
14
 
15
  # experiments: trained ablation checkpoints (large)
16
- experiments/runs/
 
13
  S2FApp/*.docx
14
 
15
  # experiments: trained ablation checkpoints (large)
16
+ experiments/runs*/
experiments/README.md CHANGED
@@ -13,7 +13,7 @@ experiments/
13
  prepare_dataset.py audit legacy data vs dataset/, copy missing images, write splits/*.csv
14
  manifest_data.py data loader driven by a split manifest (same preprocessing as data/cell_dataset.py)
15
  ablation_models.py plain U-Net and S2F generator variants
16
- losses.py per-image WFM-RME reconstruction loss (as used for the released model)
17
  configs.py shared training protocol + one entry per variant
18
  train.py train one variant
19
  evaluate.py evaluate released checkpoints / baselines / trained runs on all manifests
@@ -54,8 +54,8 @@ python -m experiments.run_all --legacy-root <path>/cellforce/dataset --steps pre
54
  # 3. released checkpoints, substrate sweep, input-free baseline, image-only stage-1 model
55
  python -m experiments.run_all --steps eval_released --stage1 <path>/outputs/checkpoints/last/last_checkpoint_final.pth
56
 
57
- # 4. baselines / ablations (about 7-8 h each on an 11 GB GPU; resumable), then their evaluation
58
- python -m experiments.run_all --steps train,eval_runs --variants s2f_full,unet,s2f_no_gan,mask_only,s2f_no_substrate
59
  ```
60
 
61
  A single run: `python -m experiments.train --variant unet --seed 0`.
@@ -66,22 +66,22 @@ A single run: `python -m experiments.train --variant unet --seed 0`.
66
  - A model is never split across GPUs: with batch size 2, that would harm batch-norm/GAN training.
67
  - `train.py` refuses to run without CUDA unless `--allow-cpu` is given.
68
  - Augmentation runs in `--num-workers` DataLoader processes (default 2 per job; use 0 if RAM is short).
69
- - Finished runs are skipped and interrupted runs resume from `last_checkpoint.pth`, so the command can simply be re-run.
70
- - Run time on an 11 GB GPU (2080 Ti class) is about 4-5 min per epoch, i.e. about 7-8 h per variant (100 epochs). On two GPUs:
71
- - the 5 core variants take about 16-24 h;
72
- - all 14 variants take about 55 h.
73
- - Progress is logged to `experiments/runs/<variant>_seed<seed>/train.log` and `history.json`.
74
 
75
- For a quick check that everything runs, add `--epochs 1 --max-batches 2` (and `--allow-cpu` without a GPU).
76
 
77
  ## Training protocol (all variants)
78
 
79
- Every variant is trained from scratch on `alltune_train` (280 pairs) with the same settings:
80
- - 100 epochs, batch size 2, 1024 × 1024 px, the augmentations of `data/augmentations.py`;
81
- - Adam (lr 1e-4, β = 0.5/0.999), cosine decay after 10 % linear warm-up;
82
- - loss = 10 × per-image WFM-RME + 1 × adversarial (PatchGAN, BCE) + 100 × MSE of mean intensity.
83
 
84
- The checkpoint with the lowest reconstruction loss on `alltune_val` (81 pairs) is kept. Variants change one component each (`configs.py`):
 
 
 
 
 
85
 
86
  | variant | change |
87
  |---|---|
@@ -90,7 +90,7 @@ The checkpoint with the lowest reconstruction loss on `alltune_val` (81 pairs) i
90
  | `unet_gan` | plain U-Net + PatchGAN (pix2pix-style) |
91
  | `mask_only` | binary cell mask instead of bright field (needs `cell_mask.tif` in each sample folder) |
92
  | `s2f_no_gan` | no discriminator (λ_GAN = 0) |
93
- | `s2f_no_force` | no stress-consistency term (λ_force = 0) |
94
  | `s2f_gan_0.1`, `s2f_gan_5` | λ_GAN = 0.1 / 5 |
95
  | `s2f_no_substrate` | bright field only (no substrate channels) |
96
  | `s2f_nominal_substrate` | substrate channels from the nominal Young's modulus of each image |
@@ -99,7 +99,7 @@ The checkpoint with the lowest reconstruction loss on `alltune_val` (81 pairs) i
99
  | `s2f_bridge_plain` | bottleneck without CBAM, Global Context Module and hierarchical attention |
100
  | `s2f_no_attention_gates` | skip connections without attention gates |
101
 
102
- The released checkpoint was obtained by staged fine-tuning (see Methods). `evaluate.py` reports it alongside the from-scratch variants.
103
 
104
  ## Metrics
105
 
 
13
  prepare_dataset.py audit legacy data vs dataset/, copy missing images, write splits/*.csv
14
  manifest_data.py data loader driven by a split manifest (same preprocessing as data/cell_dataset.py)
15
  ablation_models.py plain U-Net and S2F generator variants
16
+ losses.py per-image WFM-RME and weighted MSE + soft Dice losses (both used for the released model)
17
  configs.py shared training protocol + one entry per variant
18
  train.py train one variant
19
  evaluate.py evaluate released checkpoints / baselines / trained runs on all manifests
 
54
  # 3. released checkpoints, substrate sweep, input-free baseline, image-only stage-1 model
55
  python -m experiments.run_all --steps eval_released --stage1 <path>/outputs/checkpoints/last/last_checkpoint_final.pth
56
 
57
+ # 4. baselines / ablations (S2F about 7 h, U-Net about 2.5 h each on an RTX A6000; resumable), then their evaluation
58
+ python -m experiments.run_all --steps train,eval_runs --variants s2f_full,unet,unet_gan,s2f_no_gan,s2f_no_substrate,s2f_no_force
59
  ```
60
 
61
  A single run: `python -m experiments.train --variant unet --seed 0`.
 
66
  - A model is never split across GPUs: with batch size 2, that would harm batch-norm/GAN training.
67
  - `train.py` refuses to run without CUDA unless `--allow-cpu` is given.
68
  - Augmentation runs in `--num-workers` DataLoader processes (default 2 per job; use 0 if RAM is short).
69
+ - Finished runs (and finished phases) are skipped and interrupted runs resume from the phase's `last_checkpoint.pth`, so the command can simply be re-run.
70
+ - Run time on an RTX A6000: S2F about 3 min per epoch, i.e. about 7 h per variant (100 + 40 epochs); U-Net about 2.5 h. On two GPUs the 6 core variants take about 15 h (`s2f_no_force` reuses phase 1 of `s2f_full`).
71
+ - Progress is logged to `experiments/runs/<variant>_seed<seed>/train.log` (validation pixel r and WFM-RME every epoch) and `phase*/history.json`.
 
 
72
 
73
+ For a quick check that everything runs, add `--epochs 1 --max-batches 2` (1 epoch per phase; add `--allow-cpu` without a GPU).
74
 
75
  ## Training protocol (all variants)
76
 
77
+ Every variant is trained from scratch on `alltune_train` (280 pairs), batch size 2, 1024 × 1024 px, the augmentations of `data/augmentations.py`, Adam (β = 0.5/0.999) with cosine decay after 10 % linear warm-up, in two phases that mirror the staged training of the released model:
 
 
 
78
 
79
+ | phase | epochs | reconstruction loss | other terms | lr | starts from |
80
+ |---|---|---|---|---|---|
81
+ | 1 localisation | 100 | 100 × weighted MSE + soft Dice (pos_weight 3, threshold 0.2, Dice 0.5) | 1 × adversarial | 1e-4 | random weights |
82
+ | 2 magnitude | 40 | 10 × per-image WFM-RME | 1 × adversarial + 100 × MSE of mean intensity | 1e-5 | best phase-1 generator |
83
+
84
+ Each phase uses a new discriminator and optimiser and keeps the checkpoint with the lowest validation loss of that phase on `alltune_val` (81 pairs); the final model is the best phase-2 checkpoint. WFM-RME alone does not learn to localise the forces from random weights (a first protocol that used it from the start gave pixel r ≈ 0), which is also why the released model was first trained with the weighted MSE + Dice loss. Variants change one component each (`configs.py`):
85
 
86
  | variant | change |
87
  |---|---|
 
90
  | `unet_gan` | plain U-Net + PatchGAN (pix2pix-style) |
91
  | `mask_only` | binary cell mask instead of bright field (needs `cell_mask.tif` in each sample folder) |
92
  | `s2f_no_gan` | no discriminator (λ_GAN = 0) |
93
+ | `s2f_no_force` | no stress-consistency term (λ_force = 0 in phase 2; phase 1 is identical to `s2f_full` and reused) |
94
  | `s2f_gan_0.1`, `s2f_gan_5` | λ_GAN = 0.1 / 5 |
95
  | `s2f_no_substrate` | bright field only (no substrate channels) |
96
  | `s2f_nominal_substrate` | substrate channels from the nominal Young's modulus of each image |
 
99
  | `s2f_bridge_plain` | bottleneck without CBAM, Global Context Module and hierarchical attention |
100
  | `s2f_no_attention_gates` | skip connections without attention gates |
101
 
102
+ The released checkpoint was obtained by staged fine-tuning from earlier checkpoints, with substrate channels added only in its last two stages (see Methods). `evaluate.py` reports it alongside the from-scratch variants.
103
 
104
  ## Metrics
105
 
experiments/configs.py CHANGED
@@ -1,25 +1,32 @@
1
  """
2
  Training protocol shared by every baseline / ablation run, and the variants.
3
 
4
- All variants are trained FROM SCRATCH with the same protocol, split, number of epochs and
5
  checkpoint-selection rule, so that differences are attributable to the ablated component only.
6
- (The released single-cell checkpoint was obtained by staged fine-tuning, see the Methods; the
7
- 's2f_full' variant is its from-scratch counterpart under this protocol.)
 
 
 
 
 
8
  """
9
  import copy
10
 
11
  PROTOCOL = dict(
12
  train_manifest="alltune_train", # 280 pairs, released model's training split
13
  val_manifest="alltune_val", # 81 pairs, used for checkpoint selection only
14
- epochs=100,
 
 
 
15
  batch_size=2,
16
  img_size=1024,
17
  augment=True, # data/augmentations.AdvancedAugmentations
18
- g_lr=1e-4, d_lr=1e-4, beta1=0.5, beta2=0.999,
19
- warmup_frac=0.1, # cosine schedule with linear warm-up
20
- lambda_rec=10.0, # per-image WFM-RME
21
- lambda_gan=1.0, # vanilla (BCE) PatchGAN loss
22
- lambda_force=100.0, # MSE between mean predicted and mean target intensity
23
  arch="s2f", in_channels=3, use_multi_scale_input=True, bridge="cbam", attention_gates=True,
24
  substrate_mode="legacy", # see experiments/manifest_data.py
25
  input_mode="bf",
@@ -35,7 +42,7 @@ VARIANTS = {
35
  "mask_only": dict(input_mode="mask"), # binary cell mask instead of bright field
36
  # loss ablations
37
  "s2f_no_gan": dict(lambda_gan=0.0),
38
- "s2f_no_force": dict(lambda_force=0.0),
39
  "s2f_gan_0.1": dict(lambda_gan=0.1),
40
  "s2f_gan_5": dict(lambda_gan=5.0),
41
  # input ablations
@@ -48,13 +55,15 @@ VARIANTS = {
48
  "s2f_no_attention_gates": dict(attention_gates=False),
49
  }
50
 
51
- # Recommended order if GPU time is limited (about 7-8 h per run of 100 epochs on an 11 GB GPU)
52
- PRIORITY = ["s2f_full", "unet", "s2f_no_gan", "mask_only", "s2f_no_substrate",
53
- "s2f_no_multiscale", "s2f_bridge_plain", "s2f_no_force", "s2f_no_attention_gates",
54
- "s2f_bridge_no_cbam", "s2f_nominal_substrate", "unet_gan", "s2f_gan_0.1", "s2f_gan_5"]
 
55
 
56
 
57
- def get_config(variant, **overrides):
 
58
  if variant not in VARIANTS:
59
  raise KeyError(f"unknown variant '{variant}'. Options: {', '.join(VARIANTS)}")
60
  cfg = copy.deepcopy(PROTOCOL)
@@ -63,4 +72,11 @@ def get_config(variant, **overrides):
63
  cfg["variant"] = variant
64
  if cfg["substrate_mode"] == "none":
65
  cfg["in_channels"] = 1
 
 
 
 
 
 
 
66
  return cfg
 
1
  """
2
  Training protocol shared by every baseline / ablation run, and the variants.
3
 
4
+ All variants are trained FROM SCRATCH with the same two-phase protocol, split, number of epochs and
5
  checkpoint-selection rule, so that differences are attributable to the ablated component only.
6
+ The two phases mirror the staged training of the released model:
7
+ phase 1 localisation: weighted MSE + soft Dice (lambda_rec 100), lr 1e-4 (as train_pix2pix.ipynb)
8
+ phase 2 magnitude: per-image WFM-RME (lambda_rec 10) + force consistency (lambda_force 100),
9
+ lr 1e-5, initialised from the best phase-1 generator (as train_pix2pix_settings_mean.ipynb)
10
+ WFM-RME alone does not learn to localise forces from random weights (tested: pixel r ~ 0 after 100 epochs),
11
+ which is why the released model, too, was first trained with the weighted MSE + Dice loss.
12
+ Unlike the released model, every variant sees its full input (incl. substrate channels) from the start.
13
  """
14
  import copy
15
 
16
  PROTOCOL = dict(
17
  train_manifest="alltune_train", # 280 pairs, released model's training split
18
  val_manifest="alltune_val", # 81 pairs, used for checkpoint selection only
19
+ phases=[
20
+ dict(name="phase1", epochs=100, rec_loss="wmd", lambda_rec=100.0, lambda_force=0.0, g_lr=1e-4, d_lr=1e-4),
21
+ dict(name="phase2", epochs=40, rec_loss="rme", lambda_rec=10.0, lambda_force="from_variant", g_lr=1e-5, d_lr=1e-5),
22
+ ],
23
  batch_size=2,
24
  img_size=1024,
25
  augment=True, # data/augmentations.AdvancedAugmentations
26
+ beta1=0.5, beta2=0.999,
27
+ warmup_frac=0.1, # cosine schedule with linear warm-up (per phase)
28
+ lambda_gan=1.0, # vanilla (BCE) PatchGAN loss, both phases
29
+ lambda_force=100.0, # phase 2: MSE between mean predicted and mean target intensity
 
30
  arch="s2f", in_channels=3, use_multi_scale_input=True, bridge="cbam", attention_gates=True,
31
  substrate_mode="legacy", # see experiments/manifest_data.py
32
  input_mode="bf",
 
42
  "mask_only": dict(input_mode="mask"), # binary cell mask instead of bright field
43
  # loss ablations
44
  "s2f_no_gan": dict(lambda_gan=0.0),
45
+ "s2f_no_force": dict(lambda_force=0.0, share_phase1_with="s2f_full"), # force term is a phase-2 term only
46
  "s2f_gan_0.1": dict(lambda_gan=0.1),
47
  "s2f_gan_5": dict(lambda_gan=5.0),
48
  # input ablations
 
55
  "s2f_no_attention_gates": dict(attention_gates=False),
56
  }
57
 
58
+ # Recommended order if GPU time is limited (S2F about 7 h per run on an RTX A6000, U-Net about 2.5 h)
59
+ PRIORITY = ["s2f_full", "unet", "unet_gan", "s2f_no_gan", "s2f_no_substrate", "s2f_no_force",
60
+ "s2f_no_attention_gates", "s2f_no_multiscale", "s2f_bridge_plain",
61
+ "s2f_bridge_no_cbam", "s2f_nominal_substrate", "s2f_gan_0.1", "s2f_gan_5"]
62
+ # 'mask_only' needs a cell mask (cell_mask.tif) in every sample folder and is therefore not in PRIORITY.
63
 
64
 
65
+ def get_config(variant, epochs=None, g_lr=None, d_lr=None, **overrides):
66
+ """epochs / g_lr / d_lr override every phase (smoke tests); other overrides are top-level keys."""
67
  if variant not in VARIANTS:
68
  raise KeyError(f"unknown variant '{variant}'. Options: {', '.join(VARIANTS)}")
69
  cfg = copy.deepcopy(PROTOCOL)
 
72
  cfg["variant"] = variant
73
  if cfg["substrate_mode"] == "none":
74
  cfg["in_channels"] = 1
75
+ for ph in cfg["phases"]:
76
+ if ph["lambda_force"] == "from_variant":
77
+ ph["lambda_force"] = cfg["lambda_force"]
78
+ for k, v in (("epochs", epochs), ("g_lr", g_lr), ("d_lr", d_lr)):
79
+ if v is not None:
80
+ ph[k] = v
81
+ cfg["epochs"] = sum(ph["epochs"] for ph in cfg["phases"]) # total, used to detect finished runs
82
  return cfg
experiments/losses.py CHANGED
@@ -21,3 +21,24 @@ class PerImageWFMRMELoss(nn.Module):
21
  rel = (p - t).abs() / (t + self.eps)
22
  w = t / (t.mean(dim=(1, 2), keepdim=True) + self.eps)
23
  return (rel * w).mean()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  rel = (p - t).abs() / (t + self.eps)
22
  w = t / (t.mean(dim=(1, 2), keepdim=True) + self.eps)
23
  return (rel * w).mean()
24
+
25
+
26
+ class WeightedMSEPlusDiceLoss(nn.Module):
27
+ """Loss of the image-only training stage of the released model (Archive/src/utils/losses.py,
28
+ used in Archive/notebooks/train_pix2pix.ipynb with pos_weight=3, threshold=0.2, dice_weight=0.5).
29
+ Pixels above `threshold` get weight 1 + pos_weight in the MSE; the soft-Dice term rewards overlap of
30
+ the predicted and true high-force regions. Unlike L1 / WFM-RME it drives the network to localise
31
+ the force-generating regions when training starts from random weights."""
32
+ def __init__(self, pos_weight=3.0, threshold=0.2, dice_weight=0.5):
33
+ super().__init__()
34
+ self.pos_weight, self.threshold, self.dice_weight = pos_weight, threshold, dice_weight
35
+
36
+ def forward(self, pred, target):
37
+ w = 1.0 + self.pos_weight * (target > self.threshold).float()
38
+ mse = (w * (pred - target) ** 2).mean()
39
+ p, t = pred.flatten(1), target.flatten(1)
40
+ dice = (2 * (p * t).sum(1) + 1e-6) / (p.sum(1) + t.sum(1) + 1e-6)
41
+ return mse + self.dice_weight * (1 - dice.mean())
42
+
43
+
44
+ REC_LOSSES = {"rme": PerImageWFMRMELoss, "wmd": WeightedMSEPlusDiceLoss}
experiments/train.py CHANGED
@@ -3,15 +3,16 @@ Train one baseline / ablation variant with the shared protocol (experiments/conf
3
 
4
  python -m experiments.train --variant s2f_full
5
  python -m experiments.train --variant unet --seed 1
6
- python -m experiments.train --variant s2f_no_gan --resume # continue from last_checkpoint.pth
7
- python -m experiments.train --variant unet --epochs 1 --max-batches 5 # smoke test
8
 
9
- Outputs in experiments/runs/<variant>_seed<seed>/:
10
  config.json exact configuration used
11
- history.json per-epoch losses (each generator loss term separately), lr, time
12
- best_checkpoint.pth generator weights at the lowest validation reconstruction loss
13
- last_checkpoint.pth full state for --resume
14
- visualizations/ predictions on the first validation batch every 10 epochs
 
15
  The training loop follows training/s2f_trainer.py (same losses, label convention and update order);
16
  it additionally skips the discriminator when lambda_gan = 0, records every loss term, and seeds all RNGs.
17
  """
@@ -20,6 +21,7 @@ import json
20
  import math
21
  import os
22
  import random
 
23
  import time
24
 
25
  import numpy as np
@@ -30,7 +32,7 @@ import torch.nn.functional as F
30
  from experiments.ablation_models import build_discriminator, build_generator, n_params
31
  from experiments.configs import get_config
32
  from experiments.layout import DEFAULT_DATASET_ROOT, RUNS_DIR, S2F_ROOT
33
- from experiments.losses import PerImageWFMRMELoss
34
  from experiments.manifest_data import make_loader
35
  from models.s2f_model import create_settings_channels
36
  from utils.substrate_settings import compute_settings_normalization
@@ -77,60 +79,45 @@ def save_vis(G, val_loader, cfg, norm, device, path):
77
  fig.tight_layout(); fig.savefig(path, dpi=100); plt.close(fig)
78
 
79
 
80
- def train(cfg, run_dir, dataset_root=DEFAULT_DATASET_ROOT, device=None, resume=False, max_batches=None,
81
- num_workers=2, allow_cpu=False):
82
- device = device or ("cuda" if torch.cuda.is_available() else "cpu")
83
- if str(device).startswith("cpu") and not allow_cpu:
84
- raise SystemExit("CUDA is not available - refusing to train on CPU (pass --allow-cpu for a smoke test)")
85
- if str(device).startswith("cuda"):
86
- torch.backends.cudnn.benchmark = True
87
- print(f"GPU: {torch.cuda.get_device_name(torch.device(device))} "
88
- f"(CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES', 'all')})")
89
- os.makedirs(os.path.join(run_dir, "visualizations"), exist_ok=True)
90
- seed_everything(cfg["seed"])
91
- train_loader = make_loader(cfg["train_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
92
- augment=cfg["augment"], shuffle=True, substrate_mode=cfg["substrate_mode"],
93
- input_mode=cfg["input_mode"], seed=cfg["seed"], num_workers=num_workers)
94
- if num_workers > 0: # check that worker processes work on this machine
95
- try:
96
- next(iter(train_loader))
97
- except RuntimeError as e:
98
- print(f"DataLoader workers failed ({str(e).splitlines()[0][:120]}); falling back to num_workers=0")
99
- train_loader = make_loader(cfg["train_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
100
- augment=cfg["augment"], shuffle=True, substrate_mode=cfg["substrate_mode"],
101
- input_mode=cfg["input_mode"], seed=cfg["seed"], num_workers=0)
102
- seed_everything(cfg["seed"])
103
- val_loader = make_loader(cfg["val_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
104
- substrate_mode=cfg["substrate_mode"], input_mode=cfg["input_mode"])
105
- norm = compute_settings_normalization(config_path=CONFIG_PATH)
106
  use_gan = cfg["lambda_gan"] > 0
107
- G = build_generator(**cfg).to(device)
108
  D = build_discriminator(cfg["in_channels"]).to(device) if use_gan else None
109
- cfg = dict(cfg, n_params_generator=n_params(G), n_params_discriminator=n_params(D) if D else 0,
110
- n_train=len(train_loader.dataset), n_val=len(val_loader.dataset), device=str(device),
111
- torch_version=torch.__version__)
112
- json.dump(cfg, open(os.path.join(run_dir, "config.json"), "w"), indent=2)
113
- print(f"[{cfg['variant']}] G {cfg['n_params_generator']/1e6:.2f} M params, D {cfg['n_params_discriminator']/1e6:.2f} M; "
114
- f"train {cfg['n_train']}, val {cfg['n_val']}, device {device}")
115
-
116
- g_opt = torch.optim.Adam(G.parameters(), lr=cfg["g_lr"], betas=(cfg["beta1"], cfg["beta2"]))
117
- d_opt = torch.optim.Adam(D.parameters(), lr=cfg["d_lr"], betas=(cfg["beta1"], cfg["beta2"])) if use_gan else None
118
- steps = len(train_loader) * cfg["epochs"]
119
  g_sch = cosine_with_warmup(g_opt, int(steps * cfg["warmup_frac"]), steps)
120
  d_sch = cosine_with_warmup(d_opt, int(steps * cfg["warmup_frac"]), steps) if use_gan else None
121
- rec_loss, bce = PerImageWFMRMELoss(), nn.BCEWithLogitsLoss()
122
  hist, best, start = [], float("inf"), 0
123
-
124
- last_path = os.path.join(run_dir, "last_checkpoint.pth")
125
  if resume and os.path.exists(last_path):
126
  ck = torch.load(last_path, map_location="cpu", weights_only=False)
127
  G.load_state_dict(ck["generator_state_dict"]); g_opt.load_state_dict(ck["g_optimizer"]); g_sch.load_state_dict(ck["g_scheduler"])
128
  if use_gan:
129
  D.load_state_dict(ck["discriminator_state_dict"]); d_opt.load_state_dict(ck["d_optimizer"]); d_sch.load_state_dict(ck["d_scheduler"])
130
  hist, best, start = ck["history"], ck["best_val_loss"], ck["epoch"] + 1
131
- print(f"resumed at epoch {start}")
 
 
132
 
133
- for epoch in range(start, cfg["epochs"]):
134
  t0 = time.time(); G.train(); D.train() if use_gan else None
135
  acc = dict(g=0.0, rec=0.0, gan=0.0, force=0.0, d=0.0); nb = 0
136
  for bi, batch in enumerate(train_loader):
@@ -144,7 +131,7 @@ def train(cfg, run_dir, dataset_root=DEFAULT_DATASET_ROOT, device=None, resume=F
144
  l_gan = torch.zeros((), device=device)
145
  if use_gan:
146
  pf = D(torch.cat([x, fake], 1)); l_gan = bce(pf, torch.ones_like(pf))
147
- l_g = cfg["lambda_rec"] * l_rec + cfg["lambda_gan"] * l_gan + cfg["lambda_force"] * l_force
148
  g_opt.zero_grad(); l_g.backward(); g_opt.step()
149
  l_d = torch.zeros(())
150
  if use_gan:
@@ -153,37 +140,98 @@ def train(cfg, run_dir, dataset_root=DEFAULT_DATASET_ROOT, device=None, resume=F
153
  d_opt.zero_grad(); l_d.backward(); d_opt.step(); d_sch.step()
154
  g_sch.step()
155
  for k, v in zip(acc, [l_g, l_rec, l_gan, l_force, l_d]):
156
- acc[k] += float(v)
157
  nb += 1
158
- G.eval(); v_rec = v_mse = 0.0; nv = 0
159
  with torch.no_grad():
160
  for bi, batch in enumerate(val_loader):
161
  if max_batches and bi >= max_batches:
162
  break
163
  x = add_settings(batch[0].to(device), batch, cfg, norm, device); y = batch[1].to(device)
164
  p = (G(x) + 1) / 2
165
- v_rec += float(rec_loss(p, y)); v_mse += float(F.mse_loss(p, y)); nv += 1
166
- h = {"epoch": epoch, **{f"train_{k}": v / max(nb, 1) for k, v in acc.items()},
167
- "val_rec": v_rec / max(nv, 1), "val_mse": v_mse / max(nv, 1),
 
168
  "g_lr": g_opt.param_groups[0]["lr"], "epoch_seconds": time.time() - t0}
169
  hist.append(h)
170
  is_best = h["val_rec"] < best
171
  best = min(best, h["val_rec"])
172
- print(f"epoch {epoch:3d} G {h['train_g']:.4f} (rec {h['train_rec']:.4f} gan {h['train_gan']:.4f} "
173
  f"force {h['train_force']:.5f}) D {h['train_d']:.4f} val_rec {h['val_rec']:.4f} "
174
- f"{'*' if is_best else ' '} {h['epoch_seconds']:.0f}s", flush=True)
175
- json.dump(hist, open(os.path.join(run_dir, "history.json"), "w"), indent=1)
176
  if is_best:
177
- torch.save({"epoch": epoch, "generator_state_dict": G.state_dict(), "config": cfg,
178
- "val_loss": h["val_rec"], "history": hist}, os.path.join(run_dir, "best_checkpoint.pth"))
179
  state = {"epoch": epoch, "generator_state_dict": G.state_dict(), "g_optimizer": g_opt.state_dict(),
180
  "g_scheduler": g_sch.state_dict(), "history": hist, "best_val_loss": best, "config": cfg}
181
  if use_gan:
182
  state.update(discriminator_state_dict=D.state_dict(), d_optimizer=d_opt.state_dict(), d_scheduler=d_sch.state_dict())
183
  torch.save(state, last_path)
184
- if epoch % 10 == 0 or epoch == cfg["epochs"] - 1:
185
- save_vis(G, val_loader, cfg, norm, device, os.path.join(run_dir, "visualizations", f"epoch_{epoch:03d}.png"))
186
- print(f"done. best val_rec {best:.4f}; checkpoints in {run_dir}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  return run_dir
188
 
189
 
 
3
 
4
  python -m experiments.train --variant s2f_full
5
  python -m experiments.train --variant unet --seed 1
6
+ python -m experiments.train --variant s2f_no_gan --resume # continue an interrupted run
7
+ python -m experiments.train --variant unet --epochs 1 --max-batches 5 # smoke test (1 epoch per phase)
8
 
9
+ Two phases (see experiments/configs.py). Outputs in experiments/runs/<variant>_seed<seed>/:
10
  config.json exact configuration used
11
+ phase1/, phase2/ per phase: history.json (every loss term, val loss / WFM-RME / pixel r, lr, time),
12
+ best_checkpoint.pth (lowest validation loss of that phase), last_checkpoint.pth
13
+ (full state for --resume), visualizations/ (first validation batch every 10 epochs)
14
+ best_checkpoint.pth final model = best phase-2 generator (written when the run is complete)
15
+ history.json both phases concatenated (written when the run is complete)
16
  The training loop follows training/s2f_trainer.py (same losses, label convention and update order);
17
  it additionally skips the discriminator when lambda_gan = 0, records every loss term, and seeds all RNGs.
18
  """
 
21
  import math
22
  import os
23
  import random
24
+ import shutil
25
  import time
26
 
27
  import numpy as np
 
32
  from experiments.ablation_models import build_discriminator, build_generator, n_params
33
  from experiments.configs import get_config
34
  from experiments.layout import DEFAULT_DATASET_ROOT, RUNS_DIR, S2F_ROOT
35
+ from experiments.losses import REC_LOSSES, PerImageWFMRMELoss
36
  from experiments.manifest_data import make_loader
37
  from models.s2f_model import create_settings_channels
38
  from utils.substrate_settings import compute_settings_normalization
 
79
  fig.tight_layout(); fig.savefig(path, dpi=100); plt.close(fig)
80
 
81
 
82
+ def pixel_r(p, y):
83
+ """Mean per-image Pearson correlation between predicted and true maps (monitoring only)."""
84
+ p, y = p.flatten(1), y.flatten(1)
85
+ p, y = p - p.mean(1, keepdim=True), y - y.mean(1, keepdim=True)
86
+ return ((p * y).sum(1) / (p.norm(dim=1) * y.norm(dim=1) + 1e-12)).mean()
87
+
88
+
89
+ def _done(phase_dir, epochs):
90
+ h = os.path.join(phase_dir, "history.json")
91
+ return (os.path.exists(h) and len(json.load(open(h))) >= epochs
92
+ and os.path.exists(os.path.join(phase_dir, "best_checkpoint.pth")))
93
+
94
+
95
+ def run_phase(G, ph, phase_dir, cfg, train_loader, val_loader, norm, device, resume, max_batches):
96
+ """One training phase. Fresh discriminator, optimisers and schedule (as in the original staged training);
97
+ the generator continues from its current weights. Checkpoint selection: lowest validation loss of this phase."""
98
+ os.makedirs(os.path.join(phase_dir, "visualizations"), exist_ok=True)
 
 
 
 
 
 
 
 
 
99
  use_gan = cfg["lambda_gan"] > 0
 
100
  D = build_discriminator(cfg["in_channels"]).to(device) if use_gan else None
101
+ g_opt = torch.optim.Adam(G.parameters(), lr=ph["g_lr"], betas=(cfg["beta1"], cfg["beta2"]))
102
+ d_opt = torch.optim.Adam(D.parameters(), lr=ph["d_lr"], betas=(cfg["beta1"], cfg["beta2"])) if use_gan else None
103
+ n_batches = min(len(train_loader), max_batches) if max_batches else len(train_loader)
104
+ steps = n_batches * ph["epochs"]
 
 
 
 
 
 
105
  g_sch = cosine_with_warmup(g_opt, int(steps * cfg["warmup_frac"]), steps)
106
  d_sch = cosine_with_warmup(d_opt, int(steps * cfg["warmup_frac"]), steps) if use_gan else None
107
+ rec_loss, rme, bce = REC_LOSSES[ph["rec_loss"]](), PerImageWFMRMELoss(), nn.BCEWithLogitsLoss()
108
  hist, best, start = [], float("inf"), 0
109
+ last_path = os.path.join(phase_dir, "last_checkpoint.pth")
 
110
  if resume and os.path.exists(last_path):
111
  ck = torch.load(last_path, map_location="cpu", weights_only=False)
112
  G.load_state_dict(ck["generator_state_dict"]); g_opt.load_state_dict(ck["g_optimizer"]); g_sch.load_state_dict(ck["g_scheduler"])
113
  if use_gan:
114
  D.load_state_dict(ck["discriminator_state_dict"]); d_opt.load_state_dict(ck["d_optimizer"]); d_sch.load_state_dict(ck["d_scheduler"])
115
  hist, best, start = ck["history"], ck["best_val_loss"], ck["epoch"] + 1
116
+ print(f"[{ph['name']}] resumed at epoch {start}")
117
+ print(f"[{ph['name']}] {ph['epochs']} epochs, loss {ph['rec_loss']} x{ph['lambda_rec']:g}, "
118
+ f"GAN x{cfg['lambda_gan']:g}, force x{ph['lambda_force']:g}, lr {ph['g_lr']:g}", flush=True)
119
 
120
+ for epoch in range(start, ph["epochs"]):
121
  t0 = time.time(); G.train(); D.train() if use_gan else None
122
  acc = dict(g=0.0, rec=0.0, gan=0.0, force=0.0, d=0.0); nb = 0
123
  for bi, batch in enumerate(train_loader):
 
131
  l_gan = torch.zeros((), device=device)
132
  if use_gan:
133
  pf = D(torch.cat([x, fake], 1)); l_gan = bce(pf, torch.ones_like(pf))
134
+ l_g = ph["lambda_rec"] * l_rec + cfg["lambda_gan"] * l_gan + ph["lambda_force"] * l_force
135
  g_opt.zero_grad(); l_g.backward(); g_opt.step()
136
  l_d = torch.zeros(())
137
  if use_gan:
 
140
  d_opt.zero_grad(); l_d.backward(); d_opt.step(); d_sch.step()
141
  g_sch.step()
142
  for k, v in zip(acc, [l_g, l_rec, l_gan, l_force, l_d]):
143
+ acc[k] += float(v.detach())
144
  nb += 1
145
+ G.eval(); v = dict(rec=0.0, rme=0.0, mse=0.0, r=0.0); nv = 0
146
  with torch.no_grad():
147
  for bi, batch in enumerate(val_loader):
148
  if max_batches and bi >= max_batches:
149
  break
150
  x = add_settings(batch[0].to(device), batch, cfg, norm, device); y = batch[1].to(device)
151
  p = (G(x) + 1) / 2
152
+ v["rec"] += float(rec_loss(p, y)); v["rme"] += float(rme(p, y))
153
+ v["mse"] += float(F.mse_loss(p, y)); v["r"] += float(pixel_r(p, y)); nv += 1
154
+ h = {"phase": ph["name"], "epoch": epoch, **{f"train_{k}": a / max(nb, 1) for k, a in acc.items()},
155
+ **{f"val_{k}": a / max(nv, 1) for k, a in v.items()},
156
  "g_lr": g_opt.param_groups[0]["lr"], "epoch_seconds": time.time() - t0}
157
  hist.append(h)
158
  is_best = h["val_rec"] < best
159
  best = min(best, h["val_rec"])
160
+ print(f"[{ph['name']}] epoch {epoch:3d} G {h['train_g']:.4f} (rec {h['train_rec']:.4f} gan {h['train_gan']:.4f} "
161
  f"force {h['train_force']:.5f}) D {h['train_d']:.4f} val_rec {h['val_rec']:.4f} "
162
+ f"val_r {h['val_r']:.3f} val_rme {h['val_rme']:.3f} {'*' if is_best else ' '} {h['epoch_seconds']:.0f}s", flush=True)
 
163
  if is_best:
164
+ torch.save({"epoch": epoch, "phase": ph["name"], "generator_state_dict": G.state_dict(), "config": cfg,
165
+ "val_loss": h["val_rec"], "history": hist}, os.path.join(phase_dir, "best_checkpoint.pth"))
166
  state = {"epoch": epoch, "generator_state_dict": G.state_dict(), "g_optimizer": g_opt.state_dict(),
167
  "g_scheduler": g_sch.state_dict(), "history": hist, "best_val_loss": best, "config": cfg}
168
  if use_gan:
169
  state.update(discriminator_state_dict=D.state_dict(), d_optimizer=d_opt.state_dict(), d_scheduler=d_sch.state_dict())
170
  torch.save(state, last_path)
171
+ json.dump(hist, open(os.path.join(phase_dir, "history.json"), "w"), indent=1)
172
+ if epoch % 10 == 0 or epoch == ph["epochs"] - 1:
173
+ save_vis(G, val_loader, cfg, norm, device, os.path.join(phase_dir, "visualizations", f"epoch_{epoch:03d}.png"))
174
+ return hist
175
+
176
+
177
+ def train(cfg, run_dir, dataset_root=DEFAULT_DATASET_ROOT, device=None, resume=False, max_batches=None,
178
+ num_workers=2, allow_cpu=False):
179
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
180
+ if str(device).startswith("cpu") and not allow_cpu:
181
+ raise SystemExit("CUDA is not available - refusing to train on CPU (pass --allow-cpu for a smoke test)")
182
+ if str(device).startswith("cuda"):
183
+ torch.backends.cudnn.benchmark = True
184
+ print(f"GPU: {torch.cuda.get_device_name(torch.device(device))} "
185
+ f"(CUDA_VISIBLE_DEVICES={os.environ.get('CUDA_VISIBLE_DEVICES', 'all')})")
186
+ os.makedirs(run_dir, exist_ok=True)
187
+ seed_everything(cfg["seed"])
188
+ train_loader = make_loader(cfg["train_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
189
+ augment=cfg["augment"], shuffle=True, substrate_mode=cfg["substrate_mode"],
190
+ input_mode=cfg["input_mode"], seed=cfg["seed"], num_workers=num_workers)
191
+ if num_workers > 0: # check that worker processes work on this machine
192
+ try:
193
+ next(iter(train_loader))
194
+ except RuntimeError as e:
195
+ print(f"DataLoader workers failed ({str(e).splitlines()[0][:120]}); falling back to num_workers=0")
196
+ train_loader = make_loader(cfg["train_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
197
+ augment=cfg["augment"], shuffle=True, substrate_mode=cfg["substrate_mode"],
198
+ input_mode=cfg["input_mode"], seed=cfg["seed"], num_workers=0)
199
+ seed_everything(cfg["seed"])
200
+ val_loader = make_loader(cfg["val_manifest"], dataset_root, cfg["batch_size"], cfg["img_size"],
201
+ substrate_mode=cfg["substrate_mode"], input_mode=cfg["input_mode"])
202
+ norm = compute_settings_normalization(config_path=CONFIG_PATH)
203
+ G = build_generator(**cfg).to(device)
204
+ n_d = n_params(build_discriminator(cfg["in_channels"])) if cfg["lambda_gan"] > 0 else 0
205
+ cfg = dict(cfg, n_params_generator=n_params(G), n_params_discriminator=n_d,
206
+ n_train=len(train_loader.dataset), n_val=len(val_loader.dataset), device=str(device),
207
+ torch_version=torch.__version__)
208
+ json.dump(cfg, open(os.path.join(run_dir, "config.json"), "w"), indent=2)
209
+ print(f"[{cfg['variant']}] G {cfg['n_params_generator']/1e6:.2f} M params, D {n_d/1e6:.2f} M; "
210
+ f"train {cfg['n_train']}, val {cfg['n_val']}, device {device}", flush=True)
211
+
212
+ all_hist = []
213
+ for ph in cfg["phases"]:
214
+ phase_dir = os.path.join(run_dir, ph["name"])
215
+ shared = cfg.get("share_phase1_with")
216
+ if ph["name"] == "phase1" and shared and not _done(phase_dir, ph["epochs"]):
217
+ src = os.path.join(os.path.dirname(run_dir), f"{shared}_seed{cfg['seed']}", "phase1")
218
+ if _done(src, ph["epochs"]):
219
+ print(f"[phase1] identical to {shared} (same seed, data and phase-1 settings) - copying {src}")
220
+ shutil.copytree(src, phase_dir, dirs_exist_ok=True)
221
+ else:
222
+ print(f"[phase1] {shared} phase 1 not finished yet - training phase 1 for this run")
223
+ if _done(phase_dir, ph["epochs"]) and (resume or shared):
224
+ print(f"[{ph['name']}] already finished - skipping")
225
+ hist = json.load(open(os.path.join(phase_dir, "history.json")))
226
+ else:
227
+ hist = run_phase(G, ph, phase_dir, cfg, train_loader, val_loader, norm, device, resume, max_batches)
228
+ all_hist += hist
229
+ best_ck = torch.load(os.path.join(phase_dir, "best_checkpoint.pth"), map_location="cpu", weights_only=False)
230
+ G.load_state_dict(best_ck["generator_state_dict"]) # next phase starts from this phase's best
231
+ best_ck["config"] = cfg
232
+ torch.save(best_ck, os.path.join(run_dir, "best_checkpoint.pth")) # final model = best of the last phase
233
+ json.dump(all_hist, open(os.path.join(run_dir, "history.json"), "w"), indent=1) # written last = run finished
234
+ print(f"done. final model: {best_ck['phase']} epoch {best_ck['epoch']}, val loss {best_ck['val_loss']:.4f}; in {run_dir}")
235
  return run_dir
236
 
237