Building Age-Sex Stratified Denominators for Standardization
This guide, part of Small-Area Population Denominators, builds the stratified population table that age standardisation depends on — the one input where a small misalignment silently moves every standardised rate on the map.
Problem Context & Constraints
Age standardisation exists because crude rates confound disease risk with age structure. A retirement community and a university district with identical age-specific risks have wildly different crude rates for any age-related condition, and a crude map compares their age pyramids rather than their risk. Standardisation removes that by weighting age-specific rates to a common standard population.
It only works if the age-specific rates are right, which requires the numerator and denominator to use identical strata. That is harder than it sounds. Survey tables publish ages in groups chosen for their own purposes — under 5, 5 to 9, 10 to 14, 15 to 17, 18 and 19, 20, 21, 22 to 24 — while case data carry single-year ages and the standard population uses its own bands. Three schemes, none of which line up, and every misalignment moves population from one stratum to another without moving cases.
Three constraints govern the build. Every source age group must map to exactly one analysis band, and a source group that straddles two analysis bands must be split explicitly rather than assigned wholesale. The open-ended top band — 85 and over, say — must match across numerator, denominator and standard, because it contains a disproportionate share of deaths for many outcomes. And small areas favour indirect standardisation, because direct standardisation needs a reliable rate in every stratum, which small areas rarely have.
Prerequisites
python3.11pandas2.2.2,numpy1.26.4- A population table by area, sex and source age group, with margins where it is a survey product
- Case counts by area, sex and single-year age, or in bands that nest inside the analysis bands
- A standard population in the analysis bands, recorded with its source
- Stratum-specific reference rates for indirect standardisation, typically state or national
Step-by-Step Solution
Map source groups to analysis bands with explicit splits for straddlers, verify population totals are conserved, then compute both indirect and direct standardised measures and choose by area size.
# Stratified denominators and direct/indirect standardisation for small areas.
# Pinned: pandas==2.2.2, numpy==1.26.4
import logging
import numpy as np
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("standardise")
def map_to_bands(pop: pd.DataFrame, band_map: list[tuple[str, str, float]]) -> pd.DataFrame:
"""band_map rows: (source_group, analysis_band, share). Shares per source must sum to 1.
A source group lying entirely in one band has one row with share 1.0; a group
straddling two bands has two rows whose shares split it (e.g. by single-year
distribution, or evenly by years if nothing better exists).
"""
m = pd.DataFrame(band_map, columns=["source_group", "band", "share"])
check = m.groupby("source_group")["share"].sum()
bad = check[~np.isclose(check, 1.0)]
if len(bad):
raise ValueError(f"shares do not sum to one for: {bad.to_dict()}")
unmapped = set(pop["source_group"]) - set(m["source_group"])
if unmapped:
raise KeyError(f"source groups with no mapping: {sorted(unmapped)}")
j = pop.merge(m, on="source_group")
j["population"] = j["estimate"] * j["share"]
j["moe_part"] = j["moe"] * j["share"]
out = j.groupby(["geoid", "sex", "band"], as_index=False).agg(
population=("population", "sum"),
moe=("moe_part", lambda s: float(np.sqrt((s ** 2).sum()))))
# Conservation: banding must not create or lose people.
before = pop.groupby("geoid")["estimate"].sum()
after = out.groupby("geoid")["population"].sum()
gap = (before - after).abs().max()
if gap > 1e-6:
raise AssertionError(f"population not conserved by banding (max gap {gap})")
log.info("banded %d areas into %d strata; totals conserved", out["geoid"].nunique(),
out.groupby(["sex", "band"]).ngroups)
return out
def indirect_smr(cases: pd.DataFrame, pop: pd.DataFrame, ref_rates: pd.DataFrame) -> pd.DataFrame:
"""Observed over expected, using reference stratum rates. Stable for small areas."""
e = pop.merge(ref_rates, on=["sex", "band"])
e["expected"] = e["population"] * e["ref_rate"]
exp_ = e.groupby("geoid")["expected"].sum()
obs = cases.groupby("geoid")["cases"].sum()
out = pd.DataFrame({"observed": obs, "expected": exp_}).fillna(0.0)
out["smr"] = out["observed"] / out["expected"].replace(0, np.nan)
log.info("indirect SMR for %d areas; median expected=%.1f", len(out),
out["expected"].median())
return out.reset_index()
def direct_rate(cases: pd.DataFrame, pop: pd.DataFrame, standard: pd.DataFrame,
min_stratum_cases: int = 5) -> pd.DataFrame:
"""Directly standardised rate; flags areas with too many sparse strata."""
j = cases.merge(pop, on=["geoid", "sex", "band"], how="right").fillna({"cases": 0})
j = j.merge(standard, on=["sex", "band"])
j["rate"] = j["cases"] / j["population"].replace(0, np.nan)
w = j["std_weight"] / j.groupby("geoid")["std_weight"].transform("sum")
j["wrate"] = j["rate"] * w
out = j.groupby("geoid").agg(dsr=("wrate", "sum"),
sparse=("cases", lambda c: int((c < min_stratum_cases).sum())),
strata=("cases", "size")).reset_index()
out["sparse_share"] = out["sparse"] / out["strata"]
log.info("direct rates for %d areas; median sparse-stratum share=%.2f",
len(out), out["sparse_share"].median())
return out
The conservation assertion in map_to_bands is the cheapest correctness check in the whole standardisation workflow. Banding should neither create nor lose people; if the total population of an area changes during banding, a share is wrong or a group was dropped, and every rate downstream is wrong by the same fraction. It takes one line and catches the error that otherwise takes days to find.
The sparse-stratum share reported by direct_rate is what decides between the two methods for a given area. Direct standardisation multiplies each stratum’s rate by a weight; when most strata have two or three cases, those rates are unstable and the weighted sum inherits their instability. Indirect standardisation borrows stable rates from a reference population and only needs the area’s total observed count, which is why it is the usual choice below county level.
Validation & Edge Cases
Straddling groups split evenly by default. Splitting a survey group of 15 to 19 evenly across bands of 15 to 17 and 18 to 24 assumes a uniform age distribution within the group, which is wrong near a university or a military base. Where single-year census counts exist for the area, use them to set the shares.
Open-ended top bands that differ. A survey whose top group is 85 and over, paired with a standard whose top band is 90 and over, cannot be aligned by splitting — the population above 90 is unknown. Collapse both to the coarser top band and accept the loss of detail.
Indirect ratios compared across areas. A standardised mortality ratio compares an area with the reference, not with other areas; two ratios from populations with very different age structures are not strictly comparable with each other. That is a limitation of the method, and ranking areas by it should be done with that stated.
Reference rates from a different period. Expected counts built on reference rates from a different year inherit any secular trend between the two years, which shifts every ratio together. Use reference rates from the same period as the observed counts.
Cases in strata with zero population. A case recorded in a stratum whose denominator is zero — an 80-year-old in a tract the survey estimates has nobody over 75 — is a signal of either a denominator estimate error or a geocoding problem. Count these and investigate rather than dividing by zero.
INFO standardise banded 1842 areas into 36 strata; totals conserved
INFO standardise direct rates for 1842 areas; median sparse-stratum share=0.72
INFO standardise indirect SMR for 1842 areas; median expected=6.4
Compliance Notes
Record the band mapping with every share and its basis, the conservation check result, the open-ended top band used, the standard population and its source, the reference rates and their period for indirect measures, the method chosen per area with the sparse-stratum share that drove the choice, and any cases found in zero-population strata.
Standardised rates are compared across agencies more than almost any other surveillance product, and two agencies standardising the same data to different standards or bands will publish different numbers for the same county. Stating the standard population and the bands in the product metadata is what makes those comparisons resolvable rather than a source of recurring confusion.
Related Topics
- Small-Area Population Denominators — the parent guide on denominator sources and reliability.
- Handling ACS Margins of Error in Small-Area Rates — propagating stratum margins into the standardised measure.
- Bayesian Disease Mapping & Rate Smoothing — smoothing standardised ratios when expected counts are small.