fix(#323/#627): Malawi's and Guinea-Bissau's cartesian dfs: merges, and #637's three Malawi .first() sites#653
Merged
Merged
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>
PR #627's 40-country census found 8 many-to-many `dfs:` merges fabricating 4,907,774 phantom rows. Mali's 4.3M went in PR #641. These are the three nobody owned: Malawi 2010-11 12,271 x 12,271 -> 196,083 rows (183,812 phantom) Malawi 2019-20 14,612 x 11,434 -> 185,842 rows (171,230 phantom) Guinea-Bissau 2018-19 5,351 x 450 -> 5,410 rows ( 59 phantom) All three now emit 0 phantom rows, measured cold in an isolated data root with #627's `_cartesian_keys` detector on PYTHONPATH. They are TWO DIFFERENT SHAPES and take two different cures. MALAWI -- a household-grain frame keyed on the cluster. Both sub-frames are one row per household, merged on `v`, so pd.merge paired every household of an EA with every other, and `_normalize_dataframe_index` then mopped the wreck up with groupby().first() down to 768 / 819 clusters -- so the table read clean at every point anyone ever looked. The geo files carry `case_id`, so the honest key was there all along: `idxvars: {i: case_id}` + `merge_on: [i]` (2019-20 runs it through `cs_i`, exactly as its df_main does). 196,083 -> 12,271 and 185,842 -> 14,612 wave rows. PR #639's author investigated these two cells, implemented the same one-line fix, measured it, then deliberately REVERTED it so #627 would keep its evidence, leaving the diagnosis in the YAML. Every claim in that note was re-verified here from source rather than taken on trust, and all of it holds: the file pairs are exactly 1:1 on case_id (12,271 = 12,271 and 11,434 = 11,434, zero rows on either side alone, no duplicate ids); lat/lon are constant within all 768 and all 717 EAs; and df_main.ea_id == df_geo.ea_id for every shared household. This branch is stacked on #639 and replaces its two "deliberately not fixed" comments. GUINEA-BISSAU -- not that shape, and it does not take that cure. `grappe_gps_gnb2018.dta` ships 450 rows for 445 grappes: five appear twice as VERBATIM duplicate records, same lat/lon/accuracy/altitude/GPS timestamp (the companion community cover s00a_co carries the identical 450/445 split, so the duplication happened once, on the community side of the export). There is no household id to re-key on -- the GPS file is genuinely grappe-grain and carries nothing finer, and both copies of every duplicate agree on `vague` -- and no single-file cure, because the cover page has no GPS columns at all. So this one takes #627's OTHER named remedy, reduce the sub-frame to the merge-key grain BEFORE the merge, via a `df_geo` hook in the wave's mapping.py. 5,410 -> 5,351 rows. Two things about that hook. It is dispatched BY NAME off the sub-frame key (`Wave.grab_data` resolves a sub-frame's df_edit with `column_mapping(<sub-frame name>, ...)`) -- real current behaviour on both `development` and #627's branch, but undocumented, with no prior art anywhere in the corpus, so it is documented in three places and pinned by a test. And it uses drop_duplicates() over EVERY column, key and payload alike: that is de-duplication, not aggregation (D1), and it removes a row only when an identical row already exists -- so two GPS fixes that actually DISAGREE would both survive and re-trip #627's guard rather than being silently reduced to one. `groupby().first()` or `keep='first'` would hide exactly that. VALUES DO NOT MOVE. The country-level `cluster_features` frame is bit-for-bit identical before and after for both countries -- DataFrame.equals True, index equal, dtypes equal (Malawi 3,235 x 5 over all five waves; Guinea-Bissau 450 x 4). Malawi's published lat/lon is the EA's displaced fix stamped on every household, so handing a household its own row returns the coordinate the `v`-merge was already returning it. One honest wrinkle, at HOUSEHOLD grain only: 8 IHS5 geo rows have a null coordinate while their 15 EA siblings carry the EA's fix, so those 8 households now get their own NULL instead of borrowing a sibling's value. The returned (t, v) table is unchanged -- .first() skips NA -- and the household-grain intermediate now tells the truth about which rows the survey actually geolocated. Tests assert on `grab_data('cluster_features')`, NOT `Wave.cluster_features()`: the latter runs the site-2 projection onto (t, v) first and returns 768 / 819 rows with or without the cartesian, so a test there is vacuous. Negative control run with the pre-fix configs restored in a fresh isolated data root: 6 failed, 10 passed -- the three cartesian tests report 5410 / 196083 / 185842, the three structural tests fail, and the ten source-invariant and cluster-count tests pass WITH THE BUG FULLY PRESENT, which is the blindness the module docstring warns about. After: 16 passed. ALSO IN HERE: GH #637's three `groupby().first()` sites in Malawi/_/malawi.py, reviewed for KEY SOUNDNESS. Same method (patch DataFrameGroupBy.first in-process, drive the wave scripts with runpy -- all three tables are `materialize: make`, so an in-process patch cannot see them through the subprocess; that is what made an earlier probe of a site like this report a meaningless "0"). plot_features_for_wave 0 groups >1 row on a non-null key food_coping_for_wave 0 groups >1 row months_food_inadequate_for_wave 0 groups >1 row No composite is ever built at any of the three, because no group ever has more than one row to build one from. For the two Module-H sites rows in == groups out EXACTLY (61,355 / 19,994 / 62,232 / 57,157 and 12,271 / 4,000 / 12,447 / 11,434, the latter being each wave's Module H household count). plot_features' 4 apparent duplicate groups consist ENTIRELY of null-plot_id rows. No `.first(skipna=False)` anywhere -- it would change nothing, and the repo already rejected that approach. All three of #637's traps were checked, and they need two different instruments. The exact equality above disposes of the first two: "they're only exact duplicates" is not reassurance -- but here there are no duplicate groups AT ALL, which is strictly stronger -- and invariance-by-missingness does not apply, since the household id is non-null in every row and either a shared id or a format_id collision would surface right there as a duplicate group (confirmed at source: case_id / y2_hhid are unique in Module H, format_id merges none). Trap 3 -- A BROKEN KEY CAN PRODUCE ZERO DUPLICATES (the Tanzania shocks inversion) -- defeats that instrument entirely, so it was checked separately with a PER-WAVE overlap of each table's `i` against the roster's: 100% for all three tables in all four waves, per wave rather than in aggregate, matching literally (101011000014 on both sides) and with no row-count inflation relative to source households x strategies. Worth running rather than waving away: 2019-20 has exactly the shape of a namespace split -- cs_i ('cs-19-' + format_id) in data_info.yml for sample / household_roster / cluster_features vs i_prefix='' in all three wave scripts -- but the prefix survives to the API on NEITHER side (0 of 14,612 ids carry it, in any table), so the two agree. Now pinned per wave, with household counts, by test_i_lives_in_the_same_namespace_as_the_roster. What the audit did find is a silent deletion: groupby() defaults to dropna=True, so 468 plot_features rows with a NULL plot_id (275 / 158 / 27+6 / 0+2) were being deleted without a word -- #323 §3b's delete-and-report, in a country script rather than in core. They are empty Module C roster stubs, not plots: 0 of all 468 carries a non-null ag_c04a / ag_c04b / ag_c04c. Dropping them is right; it should not have been silent. It is now an explicit dropna(subset=['plot_id']) before the groupby, and it is a provable no-op -- plot_features, food_coping and months_food_inadequate are each bit-for-bit identical before and after (68,339 x 10 / 200,738 x 1 / 40,152 x 2, DataFrame.equals True). Config/script only; nothing under lsms_library/*.py outside countries/{Malawi,Guinea-Bissau}/**. No `aggregation:` key. Ledger: .coder/ledger/323-malawi-gb-cartesian.md Refs #323, #627, #637. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI's `unit-tests` job collects `tests/` as a PACKAGE, so `from tests.conftest import requires_s3` resolves as `tests.tests.conftest` and raises ModuleNotFoundError; the fallback then hits the repo ROOT conftest.py, which has no `requires_s3`. Both new modules errored at collection. Locally the same line worked, because pytest was inserting the rootdir instead -- so the local run could not have caught it, and did not. Loading the sibling file by path is the same in every import mode. Verified all three: default rootdir-insert, `--import-mode=importlib`, and a whole-`tests/` collection (1,794 tests) -- 38 collected, 0 errors, in each. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-cartesian # Conflicts: # lsms_library/countries/Malawi/2010-11/_/data_info.yml # lsms_library/countries/Malawi/2019-20/_/data_info.yml # lsms_library/countries/Malawi/_/CONTENTS.org # tests/test_gh323_malawi_cluster_key.py
This was referenced Jul 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #639 (
fix/323-malawi-config), which this branch is based on —its commit appears in the diff below and should merge first. Everything after
that commit is this PR.
Closes the last two Malawi cells and the Guinea-Bissau cell from #627's
cartesian census, and answers #637 for Malawi's three
groupby().first()sites.
Piece 1 + 3 — the cartesians
All measured cold, in an isolated
LSMS_DATA_DIRwith onlydvc-cachesymlinked,
LSMS_NO_CACHE=1, and #627's_cartesian_keysdetector onPYTHONPATH(asserted in-process, not assumed).Values do not move. Country-level
cluster_featuresis bit-for-bitidentical before and after for both countries —
DataFrame.equalsTrue, indexequal, dtypes equal (Malawi 3,235 × 5 over all five waves; Guinea-Bissau
450 × 4).
Malawi — a household-grain frame keyed on the cluster
Both sub-frames are one row per household, merged on
v, sopd.mergepairedevery household of an EA with every other;
_normalize_dataframe_indexthencollapsed the wreck to 768 / 819 clusters, so the table read clean everywhere
anyone looked. The geo files carry
case_id, so the honest key was alwaysthere:
idxvars: {i: case_id}+merge_on: [i](2019-20 throughcs_i, as itsdf_maindoes).What I verified vs. what I took from #639. #639's author implemented this
same fix, measured it, then deliberately reverted it so #627 would keep its
evidence, leaving the diagnosis in the YAML. I re-derived every load-bearing
claim from source rather than trusting the note, and all of it holds:
case_id— 12,271 = 12,271 and11,434 = 11,434, zero rows on either side alone, no duplicate ids;
coordinates) — the published GPS is the EA's displaced fix stamped on each
household, so a household's own row is what the
v-merge was giving it;df_main.ea_id == df_geo.ea_idfor every shared household;ea_idand no household id with theIHS5 geo file, so their NaN coordinates are absent data, unchanged.
One honest wrinkle, at household grain only: 8 IHS5 geo rows carry a NULL
coordinate while their 15 EA siblings carry the EA's fix. Under the
v-mergethose 8 households borrowed a sibling's value; under the
i-merge they gettheir own NULL. The returned
(t, v)table is unchanged (.first()skips NA),and the household-grain intermediate now tells the truth about which rows the
survey actually geolocated.
Guinea-Bissau — a different shape, and a different cure
Not two household-grain frames.
grappe_gps_gnb2018.dtaships 450 rows for445 grappes: five appear twice as verbatim duplicate records — same lat,
lon, accuracy, altitude, GPS timestamp. (The companion community cover
s00a_co_gnb2018.dtacarries the identical 450/445 split, so the duplicationhappened once, on the community side of the export.)
Neither of the other cures applies: there is no household id to re-key on (the
GPS file is genuinely grappe-grain and carries nothing finer than
grappe+vague, and both copies of every duplicate agree onvague), and nosingle-file cure à la Mali (the cover page has no GPS columns at all —
checked). So this takes #627's other named remedy — reduce the sub-frame to
the merge-key grain before it is merged — via a
df_geohook in the wave'smapping.py.drop_duplicates()over every column, key and payload alike: that isde-duplication, not aggregation, and it removes a row only when an identical row
already exists — so two GPS fixes that actually disagree would both survive
and re-trip #627's guard rather than being silently reduced to one.
groupby().first()/keep='first'would hide exactly that.Piece 2 — #637's three
.first()sites inMalawi/_/malawi.pyMethod: patched
DataFrameGroupBy.firstin-process and drove the wavescripts with
runpy(PR #646's method). All three tables arematerialize: make, so an in-process patch cannot see them through thesubprocess — the mistake that made an earlier probe of a site like this report a
meaningless "0".
plot_features_for_wave.first()is a no-opfood_coping_for_wave.first()is a no-opmonths_food_inadequate_for_wave.first()is a no-opNo composite is ever built at any of the three, because no group ever has
more than one row to build one from. For the two Module-H sites rows in equals
groups out exactly — 61,355 / 19,994 / 62,232 / 57,157 and 12,271 / 4,000 /
12,447 / 11,434, the latter being precisely each wave's Module H household
count.
The three traps, and which check answers each
"They're only exact duplicates" is not reassurance, and a key can look
invariant by being null. Both are disposed of by the exact equality above:
there are no duplicate groups at all, which is strictly stronger than "the
duplicates agreed"; and the household id is non-null in every row of all three
tables. A household id shared by two rows, or two distinct ids merged by
format_id, would each surface right there as a duplicate group. Confirmed atsource:
case_id/y2_hhidare unique in Module H andformat_idcollapsesnone of them.
A broken key can produce zero duplicates — the Tanzania inversion, which
turns this instrument inside out. "0 duplicates" is necessary, not
sufficient, so I ran the discriminating check separately: a per-wave
overlap of each table's
iagainst the roster's.plot_featuresfood_copingmonths_food_inadequatePer wave, never in aggregate. And not vacuously — the id strings match
literally (
101011000014on both sides), and the second signal agrees: rowcounts show no namespace inflation (never above source households ×
strategies).
This one was worth running rather than waving away. Malawi 2019-20 has the
surface shape of a namespace split: its
data_info.ymlrunscase_idthrough
cs_i('cs-19-' + format_id) forsample/household_roster/cluster_features, while all three wave scripts passi_prefix=''/ bareformat_id.Malawi/_/CONTENTS.orgalready records that asymmetry as a deliberatedecision, and I am citing it rather than re-deriving it — "2016-17
cross-sectional id is
cs-17--prefixed insample()…; the wave script appliesthe same prefix so XS plots are not ~100% orphaned. All other halves emit the
raw wave hhid and rely on framework id_walk /
panel_idschaining." 2019-20 isone of those other halves. The measurement confirms the documented
expectation: the
cs-19-prefix survives to the API on neither side (0 of14,612 ids carry it, in any table), so both are in the raw
case_idnamespaceand
ijoins exactly. 2016-17 reaches the same place by the other route.test_i_lives_in_the_same_namespace_as_the_rosterpins both per wave, withhousehold counts, so neither route can drift.
plot_featuresshowed 4 apparent duplicate groups (3 in 2016-17 CS, 1 in itsPanel half); every one consists entirely of rows whose
plot_idis NULL.No
.first(skipna=False). It would change nothing here, and the repo hasalready rejected that approach.
What the audit did find: 468 rows deleted on a null key
groupby()defaults todropna=True, so aplot_featuresrow with a NULLplot_idwas being deleted without a word — #323 §3b's delete-and-report, ina country script rather than in core:
They are empty Module C roster stubs, not plots: 0 of all 468 carries a
non-null value in any of
ag_c04a/ag_c04b/ag_c04c. A row with no plotid and no measurement cannot go into a
(t, i, plot_id)table, so dropping it isright — it just should not have been silent. It is now an explicit
dropna(subset=['plot_id'])immediately before the groupby, which is a provableno-op: the three built tables are bit-for-bit identical before and after.
Tests, and the negative control
tests/test_gh323_malawi_gb_cartesian.py(16 tests) asserts ongrab_data('cluster_features'), notWave.cluster_features()— the latterruns the site-2 projection onto
(t, v)first and returns 768 / 819 rows forMalawi with or without the cartesian. My first draft asserted on the wrong one
and passed with the bug present.
Negative control (pre-fix configs restored, fresh isolated data root):
6 failed, 10 passed. The three cartesian tests report 5410 / 196083 / 185842;the three structural tests fail; and the ten source-invariant and cluster-count
tests pass with the bug fully present — exactly the blindness the module
docstring warns about, and why the row-count assertions are where they are.
After the fix:
16 passed.tests/test_gh637_malawi_first_sites.pypins the invariants the #637 verdictsrest on. It has no meaningful negative control, and I want to be straight about
that: #637 produced no behavioural change, so there is no "before" state for
these tests to fail against. They are forward-looking regression pins.
Both files use
requires_s3so they skip cleanly in the data-free CI job.Read against the country documentation
Malawi/_/CONTENTS.org(757 lines) andGuinea-Bissau/_/CONTENTS.orgwereread end to end before concluding. Three things came out of that:
cited above rather than re-derived. Reading it first is what kept "0
duplicates on a bare
case_id" from being written up as a namespace defect.EA-constant" (7 of 779 EAs). The new EA-constant-coordinate test is
therefore parametrized over 2010-11 and 2019-20 only — where I measured 0 of
768 and 0 of 717 — and its docstring says explicitly not to widen it to
2016-17, citing the passage.
"IHS5 (2019-20): cross-sectional (11,434) + panel (3,178) = 14,552
households". The addends are right; the total is not. 14,612 is what
sample(),household_roster()andcluster_features.df_mainall return,and it is the figure this PR's row counts rest on.
Guinea-Bissau's file had nothing recorded about the GPS file or the cluster
merge, so the duplicated-records finding is new and has been added there.
Scope
Config/script only. Nothing under
lsms_library/*.pyoutsidecountries/Malawi/**andcountries/Guinea-Bissau/**. Noaggregation:key,no declared reducer, no
dvcCLI invocation. Ledger:.coder/ledger/323-malawi-gb-cartesian.md.Refs #323, #627, #637.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr