Session 4: CNN Hands-On Lab - EuroSAT Classification

Building CNNs with TensorFlow/Keras for Earth Observation

Duration: 90 minutes | Difficulty: Intermediate
Dataset: EuroSAT (27,000 Sentinel-2 images, 10 classes)


🎯 Objectives

By the end of this lab, you will:

  1. βœ… Build a CNN from scratch using TensorFlow/Keras
  2. βœ… Train on real satellite imagery (EuroSAT dataset)
  3. βœ… Achieve >90% accuracy on land use classification
  4. βœ… Evaluate model performance comprehensively
  5. βœ… Understand training dynamics and hyperparameters
  6. βœ… Apply data augmentation for better generalization

πŸ“‹ Lab Structure

Step Activity Duration
1 Environment Setup & GPU Check 5 min
2 Dataset Download & Exploration 15 min
3 Data Preprocessing & Augmentation 15 min
4 Build CNN Architecture 20 min
5 Training & Monitoring 20 min
6 Evaluation & Analysis 15 min

🌍 About EuroSAT Dataset

What: Benchmark dataset for satellite image classification
Source: Sentinel-2 RGB and multi-spectral
Images: 27,000 labeled patches (64Γ—64 pixels)
Classes: 10 land use/land cover types
Purpose: Standardized comparison of classification methods

Classes: 1. Annual Crop 2. Forest 3. Herbaceous Vegetation 4. Highway 5. Industrial 6. Pasture 7. Permanent Crop 8. Residential 9. River 10. SeaLake


Let’s build your first CNN! πŸš€


Step 1: Environment Setup (5 minutes)

First, let’s import libraries and check if GPU is available.

# Install required packages if not present
import subprocess
import sys

def install_package(package):
    subprocess.check_call([sys.executable, "-m", "pip", "install", package])

# Check and install tensorflow-datasets if needed
try:
    import tensorflow_datasets
except ImportError:
    print("Installing tensorflow-datasets...")
    install_package("tensorflow-datasets")
    import tensorflow_datasets
# Core libraries
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from pathlib import Path
import os
import warnings
warnings.filterwarnings('ignore')

# TensorFlow and Keras
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers, models
from tensorflow.keras.preprocessing.image import ImageDataGenerator
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau

# TensorFlow Datasets
import tensorflow_datasets as tfds

# scikit-learn for metrics
from sklearn.metrics import classification_report, confusion_matrix

# Plotting style
sns.set_style("whitegrid")
plt.rcParams['figure.figsize'] = (12, 8)

print(f"βœ” TensorFlow version: {tf.__version__}")
print(f"βœ” Keras version: {keras.__version__}")
print(f"βœ” NumPy version: {np.__version__}")

Check GPU Availability

GPUs dramatically speed up training. Let’s check if one is available.

# Check for GPU
gpus = tf.config.list_physical_devices('GPU')

if gpus:
    print(f"\nβœ” GPU(s) Available: {len(gpus)}")
    for gpu in gpus:
        print(f"  - {gpu.name}")
    
    # Enable memory growth (prevents TensorFlow from allocating all GPU memory)
    try:
        for gpu in gpus:
            tf.config.experimental.set_memory_growth(gpu, True)
        print("\nβœ” GPU memory growth enabled")
    except RuntimeError as e:
        print(e)
else:
    print("\n⚠️  No GPU found - training will use CPU (slower)")
    print("   Consider using Google Colab with GPU runtime:")
    print("   Runtime > Change runtime type > Hardware accelerator > GPU")

# Set random seeds for reproducibility
np.random.seed(42)
tf.random.set_seed(42)

print("\nβœ” Environment ready!")

Step 2: Dataset Download & Exploration (15 minutes)

We’ll download the EuroSAT RGB dataset from TensorFlow Datasets.

# Download EuroSAT dataset
print("Downloading EuroSAT RGB dataset...")
print("(This may take a few minutes on first run - ~90MB)")

