Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog.d/fix-2579-wealth-decile-ties.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 71 additions & 0 deletions policyengine_uk/tests/test_household_wealth_decile.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
Loading