Changelog
September 12, 2026 · View on GitHub
All notable changes to PhilanthroPy are documented here. Format: Keep a Changelog
[Unreleased]
Added
- Tests for
MovesManagementClassifiernow fit a named DataFrame and an array sofeature_names_in_is both recorded and absent on the paths sklearn specifies. Closes #200. philanthropy.ingest.raisers_edge_gifts_to_featuresandread_raisers_edge_gifts: an on-ramp from a Blackbaud Raiser's Edge gift export to the donor-level feature table, alongside the existing CiviCRM and UniSchema bridges. Headers are normalised from any of the three spellings the product uses (desktop Export labels, the RE7 database columns, the RE NXT SKY API field names) onto the canonicalcontact_id/receive_date/total_amount, then the roll-up is delegated to the CiviCRM aggregator. The domain knowledge it adds is the commitment-versus-payment filter: in Raiser's Edge a pledge and the payments made against it are separate gift records, and a recurring gift row is a template rather than money received, so summing the amount column counts every committed dollar twice. The commitment rows and the ledger corrections are dropped by default, matched case-, space- and punctuation-insensitively so one spelling covers both the desktop and NXT vocabularies, with the desktop's abbreviated matching-gift types (MG Pledge,MG Write Off) named separately because they are not the long forms with the spaces taken out. The excluded set is the documentedexclude_gift_typesparameter, defaulting to the newDEFAULT_EXCLUDED_GIFT_TYPES, because Raiser's Edge exports are user-configured. An export with no gift-type column warns rather than silently double-counting. Closes #213.philanthropy features --source {raisers_edge,civicrm} --data gifts.csv --out features.csv, a fourth CLI subcommand. This is what makes the advertised no-Python path true end to end: previouslytrainrequired--featurescolumns such astotal_gift_amountthat nothing in the CLI could build, so "CSV in, scored CSV out" only held for someone who had already written the Python that produced them. The subcommand's--helplists the columns it emits, and the output runs through the same formula-injection neutralisation asscorebecause a feature table echoes donor names and emails. It does not produce a label:train --targetstill needs a column the analyst defines.EncounterRecencyTransformergains anas_ofparameter, the last dated transformer without one. Encounters dated after it are blanked toNaTbefore the features are computed, so a clinical encounter that had not happened yet on the scoring date reads as a missing encounter instead of producing a negativedays_since_last_encounter. It blanks rather than drops because the transformer emits one row per input row, and dropping would break it inside aPipeline. Left at the defaultNonenothing is blanked, but a post-reference-date encounter now warns instead of passing silently. Closes #210.RFMTransformergains anas_ofparameter. Gifts dated after it are dropped before the roll-up, sofrequencyandmonetarydescribe only what had happened by the scoring date. The gift-side roll-up was the one feature builder that did not enforce the cutoff the clinical-encounter builders already did: withreference_dateset and a gift table running past it,monetarysilently summed gifts from after the date being scored andrecencywent negative. Left at the defaultNonethe behaviour is unchanged, but it now warns instead of aggregating the future silently. Closes #208.
Changed
- Seven high-intent documentation pages now carry a per-page
descriptionin YAML front matter, so a search result or social card shows that page's own summary rather than the site-widesite_description. The first-model tutorial is retitled "Building Your First Donor Propensity Model in Python"; the previous "Building Your First Model" named neither the domain nor the language, and MkDocs derives the nav label from the first heading, so the entry was equally opaque there. Every description is quoted: an unquoted YAML scalar containing a colon and a space parses as a nested mapping, which silently drops the<meta name="description">tag and renders the front matter as visible page text instead. paper.md's Research impact statement now cites the leakage preprint, archived on Zenodo as DOI10.5281/zenodo.22665386, alongside the real-data replication archive it already cited.paper.bibgains the matchingleakagepreprint2026entry. The same statement's external-contribution count was stale and understated: it now reads thirty-five merged pull requests from eleven contributors external to the project, measured from the merged-PR list rather than recalled. Part of #127.- The leakage tutorial's opening example is now a real one, contributed and
attributed with permission: Marianne Pelletier's new-donor model built on a
lifetime-giving-greater-than-zero variable, where the feature was the
outcome. It replaces the synthetic
total_lifetime_givingsketch and gains a runnableas_ofwalkthrough of the same failure. Closes #209. CONTRIBUTING.mdanddocs/how-to/develop_and_test.mdnow document the Windows-specific gaps in the local test/CI gate:make ci/make riskcovrequiremake, which is not available in PowerShell by default, andsh scripts/install_hooks.shrequires Git Bash. Testing confirmed the installed pre-push hook does not fire when pushing from plain PowerShell. Closes #195.
Fixed
ensure_local_pathnow accepts absolute Windows drive-letter paths such asC:\\data\\gifts.csvwithout weakening rejection of network URLs. Closes #217.EncounterRecencyTransformerno longer catches timezone conversion errors and retries withtz_localizeafter parsing withutc=True. The retry was unreachable for valid timezone inputs and replaced the usefulUnknownTimeZoneErrorfor invalid timezone names with a misleading "Already tz-aware" error. Closes #201.CRMCleaner.get_feature_names_outraisedAttributeError: 'CRMCleaner' object has no attribute 'feature_names_in_'when the transformer had been fitted on an unnamed array.check_is_fittedpassed, becausen_features_in_was set, and the next line then read an attribute that scikit-learn only assigns when the input carried column names. It now falls back tox0,x1, ... for an array fit, matching whatWealthScreeningImputerandWealthScreeningImputerKNNalready did. Closes #157.- Added regression coverage for
PlannedGivingSignalTransformerwhen transforming a NumPy array after fitting on a DataFrame.
Added
tests/test_public_api_contract.pygains two contracts over every public transformer, covering the twoget_feature_names_outcall shapes the suite never exercised. It previously only ever calledget_feature_names_out()with no argument on a DataFrame-fitted transformer.test_feature_names_out_accepts_input_featurespasses the real column names through, which is the callColumnTransformerandPipeline.get_feature_names_outactually make, andtest_feature_names_out_width_after_array_fitfits on an unnamed array. The second one is what caught theCRMCleanerdefect above. Transformers that genuinely cannot fit on an unnamed array are listed in a new_EXEMPT_ARRAY_FITtable with a written reason each:EncounterTransformermerges on a nameddonor_id, andMatchingGiftFeaturizerrejects a non-DataFrame outright. That table is kept separate from_EXEMPTon purpose, because folding these two into_EXEMPTwould also have dropped them from the width andinput_featureschecks they do pass. Three hygiene tests police it: no stale entry, no name in both tables, and a reason on every entry in both.- Notebooks 02 and 03 install the published wheel instead of a
git+mainsnapshot. Both importeddatasets.make_donor_panel, which 0.7.0 did not ship, and fell back to atry/except ImportErrorthat pip-installed fromgit+...@main. That fallback could not work in-process: the failedfrom philanthropy.datasets import make_donor_panelleaves the stale module cached insys.modules, so the re-import after a successful install raises the sameImportError. It only worked where philanthropy was absent entirely, which is fresh Colab, so anyone who followed the README'spip install philanthropyand then opened a notebook locally got a hard failure, and the "zero install, try it now" Colab badge ran an unreleased snapshot rather than the archived releasepaper.mdpoints at. Now a plainpip install -q "philanthropy[viz]>=0.7.1", which 0.7.1 satisfies from PyPI. Notebook 01 keeps itstry/exceptbecause a bareimport philanthropysucceeds against any release, so its guard never misfires.
[0.7.1] - 2026-09-08
Added
-
scripts/render_leakage_chart.py, the figure companion to the two leakage experiments.leakage_experiment.pyandreal_data_leakage_experiment.pyeach print five-seed means; this script re-runs the identical cross-validation and keeps the per-fold scores, so the shape of the walk-forward backtest is visible rather than one number per condition. It writes a two-panel figure (synthetic and KDD Cup 1998, as-of against whole-history features, seed spread shaded) plus the numbers as JSON, and reproduces the published figures: 0.625 against 0.750 on the synthetic panel, 0.482 against 0.858 on the real one.--cachedre-plots from a saved score file so styling changes do not refit the real panel, and--outchooses the output directory. The rendered files are gitignored rather than committed. -
README.mdgets a### Prior artsection under Research, crediting the R repositories this package is downstream of:michaelpawlus/pg_donors(2015),michaelpawlus/fundraising_analytics(2016), andcrazybilly/fundRaising(2021).PlannedGivingIntentScoreris named as the descendant ofpg_donors, andRFMTransformer,DonorPropensityModel,MovesManagementClassifier,FiscalYearTransformer, andLapsePredictorare matched to the R scripts and functions that did the same job first. The omission read as a state-of-the-field gap:paper.mdcompares against Python libraries only, and nothing anywhere in the project acknowledged the R prior art. -
CONTRIBUTING.mdgets a "Claiming an issue" rule: comment on an issue before opening a PR, and wait for a maintainer to assign it. Issues #175 and #176 were two people independently fixing the same four-line bug the same evening; that was a process failure on this project's side, not a mistake by either contributor. The issue-draft template now states the same rule inline, so every newly filedgood first issuecarries the reminder in its own body rather than depending on a reader following a link. -
Tests pinning the two untested branches of
WealthPercentileTransformer: the all-missing column path (returns NaN ranks, keeps a stable output width, raises no warning) and the partially-missing column path (NaN input rows get NaN rank, observed rows get numeric rank). Closes #169. -
datasets.make_donor_panel(Tier 2, Beta): a seeded multi-year donor panel returning gift-level rows rather than one aggregated row per donor.generate_synthetic_donor_datacannot demonstrateRFMTransformer(needs a gift log),FiscalYearGroupedSplitter(needs repeated donor-years), anas_ofcutoff (needs something to cut off), or the grateful-patient transformers (need encounters), so the only generator that could show the library's central ideas lived privately insidescripts/leakage_experiment.py. This promotes it.- Returns
{"gifts", "donors"}, plus"encounters"wheninclude_encounters=True. 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, so "recent" is well defined.
- No label column, deliberately. A label is a claim about a point in time, and shipping one pre-computed hands every user the exact mistake this package exists to prevent. The docstring shows the one-line derivation.
wealth_estimateis ~30% missing by design, because a wealth screen that came back for every record is not a wealth screen anyone has received.scripts/leakage_experiment.pynow imports it instead of defining a private copy, so the published experiment and the tutorials run on the same generator. The experiment's numbers are unchanged, and not merely to three decimals: the aggregated frames are asserted byte-identical to the ones the private generator produced, on all five published seeds. Gift amounts are deliberately not rounded to cents for that reason; rounding moved the reported min-max ranges by 0.001 AUC.
- Returns
-
Three notebooks under
examples/notebooks/, each with a Colab badge, executed end to end in CI on every push (pytest --nbmake examples/notebooks, one leg of thelintjob):01_quickstart_propensity.ipynb(the README quickstart plus a call list, a distribution plot, and permutation importance),02_temporal_leakage.ipynb(buildsmake_donor_panel's features as-of and over the whole export and measures the inflation, an optional cell behindPHILANTHROPY_FETCH_KDD98reproduces the real-data number), and03_grateful_patient_pipeline.ipynb(encounters, anas_ofcutoff, service-line weighting, the solicitation window, routed through aColumnTransformerrather than a serialPipeline, with an assertion that the pipeline is not degenerate).nbmake>=1.5added to thedevextra as a dev-only dependency; the runtime dependency rule (scikit-learn, pandas, numpy, matplotlib, seaborn, nothing else) is untouched.examples/quickstart.ipynbbecomes a one-cell redirect to notebook 01 for one release, so the previous Colab badge and any existing links keep working;tests/test_examples.py's docstring now says notebooks are covered bynbmake, not by it. -
credit-guardCI job: pull requests touchingphilanthropy/must also update this changelog, and the author must be credited in CONTRIBUTORS.md. Implemented asscripts/check_credit.sh, wired intoci.ymlonpull_requestevents only; failures surface as inline::error::annotations on the Files tab. Closes #113. -
Regression coverage ensuring
MovesManagementClassifier.fitpreserves DataFrame column names infeature_names_in_. Closes #54. -
.github/workflows/pypi-smoke.yml: a weekly (Mondays 12:00 UTC) and manually dispatchable job that installs the published wheel from PyPI on Linux, macOS and Windows, then runsexamples/quickstart.pyandphilanthropy --help. Every other job tests the working tree; this one tests whatpip install philanthropyactually serves, which is the only thing a new user or a reviewer runs. The repository is checked out into a subdirectory and the job assertsphilanthropy.__file__resolves insidesite-packages, so aphilanthropy/directory in the working directory cannot silently shadow the wheel and turn this into a second working-tree test. Windows is included because the main matrix is Linux plus macOS. -
models.GiftIntervalCalibrator: distribution-free intervals on a dollar amount. Wraps an already-fitted regressor (AskAmountRecommender,ShareOfWalletRegressor, or anypredict-per-row estimator) and calibrates on held-out rows via split conformal prediction. Until now nothing in the package returned an interval on a gift amount; every dollar-valued estimator returned a point.- Refuses below the certification floor. One order statistic needs
n >= 1/alpha - 1calibration rows, 19 at the 95 % level, andfitraises rather than returning an interval it cannot certify. The floor is computed infractions.Fraction, because it is a ceiling andint(1 / alpha - 1)truncates: atalpha = 0.07that reports 13 where the floor is 14. There is no parameter that switches the check off. - Reports the attained level,
r / (n + 1), on the returnedGiftIntervaland asattained_level_. A request for 0.95 resolves to 0.9677 at 30 calibration rows and 0.9524 at 20; the requested level is kept separately asrequested_level_. - Three one-rank conformity scores via
score=:"absolute","difficulty"(residual over a difficulty estimate) and"log"(residual onlog1pdollars, inverted). Equal-tailed two-rank intervals are deliberately not offered: two order statistics atalpha / 2more than double the floor to 39 rows and buy nothing the one-rank forms do not. - Intersects the interval with
[lower_bound, inf), default0.0. A gift cannot be negative, so coverage is bit-identical and width strictly falls. Calibration targets below the bound raise, since they are evidence the bound is wrong. - Optional
groups=calibrates within a segment. A pooled calibration set is dominated by whichever segment supplies most of the rows and under-covers the others however much marginal data is added; a group below the per-group floor is refused by name rather than quietly pooled with a segment at another capacity level.
- Refuses below the certification floor. One order statistic needs
-
metrics.interval_scoreandmetrics.interval_report: the interval score(u - l) + (2/alpha)(l - y)+ + (2/alpha)(y - u)+, which is proper for a central interval, plus a report carrying coverage, the score as mean/median/ trimmed mean (it is a heavy-tailed loss on gift amounts, and a ranking that flips between the three is a ranking of the tail), median width, andwidth_ratio= median width over median target. A valid interval can carry no information; the ratio is what separates the two. -
Test coverage for
PlannedGivingIntentScorer.predict_intent_score: the single-classpredict_probafallback path that returns an all-zero score. (#56)
Changed
-
The
ShareOfWalletScoreroutput rename describes itself as landing in 0.7.1 rather than 0.8.0. The docstring, theget_legacy_feature_names_outsummary line, itsDeprecationWarningtext, and the deprecations table indocs/reference/index.mdall named 0.8.0, which was the version this work was staged for before the release was cut as a patch. The three future promises are untouched and still say 0.8.0:WealthScreeningImputerKNN(group_col_idx=...),philanthropy.utils.make_donor_dataset, and theFiscalYearGroupedSplitterdrop_repeat_donorsdefault flip. Removal of the legacy names accessor stays at 0.9.0, which is more grace than the one-published-minor rule requires. -
Generative AI disclosure reordered in
paper.md,README.md, andphilanthropy/__init__.pyto lead with human design authority and human review, then state the scope of the assistance within those constraints. The facts are unchanged: the scope remains package-wide, no numeric split is estimated, and the review gate is still what the disclosure rests on. Only the order and emphasis moved, so the disclosure stays consistent withAGENTS.mdand the public commit history. -
WealthPercentileTransformer.fitnow raises an actionableValueErrorwhen an explicitwealth_colslist matches no training column; partial matches and automatic detection remain unchanged. -
README coverage badge now links to
pyproject.tomland reads "≥92% floor" rather than a bare "≥92%". It was a static shields.io string with no tie to the enforced number, so it would have silently lied hadfail_underever moved. Not wiring a dynamic gist badge (schneegans/dynamic-badges-action): that needs a personal-access-token secret this change doesn't have standing to create, and Codecov/Coveralls are explicitly ruled out elsewhere in this project's standing rules. -
ShareOfWalletScoreroutput column 0 is renamedsow_score→capacity_utilisation_ratio. The formula was always capacity ÷ clipped modelled wealth: utilisation of estimated capacity, with no term for giving to your institution, so the old name claimed a share-of-wallet quantity the score cannot express (the class docstring has warned about exactly this since it shipped). Values, column order, andcapacity_tierare unchanged; code reading column 0 positionally needs nothing. Code spelling the name gets one published minor of grace viaget_legacy_feature_names_out(), which returns the old["sow_score", "capacity_tier"]under aDeprecationWarningand is removed in 0.9.0; the shim is registered intests/test_deprecations.py. Closes #109. -
predict_<thing>_intervaljoins_scoreand_forecastas an accepted domain-method suffix in the public-API naming contract (tests/test_public_api_contract.py,AGENTS.md). -
test_predict_methods_are_callable_with_x_alone_and_return_one_value_per_rownow skips non-estimator symbols inmodels.__all__instead of raisingKeyErroron the first one. -
CRMCleanerandFiscalYearTransformernow use a shared_validate_Xhelper, and the unreachablenp.iscomplexobjguard is removed. Complex data inside object arrays bypasses the guard and is properly handled downstream. Closes #155.
Deprecated
-
FiscalYearGroupedSplitter's default fordrop_repeat_donors(currentlyFalse) is deprecated and will change toTruein 0.8.0. Leaving it at its default now emits aDeprecationWarning. Passdrop_repeat_donors=Falseexplicitly to silence the warning and retain current behavior. Closes #108, by @shubhrai23. -
philanthropy.utils.make_donor_datasetmoves tophilanthropy.datasets.make_donor_datasetand the old location emits aDeprecationWarning; removed in 0.8.0. The gift-level generator now lives next togenerate_synthetic_donor_data, which is the canonical datasets home. Closes #111.
Fixed
-
GratefulPatientFeaturizernow reports one fallbackgeneralservice line per known donor when the encounter table omits the service-line column. Missing physician columns continue to report zero distinct physicians, and both optional-column paths now have regression coverage. Closes #151. -
EncounterTransformernow accepts parseddatetime64gift dates alongside date strings, and reports unparseable values with the configured gift-date column name instead of exposing NumPy's mixed-dtype promotion error. Closes #163. -
CRMCleaner.transformno longer silently corrupts complex amounts into wrong finite floats: cells holding actualcomplexvalues are masked to NaN with aUserWarningnaming them, and a column where nothing parses (all-complex included) still raisescould not parseper the documented contract. Closes #129.
[1.0.0] - TBD
The API freeze. No code changes: 1.0.0 is a promise, not a feature.
Changed
Development Status :: 5 - Production/Stable.- Tier 1 is now semver-protected. A breaking change to any Tier 1 symbol
requires a major release, preceded by one full published minor emitting
DeprecationWarning. Tier 2 may still break in a minor; Tier 3 carries no guarantee. The tiers are listed per-symbol in docs/reference/index.md.
Added
test_stability_tier_table_covers_every_public_symbol: the tier table is now machine-checked against__all__, so a new public symbol cannot ship without a stated tier. At 1.0 that table is the contract; an out-of-date one is a broken promise, not a docs nit.
Notes
The five 1.0 gates all hold at this commit: 0.7.0 published; the public-API
contract test green with no exemption added since 0.7.0; no deprecated_alias
anywhere in philanthropy/; __version__ == importlib.metadata.version(...)
and py.typed in the wheel; every __all__ symbol carries a tier and no Tier 1
entry is mid-deprecation.
Documentation
- New page Real-Data Replication: KDD Cup 1998
(
docs/explanation/real_data_replication.md), promoted out of a section ofbenchmarks.mdand expanded: synthetic and real numbers side by side, the panel construction from the wide promotion history, the pre-registered prediction that was wrong by a factor of five, the download caveat, and the Zenodo replication DOI.benchmarks.mdkeeps the headline tables and links out, so the measured numbers still live in exactly one place. The README gains a "Validated on real donor data" section pointing at it. - "Which estimator do I need?" table at the top of
docs/tutorials/index.md, keyed by the question a fundraising shop actually asks rather than by module. Fifteen rows covering every Tier 1 and Tier 2 estimator plus the metrics, with the required data shape in the middle column, because that is usually the real work. Closes the gap where a reader had to infer the entry point from the feature tables.
Typing
- mypy ratchet finished.
py.typedships in the wheel, so a user's type checker treats every unannotated function here asAny, which is worse than shipping no type information: it silently disables checking at the boundary instead of admitting there is nothing to check.experimental,modelsandpreprocessing, the last three subpackages, are now fully annotated:fitreturns a per-classTypeVarbound to the class rather than a string literal, so a subclass'sfitno longer reports its parent's type;__sklearn_tags__returns sklearn's publicTagsdataclass (scikit-learn>=1.6, the declared floor). With every subpackage covered, the[[tool.mypy.overrides]]block is gone anddisallow_untyped_defs = trueis a top-level[tool.mypy]setting, so a newly-added unannotated function anywhere inphilanthropyfails CI. Closes #166. ensure_local_pathis now generic in its argument (TypeVar) rather than declared-> str. It returns its input unchanged, and both call sites pass something that may be aPath, so the old annotation was a small lie; the docstring said "the unchanged path" and now the type says so too.
Changed
- Issue templates converted from Markdown to YAML issue forms
(
bug_report.yml,feature_request.yml). The Markdown versions asked for a version and a reproducer and could be submitted without either, and GitHub's community profile reportedissue_template: falsebecause it counts only forms. The bug form requires the version, the environment line and a runnable reproducer, plus an explicit tick that the reproducer contains no real donor or patient data. The feature form states the two constraints that decide most requests (frozen dependency set, no network in the core) before the author starts writing.config.ymlis unchanged.
[0.7.0] - 2026-08-21
The removal release, plus everything else merged since 0.6.0. Every shim
under Breaking shipped in 0.6.0 emitting a DeprecationWarning for one full
published minor; everything under Added, Changed and Deprecated below is new
work that ships for the first time in this release.
Breaking
-
Four deprecated method aliases removed. Use the replacement in every case:
Removed Use instead AskAmountRecommender.predict_ask_arrayask_ladderShareOfWalletRegressor.predict_capacity_ratiocapacity_ratioMovesManagementClassifier.predict_action_priorityaction_priorityPlannedGivingIntentScorer.predict_bequest_intent_scorepredict_intent_score -
Three dead constructor parameters removed. Passing any of them is now a
TypeError:LapsePredictor(lapse_window_years=...)(the window is a property of how you labelledy),PropensityScorer(estimator=...)(the baseline is a constant 0.5),FiscalYearGroupedSplitter(fiscal_year_start=...)(groupsalready carries fiscal-year labels). -
donor_acquisition_cost,cost_per_dollar_raisedandfundraising_roiare keyword-only. They do not share an argument order:cost_per_dollar_raisedtakes expense first,fundraising_roitakes raised first, so a positional call was silently accepted and returned a plausible wrong number. It is now aTypeError. -
Four accidental second import paths moved behind underscores so 1.0 does not freeze them:
metrics.scoring→metrics._scoring,preprocessing.transformers→preprocessing._transformers,models.propensity→models._propensity_baseline,utils.testing→utils._testing. Every public symbol is unchanged and still exported from its subpackage; only a directfrom philanthropy.metrics.scoring import ...breaks. Import from the subpackage instead. -
philanthropy/utils/_deprecation.pyis gone.tests/test_deprecations.py, which existed solely to police the shims removed above, went with it, then came back later in this same release to police a new one; see Deprecated below.
Changed
GratefulPatientFeaturizer,EncounterTransformerand thephilanthropyCLI now reject network-scheme paths (https://,s3://,gs://) with aValueErrorbefore any file read. Previously the no-network guarantee held for the library's own logic but not for its documented public parameters, becausepandaswill follow a remote URI if handed one. Local paths, includingfile://, are unaffected. Closes #114.- The no-network promise in
README.md,SECURITY.mdand the security review Q&A is now stated as two precise guarantees, "never transmits your data" and "downloads nothing", instead of the blanket "no network calls of any kind", and the second one is machine-checked.tests/test_no_network.pynow parses every module in the package and fails the build if one imports a network-capable library without appearing on an explicit allowlist. The allowlist is empty, so the effective promise is unchanged and is now enforced across modules no test happens to import, rather than only on the paths the socket fixture walks.
Added
- Question 1a in the security review Q&A documents the remote-path rejection, which is the behaviour a privacy officer asks about after reading question 1.
philanthropy.datasets.fetch_kdd98_donors, an opt-in fetcher for the KDD Cup 1998 direct-mail donor dataset, cached locally after first download. It is the one entry in the no-network allowlist added above, and it exists so the library can be validated against real donor data instead of only synthetic data. Part of #124.scripts/real_data_leakage_experiment.pyreplicatesleakage_experiment.pyon the real KDD Cup 1998 file instead of the synthetic panel. The predicted effect (recorded in the script before it was run) was smaller than the synthetic numbers; the measured effect is larger: whole-history feature construction inflates walk-forward ROC-AUC by +0.376 AUC (versus +0.126 synthetic), and a randomStratifiedKFoldsplit overstates the true future by +0.107 AUC (versus 0.014-0.030 synthetic). Documented indocs/explanation/benchmarks.mdand inpaper.md's Statement of need and Research impact statement. The script outputs and environment lock are archived on Zenodo (DOI 10.5281/zenodo.22050649). Closes #124.
Deprecated
WealthScreeningImputerKNN(group_col_idx=...)is deprecated and will be removed in 0.8.0. It still works and now emits aDeprecationWarning. There is no replacement because there is nothing to replace: measured across several synthetic two-group pools, and on five Python versions in CI, per-group and global KNN imputation produce bit-identical output (50263.48615163204both ways). A donor's nearest neighbours by feature distance almost always share their group already, andKNNImputerweights distance by column magnitude, so a 0/1 group flag barely registers. The parameter costs a per-group imputer, three fallback paths and a documented contract, and buys no measurable accuracy. Split the frame by group and fit one imputer per part if you need that behaviour.tests/test_deprecations.pyis reintroduced perRELEASING.md, with the registry meta-test that fails when a shim ships untested; it walks the package AST forwarnings.warn(..., DeprecationWarning)call sites, so a docstring merely mentioning the class is not miscounted. Closes #85.
Added
paper.mdnow carries the four JOSS sections it was missing: State of the field, Software design, Research impact statement, and AI usage disclosure. JOSS made all six sections required and moved the length window to 750-1750 words in January 2026; the paper was 635 words with two of six sections, which is a pre-review bounce on its own. It is now 1447 words. The AI usage disclosure restates the one already inREADME.mdand inphilanthropy/__init__.py, since JOSS requires it as a named section of the paper itself.paper.bibgains the prior art the paper had never cited: feature-engine, mlxtend, sktime, pymc-marketing, MAPIE, crepes, Fader-Hardie-Lee (BTYD), Zhang (2003) for the linear-plus-nonlinear forecast decomposition, and Bates et al. (2023) for the conformal p-value the code already attributes to it. JOSS accepts re-implementations "provided that they cite prior similar work", and the bibliography previously cited no comparable package.RFMTransformer(include_tenure=True)emits a fifth column,tenure: days from the donor's first gift to the frozen reference date. Recency, frequency and monetary alone cannot feed a buy-till-you-die model, which needs the observation window T as well. Defaults to False so the output shape does not move under existing callers.ShareOfWalletScorer(major_tier_threshold=..., principal_tier_threshold=...). The 0.40 and 0.75 cut points were hardcoded intransformwith no source and no way to match an institution's own tiering.DischargeToSolicitationWindowTransformer(window_shape=...), with the legacy symmetric triangle available as"triangle"for reproducing older runs.EncounterTransformer.fitwarns whenas_of=Noneand the encounter table contains discharges later than the latest gift date inX, naming the row count. That is the one leakage path no cross-validation splitter can see: the encounter table is a constructor argument, so its rows are never part of any split.GratefulPatientFeaturizerwarns whendrg_weight_colis set. A DRG relative weight is diagnosis-derived, and diagnosis is not in the element list the HIPAA fundraising carve-out permits (45 CFR 164.514(f)).
Changed
- Behaviour change.
DischargeToSolicitationWindowTransformernow decayswindow_position_scorefrom 1.0 atmin_days_post_dischargeto 0.0 atmax_days_post_dischargeinstead of peaking at the window midpoint. The old symmetric triangle treated the ethical cooling-off floor as a propensity minimum: with the default 90-365 window, day 91 and day 364 both scored about 0.007 while day 227 scored 1.0. Passwindow_shape="triangle"to reproduce the previous numbers. - Behaviour change. A missing days-since-discharge value now yields
window_position_score=NaNrather than0.0, so "no discharge on record" is distinguishable from "discharged, but outside the window", which still scores a hard 0.0.in_solicitation_windowis unchanged at 0. - Behaviour change.
GratefulPatientFeaturizer(use_capacity_weights=...)now defaults toFalse. The built-in service-line multipliers have no published source, and defaulting them on meant the headlineclinical_gravity_scoresilently carried unsourced 2.7x to 3.2x weighting. EncounterTransformer.dropped_cols_now includesgift_date_col, whichtransformdrops separately.compliance_considerations.mdtells operators to inspect this attribute as their audit trail, and it was under-reporting what actually left.philanthropy.preprocessing.SolicitationWindowTransformeris deprecated and emits aDeprecationWarningon access via PEP 562 module__getattr__. It still resolves toDischargeToSolicitationWindowTransformeritself, soisinstanceandcloneare unaffected, and it is registered intests/test_deprecations.pyfor removal in 1.0.0. Two public names for one transformer inflated the API surface without adding capability.CITATION.cffand.zenodo.jsonnow matchpaper.mdon title and author name, and both carry the ORCID.CITATION.cffrecordsversion: 0.6.0, the release the concept DOI actually resolves to, instead of the in-development1.0.0frompyproject.toml. A reviewer following the archive DOI was landing on a record that contradicted the paper byline.
Fixed
- Four claims in
paper.mdthat were falsifiable by running the code. The conformance claim namedUpliftTLearneras "the one documented exception" against four entries in_MANUALLY_COVERED; the leakage claim said a PhilanthroPy pipeline "cannot leak test-period or future information" while_encounters.pydocuments that exact leak at the defaultas_of=None; fiscal-year boundaries were listed as a frozen fitted statistic althoughFiscalYearTransformerhas no fitted state (its own test is namedtest_fiscal_year_stateless); and "compose directly insidesklearn.pipeline.Pipeline" did not exclude the row-reducingRFMTransformer. The Summary now describes the conformance registry as the mechanism it is: 20 configured instances, 1016 checks on scikit-learn 1.8.0, four documented exemptions, and a build-failing guard against a public estimator appearing in neither list. conformal_pvalue: thresholding atalphabounds the expected selection rate, not the false-positive rate. The FPR reading needs a calibration set of nulls only, which is the construction in Bates et al. (2023) and not what "donors held out of training" gives you. The wrong statement was in the shipped module docstring and therefore in the rendered API docs, not only in the paper.- The
check_estimatorclaim was corrected inpaper.mdbut still stood in three other places, in its strongest and most falsifiable form:README.md("Every public estimator passescheck_estimator", with theUpliftTLearnerqualification trimmed off at some point),docs/explanation/design_principles.md("the one exception"), anddocs/explanation/security_review_answers.md, which is the page written to be forwarded to a privacy or procurement reviewer. All three now describe the battery, its four documented exemptions, and the build-failing guard, matching the paper. EncounterRecencyTransformerdescribed itself as producing "HIPAA-safe" features in four places. Date-only input is not de-identified: Safe Harbor strips every date element more granular than a year, so encounter dates are themselves identifiers, permitted for fundraising only under the narrower 164.514(f) carve-out. This contradicted the project's own compliance page.security_review_answers.mdattributedPII_PATTERNScolumn-dropping toCRMCleaner, which has neither the attribute nor any dropping logic. It lives onEncounterTransformer. That page exists to be forwarded to a privacy officer, so the error cost more than a docs bug normally would. It now also states thatpii_patternsreplaces rather than extends the defaults.ShareOfWalletScorer's docstring claimed a share of wallet. The formula has no term for giving to your institution anywhere in it, so it cannot express what fraction of a donor's philanthropy you receive; it is capacity over modelled wealth.docs/index.mdrepeated the wrong definition. The output name is kept for compatibility and flagged for renaming in the next major release. The fit-time 95th-percentile denominator clip, which inflates exactly the top tier, is now documented rather than silent.- The README figure caption said the affinity scores "cleanly separate major from non-major donors", 21 lines below the text explaining that the distributions overlap. That was the in-sample overclaim commit c54a6b3 retracted, left behind in the caption.
AskAmountRecommenderandShareOfWalletRegressornow say in their docstrings that they are the sameHistGradientBoostingRegressorwrapper with different targets, andPropensityScorerthat it is equivalent in effect toDummyClassifier(strategy="uniform"). Four classes exposedproba * 100under four names with nothing saying they were the same thing.
Notes
paper.mdcitesscripts/leakage_experiment.pyand its measured result (whole-history feature aggregation inflates walk-forward ROC-AUC from 0.625 to 0.750, +0.126, against 0.014 and 0.030 of splitter-choice error). That script arrives with #101, so #101 must land for the reference to resolve. The numbers above were reproduced locally against this branch before being written down.- Still open, and not fixable in a pull request: JOSS requires demonstrated
research impact, and no estimator here has ever been fitted on real donor
data.
load_ciob_fundraisingcarries no donor rows, amounts or labels. The paper's Research impact statement says so plainly rather than implying adoption that does not exist. WealthScreeningImputerKNN.group_col_idxnow does what it always claimed. It was documented as stratifying KNN imputation per group "improving local accuracy", and was stored and never read. When set withstrategy="knn", a separateKNNImputeris now fitted per group, so a donor's missing wealth is filled from neighbours inside their own group instead of from the whole database. The measured benefit is small and setup-dependent, which is worth saying plainly given the old docstring promised "improving local accuracy": across several synthetic two-group pools the grouped and global fits often agree exactly, because a donor's nearest neighbours by feature distance usually share their group already, andKNNImputer's distance is dominated by large-magnitude columns so a 0/1 group flag contributes little either way. CI demonstrated this on other numpy/sklearn versions, where the two fills came out bit-identical, so no test here asserts that grouping changes a value. The honest case for the parameter is explicit control, not a demonstrated accuracy gain, and issue #85's option B (deprecate it) remains defensible on that basis. Three fallbacks are frozen at fit time so nothing is learned at transform time: a group with fewer thann_neighbors + 1training rows gets no imputer of its own; a group value unseen at fit, or a row whose group label is missing, uses the global imputer; and a column entirely missing within a group also defers to the global imputer, becauseKNNImputer(keep_empty_features=True)fills such a column with a hard0.0rather thanNaN, which for a wealth column reads as "no capacity" and would be a materially wrong number for every donor in that group. The global imputer is always fitted, so output is neverNaNregardless of grouping. Ignored for the columnwise strategies, which have no notion of a neighbourhood. An out-of-range index raises, forstrategy="knn"where the parameter has any effect. Closes #85.FiscalYearGroupedSplitter(drop_repeat_donors=True)for the static-per-donor label case. The splitter groups by fiscal year, correctly, but not by donor, so a donor with gifts in several fiscal years lands in both folds of a split. That is right for a time-varying target and is leakage for a static label such asis_major_donor, which is the label used throughout the README, the benchmarks page andscripts/benchmark_models.py. With the flag set, each test fold drops donors already present in its training rows;groupsthen takes shape(n_samples, 2)with the donor identifier in column 1. Training rows are never dropped. The cost is made visible rather than silent:splitwarns with the number of test rows removed, and notes that the remaining test donors are systematically newer to the file. A test fold emptied entirely raises with an actionable message rather than being skipped, which would have putsplitandget_n_splitsback out of step. A row with a missing donor id is treated as already-seen and dropped:np.isinnever matchesNaNtoNaN, so it would otherwise have been kept, and an unidentifiable donor cannot be shown to be absent from training. A string-typedgroups(which is whatnp.column_stackproduces from integer years and string donor ids) has its fiscal-year column coerced back to numeric, and a genuinely non-numeric year column now raises with an actionable message instead of a bare numpyTypeError.__repr__includes the flag, so two splitters that split differently no longer print identically. Defaults toFalse, so nothing changes until you opt in. Part of #87; the docs and benchmark follow-up that issue also scopes is not done here.scripts/leakage_experiment.pyquantifies the library's central claim, which was previously architectural and untested. On a seeded donor-year panel across five seeds: walk-forwardFiscalYearGroupedSplitterestimates the true future to within -0.014 ROC-AUC where a randomStratifiedKFoldis off by -0.030, so the splitter is worth roughly twice the accuracy in your estimate. Both CV runs exclude the year the target scores, because "train on everything earlier, score the final year" is what a walk-forward splitter's last fold does and leaving it in would hand walk-forward the win by construction. Computing the same aggregate features over the whole export instead of as of each panel year inflates the score by +0.126 ROC-AUC, under an identical model, splitter and label: roughly eight times what the splitter choice is worth. Correct feature timing is worth an order of magnitude more than a correct splitter, which is the case for freezing fit-time statistics and for the newas_ofcutoff. Reported indocs/explanation/benchmarks.md, including the negative result: the common claim that a random split inflates a backtest did not reproduce here in three separate configurations. Closes #84..gitattributessetsCHANGELOG.md merge=union.AGENTS.mdrequires every PR to add an entry under## [Unreleased], so every concurrent PR conflicts with every other one, always in the same place and always additively. 10 of the last 20 commits onmaintouch this file.unionkeeps both sides instead of stopping, which is the standard treatment for an append-only file. It is line-based rather than section-aware, and it never reports a conflict for this file at all: if two branches edit the same entry it keeps both, silently. So the## [Unreleased]block is worth a skim at release time, for a bullet under the wrong heading and for a duplicated one;RELEASING.mdnow says so. GitHub does not read.gitattributes, so its own merge behaviour is unchanged: the benefit is to the localgit merge origin/mainthat currently absorbs the cost.philanthropy.metrics.conformal_pvalue: the non-smoothed split-conformalphilanthropy.metrics.conformal_pvalue: the non-smoothed split-conformal p-value of a donor score against a held-out calibration set,(1 + |{i : s_i >= s}|) / (n + 1). A calibrated probability threshold fixes no error rate; thresholding this p-value atalphabounds the expected selection rate atalphain finite samples with no distributional assumption. It is a selection-rate bound, not a false-positive rate: the latter reading needs a calibration set of nulls only, as in Bates et al. (2023). Both the1 +and the+ 1are load-bearing and tested: the result is never 0 and never above 1, and leave-one-out over exchangeable scores lands exactly on the uniform lattice.as_ofonEncounterTransformerandGratefulPatientFeaturizer: an as-of cutoff that excludes encounters discharged after a given date fromencounter_summary_at fit time. Without it there was no way to bound the encounter table to what was observable at the decision point, so a gift dated 2020 was featurised from encounters recorded in 2024 anddays_since_last_dischargewas measured from the all-time max discharge. The failure was systematic rather than random: the more a donor engaged after the gift, the further the feature was pushed past the gift date and the more often it collapsed toNaN, destroying it for exactly the donors it should be strongest for. Defaults toNone, which is the previous behaviour, so nothing changes until you opt in; set it to the last day of your training window for walk-forward evaluation.- Test coverage for
constituent_events_to_features: the all-unparseable-timestamps empty-frame path and thedistinct_source_systemsdefault-to-zero path whensourceSystemis absent from the input. (#51) AGENTS.md: every change, including maintainer- and agent-authored ones, must go on a branch and through a PR: no direct commits tomain, no self-merges. go on a branch and through a PR, and never straight tomain. (The blanket "no self-merges" this originally also promised is superseded below: with one account holding merge rights it could not hold.)tests/test_no_network.pyenforces in CI what the docs now promise: the package makes no network calls. Every socket entry point is monkeypatched to raise, then a full train/score cycle, an imputation pass and a CiviCRM ingest all run. A telemetry hook, HTTP client or lazily downloaded asset added later fails this test instead of shipping.docs/explanation/security_review_answers.md: the ten questions an institutional security or privacy review actually asks, on one forwardable page (BAA status, dependency provenance, the pickle trust boundary, de-identification scope, bus factor, disclosure route).make riskcov: the risk-tier coverage floor as a single source of truth.ci.ymlandCONTRIBUTING.mdnow both call it.scripts/issue-drafts/_TEMPLATE.mdandscripts/check_issue_lines.py: the issue shape that converts, and a drift checker for thepath:linereferences in issue bodies. Deliberately outside.github/ISSUE_TEMPLATE/, which is the public chooser.- Two tests for guards that only fire at transform time and were previously
uncovered:
MatchingGiftFeaturizer.transformrejecting a non-DataFrame, andShareOfWalletScorerenforcing thecapacity_col_idxupper bound thatfitdeliberately does not check.
Changed
- Logo: a new mark, an outlined heart crossed by a rising arrow, drawn as SVG so it
stays crisp at favicon size and follows the colour scheme.
docs/assets/logo.svgis the favicon,overrides/.icons/philanthropy/heart-rise.svgis inlined as the header logo, anddocs/assets/logo.pngis the regenerated wordmark lockup the README uses. - Homepage figure: the affinity-score distribution is now a chart, not an ASCII dump
of
describe(), and it plots the held-out scores the quickstart reports after #89, not the in-sample ones. Interquartile bar, median notch, full min-to-max range, the overlapping tails left visible, and the 47-point separation between the two middle halves called out beside the held-out ROC-AUC of 0.932. The accent marks the group being ranked, muted ink the reference group; both fills clear 3:1 on their surface in each scheme. Hover gives the five-number summary and a collapsible table view carries every number, so nothing is gated behind the tooltip. - Informational admonitions (note, info, tip, abstract, example, quote) now wear the palette instead of Material's blue; warning and danger keep their semantic colours.
- Documentation site: a new visual system (Fraunces display serif over Geist,
a single amber accent, warm near-black canvas with a paper light mode),
dark scheme first, and a homepage that shows the ten-line quickstart and its
output above the fold.
mkdocs.ymlalso gains section index pages, instant navigation, prev/next footer links, footer social links, and a correctedit_uri(the "edit this page" links previously pointed at amasterbranch that does not exist). - Removed every em dash from the repository's prose, 442 of them across 91 files:
paper.md,README.md, all documentation pages, docstrings, inline comments,CHANGELOG.md, theMakefile,.flake8and both CI workflows. Each site got the punctuation the sentence wanted, picked individually rather than by blanket substitution: a colon for a label or definition, commas for an appositive, a semicolon for two independent clauses, parentheses for a paired aside. The only em dash left is inside a nonprofit's name inphilanthropy/datasets/data/ciob_official_fundraising.csv, which is source data rather than prose. No behaviour changes, though a handful of the edited sites are user-visible strings rather than prose: the pickle-trust warning inphilanthropy/cli.pyand several test comments. AGENTS.mdsaid "never merge your own PR; open it and leave the merge to review" while.github/CODEOWNERSis* @shivamlalakiyaand no second account holds merge rights. Taken literally the rule means nothing ever merges, and it was visibly not being followed. It now describes what is actually required: a PR for every change, green CI before merge, a second reviewer when one is available, the maintainer merging their own PR when one is not, and agents never merging at all. The section says explicitly that this is a description rather than an endorsement, and points at the real fix.- Four docstrings described behaviour the code does not have, each now corrected
against a test in
tests/test_documented_contracts.py.FiscalYearTransformersaid it appendsfiscal_year/fiscal_quarter;transformin fact returns only those two columns and drops the input, which silently discarded a pipeline's features.EncounterTransformer.fitclaimed it "prevents temporal data leakage"; it only guarantees that nothing fromXenters the summary, and the summary itself has no as-of cutoff, so a 2020 gift is scored against 2024 encounters.WealthScreeningImputerKNN.group_col_idxdocumented per-group stratified imputation "improving local accuracy"; it is stored and never read. TheGratefulPatientFeaturizerservice-line weights were attributed to "commonly-cited AMC development benchmarks"; they have no published source. No behaviour changed in this entry: the docs moved to meet the code. FiscalYearGroupedSplitternow documents the leakage it does not prevent: its grouping unit is the fiscal year, not the donor, so a donor with gifts in several fiscal years appears in both folds of a split. That is correct for a time-varying target and is leakage for a static per-donor label such asis_major_donor. The class docstring previously implied it prevented leakage generally.- JOSS paper prep: restored the leakage (
kaufman2012leakage,kapoor2023leakage) and grateful-patient-ethics (collins2018grateful) citations that the rootpaper.mdhad dropped, so the temporal-leakage claim in the Statement of need and the AMC domain claim both have sources again. Settled the author affiliation to "Independent Researcher" in.zenodo.json, which still said "Washington University in St. Louis" and so disagreed withpaper.md; that value is minted into a permanent citable Zenodo record. Deleting the duplicatepaper/draft and repointingdraft-pdf.ymllanded separately in #72. - Added complete output-column documentation to all eleven preprocessing
get_feature_names_outoverrides that previously rendered blank in the API reference. - Documented
fit/transformonCRMCleaner,FiscalYearTransformerandWealthPercentileTransformer, including which attributes eachfitfreezes and thatWealthPercentileTransformerranks held-out rows against the frozen training distribution rather than the batch being transformed. - Documented
fit,predict,predict_probaandpredict_affinity_scoreonMovesManagementClassifier,PlannedGivingIntentScorer,MajorGiftClassifierandPropensityScorer: the fitted attributes each sets, and thatPropensityScorer's default threshold returnsclasses_[0]for every row. make_donor_datasetnow documents that it returns a gift-level frame, solen(df) > n_donors(each donor contributes 1–5 rows), and thatfiscal_year_startandlapse_rateare accepted but currently unused.CLAUDE.mdis nowAGENTS.md, the tool-neutral convention, withCLAUDE.mdreduced to an@AGENTS.mdimport. Agents other than Claude Code were reading no project instructions at all: not the leakage contract, not the dependency constraint, notmake ci.README.mdquickstart prints a result instead of ending in a bareassert, and documents the CLI path for readers who do not write Python.SECURITY.mdsupported-versions table named0.5.x, which has not been the installable release since0.6.0. Now0.6.x, and kept current by theRELEASING.mdchecklist. Adds GitHub private vulnerability reporting as the preferred disclosure channel.AGENTS.md's merging section no longer bars agents from merging outright. An agent may now merge a PR under the same bar as the maintainer's own-PR merge (all required CI checks green, no second reviewer available), plus having actually read the diff and judged it good..gitignorenow excludes.claude/CLAUDE.local.md, for personal working notes that shouldn't end up in the repo.
Fixed
- Version metadata now names the release that actually exists.
pyproject.tomlandCITATION.cffboth declared1.0.0, which has no git tag, no PyPI artifact and no Zenodo deposit; PyPI's newest is0.6.0and so is the newest tag.CITATION.cffadditionally dated that phantom 1.0.0 to2026-08-01, which is 0.6.0's release date, and its DOI comment cited the v0.6.0 per-version DOI while the file claimed 1.0.0. Both now say0.6.0, and the comment states the rule:versiontracks the newest published release, notmain.maincontinues to carry unreleased0.7.0and1.0.0work, and those CHANGELOG headings stay- TBDuntil a release is cut. This also unblocks the JOSS archive step, which requires the submitted version to correspond to a real tagged, archived release.RELEASING.md's "cutting a releasemainhas already moved past" section assumed the tip carried the newest staged version and walked through tagging an older commit; with the tip back at the published version the normal branch-bump-date-tag path applies, so that section documents that instead and keeps the older-commit case as the caveat it is. Closes #88. generate_synthetic_donor_dataran the domain's causal arrow backwards. It drewis_major_donorfrom a logistic model ofyears_activeandevent_attendance_count, then drewtotal_gift_amountconditional on that label, so the strongest feature was generated from the answer. Measurably: a model giventotal_gift_amountscored ROC-AUC 0.935 against a causal Bayes accuracy ceiling of 0.768, beating the Bayes rate of the generator's own process by about 19 AUC points, which no model can legitimately do. Using cumulative lifetime giving to predict "is a major donor" is also the classic fundraising leakage this library exists to prevent, so the reference dataset was teaching the anti-pattern.last_gift_datewas a second target-derived feature, drawn Beta for majors and uniform for everyone else. A latent giving capacity now drives everything: a confounder causing both the giving history and the label.total_gift_amountis a noisy realisation of capacity,is_major_donora soft $25,000 threshold on it, andlast_gift_datefollows engagement. The model now sits below the ceiling (accuracy 0.759 against 0.806) rather than above it, which is the correct relationship. Held-out ROC-AUC moves from 0.935 to 0.814 and the base rate from 0.687 to 0.378: worse numbers, trustworthy ones. Benchmark table, README quickstart and the benchmarks page are regenerated from the committed script.generate_synthetic_donor_datais Tier 1, so this changes returned data for a documented-stable function; release sequencing is the open version question. Closes #86.- The README quickstart fitted and scored the same rows, then reported the
resulting gap ("non-major donors top out at 39; no major donor scores below 65")
as the headline result. That gap was a random forest reciting its training set:
RF leaves go pure and
predict_affinity_scoreispredict_proba(X)[:, 1] * 100. On held-out rows from the same 500-row sample the two groups overlap almost completely (non-major max 97.5, major min 6.5). The quickstart now splits before fitting and reports held-out ROC-AUC 0.932 with overlapping score distributions, which is a weaker claim and a true one. docs/explanation/benchmarks.mddistrusted its own numbers for the wrong reason. It said the synthetic data was "cleanly separable by construction"; the label is a Bernoulli draw with a real noise term and the irreducible error over the causal features is 23.2%. The actual problem is that the generator drawstotal_gift_amountfrom the label, so including that feature lets a model score ROC-AUC 0.935 against a causal Bayes ceiling of 0.768 accuracy: it beats the Bayes rate of its own data-generating process by about 19 AUC points, which is the signature of a target-derived feature. The page now measures and states this, and records that there is no validation on real donor data anywhere in the repository.- Two
EncounterTransformeroutput columns were documented with the wrong type and the wrong semantics.days_since_last_dischargewas described as an "Integer number of days"; it isfloat64, and it has to be, because a donor absent from the encounter table getsNaNand an integer dtype cannot carry that. A caller who trusted the docstring and cast the column would silently destroy the missingness, which is signal in this library.encounter_frequency_scorewas described as a "Log-scaled count of distinct encounter records"; it islog1pof the row count, so a donor with three rows on two dates scoreslog1p(3), notlog1p(2). Both are now stated correctly and locked by tests intests/test_documented_contracts.py. Found during review of the em-dash branch; docstrings only, no behaviour change. LapsePredictorandexperimental.UpliftTLearnervalidated input withcheck_array/check_X_yinstead ofvalidate_data, the convention every other estimator follows. Neither setfeature_names_in_, so a DataFrame with reordered columns was silently scored instead of raising. These were the only two estimators in the package with that gap, and both are now closed with a regression test each.CRMCleanerNaN'd every value in a currency-formatted amount column, e.g."\$1,000.00"(the default export format for Raiser's Edge NXT and Salesforce NPSP), becausepd.to_numerictreats the whole string as unparseable. It now strips currency symbols, thousands separators and parenthesised negatives before parsing, and raises rather than returning an all-NaN column when a column truly has nothing parseable in it. The string/numeric branch checkspd.api.types.is_numeric_dtyperather thandtype == object, so it also parses correctly under pandas 3.0's non-object default string dtype, not just the legacyobjectdtype.MatchingGiftFeaturizerran zerocheck_estimatorchecks:tags._skip_test = Truesilently skipped the whole battery instead of excluding it from_STANDARD_ESTIMATORSwith a documented reason, the wayRFMTransformeralready was. It has no such reason on its own (it genuinely cannot accept the generic numeric ndarrays the battery feeds), so this falsified the README/paper claim that every public estimator passescheck_estimator.FinancialForecastModelhad the same gap for no documented reason at all; it in fact passes the battery cleanly and is now in it. A newtest_every_public_estimator_is_covered_by_the_battery_or_documentedtest cross-referencesphilanthropy.models.__all__andphilanthropy.preprocessing.__all__against_STANDARD_ESTIMATORSplus a reasoned exemption registry, so this can't recur silently. README, paper.md, and the design-principles/security-review docs now state the one real exception (UpliftTLearner) instead of claiming "every estimator" flatly.- Two JOSS paper drafts were tracked at once:
paper.md/paper.bibat the repo root (current, last touched 2026-08-11) and a stale copy inpaper/(2026-08-01, different affiliation and bibliography style).draft-pdf.ymlbuilt only the stale one, so the current draft has never produced a PDF. Deletedpaper/; the workflow now points at the root files. - Saving a fitted
EncounterTransformerorGratefulPatientFeaturizerwrote the raw clinical encounter table into the model bundle. Both takeencounter_dfas a constructor parameter, sojoblib.dump/save_modelpersisted medical record numbers, attending physicians and service lines verbatim; a bundle attached to a ticket or handed to a vendor was a PHI disclosure. Both now drop the raw table on serialisation and keep only the per-donorencounter_summary_thattransformactually reads, so a round-tripped transformer still scores identically.cloneis unaffected (it goes throughget_params, not pickle), and a refit now requires the table to be supplied again rather than reusing stale clinical rows.SECURITY.mdpreviously treated pickles only as an inbound code-execution risk and never mentioned that a bundle you produce is itself donor data; it now does. FiscalYearGroupedSplitternever validatedn_splits, despite documenting aValueErrorforn_splits < 1. A non-positive value reached theunique_fy[-(n_splits):]slice, where it flips open-ended:n_splits=0yielded 3 folds on a 4-fiscal-year panel whileget_n_splits()reported 0, andn_splits=-1yielded 3 while reporting -1.cross_val_scoresizes its result array fromget_n_splits(), so the two disagreeing is a real failure. Both entry points now validate through one helper,gap_years < 0and non-integer values are rejected, and a test assertsget_n_splits() == len(list(split()))across the parameter grid.donor_lifetime_valueoverstated LTV wheneverretention_ratewas given. It converted the retention rate to an expected lifespan,L = 1 / (1 - r), and fed that mean into the concave annuity formula. By Jensen's inequalityNPV(E[L]) >= E[NPV(L)], so the result was biased high in one direction every time: +8.2% atr = 0.8, d = 0.05and +22.9% atr = 0.9, d = 0.10. A one-signed error does not average out across a portfolio, and this is a number that goes into board decks and acquisition-cost justifications. The retention branch now uses the correct closed form for a geometric lifetime,E[NPV] = m / (1 + d - r), verified against a term-by-term expectation and a two-million-draw Monte Carlo.retention_rate=1.0with a positive discount rate now returns the perpetuitym / drather thaninf; it is stillinfwhendiscount_rateis 0.retention_rate > 1now raises instead of returning a negative number. The fixed-horizon path (retention_rate=None) is unchanged and was always correct, as is thediscount_rate=0path in both modes, since an undiscounted sum is linear in the lifespan. This changes returned values: seedocs/explanation/fundraising_metrics.mdfor both formulas and why they differ.mkdocs.ymlhad nosite_url, so the generatedsitemap.xmlwas empty and all 38 documentation pages were uncrawlable, with norel=canonicalanywhere.CONTRIBUTING.mddocumented a risk-tier coverage command measuringmetrics/andmodel_selection/, while CI measuredingest/,cli.pyandutils/_persistence.py. A contributor could run the documented command, pass, and still fail CI on files it never looked at.
Removed
FiscalYearGroupedSplitter._iter_test_indicesand_iter_test_masks. Both were unreachable (the class overridessplit, socross_validatenever called either) and the comment claimingBaseCrossValidatorrequires them was false.
Fixed
FiscalYearGroupedSplitter's module doctest asserted... <= ... + 1 or True, which passes for every possible input and so proved nothing about the split. It now asserts what the class actually promises,fiscal_years[train_idx].max() < fiscal_years[test_idx].min(), and a newtest_default_splitter_no_leakage_gap_years_zerocovers the defaultgap_years=0path, which had no leakage test at all. Thanks to @fuleinist (Chris Chen) for the first external contribution (#30, closes #26).
Added
-
CiviCRM contribution bridge:
philanthropy.ingest.read_civicrm_contributionsandcivicrm_contributions_to_features(Tier 2). Turns a CiviCRM contribution export, or an APIv4Contribution.getresult, into the one-row-per-donor feature table the estimators consume. Headers normalise to the APIv4 spelling, so the human export labels (Contact ID,Total Amount,Contribution Date) and the DB columns (contact_id,total_amount,receive_date) both work.It exists because two things a bare
pd.read_csvgets wrong are expensive: CiviCRM writes payment-processor test transactions into the same table, andcontribution_statusseparatesCompletedfromPending,Failed,RefundedandChargeback. Test rows are always dropped; onlyCompletedis counted unlessstatusessays otherwise, and asking for a status filter that cannot be applied warns instead of silently summing refunds.Recency is anchored to
reference_dateor the batch's latest gift, never a moving "now", the same leakage contract as the UniSchema bridge. See docs/how-to/ingest_civicrm_contributions.md.
[0.6.0] - 2026-08-01
Breaking
pandas>=2.0is now the declared floor (was>=1.5). The ingest bridge pinsformat="ISO8601", which is pandas 2.0+; on a conforming 1.5.x installerrors="coerce"silently produced an all-NaT, zero-row feature frame. The build backend floor moves tosetuptools>=77for the PEP 639 license fields.DischargeToSolicitationWindowTransformer.transformnow raisesValueErrorwhen given a DataFrame withoutdays_since_discharge_col, instead of readingX.iloc[:, 0]. That fallback made a serialPipelinebehindFiscalYearTransformerscore every donor0.0and exit cleanly. Route the transformer with aColumnTransformer; seedocs/tutorials/building_your_first_model.mdfor the migration.philanthropy.experimental.LapsePredictoris removed. It collided by name withphilanthropy.models.LapsePredictorand took different positional arguments. Tier 3, so no deprecation runway. Usemodels.LapsePredictor.PropensityScorer.predictnow uses a strict threshold comparison (proba > threshold), flipping its default-threshold prediction from class 1 to class 0. scikit-learn requiresargmax(predict_proba) == predict, andargmaxof a tied[0.5, 0.5]row is index 0. Arbitrary either way for a constant scorer; only its ROC-AUC of 0.500 ever carried information.PropensityScorer.fitnow raisesValueErroron a multiclassy.
Added
philanthropy.model_selection,.experimentaland.visualisationare now importable fromimport philanthropyand listed in__all__; they raisedAttributeErrorbefore while the docs rendered reference pages for them.philanthropy/py.typed: the package now ships its type information.joblib>=1.2is declared; it was a direct import carried transitively.tests/test_public_api_contract.py: an executable spec for the public API: subpackage__all__completeness, reference-page coverage, thepredict_<thing>_(score|forecast)naming and shape contract, andget_feature_names_outwidth. Two named exemptions, each with a reason.tests/test_metrics_oracles.py: closed-form oracles for the money metrics: the textbook Gini definition, a term-by-term discounted annuity, and the EEOC four-fifths worked example.- Seven how-to guides: use the CLI, ingest UniSchema events, recommend ask
amounts, score matching-gift eligibility, measure campaign efficiency, audit
score fairness, estimate appeal uplift. Reference pages for
experimental,utilsandcli. A stability-tier and score-scale table indocs/reference/index.md. .zenodo.jsonand a concept-DOI placeholder inCITATION.cff.
Deprecated
All of the following still work and emit DeprecationWarning. Removed in
0.7.0.
| Deprecated | Use instead |
|---|---|
AskAmountRecommender.predict_ask_array | ask_ladder |
ShareOfWalletRegressor.predict_capacity_ratio | capacity_ratio |
MovesManagementClassifier.predict_action_priority | action_priority |
PlannedGivingIntentScorer.predict_bequest_intent_score | predict_intent_score |
The predict_ prefix is now reserved for methods that take X alone and return
one value per row. The first three returned a (n, 3) dollar matrix, required a
second argument, and returned a dict respectively.
Three constructor parameters have no effect and warn when set to a non-default
value: LapsePredictor(lapse_window_years=...),
PropensityScorer(estimator=...),
FiscalYearGroupedSplitter(fiscal_year_start=...). All removed in 0.7.0.
Fixed
philanthropy.__version__is read from installed metadata. It reported0.4.0against a0.5.0package, and every bundle written bysave_modelcarried the wrong stamp.MajorGiftClassifier.n_iter_reports the real mean boosting iterations across the calibration folds instead of a hardcoded1.GratefulPatientFeaturizer.transformemits aUserWarningbefore each all-zero fallback instead of silently returningzeros((n, 4)).philanthropy train --features " , "now exits with an error instead of fitting on a zero-column matrix.read_constituent_eventsraisesFileNotFoundErrorfor a missing path instead of surfacing an opaque OSError from the single-file branch.WealthScreeningImputerno longer emits aMean of empty sliceRuntimeWarningon an all-NaN column.CRMCleaner.fiscal_year_startis documented as validated-but-unused.- Doc corrections:
ShareOfWalletScorercapacity-tier thresholds, PHI-dropping attributed toEncounterTransformerrather thanCRMCleaner, and theGratefulPatientFeaturizeroutput columns.
Changed
- The
check_estimatorbattery is consolidated into one list intests/test_sklearn_compliance.py.MajorGiftClassifierruns atmax_iter=10, cutting suite runtime by roughly two thirds;PropensityScorer,WealthPercentileTransformer,WealthScreeningImputerKNN,ShareOfWalletScorer,CRMCleaner,EncounterRecencyTransformerand bare-default variants were added.RFMTransformermoved to an explicit contract class: its_skip_test=Truetag was running 1 check instead of 46. - Branch coverage is enabled and gated; the risk-tier subtree has its own floor.
docs/explanation/benchmarks.mdreports mean and min–max across five seeds instead of three decimals from one split.- CI: the duplicate full-suite run is gone, lint runs once instead of five
times, and there are new
floors(lowest-direct dependency resolution),package, andminimal(no-matplotlib import) jobs plus a macOS leg. publish.ymlgates on tag ↔pyproject.toml↔CHANGELOG.mdagreement, and both third-party actions are SHA-pinned.
[0.5.0] - 2026-07-24
Added
- Donor-base concentration metrics:
gift_concentration_giniandtop_donor_share(philanthropy.metrics). - Campaign-efficiency metrics:
cost_per_dollar_raisedandfundraising_roi. philanthropy.utils.save_model/load_model: self-describing model bundles that warn on a scikit-learn / PhilanthroPy version mismatch at load time; the CLI now persists and loads through them.AskAmountRecommender: capacity regressor exposing a discrete gift-array ask ladder (predict_ask_array).MatchingGiftFeaturizer: corporate matching-gift features (has_employer,match_ratio,potential_matched_amount), leakage-safe.philanthropy.experimental.UpliftTLearner: two-model uplift (treatment- effect) scorer for appeals (predict_uplift_score).MovesManagementClassifieris now covered by thecheck_estimatorbattery.- Dependabot (
github-actions+pip), a CodeQL scanning workflow, and a.pre-commit-config.yamlrunning the flake8 / mypy gates locally. philanthropy.ingestandphilanthropy.visualisationAPI reference pages.constituent_events_to_featurescarriesfirst_name/last_namethrough to the donor feature table when the UniSchema feed supplies them (guarded; null when absent).- Community health files:
.githubissue/PR templates,CODE_OF_CONDUCT.md(Contributor Covenant 2.1), andSECURITY.md. - flake8 lint gate:
.flake8(enforces pyflakesF+ syntaxE9defects), amake linttarget folded intomake ci, and a CI step. philanthropy.inspection.donor_feature_importance: model-agnostic permutation feature importance (dependency-free interpretability; works on calibrated models that lackfeature_importances_).philanthropy.metrics.disparate_impact_ratioandselection_rate_by_group: four-fifths-rule fairness diagnostics for scored cohorts.philanthropycommand-line interface (train/score/validate) over CSV.EncounterTransformer(pii_patterns=...)to override the PII column heuristic (defaults broadened);allow_negative_days=Truenow emits a complianceUserWarning.GratefulPatientFeaturizer(capacity_weights=...)to override service-line weights.- mypy type-check gate wired into
make ciand CI. - Docs: Responsible Use & Compliance, Model Validation & Benchmarks, vendor
comparison, and model-persistence guides;
.github/CODEOWNERS; a JOSSpaper/.
Security
- CLI
scoreneutralizes spreadsheet formula-injection (CWE-1236) in donor-controlled string cells before writing the output CSV. - Documented the model-bundle pickle trust boundary in
SECURITY.mdand thescore/validate--helptext. read_constituent_eventsskips symlinked files (path-traversal hardening) and reports malformed JSON with the offending file and line number.- Least-privilege
permissions: contents: readon all GitHub Actions workflows.
Fixed
RFMTransformerfreezes the recency reference date infit(reference_date_) instead of recomputing it from the transform batch, a leakage-contract violation that made a donor's recency depend on batchmates.LapsePredictor.predict_lapse_scoreno longer raisesIndexErrorwhen fit on a single-class training fold.MovesManagementClassifierrejects continuous targets and exposesn_iter_.disparate_impact_ratio/selection_rate_by_groupraise on missing (NaN/None) group labels instead of silently returningNaN.- Removed dead code (
_assign_tier/_TIER_THRESHOLDS,_resolve_cols, redundant_more_tags) and cleared 4 pre-existing type-check errors. - Cleared 31 real-defect lint violations (unused imports/variables) across the package and tests, including two dead code blocks.
- Corrected
EncounterTransformerAPI drift in the grateful-patient tutorial and the README example (invalidencounter_date_col/donor_id_colkwargs →discharge_col/merge_key; pipeline scored viapredict_proba). - README metrics table listed
retention_rate; the real export isdonor_retention_rate. - Removed a documented-but-nonexistent
fiscal_year_startparameter from theEncounterTransformerandWealthScreeningImputerdocstrings.
[0.4.0] - 2026-07-18
Added
philanthropy.ingest: the UniSchema on-ramp.constituent_events_to_features()aggregates a UniSchemaConstituentEventstream into a one-row-per-donor feature table whose columns (total_gift_amount,years_active,event_attendance_count,last_gift_date, ...) feed the estimators directly;read_constituent_events()loads UniSchema's JSON / NDJSON egress files. Leakage-safe (recency anchored to an explicitreference_dateor the batch's latest event), at-least-once-safe (deduplicates byeventId).constituent_events_to_featuresandread_constituent_eventsre-exported at the top level (from philanthropy import constituent_events_to_features).examples/quickstart.pyandexamples/unischema_to_scores.py: runnable, end-to-end scripts (train + score; UniSchemaConstituentEventstream → features → score). Smoke-tested intests/test_examples.py.- tests/test_ingest.py (aggregation, identity resolution, dedup, file/dir readers, mixed-currency warning, estimator integration)
Fixed
- Pinned
scikit-learn>=1.6; the code relies onvalidate_dataand__sklearn_tags__, both 1.6+ APIs, so an unpinned install on 1.3–1.5 imported broken. MovesManagementClassifiernow imports on Python 3.9 (addedfrom __future__ import annotations; itsstr | dict | Noneannotation was evaluated eagerly and crashed the advertised 3.9).- Removed the nonexistent
philanthropy==0.2.0pin fromenvironment.ymlthat madeconda env createfail. constituent_events_to_featureswarns on a mixed-currency batch instead of silently summing unlike amounts intototal_gift_amount.EncounterRecencyTransformerno longer raisesOverflowErrorwhen two encounter dates span more than ~292 years (adatetime64[ns]timedelta overflows int64); it falls back to day-resolution differencing.
Changed
- README leads installation with
pip install philanthropy; fixed the Tests badge and the UniSchema scoring snippet. - Sharpened the PyPI
description, addedmachine-learning/predictive-analytics/data-science/pythonkeywords, and added the UniSchema project URL (pyproject + CITATION.cff). - README roadmap corrected (docs site, PyPI, and retention-waterfall plot moved
to Completed); dropped the stale per-file test table; ingest docs/example now
point at UniSchema's real
data/egress/path. PropensityScorerdocumented as a constant P=0.5 baseline (points toDonorPropensityModel); added docstrings for the metrics helpers andpredict_action_priority;CONTRIBUTING.mdgained a Setup section.
[0.3.0] - 2026-07-17
Added
- FinancialForecastModel: hybrid LSTM-ARIMA revenue/giving forecaster
(linear ARIMA-surrogate + neural residual component) with
predict_revenue_forecast(X, horizon); leakage-safe: fill values and autoregressive coefficients frozen atfit(); passes sklearncheck_estimator - tests/test_forecast_model.py (fit/predict, forecast horizon, leakage, NaN handling, check_estimator compliance)
- PyPI packaging: complete project metadata, classifiers, keywords, and project URLs (docs / repo / changelog / issues); version bumped to 0.3.0
- MANIFEST.in so the sdist ships source only (no tests/dev artifacts)
- PyPI Trusted Publishing workflow (.github/workflows/publish.yml): OIDC, no stored token, fires on published GitHub Releases (v*..)
- CONTRIBUTING.md split out of the README
- CITATION.cff for Zenodo/DOI archival
- README "Research" section mapping the literature to concrete estimators, and an affinity-distribution visual
[0.2.0] - 2026-03-14
Added
- GitHub Actions CI workflow (Python 3.10 + 3.11 matrix)
- Coverage gate: pytest --cov-fail-under=85
- Makefile with check / test / coverage / ci targets
- Branch protection + PR-based merge workflow
- DischargeToSolicitationWindowTransformer (2-column output: in_window, window_position_score)
- PlannedGivingIntentScorer with predict_intent_score()
- LapsePredictor: production RF, predict_lapse_score(), full param set
- 1052 tests across 23 test files (up from 161 across 7)
- Coverage: 88.29%
Fixed
- SolicitationWindowTransformer.transform() now returns (n, 2) not (n, 3)
- Removed contradictory test_output_shape_is_n_by_3
- InvalidParameterError accepted alongside ValueError (sklearn 1.6+ compat)
- check_do_not_raise_errors_in_init_or_set_params: validation moved to fit()
- Hypothesis tests stabilised with @settings(suppress_health_check=...)
[0.1.0] - 2026-01-01
Added
- Initial release: DonorPropensityModel, ShareOfWalletRegressor, MajorGiftClassifier, CRMCleaner, WealthScreeningImputer, FiscalYearTransformer, EncounterTransformer, RFMTransformer
- philanthropy.metrics: donor_retention_rate, donor_acquisition_cost, donor_lifetime_value
- philanthropy.visualisation: plot_affinity_distribution
- philanthropy.utils: make_donor_dataset
- 161 tests across 7 test files