| from __future__ import annotations |
|
|
| import argparse |
| from pathlib import Path |
|
|
| import torch |
| from torch import nn |
| from torch.utils.data import DataLoader |
|
|
| from pet_vlm_dataset import PETSUVRDataset, collate_pet_suvr |
|
|
|
|
| class Small3DPETEncoder(nn.Module): |
| def __init__(self, embed_dim: int = 256) -> 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, embed_dim) |
|
|
| def forward(self, image: torch.Tensor) -> torch.Tensor: |
| x = self.net(image).flatten(1) |
| return self.proj(x) |
|
|
|
|
| class RegionSUVREncoder(nn.Module): |
| def __init__(self, n_regions: int = 120, embed_dim: int = 256) -> None: |
| super().__init__() |
| self.net = nn.Sequential( |
| nn.LayerNorm(n_regions), |
| nn.Linear(n_regions, 256), |
| nn.GELU(), |
| nn.Linear(256, embed_dim), |
| ) |
|
|
| def forward(self, suvr: torch.Tensor) -> torch.Tensor: |
| return self.net(suvr) |
|
|
|
|
| class PETSUVRAlignmentModel(nn.Module): |
| def __init__(self, n_regions: int = 120, embed_dim: int = 256) -> None: |
| super().__init__() |
| self.pet_encoder = Small3DPETEncoder(embed_dim) |
| self.suvr_encoder = RegionSUVREncoder(n_regions, embed_dim) |
| self.suvr_head = 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]: |
| pet_z = nn.functional.normalize(self.pet_encoder(image), 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 {"pet_z": pet_z, "suvr_z": suvr_z, "pred_suvr": pred_suvr, "logits": logits} |
|
|
|
|
| def alignment_loss(outputs: dict[str, torch.Tensor], suvr: torch.Tensor) -> torch.Tensor: |
| 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_reg = nn.functional.mse_loss(outputs["pred_suvr"], suvr) |
| return 0.5 * (loss_i + loss_t) + loss_reg |
|
|
|
|
| def main() -> None: |
| parser = argparse.ArgumentParser(description="Train a minimal FDG-PET + SUVR alignment baseline.") |
| 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=2) |
| 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=0) |
| parser.add_argument("--output-size", type=int, nargs=3, default=(96, 96, 96)) |
| parser.add_argument("--max-samples", type=int, default=0, help="Use the first N samples for a quick smoke test.") |
| parser.add_argument("--log-every", type=int, default=10) |
| parser.add_argument("--out", type=Path, default=Path("runs/pet_suvr_baseline.pt")) |
| args = parser.parse_args() |
|
|
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") |
| dataset = PETSUVRDataset(args.manifest, output_size=tuple(args.output_size)) |
| val_dataset = PETSUVRDataset(args.val_manifest, output_size=tuple(args.output_size)) |
| if args.max_samples > 0: |
| max_samples = min(args.max_samples, len(dataset)) |
| dataset = torch.utils.data.Subset(dataset, range(max_samples)) |
| val_max_samples = min(max(1, args.max_samples // 4), len(val_dataset)) |
| val_dataset = torch.utils.data.Subset(val_dataset, range(val_max_samples)) |
| train_loader = DataLoader(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 = dataset[0] |
| model = PETSUVRAlignmentModel(n_regions=int(sample["suvr"].numel())).to(device) |
| optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4) |
|
|
| for epoch in range(1, args.epochs + 1): |
| model.train() |
| train_loss = 0.0 |
| for step, batch in enumerate(train_loader, start=1): |
| image = batch["image"].to(device) |
| suvr = batch["suvr"].to(device) |
| outputs = model(image, suvr) |
| loss = alignment_loss(outputs, suvr) |
| optimizer.zero_grad(set_to_none=True) |
| loss.backward() |
| optimizer.step() |
| train_loss += float(loss.detach()) * image.shape[0] |
| if args.log_every > 0 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(dataset) |
|
|
| model.eval() |
| val_loss = 0.0 |
| with torch.no_grad(): |
| for batch in val_loader: |
| image = batch["image"].to(device) |
| suvr = batch["suvr"].to(device) |
| outputs = model(image, suvr) |
| val_loss += float(alignment_loss(outputs, suvr)) * image.shape[0] |
| val_loss = val_loss / max(1, len(val_dataset)) |
| print(f"epoch={epoch} train_loss={train_loss:.4f} val_loss={val_loss:.4f}") |
|
|
| args.out.parent.mkdir(parents=True, exist_ok=True) |
| torch.save({"model": model.state_dict(), "args": vars(args)}, args.out) |
| print(f"saved {args.out}", flush=True) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|