Add SACFlow source code
Browse files- sacflow/__init__.py +2 -0
- sacflow/data/__init__.py +1 -0
- sacflow/data/loader.py +54 -0
- sacflow/data/nifti_dataset.py +162 -0
- sacflow/engine/__init__.py +1 -0
- sacflow/engine/train_loop.py +398 -0
- sacflow/methods/__init__.py +1 -0
- sacflow/methods/sacflow_step.py +202 -0
- sacflow/methods/source_memory.py +72 -0
- sacflow/methods/task_space.py +39 -0
- sacflow/models/__init__.py +1 -0
- sacflow/models/unet3d.py +149 -0
- sacflow/models/velocity_field.py +121 -0
- sacflow/utils/__init__.py +1 -0
- sacflow/utils/config.py +54 -0
- sacflow/utils/distributed.py +70 -0
- sacflow/utils/eta.py +37 -0
- sacflow/utils/metrics.py +114 -0
- sacflow/utils/misc.py +40 -0
- sacflow/utils/wandb_utils.py +124 -0
sacflow/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SACFlow-FM research package."""
|
| 2 |
+
__version__ = "0.1.0"
|
sacflow/data/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
sacflow/data/loader.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from torch.utils.data import DataLoader, DistributedSampler, Sampler
|
| 3 |
+
from .nifti_dataset import NiftiSegDataset
|
| 4 |
+
from sacflow.utils.distributed import get_world_size, get_rank
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class DistributedEvalSamplerNoPad(Sampler):
|
| 8 |
+
"""Shard evaluation data across ranks without padding/duplication.
|
| 9 |
+
|
| 10 |
+
PyTorch's DistributedSampler pads samples so every rank has equal length.
|
| 11 |
+
That is useful for training but biases validation metrics because some cases
|
| 12 |
+
are duplicated. This sampler uses rank::world_size indices exactly once.
|
| 13 |
+
"""
|
| 14 |
+
def __init__(self, dataset):
|
| 15 |
+
self.dataset = dataset
|
| 16 |
+
self.rank = get_rank()
|
| 17 |
+
self.world_size = get_world_size()
|
| 18 |
+
self.indices = list(range(self.rank, len(dataset), self.world_size))
|
| 19 |
+
|
| 20 |
+
def __iter__(self):
|
| 21 |
+
return iter(self.indices)
|
| 22 |
+
|
| 23 |
+
def __len__(self):
|
| 24 |
+
return len(self.indices)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def build_loader(cfg, split: str, training: bool, require_label: bool = False, distributed: bool | None = None):
|
| 28 |
+
"""Build a NIfTI segmentation loader.
|
| 29 |
+
|
| 30 |
+
Important DDP behavior:
|
| 31 |
+
- Training loaders use DistributedSampler when world_size > 1.
|
| 32 |
+
- Evaluation/validation loaders default to *no* DistributedSampler. This is deliberate:
|
| 33 |
+
training-time validation is run only on rank 0, and standalone eval usually uses one rank.
|
| 34 |
+
Using a DistributedSampler for validation without metric all-gather biases metrics to a
|
| 35 |
+
rank-local subset.
|
| 36 |
+
"""
|
| 37 |
+
data_cfg = cfg["data"]
|
| 38 |
+
ds = NiftiSegDataset(data_cfg["manifest"], split=split, cfg=data_cfg, training=training, require_label=require_label)
|
| 39 |
+
if distributed is None:
|
| 40 |
+
distributed = bool(training and get_world_size() > 1)
|
| 41 |
+
if distributed:
|
| 42 |
+
sampler = DistributedSampler(ds, shuffle=True) if training else DistributedEvalSamplerNoPad(ds)
|
| 43 |
+
else:
|
| 44 |
+
sampler = None
|
| 45 |
+
loader = DataLoader(
|
| 46 |
+
ds,
|
| 47 |
+
batch_size=data_cfg.get("batch_size" if training else "val_batch_size", 1),
|
| 48 |
+
shuffle=(training and sampler is None),
|
| 49 |
+
sampler=sampler,
|
| 50 |
+
num_workers=cfg.get("num_workers", 4),
|
| 51 |
+
pin_memory=cfg.get("pin_memory", True),
|
| 52 |
+
persistent_workers=cfg.get("num_workers", 4) > 0,
|
| 53 |
+
)
|
| 54 |
+
return loader
|
sacflow/data/nifti_dataset.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Dict, List, Optional, Tuple
|
| 4 |
+
import json
|
| 5 |
+
import random
|
| 6 |
+
import numpy as np
|
| 7 |
+
import nibabel as nib
|
| 8 |
+
import torch
|
| 9 |
+
from torch.utils.data import Dataset
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_nifti(path: str | Path):
|
| 13 |
+
img = nib.load(str(path))
|
| 14 |
+
data = img.get_fdata(dtype=np.float32)
|
| 15 |
+
return data, img.affine, img.header
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def normalize_intensity(x: np.ndarray, mode: str = "zscore_nonzero", clip=None) -> np.ndarray:
|
| 19 |
+
x = x.astype(np.float32)
|
| 20 |
+
if clip is not None:
|
| 21 |
+
lo, hi = clip
|
| 22 |
+
x = np.clip(x, lo, hi)
|
| 23 |
+
if mode == "zscore_nonzero":
|
| 24 |
+
mask = np.abs(x) > 1e-6
|
| 25 |
+
if mask.sum() > 10:
|
| 26 |
+
mu, sd = x[mask].mean(), x[mask].std()
|
| 27 |
+
else:
|
| 28 |
+
mu, sd = x.mean(), x.std()
|
| 29 |
+
x = (x - mu) / (sd + 1e-6)
|
| 30 |
+
elif mode == "zscore":
|
| 31 |
+
x = (x - x.mean()) / (x.std() + 1e-6)
|
| 32 |
+
elif mode == "minmax":
|
| 33 |
+
x = (x - x.min()) / (x.max() - x.min() + 1e-6)
|
| 34 |
+
elif mode in ("none", None):
|
| 35 |
+
pass
|
| 36 |
+
else:
|
| 37 |
+
raise ValueError(f"Unknown intensity mode: {mode}")
|
| 38 |
+
return x.astype(np.float32)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _pad_to_shape(arr: np.ndarray, shape: Tuple[int, int, int], value=0):
|
| 42 |
+
pads = []
|
| 43 |
+
for dim, target in zip(arr.shape[-3:], shape):
|
| 44 |
+
total = max(0, target - dim)
|
| 45 |
+
before = total // 2
|
| 46 |
+
after = total - before
|
| 47 |
+
pads.append((before, after))
|
| 48 |
+
if arr.ndim == 4:
|
| 49 |
+
pad_width = [(0, 0)] + pads
|
| 50 |
+
else:
|
| 51 |
+
pad_width = pads
|
| 52 |
+
return np.pad(arr, pad_width, mode="constant", constant_values=value)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def random_crop_pair(img: np.ndarray, lab: Optional[np.ndarray], patch_size: Tuple[int, int, int], foreground_prob: float = 0.5):
|
| 56 |
+
# img: [C,H,W,D], lab: [H,W,D]
|
| 57 |
+
img = _pad_to_shape(img, patch_size, 0)
|
| 58 |
+
if lab is not None:
|
| 59 |
+
lab = _pad_to_shape(lab, patch_size, 0)
|
| 60 |
+
H, W, D = img.shape[-3:]
|
| 61 |
+
ph, pw, pd = patch_size
|
| 62 |
+
if lab is not None and random.random() < foreground_prob and (lab > 0).sum() > 0:
|
| 63 |
+
coords = np.argwhere(lab > 0)
|
| 64 |
+
center = coords[random.randrange(len(coords))]
|
| 65 |
+
starts = []
|
| 66 |
+
for c, dim, p in zip(center, (H, W, D), patch_size):
|
| 67 |
+
s = int(c) - p // 2
|
| 68 |
+
s = max(0, min(s, dim - p))
|
| 69 |
+
starts.append(s)
|
| 70 |
+
else:
|
| 71 |
+
starts = [random.randint(0, max(0, dim - p)) for dim, p in zip((H, W, D), patch_size)]
|
| 72 |
+
sh, sw, sd = starts
|
| 73 |
+
img_c = img[:, sh:sh+ph, sw:sw+pw, sd:sd+pd]
|
| 74 |
+
lab_c = None if lab is None else lab[sh:sh+ph, sw:sw+pw, sd:sd+pd]
|
| 75 |
+
return img_c, lab_c
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def center_crop_pair(img: np.ndarray, lab: Optional[np.ndarray], patch_size: Tuple[int, int, int]):
|
| 79 |
+
img = _pad_to_shape(img, patch_size, 0)
|
| 80 |
+
if lab is not None:
|
| 81 |
+
lab = _pad_to_shape(lab, patch_size, 0)
|
| 82 |
+
H, W, D = img.shape[-3:]
|
| 83 |
+
ph, pw, pd = patch_size
|
| 84 |
+
sh, sw, sd = [(dim - p)//2 for dim, p in zip((H,W,D), patch_size)]
|
| 85 |
+
img_c = img[:, sh:sh+ph, sw:sw+pw, sd:sd+pd]
|
| 86 |
+
lab_c = None if lab is None else lab[sh:sh+ph, sw:sw+pw, sd:sd+pd]
|
| 87 |
+
return img_c, lab_c
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def augment(img: np.ndarray, lab: Optional[np.ndarray], cfg: Dict):
|
| 91 |
+
if cfg.get("random_flip", False):
|
| 92 |
+
for ax in range(3):
|
| 93 |
+
if random.random() < 0.5:
|
| 94 |
+
img = np.flip(img, axis=ax+1).copy()
|
| 95 |
+
if lab is not None:
|
| 96 |
+
lab = np.flip(lab, axis=ax).copy()
|
| 97 |
+
shift = float(cfg.get("random_intensity_shift", 0.0) or 0.0)
|
| 98 |
+
scale = float(cfg.get("random_intensity_scale", 0.0) or 0.0)
|
| 99 |
+
if shift > 0:
|
| 100 |
+
img = img + np.random.uniform(-shift, shift)
|
| 101 |
+
if scale > 0:
|
| 102 |
+
img = img * np.random.uniform(1-scale, 1+scale)
|
| 103 |
+
return img, lab
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class NiftiSegDataset(Dataset):
|
| 107 |
+
def __init__(self, manifest: str | Path, split: str, cfg: Dict, training: bool = True, require_label: bool = False):
|
| 108 |
+
self.manifest_path = Path(manifest)
|
| 109 |
+
with open(self.manifest_path, "r") as f:
|
| 110 |
+
man = json.load(f)
|
| 111 |
+
if split not in man:
|
| 112 |
+
raise KeyError(f"Split {split} not found in {manifest}")
|
| 113 |
+
self.items = man[split]
|
| 114 |
+
self.split = split
|
| 115 |
+
self.cfg = cfg
|
| 116 |
+
self.training = training
|
| 117 |
+
self.require_label = require_label
|
| 118 |
+
self.patch_size = tuple(cfg.get("patch_size", [96,96,96]))
|
| 119 |
+
self.intensity_cfg = cfg.get("intensity", {})
|
| 120 |
+
self.aug_cfg = cfg.get("augmentation", {})
|
| 121 |
+
|
| 122 |
+
def __len__(self):
|
| 123 |
+
return len(self.items)
|
| 124 |
+
|
| 125 |
+
def __getitem__(self, idx):
|
| 126 |
+
item = self.items[idx]
|
| 127 |
+
img, affine, header = load_nifti(item["image"])
|
| 128 |
+
if img.ndim == 3:
|
| 129 |
+
img = img[None]
|
| 130 |
+
elif img.ndim == 4:
|
| 131 |
+
# assume channels last if last dim small
|
| 132 |
+
if img.shape[-1] <= 8:
|
| 133 |
+
img = np.moveaxis(img, -1, 0)
|
| 134 |
+
else:
|
| 135 |
+
img = img[None, ..., 0]
|
| 136 |
+
img = normalize_intensity(img, self.intensity_cfg.get("normalize", "zscore_nonzero"), self.intensity_cfg.get("clip"))
|
| 137 |
+
lab = None
|
| 138 |
+
if item.get("label"):
|
| 139 |
+
lab, _, _ = load_nifti(item["label"])
|
| 140 |
+
lab = lab.astype(np.int64)
|
| 141 |
+
elif self.require_label:
|
| 142 |
+
raise ValueError(f"Item {idx} has no label in split {self.split}")
|
| 143 |
+
if self.training:
|
| 144 |
+
img, lab = random_crop_pair(img, lab, self.patch_size)
|
| 145 |
+
img, lab = augment(img, lab, self.aug_cfg)
|
| 146 |
+
else:
|
| 147 |
+
# keep full volume for sliding window eval; no crop
|
| 148 |
+
pass
|
| 149 |
+
batch = {
|
| 150 |
+
"image": torch.from_numpy(img.copy()).float(),
|
| 151 |
+
"case_id": item.get("id", Path(item["image"]).stem),
|
| 152 |
+
"image_path": item["image"],
|
| 153 |
+
}
|
| 154 |
+
if lab is not None:
|
| 155 |
+
batch["label"] = torch.from_numpy(lab.copy()).long()
|
| 156 |
+
batch["spacing"] = torch.tensor(header.get_zooms()[:3] if header is not None else (1,1,1), dtype=torch.float32)
|
| 157 |
+
return batch
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def load_manifest(path: str | Path):
|
| 161 |
+
with open(path, "r") as f:
|
| 162 |
+
return json.load(f)
|
sacflow/engine/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
sacflow/engine/train_loop.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
import time
|
| 4 |
+
import copy
|
| 5 |
+
import re
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn.functional as F
|
| 8 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 9 |
+
from tqdm import tqdm
|
| 10 |
+
from monai.inferers import sliding_window_inference
|
| 11 |
+
from sacflow.data.loader import build_loader
|
| 12 |
+
from sacflow.models.unet3d import build_model, freeze_except_adapters
|
| 13 |
+
from sacflow.models.velocity_field import VelocityField3D
|
| 14 |
+
from sacflow.methods.sacflow_step import sacflow_forward_step, ce_loss_masked, dice_loss_masked
|
| 15 |
+
from sacflow.methods.source_memory import load_source_memory, class_moments
|
| 16 |
+
from sacflow.utils.metrics import torch_soft_dice_loss, entropy_loss, confidence_and_margin, dice_per_class, hd95_per_class
|
| 17 |
+
from sacflow.utils.misc import ensure_dir, count_trainable, move_to_device, unwrap_model
|
| 18 |
+
from sacflow.utils.distributed import is_main_process, get_world_size, get_rank, reduce_mean, barrier, is_dist_avail_and_initialized
|
| 19 |
+
import torch.distributed as dist
|
| 20 |
+
from sacflow.utils.wandb_utils import wandb_log
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def build_optimizer(params, cfg):
|
| 24 |
+
ocfg = cfg["optim"]
|
| 25 |
+
params = [p for p in params if p.requires_grad]
|
| 26 |
+
if ocfg.get("optimizer", "adamw").lower() == "sgd":
|
| 27 |
+
return torch.optim.SGD(params, lr=float(ocfg["lr"]), momentum=0.9, weight_decay=float(ocfg.get("weight_decay", 0)))
|
| 28 |
+
return torch.optim.AdamW(params, lr=float(ocfg["lr"]), weight_decay=float(ocfg.get("weight_decay", 0)), betas=tuple(ocfg.get("betas", [0.9, 0.999])))
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def update_ema(teacher, student, decay):
|
| 32 |
+
with torch.no_grad():
|
| 33 |
+
for pt, ps in zip(teacher.parameters(), student.parameters()):
|
| 34 |
+
pt.data.mul_(decay).add_(ps.data, alpha=1-decay)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def load_checkpoint_into(model, path, strict=False):
|
| 38 |
+
ckpt = torch.load(path, map_location="cpu")
|
| 39 |
+
state = ckpt.get("model", ckpt)
|
| 40 |
+
missing, unexpected = model.load_state_dict(state, strict=strict)
|
| 41 |
+
return missing, unexpected
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def save_checkpoint(path, model, optimizer, epoch, step, best_metric=None, velocity_field=None, cfg=None, teacher=None, include_optimizer=True):
|
| 45 |
+
"""Save a checkpoint on rank 0 only.
|
| 46 |
+
|
| 47 |
+
Disk policy:
|
| 48 |
+
- best.pt is intended for evaluation/inference and is saved without optimizer by default.
|
| 49 |
+
- last.pt is intended for resume and includes optimizer.
|
| 50 |
+
This avoids filling the disk with epoch_N.pt checkpoints.
|
| 51 |
+
"""
|
| 52 |
+
if not is_main_process():
|
| 53 |
+
return
|
| 54 |
+
path = Path(path)
|
| 55 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 56 |
+
obj = {
|
| 57 |
+
"model": unwrap_model(model).state_dict(),
|
| 58 |
+
"epoch": epoch,
|
| 59 |
+
"step": step,
|
| 60 |
+
"best_metric": best_metric,
|
| 61 |
+
"cfg": cfg,
|
| 62 |
+
}
|
| 63 |
+
if include_optimizer and optimizer is not None:
|
| 64 |
+
obj["optimizer"] = optimizer.state_dict()
|
| 65 |
+
if velocity_field is not None:
|
| 66 |
+
obj["velocity_field"] = unwrap_model(velocity_field).state_dict()
|
| 67 |
+
if teacher is not None:
|
| 68 |
+
obj["teacher"] = unwrap_model(teacher).state_dict()
|
| 69 |
+
torch.save(obj, path)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _epoch_number(path: Path) -> int:
|
| 73 |
+
m = re.search(r"epoch_(\d+)\.pt$", path.name)
|
| 74 |
+
return int(m.group(1)) if m else -1
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def resolve_resume_checkpoint(ckpt_dir: Path, resume_value):
|
| 78 |
+
"""Return a usable resume checkpoint path.
|
| 79 |
+
|
| 80 |
+
resume_value can be:
|
| 81 |
+
- None/False: do not resume
|
| 82 |
+
- "auto"/True: prefer last.pt, then newest epoch_*.pt, then best.pt
|
| 83 |
+
- explicit checkpoint path
|
| 84 |
+
Corrupted/incomplete checkpoints are skipped.
|
| 85 |
+
"""
|
| 86 |
+
if not resume_value:
|
| 87 |
+
return None
|
| 88 |
+
if str(resume_value).lower() not in ("auto", "true", "1", "yes"):
|
| 89 |
+
return Path(resume_value)
|
| 90 |
+
candidates = []
|
| 91 |
+
last = ckpt_dir / "last.pt"
|
| 92 |
+
if last.exists():
|
| 93 |
+
candidates.append(last)
|
| 94 |
+
candidates.extend(sorted(ckpt_dir.glob("epoch_*.pt"), key=_epoch_number, reverse=True))
|
| 95 |
+
best = ckpt_dir / "best.pt"
|
| 96 |
+
if best.exists():
|
| 97 |
+
candidates.append(best)
|
| 98 |
+
for c in candidates:
|
| 99 |
+
try:
|
| 100 |
+
torch.load(c, map_location="cpu")
|
| 101 |
+
return c
|
| 102 |
+
except Exception as e:
|
| 103 |
+
if is_main_process():
|
| 104 |
+
print(f"Skipping unusable checkpoint {c}: {e}")
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def load_training_checkpoint(path, model, optimizer=None, velocity_field=None, teacher=None):
|
| 109 |
+
ckpt = torch.load(path, map_location="cpu")
|
| 110 |
+
missing, unexpected = unwrap_model(model).load_state_dict(ckpt.get("model", ckpt), strict=False)
|
| 111 |
+
if is_main_process():
|
| 112 |
+
print(f"Loaded resume model from {path} missing={len(missing)} unexpected={len(unexpected)}")
|
| 113 |
+
if velocity_field is not None and "velocity_field" in ckpt:
|
| 114 |
+
unwrap_model(velocity_field).load_state_dict(ckpt["velocity_field"], strict=False)
|
| 115 |
+
if teacher is not None and "teacher" in ckpt:
|
| 116 |
+
unwrap_model(teacher).load_state_dict(ckpt["teacher"], strict=False)
|
| 117 |
+
elif teacher is not None:
|
| 118 |
+
unwrap_model(teacher).load_state_dict(unwrap_model(model).state_dict(), strict=False)
|
| 119 |
+
if optimizer is not None and ckpt.get("optimizer") is not None:
|
| 120 |
+
optimizer.load_state_dict(ckpt["optimizer"])
|
| 121 |
+
start_epoch = int(ckpt.get("epoch", 0))
|
| 122 |
+
global_step = int(ckpt.get("step", 0))
|
| 123 |
+
best = float(ckpt.get("best_metric", -1e9) if ckpt.get("best_metric", None) is not None else -1e9)
|
| 124 |
+
return start_epoch, global_step, best
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def supervised_step(model, batch, cfg):
|
| 128 |
+
x = batch["image"]
|
| 129 |
+
y = batch["label"]
|
| 130 |
+
logits = model(x)
|
| 131 |
+
ce = F.cross_entropy(logits, y.long())
|
| 132 |
+
dice = torch_soft_dice_loss(logits, y, cfg["data"]["num_classes"])
|
| 133 |
+
loss = cfg["train"].get("loss", {}).get("ce", 1.0)*ce + cfg["train"].get("loss", {}).get("dice", 1.0)*dice
|
| 134 |
+
return loss, {"loss_total": loss.detach(), "loss_ce": ce.detach(), "loss_dice": dice.detach()}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def proto_align_step(model, teacher, batch, memory, cfg):
|
| 139 |
+
x = batch["image"]
|
| 140 |
+
logits, feats = model(x, return_features=True)
|
| 141 |
+
feat = feats["prelogit"]
|
| 142 |
+
with torch.no_grad():
|
| 143 |
+
tlogits = teacher(x)
|
| 144 |
+
tprobs = torch.softmax(tlogits, dim=1)
|
| 145 |
+
conf, margin, pseudo = confidence_and_margin(tprobs)
|
| 146 |
+
mask = conf > float(cfg["train"].get("pseudo_conf_threshold", 0.75))
|
| 147 |
+
probs_f = tprobs
|
| 148 |
+
if probs_f.shape[-3:] != feat.shape[-3:]:
|
| 149 |
+
probs_f = F.interpolate(probs_f, size=feat.shape[-3:], mode="trilinear", align_corners=False)
|
| 150 |
+
ce = ce_loss_masked(logits, pseudo, mask)
|
| 151 |
+
dice = dice_loss_masked(logits, pseudo, mask, cfg["data"]["num_classes"])
|
| 152 |
+
proto_loss = torch.tensor(0.0, device=x.device)
|
| 153 |
+
if memory is not None and "feature_mu" in memory:
|
| 154 |
+
mu = memory["feature_mu"].to(feat.device, feat.dtype) # [C,d]
|
| 155 |
+
# expected source prototype at each voxel based on teacher probabilities
|
| 156 |
+
proto = torch.einsum("bchwz,cf->bfhwz", probs_f.detach(), mu)
|
| 157 |
+
proto_loss = ((feat - proto).pow(2) * probs_f.max(1, keepdim=True).values.detach()).mean()
|
| 158 |
+
ent = entropy_loss(logits)
|
| 159 |
+
loss_cfg = cfg["train"].get("loss", {})
|
| 160 |
+
loss = float(loss_cfg.get("ce", 1.0))*ce + float(loss_cfg.get("dice", 1.0))*dice + float(loss_cfg.get("prototype", 0.1))*proto_loss + float(loss_cfg.get("entropy", 0.01))*ent
|
| 161 |
+
return loss, {"loss_total": loss.detach(), "loss_pseudo_ce": ce.detach(), "loss_pseudo_dice": dice.detach(), "loss_proto_align": proto_loss.detach(), "loss_entropy": ent.detach(), "pseudo_conf_mean": conf.mean().detach(), "pseudo_accept_rate": mask.float().mean().detach()}
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def pseudo_step(model, teacher, batch, cfg):
|
| 165 |
+
x = batch["image"]
|
| 166 |
+
with torch.no_grad():
|
| 167 |
+
tlogits = teacher(x)
|
| 168 |
+
tprobs = torch.softmax(tlogits, dim=1)
|
| 169 |
+
conf, margin, pseudo = confidence_and_margin(tprobs)
|
| 170 |
+
mask = conf > float(cfg["train"].get("pseudo_conf_threshold", 0.75))
|
| 171 |
+
logits = model(x)
|
| 172 |
+
ce = ce_loss_masked(logits, pseudo, mask)
|
| 173 |
+
dice = dice_loss_masked(logits, pseudo, mask, cfg["data"]["num_classes"])
|
| 174 |
+
ent = entropy_loss(logits)
|
| 175 |
+
loss_cfg = cfg["train"].get("loss", {})
|
| 176 |
+
loss = float(loss_cfg.get("ce", 1.0))*ce + float(loss_cfg.get("dice", 1.0))*dice + float(loss_cfg.get("entropy", 0.01))*ent
|
| 177 |
+
return loss, {"loss_total": loss.detach(), "loss_pseudo_ce": ce.detach(), "loss_pseudo_dice": dice.detach(), "loss_entropy": ent.detach(), "pseudo_conf_mean": conf.mean().detach(), "pseudo_accept_rate": mask.float().mean().detach()}
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
@torch.no_grad()
|
| 181 |
+
def evaluate(model, loader, cfg, device, max_batches=None):
|
| 182 |
+
"""Evaluate segmentation metrics.
|
| 183 |
+
|
| 184 |
+
In DDP this function is called on *all* ranks with a no-padding sharded
|
| 185 |
+
validation loader. It then all-reduces metric sums/counts so rank 0 gets
|
| 186 |
+
exact full-validation metrics without other ranks idling at a barrier.
|
| 187 |
+
"""
|
| 188 |
+
model.eval()
|
| 189 |
+
all_metrics = []
|
| 190 |
+
num_classes = cfg["data"]["num_classes"]
|
| 191 |
+
roi_size = tuple(cfg.get("eval", {}).get("roi_size", cfg["data"].get("patch_size", [96,96,96])))
|
| 192 |
+
sw_batch_size = int(cfg.get("eval", {}).get("sw_batch_size", 1))
|
| 193 |
+
overlap = float(cfg.get("eval", {}).get("overlap", 0.5))
|
| 194 |
+
iterator = enumerate(loader)
|
| 195 |
+
if is_main_process():
|
| 196 |
+
iterator = tqdm(iterator, total=len(loader), desc="eval", leave=False)
|
| 197 |
+
for i, batch in iterator:
|
| 198 |
+
if max_batches is not None and i >= max_batches:
|
| 199 |
+
break
|
| 200 |
+
if "label" not in batch:
|
| 201 |
+
continue
|
| 202 |
+
x = batch["image"].to(device, non_blocking=True)
|
| 203 |
+
y = batch["label"].numpy()
|
| 204 |
+
if cfg.get("eval", {}).get("sliding_window", True):
|
| 205 |
+
logits = sliding_window_inference(x, roi_size=roi_size, sw_batch_size=sw_batch_size, predictor=model, overlap=overlap)
|
| 206 |
+
else:
|
| 207 |
+
logits = model(x)
|
| 208 |
+
pred = logits.argmax(1).cpu().numpy()
|
| 209 |
+
for b in range(pred.shape[0]):
|
| 210 |
+
m = {}
|
| 211 |
+
m.update(dice_per_class(pred[b], y[b], num_classes))
|
| 212 |
+
spacing = tuple(batch.get("spacing", torch.ones(1,3))[b].cpu().numpy().tolist()) if "spacing" in batch else (1,1,1)
|
| 213 |
+
m.update(hd95_per_class(pred[b], y[b], num_classes, spacing=spacing))
|
| 214 |
+
all_metrics.append(m)
|
| 215 |
+
|
| 216 |
+
metric_keys = [f"dice_c{c}" for c in range(1, num_classes)] + ["dice_mean"] + [f"hd95_c{c}" for c in range(1, num_classes)] + ["hd95_mean"]
|
| 217 |
+
sums = torch.zeros(len(metric_keys), device=device, dtype=torch.float64)
|
| 218 |
+
counts = torch.zeros(len(metric_keys), device=device, dtype=torch.float64)
|
| 219 |
+
for m in all_metrics:
|
| 220 |
+
for j, k in enumerate(metric_keys):
|
| 221 |
+
v = m.get(k, float("nan"))
|
| 222 |
+
if v == v: # not NaN
|
| 223 |
+
sums[j] += float(v)
|
| 224 |
+
counts[j] += 1.0
|
| 225 |
+
if is_dist_avail_and_initialized():
|
| 226 |
+
dist.all_reduce(sums, op=dist.ReduceOp.SUM)
|
| 227 |
+
dist.all_reduce(counts, op=dist.ReduceOp.SUM)
|
| 228 |
+
out = {}
|
| 229 |
+
for j, k in enumerate(metric_keys):
|
| 230 |
+
if counts[j].item() > 0:
|
| 231 |
+
out[f"val/{k}"] = float((sums[j] / counts[j]).item())
|
| 232 |
+
if not out:
|
| 233 |
+
out["val/dice_mean"] = float("nan")
|
| 234 |
+
return out
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def run_training(cfg, device, wandb_run=None):
|
| 238 |
+
mode = cfg["train"]["mode"]
|
| 239 |
+
out_dir = ensure_dir(cfg["output_dir"])
|
| 240 |
+
ckpt_dir = ensure_dir(out_dir / "checkpoints")
|
| 241 |
+
require_label = mode in ("source_train", "oracle_train")
|
| 242 |
+
split = "source_train" if mode == "source_train" else ("target_train" if mode in ("oracle_train", "self_train", "peft", "sacflow_fm", "proto_align") else "target_train")
|
| 243 |
+
train_loader = build_loader(cfg, split=split, training=True, require_label=require_label)
|
| 244 |
+
val_split = "source_val" if mode == "source_train" else "target_val"
|
| 245 |
+
try:
|
| 246 |
+
val_loader = build_loader(cfg, split=val_split, training=False, require_label=True, distributed=(get_world_size() > 1))
|
| 247 |
+
except Exception:
|
| 248 |
+
val_loader = None
|
| 249 |
+
model = build_model(cfg).to(device)
|
| 250 |
+
if cfg["train"].get("source_checkpoint"):
|
| 251 |
+
missing, unexpected = load_checkpoint_into(model, cfg["train"]["source_checkpoint"], strict=False)
|
| 252 |
+
if is_main_process():
|
| 253 |
+
print("Loaded source checkpoint", cfg["train"]["source_checkpoint"], "missing", len(missing), "unexpected", len(unexpected))
|
| 254 |
+
if mode in ("peft", "sacflow_fm", "proto_align") and cfg.get("model", {}).get("adapter", {}).get("enabled", False):
|
| 255 |
+
freeze_except_adapters(model, train_norm_affine=True)
|
| 256 |
+
teacher = copy.deepcopy(model).to(device)
|
| 257 |
+
for p in teacher.parameters():
|
| 258 |
+
p.requires_grad = False
|
| 259 |
+
velocity_field = None
|
| 260 |
+
memory = None
|
| 261 |
+
if mode in ("sacflow_fm", "proto_align"):
|
| 262 |
+
if cfg["train"].get("memory_path"):
|
| 263 |
+
memory = load_source_memory(cfg["train"]["memory_path"], device=device)
|
| 264 |
+
feat_ch = model.prelogit_channels
|
| 265 |
+
vcfg = cfg.get("sacflow", {}).get("velocity", {})
|
| 266 |
+
if cfg.get("sacflow", {}).get("use_velocity_field", True):
|
| 267 |
+
velocity_field = VelocityField3D(
|
| 268 |
+
residual_channels=feat_ch,
|
| 269 |
+
num_classes=cfg["data"]["num_classes"],
|
| 270 |
+
hidden_ratio=float(vcfg.get("hidden_ratio", 0.25)),
|
| 271 |
+
depth=int(vcfg.get("depth", 2)),
|
| 272 |
+
tau_embedding_dim=int(vcfg.get("tau_embedding_dim", 32)),
|
| 273 |
+
organ_embedding_dim=int(vcfg.get("organ_embedding_dim", 16)),
|
| 274 |
+
include_teacher_probs=bool(vcfg.get("include_teacher_probs", True)),
|
| 275 |
+
include_confidence=bool(vcfg.get("include_confidence", True)),
|
| 276 |
+
include_boundary=bool(vcfg.get("include_boundary", True)),
|
| 277 |
+
use_depthwise=bool(vcfg.get("use_depthwise", True)),
|
| 278 |
+
use_group_norm=bool(vcfg.get("use_group_norm", True)),
|
| 279 |
+
use_film=bool(vcfg.get("use_film", True)),
|
| 280 |
+
).to(device)
|
| 281 |
+
params = list(model.parameters()) + ([] if velocity_field is None else list(velocity_field.parameters()))
|
| 282 |
+
optimizer = build_optimizer(params, cfg)
|
| 283 |
+
if get_world_size() > 1:
|
| 284 |
+
# SACFlow uses a custom feature-path loss in addition to the ordinary forward pass.
|
| 285 |
+
# find_unused_parameters=True is safer for this mode because some classifier outputs
|
| 286 |
+
# from the first forward are not directly used in the loss, while PEFT adapters are
|
| 287 |
+
# used again for path-state classification.
|
| 288 |
+
find_unused = bool(cfg.get("distributed", {}).get("find_unused_parameters", False)) or mode == "sacflow_fm"
|
| 289 |
+
model = DDP(model, device_ids=[device.index] if device.type == "cuda" else None, find_unused_parameters=find_unused)
|
| 290 |
+
if velocity_field is not None:
|
| 291 |
+
velocity_field = DDP(velocity_field, device_ids=[device.index] if device.type == "cuda" else None, find_unused_parameters=True)
|
| 292 |
+
trainable, total = count_trainable(unwrap_model(model))
|
| 293 |
+
if velocity_field is not None:
|
| 294 |
+
vt, vtotal = count_trainable(unwrap_model(velocity_field))
|
| 295 |
+
trainable += vt
|
| 296 |
+
total += vtotal
|
| 297 |
+
if is_main_process():
|
| 298 |
+
print(f"Mode={mode} trainable={trainable:,} total={total:,} ({100*trainable/max(1,total):.2f}%)")
|
| 299 |
+
scaler = torch.cuda.amp.GradScaler(enabled=bool(cfg.get("amp", True)) and device.type == "cuda")
|
| 300 |
+
best = -1e9
|
| 301 |
+
global_step = 0
|
| 302 |
+
start_epoch = 0
|
| 303 |
+
resume_value = cfg.get("train", {}).get("resume_checkpoint")
|
| 304 |
+
resume_path = resolve_resume_checkpoint(ckpt_dir, resume_value)
|
| 305 |
+
if resume_path is not None:
|
| 306 |
+
start_epoch, global_step, best = load_training_checkpoint(
|
| 307 |
+
resume_path, model, optimizer=optimizer, velocity_field=velocity_field, teacher=teacher
|
| 308 |
+
)
|
| 309 |
+
if is_main_process():
|
| 310 |
+
print(f"Resuming from epoch={start_epoch}, step={global_step}, best={best:.6f}")
|
| 311 |
+
elif resume_value and is_main_process():
|
| 312 |
+
print(f"WARNING: requested resume={resume_value!r}, but no usable checkpoint was found in {ckpt_dir}")
|
| 313 |
+
epochs = int(cfg["train"].get("epochs", 100))
|
| 314 |
+
steps_per_epoch = int(cfg["train"].get("steps_per_epoch", len(train_loader)))
|
| 315 |
+
if start_epoch >= epochs and is_main_process():
|
| 316 |
+
print(f"Checkpoint epoch {start_epoch} is already >= configured epochs {epochs}; nothing to train.")
|
| 317 |
+
for epoch in range(start_epoch, epochs):
|
| 318 |
+
if hasattr(train_loader.sampler, "set_epoch"):
|
| 319 |
+
train_loader.sampler.set_epoch(epoch)
|
| 320 |
+
model.train()
|
| 321 |
+
if velocity_field is not None:
|
| 322 |
+
velocity_field.train()
|
| 323 |
+
iterator = iter(train_loader)
|
| 324 |
+
pbar = range(steps_per_epoch)
|
| 325 |
+
if is_main_process():
|
| 326 |
+
pbar = tqdm(pbar, desc=f"epoch {epoch+1}/{epochs}", dynamic_ncols=True)
|
| 327 |
+
for _ in pbar:
|
| 328 |
+
try:
|
| 329 |
+
batch = next(iterator)
|
| 330 |
+
except StopIteration:
|
| 331 |
+
iterator = iter(train_loader)
|
| 332 |
+
batch = next(iterator)
|
| 333 |
+
batch = move_to_device(batch, device)
|
| 334 |
+
optimizer.zero_grad(set_to_none=True)
|
| 335 |
+
with torch.cuda.amp.autocast(enabled=bool(cfg.get("amp", True)) and device.type == "cuda"):
|
| 336 |
+
if mode in ("source_train", "oracle_train"):
|
| 337 |
+
loss, logs = supervised_step(model, batch, cfg)
|
| 338 |
+
elif mode in ("self_train", "peft"):
|
| 339 |
+
loss, logs = pseudo_step(model, teacher, batch, cfg)
|
| 340 |
+
elif mode == "proto_align":
|
| 341 |
+
loss, logs = proto_align_step(model, teacher, batch, memory, cfg)
|
| 342 |
+
elif mode == "sacflow_fm":
|
| 343 |
+
loss, logs = sacflow_forward_step(model, teacher, velocity_field, batch, memory, cfg)
|
| 344 |
+
else:
|
| 345 |
+
raise ValueError(f"Unknown train mode {mode}")
|
| 346 |
+
scaler.scale(loss).backward()
|
| 347 |
+
if float(cfg["optim"].get("grad_clip_norm", 0) or 0) > 0:
|
| 348 |
+
scaler.unscale_(optimizer)
|
| 349 |
+
torch.nn.utils.clip_grad_norm_([p for p in params if p.requires_grad], float(cfg["optim"].get("grad_clip_norm")))
|
| 350 |
+
scaler.step(optimizer)
|
| 351 |
+
scaler.update()
|
| 352 |
+
if mode in ("self_train", "peft", "sacflow_fm", "proto_align"):
|
| 353 |
+
update_ema(teacher, unwrap_model(model), float(cfg["train"].get("ema_decay", 0.995)))
|
| 354 |
+
global_step += 1
|
| 355 |
+
red_logs = {}
|
| 356 |
+
for k, v in logs.items():
|
| 357 |
+
if torch.is_tensor(v):
|
| 358 |
+
red_logs[f"train/{k}"] = float(reduce_mean(v.float()).item())
|
| 359 |
+
else:
|
| 360 |
+
red_logs[f"train/{k}"] = v
|
| 361 |
+
if is_main_process() and global_step % int(cfg["train"].get("log_every", 20)) == 0:
|
| 362 |
+
red_logs["train/epoch"] = epoch + 1
|
| 363 |
+
red_logs["train/lr"] = optimizer.param_groups[0]["lr"]
|
| 364 |
+
wandb_log(wandb_run, red_logs, step=global_step)
|
| 365 |
+
if hasattr(pbar, "set_postfix"):
|
| 366 |
+
pbar.set_postfix({"loss": f"{red_logs.get('train/loss_total', 0):.4f}", "step": global_step})
|
| 367 |
+
if val_loader is not None and ((epoch + 1) % int(cfg["train"].get("val_every", 1)) == 0):
|
| 368 |
+
metrics = evaluate(unwrap_model(model), val_loader, cfg, device)
|
| 369 |
+
if is_main_process():
|
| 370 |
+
score = metrics.get("val/dice_mean", -1e9)
|
| 371 |
+
print(f"Epoch {epoch+1} validation: {metrics}")
|
| 372 |
+
wandb_log(wandb_run, metrics, step=global_step)
|
| 373 |
+
if score > best:
|
| 374 |
+
best = score
|
| 375 |
+
save_checkpoint(
|
| 376 |
+
ckpt_dir / "best.pt", model, optimizer, epoch+1, global_step, best,
|
| 377 |
+
velocity_field, cfg, teacher=teacher,
|
| 378 |
+
include_optimizer=bool(cfg["train"].get("save_optimizer_in_best", False)),
|
| 379 |
+
)
|
| 380 |
+
# Keep all ranks synchronized after rank0 best-checkpoint writing.
|
| 381 |
+
barrier()
|
| 382 |
+
# Always keep a resumable last.pt. Do not create epoch_N.pt files unless explicitly requested.
|
| 383 |
+
save_checkpoint(
|
| 384 |
+
ckpt_dir / "last.pt", model, optimizer, epoch+1, global_step, best,
|
| 385 |
+
velocity_field, cfg, teacher=teacher, include_optimizer=True,
|
| 386 |
+
)
|
| 387 |
+
# Ensure rank0 has finished writing last.pt before other ranks start the next epoch.
|
| 388 |
+
barrier()
|
| 389 |
+
if bool(cfg["train"].get("keep_epoch_checkpoints", False)) and int(cfg["train"].get("save_every", 0) or 0) > 0:
|
| 390 |
+
if (epoch + 1) % int(cfg["train"].get("save_every", 5)) == 0:
|
| 391 |
+
save_checkpoint(
|
| 392 |
+
ckpt_dir / f"epoch_{epoch+1}.pt", model, optimizer, epoch+1, global_step, best,
|
| 393 |
+
velocity_field, cfg, teacher=teacher, include_optimizer=True,
|
| 394 |
+
)
|
| 395 |
+
barrier()
|
| 396 |
+
# final last.pt is already saved after every epoch; save once more for completeness.
|
| 397 |
+
save_checkpoint(ckpt_dir / "last.pt", model, optimizer, epochs, global_step, best, velocity_field, cfg, teacher=teacher, include_optimizer=True)
|
| 398 |
+
return unwrap_model(model)
|
sacflow/methods/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
sacflow/methods/sacflow_step.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import Dict, Tuple
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
from sacflow.utils.metrics import torch_soft_dice_loss, entropy_loss, confidence_and_margin, finite_difference_boundary
|
| 6 |
+
from sacflow.methods.task_space import centered_classifier_basis, random_basis, project_task_and_residual, project_to_residual
|
| 7 |
+
from sacflow.methods.source_memory import class_moments, moment_transport_residual
|
| 8 |
+
from sacflow.utils.misc import unwrap_model
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def ce_loss_masked(logits, target, mask=None):
|
| 12 |
+
loss = F.cross_entropy(logits, target.long(), reduction="none")
|
| 13 |
+
if mask is not None:
|
| 14 |
+
loss = loss * mask.float()
|
| 15 |
+
return loss.sum() / (mask.float().sum() + 1e-6)
|
| 16 |
+
return loss.mean()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def dice_loss_masked(logits, target, mask=None, num_classes=None, eps=1e-5):
|
| 22 |
+
if num_classes is None:
|
| 23 |
+
num_classes = logits.shape[1]
|
| 24 |
+
probs = torch.softmax(logits, dim=1)
|
| 25 |
+
target = target.clamp(0, num_classes - 1).long()
|
| 26 |
+
onehot = torch.nn.functional.one_hot(target, num_classes).permute(0,4,1,2,3).float()
|
| 27 |
+
if mask is not None:
|
| 28 |
+
m = mask.float().unsqueeze(1)
|
| 29 |
+
probs = probs * m
|
| 30 |
+
onehot = onehot * m
|
| 31 |
+
dims = tuple(range(2, logits.ndim))
|
| 32 |
+
inter = (probs * onehot).sum(dims)
|
| 33 |
+
denom = probs.sum(dims) + onehot.sum(dims)
|
| 34 |
+
dice = (2 * inter + eps) / (denom + eps)
|
| 35 |
+
return 1.0 - dice[:, 1:].mean()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def kl_masked(p, q, mask=None, eps=1e-8):
|
| 39 |
+
# p,q probabilities [B,C,H,W,D]
|
| 40 |
+
kl = (p * ((p+eps).log() - (q+eps).log())).sum(dim=1)
|
| 41 |
+
if mask is not None:
|
| 42 |
+
kl = kl * mask.float()
|
| 43 |
+
return kl.sum() / (mask.float().sum() + 1e-6)
|
| 44 |
+
return kl.mean()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _stats_distance(mu_a, std_a, mu_b, std_b):
|
| 48 |
+
# simple differentiable class/channel statistic distance
|
| 49 |
+
return (mu_a - mu_b).abs().mean() + (std_a - std_b).abs().mean()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def build_task_basis(model, cfg, feat_dim, device, dtype):
|
| 53 |
+
basis = cfg.get("sacflow", {}).get("basis", "centered_svd")
|
| 54 |
+
W = model.final_classifier_weight().detach().to(device=device, dtype=dtype)
|
| 55 |
+
if basis == "random":
|
| 56 |
+
rank = max(1, min(W.shape[0]-1, feat_dim))
|
| 57 |
+
return random_basis(feat_dim, rank, device, dtype)
|
| 58 |
+
return centered_classifier_basis(W, foreground_only=cfg.get("sacflow", {}).get("foreground_only_basis", False))
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def make_tau(B, device, dtype):
|
| 62 |
+
return torch.rand(B, device=device, dtype=dtype)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def sacflow_forward_step(model, teacher, velocity_field, batch, memory, cfg):
|
| 66 |
+
x = batch["image"]
|
| 67 |
+
num_classes = cfg["data"]["num_classes"]
|
| 68 |
+
# Use the wrapped model for the main forward when DDP is active; unwrap only for helper methods.
|
| 69 |
+
base_model = unwrap_model(model)
|
| 70 |
+
logits, feats = model(x, return_features=True)
|
| 71 |
+
feat = feats["prelogit"]
|
| 72 |
+
B, d, H, W, D = feat.shape
|
| 73 |
+
with torch.no_grad():
|
| 74 |
+
tlogits = teacher(x)
|
| 75 |
+
tprobs = torch.softmax(tlogits, dim=1)
|
| 76 |
+
if tprobs.shape[-3:] != (H,W,D):
|
| 77 |
+
tprobs_f = F.interpolate(tprobs, size=(H,W,D), mode="trilinear", align_corners=False)
|
| 78 |
+
else:
|
| 79 |
+
tprobs_f = tprobs
|
| 80 |
+
conf, margin, pseudo = confidence_and_margin(tprobs)
|
| 81 |
+
mask = (conf > float(cfg["train"].get("pseudo_conf_threshold", 0.75))).float()
|
| 82 |
+
Q = build_task_basis(base_model, cfg, d, feat.device, feat.dtype)
|
| 83 |
+
F_task, Rt = project_task_and_residual(feat, Q)
|
| 84 |
+
if cfg.get("sacflow", {}).get("flow_space", "residual") == "whole_feature":
|
| 85 |
+
F_task = torch.zeros_like(feat)
|
| 86 |
+
Rt = feat
|
| 87 |
+
Q = torch.empty(d, 0, device=feat.device, dtype=feat.dtype)
|
| 88 |
+
use_mem = cfg.get("sacflow", {}).get("use_compact_memory", True) and memory is not None
|
| 89 |
+
whole_feature = cfg.get("sacflow", {}).get("flow_space", "residual") == "whole_feature"
|
| 90 |
+
if use_mem and whole_feature and "feature_mu" in memory:
|
| 91 |
+
src_mu = memory["feature_mu"].to(feat.device, feat.dtype)
|
| 92 |
+
src_std = memory["feature_std"].to(feat.device, feat.dtype)
|
| 93 |
+
elif use_mem and "residual_mu" in memory:
|
| 94 |
+
src_mu = memory["residual_mu"].to(feat.device, feat.dtype)
|
| 95 |
+
src_std = memory["residual_std"].to(feat.device, feat.dtype)
|
| 96 |
+
else:
|
| 97 |
+
# strict fallback: use target stats as weak source proxy; ablate this separately.
|
| 98 |
+
# This makes R0 close to Rt and is intentionally weaker than compact-memory SACFlow.
|
| 99 |
+
src_mu, src_std, _ = class_moments(Rt.detach(), tprobs_f.detach())
|
| 100 |
+
R0, tgt_mu, tgt_std = moment_transport_residual(Rt.detach(), tprobs_f.detach(), src_mu, src_std)
|
| 101 |
+
R1 = Rt
|
| 102 |
+
tau = make_tau(B, feat.device, feat.dtype)
|
| 103 |
+
tau_view = tau.view(B,1,1,1,1)
|
| 104 |
+
sigma_tau = float(cfg.get("sacflow", {}).get("sigma_tau", 0.0) or 0.0)
|
| 105 |
+
noise = torch.randn_like(Rt) if sigma_tau > 0 else torch.zeros_like(Rt)
|
| 106 |
+
R_interp = (1 - tau_view) * R0 + tau_view * R1 + sigma_tau * tau_view * (1 - tau_view) * noise
|
| 107 |
+
u = R1 - R0 + sigma_tau * (1 - 2*tau_view) * noise
|
| 108 |
+
anchors = {"probs": tprobs_f.detach(), "boundary": finite_difference_boundary(tprobs_f.detach())}
|
| 109 |
+
use_v = bool(cfg.get("sacflow", {}).get("use_velocity_field", True))
|
| 110 |
+
if use_v and velocity_field is not None:
|
| 111 |
+
v = velocity_field(R_interp, tau, anchors)
|
| 112 |
+
if cfg.get("sacflow", {}).get("project_velocity_to_nullspace", True) and Q.numel() > 0:
|
| 113 |
+
v = project_to_residual(v, Q)
|
| 114 |
+
fm_loss = F.mse_loss(v, u.detach())
|
| 115 |
+
path_mode = cfg.get("sacflow", {}).get("path_from_velocity", "one_step")
|
| 116 |
+
if path_mode == "one_step":
|
| 117 |
+
Rtau = R0 + tau_view * v
|
| 118 |
+
else:
|
| 119 |
+
Rtau = R_interp
|
| 120 |
+
else:
|
| 121 |
+
v = torch.zeros_like(Rt)
|
| 122 |
+
fm_loss = torch.tensor(0.0, device=feat.device)
|
| 123 |
+
Rtau = R_interp
|
| 124 |
+
Ftau = F_task + Rtau
|
| 125 |
+
# Classify path states through the wrapped model so DDP can track gradients.
|
| 126 |
+
path_logits = model(prelogit_features=Ftau)
|
| 127 |
+
# resize mask/pseudo if needed
|
| 128 |
+
pseudo_f = pseudo
|
| 129 |
+
mask_f = mask
|
| 130 |
+
if pseudo.shape[-3:] != path_logits.shape[-3:]:
|
| 131 |
+
pseudo_f = F.interpolate(pseudo[:, None].float(), size=path_logits.shape[-3:], mode="nearest")[:,0].long()
|
| 132 |
+
mask_f = F.interpolate(mask[:, None].float(), size=path_logits.shape[-3:], mode="nearest")[:,0]
|
| 133 |
+
path_probs = torch.softmax(path_logits, dim=1)
|
| 134 |
+
task_loss = ce_loss_masked(path_logits, pseudo_f, mask_f)
|
| 135 |
+
dice = dice_loss_masked(path_logits, pseudo_f, mask_f, num_classes)
|
| 136 |
+
ent = entropy_loss(path_logits)
|
| 137 |
+
if tprobs.shape[-3:] != path_logits.shape[-3:]:
|
| 138 |
+
tprobs_path = F.interpolate(tprobs.detach(), size=path_logits.shape[-3:], mode="trilinear", align_corners=False)
|
| 139 |
+
else:
|
| 140 |
+
tprobs_path = tprobs.detach()
|
| 141 |
+
task_kl = kl_masked(path_probs, tprobs_path, mask_f)
|
| 142 |
+
b_pred = finite_difference_boundary(path_probs)
|
| 143 |
+
b_ref = finite_difference_boundary(tprobs_path)
|
| 144 |
+
boundary_loss = F.l1_loss(b_pred, b_ref)
|
| 145 |
+
null_leak = torch.tensor(0.0, device=feat.device)
|
| 146 |
+
if Q.numel() > 0:
|
| 147 |
+
null_leak = (Rtau - R0 - project_to_residual(Rtau - R0, Q)).pow(2).mean()
|
| 148 |
+
|
| 149 |
+
# Domain-progress proxy in residual-statistic space. A valid path state should
|
| 150 |
+
# move monotonically from source-like residual stats toward target residual stats.
|
| 151 |
+
rtau_mu, rtau_std, _ = class_moments(Rtau, tprobs_f.detach())
|
| 152 |
+
dist_src = _stats_distance(rtau_mu, rtau_std, src_mu.detach(), src_std.detach())
|
| 153 |
+
dist_tgt = _stats_distance(rtau_mu, rtau_std, tgt_mu.detach(), tgt_std.detach())
|
| 154 |
+
rho = dist_src / (dist_src + dist_tgt + 1e-6)
|
| 155 |
+
domain_progress_loss = (rho - tau.mean()).abs()
|
| 156 |
+
|
| 157 |
+
losses_cfg = cfg.get("sacflow", {}).get("losses", {})
|
| 158 |
+
val_cfg = cfg.get("sacflow", {}).get("validation", {})
|
| 159 |
+
use_path_weights = bool(val_cfg.get("use_weights", True))
|
| 160 |
+
if use_path_weights:
|
| 161 |
+
alpha = float(val_cfg.get("task_alpha", 1.0))
|
| 162 |
+
beta = float(val_cfg.get("anatomy_beta", 1.0))
|
| 163 |
+
# Use detached scalar weights so the gate selects/weights path states but
|
| 164 |
+
# does not create degenerate gradients that simply lower the weight.
|
| 165 |
+
accepted_weight = torch.exp(-alpha * task_kl.detach() - beta * boundary_loss.detach()).clamp(
|
| 166 |
+
min=float(val_cfg.get("min_weight", 0.05)), max=1.0
|
| 167 |
+
)
|
| 168 |
+
else:
|
| 169 |
+
accepted_weight = torch.tensor(1.0, device=feat.device, dtype=feat.dtype)
|
| 170 |
+
|
| 171 |
+
path_loss = accepted_weight * (
|
| 172 |
+
float(losses_cfg.get("path_ce", 1.0)) * task_loss +
|
| 173 |
+
float(losses_cfg.get("path_dice", 1.0)) * dice
|
| 174 |
+
)
|
| 175 |
+
loss = (
|
| 176 |
+
float(losses_cfg.get("fm", 1.0)) * fm_loss +
|
| 177 |
+
path_loss +
|
| 178 |
+
float(losses_cfg.get("task_kl", 0.25)) * task_kl +
|
| 179 |
+
float(losses_cfg.get("boundary", 0.05)) * boundary_loss +
|
| 180 |
+
float(losses_cfg.get("null_leakage", 0.1)) * null_leak +
|
| 181 |
+
float(losses_cfg.get("domain_progress", 0.05)) * domain_progress_loss +
|
| 182 |
+
float(losses_cfg.get("entropy", 0.01)) * ent
|
| 183 |
+
)
|
| 184 |
+
logs = {
|
| 185 |
+
"loss_total": loss.detach(),
|
| 186 |
+
"loss_fm": fm_loss.detach(),
|
| 187 |
+
"loss_path_ce": task_loss.detach(),
|
| 188 |
+
"loss_path_dice": dice.detach(),
|
| 189 |
+
"loss_task_kl": task_kl.detach(),
|
| 190 |
+
"loss_boundary": boundary_loss.detach(),
|
| 191 |
+
"loss_null_leak": null_leak.detach(),
|
| 192 |
+
"loss_domain_progress": domain_progress_loss.detach(),
|
| 193 |
+
"loss_entropy": ent.detach(),
|
| 194 |
+
"sacflow_rho_mean": rho.detach(),
|
| 195 |
+
"sacflow_tau_mean": tau.mean().detach(),
|
| 196 |
+
"sacflow_velocity_mag": v.detach().abs().mean(),
|
| 197 |
+
"sacflow_residual_gap": (R1-R0).detach().abs().mean(),
|
| 198 |
+
"pseudo_conf_mean": conf.detach().mean(),
|
| 199 |
+
"pseudo_accept_rate": mask.detach().mean(),
|
| 200 |
+
"path_weight": accepted_weight.detach(),
|
| 201 |
+
}
|
| 202 |
+
return loss, logs
|
sacflow/methods/source_memory.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Dict
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn.functional as F
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@torch.no_grad()
|
| 9 |
+
def class_moments(residual: torch.Tensor, probs: torch.Tensor, eps: float = 1e-6):
|
| 10 |
+
"""Class-conditional channel moments without materializing [B,C,d,N].
|
| 11 |
+
|
| 12 |
+
residual: [B,d,H,W,D]
|
| 13 |
+
probs: [B,C,H,W,D], soft or one-hot class weights at the same resolution.
|
| 14 |
+
|
| 15 |
+
Returns:
|
| 16 |
+
mu: [C,d]
|
| 17 |
+
std: [C,d]
|
| 18 |
+
weights: [C]
|
| 19 |
+
|
| 20 |
+
This implementation uses first and second moments directly and is much more
|
| 21 |
+
memory efficient than computing (residual - mu)^2 for every class/voxel.
|
| 22 |
+
"""
|
| 23 |
+
B, d, H, W, D = residual.shape
|
| 24 |
+
C = probs.shape[1]
|
| 25 |
+
r = residual.reshape(B, d, -1)
|
| 26 |
+
p = probs.reshape(B, C, -1).to(dtype=residual.dtype)
|
| 27 |
+
weights = p.sum(dim=(0, 2)).clamp_min(eps) # [C]
|
| 28 |
+
mu = torch.einsum("bcn,bdn->cd", p, r) / weights[:, None]
|
| 29 |
+
second = torch.einsum("bcn,bdn->cd", p, r.pow(2)) / weights[:, None]
|
| 30 |
+
var = (second - mu.pow(2)).clamp_min(eps)
|
| 31 |
+
std = torch.sqrt(var)
|
| 32 |
+
return mu, std, weights
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def hard_onehot(label: torch.Tensor, num_classes: int):
|
| 36 |
+
return F.one_hot(label.long().clamp(0, num_classes-1), num_classes).permute(0,4,1,2,3).float()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def save_source_memory(path: str | Path, memory: Dict):
|
| 40 |
+
path = Path(path)
|
| 41 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
torch.save(memory, path)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def load_source_memory(path: str | Path, device=None):
|
| 46 |
+
mem = torch.load(path, map_location=device or "cpu")
|
| 47 |
+
return mem
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def moment_transport_residual(Rt: torch.Tensor, probs: torch.Tensor, source_mu: torch.Tensor, source_std: torch.Tensor,
|
| 51 |
+
target_mu: torch.Tensor | None = None, target_std: torch.Tensor | None = None,
|
| 52 |
+
eps: float = 1e-5):
|
| 53 |
+
"""Class-gated diagonal moment transport from target residual stats to source residual stats.
|
| 54 |
+
|
| 55 |
+
Computes R0(u) = sum_c p_c(u) [mu_s_c + sigma_s_c / sigma_t_c * (Rt(u)-mu_t_c)].
|
| 56 |
+
|
| 57 |
+
The implementation intentionally avoids stacking all class-wise transported
|
| 58 |
+
residuals, because [B,C,d,H,W,D] can be very large for 3D volumes.
|
| 59 |
+
"""
|
| 60 |
+
B, d, H, W, D = Rt.shape
|
| 61 |
+
C = probs.shape[1]
|
| 62 |
+
if target_mu is None or target_std is None:
|
| 63 |
+
target_mu, target_std, _ = class_moments(Rt, probs, eps=eps)
|
| 64 |
+
source_mu = source_mu.to(Rt.device, Rt.dtype)
|
| 65 |
+
source_std = source_std.to(Rt.device, Rt.dtype)
|
| 66 |
+
target_mu = target_mu.to(Rt.device, Rt.dtype)
|
| 67 |
+
target_std = target_std.to(Rt.device, Rt.dtype)
|
| 68 |
+
R0 = torch.zeros_like(Rt)
|
| 69 |
+
for c in range(C):
|
| 70 |
+
tr = source_mu[c].view(1,d,1,1,1) + (source_std[c].view(1,d,1,1,1) / (target_std[c].view(1,d,1,1,1) + eps)) * (Rt - target_mu[c].view(1,d,1,1,1))
|
| 71 |
+
R0 = R0 + probs[:, c:c+1].to(Rt.dtype) * tr
|
| 72 |
+
return R0, target_mu, target_std
|
sacflow/methods/task_space.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn.functional as F
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def centered_classifier_basis(W: torch.Tensor, tol: float = 1e-5, foreground_only: bool = False):
|
| 7 |
+
# W: [C,d]
|
| 8 |
+
if foreground_only and W.shape[0] > 1:
|
| 9 |
+
W = W[1:]
|
| 10 |
+
Wc = W - W.mean(dim=0, keepdim=True)
|
| 11 |
+
# U basis in feature dimension for row space of Wc
|
| 12 |
+
U, S, Vh = torch.linalg.svd(Wc.T, full_matrices=False)
|
| 13 |
+
if S.numel() == 0:
|
| 14 |
+
return torch.empty(W.shape[1], 0, device=W.device, dtype=W.dtype)
|
| 15 |
+
r = int((S > tol * S.max()).sum().item())
|
| 16 |
+
return U[:, :max(1, r)]
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def random_basis(feature_dim: int, rank: int, device, dtype):
|
| 20 |
+
A = torch.randn(feature_dim, rank, device=device, dtype=dtype)
|
| 21 |
+
Q, _ = torch.linalg.qr(A, mode="reduced")
|
| 22 |
+
return Q
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def project_task_and_residual(Fmap: torch.Tensor, Q: torch.Tensor):
|
| 26 |
+
# Fmap [B,d,H,W,D], Q [d,r]
|
| 27 |
+
if Q.numel() == 0:
|
| 28 |
+
return torch.zeros_like(Fmap), Fmap
|
| 29 |
+
B, d, H, W, D = Fmap.shape
|
| 30 |
+
flat = Fmap.permute(0,2,3,4,1).reshape(-1, d)
|
| 31 |
+
task = (flat @ Q) @ Q.T
|
| 32 |
+
task = task.reshape(B,H,W,D,d).permute(0,4,1,2,3).contiguous()
|
| 33 |
+
residual = Fmap - task
|
| 34 |
+
return task, residual
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def project_to_residual(x: torch.Tensor, Q: torch.Tensor):
|
| 38 |
+
task, res = project_task_and_residual(x, Q)
|
| 39 |
+
return res
|
sacflow/models/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
sacflow/models/unet3d.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn as nn
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def norm_layer(kind: str, channels: int):
|
| 8 |
+
if kind == "batch":
|
| 9 |
+
return nn.BatchNorm3d(channels)
|
| 10 |
+
if kind == "group":
|
| 11 |
+
return nn.GroupNorm(num_groups=min(8, channels), num_channels=channels)
|
| 12 |
+
return nn.InstanceNorm3d(channels, affine=True)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ConvBlock(nn.Module):
|
| 16 |
+
def __init__(self, in_ch, out_ch, norm="instance", dropout=0.0):
|
| 17 |
+
super().__init__()
|
| 18 |
+
self.block = nn.Sequential(
|
| 19 |
+
nn.Conv3d(in_ch, out_ch, 3, padding=1, bias=False),
|
| 20 |
+
norm_layer(norm, out_ch),
|
| 21 |
+
nn.LeakyReLU(0.01, inplace=True),
|
| 22 |
+
nn.Dropout3d(dropout) if dropout > 0 else nn.Identity(),
|
| 23 |
+
nn.Conv3d(out_ch, out_ch, 3, padding=1, bias=False),
|
| 24 |
+
norm_layer(norm, out_ch),
|
| 25 |
+
nn.LeakyReLU(0.01, inplace=True),
|
| 26 |
+
)
|
| 27 |
+
def forward(self, x):
|
| 28 |
+
return self.block(x)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class BottleneckAdapter3D(nn.Module):
|
| 32 |
+
def __init__(self, channels: int, ratio: float = 0.25, norm: str = "group"):
|
| 33 |
+
super().__init__()
|
| 34 |
+
hidden = max(4, int(channels * ratio))
|
| 35 |
+
self.net = nn.Sequential(
|
| 36 |
+
nn.Conv3d(channels, hidden, 1, bias=False),
|
| 37 |
+
norm_layer(norm, hidden),
|
| 38 |
+
nn.GELU(),
|
| 39 |
+
nn.Conv3d(hidden, channels, 1, bias=True),
|
| 40 |
+
)
|
| 41 |
+
nn.init.zeros_(self.net[-1].weight)
|
| 42 |
+
nn.init.zeros_(self.net[-1].bias)
|
| 43 |
+
def forward(self, x):
|
| 44 |
+
return x + self.net(x)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
class UNet3D(nn.Module):
|
| 48 |
+
def __init__(self, in_channels=1, num_classes=2, base_channels=32, levels=4, norm="instance", dropout=0.0, adapter_cfg=None):
|
| 49 |
+
super().__init__()
|
| 50 |
+
self.in_channels = in_channels
|
| 51 |
+
self.num_classes = num_classes
|
| 52 |
+
self.base_channels = base_channels
|
| 53 |
+
self.levels = levels
|
| 54 |
+
chs = [base_channels * (2 ** i) for i in range(levels)]
|
| 55 |
+
self.enc = nn.ModuleList()
|
| 56 |
+
self.down = nn.ModuleList()
|
| 57 |
+
prev = in_channels
|
| 58 |
+
for ch in chs:
|
| 59 |
+
self.enc.append(ConvBlock(prev, ch, norm, dropout))
|
| 60 |
+
self.down.append(nn.Conv3d(ch, ch, 3, stride=2, padding=1))
|
| 61 |
+
prev = ch
|
| 62 |
+
self.bottleneck = ConvBlock(chs[-1], chs[-1]*2, norm, dropout)
|
| 63 |
+
self.up = nn.ModuleList()
|
| 64 |
+
self.dec = nn.ModuleList()
|
| 65 |
+
prev = chs[-1]*2
|
| 66 |
+
for ch in reversed(chs):
|
| 67 |
+
self.up.append(nn.ConvTranspose3d(prev, ch, 2, stride=2))
|
| 68 |
+
self.dec.append(ConvBlock(ch*2, ch, norm, dropout))
|
| 69 |
+
prev = ch
|
| 70 |
+
self.prelogit_channels = base_channels
|
| 71 |
+
self.adapter_enabled = bool(adapter_cfg and adapter_cfg.get("enabled", False))
|
| 72 |
+
if self.adapter_enabled:
|
| 73 |
+
self.prelogit_adapter = BottleneckAdapter3D(base_channels, ratio=float(adapter_cfg.get("bottleneck_ratio", 0.25)))
|
| 74 |
+
else:
|
| 75 |
+
self.prelogit_adapter = nn.Identity()
|
| 76 |
+
self.out_conv = nn.Conv3d(base_channels, num_classes, 1)
|
| 77 |
+
|
| 78 |
+
def forward_features(self, x):
|
| 79 |
+
skips = []
|
| 80 |
+
out = x
|
| 81 |
+
for enc, down in zip(self.enc, self.down):
|
| 82 |
+
out = enc(out)
|
| 83 |
+
skips.append(out)
|
| 84 |
+
out = down(out)
|
| 85 |
+
out = self.bottleneck(out)
|
| 86 |
+
for up, dec, skip in zip(self.up, self.dec, reversed(skips)):
|
| 87 |
+
out = up(out)
|
| 88 |
+
if out.shape[-3:] != skip.shape[-3:]:
|
| 89 |
+
out = F.interpolate(out, size=skip.shape[-3:], mode="trilinear", align_corners=False)
|
| 90 |
+
out = torch.cat([out, skip], dim=1)
|
| 91 |
+
out = dec(out)
|
| 92 |
+
return out
|
| 93 |
+
|
| 94 |
+
def classify_from_features(self, feat):
|
| 95 |
+
feat = self.prelogit_adapter(feat)
|
| 96 |
+
return self.out_conv(feat)
|
| 97 |
+
|
| 98 |
+
def forward(self, x=None, return_features=False, prelogit_features=None):
|
| 99 |
+
# prelogit_features allows SACFlow to classify generated/intermediate
|
| 100 |
+
# feature states through the normal module/DDP call path. This is safer
|
| 101 |
+
# than bypassing DistributedDataParallel by calling module methods directly.
|
| 102 |
+
if prelogit_features is not None:
|
| 103 |
+
feat = prelogit_features
|
| 104 |
+
else:
|
| 105 |
+
if x is None:
|
| 106 |
+
raise ValueError("Either x or prelogit_features must be provided")
|
| 107 |
+
feat = self.forward_features(x)
|
| 108 |
+
logits = self.classify_from_features(feat)
|
| 109 |
+
if return_features:
|
| 110 |
+
return logits, {"prelogit": feat}
|
| 111 |
+
return logits
|
| 112 |
+
|
| 113 |
+
def final_classifier_weight(self):
|
| 114 |
+
return self.out_conv.weight.squeeze(-1).squeeze(-1).squeeze(-1)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def build_model(cfg):
|
| 118 |
+
mcfg = cfg["model"]
|
| 119 |
+
return UNet3D(
|
| 120 |
+
in_channels=mcfg.get("in_channels", cfg["data"].get("in_channels", 1)),
|
| 121 |
+
num_classes=mcfg.get("num_classes", cfg["data"].get("num_classes", 2)),
|
| 122 |
+
base_channels=mcfg.get("base_channels", 32),
|
| 123 |
+
levels=mcfg.get("levels", 4),
|
| 124 |
+
norm=mcfg.get("norm", "instance"),
|
| 125 |
+
dropout=mcfg.get("dropout", 0.0),
|
| 126 |
+
adapter_cfg=mcfg.get("adapter", None),
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def freeze_except_adapters(model: nn.Module, train_norm_affine: bool = True):
|
| 131 |
+
"""Freeze the backbone, train adapters and optionally norm affine params.
|
| 132 |
+
|
| 133 |
+
The previous name-based norm check missed InstanceNorm/GroupNorm modules
|
| 134 |
+
inside Sequential containers. This module-type based version reliably
|
| 135 |
+
unfreezes affine normalization parameters when requested.
|
| 136 |
+
"""
|
| 137 |
+
for p in model.parameters():
|
| 138 |
+
p.requires_grad = False
|
| 139 |
+
for name, p in model.named_parameters():
|
| 140 |
+
if "adapter" in name:
|
| 141 |
+
p.requires_grad = True
|
| 142 |
+
if train_norm_affine:
|
| 143 |
+
norm_types = (nn.BatchNorm3d, nn.InstanceNorm3d, nn.GroupNorm)
|
| 144 |
+
for module in model.modules():
|
| 145 |
+
if isinstance(module, norm_types):
|
| 146 |
+
if getattr(module, "weight", None) is not None:
|
| 147 |
+
module.weight.requires_grad = True
|
| 148 |
+
if getattr(module, "bias", None) is not None:
|
| 149 |
+
module.bias.requires_grad = True
|
sacflow/models/velocity_field.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import math
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
import torch.nn.functional as F
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def sinusoidal_embedding(t: torch.Tensor, dim: int):
|
| 9 |
+
# t: [B] or [B,1]
|
| 10 |
+
if t.ndim == 0:
|
| 11 |
+
t = t[None]
|
| 12 |
+
t = t.view(-1, 1)
|
| 13 |
+
half = dim // 2
|
| 14 |
+
freqs = torch.exp(torch.arange(half, device=t.device, dtype=t.dtype) * -(math.log(10000.0) / max(1, half - 1)))
|
| 15 |
+
args = t * freqs[None]
|
| 16 |
+
emb = torch.cat([torch.sin(args), torch.cos(args)], dim=1)
|
| 17 |
+
if dim % 2 == 1:
|
| 18 |
+
emb = F.pad(emb, (0,1))
|
| 19 |
+
return emb
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class FiLM(nn.Module):
|
| 23 |
+
def __init__(self, cond_dim: int, channels: int):
|
| 24 |
+
super().__init__()
|
| 25 |
+
self.net = nn.Sequential(nn.Linear(cond_dim, channels*2), nn.SiLU(), nn.Linear(channels*2, channels*2))
|
| 26 |
+
nn.init.zeros_(self.net[-1].weight)
|
| 27 |
+
nn.init.zeros_(self.net[-1].bias)
|
| 28 |
+
def forward(self, x, cond):
|
| 29 |
+
gb = self.net(cond)
|
| 30 |
+
gamma, beta = gb.chunk(2, dim=1)
|
| 31 |
+
shape = [x.shape[0], x.shape[1]] + [1]*(x.ndim-2)
|
| 32 |
+
gamma = gamma.view(*shape)
|
| 33 |
+
beta = beta.view(*shape)
|
| 34 |
+
return x * (1 + gamma) + beta
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class VelocityField3D(nn.Module):
|
| 38 |
+
def __init__(self, residual_channels: int, num_classes: int, hidden_ratio: float = 0.25, depth: int = 2,
|
| 39 |
+
tau_embedding_dim: int = 32, organ_embedding_dim: int = 16,
|
| 40 |
+
include_teacher_probs: bool = True, include_confidence: bool = True, include_boundary: bool = True,
|
| 41 |
+
use_depthwise: bool = True, use_group_norm: bool = True, use_film: bool = True):
|
| 42 |
+
super().__init__()
|
| 43 |
+
self.residual_channels = residual_channels
|
| 44 |
+
self.num_classes = num_classes
|
| 45 |
+
self.include_teacher_probs = include_teacher_probs
|
| 46 |
+
self.include_confidence = include_confidence
|
| 47 |
+
self.include_boundary = include_boundary
|
| 48 |
+
self.use_film = use_film
|
| 49 |
+
self.tau_embedding_dim = tau_embedding_dim
|
| 50 |
+
self.organ_embedding_dim = organ_embedding_dim
|
| 51 |
+
self.organ_emb = nn.Embedding(num_classes, organ_embedding_dim)
|
| 52 |
+
anchor_ch = 0
|
| 53 |
+
if include_teacher_probs:
|
| 54 |
+
anchor_ch += num_classes
|
| 55 |
+
if include_confidence:
|
| 56 |
+
anchor_ch += 1
|
| 57 |
+
if include_boundary:
|
| 58 |
+
anchor_ch += 1
|
| 59 |
+
anchor_ch += organ_embedding_dim
|
| 60 |
+
in_ch = residual_channels + anchor_ch + 1 # tau map
|
| 61 |
+
hidden = max(8, int(residual_channels * hidden_ratio))
|
| 62 |
+
self.in_proj = nn.Conv3d(in_ch, hidden, 1)
|
| 63 |
+
blocks = []
|
| 64 |
+
for _ in range(depth):
|
| 65 |
+
if use_depthwise:
|
| 66 |
+
conv = nn.Sequential(
|
| 67 |
+
nn.Conv3d(hidden, hidden, 3, padding=1, groups=hidden, bias=False),
|
| 68 |
+
nn.Conv3d(hidden, hidden, 1, bias=False),
|
| 69 |
+
)
|
| 70 |
+
else:
|
| 71 |
+
conv = nn.Conv3d(hidden, hidden, 3, padding=1, bias=False)
|
| 72 |
+
norm = nn.GroupNorm(min(8, hidden), hidden) if use_group_norm else nn.InstanceNorm3d(hidden, affine=True)
|
| 73 |
+
blocks.append(nn.Sequential(conv, norm, nn.GELU()))
|
| 74 |
+
self.blocks = nn.ModuleList(blocks)
|
| 75 |
+
cond_dim = tau_embedding_dim
|
| 76 |
+
self.films = nn.ModuleList([FiLM(cond_dim, hidden) for _ in range(depth)]) if use_film else None
|
| 77 |
+
self.out_proj = nn.Conv3d(hidden, residual_channels, 1)
|
| 78 |
+
nn.init.zeros_(self.out_proj.weight)
|
| 79 |
+
nn.init.zeros_(self.out_proj.bias)
|
| 80 |
+
|
| 81 |
+
def organ_embedding_map(self, probs: torch.Tensor):
|
| 82 |
+
# probs [B,C,H,W,D]
|
| 83 |
+
emb = self.organ_emb.weight # [C,E]
|
| 84 |
+
e = torch.einsum("bchwd,ce->behwd", probs, emb)
|
| 85 |
+
return e
|
| 86 |
+
|
| 87 |
+
def forward(self, residual: torch.Tensor, tau: torch.Tensor, anchors: dict):
|
| 88 |
+
B, _, H, W, D = residual.shape
|
| 89 |
+
xs = [residual]
|
| 90 |
+
tau_vec = tau.view(B).to(residual.dtype)
|
| 91 |
+
tau_map = tau_vec.view(B,1,1,1,1).expand(B,1,H,W,D)
|
| 92 |
+
xs.append(tau_map)
|
| 93 |
+
probs = anchors.get("probs")
|
| 94 |
+
if probs is None:
|
| 95 |
+
probs = torch.zeros(B, self.num_classes, H, W, D, device=residual.device, dtype=residual.dtype)
|
| 96 |
+
probs[:, 0] = 1.0
|
| 97 |
+
if probs.shape[-3:] != (H,W,D):
|
| 98 |
+
probs = F.interpolate(probs, size=(H,W,D), mode="trilinear", align_corners=False)
|
| 99 |
+
if self.include_teacher_probs:
|
| 100 |
+
xs.append(probs)
|
| 101 |
+
if self.include_confidence:
|
| 102 |
+
conf = probs.max(dim=1, keepdim=True).values
|
| 103 |
+
xs.append(conf)
|
| 104 |
+
if self.include_boundary:
|
| 105 |
+
bnd = anchors.get("boundary")
|
| 106 |
+
if bnd is None:
|
| 107 |
+
# simple finite diff boundary from probs
|
| 108 |
+
bnd = (torch.abs(probs[:, :, 1:] - probs[:, :, :-1]).mean(1, keepdim=True))
|
| 109 |
+
bnd = F.pad(bnd, (0,0,0,0,1,0))
|
| 110 |
+
if bnd.shape[-3:] != (H,W,D):
|
| 111 |
+
bnd = F.interpolate(bnd, size=(H,W,D), mode="trilinear", align_corners=False)
|
| 112 |
+
xs.append(bnd)
|
| 113 |
+
xs.append(self.organ_embedding_map(probs))
|
| 114 |
+
x = torch.cat(xs, dim=1)
|
| 115 |
+
h = self.in_proj(x)
|
| 116 |
+
cond = sinusoidal_embedding(tau_vec, self.tau_embedding_dim)
|
| 117 |
+
for i, block in enumerate(self.blocks):
|
| 118 |
+
h = block(h)
|
| 119 |
+
if self.use_film:
|
| 120 |
+
h = self.films[i](h, cond)
|
| 121 |
+
return self.out_proj(h)
|
sacflow/utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
sacflow/utils/config.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
from typing import Any, Dict, List
|
| 4 |
+
import copy
|
| 5 |
+
import yaml
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _deep_update(base: Dict[str, Any], update: Dict[str, Any]) -> Dict[str, Any]:
|
| 9 |
+
out = copy.deepcopy(base)
|
| 10 |
+
for k, v in update.items():
|
| 11 |
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
| 12 |
+
out[k] = _deep_update(out[k], v)
|
| 13 |
+
else:
|
| 14 |
+
out[k] = copy.deepcopy(v)
|
| 15 |
+
return out
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def load_yaml(path: str | Path) -> Dict[str, Any]:
|
| 19 |
+
path = Path(path)
|
| 20 |
+
with open(path, "r") as f:
|
| 21 |
+
cfg = yaml.safe_load(f) or {}
|
| 22 |
+
base_files = cfg.pop("_base_", [])
|
| 23 |
+
if isinstance(base_files, str):
|
| 24 |
+
base_files = [base_files]
|
| 25 |
+
merged: Dict[str, Any] = {}
|
| 26 |
+
for base in base_files:
|
| 27 |
+
base_path = (path.parent / base).resolve()
|
| 28 |
+
merged = _deep_update(merged, load_yaml(base_path))
|
| 29 |
+
merged = _deep_update(merged, cfg)
|
| 30 |
+
return merged
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def save_yaml(cfg: Dict[str, Any], path: str | Path) -> None:
|
| 34 |
+
path = Path(path)
|
| 35 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
with open(path, "w") as f:
|
| 37 |
+
yaml.safe_dump(cfg, f, sort_keys=False)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get(cfg: Dict[str, Any], key: str, default=None):
|
| 41 |
+
cur = cfg
|
| 42 |
+
for part in key.split("."):
|
| 43 |
+
if not isinstance(cur, dict) or part not in cur:
|
| 44 |
+
return default
|
| 45 |
+
cur = cur[part]
|
| 46 |
+
return cur
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def set_by_path(cfg: Dict[str, Any], key: str, value: Any) -> None:
|
| 50 |
+
cur = cfg
|
| 51 |
+
parts = key.split(".")
|
| 52 |
+
for p in parts[:-1]:
|
| 53 |
+
cur = cur.setdefault(p, {})
|
| 54 |
+
cur[parts[-1]] = value
|
sacflow/utils/distributed.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import os
|
| 3 |
+
from datetime import timedelta
|
| 4 |
+
import torch
|
| 5 |
+
import torch.distributed as dist
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def is_dist_avail_and_initialized() -> bool:
|
| 9 |
+
return dist.is_available() and dist.is_initialized()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def get_rank() -> int:
|
| 13 |
+
if not is_dist_avail_and_initialized():
|
| 14 |
+
return 0
|
| 15 |
+
return dist.get_rank()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_world_size() -> int:
|
| 19 |
+
if not is_dist_avail_and_initialized():
|
| 20 |
+
return 1
|
| 21 |
+
return dist.get_world_size()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def is_main_process() -> bool:
|
| 25 |
+
return get_rank() == 0
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def init_distributed(backend: str = "nccl") -> torch.device:
|
| 29 |
+
"""Initialize distributed training.
|
| 30 |
+
|
| 31 |
+
Validation on 3D medical volumes can take longer than PyTorch's default
|
| 32 |
+
10 minute NCCL/RCCL watchdog timeout if some ranks are waiting at a
|
| 33 |
+
collective. We therefore set a longer timeout by default. The value can be
|
| 34 |
+
overridden with DIST_TIMEOUT_MINUTES.
|
| 35 |
+
"""
|
| 36 |
+
if "RANK" in os.environ and "WORLD_SIZE" in os.environ:
|
| 37 |
+
local_rank = int(os.environ.get("LOCAL_RANK", 0))
|
| 38 |
+
torch.cuda.set_device(local_rank)
|
| 39 |
+
timeout_min = int(os.environ.get("DIST_TIMEOUT_MINUTES", "180"))
|
| 40 |
+
dist.init_process_group(
|
| 41 |
+
backend=backend,
|
| 42 |
+
init_method="env://",
|
| 43 |
+
timeout=timedelta(minutes=timeout_min),
|
| 44 |
+
)
|
| 45 |
+
device = torch.device("cuda", local_rank)
|
| 46 |
+
else:
|
| 47 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 48 |
+
return device
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def barrier():
|
| 52 |
+
if is_dist_avail_and_initialized():
|
| 53 |
+
if torch.cuda.is_available():
|
| 54 |
+
dist.barrier(device_ids=[torch.cuda.current_device()])
|
| 55 |
+
else:
|
| 56 |
+
dist.barrier()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def cleanup():
|
| 60 |
+
if is_dist_avail_and_initialized():
|
| 61 |
+
dist.destroy_process_group()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def reduce_mean(tensor: torch.Tensor) -> torch.Tensor:
|
| 65 |
+
if not is_dist_avail_and_initialized():
|
| 66 |
+
return tensor
|
| 67 |
+
rt = tensor.detach().clone()
|
| 68 |
+
dist.all_reduce(rt, op=dist.ReduceOp.SUM)
|
| 69 |
+
rt /= get_world_size()
|
| 70 |
+
return rt
|
sacflow/utils/eta.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import time
|
| 3 |
+
|
| 4 |
+
class ETA:
|
| 5 |
+
def __init__(self, total: int, smoothing: float = 0.95):
|
| 6 |
+
self.total = max(1, total)
|
| 7 |
+
self.smoothing = smoothing
|
| 8 |
+
self.start = time.time()
|
| 9 |
+
self.last = self.start
|
| 10 |
+
self.avg = None
|
| 11 |
+
self.n = 0
|
| 12 |
+
|
| 13 |
+
def step(self, count: int = 1):
|
| 14 |
+
now = time.time()
|
| 15 |
+
dt = (now - self.last) / max(1, count)
|
| 16 |
+
self.last = now
|
| 17 |
+
self.n += count
|
| 18 |
+
if self.avg is None:
|
| 19 |
+
self.avg = dt
|
| 20 |
+
else:
|
| 21 |
+
self.avg = self.smoothing * self.avg + (1 - self.smoothing) * dt
|
| 22 |
+
|
| 23 |
+
def eta_seconds(self) -> float:
|
| 24 |
+
rem = max(0, self.total - self.n)
|
| 25 |
+
return rem * (self.avg or 0.0)
|
| 26 |
+
|
| 27 |
+
@staticmethod
|
| 28 |
+
def fmt(seconds: float) -> str:
|
| 29 |
+
seconds = int(seconds)
|
| 30 |
+
h = seconds // 3600
|
| 31 |
+
m = (seconds % 3600) // 60
|
| 32 |
+
s = seconds % 60
|
| 33 |
+
if h > 0:
|
| 34 |
+
return f"{h:d}h {m:02d}m {s:02d}s"
|
| 35 |
+
if m > 0:
|
| 36 |
+
return f"{m:d}m {s:02d}s"
|
| 37 |
+
return f"{s:d}s"
|
sacflow/utils/metrics.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import Dict, List
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
from scipy import ndimage
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def dice_per_class(pred: np.ndarray, target: np.ndarray, num_classes: int, include_bg: bool = False) -> Dict[str, float]:
|
| 9 |
+
out = {}
|
| 10 |
+
start = 0 if include_bg else 1
|
| 11 |
+
vals = []
|
| 12 |
+
for c in range(start, num_classes):
|
| 13 |
+
p = pred == c
|
| 14 |
+
t = target == c
|
| 15 |
+
denom = p.sum() + t.sum()
|
| 16 |
+
if denom == 0:
|
| 17 |
+
d = np.nan
|
| 18 |
+
else:
|
| 19 |
+
d = 2.0 * np.logical_and(p, t).sum() / denom
|
| 20 |
+
out[f"dice_c{c}"] = float(d) if not np.isnan(d) else np.nan
|
| 21 |
+
if not np.isnan(d):
|
| 22 |
+
vals.append(d)
|
| 23 |
+
out["dice_mean"] = float(np.mean(vals)) if vals else np.nan
|
| 24 |
+
return out
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _surface(mask: np.ndarray) -> np.ndarray:
|
| 28 |
+
if mask.sum() == 0:
|
| 29 |
+
return mask.astype(bool)
|
| 30 |
+
eroded = ndimage.binary_erosion(mask, iterations=1, border_value=0)
|
| 31 |
+
return np.logical_xor(mask, eroded)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def hd95_per_class(pred: np.ndarray, target: np.ndarray, num_classes: int, spacing=None, include_bg: bool = False, empty_penalty: str = "diagonal") -> Dict[str, float]:
|
| 35 |
+
"""Compute per-class HD95.
|
| 36 |
+
|
| 37 |
+
If one mask is empty and the other is not, assigning NaN and dropping the
|
| 38 |
+
class makes failed segmentations look artificially good. We instead use the
|
| 39 |
+
physical image diagonal as a conservative finite penalty. If both masks are
|
| 40 |
+
empty, the class is not present and is excluded from the mean.
|
| 41 |
+
"""
|
| 42 |
+
out = {}
|
| 43 |
+
start = 0 if include_bg else 1
|
| 44 |
+
vals = []
|
| 45 |
+
if spacing is None:
|
| 46 |
+
spacing = (1.0, 1.0, 1.0)
|
| 47 |
+
spacing = tuple(float(x) for x in spacing)
|
| 48 |
+
diag = float(np.sqrt(sum(((s * max(1, n - 1)) ** 2) for s, n in zip(spacing, target.shape))))
|
| 49 |
+
for c in range(start, num_classes):
|
| 50 |
+
p = pred == c
|
| 51 |
+
t = target == c
|
| 52 |
+
p_sum = int(p.sum())
|
| 53 |
+
t_sum = int(t.sum())
|
| 54 |
+
if p_sum == 0 and t_sum == 0:
|
| 55 |
+
hd = np.nan
|
| 56 |
+
elif p_sum == 0 or t_sum == 0:
|
| 57 |
+
hd = diag if empty_penalty == "diagonal" else np.nan
|
| 58 |
+
else:
|
| 59 |
+
ps = _surface(p)
|
| 60 |
+
ts = _surface(t)
|
| 61 |
+
if ps.sum() == 0 or ts.sum() == 0:
|
| 62 |
+
hd = diag if empty_penalty == "diagonal" else np.nan
|
| 63 |
+
else:
|
| 64 |
+
dt_t = ndimage.distance_transform_edt(~ts, sampling=spacing)
|
| 65 |
+
dt_p = ndimage.distance_transform_edt(~ps, sampling=spacing)
|
| 66 |
+
dists = np.concatenate([dt_t[ps], dt_p[ts]])
|
| 67 |
+
hd = np.percentile(dists, 95) if dists.size else np.nan
|
| 68 |
+
out[f"hd95_c{c}"] = float(hd) if not np.isnan(hd) else np.nan
|
| 69 |
+
if not np.isnan(hd):
|
| 70 |
+
vals.append(hd)
|
| 71 |
+
out["hd95_mean"] = float(np.mean(vals)) if vals else np.nan
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def torch_soft_dice_loss(logits: torch.Tensor, target: torch.Tensor, num_classes: int, ignore_index: int | None = None, eps: float = 1e-5):
|
| 76 |
+
probs = torch.softmax(logits, dim=1)
|
| 77 |
+
if target.ndim == logits.ndim:
|
| 78 |
+
onehot = target.float()
|
| 79 |
+
else:
|
| 80 |
+
target_clamped = target.clamp(0, num_classes - 1).long()
|
| 81 |
+
onehot = torch.nn.functional.one_hot(target_clamped, num_classes).permute(0, 4, 1, 2, 3).float()
|
| 82 |
+
if ignore_index is not None:
|
| 83 |
+
mask = target != ignore_index
|
| 84 |
+
probs = probs * mask.unsqueeze(1)
|
| 85 |
+
onehot = onehot * mask.unsqueeze(1)
|
| 86 |
+
dims = tuple(range(2, logits.ndim))
|
| 87 |
+
inter = (probs * onehot).sum(dims)
|
| 88 |
+
denom = probs.sum(dims) + onehot.sum(dims)
|
| 89 |
+
dice = (2 * inter + eps) / (denom + eps)
|
| 90 |
+
return 1.0 - dice[:, 1:].mean()
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def entropy_loss(logits: torch.Tensor, eps: float = 1e-8):
|
| 94 |
+
p = torch.softmax(logits, dim=1)
|
| 95 |
+
return -(p * (p + eps).log()).sum(dim=1).mean()
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def confidence_and_margin(probs: torch.Tensor):
|
| 99 |
+
vals, inds = probs.topk(k=2, dim=1)
|
| 100 |
+
conf = vals[:, 0]
|
| 101 |
+
margin = vals[:, 0] - vals[:, 1]
|
| 102 |
+
pred = inds[:, 0]
|
| 103 |
+
return conf, margin, pred
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def finite_difference_boundary(x: torch.Tensor) -> torch.Tensor:
|
| 107 |
+
# x: [B,C,H,W,D] probabilities or one-hot maps
|
| 108 |
+
dx = torch.abs(x[:, :, 1:] - x[:, :, :-1]).mean(dim=1, keepdim=True)
|
| 109 |
+
dx = torch.nn.functional.pad(dx, (0,0,0,0,1,0))
|
| 110 |
+
dy = torch.abs(x[:, :, :, 1:] - x[:, :, :, :-1]).mean(dim=1, keepdim=True)
|
| 111 |
+
dy = torch.nn.functional.pad(dy, (0,0,1,0,0,0))
|
| 112 |
+
dz = torch.abs(x[:, :, :, :, 1:] - x[:, :, :, :, :-1]).mean(dim=1, keepdim=True)
|
| 113 |
+
dz = torch.nn.functional.pad(dz, (1,0,0,0,0,0))
|
| 114 |
+
return (dx + dy + dz) / 3.0
|
sacflow/utils/misc.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import os
|
| 3 |
+
import random
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
import numpy as np
|
| 6 |
+
import torch
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def seed_everything(seed: int) -> None:
|
| 10 |
+
random.seed(seed)
|
| 11 |
+
np.random.seed(seed)
|
| 12 |
+
torch.manual_seed(seed)
|
| 13 |
+
torch.cuda.manual_seed_all(seed)
|
| 14 |
+
os.environ["PYTHONHASHSEED"] = str(seed)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def ensure_dir(path: str | Path) -> Path:
|
| 18 |
+
p = Path(path)
|
| 19 |
+
p.mkdir(parents=True, exist_ok=True)
|
| 20 |
+
return p
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def count_trainable(model: torch.nn.Module) -> tuple[int, int]:
|
| 24 |
+
total = sum(p.numel() for p in model.parameters())
|
| 25 |
+
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| 26 |
+
return trainable, total
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def move_to_device(batch, device):
|
| 30 |
+
if isinstance(batch, torch.Tensor):
|
| 31 |
+
return batch.to(device, non_blocking=True)
|
| 32 |
+
if isinstance(batch, dict):
|
| 33 |
+
return {k: move_to_device(v, device) for k, v in batch.items()}
|
| 34 |
+
if isinstance(batch, (list, tuple)):
|
| 35 |
+
return type(batch)(move_to_device(v, device) for v in batch)
|
| 36 |
+
return batch
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def unwrap_model(model):
|
| 40 |
+
return model.module if hasattr(model, "module") else model
|
sacflow/utils/wandb_utils.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from typing import Any, Dict
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
try:
|
| 6 |
+
import wandb
|
| 7 |
+
except Exception: # pragma: no cover
|
| 8 |
+
wandb = None
|
| 9 |
+
from .distributed import is_main_process
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _read_text(path: Path) -> str | None:
|
| 13 |
+
try:
|
| 14 |
+
if path.exists():
|
| 15 |
+
text = path.read_text().strip()
|
| 16 |
+
return text or None
|
| 17 |
+
except Exception:
|
| 18 |
+
pass
|
| 19 |
+
return None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _write_text(path: Path, text: str) -> None:
|
| 23 |
+
try:
|
| 24 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
path.write_text(text.strip() + "\n")
|
| 26 |
+
except Exception:
|
| 27 |
+
# W&B should never crash training just because the local ID file could not be written.
|
| 28 |
+
pass
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _env_int(name: str, default: int) -> int:
|
| 32 |
+
try:
|
| 33 |
+
return int(os.environ.get(name, default))
|
| 34 |
+
except Exception:
|
| 35 |
+
return default
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def init_wandb(cfg: Dict[str, Any], run_name: str | None = None):
|
| 39 |
+
"""Initialize W&B on rank 0 only and never crash training if W&B is slow/down.
|
| 40 |
+
|
| 41 |
+
Resume behavior:
|
| 42 |
+
- If wandb.id or WANDB_RUN_ID is provided, use it.
|
| 43 |
+
- Otherwise, if <output_dir>/wandb_run_id.txt exists, reuse that ID.
|
| 44 |
+
- If a run ID is used, default resume mode is "allow" unless overridden by
|
| 45 |
+
wandb.resume or WANDB_RESUME.
|
| 46 |
+
|
| 47 |
+
Reliability behavior:
|
| 48 |
+
- WANDB_INIT_TIMEOUT controls wandb.init timeout seconds (default 300).
|
| 49 |
+
- WANDB_SERVICE_WAIT controls W&B service startup wait seconds (default 300).
|
| 50 |
+
- If wandb.init raises any exception, training continues with W&B disabled.
|
| 51 |
+
"""
|
| 52 |
+
wb_cfg = cfg.get("wandb", {})
|
| 53 |
+
if not wb_cfg.get("enabled", False) or not is_main_process() or wandb is None:
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
mode = os.environ.get("WANDB_MODE", wb_cfg.get("mode", "online"))
|
| 57 |
+
entity = wb_cfg.get("entity") or os.environ.get("WANDB_ENTITY") or None
|
| 58 |
+
project = os.environ.get("WANDB_PROJECT", wb_cfg.get("project", "SACFlow-FM"))
|
| 59 |
+
|
| 60 |
+
out_dir = Path(cfg.get("output_dir", "."))
|
| 61 |
+
run_id_file = out_dir / "wandb_run_id.txt"
|
| 62 |
+
|
| 63 |
+
run_id = (
|
| 64 |
+
wb_cfg.get("id")
|
| 65 |
+
or os.environ.get("WANDB_RUN_ID")
|
| 66 |
+
or _read_text(run_id_file)
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
resume_mode = wb_cfg.get("resume") or os.environ.get("WANDB_RESUME")
|
| 70 |
+
if run_id and resume_mode is None:
|
| 71 |
+
resume_mode = "allow"
|
| 72 |
+
|
| 73 |
+
init_timeout = _env_int("WANDB_INIT_TIMEOUT", int(wb_cfg.get("init_timeout", 300)))
|
| 74 |
+
service_wait = _env_int("WANDB_SERVICE_WAIT", int(wb_cfg.get("service_wait", 300)))
|
| 75 |
+
|
| 76 |
+
# Keep W&B files away from home/cache quotas if an output directory exists.
|
| 77 |
+
os.environ.setdefault("WANDB_DIR", str(out_dir / "wandb"))
|
| 78 |
+
os.environ.setdefault("WANDB_CACHE_DIR", str(out_dir / "wandb_cache"))
|
| 79 |
+
os.environ.setdefault("WANDB_CONFIG_DIR", str(out_dir / "wandb_config"))
|
| 80 |
+
|
| 81 |
+
try:
|
| 82 |
+
settings = wandb.Settings(init_timeout=init_timeout, _service_wait=service_wait)
|
| 83 |
+
run = wandb.init(
|
| 84 |
+
project=project,
|
| 85 |
+
entity=entity,
|
| 86 |
+
mode=mode,
|
| 87 |
+
name=run_name,
|
| 88 |
+
id=run_id,
|
| 89 |
+
resume=resume_mode,
|
| 90 |
+
tags=wb_cfg.get("tags", []),
|
| 91 |
+
config=cfg,
|
| 92 |
+
settings=settings,
|
| 93 |
+
)
|
| 94 |
+
except Exception as e: # pragma: no cover - depends on external W&B service/network
|
| 95 |
+
print(
|
| 96 |
+
f"[W&B WARNING] wandb.init failed ({type(e).__name__}: {e}). "
|
| 97 |
+
"Continuing training with W&B disabled for this run. "
|
| 98 |
+
"To avoid this, use WANDB_MODE=offline or increase WANDB_INIT_TIMEOUT.",
|
| 99 |
+
flush=True,
|
| 100 |
+
)
|
| 101 |
+
return None
|
| 102 |
+
|
| 103 |
+
# Persist the generated or reused run ID so future --resume auto attaches to
|
| 104 |
+
# the same W&B run from this output directory.
|
| 105 |
+
if getattr(run, "id", None):
|
| 106 |
+
_write_text(run_id_file, run.id)
|
| 107 |
+
|
| 108 |
+
return run
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def wandb_log(run, data: Dict[str, Any], step: int | None = None):
|
| 112 |
+
if run is not None:
|
| 113 |
+
try:
|
| 114 |
+
run.log(data, step=step)
|
| 115 |
+
except Exception as e:
|
| 116 |
+
print(f"[W&B WARNING] wandb.log failed: {e}", flush=True)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def wandb_finish(run):
|
| 120 |
+
if run is not None:
|
| 121 |
+
try:
|
| 122 |
+
run.finish()
|
| 123 |
+
except Exception as e:
|
| 124 |
+
print(f"[W&B WARNING] wandb.finish failed: {e}", flush=True)
|