Source code: Kernel density estimation in Python for GIS: bandwidths, GeoTIFF heatmaps and relative risk
Runnable Python scripts, requirements and console output for the tutorial “Kernel density estimation in Python for GIS: bandwidths, GeoTIFF heatmaps and…
Last updated
These are the exact scripts used to produce every figure and number in Kernel density estimation in Python for GIS: bandwidths, GeoTIFF heatmaps and relative risk. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- kde_trees.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython kde_trees.pykde_trees.py
"""Kernel density estimation in Python for GIS: bandwidth selection (Scott, Silverman, likelihoodcross-validation, a fixed planning distance), kernels, sample weights, a georeferenced GeoTIFF heatmapand a case-control relative-risk surface, on London plane trees in the London Borough of Camden.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/kernel-density-estimation-python/Part 2 of the "Point pattern analysis in Python" series. It downloads the same data as Part 1, so it runs on its own.Data: Trees In Camden (London Borough of Camden, OGL v3), ONS local authority boundary (OGL v3),basemap tiles (c) Esri (World Light Gray Canvas)."""import sysimport warningsfrom importlib.metadata import versionfrom pathlib import Path
import numpy as npimport pandas as pdimport geopandas as gpdimport shapelyimport requestsimport rasteriofrom rasterio.transform import from_originimport contextily as cximport matplotlibmatplotlib.use("Agg") # render to files only; no GUI backendimport matplotlib.pyplot as pltfrom matplotlib.colors import TwoSlopeNormfrom scipy import ndimagefrom scipy.stats import gaussian_kdefrom sklearn.model_selection import GridSearchCV, KFoldfrom sklearn.neighbors import KernelDensity
warnings.filterwarnings("ignore")np.seterr(all="ignore")plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
DATA = Path("data"); DATA.mkdir(exist_ok=True)OUT = Path("output"); OUT.mkdir(exist_ok=True)SEED = 42NSIM = 199CRS = "EPSG:27700"SPECIES = "London plane"LAD = "E09000007" # CamdenRES = 25 # output grid cell size (m)PLANNING_RADIUS = 400 # a 5-minute walk: a fixed, explainable search radiusHA = 1e4 # m2 per hectare
TREES_URL = "https://opendata.camden.gov.uk/api/views/csqp-kdss/rows.csv?accessType=DOWNLOAD"ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services"LAD_URL = f"{ONS}/Local_Authority_Districts_May_2026_Boundaries_UK_BFC/FeatureServer/0/query"
def download(url, path, **params): if not path.exists(): r = requests.get(url, params=params or None, timeout=600, headers={"User-Agent": "TechnicalGIS-tutorial"}) r.raise_for_status() path.write_bytes(r.content) return path
def section(title): print(f"\n=== {title} ===")
section("versions")print(f"python {sys.version.split()[0]} | scikit-learn {version('scikit-learn')} | scipy {version('scipy')} | " f"rasterio {version('rasterio')} | contextily {version('contextily')} | geopandas {version('geopandas')}")
# ---------------------------------------------------------------- the same points and window as Part 1section("data (same cleaning as Part 1)")df = pd.read_csv(download(TREES_URL, DATA / "trees_in_camden.csv"), low_memory=False)snapshot = pd.to_datetime(df["Last Uploaded"], format="%d/%m/%Y %I:%M:%S %p").max()df = df[df.Easting > 0]boundary = gpd.read_file(download(LAD_URL, DATA / "camden_lad.geojson", where=f"LAD26CD='{LAD}'", outFields="LAD26CD,LAD26NM", outSR=27700, f="geojson")).set_crs(CRS, allow_override=True)W = boundary.geometry.iloc[0]shapely.prepare(W)trees = df[shapely.contains_xy(W, df.Easting, df.Northing)].drop_duplicates(["Easting", "Northing", "Common Name"])is_plane = (trees["Common Name"] == SPECIES).to_numpy()planes = trees[is_plane]xy = planes[["Easting", "Northing"]].to_numpy(float)n = len(xy)print(f"snapshot uploaded {snapshot:%d %B %Y}: {len(trees):,} council trees in the borough, {n:,} {SPECIES}s")
# a 25 m grid of cell centres over the borough, top row first (the order a GeoTIFF expects)x0, y0 = np.floor(np.array(W.bounds[:2]) / RES) * RESx1, y1 = np.ceil(np.array(W.bounds[2:]) / RES) * RESxs = np.arange(x0 + RES / 2, x1, RES)ys = np.arange(y1 - RES / 2, y0, -RES)XX, YY = np.meshgrid(xs, ys)inside = shapely.contains_xy(W, XX, YY)cells = np.column_stack([XX[inside], YY[inside]])EXTENT = (x0, x1, y0, y1)print(f"grid: {len(xs)} x {len(ys)} cells of {RES} m, {inside.sum():,} inside the borough")
def to_grid(values): out = np.full(XX.shape, np.nan) out[inside] = values return out
def kde_sklearn(points, bandwidth, kernel="gaussian", weights=None): """Density per m2 (integrates to 1 over the plane) at every grid cell inside the borough.""" kd = KernelDensity(bandwidth=bandwidth, kernel=kernel, rtol=1e-4).fit(points, sample_weight=weights) return to_grid(np.exp(kd.score_samples(cells)))
# ---------------------------------------------------------------- Step 1: bandwidth selectionsection("bandwidth selection")# Rules of thumb. In two dimensions Scott's factor n^(-1/(d+4)) and Silverman's (n(d+2)/4)^(-1/(d+4)) are identical.scott = gaussian_kde(xy.T, bw_method="scott")silver = gaussian_kde(xy.T, bw_method="silverman")sd_xy = np.sqrt(np.diag(scott.covariance))print(f"scipy gaussian_kde: Scott factor {scott.factor:.4f}, Silverman factor {silver.factor:.4f}")print(f" kernel standard deviation (from the data covariance): {sd_xy[0]:.0f} m east-west, " f"{sd_xy[1]:.0f} m north-south")kd_scott = KernelDensity(bandwidth="scott").fit(xy)print(f"scikit-learn KernelDensity(bandwidth='scott').bandwidth_ = {kd_scott.bandwidth_:.3f} " f"-> read as metres on raw coordinates: standardise first or pass a number")
# Likelihood cross-validation: choose h to maximise the log-likelihood of held-out treesgrid_h = np.geomspace(10, 1000, 25)cv = GridSearchCV(KernelDensity(kernel="gaussian"), {"bandwidth": grid_h}, cv=KFold(5, shuffle=True, random_state=SEED))cv.fit(xy)h_cv = cv.best_params_["bandwidth"]cv_ll = cv.cv_results_["mean_test_score"] / (n / 5) # mean held-out log-likelihood per treeprint(f"likelihood cross-validation (5-fold, {len(grid_h)} bandwidths from 10 to 1,000 m): best h = {h_cv:.0f} m")for h in (10, 21, 46, 100, 215, 464, 1000): i = np.argmin(np.abs(grid_h - h)) print(f" h = {grid_h[i]:6.0f} m: mean held-out log-likelihood {cv_ll[i]:.3f} per tree")
# A fixed planning distance: an Epanechnikov kernel that reaches exactly 400 m and is zero beyondh_epan = PLANNING_RADIUSprint(f"fixed planning distance: Epanechnikov kernel, radius {h_epan} m " f"(same spread as a Gaussian with sd {h_epan / np.sqrt(6):.0f} m)")
dens = { f"Scott/Silverman (Gaussian, sd {sd_xy[0]:.0f} × {sd_xy[1]:.0f} m)": to_grid(scott(cells.T)), f"likelihood CV (Gaussian, sd {h_cv:.0f} m)": kde_sklearn(xy, h_cv), f"planning distance (Epanechnikov, radius {h_epan} m)": kde_sklearn(xy, h_epan, "epanechnikov"),}for name, d in dens.items(): t = d * n * HA print(f"{name}: peak {np.nanmax(t):.1f} planes/ha, 99th percentile {np.nanpercentile(t, 99):.1f}, " f"share of the kernel mass inside the borough {np.nansum(d) * RES * RES:.3f}")
fig, axes = plt.subplots(1, 3, figsize=(13, 4.6))for ax, (name, d) in zip(axes, dens.items()): t = d * n * HA im = ax.imshow(t, extent=EXTENT, cmap="viridis", vmin=0, vmax=np.nanpercentile(t, 99.5)) fig.colorbar(im, ax=ax, shrink=0.7, orientation="horizontal", pad=0.03, label="planes per hectare", extend="max") boundary.boundary.plot(ax=ax, color="black", linewidth=0.6) ax.set_title(name, fontsize=9); ax.set_axis_off()fig.suptitle("Same 3,292 trees, three bandwidths (each map has its own colour scale, capped at the 99.5th percentile)", y=0.98)fig.savefig(OUT / "01-bandwidth-comparison.png")plt.close(fig)
fig, ax = plt.subplots(figsize=(7, 3.8))ax.plot(grid_h, cv_ll, "o-", color="#0072B2", ms=4)ax.set_xscale("log")ax.axvline(h_cv, color="#D55E00", lw=2, label=f"likelihood CV maximum: {h_cv:.0f} m")ax.axvline(sd_xy.mean(), color="black", ls="--", lw=1.5, label=f"Scott/Silverman: about {sd_xy.mean():.0f} m")ax.axvline(h_epan / np.sqrt(6), color="#E69F00", ls=":", lw=2, label=f"400 m Epanechnikov ≈ Gaussian sd {h_epan / np.sqrt(6):.0f} m")ax.set_xlabel("Gaussian bandwidth h (standard deviation, m)")ax.set_ylabel("held-out log-likelihood per tree")ax.set_title("Likelihood cross-validation picks a small bandwidth for clustered street trees")ax.legend(frameon=False, fontsize=8); ax.grid(alpha=0.3, which="both")fig.tight_layout()fig.savefig(OUT / "02-cv-bandwidth.png")plt.close(fig)
# ---------------------------------------------------------------- Step 2: the kernel matters much less than hsection("kernel comparison at the same spread (sd 100 m per axis)")SD = 100kernels = { # scikit-learn h giving each 2-D kernel a 100 m standard deviation along each axis "gaussian": SD, "epanechnikov": SD * np.sqrt(6), "tophat": SD * 2, "exponential": SD / np.sqrt(3)}kd_maps = {k: kde_sklearn(xy, h, k) for k, h in kernels.items()}ref = kd_maps["gaussian"][inside]for k, h in kernels.items(): m = kd_maps[k][inside] print(f"{k:>12}: h = {h:5.0f} m, peak {m.max() * n * HA:5.1f} planes/ha, " f"correlation with Gaussian r = {np.corrcoef(ref, m)[0, 1]:.3f}")zoom = (528300, 530800, 181000, 183300) # Bloomsbury, King's Cross and Somers Townzx = (xy[:, 0] > zoom[0]) & (xy[:, 0] < zoom[1]) & (xy[:, 1] > zoom[2]) & (xy[:, 1] < zoom[3])vmax_k = max(np.nanmax(m) for m in kd_maps.values()) * n * HAfig, axes = plt.subplots(1, 4, figsize=(13, 3.9), layout="constrained")for ax, (k, m) in zip(axes, kd_maps.items()): im = ax.imshow(m * n * HA, extent=EXTENT, cmap="viridis", vmin=0, vmax=vmax_k) ax.scatter(xy[zx, 0], xy[zx, 1], s=0.15, color="white", alpha=0.5, linewidths=0) ax.set_xlim(zoom[:2]); ax.set_ylim(zoom[2:]) ax.set_title(f"{k} (h = {kernels[k]:.0f} m)", fontsize=9); ax.set_xticks([]); ax.set_yticks([])fig.colorbar(im, ax=axes, shrink=0.9, label="London planes per hectare")fig.suptitle("Four kernels with the same 100 m spread, south Camden (white dots: planes; white area: outside Camden)")fig.savefig(OUT / "03-kernel-comparison.png")plt.close(fig)
# ---------------------------------------------------------------- Step 3: sample weightssection("sample weights: crown area")spread = pd.to_numeric(planes["Spread In Metres"], errors="coerce").to_numpy()ok = spread > 0crown = np.pi * (spread[ok] / 2) ** 2print(f"{ok.sum():,} of {n:,} planes have a crown spread > 0 (median {np.median(spread[ok]):.0f} m); " f"total crown area {crown.sum() / HA:.1f} ha")H_MAP = 100unweighted = kde_sklearn(xy, H_MAP)weighted = kde_sklearn(xy[ok], H_MAP, weights=crown)u_map = unweighted * n * HA # planes per hectarew_map = weighted * crown.sum() # m2 of plane crown per m2 of groundprint(f"Gaussian h = {H_MAP} m: peak {np.nanmax(u_map):.1f} planes/ha; peak crown density " f"{np.nanmax(w_map) * 100:.1f}% of the ground under plane crowns")print(f"correlation between count and crown-area surfaces: r = {np.corrcoef(u_map[inside], w_map[inside])[0, 1]:.3f}")iu, iw = np.nanargmax(u_map), np.nanargmax(w_map)print(f"count peak at E {XX.flat[iu]:.0f} N {YY.flat[iu]:.0f}; crown-area peak at E {XX.flat[iw]:.0f} N {YY.flat[iw]:.0f} " f"({np.hypot(XX.flat[iu] - XX.flat[iw], YY.flat[iu] - YY.flat[iw]):,.0f} m apart)")
fig, axes = plt.subplots(1, 2, figsize=(11, 4.8))im0 = axes[0].imshow(u_map, extent=EXTENT, cmap="viridis", vmin=0)fig.colorbar(im0, ax=axes[0], shrink=0.75, label="London planes per hectare")axes[0].set_title(f"Unweighted: every tree counts once (h = {H_MAP} m)")im1 = axes[1].imshow(w_map * 100, extent=EXTENT, cmap="viridis", vmin=0)fig.colorbar(im1, ax=axes[1], shrink=0.75, label="plane crown area, % of ground")axes[1].set_title("Weighted by crown area: big trees count more")for ax in axes: boundary.boundary.plot(ax=ax, color="black", linewidth=0.6); ax.set_axis_off()fig.tight_layout()fig.savefig(OUT / "04-weighted-kde.png")plt.close(fig)
# ---------------------------------------------------------------- Step 4: edge correction, GeoTIFF export and a basemapsection("GeoTIFF export")# Edge correction (Diggle 1985): divide by the share of each cell's kernel that falls inside the borough.edge = ndimage.gaussian_filter(inside.astype(float), sigma=H_MAP / RES, mode="constant", cval=0.0)corrected = np.where(inside, u_map / edge, np.nan)print(f"edge correction factor 1/c(x): median {np.nanmedian(1 / edge[inside]):.2f}, " f"max {np.nanmax(1 / edge[inside]):.2f} (cells on the boundary)")tif = OUT / "camden_london_plane_density.tif"profile = dict(driver="GTiff", width=len(xs), height=len(ys), count=1, dtype="float32", crs=CRS, transform=from_origin(x0, y1, RES, RES), nodata=-9999.0, compress="deflate", tiled=True, blockxsize=256, blockysize=256)with rasterio.open(tif, "w", **profile) as dst: dst.write(np.where(inside, corrected, -9999.0).astype("float32"), 1) dst.update_tags(units="trees per hectare", kernel="gaussian", bandwidth_m=str(H_MAP), edge_correction="Diggle: divided by the kernel mass inside the borough", source=f"Trees In Camden, London Borough of Camden, snapshot {snapshot:%Y-%m-%d}, OGL v3") dst.set_band_description(1, "London plane density (trees/ha)")with rasterio.open(tif) as src: arr = src.read(1, masked=True) bounds = src.bounds print(f"wrote {tif}: {src.width} x {src.height} cells, {src.crs.to_string()}, {src.res[0]:.0f} m, " f"{src.dtypes[0]}, nodata {src.nodata:g}, {tif.stat().st_size / 1024:.0f} KB") print(f" read back: max {arr.max():.1f} trees/ha, mean {arr.mean():.2f} trees/ha " f"(the borough average is {n / (W.area / HA):.2f})")
fig, ax = plt.subplots(figsize=(8, 7.5))show = np.ma.masked_less(arr, 2) # leave low densities transparent so the streets show throughim = ax.imshow(show, extent=(bounds.left, bounds.right, bounds.bottom, bounds.top), cmap="magma_r", alpha=0.75, vmin=2, vmax=float(np.percentile(arr.compressed(), 99.5)), zorder=2)boundary.boundary.plot(ax=ax, color="black", linewidth=0.8, zorder=3)cx.add_basemap(ax, crs=CRS, source=cx.providers.Esri.WorldGrayCanvas, zoom=14, attribution_size=6)fig.colorbar(im, ax=ax, shrink=0.7, label="London planes per hectare (below 2: transparent)", extend="max")ax.set_axis_off()ax.set_title(f"camden_london_plane_density.tif on an Esri basemap (Gaussian h = {H_MAP} m, edge-corrected)")fig.tight_layout()fig.savefig(OUT / "05-geotiff-basemap.png")plt.close(fig)
# ---------------------------------------------------------------- Step 5: relative risk (cases vs controls)section("relative risk: London planes vs all other council trees")H_RR = 150all_xy = trees[["Easting", "Northing"]].to_numpy(float)xedges = np.arange(x0, x1 + RES / 2, RES)yedges = np.arange(y0, y1 + RES / 2, RES)
def binned_kde(points): """Fast binned Gaussian KDE: counts per 25 m cell smoothed with a Gaussian filter (trees per cell).""" h, _, _ = np.histogram2d(points[:, 1], points[:, 0], bins=[yedges, xedges]) return ndimage.gaussian_filter(np.flipud(h), sigma=H_RR / RES, mode="constant", cval=0.0)
case = binned_kde(all_xy[is_plane])control = binned_kde(all_xy[~is_plane])n_case, n_ctrl = int(is_plane.sum()), int((~is_plane).sum())# relative risk = ratio of the two densities (each scaled to integrate to 1), so the overall plane share cancelsvalid = inside & (control / (RES * RES) * HA >= 5) # at least 5 other trees per hectare for a stable ratiolog_rr = np.where(valid, np.log2((case / n_case) / (control / n_ctrl)), np.nan)r_raw = np.corrcoef(case[inside], (case + control)[inside])[0, 1]print(f"cases {n_case:,} planes, controls {n_ctrl:,} other trees; common Gaussian h = {H_RR} m")print(f"correlation of the raw plane density with the density of all council trees: r = {r_raw:.2f}")print(f"{valid.sum() / inside.sum():.0%} of the borough has >= 5 other trees per ha (elsewhere the ratio is masked)")lo, hi = np.nanpercentile(log_rr, [1, 99])print(f"log2 relative risk, 1st to 99th percentile: {lo:.2f} to {hi:.2f} " f"(planes {2 ** lo:.2f}x to {2 ** hi:.1f}x their borough-wide share)")
# Monte Carlo tolerance contours: shuffle the plane/other labels over the same tree locationsrng = np.random.default_rng(SEED)total = case + controltarget = np.nan_to_num(log_rr, nan=np.inf)exceed = np.zeros_like(case)for _ in range(NSIM): lab = np.zeros(len(all_xy), bool) lab[rng.choice(len(all_xy), n_case, replace=False)] = True c = binned_kde(all_xy[lab]) exceed += np.log2((c / n_case) / ((total - c) / n_ctrl)) >= targetp_upper = (exceed + 1) / (NSIM + 1)sig = valid & (p_upper <= 0.05)print(f"pointwise p <= 0.05 (planes over-represented), {NSIM} random labellings: {sig.sum() / valid.sum():.1%} of " f"valid cells ({sig.sum() * RES * RES / 1e6:.2f} km2); about 5% would be flagged by chance alone")
fig, axes = plt.subplots(1, 3, figsize=(14, 4.9))for ax, d, nm, lab in ((axes[0], case, f"{n_case:,} London planes", "planes per hectare"), (axes[1], control, f"{n_ctrl:,} other council trees", "other trees per hectare")): im = ax.imshow(np.where(inside, d / (RES * RES) * HA, np.nan), extent=EXTENT, cmap="viridis", vmin=0) fig.colorbar(im, ax=ax, shrink=0.7, orientation="horizontal", pad=0.03, label=lab) ax.set_title(f"{nm} (h = {H_RR} m)", fontsize=9)lim = float(np.nanmax(np.abs([lo, hi])))gpd.GeoSeries([W], crs=CRS).plot(ax=axes[2], facecolor="#d0d0d0", edgecolor="none", zorder=0)im = axes[2].imshow(np.clip(log_rr, -lim, lim), extent=EXTENT, cmap="PuOr_r", norm=TwoSlopeNorm(0, -lim, lim), zorder=1)axes[2].contour(XX, YY, sig.astype(float), levels=[0.5], colors="black", linewidths=0.6, zorder=2)fig.colorbar(im, ax=axes[2], shrink=0.7, orientation="horizontal", pad=0.03, extend="both", label="log₂ relative risk (0 = planes at their borough-wide share)")axes[2].set_title("Planes vs other trees; black outline: pointwise p ≤ 0.05\n" "grey: fewer than 5 other trees per ha (masked)", fontsize=9)for ax in axes: boundary.boundary.plot(ax=ax, color="black", linewidth=0.6, zorder=3); ax.set_axis_off()fig.savefig(OUT / "06-relative-risk.png")plt.close(fig)
# a coarse sanity check readers can reproduce: plane share by contract areasection("plane share by land owner")share = trees.groupby("Contract Area")["Common Name"].apply(lambda s: (s == SPECIES).mean())print("London plane share of council trees by contract area: " + ", ".join(f"{k} {v:.0%}" for k, v in share.sort_values(ascending=False).items()))print(f"borough-wide share: {n_case / len(trees):.1%}")