How to Compare Population Grids Cell by Cell in Python

On this page

Problem statement

Two modelled population grids, set against a census grid of registered residents square kilometre by square kilometre, miss the median populated cell by 74 and 120 people β€” in cells where the census counts 55. In the emptiest cells the models put four and six times too many people; in the densest, 13% to 23% too few. A correlation over all cells, 0.93 to 0.95 here, shows none of that, because any grid that puts more people in cities than in fields scores well on it.

Below, the 100 m census squares, GHS-POP at 100 m and WorldPop at 3 arc-seconds go onto one 1 km grid in RD New.

Quick answer

Assign every fine cell to a 1 km cell of one grid by its centre, sum, and align the three on the same index:

K = pd.concat({"census": km(CBS_XY, CBS_POP), "ghs_pop": km(GHS_XY, GHS_POP), "worldpop": km(WP_XY, WP_POP)}, axis=1).fillna(0)
K = K[K.census > 0]                                                       # compare where the census has residents
for m in ("ghs_pop", "worldpop"):
    e = K[m] - K.census
    print(f"{m:>8}: total {K[m].sum():,.0f} against {K.census.sum():,.0f}; "
          f"r = {np.corrcoef(K[m], K.census)[0, 1]:.3f}; median absolute error {e.abs().median():,.0f} people per km2")
 ghs_pop: total 16,587,776 against 16,740,785; r = 0.945; median absolute error 74 people per km2
worldpop: total 16,634,547 against 16,740,785; r = 0.928; median absolute error 120 people per km2

On the 23,556 cells of 1 km where the census has residents, both models come within 1% of the census total and correlate at 0.93 to 0.95. The median cell is off by 74 people in GHS-POP and 120 in WorldPop, in cells whose median census population is 55.

Map of the Netherlands on 1 km cells coloured by GHS-POP 2020 minus the CBS census, blue where the model has too many people and red where too few, on a symmetric log scale up to 5,000 per square kilometre: the countryside is mostly pale blue, the model a few dozen too high; city centres in Amsterdam, Rotterdam, Den Haag and many towns are red, with dark blue cells beside them. Map of the Netherlands on 1 km cells coloured by GHS-POP 2020 minus the CBS census, blue where the model has too many people and red where too few, on a symmetric log scale up to 5,000 per square kilometre: the countryside is mostly pale blue, the model a few dozen too high; city centres in Amsterdam, Rotterdam, Den Haag and many towns are red, with dark blue cells beside them.
GHS-POP minus the census.

One grid for all, agreement, errors by density

1. One grid for all three

Each source has its own grid and CRS. Convert each fine cell's centre to RD New and floor to the kilometre; the census squares are already in RD New, so their IDs divide straight into kilometres:

print(f"census squares: {len(CBS_POP):,}; GHS-POP 100 m cells: {len(GHS_POP):,}; WorldPop cells: {len(WP_POP):,}")
print(f"1 km cells with census residents: {len(K):,}")
print(K.describe().loc[["mean", "50%", "max"]].round(0).to_string())
census squares: 379,905; GHS-POP 100 m cells: 1,221,963; WorldPop cells: 3,527,556
1 km cells with census residents: 23,556
       census  ghs_pop  worldpop
mean    711.0    704.0     706.0
50%      55.0    140.0     211.0
max   23165.0  20804.0   11664.0

379,905 census squares, 1.2 million GHS-POP cells and 3.5 million WorldPop cells go into 23,556 populated 1 km cells. The means agree, about 710 people per cell, but the medians do not: 55 in the census, 140 in GHS-POP and 211 in WorldPop, and the densest cell holds 23,165 in the census, 20,804 in GHS-POP and 11,664 in WorldPop.

2. How often they agree

A correlation over all cells is dominated by the contrast between city and country. Ask instead what share of cells, and of people, each model gets within 25% of the census:

for m in ("ghs_pop", "worldpop"):
    rel = (K[m] - K.census) / K.census
    ok = rel.abs() <= 0.25
    print(f"{m:>8}: cells within 25%: {ok.mean():.0%}; people in those cells: {K.census[ok].sum() / K.census.sum():.0%}; "
          f"cells off by more than half: {(rel.abs() > 0.5).mean():.0%}")
 ghs_pop: cells within 25%: 24%; people in those cells: 60%; cells off by more than half: 60%
worldpop: cells within 25%: 18%; people in those cells: 46%; cells off by more than half: 68%

Only a quarter of the cells are within 25% of the census in GHS-POP and under a fifth in WorldPop, and most cells are off by more than half. The cells that are right are the populous ones, so 60% and 46% of the people live in cells the models get within 25%.

