Estimating a Spatial Relative Risk Surface
This guide, part of Kernel Density & Spatial Relative Risk Surfaces, runs the whole method once, from a case list to a published map, and concentrates on the steps that decide whether the result means anything: the control series and the matching of the two point sets.
Problem Context & Constraints
The mathematics of a relative risk surface is two kernel densities and a logarithm. The epidemiology is almost entirely in the controls. The surface compares where cases are with where the population at risk is, and the control points are the only representation of that population the method sees. If the controls are drawn from the wrong population, or geocoded differently, the surface faithfully maps the difference — which is not risk.
Three control strategies are common in surveillance. Random residential addresses sampled in proportion to population from an address-point file are the cleanest representation of the at-risk population, as long as the address file covers the whole study area evenly. Cases of an unrelated condition from the same surveillance system share the cases’ reporting and geocoding pathway, which cancels those biases, but carry their own geography if the control condition has any spatial structure. Population-weighted random points within census blocks are easy to generate and ignore within-block distribution, which is acceptable at bandwidths well above block size.
Three constraints follow. Controls must match the at-risk population in time and, where the outcome is age-specific, in age. Both series must pass through the same geocoding and quality filter, so positional error affects them alike. And the case count must be adequate — below roughly fifty cases the surface is dominated by kernel shape and should not be mapped.
Prerequisites
python3.11numpy1.26.4,scipy1.13.1,geopandas0.14.4,pandas2.2.2- The relative risk routine from the parent guide
- Geocoded cases with match type; an address-point file or block populations for controls
- A study-region polygon in a projected metric coordinate reference system
Step-by-Step Solution
Draw controls, filter both series identically, check the controls against population, estimate the surface and tolerance contours, and export contours rather than raw peaks.
# End-to-end relative risk surface: controls, matching, estimation, export.
# Pinned: numpy==1.26.4, scipy==1.13.1, geopandas==0.14.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("rr.run")
SEED = 20260918
GOOD_MATCH = {"rooftop", "parcel"}
MIN_CASES = 50
def draw_controls(addresses: gpd.GeoDataFrame, n: int, seed: int = SEED) -> gpd.GeoDataFrame:
"""Simple random sample of residential address points — each address equally likely,
so the sample is proportional to where households are."""
rng = np.random.default_rng(seed)
idx = rng.choice(len(addresses), size=n, replace=False)
ctrl = addresses.iloc[np.sort(idx)].copy()
ctrl["geocode_match"] = "rooftop" # address points ARE rooftop locations
log.info("drew %d controls from %d residential addresses", n, len(addresses))
return ctrl
def match_filters(cases: gpd.GeoDataFrame, controls: gpd.GeoDataFrame):
"""Apply the SAME quality filter to both series."""
c = cases[cases["geocode_match"].isin(GOOD_MATCH)]
k = controls[controls["geocode_match"].isin(GOOD_MATCH)]
log.info("after identical filtering: cases %d of %d, controls %d of %d",
len(c), len(cases), len(k), len(controls))
if len(c) < MIN_CASES:
raise ValueError(f"{len(c)} cases after filtering — too few for a surface")
return c, k
def control_check(controls: gpd.GeoDataFrame, blocks: gpd.GeoDataFrame,
pop_col: str = "population") -> float:
"""Correlation between control counts and population across blocks."""
j = gpd.sjoin(controls[["geometry"]], blocks[["geoid", "geometry"]], predicate="within")
counts = j.groupby("geoid").size().reindex(blocks["geoid"]).fillna(0)
r = float(np.corrcoef(counts.to_numpy(), blocks[pop_col].to_numpy())[0, 1])
log.info("control count vs block population correlation r=%.3f", r)
if r < 0.8:
log.warning("controls track population poorly — the surface will map the control design")
return r
def run(cases, controls, blocks, region, h, relative_risk_fn, grid_fn, step):
c, k = match_filters(cases, controls)
control_check(k, blocks)
grid = grid_fn(region, step)
xy = lambda g: np.c_[g.geometry.x.to_numpy(), g.geometry.y.to_numpy()]
res = relative_risk_fn(xy(c), xy(k), grid, region, h)
out = gpd.GeoDataFrame({"rho": res["rho"], "p_upper": res["p_upper"]},
geometry=gpd.points_from_xy(grid[:, 0], grid[:, 1]),
crs=region.crs)
sig = out[out["p_upper"] < 0.05]
log.info("surface: %d cells, %d inside tolerance contour, max rho=%.2f",
len(out), len(sig), float(out["rho"].max()))
return out
The control-population check is the step that protects the whole analysis. If controls drawn from the address file correlate weakly with block population — because the address file under-covers rural routes or new subdivisions — the surface will show elevated risk wherever controls are sparse, which is a map of the address file’s gaps. A correlation above about 0.8 at block level is a reasonable minimum; below it, investigate the control source before estimating anything.
Filtering both series with the same match-type rule is equally important and easy to forget, because cases usually go through a quality filter in the surveillance pipeline while controls, drawn from an address file, are assumed to be perfect. If cases are filtered to rooftop matches and controls include everything, rural cases are under-represented relative to rural controls and the surface shows spurious low risk in the countryside.
Validation & Edge Cases
Controls sampled once and reused for years. Population moves; a control set from five years ago misrepresents today’s at-risk population in growing areas. Redraw controls when the analysis period changes materially.
Institutional addresses. Nursing homes and prisons appear once in an address file and may hold hundreds of residents. Weight controls by household size, or handle group quarters separately, so the control density reflects people, not addresses.
Age-specific outcomes. For a paediatric outcome, controls should be drawn in proportion to the child population, not all residents. An all-ages control series maps where children live relative to adults.
Temporal mismatch. Cases accumulated over three years and controls drawn from this year’s address file differ by any construction in between. Align the address vintage with the middle of the case period.
Control-to-case ratio drifting across runs. A weekly surface re-estimated as cases accrue while the control set stays fixed changes its ratio of controls to cases every week. The log relative risk is invariant to that ratio in expectation, but its variance is not, so contours tighten or loosen from week to week for a reason unrelated to risk. Hold the ratio roughly constant by redrawing controls in proportion to the case count, or report the ratio alongside every surface.
Cases missing from the address file. Some case addresses — new builds, informal housing — are absent from the address-point file used for controls. Their areas have cases but systematically too few controls, which reads as elevated risk. Compare the share of cases whose address appears in the file across areas; a pronounced deficit in one area is a warning sign about that area’s surface.
Too-narrow tolerance contours. Small contours around one or two cases are usually noise. Require a contour to contain a minimum number of cases before reporting it.
INFO rr.run after identical filtering: cases 412 of 468, controls 1528 of 1640
INFO rr.run control count vs block population correlation r=0.884
INFO rr.run surface: 21406 cells, 318 inside tolerance contour, max rho=0.94
Before the map leaves the analysis environment, convert it to the form a program uses: contours, not a raster of raw values. A contour of the significant area with the maximum relative risk inside it is actionable and much less disclosive than a fine raster whose peaks mark individual addresses.
Compliance Notes
Record the control strategy and source, the sampling seed, the geocoder and filter applied to both series, the control-population correlation, the bandwidth and its selection method, the edge correction, the simulation count and seed, and the minimum-case rule for reporting a contour.
Controls drawn from a residential address file are themselves locations of real households, even though those households are not cases. The control sample should be held inside the protected environment, never published, and discarded or retained under the same rules as the case file, since a published control set alongside a published case surface can narrow individual locations.
Related Topics
- Kernel Density & Spatial Relative Risk Surfaces — the parent guide and the estimation routine used here.
- Testing Significance of a Relative Risk Surface — confirming the contours are more than noise.
- Measuring Geocoder Match Rate Bias in Surveillance Data — why identical filtering matters.