Classify Satellite Images with a CNN
Train a CNN on 27,000 satellite images to classify land use in PyTorch.
Introduction
30 Second Summary
Satellites photograph the entire Earth's surface every few days. Turning those millions of raw images into useful data (forest here, highway there, residential zone over there) is a classification problem that computers solve using neural networks.
In this project, you will build a convolutional neural network from scratch in PyTorch that classifies satellite images into 10 different land-use categories. You will train it on 27,000 real satellite photos, watch its accuracy climb in real time, and then dissect exactly where it struggles using a confusion matrix.
What You'll Build
Feed any of the 27,000 EuroSAT satellite images into your trained model and get back an instant land-use prediction, then examine a color-coded confusion matrix that shows you exactly which terrain types fool the network.
By the end of this project, you'll have:
- A trained CNN that takes a 64x64 satellite image and predicts one of 10 land-use categories like forest, highway, or river.
- Real-time training curves plotted over 10 epochs showing loss decreasing and accuracy climbing as your model learns on your GPU.
- A per-class confusion matrix visualization that reveals which terrain types your model confuses and why they look similar from space.
- Secret Mission: Swap your hand-built CNN for a pretrained ResNet18 and compare the accuracy jump.
Are there any prerequisites?
You should be comfortable with Python syntax (functions, classes, loops). No prior PyTorch or deep learning experience is required.
This project assumes you have a Windows laptop with an NVIDIA GPU. You will install PyTorch with CUDA acceleration in Step 1.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters for your portfolio.
Set Up Your Environment
Training a neural network on 27,000 satellite images requires serious computation. Your NVIDIA RTX 3060 can handle this in minutes instead of hours, but only if PyTorch knows how to talk to your GPU.
In this step, you'll create a project workspace, install PyTorch with CUDA GPU acceleration, and set up a Jupyter Notebook in VS Code where you'll run the entire project interactively.
In this step, get ready to:
- Create a project folder with a Python virtual environment.
- Install PyTorch with GPU acceleration, data science libraries, and the Jupyter kernel.
- Set up a Jupyter Notebook in VS Code and verify your RTX 3060 is accessible.
Create your project folder and virtual environment
A virtual environment gives your project its own isolated set of Python packages. This prevents version conflicts between different projects on your machine.
- Press Cmd+` (macOS) or Ctrl+` (Windows) to open a terminal panel in VS Code.
- Move to your Desktop and create the project folder by running these commands:
cd ~/Desktop
mkdir eurosat-classifier
cd eurosat-classifier
- Create a virtual environment by running:
python -m venv venv
- Activate the virtual environment by running:
venv\Scripts\activate
Your terminal prompt now shows (venv) at the beginning. This confirms the virtual environment is active.
Seeing an error about scripts being disabled?
If PowerShell shows running scripts is disabled on this system, run this command to allow scripts for your user, then try activating again:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
Still stuck? Help me fix my virtual environment activation on Windows.
Install PyTorch with CUDA, auxiliary libraries, and Jupyter
PyTorch needs a special build that includes CUDA 12.6 drivers to communicate with your NVIDIA GPU. You'll also install the Jupyter kernel so VS Code can run notebook cells using your virtual environment.
- Install PyTorch, torchvision, and torchaudio with CUDA 12.6 support by running:
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126
What does this command do?
The --index-url flag tells pip to download from PyTorch's CUDA 12.6 package repository instead of the default PyPI. This ensures you get the GPU-accelerated version, not the CPU-only build.
This download is around 2-3 GB. Wait for the Successfully installed message before continuing.
- Install matplotlib, scikit-learn, and the Jupyter kernel by running:
pip install matplotlib scikit-learn ipykernel
Wait for Successfully installed to appear, confirming all packages are ready.
Why ipykernel?
The ipykernel package lets VS Code's Jupyter extension use your virtual environment as a notebook kernel. Without it, VS Code won't be able to find the packages you just installed when running notebook cells.
🙋♀️ Install failing or timing out?
Make sure your virtual environment is active. You should see (venv) in your terminal prompt. If not, run the activation command again.
If the download times out, check your internet connection and try the install command again. pip will resume from where it left off.
Help me troubleshoot my PyTorch CUDA installation on Windows.
Set up your Jupyter Notebook in VS Code
VS Code has built-in Jupyter support through an extension. You'll install it, create your notebook, and connect it to your virtual environment's kernel.
- Click the Extensions icon in the left sidebar (the square icon with four pieces).
- Type Jupyter in the search bar.
- Click Install on the Jupyter extension by Microsoft (it should be the top result).
Once installed, you can create and run notebooks directly inside VS Code.
- Open the Command Palette by pressing Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows).
- Type Create: New Jupyter Notebook and select it from the dropdown.
- A new untitled notebook opens. Save it immediately by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Name it satellite_classifier.ipynb and save it in your eurosat-classifier folder.
- In the top-right corner of the notebook, click Select Kernel.
- Select Python Environments.
- Choose the venv environment you just created (it shows the path to your eurosat-classifier/venv folder).
Why use a Jupyter Notebook?
Notebooks let you run code in cells and see results inline, directly below each cell. For machine learning, this means you can train your model once, then re-run evaluation cells without retraining. Plots appear inline instead of in popup windows.
You'll build the entire project in this single notebook, adding cells as you go through each step.
🙋♀️ Don't see your venv in the kernel list?
Make sure you installed ipykernel with the virtual environment active. If it's missing, activate your venv in the terminal and run pip install ipykernel again.
If it still doesn't appear, try reloading VS Code: open the Command Palette and type Developer: Reload Window.
Help me connect my virtual environment to VS Code's Jupyter kernel.
Verify GPU access in your first notebook cell
Installing PyTorch with CUDA does not guarantee your GPU is reachable. A quick verification cell eliminates doubt before you start training.
- Click into the first empty cell in your notebook and paste this code:
import torch
print(f"PyTorch version: {torch.__version__}")
print(f"CUDA available: {torch.cuda.is_available()}")
if torch.cuda.is_available():
print(f"GPU: {torch.cuda.get_device_name(0)}")
print(f"CUDA version: {torch.version.cuda}")
else:
print("WARNING: CUDA not detected. Training will be slow on CPU.")
What does this code do?
- torch.__version__ prints which PyTorch build you installed.
- torch.cuda.is_available() checks whether PyTorch can see a CUDA-capable GPU on your system.
- torch.cuda.get_device_name(0) returns the name of your first GPU, confirming the exact hardware PyTorch will use for training.
- Run the cell by clicking the play button on the left side of the cell, or by pressing Shift+Enter.
You should see output directly below the cell like this:
PyTorch version: 2.13.0+cu126
CUDA available: True
GPU: NVIDIA GeForce RTX 3060 Laptop GPU
CUDA version: 12.6
The key line is CUDA available: True. This confirms PyTorch can communicate with your RTX 3060 and will use it for training.
Seeing False or WARNING: CUDA not detected?
Make sure you installed from the CUDA 12.6 index URL, not from the default PyPI. Open a terminal in VS Code and run pip show torch and check that the version ends in +cu126. If it says +cpu, uninstall and reinstall with the correct command from the previous substep.
Also confirm your NVIDIA drivers are up to date. Open GeForce Experience or NVIDIA App and check for driver updates.
Help me fix CUDA not being detected in PyTorch.
Your environment is ready. PyTorch can see your GPU, your notebook is connected to the right kernel, and all the libraries are installed. Next up, you'll download 27,000 real satellite images and see what your CNN needs to learn.
Load and Explore EuroSAT
Your GPU is confirmed and your notebook is ready. Now you need data to train on.
Before building a model, you need to see what the data actually looks like. In this step, you'll download the EuroSAT satellite image dataset and visualize samples from each land-use class. Seeing real examples builds intuition for what your CNN needs to learn to distinguish.
In this step, get ready to:
- Download the EuroSAT satellite image dataset (27,000 images across 10 classes).
- Print dataset statistics including image dimensions and class names.
- Visualize one sample from each of the 10 land-use classes in an inline grid.
Download EuroSAT and print dataset statistics
EuroSAT contains 27,000 real Sentinel-2 satellite images from across Europe, each labeled with one of 10 land-use categories. The torchvision library handles the download and organization for you.
- In your notebook, click the + Code button to add a new cell below your GPU verification cell.
- Paste the following code into the new cell:
from torchvision import datasets, transforms
# Convert raw images to PyTorch tensors
transform = transforms.Compose([
transforms.ToTensor(),
])
# Download EuroSAT dataset (first run downloads ~90MB)
dataset = datasets.EuroSAT(root="data", download=True, transform=transform)
# Print dataset statistics
class_names = dataset.classes
print(f"Dataset size: {len(dataset)} images")
print(f"Number of classes: {len(class_names)}")
print(f"Classes: {class_names}")
# Inspect a single image tensor
image, label = dataset[0]
print(f"\nImage shape: {image.shape}")
print(f"Label: {label} ({class_names[label]})")
What does this code do?
- transforms.ToTensor() converts each image from a PIL image to a PyTorch tensor with values between 0 and 1.
- datasets.EuroSAT(root="data", download=True) downloads the dataset into a data/ folder the first time you run it. Subsequent runs skip the download.
- dataset.classes returns the 10 land-use category names. Accessing dataset[0] gives you the first image as a tensor plus its label index.
- Run the cell by pressing Shift+Enter.
The first run downloads the EuroSAT dataset. You should see download progress, then output directly below the cell:
Dataset size: 27000 images
Number of classes: 10
Classes: ['AnnualCrop', 'Forest', 'HerbaceousVegetation', 'Highway', 'Industrial', 'Pasture', 'PermanentCrop', 'Residential', 'River', 'SeaLake']
Image shape: torch.Size([3, 64, 64])
Label: 0 (AnnualCrop)
What do these numbers mean?
torch.Size([3, 64, 64]) means each image has 3 color channels (RGB), and is 64 pixels wide by 64 pixels tall. That's the raw satellite photo your CNN will learn from.
🙋♀️ Seeing a download error?
- Make sure you have an internet connection. The first run needs to download the dataset from Hugging Face.
- If you see a timeout error, try running the cell again. Large downloads can sometimes fail on unstable connections.
- Help me troubleshoot my EuroSAT download error.
Visualize one sample from each class
Numbers alone don't tell you what these satellite images look like. Visualizing one example from each class reveals the visual patterns your CNN needs to distinguish.
- Add a new cell below and paste this visualization code:
import matplotlib.pyplot as plt
# Create a 2x5 grid for displaying samples
fig, axes = plt.subplots(2, 5, figsize=(15, 6))
axes = axes.flatten()
# Find the first example of each class
class_examples = {}
for i in range(len(dataset)):
_, label = dataset[i]
if label not in class_examples:
class_examples[label] = i
if len(class_examples) == 10:
break
# Plot each class example
for idx, (label, sample_idx) in enumerate(sorted(class_examples.items())):
image, _ = dataset[sample_idx]
axes[idx].imshow(image.permute(1, 2, 0))
axes[idx].set_title(class_names[label], fontsize=10)
axes[idx].axis("off")
plt.suptitle("EuroSAT: One Sample Per Class", fontsize=14)
plt.tight_layout()
plt.savefig("eurosat_samples.png")
plt.show()
print("Saved visualization to eurosat_samples.png")
What does this visualization code do?
- The loop scans through the dataset until it finds one example image for each of the 10 classes. It stops early once all classes are covered.
- image.permute(1, 2, 0) rearranges the tensor from PyTorch's channel-first format (3, 64, 64) to matplotlib's expected format (64, 64, 3).
- In a notebook, plt.show() renders the plot inline directly below the cell. No popup window needed.
- Run the cell by pressing Shift+Enter.
A 2x5 grid of satellite images appears directly below the cell. You should see 10 distinct land-use types: AnnualCrop, Forest, HerbaceousVegetation, Highway, Industrial, Pasture, PermanentCrop, Residential, River, and SeaLake.
No image appearing below the cell?
- Make sure you selected the correct kernel (your venv environment) in the top-right corner of the notebook. If it says a different Python, click it and switch.
- Check for any import errors in the cell output. If matplotlib is not found, open the terminal and run pip install matplotlib with your venv active.
- Help me debug why my matplotlib plot isn't showing in Jupyter.
Identify visually similar classes
Look closely at the 2x5 grid rendered below your cell. Some of these land-use types look almost identical from space.
- Compare AnnualCrop and PermanentCrop. Both show green agricultural fields with similar textures.
- Compare HerbaceousVegetation and Pasture. Both are flat green areas with minimal structure.
- Notice how Highway and River are both long, narrow features cutting through other terrain.
Why does visual similarity matter?
If two classes look nearly identical to your eyes from a satellite view, they'll also confuse your CNN. When you train and evaluate your model in the next steps, the confusion matrix will reveal exactly these class pairs as the main source of errors.
This is why visualizing your data first matters. You can predict failure modes before training even begins.
Your dataset is downloaded, explored, and you already have hypotheses about where your model will struggle. Next up, you'll build the CNN architecture and train it to classify these images.
Build and Train Your CNN
In the previous step, you explored the EuroSAT dataset and saw what satellite images look like across 10 land-use classes. You also noticed which classes look visually similar.
Now it is time for the core of the project: building a neural network from scratch, wiring up the PyTorch training pipeline, and training your model on 27,000 satellite images with GPU acceleration. By the end of this step, you will have a trained CNN that has learned to classify land types.
In this step, get ready to:
- Set up the data loading pipeline with normalization and an 80/20 train/test split.
- Define a 3-layer CNN architecture and verify it produces the correct output shape.
- Write the training loop, run 10 epochs, and plot training curves inline.
Set up the data loading pipeline
Before your CNN can learn anything, it needs data served in a specific format. The data pipeline normalizes pixel values (so the network trains faster) and splits images into training and test sets (so you can measure real performance later).
- Add a new cell to your notebook and paste the data loading code:
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, random_split
import numpy as np
from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay
# --- Data Loading ---
transform_train = transforms.Compose([
transforms.Resize((64, 64)),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
# Reload dataset with normalization
dataset_normalized = datasets.EuroSAT(root="data", download=True, transform=transform_train)
class_names = dataset_normalized.classes
# Split into train/test (80/20)
generator = torch.Generator().manual_seed(42)
train_size = int(0.8 * len(dataset_normalized))
test_size = len(dataset_normalized) - train_size
train_dataset, test_dataset = random_split(
dataset_normalized, [train_size, test_size], generator=generator
)
print(f"Training samples: {train_size}")
print(f"Test samples: {test_size}")
What does this code do?
The transforms.Normalize step scales pixel values to match what most pretrained models expect. These specific mean and standard deviation values come from the ImageNet dataset and help the network converge faster during training.
The random_split with manual_seed(42) creates a reproducible 80/20 split. This means you get the same train/test split every time you run the cell, making your results consistent.
- Run the cell by pressing Shift+Enter.
You should see Training samples: 21600 and Test samples: 5400 printed below the cell.
- Add a new cell and create the DataLoaders:
# Set up device and DataLoaders
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True, num_workers=0)
test_loader = DataLoader(test_dataset, batch_size=64, shuffle=False, num_workers=0)
Why num_workers=0?
On Windows, using num_workers > 0 with DataLoaders can trigger a BrokenPipeError due to how Windows handles multiprocessing. Setting it to 0 avoids this. Training is still fast because GPU computation dominates over data loading for this dataset size.
- Run the cell. You should see Using device: cuda confirming GPU access.
Seeing Using device: cpu?
If the device shows cpu instead of cuda, scroll back up to your GPU verification cell and re-run it. If CUDA was available there but not here, try restarting your kernel: click Restart in the notebook toolbar, then re-run all cells from the top.
Help me debug why my notebook lost GPU access.
Define the CNN architecture
A Convolutional Neural Network learns to recognize patterns in images by stacking layers that detect increasingly complex features. Early layers detect edges and textures. Deeper layers combine those into shapes and objects.
- Add a new cell and define the SatelliteCNN class:
# --- Model Definition ---
class SatelliteCNN(nn.Module):
def __init__(self):
super(SatelliteCNN, self).__init__()
self.features = nn.Sequential(
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2, 2),
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2, 2),
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(2, 2),
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(128 * 8 * 8, 256),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(256, 10),
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
What does this architecture do?
The features section has three convolutional blocks. Each block applies a convolution (pattern detection), ReLU (introduces non-linearity), and MaxPool (shrinks the image by half). The image goes from 64x64 to 32x32 to 16x16 to 8x8 while the number of feature channels grows from 3 to 32 to 64 to 128.
The classifier section flattens the 128x8x8 feature map into a vector, passes it through a 256-neuron hidden layer, applies Dropout (randomly zeroes 50% of neurons to prevent overfitting), and outputs 10 values representing the 10 land-use classes.
- Run the cell to define the class, then add a new cell to instantiate and test it:
# Create model and test forward pass
model = SatelliteCNN().to(device)
print(f"Model architecture:\n{model}")
# Verify dimensions with a dummy batch
dummy = torch.randn(64, 3, 64, 64).to(device)
output = model(dummy)
print(f"\nTest forward pass output shape: {output.shape}")
Why test with a dummy tensor?
Feeding a random 64x64 RGB batch through the model confirms that all the layer dimensions connect correctly. If any Linear layer has the wrong input size, this test will crash with a clear shape-mismatch error before you waste time on training.
- Run the cell. You should see the full model architecture printed, followed by Test forward pass output shape: torch.Size([64, 10]).
The shape (64, 10) means: a batch of 64 images, each producing 10 class scores.
Seeing a shape mismatch error?
If you see a RuntimeError about matrix sizes, double-check that the nn.Linear(128 * 8 * 8, 256) line matches exactly. The 8x8 comes from three MaxPool layers halving the 64x64 input (64 to 32 to 16 to 8).
Help me fix my CNN architecture shape error.
Train your CNN for 10 epochs
The training loop is where learning actually happens. Each epoch, the model sees every training image, makes predictions, measures how wrong it was (loss), and adjusts its weights to be less wrong next time.
- Add a new cell with the training function:
# --- Training Function ---
def train_model(model, train_loader, criterion, optimizer, num_epochs, device):
train_losses = []
train_accuracies = []
for epoch in range(num_epochs):
model.train()
running_loss = 0.0
correct = 0
total = 0
for images, labels in train_loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
epoch_loss = running_loss / len(train_loader)
epoch_acc = 100 * correct / total
train_losses.append(epoch_loss)
train_accuracies.append(epoch_acc)
print(f"Epoch [{epoch+1}/{num_epochs}], Loss: {epoch_loss:.4f}, Accuracy: {epoch_acc:.2f}%")
return train_losses, train_accuracies
What does the training loop do?
- optimizer.zero_grad() resets gradients from the previous batch so they do not accumulate.
- loss.backward() computes how much each weight contributed to the error (backpropagation).
- optimizer.step() adjusts the weights in the direction that reduces the loss.
- Each epoch iterates over all 21,600 training images in batches of 64, printing loss and accuracy as the model improves.
- Run the cell to define the function, then add a new cell to actually train:
# --- Run Training ---
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=0.001)
print("--- Training ---")
num_epochs = 10
train_losses, train_accuracies = train_model(
model, train_loader, criterion, optimizer, num_epochs, device
)
What are CrossEntropyLoss and Adam?
CrossEntropyLoss is the standard loss function for multi-class classification. It measures how far the model's predicted probabilities are from the correct answer.
Adam is an optimizer that adjusts learning rates per-parameter. The lr=0.001 controls how big each weight update is. Too large and training overshoots. Too small and it takes forever.
Before you run this cell, predict: do you think the model will get better at classifying images as it trains more epochs? How quickly?
- Run the cell. You'll see each epoch print as it completes (this takes 1-2 minutes on a GPU).
You should see 10 epochs printed with accuracy climbing from roughly 30-40% in epoch 1 to 80-88% by epoch 10.
Why does training start around 30-40%?
With 10 classes, random guessing gives 10% accuracy. The model starts slightly better than random because even untrained convolutions pick up basic patterns. By epoch 10, it has learned to distinguish most terrain types from space.
🙋♀️ Training seems stuck or very slow?
If each epoch takes longer than 30 seconds, scroll up and check that your device cell printed cuda. CPU training can be 10-50x slower.
If accuracy stays below 20% after 3 epochs, confirm your transforms.Normalize values match exactly: mean=[0.485, 0.456, 0.406] and std=[0.229, 0.224, 0.225].
Help me debug my CNN training performance.
- Add a new cell to plot the training curves inline:
# Plot training curves
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.plot(range(1, num_epochs + 1), train_losses)
ax1.set_title("Training Loss")
ax1.set_xlabel("Epoch")
ax1.set_ylabel("Loss")
ax2.plot(range(1, num_epochs + 1), train_accuracies)
ax2.set_title("Training Accuracy")
ax2.set_xlabel("Epoch")
ax2.set_ylabel("Accuracy (%)")
plt.tight_layout()
plt.savefig("training_curves.png")
plt.show()
print("Saved training curves to training_curves.png")
- Run the cell. You should see a side-by-side plot rendered inline showing loss decreasing and accuracy increasing over 10 epochs.
Your CNN is trained and sitting in memory. Because you're in a notebook, the trained model persists across cells. Next up, you'll evaluate it on unseen test images without retraining.
Evaluate and Visualize Results
Your model just trained for 10 epochs, and the training accuracy looked impressive. But training accuracy is misleading because the model has already seen those images.
Running on the held-out test set reveals true performance. A confusion matrix goes further: it pinpoints exactly which classes the model confuses, revealing the ceiling of your hand-built CNN.
In this step, get ready to:
- Run the trained model on unseen test data and measure real accuracy.
- Generate a confusion matrix that shows which terrain types your CNN mixes up.
- Identify the accuracy ceiling that motivates transfer learning.
Evaluate on unseen test images
Because you're working in a notebook, your trained model is still in memory from the previous step. You can evaluate it immediately without retraining.
Why not just use training accuracy?
A model can memorize training data and score 99% on it while performing terribly on new images. The test set is data the model never saw during training. Only test accuracy tells you whether the model actually learned general patterns.
- Add a new cell to your notebook with the evaluation function:
# --- Evaluation ---
def evaluate_model(model, test_loader, device, class_names):
model.eval()
all_predictions = []
all_labels = []
with torch.no_grad():
correct = 0
total = 0
for images, labels in test_loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
_, predicted = torch.max(outputs, 1)
total += labels.size(0)
correct += (predicted == labels).sum().item()
all_predictions.extend(predicted.cpu().numpy())
all_labels.extend(labels.cpu().numpy())
test_accuracy = 100 * correct / total
print(f"\nTest Accuracy: {test_accuracy:.2f}%")
print("\nPer-Class Accuracy:")
for i, name in enumerate(class_names):
class_mask = np.array(all_labels) == i
if class_mask.sum() > 0:
class_correct = np.array(all_predictions)[class_mask] == i
class_acc = 100 * class_correct.sum() / class_mask.sum()
print(f" {name}: {class_acc:.1f}%")
return test_accuracy, all_predictions, all_labels
What does this code do?
This function runs the model on images it has never seen during training.
- model.eval() disables dropout so predictions are deterministic.
- torch.no_grad() tells PyTorch to skip gradient computation, which saves memory and speeds up inference.
- The function collects ALL predictions and labels so you can compute per-class accuracy and build a confusion matrix afterwards.
- Run the cell to define the function, then add a new cell to call it:
Before you run the evaluation, think: do you expect the test accuracy to be higher, lower, or about the same as the training accuracy you saw in the last step?
- Add a new cell and run the evaluation:
# Run evaluation on test set
print("--- Evaluation ---")
test_accuracy, all_predictions, all_labels = evaluate_model(
model, test_loader, device, class_names
)
- Run the cell by pressing Shift+Enter.
You should see a test accuracy between 80-88% (lower than your training accuracy because these are unseen images), followed by a per-class breakdown showing some classes above 90% and others below 70%.
Seeing a NameError for model or test_loader?
If you restarted your notebook kernel between steps, you'll need to re-run all previous cells first. Click Run All in the notebook toolbar to execute every cell from top to bottom. This reloads the data, rebuilds the model, and retrains it.
Help me fix NameError in my evaluation cell.
Generate and examine the confusion matrix
The per-class accuracy tells the story in numbers. The confusion matrix makes it visual, showing exactly which classes the model mixes up.
- Add a new cell and generate the confusion matrix:
# Generate confusion matrix
cm = confusion_matrix(all_labels, all_predictions)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=class_names)
fig, ax = plt.subplots(figsize=(12, 10))
disp.plot(ax=ax, xticks_rotation=45)
plt.title(f"Satellite Image Classification (Test Accuracy: {test_accuracy:.2f}%)")
plt.tight_layout()
plt.savefig("confusion_matrix.png")
plt.show()
print("Saved confusion matrix to confusion_matrix.png")
What is a confusion matrix?
A confusion matrix is a 10x10 grid where row i, column j shows how many images of class i the model predicted as class j. The diagonal shows correct predictions. Off-diagonal cells reveal exactly which classes get mixed up.
- Run the cell. A large 10x10 heatmap grid appears inline below the cell.
Identify weak classes and the accuracy ceiling
Look closely at the confusion matrix rendered below your cell. The per-class accuracy breakdown tells the real story.
- Look at the off-diagonal cells. You should see numbers concentrated between AnnualCrop and PermanentCrop, and between HerbaceousVegetation and Pasture.
- Compare the high-scoring classes (Highway, SeaLake, River) with the low-scoring ones (AnnualCrop, PermanentCrop, Pasture).
Why does the model confuse these classes?
From 10 meters up in space, an annual crop field and a permanent crop field look nearly identical. The same goes for herbaceous vegetation and pasture. Your CNN sees patterns of green pixels and flat terrain. It lacks the deeper visual features needed to distinguish these subtle differences.
This 80-88% ceiling is a real limitation of a small 3-layer CNN trained from scratch. Breaking past it requires richer features learned from millions of diverse images.
🙋♀️ Confusion matrix not rendering?
- Check for any import errors mentioning sklearn. If you see one, open the terminal and run pip install scikit-learn with your venv active, then restart the kernel and re-run all cells.
- If the plot is too small to read, change figsize=(12, 10) to figsize=(14, 12) and re-run the cell.
- Help me debug my confusion matrix not rendering.
You've now hit the ceiling of a small custom CNN. Your model can reliably distinguish a river from a highway, but it struggles with terrain types that look nearly identical from space. The Secret Mission shows how transfer learning smashes through this ceiling by leveraging features learned from millions of diverse images.
Secret mission
Transfer Learning with ResNet18
Your custom CNN tops out around 85% accuracy. Can you swap it for a pretrained ResNet18 and break 93% in just 5 epochs? Transfer learning is how real ML engineers solve image classification in practice.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files for reference or delete them to free up disk space.
Resources you used:
- EuroSAT dataset (~90MB) stored in the data/ directory inside your project folder.
- ResNet18 pretrained weights (~45MB) cached by PyTorch in C:\Users\<YourUsername>\.cache\torch\hub\checkpoints\.
- Project files (Jupyter Notebook, virtual environment, generated images) inside the eurosat-classifier folder.
Keep everything running
No action needed. Choose this if you want to keep your trained model, confusion matrix, and training curves for reference or your portfolio.
- Your project folder stays on your Desktop at eurosat-classifier.
- The EuroSAT dataset remains in data/ so you can retrain or experiment without re-downloading.
- The cached ResNet18 weights stay in your PyTorch cache so future projects can reuse them instantly.
Pause - I'll come back to this later
Deactivate your virtual environment to free up any running processes. Your files stay on disk so you can pick up where you left off.
- Deactivate your virtual environment by running:
deactivate
- When you return, reactivate it by navigating to your project folder and running venv\Scripts\activate.
Delete - I don't want to use this again
Remove all project files, the downloaded dataset, and the cached model weights to free approximately 150MB of disk space.
- Deactivate your virtual environment first by running:
deactivate
- Navigate to your Desktop and delete the project folder by running:
cd ~/Desktop
rmdir /s /q eurosat-classifier
- Delete the cached ResNet18 weights from your PyTorch cache by running:
rmdir /s /q "%USERPROFILE%\.cache\torch\hub\checkpoints"
What about PyTorch itself?
PyTorch, torchvision, matplotlib, and scikit-learn were all installed inside your virtual environment. Deleting the eurosat-classifier folder already removed them since the venv lived inside it.
Nice Work!
Nice Work!
Nice work! You just built a satellite image classifier from scratch in PyTorch, trained it on real Sentinel-2 imagery, and dissected its performance with a confusion matrix.
You've learned how to:
- Build a 3-layer CNN from scratch in PyTorch that classifies 64x64 satellite images into 10 land-use categories, and train it on 27,000 real EuroSAT images using GPU acceleration.
- Wire up the full PyTorch training pipeline including data loading, transforms, loss functions, optimizers, and a training loop that reports accuracy per epoch.
- Evaluate model performance with a confusion matrix that reveals exactly which terrain types your CNN confuses, pinpointing the ceiling of a from-scratch approach.
- Secret Mission: Applied transfer learning with a pretrained ResNet18, jumping from ~85% to 93%+ accuracy and demonstrating why pretrained models dominate in practice.
Ready to quiz yourself?