Training a Random Forest Point Classifier

TL;DR: Sample up to a fixed number of points per class from every labelled tile into one table with a tile column, evaluate with GroupKFold so each fold holds out whole tiles, tune only max_depth, min_samples_leaf and max_features, then refit on all tiles and save a joblib bundle containing the model, the ordered feature list, the class list and the cross-validated scores.

# Context and Motivation

This guide is part of Machine Learning Point Classification for LiDAR. A random forest is the pragmatic default for point classification: it trains quickly on hundreds of thousands of rows, handles features on different scales without normalization, copes with correlated features, and exposes feature importances you can reason about. What decides whether it works in production is not the algorithm but three pieces of discipline around it — how the training set is sampled, how the model is evaluated, and how it is packaged so that prediction uses exactly the features training used.

The procedure below assumes features have already been computed and cached per tile, as in computing geometric features for classification.

Folds made of whole tiles A grid of five folds by ten tiles. In each fold, two different tiles are shaded as the test set and the remaining eight are training. No tile appears in more than one fold's test set, and no tile is split between training and testing within a fold. fold 1fold 2fold 3fold 4fold 5 ten labelled tiles; shaded = held out for testing in that fold

# Prerequisites and Assumptions

  • Feature-enriched LAZ files for labelled tiles, each with trustworthy Classification.
  • Python with scikit-learn 1.3+, pandas, NumPy, joblib and PDAL bindings.
  • At least eight to ten labelled tiles from different parts of the project, so grouped cross-validation has something to hold out.

# Step-by-Step Implementation

# Step 1 — Sample a balanced table from every tile

Read each tile, keep above-ground classes, and sample up to N points per class per tile. Sampling per tile keeps every landscape represented; capping per class keeps rare classes visible.

# Step 2 — Set up grouped cross-validation

GroupKFold(n_splits=5) with the tile column as groups guarantees that no tile contributes to both training and testing within a fold.

# Step 3 — Search a small parameter grid

Forests are forgiving. Tuning max_depth, min_samples_leaf and max_features over a dozen combinations captures nearly all the available gain; more search mostly fits noise in the validation scores.

# Step 4 — Score with a class-balanced metric

Use macro-averaged F1 as the selection metric, so a model that ignores wires cannot win by being good at vegetation.

# Step 5 — Refit on all tiles and save a bundle

Refit the best configuration on every labelled tile and save the model with its feature order, class list, parameters and cross-validated scores.

# Complete Working Example

python
"""Balanced sampling, grouped CV and a versioned model bundle."""
from __future__ import annotations

import json
from datetime import date
from pathlib import Path

import joblib
import numpy as np
import pandas as pd
import pdal
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import GridSearchCV, GroupKFold

FEATURES = ["HeightAboveGround", "ReturnRatio", "NormalZ", "Curvature",
            "Linearity", "Planarity", "Scattering", "Verticality",
            "Linearity_s", "Planarity_s", "Scattering_s", "Verticality_s"]
CLASSES = [3, 4, 5, 6, 9, 14, 17]


def sample_tile(path: Path, per_class: int, rng: np.random.Generator) -> pd.DataFrame:
    p = pdal.Pipeline(json.dumps({"pipeline": [str(path)]}))
    p.execute()
    a = p.arrays[0]
    df = pd.DataFrame({k: a[k] for k in FEATURES})
    df["y"] = a["Classification"]
    df = df[df.y.isin(CLASSES)]
    parts = [g.sample(min(len(g), per_class), random_state=int(rng.integers(1e9)))
             for _, g in df.groupby("y")]
    out = pd.concat(parts)
    out["tile"] = path.stem
    return out


def build_table(tiles: list[Path], per_class: int = 15_000) -> pd.DataFrame:
    rng = np.random.default_rng(42)
    table = pd.concat([sample_tile(t, per_class, rng) for t in tiles], ignore_index=True)
    print(table.groupby("y").size().rename("rows"))
    return table


