Skip to content

Add the inequality package, so the repository's name is earned - #24

Merged
Varnasr merged 1 commit into
mainfrom
claude/dev-tools-repo-structure-kw19mh
Sep 8, 2026
Merged

Varnasr merged 1 commit into
mainfrom
claude/dev-tools-repo-structure-kw19mh

Conversation

@Varnasr

@Varnasr Varnasr commented Sep 8, 2026

Copy link
Copy Markdown
Owner

What was here

Counted before starting: 27 Python files outside tests/, 872 lines. survey_estimation (357), impact_evaluation (220) and one cleaning pipeline (155) were 732 of them. Exactly one thing in the repository was about distribution, and the file whose name promised it, public_health_access_index.py, was a five-line row mean.

What this adds

inequality/, 24 functions across five modules, ~950 lines.

Module Contents
indices.py Gini, the generalised entropy family with Theil T and mean log deviation named, Atkinson at any aversion, Palma, 80/20, quantile shares, Lorenz curve, weighted quantiles
concentration.py The concentration index, Erreygers and Wagstaff corrections, concentration curve, a by-group table, Wagstaff's achievement index
decomposition.py Theil within/between split, Blinder-Oaxaca in twofold and threefold form
incidence.py Benefit incidence, single-service and split by level of service
ranks.py Weighted fractional ranks, shared by everything above

The concentration index is the one that makes the name true. An average tells you the level; it cannot tell you whether stunting falls on the poor, which is the question a programme budget answers to.

Three decisions that are centralised rather than repeated

Ranks live in one file. The Gini, the concentration index and both curves share fractional_rank, so they agree by construction. Ties take the block midpoint, which is what makes a wealth quintile a legitimate ranking variable and what the World Bank's DHS equity work does.

A raw concentration index is not comparable across prevalences. Its possible range shrinks as a binary outcome's mean moves away from 0.5, so the same indicator at 8 per cent and at 60 per cent cannot go in one table uncorrected. Both fixes are here and they can disagree about the direction of change over time; the README says to pick one per table and state it.

Every function refuses rather than returning a plausible wrong number. A Gini on negative values, GE(0) on zeros, Wagstaff where everyone has the outcome, a service level with no budget, an Oaxaca on three groups.

Tests: 25 → 101

Every new test is an identity or a hand-computed figure, never previous output. A distributional index cannot be checked by eye: 0.31 and 0.34 are both plausible consumption Ginis, and a sign error gives a number in exactly the right range that says the opposite of the truth.

  • Gini against brute-force mean absolute difference on fifteen random datasets
  • Curve areas against their indices by trapezoid, for both Lorenz and concentration
  • within + between == total for GE(0) and GE(1), with weights
  • Oaxaca components summing to the gap under all four reference choices
  • GE(2) equalling half the squared coefficient of variation
  • A weight of 2 equalling the row appearing twice
  • The mean fractional rank being exactly 0.5, which three derivations depend on

Two of my own test expectations were wrong on first run and the code was right, which is the point of pinning to closed forms. An Oaxaca test drew both groups from the same distribution and then asserted the realised endowment gap was zero; a composite-index test hardcoded arithmetic I had done in my head. Both now compute the expectation from the definition.

Also

social_sector/composite_index.py replaces the row mean with an index that makes its three decisions arguments rather than assumptions: direction, normalisation and weights. Adds goalpost normalisation so a series is comparable across years, geometric aggregation where indicators should not substitute for each other, a minimum non-missing count so a district reporting two of six indicators is not scored as though it reported all six, and rank_sensitivity to show how far a ranking moves under an equally defensible choice. compute_access_index keeps working.

Deleted workflows/ci.yml: dead configuration, pinned to Python 3.10 and running R tests in a repository with no R, duplicating the real workflow. The landing page described it as "start-to-finish routes through an analysis".

🤖 Generated with Claude Code

https://claude.ai/code/session_01DMYhECx2Dzbqad6KJxWZ7t


Generated by Claude Code

Counted before starting: 27 Python files outside tests, 872 lines, of which
survey_estimation, impact_evaluation and one cleaning pipeline were 732.
Exactly one thing in the repository was about distribution, and the file
whose name promised it, public_health_access_index.py, was a five-line
row mean.

inequality/ is 24 functions across five modules.

Indices: Gini, the full generalised entropy family with Theil T and the
mean log deviation named, Atkinson at any aversion, Palma, the 80/20
ratio, quantile shares, the Lorenz curve, weighted quantiles.

Concentration: the concentration index, which is the measure that answers
the question an average cannot, whether an outcome falls on the poor and
how steeply. With it the Erreygers and Wagstaff corrections, because a
raw index is not comparable between two places whose prevalence differs
and comparing them anyway is a standard error in published work. Plus the
concentration curve, a by-group table, and Wagstaff's achievement index.

Decomposition: the Theil within/between split, exact for GE(0) and GE(1)
and refusing any other alpha rather than returning parts that do not sum.
Blinder-Oaxaca in twofold and threefold form with four reference choices.

Incidence: benefit incidence analysis, single-service and split by level
of service, which is where the finding usually is. Primary care
progressive, tertiary regressive, the two cancelling in the aggregate.

Three things are centralised rather than reimplemented per function.
Weighted fractional ranks live in one file and are shared by the Gini,
the concentration index and both curves, so those agree by construction.
Ties take the block midpoint, which is what makes a wealth quintile a
legitimate ranking variable. And every function refuses rather than
returning a plausible wrong number when an assumption breaks: a Gini on
negative values, GE(0) on zeros, Wagstaff where everyone has the outcome,
a level with no budget, an Oaxaca on three groups.

Tests: 25 to 101. Every new one is an identity or a hand-computed figure,
never previous output. The Gini against brute-force mean absolute
difference on fifteen random datasets. Curve areas against their indices
by trapezoid. within + between == total for both alphas, with weights.
Oaxaca's components summing to the gap under all four references. GE(2)
equalling half the squared coefficient of variation. A weight of two
equalling the row appearing twice. The mean fractional rank being exactly
0.5, which three derivations here depend on.

Two of my own test expectations were wrong on first run and the code was
right, which is the point of pinning to closed forms: an Oaxaca test drew
both groups from the same distribution and then asserted the realised
endowment gap was zero, and a composite-index test hardcoded arithmetic I
had done in my head. Both now compute the expectation from the definition.

Also: social_sector/composite_index.py replaces the row mean with an
index that makes its three decisions arguments rather than assumptions
(direction, normalisation, weights), offers goalpost normalisation so a
series is comparable across years, geometric aggregation where indicators
should not substitute for each other, a minimum non-missing count so a
district reporting two of six indicators is not scored as though it
reported all six, and rank_sensitivity to show how far a ranking moves
under an equally defensible choice. compute_access_index keeps working.

Deleted workflows/ci.yml: dead configuration, pinned to Python 3.10 and
running R tests in a repository with no R, duplicating the real workflow
at .github/workflows/python-tests.yml. The landing page described it as
"start-to-finish routes through an analysis", which it never was.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DMYhECx2Dzbqad6KJxWZ7t
@Varnasr
Varnasr merged commit 819dcb0 into main Sep 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants