Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
Everything but `target` is computed from data through the end of T; the
output's `fiscal_year` and donor-id columns feed directly into
`FiscalYearGroupedSplitter`.
- `examples/notebooks/05_leadership_upgrade.ipynb`: a leadership annual-giving
upgrade model, `build_upgrade_snapshots` plus a synthetic activity log into
`MajorGiftClassifier`, validated with a fiscal-year walk-forward split and
compared against a naive "gave $500+ last FY" rule on top-N upgrade rate.

## [0.8.0] - 2026-09-24

Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ notebook before it breaks a reader's copy-paste):
| [`01_quickstart_propensity.ipynb`](notebooks/01_quickstart_propensity.ipynb) | The README quickstart, plus a call list, a distribution plot, and permutation importance. |
| [`02_temporal_leakage.ipynb`](notebooks/02_temporal_leakage.ipynb) | Builds the same features two ways, as-of each year versus over the whole export, and measures the inflation. This is the library's central argument. |
| [`03_grateful_patient_pipeline.ipynb`](notebooks/03_grateful_patient_pipeline.ipynb) | The academic-medical-center path: encounters, an `as_of` cutoff, service-line weighting, the solicitation window, routed through a `ColumnTransformer`. |
| [`05_leadership_upgrade.ipynb`](notebooks/05_leadership_upgrade.ipynb) | Ranks mid-level donors by odds of a leadership-gift upgrade next fiscal year, validated with a walk-forward split and compared against a naive "gave $500+ last FY" rule. |

