Skip to content

Score matching-gift eligibility

Corporate matching gifts are the cheapest revenue in fundraising: the donor has already given, and the employer doubles it for the cost of a form. MatchingGiftFeaturizer turns an employer name and a gift amount into three model-ready features, so "does this donor's employer match?" becomes a column rather than a manual lookup.

Build the features

The featurizer takes a pandas DataFrame; it raises TypeError on an ndarray, because it needs the two columns by name. Employer names are matched case-insensitively after stripping.

import numpy as np
import pandas as pd

from philanthropy.preprocessing import MatchingGiftFeaturizer

donors = pd.DataFrame({
    "employer": ["Boeing", "  microsoft ", "Acme Diner", "", None, "BOEING"],
    "gift_amount": [500.0, 1000.0, 250.0, 100.0, 750.0, "not a number"],
})

# Your organisation's match registry. Keys are normalised for you.
match_ratios = {"Boeing": 1.0, "Microsoft": 2.0, "Delta Air Lines": 0.5}

featurizer = MatchingGiftFeaturizer(match_ratios=match_ratios)
out = featurizer.fit_transform(donors)

print(pd.DataFrame(out, columns=featurizer.get_feature_names_out()))

Three columns, always in this order:

Column Meaning
has_employer 1.0 when the employer cell is a non-empty string, else 0.0.
match_ratio The registry lookup for the normalised employer; 0.0 when unknown.
potential_matched_amount gift_amount (non-numeric or missing → 0) × match_ratio.
names = list(featurizer.get_feature_names_out())
frame = pd.DataFrame(out, columns=names)

# "  microsoft " matches "Microsoft"; case and whitespace are normalised.
assert frame.loc[1, "match_ratio"] == 2.0
assert frame.loc[1, "potential_matched_amount"] == 2000.0

# An unknown employer is present but unmatched: has_employer 1, ratio 0.
assert frame.loc[2, "has_employer"] == 1.0
assert frame.loc[2, "match_ratio"] == 0.0

# Empty string and None both read as "no employer on file".
assert frame.loc[3, "has_employer"] == 0.0
assert frame.loc[4, "has_employer"] == 0.0

# A non-numeric gift amount is coerced to 0, not propagated as NaN.
assert frame.loc[5, "match_ratio"] == 1.0
assert frame.loc[5, "potential_matched_amount"] == 0.0

has_employer is a separate column from match_ratio on purpose. "No employer recorded" and "employer recorded but does not match" are different states, and the first is a data-quality signal your CRM team can act on.

The registry is frozen at fit time

match_ratios_ is a normalised snapshot taken in fit. Mutating the dict you passed in afterwards does not change transform output, the same leakage-safety contract every other transformer in the library follows.

match_ratios["Acme Diner"] = 3.0            # caller edits their own dict
after = featurizer.transform(donors)
np.testing.assert_array_equal(after, out)   # frozen: Acme is still unmatched

refit = MatchingGiftFeaturizer(match_ratios=match_ratios).fit_transform(donors)
assert refit[2, 1] == 3.0                   # a refit picks the new ratio up

Route it in a pipeline

Like the other named-column transformers, give it its own ColumnTransformer branch rather than chaining it in series.

from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline

from philanthropy.models import DonorPropensityModel

rng = np.random.default_rng(0)
n = 300
prospects = pd.DataFrame({
    "employer": rng.choice(["Boeing", "Microsoft", "Acme Diner", ""], size=n),
    "gift_amount": rng.lognormal(6, 1.0, n),
    "years_active": rng.integers(1, 30, n).astype(float),
})
labels = (
    (prospects["employer"].isin(["Boeing", "Microsoft"]))
    & (prospects["gift_amount"] > 400)
).astype(int).to_numpy()

pipe = Pipeline([
    ("features", ColumnTransformer([
        ("match", MatchingGiftFeaturizer(match_ratios={"Boeing": 1.0, "Microsoft": 2.0}),
         ["employer", "gift_amount"]),
        ("passthrough", "passthrough", ["years_active"]),
    ])),
    ("model", DonorPropensityModel(n_estimators=50, random_state=0)),
])
pipe.fit(prospects, labels)

scores = pipe.predict_proba(prospects)[:, 1]
print(scores[:5].round(3))
assert len(set(scores.round(6))) > 1

Sizing the opportunity

potential_matched_amount is a per-donor dollar figure, so summing it gives the unrealised match revenue sitting in your file, usually the number that justifies the campaign.

matched = MatchingGiftFeaturizer(
    match_ratios={"Boeing": 1.0, "Microsoft": 2.0}
).fit_transform(prospects[["employer", "gift_amount"]])

unrealised = matched[:, 2].sum()
eligible = int(matched[:, 1].sum() > 0) and int((matched[:, 1] > 0).sum())
print(f"{eligible} donors at match-eligible employers, ${unrealised:,.0f} unrealised")