How to Compare Asthma Estimates With Air Pollution in Python

On this page

Problem statement

A natural question to ask of a county health map is whether it follows air pollution: are asthma rates higher where fine particulate matter (PM2.5) is higher? The data to try it are open and easy to join: the EPA publishes annual PM2.5 for every regulatory monitor, and CDC PLACES publishes adult asthma prevalence for every county. The join takes a few lines. Reading the result needs more care, because monitors cover only some counties, the monitor file has several rows per monitor, and the asthma estimates are produced by a model that knows nothing about air quality.

This page computes 2023 county PM2.5 from EPA monitors, joins it to PLACES 2025 adult asthma estimates, and shows what the correlation can and cannot say.

Quick answer

Keep one annual mean per monitor (complete, with exceptional events included), average monitors by county, and join on the county FIPS code:

pm = AQS[(AQS["Parameter Code"] == 88101) & (AQS["Pollutant Standard"] == "PM25 Annual 2024")
         & (AQS["Event Type"].isin(["No Events", "Events Included"])) & (AQS["Completeness Indicator"] == "Y")]
county_pm = pm.groupby("GEOID")["Arithmetic Mean"].mean().rename("pm25")
M = PLACES.join(county_pm, how="inner")
print(f"{pm.groupby(['GEOID', 'Site Num', 'POC']).ngroups} monitors β†’ {len(county_pm)} counties; joined to PLACES: {len(M)}")
print(M.corr().loc[["pm25"], ["CASTHMA", "COPD", "CSMOKING", "OBESITY"]].round(2).to_string())
1157 monitors β†’ 563 counties; joined to PLACES: 520
      CASTHMA  COPD  CSMOKING  OBESITY
pm25    -0.07  0.13      0.15     0.29
Two scatter plots of 520 monitored counties: adult asthma prevalence from PLACES against annual PM2.5 in 2023 shows no pattern, r minus 0.07, with counties on both sides of the 9.0 microgram standard; asthma against adult smoking rises clearly, r 0.56. Two scatter plots of 520 monitored counties: adult asthma prevalence from PLACES against annual PM2.5 in 2023 shows no pattern, r minus 0.07, with counties on both sides of the 9.0 microgram standard; asthma against adult smoking rises clearly, r 0.56.
PLACES asthma follows smoking, which is built from the same survey, not air pollution.

The right rows, monitor coverage, a cautious correlation

1. Pick the right rows

The annual file repeats each monitor once for every pollutant standard, and for monitors affected by exceptional events, such as wildfire smoke, once with the events and once without:

pm_all = AQS[AQS["Parameter Code"] == 88101]
print(pm_all["Pollutant Standard"].value_counts().to_string())
print(pm_all[pm_all["Pollutant Standard"] == "PM25 Annual 2024"]["Event Type"].value_counts().to_string())
Pollutant Standard
PM25 24-hour 1997    3026
PM25 24-hour 2006    3026
PM25 24-hour 2012    3026
PM25 24-hour 2024    3026
PM25 Annual 1997     3026
PM25 Annual 2006     3018
PM25 Annual 2024     3015
PM25 Annual 2012     3011
Event Type
No Events                    1245
Events Included               660
Concurred Events Excluded     557
Events Excluded               553

For health, the concentration people breathed is the one with events included; the version without them is for regulatory decisions. One standard's rows give one mean per monitor and instrument (POC).

2. Count what the monitors cover

pop = POPEST.set_index("GEOID").POPESTIMATE2023
print(f"counties with a complete PM2.5 monitor: {len(county_pm)} of {len(pop)}; "
      f"people living in them: {pop.reindex(county_pm.index).sum() / pop.sum():.1%}")
print(f"county PM2.5: median {county_pm.median():.1f} Β΅g/mΒ³; above the 2024 annual standard of 9.0: {(county_pm > 9.0).sum()} counties, "
      f"{pop.reindex(county_pm[county_pm > 9.0].index).sum() / 1e6:.0f} million people")
