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..7fc1bf00f0 --- /dev/null +++ b/changelog.d/fix-2579-wealth-decile-ties.fixed.md @@ -0,0 +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/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..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 @@ -12,7 +20,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,