CNNs for Medical Imaging


Deep Learning for Healthcare — Part 2: From Convolutions to X-Ray Diagnosis — August 2026

Convolutional Neural Networks for Medical Imaging

"The pixels that matter are often the ones next to each other."

August 2026 · Giacomo Saccaggi


Why Images Need Special Treatment

In Part 1, we learned how to represent discrete medical codes as dense embeddings. But what about medical images? Chest X-rays, retinal scans, histopathology slides, and CT scans contain rich diagnostic information—but they require fundamentally different neural network architectures.

Consider a 224×224 grayscale X-ray image. If we flatten it into a vector, we get 50,176 input features. A single fully connected layer with 1,000 hidden units would require 50 million parameters—just for one layer! This is computationally prohibitive and prone to overfitting.

More fundamentally, fully connected networks ignore the spatial structure of images:

Convolutional Neural Networks (CNNs) address all these issues through three key innovations: local connectivity, weight sharing, and pooling.


From Fully Connected to Locally Connected

Let's trace the evolution from fully connected networks to convolutions.

Fully Connected Networks

In a fully connected (FC) layer, every input neuron connects to every output neuron. For an image input, this means each output depends on all pixels:

Fully connected network

Fully connected: every output depends on every input pixel

Problems with this approach:

Locally Connected Networks

What if each output neuron only looks at a small local region (receptive field) of the input? This is the locally connected network:

Local Connectivity

Each output neuron connects only to a small patch of the input.

For a 5×5 receptive field on a 224×224 image:

Parameters per output neuron = 5 × 5 = 25 (instead of 50,176)

This drastically reduces parameters and captures local spatial patterns. But we still have separate weights for each location—so a vertical edge detector at position (10, 10) uses different weights than at position (100, 100).

Weight Sharing: The Convolution

The key insight: the same local pattern should be detected the same way, regardless of position. This leads to weight sharing—using the same weights (called a filter or kernel) across all spatial locations.

Convolution operation

Convolution: the same filter slides across the entire image

This is the convolution operation—the foundation of CNNs.

ApproachParametersSpatial AwarenessTranslation Invariance
Fully ConnectedO(H × W × outputs)NoneNone
Locally ConnectedO(F² × H × W)Yes (local)None
ConvolutionO(F²)Yes (local)Yes

The Convolution Operation

1D Convolution

Before tackling images, let's understand 1D convolution on a signal. Given an input sequence and a filter, convolution slides the filter across the input, computing dot products:

1D Convolution

For input signal $x$ and filter $k$ of size $F$:

$$(x * k)[i] = \sum_{m=0}^{F-1} x[i+m] \cdot k[m]$$

The filter "slides" across the input, producing one output value at each position.

import numpy as np

# 1D convolution example
signal = np.array([1, 2, 3, 4, 5, 6, 7])
kernel = np.array([1, 0, -1])  # Edge detector

# Manual convolution
output = []
for i in range(len(signal) - len(kernel) + 1):
    output.append(np.sum(signal[i:i+len(kernel)] * kernel))

print(output)  # [-2, -2, -2, -2, -2] - detects constant slope

2D Convolution

For images, we extend to 2D. The filter is now a small matrix (e.g., 3×3) that slides across both height and width:

2D Convolution

For input image $I$ and kernel $K$ of size $F \times F$:

$$(I * K)[i,j] = \sum_{m=0}^{F-1} \sum_{n=0}^{F-1} I[i+m, j+n] \cdot K[m,n]$$
CNN architecture

2D convolution: kernel slides across height and width dimensions

import torch
import torch.nn.functional as F

# Create a simple 5x5 image
image = torch.tensor([
    [0, 0, 0, 0, 0],
    [0, 1, 1, 1, 0],
    [0, 1, 1, 1, 0],
    [0, 1, 1, 1, 0],
    [0, 0, 0, 0, 0]
], dtype=torch.float32).unsqueeze(0).unsqueeze(0)  # [1, 1, 5, 5]

# Horizontal edge detector
kernel = torch.tensor([
    [-1, -1, -1],
    [ 0,  0,  0],
    [ 1,  1,  1]
], dtype=torch.float32).unsqueeze(0).unsqueeze(0)  # [1, 1, 3, 3]

