Metrics Reference
philanthropy.metrics
Donor KPI calculators.
IntervalReport
dataclass
Everything needed to judge a set of intervals, not just certify them.
Attributes:
| Name | Type | Description |
|---|---|---|
n |
int
|
Rows scored. |
coverage |
float
|
Fraction of targets inside their interval. |
requested_level |
float
|
|
score_mean, score_median, score_trimmed_mean |
float
|
The interval score aggregated three ways. The mean is the proper score; the other two are there because on gift amounts a handful of donors can carry it. A ranking that flips between them is a ranking of the tail. |
median_width |
float
|
Median |
median_target |
float
|
Median |
width_ratio |
float
|
|
median_bound_ratio |
float
|
Median |
Source code in philanthropy/metrics/_conformal.py
donor_retention_rate(current_donors, prior_donors)
Share of the prior period's donors who gave again this period.
Returns a fraction in [0.0, 1.0]; 0.0 when prior_donors is
empty (no base to retain from).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
current_donors
|
collection
|
Donor identifiers seen in the current period. |
required |
prior_donors
|
collection
|
Donor identifiers seen in the prior period. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Retained share in |
Source code in philanthropy/metrics/_scoring.py
donor_acquisition_cost(*, total_fundraising_expense, new_donors_acquired)
Average spend to acquire one new donor.
Returns np.inf when new_donors_acquired is 0 (spend with nothing
acquired), so the result is always safe to compare or plot.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
total_fundraising_expense
|
float
|
Total fundraising expense for the period. |
required |
new_donors_acquired
|
int
|
Number of first-time donors acquired in the period. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Expense per new donor. Returns |
Source code in philanthropy/metrics/_scoring.py
cost_per_dollar_raised(*, total_fundraising_expense, total_raised)
Fundraising expense per dollar of revenue raised.
A headline efficiency KPI: values below ~0.20 are typically healthy for a
mature program. Returns np.inf when total_raised is 0 (spend with
nothing raised), so the result is always safe to compare or plot.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
total_fundraising_expense
|
float
|
Total fundraising expense for the period. |
required |
total_raised
|
float
|
Total revenue raised in the period. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Expense per dollar raised. Returns |
Source code in philanthropy/metrics/_scoring.py
fundraising_roi(*, total_raised, total_fundraising_expense)
Net return on fundraising investment, (raised - expense) / expense.
0.0 means the program broke even; 3.0 means every dollar spent
returned three dollars of net revenue. Returns np.inf when
total_fundraising_expense is 0 (revenue with no spend).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
total_raised
|
float
|
Total revenue raised in the period. |
required |
total_fundraising_expense
|
float
|
Total fundraising expense for the period. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Net return per dollar of fundraising expense. Returns |
Source code in philanthropy/metrics/_scoring.py
donor_lifetime_value(average_donation, lifespan_years, discount_rate=0.05, retention_rate=None)
Computes the Net Present Value (NPV) of a donor's future giving.
Two modes, and they are different calculations rather than the same one with a substituted lifespan.
Fixed horizon (retention_rate=None). lifespan_years is taken as
certain and the result is the NPV of an ordinary annuity::
discount_rate > 0: NPV = m * (1 - (1 + d) ** -L) / d
discount_rate == 0: NPV = m * L
Geometric lifetime (retention_rate given). The donor gives once at
the end of year 1, then survives each subsequent year with probability
r, so the lifetime is geometric on {1, 2, ...} with
E[L] = 1 / (1 - r). The expected NPV is then::
discount_rate > 0: E[NPV] = m / (1 + d - r)
discount_rate == 0: E[NPV] = m / (1 - r)
Note that this is not the annuity formula evaluated at E[L]. The
annuity is concave in L, so by Jensen's inequality
NPV(E[L]) >= E[NPV(L)], and substituting the expected lifespan into the
annuity therefore overstates lifetime value in every case where d > 0
and 0 < r < 1. The error is one-signed and not small: at r = 0.8,
d = 0.05 it is +8.2%, and at r = 0.9, d = 0.10 it is +22.9%.
This function computed NPV(E[L]) before version 0.7.0.
The two modes agree where they should: at r = 0 both give
m / (1 + d), one gift discounted one year, and the d == 0 branch is
the same in both because a sum with no discounting is linear in L.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
average_donation
|
float
|
The average annual donation amount. |
required |
lifespan_years
|
float
|
The fixed number of years the donor is expected to continue giving. Only used if retention_rate is None. |
required |
discount_rate
|
float
|
The discount rate used to compute the net present value of future gifts (e.g., 0.05 for 5%). |
0.05
|
retention_rate
|
float
|
The annual retention rate of the donor (e.g., 0.80 for 80%). If
provided, the geometric-lifetime expectation above is used and
|
None
|
Returns:
| Type | Description |
|---|---|
float
|
The calculated Net Present Value of the expected donor lifetime value.
|
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
An 80% retention rate implies the same 5-year expected lifespan, but the expected NPV is lower than the 5-year annuity, not equal to it:
Source code in philanthropy/metrics/_financial.py
4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 | |
demographic_parity_difference(y_pred, sensitive_features, pos_label=1)
max(selection_rate) - min(selection_rate) across protected groups.
A companion to :func:disparate_impact_ratio for when selection rates
are small: two rates of 0.01 and 0.02 give a ratio of 0.5 (looks severe)
but a difference of 0.01 (looks negligible), and which reading matters
depends on the decision being made. Report both.
This is a diagnostic, not a fairness guarantee or legal clearance: a small difference does not certify a model as non-discriminatory, and the choice of protected groups and decision threshold materially affects the result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y_pred
|
array-like of shape (n_samples,)
|
Binary decisions. Threshold continuous scores first. |
required |
sensitive_features
|
array-like of shape (n_samples,)
|
Protected-group label per sample. |
required |
pos_label
|
default=1
|
Value in |
1
|
Returns:
| Type | Description |
|---|---|
float
|
Difference in |
Raises:
| Type | Description |
|---|---|
ValueError
|
If inputs have mismatched lengths, are empty, or contain missing values. |
Source code in philanthropy/metrics/_fairness.py
disparate_impact_ratio(y_pred, sensitive_features, pos_label=1)
Four-fifths-rule disparate-impact ratio across protected groups.
Computes min(selection_rate) / max(selection_rate) over the groups in
sensitive_features. A value of 1.0 is exact parity; the US EEOC
"four-fifths rule" flags a ratio below 0.8 as evidence of adverse impact
that warrants investigation.
This is a diagnostic, not a fairness guarantee or legal clearance: a passing ratio does not certify a model as non-discriminatory, and the choice of protected groups and decision threshold materially affects the result.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y_pred
|
array-like of shape (n_samples,)
|
Binary decisions. Threshold continuous scores first. |
required |
sensitive_features
|
array-like of shape (n_samples,)
|
Protected-group label per sample. |
required |
pos_label
|
default=1
|
Value in |
1
|
Returns:
| Type | Description |
|---|---|
float
|
Ratio in |
Raises:
| Type | Description |
|---|---|
ValueError
|
If inputs have mismatched lengths, are empty, or contain missing values. |
Source code in philanthropy/metrics/_fairness.py
metric_by_group(metric, y_true, y_pred, groups)
Any metric(y_true, y_pred) computed within each group and overall.
:func:selection_rate_by_group only answers "who gets flagged". This
answers "how well does the model work for each group", for whatever
metric matters to the decision, e.g. roc_auc_score on scores or
recall_score on thresholded decisions (a group where the model misses
most real donors is under-served even at equal selection rates). Pass
extra metric arguments with :func:functools.partial.
Like the other functions here, this is a diagnostic, not a fairness
guarantee. Small groups give noisy values; read count first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
metric
|
callable
|
|
required |
y_true
|
array-like of shape (n_samples,)
|
Observed outcome. |
required |
y_pred
|
array-like of shape (n_samples,)
|
Prediction or score, as |
required |
groups
|
array-like of shape (n_samples,)
|
Group label per sample (e.g. race, age band, region). |
required |
Returns:
| Type | Description |
|---|---|
DataFrame
|
Indexed by group, with a final |
Raises:
| Type | Description |
|---|---|
ValueError
|
If inputs have mismatched lengths, are empty, contain missing group
labels, or a group is itself labelled |
Source code in philanthropy/metrics/_fairness.py
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | |
selection_rate_by_group(y_pred, sensitive_features, pos_label=1)
Fraction selected (y_pred == pos_label) within each protected group.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y_pred
|
array-like of shape (n_samples,)
|
Binary decisions (e.g. "flagged for major-gift outreach"). Threshold continuous scores before calling this. |
required |
sensitive_features
|
array-like of shape (n_samples,)
|
Protected-group label per sample (e.g. race, age band, gender). |
required |
pos_label
|
default=1
|
Value in |
1
|
Returns:
| Type | Description |
|---|---|
dict
|
Mapping of group value -> selection rate in |
Raises:
| Type | Description |
|---|---|
ValueError
|
If inputs have mismatched lengths, are empty, or contain missing values. |
Source code in philanthropy/metrics/_fairness.py
qini_auc_score(y_true, uplift, treatment)
Area between the Qini curve and the random-targeting line.
The random line runs straight from (0, 0) to the curve's end point
(1, gains[-1]): what targeting in random order would earn on
average. The score is the trapezoidal area under :func:qini_curve
minus the area under that line.
The score is not normalised (scikit-uplift's qini_auc_score
divides by the perfect model's area by default; this does not). Its unit
is incremental responses (or dollars, for amount outcomes) times
fraction targeted, so it scales with sample size and outcome scale. Use
it to compare models scored on the same holdout, not across datasets.
Positive means the ranking beats random targeting, about 0 means no better than random, negative means worse than random.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y_true
|
Collection
|
As for :func: |
required |
uplift
|
Collection
|
As for :func: |
required |
treatment
|
Collection
|
As for :func: |
required |
Returns:
| Type | Description |
|---|---|
float
|
Area between the model's Qini curve and the random line. |
Raises:
| Type | Description |
|---|---|
ValueError
|
As for :func: |
Source code in philanthropy/metrics/_uplift.py
qini_curve(y_true, uplift, treatment)
Qini curve: cumulative incremental responses by fraction targeted.
Donors are sorted by uplift, highest first. At each cut-off holding
the top k donors, the incremental gain is (Radcliffe 2007, and the
definition scikit-uplift uses)::
Y_t(k) - Y_c(k) * N_t(k) / N_c(k)
where Y_t / Y_c are the summed outcomes of the treated / control
donors in the top k, and N_t / N_c their counts. It reads as
"extra responders (or dollars) the appeal produced among the donors
targeted so far", with the control response rescaled to the treated
count. While no control donor has been reached the control term is 0.
Tied scores form one cut-off, so the curve does not depend on how ties happen to be ordered.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y_true
|
array-like of shape (n_samples,)
|
Observed outcome: 1/0 for gave, or a gift amount. |
required |
uplift
|
array-like of shape (n_samples,)
|
Predicted uplift score; higher means target first. |
required |
treatment
|
array-like of shape (n_samples,)
|
1 if the donor was solicited, 0 if held out. Assignment must have been random. |
required |
Returns:
| Name | Type | Description |
|---|---|---|
fraction_targeted |
ndarray
|
Fraction of donors targeted at each cut-off, starting at 0.0 and ending at 1.0. |
gains |
ndarray
|
Cumulative incremental gain at each cut-off, starting at 0.0. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If inputs are empty, of mismatched length, non-finite, if
|
Source code in philanthropy/metrics/_uplift.py
gift_concentration_gini(amounts)
Gini coefficient of a set of donor gift amounts.
0.0 is perfect equality (every donor gives the same); values approaching
1.0 mean revenue is concentrated in a few donors.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
amounts
|
array-like of shape (n_donors,)
|
Per-donor total giving. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Gini coefficient in |
Raises:
| Type | Description |
|---|---|
ValueError
|
If any gift amounts are negative or infinite. |
Source code in philanthropy/metrics/_concentration.py
top_donor_share(amounts, top_fraction=0.1)
Fraction of total revenue contributed by the top top_fraction donors.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
amounts
|
array-like of shape (n_donors,)
|
Per-donor total giving. |
required |
top_fraction
|
float
|
Slice of donors (ranked by giving, descending) to sum. Must be in
|
0.1
|
Returns:
| Type | Description |
|---|---|
float
|
Share in |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
conformal_pvalue(calibration_scores, scores)
Split-conformal p-value of each score against a calibration set.
Small p-values mean the score is high relative to the calibration donors,
so conformal_pvalue(...) <= alpha selects the donors whose scores are
extreme at level alpha, and the expected selection rate among
exchangeable donors is at most alpha. The calibration scores must come
from donors held out of training, exchangeable with the ones being scored;
reusing training rows breaks the guarantee exactly the way refitting a
transformer on test data does. To read alpha as a false-positive rate
the calibration set must contain only donors who did not give; see the
module docstring.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
calibration_scores
|
array-like of shape (n_calibration,)
|
Scores of held-out donors, higher meaning more likely to give. Must be
non-empty and finite; |
required |
scores
|
array-like of shape (n_samples,)
|
Scores to test. May be a scalar-like sequence of any length, including
empty. |
required |
Returns:
| Type | Description |
|---|---|
ndarray of shape (n_samples,)
|
P-values in |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Examples:
>>> import numpy as np
>>> from philanthropy.metrics import conformal_pvalue
>>> calibration = np.arange(9, dtype=float) # 0 .. 8, n = 9
>>> conformal_pvalue(calibration, [8.5, 4.0, -1.0])
array([0.1, 0.6, 1. ])
A score above every calibration point still gets 1 / (n + 1), not 0:
Source code in philanthropy/metrics/_conformal.py
interval_report(y_true, lower, upper, alpha=0.05, trim=0.1)
Coverage, interval score and width-to-target for a set of intervals.
Coverage alone cannot tell a useful interval from [0, inf). This returns
the three things that can: the proper score aggregated robustly, the width
relative to the amounts being predicted, and the ratio between the bounds.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y_true
|
array-like of shape (n_samples,)
|
Realised amounts. |
required |
lower
|
array-like of shape (n_samples,)
|
The interval bounds. |
required |
upper
|
array-like of shape (n_samples,)
|
The interval bounds. |
required |
alpha
|
float
|
Miscoverage the intervals were built for; sets both the score's penalty
weight and |
0.05
|
trim
|
float
|
Fraction of rows dropped from each end before |
0.1
|
Returns:
| Type | Description |
|---|---|
IntervalReport
|
|
Raises:
| Type | Description |
|---|---|
ValueError
|
On the same input problems as :func: |
Examples:
>>> from philanthropy.metrics import interval_report
>>> report = interval_report([10.0, 30.0], [5.0, 5.0], [20.0, 20.0], alpha=0.5)
>>> report.coverage, report.median_width, report.median_target
(0.5, 15.0, 20.0)
>>> report.width_ratio, report.median_bound_ratio
(0.75, 4.0)
The score is heavy-tailed on gift amounts, so read all three:
Source code in philanthropy/metrics/_conformal.py
250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 | |
interval_score(y_true, lower, upper, alpha=0.05)
Mean interval score of a central 1 - alpha interval.
(u - l) + (2/alpha)(l - y)+ + (2/alpha)(y - u)+, averaged. Lower is
better. Proper for a central interval, so widening to buy coverage costs
more than it gains and the score cannot be gamed.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y_true
|
array-like of shape (n_samples,)
|
Realised amounts. |
required |
lower
|
array-like of shape (n_samples,)
|
The interval bounds. |
required |
upper
|
array-like of shape (n_samples,)
|
The interval bounds. |
required |
alpha
|
float
|
The miscoverage the interval was built for. This is the score's penalty
weight, not a level to be attained, so the caller's requested |
0.05
|
Returns:
| Type | Description |
|---|---|
float
|
The mean score, in the target's units. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If the three arrays disagree in shape, are empty, |
Examples:
A covered row costs its width; a missed row adds 2 / alpha per dollar
of miss:
>>> from philanthropy.metrics import interval_score
>>> interval_score([10.0, 30.0], [5.0, 5.0], [20.0, 20.0], alpha=0.5)
35.0