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
311from __future__ import annotations
412
513import argparse
614import json
15+ import math
716from collections .abc import Mapping
817from pathlib import Path
918
1019import 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+ )
1328from 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
78132if __name__ == "__main__" :
0 commit comments