`examples/quickstart.ipynb` is a redirect to `01_quickstart_propensity.ipynb`, kept for one release
so old links do not break.
295 changes: 295 additions & 0 deletions examples/notebooks/05_leadership_upgrade.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "66aa5842",
"metadata": {},
"source": [
"# 5. Predicting leadership-annual-giving upgrades\n",
"\n",
"[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/PhilanthroPy-Project/PhilanthroPy/blob/main/examples/notebooks/05_leadership_upgrade.ipynb)\n",
"\n",
"A mid-level annual donor giving $100-$999 a year is a different prospect than\n",
"someone giving $50: they are already engaged, and some of them are one good ask\n",
"away from a leadership-level gift. This notebook builds a model that ranks\n",
"mid-level donors by how likely they are to cross that leadership threshold\n",
"next fiscal year, using `build_upgrade_snapshots` to assemble the training\n",
"table and `FiscalYearGroupedSplitter` to validate it without letting a future\n",
"fiscal year leak into training.\n",
"\n",
"**This is a smoke test on synthetic data, not a claim that the model beats\n",
"any baseline on a real fundraising file.** The donor panel and the activity\n",
"log below are both generated, with a numeric signal built in on purpose so\n",
"the notebook has something to show; treat every number here as a\n",
"demonstration of the mechanism, not a result to quote for your own program.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "9a26077f",
"metadata": {},
"outputs": [],
"source": [
"# Colab and other fresh environments only; a local checkout already has it.\n",
"try:\n",
" import philanthropy\n",
"except ImportError:\n",
" !pip install -q philanthropy\n",
" import philanthropy\n",
"\n",
"print(\"philanthropy\", philanthropy.__version__)\n"
]
},
{
"cell_type": "markdown",
"id": "3b734156",
"metadata": {},
"source": [
"## Build the training panel\n",
"\n",
"`make_donor_panel` gives us a gift log and a donor table, but no activity log:\n",
"real shops track event attendance and volunteer shifts separately, so we build\n",
"our own small synthetic one here. Engagement (event and volunteer counts) is\n",
"drawn with a rate tied to each donor's lifetime giving, so it carries some of\n",
"the same signal as the gift history, just measured a different way and with\n",
"independent noise. That is the point: a single fiscal year's total is a noisy\n",
"read on a donor's true giving capacity, and a second, independently noisy\n",
"signal lets the model average out some of that noise instead of leaning on\n",
"one number the way the $500-last-FY rule does.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "8916c34a",
"metadata": {},
"outputs": [],
"source": [
"import numpy as np\n",
"import pandas as pd\n",
"\n",
"from philanthropy.datasets import make_donor_panel\n",
"\n",
"N_YEARS = 7\n",
"START_FY = 2018\n",
"\n",
"panel = make_donor_panel(\n",
" n_donors=3000, n_years=N_YEARS, start_fiscal_year=START_FY, random_state=0\n",
")\n",
"gifts, donors = panel[\"gifts\"], panel[\"donors\"]\n",
"\n",
"rng = np.random.default_rng(0)\n",
"\n",
"# A rough, generation-time-only proxy for each donor's underlying generosity:\n",
"# lifetime giving across the whole panel. Real activity data would obviously\n",
"# correlate with something like this; the model itself never sees it, only\n",
"# the per-fiscal-year features build_upgrade_snapshots computes from data\n",
"# through the end of each snapshot year.\n",
"lifetime_total = (\n",
" gifts.groupby(\"donor_id\")[\"gift_amount\"].sum()\n",
" .reindex(donors[\"donor_id\"], fill_value=0.0)\n",
")\n",
"engagement_percentile = lifetime_total.rank(pct=True).to_numpy()\n",
"\n",
"event_lambda = (0.3 + 2.0 * engagement_percentile) * N_YEARS\n",
"volunteer_lambda = (0.1 + 1.0 * engagement_percentile) * N_YEARS\n",
"n_events = rng.poisson(event_lambda)\n",
"n_volunteer = rng.poisson(volunteer_lambda)\n",
"\n",
"window_start = pd.Timestamp(f\"{START_FY - 1}-07-01\")\n",
"window_days = 365 * N_YEARS\n",
"\n",
"\n",
"def activity_rows(donor_ids, counts, activity_type, with_hours=False):\n",
" donor_id_rep = np.repeat(donor_ids, counts)\n",
" dates = window_start + pd.to_timedelta(\n",
" rng.integers(0, window_days, size=len(donor_id_rep)), unit=\"D\"\n",
" )\n",
" rows = {\n",
" \"contact_id\": donor_id_rep,\n",
" \"activity_date\": dates,\n",
" \"activity_type\": activity_type,\n",
" }\n",
" if with_hours:\n",
" rows[\"hours\"] = rng.integers(1, 5, size=len(donor_id_rep))\n",
" return pd.DataFrame(rows)\n",
"\n",
"\n",
"donor_ids = donors[\"donor_id\"].to_numpy()\n",
"activities = pd.concat(\n",
" [\n",
" activity_rows(donor_ids, n_events, \"event\"),\n",
" activity_rows(donor_ids, n_volunteer, \"volunteer\", with_hours=True),\n",
" ],\n",
" ignore_index=True,\n",
")\n",
"activities.head()\n"
]
},
{
"cell_type": "markdown",
"id": "e93a02aa",
"metadata": {},
"source": [
"## Build upgrade-candidate snapshots\n",
"\n",
"`build_upgrade_snapshots` turns the gift log into one row per (donor, fiscal\n",
"year T) for every donor whose FY T giving falls in the $100-$999 band: the\n",
"mid-level donors a leadership-gift officer would actually work. `target` is 1\n",
"if that donor's FY T+1 total reaches $1,000; everything else is computed\n",
"from data through the end of FY T, so nothing about the outcome leaks into the\n",
"features. Passing `activities` and `donors` joins in the engagement features\n",
"and the (partly missing) wealth estimate alongside the gift-derived ones.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "17373ada",
"metadata": {},
"outputs": [],
"source": [
"from philanthropy.ingest import build_upgrade_snapshots\n",
"\n",
"snapshots = build_upgrade_snapshots(\n",
" gifts,\n",
" fiscal_years=range(START_FY + 1, START_FY + N_YEARS - 1),\n",
" threshold=1000,\n",
" band=(100, 999),\n",
" activities=activities,\n",
" donors=donors.set_index(\"donor_id\"),\n",
")\n",
"\n",
"print(snapshots.shape, \"rows\")\n",
"print(\"upgrade rate:\", round(snapshots[\"target\"].mean(), 3))\n",
"snapshots.head()\n"
]
},
{
"cell_type": "markdown",
"id": "d8460265",
"metadata": {},
"source": [
"## Fit with a fiscal-year walk-forward split\n",
"\n",
"\"Did this donor upgrade in FY T+1?\" is a time-varying label, not a static\n",
"per-donor one: a donor who shows up as a candidate in several fiscal years can\n",
"legitimately sit in both an earlier training fold and a later test fold,\n",
"because each row's target is read from a different, later year.\n",
"`FiscalYearGroupedSplitter` with `drop_repeat_donors=False` is the walk-forward\n",
"mode built for exactly that case, training on every fiscal year strictly\n",
"before the test year and never the reverse.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "4b04a43c",
"metadata": {},
"outputs": [],
"source": [
"from philanthropy.model_selection import FiscalYearGroupedSplitter\n",
"from philanthropy.models import MajorGiftClassifier\n",
"\n",
"feature_cols = [\n",
" c\n",
" for c in snapshots.select_dtypes(include=\"number\").columns\n",
" if c not in (\"target\", \"fiscal_year\")\n",
"]\n",
"X = snapshots[feature_cols]\n",
"y = snapshots[\"target\"].to_numpy()\n",
"fiscal_year = snapshots[\"fiscal_year\"].to_numpy()\n",
"\n",
"splitter = FiscalYearGroupedSplitter(n_splits=3, drop_repeat_donors=False)\n",
"\n",
"oof_scores, oof_targets, oof_fy_total = [], [], []\n",
"for train_idx, test_idx in splitter.split(X, groups=fiscal_year):\n",
" model = MajorGiftClassifier(random_state=0).fit(X.iloc[train_idx], y[train_idx])\n",
" oof_scores.append(model.predict_affinity_score(X.iloc[test_idx]))\n",
" oof_targets.append(y[test_idx])\n",
" oof_fy_total.append(X.iloc[test_idx][\"fy_total\"].to_numpy())\n",
"\n",
"scores = np.concatenate(oof_scores)\n",
"targets = np.concatenate(oof_targets)\n",
"fy_total = np.concatenate(oof_fy_total)\n",
"print(f\"{len(targets)} out-of-fold predictions across {splitter.get_n_splits(X, groups=fiscal_year)} walk-forward folds\")\n"
]
},
{
"cell_type": "markdown",
"id": "5e8af691",
"metadata": {},
"source": [
"## Model vs. the $500-last-FY rule\n",
"\n",
"The naive rule a gift officer might use without a model: flag anyone who\n",
"already gave $500 or more this fiscal year (`fy_total >= 500`) as an upgrade\n",
"prospect. We compare that against the model's own top 20% by affinity score,\n",
"on the same out-of-fold predictions, so neither side is scored on data it was\n",
"trained on.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "541991b4",
"metadata": {},
"outputs": [],
"source": [
"overall_rate = targets.mean()\n",
"\n",
"top_n = max(1, int(round(0.20 * len(targets))))\n",
"model_top_idx = np.argsort(-scores)[:top_n]\n",
"model_rate = targets[model_top_idx].mean()\n",
"\n",
"rule_flag = fy_total >= 500\n",
"rule_rate = targets[rule_flag].mean()\n",
"\n",
"rule_top_idx = np.argsort(-fy_total)[:top_n]\n",
"rule_top_rate = targets[rule_top_idx].mean()\n",
"\n",
"pd.DataFrame(\n",
" {\n",
" \"group\": [\n",
" \"overall (all candidates)\",\n",
" f\"model, top {top_n} ({top_n / len(targets):.0%}) by affinity score\",\n",
" f\"rule, everyone flagged (fy_total >= $500, n={int(rule_flag.sum())})\",\n",
" f\"rule, its own top {top_n} by fy_total\",\n",
" ],\n",
" \"upgrade rate\": [overall_rate, model_rate, rule_rate, rule_top_rate],\n",
" }\n",
").round(3)\n"
]
},
{
"cell_type": "markdown",
"id": "af4e281d",
"metadata": {},
"source": [
"## Smoke test, not a benchmark\n",
"\n",
"The model's top 20% upgrades at a noticeably higher rate than either version\n",
"of the $500 rule, and both beat the overall base rate, because the generator\n",
"above was built to give the model more than one noisy signal to combine.\n",
"These are synthetic numbers on a generator whose engagement-giving correlation\n",
"was chosen; they say nothing about how this model would do on a real donor\n",
"file, and should not be quoted as evidence it beats any particular baseline\n",
"in production. Re-validate on your own fiscal-year history before trusting a\n",
"ranking like this one for real leadership-gift outreach.\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
Loading