From 2adb7ec717c5b87294d3140c3cf34cf22b3def8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:54:50 +0100 Subject: [PATCH 1/3] Fix wealth decile ties using stable household order --- .../fix-2579-wealth-decile-ties.fixed.md | 1 + .../tests/test_household_wealth_decile.py | 71 +++++++++++++++++++ .../wealth/household_wealth_decile.py | 7 +- 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fix-2579-wealth-decile-ties.fixed.md create mode 100644 policyengine_uk/tests/test_household_wealth_decile.py diff --git a/changelog.d/fix-2579-wealth-decile-ties.fixed.md b/changelog.d/fix-2579-wealth-decile-ties.fixed.md new file mode 100644 index 0000000000..c51bbeea05 --- /dev/null +++ b/changelog.d/fix-2579-wealth-decile-ties.fixed.md @@ -0,0 +1 @@ +Fixed household wealth deciles to split equal wealth by original household order while retaining person weights. diff --git a/policyengine_uk/tests/test_household_wealth_decile.py b/policyengine_uk/tests/test_household_wealth_decile.py new file mode 100644 index 0000000000..0ef5d5b91e --- /dev/null +++ b/policyengine_uk/tests/test_household_wealth_decile.py @@ -0,0 +1,71 @@ +"""Wealth ranks split ties in original household order (issue #2579).""" + +import numpy as np +import pytest + +from policyengine_uk.variables.household.wealth.household_wealth_decile import ( + household_wealth_decile, +) + + +class FakeHousehold: + def __init__(self, wealth, weights, sizes): + self.values = { + "total_wealth": np.array(wealth, dtype=float), + "household_weight": np.array(weights, dtype=float), + "household_count_people": np.array(sizes), + } + + def __call__(self, variable, period): + return self.values[variable] + + +def test_zero_wealth_ties_fill_the_bottom_deciles(): + # Fifty equally weighted people: each five successive households fill + # one decile, including the fifteen households with zero wealth. + household = FakeHousehold([0] * 15 + list(range(1, 36)), [1] * 50, [1] * 50) + + result = household_wealth_decile.formula(household, 2026, None) + + np.testing.assert_array_equal(result, np.repeat(np.arange(1, 11), 5)) + + +@pytest.mark.parametrize( + "wealth, weights, sizes, expected", + [ + # Cumulative person weights 1, 2, 4, 10 out of 10 determine deciles. + ([0, 0, 0, 10], [1, 1, 2, 6], [1, 1, 1, 1], [1, 2, 4, 10]), + # Sorting must restore original rows, retaining order within ties. + ([10, 0, 0, 0], [6, 1, 1, 2], [1, 1, 1, 1], [10, 1, 2, 4]), + # The same effective weights, factored through household sizes. + ([0, 0, 0, 10], [1, 1, 1, 1], [1, 1, 2, 6], [1, 2, 4, 10]), + # Unique wealth retains cumulative endpoint ranks 6, 1, 10, 3. + ([30, 10, 40, 20], [3, 1, 4, 2], [1, 1, 1, 1], [6, 1, 10, 3]), + # Negative households still count in the population before masking. + ([0, -5, 10, -5, 0], [1, 1, 4, 2, 2], [1, 1, 1, 1, 1], [4, -1, 10, -1, 6]), + # Indivisible records with large weights can still skip deciles. + ([0, 0, 0], [7, 2, 1], [1, 1, 1], [7, 9, 10]), + ], + ids=[ + "unequal_weights", + "original_row_mapping", + "household_sizes", + "unique_wealth", + "negative_wealth", + "indivisible_weights", + ], +) +def test_person_weighted_wealth_ranks(wealth, weights, sizes, expected): + household = FakeHousehold(wealth, weights, sizes) + + for _ in range(2): + result = household_wealth_decile.formula(household, 2026, None) + np.testing.assert_array_equal(result, expected) + np.testing.assert_array_equal(household.values["total_wealth"], wealth) + + +def test_zero_total_weight_still_raises(): + household = FakeHousehold([0, 0], [0, 0], [1, 1]) + + with pytest.raises(ZeroDivisionError, match="zero total weight"): + household_wealth_decile.formula(household, 2026, None) diff --git a/policyengine_uk/variables/household/wealth/household_wealth_decile.py b/policyengine_uk/variables/household/wealth/household_wealth_decile.py index adb7be72a7..f76985e2ed 100644 --- a/policyengine_uk/variables/household/wealth/household_wealth_decile.py +++ b/policyengine_uk/variables/household/wealth/household_wealth_decile.py @@ -12,7 +12,12 @@ def formula(household, period, parameters): wealth = household("total_wealth", period) count_people = household("household_count_people", period) household_weight = household("household_weight", period) - weighted_wealth = MicroSeries(wealth, weights=household_weight * count_people) + # Split equal wealth in original household order without changing wealth. + wealth_rank = np.empty(len(wealth), dtype=int) + wealth_rank[np.argsort(wealth, kind="stable")] = np.arange(len(wealth)) + weighted_wealth = MicroSeries( + wealth_rank, weights=household_weight * count_people + ) decile = weighted_wealth.decile_rank().values # Set negatives to -1. # This avoids the bottom decile summing to a negative number, From 44385d90882e4285071cdc17e35634b740ea2c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:19:35 +0100 Subject: [PATCH 2/3] Fix issues from review: format changelog as a bullet --- changelog.d/fix-2579-wealth-decile-ties.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/fix-2579-wealth-decile-ties.fixed.md b/changelog.d/fix-2579-wealth-decile-ties.fixed.md index c51bbeea05..213da641af 100644 --- a/changelog.d/fix-2579-wealth-decile-ties.fixed.md +++ b/changelog.d/fix-2579-wealth-decile-ties.fixed.md @@ -1 +1 @@ -Fixed household wealth deciles to split equal wealth by original household order while retaining person weights. +- Fixed household wealth deciles to split equal wealth by original household order while retaining person weights. From e29046a4c3b20abc86bd7f127b2840f6d316b92b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:30:30 +0100 Subject: [PATCH 3/3] Clarify wealth-decile weighting and tie-order documentation --- changelog.d/fix-2579-wealth-decile-ties.fixed.md | 1 + .../household/wealth/household_wealth_decile.py | 10 +++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/changelog.d/fix-2579-wealth-decile-ties.fixed.md b/changelog.d/fix-2579-wealth-decile-ties.fixed.md index 213da641af..7fc1bf00f0 100644 --- a/changelog.d/fix-2579-wealth-decile-ties.fixed.md +++ b/changelog.d/fix-2579-wealth-decile-ties.fixed.md @@ -1 +1,2 @@ - Fixed household wealth deciles to split equal wealth by original household order while retaining person weights. +- Clarified gross wealth, person weights, order-sensitive ties, negative outputs and indivisible-weight limits in the wealth-decile documentation. diff --git a/policyengine_uk/variables/household/wealth/household_wealth_decile.py b/policyengine_uk/variables/household/wealth/household_wealth_decile.py index f76985e2ed..35eb39e993 100644 --- a/policyengine_uk/variables/household/wealth/household_wealth_decile.py +++ b/policyengine_uk/variables/household/wealth/household_wealth_decile.py @@ -3,7 +3,15 @@ class household_wealth_decile(Variable): label = "household wealth decile" - documentation = "Decile of wealth income (person-weighted)" + documentation = ( + "Person-weighted decile of gross total_wealth, before debt deductions. " + "Each household's weight is household_weight * household_count_people. " + "Equal wealth values are ranked in original household/entity order. " + "Changing this order, including when rebuilding a dataset, can change " + "tied households' decile assignments. Negative total_wealth participates " + "in the ranking before its output is set to -1. Household weights are " + "indivisible, so decile bins can still be empty." + ) entity = Household definition_period = YEAR value_type = int