Choosing a Kernel Bandwidth for Case Density Maps
This guide, part of Kernel Density & Spatial Relative Risk Surfaces, deals with the single parameter that dominates every kernel-based map: the bandwidth.
Problem Context & Constraints
The kernel shape barely matters; the bandwidth decides almost everything. Too small and every case becomes its own peak, the map is noise, and the individual addresses are visible. Too large and a real concentration is spread across a county and diluted below detection. Between those extremes the map changes character continuously, and two analysts choosing bandwidths by eye from the same data will publish different maps and draw different conclusions.
Several selectors exist and they disagree, predictably. Rule-of-thumb selectors derive a bandwidth from the spread of the points assuming a roughly Gaussian distribution; case data are never Gaussian, so these over-smooth anything with local structure. Least-squares cross-validation chooses the bandwidth that best predicts left-out points; it adapts to local structure but is notoriously unstable, often selecting a very small value when there are tied or near-duplicate points. Oversmoothing selectors give an upper bound — the largest bandwidth consistent with the data’s spread — useful as a sanity ceiling rather than a choice.
For relative risk surfaces there is an extra subtlety: the case and control densities share one bandwidth, and the bandwidth that is optimal for each density separately is not optimal for their ratio. A jointly optimal bandwidth, chosen to minimise the error of the log ratio, is the right target.
Two floors bound any choice from below. The geocode positional error: a bandwidth smaller than the uncertainty in the point locations resolves noise. And disclosure: at small bandwidths each case is a visible peak, so the published bandwidth is constrained by review regardless of what the analysis prefers.
Prerequisites
python3.11numpy1.26.4,scipy1.13.1- Case and control coordinates in a projected metric coordinate reference system
- The geocode positional error distribution from the geocoding stage
- The disclosure review’s minimum publication bandwidth
Step-by-Step Solution
Compute a candidate from each selector, choose the jointly optimal value for relative risk, apply the floors, and check the result’s stability by resampling.
# Bandwidth selection for kernel density and relative risk, with floors.
# Pinned: numpy==1.26.4, scipy==1.13.1
import logging
import numpy as np
from scipy.optimize import minimize_scalar
from scipy.spatial.distance import pdist
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("kde.bandwidth")
SEED = 20260918
def scott(points: np.ndarray) -> float:
"""Gaussian rule of thumb for 2-D data."""
n = len(points)
sigma = np.sqrt(np.mean(points.var(axis=0, ddof=1)))
return float(sigma * n ** (-1.0 / 6.0))
def oversmooth(points: np.ndarray) -> float:
"""Upper bound on a sensible fixed bandwidth (Terrell's oversmoothing rule)."""
n = len(points)
sigma = np.sqrt(np.mean(points.var(axis=0, ddof=1)))
return float(1.08 * sigma * n ** (-1.0 / 6.0) * 1.7)
def lscv(points: np.ndarray, lo: float, hi: float) -> float:
"""Least-squares cross-validation for a 2-D Gaussian kernel."""
d2 = pdist(points, "sqeuclidean")
n = len(points)
def score(h):
a = np.exp(-d2 / (4 * h * h)).sum() * 2 / (4 * np.pi * h * h * n * n)
b = np.exp(-d2 / (2 * h * h)).sum() * 2 / (2 * np.pi * h * h * n * (n - 1))
return 1 / (4 * np.pi * h * h * n) + a - 2 * b
r = minimize_scalar(score, bounds=(lo, hi), method="bounded")
return float(r.x)
def joint_rr(cases: np.ndarray, controls: np.ndarray, lo: float, hi: float) -> float:
"""Shared bandwidth minimising a cross-validated error of the log ratio.
Leave-one-out log-density of each point under its own group versus the other,
averaged — a practical proxy for the integrated error of the ratio.
"""
def loo_log_density(pts, h, other=None):
tgt = pts if other is None else other
d2 = ((tgt[:, None, :] - pts[None, :, :]) ** 2).sum(-1)
k = np.exp(-d2 / (2 * h * h))
if other is None:
np.fill_diagonal(k, 0.0)
denom = len(pts) - 1
else:
denom = len(pts)
return np.log(np.maximum(k.sum(1) / (denom * 2 * np.pi * h * h), 1e-300))
def score(h):
lc = loo_log_density(cases, h).mean()
lk = loo_log_density(controls, h).mean()
return -(lc + lk)
r = minimize_scalar(score, bounds=(lo, hi), method="bounded")
return float(r.x)
def choose(cases, controls, pos_error_p90: float, disclosure_min: float) -> dict:
pooled = np.vstack([cases, controls])
cand = {"scott": scott(pooled), "oversmooth": oversmooth(pooled)}
lo, hi = max(10.0, pos_error_p90 / 4), cand["oversmooth"]
cand["lscv"] = lscv(cases, lo, hi)
cand["joint_rr"] = joint_rr(cases, controls, lo, hi)
floor = max(pos_error_p90, disclosure_min)
chosen = max(cand["joint_rr"], floor)
binding = ("selector" if chosen == cand["joint_rr"] else
"positional error" if floor == pos_error_p90 else "disclosure")
log.info("candidates (m): %s; floor %.0f; chosen %.0f (binding: %s)",
{k: round(v) for k, v in cand.items()}, floor, chosen, binding)
return {"candidates": cand, "chosen_m": chosen, "binding": binding}
def stability(cases, controls, selector, n_boot: int = 50, seed: int = SEED) -> dict:
"""Does the selected bandwidth survive resampling of the data?"""
rng = np.random.default_rng(seed)
hs = []
for _ in range(n_boot):
c = cases[rng.integers(0, len(cases), len(cases))]
k = controls[rng.integers(0, len(controls), len(controls))]
hs.append(selector(c, k))
hs = np.asarray(hs)
out = {"median": float(np.median(hs)), "cv": float(hs.std() / hs.mean())}
log.info("bandwidth stability: median %.0f m, CV %.2f over %d resamples",
out["median"], out["cv"], n_boot)
return out
The binding field in the result is what should appear in the methods text. It says whether the bandwidth reflects the data (the selector chose it), the data quality (positional error forced it up), or the release constraint (disclosure forced it up). Those are three different stories about the resulting map, and a reader cannot tell them apart from the number alone.
The stability check matters because data-driven selectors can be fragile. A bandwidth whose value swings by half across bootstrap resamples is not well determined by the data, and the map should be presented as one of several plausible smoothings rather than as the answer.
Validation & Edge Cases
Duplicate coordinates. Multiple cases at one address — a household, an apartment building — create zero distances that pull cross-validation toward a tiny bandwidth. Jitter duplicates by a fraction of the positional error for selection only, or use the joint selector, which is less sensitive.
Very uneven density. No fixed bandwidth suits both a dense city and the surrounding countryside. If the selected value is plainly too large for the city and too small for the countryside, move to an adaptive bandwidth.
Different case and control counts. A selector applied to controls alone, when controls outnumber cases fourfold, picks a smaller bandwidth than the cases can support. Always select on the ratio or on the pooled data, never on controls alone.
Selecting after looking at maps. Trying several bandwidths and keeping the one whose map “shows the cluster” is the kernel equivalent of choosing a buffer radius by its result. Select by the declared method first; show alternatives as sensitivity.
Anisotropic spread. Cases strung along a river valley or a transit corridor spread much more in one direction than the other, and a circular kernel with one bandwidth over-smooths across the corridor while under-smoothing along it. Where the pattern is clearly directional, consider a bandwidth matrix rather than a scalar, or rotate coordinates to the corridor axis before selection, and say so in the methods.
Bandwidth across time. A weekly surface whose bandwidth is re-selected every week changes smoothness with the case count. Fix the bandwidth for a surveillance series, or select it on a pooled baseline period.
INFO kde.bandwidth candidates (m): {'scott': 4210, 'oversmooth': 7730, 'lscv': 180, 'joint_rr': 1460}; floor 350; chosen 1460 (binding: selector)
INFO kde.bandwidth bandwidth stability: median 1420 m, CV 0.14 over 50 resamples
Compliance Notes
Record every candidate bandwidth with its selector, the positional error quantile used as a floor, the disclosure minimum, the chosen value and which constraint was binding, and the bootstrap stability result. For a surveillance series, record the fixed bandwidth and the baseline period it was selected on.
The disclosure floor deserves its own entry in the release record: it is a decision by the disclosure reviewer, not the analyst, and changing it changes what the published map can reveal. Where the analytic bandwidth is smaller than the published one, the internal surface is a protected artifact and must not leak into presentations or appendices.
Related Topics
- Kernel Density & Spatial Relative Risk Surfaces — the parent guide and where bandwidth fits.
- Adaptive Bandwidth KDE for Uneven Population Density — when no fixed bandwidth fits the whole study area.
- Optimizing Bandwidth for Getis-Ord Gi* Heatmaps — the analogous choice for areal hotspot statistics.