๐Ÿ““ Open this session on Kaggle Run the notebook interactively โ€” no local setup required โ†—

Session 6 — Variational Inference: Implementation 🏥

Part 2 โ€” Core UQ Algorithms

Putting VI to work on real chest X-rays โ€” and learning what the uncertainty is actually telling us clinically.


🎯 What this notebook does

We take the concepts from the teaching notebook and apply them to a real binary classification task: normal vs pneumonia chest X-rays. The notebook walks through the full Bayesian deep learning pipeline step by step:

  • Data loading and preprocessing โ€” prepare chest X-rays, apply MONAI transforms, and preserve realistic class imbalance through controlled subsampling
  • Building a Bayesian DenseNet121 โ€” replace the final DenseNet layer with a Bayesian linear head that learns distributions over weights
  • Training with the ELBO loss โ€” optimize predictive performance while regularizing the posterior using variational inference
  • Measuring best model performance โ€” evaluate the saved best model on the held-out test set using accuracy and AUC-ROC
  • Stochastic inference on the test set โ€” run multiple stochastic forward passes to approximate the predictive posterior distribution
  • Uncertainty decomposition and visualization โ€” separate total uncertainty into epistemic and aleatoric components and visualize their behaviour
  • Clustering test cases into certain vs uncertain subgroups โ€” identify high-confidence and low-confidence predictions using uncertainty metrics
  • Statistical comparison between certain and uncertain cases โ€” test whether uncertainty meaningfully correlates with predictive performance
  • Visualization of the most certain and most uncertain chest X-rays โ€” inspect representative cases with clinical-style interpretation of the modelโ€™s behaviour

๐Ÿ’ก Architecture note โ€” last-layer BNN
We apply VI only to the final classification layer of DenseNet121. The convolutional backbone stays deterministic and frozen during the first training phase. This is the practical VI approach used in most medical imaging research โ€” it's cheaper, more stable, and gives meaningful uncertainty where it matters most: at the decision boundary.


📦 Part 1 — Installs and imports

!pip install -q monai torch-uncertainty lightning

import os, random, glob
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from PIL import Image
from tqdm import tqdm

import torch
import torch.nn as nn
import torch.optim as optim

from scipy import stats
from statsmodels.stats.proportion import proportions_ztest
from sklearn.metrics import roc_auc_score, accuracy_score, confusion_matrix, classification_report
from sklearn.mixture import GaussianMixture

# MONAI
from monai.transforms import (
    Compose, LoadImaged, EnsureChannelFirstd, RepeatChanneld,
    ScaleIntensityd, Resized, RandFlipd, RandRotated, EnsureTyped,
)
from monai.data import Dataset, DataLoader
from monai.networks.nets import DenseNet121

# Torch-Uncertainty
from torch_uncertainty.layers.bayesian import BayesLinear
from torch_uncertainty.losses import ELBOLoss

# Reproducibility
SEED = 42
random.seed(SEED)
np.random.seed(SEED)
torch.manual_seed(SEED)
torch.cuda.manual_seed_all(SEED)

device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
print(f'Device: {device}')
     โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” 44.8/44.8 kB 1.9 MB/s eta 0:00:00
   โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” 2.7/2.7 MB 73.8 MB/s eta 0:00:00:00:01
   โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” 386.9/386.9 kB 20.5 MB/s eta 0:00:00
   โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ” 853.6/853.6 kB 34.4 MB/s eta 0:00:00
<frozen importlib._bootstrap_external>:1301: FutureWarning: The cuda.cudart module is deprecated and will be removed in a future release, please switch to use the cuda.bindings.runtime module instead.
2026-05-16 18:54:26.665390: E external/local_xla/xla/stream_executor/cuda/cuda_fft.cc:467] Unable to register cuFFT factory: Attempting to register factory for plugin cuFFT when one has already been registered
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1778957667.050134      57 cuda_dnn.cc:8579] Unable to register cuDNN factory: Attempting to register factory for plugin cuDNN when one has already been registered
E0000 00:00:1778957667.161999      57 cuda_blas.cc:1407] Unable to register cuBLAS factory: Attempting to register factory for plugin cuBLAS when one has already been registered
W0000 00:00:1778957668.184968      57 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
W0000 00:00:1778957668.185058      57 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
W0000 00:00:1778957668.185063      57 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
W0000 00:00:1778957668.185066      57 computation_placer.cc:177] computation placer already registered. Please check linkage and avoid linking the same target more than once.
Device: cuda

🗂️ Part 2 — Data preparation

Because this is an educational notebook and computational resources are limited, we use a smaller subset of the dataset rather than the full training corpus. To keep the experiment clinically realistic, the subsets preserve the approximate real class imbalance of the original chest X-ray dataset.

  • Train: 1000 images, sampled with approximately normal:pneumonia = 2.7:1
  • Test: 100 images, sampled with approximately normal:pneumonia = 1.7:1
  • Validation: full validation set, no subsampling

This setup keeps training fast and reproducible while still reflecting the naturally imbalanced distribution encountered in real-world pneumonia screening datasets.

DATA_DIR = "/kaggle/input/datasets/paultimothymooney/chest-xray-pneumonia/chest_xray"

def is_grayscale(path):
    try:
        return Image.open(path).mode in ['L', 'I;16', 'I']
    except:
        return False

def make_split_list_filtered(split_dir):
    normal_files = sorted(glob.glob(os.path.join(split_dir, 'NORMAL', '*')))
    pneu_files   = sorted(glob.glob(os.path.join(split_dir, 'PNEUMONIA', '*')))
    normal_files = [f for f in normal_files if is_grayscale(f)]
    pneu_files   = [f for f in pneu_files   if is_grayscale(f)]
    return ([{'image': f, 'label': 0} for f in normal_files] +
            [{'image': f, 'label': 1} for f in pneu_files])

def subsample_with_ratio(files, n_total, normal_pneu_ratio, seed=42):

    rng    = np.random.default_rng(seed)
    normal = [d for d in files if int(d['label']) == 0]
    pneu   = [d for d in files if int(d['label']) == 1]
    rng.shuffle(normal)
    rng.shuffle(pneu)

    n_pneu   = int(round(n_total / (1 + normal_pneu_ratio)))
    n_normal = n_total - n_pneu

    n_normal = min(n_normal, len(normal))
    n_pneu   = min(n_pneu,   len(pneu))

    subset = normal[:n_normal] + pneu[:n_pneu]
    rng.shuffle(subset)

    actual = n_normal / n_pneu if n_pneu > 0 else float('inf')
    print(f'  NORMAL={n_normal}, PNEUMONIA={n_pneu}, '
          f'total={len(subset)}')
    return subset

train_files_all = make_split_list_filtered(os.path.join(DATA_DIR, 'train'))
val_files_all   = make_split_list_filtered(os.path.join(DATA_DIR, 'val'))
test_files_all  = make_split_list_filtered(os.path.join(DATA_DIR, 'test'))

print('Train subset (normal:pneu = 2.7:1):')
train_files = subsample_with_ratio(train_files_all, n_total=1000,
                                    normal_pneu_ratio=2.7, seed=SEED)
print('Test subset (normal:pneu = 1.7:1):')
test_files  = subsample_with_ratio(test_files_all,  n_total=100,
                                    normal_pneu_ratio=1.7, seed=SEED)
