PET / scripts /train_pet_foundation.py
DesonDai's picture
Add files using upload-large-folder tool
212e9d7 verified
Raw
History Blame Contribute Delete
22.7 kB
from __future__ import annotations
import argparse
import inspect
import importlib.util
from pathlib import Path
import sys
import types
import torch
from torch import nn
from torch.utils.data import DataLoader
from pet_vlm_dataset import PETSUVRDataset, collate_pet_suvr
class MedicalNetBottleneck(nn.Module):
expansion = 4
def __init__(self, inplanes: int, planes: int, stride: int = 1, downsample: nn.Module | None = None) -> None:
super().__init__()
self.conv1 = nn.Conv3d(inplanes, planes, kernel_size=1, bias=False)
self.bn1 = nn.BatchNorm3d(planes)
self.conv2 = nn.Conv3d(planes, planes, kernel_size=3, stride=stride, padding=1, bias=False)
self.bn2 = nn.BatchNorm3d(planes)
self.conv3 = nn.Conv3d(planes, planes * self.expansion, kernel_size=1, bias=False)
self.bn3 = nn.BatchNorm3d(planes * self.expansion)
self.relu = nn.ReLU(inplace=True)
self.downsample = downsample
def forward(self, x: torch.Tensor) -> torch.Tensor:
residual = x
out = self.relu(self.bn1(self.conv1(x)))
out = self.relu(self.bn2(self.conv2(out)))
out = self.bn3(self.conv3(out))
if self.downsample is not None:
residual = self.downsample(x)
out = self.relu(out + residual)
return out
class MedicalNetResNet50(nn.Module):
out_dim = 2048
def __init__(self) -> None:
super().__init__()
self.inplanes = 64
self.conv1 = nn.Conv3d(1, 64, kernel_size=7, stride=(2, 2, 2), padding=(3, 3, 3), bias=False)
self.bn1 = nn.BatchNorm3d(64)
self.relu = nn.ReLU(inplace=True)
self.maxpool = nn.MaxPool3d(kernel_size=3, stride=2, padding=1)
self.layer1 = self._make_layer(64, 3)
self.layer2 = self._make_layer(128, 4, stride=2)
self.layer3 = self._make_layer(256, 6, stride=2)
self.layer4 = self._make_layer(512, 3, stride=2)
self.pool = nn.AdaptiveAvgPool3d(1)
def _make_layer(self, planes: int, blocks: int, stride: int = 1) -> nn.Sequential:
downsample = None
if stride != 1 or self.inplanes != planes * MedicalNetBottleneck.expansion:
downsample = nn.Sequential(
nn.Conv3d(self.inplanes, planes * MedicalNetBottleneck.expansion, kernel_size=1, stride=stride, bias=False),
nn.BatchNorm3d(planes * MedicalNetBottleneck.expansion),
)
layers = [MedicalNetBottleneck(self.inplanes, planes, stride, downsample)]
self.inplanes = planes * MedicalNetBottleneck.expansion
for _ in range(1, blocks):
layers.append(MedicalNetBottleneck(self.inplanes, planes))
return nn.Sequential(*layers)
def forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.maxpool(self.relu(self.bn1(self.conv1(x))))
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
return self.pool(x).flatten(1)
def load_medicalnet(path: Path) -> MedicalNetResNet50:
model = MedicalNetResNet50()
obj = torch.load(path, map_location="cpu")
state = obj.get("state_dict", obj)
state = {k.removeprefix("module."): v for k, v in state.items()}
missing, unexpected = model.load_state_dict(state, strict=False)
if unexpected:
print(f"unexpected_keys={unexpected[:8]}", flush=True)
if missing:
print(f"missing_keys={missing[:8]}", flush=True)
return model
class BrainIACEncoder(nn.Module):
out_dim = 768
def __init__(self, weights_path: Path) -> None:
super().__init__()
from monai.networks.nets import ViT
from safetensors.torch import load_file
self.model = ViT(
in_channels=1,
img_size=(96, 96, 96),
patch_size=(16, 16, 16),
hidden_size=768,
mlp_dim=3072,
num_layers=12,
num_heads=12,
)
weights = load_file(str(weights_path))
missing, unexpected = self.model.load_state_dict(weights, strict=False)
if unexpected:
print(f"brainiac_unexpected_keys={unexpected[:8]}", flush=True)
if missing:
print(f"brainiac_missing_keys={missing[:8]}", flush=True)
def forward(self, image: torch.Tensor) -> torch.Tensor:
output = self.model(image)
tokens = output[0] if isinstance(output, tuple) else output
return tokens[:, 0]
class SwinUNETREncoder(nn.Module):
out_dim = 768
def __init__(self, weights_path: Path, img_size: tuple[int, int, int]) -> None:
super().__init__()
from monai.networks.nets import SwinUNETR
kwargs = {
"in_channels": 1,
"out_channels": 2,
"feature_size": 48,
"use_checkpoint": False,
"spatial_dims": 3,
}
if "img_size" in inspect.signature(SwinUNETR).parameters:
kwargs["img_size"] = img_size
self.model = SwinUNETR(**kwargs)
weights = torch.load(weights_path, map_location="cpu", weights_only=False)
if hasattr(self.model, "load_from"):
self.model.load_from(weights)
else:
state = weights.get("state_dict", weights.get("model", weights))
remapped = {}
for key, value in state.items():
key = key.removeprefix("module.")
if key.startswith("encoder."):
key = "swinViT." + key[len("encoder.") :]
remapped[key] = value
missing, unexpected = self.model.load_state_dict(remapped, strict=False)
if unexpected:
print(f"swinunetr_unexpected_keys={unexpected[:8]}", flush=True)
if missing:
print(f"swinunetr_missing_keys={missing[:8]}", flush=True)
self.pool = nn.AdaptiveAvgPool3d(1)
def forward(self, image: torch.Tensor) -> torch.Tensor:
hidden = self.model.swinViT(image, self.model.normalize)
feat = hidden[-1]
return self.pool(feat).flatten(1)
class SAMMed3DEncoder(nn.Module):
out_dim = 384
def __init__(self, weights_path: Path) -> None:
super().__init__()
try:
import medim
except ImportError as exc:
raise ImportError("SAM-Med3D requires `medim`. Install it before using --backbone sam_med3d.") from exc
self.model = medim.create_model("SAM-Med3D", pretrained=True, checkpoint_path=str(weights_path))
self.image_encoder = getattr(self.model, "image_encoder", self.model)
self.pool = nn.AdaptiveAvgPool3d(1)
def forward(self, image: torch.Tensor) -> torch.Tensor:
output = self.image_encoder(image)
if isinstance(output, dict):
output = output.get("image_embeddings", output.get("embeddings", next(iter(output.values()))))
if isinstance(output, (list, tuple)):
output = output[0]
if output.ndim == 2:
return output
if output.ndim == 3:
return output.mean(dim=1)
return self.pool(output).flatten(1)
class BrainFMEncoder(nn.Module):
out_dim = 2048
def __init__(self, weights_path: Path, code_root: Path) -> None:
super().__init__()
unet_root = (code_root / "Trainer" / "models" / "unet3d").resolve()
package = types.ModuleType("brainfm_unet")
package.__path__ = [str(unet_root)]
sys.modules.setdefault("brainfm_unet", package)
spec = importlib.util.spec_from_file_location("brainfm_unet.model", unet_root / "model.py")
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load BrainFM model code from {unet_root}")
module = importlib.util.module_from_spec(spec)
sys.modules["brainfm_unet.model"] = module
spec.loader.exec_module(module)
ckpt = torch.load(weights_path, map_location="cpu", weights_only=False)
train_args = ckpt["train_args"]
self.model = module.UNet3D(
train_args.in_channels,
train_args.f_maps,
train_args.layer_order,
train_args.num_groups,
train_args.num_levels,
train_args.unit_feat,
)
state = {k.removeprefix("backbone."): v for k, v in ckpt["model"].items() if k.startswith("backbone.")}
missing, unexpected = self.model.load_state_dict(state, strict=False)
if unexpected:
print(f"brainfm_unexpected_keys={unexpected[:8]}", flush=True)
if missing:
print(f"brainfm_missing_keys={missing[:8]}", flush=True)
self.pool = nn.AdaptiveAvgPool3d(1)
def forward(self, image: torch.Tensor) -> torch.Tensor:
features = self.model.get_feature(image)
bottleneck = features[0]
return self.pool(bottleneck).flatten(1)
class Small3DPETEncoder(nn.Module):
out_dim = 256
def __init__(self) -> None:
super().__init__()
self.net = nn.Sequential(
nn.Conv3d(1, 16, 3, stride=2, padding=1),
nn.BatchNorm3d(16),
nn.GELU(),
nn.Conv3d(16, 32, 3, stride=2, padding=1),
nn.BatchNorm3d(32),
nn.GELU(),
nn.Conv3d(32, 64, 3, stride=2, padding=1),
nn.BatchNorm3d(64),
nn.GELU(),
nn.Conv3d(64, 128, 3, stride=2, padding=1),
nn.BatchNorm3d(128),
nn.GELU(),
nn.AdaptiveAvgPool3d(1),
)
self.proj = nn.Linear(128, self.out_dim)
def forward(self, image: torch.Tensor) -> torch.Tensor:
return self.proj(self.net(image).flatten(1))
class RegionSUVREncoder(nn.Module):
def __init__(self, n_regions: int, embed_dim: int) -> None:
super().__init__()
self.net = nn.Sequential(
nn.LayerNorm(n_regions),
nn.Linear(n_regions, embed_dim),
nn.GELU(),
nn.Linear(embed_dim, embed_dim),
)
def forward(self, suvr: torch.Tensor) -> torch.Tensor:
return self.net(suvr)
class PETSUVRFoundationModel(nn.Module):
def __init__(self, pet_encoder: nn.Module, n_regions: int, embed_dim: int = 256, freeze_encoder: bool = True) -> None:
super().__init__()
self.pet_encoder = pet_encoder
self.freeze_encoder = freeze_encoder
if freeze_encoder:
for p in self.pet_encoder.parameters():
p.requires_grad = False
self.pet_encoder.eval()
self.pet_projector = nn.Sequential(nn.LayerNorm(pet_encoder.out_dim), nn.Linear(pet_encoder.out_dim, embed_dim))
self.suvr_encoder = RegionSUVREncoder(n_regions, embed_dim)
self.suvr_head = nn.Sequential(nn.LayerNorm(embed_dim), nn.Linear(embed_dim, n_regions))
self.temperature = nn.Parameter(torch.tensor(0.07))
def forward(self, image: torch.Tensor, suvr: torch.Tensor) -> dict[str, torch.Tensor]:
if self.freeze_encoder:
with torch.no_grad():
pet_feat = self.pet_encoder(image)
else:
pet_feat = self.pet_encoder(image)
pet_z = nn.functional.normalize(self.pet_projector(pet_feat), dim=-1)
suvr_z = nn.functional.normalize(self.suvr_encoder(suvr), dim=-1)
pred_suvr = self.suvr_head(pet_z)
logits = pet_z @ suvr_z.T / self.temperature.clamp_min(0.01)
return {"logits": logits, "pred_suvr": pred_suvr}
def alignment_loss(
outputs: dict[str, torch.Tensor],
suvr: torch.Tensor,
contrastive_weight: float = 1.0,
regression_weight: float = 1.0,
) -> tuple[torch.Tensor, dict[str, float]]:
labels = torch.arange(suvr.shape[0], device=suvr.device)
loss_i = nn.functional.cross_entropy(outputs["logits"], labels)
loss_t = nn.functional.cross_entropy(outputs["logits"].T, labels)
loss_contrastive = 0.5 * (loss_i + loss_t)
loss_reg = nn.functional.mse_loss(outputs["pred_suvr"], suvr)
loss = contrastive_weight * loss_contrastive + regression_weight * loss_reg
return loss, {
"contrastive": float(loss_contrastive.detach()),
"regression": float(loss_reg.detach()),
}
def build_encoder(args: argparse.Namespace) -> nn.Module:
if args.backbone == "small_cnn":
return Small3DPETEncoder()
if args.backbone == "medicalnet":
return load_medicalnet(args.medicalnet_weights)
if args.backbone == "brainiac":
return BrainIACEncoder(args.brainiac_weights)
if args.backbone == "swinunetr":
return SwinUNETREncoder(args.swinunetr_weights, tuple(args.output_size))
if args.backbone == "sam_med3d":
return SAMMed3DEncoder(args.sam_med3d_weights)
if args.backbone == "brainfm":
return BrainFMEncoder(args.brainfm_weights, args.brainfm_code_root)
raise ValueError(f"Unsupported backbone: {args.backbone}")
def _set_trainable(module: nn.Module | None, trainable: bool) -> None:
if module is None:
return
for p in module.parameters():
p.requires_grad = trainable
def _last_vit_block(module: nn.Module) -> nn.Module | None:
blocks = getattr(module, "blocks", None)
if isinstance(blocks, (nn.ModuleList, list, tuple)) and len(blocks) > 0:
return blocks[-1]
return None
def _last_swin_stage(module: nn.Module) -> nn.Module | None:
swin = getattr(module, "swinViT", None)
if swin is None:
return None
for name in ("layers4", "layers3", "layers2", "layers1"):
layer = getattr(swin, name, None)
if layer is not None:
return layer
return None
def _brainfm_last_stage(module: nn.Module) -> nn.Module | None:
for name in ("encoders", "encoder", "down_path"):
stage = getattr(module, name, None)
if isinstance(stage, (nn.ModuleList, nn.Sequential)) and len(stage) > 0:
return stage[-1]
return None
def _sam_last_stage(module: nn.Module) -> nn.Module | None:
for name in ("blocks", "layers", "neck"):
stage = getattr(module, name, None)
if isinstance(stage, (nn.ModuleList, list, tuple)) and len(stage) > 0:
return stage[-1]
if isinstance(stage, nn.Module):
return stage
return None
def unfreeze_last_block(encoder: nn.Module) -> None:
_set_trainable(encoder, False)
target = None
if isinstance(encoder, MedicalNetResNet50):
target = encoder.layer4
elif isinstance(encoder, BrainIACEncoder):
target = _last_vit_block(encoder.model)
elif isinstance(encoder, SwinUNETREncoder):
target = _last_swin_stage(encoder.model)
elif isinstance(encoder, BrainFMEncoder):
target = _brainfm_last_stage(encoder.model)
elif isinstance(encoder, SAMMed3DEncoder):
target = _sam_last_stage(encoder.image_encoder)
if target is None:
raise ValueError(f"Could not identify a last block for {encoder.__class__.__name__}.")
_set_trainable(target, True)
def configure_encoder_training(model: PETSUVRFoundationModel, scope: str) -> None:
if scope == "none":
for p in model.pet_encoder.parameters():
p.requires_grad = False
return
if scope == "all":
for p in model.pet_encoder.parameters():
p.requires_grad = True
return
if scope == "layer4":
for p in model.pet_encoder.parameters():
p.requires_grad = False
if not hasattr(model.pet_encoder, "layer4"):
raise ValueError("encoder_train_scope=layer4 is only supported for MedicalNet-style encoders.")
for p in model.pet_encoder.layer4.parameters():
p.requires_grad = True
return
if scope == "last_block":
unfreeze_last_block(model.pet_encoder)
return
raise ValueError(f"Unsupported encoder training scope: {scope}")
def set_encoder_mode_for_scope(model: PETSUVRFoundationModel, scope: str) -> None:
if scope == "none":
model.pet_encoder.eval()
elif scope in {"layer4", "last_block"}:
model.pet_encoder.eval()
for module in model.pet_encoder.modules():
if any(p.requires_grad for p in module.parameters(recurse=False)):
module.train()
def main() -> None:
parser = argparse.ArgumentParser(description="Train PET-SUVR alignment with pretrained 3D backbones.")
parser.add_argument("--backbone", choices=["small_cnn", "medicalnet", "brainiac", "brainfm", "swinunetr", "sam_med3d"], default="medicalnet")
parser.add_argument("--medicalnet-weights", type=Path, default=Path("pretrained/medicalnet/resnet_50_23dataset.pth"))
parser.add_argument("--brainiac-weights", type=Path, default=Path("pretrained/brainiac/backbone.safetensors"))
parser.add_argument("--brainfm-weights", type=Path, default=Path("pretrained/brainfm/assets/brainfm_pretrained.pth"))
parser.add_argument("--brainfm-code-root", type=Path, default=Path("pretrained/brainfm"))
parser.add_argument("--swinunetr-weights", type=Path, default=Path("pretrained/swinunetr/model_swinvit.pt"))
parser.add_argument("--sam-med3d-weights", type=Path, default=Path("pretrained/sam-med3d/sam_med3d_turbo.pth"))
parser.add_argument("--manifest", type=Path, default=Path("metadata/splits/train.csv"))
parser.add_argument("--val-manifest", type=Path, default=Path("metadata/splits/val.csv"))
parser.add_argument("--epochs", type=int, default=10)
parser.add_argument("--batch-size", type=int, default=2)
parser.add_argument("--lr", type=float, default=1e-4)
parser.add_argument("--num-workers", type=int, default=2)
parser.add_argument("--output-size", type=int, nargs=3, default=(96, 96, 96))
parser.add_argument("--embed-dim", type=int, default=256)
parser.add_argument("--freeze-encoder", action=argparse.BooleanOptionalAction, default=True)
parser.add_argument("--encoder-train-scope", choices=["none", "layer4", "last_block", "all"], default=None)
parser.add_argument("--contrastive-weight", type=float, default=1.0)
parser.add_argument("--regression-weight", type=float, default=1.0)
parser.add_argument("--log-every", type=int, default=10)
parser.add_argument("--out", type=Path, default=Path("runs/foundation.pt"))
parser.add_argument("--best-out", type=Path, default=None)
args = parser.parse_args()
if args.encoder_train_scope is None:
args.encoder_train_scope = "none" if args.freeze_encoder else "all"
args.freeze_encoder = args.encoder_train_scope == "none"
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
train_dataset = PETSUVRDataset(args.manifest, output_size=tuple(args.output_size))
val_dataset = PETSUVRDataset(args.val_manifest, output_size=tuple(args.output_size))
train_loader = DataLoader(train_dataset, batch_size=args.batch_size, shuffle=True, num_workers=args.num_workers, collate_fn=collate_pet_suvr)
val_loader = DataLoader(val_dataset, batch_size=args.batch_size, shuffle=False, num_workers=args.num_workers, collate_fn=collate_pet_suvr)
sample = train_dataset[0]
encoder = build_encoder(args)
model = PETSUVRFoundationModel(encoder, int(sample["suvr"].numel()), args.embed_dim, args.freeze_encoder).to(device)
configure_encoder_training(model, args.encoder_train_scope)
optimizer = torch.optim.AdamW((p for p in model.parameters() if p.requires_grad), lr=args.lr, weight_decay=1e-4)
best_val_loss = float("inf")
best_out = args.best_out or args.out.with_name(args.out.stem + "_best" + args.out.suffix)
print(
f"device={device} backbone={args.backbone} encoder_scope={args.encoder_train_scope} "
f"contrastive_weight={args.contrastive_weight} regression_weight={args.regression_weight} "
f"train={len(train_dataset)} val={len(val_dataset)}",
flush=True,
)
for epoch in range(1, args.epochs + 1):
model.train()
set_encoder_mode_for_scope(model, args.encoder_train_scope)
train_loss = 0.0
train_contrastive = 0.0
train_regression = 0.0
for step, batch in enumerate(train_loader, start=1):
image = batch["image"].to(device, non_blocking=True)
suvr = batch["suvr"].to(device, non_blocking=True)
outputs = model(image, suvr)
loss, parts = alignment_loss(outputs, suvr, args.contrastive_weight, args.regression_weight)
optimizer.zero_grad(set_to_none=True)
loss.backward()
optimizer.step()
train_loss += float(loss.detach()) * image.shape[0]
train_contrastive += parts["contrastive"] * image.shape[0]
train_regression += parts["regression"] * image.shape[0]
if args.log_every and step % args.log_every == 0:
print(f"epoch={epoch} step={step}/{len(train_loader)} loss={float(loss.detach()):.4f}", flush=True)
train_loss /= len(train_dataset)
train_contrastive /= len(train_dataset)
train_regression /= len(train_dataset)
model.eval()
val_loss = 0.0
val_contrastive = 0.0
val_regression = 0.0
with torch.no_grad():
for batch in val_loader:
image = batch["image"].to(device, non_blocking=True)
suvr = batch["suvr"].to(device, non_blocking=True)
loss, parts = alignment_loss(model(image, suvr), suvr, args.contrastive_weight, args.regression_weight)
val_loss += float(loss) * image.shape[0]
val_contrastive += parts["contrastive"] * image.shape[0]
val_regression += parts["regression"] * image.shape[0]
val_loss /= len(val_dataset)
val_contrastive /= len(val_dataset)
val_regression /= len(val_dataset)
print(
f"epoch={epoch} train_loss={train_loss:.4f} train_contrastive={train_contrastive:.4f} "
f"train_regression={train_regression:.4f} val_loss={val_loss:.4f} "
f"val_contrastive={val_contrastive:.4f} val_regression={val_regression:.4f}",
flush=True,
)
if val_loss < best_val_loss:
best_val_loss = val_loss
best_out.parent.mkdir(parents=True, exist_ok=True)
torch.save({"model": model.state_dict(), "args": vars(args), "best_val_loss": best_val_loss, "epoch": epoch}, best_out)
print(f"saved_best {best_out} val_loss={best_val_loss:.4f} epoch={epoch}", flush=True)
args.out.parent.mkdir(parents=True, exist_ok=True)
torch.save({"model": model.state_dict(), "args": vars(args), "best_val_loss": best_val_loss}, args.out)
print(f"saved {args.out}", flush=True)
if __name__ == "__main__":
main()