Handling Class Imbalance in Land-Cover Classification

Handle class imbalance in land-cover classification: use XGBoost sample weights, avoid naive SMOTE on spatial pixels, and evaluate rare classes with macro-F1.

TL;DR Answer

Land-cover datasets are severely imbalanced — forest and cropland dominate while water, wetland, and urban pixels are scarce. Do not oversample with naive SMOTE, because it fabricates spatially autocorrelated pixels and leaks signal into validation. Instead compute per-pixel sample_weight values inversely proportional to class frequency, pass them to an XGBoost multiclass model, and evaluate with macro-F1, balanced accuracy, and per-class recall inside a spatial split rather than overall accuracy. This is one piece of the broader Gradient Boosting for Raster Data workflow.

import numpy as np
from sklearn.utils.class_weight import compute_sample_weight

# Inverse-frequency weight per pixel; rare classes get a larger weight
sample_weight = compute_sample_weight(class_weight="balanced", y=y_train)

print({c: round(w, 2) for c, w in
       zip(*np.unique(y_train, return_inverse=False),
           np.bincount(y_train))})

Part of: Gradient Boosting for Raster Data


Why Land-Cover Data Is Almost Always Imbalanced

Sample a Sentinel-2 scene over a mixed agricultural region and count the labelled pixels by class. Cropland and forest will together account for the overwhelming majority; open water, wetland, bare rock, and built-up surfaces will each contribute a thin sliver. A 100:1 ratio between the most and least common class is routine, and continental land-cover products regularly exceed 1000:1 for the rarest categories. This is not a sampling flaw you can fix by collecting more data — it reflects the genuine distribution of the landscape.

Gradient-boosted trees minimise a summed loss over every training pixel. When 80 percent of the loss gradient comes from two dominant classes, the model rationally spends its capacity separating forest from cropland and treats the rare classes as noise. The result is a classifier that reports a comfortable overall accuracy while producing land-cover maps where every lake has vanished and every town is painted as surrounding farmland. Because the errors concentrate in exactly the classes analysts care about — water bodies, impervious surfaces, disturbance patches — the model is worse than useless for the decisions it was built to support.

Two forces make the geospatial version of this problem harder than a generic tabular one:

  • Spatial autocorrelation. Neighbouring pixels are near-duplicates. Any resampling that copies or interpolates pixels risks placing near-identical observations on both sides of a validation boundary.
  • Deceptive metrics. Overall accuracy is weighted by pixel count, so it rewards ignoring the minority. You need class-balanced metrics to see the failure at all.

Core Principles for Rebalancing a Land-Cover Classifier

  • Reweight the loss, do not fabricate pixels. Per-sample or per-class weights change how much each pixel contributes to the gradient without inventing new spatial observations. This is the safest lever for autocorrelated raster data.
  • Compute weights from the training fold only. Class frequencies must come from the training partition, never the full dataset, or the weighting itself leaks the validation label distribution.
  • Treat multiclass explicitly. scale_pos_weight is binary-only. For land cover use a sample_weight vector so every class is reweighted independently.
  • Never evaluate on overall accuracy alone. Track macro-F1, balanced accuracy, and per-class recall so a collapsed rare class is impossible to miss.
  • Keep the split spatial. Reweighting and honest evaluation only work if the validation fold is geographically separated, which requires the Spatial Cross-Validation Strategies you would apply to any raster model.
  • Tune the weights, then tune the model. Class weight is itself a hyperparameter. Fold it into the search described in Hyperparameter Tuning for XGBoost on Geodata rather than fixing it by eye.

Why Naive SMOTE Is Dangerous for Pixels

SMOTE synthesises new minority samples by interpolating between a point and its nearest neighbours in feature space. On independent tabular rows this is defensible. On land-cover pixels it is quietly destructive.

Adjacent pixels of the same class share almost identical spectral values because of spatial autocorrelation, so a rare-class pixel and its feature-space neighbours are frequently its literal geographic neighbours. Interpolating between them produces synthetic pixels that are statistically indistinguishable from observations already in the scene. If those real neighbours later land in the validation fold, the model has effectively seen the answer during training. Rare-class recall climbs impressively in cross-validation and then collapses on a new tile, because the apparent gain was leakage, not learning. The failure is the same mechanism covered in Handling Spatial Autocorrelation — the assumption of independent samples is false for geographic data.

Loss reweighting sidesteps the trap entirely. It never creates a pixel, so it cannot place a near-duplicate across a fold boundary. If you must resample, do it only within spatially blocked training folds and never across the split, but weighting is simpler and safer for the common case.

