How to Calculate Population-Weighted Centroids

Problem statement

A polygon's centroid is the centre of its land, not of its people. For a compact urban tract the two are close. For a large tract with a town at one end, they can be kilometres apart. Distances, travel times and nearest-facility assignments computed from centroids then start from the wrong place.

Measured on Delaware's 258 populated census tracts, the geometric centroid was a median 429 m from the population-weighted centre, and more than 1 km away for 69 tracts. The largest gap was 9.6 km, in a 355 kmยฒ tract of 3,722 people. For the 3,100 counties of the contiguous US the median gap was 5.5 km, and in Nye County, Nevada, it was 182.5 km.

The Census Bureau publishes population centres for tracts, block groups and counties, computed from block counts. The same numbers can be reproduced from the TIGER/Line block file in a few lines, which means you can compute them for any zones you like.

Quick answer

import numpy as np
import pandas as pd
import geopandas as gpd

blocks = gpd.read_file("tl_2020_10_tabblock20.zip", ignore_geometry=True,
                       columns=["GEOID20", "POP20", "INTPTLAT20", "INTPTLON20"])
lat = blocks["INTPTLAT20"].astype(float)
lon = blocks["INTPTLON20"].astype(float)
w = blocks["POP20"]
wc = w * np.cos(np.radians(lat))
tract = blocks["GEOID20"].str[:11]

centres = pd.DataFrame({
    "population": w.groupby(tract).sum(),
    "lat": (w * lat).groupby(tract).sum() / w.groupby(tract).sum(),
    "lon": (wc * lon).groupby(tract).sum() / wc.groupby(tract).sum(),
}).query("population > 0")

Measured: every one of the 258 centres agreed with the Census Bureau's published CenPop2020_Mean_TR10.txt to within 0.14 m. The calculation took 0.003 s.

Table of three methods for population-weighted centres and their largest difference from the Census Bureau's published tract centres.
Reproducing the published file checks the formula and the data in one step.

Step-by-step solution

1. Use the smallest populated units that have a point

The 2020 TIGER/Line tabulation block files carry both the population (POP20) and an internal point (INTPTLAT20, INTPTLON20) for every block. Delaware has 20,198 of them. Blocks are small enough that the internal point is a good stand-in for where the block's residents are.

Block groups would work, but each is one point for 600 to 3,000 people. The coarser the input, the more the result drifts back towards the geometric centroid.

2. Apply the Census Bureau's formula

The published centres of population use a weighted mean latitude and a longitude weighted by population ร— cos(latitude):

lat = ฮฃ(w ยท lat) / ฮฃ w
lon = ฮฃ(w ยท cos(lat) ยท lon) / ฮฃ(w ยท cos(lat))

The cosine term accounts for degrees of longitude getting shorter away from the equator. Within Delaware it barely matters: dropping it moved tract centres by at most 2.6 m. Across a large country it matters more.

Reproducing the published file checks both the formula and your data. The tract centres matched to 0.14 m, and Delaware's three county centres to 0.02, 0.05 and 0.08 m.

3. Or project the points and take a weighted mean

pts = gpd.GeoDataFrame(blocks, geometry=gpd.points_from_xy(lon, lat), crs=4269).to_crs(5070)

A population-weighted mean of projected x and y in an equal-area projection (EPSG:5070 for the US) came within 1.9 m of the published centres. Use it when the result has to be a geometry anyway, or when the zones are not census units.

4. Drop units with no population

Four Delaware tracts have no residents. Their weights sum to zero and the formula divides by zero. The published file still lists coordinates for them, with a population of 0. Filter with population > 0 and decide explicitly what a zero-population zone should use, usually its geometric centroid or nothing.

5. Measure how far the centroid was from the people

shift = centroid_shift(tracts, projected_mean_centre(pts, "tract", "POP20"))
shift["shift_m"].describe()
count     258
median    429
75%     1,020
max     9,637

