Blood Cell Classifier with Grad-CAM

Train a ResNet-18 model to classify blood cells and explain predictions with Grad-CAM.

Introduction

30 Second Summary

When a doctor looks at a blood sample under a microscope, they can identify different types of white blood cells by their shape, color, and internal structures. This skill takes years to develop. Deep learning models can learn to do the same thing in minutes.

In this project, you will train a convolutional neural network to classify microscopy images of blood cells into 8 types. Then you will use Grad-CAM to generate heatmaps that reveal exactly which parts of each cell image the model focuses on when making its prediction.

What You'll Build

You will feed a blood cell image into your trained model and instantly see both the predicted cell type and a color heatmap highlighting the cellular features that drove that prediction.

By the end of this project, you'll have:

  • A blood cell classifier that takes in a microscopy image and predicts its type (basophil, eosinophil, erythroblast, lymphocyte, monocyte, neutrophil, platelet, or immature granulocyte) with over 90% accuracy.
  • Visual Grad-CAM heatmaps overlaid on cell images showing exactly which regions (nucleus, cytoplasm, granules) the model relies on for each classification decision.
  • A reusable Jupyter Notebook where you can swap one line of code to retrain the same pipeline on any of 12 other MedMNIST biomedical datasets like chest X-rays, retinal scans, or skin lesions.
  • Secret Mission: Compare your transfer learning model against a simple CNN trained from scratch and visualize the accuracy difference in a head-to-head bar chart.

Are there any prerequisites?

You should have Python experience and basic familiarity with NumPy. No prior PyTorch or deep learning experience is required.

Before We Start

Doctors and lab technicians examine blood cell samples under microscopes every day to identify cell types and diagnose conditions. Deep learning can automate this classification at scale, but only if you can trust its predictions.

In this step, you will set up a deep learning environment with PyTorch, download the libraries needed for training and model explainability, and confirm your GPU is ready to accelerate training.

In this step, get ready to:
  • Create a Python virtual environment and install PyTorch with GPU support.
  • Launch Jupyter Notebook and create the project notebook.
  • Verify all imports and confirm GPU availability.
Create a virtual environment and install packages

A virtual environment keeps your project's packages isolated from the rest of your system. This prevents version conflicts between projects.

  • Open your terminal in WSL/Linux.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create a project folder, move into it, and set up a virtual environment by running these commands:
mkdir blood-cell-classifier
cd blood-cell-classifier
python3 -m venv .venv
source .venv/bin/activate

What do these commands do?

  • mkdir blood-cell-classifier creates your project folder.
  • python3 -m venv .venv creates an isolated Python environment inside a .venv folder.
  • source .venv/bin/activate activates the environment so all packages install into it.
  • Confirm the environment is active. Your terminal prompt should now start with (.venv).

Don't see (.venv) in your prompt?

Make sure you ran source .venv/bin/activate (not just python3 -m venv .venv). The venv command creates the environment. The source command activates it.

If you see an error about python3-venv not being installed, run sudo apt install python3-venv first, then retry.

  • Install PyTorch with GPU support, torchvision, and all other project dependencies by running:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu128
pip install medmnist grad-cam jupyter numpy matplotlib scikit-learn seaborn

Why two separate install commands?

PyTorch needs a special index URL (--index-url) to pull the GPU-enabled version. The other packages install from the default PyPI index.

The cu128 suffix means CUDA 12.8, which supports most modern NVIDIA GPUs (Turing architecture and newer).

  • The install will take a few minutes as PyTorch is a large package. You should see progress bars and a final Successfully installed message listing all packages.

Install failing or timing out?

If you see a CUDA version mismatch error, check your GPU architecture. Older GPUs (pre-Turing) need cu126 instead of cu128. Replace the index URL with https://download.pytorch.org/whl/cu126 and retry.

If pip itself fails, confirm your virtual environment is active (you should see (.venv) in your prompt).

help me install PyTorch with GPU support on my Linux system

Launch Jupyter Notebook and create the project notebook

Jupyter Notebook lets you write and run Python code in small, testable cells. This is ideal for iterative deep learning work where you want to see results after each step.

  • Launch Jupyter Notebook by running:
jupyter notebook

What happens when you run this?

Jupyter starts a local server and opens your browser to a file listing. This command takes over the terminal, so you will interact with Jupyter through the browser from now on.

If the browser does not open automatically, look for a URL in the terminal output starting with http://localhost:8888 and paste it into your browser.

  • In the Jupyter browser interface, click **New** in the top right corner.
  • Select **Python 3** from the dropdown menu.
  • A new untitled notebook opens. Click **Untitled** at the top of the page to rename it.
  • Type blood_cell_classifier and click **Rename**.
  • You should now see blood_cell_classifier as the notebook title and an empty code cell ready for input.

Browser didn't open or page won't load?

If you are running WSL, your default Windows browser should open. Copy the full URL from the terminal (including the token parameter) and paste it manually.

If you see a jupyter: command not found error, make sure your (.venv) is active and re-run pip install jupyter.

help me get Jupyter Notebook running on WSL

Verify all imports and confirm GPU availability

Before writing any model code, you need to confirm every library loads correctly and your GPU is detected. If something is missing, you want to catch it now rather than mid-training.

  • In the first cell of your blood_cell_classifier.ipynb notebook, paste the following code:
# Cell 1: Imports and Setup
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
import torchvision.transforms as transforms
from torchvision.models import resnet18, ResNet18_Weights
import medmnist
from medmnist import INFO, BloodMNIST
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix, classification_report
import seaborn as sns
from pytorch_grad_cam import GradCAM
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
from pytorch_grad_cam.utils.image import show_cam_on_image