print(f'Val (full): {len(val_files_all)} samples')
Train subset (normal:pneu = 2.7:1):
  NORMAL=730, PNEUMONIA=270, total=1000
Test subset (normal:pneu = 1.7:1):
  NORMAL=63, PNEUMONIA=37, total=100
Val (full): 16 samples
IMG_SIZE   = 224
BATCH_SIZE = 32

train_tfms = Compose([
    LoadImaged(keys=['image']),
    EnsureChannelFirstd(keys=['image']),
    RepeatChanneld(keys=['image'], repeats=3),   # grayscale [1,H,W] -> [3,H,W]
    ScaleIntensityd(keys=['image']),
    Resized(keys=['image'], spatial_size=(IMG_SIZE, IMG_SIZE)),
    RandFlipd(keys=['image'], prob=0.5, spatial_axis=1),
    RandRotated(keys=['image'], prob=0.3, range_x=0.12),
    EnsureTyped(keys=['image', 'label']),
])

eval_tfms = Compose([
    LoadImaged(keys=['image']),
    EnsureChannelFirstd(keys=['image']),
    RepeatChanneld(keys=['image'], repeats=3),
    ScaleIntensityd(keys=['image']),
    Resized(keys=['image'], spatial_size=(IMG_SIZE, IMG_SIZE)),
    EnsureTyped(keys=['image', 'label']),
])

train_ds = Dataset(data=train_files,   transform=train_tfms)
val_ds   = Dataset(data=val_files_all, transform=eval_tfms)
test_ds  = Dataset(data=test_files,    transform=eval_tfms)

train_loader = DataLoader(train_ds, batch_size=BATCH_SIZE,
                           shuffle=True,  num_workers=2, pin_memory=True)
val_loader   = DataLoader(val_ds,   batch_size=BATCH_SIZE,
                           shuffle=False, num_workers=2, pin_memory=True)
test_loader  = DataLoader(test_ds,  batch_size=BATCH_SIZE,
                           shuffle=False, num_workers=2, pin_memory=True)

print(f'Train batches : {len(train_loader)}')
print(f'Val batches   : {len(val_loader)}')
print(f'Test batches  : {len(test_loader)}')
Train batches : 32
Val batches   : 1
Test batches  : 4
# Visualizing one case per class

def get_one_per_class(ds):
    normal_sample = None
    pneumonia_sample = None

    for i in range(len(ds)):
        sample = ds[i]
        y = int(sample["label"])
        if y == 0 and normal_sample is None:
            normal_sample = sample
        elif y == 1 and pneumonia_sample is None:
            pneumonia_sample = sample
        if normal_sample is not None and pneumonia_sample is not None:
            break
    return normal_sample, pneumonia_sample


def to_rgb_for_display(x):
    # x: [C,H,W]
    if x.shape[0] > 3:
        x = x[:3]
    elif x.shape[0] == 1:
        x = x.repeat(3, 1, 1)

    img = x.permute(2, 1, 0).cpu().numpy()
    img = np.clip(img, 0, 1)
    return img


def show_normal_and_pneumonia(ds):
    normal_sample, pneumonia_sample = get_one_per_class(ds)

    plt.figure(figsize=(10, 5))
    for i, sample in enumerate([normal_sample, pneumonia_sample], 1):
        x = sample["image"]
        y = int(sample["label"])
        plt.subplot(1, 2, i)
        plt.imshow(to_rgb_for_display(x))
        plt.title("NORMAL" if y == 0 else "PNEUMONIA")
        plt.axis("off")
    plt.tight_layout()
    plt.show()

show_normal_and_pneumonia(train_ds)

🧠 Part 3 — Building the Bayesian DenseNet121

MONAI's DenseNet121 supports pretrained=True for spatial_dims=2 โ€” it loads ImageNet weights from PyTorch Hub. This gives us strong visual features from the start, which is critical with only 1000 training samples.

The architecture:

  • Frozen backbone โ€” all DenseNet121 layers except the head stay fixed. Their ImageNet features are already useful for detecting texture, opacity, and structural patterns in X-rays
  • Bayesian head โ€” we replace the final class_layers.out layer (a standard nn.Linear(1024, out_channels)) with TU's BayesLinear(1024, 2). This one layer learns a distribution over weights rather than fixed values
  • Stochastic inference โ€” T forward passes through the Bayesian head give us a distribution over predictions per image

๐Ÿ’ก Why 1024?
DenseNet121 with block_config=(6,12,24,16) and growth_rate=32 produces 1024 features before the final classifier. This is the standard DenseNet121 feature dimension.

class BayesianDenseNet121(nn.Module):
    """
    MONAI DenseNet121 with a Torch-Uncertainty BayesLinear head.

    The convolutional backbone extracts features deterministically
    using ImageNet pretrained weights.

    The final layer is Bayesian: it learns a distribution over
    weights (mean + variance per weight) instead of a single point.
    This is where Variational Inference lives in the model.
    """

    def __init__(self, num_classes=2, freeze_backbone=True):
        super().__init__()

        # MONAI DenseNet121 with ImageNet pretrained weights
        # spatial_dims=2 for 2D chest X-rays
        # in_channels=3  because RepeatChanneld gives us 3-channel input
        backbone = DenseNet121(
            spatial_dims=2,
            in_channels=3,
            out_channels=num_classes,
            pretrained=True,
        )

        # Keep the feature extraction part
        self.features     = backbone.features
        self.pool         = nn.AdaptiveAvgPool2d((1, 1))

        # Replace the final linear layer with a Bayesian one
        # DenseNet121 final feature dim = 1024
        self.bayes_head = BayesLinear(1024, num_classes)

        # Freeze all backbone parameters โ€” only train the Bayesian head
        if freeze_backbone:
            for param in self.features.parameters():
                param.requires_grad = False

    def forward(self, x):
        x = self.features(x)             # [B, 1024, H', W'] โ€” deterministic
        x = torch.relu(x)               # DenseNet convention: relu after features
        x = self.pool(x)                # [B, 1024, 1, 1]
        x = torch.flatten(x, 1)         # [B, 1024]
        x = self.bayes_head(x)          # [B, 2] โ€” stochastic sampling here
        return x


model = BayesianDenseNet121(num_classes=2, freeze_backbone=True).to(device)

total_params = sum(p.numel() for p in model.parameters())
trainable    = sum(p.numel() for p in model.parameters() if p.requires_grad)
frozen       = total_params - trainable

print(f'Total parameters    : {total_params:,}')
print(f'Frozen (backbone)   : {frozen:,}  <- ImageNet pretrained, fixed')
print(f'Trainable (BayesHead): {trainable:,} <- learns mean + variance per weight')
Downloading: "https://download.pytorch.org/models/densenet121-a639ec97.pth" to /root/.cache/torch/hub/checkpoints/densenet121-a639ec97.pth
100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 30.8M/30.8M [00:00<00:00, 379MB/s]
Total parameters    : 6,957,956
Frozen (backbone)   : 6,953,856  <- ImageNet pretrained, fixed
Trainable (BayesHead): 4,100 <- learns mean + variance per weight

⚙️ Part 4 — Training with the ELBO loss

ELBOLoss from Torch-Uncertainty implements exactly what we derived in the teaching notebook:

$$\mathcal{L} = \underbrace{\mathbb{E}_{q_\phi}[\log p(\mathcal{D}|w)]}_{\text{cross-entropy averaged over T samples}} - \underbrace{\text{KL}(q_\phi(w) \| p(w))}_{\text{weighted by kl\_weight}}$$

