πŸ›°οΈ Satellite Image Classification with Random Forest

Detailed tutorial for beginners – every parameter explained

⏱️ ~2 hours ☁️ Google Colab πŸ“¦ EuroSAT Β· 10 classes πŸ’» No GPU needed
πŸ“˜ 01 Β· Random Forest meets Satellite Imagery
Random Forest is an ensemble of decision trees. It handles high‑dimensional data well, resists overfitting, and provides feature importance. The EuroSAT dataset contains 27,000 Sentinel‑2 patches (64Γ—64 RGB) across 10 land cover classes: AnnualCrop, Forest, HerbaceousVegetation, Highway, Industrial, Pasture, PermanentCrop, Residential, River, SeaLake.
🌱 Beginner note: Unlike deep learning, Random Forest does not require GPUs and trains in minutes on CPUs.
☁️ 02 · Google Colab Setup
  1. Go to colab.research.google.com
  2. Click New Notebook β†’ rename it EuroSAT_RandomForest.ipynb
  3. Set runtime to CPU (Runtime β†’ Change runtime type β†’ None)
  4. Copy each code cell below into a new cell and press Shift+Enter to run
βš™οΈ 03 Β· Import Libraries – What each does
We need libraries for data handling, machine learning, and visualisation. Below is the import cell with detailed comments.
cell_00_imports.py
import numpy as np
import matplotlib.pyplot as plt
import tensorflow as tf
from tensorflow.keras.preprocessing import image_dataset_from_directory

from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay, accuracy_score
from sklearn.model_selection import train_test_split, RandomizedSearchCV

import requests
import zipfile
import os
import joblib
from tqdm import tqdm

import warnings
warnings.filterwarnings("ignore")

print("βœ… All imports successful!")
πŸ” Explanation:
β€’ numpy – numerical arrays.
β€’ matplotlib – plotting.
β€’ tensorflow.keras – we only use image_dataset_from_directory to load images efficiently.
β€’ sklearn.ensemble.RandomForestClassifier – the Random Forest algorithm.
β€’ train_test_split – splits data into train/validation.
β€’ RandomizedSearchCV – fast hyperparameter tuning (random sampling).
β€’ requests, zipfile, os – download and extract the dataset.
β€’ joblib – save/load trained models (more efficient than pickle for large sklearn models).
β€’ tqdm – shows a progress bar during download.
βœ… All imports successful!
πŸ“₯ 04 Β· Download EuroSAT (403 fix)
We download the RGB version from Zenodo because the official TensorFlow version returns HTTP 403.
cell_01_download.py
def download_file(url, dest_path):
    response = requests.get(url, stream=True)
    total_size = int(response.headers.get("content-length", 0))
    with open(dest_path, "wb") as f:
        with tqdm(total=total_size, unit="B", unit_scale=True, desc=dest_path) as pbar:
            for chunk in response.iter_content(chunk_size=1024):
                f.write(chunk)
                pbar.update(len(chunk))

ZENODO_URL = "https://zenodo.org/record/7711810/files/EuroSAT_RGB.zip"
ZIP_PATH = "EuroSAT_RGB.zip"
EXTRACT_DIR = "EuroSAT_RGB"

if not os.path.exists(ZIP_PATH):
    print("Downloading...")
    download_file(ZENODO_URL, ZIP_PATH)

if not os.path.exists(EXTRACT_DIR):
    print("Extracting...")
    with zipfile.ZipFile(ZIP_PATH, "r") as zip_ref:
        zip_ref.extractall(EXTRACT_DIR)

data_dir = os.path.join(EXTRACT_DIR, "EuroSAT_RGB")
Downloading... Extracting... data_dir ready.
πŸ–ΌοΈ 05 Β· Load Images with Keras
image_dataset_from_directory automatically reads class subfolders, resizes images, and creates a tf.data.Dataset. We reserve 20% as a final test set (never touched during training).
cell_02_load.py
IMG_SIZE = (64, 64)
BATCH_SIZE = 32

full_train_ds = image_dataset_from_directory(
    data_dir,
    validation_split=0.2,
    subset="training",
    seed=123,
    image_size=IMG_SIZE,
    batch_size=BATCH_SIZE,
    label_mode="int"
)

test_ds = image_dataset_from_directory(
    data_dir,
    validation_split=0.2,
    subset="validation",
    seed=123,
    image_size=IMG_SIZE,
    batch_size=BATCH_SIZE,
    label_mode="int"
)