# Apply convolution
output = F.conv2d(image, kernel)
print(output.squeeze())
# tensor([[-3., -3., -3.],
#         [ 0.,  0.,  0.],
#         [ 3.,  3.,  3.]])

The output shows negative values at the top edge (dark→light transition) and positive values at the bottom edge (light→dark).


Stride and Padding

Stride

The stride controls how far the filter moves at each step. Stride=1 moves one pixel at a time; stride=2 moves two pixels, producing a smaller output:

Stride and padding

Stride controls how much the filter moves; padding controls border handling

# Stride comparison
image = torch.randn(1, 1, 8, 8)  # 8x8 image
kernel = torch.randn(1, 1, 3, 3)  # 3x3 filter

out_s1 = F.conv2d(image, kernel, stride=1)  # Output: 6x6
out_s2 = F.conv2d(image, kernel, stride=2)  # Output: 3x3

print(f"Stride=1: {out_s1.shape}")  # torch.Size([1, 1, 6, 6])
print(f"Stride=2: {out_s2.shape}")  # torch.Size([1, 1, 3, 3])

Padding

Padding adds zeros (or other values) around the input border. This allows the filter to process edge pixels and can maintain spatial dimensions:

# Padding to maintain size
image = torch.randn(1, 1, 8, 8)
kernel = torch.randn(1, 1, 3, 3)

out_no_pad = F.conv2d(image, kernel, padding=0)  # 6x6
out_pad = F.conv2d(image, kernel, padding=1)     # 8x8 (same as input!)

print(f"No padding: {out_no_pad.shape}")  # [1, 1, 6, 6]
print(f"Padding=1: {out_pad.shape}")     # [1, 1, 8, 8]

Output Dimension Formula

For input size $W_{in}$, filter size $F$, padding $P$, and stride $S$:

$$W_{out} = \frac{W_{in} - F + 2P}{S} + 1$$

Same formula applies to height $H_{out}$.


3D Convolution: Multiple Channels

Real images have multiple channels. A color image has 3 channels (RGB); a medical CT scan might have dozens of slices. The convolution extends naturally to handle this.

Multi-channel convolution

Multi-channel convolution: filter has same depth as input channels

Input Channels

For an input with $D_{in}$ channels, the filter has shape $F \times F \times D_{in}$. Each channel has its own 2D filter, and outputs are summed:

Multi-channel Convolution

$$(I * K)[i,j] = \sum_{c=0}^{D_{in}-1} \sum_{m=0}^{F-1} \sum_{n=0}^{F-1} I[c, i+m, j+n] \cdot K[c, m, n]$$

One filter produces one output channel (feature map).

Multiple Filters → Multiple Feature Maps

To detect different features (edges, textures, shapes), we use multiple filters. Each filter produces one feature map, so $K$ filters produce $K$ output channels:

import torch.nn as nn

# RGB image: 3 input channels
# 64 filters: 64 output feature maps
conv = nn.Conv2d(
    in_channels=3,      # RGB
    out_channels=64,    # Number of filters
    kernel_size=3,      # 3x3 filters
    stride=1,
    padding=1
)

# Input: [batch, channels, height, width]
x = torch.randn(1, 3, 224, 224)
out = conv(x)
print(out.shape)  # torch.Size([1, 64, 224, 224])

💡 Dimensions Through a Conv Layer


The Complete Convolution Layer

A full convolutional layer combines several operations:

  1. Convolution: Apply filters to extract features
  2. Bias addition: Add a learnable bias to each feature map
  3. Activation: Apply non-linearity (typically ReLU)

Complete Conv Layer

$$\text{output}[k] = \text{ReLU}\left( \sum_{c} I[c] * K[k,c] + b[k] \right)$$

Where $K[k,c]$ is the $c$-th channel of the $k$-th filter, and $b[k]$ is the bias for filter $k$.

import torch.nn as nn