# Load dataset with fixed splits
(ds_train, ds_val, ds_test), ds_info = tfds.load(
    'eurosat/rgb',
    split=['train[:70%]', 'train[70%:85%]', 'train[85%:]'],
    as_supervised=True,
    with_info=True,
    shuffle_files=True
)

# Calculate dataset sizes
train_size = tf.data.experimental.cardinality(ds_train).numpy()
val_size = tf.data.experimental.cardinality(ds_val).numpy()
test_size = tf.data.experimental.cardinality(ds_test).numpy()

print(f"\nβœ” Dataset loaded successfully!")
print(f"  Total images: {ds_info.splits['train'].num_examples}")
print(f"  Train split: 70% (~{train_size} images)")
print(f"  Val split: 15% (~{val_size} images)")
print(f"  Test split: 15% (~{test_size} images)")

# Class names
class_names = ds_info.features['label'].names
num_classes = len(class_names)

print(f"\n  Classes ({num_classes}):")
for i, name in enumerate(class_names):
    print(f"    {i}: {name}")

Explore Dataset

# Visualize sample images
fig, axes = plt.subplots(4, 5, figsize=(15, 12))
axes = axes.flatten()

# Take 20 samples
sample_images = list(ds_train.take(20))

for idx, (image, label) in enumerate(sample_images):
    ax = axes[idx]
    
    # Display image
    ax.imshow(image.numpy().astype('uint8'))
    label_idx = int(label.numpy())
    ax.set_title(f"{class_names[label_idx]}\n(Class {label_idx})",
                 fontsize=10, fontweight='bold')
    ax.axis('off')

plt.suptitle('EuroSAT Sample Images', fontsize=16, fontweight='bold', y=0.995)
plt.tight_layout()
plt.show()

print("\nβœ” Dataset looks good!")
print("  Notice the variety of land use patterns")

Step 3: Data Preprocessing & Augmentation (15 minutes)

We need to: 1. Normalize pixel values (0-255 β†’ 0-1) 2. Batch the data for efficient training 3. Apply data augmentation to prevent overfitting

# Preprocessing function
def preprocess(image, label):
    """
    Normalize image to [0, 1] range
    """
    image = tf.cast(image, tf.float32) / 255.0
    return image, label

# Apply preprocessing
ds_train = ds_train.map(preprocess, num_parallel_calls=tf.data.AUTOTUNE)
ds_val = ds_val.map(preprocess, num_parallel_calls=tf.data.AUTOTUNE)
ds_test = ds_test.map(preprocess, num_parallel_calls=tf.data.AUTOTUNE)

print("βœ” Images normalized to [0, 1]")

Data Augmentation

Augmentation creates variations of training images to improve generalization.

Techniques we’ll use: - Random horizontal flips - Random vertical flips - Random rotations (90Β°, 180Β°, 270Β°) - Random brightness adjustment

Why safe for satellite imagery: - Land use patterns have no preferred orientation - Small brightness variations are realistic

# Data augmentation layer
data_augmentation = keras.Sequential([
    layers.RandomFlip("horizontal_and_vertical"),
    layers.RandomRotation(0.25),  # Up to 90 degrees
    layers.RandomBrightness(0.1),  # Β±10% brightness
    layers.RandomContrast(0.1),
], name='data_augmentation')

# Augmentation function
def augment(image, label):
    image = data_augmentation(image, training=True)
    return image, label

# Apply augmentation to training data only
ds_train_augmented = ds_train.map(augment, num_parallel_calls=tf.data.AUTOTUNE)

print("βœ” Data augmentation configured")
print("  Augmentation applied to training data only")
print("  Validation and test data unchanged (for fair evaluation)")

Visualize Augmentation

# Show original vs augmented
sample_image, sample_label = next(iter(ds_train))

# Convert label to integer for indexing
label_idx = int(sample_label.numpy())