lost = pd.Series(sorted(set(county_pm.index) - set(PLACES.index))).str[:2].value_counts()
print("monitored counties with no PLACES estimate, by state code:", lost.to_dict())
print("states absent from PLACES county data:", sorted({g[:2] for g in POPEST.GEOID} - {g[:2] for g in PLACES.index}))
counties with a complete PM2.5 monitor: 563 of 3144; people living in them: 63.7%
county PM2.5: median 8.5 Β΅g/mΒ³; above the 2024 annual standard of 9.0: 226 counties, 108 million people
monitored counties with no PLACES estimate, by state code: {'42': 23, '21': 13, '09': 5, '72': 1, '80': 1}
states absent from PLACES county data: ['21', '42']

The 2025 PLACES release has no county estimates for Kentucky (21) or Pennsylvania (42). Connecticut's five monitored counties carry the old county codes in the EPA file, while PLACES uses the planning regions that replaced them (see why Connecticut goes missing); code 72 is Puerto Rico, and 80 is the EPA's code for monitors in Mexico.

Map of counties in the lower 48 states with a complete PM2.5 monitor in 2023, coloured from 2 to 12 micrograms per cubic metre, and counties above the 2024 standard of 9.0 outlined; most counties are grey with no monitor; high values cluster in California's Central Valley and Los Angeles basin, the Ohio and Mississippi valleys, the Northeast corridor and several northern Plains and Montana counties, low values in the Rockies. Map of counties in the lower 48 states with a complete PM2.5 monitor in 2023, coloured from 2 to 12 micrograms per cubic metre, and counties above the 2024 standard of 9.0 outlined; most counties are grey with no monitor; high values cluster in California's Central Valley and Los Angeles basin, the Ohio and Mississippi valleys, the Northeast corridor and several northern Plains and Montana counties, low values in the Rockies.
Where PM2.5 is measured, and where it is not.

3. Read the correlation for what it is

PLACES estimates are predictions from a small-area model. Its data sources, as the dataset's own description lists them, are the BRFSS survey, Census population estimates and the American Community Survey; air quality is not among them, so the estimates can follow PM2.5 only as far as demographics do. The same description warns that the model "cannot detect effects due to local interventions":

print(f"asthma against smoking: r = {M.CASTHMA.corr(M.CSMOKING):.2f}; against PM2.5: r = {M.CASTHMA.corr(M.pm25):.2f}")
hi = M[M.pm25 > 9.0]
print(f"adult asthma, age-adjusted: {hi.CASTHMA.mean():.1f}% in the {len(hi)} counties above 9.0 Β΅g/mΒ³, "
      f"{M[M.pm25 <= 9.0].CASTHMA.mean():.1f}% in the other {len(M) - len(hi)}")
asthma against smoking: r = 0.56; against PM2.5: r = -0.07
adult asthma, age-adjusted: 10.6% in the 196 counties above 9.0 Β΅g/mΒ³, 10.7% in the other 324

The comparison within one state

The data used on this page

The EPA's pre-generated annual concentration file for 2023 (annual_conc_by_monitor_2023.zip, Air Quality System), CDC PLACES county estimates from the 2025 release (swc5-untb, age-adjusted prevalence among adults, survey year 2023), and the Census Bureau's Vintage 2024 county population estimates.

import zipfile
from pathlib import Path

import numpy as np
import pandas as pd
import requests

Path("data").mkdir(exist_ok=True)
SRC = {"annual_conc_by_monitor_2023.zip": "https://aqs.epa.gov/aqsweb/airdata/annual_conc_by_monitor_2023.zip",
       "co-est2024-alldata.csv": "https://www2.census.gov/programs-surveys/popest/datasets/2020-2024/counties/totals/co-est2024-alldata.csv"}
for name, url in SRC.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=300).content)
AQS = pd.read_csv(zipfile.ZipFile("data/annual_conc_by_monitor_2023.zip").open("annual_conc_by_monitor_2023.csv"),
                  low_memory=False, dtype={"State Code": str, "County Code": str})
AQS["GEOID"] = AQS["State Code"].str.zfill(2) + AQS["County Code"].str.zfill(3)
rows = requests.get("https://data.cdc.gov/resource/swc5-untb.json", timeout=120, params={
    "$where": "datavaluetypeid = 'AgeAdjPrv' AND measureid in ('CASTHMA', 'COPD', 'CSMOKING', 'OBESITY')",
    "$select": "locationid, measureid, data_value", "$limit": 50000}).json()