# Complete conv layer with bias and ReLU
class ConvBlock(nn.Module):
    def __init__(self, in_channels, out_channels, kernel_size=3, padding=1):
        super().__init__()
        self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, padding=padding)
        self.relu = nn.ReLU(inplace=True)
    
    def forward(self, x):
        x = self.conv(x)   # Convolution + bias
        x = self.relu(x)   # Non-linearity
        return x

# Usage
block = ConvBlock(in_channels=3, out_channels=64)
x = torch.randn(1, 3, 224, 224)
out = block(x)  # [1, 64, 224, 224]

Parameter Count

How many learnable parameters does a conv layer have?

Number of Parameters

$$\text{Parameters} = F \times F \times D_{in} \times K + K$$

Where:

# Parameter count example
conv = nn.Conv2d(3, 64, kernel_size=3)

# Weights: 3 × 3 × 3 × 64 = 1,728
# Biases: 64
# Total: 1,792

total_params = sum(p.numel() for p in conv.parameters())
print(f"Total parameters: {total_params}")  # 1792

Pooling Layers

Pooling reduces spatial dimensions while preserving important features. It provides:

Pooling operation

Max pooling: take the maximum value in each region

Types of Pooling

Pooling TypeOperationProperties
Max PoolingTake maximum value in windowPreserves strongest activations; most common
Average PoolingTake mean of values in windowSmoother; good for final layers
Sum PoolingSum all values in windowPreserves total activation
import torch.nn as nn

# Max pooling: 2x2 window, stride 2 → halves dimensions
maxpool = nn.MaxPool2d(kernel_size=2, stride=2)

x = torch.randn(1, 64, 224, 224)
out = maxpool(x)
print(out.shape)  # torch.Size([1, 64, 112, 112])

# Average pooling
avgpool = nn.AvgPool2d(kernel_size=2, stride=2)
out_avg = avgpool(x)
print(out_avg.shape)  # torch.Size([1, 64, 112, 112])

💡 Why Max Pooling Works for Medical Imaging

In medical images, we often care about whether a feature exists, not exactly where it is. Max pooling preserves the strongest activation in each region, making the network robust to small translations. A nodule detected at position (100, 100) or (102, 98) should produce similar outputs.


CNN Architecture: AlexNet

Let's examine AlexNet (2012), the architecture that revolutionized computer vision. Understanding its structure helps grasp how CNNs build hierarchical representations.

AlexNet architecture

AlexNet: 5 convolutional layers followed by 3 fully connected layers

Layer-by-Layer Breakdown

LayerConfigOutput SizeParameters
Input227×227×3
Conv196 filters, 11×11, stride 455×55×9634,944
MaxPool13×3, stride 227×27×960
Conv2256 filters, 5×5, pad 227×27×256614,656
MaxPool23×3, stride 213×13×2560
Conv3384 filters, 3×3, pad 113×13×384885,120
Conv4384 filters, 3×3, pad 113×13×3841,327,488
Conv5256 filters, 3×3, pad 113×13×256884,992
MaxPool33×3, stride 26×6×2560
FC64096 units409637,752,832
FC74096 units409616,781,312
FC81000 units (classes)10004,097,000

Key observation: Convolutional layers have ~3.7M parameters, while fully connected layers have ~58.6M! The FC layers dominate the parameter count.

