Skip to content

Commit 0321ca2

Browse files
MaxGhenisclaude
andauthored
Fix microsim skill: warn against np.array() and entity-level mismatches (#107)
Real bug encountered: np.array() on MicroSeries strips entity context, allowing silent mismatches between tax_unit (23K rows) and household (15K rows) arrays. Boolean mask from one entity applied to weights from another gives silently wrong counts (showed 1K losers instead of 719K). Changes: - Expand CRITICAL section to warn against np.array() specifically (not just .values), explaining entity mismatch as the primary danger - Add entity-level matching section with wrong/right examples - Note that household_net_income includes state tax effects and add federal-only pattern using income_tax for scoring federal bills Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a689c1b commit 0321ca2

File tree

2 files changed

+48
-28
lines changed

2 files changed

+48
-28
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Strengthen microsimulation skill warnings against np.array() and entity-level mismatches.

skills/tools-and-apis/policyengine-microsimulation-skill/SKILL.md

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,37 +25,51 @@ description: |
2525
- **Parameter Discovery**: https://policyengine.github.io/policyengine-us/usage/parameter-discovery.html
2626
- **Reform.from_dict()**: https://policyengine.github.io/policyengine-core/usage/reforms.html
2727

28-
## CRITICAL: Use calc() with MicroSeries - No Manual Weights Ever
28+
## CRITICAL: Use calc() with MicroSeries — never use np.array() or manual weights
2929

30-
**MicroSeries handles all weighting automatically. Never access .weights or do manual weight math.**
30+
**MicroSeries handles all weighting automatically. Never convert to numpy or do manual weight math.**
3131

32-
### NEVER strip weights with .values
32+
### NEVER convert MicroSeries to numpy arrays
3333

34-
`calc()` and `calculate()` return MicroSeries with embedded weights. Calling `.values` strips them and returns a plain numpy array where `.mean()` is **unweighted**.
34+
`calc()` and `calculate()` return MicroSeries with embedded weights AND entity context. Converting to numpy via `np.array()`, `.values`, or `.to_numpy()` strips both, causing:
35+
1. **Unweighted results**`.mean()` on a numpy array is unweighted
36+
2. **Entity-level mismatches** — mixing arrays from different entities (e.g., 23K tax units vs 15K households) gives silently wrong results. Numpy won't error because boolean masks still index, but the mask from one entity applied to values from another is garbage.
3537

3638
```python
37-
# ❌ WRONG - .values strips weights, .mean() is UNWEIGHTED
39+
# ❌ WRONG - np.array() strips weights AND entity context
40+
change_arr = np.array(sim.calc("income_tax", period=2026))
41+
weights = np.array(sim.calc("household_weight", period=2026))
42+
# These may be DIFFERENT LENGTHS (tax units vs households)!
43+
# Numpy boolean indexing won't error — it just gives wrong results.
44+
losers = weights[change_arr < -1].sum() # SILENTLY WRONG
45+
46+
# ❌ WRONG - .values and .to_numpy() have the same problem
3847
result = sim.calc("household_net_income", period=2026).values
3948
wrong_mean = result.mean() # Unweighted!
4049

41-
# ❌ WRONG - same problem with .to_numpy()
42-
result = sim.calc("household_net_income", period=2026).to_numpy()
43-
4450
# ✅ CORRECT - keep as MicroSeries, all operations are weighted
45-
result = sim.calc("household_net_income", period=2026)
46-
correct_mean = result.mean() # Weighted automatically!
51+
income_tax_b = baseline.calc("income_tax", period=2026)
52+
income_tax_r = reformed.calc("income_tax", period=2026)
53+
tax_change = income_tax_r - income_tax_b
54+
loser_count = (tax_change > 1).sum() # Weighted count of losers
55+
loser_share = (tax_change > 1).mean() # Weighted share of losers
56+
avg_change = tax_change.mean() # Weighted mean change
57+
total_change = tax_change.sum() # Weighted total
4758
```
4859

49-
### Correct patterns
60+
### Entity-level matching
5061

51-
```python
52-
# ✅ CORRECT - MicroSeries handles everything
53-
change = reformed.calc('household_net_income', period=2026, map_to='person') - \
54-
baseline.calc('household_net_income', period=2026, map_to='person')
55-
loser_share = (change < 0).mean() # Weighted automatically!
62+
When comparing variables across entities, use `map_to` to align them — never mix raw arrays from different entities:
5663

57-
# ❌ WRONG - never access .weights or do manual math
58-
loser_share = change.weights[change.values < 0].sum() / change.weights.sum()
64+
```python
65+
# ❌ WRONG - income_tax is tax_unit level, household_weight is household level
66+
tax = np.array(sim.calc("income_tax", period=2026)) # 23K tax units
67+
wt = np.array(sim.calc("household_weight", period=2026)) # 15K households
68+
# tax and wt have DIFFERENT lengths — any indexing is wrong
69+
70+
# ✅ CORRECT - map income_tax to household level, or just use MicroSeries
71+
tax = sim.calc("income_tax", period=2026) # tax_unit level MicroSeries
72+
losers = (tax > 0).sum() # Weighted count, correct entity
5973
```
6074

6175
## Quick start
@@ -284,7 +298,7 @@ print(f"Poverty (BHC): {baseline_rate:.1%} → {reform_rate:.1%}")
284298

285299
### Start with a BOTEC range before running code, and flag if the point estimate diverges
286300

287-
### Use `household_net_income` for total cost
301+
### Use `household_net_income` for total cost — but understand what it includes
288302

289303
**The budgetary cost of a reform is the change in `household_net_income`, NOT the change in the
290304
directly-modified program variable.** A reform that changes one program (e.g., CTC) can have
@@ -293,22 +307,27 @@ benefit clawbacks). Summing only the program-specific variable will undercount t
293307

294308
This matches the pattern used in the PolicyEngine API (`policyengine-api/endpoints/economy/compare.py`).
295309

310+
**IMPORTANT: `household_net_income` includes state tax effects.** Many states inherit federal
311+
`taxable_income`, so a federal reform that changes `taxable_income` will indirectly change
312+
state taxes too. For **federal-only** revenue estimates, use `income_tax` directly:
313+
296314
```python
297-
# Total budgetary cost = change in household_net_income
298-
baseline_hni = baseline.calc('household_net_income', period=YEAR).sum()
299-
reformed_hni = reformed.calc('household_net_income', period=YEAR).sum()
300-
total_cost = (reformed_hni - baseline_hni) / 1e9
301-
print(f"Total budgetary cost: ${total_cost:,.1f}B")
315+
# Total cost including state tax interactions
316+
total_cost = (reformed.calc('household_net_income', period=YEAR).sum() -
317+
baseline.calc('household_net_income', period=YEAR).sum()) / 1e9
318+
319+
# Federal-only revenue impact (use this when scoring a federal bill)
320+
federal_rev = (reformed.calc('income_tax', period=YEAR).sum() -
321+
baseline.calc('income_tax', period=YEAR).sum()) / 1e9
302322

303-
# Break out by federal taxes, state/local taxes, and benefits
304-
federal_tax_cost = (baseline.calc('income_tax', period=YEAR).sum() -
305-
reformed.calc('income_tax', period=YEAR).sum()) / 1e9
323+
# Break out all components
306324
state_tax_cost = (baseline.calc('state_income_tax', period=YEAR).sum() -
307325
reformed.calc('state_income_tax', period=YEAR).sum()) / 1e9
308326
benefit_cost = (reformed.calc('household_benefits', period=YEAR).sum() -
309327
baseline.calc('household_benefits', period=YEAR).sum()) / 1e9
310328

311-
print(f"Federal income tax revenue loss: ${federal_tax_cost:,.1f}B")
329+
print(f"Total budgetary cost: ${total_cost:,.1f}B")
330+
print(f"Federal income tax revenue change: ${federal_rev:,.1f}B")
312331
print(f"State/local tax revenue loss: ${state_tax_cost:,.1f}B")
313332
print(f"Benefit spending increase: ${benefit_cost:,.1f}B")
314333
```

0 commit comments

Comments
 (0)