sathiiii commited on
Commit
d65ae7d
·
verified ·
1 Parent(s): eea9a47

Add SACFlow source code

Browse files
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)