SMOTE leakage versus loss reweighting for imbalanced land-cover pixels Left panel shows a synthetic pixel interpolated between a training pixel and a validation pixel across a fold boundary, leaking signal. Right panel shows sample weights enlarging the loss contribution of rare-class pixels without crossing the boundary. Naive SMOTE leaks across the fold train fold test fold train test synthetic = leakage Sample weights reweight, never copy forest / cropland — weight 0.3 water / urban — weight 8.0 Bigger marks carry more loss gradient — no pixel is fabricated, so nothing crosses the fold boundary.

Production-Ready Code

The routine below loads a labelled pixel table, computes inverse-frequency weights from the training partition only, trains an XGBoost multiclass model, and reports per-class recall. It validates inputs, keeps projected coordinates available for spatial splitting, and never touches validation labels when building weights.

import logging
import numpy as np
import xgboost as xgb
from sklearn.utils.class_weight import compute_sample_weight
from sklearn.metrics import (
    balanced_accuracy_score,
    f1_score,
    recall_score,
    classification_report,
    confusion_matrix,
)

logger = logging.getLogger(__name__)

CLASS_NAMES = ["forest", "cropland", "grassland", "water", "urban", "wetland"]


def train_balanced_landcover(
    X_train: np.ndarray,
    y_train: np.ndarray,
    X_valid: np.ndarray,
    y_valid: np.ndarray,
    n_classes: int,
    random_state: int = 42,
) -> xgb.XGBClassifier:
    """Train an inverse-frequency-weighted XGBoost land-cover classifier.

    Parameters
    ----------
    X_train, X_valid : np.ndarray of shape (n_samples, n_features)
        Spectral / index features. Do NOT include coordinates as predictors;
        keep them separate for spatial splitting.
    y_train, y_valid : np.ndarray of shape (n_samples,)
        Integer class labels in [0, n_classes).
    n_classes : int
        Number of land-cover classes.
    """
    if X_train.ndim != 2 or X_valid.ndim != 2:
        raise ValueError("X_train and X_valid must be 2-D feature matrices.")
    if len(X_train) != len(y_train):
        raise ValueError("X_train and y_train length mismatch.")
    if set(np.unique(y_train)) - set(range(n_classes)):
        raise ValueError("y_train contains labels outside [0, n_classes).")

    # Weights come from the TRAINING fold only — never the full dataset.
    sample_weight = compute_sample_weight(class_weight="balanced", y=y_train)

    counts = np.bincount(y_train, minlength=n_classes)
    for cls, cnt in enumerate(counts):
        logger.info("class %-9s n=%7d", CLASS_NAMES[cls], cnt)

    model = xgb.XGBClassifier(
        objective="multi:softprob",
        num_class=n_classes,
        eval_metric="mlogloss",
        n_estimators=600,
        learning_rate=0.05,
        max_depth=6,
        subsample=0.8,
        colsample_bytree=0.8,
        random_state=random_state,
        n_jobs=-1,
        early_stopping_rounds=40,
    )

    model.fit(
        X_train,
        y_train,
        sample_weight=sample_weight,
        eval_set=[(X_valid, y_valid)],
        verbose=False,
    )
    return model


def report_per_class(model, X_valid, y_valid) -> dict:
    """Print class-balanced metrics and return per-class recall."""
    y_pred = model.predict(X_valid)

    macro_f1 = f1_score(y_valid, y_pred, average="macro")
    bal_acc = balanced_accuracy_score(y_valid, y_pred)
    per_class = recall_score(y_valid, y_pred, average=None)

    logger.info("macro-F1=%.3f  balanced-accuracy=%.3f", macro_f1, bal_acc)
    print(classification_report(y_valid, y_pred, target_names=CLASS_NAMES,
                                digits=3))
    return dict(zip(CLASS_NAMES, per_class))

Crucially, coordinates are kept out of the predictor matrix. They are needed only to build the spatial folds, not as features — feeding easting and northing to the trees would let the model memorise position instead of learning spectral relationships.


Step-by-Step Walkthrough

1. Assemble labelled pixels and hold out coordinates for splitting

import geopandas as gpd
import numpy as np

gdf = gpd.read_file("landcover_samples.gpkg").to_crs("EPSG:32633")

feature_cols = ["blue", "green", "red", "nir", "swir1", "ndvi", "ndwi"]
X = gdf[feature_cols].to_numpy()
y = gdf["class_id"].to_numpy().astype(int)
coords = np.column_stack([gdf.geometry.x, gdf.geometry.y])  # for spatial CV only

