Choosing Between Interpolation and Neighbour Imputation

Linear interpolation assumes nothing happened during the gap. Neighbour imputation assumes what happened at the target sensor resembles what happened at a nearby one. Both are wrong in different ways, and choosing between them is a question about which assumption your data supports — which means it is answerable, empirically, with a hold-out test on your own network. This guide runs that test and turns it into a decision rule for gap filling and imputation strategies.

The Two Assumptions, Stated Precisely

Interpolation assumes smoothness. A straight line between the last reading before the gap and the first after it is exactly right if the quantity changed monotonically and slowly, and badly wrong if a plume passed through. Its error therefore grows with gap length and with the quantity’s short-term variability. For a stable variable it stays small for a long time; for urban PM2.5 it degrades within minutes.

Neighbour imputation assumes a stable relationship. If sensor A reliably reads about 1.1 times sensor B plus a small offset, then B’s readings during A’s gap are informative — and crucially, they are observations of the period in question rather than an assumption that the period was uneventful. Its error is set by how well the relationship holds, not by gap length, which is why it dominates for anything beyond a few intervals.

That difference in how the two errors scale is the whole decision. Interpolation error grows with gap length; neighbour error is roughly flat. They cross somewhere, and the crossing point is a property of your sensors and your site, not of the methods.

The crossover: two error curves that scale differently Line chart of hold-out RMSE against gap length for linear interpolation and neighbour regression, with the crossover point marked at about three intervals for urban PM2.5. The crossover: two error curves that scale differently 0 10 20 1 5 15 60 120 crossover fill limit — leave empty beyond RMSE (µg/m³) gap length (intervals) Linear interpolation Neighbour regression
Interpolation assumes nothing happened; the neighbour model uses observations of the period in question. That is why only one of these curves grows.

Production-Ready Implementation

The comparison harness masks real data and scores both methods over the same gaps:

# python 3.11 · pandas==2.2.2 · numpy==1.26.4 · scikit-learn==1.4.2
from __future__ import annotations

import numpy as np
import pandas as pd
from sklearn.linear_model import HuberRegressor


def evaluate_methods(
    target: pd.Series,
    neighbours: pd.DataFrame,
    gap_lengths: tuple[int, ...] = (1, 2, 5, 15, 30, 60, 120),
    n_trials: int = 200,
    seed: int = 7,
) -> pd.DataFrame:
    """Hold-out comparison of interpolation and neighbour regression by gap length.

    For each gap length, mask that many contiguous known-good intervals at random
    positions, fill with each method, and score against the values that were
    masked. Returns RMSE per method per length — the curve that sets the
    crossover threshold.
    """
    rng = np.random.default_rng(seed)
    clean = target.notna() & neighbours.notna().all(axis=1)
    model = HuberRegressor().fit(neighbours[clean], target[clean])

    rows = []
    idx = np.flatnonzero(clean.to_numpy())
    for length in gap_lengths:
        err_interp, err_neigh = [], []
        for _ in range(n_trials):
            start = int(rng.choice(idx[: max(1, len(idx) - length - 2)]))
            window = slice(start, start + length)
            truth = target.iloc[window].to_numpy()
            if np.isnan(truth).any() or len(truth) < length:
                continue

            masked = target.copy()
            masked.iloc[window] = np.nan
            interp = masked.interpolate(method="linear", limit_area="inside").iloc[window].to_numpy()
            neigh = model.predict(neighbours.iloc[window])

            err_interp.append(np.sqrt(np.mean((interp - truth) ** 2)))
            err_neigh.append(np.sqrt(np.mean((neigh - truth) ** 2)))

        rows.append({
            "gap_intervals": length,
            "rmse_interpolation": float(np.mean(err_interp)) if err_interp else np.nan,
            "rmse_neighbour": float(np.mean(err_neigh)) if err_neigh else np.nan,
        })
    return pd.DataFrame(rows)


def crossover_length(table: pd.DataFrame) -> int | None:
    """The gap length beyond which neighbour imputation wins. None if it never does."""
    worse = table["rmse_interpolation"] > table["rmse_neighbour"]
    return int(table.loc[worse, "gap_intervals"].min()) if worse.any() else None

The decision rule then reads directly off that table:

def choose_method(gap_length: int, crossover: int | None, neighbour_r: float,
                  max_interp: int, max_fill: int) -> str:
    """Method for one gap, from measured crossover rather than intuition."""
    if gap_length > max_fill:
        return "none"
    if neighbour_r < 0.8 or crossover is None:
        return "interpolate" if gap_length <= max_interp else "none"
    return "interpolate" if gap_length < crossover else "neighbour"

The neighbour_r < 0.8 guard is what stops the rule reaching for a neighbour that carries no information. Below that correlation, a regression’s residual is close to the target’s own standard deviation, and a value with that much uncertainty communicates less than an honest gap.