kl_weight = 1 / N_train is the standard scaling. It ensures the KL term has the same magnitude as the reconstruction term when summed across the full dataset โ€” preventing either term from dominating.

To keep the notebook computationally practical, training is capped at 100 epochs. We also use:

  • Early stopping to terminate training when validation performance no longer improves
  • ReduceLROnPlateau scheduling to automatically decrease the learning rate when validation loss plateaus

Together, these help stabilize Bayesian optimization, reduce overfitting, and avoid wasting computation on stalled training.

N_TRAIN    = len(train_ds)
NUM_EPOCHS = 100
T_TRAIN    = 3
T_SAMPLES  = 20

EARLY_STOP_PATIENCE = 10
PLATEAU_PATIENCE    = 3
PLATEAU_FACTOR      = 0.5
MIN_LR              = 1e-6
BEST_PATH           = "/kaggle/working/best_model.pth"

loss_fn = ELBOLoss(
    model       = model,
    inner_loss  = nn.CrossEntropyLoss(),
    kl_weight   = 1 / N_TRAIN,
    num_samples = T_TRAIN,
)

# Only optimize Bayesian head
optimizer = optim.Adam(
    filter(lambda p: p.requires_grad, model.parameters()),
    lr=1e-3,
)

# Plateau scheduler
scheduler = optim.lr_scheduler.ReduceLROnPlateau(
    optimizer,
    mode='min',
    factor=PLATEAU_FACTOR,
    patience=PLATEAU_PATIENCE,
    threshold=1e-3,
    threshold_mode='rel',
    min_lr=MIN_LR
)

print(f'Training samples     : {N_TRAIN}')
print(f'KL weight            : {1/N_TRAIN:.6f}')
print(f'MC samples (train)   : {T_TRAIN}')
print(f'MC samples (infer)   : {T_SAMPLES}')
print(f'Epochs               : {NUM_EPOCHS}')
Training samples     : 1000
KL weight            : 0.001000
MC samples (train)   : 3
MC samples (infer)   : 20
Epochs               : 100
history = {
    'train_loss': [],
    'val_loss': [],
    'val_acc': [],
    'val_auc': []
}

best_val_loss = float('inf')
early_stop_counter = 0

for epoch in range(1, NUM_EPOCHS + 1):

    # โ”€โ”€ Train โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    model.train()
    epoch_loss = 0.0

    for batch in tqdm(train_loader,
                      desc=f'Epoch {epoch:02d}/{NUM_EPOCHS}', leave=False):
        images = batch['image'].to(device, dtype=torch.float32)
        labels = batch['label'].to(device, dtype=torch.long)

        optimizer.zero_grad()
        loss = loss_fn(images, labels)
        loss.backward()
        optimizer.step()

        epoch_loss += loss.item()

    avg_loss = epoch_loss / len(train_loader)

    # โ”€โ”€ Validate โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    model.eval()
    val_probs, val_preds, val_labels = [], [], []
    val_loss_total = 0.0

    with torch.no_grad():
        for batch in val_loader:
            images = batch['image'].to(device, dtype=torch.float32)
            labels_tensor = batch['label'].to(device, dtype=torch.long)
            labels_np = labels_tensor.cpu().numpy()

            # ELBO validation loss
            loss = loss_fn(images, labels_tensor)
            val_loss_total += loss.item()

            # predictions
            logits = model(images)
            probs  = torch.softmax(logits, dim=-1).cpu().numpy()

            val_probs.extend(probs[:, 1].tolist())
            val_preds.extend(probs.argmax(axis=1).tolist())
            val_labels.extend(labels_np.tolist())

    val_loss = val_loss_total / len(val_loader)
    val_acc  = accuracy_score(val_labels, val_preds)
    val_auc  = roc_auc_score(val_labels, val_probs)

    # โ”€โ”€ Scheduler step โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    scheduler.step(val_loss)
    current_lr = optimizer.param_groups[0]['lr']

    # โ”€โ”€ Early stopping + best model โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    if val_loss < best_val_loss - 1e-4:
        best_val_loss = val_loss
        early_stop_counter = 0

        torch.save(model.state_dict(), BEST_PATH)
        print(f"โœ… Saved best model (val_loss={val_loss:.4f})")

    else:
        early_stop_counter += 1

    if early_stop_counter >= EARLY_STOP_PATIENCE:
        print(f"\nโ›” Early stopping triggered at epoch {epoch}")
        break

    # โ”€โ”€ Log history โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
    history['train_loss'].append(avg_loss)
    history['val_loss'].append(val_loss)
    history['val_acc'].append(val_acc)
    history['val_auc'].append(val_auc)

    print(f'Epoch {epoch:02d}/{NUM_EPOCHS} | '
          f'Train ELBO: {avg_loss:.4f} | '
          f'Val ELBO: {val_loss:.4f} | '
          f'Val Acc: {val_acc:.3f} | '
          f'Val AUC: {val_auc:.3f} | '
          f'LR: {current_lr:.6f}')

print('\nTraining complete โœ…')
                                                             
โœ… Saved best model (val_loss=9.0834)
Epoch 01/100 | Train ELBO: 9.1687 | Val ELBO: 9.0834 | Val Acc: 0.688 | Val AUC: 0.641 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=8.6662)
Epoch 02/100 | Train ELBO: 8.5925 | Val ELBO: 8.6662 | Val Acc: 0.625 | Val AUC: 0.828 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=8.2975)
Epoch 03/100 | Train ELBO: 8.2532 | Val ELBO: 8.2975 | Val Acc: 0.875 | Val AUC: 0.938 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=8.1261)
Epoch 04/100 | Train ELBO: 8.0887 | Val ELBO: 8.1261 | Val Acc: 0.875 | Val AUC: 0.969 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=7.9827)
Epoch 05/100 | Train ELBO: 7.9319 | Val ELBO: 7.9827 | Val Acc: 0.875 | Val AUC: 0.984 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=7.7973)
Epoch 06/100 | Train ELBO: 7.8377 | Val ELBO: 7.7973 | Val Acc: 0.875 | Val AUC: 0.984 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=7.7586)
Epoch 07/100 | Train ELBO: 7.6918 | Val ELBO: 7.7586 | Val Acc: 0.812 | Val AUC: 1.000 | LR: 0.001000
                                                             
Epoch 08/100 | Train ELBO: 7.6100 | Val ELBO: 7.8156 | Val Acc: 0.688 | Val AUC: 0.969 | LR: 0.001000
                                                             
Epoch 09/100 | Train ELBO: 7.4820 | Val ELBO: 7.7654 | Val Acc: 0.688 | Val AUC: 0.969 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=7.5253)
Epoch 10/100 | Train ELBO: 7.3939 | Val ELBO: 7.5253 | Val Acc: 0.875 | Val AUC: 0.984 | LR: 0.001000
                                                             
Epoch 11/100 | Train ELBO: 7.3072 | Val ELBO: 7.5895 | Val Acc: 0.688 | Val AUC: 0.969 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=7.2878)
Epoch 12/100 | Train ELBO: 7.2205 | Val ELBO: 7.2878 | Val Acc: 0.875 | Val AUC: 0.984 | LR: 0.001000
                                                             
Epoch 13/100 | Train ELBO: 7.1238 | Val ELBO: 7.3621 | Val Acc: 0.750 | Val AUC: 0.984 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=7.1366)
Epoch 14/100 | Train ELBO: 7.0447 | Val ELBO: 7.1366 | Val Acc: 0.875 | Val AUC: 0.969 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=7.1004)
Epoch 15/100 | Train ELBO: 6.9373 | Val ELBO: 7.1004 | Val Acc: 0.812 | Val AUC: 0.938 | LR: 0.001000
                                                             
Epoch 16/100 | Train ELBO: 6.8771 | Val ELBO: 7.1129 | Val Acc: 0.750 | Val AUC: 0.953 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=7.0749)
Epoch 17/100 | Train ELBO: 6.8135 | Val ELBO: 7.0749 | Val Acc: 0.750 | Val AUC: 0.953 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.9287)
Epoch 18/100 | Train ELBO: 6.7150 | Val ELBO: 6.9287 | Val Acc: 0.750 | Val AUC: 0.953 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.8235)
Epoch 19/100 | Train ELBO: 6.6391 | Val ELBO: 6.8235 | Val Acc: 0.812 | Val AUC: 0.969 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.7664)
Epoch 20/100 | Train ELBO: 6.5652 | Val ELBO: 6.7664 | Val Acc: 0.750 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.6306)
Epoch 21/100 | Train ELBO: 6.5310 | Val ELBO: 6.6306 | Val Acc: 0.875 | Val AUC: 0.969 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.5953)
Epoch 22/100 | Train ELBO: 6.4146 | Val ELBO: 6.5953 | Val Acc: 0.812 | Val AUC: 0.938 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.5234)
Epoch 23/100 | Train ELBO: 6.3501 | Val ELBO: 6.5234 | Val Acc: 0.750 | Val AUC: 0.953 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.4973)
Epoch 24/100 | Train ELBO: 6.2985 | Val ELBO: 6.4973 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.4059)
Epoch 25/100 | Train ELBO: 6.2123 | Val ELBO: 6.4059 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.3792)
Epoch 26/100 | Train ELBO: 6.1431 | Val ELBO: 6.3792 | Val Acc: 0.812 | Val AUC: 0.938 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.3584)
Epoch 27/100 | Train ELBO: 6.0658 | Val ELBO: 6.3584 | Val Acc: 0.812 | Val AUC: 0.953 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.2868)
Epoch 28/100 | Train ELBO: 5.9876 | Val ELBO: 6.2868 | Val Acc: 0.812 | Val AUC: 0.938 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.1381)
Epoch 29/100 | Train ELBO: 5.9322 | Val ELBO: 6.1381 | Val Acc: 0.812 | Val AUC: 0.938 | LR: 0.001000
                                                             
Epoch 30/100 | Train ELBO: 5.9078 | Val ELBO: 6.2500 | Val Acc: 0.750 | Val AUC: 0.938 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=6.1041)
Epoch 31/100 | Train ELBO: 5.8037 | Val ELBO: 6.1041 | Val Acc: 0.750 | Val AUC: 0.953 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=5.9254)
Epoch 32/100 | Train ELBO: 5.7209 | Val ELBO: 5.9254 | Val Acc: 0.812 | Val AUC: 0.953 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=5.8222)
Epoch 33/100 | Train ELBO: 5.6569 | Val ELBO: 5.8222 | Val Acc: 0.812 | Val AUC: 0.953 | LR: 0.001000
                                                             
Epoch 34/100 | Train ELBO: 5.5785 | Val ELBO: 5.8398 | Val Acc: 0.812 | Val AUC: 0.953 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=5.8117)
Epoch 35/100 | Train ELBO: 5.5288 | Val ELBO: 5.8117 | Val Acc: 0.750 | Val AUC: 0.938 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=5.6735)
Epoch 36/100 | Train ELBO: 5.4624 | Val ELBO: 5.6735 | Val Acc: 0.938 | Val AUC: 0.938 | LR: 0.001000
                                                             
Epoch 37/100 | Train ELBO: 5.4027 | Val ELBO: 5.8401 | Val Acc: 0.750 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=5.5454)
Epoch 38/100 | Train ELBO: 5.3441 | Val ELBO: 5.5454 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
Epoch 39/100 | Train ELBO: 5.2521 | Val ELBO: 5.5872 | Val Acc: 0.750 | Val AUC: 0.922 | LR: 0.001000
                                                             
Epoch 40/100 | Train ELBO: 5.2183 | Val ELBO: 5.5749 | Val Acc: 0.750 | Val AUC: 0.938 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=5.3233)
Epoch 41/100 | Train ELBO: 5.1263 | Val ELBO: 5.3233 | Val Acc: 0.812 | Val AUC: 0.938 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=5.2227)
Epoch 42/100 | Train ELBO: 5.0848 | Val ELBO: 5.2227 | Val Acc: 0.812 | Val AUC: 0.938 | LR: 0.001000
                                                             
Epoch 43/100 | Train ELBO: 4.9965 | Val ELBO: 5.3360 | Val Acc: 0.625 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=5.1709)
Epoch 44/100 | Train ELBO: 4.9447 | Val ELBO: 5.1709 | Val Acc: 0.750 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=5.1162)
Epoch 45/100 | Train ELBO: 4.8761 | Val ELBO: 5.1162 | Val Acc: 0.750 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=5.1000)
Epoch 46/100 | Train ELBO: 4.8051 | Val ELBO: 5.1000 | Val Acc: 0.688 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=4.9902)
Epoch 47/100 | Train ELBO: 4.7353 | Val ELBO: 4.9902 | Val Acc: 0.750 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=4.9130)
Epoch 48/100 | Train ELBO: 4.6864 | Val ELBO: 4.9130 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=4.8227)
Epoch 49/100 | Train ELBO: 4.6047 | Val ELBO: 4.8227 | Val Acc: 0.750 | Val AUC: 0.938 | LR: 0.001000
                                                             
Epoch 50/100 | Train ELBO: 4.5380 | Val ELBO: 4.8856 | Val Acc: 0.812 | Val AUC: 0.906 | LR: 0.001000
                                                             
Epoch 51/100 | Train ELBO: 4.4643 | Val ELBO: 4.8339 | Val Acc: 0.625 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=4.5935)
Epoch 52/100 | Train ELBO: 4.4139 | Val ELBO: 4.5935 | Val Acc: 0.875 | Val AUC: 0.922 | LR: 0.001000
                                                             
Epoch 53/100 | Train ELBO: 4.3597 | Val ELBO: 4.8968 | Val Acc: 0.562 | Val AUC: 0.891 | LR: 0.001000
                                                             
Epoch 54/100 | Train ELBO: 4.2738 | Val ELBO: 4.6510 | Val Acc: 0.688 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=4.4353)
Epoch 55/100 | Train ELBO: 4.2121 | Val ELBO: 4.4353 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
Epoch 56/100 | Train ELBO: 4.1286 | Val ELBO: 4.5112 | Val Acc: 0.812 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=4.2873)
Epoch 57/100 | Train ELBO: 4.0890 | Val ELBO: 4.2873 | Val Acc: 0.750 | Val AUC: 0.922 | LR: 0.001000
                                                             
Epoch 58/100 | Train ELBO: 4.0048 | Val ELBO: 4.3352 | Val Acc: 0.688 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=4.2391)
Epoch 59/100 | Train ELBO: 3.9498 | Val ELBO: 4.2391 | Val Acc: 0.625 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=4.1174)
Epoch 60/100 | Train ELBO: 3.8804 | Val ELBO: 4.1174 | Val Acc: 0.750 | Val AUC: 0.906 | LR: 0.001000
                                                             
Epoch 61/100 | Train ELBO: 3.8084 | Val ELBO: 4.2509 | Val Acc: 0.625 | Val AUC: 0.891 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=4.0506)
Epoch 62/100 | Train ELBO: 3.7356 | Val ELBO: 4.0506 | Val Acc: 0.812 | Val AUC: 0.891 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.9617)
Epoch 63/100 | Train ELBO: 3.6795 | Val ELBO: 3.9617 | Val Acc: 0.812 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.9577)
Epoch 64/100 | Train ELBO: 3.6224 | Val ELBO: 3.9577 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.7772)
Epoch 65/100 | Train ELBO: 3.5550 | Val ELBO: 3.7772 | Val Acc: 0.812 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.7591)
Epoch 66/100 | Train ELBO: 3.4973 | Val ELBO: 3.7591 | Val Acc: 0.750 | Val AUC: 0.891 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.6489)
Epoch 67/100 | Train ELBO: 3.4311 | Val ELBO: 3.6489 | Val Acc: 0.875 | Val AUC: 0.906 | LR: 0.001000
                                                             
Epoch 68/100 | Train ELBO: 3.3556 | Val ELBO: 3.6729 | Val Acc: 0.750 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.6172)
Epoch 69/100 | Train ELBO: 3.3071 | Val ELBO: 3.6172 | Val Acc: 0.688 | Val AUC: 0.891 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.5381)
Epoch 70/100 | Train ELBO: 3.2242 | Val ELBO: 3.5381 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.4258)
Epoch 71/100 | Train ELBO: 3.1924 | Val ELBO: 3.4258 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.3128)
Epoch 72/100 | Train ELBO: 3.1073 | Val ELBO: 3.3128 | Val Acc: 0.812 | Val AUC: 0.891 | LR: 0.001000
                                                             
Epoch 73/100 | Train ELBO: 3.0659 | Val ELBO: 3.4701 | Val Acc: 0.688 | Val AUC: 0.891 | LR: 0.001000
                                                             
Epoch 74/100 | Train ELBO: 2.9885 | Val ELBO: 3.3328 | Val Acc: 0.812 | Val AUC: 0.891 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.1194)
Epoch 75/100 | Train ELBO: 2.9289 | Val ELBO: 3.1194 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
Epoch 76/100 | Train ELBO: 2.8607 | Val ELBO: 3.2778 | Val Acc: 0.625 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.0362)
Epoch 77/100 | Train ELBO: 2.8125 | Val ELBO: 3.0362 | Val Acc: 0.812 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=3.0314)
Epoch 78/100 | Train ELBO: 2.7417 | Val ELBO: 3.0314 | Val Acc: 0.750 | Val AUC: 0.938 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.8731)
Epoch 79/100 | Train ELBO: 2.6839 | Val ELBO: 2.8731 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
Epoch 80/100 | Train ELBO: 2.6340 | Val ELBO: 2.9869 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.7689)
Epoch 81/100 | Train ELBO: 2.5595 | Val ELBO: 2.7689 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
Epoch 82/100 | Train ELBO: 2.4964 | Val ELBO: 2.8213 | Val Acc: 0.750 | Val AUC: 0.891 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.7495)
Epoch 83/100 | Train ELBO: 2.4157 | Val ELBO: 2.7495 | Val Acc: 0.750 | Val AUC: 0.938 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.5516)
Epoch 84/100 | Train ELBO: 2.3659 | Val ELBO: 2.5516 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.5153)
Epoch 85/100 | Train ELBO: 2.3299 | Val ELBO: 2.5153 | Val Acc: 0.812 | Val AUC: 0.938 | LR: 0.001000
                                                             
Epoch 86/100 | Train ELBO: 2.2442 | Val ELBO: 2.5687 | Val Acc: 0.750 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.3494)
Epoch 87/100 | Train ELBO: 2.2035 | Val ELBO: 2.3494 | Val Acc: 0.875 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.3271)
Epoch 88/100 | Train ELBO: 2.1000 | Val ELBO: 2.3271 | Val Acc: 0.812 | Val AUC: 0.906 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.3017)
Epoch 89/100 | Train ELBO: 2.0391 | Val ELBO: 2.3017 | Val Acc: 0.750 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.1995)
Epoch 90/100 | Train ELBO: 2.0176 | Val ELBO: 2.1995 | Val Acc: 0.812 | Val AUC: 0.906 | LR: 0.001000
                                                             
Epoch 91/100 | Train ELBO: 1.9281 | Val ELBO: 2.2294 | Val Acc: 0.688 | Val AUC: 0.891 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.0869)
Epoch 92/100 | Train ELBO: 1.9243 | Val ELBO: 2.0869 | Val Acc: 0.750 | Val AUC: 0.891 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.0347)
Epoch 93/100 | Train ELBO: 1.8341 | Val ELBO: 2.0347 | Val Acc: 0.812 | Val AUC: 0.891 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=2.0065)
Epoch 94/100 | Train ELBO: 1.7680 | Val ELBO: 2.0065 | Val Acc: 0.750 | Val AUC: 0.891 | LR: 0.001000
                                                             
Epoch 95/100 | Train ELBO: 1.7093 | Val ELBO: 2.0390 | Val Acc: 0.812 | Val AUC: 0.922 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=1.9620)
Epoch 96/100 | Train ELBO: 1.6468 | Val ELBO: 1.9620 | Val Acc: 0.812 | Val AUC: 0.891 | LR: 0.001000
                                                             
โœ… Saved best model (val_loss=1.8053)
Epoch 97/100 | Train ELBO: 1.6226 | Val ELBO: 1.8053 | Val Acc: 0.812 | Val AUC: 0.906 | LR: 0.001000
                                                             
Epoch 98/100 | Train ELBO: 1.5351 | Val ELBO: 1.9322 | Val Acc: 0.750 | Val AUC: 0.922 | LR: 0.001000
                                                             
Epoch 99/100 | Train ELBO: 1.4690 | Val ELBO: 2.1150 | Val Acc: 0.750 | Val AUC: 0.875 | LR: 0.001000
                                                              
โœ… Saved best model (val_loss=1.7474)
Epoch 100/100 | Train ELBO: 1.4264 | Val ELBO: 1.7474 | Val Acc: 0.812 | Val AUC: 0.953 | LR: 0.001000

Training complete โœ…
# Training curves
fig, axes = plt.subplots(1, 2, figsize=(18, 4))
fig.suptitle('Variational Inference โ€” Training curves', fontsize=13)

epochs = range(1, len(history['train_loss']) + 1)

plot_config = [
    ('train_loss', '#D85A30', 'Train ELBO Loss'),
    ('val_loss',   '#7F77DD', 'Val ELBO Loss')
]

for ax, (key, color, title) in zip(axes, plot_config):
    ax.plot(epochs, history[key], color=color, linewidth=2)
    ax.set_title(title)
    ax.set_xlabel('Epoch')
    ax.spines[['top', 'right']].set_visible(False)

plt.tight_layout()
plt.show()

🔮 Part 5 — Stochastic inference on the test set

For each test image we run T=20 stochastic forward passes. Each pass samples different weights from $q_\phi(w)$ via the reparameterization trick inside BayesLinear. The spread across T predictions is the epistemic uncertainty.

QuantityFormulaClinical meaning
Mean prediction$\bar{p} = \frac{1}{T}\sum_t p_t$The model's best estimate of the probability of each diagnosis
Predictive entropy$H(\bar{p}) = -\sum_c \bar{p}_c \log \bar{p}_c$Total uncertainty in the final prediction
Aleatoric uncertainty$\frac{1}{T}\sum_t H(p_t)$Uncertainty due to image ambiguity, noise, overlapping findings, or inherently difficult cases
Epistemic uncertainty$H(\bar{p}) - \frac{1}{T}\sum_t H(p_t)$Uncertainty due to limited model knowledge; high when different weight samples disagree
Correct$\mathrm{argmax}(\bar{p}) = y$Whether the final prediction matches the ground truth
# Loading the best model

model = BayesianDenseNet121(
    num_classes=2,
    freeze_backbone=True
).to(device)

# I renamed the best model to VI_best_model.pth and added it to inputs

BEST_PATH = "/kaggle/input/models/benyamingheiji/vi-best-model-pth/pytorch/default/1/VI_best_model.pth"

model.load_state_dict(torch.load(BEST_PATH, map_location=device))

model.eval()

with torch.no_grad():
    batch = next(iter(test_loader))
    images = batch['image'].to(device)

    logits = model(images)
    probs = torch.softmax(logits, dim=-1)

print("Loaded model works. Output shape:", probs.shape)
Loaded model works. Output shape: torch.Size([32, 2])
def stochastic_inference_vi(model, loader, T=20, device=device):
    """
    VI inference: T forward passes, each sampling different weights from q_ฯ†(w).

    model.eval() keeps batchnorm deterministic.
    BayesLinear still samples weights each forward pass โ€” this is correct VI.

    Returns results DataFrame and raw image tensors.
    """
    model.eval()

    all_mean_preds, all_entropy, all_variance = [], [], []
    all_epistemic, all_aleatoric              = [], []
    all_labels, all_paths, all_images         = [], [], []

    with torch.no_grad():
        for batch in tqdm(loader, desc='VI stochastic inference'):
            images = batch['image'].to(device, dtype=torch.float32)
            labels = batch['label'].cpu().numpy()
            paths  = (batch.get('image_meta_dict', {}).get('filename_or_obj')
                      or [''] * len(labels))

            # T forward passes โ€” each samples different weights from q_ฯ†
            sample_probs = []
            for _ in range(T):
                probs = torch.softmax(model(images), dim=-1).cpu().numpy()
                sample_probs.append(probs)

            sample_probs = np.stack(sample_probs, axis=0)  # [T, B, 2]
            mean_probs   = sample_probs.mean(axis=0)       # [B, 2]

            # Total uncertainty: H of the mean prediction
            total_H = -np.sum(
                mean_probs * np.log(mean_probs + 1e-8), axis=-1
            )  # [B]

            # Aleatoric: expected entropy across T weight samples
            per_H       = -np.sum(
                sample_probs * np.log(sample_probs + 1e-8), axis=-1
            )  # [T, B]
            aleatoric_H = per_H.mean(axis=0)   # [B]

            # Epistemic: mutual information = total - aleatoric
            epistemic_MI = total_H - aleatoric_H   # [B]

            # Variance of P(pneumonia) across T
            variance = sample_probs[:, :, 1].var(axis=0)   # [B]

            all_mean_preds.extend(mean_probs[:, 1].tolist())
            all_entropy.extend(total_H.tolist())
            all_epistemic.extend(epistemic_MI.tolist())
            all_aleatoric.extend(aleatoric_H.tolist())
            all_variance.extend(variance.tolist())
            all_labels.extend(labels.tolist())
            all_paths.extend(list(paths))
            all_images.extend(images.cpu().unbind(0))

    preds   = (np.array(all_mean_preds) >= 0.5).astype(int)
    correct = (preds == np.array(all_labels)).astype(int)

    results = pd.DataFrame({
        'image_path'        : all_paths,
        'true_label'        : all_labels,
        'mean_pred'         : all_mean_preds,
        'pred_label'        : preds.tolist(),
        'predictive_entropy': all_entropy,
        'epistemic_mi'      : all_epistemic,
        'aleatoric_h'       : all_aleatoric,
        'variance'          : all_variance,
        'correct'           : correct.tolist(),
    })
    return results, all_images


results, test_images = stochastic_inference_vi(
    model, test_loader, T=T_SAMPLES, device=device
)

print(f'Test samples  : {len(results)}')
print(f'Accuracy      : {results["correct"].mean():.3f}')
print(f'AUC-ROC       : {roc_auc_score(results["true_label"], results["mean_pred"]):.3f}')
VI stochastic inference: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 4/4 [00:10<00:00,  2.57s/it]
Test samples  : 100
Accuracy      : 0.900
AUC-ROC       : 0.949

🎲 Part 6 — Uncertainty decomposition across the test set

We now analyse how the Bayesian model decomposes predictive uncertainty on unseen chest X-rays into total, epistemic, and aleatoric components. The following plots show the distribution of uncertainty across the test set, the contribution of each uncertainty type per case, and the relationship between prediction confidence and uncertainty.

# Uncertainty decomposition

print('Uncertainty decomposition (mean across test set):')
print(f'  Total entropy  : {results["predictive_entropy"].mean():.4f}')
print(f'  Epistemic (MI) : {results["epistemic_mi"].mean():.4f}')
print(f'  Aleatoric (H)  : {results["aleatoric_h"].mean():.4f}')
Uncertainty decomposition (mean across test set):
  Total entropy  : 0.3228
  Epistemic (MI) : 0.0227
  Aleatoric (H)  : 0.3001
# โ”€โ”€ Uncertainty decomposition visualisation โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

fig, axes = plt.subplots(1, 3, figsize=(15, 4.5))
fig.suptitle('Uncertainty decomposition across the test set', fontsize=13)

axes[0].hist(results['predictive_entropy'], bins=20,
             color='#534AB7', alpha=0.7, edgecolor='white')
axes[0].set_xlabel('Total uncertainty (entropy)', fontsize=10)
axes[0].set_ylabel('Count', fontsize=10)
axes[0].set_title('Total uncertainty', fontsize=11)
axes[0].spines[['top', 'right']].set_visible(False)

idx_sorted = results['predictive_entropy'].argsort().values
axes[1].bar(range(len(results)),
            results['epistemic_mi'].values[idx_sorted],
            color='#1D9E75', alpha=0.8, label='Epistemic (MI)')
axes[1].bar(range(len(results)),
            results['aleatoric_h'].values[idx_sorted],
            bottom=results['epistemic_mi'].values[idx_sorted],
            color='#D85A30', alpha=0.8, label='Aleatoric (H)')
axes[1].set_xlabel('Cases sorted by total uncertainty', fontsize=10)
axes[1].set_ylabel('Uncertainty (nats)', fontsize=10)
axes[1].set_title('Epistemic vs aleatoric per case', fontsize=11)
axes[1].legend(fontsize=9)
axes[1].spines[['top', 'right']].set_visible(False)

correct_mask = results['correct'].astype(bool)
axes[2].scatter(results.loc[correct_mask, 'mean_pred'],
                results.loc[correct_mask, 'predictive_entropy'],
                c='#1D9E75', alpha=0.6, s=30, label='Correct')