fig, axes = plt.subplots(2, 4, figsize=(14, 7))

# Original
axes[0, 0].imshow(sample_image.numpy())
axes[0, 0].set_title('Original', fontweight='bold')
axes[0, 0].axis('off')

# Augmented versions
for idx in range(1, 8):
    row = idx // 4
    col = idx % 4
    
    augmented = data_augmentation(tf.expand_dims(sample_image, 0), training=True)[0]
    axes[row, col].imshow(augmented.numpy())
    axes[row, col].set_title(f'Augmented {idx}', fontweight='bold')
    axes[row, col].axis('off')

plt.suptitle(f'Data Augmentation Examples\nClass: {class_names[label_idx]}',
             fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()

print("\nβœ” Augmentation creates realistic variations")

Create Batched Datasets

# Configuration
BATCH_SIZE = 32
SHUFFLE_BUFFER = 1000

# Batch and prefetch for performance
ds_train_final = ds_train_augmented.shuffle(SHUFFLE_BUFFER).batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE)
ds_val_final = ds_val.batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE)
ds_test_final = ds_test.batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE)

print(f"βœ” Datasets configured")
print(f"  Batch size: {BATCH_SIZE}")
print(f"  Prefetch enabled for optimal performance")

Step 4: Build CNN Architecture (20 minutes)

Now we’ll design and build a CNN from scratch!

Architecture Design

We’ll create a 3-block CNN:

Input (64Γ—64Γ—3)
    ↓
Block 1: Conv(32) β†’ Conv(32) β†’ MaxPool β†’ Dropout
    ↓
Block 2: Conv(64) β†’ Conv(64) β†’ MaxPool β†’ Dropout
    ↓
Block 3: Conv(128) β†’ MaxPool β†’ Dropout
    ↓
Flatten
    ↓
Dense(256) β†’ Dropout
    ↓
Output (10 classes)

Design Principles: - Start with 32 filters, double each block (32β†’64β†’128) - Use 3Γ—3 convolutions (standard) - MaxPool after each block (reduce dimensions) - Dropout for regularization (prevent overfitting) - ReLU activation (all hidden layers) - Softmax activation (output layer)


4.1: Define the Model

# Build CNN model
def build_cnn_model(input_shape=(64, 64, 3), num_classes=10):
    """
    Build a 3-block CNN for EuroSAT classification
    
    Parameters:
    -----------
    input_shape : tuple
        Input image dimensions
    num_classes : int
        Number of output classes
    
    Returns:
    --------
    model : keras.Model
        Compiled CNN model
    """
    
    model = models.Sequential([
        # Input
        layers.Input(shape=input_shape),
        
        # Block 1
        layers.Conv2D(32, (3, 3), activation='relu', padding='same', name='conv1_1'),
        layers.Conv2D(32, (3, 3), activation='relu', padding='same', name='conv1_2'),
        layers.MaxPooling2D((2, 2), name='pool1'),
        layers.Dropout(0.25, name='dropout1'),
        
        # Block 2
        layers.Conv2D(64, (3, 3), activation='relu', padding='same', name='conv2_1'),
        layers.Conv2D(64, (3, 3), activation='relu', padding='same', name='conv2_2'),
        layers.MaxPooling2D((2, 2), name='pool2'),
        layers.Dropout(0.25, name='dropout2'),
        
        # Block 3
        layers.Conv2D(128, (3, 3), activation='relu', padding='same', name='conv3_1'),
        layers.MaxPooling2D((2, 2), name='pool3'),
        layers.Dropout(0.25, name='dropout3'),
        
        # Classifier
        layers.Flatten(name='flatten'),
        layers.Dense(256, activation='relu', name='fc1'),
        layers.Dropout(0.5, name='dropout4'),
        layers.Dense(num_classes, activation='softmax', name='output')
    ], name='EuroSAT_CNN')
    
    return model

# Create model
model = build_cnn_model(input_shape=(64, 64, 3), num_classes=num_classes)

