Evaluating Point Classification with a Confusion Matrix

TL;DR: Align predicted and reference classifications point by point, build sklearn.metrics.confusion_matrix with an explicit class list, normalize each row by its reference count, and report per-class precision, recall, F1 and IoU plus Cohen’s kappa — never overall accuracy alone, which a large vegetation class will inflate.

# Context and Motivation

This guide is part of Machine Learning Point Classification for LiDAR. Whether the classes came from a model, from rules, or from a vendor, someone eventually asks how good they are. Overall accuracy is the number people reach for, and it is nearly useless on LiDAR: in a typical suburban tile, ground and high vegetation make up 80 percent or more of points, so a classifier that never finds a single building or wire can still report 90 percent accuracy.

The confusion matrix answers the real question — which classes are confused with which, and how often — and every useful summary metric can be read from it. The same procedure works for model evaluation on held-out tiles and for acceptance testing a vendor delivery against a manually checked reference area.

Reading a row-normalized confusion matrix A five by five grid with reference classes as rows and predicted classes as columns: ground, low vegetation, high vegetation, building and wire. The diagonal is dark with recalls of 0.99, 0.81, 0.97, 0.94 and 0.72. The largest off-diagonal cells are low vegetation predicted as ground at 0.14, and wire predicted as high vegetation at 0.25. Each row sums to one. predicted → groundlow veghigh vegbuildingwire groundlow veghigh vegbuildingwire 0.990.010.000.000.00 0.140.810.040.010.00 0.000.020.970.010.00 0.010.010.040.940.00 0.000.000.250.030.72 low veg lost to ground wires read as canopy

# Prerequisites and Assumptions

  • Two classifications of the same points: predicted and reference. Either two LAS files with identical point order, or one file with the reference stored in an extra dimension.
  • Python with scikit-learn, NumPy, pandas and PDAL bindings.
  • A reference you trust: a manually edited area, or a held-out tile whose labels passed QA.

# Step-by-Step Implementation

# Step 1 — Align the two classifications

If both files came from the same source tile and neither pipeline reordered points, compare arrays index by index after checking X, Y, Z and GpsTime match. Otherwise join on those coordinates.

# Step 2 — Fix the class list

Pass an explicit labels list to confusion_matrix. Without it, a class absent from one side silently disappears and the matrix changes shape between tiles.

# Step 3 — Row-normalize

Divide each row by its sum. Each diagonal cell is then that class’s recall, and each off-diagonal cell is the share of that reference class lost to another class.

# Step 4 — Compute per-class metrics

Precision (column-wise), recall (row-wise), F1 and intersection-over-union for every class, plus Cohen’s kappa for overall agreement beyond chance.

# Step 5 — Report and archive

Save the raw counts, not just the normalized matrix, so results can be pooled across tiles later.

# Complete Working Example

python
"""Confusion matrix and per-class metrics for a predicted vs reference classification."""
from __future__ import annotations

import json
from pathlib import Path

import numpy as np
import pandas as pd
import pdal
from sklearn.metrics import cohen_kappa_score, confusion_matrix

NAMES = {2: "ground", 3: "low veg", 4: "med veg", 5: "high veg", 6: "building",
         9: "water", 14: "wire", 17: "bridge"}


def read(path: Path) -> np.ndarray:
    p = pdal.Pipeline(json.dumps({"pipeline": [str(path)]}))
    p.execute()
    return p.arrays[0]


def aligned(pred_path: Path, ref_path: Path) -> tuple[np.ndarray, np.ndarray]:
    pred, ref = read(pred_path), read(ref_path)
    if len(pred) != len(ref):
        raise ValueError(f"point counts differ: {len(pred)} vs {len(ref)}")
    for dim in ("X", "Y", "Z", "GpsTime"):
        if not np.array_equal(pred[dim], ref[dim]):
            raise ValueError(f"point order differs on {dim}; join on coordinates instead")
    return pred["Classification"], ref["Classification"]


