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 | |
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
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
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. |
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 |
Source code in philanthropy/metrics/_concentration.py
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