Computing RMSE for Control Point Alignment in Python

To measure an alignment honestly, split the surveyed points into a control subset used for the fit and a check subset the fit never sees, transform the check points, and report the maximum residual and its index alongside the root-mean-square error. An RMSE computed on the points that were fitted is a measure of the optimiser, not of the transform. This page is part of Scale and Rotation Synchronization.

Why the Split Matters More Than the Statistic

A similarity transform in the plane has four unknowns and in three-space seven. Given exactly the minimum number of points, the solve reproduces them exactly and the residual is zero — whether or not the correspondences were right, whether or not one point was mislabelled, whether or not the two datasets are on the same datum at all.

Control points and the check points held back from the fit One site with two point sets. The control points are used to solve the transform; the check points are excluded from it and used only to measure. A residual on the control points reports how well the optimiser minimised what it was minimising; only the check points measure whether the transform predicts. Easting (m) Northing (m) control check With the minimum number of points a fit is exact on them, whatever it does elsewhere.

Adding points reduces that effect without removing it. The solver is still minimising the residual on those points, so their residual remains the quantity that was optimised rather than an independent test. Only a point excluded from the fit measures prediction.

The second half of the argument is about which statistic to act on. The root-mean-square error describes the bulk and is the number usually quoted. The maximum describes the worst case and is the number that identifies problems, because the characteristic failure in this domain is not general imprecision but a single mismatched point — the same monument identified as two different points in the two datasets. A set with an RMSE of 0.03 m and a maximum of 0.42 m is not a slightly imprecise transform; it is a good transform with one bad correspondence, and the average conceals exactly the observation that finds it.

Production-Ready Script

# numpy>=1.24, Python 3.9+
from __future__ import annotations

from dataclasses import dataclass
import numpy as np


@dataclass(frozen=True)
class ResidualReport:
    n_control: int
    n_check: int
    rmse: float
    mean: float
    p95: float
    maximum: float
    worst_index: int
    passed: bool
    tolerance: float

    def summary(self) -> str:
        verdict = "PASS" if self.passed else "FAIL"
        return (f"{verdict}  rmse={self.rmse:.4f} m  max={self.maximum:.4f} m "
                f"(check point {self.worst_index})  tol={self.tolerance:.3f} m")


def split_control_check(points: np.ndarray, *, holdout: float = 0.34, seed: int = 0):
    """Deterministic split — the same points every run, so results compare."""
    n = len(points)
    rng = np.random.default_rng(seed)
    idx = rng.permutation(n)
    n_check = max(2, int(round(n * holdout)))
    if n - n_check < 3:
        raise ValueError(f"{n} points is too few to both fit and check")
    return idx[n_check:], idx[:n_check]        # control, check


def residuals(transformed: np.ndarray, surveyed: np.ndarray) -> np.ndarray:
    a = np.asarray(transformed, dtype=float)
    b = np.asarray(surveyed, dtype=float)
    if a.shape != b.shape:
        raise ValueError(f"shape mismatch {a.shape} vs {b.shape}")
    return np.linalg.norm(a - b, axis=1)


def report(
    transformed_check: np.ndarray,
    surveyed_check: np.ndarray,
    *,
    n_control: int,
    tolerance_m: float,
) -> ResidualReport:
    d = residuals(transformed_check, surveyed_check)
    worst = int(d.argmax())
    return ResidualReport(
        n_control=n_control,
        n_check=int(d.size),
        rmse=float(np.sqrt((d ** 2).mean())),
        mean=float(d.mean()),
        p95=float(np.percentile(d, 95)),
        maximum=float(d[worst]),
        worst_index=worst,
        # Both statistics have to clear: the bulk AND the worst case.
        passed=bool(np.sqrt((d ** 2).mean()) <= tolerance_m and d[worst] <= tolerance_m * 2),
        tolerance=tolerance_m,
    )


