PolicyEngine Python analysis
The policyengine package (repo: PolicyEngine/policyengine.py) is the canonical Python
interface for both single-household calculations and population microsimulation. It pins a
certified model + data bundle, so results are reproducible and the data provenance is known.
Originally verified against policyengine 4.21.0 (2026-07); the marked examples re-run in CI against the latest release (5.0.1 at 2026-08). Re-verify the bundle when precision matters (see "Checking what you're running" below).
Setup
Country models are extras — bare policyengine installs neither:
uv pip install "policyengine[us]" # US model + certified US data bundle
uv pip install "policyengine[uk]" # UK model (population data needs HUGGING_FACE_TOKEN)
uv pip install "policyengine" # both countries
Analysis always runs on the latest released policyengine (>=5.0.1; resolve "latest"
from PyPI as described in "Checking what you're running"). Each release pins exactly-matched
country-model versions and the certified data bundle, which is what makes results
reproducible. Directly-imported country packages (policyengine_us / policyengine_uk) are
for model development and tests, not for analysis compute.
Household calculations (fast, ~2 GB RAM)
calculate_household answers "what does this specific household get/pay?" — no dataset
download, runs in seconds.
import policyengine as pe
result = pe.us.calculate_household(
people=[{"age": 40, "employment_income": 50_000}, {"age": 8}],
tax_unit={"filing_status": "HEAD_OF_HOUSEHOLD"},
household={"state_code": "CA", "county_fips": "06037"},
year=2026,
extra_variables=["income_tax"],
)
assert result.tax_unit.ctc == 2_200 # OBBBA CTC, 2026
assert round(result.household.household_net_income) == 46_369
print(result.spm_unit.snap, result.tax_unit.eitc, result.tax_unit.income_tax)
Result access is dot-attribute on singular entities — result.tax_unit.ctc, never
result.tax_unit[0]["ctc"]. Only result.person is a list (result.person[0].age). Entities:
person[i], marital_unit, family, spm_unit, tax_unit, household (US);
person[i], benunit, household (UK).
Each entity exposes a limited default column set — accessing anything else raises
AttributeError listing what's available and telling you the fix: pass
extra_variables=["variable_name"] to materialize it (as with income_tax above; the
default person columns don't include it).
Reforms are a flat dict of {parameter_path: value}:
import policyengine as pe
baseline = pe.us.calculate_household(
people=[{"age": 40, "employment_income": 50_000}, {"age": 8}],
tax_unit={"filing_status": "HEAD_OF_HOUSEHOLD"},
household={"state_code": "CA", "county_fips": "06037"},
year=2026,
)
reformed = pe.us.calculate_household(
people=[{"age": 40, "employment_income": 50_000}, {"age": 8}],
tax_unit={"filing_status": "HEAD_OF_HOUSEHOLD"},
household={"state_code": "CA", "county_fips": "06037"},
year=2026,
reform={"gov.irs.credits.ctc.amount.base[0].amount": 3_000},
)
assert reformed.tax_unit.ctc == 3_000
assert reformed.household.household_net_income - baseline.household.household_net_income == 800
UK works the same way:
uk = pe.uk.calculate_household(
people=[{"age": 35, "employment_income": 50_000}],
year=2026,
)
uk.person[0].income_tax
uk.household.hbai_household_net_income
To sweep an input (e.g. earnings 0→200k for an MTR curve), pass axes. Every variable on the
result then comes back as a list of values across the sweep instead of a scalar:
import policyengine as pe
result = pe.us.calculate_household(
people=[{"age": 40}],
tax_unit={"filing_status": "SINGLE"},
household={"state_code": "TX", "county_fips": "48201"},
year=2026,
axes=[[{"name": "employment_income", "min": 0, "max": 200_000, "count": 401}]],
)
earnings = result.person[0].employment_income # [0.0, 500.0, ..., 200000.0]
net = result.household.household_net_income # list of 401 values
assert len(earnings) == len(net) == 401
assert earnings[1] == 500.0
Population analysis (heavy: tens of GB RAM, minutes per simulation)
The canonical population flow builds year-specific datasets from the certified bundle, then
runs baseline and reform Simulations:
import policyengine as pe
from policyengine.core import Simulation
datasets = pe.us.ensure_datasets(years=[2026], data_folder="./data")
dataset = next(iter(datasets.values()))
baseline = Simulation(dataset=dataset, tax_benefit_model_version=pe.us.model)
reform = Simulation(
dataset=dataset,
tax_benefit_model_version=pe.us.model,
policy={"gov.irs.credits.ctc.amount.base[0].amount": 3_000},
)
analysis = pe.us.economic_impact_analysis(baseline, reform)
budget = pe.us.calculate_budgetary_impact(baseline, reform)
print(f"Total budgetary impact: ${budget.total / 1e9:,.1f}B "
f"(federal ${budget.federal / 1e9:,.1f}B, state ${budget.state / 1e9:,.1f}B)")
for d in analysis.decile_impacts.outputs:
print(d.decile, d.absolute_change, d.relative_change)
Key facts:
- Call
economic_impact_analysisbefore (or instead of) manualensure(). It configures conditionally-materialized output variables (e.g.federal_benefit_cost) and ensures both simulations. If you callSimulation.ensure()yourself and thencalculate_budgetary_impact, it fails with "variable ... is not present in simulation output data" — the fix ispe.us.economic_impact_analysis(baseline, reform)first, orconfigure_budgetary_impact_variableson each simulation beforeensure(). economic_impact_analysisreturns aPolicyReformAnalysis:decile_impacts,program_statistics,baseline_poverty/reform_poverty(by measure and demographic group),baseline_inequality/reform_inequality(Gini, top shares). EachOutputCollectionexposes.outputs(typed) and.dataframe.calculate_budgetary_impactpartitions intototal/federal/state/unattributed. Sign convention: positive = government better off.totalis Δhousehold_tax − Δhousehold_benefits plus shared-funding health-program cost (Medicaid/CHIP/MSP), so it captures cascading interactions — never score a reform by summing the directly-modified program variable alone.- Memory/time: a full US population simulation is tens of GB of RAM and several minutes; a baseline+reform pair with full outputs took ~15 minutes on a 128 GB machine. Run ONE heavy simulation pipeline at a time. Household calculations are the cheap path — prefer them whenever the question is about specific households.
Simulation(policy={...})takes the same flat reform dict ascalculate_household.
Aggregates and filters
For a single number (program spending, revenue, caseload), use Aggregate /
ChangeAggregate instead of the full analysis bundle:
from policyengine.outputs import Aggregate, AggregateType
ca_snap = Aggregate(
simulation=baseline,
variable="snap",
aggregate_type=AggregateType.SUM,
filter_variable="state_code",
filter_variable_eq="CA",
)
ca_snap.run()
ca_snap.result
The managed country-package surface (MicroSeries)
managed_microsimulation returns a country-package Microsimulation pinned to the certified
bundle — the required route whenever you want the familiar .calc() / MicroSeries analyst
surface (pe.uk.managed_microsimulation() is the UK twin; kwargs such as reform= forward
to the country package's constructor):
import policyengine as pe
sim = pe.us.managed_microsimulation() # certified default dataset
sim.policyengine_bundle # provenance: model + data release pins
income = sim.calc("household_net_income", period=2026, map_to="person")
income.mean() # weighted mean
income.median() # weighted median
income.gini() # weighted Gini
(income < 30_000).mean() # weighted share
MicroSeries (from microdf-python, still a live dependency of policyengine-us and core)
embeds survey weights in every operation. Discipline:
- Never strip weights: no
np.array(series),.values,.to_numpy(),.astype(...)mid-analysis, and never fetchhousehold_weight/person_weightyourself —map_to=handles entity projection and weighting. - Weighted stats are the methods themselves:
.sum(),.mean(),.median(),.quantile(q),.gini(),.top_x_pct_share(x). (decile_values()/percentile()do not exist.) - Don't subtract boolean MicroSeries (numpy ≥2.4 raises
TypeError): compute.mean()rates first, then subtract floats. - US
Microsimulationuses.calc(...); UK uses.calculate(...). - Arbitrary dataset URIs require
allow_unmanaged=True— if you reach for that, you are leaving the certified bundle and should say so in your results.
Direct from policyengine_us import Microsimulation (unmanaged, whatever data it defaults
to) is deprecated for analysis — its default dataset can lag the certified bundle, so
results are not provenance-known. It remains fine for country-model development and tests
inside the model repos. Any population number you report must come through the managed
surface above.
Datasets
Certified defaults resolve automatically — do not pass raw hf:// URIs:
| Name | What | Notes |
|---|---|---|
| populace_us_2024 | US default (Microcosm, ~57k households calibrated to ~30k+ admin targets) | public |
| populace_us_2024_acs_local | US local-area build (~1.6M households, ACS multispine, PUMA-assigned CD-119/county/state) | load by name for state/district work; never selected implicitly |
| populace_uk_2023 | UK default (Microcosm) | private HF repo — set HUGGING_FACE_TOKEN |
The pre-2026 datasets are gone:
<!-- stale-ok -->enhanced_cps_2024 and enhanced_frs_2023_24 are superseded by Microcosm, and the per-area
files (hf://policyengine/policyengine-us-data/states/*.h5, districts/*.h5) no longer
exist — policyengine-us-data is archived. Local-area analysis = filter one national dataset
by its geography columns, never a per-area file. See the policyengine-data skill for how
Microcosm is built and calibrated.
Regional analysis
States (works on the certified national dataset — it carries state_fips / state_code):
# Option A: filter any aggregate (see Aggregate example above).
# Option B: scope a Simulation to one state's rows.
from policyengine.core import Simulation
from policyengine.core.scoping_strategy import RowFilterStrategy
datasets = pe.us.ensure_datasets(datasets=["populace_us_2024_acs_local"], years=[2024])
dataset = datasets["populace_us_2024_acs_local_2024"]
ca = Simulation(
dataset=dataset,
tax_benefit_model_version=pe.us.model,
scoping_strategy=RowFilterStrategy(variable_name="state_fips", variable_value=6),
)
# Registry of ready-made state regions with scoping strategies:
states = pe.us.model.region_registry.get_by_type("state")
Congressional districts (district_geoid = state FIPS × 100 + district number; at-large = 00):
from policyengine.outputs import compute_us_congressional_district_impacts
impacts = compute_us_congressional_district_impacts(
baseline_simulation=baseline, reform_simulation=reform,
)
for row in impacts.district_results:
print(row["district_geoid"], row["avg_change"], row["winner_percentage"])
UK equivalents: compute_uk_constituency_impacts (groups by the dataset's
constituency_code_oa column) and compute_uk_local_authority_impacts.
For state and CD breakdowns, prefer populace_us_2024_acs_local (calibrated to state admin
totals and state/CD population); read its release-gate summary for reviewed limitations.
Reform dictionaries and parameter paths
Three reform formats exist — match the surface you're using:
calculate_household(reform={...})andSimulation(policy={...}): flat{"gov.path.to.param": value}.- Country-package
Microsimulation(reform=...)— reached in analysis viamanaged_microsimulation(reform=...), which forwards the kwarg: date-ranged dicts —{"gov.path": {"2026-01-01.2100-12-31": value}}— passed directly, or wrapped withReform.from_dict(..., "policyengine_us"). The UK package also accepts plain dicts and converts internally (its reform surface isScenario— see the policyengine-uk skill).
Bracket/scale parameters index the scale node directly — there is no .brackets in the
path:
"gov.irs.credits.ctc.amount.base[0].amount" # correct
"gov.irs.credits.ctc.amount.base.brackets[0].amount" # wrong — no such path
Discover and verify paths against the live parameter tree before using them:
<!-- verify -->from policyengine_us import CountryTaxBenefitSystem
p = CountryTaxBenefitSystem().parameters
assert p.gov.irs.credits.ctc.amount.base[0].amount("2026-01-01") == 2_200
# Browse: p.gov.irs.credits.ctc.amount.children keys, or grep the YAML tree in
# policyengine-us/policyengine_us/parameters/gov/.
Paths change names (e.g. ctc.amount.base, not base_amount) — a ValueError listing real
children means you guessed; browse instead. Always check the baseline value in your
simulation year before interpreting a reform (post-OBBBA law: CTC $2,200 in 2026, SALT cap
$40,400 JOINT in 2026, etc. — don't trust pre-2026 summaries).
Cost methodology
- Population cost of a reform = change in government budget, from
calculate_budgetary_impact(preferred: handles tax/benefit/health-program composition and the federal/state split). - On the MicroSeries surface, Δ
household_net_incometotal ≈ −Δbudget but includes state tax interactions (many states inherit federaltaxable_income); federal-only revenue is Δincome_tax. - Sanity-check against a back-of-envelope (rate × base × takeup) and, when one exists, a published score (JCT/CBO/TPC — see the policyengine-prior-scores skill) before reporting.
Checking what you're running
<!-- verify -->import importlib.metadata as md
import json
from pathlib import Path
versions = {p: md.version(p) for p in ("policyengine", "policyengine-us")}
manifest = json.loads(
Path(md.distribution("policyengine").locate_file("policyengine/data/bundle/manifest.json"))
.read_text()
)
us = manifest["data_releases"]["us"]
print(versions, us["default_dataset"], us["build_id"])
assert us["default_dataset"].startswith("populace_us")
When the user asks for "the latest," verify the released version on PyPI
(https://pypi.org/pypi/policyengine/json) and install that exact version — never infer
"latest" from a lockfile or a stale local checkout.