Skip to content

Estimate appeal uplift

A propensity model ranks who is likely to give. That is not the same as who gives because you asked. Some donors would have given anyway; soliciting them costs money and goodwill for nothing. A few are "sleeping dogs": the appeal actively suppresses their giving.

UpliftTLearner estimates the causal lift of the appeal itself: P(give | solicited) − P(give | not solicited).

Tier 3: Experimental

UpliftTLearner lives in philanthropy.experimental. Its fit(X, y, treatment) signature breaks the sklearn fit(X, y) contract, so it is not check_estimator compliant, it cannot go into a Pipeline, and its API may change without a deprecation cycle. Do not build production infrastructure on it.

You need a randomised holdout

Uplift modelling is causal inference, not curve fitting. It requires a treatment column recording who actually received the appeal, and that assignment must have been random. If your team solicited whoever looked most promising, the treated and control arms differ systematically and the "uplift" you measure is selection bias.

Hold out a random control group in the appeal itself. There is no way to recover this after the fact.

import numpy as np
import pandas as pd

from philanthropy.experimental import UpliftTLearner

rng = np.random.default_rng(0)
n = 1200

X = pd.DataFrame({
    "years_active": rng.integers(1, 30, n).astype(float),
    "total_gift_amount": rng.lognormal(6, 1.0, n),
    "event_attendance_count": rng.integers(0, 12, n).astype(float),
})

# Randomised: a coin flip decides who received the appeal.
treatment = rng.integers(0, 2, n)

# Simulated outcome. Loyal donors give regardless (low uplift); newer donors
# respond to being asked (high uplift).
baseline = 0.15 + 0.02 * np.minimum(X["years_active"], 20)
lift = np.where(X["years_active"] < 5, 0.25, 0.02)
p_give = baseline + treatment * lift
y = (rng.random(n) < p_give).astype(int)

model = UpliftTLearner(n_estimators=100, random_state=0)
model.fit(X.to_numpy(), y, treatment)

Both arms must be non-empty and treatment must be binary {0, 1}; anything else raises.

import pytest

with pytest.raises(ValueError, match="Both arms must be present"):
    UpliftTLearner(n_estimators=10, random_state=0).fit(
        X.to_numpy(), y, np.ones(n, dtype=int)
    )

with pytest.raises(ValueError, match="binary"):
    UpliftTLearner(n_estimators=10, random_state=0).fit(
        X.to_numpy(), y, rng.integers(0, 3, n)
    )

Read the score on its own scale

predict_uplift_score returns a value in [−1, 1], not the 0–100 scale the propensity models use. Zero means the appeal makes no difference.

uplift = model.predict_uplift_score(X.to_numpy())
print(f"range: [{uplift.min():.3f}, {uplift.max():.3f}]  mean: {uplift.mean():.3f}")

assert uplift.shape == (n,)
assert ((uplift >= -1.0) & (uplift <= 1.0)).all()

Three bands matter operationally:

Score Segment Action
Clearly positive Persuadables Solicit. This is where the appeal budget belongs.
Near zero Sure things / lost causes Skip. They give anyway, or never.
Negative Sleeping dogs Suppress. Asking reduces their giving.
segments = pd.cut(
    uplift, bins=[-1.01, -0.02, 0.02, 1.01],
    labels=["sleeping dogs", "no effect", "persuadable"],
)
print(pd.Series(segments).value_counts())

# The simulated lift was concentrated in newer donors; recover that.
by_tenure = pd.DataFrame({"uplift": uplift, "new": X["years_active"] < 5})
print(by_tenure.groupby("new")["uplift"].mean().round(3))
assert by_tenure.loc[by_tenure["new"], "uplift"].mean() > \
       by_tenure.loc[~by_tenure["new"], "uplift"].mean()

predict is a convenience wrapper, (predict_uplift_score(X) > 0).astype(int), marking donors worth soliciting.

solicit = model.predict(X.to_numpy())
print(f"{solicit.sum()} of {n} donors worth soliciting")
np.testing.assert_array_equal(solicit, (uplift > 0).astype(int))

Uplift ranking is not propensity ranking

The point of the exercise: the two orderings disagree, and acting on propensity alone wastes the appeal on people who were going to give regardless.

from philanthropy.models import DonorPropensityModel

propensity = DonorPropensityModel(n_estimators=50, random_state=0).fit(X.to_numpy(), y)
affinity = propensity.predict_affinity_score(X.to_numpy())

top_by_uplift = set(np.argsort(uplift)[-200:])
top_by_affinity = set(np.argsort(affinity)[-200:])
overlap = len(top_by_uplift & top_by_affinity)
print(f"top-200 overlap: {overlap} of 200")

assert overlap < 200, "identical rankings would mean uplift adds nothing"

Budget against the uplift ranking; use the propensity score for sizing the ask, not for deciding whom to ask.