Skip to content

Measure campaign efficiency

Four numbers answer "was this campaign worth running?": cost per dollar raised, net ROI, donor acquisition cost, and how concentrated the revenue was. philanthropy.metrics computes all four from plain totals; no estimator required.

Pass arguments by keyword

The efficiency functions do not share an argument order: cost_per_dollar_raised takes expense first, fundraising_roi takes raised first. Swapping them is silently accepted and returns a plausible wrong number. These three are keyword-only as of 0.7.0, so a positional call is a TypeError rather than a plausible wrong number.

from philanthropy.metrics import (
    cost_per_dollar_raised,
    donor_acquisition_cost,
    fundraising_roi,
)

total_raised = 1_000_000.0
total_fundraising_expense = 250_000.0
new_donors_acquired = 500

cpdr = cost_per_dollar_raised(
    total_fundraising_expense=total_fundraising_expense,
    total_raised=total_raised,
)
roi = fundraising_roi(
    total_raised=total_raised,
    total_fundraising_expense=total_fundraising_expense,
)
cac = donor_acquisition_cost(
    total_fundraising_expense=total_fundraising_expense,
    new_donors_acquired=new_donors_acquired,
)

print(f"cost per dollar raised: ${cpdr:.2f}")
print(f"net ROI:                {roi:.1f}x")
print(f"donor acquisition cost: ${cac:,.0f}")

assert cpdr == 0.25
assert roi == 3.0            # NET return: (raised - expense) / expense
assert cac == 500.0

fundraising_roi is net, not gross: (raised − expense) / expense. A campaign that exactly breaks even scores 0.0, not 1.0. It is therefore one less than the reciprocal of cost per dollar raised.

assert fundraising_roi(total_raised=100_000.0, total_fundraising_expense=100_000.0) == 0.0
assert roi == 1.0 / cpdr - 1.0

Both functions return np.inf rather than raising when their denominator is zero, so a campaign with revenue and no recorded spend is safe to sort and plot.

Retention

donor_retention_rate is the share of last period's donors who gave again. It takes two donor-id collections, not counts.

from philanthropy.metrics import donor_retention_rate

prior_year = [101, 102, 103, 104, 105]
this_year = [103, 104, 105, 106, 107]

print(f"retention: {donor_retention_rate(this_year, prior_year):.0%}")
assert donor_retention_rate(this_year, prior_year) == 0.6

How concentrated was the revenue?

A campaign that raised its target from three donors is a different risk profile from one that raised it from three thousand. gift_concentration_gini and top_donor_share quantify that.

import numpy as np

from philanthropy.metrics import gift_concentration_gini, top_donor_share

rng = np.random.default_rng(0)
broad = rng.lognormal(6, 0.4, 1000)                       # many similar gifts
concentrated = np.concatenate([rng.lognormal(5, 0.3, 990), rng.lognormal(13, 0.5, 10)])

for label, gifts in [("broad", broad), ("concentrated", concentrated)]:
    print(
        f"{label:13s} gini={gift_concentration_gini(gifts):.3f} "
        f"top-1%={top_donor_share(gifts, top_fraction=0.01):.1%}"
    )

assert gift_concentration_gini(concentrated) > gift_concentration_gini(broad)

The Gini coefficient is 0.0 under perfect equality and approaches 1.0 as one donor holds everything; for n donors where exactly one gives, it is exactly (n-1)/n.

assert gift_concentration_gini([1, 1, 1, 1]) == 0.0
assert gift_concentration_gini([0, 0, 0, 4]) == 0.75
assert top_donor_share([500.0] + [500.0 / 9.0] * 9, top_fraction=0.1) == 0.5

Long-run value of an acquired donor

Acquisition cost only means something against what the donor is worth. donor_lifetime_value has two modes: the net present value of a discounted annuity over a fixed horizon, or the expected net present value over a geometric lifetime implied by a retention rate. They are different calculations, not the same one with a substituted lifespan.

from philanthropy.metrics import donor_lifetime_value

fixed = donor_lifetime_value(250.0, 10, discount_rate=0.05)
from_retention = donor_lifetime_value(250.0, 999, discount_rate=0.05, retention_rate=0.8)

print(f"LTV over 10 years:        ${fixed:,.0f}")
print(f"LTV at 80% retention:     ${from_retention:,.0f}")

# 80% annual retention does imply a 1 / (1 - 0.8) = 5-year expected lifespan,
# but the expected NPV is NOT the 5-year annuity. The annuity is concave in the
# lifespan, so by Jensen's inequality plugging in the mean overstates value:
# E[NPV(L)] < NPV(E[L]). The retention mode uses the closed form G / (1 + d - r).
assert from_retention == 250.0 / (1 + 0.05 - 0.8)
assert from_retention < donor_lifetime_value(250.0, 5, discount_rate=0.05)
assert fixed > cac  # the acquisition pays for itself

A real registry, and what it cannot tell you

load_ciob_fundraising ships a real open-government dataset: every not-for-profit a New York City agency reported soliciting for, under the Conflicts of Interest Board's disclosure mandate.

from philanthropy.datasets import load_ciob_fundraising

ciob = load_ciob_fundraising()
print(ciob.shape, sorted(ciob["year"].unique()))
print(ciob["name_of_not_for_profit"].value_counts().head(5))

It is an affiliation registry, one row per (year, agency, nonprofit) link, with no gift amounts, donor records, or engagement labels. It supports honest questions about who solicits for whom:

breadth = ciob.groupby("agency")["name_of_not_for_profit"].nunique().sort_values()
print(breadth.tail(5))
assert set(ciob.columns) == {"year", "agency", "name_of_not_for_profit"}

It does not support the efficiency metrics above or the RFM/propensity modelling elsewhere in the library; there are no dollars in it. Use generate_synthetic_donor_data for those, or your own CRM export.