axes[2].scatter(results.loc[~correct_mask, 'mean_pred'],
                results.loc[~correct_mask, 'predictive_entropy'],
                c='#D85A30', alpha=0.8, s=55, marker='x', label='Wrong')
axes[2].set_xlabel('Mean P(pneumonia)', fontsize=10)
axes[2].set_ylabel('Predictive entropy', fontsize=10)
axes[2].set_title('Confidence vs uncertainty', fontsize=11)
axes[2].legend(fontsize=9)
axes[2].spines[['top', 'right']].set_visible(False)

plt.tight_layout()
plt.show()

The figure illustrates the uncertainty decomposition obtained from the variational inference framework on the test set. The left panel shows the distribution of total predictive uncertainty, measured using entropy, across all test samples. The middle panel decomposes the uncertainty for each test case into epistemic uncertainty (mutual information), representing uncertainty in the model parameters, and aleatoric uncertainty (predictive entropy), representing inherent ambiguity or noise in the data. Test cases are sorted by increasing total uncertainty, revealing that aleatoric uncertainty dominates for most samples while epistemic uncertainty remains comparatively small. The right panel relates the modelโ€™s predicted probability for the positive class to predictive entropy, highlighting how uncertainty increases for intermediate confidence predictions and decreases for highly confident predictions near 0 or 1, with misclassified cases tending to occur in regions of higher uncertainty.


📊 Part 7 — Clustering certain vs uncertain cases

We split the test set into certain and uncertain groups using the gaussian mixture model, then:

  1. Compare accuracy and AUC between groups
  2. Run a Mann-Whitney U test (non-parametric, no normality assumption) to check whether the uncertainty scores of correct and wrong predictions are statistically distinguishable

If VI produces meaningful uncertainty, the uncertain group should have lower accuracy โ€” the model should be less reliable precisely when it says it's unsure.

# Run stochastic inference on validation set
# We use validation entropy distribution to LEARN the uncertainty threshold.
# Then we apply that threshold to the TEST set.

val_results, _ = stochastic_inference_vi(
    model,
    val_loader,
    T=T_SAMPLES,
    device=device
)

# Fit GMM on VALIDATION entropy

X_val = val_results['predictive_entropy'].values.reshape(-1, 1)

gmm = GaussianMixture(
    n_components=2,
    random_state=42
)

gmm.fit(X_val)

# Find threshold between the two Gaussians

means = gmm.means_.flatten()
means = np.sort(means)

# midpoint between Gaussian means
threshold = means.mean()

print('GMM learned from VALIDATION set')
print(f'Cluster means         : {means}')
print(f'Entropy threshold     : {threshold:.4f}')
VI stochastic inference: 100%|โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ| 1/1 [00:02<00:00,  2.24s/it]
GMM learned from VALIDATION set
Cluster means         : [0.16512958 0.55724172]
Entropy threshold     : 0.3612

# Apply threshold to TEST set

results['uncertainty_group'] = np.where(
    results['predictive_entropy'] > threshold,
    'Uncertain',
    'Certain'
)

certain   = results[results['uncertainty_group'] == 'Certain']
uncertain = results[results['uncertainty_group'] == 'Uncertain']

print()
print(f'Certain group   : {len(certain)} cases')
print(f'Uncertain group : {len(uncertain)} cases')


# Metrics function

def group_metrics(df, name):

    acc = df['correct'].mean()

    auc = (
        roc_auc_score(df['true_label'], df['mean_pred'])
        if df['true_label'].nunique() > 1
        else float('nan')
    )

    mean_ent = df['predictive_entropy'].mean()
    mean_var = df['variance'].mean()

    return {
        'Group'         : name,
        'N Cases'       : len(df),
        'Accuracy'      : acc,
        'AUC-ROC'       : auc,
        'Mean Entropy'  : mean_ent,
        'Mean Variance' : mean_var,
    }


# Compute metrics

certain_metrics   = group_metrics(certain, 'Certain')
uncertain_metrics = group_metrics(uncertain, 'Uncertain')

summary_df = pd.DataFrame([
    certain_metrics,
    uncertain_metrics
])

summary_display = summary_df.copy()

for col in ['Accuracy', 'AUC-ROC', 'Mean Entropy', 'Mean Variance']:
    summary_display[col] = summary_display[col].map(lambda x: f'{x:.4f}')

print('\n๐Ÿ“Š Certain vs Uncertain Summary')
display(summary_display)


# Extract values

acc_cert   = certain_metrics['Accuracy']
acc_uncert = uncertain_metrics['Accuracy']

auc_cert   = certain_metrics['AUC-ROC']
auc_uncert = uncertain_metrics['AUC-ROC']


# Mann-Whitney U test (entropy difference between correct vs wrong)

entropy_correct = results.loc[
    results['correct'] == 1,
    'predictive_entropy'
].values

entropy_wrong = results.loc[
    results['correct'] == 0,
    'predictive_entropy'
].values

if len(entropy_wrong) > 0 and len(entropy_correct) > 0:

    stat, p_value = stats.mannwhitneyu(
        entropy_correct,
        entropy_wrong,
        alternative='less'
    )

    print()
    print('๐Ÿ“Š Mann-Whitney U test')
    print('H0: entropy(correct) == entropy(wrong)')

    print(f'U statistic : {stat:.2f}')
    print(f'p-value     : {p_value:.4f}')


# Accuracy comparison (z-test)

stat2, p2 = proportions_ztest(
    [
        int(acc_cert * len(certain)),
        int(acc_uncert * len(uncertain))
    ],
    [
        len(certain),
        len(uncertain)
    ]
)

print()
print('๐Ÿ“Š Z-test for accuracy difference')
print('H0: accuracy(certain) == accuracy(uncertain)')

print(f'Z statistic : {stat2:.3f}')
print(f'p-value     : {p2:.4f}')

fig, axes = plt.subplots(1, 3, figsize=(16, 4.8))

fig.suptitle(
    'Certain vs Uncertain Groups',
    fontsize=13
)

groups = ['Certain', 'Uncertain']
colors = ['#1D9E75', '#D85A30']


# Accuracy comparison

axes[0].bar(groups, [acc_cert, acc_uncert], color=colors, alpha=0.8, width=0.5)

axes[0].set_ylim(0, 1.05)
axes[0].set_ylabel('Accuracy')
axes[0].set_title('Accuracy by group')

for i, v in enumerate([acc_cert, acc_uncert]):
    axes[0].text(i, v + 0.02, f'{v:.3f}', ha='center', fontsize=11, fontweight='bold')

axes[0].spines[['top', 'right']].set_visible(False)


# AUC comparison (descriptive only)

axes[1].bar(groups, [auc_cert, auc_uncert], color=colors, alpha=0.8, width=0.5)

axes[1].set_ylim(0, 1.05)
axes[1].set_ylabel('AUC-ROC')
axes[1].set_title('AUC by group')

for i, v in enumerate([auc_cert, auc_uncert]):
    axes[1].text(i, v + 0.02, f'{v:.3f}', ha='center', fontsize=11, fontweight='bold')

axes[1].spines[['top', 'right']].set_visible(False)


# Entropy distributions

axes[2].hist(
    certain['predictive_entropy'],
    bins=15,
    alpha=0.6,
    color='#1D9E75',
    edgecolor='white',
    label='Certain'
)

