torch_em.model.probabilistic_unet
@private
1"""@private 2""" 3 4# This code is based on: 5# 1. The original TensorFlow implementation: https://github.com/SimonKohl/probabilistic_unet 6# 2. PyTorch adaptation from https://github.com/stefanknegt/Probabilistic-Unet-Pytorch 7# 3. PhiSeg's benchmarking script from https://github.com/annawundram/glaucoma-diagnosis-pipeline. 8 9from typing import Optional, List 10 11import torch 12import torch.nn as nn 13import torch.nn.functional as F 14from torch.distributions import Normal, Independent, kl 15 16from torch_em.model import UNet2d 17from torch_em.loss.dice import DiceLoss 18from torch_em.model.unet import get_norm_layer 19 20 21def init_weights(m: nn.Module) -> None: 22 """@private 23 """ 24 if isinstance(m, nn.Conv2d) or isinstance(m, nn.ConvTranspose2d): 25 nn.init.kaiming_normal_(m.weight, mode='fan_in', nonlinearity='relu') 26 if m.bias is not None: 27 nn.init.trunc_normal_(m.bias, std=0.001) 28 29 30def init_weights_orthogonal_normal(m: nn.Module) -> None: 31 """@private 32 """ 33 if isinstance(m, nn.Conv2d) or isinstance(m, nn.ConvTranspose2d): 34 nn.init.orthogonal_(m.weight) 35 if m.bias is not None: 36 nn.init.trunc_normal_(m.bias, std=0.001) 37 38 39class Encoder(nn.Module): 40 """Convolutional encoder for the prior and posterior networks. 41 42 Stacks len(num_filters) blocks of no_convs_per_block Conv-Norm-ReLU layers with average 43 pooling between blocks. For the posterior, the segmentation mask is concatenated with the 44 image along the channel axis before encoding. 45 46 Args: 47 input_channels: Number of input image channels. 48 num_filters: Number of filters for each encoder block. 49 no_convs_per_block: Number of Conv-Norm-ReLU layers per block. 50 posterior: If True, expects a concatenated segmentation mask as input. 51 num_classes: Number of segmentation channels appended when posterior=True. 52 norm: Normalization type applied after each convolution. None disables normalisation. 53 """ 54 def __init__( 55 self, 56 input_channels: int, 57 num_filters: List[int], 58 no_convs_per_block: int, 59 posterior: bool = False, 60 num_classes: Optional[int] = None, 61 norm: Optional[str] = None, 62 ) -> None: 63 super().__init__() 64 65 self.input_channels = input_channels 66 self.num_filters = num_filters 67 68 if posterior: 69 # To accommodate for the mask concatenated at the channel axis, increase input_channels. 70 assert num_classes is not None 71 self.input_channels += num_classes 72 73 layers = [] 74 output_dim = None 75 76 for i in range(len(self.num_filters)): 77 # First block: input_channels -> num_filters[i]; subsequent: prev_output -> num_filters[i]. 78 input_dim = self.input_channels if i == 0 else output_dim 79 output_dim = num_filters[i] 80 81 if i != 0: 82 layers.append(nn.AvgPool2d(kernel_size=2, stride=2, padding=0, ceil_mode=True)) 83 84 layers.append(nn.Conv2d(input_dim, output_dim, kernel_size=3)) 85 if norm is not None: 86 layers.append(get_norm_layer(norm, 2, output_dim)) 87 layers.append(nn.ReLU(inplace=True)) 88 89 for _ in range(no_convs_per_block - 1): 90 layers.append(nn.Conv2d(output_dim, output_dim, kernel_size=3)) 91 if norm is not None: 92 layers.append(get_norm_layer(norm, 2, output_dim)) 93 layers.append(nn.ReLU(inplace=True)) 94 95 self.layers = nn.Sequential(*layers) 96 self.layers.apply(init_weights) 97 98 def forward(self, x: torch.Tensor) -> torch.Tensor: 99 return self.layers(x) 100 101 102class AxisAlignedConvGaussian(nn.Module): 103 """Convolutional network that outputs a diagonal-covariance Gaussian distribution. 104 105 Encodes the input (and optionally a segmentation mask) into a global feature vector, 106 then predicts mu and log-sigma for each latent dimension. Returns an Independent(Normal) 107 distribution over the latent space. 108 109 Args: 110 input_channels: Number of input image channels. 111 num_filters: Number of filters per encoder block. 112 no_convs_per_block: Number of convolutions per encoder block. 113 latent_dim: Dimensionality of the latent space. 114 posterior: If True, encodes both image and segmentation (posterior q(z|x,y)). 115 num_classes: Number of segmentation channels when posterior=True. 116 use_onehot: If True, converts integer class labels to one-hot before concatenation. 117 num_raters: Number of segmentation maps per image for the joint posterior. 118 norm: Normalization type passed to the encoder. None disables normalisation. 119 """ 120 def __init__( 121 self, 122 input_channels: int, 123 num_filters: List[int], 124 no_convs_per_block: int, 125 latent_dim: int, 126 posterior: bool = False, 127 num_classes: Optional[int] = None, 128 use_onehot: bool = False, 129 num_raters: int = 1, 130 norm: Optional[str] = None, 131 ) -> None: 132 super().__init__() 133 self.latent_dim = latent_dim 134 self.num_classes = num_classes 135 self.use_onehot = use_onehot 136 137 self.encoder = Encoder( 138 input_channels, 139 num_filters, 140 no_convs_per_block, 141 posterior=posterior, 142 num_classes=None if num_classes is None else num_classes * num_raters, 143 norm=norm, 144 ) 145 146 self.conv_layer = nn.Conv2d(num_filters[-1], 2 * self.latent_dim, (1, 1), stride=1) 147 # Orthogonal init + truncated-normal bias per the original paper's training details. 148 nn.init.orthogonal_(self.conv_layer.weight, gain=1) 149 nn.init.trunc_normal_(self.conv_layer.bias, std=0.001) 150 151 def forward(self, patch: torch.Tensor, segm: Optional[torch.Tensor] = None) -> Independent: 152 # Posterior: encode segm and concatenate with image along channel axis. 153 # One-hot encodes class-index labels to remove spurious ordinal relationships. 154 # Centering (- 0.5) keeps inputs zero-mean in both the one-hot and raw binary cases. 155 if segm is not None: 156 if self.use_onehot: 157 segm = F.one_hot(segm.long(), self.num_classes).movedim(-1, 2).flatten(1, 2).float() - 0.5 158 else: 159 segm = segm.float() - 0.5 160 patch = torch.cat((patch, segm), dim=1) 161 162 encoding = self.encoder(patch) 163 164 # Global average pool to (B, C, 1, 1), then squeeze spatial dims to (B, C). 165 encoding = encoding.mean(dim=(2, 3), keepdim=True) 166 mu_log_sigma = self.conv_layer(encoding).squeeze(3).squeeze(2) 167 168 mu = mu_log_sigma[:, :self.latent_dim] 169 log_sigma = mu_log_sigma[:, self.latent_dim:] 170 171 # This is a multivariate normal with diagonal covariance matrix sigma 172 # https://github.com/pytorch/pytorch/pull/11178 173 dist = Independent(Normal(loc=mu, scale=torch.exp(log_sigma)), 1) 174 return dist 175 176 177class Fcomb(nn.Module): 178 """A sequence of 1x1 convolutions that combines the UNet feature map with a latent sample. 179 180 Broadcasts z to the spatial size of the feature map, concatenates along the channel axis, 181 and applies no_convs_fcomb pointwise convolutions to produce segmentation logits. 182 183 Args: 184 num_filters: Filter counts from the UNet encoder; num_filters[0] sets the fcomb width. 185 latent_dim: Dimensionality of the latent sample z. 186 num_output_channels: Number of output segmentation channels. 187 no_convs_fcomb: Total number of 1x1 conv layers (including the final projection). 188 """ 189 def __init__( 190 self, 191 num_filters: List[int], 192 latent_dim: int, 193 num_output_channels: int, 194 no_convs_fcomb: int, 195 ) -> None: 196 super().__init__() 197 198 layers = [] 199 layers.append(nn.Conv2d(num_filters[0] + latent_dim, num_filters[0], kernel_size=1)) 200 layers.append(nn.ReLU(inplace=True)) 201 202 for _ in range(no_convs_fcomb - 2): 203 layers.append(nn.Conv2d(num_filters[0], num_filters[0], kernel_size=1)) 204 layers.append(nn.ReLU(inplace=True)) 205 206 self.layers = nn.Sequential(*layers) 207 self.last_layer = nn.Conv2d(num_filters[0], num_output_channels, kernel_size=1) 208 209 self.layers.apply(init_weights_orthogonal_normal) 210 self.last_layer.apply(init_weights_orthogonal_normal) 211 212 def forward(self, feature_map: torch.Tensor, z: torch.Tensor) -> torch.Tensor: 213 """Combine UNet feature map with a latent sample to produce a segmentation. 214 215 Args: 216 feature_map: UNet decoder output of shape (B, C, H, W). 217 z: Latent sample of shape (B, latent_dim), broadcast to (B, latent_dim, H, W) 218 before concatenation with feature_map. 219 """ 220 H, W = feature_map.shape[2], feature_map.shape[3] 221 z = z.view(z.shape[0], z.shape[1], 1, 1).expand(-1, -1, H, W) 222 feature_map = torch.cat((feature_map, z), dim=1) 223 return self.last_layer(self.layers(feature_map)) 224 225 226class ProbabilisticUNet(nn.Module): 227 """Network implementation for the Probabilistic UNet of Kohl et al. (https://arxiv.org/abs/1806.05034). 228 This generative segmentation heuristic uses UNet combined with a conditional variational 229 autoencoder enabling to efficiently produce an unlimited number of plausible hypotheses. 230 231 Labels have shape (B, R, H, W), where R is num_raters. Each rater provides a binary mask 232 or a multiclass map of integer class IDs. The joint posterior receives all raters as input channels. 233 Multiclass maps become R * C one-hot channels. Reconstruction loss averages over raters. 234 Prior samples have shape (B, C, H, W). 235 236 Args: 237 input_channels: Number of channels in the image (1 for grayscale and 3 for RGB). The default is set to 1. 238 output_channels: Number of channels to predict. The default is set to 1. 239 num_raters: Number of annotators per image for binary or multiclass labels. The default is set to 1. 240 num_filters: Number of filters per encoder level. The default is set to [32, 64, 128, 192]. 241 latent_dim: Dimension of the latent space. The default is set to 6. 242 no_convs_per_block: Number of convolutions per block in the prior/posterior encoder. The default is set to 3. 243 no_convs_fcomb: Number of convolutions in the feature combination module. The default is set to 4. 244 norm: Normalization type for the prior and posterior encoder. The default is set to 'InstanceNorm'. 245 beta: Weighting factor for the KL divergence term in the ELBO (loss = reconstruction + beta * KL). 246 beta=1.0 is the standard VAE objective with no extra regularization. The original paper 247 used beta=10.0 for the LIDC-IDRI multi-rater chest X-ray task after task-specific tuning, 248 which over-regularizes the latent space for most other tasks. Set beta > 1 to encourage a 249 more structured latent space at the cost of reconstruction quality, or beta < 1 to 250 prioritize reconstruction. The default is set to 1.0. 251 consensus_masking: Whether to apply consensus masking in the reconstruction loss. The default is set to False. 252 rl_swap: Whether to use dice loss instead of BCE/CE for reconstruction. The default is set to False. 253 device: Device to place the model on. The default is set to None. 254 """ 255 256 def __init__( 257 self, 258 input_channels: int = 1, 259 output_channels: int = 1, 260 num_raters: int = 1, 261 num_filters: List[int] = [32, 64, 128, 192], 262 latent_dim: int = 6, 263 no_convs_per_block: int = 3, 264 no_convs_fcomb: int = 4, 265 norm: Optional[str] = "InstanceNorm", 266 beta: float = 1.0, 267 consensus_masking: bool = False, 268 rl_swap: bool = False, 269 device: Optional[torch.device] = None, 270 ) -> None: 271 super().__init__() 272 273 if output_channels < 1 or num_raters < 1: 274 raise ValueError("output_channels and num_raters must be positive.") 275 276 self.input_channels = input_channels 277 self.output_channels = output_channels 278 self.num_raters = num_raters 279 self.num_filters = num_filters 280 self.latent_dim = latent_dim 281 self.no_convs_per_block = no_convs_per_block 282 self.no_convs_fcomb = no_convs_fcomb 283 self.beta = beta 284 self.consensus_masking = consensus_masking 285 self.rl_swap = rl_swap 286 self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') if device is None else device 287 288 self.unet = UNet2d( 289 in_channels=self.input_channels, 290 out_channels=None, 291 depth=len(self.num_filters), 292 initial_features=num_filters[0], 293 norm=norm, 294 ) 295 296 self.prior = AxisAlignedConvGaussian( 297 self.input_channels, 298 self.num_filters, 299 self.no_convs_per_block, 300 self.latent_dim, 301 norm=norm, 302 ) 303 304 use_onehot = output_channels > 1 305 self.posterior = AxisAlignedConvGaussian( 306 self.input_channels, 307 self.num_filters, 308 self.no_convs_per_block, 309 self.latent_dim, 310 posterior=True, 311 num_classes=output_channels, 312 use_onehot=use_onehot, 313 num_raters=num_raters, 314 norm=norm, 315 ) 316 317 self.fcomb = Fcomb( 318 self.num_filters, 319 self.latent_dim, 320 self.output_channels, 321 self.no_convs_fcomb, 322 ) 323 324 if rl_swap: 325 self._criterion = DiceLoss() 326 elif output_channels == 1: 327 self._criterion = nn.BCEWithLogitsLoss(reduction="none") 328 else: 329 self._criterion = nn.CrossEntropyLoss(reduction="none") 330 331 self.to(self.device) 332 333 def _check_shape(self, patch: torch.Tensor) -> None: 334 spatial_shape = patch.shape[2:] 335 depth = len(self.num_filters) 336 factor = [2**depth] * len(spatial_shape) 337 if any(sh % fac != 0 for sh, fac in zip(spatial_shape, factor)): 338 msg = f"Invalid shape for Probabilistic U-Net: {spatial_shape} is not divisible by {factor}" 339 raise ValueError(msg) 340 341 def forward(self, patch: torch.Tensor, segm: Optional[torch.Tensor] = None) -> None: 342 """Run the image through the UNet and build the prior latent space. 343 344 Labels have shape (B, R, H, W). The joint posterior receives all R raters. 345 """ 346 self._check_shape(patch) 347 348 if segm is not None: 349 self._check_labels(segm, patch) 350 self.posterior_latent_space = self.posterior(patch, segm) 351 352 self.prior_latent_space = self.prior(patch) 353 self.unet_features = self.unet(patch) 354 355 def _check_labels(self, segm: torch.Tensor, image: torch.Tensor) -> None: 356 expected_shape = (image.shape[0], self.num_raters, *image.shape[2:]) 357 if segm.shape != expected_shape: 358 raise ValueError(f"Expected labels with shape {expected_shape}, got {tuple(segm.shape)}.") 359 360 def sample(self) -> torch.Tensor: 361 """Sample a segmentation from the prior and decode it through the UNet feature map. 362 363 Draws a latent vector z from the prior p(z|x) and passes it through fcomb together 364 with the UNet features to produce one segmentation hypothesis. Calling this N times 365 yields N diverse hypotheses for the same input image. 366 """ 367 z_prior = self.prior_latent_space.sample() 368 return self.fcomb(self.unet_features, z_prior) 369 370 def reconstruct( 371 self, 372 use_posterior_mean: bool = False, 373 calculate_posterior: bool = False, 374 z_posterior: Optional[torch.Tensor] = None, 375 ) -> torch.Tensor: 376 """Decode a joint posterior sample into logits with shape (B, C, H, W). 377 378 Args: 379 use_posterior_mean: Use the posterior mean as z instead of sampling. 380 calculate_posterior: Draw a fresh reparametrized sample from the posterior. 381 Ignored when use_posterior_mean=True or z_posterior is provided. 382 z_posterior: Pre-computed posterior sample to decode. Used directly when 383 use_posterior_mean=False and calculate_posterior=False. 384 """ 385 if use_posterior_mean: 386 z_posterior = self.posterior_latent_space.mean 387 else: 388 if calculate_posterior: 389 z_posterior = self.posterior_latent_space.rsample() 390 return self.fcomb(self.unet_features, z_posterior) 391 392 def kl_divergence( 393 self, 394 analytic: bool = True, 395 calculate_posterior: bool = False, 396 z_posterior: Optional[torch.Tensor] = None, 397 ) -> torch.Tensor: 398 """Compute KL(posterior || prior). 399 400 Args: 401 analytic: If True, compute the KL in closed form. If False, estimate via 402 a posterior sample (log q(z) - log p(z)). 403 calculate_posterior: Draw a fresh reparametrized sample when analytic=False. 404 Ignored when z_posterior is provided. 405 z_posterior: Pre-computed posterior sample for the Monte Carlo estimate. 406 Only used when analytic=False. 407 """ 408 if analytic: 409 kl_div = kl.kl_divergence(self.posterior_latent_space, self.prior_latent_space) 410 else: 411 if calculate_posterior: 412 z_posterior = self.posterior_latent_space.rsample() 413 log_posterior_prob = self.posterior_latent_space.log_prob(z_posterior) 414 log_prior_prob = self.prior_latent_space.log_prob(z_posterior) 415 kl_div = log_posterior_prob - log_prior_prob 416 return kl_div 417 418 def elbo( 419 self, 420 segm: torch.Tensor, 421 consm: Optional[torch.Tensor] = None, 422 analytic_kl: bool = True, 423 reconstruct_posterior_mean: bool = False, 424 ) -> torch.Tensor: 425 """Compute the evidence lower bound -E[log p(y|x,z)] + beta * KL(q||p). 426 427 One joint posterior sample supplies the reconstruction and KL terms for each image. 428 Reconstruction loss averages over raters. The KL term contributes once per image. 429 430 Args: 431 segm: Binary labels or multiclass indices of shape (B, R, H, W). 432 consm: Optional shared mask (B, 1, H, W) or per-rater mask (B, R, H, W). 433 Applied as a multiplicative weight when consensus_masking=True. 434 analytic_kl: If True, compute the KL divergence in closed form. 435 reconstruct_posterior_mean: If True, decode the posterior mean instead of a sample. 436 """ 437 438 self._check_labels(segm, self.unet_features) 439 mask = consm if self.consensus_masking else None 440 if mask is not None: 441 shared_shape = (segm.shape[0], 1, *segm.shape[2:]) 442 if mask.shape != segm.shape and mask.shape != shared_shape: 443 raise ValueError(f"Expected consensus mask with shape {shared_shape} or {tuple(segm.shape)}.") 444 mask = mask.expand_as(segm) 445 446 z_posterior = self.posterior_latent_space.rsample() 447 448 kl_term = torch.mean( 449 self.kl_divergence(analytic=analytic_kl, calculate_posterior=False, z_posterior=z_posterior) 450 ) 451 452 reconstruction = self.reconstruct( 453 use_posterior_mean=reconstruct_posterior_mean, 454 calculate_posterior=False, 455 z_posterior=z_posterior 456 ) 457 losses = [] 458 for rater in range(self.num_raters): 459 target = segm[:, rater:rater + 1] 460 rater_mask = None if mask is None else mask[:, rater:rater + 1] 461 losses.append(self._reconstruction_loss(reconstruction, target, rater_mask)) 462 reconstruction_loss = torch.stack(losses).mean() 463 return -(reconstruction_loss + self.beta * kl_term) 464 465 def _reconstruction_loss( 466 self, reconstruction: torch.Tensor, segm: torch.Tensor, mask: Optional[torch.Tensor] 467 ) -> torch.Tensor: 468 use_dice = self.rl_swap 469 use_bce = isinstance(self._criterion, nn.BCEWithLogitsLoss) 470 if use_dice: 471 if self.output_channels == 1: 472 prediction = reconstruction.sigmoid() 473 target = segm.float() 474 else: 475 prediction = reconstruction.softmax(dim=1) 476 target = F.one_hot(segm.squeeze(1).long(), self.output_channels).movedim(-1, 1).float() 477 if mask is not None: 478 # Mask probabilities before Dice reduces the spatial dimensions. 479 prediction = prediction * mask 480 target = target * mask 481 reconstruction_loss = self._criterion(prediction, target) 482 if mask is not None: 483 reconstruction_loss = reconstruction_loss * mask.any() 484 else: 485 segm_t = segm.float() if use_bce else segm.squeeze(1).long() 486 reconstruction_loss = self._criterion(reconstruction, segm_t) 487 if mask is not None: 488 reconstruction_loss = reconstruction_loss * (mask if use_bce else mask.squeeze(1)) 489 490 if use_dice: 491 return reconstruction_loss 492 493 # Sum over spatial dims, mean over batch - keeps loss scale independent of batch size. 494 reconstruction_loss = reconstruction_loss.sum(dim=tuple(range(1, reconstruction_loss.dim()))).mean() 495 return reconstruction_loss
40class Encoder(nn.Module): 41 """Convolutional encoder for the prior and posterior networks. 42 43 Stacks len(num_filters) blocks of no_convs_per_block Conv-Norm-ReLU layers with average 44 pooling between blocks. For the posterior, the segmentation mask is concatenated with the 45 image along the channel axis before encoding. 46 47 Args: 48 input_channels: Number of input image channels. 49 num_filters: Number of filters for each encoder block. 50 no_convs_per_block: Number of Conv-Norm-ReLU layers per block. 51 posterior: If True, expects a concatenated segmentation mask as input. 52 num_classes: Number of segmentation channels appended when posterior=True. 53 norm: Normalization type applied after each convolution. None disables normalisation. 54 """ 55 def __init__( 56 self, 57 input_channels: int, 58 num_filters: List[int], 59 no_convs_per_block: int, 60 posterior: bool = False, 61 num_classes: Optional[int] = None, 62 norm: Optional[str] = None, 63 ) -> None: 64 super().__init__() 65 66 self.input_channels = input_channels 67 self.num_filters = num_filters 68 69 if posterior: 70 # To accommodate for the mask concatenated at the channel axis, increase input_channels. 71 assert num_classes is not None 72 self.input_channels += num_classes 73 74 layers = [] 75 output_dim = None 76 77 for i in range(len(self.num_filters)): 78 # First block: input_channels -> num_filters[i]; subsequent: prev_output -> num_filters[i]. 79 input_dim = self.input_channels if i == 0 else output_dim 80 output_dim = num_filters[i] 81 82 if i != 0: 83 layers.append(nn.AvgPool2d(kernel_size=2, stride=2, padding=0, ceil_mode=True)) 84 85 layers.append(nn.Conv2d(input_dim, output_dim, kernel_size=3)) 86 if norm is not None: 87 layers.append(get_norm_layer(norm, 2, output_dim)) 88 layers.append(nn.ReLU(inplace=True)) 89 90 for _ in range(no_convs_per_block - 1): 91 layers.append(nn.Conv2d(output_dim, output_dim, kernel_size=3)) 92 if norm is not None: 93 layers.append(get_norm_layer(norm, 2, output_dim)) 94 layers.append(nn.ReLU(inplace=True)) 95 96 self.layers = nn.Sequential(*layers) 97 self.layers.apply(init_weights) 98 99 def forward(self, x: torch.Tensor) -> torch.Tensor: 100 return self.layers(x)
Convolutional encoder for the prior and posterior networks.
Stacks len(num_filters) blocks of no_convs_per_block Conv-Norm-ReLU layers with average pooling between blocks. For the posterior, the segmentation mask is concatenated with the image along the channel axis before encoding.
Arguments:
- input_channels: Number of input image channels.
- num_filters: Number of filters for each encoder block.
- no_convs_per_block: Number of Conv-Norm-ReLU layers per block.
- posterior: If True, expects a concatenated segmentation mask as input.
- num_classes: Number of segmentation channels appended when posterior=True.
- norm: Normalization type applied after each convolution. None disables normalisation.
55 def __init__( 56 self, 57 input_channels: int, 58 num_filters: List[int], 59 no_convs_per_block: int, 60 posterior: bool = False, 61 num_classes: Optional[int] = None, 62 norm: Optional[str] = None, 63 ) -> None: 64 super().__init__() 65 66 self.input_channels = input_channels 67 self.num_filters = num_filters 68 69 if posterior: 70 # To accommodate for the mask concatenated at the channel axis, increase input_channels. 71 assert num_classes is not None 72 self.input_channels += num_classes 73 74 layers = [] 75 output_dim = None 76 77 for i in range(len(self.num_filters)): 78 # First block: input_channels -> num_filters[i]; subsequent: prev_output -> num_filters[i]. 79 input_dim = self.input_channels if i == 0 else output_dim 80 output_dim = num_filters[i] 81 82 if i != 0: 83 layers.append(nn.AvgPool2d(kernel_size=2, stride=2, padding=0, ceil_mode=True)) 84 85 layers.append(nn.Conv2d(input_dim, output_dim, kernel_size=3)) 86 if norm is not None: 87 layers.append(get_norm_layer(norm, 2, output_dim)) 88 layers.append(nn.ReLU(inplace=True)) 89 90 for _ in range(no_convs_per_block - 1): 91 layers.append(nn.Conv2d(output_dim, output_dim, kernel_size=3)) 92 if norm is not None: 93 layers.append(get_norm_layer(norm, 2, output_dim)) 94 layers.append(nn.ReLU(inplace=True)) 95 96 self.layers = nn.Sequential(*layers) 97 self.layers.apply(init_weights)
Initialize internal Module state, shared by both nn.Module and ScriptModule.
Define the computation performed at every call.
Should be overridden by all subclasses.
Although the recipe for forward pass needs to be defined within
this function, one should call the Module instance afterwards
instead of this since the former takes care of running the
registered hooks while the latter silently ignores them.
103class AxisAlignedConvGaussian(nn.Module): 104 """Convolutional network that outputs a diagonal-covariance Gaussian distribution. 105 106 Encodes the input (and optionally a segmentation mask) into a global feature vector, 107 then predicts mu and log-sigma for each latent dimension. Returns an Independent(Normal) 108 distribution over the latent space. 109 110 Args: 111 input_channels: Number of input image channels. 112 num_filters: Number of filters per encoder block. 113 no_convs_per_block: Number of convolutions per encoder block. 114 latent_dim: Dimensionality of the latent space. 115 posterior: If True, encodes both image and segmentation (posterior q(z|x,y)). 116 num_classes: Number of segmentation channels when posterior=True. 117 use_onehot: If True, converts integer class labels to one-hot before concatenation. 118 num_raters: Number of segmentation maps per image for the joint posterior. 119 norm: Normalization type passed to the encoder. None disables normalisation. 120 """ 121 def __init__( 122 self, 123 input_channels: int, 124 num_filters: List[int], 125 no_convs_per_block: int, 126 latent_dim: int, 127 posterior: bool = False, 128 num_classes: Optional[int] = None, 129 use_onehot: bool = False, 130 num_raters: int = 1, 131 norm: Optional[str] = None, 132 ) -> None: 133 super().__init__() 134 self.latent_dim = latent_dim 135 self.num_classes = num_classes 136 self.use_onehot = use_onehot 137 138 self.encoder = Encoder( 139 input_channels, 140 num_filters, 141 no_convs_per_block, 142 posterior=posterior, 143 num_classes=None if num_classes is None else num_classes * num_raters, 144 norm=norm, 145 ) 146 147 self.conv_layer = nn.Conv2d(num_filters[-1], 2 * self.latent_dim, (1, 1), stride=1) 148 # Orthogonal init + truncated-normal bias per the original paper's training details. 149 nn.init.orthogonal_(self.conv_layer.weight, gain=1) 150 nn.init.trunc_normal_(self.conv_layer.bias, std=0.001) 151 152 def forward(self, patch: torch.Tensor, segm: Optional[torch.Tensor] = None) -> Independent: 153 # Posterior: encode segm and concatenate with image along channel axis. 154 # One-hot encodes class-index labels to remove spurious ordinal relationships. 155 # Centering (- 0.5) keeps inputs zero-mean in both the one-hot and raw binary cases. 156 if segm is not None: 157 if self.use_onehot: 158 segm = F.one_hot(segm.long(), self.num_classes).movedim(-1, 2).flatten(1, 2).float() - 0.5 159 else: 160 segm = segm.float() - 0.5 161 patch = torch.cat((patch, segm), dim=1) 162 163 encoding = self.encoder(patch) 164 165 # Global average pool to (B, C, 1, 1), then squeeze spatial dims to (B, C). 166 encoding = encoding.mean(dim=(2, 3), keepdim=True) 167 mu_log_sigma = self.conv_layer(encoding).squeeze(3).squeeze(2) 168 169 mu = mu_log_sigma[:, :self.latent_dim] 170 log_sigma = mu_log_sigma[:, self.latent_dim:] 171 172 # This is a multivariate normal with diagonal covariance matrix sigma 173 # https://github.com/pytorch/pytorch/pull/11178 174 dist = Independent(Normal(loc=mu, scale=torch.exp(log_sigma)), 1) 175 return dist
Convolutional network that outputs a diagonal-covariance Gaussian distribution.
Encodes the input (and optionally a segmentation mask) into a global feature vector, then predicts mu and log-sigma for each latent dimension. Returns an Independent(Normal) distribution over the latent space.
Arguments:
- input_channels: Number of input image channels.
- num_filters: Number of filters per encoder block.
- no_convs_per_block: Number of convolutions per encoder block.
- latent_dim: Dimensionality of the latent space.
- posterior: If True, encodes both image and segmentation (posterior q(z|x,y)).
- num_classes: Number of segmentation channels when posterior=True.
- use_onehot: If True, converts integer class labels to one-hot before concatenation.
- num_raters: Number of segmentation maps per image for the joint posterior.
- norm: Normalization type passed to the encoder. None disables normalisation.
121 def __init__( 122 self, 123 input_channels: int, 124 num_filters: List[int], 125 no_convs_per_block: int, 126 latent_dim: int, 127 posterior: bool = False, 128 num_classes: Optional[int] = None, 129 use_onehot: bool = False, 130 num_raters: int = 1, 131 norm: Optional[str] = None, 132 ) -> None: 133 super().__init__() 134 self.latent_dim = latent_dim 135 self.num_classes = num_classes 136 self.use_onehot = use_onehot 137 138 self.encoder = Encoder( 139 input_channels, 140 num_filters, 141 no_convs_per_block, 142 posterior=posterior, 143 num_classes=None if num_classes is None else num_classes * num_raters, 144 norm=norm, 145 ) 146 147 self.conv_layer = nn.Conv2d(num_filters[-1], 2 * self.latent_dim, (1, 1), stride=1) 148 # Orthogonal init + truncated-normal bias per the original paper's training details. 149 nn.init.orthogonal_(self.conv_layer.weight, gain=1) 150 nn.init.trunc_normal_(self.conv_layer.bias, std=0.001)
Initialize internal Module state, shared by both nn.Module and ScriptModule.
152 def forward(self, patch: torch.Tensor, segm: Optional[torch.Tensor] = None) -> Independent: 153 # Posterior: encode segm and concatenate with image along channel axis. 154 # One-hot encodes class-index labels to remove spurious ordinal relationships. 155 # Centering (- 0.5) keeps inputs zero-mean in both the one-hot and raw binary cases. 156 if segm is not None: 157 if self.use_onehot: 158 segm = F.one_hot(segm.long(), self.num_classes).movedim(-1, 2).flatten(1, 2).float() - 0.5 159 else: 160 segm = segm.float() - 0.5 161 patch = torch.cat((patch, segm), dim=1) 162 163 encoding = self.encoder(patch) 164 165 # Global average pool to (B, C, 1, 1), then squeeze spatial dims to (B, C). 166 encoding = encoding.mean(dim=(2, 3), keepdim=True) 167 mu_log_sigma = self.conv_layer(encoding).squeeze(3).squeeze(2) 168 169 mu = mu_log_sigma[:, :self.latent_dim] 170 log_sigma = mu_log_sigma[:, self.latent_dim:] 171 172 # This is a multivariate normal with diagonal covariance matrix sigma 173 # https://github.com/pytorch/pytorch/pull/11178 174 dist = Independent(Normal(loc=mu, scale=torch.exp(log_sigma)), 1) 175 return dist
Define the computation performed at every call.
Should be overridden by all subclasses.
Although the recipe for forward pass needs to be defined within
this function, one should call the Module instance afterwards
instead of this since the former takes care of running the
registered hooks while the latter silently ignores them.
178class Fcomb(nn.Module): 179 """A sequence of 1x1 convolutions that combines the UNet feature map with a latent sample. 180 181 Broadcasts z to the spatial size of the feature map, concatenates along the channel axis, 182 and applies no_convs_fcomb pointwise convolutions to produce segmentation logits. 183 184 Args: 185 num_filters: Filter counts from the UNet encoder; num_filters[0] sets the fcomb width. 186 latent_dim: Dimensionality of the latent sample z. 187 num_output_channels: Number of output segmentation channels. 188 no_convs_fcomb: Total number of 1x1 conv layers (including the final projection). 189 """ 190 def __init__( 191 self, 192 num_filters: List[int], 193 latent_dim: int, 194 num_output_channels: int, 195 no_convs_fcomb: int, 196 ) -> None: 197 super().__init__() 198 199 layers = [] 200 layers.append(nn.Conv2d(num_filters[0] + latent_dim, num_filters[0], kernel_size=1)) 201 layers.append(nn.ReLU(inplace=True)) 202 203 for _ in range(no_convs_fcomb - 2): 204 layers.append(nn.Conv2d(num_filters[0], num_filters[0], kernel_size=1)) 205 layers.append(nn.ReLU(inplace=True)) 206 207 self.layers = nn.Sequential(*layers) 208 self.last_layer = nn.Conv2d(num_filters[0], num_output_channels, kernel_size=1) 209 210 self.layers.apply(init_weights_orthogonal_normal) 211 self.last_layer.apply(init_weights_orthogonal_normal) 212 213 def forward(self, feature_map: torch.Tensor, z: torch.Tensor) -> torch.Tensor: 214 """Combine UNet feature map with a latent sample to produce a segmentation. 215 216 Args: 217 feature_map: UNet decoder output of shape (B, C, H, W). 218 z: Latent sample of shape (B, latent_dim), broadcast to (B, latent_dim, H, W) 219 before concatenation with feature_map. 220 """ 221 H, W = feature_map.shape[2], feature_map.shape[3] 222 z = z.view(z.shape[0], z.shape[1], 1, 1).expand(-1, -1, H, W) 223 feature_map = torch.cat((feature_map, z), dim=1) 224 return self.last_layer(self.layers(feature_map))
A sequence of 1x1 convolutions that combines the UNet feature map with a latent sample.
Broadcasts z to the spatial size of the feature map, concatenates along the channel axis, and applies no_convs_fcomb pointwise convolutions to produce segmentation logits.
Arguments:
- num_filters: Filter counts from the UNet encoder; num_filters[0] sets the fcomb width.
- latent_dim: Dimensionality of the latent sample z.
- num_output_channels: Number of output segmentation channels.
- no_convs_fcomb: Total number of 1x1 conv layers (including the final projection).
190 def __init__( 191 self, 192 num_filters: List[int], 193 latent_dim: int, 194 num_output_channels: int, 195 no_convs_fcomb: int, 196 ) -> None: 197 super().__init__() 198 199 layers = [] 200 layers.append(nn.Conv2d(num_filters[0] + latent_dim, num_filters[0], kernel_size=1)) 201 layers.append(nn.ReLU(inplace=True)) 202 203 for _ in range(no_convs_fcomb - 2): 204 layers.append(nn.Conv2d(num_filters[0], num_filters[0], kernel_size=1)) 205 layers.append(nn.ReLU(inplace=True)) 206 207 self.layers = nn.Sequential(*layers) 208 self.last_layer = nn.Conv2d(num_filters[0], num_output_channels, kernel_size=1) 209 210 self.layers.apply(init_weights_orthogonal_normal) 211 self.last_layer.apply(init_weights_orthogonal_normal)
Initialize internal Module state, shared by both nn.Module and ScriptModule.
213 def forward(self, feature_map: torch.Tensor, z: torch.Tensor) -> torch.Tensor: 214 """Combine UNet feature map with a latent sample to produce a segmentation. 215 216 Args: 217 feature_map: UNet decoder output of shape (B, C, H, W). 218 z: Latent sample of shape (B, latent_dim), broadcast to (B, latent_dim, H, W) 219 before concatenation with feature_map. 220 """ 221 H, W = feature_map.shape[2], feature_map.shape[3] 222 z = z.view(z.shape[0], z.shape[1], 1, 1).expand(-1, -1, H, W) 223 feature_map = torch.cat((feature_map, z), dim=1) 224 return self.last_layer(self.layers(feature_map))
Combine UNet feature map with a latent sample to produce a segmentation.
Arguments:
- feature_map: UNet decoder output of shape (B, C, H, W).
- z: Latent sample of shape (B, latent_dim), broadcast to (B, latent_dim, H, W) before concatenation with feature_map.
227class ProbabilisticUNet(nn.Module): 228 """Network implementation for the Probabilistic UNet of Kohl et al. (https://arxiv.org/abs/1806.05034). 229 This generative segmentation heuristic uses UNet combined with a conditional variational 230 autoencoder enabling to efficiently produce an unlimited number of plausible hypotheses. 231 232 Labels have shape (B, R, H, W), where R is num_raters. Each rater provides a binary mask 233 or a multiclass map of integer class IDs. The joint posterior receives all raters as input channels. 234 Multiclass maps become R * C one-hot channels. Reconstruction loss averages over raters. 235 Prior samples have shape (B, C, H, W). 236 237 Args: 238 input_channels: Number of channels in the image (1 for grayscale and 3 for RGB). The default is set to 1. 239 output_channels: Number of channels to predict. The default is set to 1. 240 num_raters: Number of annotators per image for binary or multiclass labels. The default is set to 1. 241 num_filters: Number of filters per encoder level. The default is set to [32, 64, 128, 192]. 242 latent_dim: Dimension of the latent space. The default is set to 6. 243 no_convs_per_block: Number of convolutions per block in the prior/posterior encoder. The default is set to 3. 244 no_convs_fcomb: Number of convolutions in the feature combination module. The default is set to 4. 245 norm: Normalization type for the prior and posterior encoder. The default is set to 'InstanceNorm'. 246 beta: Weighting factor for the KL divergence term in the ELBO (loss = reconstruction + beta * KL). 247 beta=1.0 is the standard VAE objective with no extra regularization. The original paper 248 used beta=10.0 for the LIDC-IDRI multi-rater chest X-ray task after task-specific tuning, 249 which over-regularizes the latent space for most other tasks. Set beta > 1 to encourage a 250 more structured latent space at the cost of reconstruction quality, or beta < 1 to 251 prioritize reconstruction. The default is set to 1.0. 252 consensus_masking: Whether to apply consensus masking in the reconstruction loss. The default is set to False. 253 rl_swap: Whether to use dice loss instead of BCE/CE for reconstruction. The default is set to False. 254 device: Device to place the model on. The default is set to None. 255 """ 256 257 def __init__( 258 self, 259 input_channels: int = 1, 260 output_channels: int = 1, 261 num_raters: int = 1, 262 num_filters: List[int] = [32, 64, 128, 192], 263 latent_dim: int = 6, 264 no_convs_per_block: int = 3, 265 no_convs_fcomb: int = 4, 266 norm: Optional[str] = "InstanceNorm", 267 beta: float = 1.0, 268 consensus_masking: bool = False, 269 rl_swap: bool = False, 270 device: Optional[torch.device] = None, 271 ) -> None: 272 super().__init__() 273 274 if output_channels < 1 or num_raters < 1: 275 raise ValueError("output_channels and num_raters must be positive.") 276 277 self.input_channels = input_channels 278 self.output_channels = output_channels 279 self.num_raters = num_raters 280 self.num_filters = num_filters 281 self.latent_dim = latent_dim 282 self.no_convs_per_block = no_convs_per_block 283 self.no_convs_fcomb = no_convs_fcomb 284 self.beta = beta 285 self.consensus_masking = consensus_masking 286 self.rl_swap = rl_swap 287 self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') if device is None else device 288 289 self.unet = UNet2d( 290 in_channels=self.input_channels, 291 out_channels=None, 292 depth=len(self.num_filters), 293 initial_features=num_filters[0], 294 norm=norm, 295 ) 296 297 self.prior = AxisAlignedConvGaussian( 298 self.input_channels, 299 self.num_filters, 300 self.no_convs_per_block, 301 self.latent_dim, 302 norm=norm, 303 ) 304 305 use_onehot = output_channels > 1 306 self.posterior = AxisAlignedConvGaussian( 307 self.input_channels, 308 self.num_filters, 309 self.no_convs_per_block, 310 self.latent_dim, 311 posterior=True, 312 num_classes=output_channels, 313 use_onehot=use_onehot, 314 num_raters=num_raters, 315 norm=norm, 316 ) 317 318 self.fcomb = Fcomb( 319 self.num_filters, 320 self.latent_dim, 321 self.output_channels, 322 self.no_convs_fcomb, 323 ) 324 325 if rl_swap: 326 self._criterion = DiceLoss() 327 elif output_channels == 1: 328 self._criterion = nn.BCEWithLogitsLoss(reduction="none") 329 else: 330 self._criterion = nn.CrossEntropyLoss(reduction="none") 331 332 self.to(self.device) 333 334 def _check_shape(self, patch: torch.Tensor) -> None: 335 spatial_shape = patch.shape[2:] 336 depth = len(self.num_filters) 337 factor = [2**depth] * len(spatial_shape) 338 if any(sh % fac != 0 for sh, fac in zip(spatial_shape, factor)): 339 msg = f"Invalid shape for Probabilistic U-Net: {spatial_shape} is not divisible by {factor}" 340 raise ValueError(msg) 341 342 def forward(self, patch: torch.Tensor, segm: Optional[torch.Tensor] = None) -> None: 343 """Run the image through the UNet and build the prior latent space. 344 345 Labels have shape (B, R, H, W). The joint posterior receives all R raters. 346 """ 347 self._check_shape(patch) 348 349 if segm is not None: 350 self._check_labels(segm, patch) 351 self.posterior_latent_space = self.posterior(patch, segm) 352 353 self.prior_latent_space = self.prior(patch) 354 self.unet_features = self.unet(patch) 355 356 def _check_labels(self, segm: torch.Tensor, image: torch.Tensor) -> None: 357 expected_shape = (image.shape[0], self.num_raters, *image.shape[2:]) 358 if segm.shape != expected_shape: 359 raise ValueError(f"Expected labels with shape {expected_shape}, got {tuple(segm.shape)}.") 360 361 def sample(self) -> torch.Tensor: 362 """Sample a segmentation from the prior and decode it through the UNet feature map. 363 364 Draws a latent vector z from the prior p(z|x) and passes it through fcomb together 365 with the UNet features to produce one segmentation hypothesis. Calling this N times 366 yields N diverse hypotheses for the same input image. 367 """ 368 z_prior = self.prior_latent_space.sample() 369 return self.fcomb(self.unet_features, z_prior) 370 371 def reconstruct( 372 self, 373 use_posterior_mean: bool = False, 374 calculate_posterior: bool = False, 375 z_posterior: Optional[torch.Tensor] = None, 376 ) -> torch.Tensor: 377 """Decode a joint posterior sample into logits with shape (B, C, H, W). 378 379 Args: 380 use_posterior_mean: Use the posterior mean as z instead of sampling. 381 calculate_posterior: Draw a fresh reparametrized sample from the posterior. 382 Ignored when use_posterior_mean=True or z_posterior is provided. 383 z_posterior: Pre-computed posterior sample to decode. Used directly when 384 use_posterior_mean=False and calculate_posterior=False. 385 """ 386 if use_posterior_mean: 387 z_posterior = self.posterior_latent_space.mean 388 else: 389 if calculate_posterior: 390 z_posterior = self.posterior_latent_space.rsample() 391 return self.fcomb(self.unet_features, z_posterior) 392 393 def kl_divergence( 394 self, 395 analytic: bool = True, 396 calculate_posterior: bool = False, 397 z_posterior: Optional[torch.Tensor] = None, 398 ) -> torch.Tensor: 399 """Compute KL(posterior || prior). 400 401 Args: 402 analytic: If True, compute the KL in closed form. If False, estimate via 403 a posterior sample (log q(z) - log p(z)). 404 calculate_posterior: Draw a fresh reparametrized sample when analytic=False. 405 Ignored when z_posterior is provided. 406 z_posterior: Pre-computed posterior sample for the Monte Carlo estimate. 407 Only used when analytic=False. 408 """ 409 if analytic: 410 kl_div = kl.kl_divergence(self.posterior_latent_space, self.prior_latent_space) 411 else: 412 if calculate_posterior: 413 z_posterior = self.posterior_latent_space.rsample() 414 log_posterior_prob = self.posterior_latent_space.log_prob(z_posterior) 415 log_prior_prob = self.prior_latent_space.log_prob(z_posterior) 416 kl_div = log_posterior_prob - log_prior_prob 417 return kl_div 418 419 def elbo( 420 self, 421 segm: torch.Tensor, 422 consm: Optional[torch.Tensor] = None, 423 analytic_kl: bool = True, 424 reconstruct_posterior_mean: bool = False, 425 ) -> torch.Tensor: 426 """Compute the evidence lower bound -E[log p(y|x,z)] + beta * KL(q||p). 427 428 One joint posterior sample supplies the reconstruction and KL terms for each image. 429 Reconstruction loss averages over raters. The KL term contributes once per image. 430 431 Args: 432 segm: Binary labels or multiclass indices of shape (B, R, H, W). 433 consm: Optional shared mask (B, 1, H, W) or per-rater mask (B, R, H, W). 434 Applied as a multiplicative weight when consensus_masking=True. 435 analytic_kl: If True, compute the KL divergence in closed form. 436 reconstruct_posterior_mean: If True, decode the posterior mean instead of a sample. 437 """ 438 439 self._check_labels(segm, self.unet_features) 440 mask = consm if self.consensus_masking else None 441 if mask is not None: 442 shared_shape = (segm.shape[0], 1, *segm.shape[2:]) 443 if mask.shape != segm.shape and mask.shape != shared_shape: 444 raise ValueError(f"Expected consensus mask with shape {shared_shape} or {tuple(segm.shape)}.") 445 mask = mask.expand_as(segm) 446 447 z_posterior = self.posterior_latent_space.rsample() 448 449 kl_term = torch.mean( 450 self.kl_divergence(analytic=analytic_kl, calculate_posterior=False, z_posterior=z_posterior) 451 ) 452 453 reconstruction = self.reconstruct( 454 use_posterior_mean=reconstruct_posterior_mean, 455 calculate_posterior=False, 456 z_posterior=z_posterior 457 ) 458 losses = [] 459 for rater in range(self.num_raters): 460 target = segm[:, rater:rater + 1] 461 rater_mask = None if mask is None else mask[:, rater:rater + 1] 462 losses.append(self._reconstruction_loss(reconstruction, target, rater_mask)) 463 reconstruction_loss = torch.stack(losses).mean() 464 return -(reconstruction_loss + self.beta * kl_term) 465 466 def _reconstruction_loss( 467 self, reconstruction: torch.Tensor, segm: torch.Tensor, mask: Optional[torch.Tensor] 468 ) -> torch.Tensor: 469 use_dice = self.rl_swap 470 use_bce = isinstance(self._criterion, nn.BCEWithLogitsLoss) 471 if use_dice: 472 if self.output_channels == 1: 473 prediction = reconstruction.sigmoid() 474 target = segm.float() 475 else: 476 prediction = reconstruction.softmax(dim=1) 477 target = F.one_hot(segm.squeeze(1).long(), self.output_channels).movedim(-1, 1).float() 478 if mask is not None: 479 # Mask probabilities before Dice reduces the spatial dimensions. 480 prediction = prediction * mask 481 target = target * mask 482 reconstruction_loss = self._criterion(prediction, target) 483 if mask is not None: 484 reconstruction_loss = reconstruction_loss * mask.any() 485 else: 486 segm_t = segm.float() if use_bce else segm.squeeze(1).long() 487 reconstruction_loss = self._criterion(reconstruction, segm_t) 488 if mask is not None: 489 reconstruction_loss = reconstruction_loss * (mask if use_bce else mask.squeeze(1)) 490 491 if use_dice: 492 return reconstruction_loss 493 494 # Sum over spatial dims, mean over batch - keeps loss scale independent of batch size. 495 reconstruction_loss = reconstruction_loss.sum(dim=tuple(range(1, reconstruction_loss.dim()))).mean() 496 return reconstruction_loss
Network implementation for the Probabilistic UNet of Kohl et al. (https://arxiv.org/abs/1806.05034). This generative segmentation heuristic uses UNet combined with a conditional variational autoencoder enabling to efficiently produce an unlimited number of plausible hypotheses.
Labels have shape (B, R, H, W), where R is num_raters. Each rater provides a binary mask or a multiclass map of integer class IDs. The joint posterior receives all raters as input channels. Multiclass maps become R * C one-hot channels. Reconstruction loss averages over raters. Prior samples have shape (B, C, H, W).
Arguments:
- input_channels: Number of channels in the image (1 for grayscale and 3 for RGB). The default is set to 1.
- output_channels: Number of channels to predict. The default is set to 1.
- num_raters: Number of annotators per image for binary or multiclass labels. The default is set to 1.
- num_filters: Number of filters per encoder level. The default is set to [32, 64, 128, 192].
- latent_dim: Dimension of the latent space. The default is set to 6.
- no_convs_per_block: Number of convolutions per block in the prior/posterior encoder. The default is set to 3.
- no_convs_fcomb: Number of convolutions in the feature combination module. The default is set to 4.
- norm: Normalization type for the prior and posterior encoder. The default is set to 'InstanceNorm'.
- beta: Weighting factor for the KL divergence term in the ELBO (loss = reconstruction + beta * KL). beta=1.0 is the standard VAE objective with no extra regularization. The original paper used beta=10.0 for the LIDC-IDRI multi-rater chest X-ray task after task-specific tuning, which over-regularizes the latent space for most other tasks. Set beta > 1 to encourage a more structured latent space at the cost of reconstruction quality, or beta < 1 to prioritize reconstruction. The default is set to 1.0.
- consensus_masking: Whether to apply consensus masking in the reconstruction loss. The default is set to False.
- rl_swap: Whether to use dice loss instead of BCE/CE for reconstruction. The default is set to False.
- device: Device to place the model on. The default is set to None.
257 def __init__( 258 self, 259 input_channels: int = 1, 260 output_channels: int = 1, 261 num_raters: int = 1, 262 num_filters: List[int] = [32, 64, 128, 192], 263 latent_dim: int = 6, 264 no_convs_per_block: int = 3, 265 no_convs_fcomb: int = 4, 266 norm: Optional[str] = "InstanceNorm", 267 beta: float = 1.0, 268 consensus_masking: bool = False, 269 rl_swap: bool = False, 270 device: Optional[torch.device] = None, 271 ) -> None: 272 super().__init__() 273 274 if output_channels < 1 or num_raters < 1: 275 raise ValueError("output_channels and num_raters must be positive.") 276 277 self.input_channels = input_channels 278 self.output_channels = output_channels 279 self.num_raters = num_raters 280 self.num_filters = num_filters 281 self.latent_dim = latent_dim 282 self.no_convs_per_block = no_convs_per_block 283 self.no_convs_fcomb = no_convs_fcomb 284 self.beta = beta 285 self.consensus_masking = consensus_masking 286 self.rl_swap = rl_swap 287 self.device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') if device is None else device 288 289 self.unet = UNet2d( 290 in_channels=self.input_channels, 291 out_channels=None, 292 depth=len(self.num_filters), 293 initial_features=num_filters[0], 294 norm=norm, 295 ) 296 297 self.prior = AxisAlignedConvGaussian( 298 self.input_channels, 299 self.num_filters, 300 self.no_convs_per_block, 301 self.latent_dim, 302 norm=norm, 303 ) 304 305 use_onehot = output_channels > 1 306 self.posterior = AxisAlignedConvGaussian( 307 self.input_channels, 308 self.num_filters, 309 self.no_convs_per_block, 310 self.latent_dim, 311 posterior=True, 312 num_classes=output_channels, 313 use_onehot=use_onehot, 314 num_raters=num_raters, 315 norm=norm, 316 ) 317 318 self.fcomb = Fcomb( 319 self.num_filters, 320 self.latent_dim, 321 self.output_channels, 322 self.no_convs_fcomb, 323 ) 324 325 if rl_swap: 326 self._criterion = DiceLoss() 327 elif output_channels == 1: 328 self._criterion = nn.BCEWithLogitsLoss(reduction="none") 329 else: 330 self._criterion = nn.CrossEntropyLoss(reduction="none") 331 332 self.to(self.device)
Initialize internal Module state, shared by both nn.Module and ScriptModule.
342 def forward(self, patch: torch.Tensor, segm: Optional[torch.Tensor] = None) -> None: 343 """Run the image through the UNet and build the prior latent space. 344 345 Labels have shape (B, R, H, W). The joint posterior receives all R raters. 346 """ 347 self._check_shape(patch) 348 349 if segm is not None: 350 self._check_labels(segm, patch) 351 self.posterior_latent_space = self.posterior(patch, segm) 352 353 self.prior_latent_space = self.prior(patch) 354 self.unet_features = self.unet(patch)
Run the image through the UNet and build the prior latent space.
Labels have shape (B, R, H, W). The joint posterior receives all R raters.
361 def sample(self) -> torch.Tensor: 362 """Sample a segmentation from the prior and decode it through the UNet feature map. 363 364 Draws a latent vector z from the prior p(z|x) and passes it through fcomb together 365 with the UNet features to produce one segmentation hypothesis. Calling this N times 366 yields N diverse hypotheses for the same input image. 367 """ 368 z_prior = self.prior_latent_space.sample() 369 return self.fcomb(self.unet_features, z_prior)
Sample a segmentation from the prior and decode it through the UNet feature map.
Draws a latent vector z from the prior p(z|x) and passes it through fcomb together with the UNet features to produce one segmentation hypothesis. Calling this N times yields N diverse hypotheses for the same input image.
371 def reconstruct( 372 self, 373 use_posterior_mean: bool = False, 374 calculate_posterior: bool = False, 375 z_posterior: Optional[torch.Tensor] = None, 376 ) -> torch.Tensor: 377 """Decode a joint posterior sample into logits with shape (B, C, H, W). 378 379 Args: 380 use_posterior_mean: Use the posterior mean as z instead of sampling. 381 calculate_posterior: Draw a fresh reparametrized sample from the posterior. 382 Ignored when use_posterior_mean=True or z_posterior is provided. 383 z_posterior: Pre-computed posterior sample to decode. Used directly when 384 use_posterior_mean=False and calculate_posterior=False. 385 """ 386 if use_posterior_mean: 387 z_posterior = self.posterior_latent_space.mean 388 else: 389 if calculate_posterior: 390 z_posterior = self.posterior_latent_space.rsample() 391 return self.fcomb(self.unet_features, z_posterior)
Decode a joint posterior sample into logits with shape (B, C, H, W).
Arguments:
- use_posterior_mean: Use the posterior mean as z instead of sampling.
- calculate_posterior: Draw a fresh reparametrized sample from the posterior. Ignored when use_posterior_mean=True or z_posterior is provided.
- z_posterior: Pre-computed posterior sample to decode. Used directly when use_posterior_mean=False and calculate_posterior=False.
393 def kl_divergence( 394 self, 395 analytic: bool = True, 396 calculate_posterior: bool = False, 397 z_posterior: Optional[torch.Tensor] = None, 398 ) -> torch.Tensor: 399 """Compute KL(posterior || prior). 400 401 Args: 402 analytic: If True, compute the KL in closed form. If False, estimate via 403 a posterior sample (log q(z) - log p(z)). 404 calculate_posterior: Draw a fresh reparametrized sample when analytic=False. 405 Ignored when z_posterior is provided. 406 z_posterior: Pre-computed posterior sample for the Monte Carlo estimate. 407 Only used when analytic=False. 408 """ 409 if analytic: 410 kl_div = kl.kl_divergence(self.posterior_latent_space, self.prior_latent_space) 411 else: 412 if calculate_posterior: 413 z_posterior = self.posterior_latent_space.rsample() 414 log_posterior_prob = self.posterior_latent_space.log_prob(z_posterior) 415 log_prior_prob = self.prior_latent_space.log_prob(z_posterior) 416 kl_div = log_posterior_prob - log_prior_prob 417 return kl_div
Compute KL(posterior || prior).
Arguments:
- analytic: If True, compute the KL in closed form. If False, estimate via a posterior sample (log q(z) - log p(z)).
- calculate_posterior: Draw a fresh reparametrized sample when analytic=False. Ignored when z_posterior is provided.
- z_posterior: Pre-computed posterior sample for the Monte Carlo estimate. Only used when analytic=False.
419 def elbo( 420 self, 421 segm: torch.Tensor, 422 consm: Optional[torch.Tensor] = None, 423 analytic_kl: bool = True, 424 reconstruct_posterior_mean: bool = False, 425 ) -> torch.Tensor: 426 """Compute the evidence lower bound -E[log p(y|x,z)] + beta * KL(q||p). 427 428 One joint posterior sample supplies the reconstruction and KL terms for each image. 429 Reconstruction loss averages over raters. The KL term contributes once per image. 430 431 Args: 432 segm: Binary labels or multiclass indices of shape (B, R, H, W). 433 consm: Optional shared mask (B, 1, H, W) or per-rater mask (B, R, H, W). 434 Applied as a multiplicative weight when consensus_masking=True. 435 analytic_kl: If True, compute the KL divergence in closed form. 436 reconstruct_posterior_mean: If True, decode the posterior mean instead of a sample. 437 """ 438 439 self._check_labels(segm, self.unet_features) 440 mask = consm if self.consensus_masking else None 441 if mask is not None: 442 shared_shape = (segm.shape[0], 1, *segm.shape[2:]) 443 if mask.shape != segm.shape and mask.shape != shared_shape: 444 raise ValueError(f"Expected consensus mask with shape {shared_shape} or {tuple(segm.shape)}.") 445 mask = mask.expand_as(segm) 446 447 z_posterior = self.posterior_latent_space.rsample() 448 449 kl_term = torch.mean( 450 self.kl_divergence(analytic=analytic_kl, calculate_posterior=False, z_posterior=z_posterior) 451 ) 452 453 reconstruction = self.reconstruct( 454 use_posterior_mean=reconstruct_posterior_mean, 455 calculate_posterior=False, 456 z_posterior=z_posterior 457 ) 458 losses = [] 459 for rater in range(self.num_raters): 460 target = segm[:, rater:rater + 1] 461 rater_mask = None if mask is None else mask[:, rater:rater + 1] 462 losses.append(self._reconstruction_loss(reconstruction, target, rater_mask)) 463 reconstruction_loss = torch.stack(losses).mean() 464 return -(reconstruction_loss + self.beta * kl_term)
Compute the evidence lower bound -E[log p(y|x,z)] + beta * KL(q||p).
One joint posterior sample supplies the reconstruction and KL terms for each image. Reconstruction loss averages over raters. The KL term contributes once per image.
Arguments:
- segm: Binary labels or multiclass indices of shape (B, R, H, W).
- consm: Optional shared mask (B, 1, H, W) or per-rater mask (B, R, H, W). Applied as a multiplicative weight when consensus_masking=True.
- analytic_kl: If True, compute the KL divergence in closed form.
- reconstruct_posterior_mean: If True, decode the posterior mean instead of a sample.