fix(#645): to_parquet stops deleting real data via string-matched nulls#655
Conversation
`to_parquet` serialized object columns as
`astype(str).astype('string[pyarrow]').replace({'nan': None, 'None': None,
'<NA>': None})` -- it stringified FIRST and then tried to recover the nulls by
matching the resulting characters. After `astype(str)` a genuine missing and a
legitimate value spelling 'None' are the same characters, so the recovery is
wrong in principle. `None` was the canonical library label for "no education",
so every never-schooled person was nulled IN THE ACT OF WRITING the cache, and
`_finalize_result`'s `dropna(how='all')` then deleted the row -- the table has
exactly one column, so a nulled value is a deleted person.
Measured cold, isolated LSMS_DATA_DIR, both directions:
Guatemala individual_education 20,678 / 20,678 -> 29,527 (+8,849, 30%)
Tajikistan individual_education 59,297 / 54,462 -> 59,790 (+493 / +5,328)
Ethiopia individual_education 63,139 / 62,939 -> 63,181 (+42 / +242)
Guatemala was cold == warm == wrong, so no A/B comparison could see it and the
coverage matrix graded it `sane`. All three are now cold == warm, pinned as a
test.
Changes:
* local_tools.to_parquet -- capture the null mask BEFORE the cast, restore it
after. The pyarrow guard itself is KEPT: a genuinely mixed object column
still raises ArrowTypeError under pandas 3.0.2 / pyarrow 23, and narrowing
the block would change the stored dtype of homogeneous non-str object columns
(silently changing returned data corpus-wide -- the very failure class this
issue is about).
* LSMS_CACHE_SCHEMA 4 -> 5. The loss happened in the act of writing, so every
existing L2-wave and L2-country parquet already has the nulls baked in, and
the per-table input hashes are unchanged. Without the bump nothing rebuilds
and the fix is invisible on exactly the machines where the corruption is
present. Forces one full rebuild; this changes returned data.
* Canonical label `None` -> `No education` (38 rows across 20
harmonize_education tables, canonical_education_labels.org, 3 wave
data_info.yml inline mappings, China/_/china.py,
Uganda/_/_education_helpers.py). A canonical value colliding with the null
literal under str()/YAML/CSV/parquet keeps re-arming the trap. `None` /
`NONE` / `none` stay as accepted VARIANTS in a new data_info.yml `spellings`
block, so historical caches and un-updated countries resolve at API time.
* Ethiopia 2013-14 `individual_education` df_edit hook: one row of 12,583
carries a bare unlabelled float (76.0) that cannot be mapped in the org table
(mixed-text Original Label => string keys can never match a float source).
Folded onto `Unknown`, the vocabulary's own "unmappable" sentinel. Zero
effect on the row count; on the current base (post-#644) it is what keeps the
API frame inside its own declared vocabulary. Surfaced by
declaring the vocabulary.
* country.py: `dropna(how='all')` now logs how many rows it removed. This is
where a value corruption becomes a silent deletion, and 30% of a table
vanished here without a word.
Blast radius swept: 28 data-bearing countries cold (all cold == warm, all
in-vocabulary) + 107 freshly-built parquets audited against the declared
vocabulary (0 failures). Nigeria/food_acquired is the one other behaviour
change: its deliberate `u = fillna('None')` sentinel now survives as the
literal instead of becoming a NaN index key -- same row count (1,005,656) and
identical Quantity_kg. Guyana / South Africa `housing.Toilet == 'None'`
measured identical before and after (those columns arrive as `str`, so the
block never fired).
Tests: tests/test_gh645_to_parquet_null_coercion.py (17). Negative control
against unmodified origin/development: 14 of 17 fail. GH #323 guards re-run
green (no `aggregation:` key, no reducer).
Ledger: .coder/ledger/645-to-parquet-null-coercion.md
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr
`pytest.raises(Exception)` would have passed on any error at all, including one caused by the test's own frame construction -- which is exactly the trap this file exists to guard against (an earlier draft of this very test built a mis-indexed frame and silently asserted nothing). Name the two errors pyarrow actually raises for a mixed object column, and write into a TemporaryDirectory instead of `tempfile.mktemp`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr
First CI run on this PR failed at collection with BOTH import spellings:
from tests.conftest import requires_s3 -> ModuleNotFoundError: 'tests.tests'
from conftest import requires_s3 -> ImportError: cannot import name
'requires_s3' from 'conftest'
(<repo root>/conftest.py)
The repo has conftest.py at BOTH the root and in tests/, and under the CI
rootdir the root one wins the bare name while 'tests' resolves relative to
tests/ itself. This is a live trap for #648's helper: tests/conftest.py's own
docstring recommends 'from conftest import aws_creds_available', which no
module had yet exercised and which does not work in CI.
Load it by file path instead — import-mode agnostic, and still #648's single
source of truth rather than a fourth private copy of the creds check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr
|
Note for review — a finding about #648, surfaced by this PR's first CI run.
from conftest import aws_creds_availableThat does not work in CI. This repo has a No module had exercised the documented pattern yet, so it had never been caught. This PR works around it by loading The durable fix belongs in #648's own files, not here — either re-export |
Closes #645.
local_tools.to_parquetstringified everydtype == objectcolumn and thentried to recover the nulls by string matching:
After
astype(str)a genuine missing and a legitimate value spelling'None'are the same characters, so the recovery cannot be right — and
Nonewas thecanonical library label for "no education"
(
categorical_mapping/harmonize_education.org). Every such person was nulledin the act of writing the cache, and
_finalize_result'sdropna(how='all')then deleted the row outright:
individual_educationhas exactly one column,so a nulled value is a deleted person.
Recovered counts (cold, isolated
LSMS_DATA_DIR, measured both directions)Every recovered row is a person the survey recorded as having no education.
Ethiopia's pair is exactly the
63,139 vs 62,939in the issue title — thatcall-order symptom is gone.
Guatemala is why this survived so long: cold == warm == wrong, so no A/B
comparison could see it, and
.coder/coverage/latest.csvcertified itGuatemala,individual_education,2000,sane,declared,20678. After the fix allthree are cold == warm, which is now a tested invariant.
What changed
local_tools.py). Capture the null mask before thecast, restore it after —
na = col.isna()….mask(na). Only cells thatwere actually missing become null.
LSMS_CACHE_SCHEMA4 → 5. The loss happened in the act of writing, soevery existing L2-wave and L2-country parquet already has the nulls baked in,
and the per-table input hashes are unchanged. Without the bump nothing
rebuilds and the fix is invisible on exactly the machines where the
corruption is present. This forces one full rebuild.
None→No education(38 rows across 20harmonize_educationtables,canonical_education_labels.org, 3 wavedata_info.ymlinline mappings,China/_/china.py,Uganda/_/_education_helpers.py). A canonical value that collides with thenull literal under
str(), YAML, CSV and parquet round-trips is a trapthat keeps re-arming itself; the write-path fix alone leaves it loaded.
None/NONE/nonestay as accepted variants in the newdata_info.ymlspellingsblock, so historical caches and anynot-yet-updated country still resolve at API time — no rebuild needed for
the rename itself.
dropna(how='all')now says how many rows it took (country.py,logger.info). This is where a value corruption upstream becomes a silentdeletion; 30% of a table vanished here without a word. INFO, not a warning
— legitimately hollow rows are common and it must not cry wolf. Whether it
should escalate above some rate is left to you.
The pandas-3.0 assessment (issue asked; answer: KEEP the block)
The comment's premise — "Pyarrow can't deal with mixes of types in columns of
type object" — is still true under pandas 3.0.2 / pyarrow 23.0.1.
Measured:
str+int,str+float,str+Timestamp,str+list/tuple/dict,bool+str,str+Decimalall still raiseArrowTypeError. Pinned bytest_mixed_type_object_column_is_still_stringified.What has changed is that far fewer columns reach it: with
future.infer_string=Truea pure string column is inferred asstr, notobject, so it skips the guard entirely. That is why exactly 3 of 25at-risk countries were hit — the other 22 were correct by accident of dtype
inference, not by design.
Narrowing the block to genuinely-mixed columns was considered and rejected.
It would change the stored dtype of homogeneous non-
strobject columns (anobject column of ints would round-trip as
int64instead of the strings'1','2'), i.e. it would silently change returned data across the corpus —the exact class of failure this issue is about. Recorded in the ledger, not
acted on.
Blast radius — everything that changed behaviour
Swept 28 data-bearing countries cold (one process, one empty
LSMS_DATA_DIReach, onlydvc-cachesymlinked), plus before/after coldbuilds of the other tables that could manufacture these literals.
individual_education, 28 countries — only the 3 above change. All 28are cold == warm, and every value falls inside the canonical vocabulary.
Nigeria.food_acquired— the only other site deliberately manufacturingthe literal:
df['u'].fillna('None')in 4 waves'food_acquired.py.1,005,656 rows before and after (no rows gained or lost) and
Quantity_kgidentical; what changes is that 1,151 rows now carry the intended sentinel
u='None'instead of a NaN index key. Strictly better (a NaN key is theGH bug: framework silently drops rows on duplicate canonical index (groupby().first()) #323 §3b delete-on-collapse hazard) but it is visible to anyone reading
that level. Recommend renaming that sentinel too (e.g.
Not recorded) —deliberately not bundled here, since
ufeeds the unit-conversion tables.Guyana.housing/South Africa.housing— carryToilet == 'None'(52 / 1,102 rows). Measured identical before and after: those columns
arrive as
str, notobject, so the block never fired. No change.transformations.py_na_strings,country.py_NA_SENTINELS,conversion.py. All operate onRelationship/
sex/age/ dates, whose vocabularies genuinely exclude these strings.They are deliberate and correct; generalising one would recreate this bug a
layer up.
One pre-existing defect surfaced (not caused by this PR)
Declaring the
Educational Attainmentvocabulary makesdiagnostics._check_declared_spellingsenforce it. Auditing 107 freshlycold-built parquets (28 countries, wave- and country-level) turned up exactly
one off-vocabulary value in the whole corpus:
Ethiopia 2013-14
hh_s2q05has one row of 12,583 carrying a bare76.0that never received a Stata value label. It cannot be handled in
categorical_mapping.org— that table'sOriginal Labelcolumn is mixed text,so every key is read as a string and a float source value can never match
(contrast Guyana, whose all-numeric table parses its keys as floats). Folded to
Unknownby adf_edithook in2013-14/_/mapping.py, reusing the shape ofUganda's existing
_/_education_helpers.pyand reading the vocabulary fromdata_info.ymlrather than re-listing it.Unknownis not a guess:canonical_education_labels.orgdefines it as the sentinel for "unmappable".On the pre-#644 base this was cosmetic (the row was collapsed away before
reaching the API); on the current base it is not — #644's re-key means
'76.0'now survives into the user-facing frame, so the hook is what keepsEthiopia's
Educational Attainmentinside its own declared vocabulary.Row count is unaffected either way (63,181);
Unknowngoes 33 → 34.Tests
tests/test_gh645_to_parquet_null_coercion.py— 17 tests, in three groups: thewrite path (data-free), the vocabulary (data-free), and the three recovered row
counts (cold,
requires_s3fromtests/conftest.pyso the data-free CI jobskips rather than errors). The integration cases run in a subprocess with a
genuinely empty data root —
LSMS_NO_CACHE=1MASKS this bug (it skips theL2-wave write where the destruction happened), so it is exactly the wrong lever.
Negative control: against unmodified
origin/development, 14 of 17fail. The 3 that pass are the two deliberate "behaviour must NOT change"
guards, plus
cold == warmfor Guatemala — which passed because Guatemala wasequal and wrong, the whole reason nobody noticed.
Merged guards re-run and green:
test_gh323_explicit_reducers.py,test_gh323_grain_contract.py,test_schema_consistency.py,test_canonical_spellings.py,test_cache_hash_invalidation.py(280 passed).No
aggregation:key, no reducer — GH #323 D1 respected.Ledger:
.coder/ledger/645-to-parquet-null-coercion.md.For the reviewer
Guatemala,TajikistanorEthiopiaindividual_educationneeds re-checking; Guatemalagrows 43%.
.coder/coverage/latest.csvis now wrong for those cells and needs amake matrixrefresh (not done here — it is a generated snapshot).censuses, not as substantive education distributions.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Su5JKX3wKChyfMAdXrdCTr