Skip to content

Estimate donor lifetime value from a gift export

Most of PhilanthroPy's models need a label: who upgraded, who lapsed, who made a major gift. Two models need nothing but the gift history every CRM can export (donor, date, amount):

  • BetaGeoModel (the BG/NBD "buy till you die" model) estimates how likely each donor is to still be giving, and how many gifts they will make over the next year or any other horizon.
  • GammaGammaModel estimates each donor's typical future gift, pulling donors with few gifts toward the file's average.

Multiply the two and you have each donor's expected giving over the horizon, per donor rather than one file-wide average.

Tier 2: Beta

Both models reproduce the published CDNOW parameters, but they have not yet been checked against a real gift file's holdout year. Run the holdout check below on your own data before you rely on them.

Shape the export into a gift table

You need one row per gift with three columns: donor_id, gift_date and gift_amount. A CRM export has its own column names, so rename them. Filter out what does not count as a gift first (failed or refunded payments, pledges, soft credits): the model treats every remaining row as money received.

The example simulates a CiviCRM-style contribution export for 2,000 donors, 2019 to 2024.

import numpy as np
import pandas as pd

rng = np.random.default_rng(0)
start = pd.Timestamp("2019-01-01")

rows = []
for donor in range(2000):
    rate = rng.gamma(1.5, 1.0)        # gifts per year while still active
    p_drop = rng.beta(1.0, 4.0)       # chance of dropping out after each gift
    mean_gift = rng.gamma(3.0, 40.0)  # this donor's typical gift
    t = rng.uniform(0, 2)             # first gift in 2019 or 2020
    while t < 6:                      # through the end of 2024
        rows.append({
            "Contact ID": f"D{donor:04d}",
            "Contribution Date": start + pd.Timedelta(days=365.25 * t),
            "Total Amount": round(rng.gamma(4.0, mean_gift / 4.0), 2),
        })
        if rng.random() < p_drop:
            break
        t += rng.exponential(1 / rate)
export = pd.DataFrame(rows)

gifts = export.rename(columns={
    "Contact ID": "donor_id",
    "Contribution Date": "gift_date",
    "Total Amount": "gift_amount",
})

Summarise each donor

repeat_giving_summary turns the gift table into the four numbers both models read. Hold out the last year so you can check the forecast against what actually happened: as_of="2023-12-31" ignores every gift after that date.

from philanthropy.models import repeat_giving_summary

summary = repeat_giving_summary(gifts, as_of="2023-12-31")
print(summary.head())
Column Meaning
frequency Repeat gifts: distinct gift days minus one. Two gifts on the same day count once.
recency Years from the donor's first gift to their last.
T Years from the first gift to as_of.
monetary_value Mean of the repeat gifts. The first gift is left out, as the model expects; 0 for a one-time donor.

Times are in years by default, so a horizon of 1 later means "the next year". Pass time_unit_days=7 to work in weeks.

Who is still giving, and how often

from philanthropy.models import BetaGeoModel

rfm = summary[["frequency", "recency", "T"]]
bg = BetaGeoModel().fit(rfm)

summary["p_active"] = bg.predict_alive_score(rfm)
summary["gifts_next_year"] = bg.predict_gifts_forecast(rfm, horizon=1.0)
print(summary[["frequency", "recency", "T", "p_active", "gifts_next_year"]].head())

assert summary["p_active"].between(0, 1).all()

Read p_active with care:

  • A donor who gave often and then went quiet scores low, because the model expected another gift by now.
  • A donor whose only gift so far was the first one always scores 1. In this model a donor can only drop out right after a repeat gift, so it has no evidence either way. Use gifts_next_year, which stays low for them, to rank.

How much each gift will be

GammaGammaModel assumes a donor's gift size does not depend on how often they give. Check that on your repeat donors before you use it. A correlation near zero is fine; a strong one means the product below double counts.

from philanthropy.models import GammaGammaModel

repeat = summary["frequency"] > 0
corr = summary.loc[repeat, ["frequency", "monetary_value"]].corr().iloc[0, 1]
print(f"frequency vs gift size correlation: {corr:.2f}")
assert abs(corr) < 0.3

size_inputs = summary[["frequency", "monetary_value"]]
gg = GammaGammaModel().fit(size_inputs)
summary["gift_size"] = gg.predict_gift_size_forecast(size_inputs)

A donor with many repeat gifts is predicted close to their own average gift. A donor with one or two is pulled toward the file's average, and a one-time donor gets the file's average.

Expected giving next year

summary["value_next_year"] = summary["gifts_next_year"] * summary["gift_size"]
print(summary.sort_values("value_next_year", ascending=False)
      [["frequency", "p_active", "gifts_next_year", "gift_size", "value_next_year"]]
      .head().round(2))

For a multi-year value, forecast over a longer horizon and discount it the way your finance office does. philanthropy.metrics.donor_lifetime_value does the discounting for a single average donor.

Check the forecast on the year you held out

Count each donor's actual gift days in 2024 and compare them with the forecast made from data up to the end of 2023.

actual = (
    gifts[(gifts["gift_date"] > "2023-12-31") & (gifts["gift_date"] <= "2024-12-31")]
    .assign(day=lambda d: d["gift_date"].dt.normalize())
    .groupby("donor_id")["day"].nunique()
    .reindex(summary.index, fill_value=0)
)

predicted_total = summary["gifts_next_year"].sum()
print(f"gifts in 2024  predicted: {predicted_total:.0f}  actual: {actual.sum()}")
assert abs(predicted_total - actual.sum()) / actual.sum() < 0.15

check = pd.DataFrame({
    "predicted": summary["gifts_next_year"],
    "actual": actual,
    "repeat_gifts_so_far": summary["frequency"].clip(upper=6),
})
print(check.groupby("repeat_gifts_so_far").mean().round(2))

Two things to look at:

  • The total. It should be close. A large miss usually means the export still contains rows that are not gifts, or the giving pattern changed (a new monthly giving programme, a campaign year).
  • The table by past repeat gifts. Predicted and actual should rise together. If the model is right on average but wrong for one group, plan around that group.

On real files, expect the totals to be less tight than on this simulated one. The model knows nothing about appeals, campaigns or seasons, only each donor's past rhythm.