Use the CLI
Installing the package puts a philanthropy executable on your PATH. It is a CSV-in / CSV-out interface for analysts who are not primarily Python engineers: turn a raw CRM gift export into a feature table, train a model from it, score a prospect list, report holdout metrics, and check whether a model beats the simple rule on your own file, no Python file to write.
Five subcommands: features, train, score, validate, check.
Build the feature table from a CRM gift export
train wants one row per donor with columns like total_gift_amount and years_active. A CRM gift export is one row per gift, so features does the roll-up.
--source accepts raisers_edge (Blackbaud Raiser's Edge and RE NXT), npsp (Salesforce Nonprofit Success Pack), or civicrm. --data takes a single CSV or a directory of them, walked recursively, which is the shape of a folder of monthly exports. Omit --out and the CSV goes to stdout.
The output has one row per donor and these columns:
contact_id, constituent_email, first_name, last_name, total_gift_amount, gift_count, largest_gift_amount, first_gift_date, last_gift_date, years_active, recency_days, distinct_financial_types
Header spelling is normalised for you, so a desktop Export (Constituent ID, Gift Date, Gift Amount, Gift Type) and a SKY API pull (constituent_id, date, amount, type) both work.
A pledge is not a payment, and features knows the difference
In Raiser's Edge a pledge and the money paid against it are separate gift records, and a recurring gift row is a template rather than a sum ever received. Adding up the amount column double-counts every committed dollar. features drops the commitment rows (Pledge, Matching Gift Pledge, Recurring Gift) and the ledger corrections, and keeps the payments (Pay-Cash, PledgePayment, RecurringGiftPayment, ...). Export the Gift Type field or it cannot do this, and it will warn you. The excluded set is the exclude_gift_types parameter of philanthropy.ingest.raisers_edge_gifts_to_features if your site spells its types differently. NPSP writes the same split through Opportunity stage instead of a separate record: a Recurring Donation instalment is created Pledged and only moved to Closed Won (or a site's own Posted, or Awarded on a Grant) once received. Open stages such as Prospecting, and Closed Lost, are not gifts either. features keeps only those closed/won stages for npsp; override with the include_stages parameter of philanthropy.ingest.npsp_opportunities_to_features if your org's closed/won stage has another name. For CiviCRM the equivalent traps are test-mode rows and non-Completed contributions, and they are dropped the same way.
features does not invent a label
train needs a --target column and a gift export contains no such column. Deciding who counts as a major donor, who lapsed, or who is a planned-giving prospect is yours to define; features gets you the predictors, not the answer key.
Fold in engagement data
Repeat --activity TYPE=PATH to add philanthropy.ingest.activities_to_features columns (event attendance, volunteer hours, ...) alongside the gift features. Each file is tagged with its own type and concatenated into one activity log; --as-of sets the cutoff (default: the latest activity_date across every file given).
philanthropy features --source raisers_edge --data gifts.csv \
--activity event=events.csv --activity volunteer=shifts.csv \
--as-of 2025-06-30 --out features.csv
Train a model from a labelled CSV
train needs the label column, the feature columns, and an output path. Point it at the features.csv from the previous step once you have added your own label column to it. It writes a bundle (the fitted model plus the feature list, the target name, and the library versions) via philanthropy.utils.save_model.
philanthropy train \
--data gifts.csv \
--target is_major_donor \
--features total_gift_amount,years_active,event_attendance_count \
--model DonorPropensityModel \
--out model.joblib
--model accepts DonorPropensityModel (the default), MajorGiftClassifier, LapsePredictor, or PlannedGivingIntentScorer. --random-state defaults to 0, so a rerun on the same CSV gives the same model.
--task leadership: train and score the leadership-upgrade model in one call
--task leadership is a different shape of train: --data is a raw gift export (not a pre-built features CSV), read via --source the same way features does, and it calls philanthropy.models.score_leadership_prospects directly rather than fitting --model on --features/--target. That one call trains on every fully-resolved historical fiscal year and scores today's band-qualifying donors, so --out here is a scored CSV, not a saved model bundle, and a validation report prints to stdout the way validate's metrics do.
philanthropy train --task leadership --source raisers_edge --data gifts.csv \
--threshold 1000 --band 100 999 --fiscal-year-start 7 \
--activity event=events.csv --donors donors.csv \
--out upgrade_scores.csv --random-state 0
--threshold, --band, --fiscal-year-start, --activity and --as-of mirror score_leadership_prospects's own parameters; --donors is an optional CSV of static donor attributes with a donor_id column. See the function's docstring for the scored columns (affinity_score, rank, decile, top_reasons, suggested_ask) and the report fields printed after the CSV is written.
Score a prospect list
score reuses the feature list stored in the bundle, so you do not repeat --features. Omit --out and the CSV goes to stdout, which pipes.
philanthropy score --model model.joblib --data prospects.csv --out scores.csv
# or straight to a pipe
philanthropy score --model model.joblib --data prospects.csv | head -20
The output is the input CSV plus one score column: predict_affinity_score when the model has one, otherwise predict_proba[:, 1].
Scored CSVs are neutralised against formula injection
Donor-controlled string fields arrive from third-party webhooks. A cell like =cmd|'/c calc'!A1 executes when an analyst opens the file in Excel or Google Sheets. Any text cell starting with =, +, -, @, tab or CR is prefixed with an apostrophe on the way out. Numeric columns are untouched.
Report holdout metrics
validate reports both a threshold-0.5 view (precision, recall, F1) and a
ranking view (ROC-AUC, average precision, a 10-row decile table, and a top-N
hit rate), because a rare outcome can look broken at 0.5 while still ranking
donors usefully. The target comes from the bundle unless you override it.
--top-n takes a row count or a percentage, e.g. --top-n 50 or --top-n
10%; it defaults to the top 10% of rows.
philanthropy validate --model model.joblib --data holdout.csv
philanthropy validate --model model.joblib --data holdout.csv --target is_major_donor
philanthropy validate --model model.joblib --data holdout.csv --top-n 100
On a holdout where only 12.4% of donors are major donors, this looks like:
precision (at threshold 0.5) 1.000
recall (at threshold 0.5) 1.000
f1 (at threshold 0.5) 1.000
roc_auc 1.000
average_precision 1.000
base_rate 0.124
decile n positives hit_rate lift
1 146 146 1.000 8.05x
2 146 35 0.240 1.93x
3 146 0 0.000 0.00x
4 146 0 0.000 0.00x
5 146 0 0.000 0.00x
6 146 0 0.000 0.00x
7 146 0 0.000 0.00x
8 145 0 0.000 0.00x
9 145 0 0.000 0.00x
10 145 0 0.000 0.00x
top 146 of 1457: hit_rate 1.000, captures 0.807 of all positives
The decile table sorts donors by predicted probability, splits them into 10
equal-sized groups, and reports the positive rate and lift over the base rate
in each; top N restates the same thing for whatever cutoff you'd actually
mail to.
Check the model against the simple rule on your own file
check reads a raw gift export the way features does and runs philanthropy.model_selection.compare_to_rule on it. It trains on every labelled fiscal year but the last, then compares the model with the best of a few rules fixed in code (for lapse: months since last gift, shortest giving streak, declining trend) on your last complete fiscal year. --task is upgrade, lapse or ask.
verdict: About the same as the rule
use: the rule
rule: shortest giving streak (best of 3)
test fiscal year: 2021 (310 training rows, 156 test rows)
top10pct_hit_rate: model 0.375, rule 0.438
model - rule, 95% interval: -0.438 to 0.250
The verdict uses the same words as the Results pages: Beats the rule, About the same as the rule, Loses to the rule, not enough labels, or can't test (reason). use: is the model only when the model beats the rule; every other verdict, a tie included, says to use the rule, which is free and easy to explain.
End to end, in Python
The same three steps, so this page is executable. main() takes the argument list a shell would pass.
import pandas as pd
from philanthropy.cli import main
from philanthropy.datasets import generate_synthetic_donor_data
FEATURES = "total_gift_amount,years_active,event_attendance_count"
generate_synthetic_donor_data(n_samples=300, random_state=1).to_csv("gifts.csv", index=False)
main(["train", "--data", "gifts.csv", "--target", "is_major_donor",
"--features", FEATURES, "--out", "model.joblib"])
main(["score", "--model", "model.joblib", "--data", "gifts.csv", "--out", "scores.csv"])
scored = pd.read_csv("scores.csv")
print(scored[["total_gift_amount", "score"]].head())
main(["validate", "--model", "model.joblib", "--data", "gifts.csv"])
assert "score" in scored.columns
assert len(scored) == 300
Bundles are pickles
Only load model files you trust
--model unpickles the file, which executes arbitrary code. Never point score or validate at a bundle from an untrusted source.
The artifacts are the same objects save_model / load_model produce, so anything the CLI trains loads in Python and vice versa. See Save and load models.