print(f"pixels: {len(y)}  classes: {np.bincount(y)}")

2. Build a spatially blocked, class-stratified split

Random pixel splits leak geography. Block the split spatially so each fold is a contiguous region, then confirm every class still appears in the validation fold — a fold missing the water class cannot measure water recall.

from sklearn.cluster import KMeans

# Group pixels into spatial blocks, hold one block out for validation
blocks = KMeans(n_clusters=5, random_state=42, n_init=10).fit_predict(coords)
valid_mask = blocks == 0
train_mask = ~valid_mask

X_train, y_train = X[train_mask], y[train_mask]
X_valid, y_valid = X[valid_mask], y[valid_mask]

missing = set(range(6)) - set(np.unique(y_valid))
if missing:
    raise ValueError(f"Validation block missing classes {missing}; "
                     "rotate to a block that contains every class.")

3. Train with inverse-frequency weights

model = train_balanced_landcover(
    X_train, y_train, X_valid, y_valid, n_classes=6
)

4. Evaluate on class-balanced metrics

per_class_recall = report_per_class(model, X_valid, y_valid)

Typical output contrasts the dominant and rare classes sharply:

              precision  recall  f1-score  support
      forest      0.94    0.96     0.95     41230
    cropland      0.91    0.93     0.92     38044
   grassland      0.78    0.74     0.76      9120
       water      0.83    0.81     0.82      1204
       urban      0.71    0.69     0.70       880
     wetland      0.66    0.62     0.64       410

macro-F1=0.798  balanced-accuracy=0.792

Overall accuracy on this run is 0.93 — reassuring and misleading. The macro-F1 of 0.80 and the wetland recall of 0.62 are the honest picture, and they only became visible because the metrics weight every class equally.


Verification

Confirm that weighting actually rescued the rare classes by asserting a per-class recall floor on the confusion matrix. This is the guardrail that catches a silent collapse before a map ships.

import numpy as np
from sklearn.metrics import confusion_matrix

def assert_min_recall(y_true, y_pred, floor: float = 0.55) -> None:
    """Fail if any class recall falls below the floor."""
    cm = confusion_matrix(y_true, y_pred)
    recall = cm.diagonal() / cm.sum(axis=1)
    for cls, r in enumerate(recall):
        print(f"class {cls}: recall={r:.3f}")
    worst = np.argmin(recall)
    assert recall[worst] >= floor, (
        f"class {worst} recall {recall[worst]:.3f} below floor {floor}"
    )

assert_min_recall(y_valid, model.predict(X_valid), floor=0.55)

Run the same assertion after an unweighted baseline to quantify the gain. Without weights, wetland and urban recall commonly sit near zero while overall accuracy barely moves; with inverse-frequency weights they climb into a usable range. If a class still fails the floor, raise its weight specifically, gather more labels for it, or accept that its spectral signature overlaps a neighbour and merge the classes. Once the classifier passes, you are ready to write predictions across the full scene, covered in Mapping XGBoost Predictions Back to a Raster Grid.


FAQ

Should I use SMOTE to oversample rare land-cover classes?

Avoid naive SMOTE on land-cover pixels. It interpolates synthetic samples between nearest neighbours in feature space, but adjacent pixels are spatially autocorrelated, so the synthetic points duplicate signal that already exists near your validation folds. That leaks information across the split and inflates rare-class recall during validation while doing nothing for genuinely new geographic extents. Prefer sample_weight or class weights, which reweight the loss without fabricating spatial observations. If you must resample, confine it to spatially blocked training folds and never generate across a split.

Why is overall accuracy misleading for imbalanced land-cover maps?

When forest and cropland make up 80 percent of pixels, a classifier that ignores water and urban entirely can still report 80 percent overall accuracy. The metric is dominated by the majority classes and hides total failure on the rare classes that often matter most. Report macro-F1, balanced accuracy, and per-class recall instead — they average performance across classes equally and expose collapse on any single land-cover type.

How do sample weights differ from scale_pos_weight in XGBoost?

scale_pos_weight is a single scalar for binary classification that rebalances the positive class against the negative one. Multiclass land-cover problems need a per-sample weight vector passed through sample_weight, where each pixel gets a weight inversely proportional to its class frequency. That generalises the same idea to any number of classes and lets you tune each land-cover type independently.


Part of: Gradient Boosting for Raster DataTraining Geospatial Predictive Models in Python