# Simplified AlexNet-style architecture
class SimpleCNN(nn.Module):
    def __init__(self, num_classes=1000):
        super().__init__()
        
        self.features = nn.Sequential(
            # Conv1: 3 → 96 channels, 11x11, stride 4
            nn.Conv2d(3, 96, kernel_size=11, stride=4),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            
            # Conv2: 96 → 256 channels, 5x5
            nn.Conv2d(96, 256, kernel_size=5, padding=2),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
            
            # Conv3-5: deeper feature extraction
            nn.Conv2d(256, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(384, 384, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(384, 256, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=3, stride=2),
        )
        
        self.classifier = nn.Sequential(
            nn.Dropout(),
            nn.Linear(256 * 6 * 6, 4096),
            nn.ReLU(inplace=True),
            nn.Dropout(),
            nn.Linear(4096, 4096),
            nn.ReLU(inplace=True),
            nn.Linear(4096, num_classes),
        )
    
    def forward(self, x):
        x = self.features(x)
        x = x.view(x.size(0), 256 * 6 * 6)  # Flatten
        x = self.classifier(x)
        return x

Healthcare Applications of CNNs

CNNs have achieved remarkable success in medical imaging tasks, often matching or exceeding expert human performance.

Retinal Disease Detection

Gulshan et al. (JAMA 2016) trained a CNN to detect diabetic retinopathy from fundus photographs. The model achieved:

Retinal imaging CNN

CNNs can detect diabetic retinopathy from retinal fundus images

Chest X-Ray Classification

CheXNet (Rajpurkar et al., 2017) detected pneumonia from chest X-rays, exceeding the average performance of four radiologists. This is the task we'll implement in the practical section below.

🏥 Why Medical Imaging + CNNs?


Practical Implementation: Pneumonia Detection from Chest X-Rays

Let's build a complete CNN system to classify chest X-rays as Normal or Pneumonia. We'll use transfer learning with a pre-trained ResNet18 model.

Dataset Overview

Our dataset contains anterior-posterior chest X-ray images from pediatric patients (ages 1-5 years) from Guangzhou Women and Children's Medical Center:

# Dataset structure
# chest_xray/
# ├── train/
# │   ├── NORMAL/
# │   │   ├── IM-0115-0001.jpeg
# │   │   └── ...
# │   └── PNEUMONIA/
# │       ├── person1_bacteria_1.jpeg
# │       └── ...
# ├── val/
# │   ├── NORMAL/
# │   └── PNEUMONIA/
# └── test/
#     ├── NORMAL/
#     └── PNEUMONIA/

Data Loading and Transforms

We use torchvision transforms to preprocess images: resize to 224×224, convert to tensor, and normalize using ImageNet statistics (since we'll use a pre-trained model).

import os
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms, models
import numpy as np
from PIL import Image

# Define transforms
train_transforms = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.RandomHorizontalFlip(),        # Data augmentation
    transforms.RandomRotation(10),            # Slight rotation
    transforms.ToTensor(),
    transforms.Normalize(
        mean=[0.485, 0.456, 0.406],          # ImageNet mean
        std=[0.229, 0.224, 0.225]            # ImageNet std
    )
])

val_transforms = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(
        mean=[0.485, 0.456, 0.406],
        std=[0.229, 0.224, 0.225]
    )
])

# Load datasets
data_dir = "chest_xray"

train_dataset = datasets.ImageFolder(
    root=os.path.join(data_dir, "train"),
    transform=train_transforms
)

val_dataset = datasets.ImageFolder(
    root=os.path.join(data_dir, "val"),
    transform=val_transforms
)

test_dataset = datasets.ImageFolder(
    root=os.path.join(data_dir, "test"),
    transform=val_transforms
)

# Create data loaders
train_loader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4)
val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4)
test_loader = DataLoader(test_dataset, batch_size=32, shuffle=False, num_workers=4)

print(f"Training samples: {len(train_dataset)}")
print(f"Validation samples: {len(val_dataset)}")
print(f"Test samples: {len(test_dataset)}")
print(f"Classes: {train_dataset.classes}")
# Output:
# Training samples: 5216
# Validation samples: 16
# Test samples: 624
# Classes: ['NORMAL', 'PNEUMONIA']

Transfer Learning with ResNet18

Transfer learning leverages features learned from large datasets (ImageNet with 1.2M images) and adapts them to our smaller medical dataset. We:

  1. Load a pre-trained ResNet18 model
  2. Replace the final fully connected layer for binary classification
  3. Optionally freeze early layers (feature extraction) and only train later layers
import torchvision.models as models

def create_model(num_classes=2, pretrained=True, freeze_features=False):
    """Create ResNet18 model for transfer learning.
    
    Args:
        num_classes: Number of output classes (2 for Normal/Pneumonia)
        pretrained: Use ImageNet pre-trained weights
        freeze_features: If True, freeze convolutional layers
    """
    # Load pre-trained ResNet18
    model = models.resnet18(pretrained=pretrained)
    
    # Optionally freeze feature extraction layers
    if freeze_features:
        for param in model.parameters():
            param.requires_grad = False
    
    # Replace final FC layer
    # Original: Linear(512, 1000) for ImageNet
    # New: Linear(512, 2) for binary classification
    num_features = model.fc.in_features
    model.fc = nn.Linear(num_features, num_classes)
    
    return model