print("βœ” CNN model created")
print(f"  Architecture: 3-block CNN")
print(f"  Input shape: (64, 64, 3)")
print(f"  Output classes: {num_classes}")

Model Summary

# Display model architecture
model.summary()

# Calculate total parameters
total_params = model.count_params()
print(f"\nπŸ“Š Total Parameters: {total_params:,}")
print(f"   Trainable: {total_params:,}")

# Breakdown by layer type
conv_params = sum([layer.count_params() for layer in model.layers if 'conv' in layer.name])
dense_params = sum([layer.count_params() for layer in model.layers if 'dense' in layer.name or 'fc' in layer.name])

print(f"\n   Convolutional layers: {conv_params:,}")
print(f"   Dense layers: {dense_params:,}")

Visualize Architecture

# Plot model architecture (optional - may fail without graphviz)
try:
    keras.utils.plot_model(
        model,
        to_file='cnn_architecture.png',
        show_shapes=True,
        show_layer_names=True,
        rankdir='TB',  # Top to bottom
        dpi=96
    )
    
    # Display
    from IPython.display import Image
    display(Image('cnn_architecture.png'))
except Exception as e:
    print("Note: Model visualization requires graphviz. Skipping...")
    print("To install: apt-get install graphviz (Linux) or brew install graphviz (Mac)")

4.2: Compile the Model

We need to configure: - Loss function: Sparse categorical crossentropy (for integer labels) - Optimizer: Adam (adaptive learning rate) - Metrics: Accuracy (percentage of correct predictions)

# Compile model
model.compile(
    optimizer=keras.optimizers.Adam(learning_rate=0.001),
    loss='sparse_categorical_crossentropy',
    metrics=['accuracy']
)

print("βœ” Model compiled")
print(f"  Optimizer: Adam (lr=0.001)")
print(f"  Loss: Sparse Categorical Crossentropy")
print(f"  Metrics: Accuracy")

Step 5: Training & Monitoring (20 minutes)

Time to train! We’ll use callbacks to: - Early Stopping: Stop if validation loss doesn’t improve - Model Checkpoint: Save best model weights - Reduce LR: Lower learning rate when plateauing


5.1: Configure Callbacks

# Create directory for model checkpoints
os.makedirs('model_checkpoints', exist_ok=True)

# Define callbacks
callbacks = [
    # Early stopping: stop if val_loss doesn't improve for 10 epochs
    EarlyStopping(
        monitor='val_loss',
        patience=10,
        restore_best_weights=True,
        verbose=1
    ),
    
    # Model checkpoint: save best model
    ModelCheckpoint(
        'model_checkpoints/best_model.h5',
        monitor='val_accuracy',
        save_best_only=True,
        verbose=1
    ),
    
    # Reduce learning rate: divide by 2 if plateau
    ReduceLROnPlateau(
        monitor='val_loss',
        factor=0.5,
        patience=5,
        min_lr=1e-7,
        verbose=1
    )
]

print("βœ” Callbacks configured")
print("  - Early stopping (patience=10)")
print("  - Model checkpoint (save best)")
print("  - Reduce LR on plateau")

5.2: Train the Model

⏱️ Training Time: ~15-20 minutes on GPU, 1-2 hours on CPU

We’ll train for up to 50 epochs, but early stopping will likely halt around 20-30 epochs.

# Train model
print("Starting training...")
print("=" * 70)

EPOCHS = 50

history = model.fit(
    ds_train_final,
    validation_data=ds_val_final,
    epochs=EPOCHS,
    callbacks=callbacks,
    verbose=1
)

print("=" * 70)
print("\nβœ” Training complete!")

Training Curves