print(f"PyTorch version: {torch.__version__}")
print(f"MedMNIST version: {medmnist.__version__}")
print(f"GPU available: {torch.cuda.is_available()}")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")

What does this code do?

  • The first group of imports loads PyTorch (for building and training neural networks), torchvision (for pre-trained models and image transforms), and MedMNIST (for the blood cell dataset).
  • The second group loads visualization tools (matplotlib, seaborn) and evaluation tools (scikit-learn) for plotting results and computing metrics.
  • The pytorch_grad_cam imports bring in the explainability library that will generate heatmaps showing what the model focuses on.
  • The final lines print version info and set up the device variable, which tells PyTorch to use your GPU for training if one is available.

Before you run this cell, what do you expect to see printed? Think about whether your GPU should be detected.

  • Run the cell by pressing **Shift+Enter**.

You should see four lines of output. The PyTorch version should show 2.13.0 (or your installed version), the MedMNIST version should show 3.0.2, GPU available should print True, and the device should read cuda.

GPU showing False or seeing an ImportError?

If torch.cuda.is_available() returns False, your PyTorch installation may not have CUDA support. Return to your terminal, deactivate the environment with deactivate, then delete and recreate it. Reinstall PyTorch with the --index-url flag for CUDA 12.8.

If you see ModuleNotFoundError for any package, the install may have partially failed. Run pip install again for the missing package name from your terminal (with the venv active).

help me fix my PyTorch CUDA or import errors

✔️ Awesome, I've got everything!

Great. Double check that you have saved your notebook (Ctrl+S) and that the output shows GPU available: True.

ⓧ I'd like to double check the full code

Here is the complete Cell 1 that should be in your notebook:

# Cell 1: Imports and Setup
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
import torchvision.transforms as transforms
from torchvision.models import resnet18, ResNet18_Weights
import medmnist
from medmnist import INFO, BloodMNIST
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix, classification_report
import seaborn as sns
from pytorch_grad_cam import GradCAM
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
from pytorch_grad_cam.utils.image import show_cam_on_image

print(f"PyTorch version: {torch.__version__}")
print(f"MedMNIST version: {medmnist.__version__}")
print(f"GPU available: {torch.cuda.is_available()}")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")

Your deep learning environment is ready and your GPU is confirmed. Next up, you will download the BloodMNIST dataset and explore the blood cell images you will be classifying.

Set Up Your Deep Learning Environment

Training a deep learning model requires a specific stack of libraries working together. PyTorch handles the neural network training, torchvision provides pre-trained models, and specialized libraries like MedMNIST and pytorch-grad-cam connect your model to biomedical data and explainability tools.

In this step, you will confirm that your environment is fully wired together by running your first notebook cell. A single successful run proves that every package can talk to each other and that your GPU is ready to accelerate training.

In this step, get ready to:
  • Verify your installed deep learning packages load without errors.
  • Confirm GPU access so training runs on your graphics card instead of the CPU.
  • Run Cell 1 in your Jupyter Notebook to lock in your environment baseline.
Review your imports and setup code

Your blood_cell_classifier.ipynb notebook already has Cell 1 written. Before running it, take a moment to understand what each group of imports brings to the project.

  • In your open Jupyter Notebook, look at the code in Cell 1:
# Cell 1: Imports and Setup
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
import torchvision.transforms as transforms
from torchvision.models import resnet18, ResNet18_Weights
import medmnist
from medmnist import INFO, BloodMNIST
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix, classification_report
import seaborn as sns
from pytorch_grad_cam import GradCAM
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
from pytorch_grad_cam.utils.image import show_cam_on_image

What do these imports do?

  • torch, torch.nn, torch.optim provide the core neural network building blocks, layers, and optimizers.
  • torchvision.transforms and torchvision.models give you image preprocessing tools and the pre-trained ResNet-18 model.
  • medmnist handles downloading and loading the BloodMNIST dataset of labeled blood cell images.
  • pytorch_grad_cam generates heatmap explanations showing which image regions drive the model's decisions.
  • numpy, matplotlib, seaborn, and sklearn.metrics handle array operations, plotting, and evaluation metrics.
Run Cell 1 and confirm GPU access

The bottom of Cell 1 prints version numbers and checks whether your GPU is visible to PyTorch. This is the moment of truth for your environment.

  • Look at the remaining lines at the bottom of Cell 1:
print(f"PyTorch version: {torch.__version__}")
print(f"MedMNIST version: {medmnist.__version__}")
print(f"GPU available: {torch.cuda.is_available()}")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")

What does this code do?

torch.__version__ and medmnist.__version__ print the installed library versions so you can confirm you are running the expected releases.

torch.cuda.is_available() returns True if PyTorch detects a CUDA-capable GPU. The device variable is set to cuda or cpu accordingly. Every tensor and model you create later will be sent to this device.

Before you run this cell, what do you expect the output to show for GPU availability?

  • Click inside Cell 1 and press Shift+Enter to run it.

You should see output similar to this:

PyTorch version: 2.13.0
MedMNIST version: 3.0.2
GPU available: True
Using device: cuda

What should I see?

The exact version numbers may differ slightly depending on when you installed. The critical line is GPU available: True and Using device: cuda. If both appear, your GPU is ready to accelerate model training.

Seeing GPU available: False?

  • Confirm your NVIDIA drivers are installed by running nvidia-smi in your terminal. If it returns an error, your GPU drivers need to be installed or updated.
  • Make sure you installed the CUDA-enabled version of PyTorch. The CPU-only build always returns False for torch.cuda.is_available().
  • If you are on WSL, ensure your Windows NVIDIA driver supports WSL2 GPU passthrough.

Why is my GPU not detected by PyTorch?