# Create model
model = create_model(num_classes=2, pretrained=True, freeze_features=False)

# Count parameters
total_params = sum(p.numel() for p in model.parameters())
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Total parameters: {total_params:,}")
print(f"Trainable parameters: {trainable_params:,}")
# Output:
# Total parameters: 11,177,538
# Trainable parameters: 11,177,538

💡 Why Transfer Learning Works

Early CNN layers learn generic features (edges, textures, shapes) that transfer well across domains. A vertical edge detector learned on cats works equally well on X-rays. By reusing these features, we need fewer labeled medical images to achieve good performance.

Training Loop

def train_model(model, train_loader, val_loader, num_epochs=10, lr=0.001):
    """Train the CNN model."""
    device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
    print(f"Training on: {device}")
    
    model = model.to(device)
    
    # Loss function and optimizer
    criterion = nn.CrossEntropyLoss()
    optimizer = optim.Adam(model.parameters(), lr=lr)
    scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1)
    
    # Training history
    history = {'train_loss': [], 'train_acc': [], 'val_loss': [], 'val_acc': []}
    
    best_val_acc = 0.0
    
    for epoch in range(num_epochs):
        # ============ Training Phase ============
        model.train()
        running_loss = 0.0
        correct = 0
        total = 0
        
        for inputs, labels in train_loader:
            inputs, labels = inputs.to(device), labels.to(device)
            
            optimizer.zero_grad()
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            loss.backward()
            optimizer.step()
            
            running_loss += loss.item() * inputs.size(0)
            _, predicted = outputs.max(1)
            total += labels.size(0)
            correct += predicted.eq(labels).sum().item()
        
        train_loss = running_loss / total
        train_acc = correct / total
        
        # ============ Validation Phase ============
        model.eval()
        running_loss = 0.0
        correct = 0
        total = 0
        
        with torch.no_grad():
            for inputs, labels in val_loader:
                inputs, labels = inputs.to(device), labels.to(device)
                outputs = model(inputs)
                loss = criterion(outputs, labels)
                
                running_loss += loss.item() * inputs.size(0)
                _, predicted = outputs.max(1)
                total += labels.size(0)
                correct += predicted.eq(labels).sum().item()
        
        val_loss = running_loss / total
        val_acc = correct / total
        
        # Update learning rate
        scheduler.step()
        
        # Save history
        history['train_loss'].append(train_loss)
        history['train_acc'].append(train_acc)
        history['val_loss'].append(val_loss)
        history['val_acc'].append(val_acc)
        
        # Save best model
        if val_acc > best_val_acc:
            best_val_acc = val_acc
            torch.save(model.state_dict(), "best_model.pt")
        
        print(f"Epoch {epoch+1:2d}/{num_epochs} | "
              f"Train Loss: {train_loss:.4f} Acc: {train_acc:.4f} | "
              f"Val Loss: {val_loss:.4f} Acc: {val_acc:.4f}")
    
    return history

# Train the model
history = train_model(model, train_loader, val_loader, num_epochs=10, lr=0.001)
# Sample output:
# Training on: cuda
# Epoch  1/10 | Train Loss: 0.3842 Acc: 0.8456 | Val Loss: 0.2134 Acc: 0.9375
# Epoch  2/10 | Train Loss: 0.1823 Acc: 0.9312 | Val Loss: 0.1567 Acc: 0.9375
# Epoch  3/10 | Train Loss: 0.1345 Acc: 0.9501 | Val Loss: 0.1234 Acc: 0.9688
# Epoch  4/10 | Train Loss: 0.1012 Acc: 0.9623 | Val Loss: 0.0987 Acc: 0.9688
# Epoch  5/10 | Train Loss: 0.0823 Acc: 0.9712 | Val Loss: 0.0856 Acc: 1.0000
# Epoch  6/10 | Train Loss: 0.0534 Acc: 0.9801 | Val Loss: 0.0734 Acc: 1.0000
# Epoch  7/10 | Train Loss: 0.0412 Acc: 0.9856 | Val Loss: 0.0698 Acc: 1.0000
# Epoch  8/10 | Train Loss: 0.0345 Acc: 0.9878 | Val Loss: 0.0654 Acc: 1.0000
# Epoch  9/10 | Train Loss: 0.0289 Acc: 0.9901 | Val Loss: 0.0623 Acc: 1.0000
# Epoch 10/10 | Train Loss: 0.0256 Acc: 0.9912 | Val Loss: 0.0612 Acc: 1.0000

