How to Create a Heatmap from Location Data (Without Misleading Anyone)
Bandwidth, normalisation and colour classification each change the conclusion. A heatmap without those three stated is decoration.
- Author
- HuiTu Technology
- Published
A heatmap looks objective. It is a picture of where the data is dense, and pictures feel like facts. In reality the same points can produce one broad hotspot or five distinct ones depending on choices the analyst made, and both maps look equally authoritative.
This guide covers the five decisions that determine what a heatmap actually says, and how to make each one defensible.
1. Clean the points first
Density maps amplify data errors rather than hiding them. The specific error that ruins heatmaps is centroid fallback: when geocoding cannot find an address, many services return the centre of the postcode or the city. Those records pile up on one coordinate and produce a hotspot that is purely an artefact.
-- Coordinates shared by many records are almost always centroid fallbacks
SELECT ROUND(lat::numeric, 5) AS lat,
ROUND(lon::numeric, 5) AS lon,
COUNT(*) AS records
FROM points
GROUP BY 1, 2
HAVING COUNT(*) > 5
ORDER BY records DESC;2. Project into an equal-area system
Never compute density in degrees. A degree of longitude shrinks as you move away from the equator, so a fixed-radius kernel in degrees covers less ground in the north of your study area than in the south. Reproject to a UTM zone, a national grid or an equal-area projection before any distance calculation.
3. Choose the aggregation method
| Method | Output | Use when |
|---|---|---|
| Kernel density estimation | A smooth continuous raster | You want to see the shape of a phenomenon |
| Hexagonal binning | Equal-area polygons with counts | You need to join, compare or report by unit |
| Square grid | Grid cells with counts | The result must align with an existing raster or reporting grid |
Hexagons are preferred over squares for binning because every neighbour is equidistant, so hexbins do not introduce the directional artefacts that a square grid produces along its axes.
4. Choose and test the bandwidth
Bandwidth is the radius of influence of each point, and it is the single most consequential parameter. Too small and you see individual points; too large and everything merges into one blob centred on the busiest area.
- Start from the scale of the decision. A walkable retail question suggests 200 to 400 m. A regional service question suggests 2 to 5 km.
- Produce the surface at three or four bandwidths, not one.
- Check which hotspots persist across all of them. Those are real features. Ones that appear at a single bandwidth are artefacts of that choice.
- Publish the bandwidth on the map. A density legend without units or radius is not interpretable.
5. Normalise by the right denominator
This is the step most often skipped, and it is usually the one that changes the answer. Raw counts of almost any human activity produce a hotspot over the city centre, because that is where the people are. The map has rediscovered population density, at some expense.
| Question | Normalise by |
|---|---|
| Where do residents generate the most activity? | Residential population |
| Where is daytime activity concentrated? | Workplace population |
| Where is a category over-supplied? | Competing venues or floorspace |
| Where is the risk highest per exposure? | Trips, visits or hours of exposure |
| Where is absolute volume greatest? | Nothing; raw counts are correct here |
Producing both the raw and the normalised surface is usually right. The comparison between them is often the most informative output of the whole exercise.
Then test whether the hotspots are real
A heatmap shows where values are high. It does not tell you whether that concentration exceeds what random distribution would produce. Getis-Ord Gi* does, returning a z-score and a confidence level per cell, so a hotspot becomes a claim you can defend rather than a colour you chose.
import geopandas as gpd
from libpysal.weights import Queen
from esda.getisord import G_Local
hexes = gpd.read_file("hexbins.gpkg")
weights = Queen.from_dataframe(hexes)
weights.transform = "r"
gi = G_Local(hexes["count_per_1k"], weights, permutations=999)
hexes["z"] = gi.Zs
hexes["p"] = gi.p_sim
hexes["hotspot"] = (hexes.z > 1.96) & (hexes.p < 0.05)Finally, choose the colours honestly
- Use a perceptually uniform ramp such as viridis or magma. Rainbow ramps invent boundaries that are not in the data.
- State the classification method. Quantiles, equal interval and natural breaks produce very different maps from identical data.
- Label the legend with real units, not 'low' to 'high'.
- Check the ramp for colour-vision deficiency before publishing.