✔️ Awesome, I've got everything!

Great. Double check that you have saved your notebook with Ctrl+S.

ⓧ I'd like to double check the full code

Your Cell 1 should contain exactly this code:

# Cell 1: Imports and Setup
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
import torchvision.transforms as transforms
from torchvision.models import resnet18, ResNet18_Weights
import medmnist
from medmnist import INFO, BloodMNIST
import numpy as np
import matplotlib.pyplot as plt
from sklearn.metrics import confusion_matrix, classification_report
import seaborn as sns
from pytorch_grad_cam import GradCAM
from pytorch_grad_cam.utils.model_targets import ClassifierOutputTarget
from pytorch_grad_cam.utils.image import show_cam_on_image

print(f"PyTorch version: {torch.__version__}")
print(f"MedMNIST version: {medmnist.__version__}")
print(f"GPU available: {torch.cuda.is_available()}")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")

Your deep learning environment is confirmed and ready. Next up, you will load thousands of blood cell images and see what the data looks like before training a model on it.

Load and Explore the BloodMNIST Dataset

Your deep learning environment is ready. Now it is time to meet the data you will train your model on.

Before building any model, you need to see what the data looks like. Understanding image dimensions, class distribution, and visual differences between cell types will directly inform your model design choices in the next step.

In this step, get ready to:
  • Download and inspect the BloodMNIST dataset structure.
  • Set up image transforms and PyTorch DataLoaders for training, validation, and test splits.
  • Visualize sample images from all 8 cell types in a grid.
Download and inspect the dataset structure

The MedMNIST library ships with a metadata registry called INFO that describes every dataset it offers. You will query it for BloodMNIST to learn the class names, sample counts, and image properties before downloading anything.

  • In your notebook, create a new cell below Cell 1 and paste the following code:
# Cell 2: Dataset Loading and Exploration
info = INFO["bloodmnist"]
n_classes = len(info["label"])
class_names = [info["label"][str(i)] for i in range(n_classes)]
print(f"Dataset: BloodMNIST")
print(f"Classes ({n_classes}): {class_names}")
print(f"Samples: train={info['n_samples']['train']}, val={info['n_samples']['val']}, test={info['n_samples']['test']}")

What does this code do?

  • INFO["bloodmnist"] pulls the metadata dictionary for the BloodMNIST dataset from the MedMNIST registry.
  • info["label"] is a dictionary mapping class indices to human-readable cell type names.
  • The print statements show the total number of images across the train, validation, and test splits so you know how much data the model will work with.
  • Run the cell by pressing Shift+Enter.

You should see output showing 8 class names (basophil, eosinophil, erythroblast, and more) and sample counts: 11,959 training images, 1,712 validation images, and 3,421 test images.

Seeing a KeyError?

Make sure you typed "bloodmnist" in all lowercase. The INFO dictionary uses lowercase keys.

Confirm that Cell 1 ran successfully before this cell. The INFO import lives there.

Why am I getting a KeyError on INFO in my MedMNIST notebook?

Set up transforms and DataLoaders

The raw BloodMNIST images are 28x28 pixels. Your model (ResNet-18) expects 224x224 input because it was pre-trained on ImageNet images at that resolution. You need a transform pipeline that resizes, converts to tensors, and normalizes each image before it reaches the model.

  • Add this code to the bottom of Cell 2 (the same cell you just ran):
data_transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5])
])

train_dataset = BloodMNIST(split="train", transform=data_transform, download=True, size=28)
val_dataset = BloodMNIST(split="val", transform=data_transform, download=True, size=28)
test_dataset = BloodMNIST(split="test", transform=data_transform, download=True, size=28)

train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False)

What does this code do?

  • data_transform chains three operations: resize to 224x224, convert to a PyTorch tensor, then normalize pixel values. The normalization uses mean=0.5 and std=0.5 per channel, matching the official MedMNIST benchmark setup.
  • download=True tells MedMNIST to fetch the dataset from the internet and cache it locally at ~/.medmnist/. This download only happens once.
  • DataLoader wraps each dataset so PyTorch can feed images to the model in batches of 64. Shuffling the training data prevents the model from memorizing the order of examples.
  • Run Cell 2 again by pressing Shift+Enter.

You should see download progress messages as MedMNIST fetches the BloodMNIST data files (~50 MB). After the download completes, the cell finishes with no errors.

Download hanging or failing?

Check your internet connection. The dataset downloads from a remote server on first run.

If the download was interrupted, delete the partial files by running rm -rf ~/.medmnist/bloodmnist* in your terminal, then re-run the cell.

Why is my BloodMNIST dataset download failing in Jupyter?

Visualize sample images from each cell type

Numbers tell you the dataset has 8 classes, but you need to see the cells themselves to understand what your model will learn to distinguish. A visualization grid lets you compare visual features across all 8 types at once.

  • Create a new cell below Cell 2 and paste this visualization code:
# Cell 3: Visualize Sample Images
raw_dataset = BloodMNIST(split="train", transform=transforms.ToTensor(), download=False, size=28)
fig, axes = plt.subplots(2, 4, figsize=(12, 6))
for i, ax in enumerate(axes.flat):
    for idx in range(len(raw_dataset)):
        img, label = raw_dataset[idx]
        if label.item() == i:
            ax.imshow(img.permute(1, 2, 0).numpy())
            ax.set_title(class_names[i][:15], fontsize=9)
            ax.axis("off")
            break
plt.suptitle("Blood Cell Types in BloodMNIST", fontsize=14)
plt.tight_layout()
plt.show()