Two log-log density plots of 1 km cells, census on the horizontal axis and model on the vertical, GHS-POP on the left and WorldPop on the right: both clouds sit above the dashed line of agreement at low census densities and slightly below it at high densities, WorldPop's cloud flatter and topping out near 10,000. Two log-log density plots of 1 km cells, census on the horizontal axis and model on the vertical, GHS-POP on the left and WorldPop on the right: both clouds sit above the dashed line of agreement at low census densities and slightly below it at high densities, WorldPop's cloud flatter and topping out near 10,000.
Each model against the census.

3. Errors by density

Group the cells by census density and look at the median error of each model in each group:

K["band"] = pd.cut(K.census, [0, 100, 500, 2_000, 5_000, 50_000], labels=["<100", "100-500", "500-2k", "2k-5k", "5k+"])
T = K.groupby("band", observed=True).apply(lambda g: pd.Series({
    "cells": len(g),
    "ghs_pop": f"{((g.ghs_pop - g.census) / g.census).median():+.0%}",
    "worldpop": f"{((g.worldpop - g.census) / g.census).median():+.0%}"}), include_groups=False)
print(T.to_string())
         cells ghs_pop worldpop
band                           
<100     13663   +304%    +530%
100-500   3932     +4%     +43%
500-2k    3170    -15%     -16%
2k-5k     2066    -13%     -23%
5k+        725    -15%     -22%

The pattern is flattening. In cells with fewer than 100 residents, GHS-POP puts a median of four times as many people and WorldPop six times as many; in cells above 500 per kmΒ², both put 13% to 23% too few. The models spread people more evenly than the register shows they live.

Where GHS-POP disagrees most

The data used on this page

The CBS 100 m grid, final 2020 edition, GHS-POP R2023A epoch 2020 at 100 m in World Mollweide (tiles R3_C19 and R4_C19), and WorldPop R2025A constrained 2020 for the Netherlands; the land boundary from the PDOK CBS gebiedsindelingen web feature service limits the modelled grids to the country.

import io
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 pyproj import Transformer
from rasterio.features import geometry_mask
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")
WP = Path("data/nld_pop_2020_CN_100m_R2025A_v1.tif")
if not WP.exists():
    WP.write_bytes(requests.get("https://data.worldpop.org/GIS/Population/Global_2015_2030/R2025A/2020/NLD/v1/100m/constrained/"
                                "nld_pop_2020_CN_100m_R2025A_v1.tif", timeout=600).content)
BASE = "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL/GHS_POP_GLOBE_R2023A/GHS_POP_E2020_GLOBE_R2023A_54009_100/V1-0/tiles"
srcs = []
for t in ("R3_C19", "R4_C19"):
    tif = Path("data", f"GHS_POP_E2020_GLOBE_R2023A_54009_100_V1_0_{t}.tif")
    if not tif.exists():
        zipfile.ZipFile(io.BytesIO(requests.get(f"{BASE}/{tif.stem}.zip", timeout=900).content)).extract(tif.name, "data")
    srcs.append(rasterio.open(tif))
B = Path("data/landsdeel_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:landsdeel_gegeneraliseerd",
        "outputFormat": "application/json", "srsName": "EPSG:28992"}).content)
NL = gpd.read_file(B).dissolve()
def cells(a, transform, crs):
    """Centres of the populated cells inside the Netherlands, in RD New metres, and their counts."""
    inside = ~geometry_mask(NL.to_crs(crs).geometry, a.shape, transform)
    r, c = np.nonzero(inside & (a > 0))
    x, y = rasterio.transform.xy(transform, r, c)
    X, Y = Transformer.from_crs(crs, 28992, always_xy=True).transform(np.asarray(x), np.asarray(y))
    return np.c_[X, Y], a[r, c].astype("float64")
g, gt = merge(srcs, bounds=(230_000, 5_930_000, 530_000, 6_280_000))
GHS_XY, GHS_POP = cells(g[0], gt, "ESRI:54009")
with rasterio.open(WP) as src:
    WP_XY, WP_POP = cells(src.read(1), src.transform, src.crs)