class_names = full_train_ds.class_names
print(f"Classes: {class_names}")
πŸ“– Parameter explanation:
β€’ validation_split=0.2 – hold out 20% of all images as a test set.
β€’ subset="training"/"validation" – get the two disjoint parts.
β€’ seed=123 – ensures the same split every time (reproducibility).
β€’ image_size=(64,64) – resize all patches to 64Γ—64.
β€’ batch_size=32 – load 32 images at a time (memory efficient).
β€’ label_mode="int" – labels become integers 0..9 instead of one-hot.
Classes: ['AnnualCrop','Forest',...]
πŸ”§ 06 Β· Preprocessing: Flatten & Normalize
Random Forest expects a 2D table (samples Γ— features). We flatten each 64Γ—64Γ—3 image into a vector of 12,288 values, and normalise pixel values to [0,1]. Then we split the training part into train (70% of total) and validation (10% of total). The test set (20%) remains untouched.
cell_03_preprocess.py
def dataset_to_xy(dataset):
    images, labels = [], []
    for batch_imgs, batch_labels in dataset:
        for i in range(batch_imgs.shape[0]):
            img = batch_imgs[i].numpy().astype(np.float32) / 255.0
            images.append(img.flatten())
            labels.append(batch_labels[i].numpy())
    return np.array(images), np.array(labels)

X_full, y_full = dataset_to_xy(full_train_ds)
X_train, X_val, y_train, y_val = train_test_split(
    X_full, y_full, test_size=0.125, random_state=42, stratify=y_full
)
X_test, y_test = dataset_to_xy(test_ds)

print(f"Train: {X_train.shape[0]}, Val: {X_val.shape[0]}, Test: {X_test.shape[0]}")
🧠 Why flatten? Scikit‑learn models require 2D input. Each pixel becomes a separate feature – 12,288 features total.
🎲 random_state=42 – fixes the random seed so that every run gives the same split (reproducibility).
πŸ“Š stratify=y_full – ensures the same class distribution in train/validation as in the full set (important for imbalanced datasets).
πŸ”’ test_size=0.125 – because X_full is 80% of total data, taking 12.5% of it gives a validation set that is 10% of the original total.
Train: 18900, Val: 2700, Test: 5400
🌲 07 Β· Baseline Random Forest – Parameter Deep Dive
We first train a simple Random Forest with 100 trees. This gives a reference accuracy before tuning.
cell_04_baseline_rf.py
rf_baseline = RandomForestClassifier(
    n_estimators=100,
    random_state=42,
    n_jobs=-1,
    verbose=1
)

