Skip to content

Commit 07e1752

Browse files
authored
Merge pull request #709 from PolicyEngine/uk-stochastic-680
Port the UK stochastic layer as declarative source stages
2 parents 1131576 + 658b9f1 commit 07e1752

39 files changed

Lines changed: 4979 additions & 66 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add the UK FRS stochastic take-up, draw, and BRMA source-stage layer.

packages/microcosm-build/src/microcosm/build/country_spec.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"spine_agreement",
9191
"support",
9292
"tail_concentration",
93+
"take_up_signal",
9394
"target_fit",
9495
"target_profile_coverage",
9596
"target_surface",

packages/microcosm-build/src/microcosm/build/source_manifest.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,12 @@
4343
"aggregate_person_to_household",
4444
"aggregate_person_to_tax_unit",
4545
"assign_by_plan_type",
46+
"assign_binary_from_banded_rates",
4647
"assign_binary_from_rate",
48+
"assign_binary_with_anchored_residual",
49+
"assign_clipped_normal",
50+
"assign_uniform_draw",
51+
"aggregate_person_to_benunit",
4752
"annualize_periodic_amounts",
4853
"assemble_group_entities",
4954
"attribute_self_employed_health_premiums",
@@ -112,6 +117,7 @@
112117
"read_acs_rent_donor",
113118
"replace_zero_weight_spi_support",
114119
"retain_adjudicated_frs_hmrc_leaves",
120+
"sample_categorical_from_count_table",
115121
"replace_sentinels",
116122
"split_component_by_share",
117123
"strict_read_private_table",
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""Pure helpers for identity-keyed stochastic source assignments."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
from collections.abc import Mapping, Sequence
7+
from typing import Any
8+
9+
import numpy as np
10+
from scipy.stats import norm
11+
12+
__all__ = [
13+
"assign_binary_from_rate",
14+
"assign_binary_with_anchored_residual",
15+
"clipped_normal_from_uniforms",
16+
"sample_categorical_from_counts",
17+
"stable_identity_uniforms",
18+
]
19+
20+
21+
def stable_identity_uniforms(
22+
ids: Sequence[object] | np.ndarray,
23+
*,
24+
seed: int,
25+
salt: str,
26+
) -> np.ndarray:
27+
"""Return deterministic U[0,1) draws keyed by ``seed:salt:id``."""
28+
29+
denominator = float(2**64)
30+
return np.asarray(
31+
[
32+
int.from_bytes(
33+
hashlib.blake2b(
34+
f"{int(seed)}:{salt}:{value}".encode(),
35+
digest_size=8,
36+
).digest(),
37+
byteorder="big",
38+
signed=False,
39+
)
40+
/ denominator
41+
for value in ids
42+
],
43+
dtype=np.float64,
44+
)
45+
46+
47+
def assign_binary_from_rate(
48+
draws: Sequence[float] | np.ndarray,
49+
rate: float,
50+
) -> np.ndarray:
51+
"""Assign a boolean flag from uniform draws and a scalar rate."""
52+
53+
rate = _validate_rate(rate)
54+
return np.asarray(draws, dtype=np.float64) < rate
55+
56+
57+
def assign_binary_with_anchored_residual(
58+
draws: Sequence[float] | np.ndarray,
59+
rate: float,
60+
anchor: Sequence[bool] | np.ndarray | None = None,
61+
) -> np.ndarray:
62+
"""Assign a flag while forcing reported-recipient anchors to true.
63+
64+
The target count is ``int(rate * n_units)`` over the full unweighted
65+
population. Anchored overshoot is accepted; the residual fills only
66+
non-anchored rows.
67+
"""
68+
69+
draws = np.asarray(draws, dtype=np.float64)
70+
rate = _validate_rate(rate)
71+
if anchor is None:
72+
return draws < rate
73+
anchor = np.asarray(anchor, dtype=bool)
74+
if anchor.shape != draws.shape:
75+
raise ValueError("anchor and draws must align")
76+
result = anchor.copy()
77+
target = int(rate * len(draws))
78+
remaining_needed = max(0, target - int(anchor.sum()))
79+
non_anchored = ~anchor
80+
if remaining_needed == 0 or not non_anchored.any():
81+
return result
82+
adjusted = remaining_needed / int(non_anchored.sum())
83+
result |= non_anchored & (draws < adjusted)
84+
return result
85+
86+
87+
def clipped_normal_from_uniforms(
88+
draws: Sequence[float] | np.ndarray,
89+
*,
90+
mean: float,
91+
sd: float,
92+
lower: float,
93+
upper: float,
94+
) -> np.ndarray:
95+
"""Map U[0,1) draws through a clipped normal inverse CDF."""
96+
97+
if sd <= 0:
98+
raise ValueError("sd must be positive")
99+
values = norm.ppf(np.asarray(draws, dtype=np.float64), loc=mean, scale=sd)
100+
return np.clip(values, lower, upper)
101+
102+
103+
def sample_categorical_from_counts(
104+
draws: Sequence[float] | np.ndarray,
105+
*,
106+
counts: Mapping[str, int | float],
107+
) -> np.ndarray:
108+
"""Sample category names by inverse CDF from a count mapping."""
109+
110+
draws = np.asarray(draws, dtype=np.float64)
111+
if not counts:
112+
raise ValueError("count table cell is empty")
113+
categories: list[str] = []
114+
weights: list[float] = []
115+
for category, count in sorted(counts.items()):
116+
weight = float(count)
117+
if weight < 0:
118+
raise ValueError(f"{category!r} has a negative count")
119+
if weight > 0:
120+
categories.append(str(category))
121+
weights.append(weight)
122+
total = float(sum(weights))
123+
if total <= 0:
124+
raise ValueError("count table cell has no positive counts")
125+
cdf = np.cumsum(np.asarray(weights, dtype=np.float64)) / total
126+
indexes = np.searchsorted(cdf, draws, side="right")
127+
indexes = np.minimum(indexes, len(categories) - 1)
128+
return np.asarray([categories[index] for index in indexes], dtype=object)
129+
130+
131+
def _validate_rate(rate: Any) -> float:
132+
value = float(rate)
133+
if not 0.0 <= value <= 1.0:
134+
raise ValueError(f"rate must be in [0, 1], got {value}.")
135+
return value

0 commit comments

Comments
 (0)