What does this code do?

  • raw_dataset loads the same training images with only ToTensor() applied (no normalization). This keeps pixel values in [0, 1] so matplotlib can display them as natural colors.
  • The nested loop finds the first image matching each class index (0 through 7), so you get one representative per cell type.
  • img.permute(1, 2, 0) rearranges dimensions from PyTorch format (channels, height, width) to matplotlib format (height, width, channels).

Before you run this cell, what do you expect to see? Eight identical squares, or eight visually different cell types?

  • Run Cell 3 by pressing Shift+Enter.

You should see a 2x4 grid of microscopy images. Each cell shows a different blood cell type with its label above it. Notice how some types (like eosinophil and neutrophil) share a similar overall shape but differ in internal granule patterns.

What am I looking at?

Each square is a 28x28 pixel microscopy image of a stained blood cell. The purple/blue coloring comes from the staining process used in medical labs. Your model will learn to distinguish these 8 types based on features like cell size, nucleus shape, and granule density.

Not seeing the image grid?

If the plot does not appear inline, add %matplotlib inline at the top of the cell and re-run.

If you see a blank figure, confirm that Cell 2 ran without errors. The class_names variable must be defined before Cell 3 can use it.

Why is my matplotlib plot not showing in Jupyter Notebook?

✔️ Awesome, I've got everything!

Great. Double check that you have saved your notebook.

ⓧ I'd like to double check the full code

Your notebook should now have three cells, in this order:

  • Cell 1: Imports and Setup (from Step 1). All imports, version prints, and device setup.
  • Cell 2: Dataset Loading and Exploration. Metadata inspection, transform pipeline, dataset instantiation, and DataLoader creation.
  • Cell 3: Visualize Sample Images. The raw_dataset load, 2x4 subplot grid, and plt.show().

Scroll through your notebook and confirm each cell is present and runs without errors.

You now have a clear picture of your data: 17,092 microscopy images split into 8 distinct blood cell types, all resized and normalized for your model. Next up, you will build a ResNet-18 model and train it to classify these cells using transfer learning.

Train a ResNet-18 Classifier with Transfer Learning

You have your data loaded and you have seen what blood cell images look like. Now the question is: how do you get a computer to tell these 8 cell types apart?

Training a CNN from scratch on just 11,959 images would be slow and underperform. Transfer learning solves this by leveraging features already learned from millions of natural images and adapting them to recognize cell structures in minutes.

In this step, get ready to:
  • Load a pre-trained ResNet-18 model and adapt it for 8-class blood cell classification.
  • Define and run a training loop with loss tracking and validation.
  • Observe the model converge above 90% validation accuracy in 5 epochs.
Load a pre-trained ResNet-18 and prepare it for transfer learning

ResNet-18 is a convolutional neural network that was trained on over a million images from ImageNet. It already knows how to detect edges, textures, and shapes. You will freeze those learned features and only retrain the final classification layer to output your 8 blood cell types.

  • Create a new cell in your notebook (Cell 4) and add the following code to load and configure the model:
# Cell 4: Build Transfer Learning Model
model = resnet18(weights=ResNet18_Weights.IMAGENET1K_V1)
for param in model.parameters():
    param.requires_grad = False
model.fc = nn.Linear(model.fc.in_features, n_classes)
model = model.to(device)
print(f"Model: ResNet-18 (frozen backbone, trainable FC head)")
print(f"Output classes: {n_classes}")

What does this code do?

  • resnet18(weights=ResNet18_Weights.IMAGENET1K_V1) downloads a ResNet-18 model that was pre-trained on ImageNet (1.2 million images across 1,000 classes).
  • param.requires_grad = False freezes every layer so its weights will not change during training. This preserves the feature-detection ability the model already learned.
  • model.fc = nn.Linear(model.fc.in_features, n_classes) replaces the final 1,000-class head with a fresh 8-class layer. This is the only part that will train.
  • model.to(device) moves the model to your GPU for fast computation.
  • Run the cell.
  • You should see output confirming the model has a frozen backbone and 8 output classes.

Seeing an error about weights?

If you see an error mentioning ResNet18_Weights, confirm that your Cell 1 imports include from torchvision.models import resnet18, ResNet18_Weights. This import was added in the setup step.

If you see a download timeout, your network may be blocking the model download. Re-run the cell to retry.

Help me debug my ResNet-18 model loading error.

Why freeze the backbone?

The convolutional layers of ResNet-18 contain millions of parameters that already detect useful visual features like edges, textures, and colour gradients. Training all of them on just 11,959 images risks overfitting.

By freezing the backbone, you only train 4,104 parameters (the 512x8 FC layer plus bias) instead of 11 million. Training completes in minutes and generalises better on small datasets.

Define the training loop

With the model configured, you need a training loop that feeds batches of images through the network, computes the loss, and updates the FC layer weights. Each pass through the full training set is called an epoch.

  • Create a new cell (Cell 5) and add the loss function, optimizer, and epoch count:
# Cell 5: Training Loop
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.fc.parameters(), lr=0.001)

num_epochs = 5

What do these settings control?

  • CrossEntropyLoss is the standard loss function for multi-class classification. It measures how far the model's predicted probabilities are from the correct class.
  • Adam is an optimizer that adjusts learning rates per-parameter. It converges faster than basic gradient descent.
  • model.fc.parameters() tells the optimizer to ONLY update the FC head. The frozen backbone parameters are excluded.
  • lr=0.001 is the learning rate. Too high and training overshoots. Too low and it barely moves. 0.001 is a standard starting point for Adam.
  • In the same cell, add the training and validation loop below the code you just typed:
for epoch in range(num_epochs):
    model.train()
    running_loss = 0.0
    for inputs, labels in train_loader:
        inputs = inputs.to(device)
        labels = labels.squeeze().long().to(device)
        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        running_loss += loss.item()

    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for inputs, labels in val_loader:
            inputs = inputs.to(device)
            labels = labels.squeeze().long().to(device)
            outputs = model(inputs)
            _, predicted = torch.max(outputs, 1)
            total += labels.size(0)
            correct += (predicted == labels).sum().item()

    val_acc = correct / total
    avg_loss = running_loss / len(train_loader)
    print(f"Epoch [{epoch+1}/{num_epochs}] Loss: {avg_loss:.4f} Val Acc: {val_acc:.4f}")

What does the training loop do each epoch?

Each epoch has two phases:

  • Training phase: the model sees every batch of 64 images, computes predictions, measures the error (loss), and updates the FC layer weights via backpropagation.
  • Validation phase: the model makes predictions on the 1,712 validation images with gradients disabled (torch.no_grad()). This measures how well the model generalises to images it has never trained on.
  • labels.squeeze().long() reshapes the MedMNIST labels from shape (1,) to a flat tensor and converts them to the integer type that CrossEntropyLoss expects.
Run training and observe the model converge

Before you run this cell, think about what you expect to see. The model starts with a randomly initialised FC layer. Do you think accuracy will be high or low on the first epoch?

  • Run Cell 5. Training takes approximately 2-3 minutes on a modern GPU.

You should see output like this printed epoch by epoch, with loss decreasing and validation accuracy climbing above 90% by the final epoch:

Epoch [1/5] Loss: 0.4532 Val Acc: 0.8762
Epoch [2/5] Loss: 0.1987 Val Acc: 0.9187
Epoch [3/5] Loss: 0.1342 Val Acc: 0.9298
Epoch [4/5] Loss: 0.1078 Val Acc: 0.9356
Epoch [5/5] Loss: 0.0891 Val Acc: 0.9415

What do these numbers tell you?

Your exact numbers will differ slightly due to random batch ordering, but the pattern should match: loss drops steadily and validation accuracy crosses 90% within a few epochs.

This is remarkable. You trained only 4,104 parameters for a few minutes, yet the model classifies blood cells with over 90% accuracy. That is the power of transfer learning.

Training seems stuck or accuracy is low?

  • If validation accuracy stays below 50%, confirm you froze all backbone parameters. The loop for param in model.parameters(): param.requires_grad = False must come BEFORE you replace model.fc.
  • If you see a CUDA out-of-memory error, reduce the batch size in your DataLoaders from 64 to 32 in Cell 2 and re-run from Cell 2 onwards.
  • If training is extremely slow (more than 10 minutes), confirm GPU is being used by checking that Cell 1 printed GPU available: True.

Help me debug why my training loop isn't converging.

✔️ Awesome, I've got everything!

Great. Double check that you have saved your notebook with Cells 4 and 5 complete.

ⓧ I'd like to double check the full code

Your notebook should now have 5 cells in this order:

  • Cell 1: Imports and Setup (all imports, device configuration).
  • Cell 2: Dataset Loading and Exploration (info dict, transforms, DataLoaders).
  • Cell 3: Visualize Sample Images (2x4 grid of cell types).
  • Cell 4: Build Transfer Learning Model (ResNet-18 with frozen backbone, replaced FC head).
  • Cell 5: Training Loop (criterion, optimizer, 5-epoch loop with validation).

Scroll through your notebook and confirm each cell is present. Cell 5 should have printed 5 lines of epoch output with the final validation accuracy above 0.90.

Your model is trained and achieving over 90% accuracy on blood cell classification. Next up, you will dig deeper into which specific cell types the model gets right and which ones it confuses.

Evaluate Your Model and Diagnose Mistakes

Your model hit over 90% validation accuracy in the last step. That sounds impressive, but a single accuracy number hides critical details.

Which cell types is the model confusing? Are there specific pairs it struggles with? In this step, you'll run inference on all 3,421 test images and generate a confusion matrix that exposes exactly where the model fails. These failure patterns will motivate the Grad-CAM explanations you build in the next step.

In this step, get ready to:
  • Run inference on the full test set and compute per-class precision and recall.
  • Visualize a confusion matrix heatmap showing which cell types the model confuses.
  • Identify misclassified examples to investigate with Grad-CAM.
Run inference on the full test set

During training you measured validation accuracy, but you held back a separate test set of 3,421 images that the model has never seen. This is the true measure of how well your model generalizes.

  • Create a new cell in your notebook (Cell 6) and run inference across the entire test set by pasting this code:
# Cell 6: Test Evaluation
model.eval()
all_preds = []
all_labels = []
with torch.no_grad():
    for inputs, labels in test_loader:
        inputs = inputs.to(device)
        labels = labels.squeeze().long().to(device)
        outputs = model(inputs)
        _, predicted = torch.max(outputs, 1)
        all_preds.extend(predicted.cpu().numpy())
        all_labels.extend(labels.cpu().numpy())

all_preds = np.array(all_preds)
all_labels = np.array(all_labels)
test_acc = (all_preds == all_labels).mean()
print(f"\nTest Accuracy: {test_acc:.4f}")
print("\nClassification Report:")
print(classification_report(all_labels, all_preds, target_names=[n[:15] for n in class_names]))

What does this code do?

  • model.eval() switches the model out of training mode so dropout and batch normalization behave correctly for inference.
  • torch.no_grad() disables gradient tracking, which saves memory and speeds up inference since you are not training.
  • The loop collects all predictions and ground truth labels into all_preds and all_labels for analysis.
  • classification_report from scikit-learn computes precision (how many predictions for a class were correct) and recall (how many actual examples of a class were found) for each cell type.
  • Run the cell.

