Choosing a Buffer Radius for Proximity Exposure

This guide, part of Assigning Exposure to Residential Locations, addresses the single most under-justified number in environmental epidemiology: the radius of the circle drawn around a residence to decide whether someone was exposed.

Problem Context & Constraints

Proximity exposure reduces a continuous field to a binary or a count: is there a facility within 500 m, how many road kilometres lie within 1 km. The radius is not a nuisance parameter — it is the exposure definition. Change it from 400 m to 800 m and a different set of people is classified as exposed, the contrast between groups changes, and the effect estimate moves. A radius adopted because a previous paper used it inherits that paper’s source, geography and geocode quality along with the number.

Three distinct considerations set the radius, and conflating them is how indefensible choices happen.

Source physics. How far does the agent actually travel at a concentration that matters? Near-road pollutant gradients flatten within 150–300 m; odour complaints from a facility extend further; a groundwater plume follows the hydraulic gradient and is not circular at all. The radius should encode a mechanism, and where the mechanism is directional, a circle is the wrong shape regardless of size.

Positional error. If residences are geocoded to street segments with 120 m of typical error, a 100 m buffer classifies exposure essentially at random for records near the boundary. The radius cannot be smaller than the geocode uncertainty without the classification becoming noise.

Statistical power. A tight radius produces few exposed people and a wide confidence interval; a loose one produces many exposed people who are not really exposed, diluting the contrast. Both extremes lose power, and the optimum depends on the true gradient, which is what you are trying to estimate.

The constraint that makes this tractable is honesty about multiplicity. Testing eight radii and reporting the one that reached significance is a well-known way to produce a finding that does not replicate.

Three Constraints, One Admissible Window A distance axis from zero to two kilometres with three constraint bands drawn above it. The positional error constraint rules out radii below the geocode uncertainty, shown as a barred region at the left. The source physics constraint rules out radii beyond the distance at which the agent is still at a meaningful concentration, shown as a barred region at the right. The statistical power constraint peaks in the middle, falling off toward both ends because too few exposed people widen the interval and too many dilute the contrast. The overlap of all three is marked as the admissible window, and the caption notes the window is usually narrow enough that the choice within it matters little. Two hard bounds and one soft optimum define the defensible range 0 500 m 1 km 2 km excluded: below geocode positional error excluded: beyond the source's reach statistical power admissible window Declare the window before looking at the outcome; inside it, the exact radius rarely changes the conclusion

Prerequisites

  • python 3.11
  • geopandas 0.14.4, numpy 1.26.4, pandas 2.2.2
  • Geocoded residences with a match type, and the positional error table from the parent guide
  • A hazard or source layer with its own positional accuracy documented
  • A pre-registered primary radius and, optionally, a declared sensitivity ladder

Step-by-Step Solution

Compute the admissible window from the two hard constraints, choose the primary radius inside it on physical grounds, and evaluate the ladder as a sensitivity analysis whose spread is reported rather than mined.

# Buffer radius selection and a pre-declared sensitivity ladder.
# Pinned: geopandas==0.14.4, numpy==1.26.4, pandas==2.2.2
import logging

import numpy as np
import pandas as pd
import geopandas as gpd

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("exposure.buffer")


def admissible_window(pos_err_m: pd.Series, source_reach_m: float,
                      quantile: float = 0.9) -> tuple[float, float]:
    """Lower bound from geocode error, upper bound from the source's physical reach."""
    lo = float(np.quantile(pos_err_m.dropna(), quantile))
    hi = float(source_reach_m)
    if lo >= hi:
        raise ValueError(
            f"no admissible radius: geocode error at q{quantile:.2f} is {lo:.0f} m but "
            f"the source reaches only {hi:.0f} m — proximity exposure is not estimable "
            "with this address quality")
    log.info("admissible radius window: %.0f–%.0f m", lo, hi)
    return lo, hi


def proximity_flags(cases: gpd.GeoDataFrame, sources: gpd.GeoDataFrame,
                    radii_m) -> pd.DataFrame:
    """Exposed/unexposed and source count at each radius, per record."""
    if cases.crs != sources.crs or cases.crs.is_geographic:
        raise ValueError("both layers must share one projected metric CRS")
    cases = cases.sort_values("study_id").reset_index(drop=True)
    # One nearest-distance computation serves every radius in the ladder.
    nearest = gpd.sjoin_nearest(cases[["study_id", "geometry"]],
                                sources[["geometry"]],
                                how="left", distance_col="dist_m")
    nearest = nearest.groupby("study_id", as_index=False)["dist_m"].min()
    out = nearest.copy()
    for r in sorted(radii_m):
        out[f"exposed_{int(r)}"] = (out["dist_m"] <= r).astype(int)
        n = int(out[f"exposed_{int(r)}"].sum())
        log.info("radius %5.0f m: exposed=%d (%.1f%%)", r, n, 100.0 * n / len(out))
    return out


