Skip to content

Fix size-less RNG draws that made 15 columns constant; add tests and an answer key - #8

Merged
Varnasr merged 1 commit into
mainfrom
claude/outdated-repos-maintenance-nbxnae
Sep 22, 2026
Merged

Varnasr merged 1 commit into
mainfrom
claude/outdated-repos-maintenance-nbxnae

Conversation

@Varnasr

@Varnasr Varnasr commented Sep 22, 2026

Copy link
Copy Markdown
Owner

Six months since the last commit. The substantive finding is a defect that made part of the data silently useless.

The bug

rng.binomial(1, 0.55) without a size argument returns a scalar, which numpy broadcasts across the whole column. Nothing raises, the row count is right, the CSV writes, and the variable is a constant.

owns_radio = rng.binomial(1, 0.55)                              # one draw, every row
months_displaced = np.where(displaced, rng.exponential(14), 0)  # one draw, every row

Twenty-two call sites across ten generators. Fifteen constant columns, including:

  • four asset variables in targeting (rooms, owns_radio, owns_mobile, owns_bicycle). targeting exists to teach proxy means testing. A PMT whose asset predictors do not vary is not a PMT.
  • the three dropout barriers in girls_education (barrier_marriage, barrier_pregnancy, barrier_household_chores), all identically zero.

The tell that confirmed it is still in the file: barrier_cost on the neighbouring line takes an array probability (0.35 - 0.10 * receives_scholarship), so numpy returned a vector and that column was always fine.

Why nothing caught it. CI ran python generate.py --list, which imports no generator. All 36 modules were unexecuted by any automated check.

Two further defects the new guard found

rct_experiment.spillover_risk was an alias of the treatment dummy. 1 for every control, 0 for every treated unit, because randomisation was stratified within district so every district always contained treated units. Any regression including both dropped a collinear term.

Fixed by adding a village level: 70% of villages are randomised into treatment, 30% into pure control, and exposed controls now receive a real +3% spillover. The flag varies, and the contamination lesson is now visible in the data — using all controls as the comparison biases every ITT toward zero by ~0.011 log points.

public_health.depression_severe was identically zero in every draw. PHQ-9 was built as 9 * logistic(latent) * 3: a bounded transform of one normal with no item-level variance, so the floor sat at 2, the median at 8, and nothing reached the severe threshold of 20. Rebuilt as the sum of nine 0–3 items.

before after
support 2–21 0–27
minimal (0–4) 0% 56.5%
moderate (≥10) 33.5% 14.6%
severe (≥20) 0.02% 1.45%

Wealth and sex gradients run the right way (quintile 1 at 22.0% moderate vs quintile 5 at 10.4%; women roughly twice men on severe).

girls_education schooling status is now three-state. dropped_out was defined as the complement of enrolled, which both made them perfect aliases and collapsed never-enrolled girls into dropouts. Those are different problems with different policy responses. Now 7.0% never enrolled / 80.6% enrolled / 12.4% dropped out.

Tests

233 tests, about two minutes. The largest part is the degeneracy guard, which runs against every registered generator automatically.

  • every generator runs, is reproducible under a seed, and actually responds to a seed change
  • the registry matches the modules on disk, in both directions
  • no column is constant, all-null, or a perfect binary alias of another, with the legitimate exceptions (IRT item parameters, the poverty line) listed with written reasons
  • the parameters in TRUTH.md are recoverable within tolerance

TRUTH.md

A practice dataset you cannot check your answer against is a worked example with the answer torn off. This records which parameters are in the data and which estimand recovers each one.

The point worth reading: the ITT is not a stable target. Take-up is drawn U(0.65, 0.85) per arm per run, so it moved between +0.089 and +0.203 log points across five seeds. Only the complier effect is stable, and only against pure-control villages. The Wald LATE recovers theory to within 0.002 log points across those seeds, which is what the test asserts.

