Two model families dominate modern land-cover and raster classification work, and they could hardly be more different. Gradient boosting — the XGBoost and LightGBM lineage — treats every pixel as an independent row of tabular features and builds an ensemble of decision trees. Graph neural networks (GNNs) instead treat the scene as a network of connected regions, passing messages along adjacency edges so that each unit’s prediction is shaped by its neighbours. Choosing between them is one of the most consequential decisions in a geospatial modelling project, and the wrong choice costs weeks of GPU time or leaves accuracy on the table.
This guide is part of Training Geospatial Predictive Models in Python, and it exists to make that decision defensible. Rather than declaring a universal winner, it lays out a decision framework, compares the two approaches across eight practical criteria, gives runnable baselines for both, and shows how to judge them fairly under spatial cross-validation. If you have already committed to one path, the dedicated guides on gradient boosting for raster data and graph neural networks for spatial data go deeper on each.
Problem Framing
Raster classification assigns a class label — cropland, water, impervious surface, forest type — to every cell in a gridded scene. The modelling question is how much of the label depends on a pixel’s own spectral signature versus its relationship to surrounding pixels and objects.
Gradient boosting answers this by flattening the raster into a table: each pixel becomes a row, each band or spectral index a column, and the model learns axis-aligned splits that carve feature space into class regions. It knows nothing about geography unless you hand it neighbourhood features explicitly. A GNN takes the opposite stance. You first segment the scene into homogeneous regions, build a graph where each region is a node and shared borders become edges, and let the network propagate information along those edges. The prediction for one region is then a function of its own features and its neighbours’ features, learned end to end.
Neither framing is universally correct. The right choice depends on where the predictive signal actually lives, how many labelled samples you have, what hardware you can commit, and how the model must be deployed and explained. The rest of this page turns those factors into a repeatable decision.
A Decision Framework
Before writing model code, score your problem on five axes. Each axis nudges you toward tabular boosting or relational message passing, and the aggregate points to a default.
- Data structure. Is the label predictable from a pixel’s own bands (route to boosting), or does it hinge on the arrangement of segments and their neighbours (route to a GNN)?
- Spatial autocorrelation. Weak-to-moderate autocorrelation is easily captured by a handful of engineered lag features feeding a tree. Strong, structured autocorrelation across irregular regions is exactly what message passing exists to exploit.
- Sample efficiency. With a modest labelled budget, boosting is far more sample-efficient. GNNs are data-hungry and overfit small graphs unless heavily regularised.
- Hardware and cost. Boosting trains on a CPU in minutes. A non-trivial GNN effectively requires a GPU. If your training and inference targets are CPU-only, that alone can settle the question.
- Interpretability and deployment. Boosting yields feature importances and SHAP values and exports to a single portable artefact. A GNN offers attention or gradient attributions but ships a heavier, graph-dependent inference pipeline.
The guiding principle: default to the strong tabular baseline and make the GNN earn its place with a measured accuracy gain under honest evaluation. A GNN that beats a tuned XGBoost model by one point of macro-F1 but needs a GPU, a segmentation stage, and a graph-construction step at inference time is rarely worth it. A GNN that captures relational structure boosting simply cannot see — and gains ten points — is a clear win.
Step-by-Step Comparison
Data requirements: tabular rows vs a constructed graph
Gradient boosting wants a clean feature matrix. For raster classification you sample labelled pixels and stack per-pixel predictors: raw bands, spectral indices such as NDVI, texture measures, and optionally engineered neighbourhood statistics. Every row is independent; missing values are handled natively; adding a feature is a column append.
A GNN wants a graph. You must first segment the raster (for example with SLIC or a watershed), summarise each segment into a node feature vector, and derive edges from shared borders or proximity. That construction step is non-trivial and is a frequent source of subtle bugs — the dedicated walkthrough on building adjacency graphs from GeoDataFrames for GNNs covers it end to end. The payoff is that the graph encodes topology the tabular table discards.
How each handles spatial autocorrelation
This is the crux. Trees are spatially blind: two pixels with identical spectra get identical predictions regardless of location. You can inject spatial awareness by engineering features — spatial lag, focal means, distance-to-feature — as described in handling spatial autocorrelation. Done well, this closes much of the gap for moderate autocorrelation.
A GNN consumes autocorrelation as a first-class citizen. Each message-passing layer averages or attends over neighbouring nodes, so the model learns how far and how strongly to smooth, rather than you hand-specifying a fixed lag radius. When the class boundary follows region topology — think a floodplain whose extent depends on connectivity to a river network — this is decisive.
Critically, both models still require spatial cross-validation. Autocorrelation inflates random-split scores for trees and GNNs alike; only geographically blocked folds reveal true generalisation.
Sample efficiency
Gradient boosting is remarkably sample-efficient. A few thousand well-chosen labelled pixels can train a competitive classifier, and performance degrades gracefully as labels thin out. GNNs sit at the opposite end: message passing has many parameters relative to the number of graph nodes, and small graphs overfit quickly. If your labelled budget is measured in hundreds or low thousands of segments, boosting almost always wins on the same data.
Training cost and hardware
XGBoost and LightGBM are engineered for CPU throughput; histogram-based split finding trains on millions of rows in minutes without a GPU. A GNN’s message passing is dense linear algebra over the whole graph per epoch, and dozens to hundreds of epochs on a large graph are painfully slow on CPU. Budget a GPU for any serious GNN. This single constraint eliminates GNNs for many teams whose inference runs on commodity or edge hardware.
Interpretability
Boosting offers mature, trusted explanations: gain-based feature importance, permutation importance, and SHAP values that attribute each prediction to specific bands or indices. Regulators and domain scientists accept these readily. GNN interpretability is younger — attention weights and gradient-based node/edge attributions exist but are harder to communicate and validate. If your deliverable must justify why a pixel was classed as cropland, boosting is the safer default.
Inference and deployment complexity
A trained boosting model is a single portable artefact. Export it to ONNX and it runs anywhere with a few megabytes of dependencies. A GNN inference path must reproduce the entire pipeline: segment the incoming raster, rebuild node features, construct the adjacency graph, then run the network — every step a potential point of failure and version drift. That operational weight matters as much as accuracy for production systems.
When each wins
Boosting wins on tabular per-pixel problems with modest labels, CPU-only budgets, and strict interpretability or deployment constraints. GNNs win when the label genuinely depends on relational structure among segments, you have a GPU and enough labelled regions, and the accuracy gain survives spatial cross-validation. When you are unsure, build both — they share the same labels and the same evaluation harness.
Head-to-head comparison
| Criterion | Gradient Boosting (XGBoost / LightGBM) | Graph Neural Network (torch-geometric) |
|---|---|---|
| Natural data unit | Per-pixel tabular rows | Segments / regions as graph nodes |
| Spatial autocorrelation | Ignored unless engineered as features | Modelled directly via message passing |
| Sample efficiency | High — competitive on thousands of labels | Low — needs many labelled nodes |
| Training hardware | CPU, minutes | GPU strongly recommended |
| Feature engineering burden | Moderate (indices, spatial lags) | High (segmentation + graph construction) |
| Interpretability | Mature: importance, SHAP | Emerging: attention, gradient attribution |
| Inference footprint | Single portable artefact (ONNX-friendly) | Multi-stage graph pipeline |
| Best fit | Small-to-mid labelled, CPU, explainable | Strong relational signal, GPU available |
Building the Two Baselines
Fair comparison means both models see the same labels and the same spatial folds. Below are minimal, runnable sketches for each.
XGBoost baseline on per-pixel features
This trains a multiclass land-cover classifier on a sampled pixel table. In practice you would extract these features from a raster stack; here the extraction is abstracted behind load_pixel_table.
import numpy as np
import xgboost as xgb
from sklearn.metrics import f1_score
from sklearn.model_selection import GroupKFold
def load_pixel_table():
"""Return per-pixel features, integer labels, and spatial block IDs.
Replace with real extraction from a raster stack. Each row is one
labelled pixel; `blocks` groups pixels into spatial folds so that
train and validation never share a block.
"""
rng = np.random.default_rng(42)
n = 20_000
X = rng.normal(size=(n, 8)).astype(np.float32) # 8 bands / indices
y = rng.integers(0, 5, size=n) # 5 land-cover classes
blocks = rng.integers(0, 10, size=n) # 10 spatial blocks
return X, y, blocks
X, y, blocks = load_pixel_table()
assert X.ndim == 2 and len(X) == len(y) == len(blocks), "shape mismatch"
assert not np.isnan(X).any(), "features contain NaN — impute before training"
model = xgb.XGBClassifier(
n_estimators=400,
max_depth=6,
learning_rate=0.05,
subsample=0.8,
colsample_bytree=0.8,
objective="multi:softprob",
num_class=len(np.unique(y)),
tree_method="hist", # fast CPU histogram method
n_jobs=-1,
random_state=42,
)
gkf = GroupKFold(n_splits=5)
f1_scores = []
for train_idx, val_idx in gkf.split(X, y, groups=blocks):
model.fit(X[train_idx], y[train_idx])
pred = model.predict(X[val_idx])
f1_scores.append(f1_score(y[val_idx], pred, average="macro"))
print(f"XGBoost spatial CV macro-F1: {np.mean(f1_scores):.3f} ± {np.std(f1_scores):.3f}")The whole run finishes in seconds to minutes on a laptop CPU. For tuning depth, learning rate, and regularisation on geospatial targets, see hyperparameter tuning for XGBoost on geodata.
Minimal torch-geometric GNN on a region-adjacency graph
The GNN classifies segments rather than raw pixels. Each node carries a feature vector summarising its segment; edges connect segments that share a border. A two-layer graph convolution learns to blend each node with its neighbourhood before the final classifier.
import torch
import torch.nn.functional as F
from torch_geometric.nn import GCNConv
from torch_geometric.data import Data
def build_region_graph():
"""Return a torch-geometric Data object for a region-adjacency graph.
node_features: [num_nodes, num_feats] per-segment summaries
edge_index: [2, num_edges] shared-border adjacency (both directions)
y: [num_nodes] integer class labels
train/val_mask: boolean node masks from spatially separated blocks
"""
num_nodes, num_feats, num_classes = 1_200, 8, 5
g = torch.Generator().manual_seed(42)
x = torch.randn(num_nodes, num_feats, generator=g)
# random symmetric adjacency stand-in for a real RAG
src = torch.randint(0, num_nodes, (4_000,), generator=g)
dst = torch.randint(0, num_nodes, (4_000,), generator=g)
edge_index = torch.cat([torch.stack([src, dst]),
torch.stack([dst, src])], dim=1)
y = torch.randint(0, num_classes, (num_nodes,), generator=g)
# spatial split: nodes 0..899 train, 900..1199 validation (disjoint blocks)
train_mask = torch.zeros(num_nodes, dtype=torch.bool)
val_mask = torch.zeros(num_nodes, dtype=torch.bool)
train_mask[:900] = True
val_mask[900:] = True
return Data(x=x, edge_index=edge_index, y=y,
train_mask=train_mask, val_mask=val_mask), num_feats, num_classes
class RAGClassifier(torch.nn.Module):
def __init__(self, in_feats: int, hidden: int, num_classes: int) -> None:
super().__init__()
self.conv1 = GCNConv(in_feats, hidden)
self.conv2 = GCNConv(hidden, num_classes)
def forward(self, x, edge_index):
x = F.relu(self.conv1(x, edge_index))
x = F.dropout(x, p=0.5, training=self.training)
return self.conv2(x, edge_index)
data, n_feats, n_classes = build_region_graph()
device = "cuda" if torch.cuda.is_available() else "cpu"
data = data.to(device)
gnn = RAGClassifier(n_feats, hidden=32, num_classes=n_classes).to(device)
opt = torch.optim.Adam(gnn.parameters(), lr=0.01, weight_decay=5e-4)
for epoch in range(200):
gnn.train()
opt.zero_grad()
out = gnn(data.x, data.edge_index)
loss = F.cross_entropy(out[data.train_mask], data.y[data.train_mask])
loss.backward()
opt.step()
gnn.eval()
with torch.no_grad():
pred = gnn(data.x, data.edge_index).argmax(dim=1)
val_acc = (pred[data.val_mask] == data.y[data.val_mask]).float().mean()
print(f"GNN validation accuracy on held-out blocks: {val_acc:.3f}")Note the device line: the same code runs on CPU but will be slow for larger graphs. Deeper coverage of message-passing layers and architectures lives in graph neural networks for spatial data.
Verification & Testing
The only fair way to compare the two is under identical, spatially honest evaluation. Both baselines above already use block-based splits; the rule is that no spatial block may appear in both training and validation for either model. If you compare an XGBoost model tuned under random k-fold against a GNN tuned under spatial folds, the boosting score is inflated and the comparison is meaningless.
Wrap both models in one harness that consumes the same block IDs:
import numpy as np
def compare_under_spatial_cv(xgb_scores, gnn_scores) -> None:
"""Report mean, spread, and the honest winner under spatial CV.
Both inputs are per-fold macro-F1 arrays computed on the SAME
geographically blocked folds so the comparison is apples to apples.
"""
xgb_scores = np.asarray(xgb_scores)
gnn_scores = np.asarray(gnn_scores)
assert len(xgb_scores) == len(gnn_scores), "fold counts differ — not comparable"
diff = gnn_scores.mean() - xgb_scores.mean()
print(f"XGBoost: {xgb_scores.mean():.3f} ± {xgb_scores.std():.3f}")
print(f"GNN: {gnn_scores.mean():.3f} ± {gnn_scores.std():.3f}")
print(f"GNN - XGB gap: {diff:+.3f}")
if diff <= 0.01:
print("Verdict: gap within noise — prefer the simpler XGBoost pipeline.")
else:
print("Verdict: GNN gain is real — weigh it against its GPU and pipeline cost.")A one-point gap that sits inside the fold-to-fold standard deviation is not a real gain. Insist that the GNN clears the boosting baseline by more than the noise floor before you accept its operational cost. For the full methodology of spatially blocked folds and per-region metrics, see spatial cross-validation strategies.
Troubleshooting & Common Errors
The GNN scores worse than XGBoost despite more compute
Cause: Too few labelled nodes, or an adjacency graph that encodes no useful structure (for example random or over-connected edges).
Fix: Confirm the graph reflects real topology, add labelled segments, and reduce model depth. If a two-layer GCN cannot beat boosting, the relational signal probably is not there — keep the tabular model.
XGBoost model looks excellent but collapses on new regions
Cause: It was validated with random k-fold, so autocorrelated neighbours leaked between train and validation.
Fix: Re-evaluate with GroupKFold on spatial blocks. Expect the honest score to drop; that drop is the leakage you were previously hiding.
CUDA out of memory while training the GNN
Cause: The full graph and its intermediate activations exceed GPU memory during full-batch message passing.
Fix: Switch to neighbour sampling with torch_geometric.loader.NeighborLoader to train in mini-batches, reduce the hidden dimension, or coarsen the segmentation to fewer, larger regions.
Class imbalance wrecks macro-F1 for both models
Cause: Land-cover classes are naturally skewed; rare classes get ignored by a metric-blind objective.
Fix: Apply class weights (scale_pos_weight or sample weights for boosting, a weighted cross_entropy for the GNN). The dedicated guide on handling class imbalance in land-cover classification covers resampling and threshold tuning.
GNN predictions are over-smoothed and blur class boundaries
Cause: Too many message-passing layers average neighbourhoods until every node looks alike.
Fix: Cut to two or three layers, add residual connections, or reduce edge density so boundaries survive propagation.
Feature scales differ wildly between bands
Cause: Raw reflectance, indices, and texture measures live on different ranges. Trees tolerate this; a GNN’s gradient descent does not.
Fix: Standardise node features before training the GNN. Boosting can skip scaling, but consistent preprocessing keeps the comparison clean.
Performance and Scalability
Gradient boosting scales along rows: histogram methods (tree_method="hist") train on tens of millions of pixels on a single CPU node, and prediction is embarrassingly parallel across raster tiles. Memory grows with the feature matrix, so you stream tiles rather than loading the whole scene. Mapping tile predictions back to a georeferenced grid is a solved problem covered in mapping XGBoost predictions back to a raster grid.
GNNs scale along the graph. Full-batch training holds the entire adjacency and all node activations in GPU memory, which caps the graph size you can fit. Beyond that ceiling you move to neighbour sampling or graph partitioning, both of which add engineering complexity and can perturb accuracy. Inference also stays graph-bound: you cannot classify one segment without its neighbourhood, so streaming is harder than boosting’s independent tiles.
The practical implication for large scenes: boosting parallelises across space almost for free, while a GNN’s relational dependency is exactly what makes it powerful and what makes it harder to scale and serve. Factor that asymmetry into the decision as heavily as raw accuracy.
FAQ
Is a graph neural network always more accurate than XGBoost for land cover?
No. On per-pixel tabular features with a few thousand to a few hundred thousand labelled points, a well-tuned XGBoost or LightGBM model usually matches or beats a GNN while training in minutes on a CPU. GNNs pull ahead only when the label depends on relational structure between segments or regions that tree features cannot encode.
Do I need a GPU to train a graph neural network on raster data?
For anything beyond a toy graph, yes. Message passing over tens of thousands of nodes with multiple layers is far faster on a GPU. Gradient boosting trains comfortably on a CPU, which is often the deciding factor when GPU access is limited or inference must run on commodity hardware.
How do the two model families handle spatial autocorrelation?
Gradient boosting is blind to space unless you engineer neighbourhood features such as spatial lag or focal statistics. A GNN consumes autocorrelation directly through message passing across adjacency edges. Both must still be evaluated with spatial cross-validation, because autocorrelation inflates random-split scores for either model.
Can I combine gradient boosting and a graph neural network?
Yes. A common hybrid runs gradient boosting on per-pixel or per-segment features, then feeds its class probabilities as node attributes into a GNN that smooths predictions across adjacency. This captures both sharp spectral signal and relational context, at the cost of a two-stage pipeline.
Related
- Gradient Boosting for Raster Data — the full XGBoost/LightGBM workflow for per-pixel classification
- Graph Neural Networks for Spatial Data — architectures and message passing for geospatial graphs
- Building Adjacency Graphs from GeoDataFrames for GNNs — turning segments into nodes and shared borders into edges
- Handling Spatial Autocorrelation — engineering spatial signal that boosting can consume
- Spatial Cross-Validation Strategies — the blocked-fold evaluation both models must pass