def report(y_pred: np.ndarray, y_ref: np.ndarray, classes: list[int]) -> pd.DataFrame:
    keep = np.isin(y_ref, classes)
    cm = confusion_matrix(y_ref[keep], y_pred[keep], labels=classes)
    tp = np.diag(cm).astype(float)
    ref_n, pred_n = cm.sum(axis=1), cm.sum(axis=0)
    recall = np.divide(tp, ref_n, out=np.zeros_like(tp), where=ref_n > 0)
    precision = np.divide(tp, pred_n, out=np.zeros_like(tp), where=pred_n > 0)
    f1 = np.divide(2 * precision * recall, precision + recall,
                   out=np.zeros_like(tp), where=(precision + recall) > 0)
    iou = np.divide(tp, ref_n + pred_n - tp, out=np.zeros_like(tp), where=(ref_n + pred_n) > 0)
    table = pd.DataFrame({"class": [NAMES.get(c, c) for c in classes], "reference_pts": ref_n,
                          "precision": precision, "recall": recall, "f1": f1, "iou": iou}).round(3)
    kappa = cohen_kappa_score(y_ref[keep], y_pred[keep], labels=classes)
    overall = tp.sum() / cm.sum()
    print(f"overall accuracy {overall:.3f}  kappa {kappa:.3f}  macro F1 {f1.mean():.3f}")
    norm = pd.DataFrame(cm / np.maximum(ref_n[:, None], 1),
                        index=[NAMES.get(c, c) for c in classes],
                        columns=[NAMES.get(c, c) for c in classes]).round(2)
    print(norm.to_string())
    np.save("confusion_counts.npy", cm)
    return table


if __name__ == "__main__":
    y_pred, y_ref = aligned(Path("out/tile_6021_4402.laz"), Path("reference/tile_6021_4402.laz"))
    print(report(y_pred, y_ref, [2, 3, 5, 6, 14]).to_string(index=False))

# Key Parameter Table

Metric Formula Reads as Watch for
Recall TP / reference count Share of the class found Missed buildings, missed wires
Precision TP / predicted count Share of predictions that are right False buildings in canopy
F1 2PR / (P + R) Balance of the two Single number per class
IoU TP / (ref + pred − TP) Overlap of sets Stricter than F1; common in ML papers
Cohen’s kappa agreement beyond chance Overall, class-mix aware Better than accuracy for imbalanced classes
Overall accuracy trace / total Share of all points right Dominated by the largest class
Why overall accuracy hides failures Paired bars for two classifiers. Classifier A has overall accuracy 0.95 and wire recall 0.72. Classifier B has overall accuracy 0.94 and wire recall 0.05, having essentially stopped detecting wires, yet its overall accuracy is almost identical because wires are a tiny share of points. 0.950.720.940.05 classifier A classifier B overall accuracy wire recall

# Verification

  • Row sums. Each row of the normalized matrix sums to 1 (within rounding) for every class present in the reference.
  • Counts add up. The sum of the raw matrix equals the number of reference points in the listed classes.
  • Reference quality. Spot-check twenty disagreement points in a viewer. If the reference is wrong in a meaningful share of them, your metrics are measuring the reference, not the classifier.
python
cm = np.load("confusion_counts.npy")
norm = cm / cm.sum(axis=1, keepdims=True)
assert np.allclose(norm.sum(axis=1)[cm.sum(axis=1) > 0], 1.0)

# Gotchas and Edge Cases

Class 1 in the reference. Unclassified points in the reference have no true class. Exclude them from evaluation, or you will penalize the classifier for labelling points the reference never did.

Pooling across tiles. Average per-tile metrics weight a sparse rural tile as much as a dense urban one. Sum the raw count matrices across tiles and compute metrics once from the total.

Pool the counts, then compute Two tiles with building recall. Tile A has 40 reference building points of which 20 are found, recall 0.50. Tile B has 4,000 of which 3,800 are found, recall 0.95. Averaging the two recalls gives 0.73. Pooling the counts gives 3,820 of 4,040, recall 0.95, which reflects where the buildings actually are. tile A: 20 of 40 recall 0.50 tile B: 3,800 of 4,000 recall 0.95 mean of per-tile recalls: 0.73 a 40-point tile outweighs 4,000 points pooled counts: 3,820 of 4,040 = 0.95 every reference point weighs the same

Boundary points. Most disagreement concentrates at object edges — eaves, crown fringes — where even two human editors disagree. Consider reporting metrics with a one-point buffer around class boundaries excluded, alongside the full numbers.

Point order changed. A pipeline with a sort or a merge reorders points, and index-wise comparison becomes meaningless. The aligned function checks coordinates for exactly this reason.

# Frequently Asked Questions

Why is overall accuracy misleading for LiDAR classification?

Because class sizes are extremely unequal. Ground and vegetation dominate the point count, so a classifier can miss every wire and most buildings and still score above 90 percent. Per-class recall and precision show those failures immediately.

Should I normalize the confusion matrix by rows or columns?

By rows to read recall — what share of each true class was found and where the rest went. By columns to read precision — what share of each predicted class is correct. Keep the raw counts as well so both can be derived.

What is a good F1 score for building classification?

On clean airborne data with a well-tuned classifier, building F1 above 0.9 is common. Wires and bridges usually score lower because they are small and rare. Compare against a baseline on the same data rather than a universal number.

How large should the reference area be?

Large enough that every class you report has several thousand reference points from several distinct objects, and taken from a location not used for training or tuning.