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))

Check the ranking on a held-out sample

Uplift cannot be scored with ROC AUC, because no donor is observed both solicited and not solicited. The Qini curve does it instead: rank donors by uplift score, and at each cut-off count the extra responders the appeal produced among the donors targeted so far (treated responses minus control responses rescaled to the treated count). qini_auc_score is the area between that curve and the straight line random targeting would give. Positive beats random, about zero is no better than random.

Score a sample the model did not train on, and keep the randomised treatment for it.

from sklearn.model_selection import train_test_split

from philanthropy.metrics import qini_auc_score, qini_curve

X_tr, X_te, y_tr, y_te, t_tr, t_te = train_test_split(
    X.to_numpy(), y, treatment, test_size=0.5, random_state=0
)
holdout_model = UpliftTLearner(n_estimators=100, random_state=0).fit(X_tr, y_tr, t_tr)
holdout_uplift = holdout_model.predict_uplift_score(X_te)

fraction, gains = qini_curve(y_te, holdout_uplift, t_te)
model_qini = qini_auc_score(y_te, holdout_uplift, t_te)
random_qini = qini_auc_score(y_te, rng.random(len(y_te)), t_te)
print(f"Qini area  model: {model_qini:.2f}  random order: {random_qini:.2f}")

assert fraction[0] == 0.0 and fraction[-1] == 1.0
assert model_qini > random_qini

The score is not normalised: its unit is extra responders times fraction targeted, so it grows with the size of the holdout. Compare models on the same holdout, not across datasets. y_true can be gift amounts instead of 0/1, which turns the curve into extra dollars.

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.