experiments: two-phase training protocol
Browse files- .gitignore +1 -1
- experiments/README.md +16 -16
- experiments/configs.py +31 -15
- experiments/losses.py +21 -0
- experiments/train.py +112 -64
.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
|
| 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-
|
| 58 |
-
python -m experiments.run_all --steps train,eval_runs --variants s2f_full,unet,s2f_no_gan,
|
| 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
|
| 71 |
-
|
| 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` (
|
| 76 |
|
| 77 |
## Training protocol (all variants)
|
| 78 |
|
| 79 |
-
Every variant is trained from scratch on `alltune_train` (280 pairs) with the
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 15 |
batch_size=2,
|
| 16 |
img_size=1024,
|
| 17 |
augment=True, # data/augmentations.AdvancedAugmentations
|
| 18 |
-
|
| 19 |
-
warmup_frac=0.1, # cosine schedule with linear warm-up
|
| 20 |
-
|
| 21 |
-
|
| 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
|
| 52 |
-
PRIORITY = ["s2f_full", "unet", "
|
| 53 |
-
"
|
| 54 |
-
"s2f_bridge_no_cbam", "s2f_nominal_substrate", "
|
|
|
|
| 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
|
| 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
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
| 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
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 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 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 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,
|
| 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 =
|
| 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();
|
| 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 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
| 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(
|
| 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 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|