Microsimulation¶
The Microsimulation class is the primary tool for population-level policy analysis in PolicyEngine US. It combines representative survey microdata with PolicyEngine’s tax-benefit model to estimate how policies affect the entire US population.
Getting started¶
from policyengine_us import Microsimulation
# Create a microsimulation with the default dataset
sim = Microsimulation()
# Calculate a variable for the default year (2024)
household_net_income = sim.calc("household_net_income")
# The result is a weighted MicroSeries
print(f"Total household net income: ${household_net_income.sum():,.0f}")
print(f"Mean household net income: ${household_net_income.mean():,.0f}")
Methods: calc() vs calculate()¶
The Microsimulation class provides two methods for computing variables:
calc() / calculate()¶
These methods are aliases - calc is shorthand for calculate. Both return a MicroSeries object that includes survey weights, enabling proper population-level statistics.
# These are equivalent
income = sim.calc("household_net_income")
income = sim.calculate("household_net_income")
# MicroSeries supports weighted aggregations
income.sum() # Total across population (using weights)
income.mean() # Weighted mean
income.median() # Weighted median
income.gini() # Gini coefficient
Key parameters¶
variable_name(str): The variable to calculateperiod(int or str): The time period (e.g.,2024or"2024")map_to(str): Entity to map results to (see below)use_weights(bool): IfFalse, returns raw unweighted array
# Calculate SNAP benefits for 2024
snap = sim.calc("snap", period=2024)
# Get unweighted array
snap_raw = sim.calc("snap", period=2024, use_weights=False)
Entity mapping with map_to¶
PolicyEngine US models multiple entity levels: person, tax_unit, spm_unit, family, and household. The map_to parameter transforms results between these levels.
Mapping from groups to persons¶
When mapping a group-level variable (like household_net_income) to persons, each person receives their group’s value:
# Get household income at person level (each person gets their household's value)
person_hh_income = sim.calc("household_net_income", map_to="person")
# Useful for person-weighted analysis
print(f"Average income per person: ${person_hh_income.mean():,.0f}")
Mapping from persons to groups¶
When mapping a person-level variable to groups, values are summed by default:
# Sum employment income to household level
hh_earnings = sim.calc("employment_income", map_to="household")
Common use cases¶
# Calculate poverty at person level for person-weighted statistics
person_poverty = sim.calc("in_poverty", map_to="person")
poverty_rate = person_poverty.mean()
# Map tax credits to see household-level impact
hh_eitc = sim.calc("eitc", map_to="household")
Available datasets¶
The default build¶
The default is the certified Populace build, pinned by build id and hosted as a HuggingFace dataset repository:
# Default: the certified Populace build named in DEFAULT_DATASET.
sim = Microsimulation()
Supplying another population¶
A population file has to satisfy the SPM input contract: it supplies primitive
inputs, including observed county_fips codes as five-digit strings and
source-backed is_spm_independent_minor_role values, and it must not store
formula-owned SPM outputs such as spm_unit_spm_threshold. Any observed Census
measurement is retained under a separate report-only name.
The legacy files under hf://policyengine/policyengine-us-data/ predate that
contract, so SPM measurements are not available over them:
cps_2023.h5storesspm_unit_spm_threshold, so the loader rejects it.enhanced_cps_2024.h5loads and computes tax variables, but carries no SPM independence roles, so 18 of its SPM units - each a lone 15-to-17-year-old - classify no measurement adult, andspm_unit_spm_thresholdraisesSPM_COMPOSITION_REQUIREDover the file. That is whattest_legacy_enhanced_cps_lacks_source_backed_spm_independence_roleschecks. Outputs that no longer reach the threshold, household net income and benefits among them, do compute over the file.
A household simulation that has no county input can select an SPM area explicitly instead:
sim = Microsimulation(dataset=..., spm={"geography_kind": "national"})
Entity-level single-year datasets are extended through the latest available
economic-assumption year by default. If a calculation needs only an earlier
range, set dataset_end_year to its final year. Setting it to the dataset’s own
year loads only that year:
sim = Microsimulation(dataset=single_year_dataset, dataset_end_year=2024)
dataset_end_year also works with the default dataset and entity-level HDFStore
paths. It does not apply to an existing USMultiYearDataset or to the legacy
variable-centric HDF5 format.
Tax-unit roles¶
A population file can supply the tax units its constructor built, not just
their membership, through tax_unit_role_input (person): HEAD, SPOUSE or
DEPENDENT. The certified Populace build supplies it. When every member of a
tax unit has a supplied role, is_tax_unit_head and is_tax_unit_spouse
follow it and is_tax_unit_dependent covers everyone else. Explicitly set
is_tax_unit_head, is_tax_unit_spouse or is_tax_unit_dependent inputs
still take precedence.
Without supplied roles, a unit falls back to age ordering. The oldest adult is the head, skipping adults input as tax-unit dependents unless every adult in the unit is one. The next-oldest such adult is the spouse, unless any member of the unit is separated. Without dependent inputs, that pairs an adult student with a parent as joint filers and leaves a minor living without a parent with no head.
The Populace build carries its constructor’s filing status in a
filing_status_input column. No variable has that name, so the column is
ignored. By default, filing_status is computed from the unit’s members,
including any supplied roles, and the filing rules. Reforms to those rules
apply when the formula computes the status. A direct filing_status input
supplied by a caller still overrides the formula.
Supplied roles fail closed rather than falling back. Each of these raises a
ValueError:
roles supplied for only some members of a unit;
a unit without exactly one
HEAD, or with more than oneSPOUSE.
Two consequences follow from honoring supplied roles. First, a dependent’s
income is left out of the return they are claimed on, because
irs_gross_income excludes tax-unit dependents, and so are their
above-the-line deductions. The model does not yet compute a dependent’s own
return (#9618),
so an adult dependent’s earnings leave the income tax base. Second, a minor can
head a return, which programs that identify minor parents as a tax-unit head or
spouse under 18 will treat as a minor parent
(#9619).
Filtering by geography¶
The microdata includes geographic identifiers that can be used for state-level analysis:
# Calculate for all households, then filter to a specific state
sim = Microsimulation()
in_california = sim.calc("CA") # Boolean for California residents
ca_benefits = sim.calc("household_benefits")[in_california]
print(f"California total benefits: ${ca_benefits.sum():,.0f}")
Each state has a corresponding boolean variable using its two-letter abbreviation (e.g., CA, NY, TX).
Subsampling for faster development¶
For development and testing, use subsample() to work with a smaller representative sample:
sim = Microsimulation()
sim.subsample(1_000) # Sample 1,000 households
# Or use a fraction
sim.subsample(frac=0.1) # 10% of households
# Results are still weighted to represent the full population
total = sim.calc("household_net_income").sum()
The subsample() method:
Samples households (keeping all people within sampled households)
Adjusts weights to maintain population totals
Accepts a
seedparameter for reproducibility
Winners and losers analysis¶
A common use case is comparing baseline policy to a reform:
from policyengine_us import Microsimulation
from policyengine_core.reforms import Reform
# Define a reform (example: double the EITC)
reform = Reform.from_dict(
{"gov.irs.credits.eitc.max[0].amount": {"2024-01-01.2100-12-31": 1246}},
country_id="us",
)
# Create baseline and reformed simulations
baseline = Microsimulation()
reformed = Microsimulation(reform=reform)
# Calculate net income change at person level for proper population weighting
baseline_income = baseline.calc("household_net_income", map_to="person")
reformed_income = reformed.calc("household_net_income", map_to="person")
gain = reformed_income - baseline_income
# Analyze winners and losers
winners = gain > 1 # Gained more than $1
losers = gain < -1 # Lost more than $1
no_change = ~winners & ~losers
print(f"Winners: {winners.mean():.1%}")
print(f"Losers: {losers.mean():.1%}")
print(f"No change: {no_change.mean():.1%}")
# Total cost/benefit
print(f"Net cost: ${gain.sum():,.0f}")
# Average gain among winners
print(f"Average gain for winners: ${gain[winners].mean():,.0f}")
Weight sanity checks¶
When running microsimulations, verify that weights produce sensible population totals:
sim = Microsimulation()
# Check population. The weights are the values here, so read them
# unweighted: a weighted sum would square them.
person_weight = sim.calc("person_weight", map_to="person", use_weights=False)
print(f"Total population: {person_weight.sum():,.0f}")
# Check household count
household_weight = sim.calc("household_weight", use_weights=False)
print(f"Total households: {household_weight.sum():,.0f}")
# Verify key aggregates against published statistics
total_earnings = sim.calc("employment_income").sum()
print(f"Total employment income: ${total_earnings / 1e12:.2f}T")
total_snap = sim.calc("snap").sum()
print(f"Total SNAP benefits: ${total_snap / 1e9:.1f}B")
calc returns a weighted series whose sum() multiplies each value by its
weight, which is what makes the aggregates above population totals. Summing a
weight variable that way squares the weights: with household weights of 2 and
3, sim.calc("household_weight").sum() is 13 rather than 5. Either opt out
with use_weights=False, as above, or use count(), which sums the weights
themselves - sim.calc("age", map_to="person").count() is the same population
total.
Compare these totals against official statistics to validate your analysis:
US population: ~330 million
US households: ~130 million
SNAP expenditure: ~$110 billion annually
Performance tips¶
Subsample during development: Use
sim.subsample(1_000)while iteratingAvoid redundant calculations: Store results in variables rather than recalculating
Use appropriate entities: Calculate at the natural entity level when possible
# Good: Calculate at natural entity level, then map if needed
snap = sim.calc("snap") # spm_unit level
snap_per_person = sim.calc("snap", map_to="person")
# Slower: Unnecessarily mapping multiple times
Complete example¶
from policyengine_us import Microsimulation
from policyengine_core.reforms import Reform
import pandas as pd
# Create simulations
baseline = Microsimulation()
baseline.subsample(10_000, seed=123) # For faster iteration
# Define a reform
reform = Reform.from_dict(
{
"gov.contrib.ubi_center.basic_income.amount": {"2024-01-01.2100-12-31": 1000},
"gov.contrib.ubi_center.basic_income.phase_out.rate": {
"2024-01-01.2100-12-31": 0.1
},
},
country_id="us",
)
reformed = Microsimulation(reform=reform)
reformed.subsample(10_000, seed=123)
# Calculate impacts
baseline_income = baseline.calc("household_net_income", map_to="person")
reformed_income = reformed.calc("household_net_income", map_to="person")
change = reformed_income - baseline_income
# Distributional analysis by income decile
decile = baseline.calc("household_income_decile", map_to="person")
results = []
for d in range(1, 11):
in_decile = decile == d
avg_change = change[in_decile].mean()
results.append({"Decile": d, "Average change": avg_change})
df = pd.DataFrame(results)
print(df)