experiments: dataset audit, evaluation, baselines and ablations
Browse files- .gitignore +3 -0
- experiments/README.md +113 -0
- experiments/__init__.py +0 -0
- experiments/ablation_models.py +74 -0
- experiments/configs.py +66 -0
- experiments/evaluate.py +202 -0
- experiments/layout.py +150 -0
- experiments/losses.py +23 -0
- experiments/manifest_data.py +92 -0
- experiments/prepare_dataset.py +237 -0
- experiments/run_all.py +104 -0
- experiments/splits/.gitkeep +0 -0
- experiments/train.py +211 -0
.gitignore
CHANGED
|
@@ -11,3 +11,6 @@ S2FApp/ckp/spheroid/ckp_spheroid_FN.pth
|
|
| 11 |
S2FApp/ckp/single_cell/ckp_singlecell_GN.pth
|
| 12 |
S2FApp/ckp/spheroid/ckp_spheroid_GN.pth
|
| 13 |
S2FApp/*.docx
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
S2FApp/ckp/single_cell/ckp_singlecell_GN.pth
|
| 12 |
S2FApp/ckp/spheroid/ckp_spheroid_GN.pth
|
| 13 |
S2FApp/*.docx
|
| 14 |
+
|
| 15 |
+
# experiments: trained ablation checkpoints (large)
|
| 16 |
+
experiments/runs/
|
experiments/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# S2F experiments: data audit, evaluation, baselines and ablations
|
| 2 |
+
|
| 3 |
+
This folder holds the code for every number reported in the paper beyond the training of the released checkpoints:
|
| 4 |
+
- the dataset audit and split manifests;
|
| 5 |
+
- the evaluation of the released models;
|
| 6 |
+
- the baselines and ablation study.
|
| 7 |
+
|
| 8 |
+
Run every command from the repository root (`Shape2Force/`); a GPU is used automatically when available.
|
| 9 |
+
|
| 10 |
+
```
|
| 11 |
+
experiments/
|
| 12 |
+
layout.py dataset layout, per-set metadata (Young's modulus, TFM settings), manifest helpers
|
| 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
|
| 20 |
+
run_all.py run the steps above in order
|
| 21 |
+
splits/ split manifests (tracked in git)
|
| 22 |
+
runs/ training outputs (git-ignored)
|
| 23 |
+
results/ audit and evaluation tables
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
## Data
|
| 27 |
+
|
| 28 |
+
`dataset/` (git-ignored, Zenodo record 10.5281/zenodo.18457879) contains:
|
| 29 |
+
- `singlecell/train` (236), `singlecell/test` (46);
|
| 30 |
+
- `spheroid/train` (129), `spheroid/test` (28).
|
| 31 |
+
|
| 32 |
+
The released single-cell model was trained on a larger split (`all_tune`: 280 training / 81 validation pairs, 1, 6 and 10 kPa). The paper also reports independent test sets. `prepare_dataset.py` reconstructs all of this from the original data tree and adds the missing images to `dataset/`:
|
| 33 |
+
|
| 34 |
+
```
|
| 35 |
+
dataset/singlecell_extra/<set>/<sample>/ all_tune images that are not in singlecell/ (1 and 10 kPa)
|
| 36 |
+
dataset/singlecell_external/<set>/<sample>/ independent test sets (DMSO, Y-27632, TGF-beta, fibronectin, ...)
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
Each manifest in `splits/` lists one sample per row:
|
| 40 |
+
- the sample folder relative to `dataset/`;
|
| 41 |
+
- condition, Young's modulus and TFM settings: colour limit of the heatmap (`ythreshold_Pa`, 500 Pa for single cells and 200 Pa for spheroids) and pixel-size setting;
|
| 42 |
+
- the substrate setting the original code fed to the network (`substrate_legacy`) and the nominal one (`substrate_nominal`);
|
| 43 |
+
- the MD5 of the bright-field image.
|
| 44 |
+
|
| 45 |
+
## Steps
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
# 1. audit only: what is in dataset/ vs the original tree, all_tune composition, leakage check
|
| 49 |
+
python -m experiments.run_all --legacy-root <path>/cellforce/dataset --steps audit
|
| 50 |
+
|
| 51 |
+
# 2. complete dataset/ and write splits/*.csv (then commit experiments/splits/)
|
| 52 |
+
python -m experiments.run_all --legacy-root <path>/cellforce/dataset --steps prepare
|
| 53 |
+
|
| 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`.
|
| 62 |
+
|
| 63 |
+
### GPUs and run time
|
| 64 |
+
|
| 65 |
+
- `--steps train` runs one job per GPU in parallel (default `--gpus 0,1`). Each job sees only its own GPU (`CUDA_VISIBLE_DEVICES`), and the next variant starts as soon as a GPU is free.
|
| 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 |
+
|---|---|
|
| 88 |
+
| `s2f_full` | none (from-scratch counterpart of the released model) |
|
| 89 |
+
| `unet` | plain U-Net (Ronneberger et al. 2015), no attention modules, no discriminator |
|
| 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 |
|
| 97 |
+
| `s2f_no_multiscale` | no multi-scale input module |
|
| 98 |
+
| `s2f_bridge_no_cbam` | bottleneck without CBAM |
|
| 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 |
+
|
| 106 |
+
`evaluate.py` computes every metric per image and reports the mean and SD across images:
|
| 107 |
+
- MSE, MS-SSIM and pixel-wise Pearson r;
|
| 108 |
+
- per-image WFM-RME;
|
| 109 |
+
- mean traction stress in Pa (heatmap value × colour limit);
|
| 110 |
+
- the fraction of saturated ground-truth pixels;
|
| 111 |
+
- inference time.
|
| 112 |
+
|
| 113 |
+
Its outputs are `results/eval/per_image.csv`, `summary.csv` and `model_params.csv`.
|
experiments/__init__.py
ADDED
|
File without changes
|
experiments/ablation_models.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generators for the baseline / ablation study. All variants output tanh in [-1, 1], like the
|
| 3 |
+
released S2F generator, so they share one training and evaluation path.
|
| 4 |
+
|
| 5 |
+
arch='s2f' S2FGenerator (models/s2f_model.py) with optional switches:
|
| 6 |
+
use_multi_scale_input (False -> single 3x3 input conv)
|
| 7 |
+
bridge 'cbam' (paper) | 'cnn' (no CBAM) | 'plain' (dilated conv block only:
|
| 8 |
+
no CBAM, no Global Context Module, no hierarchical attention)
|
| 9 |
+
attention_gates False -> skip connections pass encoder features unchanged
|
| 10 |
+
arch='unet' plain U-Net (Ronneberger et al., 2015): 4 levels, 64-1024 channels, double 3x3 conv + BN +
|
| 11 |
+
ReLU, transposed-conv upsampling, concatenation skips; no attention modules.
|
| 12 |
+
"""
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
|
| 16 |
+
from models.blocks import ResidualBlock
|
| 17 |
+
from models.s2f_model import PatchGANDiscriminator, S2FGenerator
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class _PassSkip(nn.Module):
|
| 21 |
+
"""Replaces an attention gate: returns the encoder skip feature unchanged."""
|
| 22 |
+
def forward(self, g, x):
|
| 23 |
+
return x
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _double_conv(i, o):
|
| 27 |
+
return nn.Sequential(nn.Conv2d(i, o, 3, padding=1, bias=False), nn.BatchNorm2d(o), nn.ReLU(inplace=True),
|
| 28 |
+
nn.Conv2d(o, o, 3, padding=1, bias=False), nn.BatchNorm2d(o), nn.ReLU(inplace=True))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class UNet(nn.Module):
|
| 32 |
+
def __init__(self, in_channels=1, out_channels=1, base=64):
|
| 33 |
+
super().__init__()
|
| 34 |
+
c = [base, base * 2, base * 4, base * 8, base * 16]
|
| 35 |
+
self.downs = nn.ModuleList([_double_conv(in_channels, c[0])] + [_double_conv(c[i], c[i + 1]) for i in range(3)])
|
| 36 |
+
self.pool = nn.MaxPool2d(2)
|
| 37 |
+
self.bottleneck = _double_conv(c[3], c[4])
|
| 38 |
+
self.ups = nn.ModuleList([nn.ConvTranspose2d(c[i + 1], c[i], 2, stride=2) for i in reversed(range(4))])
|
| 39 |
+
self.decs = nn.ModuleList([_double_conv(c[i] * 2, c[i]) for i in reversed(range(4))])
|
| 40 |
+
self.out = nn.Sequential(nn.Conv2d(c[0], out_channels, 1), nn.Tanh())
|
| 41 |
+
|
| 42 |
+
def forward(self, x):
|
| 43 |
+
skips = []
|
| 44 |
+
for d in self.downs:
|
| 45 |
+
x = d(x); skips.append(x); x = self.pool(x)
|
| 46 |
+
x = self.bottleneck(x)
|
| 47 |
+
for up, dec, s in zip(self.ups, self.decs, reversed(skips)):
|
| 48 |
+
x = dec(torch.cat([up(x), s], dim=1))
|
| 49 |
+
return self.out(x)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def build_generator(arch="s2f", in_channels=3, use_multi_scale_input=True, bridge="cbam",
|
| 53 |
+
attention_gates=True, **_):
|
| 54 |
+
if arch == "unet":
|
| 55 |
+
return UNet(in_channels=in_channels)
|
| 56 |
+
if arch != "s2f":
|
| 57 |
+
raise ValueError(f"unknown arch {arch}")
|
| 58 |
+
g = S2FGenerator(in_channels=in_channels, out_channels=1, img_size=1024,
|
| 59 |
+
bridge_type="cnn" if bridge in ("cnn", "plain") else "cbam",
|
| 60 |
+
use_multi_scale_input=use_multi_scale_input)
|
| 61 |
+
if bridge == "plain":
|
| 62 |
+
g.bridge = nn.Sequential(nn.Conv2d(512, 1024, 3, padding=2, dilation=2), nn.BatchNorm2d(1024),
|
| 63 |
+
nn.ReLU(inplace=True), ResidualBlock(1024, 1024))
|
| 64 |
+
if not attention_gates:
|
| 65 |
+
g.att4, g.att3, g.att2, g.att1 = _PassSkip(), _PassSkip(), _PassSkip(), _PassSkip()
|
| 66 |
+
return g
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def build_discriminator(in_channels=3):
|
| 70 |
+
return PatchGANDiscriminator(in_channels=in_channels + 1, ndf=64, n_layers=3)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def n_params(module):
|
| 74 |
+
return sum(p.numel() for p in module.parameters())
|
experiments/configs.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 26 |
+
seed=0,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
VARIANTS = {
|
| 30 |
+
# reference
|
| 31 |
+
"s2f_full": dict(),
|
| 32 |
+
# baselines
|
| 33 |
+
"unet": dict(arch="unet", lambda_gan=0.0), # plain U-Net, no attention, no discriminator
|
| 34 |
+
"unet_gan": dict(arch="unet"), # pix2pix-style U-Net + PatchGAN
|
| 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
|
| 42 |
+
"s2f_no_substrate": dict(in_channels=1, substrate_mode="none"),
|
| 43 |
+
"s2f_nominal_substrate": dict(substrate_mode="nominal"),
|
| 44 |
+
# architecture ablations
|
| 45 |
+
"s2f_no_multiscale": dict(use_multi_scale_input=False),
|
| 46 |
+
"s2f_bridge_no_cbam": dict(bridge="cnn"),
|
| 47 |
+
"s2f_bridge_plain": dict(bridge="plain"),
|
| 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)
|
| 61 |
+
cfg.update(VARIANTS[variant])
|
| 62 |
+
cfg.update({k: v for k, v in overrides.items() if v is not None})
|
| 63 |
+
cfg["variant"] = variant
|
| 64 |
+
if cfg["substrate_mode"] == "none":
|
| 65 |
+
cfg["in_channels"] = 1
|
| 66 |
+
return cfg
|
experiments/evaluate.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Evaluate released checkpoints, baselines and trained ablation runs on every split manifest.
|
| 3 |
+
|
| 4 |
+
python -m experiments.evaluate --released --meanmap # released models + input-free baseline
|
| 5 |
+
python -m experiments.evaluate --released --sweep # + substrate-setting sensitivity
|
| 6 |
+
python -m experiments.evaluate --stage1 <path>/last_checkpoint_final.pth # image-only stage-1 model
|
| 7 |
+
python -m experiments.evaluate --runs # every experiments/runs/*/best_checkpoint.pth
|
| 8 |
+
|
| 9 |
+
Metrics are computed PER IMAGE (mean and SD are then taken across images):
|
| 10 |
+
mse, ms_ssim, pixel_r (Pearson), rme (per-image WFM-RME), mean_gt / mean_pred (heatmap units),
|
| 11 |
+
mean_gt_Pa / mean_pred_Pa (x colour-scale limit of the set, 500 Pa single cells, 200 Pa spheroids),
|
| 12 |
+
gt_frac_saturated (pixels >= 0.98 of the colour limit), inference time.
|
| 13 |
+
Outputs (experiments/results/eval/ by default; existing rows of other models are kept):
|
| 14 |
+
per_image.csv, summary.csv
|
| 15 |
+
"""
|
| 16 |
+
import argparse
|
| 17 |
+
import glob
|
| 18 |
+
import os
|
| 19 |
+
import time
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import pandas as pd
|
| 23 |
+
import torch
|
| 24 |
+
|
| 25 |
+
from experiments.ablation_models import build_generator, n_params
|
| 26 |
+
from experiments.layout import DEFAULT_DATASET_ROOT, RESULTS_DIR, RUNS_DIR, S2F_ROOT, list_manifests
|
| 27 |
+
from experiments.losses import PerImageWFMRMELoss
|
| 28 |
+
from experiments.manifest_data import make_loader
|
| 29 |
+
from models.s2f_model import create_s2f_model, create_settings_channels
|
| 30 |
+
from utils.metrics import TorchMetricsWrapper
|
| 31 |
+
from utils.substrate_settings import compute_settings_normalization
|
| 32 |
+
|
| 33 |
+
CONFIG_PATH = os.path.join(S2F_ROOT, "config", "substrate_settings.json")
|
| 34 |
+
SWEEP_KEYS = ["PDMS_1kPa_3um", "fibroblasts_PDMS", "PDMS_10kPa_3um"]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def released_models(device):
|
| 38 |
+
out = {}
|
| 39 |
+
g, _ = create_s2f_model(in_channels=3, model_type="s2f")
|
| 40 |
+
g.load_state_dict(torch.load(os.path.join(S2F_ROOT, "ckp/single_cell/ckp_singlecell_GN.pth"), map_location="cpu",
|
| 41 |
+
weights_only=False)["generator_state_dict"], strict=True)
|
| 42 |
+
out["released_singlecell"] = dict(model=g.to(device).eval(), kind="singlecell", in_channels=3, tanh=True)
|
| 43 |
+
g, _ = create_s2f_model(in_channels=1, model_type="s2f")
|
| 44 |
+
g.load_state_dict(torch.load(os.path.join(S2F_ROOT, "ckp/spheroid/ckp_spheroid_GN.pth"), map_location="cpu",
|
| 45 |
+
weights_only=False)["generator_state_dict"], strict=True)
|
| 46 |
+
out["released_spheroid_GN"] = dict(model=g.to(device).eval(), kind="spheroid", in_channels=1, tanh=True)
|
| 47 |
+
fn = os.path.join(S2F_ROOT, "ckp/spheroid/ckp_spheroid_FN.pth")
|
| 48 |
+
if os.path.exists(fn):
|
| 49 |
+
g, _ = create_s2f_model(in_channels=1, model_type="s2f_spheroid")
|
| 50 |
+
g.load_state_dict(torch.load(fn, map_location="cpu", weights_only=False)["model_state_dict"], strict=True)
|
| 51 |
+
g.set_output_mode(use_tanh=False)
|
| 52 |
+
out["supervised_spheroid_FN"] = dict(model=g.to(device).eval(), kind="spheroid", in_channels=1, tanh=False)
|
| 53 |
+
return out
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def stage1_model(path, device):
|
| 57 |
+
g, _ = create_s2f_model(in_channels=1, model_type="s2f")
|
| 58 |
+
sd = torch.load(path, map_location="cpu", weights_only=False)["generator_state_dict"]
|
| 59 |
+
g.load_state_dict({k.replace("denoise_module", "initial_conv"): v for k, v in sd.items()}, strict=True)
|
| 60 |
+
return dict(model=g.to(device).eval(), kind="singlecell", in_channels=1, tanh=True)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def run_models(device, pattern):
|
| 64 |
+
out = {}
|
| 65 |
+
for p in sorted(glob.glob(pattern)):
|
| 66 |
+
ck = torch.load(p, map_location="cpu", weights_only=False)
|
| 67 |
+
cfg = ck["config"]
|
| 68 |
+
g = build_generator(**cfg); g.load_state_dict(ck["generator_state_dict"], strict=True)
|
| 69 |
+
name = os.path.basename(os.path.dirname(p))
|
| 70 |
+
out[name] = dict(model=g.to(device).eval(), kind="singlecell", in_channels=cfg["in_channels"], tanh=True,
|
| 71 |
+
substrate_mode=cfg["substrate_mode"], input_mode=cfg["input_mode"],
|
| 72 |
+
epoch=ck.get("epoch"), val_loss=ck.get("val_loss"))
|
| 73 |
+
return out
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def mean_map(manifest, dataset_root):
|
| 77 |
+
acc, n = None, 0
|
| 78 |
+
for b in make_loader(manifest, dataset_root, batch_size=8, substrate_mode="none"):
|
| 79 |
+
acc = b[1].sum(0, keepdim=True) if acc is None else acc + b[1].sum(0, keepdim=True); n += b[1].size(0)
|
| 80 |
+
return acc / n
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def evaluate_one(name, spec, manifest, dataset_root, device, mw, norm, substrate_mode, batch, mean_pred=None):
|
| 84 |
+
input_mode = spec.get("input_mode", "bf")
|
| 85 |
+
use_settings = spec.get("in_channels", 1) == 3
|
| 86 |
+
sm = substrate_mode if use_settings else "none"
|
| 87 |
+
forced = None
|
| 88 |
+
if sm and sm.startswith("force:"):
|
| 89 |
+
forced, sm = sm.split(":", 1)[1], "reference"
|
| 90 |
+
try:
|
| 91 |
+
loader = make_loader(manifest, dataset_root, batch_size=batch, substrate_mode=sm, input_mode=input_mode)
|
| 92 |
+
except FileNotFoundError as e:
|
| 93 |
+
print(f" [skip] {name} on {manifest}: {e}"); return []
|
| 94 |
+
rows_meta, rec, out, k = loader.dataset.rows, PerImageWFMRMELoss(), [], 0
|
| 95 |
+
with torch.no_grad():
|
| 96 |
+
for b in loader:
|
| 97 |
+
x, y = b[0].to(device), b[1].to(device)
|
| 98 |
+
t0 = time.time()
|
| 99 |
+
if mean_pred is not None:
|
| 100 |
+
p = mean_pred.to(device).expand_as(y)
|
| 101 |
+
else:
|
| 102 |
+
if use_settings:
|
| 103 |
+
meta = b[4]
|
| 104 |
+
if forced:
|
| 105 |
+
meta = dict(meta, substrate=[forced] * x.size(0))
|
| 106 |
+
x = torch.cat([x, create_settings_channels(meta, norm, device, x.shape, config_path=CONFIG_PATH)], 1)
|
| 107 |
+
p = spec["model"](x)
|
| 108 |
+
p = (p + 1) / 2 if spec.get("tanh", True) else p
|
| 109 |
+
dt = (time.time() - t0) / x.size(0)
|
| 110 |
+
for i in range(y.size(0)):
|
| 111 |
+
r = rows_meta[k]; k += 1
|
| 112 |
+
t, q = y[i:i + 1], p[i:i + 1]
|
| 113 |
+
yt = float(r.get("ythreshold_Pa") or (200 if "spheroid" in manifest else 500))
|
| 114 |
+
out.append({"model": name, "manifest": manifest, "substrate_mode": substrate_mode if use_settings else "n/a",
|
| 115 |
+
"sample_dir": r["sample_dir"], "set": r.get("set", ""), "condition": r.get("condition", ""),
|
| 116 |
+
"mse": mw.compute_mse(t, q), "ms_ssim": mw.compute_ms_ssim(t, q),
|
| 117 |
+
"pixel_r": float(np.corrcoef(t.flatten().cpu().numpy(), q.flatten().cpu().numpy())[0, 1]),
|
| 118 |
+
"rme": float(rec(q, t)), "mean_gt": float(t.mean()), "mean_pred": float(q.mean()),
|
| 119 |
+
"mean_gt_Pa": float(t.mean()) * yt, "mean_pred_Pa": float(q.mean()) * yt,
|
| 120 |
+
"gt_max": float(t.max()), "gt_frac_saturated": float((t >= 0.98).float().mean()),
|
| 121 |
+
"seconds_per_image": dt})
|
| 122 |
+
return out
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def summarise(df):
|
| 126 |
+
g = df.groupby(["model", "manifest", "substrate_mode"])
|
| 127 |
+
s = g.agg(n=("mse", "size"), **{f"{m}_{f}": (m, f) for m in ["mse", "ms_ssim", "pixel_r", "rme", "mean_gt_Pa",
|
| 128 |
+
"mean_pred_Pa", "gt_frac_saturated"] for f in ["mean", "std"]},
|
| 129 |
+
seconds_per_image=("seconds_per_image", "mean")).reset_index()
|
| 130 |
+
s["pred_over_gt"] = s["mean_pred_Pa_mean"] / s["mean_gt_Pa_mean"]
|
| 131 |
+
return s
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def main():
|
| 135 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 136 |
+
ap.add_argument("--released", action="store_true", help="released single-cell + spheroid checkpoints")
|
| 137 |
+
ap.add_argument("--sweep", action="store_true", help="released single-cell model with 1 / 6 / 10 kPa settings")
|
| 138 |
+
ap.add_argument("--meanmap", action="store_true", help="input-free baseline (mean training map)")
|
| 139 |
+
ap.add_argument("--stage1", default=None, help="path to last_checkpoint_final.pth (image-only stage 1)")
|
| 140 |
+
ap.add_argument("--runs", nargs="?", const=os.path.join(RUNS_DIR, "*", "best_checkpoint.pth"), default=None,
|
| 141 |
+
help="glob of trained run checkpoints (default experiments/runs/*/best_checkpoint.pth)")
|
| 142 |
+
ap.add_argument("--manifests", default="", help="comma-separated manifest names (default: all in experiments/splits)")
|
| 143 |
+
ap.add_argument("--dataset-root", default=DEFAULT_DATASET_ROOT)
|
| 144 |
+
ap.add_argument("--out", default=os.path.join(RESULTS_DIR, "eval"))
|
| 145 |
+
ap.add_argument("--batch", type=int, default=2)
|
| 146 |
+
ap.add_argument("--device", default=None)
|
| 147 |
+
a = ap.parse_args()
|
| 148 |
+
device = torch.device(a.device or ("cuda" if torch.cuda.is_available() else "cpu"))
|
| 149 |
+
mans = [m for m in (a.manifests.split(",") if a.manifests else list_manifests()) if m]
|
| 150 |
+
sc_mans = [m for m in mans if not m.startswith("spheroid") and m != "alltune_train" and m != "zenodo_v1_train"]
|
| 151 |
+
sph_mans = [m for m in mans if m.startswith("spheroid") and m != "spheroid_train"]
|
| 152 |
+
print(f"device {device}; single-cell manifests {sc_mans}; spheroid manifests {sph_mans}")
|
| 153 |
+
mw, norm = TorchMetricsWrapper(device=device), compute_settings_normalization(config_path=CONFIG_PATH)
|
| 154 |
+
|
| 155 |
+
models = {}
|
| 156 |
+
if a.released:
|
| 157 |
+
models.update(released_models(device))
|
| 158 |
+
if a.stage1:
|
| 159 |
+
models["stage1_image_only"] = stage1_model(a.stage1, device)
|
| 160 |
+
if a.runs:
|
| 161 |
+
models.update(run_models(device, a.runs))
|
| 162 |
+
rows, params = [], []
|
| 163 |
+
for name, spec in models.items():
|
| 164 |
+
params.append({"model": name, "n_params_M": n_params(spec["model"]) / 1e6, "epoch": spec.get("epoch"),
|
| 165 |
+
"val_loss": spec.get("val_loss")})
|
| 166 |
+
targets = sc_mans if spec["kind"] == "singlecell" else sph_mans
|
| 167 |
+
modes = [spec.get("substrate_mode", "legacy")]
|
| 168 |
+
if name == "released_singlecell":
|
| 169 |
+
modes = ["legacy", "nominal"] + ([f"force:{k}" for k in SWEEP_KEYS] if a.sweep else [])
|
| 170 |
+
for m in targets:
|
| 171 |
+
for sm in modes:
|
| 172 |
+
print(f" {name:28s} {m:28s} {sm}", flush=True)
|
| 173 |
+
rows += evaluate_one(name, spec, m, a.dataset_root, device, mw, norm, sm, a.batch)
|
| 174 |
+
if a.meanmap:
|
| 175 |
+
for train_m, targets, name in [("alltune_train", sc_mans, "baseline_mean_map_singlecell"),
|
| 176 |
+
("spheroid_train", sph_mans, "baseline_mean_map_spheroid")]:
|
| 177 |
+
if train_m not in list_manifests():
|
| 178 |
+
continue
|
| 179 |
+
M = mean_map(train_m, a.dataset_root)
|
| 180 |
+
for m in targets:
|
| 181 |
+
print(f" {name:28s} {m}", flush=True)
|
| 182 |
+
rows += evaluate_one(name, {"in_channels": 1}, m, a.dataset_root, device, mw, norm, "n/a", a.batch, mean_pred=M)
|
| 183 |
+
os.makedirs(a.out, exist_ok=True)
|
| 184 |
+
new = pd.DataFrame(rows)
|
| 185 |
+
pi = os.path.join(a.out, "per_image.csv")
|
| 186 |
+
if os.path.exists(pi) and len(new): # keep rows of models not re-evaluated now
|
| 187 |
+
old = pd.read_csv(pi)
|
| 188 |
+
new = pd.concat([old[~old.model.isin(new.model.unique())], new], ignore_index=True)
|
| 189 |
+
if len(new):
|
| 190 |
+
new.to_csv(pi, index=False)
|
| 191 |
+
summarise(new).to_csv(os.path.join(a.out, "summary.csv"), index=False)
|
| 192 |
+
pp = os.path.join(a.out, "model_params.csv")
|
| 193 |
+
if params:
|
| 194 |
+
P = pd.DataFrame(params)
|
| 195 |
+
if os.path.exists(pp):
|
| 196 |
+
old = pd.read_csv(pp); P = pd.concat([old[~old.model.isin(P.model)], P], ignore_index=True)
|
| 197 |
+
P.to_csv(pp, index=False)
|
| 198 |
+
print(f"wrote {pi}, summary.csv, model_params.csv in {a.out}")
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
if __name__ == "__main__":
|
| 202 |
+
main()
|
experiments/layout.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dataset layout, per-set metadata and manifest helpers for the S2F experiments.
|
| 3 |
+
|
| 4 |
+
Two dataset trees are involved:
|
| 5 |
+
|
| 6 |
+
* the **repository dataset** ``S2F/dataset/`` (git-ignored; same content as the Zenodo deposit):
|
| 7 |
+
singlecell/{train,test}/<sample>/ 236 / 46 fibroblasts on 6 kPa collagen gels
|
| 8 |
+
spheroid/{train,test}/<sample>/ 129 / 28 LS174T spheroids
|
| 9 |
+
``experiments/prepare_dataset.py --write`` adds the remaining sets used in the paper:
|
| 10 |
+
singlecell_extra/<set>/<sample>/ training/validation images of the released model
|
| 11 |
+
that are not in singlecell/ (1 and 10 kPa)
|
| 12 |
+
singlecell_external/<set>/<sample>/ independent test sets (drugs, fibronectin, ...)
|
| 13 |
+
|
| 14 |
+
* the **legacy tree** used to develop the model (``cellforce/dataset`` on the workstation), whose
|
| 15 |
+
``single_cell/all_tune/{train,test}`` folder (280 / 81 pairs) is the split of the released
|
| 16 |
+
single-cell checkpoint.
|
| 17 |
+
|
| 18 |
+
Splits are defined by manifest CSV files in ``experiments/splits/`` (tracked in git), one row per
|
| 19 |
+
sample, so that every experiment is reproducible from the repository dataset alone.
|
| 20 |
+
"""
|
| 21 |
+
import csv
|
| 22 |
+
import hashlib
|
| 23 |
+
import os
|
| 24 |
+
|
| 25 |
+
S2F_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 26 |
+
# Override with environment variables if needed (e.g. dataset on another disk)
|
| 27 |
+
DEFAULT_DATASET_ROOT = os.environ.get("S2F_DATASET_ROOT", os.path.join(S2F_ROOT, "dataset"))
|
| 28 |
+
SPLITS_DIR = os.environ.get("S2F_SPLITS_DIR", os.path.join(S2F_ROOT, "experiments", "splits"))
|
| 29 |
+
RUNS_DIR = os.environ.get("S2F_RUNS_DIR", os.path.join(S2F_ROOT, "experiments", "runs"))
|
| 30 |
+
RESULTS_DIR = os.environ.get("S2F_RESULTS_DIR", os.path.join(S2F_ROOT, "experiments", "results"))
|
| 31 |
+
|
| 32 |
+
BF_SUFFIX = "BF_001.tif"
|
| 33 |
+
HM_SUFFIX = "_gray.jpg"
|
| 34 |
+
MASK_NAMES = ("cell_mask.tif", "cell_mask.png", "mask.tif", "mask.png") # optional, for the mask-only baseline
|
| 35 |
+
|
| 36 |
+
# TFM reconstruction settings (from the MATLAB settings files of each set).
|
| 37 |
+
# ythreshold_Pa = upper limit of the colour scale used to export the heatmaps -> grey 1.0 == ythreshold_Pa.
|
| 38 |
+
# tfm_pixelsize = value of Settings.PixelSize used in the TFM reconstruction.
|
| 39 |
+
FIBRO_6KPA = dict(cell_type="HFF", coating="collagen I", young_Pa=6000, tfm_pixelsize=3.0769, ythreshold_Pa=500)
|
| 40 |
+
|
| 41 |
+
# Legacy sets: key -> (relative path under legacy root, role, clean name, metadata)
|
| 42 |
+
# role: 'alltune' split of the released single-cell model (train 280 / val 81)
|
| 43 |
+
# 'published' already in S2F/dataset
|
| 44 |
+
# 'source' pool from which all_tune was assembled (1 / 10 kPa)
|
| 45 |
+
# 'external' independent test set
|
| 46 |
+
# 'union' duplicate collection, audited only
|
| 47 |
+
LEGACY_SETS = {
|
| 48 |
+
"all_tune/train": ("single_cell/all_tune/train", "alltune", None, {}),
|
| 49 |
+
"all_tune/test": ("single_cell/all_tune/test", "alltune", None, {}),
|
| 50 |
+
"fibro_split/train": ("single_cell/fibro_split/train", "published", "fibro_6kPa_train", dict(FIBRO_6KPA, condition="6 kPa collagen")),
|
| 51 |
+
"fibro_split/test": ("single_cell/fibro_split/test", "published", "fibro_6kPa_setVI", dict(FIBRO_6KPA, condition="6 kPa collagen")),
|
| 52 |
+
"1kpa_new/train": ("single_cell/1kpa_new/train", "source", "fibro_1kPa_train", dict(FIBRO_6KPA, young_Pa=1000, condition="1 kPa collagen")),
|
| 53 |
+
"1kpa_new/test": ("single_cell/1kpa_new/test", "source", "fibro_1kPa_setI", dict(FIBRO_6KPA, young_Pa=1000, condition="1 kPa collagen")),
|
| 54 |
+
"10kpa_new/train": ("single_cell/10kpa_new/train", "source", "fibro_10kPa_train", dict(FIBRO_6KPA, young_Pa=10000, condition="10 kPa collagen")),
|
| 55 |
+
"10kpa_new/test": ("single_cell/10kpa_new/test", "source", "fibro_10kPa_setI", dict(FIBRO_6KPA, young_Pa=10000, condition="10 kPa collagen")),
|
| 56 |
+
"1kPa_SET_III": ("single_cell/last_new_1kpa_10kpa/Heatmaps_01kPa_SET_III_for_Kaveh", "external", "fibro_1kPa_setIII", dict(FIBRO_6KPA, young_Pa=1000, condition="1 kPa collagen")),
|
| 57 |
+
"DMSO_SET_II": ("single_cell/last_new_1kpa_10kpa/Heatmaps_DMSO_SET_II_for_Kaveh", "external", "fibro_DMSO_setII", dict(FIBRO_6KPA, condition="DMSO")),
|
| 58 |
+
"Y27632_SET_II": ("single_cell/last_new_1kpa_10kpa/Heatmaps_Y27632_SET_II_for_Kaveh", "external", "fibro_Y27632_setII", dict(FIBRO_6KPA, condition="Y-27632")),
|
| 59 |
+
"Control_SET_I": ("single_cell/Drugs/Fibros_Control_SET_I_for_Kaveh", "external", "fibro_DMSO_setI", dict(FIBRO_6KPA, condition="DMSO")),
|
| 60 |
+
"Fibronectin_SET_I": ("single_cell/Drugs/Fibros_Fibronectin_SET_I_for_Kaveh", "external", "fibro_fibronectin_setI", dict(FIBRO_6KPA, coating="fibronectin", condition="fibronectin")),
|
| 61 |
+
"TGFB_SET_I": ("single_cell/Drugs/Fibros_TGFB_SETI_for_Kaveh", "external", "fibro_TGFb_setI", dict(FIBRO_6KPA, condition="TGF-beta")),
|
| 62 |
+
"Y27632_SET_I": ("single_cell/Drugs/Fibros_Y27632_SET_I_for_Kaveh", "external", "fibro_Y27632_setI", dict(FIBRO_6KPA, condition="Y-27632")),
|
| 63 |
+
"U2OS/test": ("single_cell/u2os/test", "external", "u2os_6kPa", dict(cell_type="U2OS", coating="collagen I", young_Pa=6000, tfm_pixelsize=6.1538, ythreshold_Pa=500, condition="U2OS")),
|
| 64 |
+
"Drugs/all": ("single_cell/Drugs/all", "union", None, {}),
|
| 65 |
+
"spheroid/train": ("spheroid/train", "published", "spheroid_train", dict(cell_type="LS174T spheroid", coating="collagen I", ythreshold_Pa=200, condition="spheroid")),
|
| 66 |
+
"spheroid/test": ("spheroid/test", "published", "spheroid_val", dict(cell_type="LS174T spheroid", coating="collagen I", ythreshold_Pa=200, condition="spheroid")),
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
# Published repository dataset (S2F/dataset), relative paths
|
| 70 |
+
PUBLISHED_SETS = {
|
| 71 |
+
"singlecell/train": "singlecell/train",
|
| 72 |
+
"singlecell/test": "singlecell/test",
|
| 73 |
+
"spheroid/train": "spheroid/train",
|
| 74 |
+
"spheroid/test": "spheroid/test",
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
MANIFEST_FIELDS = ["sample_dir", "split", "set", "condition", "cell_type", "coating", "young_Pa",
|
| 78 |
+
"tfm_pixelsize", "ythreshold_Pa", "legacy_folder", "legacy_substrate_code",
|
| 79 |
+
"substrate_legacy", "substrate_nominal", "bf_md5"]
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def legacy_substrate_code(folder_name):
|
| 83 |
+
"""Substrate code exactly as the original training code derived it (folder_name[:4]; 'kpan*' -> [:5])."""
|
| 84 |
+
code = folder_name[:4]
|
| 85 |
+
return folder_name[:5] if code.startswith("kpan") else code
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def legacy_code_to_config_key(code):
|
| 89 |
+
"""S2F config key that encodes the same (pixel size, Young's modulus) as the original code's substrate code."""
|
| 90 |
+
return {"kpa0": "PDMS_1kPa", "kpa1": "PDMS_10kPa", "kpan0": "PDMS_1kPa_3um",
|
| 91 |
+
"kpan1": "PDMS_10kPa_3um", "u2os": "U2OS_PDMS"}.get(code, "fibroblasts_PDMS")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def nominal_config_key(young_Pa, tfm_pixelsize, cell_type="HFF"):
|
| 95 |
+
"""S2F config key for the nominal Young's modulus and TFM pixel-size setting of a sample."""
|
| 96 |
+
if cell_type == "U2OS":
|
| 97 |
+
return "U2OS_PDMS"
|
| 98 |
+
if young_Pa in (None, ""):
|
| 99 |
+
return None
|
| 100 |
+
young_Pa, px = float(young_Pa), float(tfm_pixelsize or 3.0769)
|
| 101 |
+
if young_Pa == 6000:
|
| 102 |
+
return "fibroblasts_PDMS"
|
| 103 |
+
suffix = "_3um" if abs(px - 3.0769) < 1e-3 else ""
|
| 104 |
+
return {1000: "PDMS_1kPa", 10000: "PDMS_10kPa"}.get(int(young_Pa), "fibroblasts_PDMS") + suffix
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def sample_folders(root):
|
| 108 |
+
"""Yield (folder_name, bf_path, hm_path) for every direct sub-folder holding a BF image and a gray heatmap."""
|
| 109 |
+
if not os.path.isdir(root):
|
| 110 |
+
return
|
| 111 |
+
for sub in sorted(os.listdir(root)):
|
| 112 |
+
p = os.path.join(root, sub)
|
| 113 |
+
if not os.path.isdir(p):
|
| 114 |
+
continue
|
| 115 |
+
files = os.listdir(p)
|
| 116 |
+
bf = [f for f in files if f.endswith(BF_SUFFIX)]
|
| 117 |
+
hm = [f for f in files if f.endswith(HM_SUFFIX)]
|
| 118 |
+
if bf and hm:
|
| 119 |
+
yield sub, os.path.join(p, bf[0]), os.path.join(p, hm[0])
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def md5(path, chunk=1 << 20):
|
| 123 |
+
h = hashlib.md5()
|
| 124 |
+
with open(path, "rb") as f:
|
| 125 |
+
for b in iter(lambda: f.read(chunk), b""):
|
| 126 |
+
h.update(b)
|
| 127 |
+
return h.hexdigest()
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def read_manifest(path):
|
| 131 |
+
with open(path, newline="") as f:
|
| 132 |
+
return list(csv.DictReader(f))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def write_manifest(path, rows):
|
| 136 |
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
| 137 |
+
with open(path, "w", newline="") as f:
|
| 138 |
+
w = csv.DictWriter(f, fieldnames=MANIFEST_FIELDS, extrasaction="ignore")
|
| 139 |
+
w.writeheader()
|
| 140 |
+
w.writerows(rows)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def manifest_path(name):
|
| 144 |
+
return os.path.join(SPLITS_DIR, f"{name}.csv")
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def list_manifests():
|
| 148 |
+
if not os.path.isdir(SPLITS_DIR):
|
| 149 |
+
return []
|
| 150 |
+
return sorted(os.path.splitext(f)[0] for f in os.listdir(SPLITS_DIR) if f.endswith(".csv"))
|
experiments/losses.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Reconstruction loss used for the released single-cell model.
|
| 3 |
+
|
| 4 |
+
Per-image weighted force-magnitude relative error (WFM-RME), as in the manuscript and as in the
|
| 5 |
+
loss that trained the released checkpoint (Archive/src/utils/losses.WFMRelativeMagnitudeLoss):
|
| 6 |
+
the weight of each pixel is its ground-truth magnitude divided by the mean ground-truth magnitude
|
| 7 |
+
*of the same image* (eps = 1e-6). Note: utils.metrics.WFMRMELoss normalises by the mean over the
|
| 8 |
+
whole batch instead; the two coincide only for batch size 1.
|
| 9 |
+
"""
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class PerImageWFMRMELoss(nn.Module):
|
| 15 |
+
def __init__(self, eps=1e-6):
|
| 16 |
+
super().__init__()
|
| 17 |
+
self.eps = eps
|
| 18 |
+
|
| 19 |
+
def forward(self, pred, target):
|
| 20 |
+
t, p = target[:, 0], pred[:, 0] # (B, H, W) magnitudes
|
| 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()
|
experiments/manifest_data.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Manifest-based data loading. Uses exactly the preprocessing of the S2F package
|
| 3 |
+
(``data.cell_dataset.load_image`` -> grayscale, resize to 1024, /255; ``ImageDataset``;
|
| 4 |
+
``AdvancedAugmentations``), so results are comparable with the released models.
|
| 5 |
+
|
| 6 |
+
substrate_mode (single-cell only) decides which substrate setting builds the two substrate channels:
|
| 7 |
+
'legacy' what the original training code fed (derived from the all_tune folder name) - default,
|
| 8 |
+
reproduces the conditions under which the released model was trained
|
| 9 |
+
'nominal' the true Young's modulus / TFM pixel-size setting of each sample
|
| 10 |
+
'reference' the 6 kPa reference setting (fibroblasts_PDMS) for every sample
|
| 11 |
+
'none' no metadata (models without substrate channels)
|
| 12 |
+
input_mode:
|
| 13 |
+
'bf' bright-field image (default)
|
| 14 |
+
'mask' binary cell mask (file cell_mask.tif/png in the sample folder) - mask-only baseline
|
| 15 |
+
"""
|
| 16 |
+
import os
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
import torch
|
| 20 |
+
from torch.utils.data import DataLoader
|
| 21 |
+
|
| 22 |
+
from data.cell_dataset import ImageDataset, load_image
|
| 23 |
+
from experiments.layout import DEFAULT_DATASET_ROOT, MASK_NAMES, manifest_path, read_manifest
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _find(folder, suffix=None, names=None):
|
| 27 |
+
for f in sorted(os.listdir(folder)):
|
| 28 |
+
if (suffix and f.endswith(suffix)) or (names and f in names):
|
| 29 |
+
return os.path.join(folder, f)
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def load_manifest_pairs(manifest, dataset_root=DEFAULT_DATASET_ROOT, img_size=1024,
|
| 34 |
+
substrate_mode="legacy", input_mode="bf"):
|
| 35 |
+
rows = read_manifest(manifest if os.path.isfile(manifest) else manifest_path(manifest))
|
| 36 |
+
pairs = []
|
| 37 |
+
for r in rows:
|
| 38 |
+
d = os.path.join(dataset_root, r["sample_dir"])
|
| 39 |
+
bf = _find(d, suffix="BF_001.tif")
|
| 40 |
+
hm = _find(d, suffix="_gray.jpg")
|
| 41 |
+
if bf is None or hm is None:
|
| 42 |
+
raise FileNotFoundError(f"missing BF/heatmap in {d}")
|
| 43 |
+
if input_mode == "mask":
|
| 44 |
+
mp = _find(d, names=MASK_NAMES)
|
| 45 |
+
if mp is None:
|
| 46 |
+
raise FileNotFoundError(f"input_mode='mask' but no {MASK_NAMES} in {d}")
|
| 47 |
+
x = (load_image(mp, (img_size, img_size)) > 0.5).astype(np.float32)
|
| 48 |
+
else:
|
| 49 |
+
x = load_image(bf, (img_size, img_size))
|
| 50 |
+
y = load_image(hm, (img_size, img_size))
|
| 51 |
+
if substrate_mode == "none":
|
| 52 |
+
pairs.append((x, y, (0, 0)))
|
| 53 |
+
continue
|
| 54 |
+
sub = {"legacy": r.get("substrate_legacy"), "nominal": r.get("substrate_nominal"),
|
| 55 |
+
"reference": "fibroblasts_PDMS"}[substrate_mode] or "fibroblasts_PDMS"
|
| 56 |
+
pairs.append((x, y, (0, 0), {"folder_name": os.path.basename(d), "substrate": sub,
|
| 57 |
+
"root_folder": os.path.dirname(r["sample_dir"]), "set": r.get("set", "")}))
|
| 58 |
+
return pairs, rows
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _worker_init(worker_id):
|
| 62 |
+
"""Distinct, reproducible numpy seed per worker (augmentations use np.random). Module-level for Windows spawn."""
|
| 63 |
+
np.random.seed(torch.initial_seed() % 2 ** 32)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class _MaskDataset(ImageDataset):
|
| 67 |
+
"""Re-binarise the input after augmentation (intensity augmentations are meaningless for masks)."""
|
| 68 |
+
def __getitem__(self, idx):
|
| 69 |
+
out = list(super().__getitem__(idx))
|
| 70 |
+
out[0] = (out[0] > 0.5).float()
|
| 71 |
+
return tuple(out)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def make_loader(manifest, dataset_root=DEFAULT_DATASET_ROOT, batch_size=2, img_size=1024, augment=False,
|
| 75 |
+
shuffle=False, substrate_mode="legacy", input_mode="bf", seed=None, num_workers=0):
|
| 76 |
+
pairs, rows = load_manifest_pairs(manifest, dataset_root, img_size, substrate_mode, input_mode)
|
| 77 |
+
transform = None
|
| 78 |
+
if augment:
|
| 79 |
+
from data.augmentations import AdvancedAugmentations
|
| 80 |
+
transform = AdvancedAugmentations((img_size, img_size))
|
| 81 |
+
cls = _MaskDataset if input_mode == "mask" else ImageDataset
|
| 82 |
+
ds = cls(pairs, transform=transform, channel_first=True, threshold=0.0,
|
| 83 |
+
return_metadata=substrate_mode != "none")
|
| 84 |
+
ds.rows = rows
|
| 85 |
+
g = None
|
| 86 |
+
if seed is not None:
|
| 87 |
+
g = torch.Generator(); g.manual_seed(seed)
|
| 88 |
+
if num_workers > 0: # workers run the augmentations so the GPU is not left waiting
|
| 89 |
+
return DataLoader(ds, batch_size=batch_size, shuffle=shuffle, generator=g, num_workers=num_workers,
|
| 90 |
+
persistent_workers=True, pin_memory=torch.cuda.is_available(),
|
| 91 |
+
worker_init_fn=_worker_init)
|
| 92 |
+
return DataLoader(ds, batch_size=batch_size, shuffle=shuffle, generator=g, num_workers=0)
|
experiments/prepare_dataset.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Audit the legacy dataset tree against the repository dataset and (optionally) complete the
|
| 3 |
+
repository dataset with every image used in the paper, plus split manifests.
|
| 4 |
+
|
| 5 |
+
Step 1 - audit only (nothing is copied):
|
| 6 |
+
python -m experiments.prepare_dataset --legacy-root C:/Users/Kaveh/Documents/cellforce/dataset
|
| 7 |
+
|
| 8 |
+
Step 2 - copy missing images into S2F/dataset and write the manifests:
|
| 9 |
+
python -m experiments.prepare_dataset --legacy-root C:/Users/Kaveh/Documents/cellforce/dataset --write
|
| 10 |
+
|
| 11 |
+
What the audit answers (written to experiments/results/dataset_audit/):
|
| 12 |
+
counts.csv number of samples per legacy and repository set
|
| 13 |
+
published_vs_legacy.csv is S2F/dataset/singlecell (236/46) identical to legacy fibro_split? spheroids?
|
| 14 |
+
alltune_composition.csv where every image of all_tune/train (280) and all_tune/test (81) comes from
|
| 15 |
+
missing_from_published.csv all_tune images that are NOT in S2F/dataset (i.e. not in the Zenodo v1 deposit)
|
| 16 |
+
overlap_matrix.csv shared bright-field images between every pair of sets (leakage check)
|
| 17 |
+
substrate_codes.csv substrate code the original code derived from each all_tune folder name
|
| 18 |
+
report.txt human-readable summary
|
| 19 |
+
Images are matched by the MD5 of the bright-field file, so renamed/copied folders are still recognised.
|
| 20 |
+
|
| 21 |
+
With --write:
|
| 22 |
+
dataset/singlecell_extra/<set>/<folder>/ all_tune images missing from dataset/singlecell
|
| 23 |
+
dataset/singlecell_external/<set>/<folder>/ independent test sets
|
| 24 |
+
experiments/splits/*.csv manifests (commit these to git):
|
| 25 |
+
alltune_train.csv, alltune_val.csv split of the released single-cell model (280 / 81)
|
| 26 |
+
zenodo_v1_train.csv, zenodo_v1_test.csv published 6 kPa split (236 / 46)
|
| 27 |
+
spheroid_train.csv, spheroid_val.csv spheroid split (129 / 28)
|
| 28 |
+
test_<set>.csv one per independent test set
|
| 29 |
+
"""
|
| 30 |
+
import argparse
|
| 31 |
+
import collections
|
| 32 |
+
import csv
|
| 33 |
+
import os
|
| 34 |
+
import shutil
|
| 35 |
+
|
| 36 |
+
from experiments.layout import (DEFAULT_DATASET_ROOT, LEGACY_SETS, PUBLISHED_SETS, RESULTS_DIR,
|
| 37 |
+
legacy_code_to_config_key, legacy_substrate_code, manifest_path,
|
| 38 |
+
md5, nominal_config_key, sample_folders, write_manifest)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def index_tree(root, rel):
|
| 42 |
+
"""List samples of one set: [{folder, bf, hm, dir, md5}]."""
|
| 43 |
+
out = []
|
| 44 |
+
for folder, bf, hm in sample_folders(os.path.join(root, rel)):
|
| 45 |
+
out.append({"folder": folder, "bf": bf, "hm": hm, "dir": os.path.dirname(bf), "md5": md5(bf)})
|
| 46 |
+
return out
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def discover_unlisted(root, listed_rel, max_depth=4):
|
| 50 |
+
"""Directories under root that contain sample folders but are not in LEGACY_SETS (so nothing is missed)."""
|
| 51 |
+
found = []
|
| 52 |
+
listed = {os.path.normpath(os.path.join(root, r)) for r in listed_rel}
|
| 53 |
+
base_depth = os.path.normpath(root).count(os.sep)
|
| 54 |
+
for dp, dn, _ in os.walk(root):
|
| 55 |
+
if os.path.normpath(dp).count(os.sep) - base_depth >= max_depth:
|
| 56 |
+
dn[:] = []
|
| 57 |
+
continue
|
| 58 |
+
if os.path.normpath(dp) in listed:
|
| 59 |
+
dn[:] = []
|
| 60 |
+
continue
|
| 61 |
+
n = sum(1 for _ in sample_folders(dp))
|
| 62 |
+
if n:
|
| 63 |
+
found.append((os.path.relpath(dp, root), n))
|
| 64 |
+
dn[:] = []
|
| 65 |
+
return found
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def row_for(sample_dir, split, set_name, meta, legacy_folder, bf_md5):
|
| 69 |
+
code = legacy_substrate_code(legacy_folder) if legacy_folder else ""
|
| 70 |
+
return {"sample_dir": sample_dir.replace(os.sep, "/"), "split": split, "set": set_name,
|
| 71 |
+
"condition": meta.get("condition", ""), "cell_type": meta.get("cell_type", ""),
|
| 72 |
+
"coating": meta.get("coating", ""), "young_Pa": meta.get("young_Pa", ""),
|
| 73 |
+
"tfm_pixelsize": meta.get("tfm_pixelsize", ""), "ythreshold_Pa": meta.get("ythreshold_Pa", ""),
|
| 74 |
+
"legacy_folder": legacy_folder, "legacy_substrate_code": code,
|
| 75 |
+
"substrate_legacy": legacy_code_to_config_key(code) if meta.get("cell_type") != "LS174T spheroid" else "",
|
| 76 |
+
"substrate_nominal": (nominal_config_key(meta.get("young_Pa"), meta.get("tfm_pixelsize"), meta.get("cell_type"))
|
| 77 |
+
if meta.get("cell_type") != "LS174T spheroid" else "") or "",
|
| 78 |
+
"bf_md5": bf_md5}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def copy_sample(src_dir, dst_dir, expected_md5):
|
| 82 |
+
if os.path.isdir(dst_dir):
|
| 83 |
+
bf = [f for f in os.listdir(dst_dir) if f.endswith("BF_001.tif")]
|
| 84 |
+
if bf and md5(os.path.join(dst_dir, bf[0])) == expected_md5:
|
| 85 |
+
return "exists"
|
| 86 |
+
raise RuntimeError(f"{dst_dir} exists with different content; refusing to overwrite")
|
| 87 |
+
os.makedirs(dst_dir)
|
| 88 |
+
for f in os.listdir(src_dir):
|
| 89 |
+
if os.path.isfile(os.path.join(src_dir, f)):
|
| 90 |
+
shutil.copy2(os.path.join(src_dir, f), os.path.join(dst_dir, f))
|
| 91 |
+
return "copied"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def main():
|
| 95 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 96 |
+
ap.add_argument("--legacy-root", required=True, help="old dataset folder (contains single_cell/ and spheroid/)")
|
| 97 |
+
ap.add_argument("--dataset-root", default=DEFAULT_DATASET_ROOT, help="repository dataset (default: S2F/dataset)")
|
| 98 |
+
ap.add_argument("--write", action="store_true", help="copy missing images and write split manifests")
|
| 99 |
+
ap.add_argument("--out", default=os.path.join(RESULTS_DIR, "dataset_audit"))
|
| 100 |
+
a = ap.parse_args()
|
| 101 |
+
os.makedirs(a.out, exist_ok=True)
|
| 102 |
+
rep = []
|
| 103 |
+
|
| 104 |
+
def say(s=""):
|
| 105 |
+
print(s); rep.append(s)
|
| 106 |
+
|
| 107 |
+
# ---------------- index ----------------
|
| 108 |
+
legacy = {k: index_tree(a.legacy_root, v[0]) for k, v in LEGACY_SETS.items()}
|
| 109 |
+
pub = {k: index_tree(a.dataset_root, v) for k, v in PUBLISHED_SETS.items()}
|
| 110 |
+
say(f"legacy root : {a.legacy_root}\ndataset root: {a.dataset_root}\n")
|
| 111 |
+
with open(os.path.join(a.out, "counts.csv"), "w", newline="") as f:
|
| 112 |
+
w = csv.writer(f); w.writerow(["tree", "set", "path", "n_samples", "n_unique_bf"])
|
| 113 |
+
for k, v in legacy.items():
|
| 114 |
+
w.writerow(["legacy", k, LEGACY_SETS[k][0], len(v), len({s['md5'] for s in v})])
|
| 115 |
+
say(f" legacy {k:20s} n={len(v):4d}")
|
| 116 |
+
for k, v in pub.items():
|
| 117 |
+
w.writerow(["repository", k, PUBLISHED_SETS[k], len(v), len({s['md5'] for s in v})])
|
| 118 |
+
say(f" repository {k:19s} n={len(v):4d}")
|
| 119 |
+
unlisted = discover_unlisted(a.legacy_root, [v[0] for v in LEGACY_SETS.values()])
|
| 120 |
+
if unlisted:
|
| 121 |
+
say("\nFolders with samples in the legacy tree that are NOT listed in experiments/layout.py:")
|
| 122 |
+
for rel, n in unlisted:
|
| 123 |
+
say(f" {rel} ({n} samples)")
|
| 124 |
+
if not legacy["all_tune/train"]:
|
| 125 |
+
say("\nERROR: single_cell/all_tune/train not found under --legacy-root."); open(os.path.join(a.out, "report.txt"), "w").write("\n".join(rep)); return
|
| 126 |
+
|
| 127 |
+
# ---------------- published vs legacy ----------------
|
| 128 |
+
say("\nRepository dataset vs legacy:")
|
| 129 |
+
rows = []
|
| 130 |
+
for p, l in [("singlecell/train", "fibro_split/train"), ("singlecell/test", "fibro_split/test"),
|
| 131 |
+
("spheroid/train", "spheroid/train"), ("spheroid/test", "spheroid/test")]:
|
| 132 |
+
P, Lg = {s["md5"] for s in pub[p]}, {s["md5"] for s in legacy[l]}
|
| 133 |
+
r = {"repository_set": p, "legacy_set": l, "n_repository": len(P), "n_legacy": len(Lg),
|
| 134 |
+
"shared": len(P & Lg), "only_repository": len(P - Lg), "only_legacy": len(Lg - P)}
|
| 135 |
+
rows.append(r); say(f" {p:18s} vs {l:18s}: shared {r['shared']}, only repo {r['only_repository']}, only legacy {r['only_legacy']}")
|
| 136 |
+
with open(os.path.join(a.out, "published_vs_legacy.csv"), "w", newline="") as f:
|
| 137 |
+
w = csv.DictWriter(f, fieldnames=list(rows[0])); w.writeheader(); w.writerows(rows)
|
| 138 |
+
|
| 139 |
+
# ---------------- all_tune composition ----------------
|
| 140 |
+
by_md5_pub = {s["md5"]: (k, s) for k, v in pub.items() for s in v}
|
| 141 |
+
src_keys = [k for k, v in LEGACY_SETS.items() if v[1] in ("published", "source", "external")]
|
| 142 |
+
by_md5_leg = collections.defaultdict(list)
|
| 143 |
+
for k in src_keys:
|
| 144 |
+
for s in legacy[k]:
|
| 145 |
+
by_md5_leg[s["md5"]].append((k, s))
|
| 146 |
+
comp, missing, codes = [], [], collections.Counter()
|
| 147 |
+
for split_key, split in [("all_tune/train", "train"), ("all_tune/test", "val")]:
|
| 148 |
+
for s in legacy[split_key]:
|
| 149 |
+
in_pub = by_md5_pub.get(s["md5"])
|
| 150 |
+
srcs = [k for k, _ in by_md5_leg.get(s["md5"], [])]
|
| 151 |
+
src = next((k for k in srcs if LEGACY_SETS[k][1] != "external"), srcs[0] if srcs else "UNMATCHED")
|
| 152 |
+
code = legacy_substrate_code(s["folder"])
|
| 153 |
+
codes[(split, code)] += 1
|
| 154 |
+
r = {"alltune_split": split, "alltune_folder": s["folder"], "source_legacy_set": src,
|
| 155 |
+
"all_matching_sets": ";".join(srcs), "in_repository_dataset": in_pub[0] if in_pub else "",
|
| 156 |
+
"legacy_substrate_code": code, "bf_md5": s["md5"]}
|
| 157 |
+
comp.append(r)
|
| 158 |
+
if not in_pub:
|
| 159 |
+
missing.append(r)
|
| 160 |
+
for name, data in [("alltune_composition.csv", comp), ("missing_from_published.csv", missing)]:
|
| 161 |
+
with open(os.path.join(a.out, name), "w", newline="") as f:
|
| 162 |
+
w = csv.DictWriter(f, fieldnames=list(comp[0])); w.writeheader(); w.writerows(data)
|
| 163 |
+
with open(os.path.join(a.out, "substrate_codes.csv"), "w", newline="") as f:
|
| 164 |
+
w = csv.writer(f); w.writerow(["alltune_split", "legacy_substrate_code", "n", "config_key_fed_to_model"])
|
| 165 |
+
for (sp, c), n in sorted(codes.items()):
|
| 166 |
+
w.writerow([sp, c, n, legacy_code_to_config_key(c)])
|
| 167 |
+
say("\nall_tune composition (split x source set):")
|
| 168 |
+
cc = collections.Counter((r["alltune_split"], r["source_legacy_set"]) for r in comp)
|
| 169 |
+
for (sp, src), n in sorted(cc.items()):
|
| 170 |
+
say(f" {sp:5s} {src:20s} {n:4d}")
|
| 171 |
+
say(f"\nall_tune images NOT in the repository dataset (Zenodo v1): {len(missing)}")
|
| 172 |
+
for (sp, src), n in sorted(collections.Counter((r['alltune_split'], r['source_legacy_set']) for r in missing).items()):
|
| 173 |
+
say(f" {sp:5s} {src:20s} {n:4d}")
|
| 174 |
+
say("\nSubstrate code the original code fed for all_tune folders (folder_name[:4]):")
|
| 175 |
+
for (sp, c), n in sorted(codes.items()):
|
| 176 |
+
say(f" {sp:5s} '{c}' -> {legacy_code_to_config_key(c):18s} {n:4d}")
|
| 177 |
+
|
| 178 |
+
# ---------------- overlaps / leakage ----------------
|
| 179 |
+
names = list(legacy)
|
| 180 |
+
sets = {k: {s["md5"] for s in legacy[k]} for k in names}
|
| 181 |
+
with open(os.path.join(a.out, "overlap_matrix.csv"), "w", newline="") as f:
|
| 182 |
+
w = csv.writer(f); w.writerow(["set"] + names)
|
| 183 |
+
for x in names:
|
| 184 |
+
w.writerow([x] + [len(sets[x] & sets[y]) for y in names])
|
| 185 |
+
tv = sets["all_tune/train"] | sets["all_tune/test"]
|
| 186 |
+
say("\nLeakage check - independent test sets vs all_tune train+val (must be 0):")
|
| 187 |
+
for k, v in LEGACY_SETS.items():
|
| 188 |
+
if v[1] == "external":
|
| 189 |
+
say(f" {k:20s} {len(sets[k] & tv):3d} (of {len(sets[k])})")
|
| 190 |
+
say("Validation-set content - legacy 'test' folders inside all_tune/test (val):")
|
| 191 |
+
for k in ["fibro_split/test", "1kpa_new/test", "10kpa_new/test"]:
|
| 192 |
+
say(f" {k:20s} {len(sets[k] & sets['all_tune/test']):3d} of {len(sets[k])} in val; {len(sets[k] & sets['all_tune/train']):3d} in train")
|
| 193 |
+
|
| 194 |
+
# ---------------- write ----------------
|
| 195 |
+
if a.write:
|
| 196 |
+
say("\nCopying and writing manifests ...")
|
| 197 |
+
n_copied = collections.Counter()
|
| 198 |
+
alltune_rows = {"train": [], "val": []}
|
| 199 |
+
for r, s in zip(comp, legacy["all_tune/train"] + legacy["all_tune/test"]):
|
| 200 |
+
split, src = r["alltune_split"], r["source_legacy_set"]
|
| 201 |
+
meta = LEGACY_SETS[src][3] if src in LEGACY_SETS else {}
|
| 202 |
+
if r["in_repository_dataset"]:
|
| 203 |
+
pk, ps = by_md5_pub[s["md5"]]
|
| 204 |
+
sample_dir = os.path.join(PUBLISHED_SETS[pk], ps["folder"])
|
| 205 |
+
else:
|
| 206 |
+
clean = LEGACY_SETS[src][2] if src in LEGACY_SETS and LEGACY_SETS[src][2] else "unmatched"
|
| 207 |
+
sample_dir = os.path.join("singlecell_extra", clean, s["folder"])
|
| 208 |
+
n_copied[copy_sample(s["dir"], os.path.join(a.dataset_root, sample_dir), s["md5"])] += 1
|
| 209 |
+
alltune_rows[split].append(row_for(sample_dir, split, src, meta, s["folder"], s["md5"]))
|
| 210 |
+
write_manifest(manifest_path("alltune_train"), alltune_rows["train"])
|
| 211 |
+
write_manifest(manifest_path("alltune_val"), alltune_rows["val"])
|
| 212 |
+
for pk, man, lk in [("singlecell/train", "zenodo_v1_train", "fibro_split/train"),
|
| 213 |
+
("singlecell/test", "zenodo_v1_test", "fibro_split/test"),
|
| 214 |
+
("spheroid/train", "spheroid_train", "spheroid/train"),
|
| 215 |
+
("spheroid/test", "spheroid_val", "spheroid/test")]:
|
| 216 |
+
meta = LEGACY_SETS[lk][3]
|
| 217 |
+
write_manifest(manifest_path(man), [row_for(os.path.join(PUBLISHED_SETS[pk], s["folder"]), man.split("_")[-1],
|
| 218 |
+
lk, meta, s["folder"], s["md5"]) for s in pub[pk]])
|
| 219 |
+
for k, (rel, role, clean, meta) in LEGACY_SETS.items():
|
| 220 |
+
if role != "external" or not legacy[k]:
|
| 221 |
+
continue
|
| 222 |
+
rows_ = []
|
| 223 |
+
for s in legacy[k]:
|
| 224 |
+
sample_dir = os.path.join("singlecell_external", clean, s["folder"])
|
| 225 |
+
n_copied[copy_sample(s["dir"], os.path.join(a.dataset_root, sample_dir), s["md5"])] += 1
|
| 226 |
+
rows_.append(row_for(sample_dir, "test", k, meta, s["folder"], s["md5"]))
|
| 227 |
+
write_manifest(manifest_path(f"test_{clean}"), rows_)
|
| 228 |
+
say(f" sample folders: {dict(n_copied)}")
|
| 229 |
+
say(f" manifests written to experiments/splits/ - commit them to git")
|
| 230 |
+
else:
|
| 231 |
+
say("\n(audit only - rerun with --write to copy the missing images and write experiments/splits/*.csv)")
|
| 232 |
+
open(os.path.join(a.out, "report.txt"), "w").write("\n".join(rep))
|
| 233 |
+
print(f"\nReport: {os.path.join(a.out, 'report.txt')}")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
if __name__ == "__main__":
|
| 237 |
+
main()
|
experiments/run_all.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Run the revision experiments end to end (cross-platform; run from the S2F repository root).
|
| 3 |
+
|
| 4 |
+
python -m experiments.run_all --legacy-root C:/Users/Kaveh/Documents/cellforce/dataset --steps audit
|
| 5 |
+
python -m experiments.run_all --legacy-root ... --steps prepare,eval_released
|
| 6 |
+
python -m experiments.run_all --steps train,eval_runs --variants s2f_full,unet,s2f_no_gan
|
| 7 |
+
python -m experiments.run_all --legacy-root ... --stage1 C:/.../outputs/checkpoints/last/last_checkpoint_final.pth --steps eval_released
|
| 8 |
+
|
| 9 |
+
steps:
|
| 10 |
+
audit dataset audit only (no copying) -> experiments/results/dataset_audit/
|
| 11 |
+
prepare copy missing images into dataset/ + write experiments/splits/*.csv
|
| 12 |
+
eval_released released checkpoints (+ substrate sweep, mean-map baseline, optional stage-1)
|
| 13 |
+
-> experiments/results/eval/
|
| 14 |
+
train train the listed variants (default: configs.PRIORITY), skipping finished runs.
|
| 15 |
+
One job per GPU in parallel (--gpus 0,1); the next variant starts as soon as a GPU is free.
|
| 16 |
+
Logs: experiments/runs/<variant>_seed<seed>/train.log
|
| 17 |
+
eval_runs evaluate every experiments/runs/*/best_checkpoint.pth
|
| 18 |
+
"""
|
| 19 |
+
import argparse
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import subprocess
|
| 23 |
+
import sys
|
| 24 |
+
import time
|
| 25 |
+
|
| 26 |
+
from experiments.configs import PRIORITY, get_config
|
| 27 |
+
from experiments.layout import RUNS_DIR, S2F_ROOT
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def sh(args):
|
| 31 |
+
print("\n$ " + " ".join(args), flush=True)
|
| 32 |
+
r = subprocess.run([sys.executable, "-m"] + args, cwd=S2F_ROOT)
|
| 33 |
+
if r.returncode:
|
| 34 |
+
sys.exit(r.returncode)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def finished(variant, seed):
|
| 38 |
+
h = os.path.join(RUNS_DIR, f"{variant}_seed{seed}", "history.json")
|
| 39 |
+
if not os.path.exists(h):
|
| 40 |
+
return False
|
| 41 |
+
return len(json.load(open(h))) >= get_config(variant)["epochs"]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def gpu_queue(queue, gpus, num_workers, extra=()):
|
| 45 |
+
"""Run training jobs in parallel, one per GPU (each job sees only its GPU as cuda:0)."""
|
| 46 |
+
running, failed = {}, []
|
| 47 |
+
queue = list(queue)
|
| 48 |
+
while queue or running:
|
| 49 |
+
for gpu in gpus:
|
| 50 |
+
if gpu not in running and queue:
|
| 51 |
+
v, seed = queue.pop(0)
|
| 52 |
+
run_dir = os.path.join(RUNS_DIR, f"{v}_seed{seed}")
|
| 53 |
+
os.makedirs(run_dir, exist_ok=True)
|
| 54 |
+
log = open(os.path.join(run_dir, "train.log"), "a")
|
| 55 |
+
env = dict(os.environ, CUDA_VISIBLE_DEVICES=gpu, PYTHONUNBUFFERED="1")
|
| 56 |
+
cmd = [sys.executable, "-m", "experiments.train", "--variant", v, "--seed", seed, "--resume",
|
| 57 |
+
"--num-workers", num_workers] + list(extra)
|
| 58 |
+
running[gpu] = (subprocess.Popen(cmd, cwd=S2F_ROOT, env=env, stdout=log, stderr=subprocess.STDOUT), v, seed, log)
|
| 59 |
+
print(f"[{time.strftime('%H:%M')}] GPU {gpu}: started {v} seed {seed} (log {run_dir}/train.log)", flush=True)
|
| 60 |
+
time.sleep(10 if extra else 30)
|
| 61 |
+
for gpu, (proc, v, seed, log) in list(running.items()):
|
| 62 |
+
if proc.poll() is not None:
|
| 63 |
+
log.close(); del running[gpu]
|
| 64 |
+
status = "finished" if proc.returncode == 0 else f"FAILED (exit {proc.returncode}, see train.log)"
|
| 65 |
+
if proc.returncode:
|
| 66 |
+
failed.append((v, seed))
|
| 67 |
+
print(f"[{time.strftime('%H:%M')}] GPU {gpu}: {v} seed {seed} {status}", flush=True)
|
| 68 |
+
if failed:
|
| 69 |
+
print(f"failed jobs: {failed}")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def main():
|
| 73 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 74 |
+
ap.add_argument("--legacy-root", default=None)
|
| 75 |
+
ap.add_argument("--stage1", default=None, help="path to last_checkpoint_final.pth")
|
| 76 |
+
ap.add_argument("--steps", default="audit")
|
| 77 |
+
ap.add_argument("--variants", default=",".join(PRIORITY))
|
| 78 |
+
ap.add_argument("--seeds", default="0")
|
| 79 |
+
ap.add_argument("--gpus", default="0,1", help="GPU ids; one training job per GPU at a time")
|
| 80 |
+
ap.add_argument("--num-workers", default="2", help="DataLoader workers per training job")
|
| 81 |
+
ap.add_argument("--extra-train-args", default="", help="passed to experiments.train, e.g. '--epochs 1 --max-batches 2 --allow-cpu' for a smoke test")
|
| 82 |
+
a = ap.parse_args()
|
| 83 |
+
steps = a.steps.split(",")
|
| 84 |
+
if ("audit" in steps or "prepare" in steps) and not a.legacy_root:
|
| 85 |
+
sys.exit("--legacy-root is required for audit/prepare")
|
| 86 |
+
if "audit" in steps:
|
| 87 |
+
sh(["experiments.prepare_dataset", "--legacy-root", a.legacy_root])
|
| 88 |
+
if "prepare" in steps:
|
| 89 |
+
sh(["experiments.prepare_dataset", "--legacy-root", a.legacy_root, "--write"])
|
| 90 |
+
if "eval_released" in steps:
|
| 91 |
+
cmd = ["experiments.evaluate", "--released", "--sweep", "--meanmap"]
|
| 92 |
+
if a.stage1:
|
| 93 |
+
cmd += ["--stage1", a.stage1]
|
| 94 |
+
sh(cmd)
|
| 95 |
+
if "train" in steps:
|
| 96 |
+
queue = [(v, s) for v in a.variants.split(",") for s in a.seeds.split(",") if not finished(v, int(s))]
|
| 97 |
+
print(f"{len(queue)} training jobs on GPUs {a.gpus}: {queue}")
|
| 98 |
+
gpu_queue(queue, a.gpus.split(","), a.num_workers, a.extra_train_args.split())
|
| 99 |
+
if "eval_runs" in steps:
|
| 100 |
+
sh(["experiments.evaluate", "--runs"])
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
if __name__ == "__main__":
|
| 104 |
+
main()
|
experiments/splits/.gitkeep
ADDED
|
File without changes
|
experiments/train.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Train one baseline / ablation variant with the shared protocol (experiments/configs.py).
|
| 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 |
+
"""
|
| 18 |
+
import argparse
|
| 19 |
+
import json
|
| 20 |
+
import math
|
| 21 |
+
import os
|
| 22 |
+
import random
|
| 23 |
+
import time
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import torch
|
| 27 |
+
import torch.nn as nn
|
| 28 |
+
import torch.nn.functional as F
|
| 29 |
+
|
| 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
|
| 37 |
+
|
| 38 |
+
CONFIG_PATH = os.path.join(S2F_ROOT, "config", "substrate_settings.json")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def seed_everything(seed):
|
| 42 |
+
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed); torch.cuda.manual_seed_all(seed)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def cosine_with_warmup(optimizer, warmup_steps, total_steps):
|
| 46 |
+
"""Same schedule as diffusers.optimization.get_cosine_schedule_with_warmup (num_cycles=0.5)."""
|
| 47 |
+
def f(step):
|
| 48 |
+
if step < warmup_steps:
|
| 49 |
+
return step / max(1, warmup_steps)
|
| 50 |
+
progress = (step - warmup_steps) / max(1, total_steps - warmup_steps)
|
| 51 |
+
return max(0.0, 0.5 * (1.0 + math.cos(math.pi * progress)))
|
| 52 |
+
return torch.optim.lr_scheduler.LambdaLR(optimizer, f)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def add_settings(x, batch, cfg, norm, device):
|
| 56 |
+
if cfg["substrate_mode"] == "none":
|
| 57 |
+
return x
|
| 58 |
+
meta = batch[4]
|
| 59 |
+
return torch.cat([x, create_settings_channels(meta, norm, device, x.shape, config_path=CONFIG_PATH)], dim=1)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def save_vis(G, val_loader, cfg, norm, device, path):
|
| 63 |
+
import matplotlib
|
| 64 |
+
matplotlib.use("Agg")
|
| 65 |
+
import matplotlib.pyplot as plt
|
| 66 |
+
batch = next(iter(val_loader))
|
| 67 |
+
x = add_settings(batch[0].to(device), batch, cfg, norm, device)
|
| 68 |
+
with torch.no_grad():
|
| 69 |
+
p = (G(x) + 1) / 2
|
| 70 |
+
n = min(2, x.size(0))
|
| 71 |
+
fig, ax = plt.subplots(3, n, figsize=(4 * n, 12), squeeze=False)
|
| 72 |
+
for i in range(n):
|
| 73 |
+
ax[0, i].imshow(x[i, 0].cpu(), cmap="gray"); ax[1, i].imshow(p[i, 0].cpu(), cmap="jet", vmin=0, vmax=1)
|
| 74 |
+
ax[2, i].imshow(batch[1][i, 0], cmap="jet", vmin=0, vmax=1)
|
| 75 |
+
for r in range(3):
|
| 76 |
+
ax[r, i].axis("off")
|
| 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):
|
| 137 |
+
if max_batches and bi >= max_batches:
|
| 138 |
+
break
|
| 139 |
+
x = add_settings(batch[0].to(device), batch, cfg, norm, device)
|
| 140 |
+
y = batch[1].to(device)
|
| 141 |
+
fake = G(x); p = (fake + 1) / 2
|
| 142 |
+
l_rec = rec_loss(p, y)
|
| 143 |
+
l_force = F.mse_loss(p.flatten(1).mean(1), y.flatten(1).mean(1))
|
| 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:
|
| 151 |
+
pr = D(torch.cat([x, y * 2 - 1], 1)); pf = D(torch.cat([x, fake.detach()], 1))
|
| 152 |
+
l_d = 0.5 * (bce(pr, torch.ones_like(pr)) + bce(pf, torch.zeros_like(pf)))
|
| 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 |
+
|
| 190 |
+
def main():
|
| 191 |
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 192 |
+
ap.add_argument("--variant", required=True)
|
| 193 |
+
ap.add_argument("--seed", type=int, default=None)
|
| 194 |
+
ap.add_argument("--epochs", type=int, default=None)
|
| 195 |
+
ap.add_argument("--g-lr", type=float, default=None); ap.add_argument("--d-lr", type=float, default=None)
|
| 196 |
+
ap.add_argument("--batch-size", type=int, default=None)
|
| 197 |
+
ap.add_argument("--dataset-root", default=DEFAULT_DATASET_ROOT)
|
| 198 |
+
ap.add_argument("--run-dir", default=None)
|
| 199 |
+
ap.add_argument("--device", default=None)
|
| 200 |
+
ap.add_argument("--resume", action="store_true")
|
| 201 |
+
ap.add_argument("--max-batches", type=int, default=None, help="limit batches per epoch (smoke test only)")
|
| 202 |
+
ap.add_argument("--num-workers", type=int, default=2, help="DataLoader worker processes for augmentation")
|
| 203 |
+
ap.add_argument("--allow-cpu", action="store_true", help="allow training without CUDA (smoke tests)")
|
| 204 |
+
a = ap.parse_args()
|
| 205 |
+
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)
|
| 206 |
+
run_dir = a.run_dir or os.path.join(RUNS_DIR, f"{a.variant}_seed{cfg['seed']}")
|
| 207 |
+
train(cfg, run_dir, a.dataset_root, a.device, a.resume, a.max_batches, a.num_workers, a.allow_cpu)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
if __name__ == "__main__":
|
| 211 |
+
main()
|