axes[2].hist(
    uncertain['predictive_entropy'],
    bins=15,
    alpha=0.6,
    color='#D85A30',
    edgecolor='white',
    label='Uncertain'
)

axes[2].axvline(
    threshold,
    color='black',
    linestyle='--',
    linewidth=1.5,
    label=f'Threshold = {threshold:.3f}'
)

axes[2].set_xlabel('Predictive entropy')
axes[2].set_ylabel('Count')
axes[2].set_title('Entropy distributions')
axes[2].legend()

axes[2].spines[['top', 'right']].set_visible(False)

plt.tight_layout()
plt.show()
Certain group   : 58 cases
Uncertain group : 42 cases

๐Ÿ“Š Certain vs Uncertain Summary
Group N Cases Accuracy AUC-ROC Mean Entropy Mean Variance
0 Certain 58 0.9655 0.9718 0.1764 0.0014
1 Uncertain 42 0.8095 0.8541 0.5249 0.0150
๐Ÿ“Š Mann-Whitney U test
H0: entropy(correct) == entropy(wrong)
U statistic : 194.00
p-value     : 0.0017

๐Ÿ“Š Z-test for accuracy difference
H0: accuracy(certain) == accuracy(uncertain)
Z statistic : 2.566
p-value     : 0.0103

The figure compares model performance between test samples classified as certain and uncertain based on a predictive entropy threshold of 0.361 derived from the variational inference uncertainty estimates. Out of the 100 test cases, 60 samples were categorized as certain and 40 as uncertain. The left panel shows that the model achieves substantially higher accuracy on the certain group (96.7%) compared to the uncertain group (81.0%), while the middle panel demonstrates a similar trend for discrimination performance, with AUC-ROC decreasing from 97.2% in the certain group to 85.4% in the uncertain group. The right panel visualizes the entropy distributions for both groups, where low-entropy samples are concentrated in the certain set and higher-entropy samples dominate the uncertain set, separated by the chosen threshold. Quantitatively, the uncertain group exhibits significantly higher mean predictive entropy (0.52 vs 0.18) and predictive variance (0.015 vs 0.001), indicating both greater predictive ambiguity and model uncertainty. Statistical analysis further confirms these observations: the Mannโ€“Whitney U test shows a significant difference in entropy distributions between correct and incorrect predictions (p = 0.0017), and the Z-test demonstrates that the accuracy difference between the certain and uncertain groups is statistically significant (p = 0.01).


🏥 Part 8 — Medical visualization

We present two most certain and two most uncertain cases from the test set

# Visualisation helper

LABEL_NAMES = {0: 'Normal', 1: 'Pneumonia'}

def tensor_to_display(x):

    # take first channel
    img = x[0].numpy()

    # rotate 90ยฐ clockwise
    img = np.rot90(img, k=-1)

    # mirror flip (left-right flip)
    img = np.fliplr(img)

    return np.clip(img, 0, 1)


def plot_xray_group(indices, results_df, images_list,
                    group_title, n_show=2):

    indices = list(indices)[:n_show]

    if not indices:
        print(f'No cases to show for: {group_title}')
        return

    fig, axes = plt.subplots(
        1,
        len(indices),
        figsize=(5 * len(indices), 5.5)
    )

    if len(indices) == 1:
        axes = [axes]

    fig.suptitle(
        group_title,
        fontsize=13,
        fontweight='bold',
        y=1.02
    )

    for ax, idx in zip(axes, indices):

        row = results_df.loc[idx]

        # now rotated + mirrored
        img = tensor_to_display(images_list[idx])

        ax.imshow(img, cmap='gray')
        ax.axis('off')

        true_name = LABEL_NAMES[int(row['true_label'])]
        pred_name = LABEL_NAMES[int(row['pred_label'])]

        verdict = 'โœ“ Correct' if row['correct'] else 'โœ— Wrong'

        color = '#1D9E75' if row['correct'] else '#D85A30'

        title = (
            f'{verdict}\n'
            f'True: {true_name}\n'
            f'Pred: {pred_name} ({row["mean_pred"]:.2f})\n'
            f'Entropy: {row["predictive_entropy"]:.4f}\n'
            f'Variance: {row["variance"]:.5f}'
        )

        ax.set_title(
            title,
            fontsize=9,
            color=color,
            pad=5
        )

    plt.tight_layout()
    plt.show()
# MOST CERTAIN CASES

most_certain_idx = (
    certain
    .nsmallest(2, 'predictive_entropy')
    .index.tolist()
)

plot_xray_group(
    most_certain_idx,
    results,
    test_images,
    '๐ŸŸข Most Certain Cases'
)
/tmp/ipykernel_57/2731258473.py:76: UserWarning: Glyph 128994 (\N{LARGE GREEN CIRCLE}) missing from font(s) DejaVu Sans.
  plt.tight_layout()
/usr/local/lib/python3.12/dist-packages/IPython/core/pylabtools.py:151: UserWarning: Glyph 128994 (\N{LARGE GREEN CIRCLE}) missing from font(s) DejaVu Sans.
  fig.canvas.print_figure(bytes_io, **kw)
# MOST UNCERTAIN CASES

most_uncertain_idx = (
    uncertain
    .nlargest(2, 'predictive_entropy')
    .index.tolist()
)

plot_xray_group(
    most_uncertain_idx,
    results,
    test_images,
    '๐Ÿ”ด Most Uncertain Cases'
)
/tmp/ipykernel_57/2731258473.py:76: UserWarning: Glyph 128308 (\N{LARGE RED CIRCLE}) missing from font(s) DejaVu Sans.
  plt.tight_layout()
/usr/local/lib/python3.12/dist-packages/IPython/core/pylabtools.py:151: UserWarning: Glyph 128308 (\N{LARGE RED CIRCLE}) missing from font(s) DejaVu Sans.
  fig.canvas.print_figure(bytes_io, **kw)

💾 Part 9 — Save results

results['method'] = 'VI'

save_path = '/kaggle/working/results_vi.csv'
results.to_csv(save_path, index=False)

print(f'Results saved to: {save_path}')
Results saved to: /kaggle/working/results_vi.csv

✅ Session summary

Step What we did
๐Ÿ—‚๏ธ Data Subsampled with class ratios (normal:pneu = 2.7:1 train, 1.7:1 test)
๐Ÿง  Model MONAI DenseNet121 (ImageNet pretrained) + TU BayesLinear head
โš™๏ธ Training ELBOLoss with kl_weight = 1/N_train, T=3 samples per step
๐Ÿ”ฎ Inference T=20 stochastic forward passes โ†’ entropy + variance per image
๐Ÿงฉ Decomposition Total = Epistemic (MI) + Aleatoric (expected H) โ€” per image
๐Ÿ“Š Statistics Mann-Whitney U test on entropy and accuracy of correct vs wrong predictions
๐Ÿฅ Visualization Most Certain and Most Uncertain cases

โžก๏ธ Next: Session 7: MC Dropout
Session 7 introduces Monte Carlo Dropout: a cheaper approximation that keeps dropout active at test time, runs repeated stochastic forward passes, and uses prediction variability as an uncertainty signal. After building VI from scratch, this next session asks whether a much simpler trick can give clinically useful uncertainty too.

UQMIA โ€” Uncertainty Quantification in Medical Imaging Analysis GitHub repository Paper (arXiv)Benyamin Gheiji ยท Danial Elyassirad ยท Mahsa Vatanparast ยท Meysam Tavakoli ยท Shahriar Faghani