# Plot training history
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# Loss curves
ax1.plot(history.history['loss'], label='Training Loss', linewidth=2)
ax1.plot(history.history['val_loss'], label='Validation Loss', linewidth=2)
ax1.set_xlabel('Epoch', fontsize=12, fontweight='bold')
ax1.set_ylabel('Loss', fontsize=12, fontweight='bold')
ax1.set_title('Model Loss Over Time', fontsize=14, fontweight='bold')
ax1.legend(fontsize=11)
ax1.grid(True, alpha=0.3)

# Accuracy curves
ax2.plot(history.history['accuracy'], label='Training Accuracy', linewidth=2)
ax2.plot(history.history['val_accuracy'], label='Validation Accuracy', linewidth=2)
ax2.set_xlabel('Epoch', fontsize=12, fontweight='bold')
ax2.set_ylabel('Accuracy', fontsize=12, fontweight='bold')
ax2.set_title('Model Accuracy Over Time', fontsize=14, fontweight='bold')
ax2.legend(fontsize=11)
ax2.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# Print final metrics
final_train_acc = history.history['accuracy'][-1]
final_val_acc = history.history['val_accuracy'][-1]
final_train_loss = history.history['loss'][-1]
final_val_loss = history.history['val_loss'][-1]

print(f"\nπŸ“Š Final Training Metrics:")
print(f"   Train Accuracy: {final_train_acc*100:.2f}%")
print(f"   Train Loss: {final_train_loss:.4f}")
print(f"   Val Accuracy: {final_val_acc*100:.2f}%")
print(f"   Val Loss: {final_val_loss:.4f}")

# Check for overfitting
gap = final_train_acc - final_val_acc
if gap > 0.05:
    print(f"\n⚠️  Possible overfitting: {gap*100:.1f}% gap between train/val accuracy")
else:
    print(f"\nβœ” Good generalization: only {gap*100:.1f}% gap")

Step 6: Evaluation & Analysis (15 minutes)

Now let’s evaluate on the test set and analyze results.


6.1: Test Set Evaluation

# Evaluate on test set
print("Evaluating on test set...")

test_loss, test_accuracy = model.evaluate(ds_test_final, verbose=0)

print(f"\n🎯 Test Set Results:")
print(f"   Accuracy: {test_accuracy*100:.2f}%")
print(f"   Loss: {test_loss:.4f}")

if test_accuracy > 0.90:
    print("\nπŸŽ‰ Excellent! You've achieved >90% accuracy!")
elif test_accuracy > 0.85:
    print("\nβœ” Good! Above 85% is solid for first attempt")
else:
    print("\nπŸ’‘ Room for improvement - try tuning hyperparameters")

6.2: Confusion Matrix

# Generate predictions
print("Generating predictions for confusion matrix...")

y_true = []
y_pred = []

for images, labels in ds_test_final:
    predictions = model.predict(images, verbose=0)
    y_true.extend(labels.numpy())
    y_pred.extend(np.argmax(predictions, axis=1))

y_true = np.array(y_true)
y_pred = np.array(y_pred)

print(f"βœ” Predictions generated for {len(y_true)} test images")
# Compute confusion matrix
cm = confusion_matrix(y_true, y_pred)

# Plot confusion matrix
fig, ax = plt.subplots(figsize=(12, 10))

sns.heatmap(cm, annot=True, fmt='d', cmap='Blues', 
            xticklabels=class_names, yticklabels=class_names,
            cbar_kws={'label': 'Count'}, ax=ax)

ax.set_xlabel('Predicted Label', fontsize=12, fontweight='bold')
ax.set_ylabel('True Label', fontsize=12, fontweight='bold')
ax.set_title('Confusion Matrix - EuroSAT Test Set', fontsize=14, fontweight='bold')

plt.xticks(rotation=45, ha='right')
plt.yticks(rotation=0)
plt.tight_layout()
plt.show()

print("\nβœ” Confusion matrix generated")
print("  Diagonal = correct predictions")
print("  Off-diagonal = misclassifications")

6.3: Per-Class Metrics

# Classification report
report = classification_report(y_true, y_pred, target_names=class_names, output_dict=True)