The shift grows with size: across tracts, the rank correlation between shift and area was 0.76. Relative to tract size it is more stable, with a median shift of 0.16 ร— โˆšarea.

6. Check whether each centre falls inside its polygon

A weighted mean is not guaranteed to land inside its zone. Two of Delaware's 258 tract centres fell outside their own tract, by 364 m and 161 m. Of the 3,100 contiguous-US counties, 13 had a population centre outside the county. One is Baltimore County, Maryland, which wraps around the separate city of Baltimore. Twelve counties' geometric centroids also fell outside the county.

If the centre must be a location in the zone, snap it to the nearest populated block inside the zone rather than using the mean.

7. Use the centres where distances start

Replace centroids with population centres as the origins for distance, travel-time and access calculations. For small urban tracts the change is a few hundred metres; for large rural ones it can move the nearest facility.

Bar chart of the distance from geometric centroid to population centre for Delaware tracts: lower quartile 172 m, median 429 m, upper quartile 1,020 m and largest 9,637 m.
Large rural tracts have the most room for their people to sit away from the middle.

Code examples

Example 1 โ€” the Census Bureau's mean centre for any grouping

import numpy as np
import pandas as pd


def census_mean_centre(points, group, weight, lat="lat", lon="lon"):
    """Population-weighted mean centre using the Census Bureau's formula."""
    phi = np.radians(points[lat])
    w = points[weight]
    wc = w * np.cos(phi)
    sums = pd.DataFrame({
        "w": w, "w_lat": w * points[lat], "wc": wc, "wc_lon": wc * points[lon],
        group: points[group],
    }).groupby(group).sum()
    out = pd.DataFrame({
        "population": sums["w"],
        "lat": sums["w_lat"] / sums["w"],
        "lon": sums["wc_lon"] / sums["wc"],
    })
    return out[out["population"] > 0]
             population        lat        lon
tract
10001040100        7315  39.237913 -75.680801
10001040201        5446  39.295250 -75.626417

The published file lists 39.237913, โˆ’75.680800 for the first tract. The difference in the sixth decimal place of longitude is 0.09 m.

Example 2 โ€” a projected version that returns geometries

import geopandas as gpd


def projected_mean_centre(points, group, weight, crs=5070):
    """Population-weighted mean of projected coordinates, as a GeoDataFrame."""
    p = points.to_crs(crs)
    frame = pd.DataFrame({"x": p.geometry.x * p[weight], "y": p.geometry.y * p[weight],
                          "w": p[weight], group: p[group]}).groupby(group).sum()
    frame = frame[frame["w"] > 0]
    geometry = gpd.points_from_xy(frame["x"] / frame["w"], frame["y"] / frame["w"])
    return gpd.GeoDataFrame({"population": frame["w"]}, geometry=geometry, crs=crs, index=frame.index)

The grouping column can be anything present on the points: a tract prefix, a school district from a spatial join, or an H3 cell. That is the advantage over the published files, which exist only for census units.

Example 3 โ€” the shift from centroid to population centre

from pyproj import Geod


def centroid_shift(polygons, centres, crs=5070):
    """Geodesic distance in metres from each polygon's centroid to its population centre."""
    geod = Geod(ellps="WGS84")
    centroids = polygons.to_crs(crs).centroid.to_crs(4326)
    centres = centres.to_crs(4326).reindex(centroids.index)
    _, _, dist = geod.inv(centroids.x.to_numpy(), centroids.y.to_numpy(),
                          centres.geometry.x.to_numpy(), centres.geometry.y.to_numpy())
    inside = polygons.to_crs(4326).geometry.contains(centres.geometry)
    return pd.DataFrame({"shift_m": dist, "centre_inside": inside}, index=centroids.index)

The centroid is computed in a projected CRS and converted afterwards. For contiguous-US counties, a centroid computed directly on longitude and latitude was a median 12 m and at most 1,203 m from the projected one. The difference is small but avoidable.