def ladder_sensitivity(flags: pd.DataFrame, outcome: np.ndarray,
                       radii_m, primary_m: float) -> pd.DataFrame:
    """Effect estimate at every declared radius. The PRIMARY one is the result."""
    rows = []
    for r in sorted(radii_m):
        col = f"exposed_{int(r)}"
        a = outcome[flags[col].to_numpy() == 1]
        b = outcome[flags[col].to_numpy() == 0]
        if len(a) < 10 or len(b) < 10:
            rows.append({"radius_m": r, "n_exposed": len(a), "estimate": np.nan,
                         "note": "too few in one arm"})
            continue
        rows.append({"radius_m": r, "n_exposed": len(a),
                     "estimate": float(a.mean() - b.mean()),
                     "note": "PRIMARY" if r == primary_m else "sensitivity"})
    df = pd.DataFrame(rows)
    spread = df["estimate"].max() - df["estimate"].min()
    log.info("ladder spread across %d radii = %.4f (primary at %.0f m)",
             len(df), spread, primary_m)
    return df

The admissible_window function can refuse. If the ninetieth-percentile positional error exceeds the distance the source’s influence extends, then proximity exposure simply is not estimable with this address data, and building the buffer anyway produces a classification with no relationship to reality. Saying so at the top of the analysis is far better than discovering it in a null result.

The ladder function marks exactly one radius as the primary result. The others are there to show how sensitive the conclusion is to a choice that was necessarily somewhat arbitrary — a spread that is small strengthens the finding, a spread that changes sign undermines it. What the ladder must never become is a menu from which the most favourable row is promoted after the fact.

Reporting a Radius Ladder Honestly Effect estimates with confidence intervals plotted against buffer radius across a declared ladder of six radii. The pre-declared primary radius is marked with a filled point and the others with open points. All estimates lie on the same side of the null line, and the vertical spread between the largest and smallest is annotated as the sensitivity of the conclusion to the radius choice. A note states that the primary estimate is the result and the ladder is context, never a set of candidates from which the most favourable is selected afterwards. The ladder shows sensitivity — it is not a menu no effect primary, declared in advance spread 150 m 500 m 1500 m buffer radius A ladder whose estimates cross the null line is a weak finding, however good the primary looks

Validation & Edge Cases

A source layer with its own positional error. Facility coordinates from a permit database are often the mailing address of the operator, not the stack. Add the source’s positional error to the residence’s before setting the lower bound.

Directional exposure. Prevailing wind, river flow and groundwater gradient all make a circle wrong. Where a direction is known, use a wedge or a plume polygon; where it is not, acknowledge that the circular buffer averages exposed and unexposed directions together and attenuates the estimate.

Overlapping sources. With several facilities, a binary “within 500 m of any” discards dose. A count, or an inverse-distance sum, retains more information at no extra data cost — but changes the exposure definition, so it must be declared rather than swapped in.

Edge of the study area. Residences near the boundary have sources outside it that are not in the layer, so their counts are systematic undercounts. Extend the source layer beyond the study boundary by at least the largest radius.

Too few exposed. If the primary radius yields fewer than a couple of dozen exposed cases, the analysis is underpowered whatever the radius. Report that rather than widening the buffer until the count looks comfortable.

INFO exposure.buffer admissible radius window: 120–1500 m
INFO exposure.buffer radius   150 m: exposed=38 (0.7%)
INFO exposure.buffer radius   500 m: exposed=412 (7.6%)
INFO exposure.buffer radius  1500 m: exposed=2871 (52.8%)
INFO exposure.buffer ladder spread across 6 radii = 0.0182 (primary at 500 m)

The edge problem in that list has a characteristic geometry worth drawing, because it produces an undercount that looks like a real low exposure:

Sources Beyond the Boundary Still Count A residence near the edge of the study area with a one-kilometre buffer drawn around it. Half the buffer extends beyond the study boundary. Two facilities sit just across the boundary, inside the buffer. If the source inventory was clipped to the study area, the residence is assigned a count of zero; with the inventory padded by the largest radius, the correct count of two is found. The error is systematic for every residence near the edge. A clipped inventory undercounts every edge residence study area residence facility outside facility outside Clipped inventory count within 1 km = 0 Padded inventory count within 1 km = 2 Pad the source layer beyond the study boundary by at least the widest radius

Compliance Notes

The radius is a pre-specified analytic choice and must be recorded as one, before the outcome is examined. Store the positional error distribution used for the lower bound with its quantile, the source reach used for the upper bound with the physical reasoning or citation behind it, the primary radius and the date it was fixed, the declared ladder, the exposed counts at each rung, and the ladder spread.

Where a buffer analysis will be published, state the primary radius and its justification in the methods rather than in a supplement. A reader who cannot tell whether the radius was chosen before or after the results cannot evaluate the finding, and the difference between those two situations is the whole credibility of a proximity study.