# Convert to DataFrame for nice display
report_df = pd.DataFrame(report).transpose()

print("\nπŸ“Š Per-Class Performance:")
print("=" * 80)
print(report_df.round(3))
print("=" * 80)

# Highlight best and worst classes
metrics_df = report_df[:-3]  # Exclude accuracy, macro avg, weighted avg
best_class = metrics_df['f1-score'].idxmax()
worst_class = metrics_df['f1-score'].idxmin()

print(f"\n✨ Best performing class: {best_class} (F1={metrics_df.loc[best_class, 'f1-score']:.3f})")
print(f"⚠️  Worst performing class: {worst_class} (F1={metrics_df.loc[worst_class, 'f1-score']:.3f})")

Visualize Per-Class Accuracy

# Plot per-class F1 scores
fig, ax = plt.subplots(figsize=(12, 6))

classes = metrics_df.index.tolist()
f1_scores = metrics_df['f1-score'].values

bars = ax.barh(classes, f1_scores, color='steelblue', edgecolor='black', linewidth=1.5)

# Color best and worst
bars[classes.index(best_class)].set_color('green')
bars[classes.index(worst_class)].set_color('orange')

ax.set_xlabel('F1 Score', fontsize=12, fontweight='bold')
ax.set_ylabel('Class', fontsize=12, fontweight='bold')
ax.set_title('Per-Class F1 Scores', fontsize=14, fontweight='bold')
ax.set_xlim(0, 1.0)
ax.grid(axis='x', alpha=0.3)

# Add value labels
for i, (bar, score) in enumerate(zip(bars, f1_scores)):
    ax.text(score + 0.01, i, f'{score:.3f}', 
            va='center', fontsize=10, fontweight='bold')

plt.tight_layout()
plt.show()

6.4: Analyze Misclassifications

# Find misclassified examples
misclassified_indices = np.where(y_true != y_pred)[0]
print(f"\nTotal misclassifications: {len(misclassified_indices)} / {len(y_true)}")
print(f"Error rate: {len(misclassified_indices)/len(y_true)*100:.2f}%")

# Show some misclassified examples
if len(misclassified_indices) > 0:
    print("\n⚠️ Note: Displaying misclassified samples (this may take a moment)...")
    
    # Get first few misclassifications
    num_samples_to_show = min(12, len(misclassified_indices))
    
    # Create a more efficient way to get misclassified samples
    # We'll iterate through test set and collect misclassified images
    misclassified_samples = []
    batch_idx = 0
    
    for images, labels in ds_test_final:
        batch_preds = model.predict(images, verbose=0)
        batch_preds = np.argmax(batch_preds, axis=1)
        
        for i in range(len(labels)):
            if labels[i] != batch_preds[i]:
                misclassified_samples.append({
                    'image': images[i].numpy(),
                    'true_label': int(labels[i].numpy()),
                    'pred_label': int(batch_preds[i])
                })
                
                if len(misclassified_samples) >= num_samples_to_show:
                    break
        
        if len(misclassified_samples) >= num_samples_to_show:
            break
    
    # Plot misclassifications
    if misclassified_samples:
        fig, axes = plt.subplots(3, 4, figsize=(14, 10))
        axes = axes.flatten()
        
        for idx, sample in enumerate(misclassified_samples[:12]):
            ax = axes[idx]
            
            # Display image
            ax.imshow(sample['image'])
            
            true_label = class_names[sample['true_label']]
            pred_label = class_names[sample['pred_label']]
            
            ax.set_title(f'True: {true_label}\nPred: {pred_label}',
                         fontsize=9, fontweight='bold',
                         color='red')
            ax.axis('off')
        
        plt.suptitle('Sample Misclassifications', fontsize=14, fontweight='bold', color='red')
        plt.tight_layout()
        plt.show()
        
        print("\nπŸ’‘ Misclassification Analysis:")
        print("  Look for patterns:")
        print("  - Similar-looking classes (e.g., forest types)")
        print("  - Ambiguous examples")
        print("  - Data augmentation might help")

