fix(#637): Tanzania key-soundness review — 2008-15 shocks was keyed on the panel LINE, not the household#651
fix(#637): Tanzania key-soundness review — 2008-15 shocks was keyed on the panel LINE, not the household#651ligon wants to merge 1 commit into
shocks was keyed on the panel LINE, not the household#651Conversation
…he household Key-soundness review of Tanzania's 21 `groupby().first()` sites (GH #637). Per the corrected reading on that issue the per-column NA-skip is a correct completion, so this reviews the KEYS, not `.first()`. No `.first(skipna=False)`, no `aggregation:` keys, nothing under `lsms_library/*.py`. 20 of 21 keys are sound; 15 of those never reach `.first()` at all and two (housing, food_coping) collapse only the NPS panel-LINE replication that `sample.py:54` already documents -- de-replication, not a choice: 0 of 8,488 and 0 of 6,814 duplicate groups differ on ANY source column, and lineage confirms the copies are one household (a round-1 household with k lines maps to k distinct round-4 households in 174 of 211 observed cases). BROKEN: `2008-15/_/shocks.py` built `i` from UPHI, the panel-line index ("1".."14985"), not `r_hhid`. The two namespaces shared ZERO values, so no 2008-15 shocks row could be joined to its own household and `id_walk` matched nothing (`updated_ids.json` is r_hhid-keyed); and the line replication survived as data -- 74,341 rows standing for 39,724 household-shock facts, inflating any shock count ~1.9x. Fixed at the identifier per #323 D1: `i = r_hhid`, which re-exposes the replication as duplicates that the existing `.first()` collapses, now guarded by an assert (0 of 134,035 duplicate groups differ on any hr_* column). API: 60,463 -> 35,746 rows; per-wave overlap with household_roster 0/0/0/0 -> 100%/100%/100%/100%; 0 (t,i) pairs unknown to sample(). Flagged not fixed: `_/tanzania.py` community_prices resolves genuinely disagreeing observed prices arbitrarily (243/261 and 1,149/1,281 colliding groups) -- the #323 Option-B discussion, not #637. 100% of its collisions span >1 source item code and 0 repeat a code, so the grain is the intended harmonize_community_price label lump and no level is missing. Its comment claiming a MEAN was corrected to match the code (doc only). Every site carries a comment recording what was checked and the counts. Ledger: `.coder/ledger/637-tanzania-keys.md`. Test: tests/test_gh637_tanzania_shocks_household_id.py (11 tests). Negative control against a pristine origin/development config tree: all 11 fail. cluster_features untouched -- PR #642 owns it. Refs #637. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
| round | rows | distinct UPHI |
distinct r_hhid |
|---|---|---|---|
| 1 | 20,039 | 5,594 | 2,956 |
| 2 | 155,097 | 8,163 | 3,924 |
| 3 | 189,962 | 9,998 | 5,010 |
| 4 | 84,337 | 4,961 | 4,341 |
In round 1, 1,526 r_hhid values carry more than one UPHI, up to 11. That is the back-cast signature, not a replication bug: one 2008-09 household appears once per descendant household it eventually produces.
So the "1.9x inflation" is the surveys deliberate panel construction, and UPHI is the correct identifier for this grain. Re-keying to r_hhid collapses 5,594 Y4-panel households in round 1 back into 2,956 round-1 households and destroys the panel linkage. The 60,463 -> 35,746 row reduction is that destruction, not a recovery.
What IS real here
The zero overlap with household_rosters i is a genuine interoperability problem — these two tables cannot be joined. But the likely resolution runs the other way: establish which id convention Tanzanias 2008-15 tables should share (and whether the roster itself should be on UPHI, or a documented UPHI <-> r_hhid mapping is needed), rather than dropping shocks to the round id.
See .claude/skills/tanzania-panel-design.md (NPS extended vs refresh sub-panels) — this belongs in that design space.
Requested
- Drop or revert the
shocks.pyre-key and its 11 tests, which assert the collapsed counts. - The other 20 site verdicts in this PR are unaffected by this and look sound — worth keeping, ideally split out so they are not blocked.
- Record the back-casting in
Tanzania/_/CONTENTS.org, since it is a genuine cross-cutting fact about this country that nothing in the repo currently states.
Apologies for the misdirection — the brief was mine.
Follow-up: this IS documented, I failed to read it, and the precedent cuts both waysThe maintainer pointed me at existing documentation. It exists, and I should have read it before either endorsing the re-key or flagging it. Process failure on my part: the Sources:
What the record says
So the back-casting is real and documented. My measurement — round 1: 2,956 But "UPHI is the correct identifier" was too strongThe same document records the opposite decision for the sibling table:
For UPHI is also described as individual-level in origin ("assigned … to household members"), a further reason not to treat it as simply "the household id". Where that leaves the PRI no longer think the re-key is obviously wrong. I think it is an undecided design question that this PR would settle silently, and the two tables end up inconsistent either way:
Consistency argues for the change. Against it: #114's own framing treats the blunt dedup as a problem it was working around, not settled policy — and the RequestedDo not merge the Documentation gap worth closing regardless
|
Key-soundness review of Tanzania's
groupby().first()sites — the largest remaining block on GH #637, and one that had never been reviewed.Per the corrected reading on #637: the per-column NA-skip is a correct completion, and the composite is wrong only where the key merges different real entities — a broken identifier, fixed per #323 D1 at the identifier. So this reviews the keys, not
.first(). No.first(skipna=False)(withdrawn as a regression), noaggregation:keys, nothing underlsms_library/*.pyoutsidecountries/Tanzania/**.All measurements are cold builds: in-process patch of
DataFrameGroupBy.first+runpyof each script (which sidesteps thematerialize: makesubprocess blind spot), isolatedLSMS_DATA_DIRwithdvc-cachesymlinked,LSMS_COUNTRIES_ROOTasserted at the worktree. NodvcCLI invoked.cluster_featuresuntouched — PR #642 owns it.Verdicts — 21 sites
2008-15/_/anthropometry.py:70(t,i,pid)2008-15/_/food_coping.py:76(t,i,Strategy)2008-15/_/household_roster.py:37(t,i,pid)2008-15/_/housing.py:43(t,i)2008-15/_/individual_education.py:52(t,i,pid)2008-15/_/life_satisfaction.py:106(t,i,Domain)2008-15/_/shocks.py:68(t,i,Shock), i = UPHI2019-20/_/{anthropometry,food_coping,individual_education,life_satisfaction,shocks}.py2020-21/_/{anthropometry,food_coping,individual_education,life_satisfaction,shocks}.py_/anthropometry.py:59(t,i,pid)_/individual_education.py:59(t,i,pid)_/tanzania.py:1686(people_last7days)(t,i,pid)_/tanzania.py:1822(community_prices)(t,v,j,u)Every site gets a comment recording what was checked and the counts.
The one broken key: 2008-15
shockskeyed on the panel LINE2008-15/_/shocks.pybuiltifromUPHI, the NPS panel-tracking line index (1..14985), not fromr_hhid, the household idsample,household_roster,updated_ids.jsonand every other Tanzania table use. Two silent consequences.1. Not one 2008-15 shocks row could be joined to its own household. The namespaces share zero values. Per wave, overlap of
shocks().iwithhousehold_roster().i:iThe 2019-20 / 2020-21 waves key on
sdd_hhid/y5_hhidand always overlapped by 100%, which is exactly why an aggregate check would have missed this.id_walkalso matched nothing:updated_ids.jsonis r_hhid-keyed.2. The panel-line replication survived as data. The upd4 household-level modules are keyed on the LINE, so a household-round arrives once per DESCENDANT line — the same replication
sample.py:54documents for the cover page (29,250 rows → 16,540 household-rounds, group sizes 1..11). Keyed on UPHI, each copy became its own "household":74,341 wave rows stood for 39,724 household-shock facts — any count of who suffered a shock was inflated ~1.9x. Per wave, raw 20,039 / 22,308 / 22,216 / 9,778 → de-replicated 10,241 / 10,216 / 10,741 / 8,526.
The fix is the identifier (D1):
i = r_hhid. That re-exposes the replication as duplicate index entries, which the existing.first()collapses — and here the collapse is de-replication, not a choice: over the whole module (449,435 rows, 134,035 duplicate(round, r_hhid, hr_00)groups) zero groups differ on any of the ninehr_*columns. The script now asserts that before collapsing, the disciplinesample.py:76already applies to the same replication, so a future re-release that makes lines disagree fails loudly instead of silently keeping one.At the API: 60,463 → 35,746 rows, all 13,252
(t, i)pairs known tosample()(0 unknown), andid_walkproduces 0 collisions on the re-keyed table.Cited context: GH #132 filed the 60% duplicate
(t, i, Shock)rate; keying on UPHI removed the duplicates without fixing the entity. GH #114 documentsmap_08_15's split-off→parent collapse — shocks now shares that (separately-tracked) convention instead of standing outside the panel.Sound: the two de-replication sites (
housing,food_coping)Same UPHI replication, and the collapse discards exact copies.
upd4_hh_i1): 29,250 rows → 16,540 household-rounds, 8,488 duplicated (sizes 2:5477, 3:2247, 4:494, 5:164, 6:61, 7:31, 8:7, 9:4, 10:1, 11:2). Across all 8,488 groups, ZERO differ on ANY of the 74hi_*columns — including the continuous ones and including the 59 round-4 groupssample.pyflags as cluster-ambiguous. Corroborated by lineage rather than by "exact": a round-1 household with k lines maps to k distinct round-4 households in 174 of the 211 cases where more than one of its lines is still observed in round 4.upd4_hh_h): 23,122 rows → 13,275 household-rounds, 6,814 duplicated; zero differ on ANYhh_*column, not merely the eight items read. Per-round duplicate counts (R2 2,698 / R3 3,628 / R4 488) match housing's exactly — the same replication through a second module.Sound: the 15 dead-code sites
.first()is never reached. The verdict rests on the key, not on today's counts:_broster,_vanthropometry,_ceducation,_gsatisfaction) carry(r_hhid, UPI)and noUPHIcolumn, so the replication does not reach them: 83,706 rows / 83,706 groups / 0 duplicates / 0 nullUPIeach, and the pid formatting is injective (45,396 distinct UPI → 45,396 distinct pid).(t, i, Domain)a key: exactly oneHEADper household-round — 16,540/16,540 (2008-15), 1,183/1,183 (2019-20; one of that wave's 1,184 households records no head), 4,709/4,709 (2020-21).(hhid, indid)-unique with 0 nulls;format_idinjective over each wave's households (1,184 → 1,184; 4,709 → 4,709); shocks(hhid, shock_id)-unique after the YES filter.t,id_walkrenames onlyiand produced 0 collisions.Trap 2 (invariance by missingness) does not apply — every column tested is densely populated.
Flagged, not fixed:
community_prices(t, v, j, u)is the declared grain, and the collisions are exactly the intendedharmonize_community_pricelabel lump — checked rather than assumed: of 261 colliding groups in 2019-20 and 1,281 in 2020-21, 261 / 1,281 span more than one source item code and ZERO repeat a code. The survey never prices the same item twice in a cluster, and no additional level exists that would not break the sharedjthis feature exists to join on.What the collapse does do is resolve genuine disagreement arbitrarily — 243 / 1,149 groups hold more than one distinct observed price:
That is the #323 "arbitrary resolution of two OBSERVED values" discussion, explicitly not the #637 completion question, and per D1 the answer may not be a reducer. Recorded at the site with the measurements rather than half-fixed.
Also corrected there, doc only: the comment claimed the collision was combined "by their MEAN"; the code has always taken the first. Two contradictory paragraphs sat one line apart.
Test
tests/test_gh637_tanzania_shocks_household_id.py, 11 tests: per-waveshocks.i ⊆ sample.i; per-waveshocks.i ⊆ household_roster.i; ids are not bare line indices; per-wave row counts (which also pin that 2019-20 / 2020-21 were untouched); and household01010140020171reporting each 2008-09 shock exactly once.Follows the #323 instrument note: asserts the identifier and row counts, never post-collapse index uniqueness — uniqueness held perfectly with the bug present, which is how it hid.
Negative control — against a pristine
origin/developmentconfig tree (git archiveinto a separateLSMS_COUNTRIES_ROOT+ a cold data dir), all 11 fail:With the fix: 11 passed.
test_tanzania_grain_gh323.py,test_tanzania_community_cluster_xwalk.py,test_gh323_grain_contract.py,test_gh323_explicit_reducers.py,test_gh323_benin_togo.pyand the new module together: 73 passed, 2 skipped.test_schema_consistency.py+test_panel_id_collisions.pyfiltered to Tanzania: 8 passed. Every Tanzania table still builds with a unique index (shocks35,746 /housing22,430 /food_coping153,315 /household_roster112,884 /anthropometry78,674 /individual_education43,367 /life_satisfaction152,048 /people_last7days29,179 /community_prices15,831 /sample22,433), andFeature('shocks')(['Tanzania'])assembles. TheGrainCollapseWarnings in that run are the pre-existing #323Tanzania/cluster_featuresfindings PR #642 addresses — unrelated.The test imports the shared skip marker as
from tests.conftest import requires_s3:tests/is a package and the repo root has its ownconftest.py, so the barefrom conftest import ...spelling intests/conftest.py's docstring resolves to the wrong module.Scope
Config/script only — every changed file is under
lsms_library/countries/Tanzania/**, plus the new test and.coder/ledger/637-tanzania-keys.md.cluster_featuresuntouched (PR #642).CONTENTS.orgdeliberately untouched to avoid conflicting with #642's 170-line addition there; the findings live in the site comments and the ledger.Refs #637.
🤖 Generated with Claude Code