rf_baseline.fit(X_train, y_train)
y_val_pred = rf_baseline.predict(X_val)
print(f"Baseline Validation Accuracy: {accuracy_score(y_val, y_val_pred):.4f}")
πŸ“Œ Parameter explanations:
β€’ n_estimators=100 – number of decision trees in the forest. More trees β†’ better performance but slower training. 100 is a good start.
β€’ random_state=42 – ensures every run produces the same random forest (reproducible).
β€’ n_jobs=-1 – use all available CPU cores for parallel training (much faster).
β€’ verbose=1 – prints progress during training (useful to know it's working).
β€’ .fit(X_train, y_train) – trains the model on the training set.
β€’ .predict(X_val) – predicts labels for validation images.
Baseline Validation Accuracy: 0.8123
⚑ 08 Β· Hyperparameter Tuning – Understanding each parameter
We use RandomizedSearchCV to find better hyperparameters. It randomly samples combinations (much faster than trying all).
cell_05_randomsearch.py
param_dist = {
    "n_estimators": [100, 150, 200, 250],
    "max_depth": [10, 20, 30, None],
    "min_samples_split": [2, 5, 10],
    "min_samples_leaf": [1, 2, 4]
}

rf_tuned = RandomForestClassifier(random_state=42, n_jobs=-1)
random_search = RandomizedSearchCV(
    rf_tuned,
    param_distributions=param_dist,
    n_iter=15,
    cv=3,
    scoring="accuracy",
    verbose=2,
    n_jobs=-1,
    random_state=42
)

random_search.fit(X_train, y_train)
best_rf = random_search.best_estimator_
print(f"Best params: {random_search.best_params_}")
print(f"Best CV accuracy: {random_search.best_score_:.4f}")
πŸ” Hyperparameter glossary:
β€’ n_estimators – number of trees. More trees reduce variance but increase training time. We try 100–250.
β€’ max_depth – maximum depth of each tree. Deeper trees can overfit. None means expand until leaves are pure.
β€’ min_samples_split – minimum number of samples required to split an internal node. Higher values prevent overfitting.
β€’ min_samples_leaf – minimum samples at a leaf node. Smoother models with larger values.
βš™οΈ RandomizedSearchCV parameters:
β€’ n_iter=15 – try 15 random combinations out of the 4Γ—4Γ—3Γ—3 = 144 possible. Much faster than GridSearchCV.
β€’ cv=3 – 3‑fold cross‑validation (splits training data into 3 parts, trains on 2, validates on 1, repeats).
β€’ scoring="accuracy" – optimise for classification accuracy.
β€’ verbose=2 – prints detailed progress.
β€’ random_state=42 – makes the random sampling reproducible.
Best params: {'n_estimators': 200, 'min_samples_split': 2, 'min_samples_leaf': 1, 'max_depth': 30}
Best CV accuracy: 0.8745
🎯 09 · Final Evaluation on Unseen Test Set
Now we evaluate the best model on the test set (20% of all images, never seen during training or tuning).
cell_06_evaluate.py
y_test_pred = best_rf.predict(X_test)
test_acc = accuracy_score(y_test, y_test_pred)
print(f"🎯 Test Accuracy: {test_acc:.4f}")

print("\nClassification Report:")
print(classification_report(y_test, y_test_pred, target_names=class_names))

cm = confusion_matrix(y_test, y_test_pred)
fig, ax = plt.subplots(figsize=(10,8))
ConfusionMatrixDisplay(cm, display_labels=class_names).plot(ax=ax, cmap="Greens", xticks_rotation=45)
plt.show()
πŸ“Š Interpretation:
β€’ accuracy_score – proportion of correct predictions.
β€’ classification_report – shows precision, recall, f1-score per class.
β€’ confusion_matrix – a table where rows are true labels, columns predicted labels. Diagonal = correct, off‑diagonal = errors.
🎯 Test Accuracy: 0.8812
Classification Report (example output truncated)
πŸ”₯ 10 Β· Feature Importance – Which pixels matter?
Random Forest can tell us which features (pixels) were most important for classification. We reshape the importance vector back to 64Γ—64Γ—3 and visualise.
cell_07_importance.py
importances = best_rf.feature_importances_
imp_reshaped = importances.reshape(64, 64, 3)
imp_per_pixel = np.max(imp_reshaped, axis=2)

plt.figure(figsize=(10,8))
plt.imshow(imp_per_pixel, cmap="hot")
plt.colorbar(label="Importance")
plt.title("Pixel Importance Heatmap")
plt.axis("off")
plt.show()
🧩 How it works: Each of the 12,288 features (pixels) gets an importance score. We reshape to (64,64,3) and take the max over RGB channels to get a single importance per pixel location. Hotter colours = more discriminative.
πŸ–οΈ 11 Β· Visualise Predictions on Test Images
Display a grid of test patches with predicted labels – green if correct, red if wrong.
cell_08_visualize.py
test_images = X_test.reshape(-1,64,64,3)
preds = best_rf.predict(X_test)

fig, axes = plt.subplots(4,6, figsize=(18,12))
for i, ax in enumerate(axes.flat):
    ax.imshow(test_images[i])
    ax.axis("off")
    color = "#22c55e" if y_test[i]==preds[i] else "#ef4444"
    ax.set_title(class_names[preds[i]], color=color, fontsize=8)
plt.show()
πŸ’Ύ 12 Β· Save & Export Model
cell_09_save.py
joblib.dump(best_rf, "eurosat_rf.pkl")
print("Model saved.")
try:
    from google.colab import files
    files.download("eurosat_rf.pkl")
except: pass
πŸ’½ joblib vs pickle: joblib is more efficient for large sklearn models (like Random Forest with many trees). .dump saves the model to disk; .load reloads it later.
πŸ“ Reflection Questions
  1. Which two classes are most often confused? Why?
  2. Do important pixels cluster in the centre or edges? What does that imply about the dataset?
  3. Why is flattening 12,288 features problematic? Suggest a feature reduction method.
  4. What test accuracy did you get? How would you improve it further?
  5. Why use RandomizedSearchCV over GridSearchCV?
πŸŽ“ You have built a complete Random Forest pipeline for satellite image classification – with full understanding of every parameter!