You should see the overall test accuracy printed (above 90%), followed by a table showing precision, recall, and f1-score for each of the 8 cell types.

What should I see?

Most classes will show precision and recall above 0.90. But look closely. Some classes (particularly immature granulocytes or monocytes) may have noticeably lower recall. That means the model is missing those cell types more often than others.

Seeing accuracy much lower than expected?

  • Make sure Cell 5 (the training loop) ran fully for all 5 epochs before this cell. Check that your final validation accuracy in Cell 5 was above 90%.
  • Verify that test_loader was created in Cell 2 with the same data_transform used for training. Mismatched transforms cause accuracy drops.

Help me debug low test accuracy on my BloodMNIST model.

Visualize the confusion matrix

The classification report gives you numbers, but a heatmap makes patterns jump out instantly. You can see at a glance which cells get confused for which other cells.

Before you run this next cell, think about which cell types you expect the model to confuse most. Cells that look visually similar under a microscope are the most likely candidates.

  • Create a new cell (Cell 7) and generate the confusion matrix heatmap by pasting this code:
# Cell 7: Confusion Matrix
cm = confusion_matrix(all_labels, all_preds)
plt.figure(figsize=(10, 8))
sns.heatmap(cm, annot=True, fmt="d", cmap="Blues",
            xticklabels=[n[:10] for n in class_names],
            yticklabels=[n[:10] for n in class_names])
plt.xlabel("Predicted")
plt.ylabel("True")
plt.title(f"Confusion Matrix (Test Accuracy: {test_acc:.2%})")
plt.tight_layout()
plt.show()

What does this code do?

  • confusion_matrix(all_labels, all_preds) builds an 8x8 grid where each row is the true class and each column is what the model predicted.
  • Diagonal cells show correct predictions. Off-diagonal cells reveal mistakes. A high number off-diagonal means the model frequently confuses those two types.
  • sns.heatmap from seaborn renders the matrix as a color-coded grid with the count annotated in each cell.
  • Run the cell.

You should see a blue heatmap with the overall test accuracy in the title. The diagonal should be strongly colored (high correct counts), with a few off-diagonal cells showing where the model gets confused.

Why does the confusion matrix matter?

90% accuracy sounds great, but it could mean the model perfectly classifies 7 classes and completely fails on one. The confusion matrix breaks that open.

Look for off-diagonal clusters. If you see the model confusing monocytes with immature granulocytes, that makes biological sense. Those cells have similar size, shape, and staining patterns under a microscope. In the next step, Grad-CAM will show you exactly which visual features cause that confusion.

Heatmap not displaying?

  • Make sure Cell 6 ran successfully first. The variables all_preds and all_labels must exist before this cell can compute the matrix.
  • If you see a NameError for sns, scroll up and confirm Cell 1 includes import seaborn as sns.

Help me fix my confusion matrix display.

Identify misclassified examples for Grad-CAM

Now that you know which classes the model confuses, you need to save the specific test images it got wrong. In the next step, you'll feed these into Grad-CAM to see exactly what the model was looking at when it made a mistake.

  • Create a new cell (Cell 8) and find all misclassified examples by pasting this code:
# Cell 8: Identify Misclassified Examples
misclassified_indices = np.where(all_preds != all_labels)[0]
print(f"Misclassified: {len(misclassified_indices)} / {len(all_labels)} ({len(misclassified_indices)/len(all_labels):.2%})")
  • Run the cell.

You should see a count of misclassified images out of 3,421 total. With 90%+ accuracy, this should be fewer than 350 images.

What is misclassified_indices?

np.where(all_preds != all_labels)[0] returns the indices of every test image where the model's prediction disagrees with the true label. You'll use these indices in Step 5 to generate Grad-CAM heatmaps on the exact images where the model struggled.

✔️ Awesome, I've got everything!

Great. Make sure you have saved your notebook with all three new cells (Cell 6, Cell 7, Cell 8).

ⓧ I'd like to double check the full code

Your notebook should now contain 8 cells in this order. Scroll through and confirm each is present:

  • Cell 1: Imports and Setup.
  • Cell 2: Dataset Loading and Exploration.
  • Cell 3: Visualize Sample Images.
  • Cell 4: Build Transfer Learning Model.
  • Cell 5: Training Loop.
  • Cell 6: Test Evaluation (prints test accuracy and classification report).
  • Cell 7: Confusion Matrix (displays the seaborn heatmap).
  • Cell 8: Identify Misclassified Examples (prints the count of errors).

You now know exactly where your model succeeds and where it stumbles. Next up, you'll use Grad-CAM to look inside the model and see which image regions it focuses on for each prediction. Those heatmaps will explain both the correct classifications and the confusions you just uncovered.

Explain Predictions with Grad-CAM Visualizations

In the previous step, you discovered exactly where your model succeeds and fails. The confusion matrix revealed which cell types get mixed up. But a natural follow-up question remains: what is the model actually looking at inside each image to make its decision?

In biomedical applications, clinicians need to verify that AI predictions are based on medically relevant features, not image artifacts. Grad-CAM (Gradient-weighted Class Activation Mapping) generates heatmaps that highlight which regions of an input image contributed most to the model's prediction. In this step, you'll generate these visual explanations for both correct and incorrect classifications.

In this step, get ready to:
  • Set up Grad-CAM targeting the last convolutional layer of ResNet-18.
  • Generate heatmap overlays for correctly classified examples across all 8 cell types.
  • Generate heatmap overlays for misclassified examples to understand where the model went wrong.
Set up Grad-CAM targeting the last convolutional layer

Grad-CAM works by computing gradients flowing back into the final convolutional layer of your network. For ResNet-18, that layer is model.layer4[-1]. This layer contains the highest-level spatial features before the image gets flattened for classification.

  • Create a new cell in your notebook and add this Grad-CAM setup code:
# Cell 9: Grad-CAM Setup and Visualization
target_layers = [model.layer4[-1]]

def get_gradcam_visualization(model, input_tensor, target_class, raw_img):
    with GradCAM(model=model, target_layers=target_layers) as cam:
        targets = [ClassifierOutputTarget(target_class)]
        grayscale_cam = cam(input_tensor=input_tensor.unsqueeze(0).to(device), targets=targets)
        grayscale_cam = grayscale_cam[0, :]
    visualization = show_cam_on_image(raw_img, grayscale_cam, use_rgb=True)
    return visualization

What does this code do?

This cell defines a reusable helper function that wraps the entire Grad-CAM pipeline for a single image.

  • target_layers = [model.layer4[-1]] tells Grad-CAM to compute activation maps at the last convolutional block of ResNet-18. This must be a list.
  • ClassifierOutputTarget(target_class) tells Grad-CAM which class prediction to explain. The gradients flow from this specific output neuron.
  • show_cam_on_image overlays the heatmap onto the original RGB image. The input image must be a float32 numpy array in the [0,1] range, not normalized.
  • Run the cell. The function is now defined and ready to use in the next cells.

Seeing an import error?

If you see NameError: name 'GradCAM' is not defined, make sure you ran Cell 1 (which imports GradCAM, ClassifierOutputTarget, and show_cam_on_image) earlier in this session.

help me debug a GradCAM import error in my Jupyter notebook

Generate Grad-CAM heatmaps for correct predictions

To overlay Grad-CAM on an image, you need two versions of each test image: one normalized (for the model to classify) and one raw RGB in the [0,1] range (for the heatmap overlay). You'll create a separate transform without normalization for this purpose.

  • Create a new cell and add this code to set up the visualization dataset:
# Cell 10: Grad-CAM on Correct Predictions
vis_transform = transforms.Compose([
    transforms.Resize((224, 224)),
    transforms.ToTensor()
])
vis_dataset = BloodMNIST(split="test", transform=vis_transform, download=False, size=28)

Why a separate transform?

Your model's data_transform normalizes pixel values (subtracts 0.5, divides by 0.5). That's what the model needs for accurate predictions. But show_cam_on_image needs the original pixel values in [0,1] to render a meaningful color overlay.

vis_transform resizes and converts to tensor without normalizing. You'll pass each image through both transforms: the raw version for display, the normalized version for the model.

  • In the same cell, directly below the code you just added, paste this plotting loop:
fig, axes = plt.subplots(2, 4, figsize=(16, 8))
shown = set()
idx = 0
for ax in axes.flat:
    while idx < len(vis_dataset):
        img_tensor, label = vis_dataset[idx]
        label_val = label.item()
        if label_val not in shown:
            shown.add(label_val)
            norm_img = data_transform(transforms.ToPILImage()(img_tensor))
            rgb_img = img_tensor.permute(1, 2, 0).numpy()
            rgb_img = np.clip(rgb_img, 0, 1)
            model.eval()
            with torch.no_grad():
                pred = model(norm_img.unsqueeze(0).to(device)).argmax(1).item()
            vis = get_gradcam_visualization(model, norm_img, pred, rgb_img)
            ax.imshow(vis)
            ax.set_title(f"Pred: {class_names[pred][:12]}", fontsize=9)
            ax.axis("off")
            idx += 1
            break
        idx += 1

plt.suptitle("Grad-CAM: What the Model Sees in Each Cell Type", fontsize=14)
plt.tight_layout()
plt.show()

What does this plotting loop do?

  • The loop finds the first test image from each of the 8 classes using a shown set to track which classes have been displayed.
  • For each image, it creates both a normalized version (norm_img for the model) and a raw RGB version (rgb_img for the overlay).
  • get_gradcam_visualization generates the heatmap overlay based on which class the model predicted, showing what features drove that prediction.
  • Run the cell.
  • You should see a 2x4 grid of blood cell images. Each image has a color heatmap overlay where warm colors (red/yellow) highlight the regions the model focused on for its prediction.

What should I look for in the heatmaps?

Look for warm (red/yellow) regions highlighting different cell structures. For example, some cell types will show the model focusing on the nucleus, while others highlight the cytoplasm or granule patterns. These are the features the model learned from ImageNet pre-training that transfer to blood cell classification.

Seeing a blank or all-black grid?

  • Make sure you ran Cell 9 (the Grad-CAM function definition) before this cell.
  • Check that np.clip(rgb_img, 0, 1) is present. Without it, out-of-range pixel values produce a blank image.
  • Verify your model was trained in a previous cell. If you restarted the kernel, you need to re-run all cells from the top.

help me debug my Grad-CAM visualization showing blank images

Generate Grad-CAM heatmaps for misclassified examples

The correct-prediction heatmaps show what the model looks at when it gets things right. Now you'll examine what it focused on when it got things wrong. Comparing these reveals whether misclassifications happen because the model looked at the wrong region or because two cell types share similar features.

  • Create a new cell and add this code to visualize Grad-CAM on misclassified examples:
# Cell 11: Grad-CAM on Misclassified Examples
fig, axes = plt.subplots(1, 3, figsize=(12, 4))
for i, ax in enumerate(axes.flat):
    if i >= min(3, len(misclassified_indices)):
        ax.axis("off")
        continue
    mis_idx = misclassified_indices[i]
    img_tensor, label = vis_dataset[mis_idx]
    label_val = label.item()
    norm_img = data_transform(transforms.ToPILImage()(img_tensor))
    rgb_img = img_tensor.permute(1, 2, 0).numpy()
    rgb_img = np.clip(rgb_img, 0, 1)
    model.eval()
    with torch.no_grad():
        pred = model(norm_img.unsqueeze(0).to(device)).argmax(1).item()
    vis = get_gradcam_visualization(model, norm_img, pred, rgb_img)
    ax.imshow(vis)
    ax.set_title(f"True: {class_names[label_val][:10]}\nPred: {class_names[pred][:10]}", fontsize=9)
    ax.axis("off")