Model Evaluation

For medical diagnosis, accuracy alone is insufficient. We need metrics that capture the clinical implications of errors.

Evaluation Metrics

from sklearn.metrics import (
    accuracy_score, precision_score, recall_score, 
    f1_score, roc_auc_score, confusion_matrix, 
    classification_report
)

def evaluate_model(model, test_loader, device):
    """Comprehensive model evaluation."""
    model.eval()
    
    all_preds = []
    all_labels = []
    all_probs = []
    
    with torch.no_grad():
        for inputs, labels in test_loader:
            inputs = inputs.to(device)
            outputs = model(inputs)
            probs = torch.softmax(outputs, dim=1)
            _, preds = outputs.max(1)
            
            all_preds.extend(preds.cpu().numpy())
            all_labels.extend(labels.numpy())
            all_probs.extend(probs[:, 1].cpu().numpy())  # P(Pneumonia)
    
    all_preds = np.array(all_preds)
    all_labels = np.array(all_labels)
    all_probs = np.array(all_probs)
    
    # Calculate metrics
    accuracy = accuracy_score(all_labels, all_preds)
    precision = precision_score(all_labels, all_preds)
    recall = recall_score(all_labels, all_preds)
    f1 = f1_score(all_labels, all_preds)
    auroc = roc_auc_score(all_labels, all_probs)
    
    print("=" * 50)
    print("MODEL EVALUATION RESULTS")
    print("=" * 50)
    print(f"Accuracy:  {accuracy:.4f}")
    print(f"Precision: {precision:.4f}")
    print(f"Recall:    {recall:.4f}")
    print(f"F1 Score:  {f1:.4f}")
    print(f"AUROC:     {auroc:.4f}")
    print("=" * 50)
    
    # Confusion matrix
    cm = confusion_matrix(all_labels, all_preds)
    print("\nConfusion Matrix:")
    print("                 Predicted")
    print("              Normal  Pneumonia")
    print(f"Actual Normal    {cm[0,0]:4d}      {cm[0,1]:4d}")
    print(f"    Pneumonia    {cm[1,0]:4d}      {cm[1,1]:4d}")
    
    return {
        'accuracy': accuracy,
        'precision': precision,
        'recall': recall,
        'f1': f1,
        'auroc': auroc,
        'confusion_matrix': cm,
        'predictions': all_preds,
        'probabilities': all_probs
    }

# Load best model and evaluate
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.load_state_dict(torch.load("best_model.pt"))
model = model.to(device)

results = evaluate_model(model, test_loader, device)
# Sample output:
# ==================================================
# MODEL EVALUATION RESULTS
# ==================================================
# Accuracy:  0.9263
# Precision: 0.9156
# Recall:    0.9744
# F1 Score:  0.9441
# AUROC:     0.9654
# ==================================================
# 
# Confusion Matrix:
#                  Predicted
#               Normal  Pneumonia
# Actual Normal   198        36
#     Pneumonia    10       380

⚠️ Clinical Interpretation

False Negatives (FN = 10): Pneumonia cases missed by the model. In clinical practice, these are dangerous—patients with pneumonia go untreated.

False Positives (FP = 36): Normal cases flagged as pneumonia. These lead to unnecessary follow-up but are less dangerous than FN.

For screening applications, high recall (sensitivity) is prioritized to minimize missed diagnoses.

Visualizing Predictions

import matplotlib.pyplot as plt