def train(table: pd.DataFrame, out: Path) -> dict:
    X, y, groups = table[FEATURES], table["y"], table["tile"]
    grid = GridSearchCV(
        RandomForestClassifier(n_estimators=200, class_weight="balanced_subsample",
                               n_jobs=-1, random_state=0),
        param_grid={"max_depth": [14, 18, 24],
                    "min_samples_leaf": [2, 5, 10],
                    "max_features": ["sqrt", 0.5]},
        scoring="f1_macro", cv=GroupKFold(n_splits=5), n_jobs=1, refit=True, verbose=1,
    )
    grid.fit(X, y, groups=groups)
    bundle = {
        "model": grid.best_estimator_,
        "features": FEATURES,
        "classes": CLASSES,
        "params": grid.best_params_,
        "cv_f1_macro": float(grid.best_score_),
        "tiles": sorted(groups.unique()),
        "trained": date.today().isoformat(),
    }
    joblib.dump(bundle, out, compress=3)
    print(f"best {grid.best_params_}  grouped-CV macro F1 {grid.best_score_:.3f}")
    return bundle


if __name__ == "__main__":
    tiles = sorted(Path("features").glob("*.laz"))
    train(build_table(tiles), Path("models/rf_2026-09.joblib"))

refit=True means best_estimator_ is already fitted on all rows after the search, so no separate final fit is needed.

Forests have a wide plateau A heat grid of grouped cross-validated macro F1 over max_depth rows 14, 18 and 24 and min_samples_leaf columns 2, 5 and 10. Scores range narrowly from 0.842 to 0.861. The best cell, depth 18 with leaf 5, is outlined. The spread across the whole grid is under two points of F1. depth 14depth 18depth 24leaf 2leaf 5leaf 10 0.8530.8560.8490.8550.8610.8570.8420.8520.855 best, but only 0.019 above worst

# Key Parameter Table

Parameter Type Search values Effect
n_estimators int fixed 200 More trees stabilize predictions; rarely worth tuning
max_depth int 14, 18, 24 Depth limits memorization of neighbourhood quirks
min_samples_leaf int 2, 5, 10 Larger leaves regularize against label noise
max_features str/float sqrt, 0.5 Features tried per split; more is slower, sometimes better
class_weight str balanced_subsample Keeps rare classes visible within each tree
per-class sample int 15,000 per tile Caps dominant classes; raise if rare classes lack examples

# Verification

  • Grouped score versus random-split score. Run one random KFold for comparison. A gap of five or more points of macro F1 shows how much a naive evaluation would have overstated performance.
  • Per-fold spread. Look at grid.cv_results_ for the best configuration: a fold far below the others is a tile unlike the rest, and a sign you need more labelled data from that kind of landscape.
  • Bundle round trip. Load the saved bundle and predict on a few rows to make sure the feature order is honoured.
python
bundle = joblib.load("models/rf_2026-09.joblib")
row = table[bundle["features"]].head(5)
assert list(row.columns) == bundle["features"]
print(bundle["model"].predict(row), bundle["cv_f1_macro"], bundle["params"])

# Gotchas and Edge Cases

Class present in only one tile. Grouped CV will hold that tile out in one fold, leaving the class absent from training there and producing a very low score for it. That is honest — it says the model cannot generalize that class — but it means you need labels for the class in more tiles.

Label noise. Vendor classifications used as labels contain errors, especially low versus medium vegetation. A larger min_samples_leaf helps the forest average over them; do not chase perfect training accuracy.

Model size. Deep forests on many rows produce joblib files of hundreds of megabytes. compress=3 helps; so does capping depth. Check the size before shipping the model into a container image.

Depth drives model size Bars of compressed model size for 200 trees at three depths: about 40 megabytes at depth 14, 110 at depth 18, and 380 at depth 24, while grouped F1 barely changes. A note suggests choosing the shallowest depth on the score plateau. 40 MB 110 MB 380 MB depth 14 depth 18 depth 24

Data leakage through overlap. Two tiles that share a flightline overlap strip contain near-identical points. Group by a spatial block larger than a tile if your tiling has overlap, or remove overlap points before sampling.

# Frequently Asked Questions

Why use GroupKFold instead of ordinary cross-validation?

Neighbouring LiDAR points are nearly identical, so an ordinary split puts near-duplicates on both sides and measures memory rather than generalization. Grouping by tile ensures each test fold contains only areas the model has never seen.

How many trees should the forest have?

Two hundred is a sensible default. Beyond that, predictions barely change while prediction time and model size grow linearly. Tune depth and leaf size instead.

Should I scale or normalize features for a random forest?

No. Trees split on thresholds, so monotonic rescaling does not change them. Consistency matters more than scale: the prediction pipeline must compute features exactly as training did.

What should go into the saved model bundle?

The fitted model, the ordered feature list, the class list, the chosen parameters, the cross-validated score, the training tiles and a date. Those are what you need to reproduce a prediction or explain a result months later.