diff --git a/CHANGELOG.md b/CHANGELOG.md index 4916455..d6a898a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/examples/README.md b/examples/README.md index e2fb288..bfe05f1 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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. diff --git a/examples/notebooks/05_leadership_upgrade.ipynb b/examples/notebooks/05_leadership_upgrade.ipynb new file mode 100644 index 0000000..74ab803 --- /dev/null +++ b/examples/notebooks/05_leadership_upgrade.ipynb @@ -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 +}