fix(MarkovProcess): add draws= argument for per-agent CRN with iid (BUG-044)#1776
Open
llorracc wants to merge 3 commits into
Open
fix(MarkovProcess): add draws= argument for per-agent CRN with iid (BUG-044)#1776llorracc wants to merge 3 commits into
llorracc wants to merge 3 commits into
Conversation
The default `_draw_shuffled` mode (when sort_key=None) assigns specific agents to targets via `sub_rng.permutation(agents_in_j)`, which is independent of any per-agent draws. This means a shuffled simulation and an iid simulation that share the same per-agent random draws u_i produce DIFFERENT per-agent target assignments. For estimators that integrate over per-agent trajectories with shared draws across counterfactual scenarios (e.g., CRN-coupled welfare integrals), this asymmetry creates an asymptotic bias relative to iid that does not vanish with N. This commit adds an opt-in `draws=` argument that uses rank-based stratified inverse-CDF assignment instead of random permutation. For each source state j with N_j agents: 1. Sort agents by their per-agent draws u_i. 2. Compute exact quota counts K[j,k] = floor(N_j * P[j,k]) + leftover. 3. Assign sorted agents in order: first K[j,0] -> target 0, next K[j,1] -> target 1, etc. This preserves all three properties: - Quota-exact target counts (= variance reduction, original goal) - Per-agent assignment determined by per-agent draw rank (= CRN) - Asymptotic equivalence to iid via Glivenko-Cantelli (rank/N_j -> u_i as N_j -> infinity) Strictly additive: existing `shuffle=True` calls without `draws=` are bit-identical to before. Existing `sort_key=` mode unchanged. draws= and sort_key= are mutually exclusive (raises ValueError if both given). Empirical impact in HAFiscal welfare-6 (HS-Only N=49,000, simplified model): Default shuffle: ui_rec +8.26%, ui_rec_AD +6.27% bias vs iid draws= shuffle: ui_rec -0.09%, ui_rec_AD +0.05% bias vs iid Per-agent Markov match (vs iid): 92% (default) -> 99.9% (draws=) Bias under default mode is asymptotic (verified at N=49k vs N=245k: ui_rec bias 9.48% -> 9.96%, no decrease with N).
Three new tests in MarkovProcessTests: 1. test_shuffle_with_draws_converges_to_iid: at N=10000 with a 2-state chain, the draws=-mode shuffle should match per-agent iid at >99% (Glivenko-Cantelli convergence). 2. test_shuffle_with_draws_preserves_quotas: per-source-state target counts must equal floor(N_j * P[j,k]) within ±1 (the leftover-slot tolerance), confirming the variance-reduction property is preserved under the new mode. 3. test_shuffle_draws_and_sort_key_mutually_exclusive: passing both draws= and sort_key= should raise ValueError, since they specify mutually exclusive assignment modes.
Companion notebook updates for the draws= argument added to
MarkovProcess._draw_shuffled.
Two existing cells get caveats:
- Cell 5 ("Individual-agent equivalence"): explain that the marginal-
law equivalence holds within a single simulation but does NOT imply
per-agent identity preservation across shuffle-vs-iid runs sharing
the same per-agent draws.
- Cell 24 ("Cross-experiment CRN"): clarify that the CRN coupling
described holds for two SHUFFLE simulations, not across shuffle
vs iid runs.
One new section is added between cells 26 and 27 ("Per-agent CRN with
iid: the draws= argument"):
- Markdown: motivation, algorithm, Glivenko-Cantelli convergence proof
- Code: 30-line demo with two-state Markov chain at N=10000 showing
per-agent match rates (iid vs default shuffle: 73.8% from
sum-of-p^2 coincidence; iid vs draws= shuffle: 99.9%) and
quota-exact target counts.
- Markdown: discussion of when to use which mode (default / sort_key
/ new draws=) and reaffirmation of backward-compat (existing
shuffle=True calls without draws= are bit-identical to before).
Total cells: 36 -> 39.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
HARK PR: Fix asymptotic bias in
MarkovProcess._draw_shuffledfor downstream estimators sensitive to per-agent identitySummary
HARK.distributions.base.MarkovProcess._draw_shuffled(lines 240–347) implements a quota-exact shuffle for Markov state transitions. The intent is variance reduction: deterministic counts at each target state instead of binomial sampling noise.Bug: when
sort_key=None(the default), the algorithm assigns specific agents to specific targets viasub_rng.permutation(agents_in_j)(line 341). The permutation is independent of any per-agent draw, which means the same agent ends up at different targets under shuffle vs iid even when both methods see the same per-agent uniform draws. For estimators that depend on per-agent identity (e.g., welfare measurement that pairs each agent's outcomes across counterfactual scenarios), this breaks the asymptotic equivalence between shuffle and iid.Empirical signature in HAFiscal: shuffle MC produces a +8% systematic bias on UI welfare estimators relative to iid MC. The bias does NOT shrink with N (verified at N=49,000 and N=245,000). Per-agent Markov state at recession-onset matches only ~92% between shuffle and iid; ~8% of agents are at different states despite both methods seeing identical per-agent random draws.
Fix: add a new mode
assignment='inverse_cdf'(or accept an optionaldrawsargument) that implements block-stratified inverse-CDF assignment. For each source state j with N_j agents:This gives all three properties simultaneously:
Mathematical foundation
The current
_draw_shuffledalgorithmFor source state j with N_j agents and transition probabilities P[j,:]:
The marginal counts are correct (quota-exact). The per-agent assignment is independent of any per-agent draw: it's a function of the random permutation alone.
Why this asymptotically diverges from iid
Per-agent iid is
new_state[i] = searchsorted(cumsum(P[j,:]), u_i)whereu_i ∈ Uniform(0,1)is per-agent.Under iid, the per-agent assignment is determined by the per-agent draw u_i. Under shuffle, it's determined by the random permutation (independent of u_i).
Two simulations using the SAME per-agent draws u_i but DIFFERENT methods (shuffle vs iid) give:
Asymptotically, the marginal distribution at each (state, time) is the same. But the per-agent identity is not preserved across methods, so any per-agent function whose summed value depends on which specific agents end up at which states will NOT converge to the iid value at finite N.
For estimators that integrate over per-agent (state, wealth, income) trajectories built up over time, the cross-method bias is asymptotically persistent (does not vanish with N) because each method's per-agent trajectory distribution is internally consistent but differs in joint structure from the other's.
The proposed fix: rank-based stratified inverse-CDF
For source state j with N_j agents and per-agent draws u_i:
Why this is iid-equivalent asymptotically
Let F[j,k] = sum_{k'≤k} P[j,k'] (cumulative cond_mrkv).
Under iid: agent i with draw u_i goes to target k iff u_i ∈ [F[j,k-1], F[j,k]].
Under stratified: agent at rank r in source j goes to target k iff r/N_j ∈ [F[j,k-1], F[j,k]] (approximately, modulo floor effects).
For an agent with draw u_i at rank r in source j:
So the agent's target under stratified converges to the agent's target under iid as N_j → ∞. The two algorithms produce the same per-agent assignments asymptotically.
For finite N_j, the difference is concentrated at the O(√N_j) "borderline" agents whose u_i is near a cutoff F[j,k] — a small number that vanishes asymptotically as a fraction of N_j.
Why the current
sort_keymode is also wrongThe existing
sort_key-based path (lines 309-336) uses systematic sampling: minority transitions are spread evenly across the sorted order. This is also not iid-equivalent — it also produces per-agent assignments uncorrelated with per-agent draws (since the spacing isN_rem/K[jp], not the draw value).The new
inverse_cdfmode is distinct from both the random-permutation default AND the existing systematic-sampling-by-sort-key.Empirical evidence (HAFiscal welfare-6)
In
econ-ark/HAFiscal-Latest,MarkovProcess.draw(shuffle=True)is used for cohort-level Markov state propagation in the welfare-6 estimator. Welfare-6 is a per-agent integral, weighted by marginal utility — heavily dependent on which specific agents end up at which states.Test setup
Results
shuffle=TrueAsymptotic test (1×N → 5×N)
If the original shuffle's bias were finite-N noise, going to 5×N should drop bias to ~45% of its value. It did not. The bias is asymptotically persistent → real algorithmic asymmetry.
Proposed code change
Tests
Add to
HARK/tests/test_distribution.py:Backwards compatibility
Strictly additive: existing
shuffle=Truecalls without the newdrawsargument behave identically to before. Existingsort_keymode unchanged. The new behavior is opt-in viadraws=....Caveats
The downstream caller must pre-compute and pass per-agent draws u_i. This requires storing a draw vector at simulation setup time. For HARK callers using pre-computed shock histories (e.g., HAFiscal), this is natural. For callers that do not pre-compute draws, the existing default is unchanged.
The new mode does not eliminate the differences from iid at small N (e.g., when N_j is comparable to J or smaller). The existing fallback to iid for small N_j remains active.
Suggested reviewers / context
The original
_draw_shuffledwas added in HARK to provide variance reduction for MC simulations. The bug was discovered while debugging a +8% systematic bias in HAFiscal's UI welfare estimator that did not vanish with N.The math reference: per-agent iid uses
searchsorted(cumsum(P[j,:]), u_i), which is the inverse-CDF transform. The proposed fix is the RANK-BASED inverse-CDF, which is asymptotically equivalent to the per-agent inverse-CDF by Glivenko-Cantelli but produces quota-exact counts.Reviewer
@mnwhite (Matt White) — primary author of the existing
MarkovProcess._draw_shuffledmachinery and best-suited to evaluateboth the API change and the math.
Files to change
HARK/distributions/base.py(_draw_shuffledmethod)HARK/tests/test_distribution.py(add the two tests above)examples/Distributions/Shuffle_vs_IID_Draws.ipynb(companionnotebook updates — see below)
CHANGELOG.md(add note under "Added" or "Fixed")Companion notebook updates
The existing
examples/Distributions/Shuffle_vs_IID_Draws.ipynbalreadycovers the relevant theory and use cases. The PR requires both
modifications to existing cells AND one new section + code cell
so users understand when to use the new
draws=argument.Modifications to existing cells
Cell 5 (markdown, "Individual-agent equivalence") — currently
claims: "from the standpoint of any single agent, the shuffle procedure
is indistinguishable from i.i.d. sampling." This is mathematically
correct for the agent's marginal law in a SINGLE simulation, but
misleading for downstream estimators that pair per-agent outcomes
across counterfactual scenarios (e.g., welfare integrals). Add a
caveat paragraph:
Cell 24 (markdown, "Cross-experiment CRN") — currently claims that
MarkovProcess._draw_shuffled"respects common random numbers acrosscalls with different transition matrices." This holds for two SHUFFLE
calls (= one policy and one baseline, both shuffle), but does NOT hold
across shuffle-vs-iid. Add:
New section to ADD (after Cell 26, before Cell 27)
A new section titled "Per-agent CRN with iid: the
draws=argument"that:
integrate over per-agent trajectories built up over many simulation
periods, with shared per-agent draws across counterfactual scenarios.
The MU-weighted welfare integral
(1/N) Σ_i [u(c_pol_i) - u(c_base_i)] / u'(c_anchor_i)is oneexample: it depends on the SPECIFIC AGENTS at each state, not just
the marginal counts."
new_state = searchsorted(cdf, u_i)per source statemp.draw(state, shuffle=True)(no draws arg)mp.draw(state, shuffle=True, draws=u_i)new-mode > 99%
shuffle=True(default) when you only care about marginalaggregate (variance reduction with no per-agent identity needs).
shuffle=True, draws=u_iwhen downstream estimator comparesper-agent trajectories across scenarios using shared draws (e.g.,
CRN-coupled welfare integrals validated against iid).
Why both modify-and-add (not just modify)
The existing cells establish a CLAIM (single-agent equivalence + CRN
across scenarios). The new mode preserves both claims AND adds a
stronger property (per-agent identity preservation across shuffle vs iid).
The mathematical caveat needs to be inserted at the existing claims, but
the new mode and its demonstration deserve their own section because:
draws=argument).asymptotic equivalence to iid) than the existing two sections cover.
systematic bias.
Reference
This PR fixes the issue documented in HAFiscal's BUG-044 investigation:
conclusions_private/BUG-044_systematic_bias_confirmed.mdconclusions_private/math_to_code_map_for_bug044.mdBUGS_private/HAFiscal_BUG-044_*.md(TBD)Empirical fix verification (HAFiscal welfare-6, HS-Only N=49,000, simplified model):
Code/HA-Models/FromPandemicCode/welfare6_BUG044_stratified_HS_seed0/(= proposed fix)Code/HA-Models/FromPandemicCode/welfare6_BUG044_simplified_nshuf/(= per-agent iid reference)