Explanation

Why a centroid describes land, not people

The geometric centroid weights every square metre equally, and census zones are drawn to hold similar numbers of people, not similar areas. A rural tract has to be large to reach its population, and its people gather in villages and along roads. The centroid lands in whatever the tract has most of, which is usually fields.

Why the gap scales with the zone

In a large zone the people have more room to be off-centre, which is why the tract shift correlated with area at 0.76. At county scale, the largest gaps are desert counties with one city at the edge. Nye County, Nevada, is 182.5 km; San Bernardino County, California, 130.5 km; Washoe County, Nevada, 125.1 km. 808 of the 3,100 counties had a gap of more than 10 km.

Why the Census formula uses cos(latitude)

A degree of longitude spans about 111 km at the equator and less further north: about 86 km at Delaware's latitude. Averaging raw longitudes gives a northern resident's degree as much weight as a southern resident's. The cosine weight corrects that without projecting. The published file uses it, which is why the formula with the term reproduced the file to 0.14 m.

Why a mean centre can fall outside

A mean is a balance point, not a location in the set. A county shaped like a ring, or one whose people live at two far ends, balances in a place where nobody lives. Baltimore County's residents surround the city of Baltimore, so their mean lands inside the city, which is a separate county-equivalent.

Why blocks and not block groups

A block group's internal point stands in for up to 3,000 people. Using it reintroduces the problem this calculation is meant to remove, only at a smaller scale. The block file is the finest free population surface the Census Bureau publishes, and for Delaware it is 21 MB zipped.

Checklist for population-weighted centres: drop zero-population zones, test the centre is inside its zone, use blocks, match the census year, and do not take centroids in degrees.
Each check is one line, and each prevents a centre that is undefined or in the wrong place.

Edge cases or notes

  • Convert the internal point strings. INTPTLAT20 is text like +39.2379130; astype(float) handles the sign.
  • Zero-population zones divide by zero. Four Delaware tracts have none; filter them before dividing.
  • The published file name is per state. CenPop2020_Mean_TR10.txt is Delaware's tracts, not a 2010 file.
  • Connecticut counties changed in 2022. The 2020 centres of population use the old counties, so 9 current planning regions had no match.
  • A centre can be outside its zone. 13 contiguous-US counties and 2 Delaware tracts had one.
  • Project before computing a centroid. GeoPandas warns on geographic CRSs, and the county centroids moved by up to 1.2 km.
  • Use block counts from the same census as the zones. 2010 blocks will not sum to 2020 tracts.
  • A median centre is a different statistic. It minimises total distance and does not have to agree with the mean.

FAQ

What is a population-weighted centroid?

The average location of a zone's residents, computed from small populated units such as census blocks. It is where the people are centred, as opposed to the geometric centroid, which is the centre of the land.

Does the Census Bureau publish them?

Yes. The 2020 centres of population are published for tracts, block groups and counties under www2.census.gov/geo/docs/reference/cenpop2020/. Computing them from blocks reproduced the tract file to within 0.14 m.

How far is the centroid from the population centre?

For Delaware tracts, a median of 429 m and a maximum of 9.6 km. For contiguous-US counties, a median of 5.5 km and a maximum of 182.5 km, in Nye County, Nevada.

Should I project the coordinates first?

Either way works if done correctly. A weighted mean of projected coordinates in EPSG:5070 was within 1.9 m of the published centres, and the Census formula with a cosine weight on longitude was within 0.14 m.

Can the population centre fall outside the polygon?

Yes. It happened for 2 of 258 Delaware tracts and 13 of 3,100 counties, including Baltimore County, whose residents surround a city that is a separate county-equivalent.

Can I use block groups instead of blocks?

You can, but each block group's point represents up to 3,000 people, so the result is pulled back towards the geometric centroid. Use blocks when they are available.