Maintenance

  • CLAUDE.md added, so the next session does not re-derive any of this
  • CI runs the suite on 3.11 and 3.12, plus an end-to-end job writing every dataset to CSV and Parquet, since serialisation is a separate failure surface and parquet is stricter about mixed types
  • requirements pinned exactly. pandas>=2.0 would pick up pandas 3, where text columns carry a str dtype rather than object, so a check keyed on dtype == object silently skips every text column
  • README corrections: output directory is output/ not data/; Python floor is 3.11 not 3.9; the dependency list named faker, which nothing imports; and the claim that the data mirrors the distributions of real surveys was removed, since it does not and TRUTH.md says so

The 840,000+ rows claim was checked rather than assumed. It is 841,247.


Generated by Claude Code

…an answer key

Twenty-two call sites across ten generators called numpy's RNG without a size
argument, so the draw returned a scalar that broadcast across the whole column:

    owns_radio = rng.binomial(1, 0.55)                            # one draw, every row
    months_displaced = np.where(displaced, rng.exponential(14), 0)  # one draw, every row

Nothing raised, the row count was right and the file wrote. Fifteen columns were
constants, among them four asset variables in `targeting` (a proxy-means-test
dataset whose asset predictors did not vary) and the three dropout barriers in
`girls_education`, all identically zero. The neighbouring `barrier_cost` was
always fine because its probability is an array, which is what confirmed the
diagnosis.

None of it was visible because CI ran `python generate.py --list`, which imports
no generator. All 36 modules were unexecuted by any automated check.

Generators

- Add the missing size argument at all 22 sites. Scalar draws that are genuinely
  parameters (per-arm compliance rate, per-country intercept, anything inside a
  per-row loop) are left alone.
- rct_experiment: `spillover_risk` was 1 for every control and 0 for every
  treated unit, an exact alias of the treatment dummy, because randomisation was
  stratified within district so every district always held treated units. Add a
  village level, randomise 70% of villages into treatment and 30% into pure
  control, and give exposed controls a real +3% spillover. The flag now varies,
  and using contaminated controls biases the ITT toward zero by ~0.011 log
  points, which is the lesson the design should teach.
- public_health: rebuild PHQ-9 as a sum of nine 0-3 items. The previous
  `9 * logistic(...) * 3` was bounded with no item-level variance, so the floor
  sat at 2 and `depression_severe` was identically zero in every draw. Now spans
  0-27 with 14.6% at or above the moderate threshold and 1.45% at severe.
- girls_education: schooling status is three-state. `dropped_out` was defined as
  the complement of `enrolled`, which both collapsed never-enrolled girls into
  dropouts and made the two columns perfect aliases. Also vectorise the distance
  loop and remove a dead `2 if True else 5` conditional.

Tests and documentation

- tests/: 233 tests. Every generator runs, is reproducible under a seed, and
  responds to a seed change; the registry matches the modules on disk; no column
  is constant or a perfect alias of another, with the legitimate exceptions
  (IRT item parameters, the poverty line) listed with reasons.
- TRUTH.md: the answer key. Records which parameters are in the data and which
  estimand recovers each. The ITT is not a stable target because take-up is
  drawn U(0.65, 0.85) per run and moved between +0.089 and +0.203 across five
  seeds; the Wald LATE recovers theory to within 0.002.
- CLAUDE.md: new, so the next session does not re-derive this.
- CI: run the suite on 3.11 and 3.12, plus an end-to-end job writing every
  dataset to CSV and Parquet, since serialisation fails differently.
- Pin requirements exactly. `pandas>=2.0` would pick up pandas 3, where text
  columns carry a `str` dtype and a check keyed on `dtype == object` silently
  skips every text column.
- README: correct the output directory (`output/`, not `data/`), the Python
  floor (3.11), a dependency list naming `faker`, which nothing imports, and
  claims that the data mirrors the distributions of real surveys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MrvR2NXsJFVRCeJZFCPuNL
@Varnasr
Varnasr merged commit 99964f9 into main Sep 22, 2026
3 checks passed
@Varnasr
Varnasr deleted the claude/outdated-repos-maintenance-nbxnae branch September 22, 2026 23:38
@Varnasr
Varnasr restored the claude/outdated-repos-maintenance-nbxnae branch September 23, 2026 16:27
@Varnasr
Varnasr deleted the claude/outdated-repos-maintenance-nbxnae branch September 23, 2026 16:48
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