Skip to content

Commit f316312

Browse files
juaristi22claude
andcommitted
Compare BRMA distributions at the sampler's native cell margin
The distribution diagnostic compared household BRMA shares per region against the rents table's own regional margin — mixing our benunit LHA category composition with the table's lettings-weighted composition, which read as double-digit z-scores on a correct sampler. The comparison now re-derives the benunit-level assignment and checks each (region, LHA category) cell against its conditional distribution, reporting per-row z and the max |z|, with the same SDC masking. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3aca401 commit f316312

2 files changed

Lines changed: 122 additions & 46 deletions

File tree

packages/microcosm-build/tests/test_uk_stochastic_tools.py

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import numpy as np
77
import pandas as pd
8+
import pytest
89

910
from microcosm.build.uk_runtime.national_frame import uk_national_frame
1011

@@ -155,27 +156,48 @@ def continuous_entry(self, key: str):
155156
}
156157

157158

158-
def test_brma_distribution_masks_small_counts() -> None:
159+
def test_brma_cell_distribution_masks_small_counts_and_reports_z() -> None:
159160
tool = _load_tool("emit_uk_brma_distribution")
160-
household = pd.DataFrame(
161+
benunit = pd.DataFrame(
161162
{
162-
"household_id": [1, 2, 3, 4],
163-
"region": ["LONDON", "LONDON", "LONDON", "LONDON"],
164-
"brma": ["A", "A", "A", "B"],
163+
"benunit_id": [10, 20, 30, 40],
164+
"region": ["LONDON"] * 4,
165+
"LHA_category": ["A"] * 4,
166+
"brma": ["CENTRAL", "CENTRAL", "CENTRAL", "OUTER"],
165167
}
166168
)
167-
resource = {"cells": {"LONDON": {"A": {"A": 3, "B": 1}}}}
169+
resource = {"cells": {"LONDON": {"A": {"CENTRAL": 1, "OUTER": 1}}}}
168170

169-
payload = tool.brma_distribution(
170-
household,
171+
payload = tool.brma_cell_distribution(
172+
benunit,
171173
count_resource=resource,
172174
minimum_count=3,
173175
)
174176

175177
rows = {row["brma"]: row for row in payload["rows"]}
176-
assert rows["A"]["built_count"] == 3
177-
assert rows["B"]["built_count"] == "<3"
178-
assert rows["B"]["built_share"] is None
178+
assert rows["CENTRAL"]["built_count"] == 3
179+
assert rows["CENTRAL"]["cell_n"] == 4
180+
assert rows["CENTRAL"]["z"] is not None
181+
assert rows["OUTER"]["built_count"] == "<3"
182+
assert rows["OUTER"]["built_share"] is None
183+
assert rows["OUTER"]["z"] is None
184+
assert payload["max_abs_z"] > 0
185+
186+
187+
def test_brma_cell_distribution_fails_closed_on_missing_cell() -> None:
188+
tool = _load_tool("emit_uk_brma_distribution")
189+
benunit = pd.DataFrame(
190+
{
191+
"benunit_id": [10],
192+
"region": ["LONDON"],
193+
"LHA_category": ["B"],
194+
"brma": ["CENTRAL"],
195+
}
196+
)
197+
resource = {"cells": {"LONDON": {"A": {"CENTRAL": 1}}}}
198+
199+
with pytest.raises(KeyError, match="LHA_category"):
200+
tool.brma_cell_distribution(benunit, count_resource=resource)
179201

180202

181203
def _frame():

tools/emit_uk_brma_distribution.py

Lines changed: 89 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,94 @@
1-
"""Emit disclosure-safe BRMA distribution diagnostics for a UK frame."""
1+
"""Emit disclosure-safe BRMA distribution diagnostics for a UK frame.
2+
3+
The comparison runs at the sampler's native margin — (region, LHA category)
4+
cells — because that is where the assignment is defined: within a cell the
5+
built benunit-level BRMA shares are multinomial around the count table's
6+
conditional distribution. A region-level margin would mix our benunit
7+
category composition with the rents table's lettings-weighted composition
8+
and mislead.
9+
"""
210

311
from __future__ import annotations
412

513
import argparse
614
import json
15+
import math
716
from collections.abc import Mapping
817
from pathlib import Path
918

1019
import pandas as pd
1120

12-
from microcosm.build.uk_runtime.frs_brma import load_brma_count_resource
21+
from microcosm.build.uk_runtime.frs_brma import (
22+
UK_BRMA_DECLARED_SEEDS,
23+
_benunit_regions,
24+
_enum_name,
25+
assign_brma_by_cell,
26+
load_brma_count_resource,
27+
)
1328
from microcosm.build.uk_runtime.national_build import load_uk_national_frame
29+
from microcosm.build.uk_runtime.national_frame import uk_time_period
1430

1531

16-
def brma_distribution(
17-
household: pd.DataFrame,
32+
def brma_cell_distribution(
33+
benunit: pd.DataFrame,
1834
*,
1935
count_resource: Mapping[str, object],
2036
minimum_count: int = 3,
2137
) -> dict[str, object]:
22-
"""Compare built household BRMA shares with count-table region priors."""
38+
"""Compare built benunit BRMA shares per (region, LHA category) cell."""
2339

24-
required = {"region", "brma"}
25-
missing = required - set(household.columns)
40+
required = {"region", "LHA_category", "brma"}
41+
missing = required - set(benunit.columns)
2642
if missing:
27-
raise ValueError(f"household table is missing column(s): {sorted(missing)}.")
28-
rows: list[dict[str, object]] = []
43+
raise ValueError(f"benunit table is missing column(s): {sorted(missing)}.")
2944
cells = count_resource["cells"]
30-
for region, region_table in sorted(cells.items()):
31-
expected_counts: dict[str, int] = {}
32-
for category_counts in region_table.values():
33-
for brma, count in category_counts.items():
34-
expected_counts[brma] = expected_counts.get(brma, 0) + int(count)
35-
built = household.loc[household["region"].astype(str) == str(region), "brma"]
36-
built_counts = built.astype(str).value_counts().to_dict()
37-
built_total = int(sum(built_counts.values()))
38-
expected_total = int(sum(expected_counts.values()))
45+
rows: list[dict[str, object]] = []
46+
max_abs_z = 0.0
47+
for (region, category), group in benunit.groupby(
48+
["region", "LHA_category"], sort=True
49+
):
50+
expected_counts = cells.get(str(region), {}).get(str(category))
51+
if expected_counts is None:
52+
raise KeyError(
53+
f"missing count-table cell for region={region!r}, "
54+
f"LHA_category={category!r}."
55+
)
56+
expected_total = float(sum(expected_counts.values()))
57+
built_counts = group["brma"].astype(str).value_counts().to_dict()
58+
cell_n = int(len(group))
3959
for brma, expected_count in sorted(expected_counts.items()):
60+
expected_share = float(expected_count) / expected_total
4061
built_count = int(built_counts.get(brma, 0))
62+
row: dict[str, object] = {
63+
"region": str(region),
64+
"lha_category": str(category),
65+
"brma": str(brma),
66+
"cell_n": cell_n,
67+
"expected_share": expected_share,
68+
}
4169
if 0 < built_count < minimum_count:
42-
built_count_out: int | str = f"<{minimum_count}"
43-
built_share: float | None = None
70+
row["built_count"] = f"<{minimum_count}"
71+
row["built_share"] = None
72+
row["z"] = None
4473
else:
45-
built_count_out = built_count
46-
built_share = built_count / built_total if built_total else 0.0
47-
rows.append(
48-
{
49-
"region": region,
50-
"brma": brma,
51-
"built_count": built_count_out,
52-
"built_share": built_share,
53-
"count_table_share": expected_count / expected_total,
54-
}
55-
)
74+
built_share = built_count / cell_n if cell_n else 0.0
75+
sigma = (
76+
math.sqrt(expected_share * (1.0 - expected_share) / cell_n)
77+
if cell_n and 0.0 < expected_share < 1.0
78+
else None
79+
)
80+
z = (built_share - expected_share) / sigma if sigma else None
81+
row["built_count"] = built_count
82+
row["built_share"] = built_share
83+
row["z"] = z
84+
if z is not None:
85+
max_abs_z = max(max_abs_z, abs(z))
86+
rows.append(row)
5687
return {
57-
"check": "uk_brma_distribution",
88+
"check": "uk_brma_cell_distribution",
89+
"margin": "benunit BRMA within (region, LHA category) cells",
5890
"minimum_count": minimum_count,
91+
"max_abs_z": max_abs_z,
5992
"rows": rows,
6093
}
6194

@@ -66,13 +99,34 @@ def main() -> None:
6699
parser.add_argument("--output", type=Path, required=True)
67100
parser.add_argument("--minimum-count", type=int, default=3)
68101
args = parser.parse_args()
102+
103+
from microcosm.frame.adapters.policyengine_uk import PolicyEngineUKEngine
104+
69105
frame, _provenance = load_uk_national_frame(args.input_h5)
70-
payload = brma_distribution(
71-
frame.table("household"),
72-
count_resource=load_brma_count_resource(),
106+
engine = PolicyEngineUKEngine()
107+
lha_category = engine.materialize(frame, ("LHA_category",), uk_time_period(frame))[
108+
"LHA_category"
109+
]
110+
benunit = frame.table("benunit").copy()
111+
benunit["LHA_category"] = [_enum_name(value) for value in lha_category]
112+
benunit["region"] = _benunit_regions(
113+
frame.table("person"), frame.table("household"), benunit
114+
)
115+
count_resource = load_brma_count_resource()
116+
# Deterministic re-derivation of the benunit-level assignment that fed the
117+
# stored household collapse (proven identical by the identity receipt).
118+
benunit["brma"] = assign_brma_by_cell(
119+
benunit,
120+
count_resource=count_resource,
121+
seed=UK_BRMA_DECLARED_SEEDS["brma"],
122+
)
123+
payload = brma_cell_distribution(
124+
benunit,
125+
count_resource=count_resource,
73126
minimum_count=args.minimum_count,
74127
)
75128
args.output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
129+
print("brma cell distribution: max |z| =", round(payload["max_abs_z"], 2))
76130

77131

78132
if __name__ == "__main__":

0 commit comments

Comments
 (0)