From c89d9cf4f1a29e625c4f52a0b194c3a429ec09e9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 23 Aug 2026 11:04:12 +0200 Subject: [PATCH 1/2] UK WAS stage: split private pension wealth out of corporate_wealth (#750) The WAS wealth stage folded WAS private pension wealth less the current-employment defined-benefit component (totalpenr8_aggr - dvvaldbt_scaper8_aggr) into corporate_wealth, and policyengine-uk counts corporate_wealth as capital in every means test; the value of a right to receive a pension under an occupational or personal pension scheme is disregarded capital (UC Regs 2013 Sch 10 para 10 and the HB/JSA/ESA/IS/SPC parallels). On the incumbent artifact 46% of UC-reporting benefit units fail the GBP 16,000 test, 96% of them on corporate_wealth alone (uk-data#452, mechanism M2). The stage now emits private_pension_wealth as its own household output - drawn first in chain segment 2 (the slot the old folded corporate_wealth, 84.7% pension by donor mass, occupied), with the share-like holdings conditional on it and segment 3 conditional on both components - and keeps corporate_wealth to employee shares/options, UK shares, unit and investment trusts and the stocks-and-shares ISA fold. Row identity on the donor: old corporate_wealth == corporate_wealth + private_pension_wealth. The column is not DB-free (retained DB rights and DB pensions in payment stay inside it), hence the name and the documented current-DB exclusion. Lockstep: spec/sources.yaml + source_stages.json (derived, chain_order, outputs, nonnegative_outputs, notes), was_wealth_support_bounds.json regenerated from the pinned tab (new [0, 8000000] entry; corporate_wealth's committed bound unchanged at one significant figure), the export-surface allow-list (UK_ALLOWED_EXTRA_EXPORT_COLUMNS + gates.json, same slot as cash_isa / stocks_and_shares_isa), release_input_coverage_manifest.json regenerated (source_stages.json sha pins + the was_wealth outputs list; 145 required columns unchanged), UK spec_sha256 re-pinned e12a2cb8 -> d8a4ecff, gate-battery digests re-cut (policy 404968fb -> a0090bbf, gates manifest 59c7808d -> 12467bad, fingerprint bfb98736 -> e23ba388). Measurement receipt in experiments/452-uk-pension-wealth-split.md (aggregates only). Co-Authored-By: Claude Fable 5 --- .../uk-pension-wealth-split-750.added.md | 1 + .../452-uk-pension-wealth-split-receipt.json | 563 ++++++++++++++++++ experiments/452-uk-pension-wealth-split.md | 109 ++++ .../src/microcosm/build/uk/gates.json | 1 + .../uk/release_input_coverage_manifest.json | 13 +- .../src/microcosm/build/uk/source_stages.json | 8 +- .../src/microcosm/build/uk/spec/sources.yaml | 8 +- .../build/uk/was_wealth_support_bounds.json | 4 + .../build/uk_runtime/terminal_gates.py | 1 + .../microcosm/build/uk_runtime/was_wealth.py | 38 +- .../tests/test_spec_engine_country_bundles.py | 2 +- .../tests/test_uk_was_wealth.py | 42 +- .../src/microcosm/data/contract.py | 6 +- .../microcosm-data/tests/test_contract.py | 6 +- 14 files changed, 777 insertions(+), 25 deletions(-) create mode 100644 changelog.d/uk-pension-wealth-split-750.added.md create mode 100644 experiments/452-uk-pension-wealth-split-receipt.json create mode 100644 experiments/452-uk-pension-wealth-split.md diff --git a/changelog.d/uk-pension-wealth-split-750.added.md b/changelog.d/uk-pension-wealth-split-750.added.md new file mode 100644 index 000000000..44c626892 --- /dev/null +++ b/changelog.d/uk-pension-wealth-split-750.added.md @@ -0,0 +1 @@ +UK WAS wealth stage emits `private_pension_wealth` (WAS private pension wealth less the current-employment defined-benefit component) as its own household output and keeps `corporate_wealth` to the share-like holdings, so the means-tested capital tests stop counting pension rights; spec, support bounds, export allow-list, coverage manifest and gate-battery digests move in lockstep (#750, uk-data#452). diff --git a/experiments/452-uk-pension-wealth-split-receipt.json b/experiments/452-uk-pension-wealth-split-receipt.json new file mode 100644 index 000000000..a8d657341 --- /dev/null +++ b/experiments/452-uk-pension-wealth-split-receipt.json @@ -0,0 +1,563 @@ +{ + "title": "UC caseload effect of splitting private pension wealth out of corporate_wealth (uk-data#452 mechanism M2)", + "date": "2026-08-23", + "method": "WAS wealth block re-imputed on every household of the released enhanced FRS h5 with the microcosm WAS stage code (clean_was_household_table -> impute_was_wealth -> support_clip_to_donor; seed 0; 100 trees), once with the pre-split arithmetic (OLD: pension inside corporate_wealth_excl_isa) and once with the split (NEW: private_pension_wealth drawn first in chain segment 2, share-like components conditional on it, segment 3 conditional on both); household wealth columns swapped into the single-year dataset before the simulation is built so stocks uprate 2024->2025 by the engine's registry; UC measured at 2025 with policyengine-uk 2.91.0 (c93e1a05); benefit-unit weights = the artifact's calibrated weights, unchanged.", + "artifact": { + "filename": "enhanced_frs_2024_25.h5", + "sha256": "e433e532b17bd8ce76030156285816e33d44e93edabd2204adbef71d19a68712", + "release": "policyengine-uk-data 1.56.16 (HF revision a9e52499)", + "dataset_year": 2024 + }, + "was_donor": { + "tab": "was_round_8_hhold_eul_may_2025_230525.tab", + "sha256": "18b3eb980c02c99f3d8a3254af859bee31682b2bdc11703877677292b3ce9374", + "households": 15128 + }, + "recipients": 52846, + "engine": "policyengine-uk 2.91.0 @ c93e1a05, policyengine-core 3.31.0", + "seed": 0, + "n_estimators": 100, + "year": 2025, + "donor_aggregates_weighted_bn": { + "total_private_pension_wealth_totalpen": 4807.7, + "current_db_dvvaldbt_scape": 1148.7, + "private_pension_wealth_remainder": 3659.0, + "share_like_corporate_wealth": 659.4, + "pension_share_of_old_corporate_wealth_mass": 0.847, + "negative_remainder_rows": 0 + }, + "draw_aggregates_2024": { + "incumbent": { + "owned_land": { + "weighted_total_bn": 397.625, + "nonzero_weighted_share": 0.0166, + "over_16k_weighted_share": 0.0152, + "weighted_mean_gbp": 12779.0 + }, + "property_wealth": { + "weighted_total_bn": 8029.152, + "nonzero_weighted_share": 0.6658, + "over_16k_weighted_share": 0.6656, + "weighted_mean_gbp": 258044.0 + }, + "corporate_wealth": { + "weighted_total_bn": 9149.019, + "nonzero_weighted_share": 0.7754, + "over_16k_weighted_share": 0.6376, + "weighted_mean_gbp": 294035.0 + }, + "gross_financial_wealth": { + "weighted_total_bn": 7554.757, + "nonzero_weighted_share": 0.9821, + "over_16k_weighted_share": 0.5875, + "weighted_mean_gbp": 242798.0 + }, + "net_financial_wealth": { + "weighted_total_bn": 7446.631, + "nonzero_weighted_share": 0.9986, + "over_16k_weighted_share": 0.5448, + "weighted_mean_gbp": 239323.0 + }, + "main_residence_value": { + "weighted_total_bn": 6733.325, + "nonzero_weighted_share": 0.6266, + "over_16k_weighted_share": 0.6263, + "weighted_mean_gbp": 216398.0 + }, + "other_residential_property_value": { + "weighted_total_bn": 1793.852, + "nonzero_weighted_share": 0.1019, + "over_16k_weighted_share": 0.1004, + "weighted_mean_gbp": 57652.0 + }, + "non_residential_property_value": { + "weighted_total_bn": 190.364, + "nonzero_weighted_share": 0.0134, + "over_16k_weighted_share": 0.0133, + "weighted_mean_gbp": 6118.0 + }, + "savings": { + "weighted_total_bn": 2979.734, + "nonzero_weighted_share": 0.6538, + "over_16k_weighted_share": 0.3865, + "weighted_mean_gbp": 95764.0 + }, + "num_vehicles": { + "weighted_total_bn": 0.041, + "nonzero_weighted_share": 0.7731, + "over_16k_weighted_share": 0.0, + "weighted_mean_gbp": 1.0 + } + }, + "old_redraw": { + "owned_land": { + "weighted_total_bn": 119.939, + "nonzero_weighted_share": 0.0126, + "over_16k_weighted_share": 0.0112, + "weighted_mean_gbp": 3855.0 + }, + "property_wealth": { + "weighted_total_bn": 11411.434, + "nonzero_weighted_share": 0.6313, + "over_16k_weighted_share": 0.6313, + "weighted_mean_gbp": 366745.0 + }, + "corporate_wealth": { + "weighted_total_bn": 8433.52, + "nonzero_weighted_share": 0.7681, + "over_16k_weighted_share": 0.6233, + "weighted_mean_gbp": 271040.0 + }, + "gross_financial_wealth": { + "weighted_total_bn": 6537.319, + "nonzero_weighted_share": 0.9792, + "over_16k_weighted_share": 0.5924, + "weighted_mean_gbp": 210099.0 + }, + "net_financial_wealth": { + "weighted_total_bn": 6259.321, + "nonzero_weighted_share": 0.9918, + "over_16k_weighted_share": 0.5338, + "weighted_mean_gbp": 201165.0 + }, + "main_residence_value": { + "weighted_total_bn": 8495.688, + "nonzero_weighted_share": 0.5931, + "over_16k_weighted_share": 0.5931, + "weighted_mean_gbp": 273038.0 + }, + "other_residential_property_value": { + "weighted_total_bn": 535.644, + "nonzero_weighted_share": 0.0448, + "over_16k_weighted_share": 0.0442, + "weighted_mean_gbp": 17215.0 + }, + "non_residential_property_value": { + "weighted_total_bn": 91.833, + "nonzero_weighted_share": 0.0144, + "over_16k_weighted_share": 0.0135, + "weighted_mean_gbp": 2951.0 + }, + "savings": { + "weighted_total_bn": 1478.066, + "nonzero_weighted_share": 0.6326, + "over_16k_weighted_share": 0.2636, + "weighted_mean_gbp": 47503.0 + }, + "num_vehicles": { + "weighted_total_bn": 0.035, + "nonzero_weighted_share": 0.7586, + "over_16k_weighted_share": 0.0, + "weighted_mean_gbp": 1.0 + }, + "cash_isa": { + "weighted_total_bn": 348.03, + "nonzero_weighted_share": 0.3511, + "over_16k_weighted_share": 0.1414, + "weighted_mean_gbp": 11185.0 + }, + "stocks_and_shares_isa": { + "weighted_total_bn": 1108.837, + "nonzero_weighted_share": 0.2154, + "over_16k_weighted_share": 0.1711, + "weighted_mean_gbp": 35636.0 + } + }, + "new_redraw": { + "owned_land": { + "weighted_total_bn": 119.939, + "nonzero_weighted_share": 0.0126, + "over_16k_weighted_share": 0.0112, + "weighted_mean_gbp": 3855.0 + }, + "property_wealth": { + "weighted_total_bn": 11411.434, + "nonzero_weighted_share": 0.6313, + "over_16k_weighted_share": 0.6313, + "weighted_mean_gbp": 366745.0 + }, + "corporate_wealth": { + "weighted_total_bn": 2499.183, + "nonzero_weighted_share": 0.3497, + "over_16k_weighted_share": 0.2389, + "weighted_mean_gbp": 80320.0 + }, + "private_pension_wealth": { + "weighted_total_bn": 5105.551, + "nonzero_weighted_share": 0.7299, + "over_16k_weighted_share": 0.5911, + "weighted_mean_gbp": 164084.0 + }, + "gross_financial_wealth": { + "weighted_total_bn": 5952.685, + "nonzero_weighted_share": 0.981, + "over_16k_weighted_share": 0.6004, + "weighted_mean_gbp": 191310.0 + }, + "net_financial_wealth": { + "weighted_total_bn": 5616.748, + "nonzero_weighted_share": 0.9931, + "over_16k_weighted_share": 0.5329, + "weighted_mean_gbp": 180513.0 + }, + "main_residence_value": { + "weighted_total_bn": 8506.967, + "nonzero_weighted_share": 0.593, + "over_16k_weighted_share": 0.593, + "weighted_mean_gbp": 273400.0 + }, + "other_residential_property_value": { + "weighted_total_bn": 469.83, + "nonzero_weighted_share": 0.0426, + "over_16k_weighted_share": 0.0419, + "weighted_mean_gbp": 15100.0 + }, + "non_residential_property_value": { + "weighted_total_bn": 90.385, + "nonzero_weighted_share": 0.0154, + "over_16k_weighted_share": 0.0129, + "weighted_mean_gbp": 2905.0 + }, + "savings": { + "weighted_total_bn": 1488.956, + "nonzero_weighted_share": 0.6387, + "over_16k_weighted_share": 0.2853, + "weighted_mean_gbp": 47853.0 + }, + "num_vehicles": { + "weighted_total_bn": 0.036, + "nonzero_weighted_share": 0.7656, + "over_16k_weighted_share": 0.0, + "weighted_mean_gbp": 1.0 + }, + "cash_isa": { + "weighted_total_bn": 313.302, + "nonzero_weighted_share": 0.3609, + "over_16k_weighted_share": 0.1476, + "weighted_mean_gbp": 10069.0 + }, + "stocks_and_shares_isa": { + "weighted_total_bn": 845.516, + "nonzero_weighted_share": 0.2013, + "over_16k_weighted_share": 0.1633, + "weighted_mean_gbp": 27174.0 + } + } + }, + "uc_2025": { + "incumbent_artifact": { + "as_is": { + "uk_m": 6.3462, + "gb_m": 6.199, + "records": 4830, + "spend_bn": 75.301, + "eligible_m": 10.5156, + "eligible_unweighted": 14886, + "reporters_unweighted": 6447, + "reporters_m": 5.9252, + "reporters_over_16k_unweighted": 2958, + "reporters_over_16k_m": 1.3778, + "reporters_on_uc_m": 4.3178, + "reporters_not_on_uc_m": 1.6073, + "on_uc_not_reported_m": 2.0284, + "gb_housing_m": 4.8684, + "gb_lcwra_m": 2.1017, + "gb_carer_m": 0.6414, + "gb_childcare_m": 0.4712, + "gb_child_m": 3.0183, + "gb_housing_spend_bn": 37.05, + "gb_lcwra_spend_bn": 11.324, + "gb_carer_spend_bn": 1.552, + "gb_childcare_spend_bn": 2.037, + "gb_child_spend_bn": 18.944, + "gb_single_m": 2.9688, + "gb_lone_parent_m": 2.1347, + "gb_couple_no_children_m": 0.225, + "gb_couple_with_children_m": 0.8705 + }, + "reporters_anchored": { + "uk_m": 6.3632, + "gb_m": 6.214, + "records": 4892, + "spend_bn": 75.401, + "eligible_m": 10.5156, + "eligible_unweighted": 14886, + "reporters_unweighted": 6447, + "reporters_m": 5.9252, + "reporters_over_16k_unweighted": 2958, + "reporters_over_16k_m": 1.3778, + "reporters_on_uc_m": 4.3348, + "reporters_not_on_uc_m": 1.5904, + "on_uc_not_reported_m": 2.0284, + "gb_housing_m": 4.8734, + "gb_lcwra_m": 2.1115, + "gb_carer_m": 0.6422, + "gb_childcare_m": 0.4712, + "gb_child_m": 3.0197, + "gb_housing_spend_bn": 37.091, + "gb_lcwra_spend_bn": 11.374, + "gb_carer_spend_bn": 1.554, + "gb_childcare_spend_bn": 2.037, + "gb_child_spend_bn": 18.949, + "gb_single_m": 2.9807, + "gb_lone_parent_m": 2.1347, + "gb_couple_no_children_m": 0.2268, + "gb_couple_with_children_m": 0.8718 + } + }, + "incumbent_counterfactual_C_corporate_wealth_zero": { + "as_is": { + "uk_m": 6.8811, + "gb_m": 6.6933, + "records": 6594, + "spend_bn": 78.231, + "gb_housing_m": 5.0564, + "gb_lcwra_m": 2.1931, + "gb_carer_m": 0.682, + "gb_childcare_m": 0.4923, + "gb_child_m": 3.1004, + "gb_single_m": 3.2767, + "gb_lone_parent_m": 2.1602, + "gb_couple_no_children_m": 0.3271, + "gb_couple_with_children_m": 0.9293 + }, + "reporters_anchored": { + "uk_m": 6.9518, + "gb_m": 6.7615, + "records": 6724, + "spend_bn": 78.665, + "gb_housing_m": 5.0934, + "gb_lcwra_m": 2.2439, + "gb_carer_m": 0.683, + "gb_childcare_m": 0.4994, + "gb_child_m": 3.1104, + "gb_single_m": 3.3282, + "gb_lone_parent_m": 2.1604, + "gb_couple_no_children_m": 0.3338, + "gb_couple_with_children_m": 0.9391 + } + }, + "microcosm_stage_old_arithmetic": { + "as_is": { + "uk_m": 5.1926, + "gb_m": 5.036, + "records": 4683, + "spend_bn": 63.14, + "eligible_m": 9.7699, + "eligible_unweighted": 13335, + "reporters_unweighted": 6447, + "reporters_m": 5.9252, + "reporters_over_16k_unweighted": 3165, + "reporters_over_16k_m": 2.0397, + "reporters_on_uc_m": 3.6493, + "reporters_not_on_uc_m": 2.2758, + "on_uc_not_reported_m": 1.5432, + "gb_housing_m": 4.0819, + "gb_lcwra_m": 1.6962, + "gb_carer_m": 0.5612, + "gb_childcare_m": 0.2599, + "gb_child_m": 2.3445, + "gb_housing_spend_bn": 30.214, + "gb_lcwra_spend_bn": 9.14, + "gb_carer_spend_bn": 1.358, + "gb_childcare_spend_bn": 1.196, + "gb_child_spend_bn": 14.508, + "gb_single_m": 2.4714, + "gb_lone_parent_m": 1.6566, + "gb_couple_no_children_m": 0.2223, + "gb_couple_with_children_m": 0.6857 + }, + "reporters_anchored": { + "uk_m": 5.2172, + "gb_m": 5.0583, + "records": 4771, + "spend_bn": 63.306, + "eligible_m": 9.7699, + "eligible_unweighted": 13335, + "reporters_unweighted": 6447, + "reporters_m": 5.9252, + "reporters_over_16k_unweighted": 3165, + "reporters_over_16k_m": 2.0397, + "reporters_on_uc_m": 3.6739, + "reporters_not_on_uc_m": 2.2513, + "on_uc_not_reported_m": 1.5432, + "gb_housing_m": 4.0879, + "gb_lcwra_m": 1.7107, + "gb_carer_m": 0.5614, + "gb_childcare_m": 0.2616, + "gb_child_m": 2.3475, + "gb_housing_spend_bn": 30.274, + "gb_lcwra_spend_bn": 9.213, + "gb_carer_spend_bn": 1.359, + "gb_childcare_spend_bn": 1.216, + "gb_child_spend_bn": 14.524, + "gb_single_m": 2.4871, + "gb_lone_parent_m": 1.6567, + "gb_couple_no_children_m": 0.226, + "gb_couple_with_children_m": 0.6885 + } + }, + "microcosm_stage_new_arithmetic": { + "as_is": { + "uk_m": 6.2656, + "gb_m": 6.0637, + "records": 6537, + "spend_bn": 71.747, + "eligible_m": 16.2551, + "eligible_unweighted": 25623, + "reporters_unweighted": 6447, + "reporters_m": 5.9252, + "reporters_over_16k_unweighted": 1757, + "reporters_over_16k_m": 1.2815, + "reporters_on_uc_m": 4.1635, + "reporters_not_on_uc_m": 1.7617, + "on_uc_not_reported_m": 2.1021, + "gb_housing_m": 4.5921, + "gb_lcwra_m": 2.0547, + "gb_carer_m": 0.645, + "gb_childcare_m": 0.4027, + "gb_child_m": 2.8687, + "gb_housing_spend_bn": 34.147, + "gb_lcwra_spend_bn": 11.186, + "gb_carer_spend_bn": 1.561, + "gb_childcare_spend_bn": 1.767, + "gb_child_spend_bn": 17.846, + "gb_single_m": 2.8797, + "gb_lone_parent_m": 1.9224, + "gb_couple_no_children_m": 0.3263, + "gb_couple_with_children_m": 0.9353 + }, + "reporters_anchored": { + "uk_m": 6.3397, + "gb_m": 6.1349, + "records": 6696, + "spend_bn": 72.251, + "eligible_m": 16.2551, + "eligible_unweighted": 25623, + "reporters_unweighted": 6447, + "reporters_m": 5.9252, + "reporters_over_16k_unweighted": 1757, + "reporters_over_16k_m": 1.2815, + "reporters_on_uc_m": 4.2376, + "reporters_not_on_uc_m": 1.6875, + "on_uc_not_reported_m": 2.1021, + "gb_housing_m": 4.6319, + "gb_lcwra_m": 2.106, + "gb_carer_m": 0.646, + "gb_childcare_m": 0.4135, + "gb_child_m": 2.8842, + "gb_housing_spend_bn": 34.391, + "gb_lcwra_spend_bn": 11.446, + "gb_carer_spend_bn": 1.563, + "gb_childcare_spend_bn": 1.911, + "gb_child_spend_bn": 17.916, + "gb_single_m": 2.929, + "gb_lone_parent_m": 1.923, + "gb_couple_no_children_m": 0.333, + "gb_couple_with_children_m": 0.9499 + } + } + }, + "split_effect_new_minus_old": { + "uk_m": 1.073, + "gb_m": 1.0277, + "records": 1854, + "spend_bn": 8.607, + "eligible_m": 6.4852, + "eligible_unweighted": 12288, + "reporters_unweighted": 0, + "reporters_m": 0.0, + "reporters_over_16k_unweighted": -1408, + "reporters_over_16k_m": -0.7582, + "reporters_on_uc_m": 0.5142, + "reporters_not_on_uc_m": -0.5141, + "on_uc_not_reported_m": 0.5589, + "gb_housing_m": 0.5102, + "gb_lcwra_m": 0.3585, + "gb_carer_m": 0.0838, + "gb_childcare_m": 0.1428, + "gb_child_m": 0.5242, + "gb_housing_spend_bn": 3.933, + "gb_lcwra_spend_bn": 2.046, + "gb_carer_spend_bn": 0.203, + "gb_childcare_spend_bn": 0.571, + "gb_child_spend_bn": 3.338, + "gb_single_m": 0.4083, + "gb_lone_parent_m": 0.2658, + "gb_couple_no_children_m": 0.104, + "gb_couple_with_children_m": 0.2496 + }, + "split_effect_new_minus_old_anchored": { + "uk_m": 1.1225, + "gb_m": 1.0766, + "records": 1925, + "spend_bn": 8.945, + "eligible_m": 6.4852, + "eligible_unweighted": 12288, + "reporters_unweighted": 0, + "reporters_m": 0.0, + "reporters_over_16k_unweighted": -1408, + "reporters_over_16k_m": -0.7582, + "reporters_on_uc_m": 0.5637, + "reporters_not_on_uc_m": -0.5638, + "on_uc_not_reported_m": 0.5589, + "gb_housing_m": 0.544, + "gb_lcwra_m": 0.3953, + "gb_carer_m": 0.0846, + "gb_childcare_m": 0.1519, + "gb_child_m": 0.5367, + "gb_housing_spend_bn": 4.117, + "gb_lcwra_spend_bn": 2.233, + "gb_carer_spend_bn": 0.204, + "gb_childcare_spend_bn": 0.695, + "gb_child_spend_bn": 3.392, + "gb_single_m": 0.4419, + "gb_lone_parent_m": 0.2663, + "gb_couple_no_children_m": 0.107, + "gb_couple_with_children_m": 0.2614 + }, + "allocation_key_concentration_new_draw": { + "narrow": { + "mass_share_by_income_decile": [ + 0.1409, + 0.0571, + 0.0785, + 0.0347, + 0.1194, + 0.0475, + 0.0393, + 0.1052, + 0.1672, + 0.2102 + ], + "top10pct_households_share": 0.8672, + "top1pct_households_share": 0.2871, + "weighted_total_bn": 2499.183 + }, + "preserved": { + "mass_share_by_income_decile": [ + 0.0701, + 0.0441, + 0.0665, + 0.0554, + 0.097, + 0.0804, + 0.0889, + 0.1298, + 0.171, + 0.1967 + ], + "top10pct_households_share": 0.5441, + "top1pct_households_share": 0.1455, + "weighted_total_bn": 7604.734 + } + }, + "caveats": [ + "Weights are the artifact's calibrated weights and are not recalibrated; the UK calibration surface now carries a UC caseload target (microcosm#735), which would absorb part of any move in a real build.", + "OLD and NEW are the same microcosm machinery at the same seed, so NEW-OLD isolates the arithmetic split; OLD is not the incumbent's own draw (microimpute 10-point quantile grid vs RegimeGatedQRF continuous quantile over the full leaf conditional) - the OLD re-draw sits 1.15m benefit units below the incumbent artifact on the same households, a draw-machinery/predictor-surface difference outside this change.", + "The recipient predictor surface is materialised from the released h5 (num_bedrooms is absent there, so the engine default 0 is used; incomes are the post-SPI h5 incomes), not from the FRS spine the build runs on.", + "SPI-synthetic clone households are re-imputed like any household.", + "private_pension_wealth is engine-unknown at policyengine-uk 2.91.0 and is ignored by the loader; the NEW numbers are the model-side-unchanged outcome (total_wealth and the exposure keys shrink there until the model PR lands).", + "Aggregates only (UKDS EUL); no unit records leave the licensed environment." + ] +} diff --git a/experiments/452-uk-pension-wealth-split.md b/experiments/452-uk-pension-wealth-split.md new file mode 100644 index 000000000..58d515260 --- /dev/null +++ b/experiments/452-uk-pension-wealth-split.md @@ -0,0 +1,109 @@ +# #750 / uk-data#452 (M2): private pension wealth split out of `corporate_wealth` — measurement receipt + +Committed receipt for the WAS-stage change that emits `private_pension_wealth` +as its own household column and keeps `corporate_wealth` to the share-like +holdings. Companion JSON: `452-uk-pension-wealth-split-receipt.json` +(aggregates only — UKDS EUL; no unit records). Scripts and the full +diagnosis live outside the repo (`~/PolicyEngine/_uk-defects-452-448/`, +`measure/pension_split/`). + +## Why + +On the incumbent enhanced FRS (`enhanced_frs_2024_25.h5`, uk-data 1.56.14 / +1.56.16) at 2025, 46% of benefit-unit records that report Universal Credit +fail the £16,000 capital test, 96% of them on `corporate_wealth` alone. The +WAS stage folds WAS private pension wealth less the current-employment DB +component (`totalpenr8_aggr − dvvaldbt_scaper8_aggr`) into +`corporate_wealth`, and the model counts that variable as UC capital. The +value of a right to receive a pension under an occupational or personal +pension scheme is disregarded capital (UC Regs 2013 Sch 10 para 10; HB Regs +2006 Sch 6 paras 31–32 and HB(SPC) Regs 2006 Sch 6 para 24; JSA Regs 1996 +Sch 8 paras 28–29; ESA Regs 2008 Sch 9 paras 28–29; IS Regs 1987 Sch 10 +paras 23–23A; SPC Regs 2002 Sch V paras 22–23, Part I). + +## What the remainder is (donor, weighted £bn) + +WAS round 8 household tab (sha `18b3eb98…`, 15,128 households, 26.99m +weighted): total private pension wealth 4,807.7; current-employment DB at +the SCAPE rate 1,148.7; remainder 3,659.0 = current occupational DC 313.9 + +AVCs 12.2 + retained DC 550.0 + current personal pensions 203.7 + retained +DB 863.6 + pensions in payment 1,692.4 + expected from a former spouse 23.3 +(the ten-component identity is exact on 100% of rows; the remainder is never +negative). Share-like holdings (employee shares, UK shares, unit and +investment trusts, stocks-and-shares ISA) 659.4, so the pension component is +84.7% of the old `corporate_wealth` mass. Hence the column is **not** +DB-free (retained DB and DB pensions in payment stay inside it): it is named +`private_pension_wealth` with the current-DB exclusion stated first in its +documentation, not `non_db_pension_wealth`. + +## Measurement (microcosm machinery at a fixed seed, weights unchanged) + +The WAS wealth block was re-imputed on every household of the 1.56.16 +artifact with this package's stage code (seed 0, 100 trees), once with the +pre-split arithmetic (OLD) and once with the split (NEW), the household +columns swapped into the single-year dataset before the simulation is built, +and UC measured at 2025 with policyengine-uk 2.91.0. Benefit units in +millions; GB = excluding Northern Ireland. + +| | incumbent artifact | OLD re-draw | NEW re-draw | NEW − OLD | +|---|---|---|---|---| +| UC benefit units, UK | 6.346 | 5.193 | 6.266 | **+1.073** | +| UC benefit units, GB | 6.199 | 5.036 | 6.064 | +1.028 | +| UC spend, £bn | 75.3 | 63.1 | 71.7 | +8.6 | +| eligible benefit units (WA adult, capital ≤ £16k) | 10.52 | 9.77 | 16.26 | +6.49 | +| reporter records over £16k (weighted m) | 2,958 (1.38) | 3,165 (2.04) | 1,757 (1.28) | −1,408 (−0.76) | +| GB housing element | 4.868 | 4.082 | 4.592 | +0.510 | +| GB LCWRA element | 2.102 | 1.696 | 2.055 | +0.359 | +| GB carer element | 0.641 | 0.561 | 0.645 | +0.084 | +| GB childcare element | 0.471 | 0.260 | 0.403 | +0.143 | +| GB child element | 3.018 | 2.345 | 2.869 | +0.524 | +| GB single | 2.969 | 2.471 | 2.880 | +0.408 | +| GB lone parent | 2.135 | 1.657 | 1.922 | +0.266 | +| GB couple, no children | 0.225 | 0.222 | 0.326 | +0.104 | +| GB couple with children | 0.871 | 0.686 | 0.935 | +0.250 | + +With FRS reporters anchored (`would_claim_uc |= universal_credit_reported > 0`): +OLD 5.217 → NEW 6.340 (UK). The incumbent's own upper bound (counterfactual +C, `corporate_wealth` → 0 on the incumbent draws) is 6.881 UK. + +Allocation-key check on the NEW draw (why the model keeps +`corporate_sector_wealth = corporate_wealth + private_pension_wealth` as the +key for shareholding, corporate land value and the employer-NI capital +response): the narrowed key puts 86.7% of its mass in the top 10% of +households and 28.7% in the top 1% (preserved key: 54.4% / 14.6%). + +Regression receipt on the same run: chain segment 1 is untouched by the +split, so the `owned_land` and `property_wealth` draws are identical OLD vs +NEW at seed 0 (weighted totals 119.939 and 11,411.434 £bn in both); only the +segment-2/3 columns move. On this re-draw the recipient-side `corporate_wealth` +mass is 2,499 vs the incumbent's 9,149 £bn at 2024 (drift −0.73), inside the +`uk_input_mass_parity` tolerance. + +Engine coupling: `private_pension_wealth` is engine-unknown until +policyengine-uk publishes the companion variable; bump the `uk` extra lock to +that release before any national build or calibration (the UK targets +`ons.land.corporate_land_value` / `land_value` are materialised through the +engine's `corporate_sector_wealth` key), and re-pin the `efrs-post-calibration` +input-mass reference only after the uk-data mirror ships (re-pinning first +would breach the gate on `corporate_wealth`). + +## Caveats (travel with the numbers) + +- Weights are not recalibrated; the UK surface now compiles a UC caseload + target (microcosm#735), which absorbs part of any move in a real build. +- OLD is not the incumbent's own draw: this package draws a continuous + quantile over the full leaf conditional, the incumbent's microimpute QRF a + random point on a 10-quantile grid, and the predictor surface here is the + released h5 (no `num_bedrooms`, post-SPI incomes), not the FRS spine. On + the same households the OLD re-draw sits 1.15m benefit units below the + incumbent artifact — a draw-machinery difference outside this change and + worth its own look (the re-draw also carries lower savings / other-property + mass than the incumbent and higher property wealth). +- `private_pension_wealth` is engine-unknown at 2.91.0, so the NEW numbers + are the model-side-unchanged outcome; the policyengine-uk companion PR + makes the disregard explicit and keeps `total_wealth` and the exposure + keys whole. +- `uk_input_mass_parity` will report `corporate_wealth` at roughly −0.85 + against the incumbent reference (pension mass moved to a column the + reference lacks); inside the armed tolerance (4.52), so no exclusion + receipt is added — the drift is explained here. diff --git a/packages/microcosm-build/src/microcosm/build/uk/gates.json b/packages/microcosm-build/src/microcosm/build/uk/gates.json index 5b33c56ce..39b78d086 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/gates.json +++ b/packages/microcosm-build/src/microcosm/build/uk/gates.json @@ -215,6 +215,7 @@ "household.msoa_code", "household.num_vehicles", "household.oa_code", + "household.private_pension_wealth", "household.property_purchased", "household.rail_usage", "household.region_code_oa", diff --git a/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json b/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json index 7c166b3cb..26f5168f5 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json +++ b/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json @@ -478,7 +478,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "31818338ef62a19d9aebad7cdcf79af7cf05c2f0657ecc5e53b5954390ffa93b", + "source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", "source_vintages": { "source": "UK Data Service SN 8856 Effects of Taxes and Benefits household tab, DfT rail fare index, and public NHS activity/cost table.", "survey": "Effects of Taxes and Benefits 1977-2024 and NHS age-gender public table" @@ -497,7 +497,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "31818338ef62a19d9aebad7cdcf79af7cf05c2f0657ecc5e53b5954390ffa93b", + "source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", "source_vintages": { "source": "UK Data Service SN 8856 Effects of Taxes and Benefits household tab and cited VAT anchor resource.", "survey": "Effects of Taxes and Benefits 1977-2024" @@ -532,7 +532,7 @@ "base_candidate_tier": "frs", "calibration_permitted": false, "canonical_source_manifest": "source_stages.json", - "canonical_source_manifest_sha256": "31818338ef62a19d9aebad7cdcf79af7cf05c2f0657ecc5e53b5954390ffa93b", + "canonical_source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", "effective_mass_requirements": { "charitable_investment_gifts": { "mass_share_denominator": "all_person_effective_mass", @@ -642,7 +642,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "31818338ef62a19d9aebad7cdcf79af7cf05c2f0657ecc5e53b5954390ffa93b", + "source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", "source_vintages": { "source": "UK Data Service SN 9468 Living Costs and Food Survey 2023-24 household/person tabs, NEED 2023 headline energy tables, Ofgem Q2 2026 unit rates, and WAS round-8 bridge donor.", "survey": "Living Costs and Food Survey 2023-24" @@ -662,7 +662,7 @@ "property_wealth" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "31818338ef62a19d9aebad7cdcf79af7cf05c2f0657ecc5e53b5954390ffa93b", + "source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", "source_vintages": { "source": "MHCLG dwellings and ONS UK House Price Index December 2025 regional average prices.", "survey": "Public regional property reference" @@ -679,6 +679,7 @@ "owned_land", "property_wealth", "corporate_wealth", + "private_pension_wealth", "gross_financial_wealth", "net_financial_wealth", "main_residence_value", @@ -693,7 +694,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "31818338ef62a19d9aebad7cdcf79af7cf05c2f0657ecc5e53b5954390ffa93b", + "source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", "source_vintages": { "source": "Office for National Statistics Wealth and Assets Survey, UK Data Service SN 7215, DOI 10.5255/UKDA-SN-7215-20; local licensed 2006-22 household tab.", "survey": "Wealth and Assets Survey round 8" diff --git a/packages/microcosm-build/src/microcosm/build/uk/source_stages.json b/packages/microcosm-build/src/microcosm/build/uk/source_stages.json index 9e052b4c0..a990f0356 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/source_stages.json +++ b/packages/microcosm-build/src/microcosm/build/uk/source_stages.json @@ -903,7 +903,8 @@ "weight": "R8xshhwgt" }, "derived": { - "corporate_wealth_excl_isa": "(totalpenr8_aggr - dvvaldbt_scaper8_aggr) + DVFESHARESR8_aggr + DVFShUKVR8_aggr + DVFCollVR8_aggr", + "private_pension_wealth": "totalpenr8_aggr - dvvaldbt_scaper8_aggr", + "corporate_wealth_excl_isa": "DVFESHARESR8_aggr + DVFShUKVR8_aggr + DVFCollVR8_aggr", "stocks_and_shares_isa": "DVIISAVR8_aggr", "cash_isa": "DVCISAVR8_aggr", "student_loan_balance": "Tot_LosR8_aggr - Tot_los_exc_SLCR8_aggr", @@ -956,6 +957,7 @@ "chain_order": [ "owned_land", "property_wealth", + "private_pension_wealth", "corporate_wealth_excl_isa", "stocks_and_shares_isa", "corporate_wealth", @@ -1004,6 +1006,7 @@ "owned_land", "property_wealth", "corporate_wealth", + "private_pension_wealth", "gross_financial_wealth", "net_financial_wealth", "main_residence_value", @@ -1019,6 +1022,7 @@ "owned_land", "property_wealth", "corporate_wealth", + "private_pension_wealth", "gross_financial_wealth", "main_residence_value", "other_residential_property_value", @@ -1029,7 +1033,7 @@ "stocks_and_shares_isa", "student_loan_balance" ], - "notes": "Ports incumbent WAS round-8 wealth imputation with signed E5 differences: exact lower-case column matching replaces the fuzzy r/w fallback; cash ISA uses DVCISAVR8_aggr and stocks-and-shares ISA uses DVIISAVR8_aggr; corporate_wealth folds stocks-and-shares ISA after drawing corporate_wealth_excl_isa; recipient Northern Ireland regions are mapped to Wales for prediction only; student_loan_balance is allocated by household id rather than the incumbent positional off-by-one. Engine predictors materialize at their native entity and person/benunit values are summed to household, reproducing the incumbent map_to=household semantics; region is one-hot encoded jointly across donor and recipient (the incumbent's dummy encoding), with unmapped donor GOR codes becoming all-zero dummy rows. The WAS and FRS predictor definitions are not fully like-for-like and are ported as-is; raw WAS missing values are blanket-filled with zero; UKDS negative sentinel codes (-9/-8/-7/-6) are recoded to zero for the nonnegative-domain columns the licensed audit found carrying them (vcarnr8: 2 rows; HBedRmR8: 95.8 percent - the bedrooms question is effectively unasked in the WAS household file, predictor-quality revisit registered on microcosm#145) - a signed difference vs the incumbent, which trains on raw sentinels; DVPriRntR8's -9 is structural not-applicable so the is_renting mapping is unchanged; genuinely negative domains are never recoded." + "notes": "Ports incumbent WAS round-8 wealth imputation with signed E5 differences: exact lower-case column matching replaces the fuzzy r/w fallback; cash ISA uses DVCISAVR8_aggr and stocks-and-shares ISA uses DVIISAVR8_aggr; private pension wealth (WAS total private pension wealth less the current defined-benefit component, both at the SCAPE rate) is drawn as its own output private_pension_wealth and no longer folded into corporate_wealth, which keeps only the share-like holdings - pension rights are disregarded capital in every means test (UC Regs 2013 Sch 10 para 10 and the HB/JSA/ESA/IS/SPC parallels; uk-data#452) - and the downstream chain segment conditions on both components; corporate_wealth folds stocks-and-shares ISA after drawing corporate_wealth_excl_isa; recipient Northern Ireland regions are mapped to Wales for prediction only; student_loan_balance is allocated by household id rather than the incumbent positional off-by-one. Engine predictors materialize at their native entity and person/benunit values are summed to household, reproducing the incumbent map_to=household semantics; region is one-hot encoded jointly across donor and recipient (the incumbent's dummy encoding), with unmapped donor GOR codes becoming all-zero dummy rows. The WAS and FRS predictor definitions are not fully like-for-like and are ported as-is; raw WAS missing values are blanket-filled with zero; UKDS negative sentinel codes (-9/-8/-7/-6) are recoded to zero for the nonnegative-domain columns the licensed audit found carrying them (vcarnr8: 2 rows; HBedRmR8: 95.8 percent - the bedrooms question is effectively unasked in the WAS household file, predictor-quality revisit registered on microcosm#145) - a signed difference vs the incumbent, which trains on raw sentinels; DVPriRntR8's -9 is structural not-applicable so the is_renting mapping is unchanged; genuinely negative domains are never recoded." }, { "stage": "regional_property_uprating", diff --git a/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml b/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml index e41947507..1d26cbda2 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml +++ b/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml @@ -720,7 +720,8 @@ stages: num_vehicles: vcarnr8 weight: R8xshhwgt derived: - corporate_wealth_excl_isa: (totalpenr8_aggr - dvvaldbt_scaper8_aggr) + DVFESHARESR8_aggr + DVFShUKVR8_aggr + DVFCollVR8_aggr + private_pension_wealth: totalpenr8_aggr - dvvaldbt_scaper8_aggr + corporate_wealth_excl_isa: DVFESHARESR8_aggr + DVFShUKVR8_aggr + DVFCollVR8_aggr stocks_and_shares_isa: DVIISAVR8_aggr cash_isa: DVCISAVR8_aggr student_loan_balance: Tot_LosR8_aggr - Tot_los_exc_SLCR8_aggr @@ -763,6 +764,7 @@ stages: chain_order: - owned_land - property_wealth + - private_pension_wealth - corporate_wealth_excl_isa - stocks_and_shares_isa - corporate_wealth @@ -799,6 +801,7 @@ stages: - owned_land - property_wealth - corporate_wealth + - private_pension_wealth - gross_financial_wealth - net_financial_wealth - main_residence_value @@ -813,6 +816,7 @@ stages: - owned_land - property_wealth - corporate_wealth + - private_pension_wealth - gross_financial_wealth - main_residence_value - other_residential_property_value @@ -822,7 +826,7 @@ stages: - cash_isa - stocks_and_shares_isa - student_loan_balance - notes: 'Ports incumbent WAS round-8 wealth imputation with signed E5 differences: exact lower-case column matching replaces the fuzzy r/w fallback; cash ISA uses DVCISAVR8_aggr and stocks-and-shares ISA uses DVIISAVR8_aggr; corporate_wealth folds stocks-and-shares ISA after drawing corporate_wealth_excl_isa; recipient Northern Ireland regions are mapped to Wales for prediction only; student_loan_balance is allocated by household id rather than the incumbent positional off-by-one. Engine predictors materialize at their native entity and person/benunit values are summed to household, reproducing the incumbent map_to=household semantics; region is one-hot encoded jointly across donor and recipient (the incumbent''s dummy encoding), with unmapped donor GOR codes becoming all-zero dummy rows. The WAS and FRS predictor definitions are not fully like-for-like and are ported as-is; raw WAS missing values are blanket-filled with zero; UKDS negative sentinel codes (-9/-8/-7/-6) are recoded to zero + notes: 'Ports incumbent WAS round-8 wealth imputation with signed E5 differences: exact lower-case column matching replaces the fuzzy r/w fallback; cash ISA uses DVCISAVR8_aggr and stocks-and-shares ISA uses DVIISAVR8_aggr; private pension wealth (WAS total private pension wealth less the current defined-benefit component, both at the SCAPE rate) is drawn as its own output private_pension_wealth and no longer folded into corporate_wealth, which keeps only the share-like holdings - pension rights are disregarded capital in every means test (UC Regs 2013 Sch 10 para 10 and the HB/JSA/ESA/IS/SPC parallels; uk-data#452) - and the downstream chain segment conditions on both components; corporate_wealth folds stocks-and-shares ISA after drawing corporate_wealth_excl_isa; recipient Northern Ireland regions are mapped to Wales for prediction only; student_loan_balance is allocated by household id rather than the incumbent positional off-by-one. Engine predictors materialize at their native entity and person/benunit values are summed to household, reproducing the incumbent map_to=household semantics; region is one-hot encoded jointly across donor and recipient (the incumbent''s dummy encoding), with unmapped donor GOR codes becoming all-zero dummy rows. The WAS and FRS predictor definitions are not fully like-for-like and are ported as-is; raw WAS missing values are blanket-filled with zero; UKDS negative sentinel codes (-9/-8/-7/-6) are recoded to zero for the nonnegative-domain columns the licensed audit found carrying them (vcarnr8: 2 rows; HBedRmR8: 95.8 percent - the bedrooms question is effectively unasked in the WAS household file, predictor-quality revisit registered on microcosm#145) - a signed difference vs the incumbent, which trains on raw sentinels; DVPriRntR8''s -9 is structural not-applicable so the is_renting mapping is unchanged; genuinely negative domains are never recoded.' - stage: regional_property_uprating survey: Public regional property reference diff --git a/packages/microcosm-build/src/microcosm/build/uk/was_wealth_support_bounds.json b/packages/microcosm-build/src/microcosm/build/uk/was_wealth_support_bounds.json index 0d9bce9bd..1cc3475e7 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/was_wealth_support_bounds.json +++ b/packages/microcosm-build/src/microcosm/build/uk/was_wealth_support_bounds.json @@ -22,6 +22,10 @@ 0.0, 30000000 ], + "private_pension_wealth": [ + 0.0, + 8000000 + ], "gross_financial_wealth": [ 0.0, 30000000 diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/terminal_gates.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/terminal_gates.py index cefeeb86e..42cd99cf4 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/terminal_gates.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/terminal_gates.py @@ -175,6 +175,7 @@ def __post_init__(self) -> None: "household.msoa_code", "household.num_vehicles", "household.oa_code", + "household.private_pension_wealth", "household.property_purchased", "household.rail_usage", "household.region_code_oa", diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/was_wealth.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/was_wealth.py index 9c2dc72b1..e1f58cbf8 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/was_wealth.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/was_wealth.py @@ -54,6 +54,7 @@ "owned_land", "property_wealth", "corporate_wealth", + "private_pension_wealth", "gross_financial_wealth", "net_financial_wealth", "main_residence_value", @@ -252,10 +253,18 @@ def clean_was_household_table(raw: pd.DataFrame) -> pd.DataFrame: values = cleaned[column] cleaned[column] = values.where(~values.isin(_SENTINEL_CODES), 0) cleaned["is_renting"] = cleaned["private_rent_code"] == 1 + # Private pension wealth other than current-employment defined-benefit + # entitlements (WAS total private pension wealth less DVValDBT_SCAPE, both + # at the SCAPE discount rate): DC pots, AVCs, current personal pensions, + # retained DB/DC rights, pensions in payment and pension-sharing rights. + # The incumbent folds this into corporate_wealth, where the means-tested + # capital tests count it; pension rights are disregarded capital (UC Regs + # 2013 Sch 10 para 10 and the parallel HB/JSA/ESA/IS/SPC paragraphs), so + # the stage emits it as its own column and keeps corporate_wealth to the + # share-like holdings (uk-data#452). + cleaned["private_pension_wealth"] = cleaned["pensions"] - cleaned["db_pensions"] cleaned["corporate_wealth_excl_isa"] = ( - cleaned["pensions"] - - cleaned["db_pensions"] - + cleaned["emp_shares_options"] + cleaned["emp_shares_options"] + cleaned["uk_shares"] + cleaned["unit_investment_trusts"] ) @@ -397,18 +406,37 @@ def run_segment(base_predictors: Sequence[str], targets: Sequence[str]) -> None: base = encoded_predictors run_segment(base, ("owned_land", "property_wealth")) donor_encoded["corporate_wealth"] = donor_encoded["corporate_wealth"].astype(float) + donor_encoded["private_pension_wealth"] = donor_encoded[ + "private_pension_wealth" + ].astype(float) recipient_encoded["owned_land"] = raw["owned_land"] recipient_encoded["property_wealth"] = raw["property_wealth"] + # Private pension wealth is drawn first in the position the old folded + # corporate_wealth (84.7% pension by donor mass) occupied; the share-like + # components condition on it, and the fold into corporate_wealth follows. run_segment( (*base, "owned_land", "property_wealth"), - ("corporate_wealth_excl_isa", "stocks_and_shares_isa"), + ( + "private_pension_wealth", + "corporate_wealth_excl_isa", + "stocks_and_shares_isa", + ), ) raw["corporate_wealth"] = ( raw["corporate_wealth_excl_isa"] + raw["stocks_and_shares_isa"] ) + recipient_encoded["private_pension_wealth"] = raw["private_pension_wealth"] recipient_encoded["corporate_wealth"] = raw["corporate_wealth"] + # Downstream targets condition on both components, carrying the + # information the old folded corporate_wealth supplied as one column. run_segment( - (*base, "owned_land", "property_wealth", "corporate_wealth"), + ( + *base, + "owned_land", + "property_wealth", + "private_pension_wealth", + "corporate_wealth", + ), ( "gross_financial_wealth", "net_financial_wealth", diff --git a/packages/microcosm-build/tests/test_spec_engine_country_bundles.py b/packages/microcosm-build/tests/test_spec_engine_country_bundles.py index 617eff559..68b2a9a96 100644 --- a/packages/microcosm-build/tests/test_spec_engine_country_bundles.py +++ b/packages/microcosm-build/tests/test_spec_engine_country_bundles.py @@ -42,7 +42,7 @@ ), ( "uk", - "e12a2cb87c0e096af0173bd51fbede4b7df5e7c118ffe07ef616bbb30640e4ea", + "d8a4ecff17bcd03952c5d5918aa0809f56c0e84c480460151b863e3c7a4bfda2", { "benunit.benunit_id", "household.household_id", diff --git a/packages/microcosm-build/tests/test_uk_was_wealth.py b/packages/microcosm-build/tests/test_uk_was_wealth.py index ebd41ccf6..9dd565f0d 100644 --- a/packages/microcosm-build/tests/test_uk_was_wealth.py +++ b/packages/microcosm-build/tests/test_uk_was_wealth.py @@ -152,8 +152,11 @@ def test_was_donor_cleaning_arithmetic_and_exact_case_insensitive_columns() -> N assert donor["stocks_and_shares_isa"].tolist() == [5.0, 6.0] assert donor["cash_isa"].tolist() == [7.0, 8.0] - assert donor["corporate_wealth_excl_isa"].tolist() == [73.0, 166.0] - assert donor["corporate_wealth"].tolist() == [78.0, 172.0] + # Private pension wealth (total pensions less current DB) is its own + # output; corporate_wealth keeps only the share-like holdings. + assert donor["private_pension_wealth"].tolist() == [60.0, 150.0] + assert donor["corporate_wealth_excl_isa"].tolist() == [13.0, 16.0] + assert donor["corporate_wealth"].tolist() == [18.0, 22.0] assert donor["student_loan_balance"].tolist() == [5000.0, 2000.0] assert donor["region"].tolist() == ["LONDON", "SCOTLAND"] assert donor["is_renting"].tolist() == [True, False] @@ -411,11 +414,44 @@ def fit_draw_next( assert result.draws.columns.tolist() == list(UK_WAS_WEALTH_OUTPUT_COLUMNS) assert calls[0][1] == ("owned_land", "property_wealth") - assert calls[1][1] == ("corporate_wealth_excl_isa", "stocks_and_shares_isa") + assert calls[1][1] == ( + "private_pension_wealth", + "corporate_wealth_excl_isa", + "stocks_and_shares_isa", + ) + assert "private_pension_wealth" in calls[2][0] assert "corporate_wealth" in calls[2][0] + assert "private_pension_wealth" not in calls[1][0] assert calls[2][1][-1] == "cash_isa" fitted_targets = [name for _, targets in calls for name in targets] assert [record.fit_name for record in result.fit_weight_records] == [ f"uk_was_2018_20_wealth:{target}" for target in fitted_targets ] assert {record.weight_kind for record in result.fit_weight_records} == {"explicit"} + + +def test_private_pension_wealth_split_preserves_the_old_corporate_wealth_identity() -> ( + None +): + """The pension component plus the new corporate_wealth reproduces the + pre-split corporate_wealth row for row, and the pension component is + exactly total pensions less current defined-benefit wealth.""" + raw = _raw_was() + donor = clean_was_household_table(raw) + + old_corporate_wealth = ( + raw["totalpenr8_aggr"] + - raw["dvvaldbt_scaper8_aggr"] + + raw["DVFESHARESR8_aggr"] + + raw["DVFShUKVR8_aggr"] + + raw["DVFCollVR8_aggr"] + + raw["DVIISAVR8_aggR"] + ) + assert ( + donor["corporate_wealth"] + donor["private_pension_wealth"] + ).tolist() == old_corporate_wealth.tolist() + assert "private_pension_wealth" in UK_WAS_WEALTH_OUTPUT_COLUMNS + assert ( + UK_WAS_WEALTH_OUTPUT_COLUMNS.index("private_pension_wealth") + == UK_WAS_WEALTH_OUTPUT_COLUMNS.index("corporate_wealth") + 1 + ) diff --git a/packages/microcosm-data/src/microcosm/data/contract.py b/packages/microcosm-data/src/microcosm/data/contract.py index 3e5190c2c..edc0a0a94 100644 --- a/packages/microcosm-data/src/microcosm/data/contract.py +++ b/packages/microcosm-data/src/microcosm/data/contract.py @@ -344,13 +344,13 @@ # fingerprint derives from the manifest digest. Editing the spec moves all # three here in the same reviewed change. _UK_GATE_BATTERY_POLICY_SHA256 = ( - "404968fba9a626d4b534dfbef87721ff9d98c5af356758b2bab49dbaf004fdc3" + "a0090bbf99f6437fda7cd8e73e8edae1f17023aa588d51f3155c1217339fd2ac" ) _UK_GATE_BATTERY_GATES_MANIFEST_SHA256 = ( - "59c7808d50a9ef84d37f524779a7518b4fb4f62dc7d17eb47e4a108d830c3798" + "12467badd0109521ee8a47de01d5c5880d1f5666a08716fb4f984843842c7809" ) _UK_GATE_BATTERY_SPEC_FINGERPRINT = ( - "bfb987361037e6475ea9906894cb16e5b3cd0ff515096bd55d4918dfd7331d2c" + "e23ba388a40aebd3f3f9e2b141fa03076a4a3f6f44023e635947902ab67f0b4b" ) #: Spec entry id -> the legacy gate name whose observable detail checks #: apply unchanged (the battery re-keys the report by entry id; the gate diff --git a/packages/microcosm-data/tests/test_contract.py b/packages/microcosm-data/tests/test_contract.py index 8daf0078f..ecd5b893e 100644 --- a/packages/microcosm-data/tests/test_contract.py +++ b/packages/microcosm-data/tests/test_contract.py @@ -145,13 +145,13 @@ def _trusted_terminal_gate_signing_key(monkeypatch) -> None: UK_GATE_BATTERY_PRODUCER = "microcosm.build.gate_battery" UK_GATE_BATTERY_SIGNING_KEY_ENV = "MICROCOSM_UK_TERMINAL_GATE_SIGNING_KEY" UK_GATE_BATTERY_POLICY_SHA256 = ( - "404968fba9a626d4b534dfbef87721ff9d98c5af356758b2bab49dbaf004fdc3" + "a0090bbf99f6437fda7cd8e73e8edae1f17023aa588d51f3155c1217339fd2ac" ) UK_GATE_BATTERY_GATES_MANIFEST_SHA256 = ( - "59c7808d50a9ef84d37f524779a7518b4fb4f62dc7d17eb47e4a108d830c3798" + "12467badd0109521ee8a47de01d5c5880d1f5666a08716fb4f984843842c7809" ) UK_GATE_BATTERY_SPEC_FINGERPRINT = ( - "bfb987361037e6475ea9906894cb16e5b3cd0ff515096bd55d4918dfd7331d2c" + "e23ba388a40aebd3f3f9e2b141fa03076a4a3f6f44023e635947902ab67f0b4b" ) UK_GATE_BATTERY_DEGENERATE_EVIDENCE_SHA256 = ( "d0d024043132fa07c378c393dbe2b24fe99bf19e876bcc39997d2c80cc9bd4f6" From a222355bd4a114be2a94b106de52a5c00e5722af Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 23 Aug 2026 12:22:18 +0200 Subject: [PATCH 2/2] WAS chain: one child seed per segment (review finding on #750) impute_was_wealth reused one RegimeGatedQRF across its three chain segments; RegimeGatedQRF.start_chain respawns the fit and draw streams from the model seed on every call, so the k-th target of every segment consumed the same quantile and sign-gate uniforms per recipient - owned_land coupled with the first segment-2 target and property_wealth with the second, which the pension split makes the countable share-like holdings. On train/hold-out halves of the licensed donor the review measured P(shares > 0 | property_wealth = 0) at 0.011 against 0.055 observed, recovering to 0.045 with distinct per-segment seeds (coupling receipt committed alongside the measurement receipt, aggregates only). The stage now derives one child seed per segment from the declared stage seed (SeedSequence(seed).spawn(3)); the declared fit_weighted_qrf_chain seed stays the root, the result records the segment seeds, and a unit test holds the three roots distinct and deterministic. The spec note documents the behaviour (non-normative: the UK spec_sha256 is unchanged) and the coverage manifest's source_stages.json pins move with it. Re-measured: the UC caseload effect of the split is unchanged (6.268m vs 6.266m UK benefit units at 2025); the fix corrects who holds the countable assets. Co-Authored-By: Claude Fable 5 --- ...pension-wealth-split-coupling-receipt.json | 243 ++++++++++++++++++ .../452-uk-pension-wealth-split-receipt.json | 219 +++++++++++++++- experiments/452-uk-pension-wealth-split.md | 32 ++- .../uk/release_input_coverage_manifest.json | 12 +- .../src/microcosm/build/uk/source_stages.json | 2 +- .../src/microcosm/build/uk/spec/sources.yaml | 2 +- .../microcosm/build/uk_runtime/was_wealth.py | 28 +- .../tests/test_uk_was_wealth.py | 22 +- 8 files changed, 548 insertions(+), 12 deletions(-) create mode 100644 experiments/452-uk-pension-wealth-split-coupling-receipt.json diff --git a/experiments/452-uk-pension-wealth-split-coupling-receipt.json b/experiments/452-uk-pension-wealth-split-coupling-receipt.json new file mode 100644 index 000000000..7ea4f405c --- /dev/null +++ b/experiments/452-uk-pension-wealth-split-coupling-receipt.json @@ -0,0 +1,243 @@ +{ + "n_estimators": 30, + "truth": { + "P(owned_land_pos)": 0.0076383512194625365, + "P(owned_land_pos&property_wealth_pos)": 0.0076383512194625365, + "ratio_vs_indep(owned_land_pos,property_wealth_pos)": 1.558018820525906, + "P(property_wealth_pos|owned_land_pos)": 1.0, + "P(property_wealth_pos|not owned_land_pos)": 0.6390839669376266, + "P(owned_land_pos&pension_pos)": 0.007427542905194072, + "ratio_vs_indep(owned_land_pos,pension_pos)": 1.3157807591526687, + "P(pension_pos|owned_land_pos)": 0.9724013326683218, + "P(pension_pos|not owned_land_pos)": 0.7372336138234814, + "P(owned_land_pos&shares_excl_pos)": 0.003678065948690654, + "ratio_vs_indep(owned_land_pos,shares_excl_pos)": 2.7087590308557132, + "P(shares_excl_pos|owned_land_pos)": 0.4815261622585426, + "P(shares_excl_pos|not owned_land_pos)": 0.17542825051479743, + "P(owned_land_pos&isa_pos)": 0.0015102923671691392, + "ratio_vs_indep(owned_land_pos,isa_pos)": 1.5441250279671401, + "P(isa_pos|owned_land_pos)": 0.19772491782269852, + "P(isa_pos|not owned_land_pos)": 0.12751351150960955, + "P(property_wealth_pos)": 0.6418407703588922, + "P(property_wealth_pos&pension_pos)": 0.5384180116374168, + "ratio_vs_indep(property_wealth_pos,pension_pos)": 1.1350899158187926, + "P(pension_pos|property_wealth_pos)": 0.8388653954412315, + "P(pension_pos|not property_wealth_pos)": 0.5601192967136539, + "P(property_wealth_pos&shares_excl_pos)": 0.15801676395202507, + "ratio_vs_indep(property_wealth_pos,shares_excl_pos)": 1.3849252939692862, + "P(shares_excl_pos|property_wealth_pos)": 0.24619309219585458, + "P(shares_excl_pos|not property_wealth_pos)": 0.055141870670218765, + "P(property_wealth_pos&isa_pos)": 0.11703642780332342, + "ratio_vs_indep(property_wealth_pos,isa_pos)": 1.4240158258447393, + "P(isa_pos|property_wealth_pos)": 0.1823449572046993, + "P(isa_pos|not property_wealth_pos)": 0.030749963077466483, + "P(pension_pos)": 0.7390299074554981, + "P(pension_pos&shares_excl_pos)": 0.16338613792697204, + "ratio_vs_indep(pension_pos,shares_excl_pos)": 1.2436657586410924, + "P(shares_excl_pos|pension_pos)": 0.22108190247606535, + "P(shares_excl_pos|not pension_pos)": 0.05510285031153905, + "P(pension_pos&isa_pos)": 0.11449208905849834, + "ratio_vs_indep(pension_pos,isa_pos)": 1.2098583447414697, + "P(isa_pos|pension_pos)": 0.1549221322486095, + "P(isa_pos|not pension_pos)": 0.051951247363071155, + "P(shares_excl_pos)": 0.17776633387224022, + "P(shares_excl_pos&isa_pos)": 0.056780288630039935, + "ratio_vs_indep(shares_excl_pos,isa_pos)": 2.4944174642082237, + "P(isa_pos|shares_excl_pos)": 0.3194096845741761, + "P(isa_pos|not shares_excl_pos)": 0.08667794228888633, + "P(isa_pos)": 0.12804981089064132 + }, + "D1_pension_first:same_seed_per_segment(stage as-is)": { + "P(owned_land_pos)": 0.00196559740097869, + "P(owned_land_pos&property_wealth_pos)": 0.00196559740097869, + "ratio_vs_indep(owned_land_pos,property_wealth_pos)": 1.5176233931560639, + "P(property_wealth_pos|owned_land_pos)": 1.0, + "P(property_wealth_pos|not owned_land_pos)": 0.658253267242873, + "P(owned_land_pos&pension_pos)": 0.00196559740097869, + "ratio_vs_indep(owned_land_pos,pension_pos)": 1.3491400871721204, + "P(pension_pos|owned_land_pos)": 1.0, + "P(pension_pos|not owned_land_pos)": 0.7407031998150472, + "P(owned_land_pos&shares_excl_pos)": 0.000297902582448321, + "ratio_vs_indep(owned_land_pos,shares_excl_pos)": 0.8991488025158029, + "P(shares_excl_pos|owned_land_pos)": 0.15155829077714103, + "P(shares_excl_pos|not owned_land_pos)": 0.16859099779208564, + "P(owned_land_pos&isa_pos)": 0.00046631199335324164, + "ratio_vs_indep(owned_land_pos,isa_pos)": 1.8754150093940034, + "P(isa_pos|owned_land_pos)": 0.23723677754206451, + "P(isa_pos|not owned_land_pos)": 0.12628018644840672, + "P(property_wealth_pos)": 0.6589250037325733, + "P(property_wealth_pos&pension_pos)": 0.5571833986840257, + "ratio_vs_indep(property_wealth_pos,pension_pos)": 1.1408255185540233, + "P(pension_pos|property_wealth_pos)": 0.8455945601210789, + "P(pension_pos|not property_wealth_pos)": 0.5395572125235916, + "P(property_wealth_pos&shares_excl_pos)": 0.1646804701791328, + "ratio_vs_indep(property_wealth_pos,shares_excl_pos)": 1.482716027087137, + "P(shares_excl_pos|property_wealth_pos)": 0.24992293393979148, + "P(shares_excl_pos|not property_wealth_pos)": 0.011367142742040188, + "P(property_wealth_pos&isa_pos)": 0.12056520593331854, + "ratio_vs_indep(property_wealth_pos,isa_pos)": 1.4464431722099018, + "P(isa_pos|property_wealth_pos)": 0.18297257692508248, + "P(isa_pos|not property_wealth_pos)": 0.017395225587018327, + "P(pension_pos)": 0.7412128729315728, + "P(pension_pos&shares_excl_pos)": 0.1542624998744278, + "ratio_vs_indep(pension_pos,shares_excl_pos)": 1.2347222750335949, + "P(shares_excl_pos|pension_pos)": 0.20812172252797476, + "P(shares_excl_pos|not pension_pos)": 0.05523852223622324, + "P(pension_pos&isa_pos)": 0.1175458693344547, + "ratio_vs_indep(pension_pos,isa_pos)": 1.2536600604161776, + "P(isa_pos|pension_pos)": 0.1585858444006089, + "P(isa_pos|not pension_pos)": 0.034593734249617494, + "P(shares_excl_pos)": 0.16855751834744545, + "P(shares_excl_pos&isa_pos)": 0.05973213266404448, + "ratio_vs_indep(shares_excl_pos,isa_pos)": 2.801400898486773, + "P(isa_pos|shares_excl_pos)": 0.3543724020717925, + "P(isa_pos|not shares_excl_pos)": 0.0803015857918814, + "P(isa_pos)": 0.12649828243548186, + "seconds": 15.9 + }, + "D1_pension_first:fresh_seed_per_segment": { + "P(owned_land_pos)": 0.0018401306520696005, + "P(owned_land_pos&property_wealth_pos)": 0.0018401306520696005, + "ratio_vs_indep(owned_land_pos,property_wealth_pos)": 1.5227532991793198, + "P(property_wealth_pos|owned_land_pos)": 1.0, + "P(property_wealth_pos|not owned_land_pos)": 0.6560723216433186, + "P(owned_land_pos&pension_pos)": 0.001696498871896804, + "ratio_vs_indep(owned_land_pos,pension_pos)": 1.2352266494872681, + "P(pension_pos|owned_land_pos)": 0.9219447923378411, + "P(pension_pos|not owned_land_pos)": 0.7460533621869111, + "P(owned_land_pos&shares_excl_pos)": 0.0006115432048604415, + "ratio_vs_indep(owned_land_pos,shares_excl_pos)": 2.0396253523386902, + "P(shares_excl_pos|owned_land_pos)": 0.3323368393285754, + "P(shares_excl_pos|not owned_land_pos)": 0.16262785273829086, + "P(owned_land_pos&isa_pos)": 0.0003618214169878084, + "ratio_vs_indep(owned_land_pos,isa_pos)": 1.6727051580102021, + "P(isa_pos|owned_land_pos)": 0.19662811256410884, + "P(isa_pos|not owned_land_pos)": 0.1174051887018394, + "P(property_wealth_pos)": 0.6567051935063579, + "P(property_wealth_pos&pension_pos)": 0.5562533246078971, + "ratio_vs_indep(property_wealth_pos,pension_pos)": 1.134864225990029, + "P(pension_pos|property_wealth_pos)": 0.8470365852261403, + "P(pension_pos|not property_wealth_pos)": 0.5538204982853192, + "P(property_wealth_pos&shares_excl_pos)": 0.14756147269627434, + "ratio_vs_indep(property_wealth_pos,shares_excl_pos)": 1.379032325266063, + "P(shares_excl_pos|property_wealth_pos)": 0.22469971938001085, + "P(shares_excl_pos|not property_wealth_pos)": 0.04479726013698902, + "P(property_wealth_pos&isa_pos)": 0.10800472613127614, + "ratio_vs_indep(property_wealth_pos,isa_pos)": 1.3990914249140034, + "P(isa_pos|property_wealth_pos)": 0.1644645530433596, + "P(isa_pos|not property_wealth_pos)": 0.027807711973893777, + "P(pension_pos)": 0.7463770253989682, + "P(pension_pos&shares_excl_pos)": 0.15060678529527682, + "ratio_vs_indep(pension_pos,shares_excl_pos)": 1.2383921551979367, + "P(shares_excl_pos|pension_pos)": 0.20178379045733824, + "P(shares_excl_pos|not pension_pos)": 0.04862869450439896, + "P(pension_pos&isa_pos)": 0.10978857394433011, + "ratio_vs_indep(pension_pos,isa_pos)": 1.251332334729169, + "P(isa_pos|pension_pos)": 0.14709532877923695, + "P(isa_pos|not pension_pos)": 0.030606041508130954, + "P(shares_excl_pos)": 0.16294013944644734, + "P(shares_excl_pos&isa_pos)": 0.05474098400606996, + "ratio_vs_indep(shares_excl_pos,isa_pos)": 2.8579741866485104, + "P(isa_pos|shares_excl_pos)": 0.3359576356816694, + "P(isa_pos|not shares_excl_pos)": 0.07503643190437821, + "P(isa_pos)": 0.11755096923238494, + "seconds": 18.3 + }, + "D2_shares_first:same_seed_per_segment(stage as-is)": { + "P(owned_land_pos)": 0.00196559740097869, + "P(owned_land_pos&property_wealth_pos)": 0.00196559740097869, + "ratio_vs_indep(owned_land_pos,property_wealth_pos)": 1.5176233931560639, + "P(property_wealth_pos|owned_land_pos)": 1.0, + "P(property_wealth_pos|not owned_land_pos)": 0.658253267242873, + "P(owned_land_pos&pension_pos)": 0.0009746705564756995, + "ratio_vs_indep(owned_land_pos,pension_pos)": 0.668350101335664, + "P(pension_pos|owned_land_pos)": 0.49586479713007436, + "P(pension_pos|not owned_land_pos)": 0.7424083308422835, + "P(owned_land_pos&shares_excl_pos)": 0.0011692906218771662, + "ratio_vs_indep(owned_land_pos,shares_excl_pos)": 3.9137745375500335, + "P(shares_excl_pos|owned_land_pos)": 0.5948779853366539, + "P(shares_excl_pos|not owned_land_pos)": 0.15112373498894455, + "P(owned_land_pos&isa_pos)": 0.0003240965753945136, + "ratio_vs_indep(owned_land_pos,isa_pos)": 1.2361621336152444, + "P(isa_pos|owned_land_pos)": 0.1648845156353701, + "P(isa_pos|not owned_land_pos)": 0.1333221760100451, + "P(property_wealth_pos)": 0.6589250037325733, + "P(property_wealth_pos&pension_pos)": 0.5540640092367449, + "ratio_vs_indep(property_wealth_pos,pension_pos)": 1.1333516813226265, + "P(pension_pos|property_wealth_pos)": 0.8408605017235216, + "P(pension_pos|not property_wealth_pos)": 0.5507871240410442, + "P(property_wealth_pos&shares_excl_pos)": 0.14211059347518623, + "ratio_vs_indep(property_wealth_pos,shares_excl_pos)": 1.418921507399471, + "P(shares_excl_pos|property_wealth_pos)": 0.21567036107323412, + "P(shares_excl_pos|not property_wealth_pos)": 0.028983020810954458, + "P(property_wealth_pos&isa_pos)": 0.12863443635850574, + "ratio_vs_indep(property_wealth_pos,isa_pos)": 1.4635812039973677, + "P(isa_pos|property_wealth_pos)": 0.19521862978311325, + "P(isa_pos|not property_wealth_pos)": 0.013925906490523065, + "P(pension_pos)": 0.7419237255131907, + "P(pension_pos&shares_excl_pos)": 0.1380483861486046, + "ratio_vs_indep(pension_pos,shares_excl_pos)": 1.2241650628035117, + "P(shares_excl_pos|pension_pos)": 0.18606816496280146, + "P(shares_excl_pos|not pension_pos)": 0.05404445282400248, + "P(pension_pos&isa_pos)": 0.12074941170340445, + "ratio_vs_indep(pension_pos,isa_pos)": 1.2201727661123043, + "P(isa_pos|pension_pos)": 0.16275178640483795, + "P(isa_pos|not pension_pos)": 0.04895763155486333, + "P(shares_excl_pos)": 0.15199597719010127, + "P(shares_excl_pos&isa_pos)": 0.057176770336181564, + "ratio_vs_indep(shares_excl_pos,isa_pos)": 2.8202206600142823, + "P(isa_pos|shares_excl_pos)": 0.37617291847580026, + "P(isa_pos|not shares_excl_pos)": 0.08986684317143111, + "P(isa_pos)": 0.13338421486278143, + "seconds": 13.0 + }, + "D2_shares_first:fresh_seed_per_segment": { + "P(owned_land_pos)": 0.0018401306520696005, + "P(owned_land_pos&property_wealth_pos)": 0.0018401306520696005, + "ratio_vs_indep(owned_land_pos,property_wealth_pos)": 1.5227532991793198, + "P(property_wealth_pos|owned_land_pos)": 1.0, + "P(property_wealth_pos|not owned_land_pos)": 0.6560723216433186, + "P(owned_land_pos&pension_pos)": 0.0017951146194863706, + "ratio_vs_indep(owned_land_pos,pension_pos)": 1.2928305693909663, + "P(pension_pos|owned_land_pos)": 0.9755365019692487, + "P(pension_pos|not owned_land_pos)": 0.754166780661715, + "P(owned_land_pos&shares_excl_pos)": 0.0003855737593142302, + "ratio_vs_indep(owned_land_pos,shares_excl_pos)": 1.3848592092432241, + "P(shares_excl_pos|owned_land_pos)": 0.2095360777130549, + "P(shares_excl_pos|not owned_land_pos)": 0.1511976171585101, + "P(owned_land_pos&isa_pos)": 0.0005964200983227716, + "ratio_vs_indep(owned_land_pos,isa_pos)": 2.6343543430758505, + "P(isa_pos|owned_land_pos)": 0.32411834325566835, + "P(isa_pos|not owned_land_pos)": 0.12266450993249679, + "P(property_wealth_pos)": 0.6567051935063579, + "P(property_wealth_pos&pension_pos)": 0.5577197871103904, + "ratio_vs_indep(property_wealth_pos,pension_pos)": 1.1254953121501114, + "P(pension_pos|property_wealth_pos)": 0.8492696458399347, + "P(pension_pos|not property_wealth_pos)": 0.5734265099189274, + "P(property_wealth_pos&shares_excl_pos)": 0.13832545657158696, + "ratio_vs_indep(property_wealth_pos,shares_excl_pos)": 1.392125775963621, + "P(shares_excl_pos|property_wealth_pos)": 0.21063554535486972, + "P(shares_excl_pos|not property_wealth_pos)": 0.037808643564855374, + "P(property_wealth_pos&isa_pos)": 0.1123710211686045, + "ratio_vs_indep(property_wealth_pos,isa_pos)": 1.3907672559754307, + "P(isa_pos|property_wealth_pos)": 0.17111334321664168, + "P(isa_pos|not property_wealth_pos)": 0.031064233818418323, + "P(pension_pos)": 0.7545741298713331, + "P(pension_pos&shares_excl_pos)": 0.13810960521704171, + "ratio_vs_indep(pension_pos,shares_excl_pos)": 1.2096752234377992, + "P(shares_excl_pos|pension_pos)": 0.18302987042584087, + "P(shares_excl_pos|not pension_pos)": 0.053765164707418815, + "P(pension_pos&isa_pos)": 0.11466570181099152, + "ratio_vs_indep(pension_pos,isa_pos)": 1.2351002295512417, + "P(isa_pos|pension_pos)": 0.15196081772713815, + "P(isa_pos|not pension_pos)": 0.03410198562520064, + "P(shares_excl_pos)": 0.15130496754797107, + "P(shares_excl_pos&isa_pos)": 0.05358826345012221, + "ratio_vs_indep(shares_excl_pos,isa_pos)": 2.8786381557290697, + "P(isa_pos|shares_excl_pos)": 0.3541738537641344, + "P(isa_pos|not shares_excl_pos)": 0.08182791839302397, + "P(isa_pos)": 0.12303521130617168, + "seconds": 16.4 + }, + "elapsed_seconds": 64.2 +} \ No newline at end of file diff --git a/experiments/452-uk-pension-wealth-split-receipt.json b/experiments/452-uk-pension-wealth-split-receipt.json index a8d657341..f479ac895 100644 --- a/experiments/452-uk-pension-wealth-split-receipt.json +++ b/experiments/452-uk-pension-wealth-split-receipt.json @@ -242,6 +242,86 @@ "over_16k_weighted_share": 0.1633, "weighted_mean_gbp": 27174.0 } + }, + "new_redraw_per_segment_seeds": { + "owned_land": { + "weighted_total_bn": 110.987, + "nonzero_weighted_share": 0.0154, + "over_16k_weighted_share": 0.0134, + "weighted_mean_gbp": 3567.0 + }, + "property_wealth": { + "weighted_total_bn": 11368.05, + "nonzero_weighted_share": 0.6503, + "over_16k_weighted_share": 0.6502, + "weighted_mean_gbp": 365351.0 + }, + "corporate_wealth": { + "weighted_total_bn": 1794.458, + "nonzero_weighted_share": 0.3532, + "over_16k_weighted_share": 0.2306, + "weighted_mean_gbp": 57671.0 + }, + "private_pension_wealth": { + "weighted_total_bn": 5673.223, + "nonzero_weighted_share": 0.7081, + "over_16k_weighted_share": 0.5849, + "weighted_mean_gbp": 182328.0 + }, + "gross_financial_wealth": { + "weighted_total_bn": 4812.26, + "nonzero_weighted_share": 0.9819, + "over_16k_weighted_share": 0.6265, + "weighted_mean_gbp": 154658.0 + }, + "net_financial_wealth": { + "weighted_total_bn": 4548.989, + "nonzero_weighted_share": 0.9981, + "over_16k_weighted_share": 0.5664, + "weighted_mean_gbp": 146197.0 + }, + "main_residence_value": { + "weighted_total_bn": 8594.245, + "nonzero_weighted_share": 0.6004, + "over_16k_weighted_share": 0.6004, + "weighted_mean_gbp": 276205.0 + }, + "other_residential_property_value": { + "weighted_total_bn": 679.914, + "nonzero_weighted_share": 0.0563, + "over_16k_weighted_share": 0.055, + "weighted_mean_gbp": 21851.0 + }, + "non_residential_property_value": { + "weighted_total_bn": 137.889, + "nonzero_weighted_share": 0.0133, + "over_16k_weighted_share": 0.0124, + "weighted_mean_gbp": 4432.0 + }, + "savings": { + "weighted_total_bn": 898.63, + "nonzero_weighted_share": 0.6472, + "over_16k_weighted_share": 0.2824, + "weighted_mean_gbp": 28881.0 + }, + "num_vehicles": { + "weighted_total_bn": 0.036, + "nonzero_weighted_share": 0.7587, + "over_16k_weighted_share": 0.0, + "weighted_mean_gbp": 1.0 + }, + "cash_isa": { + "weighted_total_bn": 351.255, + "nonzero_weighted_share": 0.3493, + "over_16k_weighted_share": 0.1753, + "weighted_mean_gbp": 11289.0 + }, + "stocks_and_shares_isa": { + "weighted_total_bn": 747.627, + "nonzero_weighted_share": 0.2097, + "over_16k_weighted_share": 0.1722, + "weighted_mean_gbp": 24028.0 + } } }, "uc_2025": { @@ -456,6 +536,66 @@ "gb_couple_no_children_m": 0.333, "gb_couple_with_children_m": 0.9499 } + }, + "microcosm_stage_new_arithmetic_per_segment_seeds": { + "as_is": { + "uk_m": 6.2682, + "gb_m": 6.0898, + "records": 6591, + "spend_bn": 71.41, + "eligible_m": 15.5714, + "eligible_unweighted": 25009, + "reporters_unweighted": 6447, + "reporters_m": 5.9252, + "reporters_over_16k_unweighted": 1742, + "reporters_over_16k_m": 1.239, + "reporters_on_uc_m": 4.1938, + "reporters_not_on_uc_m": 1.7314, + "on_uc_not_reported_m": 2.0745, + "gb_housing_m": 4.586, + "gb_lcwra_m": 2.057, + "gb_carer_m": 0.6224, + "gb_childcare_m": 0.4007, + "gb_child_m": 2.817, + "gb_housing_spend_bn": 34.135, + "gb_lcwra_spend_bn": 11.201, + "gb_carer_spend_bn": 1.506, + "gb_childcare_spend_bn": 1.675, + "gb_child_spend_bn": 17.614, + "gb_single_m": 3.0129, + "gb_lone_parent_m": 1.8969, + "gb_couple_no_children_m": 0.2706, + "gb_couple_with_children_m": 0.9093 + }, + "reporters_anchored": { + "uk_m": 6.3449, + "gb_m": 6.1634, + "records": 6748, + "spend_bn": 71.915, + "eligible_m": 15.5714, + "eligible_unweighted": 25009, + "reporters_unweighted": 6447, + "reporters_m": 5.9252, + "reporters_over_16k_unweighted": 1742, + "reporters_over_16k_m": 1.239, + "reporters_on_uc_m": 4.2704, + "reporters_not_on_uc_m": 1.6547, + "on_uc_not_reported_m": 2.0745, + "gb_housing_m": 4.6242, + "gb_lcwra_m": 2.1099, + "gb_carer_m": 0.6233, + "gb_childcare_m": 0.4099, + "gb_child_m": 2.8305, + "gb_housing_spend_bn": 34.364, + "gb_lcwra_spend_bn": 11.469, + "gb_carer_spend_bn": 1.508, + "gb_childcare_spend_bn": 1.793, + "gb_child_spend_bn": 17.671, + "gb_single_m": 3.0647, + "gb_lone_parent_m": 1.8972, + "gb_couple_no_children_m": 0.2792, + "gb_couple_with_children_m": 0.9223 + } } }, "split_effect_new_minus_old": { @@ -559,5 +699,82 @@ "SPI-synthetic clone households are re-imputed like any household.", "private_pension_wealth is engine-unknown at policyengine-uk 2.91.0 and is ignored by the loader; the NEW numbers are the model-side-unchanged outcome (total_wealth and the exposure keys shrink there until the model PR lands).", "Aggregates only (UKDS EUL); no unit records leave the licensed environment." - ] + ], + "segment_seeds_from_declared_seed_0": [ + 3757552657, + 673228719, + 3241444873 + ], + "per_segment_seed_fix": { + "what": "impute_was_wealth now derives one child seed per chain segment from the declared stage seed (SeedSequence(seed).spawn(3)); previously one RegimeGatedQRF was reused and start_chain respawned identical fit/draw streams per segment, coupling the k-th target of every segment.", + "hold_out_receipt_30_trees": { + "P(shares_excl_isa>0 | property_wealth=0)": { + "truth": 0.0551, + "stage_as_is_same_seed": 0.0114, + "per_segment_seeds": 0.0448 + }, + "P(stocks_and_shares_isa>0 | property_wealth=0)": { + "truth": 0.0307, + "stage_as_is_same_seed": 0.0174, + "per_segment_seeds": 0.0278 + }, + "P(private_pension_wealth>0 | property_wealth=0)": { + "truth": 0.5601, + "stage_as_is_same_seed": 0.5396, + "per_segment_seeds": 0.5538 + }, + "source": "adversarial-review coupling check, train/hold-out halves of the donor, aggregates only" + }, + "uc_effect_new_with_fix_minus_new_without": { + "uk_m": 0.0026, + "gb_m": 0.0261, + "eligible_m": -0.6837, + "reporters_over_16k_m": -0.0425, + "gb_housing_m": -0.0061, + "gb_lcwra_m": 0.0023, + "gb_carer_m": -0.0226, + "gb_childcare_m": -0.002, + "gb_child_m": -0.0517, + "gb_single_m": 0.1332, + "gb_lone_parent_m": -0.0255, + "gb_couple_no_children_m": -0.0557, + "gb_couple_with_children_m": -0.026 + } + }, + "allocation_key_concentration_new_draw_per_segment_seeds": { + "narrow": { + "mass_share_by_income_decile": [ + 0.1138, + 0.0227, + 0.0277, + 0.0379, + 0.1059, + 0.0595, + 0.0761, + 0.1036, + 0.2016, + 0.2512 + ], + "top10pct_households_share": 0.8391, + "top1pct_households_share": 0.2669, + "weighted_total_bn": 1794.458 + }, + "preserved": { + "mass_share_by_income_decile": [ + 0.0461, + 0.0516, + 0.0372, + 0.0475, + 0.0719, + 0.0803, + 0.0939, + 0.1298, + 0.2014, + 0.2404 + ], + "top10pct_households_share": 0.5432, + "top1pct_households_share": 0.1354, + "weighted_total_bn": 7467.681 + } + } } diff --git a/experiments/452-uk-pension-wealth-split.md b/experiments/452-uk-pension-wealth-split.md index 58d515260..358dee025 100644 --- a/experiments/452-uk-pension-wealth-split.md +++ b/experiments/452-uk-pension-wealth-split.md @@ -63,7 +63,7 @@ millions; GB = excluding Northern Ireland. | GB couple with children | 0.871 | 0.686 | 0.935 | +0.250 | With FRS reporters anchored (`would_claim_uc |= universal_credit_reported > 0`): -OLD 5.217 → NEW 6.340 (UK). The incumbent's own upper bound (counterfactual +OLD 5.217 → NEW 6.340 (UK), NEW′ 6.345. The incumbent's own upper bound (counterfactual C, `corporate_wealth` → 0 on the incumbent draws) is 6.881 UK. Allocation-key check on the NEW draw (why the model keeps @@ -87,6 +87,36 @@ engine's `corporate_sector_wealth` key), and re-pin the `efrs-post-calibration` input-mass reference only after the uk-data mirror ships (re-pinning first would breach the gate on `corporate_wealth`). +## Per-segment child seeds (review finding, second commit) + +`impute_was_wealth` reused one `RegimeGatedQRF` across its three chain +segments, and `start_chain` respawns the fit and draw streams from the model +seed on every call — so the k-th target of every segment consumed the same +quantile and sign-gate uniforms per recipient, coupling `owned_land` with +the first segment-2 target and `property_wealth` with the second (now the +share-like holdings, the countable quantity). The adversarial review measured +it on train/hold-out halves of the donor (30 trees; companion JSON +`452-uk-pension-wealth-split-coupling-receipt.json`): + +| hold-out probability | observed | stage as-is (same seed) | per-segment seeds | +|---|---|---|---| +| P(shares excl. ISA > 0 \| property_wealth = 0) | 0.055 | 0.011 | 0.045 | +| P(stocks-and-shares ISA > 0 \| property_wealth = 0) | 0.031 | 0.017 | 0.028 | +| P(private pension wealth > 0 \| property_wealth = 0) | 0.560 | 0.540 | 0.554 | + +The stage now derives one child seed per segment from the declared seed +(`SeedSequence(0).spawn(3)` → 3757552657, 673228719, 3241444873); the +declared `fit_weighted_qrf_chain.seed: 0` stays the root and the spec digest +is unchanged (the note is documentation). Re-measured with the fix (NEW′): +UC benefit units 6.268m UK (vs 6.266m without the fix), eligible 15.57m (vs +16.26m), reporter records over £16k 1,742 (vs 1,757); GB elements within +±0.05m except singles +0.13m and couples without children −0.06m. The +caseload effect of the split is unchanged; the fix corrects who holds the +countable assets. Seed-to-seed swings in the tail-dominated totals +(`corporate_wealth` 2,499 → 1,794 £bn, `savings` 1,489 → 899 £bn at 2024 for +the two draws) are the stage's known realisation variance (cf. the +`owned_land` exclusion receipt), not an effect of either change. + ## Caveats (travel with the numbers) - Weights are not recalibrated; the UK surface now compiles a UC caseload diff --git a/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json b/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json index 26f5168f5..5675c8ba1 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json +++ b/packages/microcosm-build/src/microcosm/build/uk/release_input_coverage_manifest.json @@ -478,7 +478,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", + "source_manifest_sha256": "06e261f0bceb9c4d9fe1d5070e2dad31bc9318c5060ec09cffa381723d04993a", "source_vintages": { "source": "UK Data Service SN 8856 Effects of Taxes and Benefits household tab, DfT rail fare index, and public NHS activity/cost table.", "survey": "Effects of Taxes and Benefits 1977-2024 and NHS age-gender public table" @@ -497,7 +497,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", + "source_manifest_sha256": "06e261f0bceb9c4d9fe1d5070e2dad31bc9318c5060ec09cffa381723d04993a", "source_vintages": { "source": "UK Data Service SN 8856 Effects of Taxes and Benefits household tab and cited VAT anchor resource.", "survey": "Effects of Taxes and Benefits 1977-2024" @@ -532,7 +532,7 @@ "base_candidate_tier": "frs", "calibration_permitted": false, "canonical_source_manifest": "source_stages.json", - "canonical_source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", + "canonical_source_manifest_sha256": "06e261f0bceb9c4d9fe1d5070e2dad31bc9318c5060ec09cffa381723d04993a", "effective_mass_requirements": { "charitable_investment_gifts": { "mass_share_denominator": "all_person_effective_mass", @@ -642,7 +642,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", + "source_manifest_sha256": "06e261f0bceb9c4d9fe1d5070e2dad31bc9318c5060ec09cffa381723d04993a", "source_vintages": { "source": "UK Data Service SN 9468 Living Costs and Food Survey 2023-24 household/person tabs, NEED 2023 headline energy tables, Ofgem Q2 2026 unit rates, and WAS round-8 bridge donor.", "survey": "Living Costs and Food Survey 2023-24" @@ -662,7 +662,7 @@ "property_wealth" ], "source_manifest": "source_stages.json", - "source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", + "source_manifest_sha256": "06e261f0bceb9c4d9fe1d5070e2dad31bc9318c5060ec09cffa381723d04993a", "source_vintages": { "source": "MHCLG dwellings and ONS UK House Price Index December 2025 regional average prices.", "survey": "Public regional property reference" @@ -694,7 +694,7 @@ "required_mass_change_reason": "E5 source-stage transform preserves household rows and typed household weights; total household mass is conserved.", "rewrites": [], "source_manifest": "source_stages.json", - "source_manifest_sha256": "1111cdf6fb6b711c923b3581ac69981197a65771277b19fde698e79b31428ca5", + "source_manifest_sha256": "06e261f0bceb9c4d9fe1d5070e2dad31bc9318c5060ec09cffa381723d04993a", "source_vintages": { "source": "Office for National Statistics Wealth and Assets Survey, UK Data Service SN 7215, DOI 10.5255/UKDA-SN-7215-20; local licensed 2006-22 household tab.", "survey": "Wealth and Assets Survey round 8" diff --git a/packages/microcosm-build/src/microcosm/build/uk/source_stages.json b/packages/microcosm-build/src/microcosm/build/uk/source_stages.json index a990f0356..dc0992ccf 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/source_stages.json +++ b/packages/microcosm-build/src/microcosm/build/uk/source_stages.json @@ -1033,7 +1033,7 @@ "stocks_and_shares_isa", "student_loan_balance" ], - "notes": "Ports incumbent WAS round-8 wealth imputation with signed E5 differences: exact lower-case column matching replaces the fuzzy r/w fallback; cash ISA uses DVCISAVR8_aggr and stocks-and-shares ISA uses DVIISAVR8_aggr; private pension wealth (WAS total private pension wealth less the current defined-benefit component, both at the SCAPE rate) is drawn as its own output private_pension_wealth and no longer folded into corporate_wealth, which keeps only the share-like holdings - pension rights are disregarded capital in every means test (UC Regs 2013 Sch 10 para 10 and the HB/JSA/ESA/IS/SPC parallels; uk-data#452) - and the downstream chain segment conditions on both components; corporate_wealth folds stocks-and-shares ISA after drawing corporate_wealth_excl_isa; recipient Northern Ireland regions are mapped to Wales for prediction only; student_loan_balance is allocated by household id rather than the incumbent positional off-by-one. Engine predictors materialize at their native entity and person/benunit values are summed to household, reproducing the incumbent map_to=household semantics; region is one-hot encoded jointly across donor and recipient (the incumbent's dummy encoding), with unmapped donor GOR codes becoming all-zero dummy rows. The WAS and FRS predictor definitions are not fully like-for-like and are ported as-is; raw WAS missing values are blanket-filled with zero; UKDS negative sentinel codes (-9/-8/-7/-6) are recoded to zero for the nonnegative-domain columns the licensed audit found carrying them (vcarnr8: 2 rows; HBedRmR8: 95.8 percent - the bedrooms question is effectively unasked in the WAS household file, predictor-quality revisit registered on microcosm#145) - a signed difference vs the incumbent, which trains on raw sentinels; DVPriRntR8's -9 is structural not-applicable so the is_renting mapping is unchanged; genuinely negative domains are never recoded." + "notes": "Ports incumbent WAS round-8 wealth imputation with signed E5 differences: exact lower-case column matching replaces the fuzzy r/w fallback; cash ISA uses DVCISAVR8_aggr and stocks-and-shares ISA uses DVIISAVR8_aggr; private pension wealth (WAS total private pension wealth less the current defined-benefit component, both at the SCAPE rate) is drawn as its own output private_pension_wealth and no longer folded into corporate_wealth, which keeps only the share-like holdings - pension rights are disregarded capital in every means test (UC Regs 2013 Sch 10 para 10 and the HB/JSA/ESA/IS/SPC parallels; uk-data#452) - and the downstream chain segment conditions on both components; each chain segment starts its own fit and draw RNG streams from a child seed derived deterministically from the declared seed (start_chain respawns from the model seed on every call, so one shared model coupled the k-th target of every segment); corporate_wealth folds stocks-and-shares ISA after drawing corporate_wealth_excl_isa; recipient Northern Ireland regions are mapped to Wales for prediction only; student_loan_balance is allocated by household id rather than the incumbent positional off-by-one. Engine predictors materialize at their native entity and person/benunit values are summed to household, reproducing the incumbent map_to=household semantics; region is one-hot encoded jointly across donor and recipient (the incumbent's dummy encoding), with unmapped donor GOR codes becoming all-zero dummy rows. The WAS and FRS predictor definitions are not fully like-for-like and are ported as-is; raw WAS missing values are blanket-filled with zero; UKDS negative sentinel codes (-9/-8/-7/-6) are recoded to zero for the nonnegative-domain columns the licensed audit found carrying them (vcarnr8: 2 rows; HBedRmR8: 95.8 percent - the bedrooms question is effectively unasked in the WAS household file, predictor-quality revisit registered on microcosm#145) - a signed difference vs the incumbent, which trains on raw sentinels; DVPriRntR8's -9 is structural not-applicable so the is_renting mapping is unchanged; genuinely negative domains are never recoded." }, { "stage": "regional_property_uprating", diff --git a/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml b/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml index 1d26cbda2..41b5b59aa 100644 --- a/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml +++ b/packages/microcosm-build/src/microcosm/build/uk/spec/sources.yaml @@ -826,7 +826,7 @@ stages: - cash_isa - stocks_and_shares_isa - student_loan_balance - notes: 'Ports incumbent WAS round-8 wealth imputation with signed E5 differences: exact lower-case column matching replaces the fuzzy r/w fallback; cash ISA uses DVCISAVR8_aggr and stocks-and-shares ISA uses DVIISAVR8_aggr; private pension wealth (WAS total private pension wealth less the current defined-benefit component, both at the SCAPE rate) is drawn as its own output private_pension_wealth and no longer folded into corporate_wealth, which keeps only the share-like holdings - pension rights are disregarded capital in every means test (UC Regs 2013 Sch 10 para 10 and the HB/JSA/ESA/IS/SPC parallels; uk-data#452) - and the downstream chain segment conditions on both components; corporate_wealth folds stocks-and-shares ISA after drawing corporate_wealth_excl_isa; recipient Northern Ireland regions are mapped to Wales for prediction only; student_loan_balance is allocated by household id rather than the incumbent positional off-by-one. Engine predictors materialize at their native entity and person/benunit values are summed to household, reproducing the incumbent map_to=household semantics; region is one-hot encoded jointly across donor and recipient (the incumbent''s dummy encoding), with unmapped donor GOR codes becoming all-zero dummy rows. The WAS and FRS predictor definitions are not fully like-for-like and are ported as-is; raw WAS missing values are blanket-filled with zero; UKDS negative sentinel codes (-9/-8/-7/-6) are recoded to zero + notes: 'Ports incumbent WAS round-8 wealth imputation with signed E5 differences: exact lower-case column matching replaces the fuzzy r/w fallback; cash ISA uses DVCISAVR8_aggr and stocks-and-shares ISA uses DVIISAVR8_aggr; private pension wealth (WAS total private pension wealth less the current defined-benefit component, both at the SCAPE rate) is drawn as its own output private_pension_wealth and no longer folded into corporate_wealth, which keeps only the share-like holdings - pension rights are disregarded capital in every means test (UC Regs 2013 Sch 10 para 10 and the HB/JSA/ESA/IS/SPC parallels; uk-data#452) - and the downstream chain segment conditions on both components; each chain segment starts its own fit and draw RNG streams from a child seed derived deterministically from the declared seed (start_chain respawns from the model seed on every call, so one shared model coupled the k-th target of every segment); corporate_wealth folds stocks-and-shares ISA after drawing corporate_wealth_excl_isa; recipient Northern Ireland regions are mapped to Wales for prediction only; student_loan_balance is allocated by household id rather than the incumbent positional off-by-one. Engine predictors materialize at their native entity and person/benunit values are summed to household, reproducing the incumbent map_to=household semantics; region is one-hot encoded jointly across donor and recipient (the incumbent''s dummy encoding), with unmapped donor GOR codes becoming all-zero dummy rows. The WAS and FRS predictor definitions are not fully like-for-like and are ported as-is; raw WAS missing values are blanket-filled with zero; UKDS negative sentinel codes (-9/-8/-7/-6) are recoded to zero for the nonnegative-domain columns the licensed audit found carrying them (vcarnr8: 2 rows; HBedRmR8: 95.8 percent - the bedrooms question is effectively unasked in the WAS household file, predictor-quality revisit registered on microcosm#145) - a signed difference vs the incumbent, which trains on raw sentinels; DVPriRntR8''s -9 is structural not-applicable so the is_renting mapping is unchanged; genuinely negative domains are never recoded.' - stage: regional_property_uprating survey: Public regional property reference diff --git a/packages/microcosm-build/src/microcosm/build/uk_runtime/was_wealth.py b/packages/microcosm-build/src/microcosm/build/uk_runtime/was_wealth.py index e1f58cbf8..6452a78ca 100644 --- a/packages/microcosm-build/src/microcosm/build/uk_runtime/was_wealth.py +++ b/packages/microcosm-build/src/microcosm/build/uk_runtime/was_wealth.py @@ -354,6 +354,26 @@ class UKWASWealthImputationResult: draws: pd.DataFrame fit_weight_records: tuple[FitWeightRecord, ...] + #: The per-segment RNG roots derived from the declared stage seed. + segment_seeds: tuple[int, ...] = () + + +def was_wealth_segment_seeds(seed: int, segments: int = 3) -> tuple[int, ...]: + """Derive one independent RNG root per chain segment from the stage seed. + + :meth:`RegimeGatedQRF.start_chain` spawns its fit and draw streams from + the model seed on every call, so one model reused across segments would + restart the same streams each time and couple the k-th target of every + segment (the same quantile and sign-gate uniforms per recipient). On the + licensed donor that coupling collapsed P(shares > 0 | property_wealth = 0) + to 0.011 against 0.055 observed; distinct child seeds recover 0.045. The + declared stage seed stays the root and the children are deterministic. + """ + + return tuple( + int(child.generate_state(1, dtype=np.uint32)[0]) + for child in np.random.SeedSequence(int(seed)).spawn(int(segments)) + ) def impute_was_wealth( @@ -370,11 +390,16 @@ def impute_was_wealth( donor_encoded, recipient_encoded, encoded_predictors = encode_qrf_predictor_pair( donor, recipient_predictor_frame ) - model = RegimeGatedQRF(n_estimators=n_estimators, seed=seed) + segment_seeds = was_wealth_segment_seeds(seed) + segment_models = iter( + RegimeGatedQRF(n_estimators=n_estimators, seed=segment_seed) + for segment_seed in segment_seeds + ) raw = pd.DataFrame(index=recipient_encoded.index) fit_records: list[FitWeightRecord] = [] def run_segment(base_predictors: Sequence[str], targets: Sequence[str]) -> None: + model = next(segment_models) state = model.start_chain( donor_encoded, list(base_predictors), @@ -452,6 +477,7 @@ def run_segment(base_predictors: Sequence[str], targets: Sequence[str]) -> None: return UKWASWealthImputationResult( draws=raw.loc[:, UK_WAS_WEALTH_OUTPUT_COLUMNS], fit_weight_records=tuple(fit_records), + segment_seeds=segment_seeds, ) diff --git a/packages/microcosm-build/tests/test_uk_was_wealth.py b/packages/microcosm-build/tests/test_uk_was_wealth.py index 9dd565f0d..9b032f9a1 100644 --- a/packages/microcosm-build/tests/test_uk_was_wealth.py +++ b/packages/microcosm-build/tests/test_uk_was_wealth.py @@ -371,11 +371,12 @@ def test_was_imputer_uses_checkpointed_chain_segments( import microcosm.fit calls = [] + seeds = [] class FakeQRF: def __init__(self, *, n_estimators, seed): assert n_estimators == 7 - assert seed == 0 + seeds.append(seed) def start_chain(self, donor, predictors, targets, *, weights): assert weights == "weight" @@ -428,6 +429,10 @@ def fit_draw_next( f"uk_was_2018_20_wealth:{target}" for target in fitted_targets ] assert {record.weight_kind for record in result.fit_weight_records} == {"explicit"} + # One independent RNG root per segment, derived from the declared seed. + assert seeds == list(module.was_wealth_segment_seeds(0)) + assert len(set(seeds)) == 3 + assert result.segment_seeds == tuple(seeds) def test_private_pension_wealth_split_preserves_the_old_corporate_wealth_identity() -> ( @@ -455,3 +460,18 @@ def test_private_pension_wealth_split_preserves_the_old_corporate_wealth_identit UK_WAS_WEALTH_OUTPUT_COLUMNS.index("private_pension_wealth") == UK_WAS_WEALTH_OUTPUT_COLUMNS.index("corporate_wealth") + 1 ) + + +def test_segment_seeds_are_distinct_and_deterministic() -> None: + """Each chain segment starts its own fit/draw streams (start_chain respawns + from the model seed on every call), so the roots must differ and derive + deterministically from the declared stage seed.""" + import microcosm.build.uk_runtime.was_wealth as module + + seeds = module.was_wealth_segment_seeds(0) + + assert len(seeds) == 3 + assert len(set(seeds)) == 3 + assert seeds == module.was_wealth_segment_seeds(0) + assert seeds != module.was_wealth_segment_seeds(1) + assert all(isinstance(seed, int) for seed in seeds)