Bonus: Google Earth Engine Integration

To use this model with Google Earth Engine data, you can:

  1. Export GEE imagery for inference
  2. Deploy the model for real-time classification

Here’s how to prepare GEE data for this model:

# Example: Preparing GEE data for model inference
def prepare_gee_data_for_model(image_array):
    """
    Prepare Google Earth Engine imagery for CNN inference
    
    Parameters:
    -----------
    image_array : numpy array
        Raw satellite image from GEE (H, W, C)
    
    Returns:
    --------
    prepared_patches : numpy array
        64x64 patches ready for model.predict()
    """
    import numpy as np
    from skimage.util import view_as_windows
    
    # Normalize to [0, 1]
    if image_array.max() > 1:
        image_array = image_array / 255.0
    
    # Extract 64x64 patches
    patch_size = 64
    patches = view_as_windows(image_array, 
                              (patch_size, patch_size, 3),
                              step=patch_size)
    
    # Reshape for batch prediction
    n_patches_y, n_patches_x = patches.shape[:2]
    patches = patches.reshape(-1, patch_size, patch_size, 3)
    
    return patches, (n_patches_y, n_patches_x)

# Example usage:
# patches, grid_shape = prepare_gee_data_for_model(gee_image)
# predictions = model.predict(patches)
# classification_map = predictions.reshape(grid_shape + (num_classes,))

print("βœ” GEE integration helper function created")
print("  Use this to prepare Earth Engine exports for classification")

πŸŽ‰ Lab Complete!

Summary

You’ve successfully:

βœ… Built a CNN from scratch (3 blocks, ~300K parameters)
βœ… Trained on 27,000 Sentinel-2 images
βœ… Achieved 90%+ accuracy on EuroSAT dataset
βœ… Evaluated with confusion matrix and per-class metrics
βœ… Analyzed misclassifications


Key Takeaways

What Worked Well

  • Architecture: 3-block design with progressive filters (32β†’64β†’128)
  • Regularization: Dropout prevented overfitting
  • Data Augmentation: Improved generalization
  • Callbacks: Early stopping saved training time

Compared to Random Forest (Session 1-2)

  • CNN: 92-95% accuracy (automatic features)
  • Random Forest: 85-90% accuracy (manual features)
  • Improvement: +5-10% for critical applications

What’s Next?

  • Transfer Learning: Use pre-trained ResNet (Session 4B)
  • U-Net: Pixel-level segmentation (Session 4C)
  • Google Earth Engine: Apply to large-scale mapping
  • Production: Deploy model for monitoring

Exercises to Try

Easy

  1. Change batch size (16, 64) and observe effects
  2. Modify dropout rates (0.1, 0.5)
  3. Try different learning rates

Medium

  1. Add another convolutional block
  2. Use different augmentation techniques
  3. Experiment with optimizer (SGD vs Adam)

Advanced

  1. Implement learning rate scheduling
  2. Add batch normalization layers
  3. Try different architectures (VGG-style, ResNet-style)
  4. Integrate with Google Earth Engine exports

Save Your Work

# Save final model
model.save('eurosat_cnn_final.h5')
print("βœ” Model saved: eurosat_cnn_final.h5")

# Save training history
import pickle
with open('training_history.pkl', 'wb') as f:
    pickle.dump(history.history, f)
print("βœ” Training history saved: training_history.pkl")

# Export predictions
results = pd.DataFrame({
    'true_label': [class_names[i] for i in y_true],
    'predicted_label': [class_names[i] for i in y_pred],
    'correct': y_true == y_pred
})
results.to_csv('test_predictions.csv', index=False)
print("βœ” Predictions saved: test_predictions.csv")

print("\n🎊 All done! Ready for deployment or further experimentation.")