plt.suptitle("Grad-CAM on Misclassified Images", fontsize=13)
plt.tight_layout()
plt.show()

What does this code do?

  • It pulls the first 3 misclassified images from the misclassified_indices array you computed in the previous step.
  • Each title shows both the true label and what the model incorrectly predicted, so you can see the confusion pair.
  • The heatmap reveals what region the model fixated on when making the wrong call.

Before you run this cell, consider: do you think the model's attention region will look similar or different for a misclassified image compared to a correctly classified one?

  • Run the cell.
  • You should see a row of 3 images with Grad-CAM heatmap overlays. Each image title shows the true cell type on top and the model's incorrect prediction below it.

What do misclassified heatmaps reveal?

In many cases, misclassified cells show the model attending to a feature that is shared between the true class and the predicted class. For example, if the model confuses a monocyte with an immature granulocyte, the heatmap might highlight cytoplasm shape rather than nucleus detail. This is exactly the kind of insight clinicians need to decide whether to trust or override an AI prediction.

Seeing an IndexError?

  • If your model had very few misclassifications, the min(3, len(misclassified_indices)) guard handles this. Make sure that line is present.
  • Confirm that vis_dataset was defined in the previous cell (Cell 10). If you restarted the kernel, re-run from Cell 1.

help me fix an IndexError in my Grad-CAM misclassification visualization

✔️ Awesome, I've got everything!

Great. Double check you've saved your notebook.

ⓧ I'd like to double check the full code

Your notebook should now contain 11 cells. Scroll through and confirm each one is present in this order:

  • Cell 1: Imports and Setup (all imports, device setup).
  • Cell 2: Dataset Loading and Exploration (INFO, class_names, transforms, DataLoaders).
  • Cell 3: Visualize Sample Images (2x4 grid of raw cell images).
  • Cell 4: Build Transfer Learning Model (ResNet-18, frozen layers, new FC head).
  • Cell 5: Training Loop (5 epochs, loss and val accuracy printed).
  • Cell 6: Test Evaluation (inference, classification report).
  • Cell 7: Confusion Matrix (seaborn heatmap).
  • Cell 8: Identify Misclassified Examples (misclassified_indices).
  • Cell 9: Grad-CAM Setup and Visualization (target_layers, get_gradcam_visualization function).
  • Cell 10: Grad-CAM on Correct Predictions (vis_transform, vis_dataset, 2x4 heatmap grid).
  • Cell 11: Grad-CAM on Misclassified Examples (1x3 heatmap grid with true/predicted labels).

Secret mission

Beat Transfer Learning with... Transfer Learning

Your ResNet-18 model hit 90%+ accuracy, but how much of that is thanks to the pre-trained features? In this bonus challenge, you'll build a simple CNN from scratch and train it on the same data to find out. The accuracy gap tells the whole story.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources for future experiments, pause by shutting down Jupyter, or delete everything entirely.

Resources you used:

  • blood_cell_classifier.ipynb Jupyter Notebook (your full project code).
  • Python virtual environment (contains PyTorch, torchvision, medmnist, grad-cam, and Jupyter).
  • BloodMNIST dataset cache at ~/.medmnist/ (approximately 50 MB).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with the notebook or try other MedMNIST datasets.

  • Your notebook, virtual environment, and cached dataset all remain in place.
  • You can relaunch Jupyter at any time by activating your virtual environment and running jupyter notebook.
  • The cached dataset at ~/.medmnist/ is reused automatically if you load other MedMNIST datasets later.

Pause - I'll come back to this later

Shut down Jupyter to free up memory and GPU resources. Your files stay on disk for when you return.

  • In the terminal where Jupyter is running, press Ctrl+C and confirm with y to shut down the notebook server.
  • Deactivate your virtual environment by running:
deactivate
  • Your notebook, virtual environment, and cached dataset remain on disk. No data is lost.

Delete - I don't want to use this again

Remove all project resources and cached data to start fresh.

  • If Jupyter is still running, press Ctrl+C in the terminal to shut it down.
  • Deactivate your virtual environment if it is still active:
deactivate
  • Delete the cached BloodMNIST dataset by running:
rm -rf ~/.medmnist/bloodmnist*.npz
  • Delete your project folder (the folder on your Desktop containing blood_cell_classifier.ipynb and your virtual environment) by running:
cd ~/Desktop
rm -rf blood-cell-classifier

What if my folder has a different name?

Replace blood-cell-classifier with whatever you named your project folder in Step 1. Run ls ~/Desktop to find it if you are unsure.

Nice Work!

Nice Work!

Nice work! You just built a complete deep learning pipeline that classifies blood cell microscopy images and explains its own predictions with visual heatmaps.

You've learned how to:

  • Applied transfer learning with a pre-trained ResNet-18 model to classify 8 types of white blood cells from the BloodMNIST dataset with over 90% accuracy.
  • Used Grad-CAM to generate visual heatmap explanations showing which cellular features the model focuses on for correct and incorrect predictions.
  • Evaluated model performance with a confusion matrix and per-class metrics to diagnose which cell types the model confuses and why.
  • Secret Mission: Proved transfer learning's value by training a simple CNN from scratch and visualizing the accuracy gap in a comparison bar chart.

Ready to quiz yourself?