Fit models to your CRM columns
Every CRM export looks a little different: columns come in a different order, regions and appeal codes are categories rather than numbers, some records count for more than others, and your team knows things the data cannot teach a model ("more years of giving never makes a donor more likely to lapse"). Four options on the gradient-boosting models handle these:
| Option | What it does | Models |
|---|---|---|
| Column names | Point at a column by name, not position | AskAmountRecommender (last_gift_idx, avg_gift_idx), and dict keys in monotonic_cst |
categorical_features |
Split on category codes directly, no one-hot step | AskAmountRecommender, ShareOfWalletRegressor, LapsePredictor, MovesManagementClassifier, PlannedGivingIntentScorer |
sample_weight in fit |
Count some donors more than others | the same five |
monotonic_cst |
Force the score to only rise (or only fall) with a feature | AskAmountRecommender, ShareOfWalletRegressor, LapsePredictor, PlannedGivingIntentScorer, MajorGiftClassifier |
LapsePredictor uses these only with backend="hist_gradient_boosting"; its default random forest has no categorical splits or constraints.
All four default to None, which keeps the behaviour unchanged.
A donor table with a category column
import numpy as np
import pandas as pd
rng = np.random.default_rng(0)
n = 1500
X = pd.DataFrame({
"last_gift": rng.lognormal(5, 0.8, n).round(2),
"avg_gift": rng.lognormal(5, 0.6, n).round(2),
"years_giving": rng.integers(1, 25, n).astype(float),
"region": rng.choice(["Midwest", "Northeast", "South", "West"], n),
})
next_gift = np.maximum(X["last_gift"], X["avg_gift"]) * rng.lognormal(0.1, 0.3, n)
lapsed = (rng.random(n) < 1 / (1 + np.exp(0.15 * X["years_giving"] - 1))).astype(int)
Category columns: encode as codes, then name them
The models need numbers, so turn each category into an integer code. Pass the column to categorical_features and the model splits on groups of codes ("Midwest or South") instead of treating the codes as an ordered scale. Codes must be non-negative integers below 255, which is plenty for regions, constituencies or appeal codes.
Keep the code mapping you fit with and reuse it when you score new donors, so "South" is the same code both times.
regions = pd.CategoricalDtype(["Midwest", "Northeast", "South", "West"])
X["region"] = X["region"].astype(regions).cat.codes.astype(float)
Column names instead of positions
AskAmountRecommender in relative mode needs to know which columns hold the last and average gift. Name them. The model looks the names up in the DataFrame you fit on, so a CRM export that reorders its columns next quarter still points at the right data.
from philanthropy.models import AskAmountRecommender
ask = AskAmountRecommender(
target_mode="relative",
last_gift_idx="last_gift",
avg_gift_idx="avg_gift",
categorical_features=["region"],
random_state=0,
).fit(X, next_gift)
print(ask.last_gift_idx_, ask.avg_gift_idx_) # 0 1
reordered = X[["region", "years_giving", "avg_gift", "last_gift"]]
ask_reordered = AskAmountRecommender(
target_mode="relative",
last_gift_idx="last_gift",
avg_gift_idx="avg_gift",
categorical_features=["region"],
random_state=0,
).fit(reordered, next_gift)
print(ask_reordered.last_gift_idx_, ask_reordered.avg_gift_idx_) # 3 2
assert (ask.last_gift_idx_, ask_reordered.last_gift_idx_) == (0, 3)
A name needs a DataFrame: with a NumPy array there are no names to look up, and fit raises. Score with the same column order you fit with; sklearn checks the names and refuses a mismatch rather than reading the wrong column.
Weights: count some donors more
Pass sample_weight to fit when rows should not count equally: you oversampled major-gift prospects and need to undo it, you want recent years to count more, or the file is a weighted survey sample.
from philanthropy.models import LapsePredictor
weights = np.where(X["years_giving"] < 5, 2.0, 1.0) # newer donors count double
lapse = LapsePredictor(
backend="hist_gradient_boosting",
categorical_features=["region"],
random_state=0,
).fit(X, lapsed, sample_weight=weights)
A weight of 0 drops a donor from training without removing the row.
PlannedGivingIntentScorer also takes class_weight="balanced" now, for when bequest intent is too rare for the model to learn from unweighted. Like MajorGiftClassifier, it turns the scores into a ranking rather than a probability.
Constraints: build in what your team already knows
A model trained on a few thousand donors can learn noise: a lapse score that dips at 11 years of giving and rises again at 12. Gift officers notice, and stop trusting the list. monotonic_cst rules that out. 1 means the score never falls as the feature rises, -1 that it never rises, 0 (or leaving the feature out) no constraint. Key the dict by column name.
constrained = LapsePredictor(
backend="hist_gradient_boosting",
categorical_features=["region"],
monotonic_cst={"years_giving": -1},
random_state=0,
).fit(X, lapsed)
# One donor, every value of years_giving from 1 to 25.
grid = pd.concat([X.iloc[[0]]] * 25, ignore_index=True)
grid["years_giving"] = np.arange(1, 26, dtype=float)
scores = constrained.predict_lapse_score(grid)
print(scores.round(1))
assert (np.diff(scores) <= 1e-9).all() # never rises with tenure
Constrain only what you are sure of. A constraint the data contradicts costs accuracy, so compare the holdout score with and without it.
AskAmountRecommender enforces constraints only with loss="squared_error". With its default "absolute_error" loss the backend recomputes each leaf after splitting, which can break the constraint, so fit raises instead of silently ignoring it.
import pytest
with pytest.raises(ValueError, match="squared_error"):
AskAmountRecommender(monotonic_cst={"avg_gift": 1}).fit(X, next_gift)
ask_constrained = AskAmountRecommender(
loss="squared_error", monotonic_cst={"avg_gift": 1}, random_state=0
).fit(X, next_gift)
MovesManagementClassifier has no monotonic_cst: it predicts several stages at once, and sklearn does not support constraints for more than two classes.