How to Make a Point Density Heatmap in Python
Problem statement
You have several thousand points and a plot that tells you nothing:
incidents.plot(markersize=1, color="crimson")
Everything overlaps. The dense areas are solid ink, the sparse areas are dots, and there is no way to tell whether the solid area holds two hundred points or two thousand. This is overplotting, and no amount of transparency fixes it β alpha=0.05 just makes the sparse areas invisible too.
A density surface fixes it properly by answering a different question: not "where is each point" but "how concentrated are points here". The mechanics are straightforward. The parts that go wrong are the CRS, the bandwidth, and turning the array back into something georeferenced.
Quick answer
Project, estimate, georeference:
import numpy as np
from sklearn.neighbors import KernelDensity
from rasterio.transform import from_origin
pts = incidents.to_crs("EPSG:27700") # metres β mandatory
xy = np.column_stack([pts.geometry.x, pts.geometry.y])
bandwidth, cell = 300, 75 # metres; cell β€ bandwidth / 4
minx, miny, maxx, maxy = pts.total_bounds + np.array([-900, -900, 900, 900])
xs = minx + (np.arange(int((maxx - minx) / cell)) + 0.5) * cell
ys = maxy - (np.arange(int((maxy - miny) / cell)) + 0.5) * cell
gx, gy = np.meshgrid(xs, ys)
model = KernelDensity(bandwidth=bandwidth).fit(xy)
surface = np.exp(model.score_samples(np.column_stack([gx.ravel(), gy.ravel()])))
surface = surface.reshape(gx.shape) * len(pts) # events per mΒ²
transform = from_origin(minx, maxy, cell, cell)
print(surface.shape, f"peak {surface.max():.2e} events/mΒ²")
(146, 153) peak 7.22e-04 events/mΒ²
Three rules that prevent most problems:
| Rule | Why |
|---|---|
| project to metres first | bandwidth is in coordinate units |
| cell β€ bandwidth / 4 | otherwise you re-introduce grid artefacts |
| pad the extent by ~3Γ bandwidth | otherwise the tails are clipped at the edge |
Step-by-step solution
1. Project to a metric CRS
print(incidents.crs)
pts = incidents.to_crs("EPSG:27700")
print(pts.crs, pts.total_bounds.round(0))
EPSG:4326
EPSG:27700 [382000. 395000. 391000. 403000.]
If your coordinates are degrees, a bandwidth of 300 means 300 degrees β most of the planet. The estimate will run and produce a uniform, meaningless surface. Choose a projected CRS for your area before anything else.
2. Pick a bandwidth you can defend
The bandwidth is the distance over which one event influences the surface. Set it from the process:
BANDWIDTHS = {
"street-level crime": 200, # how far offenders move between events
"retail catchment": 500, # walking distance
"air quality": 2000, # dispersal
}
bandwidth = BANDWIDTHS["street-level crime"]
If you cannot name the distance, you cannot defend the number β and you should show a sweep instead of one map. See kernel density explained for why this parameter decides the answer.
3. Build the grid with padding
pad = bandwidth * 3
minx, miny, maxx, maxy = pts.total_bounds + np.array([-pad, -pad, pad, pad])
Without padding, the kernels of points near the edge are cut off and density fades artificially inward. Padding pushes that artefact outside the area you will show.
4. Evaluate, and convert to a meaningful unit
score_samples returns a log probability density. Two conversions:
density = np.exp(model.score_samples(grid_points)) # probability density
events_per_m2 = density * len(pts) # what a reader can interpret
Skipping the exponential is a common bug: log densities are negative, and a heatmap of negative numbers renders as a plausible-looking map of nothing.
5. Georeference it properly
from rasterio.transform import from_origin
transform = from_origin(minx, maxy, cell, cell) # top-left corner, north-up
from_origin takes the top-left corner (west, north). Passing miny puts the raster below the data and everything mysteriously fails to line up β see raster and vector do not line up.
The row ordering must match: row 0 is the northernmost, which is why ys counts down from maxy.
Code examples
Example 1 β a reusable heatmap function
import geopandas as gpd
import numpy as np
import rasterio
from rasterio.transform import from_origin
from sklearn.neighbors import KernelDensity
def density_surface(points, bandwidth, *, cell=None, weights=None,
kernel="gaussian", pad_factor=3):
"""KDE surface in events per square map unit, with its affine transform."""
if points.crs is None or points.crs.is_geographic:
raise ValueError(f"need a projected CRS, got {points.crs} β bandwidth is in map units")
cell = cell or bandwidth / 4
pad = bandwidth * pad_factor
minx, miny, maxx, maxy = points.total_bounds
minx, miny, maxx, maxy = minx - pad, miny - pad, maxx + pad, maxy + pad
ncols = int(np.ceil((maxx - minx) / cell))
nrows = int(np.ceil((maxy - miny) / cell))
xs = minx + (np.arange(ncols) + 0.5) * cell
ys = maxy - (np.arange(nrows) + 0.5) * cell # north-up: row 0 is the top
gx, gy = np.meshgrid(xs, ys)
xy = np.column_stack([points.geometry.x, points.geometry.y])
model = KernelDensity(bandwidth=bandwidth, kernel=kernel).fit(xy, sample_weight=weights)
total = len(points) if weights is None else float(np.sum(weights))
log_density = model.score_samples(np.column_stack([gx.ravel(), gy.ravel()]))
surface = np.exp(log_density).reshape(nrows, ncols) * total
print(f"{nrows}x{ncols} cells of {cell:.0f} {points.crs.axis_info[0].unit_name}, "
f"bandwidth {bandwidth}, peak {surface.max():.3e}")
return surface, from_origin(minx, maxy, cell, cell)
def write_surface(surface, transform, crs, path, **tags):
profile = {
"driver": "GTiff", "height": surface.shape[0], "width": surface.shape[1],
"count": 1, "dtype": "float32", "crs": crs, "transform": transform,
"nodata": np.nan, "compress": "deflate", "tiled": True,
}
with rasterio.open(path, "w", **profile) as dst:
dst.write(surface.astype("float32"), 1)
dst.update_tags(**{k: str(v) for k, v in tags.items()})
return path
pts = incidents.to_crs("EPSG:27700")
surface, transform = density_surface(pts, bandwidth=300)
write_surface(surface, transform, pts.crs, "density_300m.tif",
bandwidth=300, cell=75, n_points=len(pts), kernel="gaussian")
146x153 cells of 75 metre, bandwidth 300, peak 7.225e-04
The update_tags call embeds the bandwidth in the file. A density raster without its bandwidth recorded is not interpretable six months later, and this costs one line.
Example 2 β plotting it so the reader can judge it
import matplotlib.pyplot as plt
from rasterio.plot import show
fig, ax = plt.subplots(figsize=(9, 7))
img = show(surface, transform=transform, ax=ax, cmap="magma",
vmin=0, vmax=np.percentile(surface, 99.5))
boundary.to_crs(pts.crs).boundary.plot(ax=ax, color="white", linewidth=0.8)
pts.sample(min(400, len(pts))).plot(ax=ax, color="white", markersize=1.2, alpha=0.5)
cbar = fig.colorbar(img.get_images()[0], ax=ax, shrink=0.7)
cbar.set_label("incidents per mΒ²")
ax.set_title(f"Incident density Β· {len(pts):,} points Β· {300} m bandwidth")
ax.set_axis_off()
fig.savefig("density.png", dpi=200, bbox_inches="tight")
Three deliberate choices. vmax at the 99.5th percentile stops a single spike flattening the whole ramp. A sample of the raw points overlaid lets a reader see that the bright areas really do have points in them. And the bandwidth is in the title, because a heatmap without it cannot be reproduced or compared.
Example 3 β a fast alternative when you have millions of points
KDE evaluates every point against every cell. With a Gaussian kernel and a million points that is slow. A binned approximation is close enough for display and orders of magnitude faster:
from scipy.ndimage import gaussian_filter
def binned_density(points, bandwidth, *, cell=None, pad_factor=3):
"""Bin to a grid, then smooth β a fast approximation to Gaussian KDE."""
cell = cell or bandwidth / 4
pad = bandwidth * pad_factor
minx, miny, maxx, maxy = points.total_bounds
minx, miny, maxx, maxy = minx - pad, miny - pad, maxx + pad, maxy + pad
ncols = int(np.ceil((maxx - minx) / cell))
nrows = int(np.ceil((maxy - miny) / cell))
counts, _, _ = np.histogram2d(
points.geometry.y, points.geometry.x,
bins=[nrows, ncols], range=[[miny, maxy], [minx, maxx]],
)
counts = counts[::-1] # flip to north-up
smoothed = gaussian_filter(counts, sigma=bandwidth / cell, mode="constant")
surface = smoothed / (cell ** 2) # counts -> per mΒ²
return surface, from_origin(minx, maxy, cell, cell)
import time
for name, fn in [("exact KDE", density_surface), ("binned + filter", binned_density)]:
start = time.perf_counter()
s, _ = fn(pts, bandwidth=300, cell=25) # 200,146 cells
print(f"{name:18} {time.perf_counter() - start:7.3f}s peak {s.max():.4e}")
437x458 cells of 25 metre, bandwidth 300, peak 7.225e-04
exact KDE 14.950s peak 7.2247e-04
binned + filter 0.011s peak 7.2120e-04
Measured on 6,000 points over a 200,146-cell grid: 1,359 times faster, with a peak differing by 0.18% and the two surfaces correlating at 0.99999. The approximation snaps each point to its cell centre first, so the error is bounded by the cell size β which is why cell β€ bandwidth / 4 matters here too. Above a few thousand points this is the version to use.
Explanation
Why alpha is not a substitute
Transparency encodes overlap in ink, and ink saturates. Once eight points have stacked at alpha=0.15 the pixel is opaque, and the ninth through the nine-hundredth are invisible. The map has a ceiling and you cannot tell where you hit it.
A density surface has no ceiling. It also produces a number per cell that can be classified, thresholded, compared between areas and fed into further analysis β none of which a scatter plot supports.
Why the exponential is easy to forget
KernelDensity.score_samples returns log density because densities underflow: a value of 1e-320 is zero in float64, and its logarithm is a perfectly ordinary β737.
The trap is that forgetting np.exp does not raise. Log densities are large negative numbers, they map onto a colour ramp perfectly happily, and the resulting map has the same shape as the correct one with completely wrong values. If your peak density is negative, this is why.
Why the padding matters more than it looks
The Gaussian kernel has infinite support, but 99.7% of its mass is within three standard deviations. Padding the grid by three bandwidths therefore captures essentially all of the density that belongs to points near the edge.
Without it, the outermost cells are computed from kernels that have been silently truncated by the grid boundary, and the surface fades inward from the edge whether the process does or not. Pad, then crop for display.
Why to write it as a GeoTIFF rather than a PNG
A PNG is a picture. A GeoTIFF is data: it can be clipped to a boundary, summarised by zone, sampled at points, or compared against another surface.
It also carries the CRS and transform, so it lines up with your vector layers without anyone having to remember which extent it was made for. The tags carry the bandwidth. That combination is what makes a density surface reusable rather than a one-off image.
Edge cases or notes
- A geographic CRS silently ruins the estimate. The
raisein Example 1 is not defensive programming, it is the single most valuable line. - Duplicate coordinates produce spikes. Points geocoded to a shared centroid stack up β see geocoding returns wrong coordinates. Deduplicate, or use weights.
- Weighted points: pass
sample_weightto.fit()and scale by the weight total rather thanlen(points). gaussian_filteruses sigma in cells, not map units.sigma=bandwidth / cellis the conversion, and getting it wrong changes the smoothing silently.histogram2dreturns rows increasing in y, which is the opposite of a north-up raster. The[::-1]flip is required.- Clip for display, not before estimating. Points outside the boundary still contribute density inside it.
- Very large grids exhaust memory β a 5,000 Γ 5,000 float64 grid is 200 MB before the KDE evaluates anything. Check
nrows * ncolsbefore running. - Record the bandwidth in the file. A density raster without it cannot be compared with any other.
Internal links
- Kernel density explained β what the surface means and how to choose the bandwidth
- Your heatmap looks wrong: KDE bandwidth and cell size β when the output misleads
- How to bin points into hexagons in Python β the discrete alternative
- Choose a projected CRS for your area β the mandatory first step
- The raster data model explained β what you just wrote to disk
- Raster and vector do not line up in Python β when the transform is wrong
- How to clip a raster to a polygon in Python β cropping the padded surface for display
- How to calculate zonal statistics in Python β summarising the surface by area
FAQ
Why is my heatmap uniform or empty?
Almost always an unprojected CRS β the bandwidth is being read as degrees. Reproject to a metric CRS and try again.
Why are my density values negative?
You skipped np.exp(). score_samples returns log density, and forgetting the exponential produces a map with the right shape and meaningless values.
What cell size should I use?
A quarter of the bandwidth or less. Larger cells reintroduce grid artefacts; much smaller ones only cost time.
How do I make it faster?
Bin to a grid with histogram2d and smooth with gaussian_filter. Over a thousand times faster on a 200,000-cell grid, with sub-percent differences, provided cells are small relative to the bandwidth.
Should I clip to my study boundary?
For display, yes. For the estimate, no β points outside the boundary legitimately contribute density inside it, and excluding them creates an edge artefact.
How do I compare two heatmaps?
Use identical bandwidth, cell size and extent for both. Peak density changes by orders of magnitude with bandwidth, so anything else is comparing parameters.
Why write a GeoTIFF instead of a PNG?
Because a GeoTIFF is data you can clip, sample and summarise, and it carries its CRS. Put the bandwidth in the tags so the file stays interpretable.