fix(Malawi): 2004-05's cluster key was an intra-TA sequence number (GH #323, config-only)#639
Conversation
…#323, config-only) Malawi's `cluster_features` is declared (t, v) but every wave extracts it from the household cover page, so it is projected from household grain onto the cluster with groupby().first() -- one arbitrary household's answer, served as the cluster's. Measured cold, the five waves turn out to have two different diseases, and only one of them is a broken key. 2004-05 (IHS2) -- BROKEN KEY. `ea` is the EA's sequence number WITHIN its Traditional Authority: 110 distinct values for 564 enumeration areas, so ~5 unrelated EAs merged into one `v`. `psu`, already a column in the same file, is the fully qualified 8-digit code (region+district+TA+EA): 564 values, exactly 20 households each, zero contested cells. Contested cells 164 -> 0; clusters 110 -> 564; districts in cluster_features 20 -> 26; and the settlement strata go from {Rural} -- every one of the 110 mega-clusters came out rural -- back to {Rural, Urban}. Declared identically in `sample` and `cluster_features`, as _join_v_from_sample requires. 2013-14 / 2019-20 -- NOT a broken key. `ea_id` is the IHS3 baseline EA and the IHPS TRACKS movers. The GPS settles it: 188 of 204 EAs in 2013-14 have households at different coordinates, but restrict to households that did not move and all 204 collapse to exactly one coordinate. Every kilometre of within-EA spread (median 145 km, max 775 km) is a tracked mover. Re-keying on current district would give 626 groups of median ONE household and sever the panel from its baseline, so these residuals are reported, not forced to zero. Also fixed, en route: 2016-17 had GPS for 0 of its 880 clusters because df_geo declared the raw case_id while df_main runs it through `cs_i` -- the merge key never matched and the outer join produced 12,447 orphan rows. The files are 1:1 on case_id, so mirroring the transform makes the join exact: 779 of 880 clusters now carry coordinates. And 2016-17's one Region "conflict" was a spelling (Southern vs South for an EA sampled in both halves); the existing `region` table is now applied at extraction. Site 4 is NOT touched: 2010-11 and 2019-20 merge a household-grain geo file on the cluster key, 183,812 and 171,230 phantom rows. The one-line config fix was implemented and measured -- contested cells 0 and 250, identical either way, confirming that a cartesian cannot change a contested-cell count -- then reverted so PR #627's cardinality guard keeps these cells as evidence. Documented in place instead. No file under lsms_library/*.py. No `aggregation:` key, no declared reducer, no value blanked to <NA>. Refs #323. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
6d5f963 to
620615f
Compare
Adversarial review — APPROVE_WITH_NOTESI tried to break this rather than confirm it. Every number below is my own cold measurement: two git worktrees ( The headline numbers reproduceContested cells measured by monkeypatching
At the API, cold: 2004-05 goes 110 → 564 clusters, 20 → 26 districts, What I attacked, and what held1. "Did the new key score zero by splitting real entities?" No. At source, 2. "Invariance by missingness?" No. 3. "Did the fix move mass, not just rows?" It did not move mass at all. 4. "Are the 2016-17 coordinates attached to the right EAs?" Independent check, not self-consistency: each 2016-17 cluster's new coordinate vs the median coordinate of its own district in 2010-11 (a wave whose geo was already correct) → median 17.0 km, p90 41.7 km. Control with district labels shuffled → median 240.4 km. The join is right. Files confirmed 1:1: 12,447 = 12,447 5. "Does the extraction-time 6. "Is the 'leave 2013-14/2019-20 alone' story real?" The 2016-17 half of it checks out at source: 75 of 102 panel EAs (0 of 779 CS EAs) have >1 7. "Does the new test discriminate?" Yes — verified, not taken on trust. Running the PR's test file against a pristine 8. "Does anything else break?" 8 relevant test modules ( 9. Scope / D1. Diff is 4 wave Notes (none blocking)
Verdict: APPROVE_WITH_NOTES. The diagnosis is correct, the identifier is the right one, the payload is conserved exactly, the tests discriminate, and the two documentation inaccuracies above are the only things I could find in a deliberate attempt to break it. |
Malawi's half of GH #323 Site 2, config-only. Refs #323.
No file under
lsms_library/*.pyis touched, noaggregation:key is added (dead config, forbidden by D1), no reducer is declared and nothing is blanked to<NA>. Every number below is a cold build against an isolatedLSMS_DATA_DIR(fresh tree,dvc-cachesymlinked to the shared L1) withLSMS_COUNTRIES_ROOTpointed at the worktree and asserted before any run —LSMS_NO_CACHE=1alone was not trusted, because the collapse is baked into the cache it poisoned.The finding: two different diseases, and only one is a broken key
cluster_featuresis declared(t, v)but all five waves extract it from the household cover page, so the frame arrives at household grain and_collapse_to_cluster_grainreduces it with.first(). Measured per column, per cluster, on the frame captured inside that function before it runs:Two of the five waves now emit no
GrainCollapseWarningat all; before, four did.(The brief's baseline was 1,255 — the 2-cell difference is in 2004-05 and bears on nothing.)
1. 2004-05 —
eais not a cluster id, it is a sequence numbereais the EA's position within its Traditional Authority: 110 distinct values for IHS2's 564 enumeration areas. Keyed on it, roughly five unrelated EAs — different TAs, different districts, different regions — merge into onev. Measured at source:ea(dist, ea)(dist, ta, ea)psuThe
(dist, ea)row is the instructive one: it is the "makeva district composite" reflex — Uganda's fix — and here it is wrong, because it still merges EAs that share a district but sit in different TAs. The right key needed no composite at all:psuis already a column insec_a.dta, the fully qualified 8-digit coderegion(1)+district(2)+TA(2)+EA(3), 564 values with exactly 20 households each (the IHS2 design). It is also the same 8-digit keyspace the IHS3/IHS4/IHS5 waves use forea_id, sovnow has one format across the whole country.What the broken key was actually doing, at the API
.first()reached a rural household in every one of the 110 merged groups, so IHS2's entire urban stratum was absent fromcluster_features, and 6 of Malawi's 26 districts had vanished outright. Neither was visible at the API: the table looked complete.Declared identically in
sampleandcluster_features— they cannot be allowed to drift, or_join_v_from_samplematches nothing.Frame vintage, stated so nobody reads too much into it: 2004-05 shares only 3 codes with 2010-11 (1998 vs 2008 census frame). The format is common to all five waves; the code sets are not. A cross-wave join on
vis meaningful from 2010-11 on and not before, and this change neither creates nor claims a panel link.2. 2013-14 and 2019-20 — the residual is dispersal, and it is not re-keyed
ea_idin the IHPS waves is the IHS3 baseline EA (case_idis literallyea_id+ household number), the panel EA sets nest cleanly — 2019-20-PN = 2016-17-PN = 102 ⊂ 2013-14 = 204 ⊂ 2010-11 = 768 — and the survey tracks movers and split-offs wherever they go.The GPS settles it. 188 of 204 EAs in 2013-14 have households at different coordinates, which is exactly the broken-key signature. It is not one:
LAT_DD_MODis the EA-level displaced fix of the household's current location — not per-household jitter — so all of the within-EA spread (median 145 km, max 775 km) is dispersal. Corroborating: 588 of 4,000 households report a district different from the one encoded in their ownea_id, and they sit a median 68 km from their IHS3 location against 0.03 km for the 3,412 that match.Why the 75% headline is not what it looks like. With ~20 households per EA and 13% of them district-movers,
1 − 0.87²⁰ ≈ 94%of EAs are expected to contain at least one mover. A high cell rate is a property of the metric applied to a tracked panel, not evidence of a merged key.Why not re-key anyway. Splitting 2013-14 on
(district, ea_id)gives 626 groups of median one household — 422 singleton pseudo-clusters — desynchronisesvfromsampleand from the IHS3 baseline, and destroys the panel's link to its own sampling design. Same precedent as Uganda'scomm(PR #634 §6.5). So these residuals are reported, not forced to zero.2019-20's 250 cells are all in the panel half, on the same mechanism (747 of 3,178 households off their EA's modal district, 652 of them >1 km from their IHS3 location). Its 102 panel EAs share no
ea_idwith the 717 cross-sectional ones — no cross-half collision.3. 2016-17 had GPS for 0 of its 880 clusters, and the cause was a merge key
df_mainbuilds Cross_Sectionaliascs_i(case_id)('cs-17-'+format_id) to keep it apart from the Panel'sy3_hhid.df_geodeclared the rawcase_id, so its merge key matched nothing: thehow='outer'join produced 12,447 orphan geo rows next to 14,955 coordinate-less main rows. The two files are exactly 1:1 oncase_id(12,447 = 12,447, none on either side alone), so mirroring the transform makes the join exact — 779 of 880 clusters now carry coordinates (the other 101 are IHPS panel EAs, for which IHS4 publishes no geovariables).Honest cost: 7 of those 779 EAs have households whose IHS4
lat_modifieddiffers within the EA — IHS4's displacement is nearly but not exactly EA-constant — soLatitude/Longitudeshow 7 contested cells where before there were 0, because before there was no coordinate at all. That is the whole of the78 → 89movement in the table above.This is not a cardinality fix, and 2016-17 is not on PR #627's cartesian list: a non-matching key produces orphans, not a product.
Separately, 2016-17's single
Regionconflict was a spelling: EA30305580is sampled in both halves (a genuine shared EA, same district), and the CS file writesSouthernwhere the Panel writesSouth. The existingregiontable — which already collapses them at API time, so this is output-neutral — is now applied at extraction, so the collapse stops reading a spelling difference as a disagreement about the cluster. 1 → 0.4. Site 4: measured, and deliberately left alone — for #627
2010-11 and 2019-20 key a household-grain geovariables file on
ea_idand merge it on the cluster keyv, sopd.mergepairs every household with every geo row sharing thatv:The load-bearing claim, measured rather than argued: a cartesian repeats values, it does not invent them, so it cannot change a contested-cell count. The one-line config fix (
idxvars: {i: case_id}+merge_on: [i]; both file pairs are 1:1 oncase_id) was implemented and measured — contested cells 0 and 250, identical either way — and then reverted, so PR #627's cardinality guard keeps these two cells as evidence. #627 owns the decision. The fix is recorded in each wave'sdata_info.ymlnext to the declaration that causes it, and a test fails if either wave silently starts merging oniwithout telling #627.5.
Ruralneeded nothingThe pre-#602 inline
mapping:blocks were both live and inverted —rural: 0in one file andRURAL: 1in another, in the same wave, so the two halves of 2016-17 and 2019-20 coded rural households with opposite values. GH #602 already removed all five ondevelopment. Verified against the pre-collapse frames:Ruralarrives as the raw label and_enforce_canonical_spellingsnormalises it. Nothing added here; noted only so the next reader does not re-derive it.6. The invariant
sample.v≡cluster_features.vin every wave after the change: 0cluster_features(t, v)pairs unknown tosample, 0 orphanedsampleclusters.vv7. Tests
tests/test_gh323_malawi_cluster_key.py— 14 tests, all config-level; exercises no core aggregation. They pin the fix and the diagnosis:test_2013_14_within_cluster_spread_is_tracked_movers_not_a_broken_keyfails if the evidence for leaving the IHPS waves un-re-keyed ever stops holding.origin/developmentconfig tree (exported withgit archive, cold data dir), 7 of the 14 fail — including every load-bearing one.-k "malawi or Malawi or 323 or schema or sample or cluster"): 680 passed, 58 skipped, 4 xfailed, 1 failed.Pre-existing failure, not caused or fixed here (core is out of scope):
tests/test_gh323_explicit_reducers.py::test_core_does_not_dispatch_the_reducersgrepslsms_library/country.pyfor the substringcollapse_to_cluster_grainand finds PR #617's private_collapse_to_cluster_grain. This branch is config-only and does not touchcountry.py. Same failure is reported on PR #634.8. Deferred
region/districtharmonisation is applied only where it removed a measured conflict (2016-17Region). Doing it everywhere is output-neutral and tidier, but it is a diff across five waves for zero measured gain.districtis baseline (0 of 2,508 households outside their EA's modal district) whileresideis current (277 differ, 246 of them flaggedmover). That is the source's inconsistency; it accounts for all 75 of 2016-17's remaining contested cells, and it is documented rather than papered over.Diagnosis preserved in
Malawi/_/CONTENTS.organd.coder/ledger/323-malawi-key.md.🤖 Generated with Claude Code