How to Compute Population-Weighted Density in Python

On this page

Problem statement

The density of a municipality, its population over its area, mixes the people with every park, harbour, airport and field inside its boundary. Two cities with the same housing can get very different densities because one boundary includes farmland. Population-weighted density asks a different question: at what density does the average resident live? It averages the density of small cells, weighted by the people in them, so empty land inside the boundary stops mattering.

This page computes population-weighted density for Dutch municipalities from Statistics Netherlands' 100 m census squares, compares it with plain density, shows how it depends on the cell size, and repeats it with GHS-POP at 1 km.

Quick answer

Each square's density is its people over its area; weight those densities by the people:

def pwd(people, cell_km2):
    d = people / cell_km2
    return (people * d).sum() / people.sum()
ams = SQ[SQ.statnaam == "Amsterdam"].residents
print(f"Amsterdam: {ams.sum():,} residents in {len(ams):,} squares of 100 m")
print(f"plain density {OFF['GM0363'] / GM.set_index('statcode').km2['GM0363']:,.0f} per km2; population-weighted density {pwd(ams, 0.01):,.0f} per km2")
Amsterdam: 865,890 residents in 6,755 squares of 100 m
plain density 4,440 per km2; population-weighted density 20,249 per km2

Amsterdam's 865,890 residents in published squares live in 6,755 squares of 100 m. Its plain density is 4,440 per kmΒ² of land; the density at which its average resident lives is 20,249 per kmΒ², 4.6 times as high.

Map of the municipality of Rotterdam, a long strip from the port in the west to the city in the east, with CBS 100 m squares coloured by residents per square kilometre: the squares cluster in the east, many above 10,000 per square kilometre, while the port area in the west is empty; 651,157 residents on 294 square kilometres is 2,212 per square kilometre, but the average resident lives at 16,465. Map of the municipality of Rotterdam, a long strip from the port in the west to the city in the east, with CBS 100 m squares coloured by residents per square kilometre: the squares cluster in the east, many above 10,000 per square kilometre, while the port area in the west is empty; 651,157 residents on 294 square kilometres is 2,212 per square kilometre, but the average resident lives at 16,465.
Rotterdam: land against residents.

Plain and weighted density, cell-size dependence, rankings

1. Plain and weighted density for the largest municipalities

Compute both for the ten municipalities with the most people:

G = GM.set_index("statcode")
big = G.nlargest(10, "people")
T = pd.DataFrame({"municipality": big.statnaam,
                  "plain": big.people / big.km2,
                  "weighted_100m": [pwd(SQ[SQ.statcode == c].residents, 0.01) for c in big.index]})
T["ratio"] = T.weighted_100m / T.plain
print(T.sort_values("weighted_100m", ascending=False).to_string(index=False, formatters={
      "plain": "{:,.0f}".format, "weighted_100m": "{:,.0f}".format, "ratio": "{:.1f}x".format}))
 municipality plain weighted_100m ratio
    Amsterdam 4,440        20,249  4.6x
's-Gravenhage 6,378        16,729  2.6x
    Rotterdam 2,212        16,465  7.4x
      Utrecht 3,604        14,274  4.0x
    Groningen 1,177        10,731  9.1x
     Nijmegen 3,083        10,040  3.3x
      Tilburg 1,861         9,491  5.1x
    Eindhoven 2,634         9,144  3.5x
       Almere 1,521         9,082  6.0x
        Breda 1,432         8,259  5.8x

By plain density Den Haag is the densest of the ten by far and Groningen the sparsest. Weighted by population, Amsterdam comes first, and Rotterdam, which plain density puts sixth with 2,212 per kmΒ² because its municipality includes the port, moves up to third with 16,465. Groningen's ratio is the largest, 9.1: a compact city in a municipality with a lot of farmland.

2. It depends on the cell size

Aggregate the squares to 500 m and 1 km and recompute. Coarser cells average dense blocks with their surroundings:

for size in (1, 5, 10):
    agg = SQ.assign(kx=SQ.x // size, ky=SQ.y // size).groupby(["statcode", "kx", "ky"]).residents.sum()
    v = [pwd(agg[c], (size / 10) ** 2) for c in ("GM0363", "GM0518", "GM0599")]
    print(f"{size * 100:>5} m cells: Amsterdam {v[0]:>7,.0f}, Den Haag {v[1]:>7,.0f}, Rotterdam {v[2]:>7,.0f} per km2")
  100 m cells: Amsterdam  20,249, Den Haag  16,729, Rotterdam  16,465 per km2
  500 m cells: Amsterdam  12,734, Den Haag  11,544, Rotterdam   9,959 per km2
 1000 m cells: Amsterdam  10,722, Den Haag   9,984, Rotterdam   7,911 per km2

Going from 100 m to 1 km cells halves the weighted density of all three cities, from 20,249 to 10,722 in Amsterdam, and widens the gap between Den Haag and Rotterdam. The cell size is part of the measure.

Line chart of population-weighted density against cell size from 100 m to 2 km for Amsterdam, Den Haag and Rotterdam, all from the same CBS 2020 counts: every line falls as the cells grow, Amsterdam from about 20,000 at 100 m to about 10,700 at 1 km, Rotterdam lowest throughout. Line chart of population-weighted density against cell size from 100 m to 2 km for Amsterdam, Den Haag and Rotterdam, all from the same CBS 2020 counts: every line falls as the cells grow, Amsterdam from about 20,000 at 100 m to about 10,700 at 1 km, Rotterdam lowest throughout.
Weighted density by cell size.

3. Does the ranking change?

For all municipalities over 50,000 residents, compare the order by plain density with the order by weighted density:

M = G[G.people > 50_000].copy()
M["plain"] = M.people / M.km2
M["weighted"] = [pwd(SQ[SQ.statcode == c].residents, 0.01) for c in M.index]
rho = M.plain.rank().corr(M.weighted.rank())
M["moved"] = M.plain.rank(ascending=False) - M.weighted.rank(ascending=False)
print(f"{len(M)} municipalities; rank correlation {rho:.2f}")
print(M.reindex(M.moved.abs().sort_values(ascending=False).index).head(4)[["statnaam", "plain", "weighted", "moved"]].to_string(
      index=False, formatters={"plain": "{:,.0f}".format, "weighted": "{:,.0f}".format, "moved": "{:+.0f} places".format}))
88 municipalities; rank correlation 0.87
      statnaam plain weighted      moved
         Hoorn 3,494    7,864 -33 places
       Heerlen 1,912    7,197 -31 places
Sittard-Geleen 1,147    5,765 -31 places
     Groningen 1,177   10,731 +27 places

Across the 88 municipalities over 50,000 residents the two orders agree broadly, with a rank correlation of 0.87, but some move a long way. Hoorn falls 33 places and Heerlen and Sittard-Geleen 31: their boundaries hold little empty land, so plain density flatters them, while their residents live at moderate densities. Groningen rises 27 places.

The same measure from GHS-POP

The data used on this page

The CBS 100 m grid, final 2020 edition, GHS-POP R2023A epoch 2020 at 1 km in Mollweide (tiles R3_C19 and R4_C19), the 2020 municipality boundaries from the PDOK CBS gebiedsindelingen web feature service, and each municipality's population on 1 January 2020 from StatLine table 03759ned.

import io
import json
import zipfile
from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import pyogrio
import rasterio
import requests
from rasterio.merge import merge

Path("data").mkdir(exist_ok=True)
GPKG = Path("data/cbs_vk100_2020_vol.gpkg")
if not GPKG.exists():
    url = "https://www.cbs.nl/-/media/cbs/dossiers/nederland-regionaal/vierkanten/100/2023-cbs_vk100_2020_vol.zip"
    zipfile.ZipFile(io.BytesIO(requests.get(url, timeout=900).content)).extract(GPKG.name, "data")
B = Path("data/gemeente_2020.geojson")
if not B.exists():
    B.write_bytes(requests.get("https://service.pdok.nl/cbs/gebiedsindelingen/2020/wfs/v1_0", timeout=300, params={
        "service": "WFS", "version": "2.0.0", "request": "GetFeature", "typeName": "gebiedsindelingen:gemeente_gegeneraliseerd",
        "outputFormat": "application/json", "srsName": "EPSG:28992"}).content)
C = Path("data/cbs_pop_2020.json")
if not C.exists():
    C.write_bytes(requests.get("https://opendata.cbs.nl/ODataApi/odata/03759ned/TypedDataSet", timeout=300, params={
        "$filter": "Geslacht eq 'T001038' and Leeftijd eq '10000' and BurgerlijkeStaat eq 'T001019' and Perioden eq '2020JJ00' "
                   "and (substring(RegioS,0,2) eq 'GM' or substring(RegioS,0,2) eq 'NL')",
        "$select": "RegioS,BevolkingOp1Januari_1"}).content)
OFF = {r["RegioS"].strip(): r["BevolkingOp1Januari_1"] for r in json.loads(C.read_text())["value"] if r["BevolkingOp1Januari_1"]}
GM = gpd.read_file(B)
GM["people"], GM["km2"] = GM.statcode.map(OFF), GM.area / 1e6
V = pyogrio.read_dataframe(GPKG, columns=["crs28992res100m", "aantal_inwoners"], read_geometry=False)
V = V[V.aantal_inwoners > 0]                                                       # published resident counts only
xy = V.crs28992res100m.str.extract(r"E(\d+)N(\d+)").astype(int)
pts = gpd.GeoDataFrame({"residents": V.aantal_inwoners.to_numpy(), "x": xy[0].to_numpy(), "y": xy[1].to_numpy()},
                       geometry=gpd.points_from_xy(xy[0] * 100 + 50, xy[1] * 100 + 50), crs=28992)
SQ = pts.sjoin(GM[["statcode", "statnaam", "geometry"]], predicate="within")
GHSL = "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL/GHS_POP_GLOBE_R2023A"
tiles = []
for t in ("R3_C19", "R4_C19"):
    tif = Path("data", f"GHS_POP_E2020_GLOBE_R2023A_54009_1000_V1_0_{t}.tif")
    if not tif.exists():
        url = f"{GHSL}/GHS_POP_E2020_GLOBE_R2023A_54009_1000/V1-0/tiles/{tif.stem}.zip"
        zipfile.ZipFile(io.BytesIO(requests.get(url, timeout=600).content)).extract(tif.name, "data")
    tiles.append(rasterio.open(tif))
GHS, GHS_TF = merge(tiles)
print(f"{len(SQ):,} populated squares joined to {SQ.statcode.nunique()} municipalities; GHS-POP mosaic {GHS.shape[2]} x {GHS.shape[1]} km")
379,704 populated squares joined to 355 municipalities; GHS-POP mosaic 1000 x 2000 km

379,704 squares with a published resident count are joined to all 355 municipalities; the GHS-POP mosaic is 1,000 by 2,000 cells of 1 km.

Example 1: the same measure from GHS-POP at 1 km

GHS-POP's Mollweide cells are exactly 1 kmΒ². Assign each cell to a municipality by its centre and compare with the census squares aggregated to the same size:

a = GHS[0]
r, c = np.nonzero(a > 0)
cx, cy = rasterio.transform.xy(GHS_TF, r, c)
cells = gpd.GeoDataFrame({"people": a[r, c]}, geometry=gpd.points_from_xy(cx, cy), crs="ESRI:54009").to_crs(28992)
cells = cells.sjoin(GM[["statcode", "geometry"]], predicate="within")
km = SQ.assign(kx=SQ.x // 10, ky=SQ.y // 10).groupby(["statcode", "kx", "ky"]).residents.sum()
for code in ("GM0363", "GM0518", "GM0599", "GM0344"):
    print(f"{G.statnaam[code]:>14}: census at 1 km {pwd(km[code], 1.0):>7,.0f}; GHS-POP at 1 km {pwd(cells[cells.statcode == code].people, 1.0):>7,.0f} per km2")
     Amsterdam: census at 1 km  10,722; GHS-POP at 1 km   8,265 per km2
 's-Gravenhage: census at 1 km   9,984; GHS-POP at 1 km   9,600 per km2
     Rotterdam: census at 1 km   7,911; GHS-POP at 1 km   4,275 per km2
       Utrecht: census at 1 km   7,509; GHS-POP at 1 km   6,323 per km2

At the same 1 km, GHS-POP gives lower weighted densities than the census grid in all four cities: close in Den Haag, 9,600 against 9,984, but barely more than half in Rotterdam, 4,275 against 7,911. The model spreads Rotterdam's people more evenly than they live.

Population-weighted density from the census grid and from GHS-POP, both at 1 km, in four Dutch cities. weighted density at 1 km: GHS-POP as a share of the census gridDen Haag9,600 of 9,984 (96%)Utrecht6,323 of 7,509 (84%)Amsterdam8,265 of 10,722 (77%)Rotterdam4,275 of 7,911 (54%)025%50%75%100%A modelled grid spreads people more evenly than they live spatialworkflow.io
GHS-POP against the census grid.

What population-weighted density measures

Computing population-weighted density from a grid. population-weighted density in four stepscells in the areaby centre, 100 m squaresdensity per cellpeople / 0.01 km2weight by peoplesum(p x d)divide by peopleAmsterdam: 20,249Empty cells weigh nothing, so the boundary stops mattering spatialworkflow.io
Weighted density in four steps.

Population-weighted density is the mean of cell densities weighted by population, which is the same as the sum of each cell's people squared over its area, divided by the total population. It describes the density experienced by residents: the typical person's surroundings rather than the typical hectare's. Because empty cells carry no weight, it does not change when a boundary is drawn around more farmland, a harbour or a nature reserve, which is the main thing wrong with plain density as a comparison between cities.

It does depend on the grid. Smaller cells separate dense blocks from the open space between them, so the measure rises as the cells shrink, and a comparison is only fair at one cell size from one source. A census grid at 100 m is close to the building scale; a modelled grid at 1 km gives a smoother, lower figure, and a modelled grid also inherits its model's view of where people live.

Suppressed squares and crowding

Computing population-weighted density: problems and fixes. weighted density trapsTrapInsteadcities compared at different cellsizesone size: 100 m to 1 km halves itcensus against modelled gridone source: Rotterdam 7,911 vs4,275plain density as lived densityRotterdam 2,212 vs 16,465cell area assumed on lat-lon gridsarea per rowCBS 100 m grid and GHS-POP, 2020 spatialworkflow.io
Weighted density traps.
  • Suppressed and missing squares: the CBS grid leaves out squares with fewer than five residents and five dwellings and rounds the rest down to multiples of five; that lowers the weighted density slightly, mostly in rural municipalities.
  • Assignment of cells: squares are assigned to a municipality by their centre; for 1 km cells along a boundary this moves whole cells across, which matters for small municipalities.
  • Area per cell: the 0.01 kmΒ² of a 100 m square only holds on an equal-area grid; on a latitude-longitude grid use each row's area.
  • Not a measure of crowding: it says how densely an area is settled, not how many people share a dwelling.

FAQ

What is population-weighted density?

The average of small-area densities weighted by their populations: the density at which the average resident lives, rather than people divided by total area.

How do I calculate population-weighted density in Python?

For cells of equal area, sum each cell's population times its density and divide by the total population: (p * p / area).sum() / p.sum().

Why is population-weighted density higher than normal density?

People are concentrated in a small part of any area, so most people live in cells far denser than the average over all the land, including empty land.

Which grid should I use?

One fine, consistent grid for every place compared, ideally a census grid; report the cell size, because the measure rises as cells shrink.