if __name__ == "__main__":
    surveyed = np.array([[...]])          # (n, 2) or (n, 3) surveyed positions
    local = np.array([[...]])             # the same points in the source frame
    control_idx, check_idx = split_control_check(surveyed)
    # fit(...) on local[control_idx] -> surveyed[control_idx], then:
    # moved = apply(fit, local[check_idx])
    # print(report(moved, surveyed[check_idx],
    #              n_control=len(control_idx), tolerance_m=0.05).summary())
Why the maximum is reported alongside the mean Residuals on seven check points from one fit. Six sit at a few centimetres and one at forty; the root-mean-square error over all seven is comfortably inside a survey tolerance and conceals exactly the observation that identifies the problem. The characteristic failure here is one mismatched correspondence, not general imprecision. check point 1 0.021 m check point 2 0.034 m check point 3 0.028 m check point 4 0.412 m check point 5 0.019 m check point 6 0.031 m 0 0.412 RMSE 0.17 m, maximum 0.41 m — one point, not a tolerance problem.

Key implementation notes:

  • The split is seeded, so two runs on the same data compare. An unseeded split makes every re-run a different measurement.
  • passed requires both the RMSE and the maximum to clear, with a wider allowance on the maximum. A single criterion on either alone lets through the failure the other catches.
  • worst_index is returned rather than just the value, because the index is what an investigation needs.
  • The split refuses to leave fewer than three points for the fit. A degenerate fit reports a beautiful residual.

Compatibility Matrix

Component Supported range Notes
numpy >=1.24 default_rng, linalg.norm
Input (n, 2) or (n, 3) planar or spatial; the code is dimension-agnostic
Minimum points 5 planar, 6 spatial three to fit plus two to check, at least
Tolerance per asset class 0.05 m survey-grade, 0.5 m mapping
Determinism seeded split required for run-to-run comparison

Fallback Strategies

1. Too few points to split. Fit on all of them and state plainly that the residual is not independent evidence. Do not report it as a validation.

What the shape of the residuals tells you Four residual patterns and the fault each one indicates. The distribution is more informative than any single statistic: one outlier is a correspondence problem, uniformity is systematic, growth with distance is scale, and a good fit with visibly wrong geometry is a reflection that no residual will reveal. Pattern Diagnosis Next step One large, rest small a mismatched correspondence refit without that point All large and similar datum, unit or vertical offset check the declarations Growing with distance scale error was scale a free parameter? Small, model still wrong reflection check det(R) The distribution diagnoses; the single number only passes or fails.

2. Maximum far above the RMSE. One mismatched correspondence. Inspect the point named by worst_index, refit without it, and see whether the maximum drops to the bulk level — if it does, the point was the problem.

3. Every residual is large and similar. Not a correspondence problem but a systematic one: a datum difference, a unit error or an unapplied vertical offset. The uniformity is the diagnosis.

4. Residuals grow with distance from the centroid. A scale error. Check whether the solve was allowed to fit scale, and whether it should have been.

5. The RMSE passes and the model is visibly wrong. Check for a mirrored transform — a reflection fits control points as well as a rotation does. That is the subject of the sibling guide on detecting mirrored transforms.

FAQ

Why is RMSE on the fitted points misleading?

Because the fit minimised exactly that quantity. With the minimum number of points the residual on them is zero regardless of whether the transform is right, and with a few more it is still the number the solver drove down. It measures how well the optimiser optimised, not how well the transform predicts. Only points excluded from the fit are evidence.

How many points should I hold back?

At least a third, and never fewer than two more than the minimum the solve requires. With three control points and no check points a planar solve is exactly determined and reports a perfect fit on anything. The holdout is what converts the fit from an assertion into a measurement.

What does a large maximum with a small RMSE mean?

Almost always one mismatched point — the same physical monument identified differently in the two datasets, or a transcription error in a point number. Report the index of the worst residual; that single number usually resolves the investigation in minutes.