def visualize_predictions(model, test_dataset, device, num_images=8):
    """Visualize model predictions on sample images."""
    model.eval()
    
    # Get random samples
    indices = np.random.choice(len(test_dataset), num_images, replace=False)
    
    fig, axes = plt.subplots(2, 4, figsize=(16, 8))
    axes = axes.flatten()
    
    class_names = ['Normal', 'Pneumonia']
    
    for idx, ax in zip(indices, axes):
        image, label = test_dataset[idx]
        
        # Get prediction
        with torch.no_grad():
            output = model(image.unsqueeze(0).to(device))
            prob = torch.softmax(output, dim=1)
            pred = output.argmax(1).item()
            confidence = prob[0, pred].item()
        
        # Denormalize image for display
        img_display = image.permute(1, 2, 0).numpy()
        img_display = img_display * np.array([0.229, 0.224, 0.225]) + np.array([0.485, 0.456, 0.406])
        img_display = np.clip(img_display, 0, 1)
        
        # Plot
        ax.imshow(img_display)
        
        # Color based on correct/incorrect
        color = 'green' if pred == label else 'red'
        ax.set_title(
            f"True: {class_names[label]}\nPred: {class_names[pred]} ({confidence:.2f})",
            color=color, fontsize=10
        )
        ax.axis('off')
    
    plt.tight_layout()
    plt.savefig("predictions.png", dpi=150)
    plt.show()

visualize_predictions(model, test_dataset, device)

ROC Curve

from sklearn.metrics import roc_curve, auc

def plot_roc_curve(labels, probabilities):
    """Plot ROC curve."""
    fpr, tpr, thresholds = roc_curve(labels, probabilities)
    roc_auc = auc(fpr, tpr)
    
    plt.figure(figsize=(8, 6))
    plt.plot(fpr, tpr, color='#4a90d9', lw=2, 
             label=f'ROC curve (AUC = {roc_auc:.3f})')
    plt.plot([0, 1], [0, 1], color='gray', lw=1, linestyle='--', label='Random')
    
    plt.xlim([0.0, 1.0])
    plt.ylim([0.0, 1.05])
    plt.xlabel('False Positive Rate', fontsize=12)
    plt.ylabel('True Positive Rate', fontsize=12)
    plt.title('Receiver Operating Characteristic', fontsize=14)
    plt.legend(loc='lower right')
    plt.grid(True, alpha=0.3)
    plt.savefig("roc_curve.png", dpi=150)
    plt.show()

# Get predictions from evaluation
test_labels = []
for _, label in test_loader:
    test_labels.extend(label.numpy())

plot_roc_curve(test_labels, results['probabilities'])

Results Summary

MetricValueClinical Interpretation
Accuracy92.6%Overall correct classifications
Precision91.6%Of predicted pneumonia, 91.6% truly have it
Recall (Sensitivity)97.4%Of actual pneumonia, 97.4% detected
Specificity84.6%Of normal cases, 84.6% correctly identified
F1 Score94.4%Harmonic mean of precision and recall
AUROC96.5%Discriminative ability across thresholds

The model achieves 97.4% recall—critical for a screening tool where missing pneumonia cases is costly. The trade-off is lower specificity (84.6%), meaning some healthy patients are flagged for follow-up.


Summary

We covered the theory and practice of CNNs for medical imaging:

ConceptKey IdeaBenefit
Local ConnectivityEach neuron sees only a local regionCaptures spatial patterns, fewer parameters
Weight SharingSame filter applied everywhereTranslation invariance, massive parameter reduction
ConvolutionSliding dot product with learnable kernelLearns feature detectors automatically
PoolingDownsample by taking max/avgReduces dimensions, adds robustness
Transfer LearningReuse features from ImageNetWorks well with limited medical data
ResNetResidual connections enable deep networksState-of-the-art feature extraction

Key Formulas

Output size:

$$W_{out} = \frac{W_{in} - F + 2P}{S} + 1$$

Parameters:

$$\text{Params} = F \times F \times D_{in} \times K + K$$

2D Convolution:

$$(I * K)[i,j] = \sum_m \sum_n I[i+m, j+n] \cdot K[m,n]$$

🔬 What's Next?

In Part 3, we'll explore Recurrent Neural Networks (RNNs) for sequential medical data: time series of vital signs, sequences of clinical events, and longitudinal patient trajectories.


References