V = pyogrio.read_dataframe(GPKG, columns=["crs28992res100m", "aantal_inwoners"], read_geometry=False)
V = V[V.aantal_inwoners > 0]
CBS_XY = V.crs28992res100m.str.extract(r"E(\d+)N(\d+)").astype(int).to_numpy() * 100 + 50
CBS_POP = V.aantal_inwoners.to_numpy("float64")
def km(xy, pop):
    """Sum counts into 1 km RD New cells, indexed by the lower-left corner in km."""
    return pd.Series(pop).groupby([xy[:, 0] // 1000, xy[:, 1] // 1000]).sum()
print(f"census {CBS_POP.sum():,.0f}; GHS-POP inside the land boundary {GHS_POP.sum():,.0f}; WorldPop inside it {WP_POP.sum():,.0f}")
census 16,740,785; GHS-POP inside the land boundary 17,418,265; WorldPop inside it 17,589,165

Inside the land boundary, the census squares hold 16,740,785 residents, GHS-POP 17,418,265 and WorldPop 17,589,165.

Example 1: where GHS-POP disagrees most

List the 1 km cells with the largest differences in people, both ways, with their census count:

K["diff"] = K.ghs_pop - K.census
top = pd.concat([K.nlargest(3, "diff"), K.nsmallest(3, "diff")])
top.index = [f"x {a} km, y {b} km" for a, b in top.index]
print(top[["census", "ghs_pop", "worldpop", "diff"]].round(0).astype(int).to_string())
                        census  ghs_pop  worldpop  diff
x 120.0 km, y 483.0 km    2815     8993      7695  6178
x 118.0 km, y 483.0 km     610     5969      6258  5359
x 124.0 km, y 480.0 km     345     5583      5307  5238
x 93.0 km, y 438.0 km    16135     6974      6410 -9161
x 91.0 km, y 438.0 km    16905     8050      7030 -8855
x 93.0 km, y 434.0 km    14455     6289      5208 -8166

GHS-POP's three largest overestimates are cells in the south of Amsterdam where the census registers 345 to 2,815 residents and both models put 5,300 to 9,000; its largest underestimates are three cells in Rotterdam with 14,455 to 16,905 registered residents, where both models put fewer than half. The two models make the same mistakes in the same cells, which points at what they share, the view from above, rather than at either model's details.

Median error of GHS-POP and WorldPop against the census by census density, 1 km cells, the Netherlands 2020. median error against the census, by census densitycellsGHS-POPWorldPopunder 100 per km213,663βœ•+304%βœ•+530%100-5003,932βœ“+4%βœ•+43%500-2,0003,170!-15%!-16%2,000-5,0002,066!-13%!-23%5,000+725!-15%!-22%Too many people where few live, too few where many live spatialworkflow.io
Errors by density.

Counted residents against modelled spread

How a census grid and a modelled grid place people. counted or modelledCensus grid (CBS)residents at registered addressesno model, 100 m squaresrounded, small squares droppeddensest 1 km cell: 23,165Modelled gridsunit totals spread by imagerybuilt-up area and volumecannot see people per buildingdensest: 20,804 and 11,664Where the view from above misreads housing, both models miss together spatialworkflow.io
Counted or modelled.

The census grid counts residents at their registered addresses; the models distribute municipal totals over cells using what can be seen from above. Where the two agree, the model's picture of residential building matches where people are registered; where they do not, the model has misjudged how residential a building is, how many people live in it, or has old inputs. GHS-POP's Dutch inputs are the 2011 register, carried forward; WorldPop's are not listed, but its municipal sums suggest later boundaries.

Census coverage and centre assignment

Checks when comparing population grids. before trusting a comparisonone common gridassign fine cells by centresame area for every sourceclip to the same boundaryerrors in people, not only rr = 0.95 hides +304% in sparse cellsby density bandmodels flattensay the cell sizescores change with itCBS census grid, GHS-POP and WorldPop, 2020 spatialworkflow.io
Before trusting a comparison.
  • Compare on cells the census covers: cells with no published census residents can hold real people below the publication threshold; restricting to cells with residents avoids scoring the models against suppression.
  • Common grid choice: the comparison depends on the cell size; scores at 1 km do not carry over to 100 m or 5 km, so rerun it at the size the analysis uses.
  • Assignment by centre: a fine cell's people go wholly into the 1 km cell containing its centre; with 100 m cells that shift is small, with 1 km sources it would not be.
  • The census is not perfect either: it is rounded down to multiples of five and short by 667,000 people; differences of a few people per cell are within that noise.

FAQ

How do I compare two population rasters in Python?

Put them on one grid by assigning each fine cell to a coarse cell by its centre and summing, then compare cell by cell: totals, the share of cells and people within a tolerance, and errors by density.

Which is more accurate, GHSL or WorldPop?

For the Netherlands in 2020, compared with the census at 1 km, GHS-POP gets 24% of cells within 25%, holding 60% of the people, and WorldPop R2025A 18% and 46%; results differ between countries and inputs.

Why do modelled population grids underestimate dense areas?

They distribute people by what satellites see, mostly built-up area and volume, which does not capture how many people live in each building, so they tend to spread people more evenly than they are.

Is correlation a good measure of population grid accuracy?

Not alone: it is high whenever cities and countryside are told apart. Report errors in people and the share of cells within a tolerance.