PLACES = pd.DataFrame(rows).pivot(index="locationid", columns="measureid", values="data_value").astype(float)
est = pd.read_csv("data/co-est2024-alldata.csv", encoding="latin-1", dtype={"STATE": str, "COUNTY": str}).query("SUMLEV == 50")
POPEST = est.assign(GEOID=est.STATE + est.COUNTY)[["GEOID", "POPESTIMATE2023"]]
print(f"{len(AQS):,} rows in the annual file; PLACES: {len(PLACES)} counties")
82,887 rows in the annual file; PLACES: 2958 counties

Example 1: within one state

Comparing counties within a state removes state-level differences in the survey and the model:

M["state"] = M.index.str[:2]
by_state = M.groupby("state").filter(lambda d: len(d) >= 15).groupby("state").apply(
    lambda d: pd.Series({"counties": len(d), "r": d.CASTHMA.corr(d.pm25)}), include_groups=False)
print(f"states with 15 or more monitored counties: {len(by_state)}; within-state r from {by_state.r.min():.2f} to {by_state.r.max():.2f}, "
      f"median {by_state.r.median():.2f}")
states with 15 or more monitored counties: 13; within-state r from -0.50 to 0.52, median -0.14

Within states the correlation runs from clearly negative to clearly positive, with a median below zero: no consistent relationship, which is what a model without air quality in it should produce.

A near-zero correlation is not a null result

What goes into a CDC PLACES county estimate, and what does not. what a PLACES estimate can respond toData sources of the modelBRFSS survey answers, 2023Census population estimatesAmerican Community Surveycombined in a small-area modelNot in the modelair quality: PM2.5, ozonelocal interventionsemergency visits for asthmachildren (adults only)Asthma against PM2.5 across 520 counties: r = βˆ’0.07 spatialworkflow.io
What a PLACES estimate can respond to.

A near-zero correlation here is not evidence that air pollution does not cause asthma. The adult asthma estimate is a model prediction from survey responses about diagnosed asthma, carried to counties by population and American Community Survey data. It cannot respond to a county's air, because air is not among its inputs, and diagnosed asthma prevalence also reflects access to care and diagnosis. The effect of air pollution on asthma is established from studies that measure exposure and outcomes for the same people and days, such as emergency visits for asthma on high-pollution days.

The useful questions this join can answer are different: which counties with poor air quality also carry a high estimated burden of asthma, where monitors are missing, and how many people live in counties above a standard. For outcomes that do respond to air quality, use measured data, such as hospital or emergency department visits from a state's environmental public health tracking program.

Monitor coverage, instruments and years

Comparing health estimates with air pollution: problems and fixes. asthma and air quality trapsTrapInsteadevery row of the annual fileone standard, complete, eventsincludedunmonitored counties read as cleancount coverage: 563 of 3,144r = βˆ’0.07 read as no effectuse measured outcomes: air is notan inputKentucky and Pennsylvania assumedcheck: no 2025 county rows foreitherEPA AQS 2023 and CDC PLACES 2025 spatialworkflow.io
Asthma and air quality traps.
  • Monitor coverage: counties without a monitor are not "clean"; use modelled surfaces (such as EPA's downscaler or satellite-based estimates) for full coverage.
  • Several monitors per county: averaging them is crude in large counties; population-weighted interpolation is better.
  • Instruments: one site can have several instruments (POCs); this page averages all complete ones.
  • Years: PLACES 2025 uses 2023 survey data, which matches 2023 PM2.5; long-term exposure would use several years.
  • Child asthma: PLACES estimates adults; childhood asthma, more closely tied to air pollution, is not in it.

FAQ

Where can I get annual PM2.5 by county?

From the EPA's pre-generated Air Quality System files, which give annual statistics for each monitor; average complete monitors by county.

Does asthma prevalence follow air pollution in CDC PLACES?

Barely, and it should not be expected to: PLACES estimates are modelled from demographics and do not include air quality.

Which PM2.5 rows should I use from the EPA annual file?

One pollutant standard (for example PM25 Annual 2024), complete monitors, and for health purposes the rows with exceptional events included.

How many Americans live in counties with a PM2.5 monitor?

In 2023, counties with a complete PM2.5 monitor held about two thirds of the US population.