Skip to content

feat(datasets): add make_donor_panel, a gift-level multi-year donor panel - #162

Merged
shivamlalakiya merged 4 commits into
mainfrom
feat/make-donor-panel
Sep 5, 2026
Merged

feat(datasets): add make_donor_panel, a gift-level multi-year donor panel#162
shivamlalakiya merged 4 commits into
mainfrom
feat/make-donor-panel

Conversation

@shivamlalakiya

Copy link
Copy Markdown
Contributor

Why

generate_synthetic_donor_data returns one already-aggregated row per donor: five columns, no gift
log, no repeated years, no encounters. That is enough to fit a classifier and nothing else. It
cannot demonstrate

  • RFMTransformer (needs one row per gift),
  • FiscalYearGroupedSplitter (needs repeated donor-years),
  • an as_of cutoff (needs something to cut off),
  • EncounterTransformer / GratefulPatientFeaturizer (need an encounter table).

Those are the ideas this library exists for, and the only generator that could show them lived
privately inside scripts/leakage_experiment.py. Every tutorial and notebook that wants to teach
the library's actual argument has had to hand-roll a panel first.

What it returns

panel = make_donor_panel(n_donors=3000, include_encounters=True, random_state=42)
panel["gifts"]       # donor_id, gift_date, gift_amount, fiscal_year, appeal
panel["donors"]      # donor_id, first_gift_fy, wealth_estimate, employer
panel["encounters"]  # donor_id, admit_date, discharge_date, service_line

Column names match what the transformers already require, so nothing has to be renamed on the way
in. Fiscal years run 1 July to 30 June, labelled by the year they end in; at most one gift per
donor-year, which is what makes "recent" well defined.

Three design decisions worth arguing with

No label column, deliberately. A label is a claim about a point in time. Shipping one
pre-computed would hand every user the exact mistake this package exists to prevent, and the ready
availability of a y column is precisely how whole-history features get built. The docstring shows
the one-line derivation instead.

wealth_estimate is ~30% missing. A wealth screen that came back for every record is not a
wealth screen anyone has ever received, and WealthScreeningImputer has nothing to do on a full
column.

Encounter dates are drawn independently of giving. A generator that made grateful-patient
features predictive by construction would be a very convincing demo of nothing.

The part that needed proving

scripts/leakage_experiment.py now imports this instead of defining its own _panel, so the
published experiment and the tutorials share a generator. That script's numbers are in
docs/explanation/benchmarks.md and are referenced from paper.md, so "close enough" was not
acceptable.

The aggregated frames are asserted byte-identical to the private generator's output on all five
published seeds:

seed 42: as_of frame byte-identical to the old private _panel
seed 43: as_of frame byte-identical to the old private _panel
seed 44: as_of frame byte-identical to the old private _panel
seed 45: as_of frame byte-identical to the old private _panel
seed 46: as_of frame byte-identical to the old private _panel

and the script's own output is unchanged line for line:

A. Does the CV split matter? (as-of features throughout)
  true future, held-out final year : 0.639 (0.621-0.653)
  walk-forward fiscal-year CV      : 0.625 (0.620-0.636)   error -0.014
  random StratifiedKFold CV        : 0.608 (0.601-0.616)   error -0.030

B. Does feature construction matter? (walk-forward CV throughout)
  features built as of each year   : 0.625 (0.620-0.636)
  same features over whole history : 0.750 (0.745-0.757)
  inflation from whole-history      : +0.126 AUC

Gift amounts are not rounded to cents, on purpose. An earlier revision rounded them, which looks
more like a real gift log and moved the reported min-max ranges by 0.001 AUC
(0.653 → 0.652, 0.757 → 0.758). A cosmetic decimal does not outrank a number that is already in the
docs; there is a comment in the source saying so, because it will otherwise look like an oversight.

Tests

tests/test_donor_panel.py, 26 tests, in four groups: schema, panel invariants (one gift per
donor-year, dates inside their own fiscal year, first_gift_fy agrees with the gift table, wealth
missingness in range), seeding (same seed reproduces every frame; asking for encounters does not
perturb the giving draws), and validation. The last group is the point of the whole thing: the
transformers a flat frame cannot reach actually consume the output, unmodified.

One of those tests documents a wart it found rather than papering over it:
EncounterTransformer raises DTypePromotionError on a real datetime64 gift-date column and only
accepts date strings, as its own docstring example passes them. That is a defect in the
transformer, not in this generator, and it gets its own issue.

Verification

$ python -m pytest tests/test_donor_panel.py --doctest-modules philanthropy/datasets/_panel.py -q
27 passed
$ python -m pytest tests/test_public_api_contract.py -q
83 passed
$ python -m mypy philanthropy
Success: no issues found
$ python -m flake8 philanthropy tests examples scripts/leakage_experiment.py
(clean)
$ python -m mkdocs build --strict
(clean)

Tier 2 (Beta) row added to docs/reference/index.md, which the tier-table test requires.

…anel

generate_synthetic_donor_data returns one aggregated row per donor. That is
enough to fit a classifier and nothing else: it cannot demonstrate
RFMTransformer, which needs a gift log; FiscalYearGroupedSplitter, which needs
repeated donor-years; an as_of cutoff, which needs something to cut off; or the
grateful-patient transformers, which need encounters. Those are the ideas this
library exists for, and the only generator that could show them lived privately
inside scripts/leakage_experiment.py.

make_donor_panel returns the tables a shop actually exports: a gift log, a
donor table, and optionally encounters. Fiscal years run 1 July to 30 June,
labelled by the year they end in, at most one gift per donor-year.

It ships no label column, deliberately. A label is a claim about a point in
time, and shipping one pre-computed would hand every user the exact mistake
this package exists to prevent; the docstring shows the one-line derivation
instead. wealth_estimate is ~30% missing, because a wealth screen that came
back for every record is not one anyone has received. Encounter dates are drawn
independently of giving, because a generator that made grateful-patient
features predictive by construction would be a convincing demo of nothing.

scripts/leakage_experiment.py now imports it rather than defining its own copy,
so the published experiment and the tutorials share a generator. The aggregated
frames are asserted byte-identical to the private generator's on all five
published seeds, so 0.639 / 0.625 / 0.608 / 0.750 and the +0.126 inflation
figure stand unchanged. Gift amounts are not rounded to cents for exactly that
reason: rounding shifted the reported min-max ranges by 0.001 AUC, and a
cosmetic decimal does not outrank a number already in the docs.
@shivamlalakiya
shivamlalakiya enabled auto-merge (squash) September 5, 2026 20:51
@shivamlalakiya
shivamlalakiya merged commit a82c988 into main Sep 5, 2026
15 checks passed
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.

1 participant