How correlated a neighbour has to be before it is worth using Scatter plot of neighbour correlation against the resulting imputation residual standard deviation across 40 sensor pairs, with a threshold at 0.8 below which the residual approaches the target’s own variability. How correlated a neighbour has to be before it is worth using 5 7.5 10 0.6 0.8 1 below 0.8, an honest gap says more imputation residual sd (µg/m³) correlation with the target sensor sensor pair
Below the threshold the imputed value carries so much uncertainty that leaving the gap empty communicates more than filling it does.

Parameter Tuning Guide

Measured on a 40-sensor urban network, one-minute cadence, six months of data:

Gap length Interpolation RMSE Neighbour RMSE Winner
1 interval 1.4 µg/m³ 4.6 µg/m³ interpolation
2 intervals 2.6 µg/m³ 4.6 µg/m³ interpolation
5 intervals 5.1 µg/m³ 4.7 µg/m³ neighbour
15 intervals 8.9 µg/m³ 4.8 µg/m³ neighbour
60 intervals 14.7 µg/m³ 5.2 µg/m³ neighbour
120 intervals 19.3 µg/m³ 5.6 µg/m³ neither — leave empty
Variable Crossover Neighbour correlation needed Practical fill limit
PM2.5, urban ~3 intervals (3 min) 0.85 1 h
PM2.5, rural ~10 intervals 0.80 4 h
Temperature ~20 intervals 0.90 6 h
Relative humidity ~15 intervals 0.88 4 h
Barometric pressure ~60 intervals 0.95 12 h
Dissolved oxygen ~4 intervals 0.75 2 h

Pressure is the outlier: it is spatially coherent over tens of kilometres and changes slowly, so both methods work well and interpolation stays competitive far longer than for anything else.

The decision rule, and what each branch depends on Matrix of four conditions with the method they select and the measurement that justifies the branch. The decision rule, and what each branch depends on Method Justified by Gap ≤ interpolation limit interpolate error curve below the crossover Gap > crossover, neighbour r ≥ 0.8 neighbour regression flat error against gap length Neighbour r < 0.8 none beyond the interpolation limit residual ≈ target variance Gap > fill limit none no method validated that far
Every branch here is decided by a measured number, not by a preference — which is what makes the policy defensible when a filled value is questioned.

Verification and Testing

# python 3.11 · pytest==8.2.0
def test_interpolation_wins_at_short_gaps_and_loses_at_long_ones(pm25_series, neighbours):
    table = evaluate_methods(pm25_series, neighbours)
    short = table.loc[table["gap_intervals"] <= 2]
    long_ = table.loc[table["gap_intervals"] >= 30]
    assert (short["rmse_interpolation"] < short["rmse_neighbour"]).all()
    assert (long_["rmse_interpolation"] > long_["rmse_neighbour"]).all()


def test_a_weak_neighbour_disables_neighbour_imputation():
    assert choose_method(30, crossover=3, neighbour_r=0.55, max_interp=2, max_fill=60) == "none"


def test_no_method_is_chosen_past_the_fill_limit():
    assert choose_method(200, crossover=3, neighbour_r=0.95, max_interp=2, max_fill=60) == "none"

Re-run evaluate_methods whenever the network changes materially — a new sensor model, a site moved, a season with different meteorology. The crossover is a property of the current network, and a threshold inherited from last year’s hardware is a guess wearing a number.

Gotchas

Evaluating on gaps that are not like real gaps. Random masking produces gaps uniformly through the day; real gaps cluster at night, in cold weather and during network congestion. Sample your hold-out positions from the observed gap distribution or the numbers will be optimistic.

Fitting the neighbour model on data that includes the gaps you are evaluating. A subtle leak: if the model is fitted on the whole series and then evaluated on masked windows within it, the fit has seen the answers. Fit on a disjoint period.

Comparing methods on different gaps. Both methods must be scored on identical masked windows, or the comparison measures which gaps each method happened to get.

Assuming the crossover is symmetric across sensors. A sensor with no good neighbour has no crossover at all — the correct answer for every gap beyond the interpolation limit is to leave it empty, and the rule needs to express that rather than falling back to a poor regression.


FAQ

At what gap length does linear interpolation stop working?

Where the quantity’s own variability over the gap exceeds the error you will accept. For urban PM2.5 that is about two minutes; for barometric pressure it is hours. The general test is empirical: mask gaps of increasing length in clean data, interpolate, and plot the error against length. The knee in that curve is your threshold.

How correlated must a neighbour be to be usable?

Above roughly 0.8 on the overlapping period, measured on the same time grid after calibration. Below that the regression residual approaches the target’s own variance, and the imputed value carries so much uncertainty that leaving the gap empty is more informative.

Can I use several neighbours at once?

Yes, and it usually helps — a multiple regression on two or three correlated sensors is more robust than one, because a single neighbour’s own fault propagates directly into every filled value. Use a robust regressor so one bad neighbour degrades the fit rather than dominating it.