Skip to content

Models Reference

philanthropy.models

Donor propensity, lapse prediction, share-of-wallet capacity, and repeat-giving (BG/NBD, gamma-gamma) models.

PropensityScorer

Bases: ClassifierMixin, BaseEstimator

Constant-probability baseline that predicts P=0.5 for every donor.

A deliberately trivial, sklearn-compliant reference point: it fits nothing and returns 0.5 for all rows, which makes it equivalent in effect to :class:sklearn.dummy.DummyClassifier with strategy="uniform". It exists so a domain benchmark has a named floor to beat, not because it scores anything. For real propensity scoring reach for :class:~philanthropy.models.DonorPropensityModel or :class:~philanthropy.models.MajorGiftClassifier.

Parameters:

Name Type Description Default
threshold float

Decision threshold on predict_proba(X)[:, 1]. The comparison is strict (proba > threshold), so at the default the constant 0.5 score falls below the threshold and :meth:predict returns classes_[0] for every row. scikit-learn requires argmax(predict_proba) == predict, and argmax of a tied [0.5, 0.5] row is index 0, so a non-strict comparison here would make the estimator self-inconsistent.

0.5

Raises:

Type Description
ValueError

In :meth:fit, if y has more than two classes.

Source code in philanthropy/models/_propensity_baseline.py
class PropensityScorer(ClassifierMixin, BaseEstimator):
    """Constant-probability baseline that predicts P=0.5 for every donor.

    A deliberately trivial, sklearn-compliant reference point: it fits nothing
    and returns 0.5 for all rows, which makes it equivalent in effect to
    :class:`sklearn.dummy.DummyClassifier` with ``strategy="uniform"``. It
    exists so a domain benchmark has a named floor to beat, not because it
    scores anything. For real
    propensity scoring reach for
    :class:`~philanthropy.models.DonorPropensityModel` or
    :class:`~philanthropy.models.MajorGiftClassifier`.

    Parameters
    ----------
    threshold : float, default=0.5
        Decision threshold on ``predict_proba(X)[:, 1]``.  The comparison is
        **strict** (``proba > threshold``), so at the default the constant 0.5
        score falls *below* the threshold and :meth:`predict` returns
        ``classes_[0]`` for every row.  scikit-learn requires
        ``argmax(predict_proba) == predict``, and ``argmax`` of a tied
        ``[0.5, 0.5]`` row is index 0, so a non-strict comparison here would
        make the estimator self-inconsistent.

    Raises
    ------
    ValueError
        In :meth:`fit`, if ``y`` has more than two classes.
    """

    def __init__(self, threshold: float = 0.5) -> None:
        self.threshold = threshold

    def fit(self: _Self, X: Any, y: Any) -> _Self:
        """Validate input and record the target classes.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.
        y : array-like of shape (n_samples,)
            Binary target labels.

        Returns
        -------
        self : PropensityScorer
            Fitted estimator. Sets ``classes_``.

        Raises
        ------
        ValueError
            If ``y`` is not binary.
        """
        X, y = validate_data(self, X, y, reset=True)
        check_classification_targets(y)
        y_type = type_of_target(y, input_name="y", raise_unknown=True)
        if y_type not in ("binary",):
            raise ValueError(
                "Only binary classification is supported. The type of the "
                f"target is {y_type}."
            )
        self.classes_ = np.unique(y)
        return self

    def predict(self, X: Any) -> np.ndarray:
        """Predict binary labels using the constant probability baseline.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.

        Returns
        -------
        y_pred : ndarray of shape (n_samples,)
            Predicted labels. With the default threshold, the constant 0.5
            probability is not above the threshold and ``classes_[0]`` is
            returned for every row.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self)
        X = validate_data(self, X, reset=False)
        if len(self.classes_) == 1:
            return np.full(X.shape[0], self.classes_[0])
        proba = self.predict_proba(X)[:, 1]
        idx = (proba > self.threshold).astype(int)
        return self.classes_[idx]

    def predict_proba(self, X: Any) -> np.ndarray:
        """Return the constant probability baseline.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.

        Returns
        -------
        proba : ndarray of shape (n_samples, 2)
            ``[0.5, 0.5]`` for every row, or shape ``(n_samples, 1)`` when
            only one class was seen during fitting.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self)
        X = validate_data(self, X, reset=False)
        n = X.shape[0]
        if len(self.classes_) == 1:
            return np.ones((n, 1))
        prob_pos = np.full(n, 0.5)
        return np.column_stack([1 - prob_pos, prob_pos])

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.classifier_tags.poor_score = True
        tags.classifier_tags.multi_class = False
        return tags

fit(X, y)

Validate input and record the target classes.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required
y array-like of shape (n_samples,)

Binary target labels.

required

Returns:

Name Type Description
self PropensityScorer

Fitted estimator. Sets classes_.

Raises:

Type Description
ValueError

If y is not binary.

Source code in philanthropy/models/_propensity_baseline.py
def fit(self: _Self, X: Any, y: Any) -> _Self:
    """Validate input and record the target classes.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.
    y : array-like of shape (n_samples,)
        Binary target labels.

    Returns
    -------
    self : PropensityScorer
        Fitted estimator. Sets ``classes_``.

    Raises
    ------
    ValueError
        If ``y`` is not binary.
    """
    X, y = validate_data(self, X, y, reset=True)
    check_classification_targets(y)
    y_type = type_of_target(y, input_name="y", raise_unknown=True)
    if y_type not in ("binary",):
        raise ValueError(
            "Only binary classification is supported. The type of the "
            f"target is {y_type}."
        )
    self.classes_ = np.unique(y)
    return self

predict(X)

Predict binary labels using the constant probability baseline.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required

Returns:

Name Type Description
y_pred ndarray of shape (n_samples,)

Predicted labels. With the default threshold, the constant 0.5 probability is not above the threshold and classes_[0] is returned for every row.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/models/_propensity_baseline.py
def predict(self, X: Any) -> np.ndarray:
    """Predict binary labels using the constant probability baseline.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.

    Returns
    -------
    y_pred : ndarray of shape (n_samples,)
        Predicted labels. With the default threshold, the constant 0.5
        probability is not above the threshold and ``classes_[0]`` is
        returned for every row.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self)
    X = validate_data(self, X, reset=False)
    if len(self.classes_) == 1:
        return np.full(X.shape[0], self.classes_[0])
    proba = self.predict_proba(X)[:, 1]
    idx = (proba > self.threshold).astype(int)
    return self.classes_[idx]

predict_proba(X)

Return the constant probability baseline.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required

Returns:

Name Type Description
proba ndarray of shape (n_samples, 2)

[0.5, 0.5] for every row, or shape (n_samples, 1) when only one class was seen during fitting.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/models/_propensity_baseline.py
def predict_proba(self, X: Any) -> np.ndarray:
    """Return the constant probability baseline.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.

    Returns
    -------
    proba : ndarray of shape (n_samples, 2)
        ``[0.5, 0.5]`` for every row, or shape ``(n_samples, 1)`` when
        only one class was seen during fitting.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self)
    X = validate_data(self, X, reset=False)
    n = X.shape[0]
    if len(self.classes_) == 1:
        return np.ones((n, 1))
    prob_pos = np.full(n, 0.5)
    return np.column_stack([1 - prob_pos, prob_pos])

AskAmountRecommender

Bases: RegressorMixin, BaseEstimator

Recommend a donor's base ask amount and derive a gift array.

AskAmountRecommender is a scikit-learn–compatible regressor that wraps :class:~sklearn.ensemble.HistGradientBoostingRegressor to predict the base ask amount: the single dollar figure a gift officer anchors a solicitation on for a given prospect.

By using HistGradientBoostingRegressor internally, the model handles missing CRM and wealth-screening values natively without requiring an upstream imputation step, reducing pipeline complexity and eliminating one source of potential leakage.

Structurally this is the same wrapper as :class:~philanthropy.models.ShareOfWalletRegressor: same estimator, same NaN handling, different target and different domain method name. Both exist because the two quantities are separate columns in a real advancement workflow, not because the modelling differs. If you want a plain regressor, use HistGradientBoostingRegressor directly.

The companion method :meth:ask_ladder expands the base ask into a discrete gift array (or ask ladder): the low / target / stretch rungs presented in a real solicitation.

Parameters:

Name Type Description Default
learning_rate float

Step size shrinkage applied to each tree. Smaller values require more max_iter trees to converge but typically generalise better.

0.1
max_iter int

Number of boosting iterations (trees). Increase to 300–500 for production models trained on large prospect pools.

100
max_depth int or None

Maximum depth of each individual decision tree.

None
l2_regularization float

L2 regularisation term on leaf weights. Increase (e.g., to 1.0) to combat overfitting when the feature-to-sample ratio is high, a common scenario in small-shop advancement analytics.

0.0
min_samples_leaf int

Minimum number of samples per leaf. Larger values prevent overfitting on sparse major-donor training sets.

20
loss ('squared_error', 'absolute_error')

Loss function for the backend HistGradientBoostingRegressor. Ask amounts are right-skewed (a few large gifts, many small ones), and squared error fits the conditional mean, which overshoots most donors; absolute error fits the conditional median instead, which tracks a typical ask more closely.

"squared_error"
random_state int or None

Seed for the internal random-number generator. Set to an integer for reproducible model artefacts suitable for audit trails.

None
ask_floor float

Minimum recommended ask (in dollars). Predictions are clipped to this floor via np.maximum to prevent negative ask amounts that are semantically meaningless.

1.0
target_mode ('absolute', 'relative')

"absolute" fits the ask amount directly, as before. "relative" instead fits log(y / max(last_gift, avg_gift)) and multiplies the prediction back out at inference time, targeting the ratio to the simple rule this model is meant to beat (max of last and average gift) rather than the raw dollar amount. Requires last_gift_idx and avg_gift_idx.

"absolute"
last_gift_idx (int, str or None)

Column of the donor's last gift amount in X: a position, or a column name when X is a DataFrame (names survive a CRM export reordering its columns). Required when target_mode="relative".

None
avg_gift_idx (int, str or None)

Column of the donor's average gift amount in X, as for last_gift_idx. Required when target_mode="relative".

When both last_gift_idx and avg_gift_idx are set, :meth:fit also runs a self-check against the simple rule (see beats_rule_).

None
categorical_features list of int or str, bool mask, or None

Columns the backend should split on as unordered categories (a region, constituency or appeal code) instead of as numbers, so they need no one-hot step. Positions, a boolean mask, or column names when X is a DataFrame. The columns must hold non-negative integer codes below 255, as for HistGradientBoostingRegressor. None treats every column as numeric.

None
monotonic_cst array-like of int or dict

Monotonic constraint on each feature, passed to the underlying :class:HistGradientBoostingRegressor: 1 means the ask never falls as that feature rises, -1 that it never rises, 0 no constraint. A dict sets only the features it names, keyed by position or, when X is a DataFrame, column name ({"total_giving": 1}). None applies no constraint (current behaviour). With target_mode="relative" it constrains the predicted ask-to-reference ratio rather than the dollar ask. Needs loss="squared_error": with "absolute_error" the backend resets each leaf to a median after splitting, which breaks the constraint, so :meth:fit raises instead.

None

Attributes:

Name Type Description
estimator_ HistGradientBoostingRegressor

The fitted backend estimator, trained on all of X.

n_features_in_ int

Number of features seen during :meth:fit.

last_gift_idx_, avg_gift_idx_ int or None

Column positions last_gift_idx and avg_gift_idx resolved to in the data passed to :meth:fit.

beats_rule_ bool or None

Whether the model's mean absolute error beat the simple rule max(last gift, average gift) on a 20% holdout of the training data. The holdout model is a separate fit on the other 80%; the returned model is then refit on everything. None when either column index is unset or X has fewer than 100 rows. When False, use :func:suggest_ask instead of this model.

rule_mae_, model_mae_ float or None

The two holdout mean absolute errors behind beats_rule_, in the target's currency. None whenever beats_rule_ is.

Examples:

Predict a base ask and expand it into a gift array:

>>> import numpy as np
>>> from philanthropy.models import AskAmountRecommender
>>> rng = np.random.default_rng(42)
>>> X = rng.uniform(0, 1e6, (200, 6))
>>> y = rng.uniform(1e3, 250_000, 200)
>>> model = AskAmountRecommender(random_state=42).fit(X, y)
>>> model.predict(X[:3]).shape
(3,)
>>> ladder = model.ask_ladder(X[:3])
>>> ladder.shape
(3, 3)
>>> bool((ladder[:, 2] >= ladder[:, 1]).all())
True

Pipeline usage:

>>> from sklearn.pipeline import Pipeline
>>> pipe = Pipeline([("model", AskAmountRecommender(random_state=0))])
>>> _ = pipe.fit(X, y)
Notes

Why HistGradientBoosting? Wealth-screening datasets consistently contain 30–70 % missing values. HistGradientBoostingRegressor implements a native missing-value splitting strategy that treats NaN as an informative category rather than an erroneous artefact, avoiding the information loss of mean/median imputation.

Gift Array Interpretation:

The default multipliers=(1.0, 1.5, 2.5) map the base ask onto three rungs a gift officer works from:

======= ================================================== Rung Meaning ======= ================================================== Low The base ask, a comfortable, likely-accepted gift. Target 1.5× the base, the amount the ask is anchored on. Stretch 2.5× the base, the aspirational upgrade ask. ======= ==================================================

See Also

philanthropy.models.ShareOfWalletRegressor : Continuous capacity model; pair with this recommender to bound the top of the gift array by estimated philanthropic capacity.

Source code in philanthropy/models/_ask.py
class AskAmountRecommender(RegressorMixin, BaseEstimator):
    """Recommend a donor's base ask amount and derive a gift array.

    ``AskAmountRecommender`` is a scikit-learn–compatible regressor that wraps
    :class:`~sklearn.ensemble.HistGradientBoostingRegressor` to predict the
    **base ask amount**: the single dollar figure a gift officer anchors a
    solicitation on for a given prospect.

    By using ``HistGradientBoostingRegressor`` internally, the model handles
    missing CRM and wealth-screening values *natively* without requiring an
    upstream imputation step, reducing pipeline complexity and eliminating one
    source of potential leakage.

    Structurally this is the same wrapper as
    :class:`~philanthropy.models.ShareOfWalletRegressor`: same estimator, same
    NaN handling, different target and different domain method name. Both exist
    because the two quantities are separate columns in a real advancement
    workflow, not because the modelling differs. If you want a plain regressor,
    use ``HistGradientBoostingRegressor`` directly.

    The companion method :meth:`ask_ladder` expands the base ask into a
    discrete **gift array** (or *ask ladder*): the low / target / stretch
    rungs presented in a real solicitation.

    Parameters
    ----------
    learning_rate : float, default=0.1
        Step size shrinkage applied to each tree.  Smaller values require
        more ``max_iter`` trees to converge but typically generalise better.
    max_iter : int, default=100
        Number of boosting iterations (trees).  Increase to 300–500 for
        production models trained on large prospect pools.
    max_depth : int or None, default=None
        Maximum depth of each individual decision tree.
    l2_regularization : float, default=0.0
        L2 regularisation term on leaf weights.  Increase (e.g., to 1.0)
        to combat overfitting when the feature-to-sample ratio is high,
        a common scenario in small-shop advancement analytics.
    min_samples_leaf : int, default=20
        Minimum number of samples per leaf.  Larger values prevent
        overfitting on sparse major-donor training sets.
    loss : {"squared_error", "absolute_error"}, default="absolute_error"
        Loss function for the backend ``HistGradientBoostingRegressor``.
        Ask amounts are right-skewed (a few large gifts, many small ones),
        and squared error fits the conditional *mean*, which overshoots most
        donors; absolute error fits the conditional *median* instead, which
        tracks a typical ask more closely.
    random_state : int or None, default=None
        Seed for the internal random-number generator.  Set to an integer
        for reproducible model artefacts suitable for audit trails.
    ask_floor : float, default=1.0
        Minimum recommended ask (in dollars).  Predictions are clipped to
        this floor via ``np.maximum`` to prevent negative ask amounts that
        are semantically meaningless.
    target_mode : {"absolute", "relative"}, default="absolute"
        ``"absolute"`` fits the ask amount directly, as before. ``"relative"``
        instead fits ``log(y / max(last_gift, avg_gift))`` and multiplies the
        prediction back out at inference time, targeting the ratio to the
        simple rule this model is meant to beat (max of last and average
        gift) rather than the raw dollar amount. Requires ``last_gift_idx``
        and ``avg_gift_idx``.
    last_gift_idx : int, str or None, default=None
        Column of the donor's last gift amount in ``X``: a position, or a
        column name when ``X`` is a DataFrame (names survive a CRM export
        reordering its columns). Required when ``target_mode="relative"``.
    avg_gift_idx : int, str or None, default=None
        Column of the donor's average gift amount in ``X``, as for
        ``last_gift_idx``. Required when ``target_mode="relative"``.

        When both ``last_gift_idx`` and ``avg_gift_idx`` are set, :meth:`fit`
        also runs a self-check against the simple rule (see ``beats_rule_``).
    categorical_features : list of int or str, bool mask, or None, default=None
        Columns the backend should split on as unordered categories (a
        region, constituency or appeal code) instead of as numbers, so they
        need no one-hot step. Positions, a boolean mask, or column names
        when ``X`` is a DataFrame. The columns must hold non-negative
        integer codes below 255, as for ``HistGradientBoostingRegressor``.
        ``None`` treats every column as numeric.
    monotonic_cst : array-like of int or dict, default=None
        Monotonic constraint on each feature, passed to the underlying
        :class:`HistGradientBoostingRegressor`: ``1`` means the ask never falls as
        that feature rises, ``-1`` that it never rises, ``0`` no
        constraint. A dict sets only the features it names, keyed by
        position or, when ``X`` is a DataFrame, column name
        (``{"total_giving": 1}``). ``None`` applies no constraint
        (current behaviour).
        With ``target_mode="relative"`` it constrains the predicted
        ask-to-reference ratio rather than the dollar ask. Needs
        ``loss="squared_error"``: with ``"absolute_error"`` the backend
        resets each leaf to a median after splitting, which breaks the
        constraint, so :meth:`fit` raises instead.

    Attributes
    ----------
    estimator_ : HistGradientBoostingRegressor
        The fitted backend estimator, trained on all of ``X``.
    n_features_in_ : int
        Number of features seen during :meth:`fit`.
    last_gift_idx_, avg_gift_idx_ : int or None
        Column positions ``last_gift_idx`` and ``avg_gift_idx`` resolved to
        in the data passed to :meth:`fit`.
    beats_rule_ : bool or None
        Whether the model's mean absolute error beat the simple rule
        max(last gift, average gift) on a 20% holdout of the training data.
        The holdout model is a separate fit on the other 80%; the returned
        model is then refit on everything. ``None`` when either column index
        is unset or ``X`` has fewer than 100 rows. When ``False``, use
        :func:`suggest_ask` instead of this model.
    rule_mae_, model_mae_ : float or None
        The two holdout mean absolute errors behind ``beats_rule_``, in the
        target's currency. ``None`` whenever ``beats_rule_`` is.

    Examples
    --------
    **Predict a base ask and expand it into a gift array:**

    >>> import numpy as np
    >>> from philanthropy.models import AskAmountRecommender
    >>> rng = np.random.default_rng(42)
    >>> X = rng.uniform(0, 1e6, (200, 6))
    >>> y = rng.uniform(1e3, 250_000, 200)
    >>> model = AskAmountRecommender(random_state=42).fit(X, y)
    >>> model.predict(X[:3]).shape
    (3,)
    >>> ladder = model.ask_ladder(X[:3])
    >>> ladder.shape
    (3, 3)
    >>> bool((ladder[:, 2] >= ladder[:, 1]).all())
    True

    **Pipeline usage:**

    >>> from sklearn.pipeline import Pipeline
    >>> pipe = Pipeline([("model", AskAmountRecommender(random_state=0))])
    >>> _ = pipe.fit(X, y)

    Notes
    -----
    **Why HistGradientBoosting?**
    Wealth-screening datasets consistently contain 30–70 % missing values.
    ``HistGradientBoostingRegressor`` implements a native missing-value
    splitting strategy that treats ``NaN`` as an informative category rather
    than an erroneous artefact, avoiding the information loss of mean/median
    imputation.

    **Gift Array Interpretation:**

    The default ``multipliers=(1.0, 1.5, 2.5)`` map the base ask onto three
    rungs a gift officer works from:

    ======= ==================================================
    Rung    Meaning
    ======= ==================================================
    Low     The base ask, a comfortable, likely-accepted gift.
    Target  1.5× the base, the amount the ask is anchored on.
    Stretch 2.5× the base, the aspirational upgrade ask.
    ======= ==================================================

    See Also
    --------
    philanthropy.models.ShareOfWalletRegressor :
        Continuous capacity model; pair with this recommender to bound the
        top of the gift array by estimated philanthropic capacity.
    """

    def __init__(
        self,
        learning_rate: float = 0.1,
        max_iter: int = 100,
        max_depth: Optional[int] = None,
        l2_regularization: float = 0.0,
        min_samples_leaf: int = 20,
        loss: str = "absolute_error",
        random_state: Optional[int] = None,
        ask_floor: float = 1.0,
        target_mode: str = "absolute",
        last_gift_idx: Optional[int | str] = None,
        avg_gift_idx: Optional[int | str] = None,
        categorical_features: Any = None,
        monotonic_cst: Any = None,
    ) -> None:
        # scikit-learn rule: __init__ stores parameters and does NO logic.
        self.learning_rate = learning_rate
        self.max_iter = max_iter
        self.max_depth = max_depth
        self.l2_regularization = l2_regularization
        self.min_samples_leaf = min_samples_leaf
        self.loss = loss
        self.random_state = random_state
        self.ask_floor = ask_floor
        self.target_mode = target_mode
        self.last_gift_idx = last_gift_idx
        self.avg_gift_idx = avg_gift_idx
        self.categorical_features = categorical_features
        self.monotonic_cst = monotonic_cst

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.allow_nan = True
        tags.regressor_tags.poor_score = True
        return tags

    @property
    def n_iter_(self) -> int:
        """Number of iterations run by the backend estimator."""
        check_is_fitted(self, ["estimator_"])
        return self.estimator_.n_iter_

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def _relative_reference(self, X: np.ndarray) -> np.ndarray:
        """max(last gift, average gift) per row, floored to avoid log(0)."""
        if self.last_gift_idx_ is None or self.avg_gift_idx_ is None:
            raise ValueError(
                "target_mode='relative' requires both last_gift_idx and avg_gift_idx."
            )
        ref = np.maximum(X[:, self.last_gift_idx_], X[:, self.avg_gift_idx_])
        return np.maximum(ref, self.ask_floor)

    def _fit_backend(
        self, X: np.ndarray, y: np.ndarray, sample_weight: Optional[np.ndarray]
    ) -> HistGradientBoostingRegressor:
        if self.target_mode == "relative":
            ref = self._relative_reference(X)
            fit_target = np.log(np.maximum(y, self.ask_floor) / ref)
        elif self.target_mode == "absolute":
            fit_target = y
        else:
            raise ValueError(f"Unknown target_mode: {self.target_mode!r}")
        if self.monotonic_cst is not None and self.loss == "absolute_error":
            # sklearn refits absolute-error leaves as medians after the
            # constrained split search, so the constraint would not hold.
            raise ValueError(
                "monotonic_cst needs loss='squared_error'; with "
                "loss='absolute_error' the backend does not enforce it."
            )

        est = HistGradientBoostingRegressor(
            learning_rate=self.learning_rate,
            max_iter=self.max_iter,
            max_depth=self.max_depth,
            l2_regularization=self.l2_regularization,
            min_samples_leaf=self.min_samples_leaf,
            loss=self.loss,
            categorical_features=resolve_columns(self, self.categorical_features),
            monotonic_cst=resolve_monotonic_cst(self, self.monotonic_cst),
            random_state=self.random_state,
        )
        return est.fit(X, fit_target, sample_weight=sample_weight)

    def _predict_backend(
        self, est: HistGradientBoostingRegressor, X: np.ndarray
    ) -> np.ndarray:
        raw = est.predict(X)
        if self.target_mode == "relative":
            raw = np.exp(raw) * self._relative_reference(X)
        return np.maximum(raw, self.ask_floor)

    def fit(self: _Self, X: Any, y: Any, sample_weight: Any = None) -> _Self:
        """Fit the ask-amount recommender to labelled prospect data.

        With ``last_gift_idx`` and ``avg_gift_idx`` set, first compares the
        model to the simple rule on a holdout (see ``beats_rule_``).
        ``sample_weight`` is passed to the backend, and the holdout MAEs are
        weighted by it too.
        """
        X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
        self.n_features_in_ = X.shape[1]
        if sample_weight is not None:
            sample_weight = _check_sample_weight(sample_weight, X)
        self.last_gift_idx_ = resolve_column(self, self.last_gift_idx)
        self.avg_gift_idx_ = resolve_column(self, self.avg_gift_idx)

        self.beats_rule_: Optional[bool] = None
        self.rule_mae_: Optional[float] = None
        self.model_mae_: Optional[float] = None
        has_idx = self.last_gift_idx_ is not None and self.avg_gift_idx_ is not None
        if has_idx and X.shape[0] >= _SELF_CHECK_MIN_ROWS:
            w = np.ones(X.shape[0]) if sample_weight is None else sample_weight
            X_tr, X_ho, y_tr, y_ho, w_tr, w_ho = train_test_split(
                X, y, w, test_size=0.2, random_state=self.random_state
            )
            model_pred = self._predict_backend(
                self._fit_backend(X_tr, y_tr, w_tr), X_ho
            )
            rule_pred = suggest_ask(
                X_ho[:, self.last_gift_idx_],
                X_ho[:, self.avg_gift_idx_],
                stretch=0.0,
                round_to=None,
            )
            rule_pred = np.where(np.isnan(rule_pred), self.ask_floor, rule_pred)
            rule_pred = np.maximum(rule_pred, self.ask_floor)
            self.rule_mae_ = float(np.average(np.abs(y_ho - rule_pred), weights=w_ho))
            self.model_mae_ = float(np.average(np.abs(y_ho - model_pred), weights=w_ho))
            self.beats_rule_ = self.model_mae_ < self.rule_mae_

        self.estimator_ = self._fit_backend(X, y, sample_weight)
        return self

    def predict(self, X: Any) -> np.ndarray:
        """Predict the base ask amount for each prospect."""
        check_is_fitted(self, ["estimator_"])
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        return self._predict_backend(self.estimator_, X)

    def ask_ladder(
        self,
        X: Any,
        multipliers: Any = (1.0, 1.5, 2.5),
    ) -> np.ndarray:
        """Return a discrete gift array (ask ladder) for each prospect.

        The base ask (from :meth:`predict`) multiplied by each entry of
        ``multipliers`` gives the low / target / stretch rungs a gift officer
        works from when structuring a solicitation.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix passed to :meth:`predict`.  May contain ``NaN``.
        multipliers : sequence of float, default=(1.0, 1.5, 2.5)
            Ascending positive factors applied to the base ask to build each
            rung of the gift array.  Must be non-empty and strictly positive.

        Returns
        -------
        ask_array : ndarray of shape (n_samples, len(multipliers))
            Element ``[i, j]`` is ``base_ask[i] * multipliers[j]``.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        ValueError
            If ``multipliers`` is empty or contains a non-positive value.

        Examples
        --------
        >>> import numpy as np
        >>> from philanthropy.models import AskAmountRecommender
        >>> rng = np.random.default_rng(7)
        >>> X = rng.uniform(0, 1e6, (50, 4))
        >>> y = rng.uniform(1e3, 1e5, 50)
        >>> model = AskAmountRecommender(random_state=7).fit(X, y)
        >>> ladder = model.ask_ladder(X, multipliers=(1.0, 2.0, 4.0))
        >>> ladder.shape
        (50, 3)
        >>> bool((ladder[:, 2] >= ladder[:, 0]).all())
        True
        """
        multipliers = np.asarray(multipliers, dtype=float)
        if multipliers.size == 0:
            raise ValueError("`multipliers` must be non-empty.")
        if not np.all(multipliers > 0):
            raise ValueError("`multipliers` must all be strictly positive.")

        base_ask = self.predict(X)
        return base_ask[:, None] * multipliers[None, :]

n_iter_ property

Number of iterations run by the backend estimator.

fit(X, y, sample_weight=None)

Fit the ask-amount recommender to labelled prospect data.

With last_gift_idx and avg_gift_idx set, first compares the model to the simple rule on a holdout (see beats_rule_). sample_weight is passed to the backend, and the holdout MAEs are weighted by it too.

Source code in philanthropy/models/_ask.py
def fit(self: _Self, X: Any, y: Any, sample_weight: Any = None) -> _Self:
    """Fit the ask-amount recommender to labelled prospect data.

    With ``last_gift_idx`` and ``avg_gift_idx`` set, first compares the
    model to the simple rule on a holdout (see ``beats_rule_``).
    ``sample_weight`` is passed to the backend, and the holdout MAEs are
    weighted by it too.
    """
    X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
    self.n_features_in_ = X.shape[1]
    if sample_weight is not None:
        sample_weight = _check_sample_weight(sample_weight, X)
    self.last_gift_idx_ = resolve_column(self, self.last_gift_idx)
    self.avg_gift_idx_ = resolve_column(self, self.avg_gift_idx)

    self.beats_rule_: Optional[bool] = None
    self.rule_mae_: Optional[float] = None
    self.model_mae_: Optional[float] = None
    has_idx = self.last_gift_idx_ is not None and self.avg_gift_idx_ is not None
    if has_idx and X.shape[0] >= _SELF_CHECK_MIN_ROWS:
        w = np.ones(X.shape[0]) if sample_weight is None else sample_weight
        X_tr, X_ho, y_tr, y_ho, w_tr, w_ho = train_test_split(
            X, y, w, test_size=0.2, random_state=self.random_state
        )
        model_pred = self._predict_backend(
            self._fit_backend(X_tr, y_tr, w_tr), X_ho
        )
        rule_pred = suggest_ask(
            X_ho[:, self.last_gift_idx_],
            X_ho[:, self.avg_gift_idx_],
            stretch=0.0,
            round_to=None,
        )
        rule_pred = np.where(np.isnan(rule_pred), self.ask_floor, rule_pred)
        rule_pred = np.maximum(rule_pred, self.ask_floor)
        self.rule_mae_ = float(np.average(np.abs(y_ho - rule_pred), weights=w_ho))
        self.model_mae_ = float(np.average(np.abs(y_ho - model_pred), weights=w_ho))
        self.beats_rule_ = self.model_mae_ < self.rule_mae_

    self.estimator_ = self._fit_backend(X, y, sample_weight)
    return self

predict(X)

Predict the base ask amount for each prospect.

Source code in philanthropy/models/_ask.py
def predict(self, X: Any) -> np.ndarray:
    """Predict the base ask amount for each prospect."""
    check_is_fitted(self, ["estimator_"])
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    return self._predict_backend(self.estimator_, X)

ask_ladder(X, multipliers=(1.0, 1.5, 2.5))

Return a discrete gift array (ask ladder) for each prospect.

The base ask (from :meth:predict) multiplied by each entry of multipliers gives the low / target / stretch rungs a gift officer works from when structuring a solicitation.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix passed to :meth:predict. May contain NaN.

required
multipliers sequence of float

Ascending positive factors applied to the base ask to build each rung of the gift array. Must be non-empty and strictly positive.

(1.0, 1.5, 2.5)

Returns:

Name Type Description
ask_array ndarray of shape (n_samples, len(multipliers))

Element [i, j] is base_ask[i] * multipliers[j].

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

ValueError

If multipliers is empty or contains a non-positive value.

Examples:

>>> import numpy as np
>>> from philanthropy.models import AskAmountRecommender
>>> rng = np.random.default_rng(7)
>>> X = rng.uniform(0, 1e6, (50, 4))
>>> y = rng.uniform(1e3, 1e5, 50)
>>> model = AskAmountRecommender(random_state=7).fit(X, y)
>>> ladder = model.ask_ladder(X, multipliers=(1.0, 2.0, 4.0))
>>> ladder.shape
(50, 3)
>>> bool((ladder[:, 2] >= ladder[:, 0]).all())
True
Source code in philanthropy/models/_ask.py
def ask_ladder(
    self,
    X: Any,
    multipliers: Any = (1.0, 1.5, 2.5),
) -> np.ndarray:
    """Return a discrete gift array (ask ladder) for each prospect.

    The base ask (from :meth:`predict`) multiplied by each entry of
    ``multipliers`` gives the low / target / stretch rungs a gift officer
    works from when structuring a solicitation.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix passed to :meth:`predict`.  May contain ``NaN``.
    multipliers : sequence of float, default=(1.0, 1.5, 2.5)
        Ascending positive factors applied to the base ask to build each
        rung of the gift array.  Must be non-empty and strictly positive.

    Returns
    -------
    ask_array : ndarray of shape (n_samples, len(multipliers))
        Element ``[i, j]`` is ``base_ask[i] * multipliers[j]``.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    ValueError
        If ``multipliers`` is empty or contains a non-positive value.

    Examples
    --------
    >>> import numpy as np
    >>> from philanthropy.models import AskAmountRecommender
    >>> rng = np.random.default_rng(7)
    >>> X = rng.uniform(0, 1e6, (50, 4))
    >>> y = rng.uniform(1e3, 1e5, 50)
    >>> model = AskAmountRecommender(random_state=7).fit(X, y)
    >>> ladder = model.ask_ladder(X, multipliers=(1.0, 2.0, 4.0))
    >>> ladder.shape
    (50, 3)
    >>> bool((ladder[:, 2] >= ladder[:, 0]).all())
    True
    """
    multipliers = np.asarray(multipliers, dtype=float)
    if multipliers.size == 0:
        raise ValueError("`multipliers` must be non-empty.")
    if not np.all(multipliers > 0):
        raise ValueError("`multipliers` must all be strictly positive.")

    base_ask = self.predict(X)
    return base_ask[:, None] * multipliers[None, :]

DonorPropensityModel

Bases: ClassifierMixin, BaseEstimator

Predict whether a hospital prospect is a major-gift donor.

DonorPropensityModel wraps a :class:sklearn.ensemble.RandomForestClassifier and is designed specifically for hospital advancement and major-gift fundraising teams. Given a feature matrix describing donors (e.g. recency, frequency, monetary value, event attendance, giving capacity estimates), the model outputs:

  • Binary predictions (predict): 0 for standard donors, 1 for major-gift prospects above the team's threshold.
  • Probability estimates (predict_proba): class probabilities in the standard sklearn two-column format. These come straight from the underlying RandomForestClassifier and are not calibrated; see :meth:predict_affinity_score for details.
  • Affinity scores (predict_affinity_score): the positive-class probability mapped to a 0–100 integer scale, enabling gift officers to quickly rank prospects in wealth-screening reports or CRM dashboards (e.g. Salesforce NPSP, Raiser's Edge NXT, Veeva CRM).

The model is pipeline-safe and passes sklearn.utils.estimator_checks. check_estimator.

Parameters:

Name Type Description Default
n_estimators int

Number of trees in the underlying :class:RandomForestClassifier. Increase for more stable probability estimates at the cost of inference speed.

100
max_depth int or None

Maximum depth of each decision tree. None allows trees to grow until leaves are pure, which may overfit on small prospect pools; set to 5–10 for regularisation.

None
min_samples_split int or float

Minimum number of samples (or fraction) required to split an internal node. Larger values act as a regulariser, improving generalisation on sparse hospital datasets.

2
min_samples_leaf int or float

Minimum number of samples required to be at a leaf node. An int is an absolute count; a float is a fraction of the training rows (ceil(min_samples_leaf * n_samples)), so the default scales with file size: about 2 rows per leaf on 250 donors, 400 on 50,000. Changed from 1: with max_depth=None, single-sample leaves let the forest memorise the training set, collapsing predict_proba to near-0/1 votes and turning the affinity-score ranking into noise. 0.008 was chosen on a KDD98 validation fold (not the test split) among 0.001/0.002/0.004/0.008 and confirmed on a second independent split; see scripts/benchmark_models_vs_baselines.py.

0.008
min_weight_fraction_leaf float

Minimum weighted fraction of the sum of weights required to be at a leaf node. When class_weight is set, this interacts strongly with weight values and can be used to prevent minority-class leaves.

0.0
class_weight (dict, 'balanced', 'balanced_subsample' or None)

Weight scheme for the two classes. Pass "balanced" to let the model automatically compensate for class imbalance (recommended when major-donor examples are <5 % of your prospect pool), or supply an explicit dict such as {0: 1, 1: 10} for finer control.

None
random_state int or None

Seed for the internal random-number generator. Pass an integer to make model training fully reproducible, important for audit trails in gift-officer accountability dashboards.

None

Attributes:

Name Type Description
estimator_ RandomForestClassifier

The fitted backend estimator. Inspect via model.estimator_.feature_importances_ to surface the top propensity drivers for stewardship reporting.

classes_ ndarray of shape (n_classes,)

The unique class labels seen during :meth:fit. Typically array([0, 1]).

n_features_in_ int

Number of features seen during :meth:fit.

Examples:

Basic usage with synthetic data:

>>> from philanthropy.datasets import generate_synthetic_donor_data
>>> from philanthropy.models import DonorPropensityModel
>>> df = generate_synthetic_donor_data(n_samples=500, random_state=0)
>>> feature_cols = [
...     "total_gift_amount", "years_active", "event_attendance_count"
... ]
>>> X = df[feature_cols].to_numpy()
>>> y = df["is_major_donor"].to_numpy()
>>> model = DonorPropensityModel(random_state=42)
>>> model.fit(X, y)
DonorPropensityModel(random_state=42)
>>> scores = model.predict_affinity_score(X)
>>> bool(scores.min() >= 0 and scores.max() <= 100)
True

Pipeline integration:

>>> from sklearn.pipeline import Pipeline
>>> from sklearn.preprocessing import StandardScaler
>>> pipe = Pipeline([
...     ("scaler", StandardScaler()),
...     ("model", DonorPropensityModel(n_estimators=200, random_state=0)),
... ])
>>> pipe.fit(X, y)
Pipeline(...)
Notes

Why RandomForest? Random forests are a natural fit for philanthropic data science because:

  1. They handle the diverse mix of numerical and ordinal features common in CRM exports (recency in days, monetary amounts spanning four orders of magnitude, event counts) without feature scaling.
  2. Their ensemble nature provides stable, rank-ordered probability estimates suitable for affinity scoring (though not calibrated probabilities; see the note under :meth:predict_affinity_score).
  3. Feature importances are easily explained to non-technical gift officers and development committees.

Affinity Score Interpretation (0–100 scale):

====== ================================= Range Recommended action ====== ================================= 80–100 Premium prospect: assign major gift officer immediately. 60–79 Strong prospect: include in next biannual solicitation cycle. 40–59 Moderate prospect: steward via annual fund or planned giving. 0–39 Low propensity: retain in broad annual-appeal pool. ====== =================================

Isotonic calibration was checked and is not recommended. Wrapping the model in CalibratedClassifierCV(method="isotonic", cv=5) on the KDD Cup 1998 validation fold (five seeds) lowered the decile calibration gap on only three seeds (it is already 0.003 to 0.005 there) and lowered the top-10% hit rate on all five (e.g. 9.43 to 9.15 of 100). The wrapper is still a valid sklearn pipeline step if you need probabilities on your own file; check it on held-out data first.

See Also

philanthropy.datasets.generate_synthetic_donor_data : Generate a synthetic prospect pool to prototype this model. philanthropy.metrics.donor_retention_rate : Measure year-over-year donor retention alongside propensity scoring.

Source code in philanthropy/models/_propensity.py
class DonorPropensityModel(ClassifierMixin, BaseEstimator):
    """Predict whether a hospital prospect is a major-gift donor.

    ``DonorPropensityModel`` wraps a :class:`sklearn.ensemble.RandomForestClassifier`
    and is designed specifically for hospital advancement and major-gift
    fundraising teams.  Given a feature matrix describing donors (e.g. recency,
    frequency, monetary value, event attendance, giving capacity estimates), the
    model outputs:

    * **Binary predictions** (``predict``): 0 for standard donors, 1 for
      major-gift prospects above the team's threshold.
    * **Probability estimates** (``predict_proba``): class probabilities in
      the standard sklearn two-column format. These come straight from the
      underlying ``RandomForestClassifier`` and are not calibrated; see
      :meth:`predict_affinity_score` for details.
    * **Affinity scores** (``predict_affinity_score``): the positive-class
      probability mapped to a 0–100 integer scale, enabling gift officers to
      quickly rank prospects in wealth-screening reports or CRM dashboards
      (e.g. Salesforce NPSP, Raiser's Edge NXT, Veeva CRM).

    The model is pipeline-safe and passes ``sklearn.utils.estimator_checks.
    check_estimator``.

    Parameters
    ----------
    n_estimators : int, default=100
        Number of trees in the underlying :class:`RandomForestClassifier`.
        Increase for more stable probability estimates at the cost of
        inference speed.
    max_depth : int or None, default=None
        Maximum depth of each decision tree.  ``None`` allows trees to grow
        until leaves are pure, which may overfit on small prospect pools;
        set to 5–10 for regularisation.
    min_samples_split : int or float, default=2
        Minimum number of samples (or fraction) required to split an internal
        node.  Larger values act as a regulariser, improving generalisation
        on sparse hospital datasets.
    min_samples_leaf : int or float, default=0.008
        Minimum number of samples required to be at a leaf node. An int is
        an absolute count; a float is a fraction of the training rows
        (``ceil(min_samples_leaf * n_samples)``), so the default scales with
        file size: about 2 rows per leaf on 250 donors, 400 on 50,000.
        Changed from 1: with ``max_depth=None``, single-sample leaves let
        the forest memorise the training set, collapsing ``predict_proba``
        to near-0/1 votes and turning the affinity-score ranking into noise.
        0.008 was chosen on a KDD98 validation fold (not the test split)
        among 0.001/0.002/0.004/0.008 and confirmed on a second independent
        split; see ``scripts/benchmark_models_vs_baselines.py``.
    min_weight_fraction_leaf : float, default=0.0
        Minimum weighted fraction of the sum of weights required to be at a
        leaf node.  When ``class_weight`` is set, this interacts strongly with
        weight values and can be used to prevent minority-class leaves.
    class_weight : dict, "balanced", "balanced_subsample" or None, default=None
        Weight scheme for the two classes.  Pass ``"balanced"`` to let the
        model automatically compensate for class imbalance (recommended when
        major-donor examples are <5 % of your prospect pool), or supply an
        explicit dict such as ``{0: 1, 1: 10}`` for finer control.
    random_state : int or None, default=None
        Seed for the internal random-number generator.  Pass an integer to
        make model training fully reproducible, important for audit trails
        in gift-officer accountability dashboards.

    Attributes
    ----------
    estimator_ : RandomForestClassifier
        The fitted backend estimator.  Inspect via
        ``model.estimator_.feature_importances_`` to surface the top
        propensity drivers for stewardship reporting.
    classes_ : ndarray of shape (n_classes,)
        The unique class labels seen during :meth:`fit`.  Typically
        ``array([0, 1])``.
    n_features_in_ : int
        Number of features seen during :meth:`fit`.

    Examples
    --------
    **Basic usage with synthetic data:**

    >>> from philanthropy.datasets import generate_synthetic_donor_data
    >>> from philanthropy.models import DonorPropensityModel
    >>> df = generate_synthetic_donor_data(n_samples=500, random_state=0)
    >>> feature_cols = [
    ...     "total_gift_amount", "years_active", "event_attendance_count"
    ... ]
    >>> X = df[feature_cols].to_numpy()
    >>> y = df["is_major_donor"].to_numpy()
    >>> model = DonorPropensityModel(random_state=42)
    >>> model.fit(X, y)
    DonorPropensityModel(random_state=42)
    >>> scores = model.predict_affinity_score(X)
    >>> bool(scores.min() >= 0 and scores.max() <= 100)
    True

    **Pipeline integration:**

    >>> from sklearn.pipeline import Pipeline
    >>> from sklearn.preprocessing import StandardScaler
    >>> pipe = Pipeline([
    ...     ("scaler", StandardScaler()),
    ...     ("model", DonorPropensityModel(n_estimators=200, random_state=0)),
    ... ])
    >>> pipe.fit(X, y)
    Pipeline(...)

    Notes
    -----
    **Why RandomForest?**
    Random forests are a natural fit for philanthropic data science because:

    1. They handle the diverse mix of numerical and ordinal features common
       in CRM exports (recency in days, monetary amounts spanning four orders
       of magnitude, event counts) without feature scaling.
    2. Their ensemble nature provides stable, rank-ordered probability
       estimates suitable for affinity scoring (though not calibrated
       probabilities; see the note under :meth:`predict_affinity_score`).
    3. Feature importances are easily explained to non-technical gift
       officers and development committees.

    **Affinity Score Interpretation (0–100 scale):**

    ====== =================================
    Range  Recommended action
    ====== =================================
    80–100 Premium prospect: assign major gift officer immediately.
    60–79  Strong prospect: include in next biannual solicitation cycle.
    40–59  Moderate prospect: steward via annual fund or planned giving.
    0–39   Low propensity: retain in broad annual-appeal pool.
    ====== =================================

    **Isotonic calibration was checked and is not recommended.** Wrapping
    the model in ``CalibratedClassifierCV(method="isotonic", cv=5)`` on the
    KDD Cup 1998 validation fold (five seeds) lowered the decile
    calibration gap on only three seeds (it is already 0.003 to 0.005
    there) and lowered the top-10% hit rate on all five (e.g. 9.43 to 9.15
    of 100). The wrapper is still a valid sklearn pipeline step if you need
    probabilities on your own file; check it on held-out data first.

    See Also
    --------
    philanthropy.datasets.generate_synthetic_donor_data :
        Generate a synthetic prospect pool to prototype this model.
    philanthropy.metrics.donor_retention_rate :
        Measure year-over-year donor retention alongside propensity scoring.
    """

    def __sklearn_tags__(self) -> Tags:
        """Declare sklearn-compatible metadata tags for this estimator.

        Overrides the default :class:`ClassifierMixin` tags to indicate that
        ``DonorPropensityModel`` supports multi-class targets (inherited from
        the backend :class:`RandomForestClassifier`).

        Returns
        -------
        tags : Tags
            Populated sklearn Tags object.
        """
        tags = super().__sklearn_tags__()
        tags.classifier_tags.multi_class = True
        tags.input_tags.allow_nan = True
        return tags

    def __init__(
        self,
        n_estimators: int = 100,
        max_depth: Optional[int] = None,
        min_samples_split: int = 2,
        min_samples_leaf: Union[int, float] = 0.008,
        min_weight_fraction_leaf: float = 0.0,
        class_weight: Any = None,
        random_state: Optional[int] = None,
    ) -> None:
        self.n_estimators = n_estimators
        self.max_depth = max_depth
        self.min_samples_split = min_samples_split
        self.min_samples_leaf = min_samples_leaf
        self.min_weight_fraction_leaf = min_weight_fraction_leaf
        self.class_weight = class_weight
        self.random_state = random_state

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def fit(self: _SelfD, X: Any, y: Any) -> _SelfD:
        """Fit the DonorPropensityModel to labelled donor data.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.  Accepts NumPy arrays or Pandas DataFrames.
            Common features include RFM metrics, event attendance counts,
            and wealth-screening capacity estimates.
        y : array-like of shape (n_samples,)
            Binary target vector.  ``1`` indicates a major-gift prospect;
            ``0`` indicates a standard annual-fund donor.

        Returns
        -------
        self : DonorPropensityModel
            Fitted estimator (enables method chaining).

        Raises
        ------
        ValueError
            If ``X`` and ``y`` have incompatible shapes, or if ``y``
            contains values outside ``{0, 1}``.
        """
        X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)

        self.classes_ = unique_labels(y)
        self.n_features_in_ = X.shape[1]

        self.estimator_ = RandomForestClassifier(
            n_estimators=self.n_estimators,
            max_depth=self.max_depth,
            min_samples_split=self.min_samples_split,
            min_samples_leaf=self.min_samples_leaf,
            min_weight_fraction_leaf=self.min_weight_fraction_leaf,
            class_weight=self.class_weight,
            random_state=self.random_state,
        )
        self.estimator_.fit(X, y)

        return self

    def predict(self, X: Any) -> np.ndarray:
        """Predict binary major-donor labels for each prospect.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.  Must have the same number of columns as
            the data passed to :meth:`fit`.

        Returns
        -------
        y_pred : ndarray of shape (n_samples,)
            Predicted class labels (``0`` or ``1``).

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        return self.estimator_.predict(X)

    def predict_proba(self, X: Any) -> np.ndarray:
        """Return class-probability estimates for each prospect.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.

        Returns
        -------
        proba : ndarray of shape (n_samples, 2)
            Columns are ``[P(class=0), P(class=1)]``.  Each row sums to
            1.0.  The second column is the major-donor positive probability
            used internally by :meth:`predict_affinity_score`.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        return self.estimator_.predict_proba(X)

    def decision_function(self, X: np.ndarray) -> np.ndarray:
        """
        Raw P(major_donor) scores. Used by sklearn scoring and calibration.

        Returns
        -------
        np.ndarray of shape (n_samples,), dtype float64
            Scores for each sample. Centered at 0 for binary case to match
            predict threshold.
        """
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        proba = self.estimator_.predict_proba(X)
        if proba.shape[1] == 2:
            return proba[:, 1] - 0.5
        elif proba.shape[1] == 1:
            # Single class case: if classes_ is [1], prob of class 1 is all 1.0.
            # If classes_ is [0], prob of class 1 is all 0.0.
            if self.classes_[0] == 1:
                return np.ones(proba.shape[0]) - 0.5
            return np.zeros(proba.shape[0]) - 0.5
        return proba  # Multiclass

    def predict_affinity_score(self, X: np.ndarray) -> np.ndarray:
        """Map major-donor probability to a 0–100 affinity score.

        This method is the primary interface for gift officers and CRM
        integrations.  The raw ``predict_proba`` positive-class probability is
        linearly rescaled from [0.0, 1.0] to [0, 100] and rounded to two
        decimal places, making scores directly comparable across fiscal years
        and prospect cohorts.

        Affinity scores are monotonically equivalent to model probabilities,
        so any rank-ordering derived from probabilities is preserved.  Scores
        do **not** represent calibrated probabilities and should not be
        interpreted as the literal odds of a major gift.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.  Accepts NumPy arrays or Pandas DataFrames.

        Returns
        -------
        affinity_scores : ndarray of shape (n_samples,)
            Float values in the closed interval [0.0, 100.0].  Higher
            scores indicate stronger major-gift propensity.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.

        Examples
        --------
        >>> import numpy as np
        >>> from philanthropy.datasets import generate_synthetic_donor_data
        >>> from philanthropy.models import DonorPropensityModel
        >>> df = generate_synthetic_donor_data(500, random_state=7)
        >>> X = df[["total_gift_amount", "years_active",
        ...          "event_attendance_count"]].to_numpy()
        >>> y = df["is_major_donor"].to_numpy()
        >>> model = DonorPropensityModel(random_state=0).fit(X, y)
        >>> scores = model.predict_affinity_score(X)
        >>> scores.shape
        (500,)
        >>> bool((scores >= 0).all() and (scores <= 100).all())
        True
        """
        df = self.decision_function(X)
        if df.ndim == 1:
            return np.round((df + 0.5) * 100, 2)
        # Multiclass case: affinity score for "major gift" (usually class 1)
        # We assume class 1 is at index 1 if it exists
        if df.shape[1] > 1:
            return np.round(df[:, 1] * 100, 2)
        return np.round(df.ravel() * 100, 2)

__sklearn_tags__()

Declare sklearn-compatible metadata tags for this estimator.

Overrides the default :class:ClassifierMixin tags to indicate that DonorPropensityModel supports multi-class targets (inherited from the backend :class:RandomForestClassifier).

Returns:

Name Type Description
tags Tags

Populated sklearn Tags object.

Source code in philanthropy/models/_propensity.py
def __sklearn_tags__(self) -> Tags:
    """Declare sklearn-compatible metadata tags for this estimator.

    Overrides the default :class:`ClassifierMixin` tags to indicate that
    ``DonorPropensityModel`` supports multi-class targets (inherited from
    the backend :class:`RandomForestClassifier`).

    Returns
    -------
    tags : Tags
        Populated sklearn Tags object.
    """
    tags = super().__sklearn_tags__()
    tags.classifier_tags.multi_class = True
    tags.input_tags.allow_nan = True
    return tags

fit(X, y)

Fit the DonorPropensityModel to labelled donor data.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix. Accepts NumPy arrays or Pandas DataFrames. Common features include RFM metrics, event attendance counts, and wealth-screening capacity estimates.

required
y array-like of shape (n_samples,)

Binary target vector. 1 indicates a major-gift prospect; 0 indicates a standard annual-fund donor.

required

Returns:

Name Type Description
self DonorPropensityModel

Fitted estimator (enables method chaining).

Raises:

Type Description
ValueError

If X and y have incompatible shapes, or if y contains values outside {0, 1}.

Source code in philanthropy/models/_propensity.py
def fit(self: _SelfD, X: Any, y: Any) -> _SelfD:
    """Fit the DonorPropensityModel to labelled donor data.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.  Accepts NumPy arrays or Pandas DataFrames.
        Common features include RFM metrics, event attendance counts,
        and wealth-screening capacity estimates.
    y : array-like of shape (n_samples,)
        Binary target vector.  ``1`` indicates a major-gift prospect;
        ``0`` indicates a standard annual-fund donor.

    Returns
    -------
    self : DonorPropensityModel
        Fitted estimator (enables method chaining).

    Raises
    ------
    ValueError
        If ``X`` and ``y`` have incompatible shapes, or if ``y``
        contains values outside ``{0, 1}``.
    """
    X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)

    self.classes_ = unique_labels(y)
    self.n_features_in_ = X.shape[1]

    self.estimator_ = RandomForestClassifier(
        n_estimators=self.n_estimators,
        max_depth=self.max_depth,
        min_samples_split=self.min_samples_split,
        min_samples_leaf=self.min_samples_leaf,
        min_weight_fraction_leaf=self.min_weight_fraction_leaf,
        class_weight=self.class_weight,
        random_state=self.random_state,
    )
    self.estimator_.fit(X, y)

    return self

predict(X)

Predict binary major-donor labels for each prospect.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix. Must have the same number of columns as the data passed to :meth:fit.

required

Returns:

Name Type Description
y_pred ndarray of shape (n_samples,)

Predicted class labels (0 or 1).

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/models/_propensity.py
def predict(self, X: Any) -> np.ndarray:
    """Predict binary major-donor labels for each prospect.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.  Must have the same number of columns as
        the data passed to :meth:`fit`.

    Returns
    -------
    y_pred : ndarray of shape (n_samples,)
        Predicted class labels (``0`` or ``1``).

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    return self.estimator_.predict(X)

predict_proba(X)

Return class-probability estimates for each prospect.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required

Returns:

Name Type Description
proba ndarray of shape (n_samples, 2)

Columns are [P(class=0), P(class=1)]. Each row sums to 1.0. The second column is the major-donor positive probability used internally by :meth:predict_affinity_score.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/models/_propensity.py
def predict_proba(self, X: Any) -> np.ndarray:
    """Return class-probability estimates for each prospect.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.

    Returns
    -------
    proba : ndarray of shape (n_samples, 2)
        Columns are ``[P(class=0), P(class=1)]``.  Each row sums to
        1.0.  The second column is the major-donor positive probability
        used internally by :meth:`predict_affinity_score`.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    return self.estimator_.predict_proba(X)

decision_function(X)

Raw P(major_donor) scores. Used by sklearn scoring and calibration.

Returns:

Type Description
np.ndarray of shape (n_samples,), dtype float64

Scores for each sample. Centered at 0 for binary case to match predict threshold.

Source code in philanthropy/models/_propensity.py
def decision_function(self, X: np.ndarray) -> np.ndarray:
    """
    Raw P(major_donor) scores. Used by sklearn scoring and calibration.

    Returns
    -------
    np.ndarray of shape (n_samples,), dtype float64
        Scores for each sample. Centered at 0 for binary case to match
        predict threshold.
    """
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    proba = self.estimator_.predict_proba(X)
    if proba.shape[1] == 2:
        return proba[:, 1] - 0.5
    elif proba.shape[1] == 1:
        # Single class case: if classes_ is [1], prob of class 1 is all 1.0.
        # If classes_ is [0], prob of class 1 is all 0.0.
        if self.classes_[0] == 1:
            return np.ones(proba.shape[0]) - 0.5
        return np.zeros(proba.shape[0]) - 0.5
    return proba  # Multiclass

predict_affinity_score(X)

Map major-donor probability to a 0–100 affinity score.

This method is the primary interface for gift officers and CRM integrations. The raw predict_proba positive-class probability is linearly rescaled from [0.0, 1.0] to [0, 100] and rounded to two decimal places, making scores directly comparable across fiscal years and prospect cohorts.

Affinity scores are monotonically equivalent to model probabilities, so any rank-ordering derived from probabilities is preserved. Scores do not represent calibrated probabilities and should not be interpreted as the literal odds of a major gift.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix. Accepts NumPy arrays or Pandas DataFrames.

required

Returns:

Name Type Description
affinity_scores ndarray of shape (n_samples,)

Float values in the closed interval [0.0, 100.0]. Higher scores indicate stronger major-gift propensity.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Examples:

>>> import numpy as np
>>> from philanthropy.datasets import generate_synthetic_donor_data
>>> from philanthropy.models import DonorPropensityModel
>>> df = generate_synthetic_donor_data(500, random_state=7)
>>> X = df[["total_gift_amount", "years_active",
...          "event_attendance_count"]].to_numpy()
>>> y = df["is_major_donor"].to_numpy()
>>> model = DonorPropensityModel(random_state=0).fit(X, y)
>>> scores = model.predict_affinity_score(X)
>>> scores.shape
(500,)
>>> bool((scores >= 0).all() and (scores <= 100).all())
True
Source code in philanthropy/models/_propensity.py
def predict_affinity_score(self, X: np.ndarray) -> np.ndarray:
    """Map major-donor probability to a 0–100 affinity score.

    This method is the primary interface for gift officers and CRM
    integrations.  The raw ``predict_proba`` positive-class probability is
    linearly rescaled from [0.0, 1.0] to [0, 100] and rounded to two
    decimal places, making scores directly comparable across fiscal years
    and prospect cohorts.

    Affinity scores are monotonically equivalent to model probabilities,
    so any rank-ordering derived from probabilities is preserved.  Scores
    do **not** represent calibrated probabilities and should not be
    interpreted as the literal odds of a major gift.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.  Accepts NumPy arrays or Pandas DataFrames.

    Returns
    -------
    affinity_scores : ndarray of shape (n_samples,)
        Float values in the closed interval [0.0, 100.0].  Higher
        scores indicate stronger major-gift propensity.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.

    Examples
    --------
    >>> import numpy as np
    >>> from philanthropy.datasets import generate_synthetic_donor_data
    >>> from philanthropy.models import DonorPropensityModel
    >>> df = generate_synthetic_donor_data(500, random_state=7)
    >>> X = df[["total_gift_amount", "years_active",
    ...          "event_attendance_count"]].to_numpy()
    >>> y = df["is_major_donor"].to_numpy()
    >>> model = DonorPropensityModel(random_state=0).fit(X, y)
    >>> scores = model.predict_affinity_score(X)
    >>> scores.shape
    (500,)
    >>> bool((scores >= 0).all() and (scores <= 100).all())
    True
    """
    df = self.decision_function(X)
    if df.ndim == 1:
        return np.round((df + 0.5) * 100, 2)
    # Multiclass case: affinity score for "major gift" (usually class 1)
    # We assume class 1 is at index 1 if it exists
    if df.shape[1] > 1:
        return np.round(df[:, 1] * 100, 2)
    return np.round(df.ravel() * 100, 2)

MajorGiftClassifier

Bases: ClassifierMixin, BaseEstimator

Classifies whether a donor is likely to make a major gift using calibrated probabilities.

This uses HistGradientBoostingClassifier to handle missing data natively, and wraps it in a CalibratedClassifierCV so the output probabilities are true calibrated probabilities. Calibration uses sklearn's default sigmoid (Platt) method over 5 folds, the stable choice on small files; isotonic calibration needs far more rows per fold and is not used. Boosting stops early on its own once a file has more than 10,000 rows (sklearn's early_stopping="auto"), so raising max_iter or lowering learning_rate rarely changes the ranking: on a KDD Cup 1998 validation fold (55/15/30 split, five seeds, response target), learning_rate=0.05, max_iter=300 scored the same as the defaults.

Parameters:

Name Type Description Default
max_iter int

Maximum number of boosting iterations for the underlying :class:HistGradientBoostingClassifier.

100
learning_rate float

Shrinkage applied to each boosting iteration.

0.1
min_samples_leaf int

Minimum number of samples per leaf, passed straight to the underlying :class:HistGradientBoostingClassifier.

20
max_leaf_nodes int or None

Maximum number of leaves per tree, passed straight to the underlying :class:HistGradientBoostingClassifier.

31
monotonic_cst array-like of int or dict

Monotonic constraint on each feature, passed straight to the underlying :class:HistGradientBoostingClassifier. None applies no constraint (current behaviour); see sklearn's docs for the {-1, 0, 1} per-feature encoding. A dict sets only the features it names, keyed by position or, when X is a DataFrame, column name ({"total_giving": 1}). Do not expect the textbook RFM constraint (+1 frequency and monetary, -1 recency) to help: on a KDD Cup 1998 validation fold (55/15/30 split, five seeds, response target) it lowered mean ROC-AUC from 0.601 to 0.595 and the top-10% hit rate from 9.5% to 9.0%.

None
class_weight (dict, 'balanced' or None)

Weight scheme for the two classes. Useful when the major-gift class is rare (e.g. a 2-3% base rate), where predict can otherwise favour the majority class almost exclusively. Applied as sample_weight during fitting rather than passed to the underlying :class:HistGradientBoostingClassifier's own class_weight, because :class:CalibratedClassifierCV recalibrates probabilities from cross-validated folds and would otherwise wash out (or even invert) a class-weighted base estimator's decision boundary. When class_weight is set, the calibrated probabilities reflect the reweighted class balance rather than the true one, so treat them as a ranking signal rather than literal chances of a major gift.

None
random_state int or None

Seed for reproducibility.

None
Source code in philanthropy/models/_propensity.py
class MajorGiftClassifier(ClassifierMixin, BaseEstimator):
    """
    Classifies whether a donor is likely to make a major gift using calibrated probabilities.

    This uses HistGradientBoostingClassifier to handle missing data natively, and wraps
    it in a CalibratedClassifierCV so the output probabilities are true calibrated probabilities.
    Calibration uses sklearn's default sigmoid (Platt) method over 5 folds, the
    stable choice on small files; isotonic calibration needs far more rows per
    fold and is not used. Boosting stops early on its own once a file has more
    than 10,000 rows (sklearn's ``early_stopping="auto"``), so raising
    ``max_iter`` or lowering ``learning_rate`` rarely changes the ranking: on a
    KDD Cup 1998 validation fold (55/15/30 split, five seeds, response target),
    ``learning_rate=0.05, max_iter=300`` scored the same as the defaults.

    Parameters
    ----------
    max_iter : int, default=100
        Maximum number of boosting iterations for the underlying
        :class:`HistGradientBoostingClassifier`.
    learning_rate : float, default=0.1
        Shrinkage applied to each boosting iteration.
    min_samples_leaf : int, default=20
        Minimum number of samples per leaf, passed straight to the underlying
        :class:`HistGradientBoostingClassifier`.
    max_leaf_nodes : int or None, default=31
        Maximum number of leaves per tree, passed straight to the underlying
        :class:`HistGradientBoostingClassifier`.
    monotonic_cst : array-like of int or dict, default=None
        Monotonic constraint on each feature, passed straight to the
        underlying :class:`HistGradientBoostingClassifier`. ``None`` applies
        no constraint (current behaviour); see sklearn's docs for the
        ``{-1, 0, 1}`` per-feature encoding. A dict sets only the features
        it names, keyed by position or, when ``X`` is a DataFrame, column
        name (``{"total_giving": 1}``). Do not expect the textbook RFM
        constraint (+1 frequency and monetary, -1 recency) to help: on a
        KDD Cup 1998 validation fold (55/15/30 split, five seeds, response
        target) it lowered mean ROC-AUC from 0.601 to 0.595 and the top-10%
        hit rate from 9.5% to 9.0%.
    class_weight : dict, "balanced" or None, default=None
        Weight scheme for the two classes. Useful when the major-gift class
        is rare (e.g. a 2-3% base rate), where ``predict`` can otherwise
        favour the majority class almost exclusively. Applied as
        ``sample_weight`` during fitting rather than passed to the
        underlying :class:`HistGradientBoostingClassifier`'s own
        ``class_weight``, because :class:`CalibratedClassifierCV` recalibrates
        probabilities from cross-validated folds and would otherwise wash out
        (or even invert) a class-weighted base estimator's decision boundary.
        When ``class_weight`` is set, the calibrated probabilities reflect
        the reweighted class balance rather than the true one, so treat them
        as a ranking signal rather than literal chances of a major gift.
    random_state : int or None, default=None
        Seed for reproducibility.
    """
    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.allow_nan = True
        tags.classifier_tags.multi_class = True
        return tags

    def __init__(
        self,
        max_iter: int = 100,
        learning_rate: float = 0.1,
        min_samples_leaf: int = 20,
        max_leaf_nodes: Optional[int] = 31,
        monotonic_cst: Any = None,
        class_weight: Any = None,
        random_state: Optional[int] = None,
    ) -> None:
        self.max_iter = max_iter
        self.learning_rate = learning_rate
        self.min_samples_leaf = min_samples_leaf
        self.max_leaf_nodes = max_leaf_nodes
        self.monotonic_cst = monotonic_cst
        self.class_weight = class_weight
        self.random_state = random_state

    def fit(self: _SelfM, X: Any, y: Any) -> _SelfM:
        """Fit the classifier to labelled donor data.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix. Missing values are accepted.
        y : array-like of shape (n_samples,)
            Binary target vector. ``1`` indicates a major-gift prospect.

        Returns
        -------
        self : MajorGiftClassifier
            Fitted estimator. Sets ``classes_``, ``n_features_in_``,
            ``estimator_``, and ``n_iter_`` (the mean boosting iterations
            across the calibration folds).
        """
        X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
        self.classes_ = unique_labels(y)
        self.n_features_in_ = X.shape[1]

        base_estimator = HistGradientBoostingClassifier(
            max_iter=self.max_iter,
            learning_rate=self.learning_rate,
            min_samples_leaf=self.min_samples_leaf,
            max_leaf_nodes=self.max_leaf_nodes,
            monotonic_cst=resolve_monotonic_cst(self, self.monotonic_cst),
            random_state=self.random_state
        )
        self.estimator_ = CalibratedClassifierCV(base_estimator)
        # class_weight is applied as sample_weight rather than passed to the
        # base estimator's own class_weight param: CalibratedClassifierCV
        # recalibrates probabilities from cross-validated folds, which
        # otherwise washes out (and can even invert) a class-weighted base
        # estimator's decision boundary. Feeding the same weights as
        # sample_weight into fit keeps the calibration step consistent with
        # the requested class balance.
        sample_weight = (
            compute_sample_weight(self.class_weight, y)
            if self.class_weight is not None
            else None
        )
        self.estimator_.fit(X, y, sample_weight=sample_weight)
        # Mean boosting iterations across the calibration folds. Reporting a
        # hardcoded 1 here just to satisfy check_estimator would be masking.
        self.n_iter_ = int(
            np.mean([c.estimator.n_iter_ for c in self.estimator_.calibrated_classifiers_])
        )
        return self

    def predict(self, X: Any) -> np.ndarray:
        """Predict binary major-donor labels.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix with the same columns used in :meth:`fit`.

        Returns
        -------
        y_pred : ndarray of shape (n_samples,)
            Predicted class labels (``0`` or ``1``).

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        return self.estimator_.predict(X)

    def predict_proba(self, X: Any) -> np.ndarray:
        """Return calibrated class probabilities.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.

        Returns
        -------
        proba : ndarray of shape (n_samples, 2)
            Columns are ``[P(class=0), P(class=1)]``. The second column is
            the calibrated major-donor probability.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        return self.estimator_.predict_proba(X)

    def predict_affinity_score(self, X: Any) -> np.ndarray:
        """Map the calibrated major-donor probability to a 0-100 score.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.

        Returns
        -------
        scores : ndarray of shape (n_samples,)
            Positive-class probability multiplied by 100 and rounded to two
            decimal places. Class ``1`` is treated as the positive class.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        proba = self.predict_proba(X)
        if proba.shape[1] == 2:
            proba_positive = proba[:, 1]
        elif proba.shape[1] == 1:
            # Single class case (e.g. fit on all-0 or all-1 labels): classes_
            # has one entry, and predict_proba mirrors it with one column.
            if self.classes_[0] == 1:
                proba_positive = np.ones(proba.shape[0])
            else:
                proba_positive = np.zeros(proba.shape[0])
        else:
            proba_positive = proba[:, 1]  # Multiclass: class 1 by convention
        return np.round(proba_positive * 100.0, 2)

fit(X, y)

Fit the classifier to labelled donor data.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix. Missing values are accepted.

required
y array-like of shape (n_samples,)

Binary target vector. 1 indicates a major-gift prospect.

required

Returns:

Name Type Description
self MajorGiftClassifier

Fitted estimator. Sets classes_, n_features_in_, estimator_, and n_iter_ (the mean boosting iterations across the calibration folds).

Source code in philanthropy/models/_propensity.py
def fit(self: _SelfM, X: Any, y: Any) -> _SelfM:
    """Fit the classifier to labelled donor data.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix. Missing values are accepted.
    y : array-like of shape (n_samples,)
        Binary target vector. ``1`` indicates a major-gift prospect.

    Returns
    -------
    self : MajorGiftClassifier
        Fitted estimator. Sets ``classes_``, ``n_features_in_``,
        ``estimator_``, and ``n_iter_`` (the mean boosting iterations
        across the calibration folds).
    """
    X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
    self.classes_ = unique_labels(y)
    self.n_features_in_ = X.shape[1]

    base_estimator = HistGradientBoostingClassifier(
        max_iter=self.max_iter,
        learning_rate=self.learning_rate,
        min_samples_leaf=self.min_samples_leaf,
        max_leaf_nodes=self.max_leaf_nodes,
        monotonic_cst=resolve_monotonic_cst(self, self.monotonic_cst),
        random_state=self.random_state
    )
    self.estimator_ = CalibratedClassifierCV(base_estimator)
    # class_weight is applied as sample_weight rather than passed to the
    # base estimator's own class_weight param: CalibratedClassifierCV
    # recalibrates probabilities from cross-validated folds, which
    # otherwise washes out (and can even invert) a class-weighted base
    # estimator's decision boundary. Feeding the same weights as
    # sample_weight into fit keeps the calibration step consistent with
    # the requested class balance.
    sample_weight = (
        compute_sample_weight(self.class_weight, y)
        if self.class_weight is not None
        else None
    )
    self.estimator_.fit(X, y, sample_weight=sample_weight)
    # Mean boosting iterations across the calibration folds. Reporting a
    # hardcoded 1 here just to satisfy check_estimator would be masking.
    self.n_iter_ = int(
        np.mean([c.estimator.n_iter_ for c in self.estimator_.calibrated_classifiers_])
    )
    return self

predict(X)

Predict binary major-donor labels.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix with the same columns used in :meth:fit.

required

Returns:

Name Type Description
y_pred ndarray of shape (n_samples,)

Predicted class labels (0 or 1).

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/models/_propensity.py
def predict(self, X: Any) -> np.ndarray:
    """Predict binary major-donor labels.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix with the same columns used in :meth:`fit`.

    Returns
    -------
    y_pred : ndarray of shape (n_samples,)
        Predicted class labels (``0`` or ``1``).

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    return self.estimator_.predict(X)

predict_proba(X)

Return calibrated class probabilities.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required

Returns:

Name Type Description
proba ndarray of shape (n_samples, 2)

Columns are [P(class=0), P(class=1)]. The second column is the calibrated major-donor probability.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/models/_propensity.py
def predict_proba(self, X: Any) -> np.ndarray:
    """Return calibrated class probabilities.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.

    Returns
    -------
    proba : ndarray of shape (n_samples, 2)
        Columns are ``[P(class=0), P(class=1)]``. The second column is
        the calibrated major-donor probability.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    return self.estimator_.predict_proba(X)

predict_affinity_score(X)

Map the calibrated major-donor probability to a 0-100 score.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required

Returns:

Name Type Description
scores ndarray of shape (n_samples,)

Positive-class probability multiplied by 100 and rounded to two decimal places. Class 1 is treated as the positive class.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/models/_propensity.py
def predict_affinity_score(self, X: Any) -> np.ndarray:
    """Map the calibrated major-donor probability to a 0-100 score.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.

    Returns
    -------
    scores : ndarray of shape (n_samples,)
        Positive-class probability multiplied by 100 and rounded to two
        decimal places. Class ``1`` is treated as the positive class.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    proba = self.predict_proba(X)
    if proba.shape[1] == 2:
        proba_positive = proba[:, 1]
    elif proba.shape[1] == 1:
        # Single class case (e.g. fit on all-0 or all-1 labels): classes_
        # has one entry, and predict_proba mirrors it with one column.
        if self.classes_[0] == 1:
            proba_positive = np.ones(proba.shape[0])
        else:
            proba_positive = np.zeros(proba.shape[0])
    else:
        proba_positive = proba[:, 1]  # Multiclass: class 1 by convention
    return np.round(proba_positive * 100.0, 2)

ShareOfWalletRegressor

Bases: RegressorMixin, BaseEstimator

Predict a donor's total philanthropic capacity (share-of-wallet).

ShareOfWalletRegressor is a scikit-learn–compatible regressor that wraps :class:~sklearn.ensemble.HistGradientBoostingRegressor to estimate a prospect's total philanthropic capacity, i.e. the maximum lifetime gift they could make given their wealth profile, giving history, and engagement signals.

By using HistGradientBoostingRegressor internally, the model handles missing CRM and wealth-screening values natively without requiring an upstream imputation step, reducing pipeline complexity and eliminating one source of potential leakage.

Structurally this is the same wrapper as :class:~philanthropy.models.AskAmountRecommender: same estimator, same NaN handling, different target and different domain method name. Both exist because the two quantities are separate columns in a real advancement workflow, not because the modelling differs. If you want a plain regressor, use HistGradientBoostingRegressor directly.

The companion method :meth:capacity_ratio exposes the untapped-capacity ratio (predicted capacity ÷ historical cumulative giving), the primary metric gift officers use to prioritise discovery calls and major-gift portfolios.

Parameters:

Name Type Description Default
learning_rate float

Step size shrinkage applied to each tree. Smaller values require more max_iter trees to converge but typically generalise better.

0.1
max_iter int

Number of boosting iterations (trees). Increase to 300–500 for production models trained on large prospect pools.

100
max_depth int or None

Maximum depth of each individual decision tree.

None
l2_regularization float

L2 regularisation term on leaf weights. Increase (e.g., to 1.0) to combat overfitting when the feature-to-sample ratio is high, a common scenario in small-shop advancement analytics.

0.0
min_samples_leaf int

Minimum number of samples per leaf. Larger values prevent overfitting on sparse major-donor training sets.

20
random_state int or None

Seed for the internal random-number generator. Set to an integer for reproducible model artefacts suitable for audit trails.

None
capacity_floor float

Minimum predicted capacity (in dollars). Predictions are clipped to this floor via np.maximum to prevent negative capacity estimates that are semantically meaningless.

1.0
categorical_features list of int or str, bool mask, or None

Columns the backend should split on as unordered categories (a region, constituency or appeal code) instead of as numbers, so they need no one-hot step. Positions, a boolean mask, or column names when X is a DataFrame. The columns must hold non-negative integer codes below 255, as for HistGradientBoostingRegressor. None treats every column as numeric.

None
monotonic_cst array-like of int or dict

Monotonic constraint on each feature, passed to the underlying :class:HistGradientBoostingRegressor: 1 means the capacity estimate never falls as that feature rises, -1 that it never rises, 0 no constraint. A dict sets only the features it names, keyed by position or, when X is a DataFrame, column name ({"total_giving": 1}). None applies no constraint (current behaviour).

None

Attributes:

Name Type Description
estimator_ HistGradientBoostingRegressor

The fitted backend estimator.

n_features_in_ int

Number of features seen during :meth:fit.

Examples:

Predict raw capacity and untapped-capacity ratio:

>>> import numpy as np
>>> from philanthropy.models import ShareOfWalletRegressor
>>> rng = np.random.default_rng(42)
>>> X = rng.uniform(0, 1e6, (200, 6))
>>> y = rng.uniform(5e4, 5e6, 200)
>>> historical = rng.uniform(1e3, 5e5, 200)
>>> model = ShareOfWalletRegressor(random_state=42).fit(X, y)
>>> model.predict(X[:3]).shape
(3,)
>>> ratios = model.capacity_ratio(X[:3], historical_giving=historical[:3])
>>> bool((ratios >= 0).all())
True

Pipeline usage:

>>> from sklearn.pipeline import Pipeline
>>> from philanthropy.preprocessing import WealthScreeningImputer
>>> # WealthScreeningImputer only used here for non-NaN-native context;
>>> # ShareOfWalletRegressor can handle NaN inputs natively.
>>> pipe = Pipeline([("model", ShareOfWalletRegressor(random_state=0))])
>>> _ = pipe.fit(X, y)
Notes

Why HistGradientBoosting? Wealth-screening datasets consistently contain 30–70 % missing values. HistGradientBoostingRegressor implements a native missing-value splitting strategy that treats NaN as an informative category rather than an erroneous artefact, avoiding the information loss of mean/median imputation.

Capacity Ratio Interpretation:

====== ===================================================== Ratio Recommended action ====== ===================================================== ≥ 10× Dramatically under-asked; schedule discovery call. 5–9× Significant untapped potential; major-gift candidate. 2–4× Moderate upside; consider upgrade ask. < 2× Near capacity; focus on retention and stewardship. ====== =====================================================

See Also

philanthropy.models.DonorPropensityModel : Binary propensity model; use alongside this regressor for a two-stage (propensity × capacity) portfolio ranking. philanthropy.preprocessing.WealthScreeningImputer : Optional upstream imputer for non-NaN-native downstream models.

Source code in philanthropy/models/_wallet.py
class ShareOfWalletRegressor(RegressorMixin, BaseEstimator):
    """Predict a donor's total philanthropic capacity (share-of-wallet).

    ``ShareOfWalletRegressor`` is a scikit-learn–compatible regressor that
    wraps :class:`~sklearn.ensemble.HistGradientBoostingRegressor` to estimate
    a prospect's **total philanthropic capacity**, i.e. the maximum lifetime
    gift they *could* make given their wealth profile, giving history, and
    engagement signals.

    By using ``HistGradientBoostingRegressor`` internally, the model handles
    missing CRM and wealth-screening values *natively* without requiring an
    upstream imputation step, reducing pipeline complexity and eliminating one
    source of potential leakage.

    Structurally this is the same wrapper as
    :class:`~philanthropy.models.AskAmountRecommender`: same estimator, same
    NaN handling, different target and different domain method name. Both exist
    because the two quantities are separate columns in a real advancement
    workflow, not because the modelling differs. If you want a plain regressor,
    use ``HistGradientBoostingRegressor`` directly.

    The companion method :meth:`capacity_ratio` exposes the
    **untapped-capacity ratio** (predicted capacity ÷ historical cumulative
    giving), the primary metric gift officers use to prioritise discovery
    calls and major-gift portfolios.

    Parameters
    ----------
    learning_rate : float, default=0.1
        Step size shrinkage applied to each tree.  Smaller values require
        more ``max_iter`` trees to converge but typically generalise better.
    max_iter : int, default=100
        Number of boosting iterations (trees).  Increase to 300–500 for
        production models trained on large prospect pools.
    max_depth : int or None, default=None
        Maximum depth of each individual decision tree.
    l2_regularization : float, default=0.0
        L2 regularisation term on leaf weights.  Increase (e.g., to 1.0)
        to combat overfitting when the feature-to-sample ratio is high,
        a common scenario in small-shop advancement analytics.
    min_samples_leaf : int, default=20
        Minimum number of samples per leaf.  Larger values prevent
        overfitting on sparse major-donor training sets.
    random_state : int or None, default=None
        Seed for the internal random-number generator.  Set to an integer
        for reproducible model artefacts suitable for audit trails.
    capacity_floor : float, default=1.0
        Minimum predicted capacity (in dollars).  Predictions are clipped
        to this floor via ``np.maximum`` to prevent negative capacity
        estimates that are semantically meaningless.
    categorical_features : list of int or str, bool mask, or None, default=None
        Columns the backend should split on as unordered categories (a
        region, constituency or appeal code) instead of as numbers, so they
        need no one-hot step. Positions, a boolean mask, or column names
        when ``X`` is a DataFrame. The columns must hold non-negative
        integer codes below 255, as for ``HistGradientBoostingRegressor``.
        ``None`` treats every column as numeric.
    monotonic_cst : array-like of int or dict, default=None
        Monotonic constraint on each feature, passed to the underlying
        :class:`HistGradientBoostingRegressor`: ``1`` means the capacity estimate never falls as
        that feature rises, ``-1`` that it never rises, ``0`` no
        constraint. A dict sets only the features it names, keyed by
        position or, when ``X`` is a DataFrame, column name
        (``{"total_giving": 1}``). ``None`` applies no constraint
        (current behaviour).

    Attributes
    ----------
    estimator_ : HistGradientBoostingRegressor
        The fitted backend estimator.
    n_features_in_ : int
        Number of features seen during :meth:`fit`.

    Examples
    --------
    **Predict raw capacity and untapped-capacity ratio:**

    >>> import numpy as np
    >>> from philanthropy.models import ShareOfWalletRegressor
    >>> rng = np.random.default_rng(42)
    >>> X = rng.uniform(0, 1e6, (200, 6))
    >>> y = rng.uniform(5e4, 5e6, 200)
    >>> historical = rng.uniform(1e3, 5e5, 200)
    >>> model = ShareOfWalletRegressor(random_state=42).fit(X, y)
    >>> model.predict(X[:3]).shape
    (3,)
    >>> ratios = model.capacity_ratio(X[:3], historical_giving=historical[:3])
    >>> bool((ratios >= 0).all())
    True

    **Pipeline usage:**

    >>> from sklearn.pipeline import Pipeline
    >>> from philanthropy.preprocessing import WealthScreeningImputer
    >>> # WealthScreeningImputer only used here for non-NaN-native context;
    >>> # ShareOfWalletRegressor can handle NaN inputs natively.
    >>> pipe = Pipeline([("model", ShareOfWalletRegressor(random_state=0))])
    >>> _ = pipe.fit(X, y)

    Notes
    -----
    **Why HistGradientBoosting?**
    Wealth-screening datasets consistently contain 30–70 % missing values.
    ``HistGradientBoostingRegressor`` implements a native missing-value
    splitting strategy that treats ``NaN`` as an informative category rather
    than an erroneous artefact, avoiding the information loss of mean/median
    imputation.

    **Capacity Ratio Interpretation:**

    ====== =====================================================
    Ratio  Recommended action
    ====== =====================================================
    ≥ 10×  Dramatically under-asked; schedule discovery call.
    5–9×   Significant untapped potential; major-gift candidate.
    2–4×   Moderate upside; consider upgrade ask.
    < 2×   Near capacity; focus on retention and stewardship.
    ====== =====================================================

    See Also
    --------
    philanthropy.models.DonorPropensityModel :
        Binary propensity model; use alongside this regressor for a
        two-stage (propensity × capacity) portfolio ranking.
    philanthropy.preprocessing.WealthScreeningImputer :
        Optional upstream imputer for non-NaN-native downstream models.
    """

    def __init__(
        self,
        learning_rate: float = 0.1,
        max_iter: int = 100,
        max_depth: Optional[int] = None,
        l2_regularization: float = 0.0,
        min_samples_leaf: int = 20,
        random_state: Optional[int] = None,
        capacity_floor: float = 1.0,
        categorical_features: Any = None,
        monotonic_cst: Any = None,
    ) -> None:
        # scikit-learn rule: __init__ stores parameters and does NO logic.
        self.learning_rate = learning_rate
        self.max_iter = max_iter
        self.max_depth = max_depth
        self.l2_regularization = l2_regularization
        self.min_samples_leaf = min_samples_leaf
        self.random_state = random_state
        self.capacity_floor = capacity_floor
        self.categorical_features = categorical_features
        self.monotonic_cst = monotonic_cst

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.allow_nan = True
        tags.regressor_tags.poor_score = True
        return tags

    @property
    def n_iter_(self) -> int:
        """Number of iterations run by the backend estimator."""
        check_is_fitted(self, ["estimator_"])
        return self.estimator_.n_iter_

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def fit(self: _Self, X: Any, y: Any, sample_weight: Any = None) -> _Self:
        """Fit the share-of-wallet capacity model to labelled prospect data.

        ``sample_weight``, if given, is passed to the backend.
        """
        X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
        self.n_features_in_ = X.shape[1]

        self.estimator_ = HistGradientBoostingRegressor(
            learning_rate=self.learning_rate,
            max_iter=self.max_iter,
            max_depth=self.max_depth,
            l2_regularization=self.l2_regularization,
            min_samples_leaf=self.min_samples_leaf,
            categorical_features=resolve_columns(self, self.categorical_features),
            monotonic_cst=resolve_monotonic_cst(self, self.monotonic_cst),
            random_state=self.random_state,
        )
        if sample_weight is not None:
            sample_weight = _check_sample_weight(sample_weight, X)
        self.estimator_.fit(X, y, sample_weight=sample_weight)
        return self

    def predict(self, X: Any) -> np.ndarray:
        """Predict philanthropic capacity for each prospect."""
        check_is_fitted(self, ["estimator_"])
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        raw = self.estimator_.predict(X)
        return np.maximum(raw, self.capacity_floor)

    def capacity_ratio(
        self,
        X: Any,
        historical_giving: np.ndarray,
    ) -> np.ndarray:
        """Return the predicted capacity-to-historical-giving ratio.

        This ratio is the primary metric for gift officers prioritising
        discovery calls.  A ratio of 5.0 means the model estimates the donor
        could give five times more than they have historically, a strong
        signal of untapped major-gift potential.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix passed to :meth:`predict`.  May contain ``NaN``.
        historical_giving : array-like of shape (n_samples,)
            Each donor's **cumulative historical giving** in dollars.  Values
            of zero or negative are replaced with ``1.0`` (the
            ``capacity_floor`` fallback) to avoid division-by-zero errors
            and to ensure semantically valid ratios for new donors with no
            prior giving history.

        Returns
        -------
        capacity_ratio : ndarray of shape (n_samples,)
            Element-wise ratio ``predicted_capacity / max(historical_giving, 1.0)``.
            Values ≥ 1.0 indicate untapped capacity; values < 1.0 indicate
            that the predicted capacity is below current cumulative giving
            (which may signal an over-generous prior record or model noise).

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        ValueError
            If ``historical_giving`` length does not match the number of
            rows in ``X``.

        Examples
        --------
        >>> import numpy as np
        >>> from philanthropy.models import ShareOfWalletRegressor
        >>> rng = np.random.default_rng(7)
        >>> X = rng.uniform(0, 1e6, (50, 4))
        >>> y = rng.uniform(1e4, 1e6, 50)
        >>> hist = rng.uniform(500, 1e5, 50)
        >>> model = ShareOfWalletRegressor(random_state=7).fit(X, y)
        >>> ratios = model.capacity_ratio(X, historical_giving=hist)
        >>> ratios.shape
        (50,)
        >>> bool((ratios > 0).all())
        True
        """
        check_is_fitted(self, ["estimator_"])
        predicted_capacity = self.predict(X)

        historical_giving = np.asarray(historical_giving, dtype=float)
        if predicted_capacity.shape[0] != historical_giving.shape[0]:
            raise ValueError(
                f"`historical_giving` must have the same length as the number "
                f"of rows in ``X`` ({predicted_capacity.shape[0]}), "
                f"got {historical_giving.shape[0]}."
            )

        # Clip denominator to prevent division by zero for new/zero-giving donors
        safe_giving = np.maximum(historical_giving, 1.0)
        return predicted_capacity / safe_giving

n_iter_ property

Number of iterations run by the backend estimator.

fit(X, y, sample_weight=None)

Fit the share-of-wallet capacity model to labelled prospect data.

sample_weight, if given, is passed to the backend.

Source code in philanthropy/models/_wallet.py
def fit(self: _Self, X: Any, y: Any, sample_weight: Any = None) -> _Self:
    """Fit the share-of-wallet capacity model to labelled prospect data.

    ``sample_weight``, if given, is passed to the backend.
    """
    X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
    self.n_features_in_ = X.shape[1]

    self.estimator_ = HistGradientBoostingRegressor(
        learning_rate=self.learning_rate,
        max_iter=self.max_iter,
        max_depth=self.max_depth,
        l2_regularization=self.l2_regularization,
        min_samples_leaf=self.min_samples_leaf,
        categorical_features=resolve_columns(self, self.categorical_features),
        monotonic_cst=resolve_monotonic_cst(self, self.monotonic_cst),
        random_state=self.random_state,
    )
    if sample_weight is not None:
        sample_weight = _check_sample_weight(sample_weight, X)
    self.estimator_.fit(X, y, sample_weight=sample_weight)
    return self

predict(X)

Predict philanthropic capacity for each prospect.

Source code in philanthropy/models/_wallet.py
def predict(self, X: Any) -> np.ndarray:
    """Predict philanthropic capacity for each prospect."""
    check_is_fitted(self, ["estimator_"])
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    raw = self.estimator_.predict(X)
    return np.maximum(raw, self.capacity_floor)

capacity_ratio(X, historical_giving)

Return the predicted capacity-to-historical-giving ratio.

This ratio is the primary metric for gift officers prioritising discovery calls. A ratio of 5.0 means the model estimates the donor could give five times more than they have historically, a strong signal of untapped major-gift potential.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix passed to :meth:predict. May contain NaN.

required
historical_giving array-like of shape (n_samples,)

Each donor's cumulative historical giving in dollars. Values of zero or negative are replaced with 1.0 (the capacity_floor fallback) to avoid division-by-zero errors and to ensure semantically valid ratios for new donors with no prior giving history.

required

Returns:

Name Type Description
capacity_ratio ndarray of shape (n_samples,)

Element-wise ratio predicted_capacity / max(historical_giving, 1.0). Values ≥ 1.0 indicate untapped capacity; values < 1.0 indicate that the predicted capacity is below current cumulative giving (which may signal an over-generous prior record or model noise).

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

ValueError

If historical_giving length does not match the number of rows in X.

Examples:

>>> import numpy as np
>>> from philanthropy.models import ShareOfWalletRegressor
>>> rng = np.random.default_rng(7)
>>> X = rng.uniform(0, 1e6, (50, 4))
>>> y = rng.uniform(1e4, 1e6, 50)
>>> hist = rng.uniform(500, 1e5, 50)
>>> model = ShareOfWalletRegressor(random_state=7).fit(X, y)
>>> ratios = model.capacity_ratio(X, historical_giving=hist)
>>> ratios.shape
(50,)
>>> bool((ratios > 0).all())
True
Source code in philanthropy/models/_wallet.py
def capacity_ratio(
    self,
    X: Any,
    historical_giving: np.ndarray,
) -> np.ndarray:
    """Return the predicted capacity-to-historical-giving ratio.

    This ratio is the primary metric for gift officers prioritising
    discovery calls.  A ratio of 5.0 means the model estimates the donor
    could give five times more than they have historically, a strong
    signal of untapped major-gift potential.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix passed to :meth:`predict`.  May contain ``NaN``.
    historical_giving : array-like of shape (n_samples,)
        Each donor's **cumulative historical giving** in dollars.  Values
        of zero or negative are replaced with ``1.0`` (the
        ``capacity_floor`` fallback) to avoid division-by-zero errors
        and to ensure semantically valid ratios for new donors with no
        prior giving history.

    Returns
    -------
    capacity_ratio : ndarray of shape (n_samples,)
        Element-wise ratio ``predicted_capacity / max(historical_giving, 1.0)``.
        Values ≥ 1.0 indicate untapped capacity; values < 1.0 indicate
        that the predicted capacity is below current cumulative giving
        (which may signal an over-generous prior record or model noise).

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    ValueError
        If ``historical_giving`` length does not match the number of
        rows in ``X``.

    Examples
    --------
    >>> import numpy as np
    >>> from philanthropy.models import ShareOfWalletRegressor
    >>> rng = np.random.default_rng(7)
    >>> X = rng.uniform(0, 1e6, (50, 4))
    >>> y = rng.uniform(1e4, 1e6, 50)
    >>> hist = rng.uniform(500, 1e5, 50)
    >>> model = ShareOfWalletRegressor(random_state=7).fit(X, y)
    >>> ratios = model.capacity_ratio(X, historical_giving=hist)
    >>> ratios.shape
    (50,)
    >>> bool((ratios > 0).all())
    True
    """
    check_is_fitted(self, ["estimator_"])
    predicted_capacity = self.predict(X)

    historical_giving = np.asarray(historical_giving, dtype=float)
    if predicted_capacity.shape[0] != historical_giving.shape[0]:
        raise ValueError(
            f"`historical_giving` must have the same length as the number "
            f"of rows in ``X`` ({predicted_capacity.shape[0]}), "
            f"got {historical_giving.shape[0]}."
        )

    # Clip denominator to prevent division by zero for new/zero-giving donors
    safe_giving = np.maximum(historical_giving, 1.0)
    return predicted_capacity / safe_giving

MovesManagementClassifier

Bases: ClassifierMixin, BaseEstimator

Predicts the next best moves management stage for a donor.

Wraps a :class:~sklearn.ensemble.HistGradientBoostingClassifier and labels its predictions with the moves-management stage names the donor was trained on, rather than requiring the caller to encode stages themselves. class_weight="balanced" is the default because moves stages are typically imbalanced (many donors sit in early stages, few in "STEWARD"); pass class_weight=None to disable the reweighting.

predict_proba and :meth:action_priority pass NaN straight through to the HistGradientBoostingClassifier backend, which handles missing values natively, so features do not need to be imputed first.

Parameters:

Name Type Description Default
learning_rate float

Learning rate of the underlying HistGradientBoostingClassifier.

0.1
max_iter int

Maximum number of boosting iterations.

200
class_weight (str, dict or None)

Class weights passed to the backend. "balanced" reweights inversely proportional to stage frequency.

"balanced"
random_state int or None

Random seed for reproducibility.

None
categorical_features list of int or str, bool mask, or None

Columns the backend should split on as unordered categories (a region, constituency or appeal code) instead of as numbers, so they need no one-hot step. Positions, a boolean mask, or column names when X is a DataFrame. The columns must hold non-negative integer codes below 255, as for HistGradientBoostingClassifier. None treats every column as numeric.

None

Attributes:

Name Type Description
classes_ ndarray of shape (n_classes,)

Moves-stage labels seen during fit.

label_encoder_ LabelEncoder

Encoder mapping stage labels to the integer classes the backend estimator was fit on.

estimator_ HistGradientBoostingClassifier

The fitted backend estimator.

n_features_in_ int

Number of features seen during fit.

n_iter_ int

Number of boosting iterations performed by estimator_.

Examples:

>>> import numpy as np
>>> from philanthropy.models import MovesManagementClassifier
>>> rng = np.random.default_rng(0)
>>> X = rng.random((12, 3))
>>> y = ["IDENTIFY", "QUALIFY", "CULTIVATE"] * 4
>>> clf = MovesManagementClassifier(max_iter=10, random_state=0).fit(X, y)
>>> sorted(clf.classes_.tolist())
['CULTIVATE', 'IDENTIFY', 'QUALIFY']
Notes

action_priority's "confidence" is the raw max class probability from the backend estimator, not a calibrated probability: on held-out data, rows with a reported confidence of 0.73-0.99 were observed correct only 56-70% of the time. Treat it as a ranking signal for prioritizing donors, not as a calibrated likelihood.

Source code in philanthropy/models/_moves.py
class MovesManagementClassifier(ClassifierMixin, BaseEstimator):
    """
    Predicts the next best moves management stage for a donor.

    Wraps a :class:`~sklearn.ensemble.HistGradientBoostingClassifier` and
    labels its predictions with the moves-management stage names the donor
    was trained on, rather than requiring the caller to encode stages
    themselves. ``class_weight="balanced"`` is the default because moves
    stages are typically imbalanced (many donors sit in early stages, few in
    ``"STEWARD"``); pass ``class_weight=None`` to disable the reweighting.

    ``predict_proba`` and :meth:`action_priority` pass NaN straight through
    to the HistGradientBoostingClassifier backend, which handles missing
    values natively, so features do not need to be imputed first.

    Parameters
    ----------
    learning_rate : float, default=0.1
        Learning rate of the underlying HistGradientBoostingClassifier.
    max_iter : int, default=200
        Maximum number of boosting iterations.
    class_weight : str, dict or None, default="balanced"
        Class weights passed to the backend. ``"balanced"`` reweights
        inversely proportional to stage frequency.
    random_state : int or None, default=None
        Random seed for reproducibility.
    categorical_features : list of int or str, bool mask, or None, default=None
        Columns the backend should split on as unordered categories (a
        region, constituency or appeal code) instead of as numbers, so they
        need no one-hot step. Positions, a boolean mask, or column names
        when ``X`` is a DataFrame. The columns must hold non-negative
        integer codes below 255, as for ``HistGradientBoostingClassifier``.
        ``None`` treats every column as numeric.

    Attributes
    ----------
    classes_ : ndarray of shape (n_classes,)
        Moves-stage labels seen during ``fit``.
    label_encoder_ : LabelEncoder
        Encoder mapping stage labels to the integer classes the backend
        estimator was fit on.
    estimator_ : HistGradientBoostingClassifier
        The fitted backend estimator.
    n_features_in_ : int
        Number of features seen during ``fit``.
    n_iter_ : int
        Number of boosting iterations performed by ``estimator_``.

    Examples
    --------
    >>> import numpy as np
    >>> from philanthropy.models import MovesManagementClassifier
    >>> rng = np.random.default_rng(0)
    >>> X = rng.random((12, 3))
    >>> y = ["IDENTIFY", "QUALIFY", "CULTIVATE"] * 4
    >>> clf = MovesManagementClassifier(max_iter=10, random_state=0).fit(X, y)
    >>> sorted(clf.classes_.tolist())
    ['CULTIVATE', 'IDENTIFY', 'QUALIFY']

    Notes
    -----
    ``action_priority``'s ``"confidence"`` is the raw max class probability
    from the backend estimator, not a calibrated probability: on held-out
    data, rows with a reported confidence of 0.73-0.99 were observed correct
    only 56-70% of the time. Treat it as a ranking signal for prioritizing
    donors, not as a calibrated likelihood.
    """

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.allow_nan = True
        return tags

    def __init__(
        self,
        learning_rate: float = 0.1,
        max_iter: int = 200,
        class_weight: str | dict | None = "balanced",
        random_state: int | None = None,
        categorical_features: Any = None,
    ) -> None:
        self.learning_rate = learning_rate
        self.max_iter = max_iter
        self.class_weight = class_weight
        self.random_state = random_state
        self.categorical_features = categorical_features

    def fit(self: _Self, X: Any, y: Any, sample_weight: Any = None) -> _Self:
        """Fit the classifier to labelled moves-stage data.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.
        y : array-like of shape (n_samples,)
            Moves-stage target labels.
        sample_weight : array-like of shape (n_samples,), default=None
            Per-donor weights, passed to the backend and multiplied there
            with ``class_weight``.

        Returns
        -------
        self : MovesManagementClassifier
            Fitted estimator. Sets ``feature_names_in_`` when ``X`` is a
            DataFrame, ``n_features_in_``, ``label_encoder_``, ``classes_``,
            ``estimator_``, and ``n_iter_``.

        Raises
        ------
        ValueError
            If ``y`` is not a classification target, or if it contains fewer
            than 2 classes.
        """
        X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
        # Reject continuous targets: this is a classifier, so a regression
        # target must not be silently label-encoded into pseudo-classes.
        check_classification_targets(y)
        self.n_features_in_ = X.shape[1]

        self.label_encoder_ = LabelEncoder()
        y_encoded = self.label_encoder_.fit_transform(y)
        self.classes_ = self.label_encoder_.classes_
        if len(self.classes_) < 2:
            raise ValueError(
                "MovesManagementClassifier requires at least 2 classes in "
                f"y, got {len(self.classes_)} class: {list(self.classes_)}."
            )

        # No monotonic_cst here: stages are multiclass, and sklearn's
        # HistGradientBoostingClassifier rejects monotonic constraints for
        # more than two classes.
        self.estimator_ = HistGradientBoostingClassifier(
            learning_rate=self.learning_rate,
            max_iter=self.max_iter,
            class_weight=self.class_weight,
            categorical_features=resolve_columns(self, self.categorical_features),
            random_state=self.random_state,
        )
        if sample_weight is not None:
            sample_weight = _check_sample_weight(sample_weight, X)
        self.estimator_.fit(X, y_encoded, sample_weight=sample_weight)
        # Expose n_iter_ (project convention for any estimator taking max_iter;
        # check_estimator requires it).
        self.n_iter_ = self.estimator_.n_iter_
        return self

    def predict(self, X: Any) -> np.ndarray:
        """Predict the next moves-management stage for each donor.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.

        Returns
        -------
        y_pred : ndarray of shape (n_samples,)
            Predicted stage labels.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        y_pred = self.estimator_.predict(X)
        return self.label_encoder_.inverse_transform(y_pred)

    def predict_proba(self, X: Any) -> np.ndarray:
        """Return class probabilities for each moves-management stage.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.

        Returns
        -------
        proba : ndarray of shape (n_samples, n_classes)
            Predicted probabilities for each stage.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        return self.estimator_.predict_proba(X)

    def action_priority(self, X: Any) -> dict:
        """Predict the next-best stage per donor plus a portfolio rollup.

        Unlike ``predict``/``predict_proba`` (which return ndarrays), this
        returns a dict with keys ``"stage"`` (ndarray of predicted stage
        labels), ``"confidence"`` (ndarray of max class probabilities), and
        ``"portfolio_summary"`` (dict mapping each stage to its donor count).
        """
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)

        probas = self.estimator_.predict_proba(X)
        pred_idx = np.argmax(probas, axis=1)
        confidences = np.max(probas, axis=1)

        stages = self.label_encoder_.inverse_transform(pred_idx)

        unique_stages, counts = np.unique(stages, return_counts=True)
        portfolio_summary = dict(zip(unique_stages, counts))

        return {
            "stage": stages,
            "confidence": confidences,
            "portfolio_summary": portfolio_summary,
        }

fit(X, y, sample_weight=None)

Fit the classifier to labelled moves-stage data.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required
y array-like of shape (n_samples,)

Moves-stage target labels.

required
sample_weight array-like of shape (n_samples,)

Per-donor weights, passed to the backend and multiplied there with class_weight.

None

Returns:

Name Type Description
self MovesManagementClassifier

Fitted estimator. Sets feature_names_in_ when X is a DataFrame, n_features_in_, label_encoder_, classes_, estimator_, and n_iter_.

Raises:

Type Description
ValueError

If y is not a classification target, or if it contains fewer than 2 classes.

Source code in philanthropy/models/_moves.py
def fit(self: _Self, X: Any, y: Any, sample_weight: Any = None) -> _Self:
    """Fit the classifier to labelled moves-stage data.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.
    y : array-like of shape (n_samples,)
        Moves-stage target labels.
    sample_weight : array-like of shape (n_samples,), default=None
        Per-donor weights, passed to the backend and multiplied there
        with ``class_weight``.

    Returns
    -------
    self : MovesManagementClassifier
        Fitted estimator. Sets ``feature_names_in_`` when ``X`` is a
        DataFrame, ``n_features_in_``, ``label_encoder_``, ``classes_``,
        ``estimator_``, and ``n_iter_``.

    Raises
    ------
    ValueError
        If ``y`` is not a classification target, or if it contains fewer
        than 2 classes.
    """
    X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
    # Reject continuous targets: this is a classifier, so a regression
    # target must not be silently label-encoded into pseudo-classes.
    check_classification_targets(y)
    self.n_features_in_ = X.shape[1]

    self.label_encoder_ = LabelEncoder()
    y_encoded = self.label_encoder_.fit_transform(y)
    self.classes_ = self.label_encoder_.classes_
    if len(self.classes_) < 2:
        raise ValueError(
            "MovesManagementClassifier requires at least 2 classes in "
            f"y, got {len(self.classes_)} class: {list(self.classes_)}."
        )

    # No monotonic_cst here: stages are multiclass, and sklearn's
    # HistGradientBoostingClassifier rejects monotonic constraints for
    # more than two classes.
    self.estimator_ = HistGradientBoostingClassifier(
        learning_rate=self.learning_rate,
        max_iter=self.max_iter,
        class_weight=self.class_weight,
        categorical_features=resolve_columns(self, self.categorical_features),
        random_state=self.random_state,
    )
    if sample_weight is not None:
        sample_weight = _check_sample_weight(sample_weight, X)
    self.estimator_.fit(X, y_encoded, sample_weight=sample_weight)
    # Expose n_iter_ (project convention for any estimator taking max_iter;
    # check_estimator requires it).
    self.n_iter_ = self.estimator_.n_iter_
    return self

predict(X)

Predict the next moves-management stage for each donor.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required

Returns:

Name Type Description
y_pred ndarray of shape (n_samples,)

Predicted stage labels.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/models/_moves.py
def predict(self, X: Any) -> np.ndarray:
    """Predict the next moves-management stage for each donor.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.

    Returns
    -------
    y_pred : ndarray of shape (n_samples,)
        Predicted stage labels.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    y_pred = self.estimator_.predict(X)
    return self.label_encoder_.inverse_transform(y_pred)

predict_proba(X)

Return class probabilities for each moves-management stage.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required

Returns:

Name Type Description
proba ndarray of shape (n_samples, n_classes)

Predicted probabilities for each stage.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/models/_moves.py
def predict_proba(self, X: Any) -> np.ndarray:
    """Return class probabilities for each moves-management stage.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.

    Returns
    -------
    proba : ndarray of shape (n_samples, n_classes)
        Predicted probabilities for each stage.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    return self.estimator_.predict_proba(X)

action_priority(X)

Predict the next-best stage per donor plus a portfolio rollup.

Unlike predict/predict_proba (which return ndarrays), this returns a dict with keys "stage" (ndarray of predicted stage labels), "confidence" (ndarray of max class probabilities), and "portfolio_summary" (dict mapping each stage to its donor count).

Source code in philanthropy/models/_moves.py
def action_priority(self, X: Any) -> dict:
    """Predict the next-best stage per donor plus a portfolio rollup.

    Unlike ``predict``/``predict_proba`` (which return ndarrays), this
    returns a dict with keys ``"stage"`` (ndarray of predicted stage
    labels), ``"confidence"`` (ndarray of max class probabilities), and
    ``"portfolio_summary"`` (dict mapping each stage to its donor count).
    """
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)

    probas = self.estimator_.predict_proba(X)
    pred_idx = np.argmax(probas, axis=1)
    confidences = np.max(probas, axis=1)

    stages = self.label_encoder_.inverse_transform(pred_idx)

    unique_stages, counts = np.unique(stages, return_counts=True)
    portfolio_summary = dict(zip(unique_stages, counts))

    return {
        "stage": stages,
        "confidence": confidences,
        "portfolio_summary": portfolio_summary,
    }

LapsePredictor

Bases: ClassifierMixin, BaseEstimator

Predicts whether a donor will lapse within a configurable window. Uses RandomForestClassifier backend by default.

Parameters:

Name Type Description Default
n_estimators int

Number of trees in the RandomForestClassifier. Ignored when backend="hist_gradient_boosting".

100
max_depth int or None

Maximum depth of trees. None means nodes expand until pure (unbounded for RandomForestClassifier; the backend's own default depth limit for HistGradientBoostingRegressor).

None
class_weight (dict, 'balanced', 'balanced_subsample' or None)

Class weights for imbalanced lapse prediction. Ignored when backend="hist_gradient_boosting" (that backend has no class_weight parameter).

None
random_state int or None

Random seed for reproducibility.

None
backend ('random_forest', 'hist_gradient_boosting')

"hist_gradient_boosting" handles NaN natively and is faster on large files (E.12d); adopt only after it is confirmed to beat "years since last gift" at top-10% on one real file without losing on another. The default has not changed.

"random_forest"
categorical_features list of int or str, bool mask, or None

Columns the backend should split on as unordered categories (a region, constituency or appeal code) instead of as numbers, so they need no one-hot step. Positions, a boolean mask, or column names when X is a DataFrame. The columns must hold non-negative integer codes below 255, as for HistGradientBoostingClassifier. None treats every column as numeric. Used only when backend="hist_gradient_boosting"; the random forest has no categorical splits.

None
monotonic_cst array-like of int or dict

Monotonic constraint on each feature, passed to the underlying :class:HistGradientBoostingClassifier: 1 means the lapse score never falls as that feature rises, -1 that it never rises, 0 no constraint. A dict sets only the features it names, keyed by position or, when X is a DataFrame, column name ({"total_giving": 1}). None applies no constraint (current behaviour). Used only when backend="hist_gradient_boosting"; the random forest ignores it.

None

Attributes:

Name Type Description
training_lapse_rate_ float

Fraction of training rows labelled "lapsed", set at fit time.

retention_read_ bool

True when training_lapse_rate_ exceeds :data:RETENTION_READ_THRESHOLD: on files where almost everyone lapses, the retention read (the 10% least likely to lapse, see :meth:predict_retention_score) is the useful list, not the lapse read (E.12d, E.13d "Lapse" fix).

Source code in philanthropy/models/_lapse.py
class LapsePredictor(ClassifierMixin, BaseEstimator):
    """
    Predicts whether a donor will lapse within a configurable window.
    Uses RandomForestClassifier backend by default.

    Parameters
    ----------
    n_estimators : int, default=100
        Number of trees in the RandomForestClassifier. Ignored when
        ``backend="hist_gradient_boosting"``.
    max_depth : int or None, default=None
        Maximum depth of trees. None means nodes expand until pure
        (unbounded for ``RandomForestClassifier``; the backend's own default
        depth limit for ``HistGradientBoostingRegressor``).
    class_weight : dict, "balanced", "balanced_subsample" or None, default=None
        Class weights for imbalanced lapse prediction. Ignored when
        ``backend="hist_gradient_boosting"`` (that backend has no
        ``class_weight`` parameter).
    random_state : int or None, default=None
        Random seed for reproducibility.
    backend : {"random_forest", "hist_gradient_boosting"}, default="random_forest"
        ``"hist_gradient_boosting"`` handles ``NaN`` natively and is faster on
        large files (E.12d); adopt only after it is confirmed to beat "years
        since last gift" at top-10% on one real file without losing on
        another. The default has not changed.
    categorical_features : list of int or str, bool mask, or None, default=None
        Columns the backend should split on as unordered categories (a
        region, constituency or appeal code) instead of as numbers, so they
        need no one-hot step. Positions, a boolean mask, or column names
        when ``X`` is a DataFrame. The columns must hold non-negative
        integer codes below 255, as for ``HistGradientBoostingClassifier``.
        ``None`` treats every column as numeric.
        Used only when ``backend="hist_gradient_boosting"``; the random
        forest has no categorical splits.
    monotonic_cst : array-like of int or dict, default=None
        Monotonic constraint on each feature, passed to the underlying
        :class:`HistGradientBoostingClassifier`: ``1`` means the lapse score never falls as
        that feature rises, ``-1`` that it never rises, ``0`` no
        constraint. A dict sets only the features it names, keyed by
        position or, when ``X`` is a DataFrame, column name
        (``{"total_giving": 1}``). ``None`` applies no constraint
        (current behaviour).
        Used only when ``backend="hist_gradient_boosting"``; the random
        forest ignores it.

    Attributes
    ----------
    training_lapse_rate_ : float
        Fraction of training rows labelled "lapsed", set at fit time.
    retention_read_ : bool
        ``True`` when ``training_lapse_rate_`` exceeds
        :data:`RETENTION_READ_THRESHOLD`: on files where almost everyone
        lapses, the retention read (the 10% *least* likely to lapse, see
        :meth:`predict_retention_score`) is the useful list, not the lapse
        read (E.12d, E.13d "Lapse" fix).
    """

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.allow_nan = True
        tags.classifier_tags.poor_score = True
        return tags

    def __init__(
        self,
        n_estimators: int = 100,
        max_depth: int | None = None,
        class_weight: Any = None,
        random_state: int | None = None,
        backend: str = "random_forest",
        categorical_features: Any = None,
        monotonic_cst: Any = None,
    ) -> None:
        self.n_estimators = n_estimators
        self.max_depth = max_depth
        self.class_weight = class_weight
        self.random_state = random_state
        self.backend = backend
        self.categorical_features = categorical_features
        self.monotonic_cst = monotonic_cst

    def fit(self: _Self, X: Any, y: Any, sample_weight: Any = None) -> _Self:
        """Fit the LapsePredictor.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.
        y : array-like of shape (n_samples,)
            Binary target: 1 = lapse, 0 = no lapse.
        sample_weight : array-like of shape (n_samples,), default=None
            Per-donor weights, passed to the backend (and multiplied there
            with ``class_weight`` on the random forest).

        Returns
        -------
        self : LapsePredictor
        """
        X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
        self.classes_ = unique_labels(y)
        self.n_features_in_ = X.shape[1]
        self.training_lapse_rate_ = float(np.mean(y == 1))
        self.retention_read_ = self.training_lapse_rate_ > RETENTION_READ_THRESHOLD

        if self.backend == "random_forest":
            self.estimator_ = RandomForestClassifier(
                n_estimators=self.n_estimators,
                max_depth=self.max_depth,
                class_weight=self.class_weight,
                random_state=self.random_state,
            )
        elif self.backend == "hist_gradient_boosting":
            self.estimator_ = HistGradientBoostingClassifier(
                max_depth=self.max_depth,
                categorical_features=resolve_columns(self, self.categorical_features),
                monotonic_cst=resolve_monotonic_cst(self, self.monotonic_cst),
                random_state=self.random_state,
            )
        else:
            raise ValueError(f"Unknown backend: {self.backend!r}")
        if sample_weight is not None:
            sample_weight = _check_sample_weight(sample_weight, X)
        self.estimator_.fit(X, y, sample_weight=sample_weight)
        return self

    def predict(self, X: Any) -> np.ndarray:
        """Predict binary lapse labels."""
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        return self.estimator_.predict(X)

    def predict_proba(self, X: Any) -> np.ndarray:
        """Return class probabilities of shape (n_samples, 2)."""
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        return self.estimator_.predict_proba(X)

    def predict_lapse_score(self, X: Any) -> np.ndarray:
        """Return P(lapse) × 100 rounded to 2 decimal places (0–100 scale)."""
        check_is_fitted(self)
        proba = self.predict_proba(X)
        if proba.shape[1] < 2:
            # Single-class training fold (e.g. no donor lapsed in the window):
            # P(lapse) is 1.0 iff the sole class is the positive one, else 0.0.
            proba_lapse = np.full(proba.shape[0], 1.0 if 1 in self.classes_ else 0.0)
        else:
            # Column 1 is P(class=1), i.e. P(lapse) when classes_ is [0, 1].
            proba_lapse = proba[:, 1]
        return np.round(proba_lapse * 100.0, 2)

    def predict_retention_score(self, X: Any) -> np.ndarray:
        """Return P(retained) x 100, the complement of
        :meth:`predict_lapse_score`.

        Ranking donors by this score, highest first, is the useful list on a
        file where :attr:`retention_read_` is ``True``: ranking by lapse
        score barely beats random when almost everyone lapses, but the tail
        least likely to lapse still beats the rule (E.12d)."""
        return np.round(100.0 - self.predict_lapse_score(X), 2)

fit(X, y, sample_weight=None)

Fit the LapsePredictor.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required
y array-like of shape (n_samples,)

Binary target: 1 = lapse, 0 = no lapse.

required
sample_weight array-like of shape (n_samples,)

Per-donor weights, passed to the backend (and multiplied there with class_weight on the random forest).

None

Returns:

Name Type Description
self LapsePredictor
Source code in philanthropy/models/_lapse.py
def fit(self: _Self, X: Any, y: Any, sample_weight: Any = None) -> _Self:
    """Fit the LapsePredictor.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.
    y : array-like of shape (n_samples,)
        Binary target: 1 = lapse, 0 = no lapse.
    sample_weight : array-like of shape (n_samples,), default=None
        Per-donor weights, passed to the backend (and multiplied there
        with ``class_weight`` on the random forest).

    Returns
    -------
    self : LapsePredictor
    """
    X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
    self.classes_ = unique_labels(y)
    self.n_features_in_ = X.shape[1]
    self.training_lapse_rate_ = float(np.mean(y == 1))
    self.retention_read_ = self.training_lapse_rate_ > RETENTION_READ_THRESHOLD

    if self.backend == "random_forest":
        self.estimator_ = RandomForestClassifier(
            n_estimators=self.n_estimators,
            max_depth=self.max_depth,
            class_weight=self.class_weight,
            random_state=self.random_state,
        )
    elif self.backend == "hist_gradient_boosting":
        self.estimator_ = HistGradientBoostingClassifier(
            max_depth=self.max_depth,
            categorical_features=resolve_columns(self, self.categorical_features),
            monotonic_cst=resolve_monotonic_cst(self, self.monotonic_cst),
            random_state=self.random_state,
        )
    else:
        raise ValueError(f"Unknown backend: {self.backend!r}")
    if sample_weight is not None:
        sample_weight = _check_sample_weight(sample_weight, X)
    self.estimator_.fit(X, y, sample_weight=sample_weight)
    return self

predict(X)

Predict binary lapse labels.

Source code in philanthropy/models/_lapse.py
def predict(self, X: Any) -> np.ndarray:
    """Predict binary lapse labels."""
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    return self.estimator_.predict(X)

predict_proba(X)

Return class probabilities of shape (n_samples, 2).

Source code in philanthropy/models/_lapse.py
def predict_proba(self, X: Any) -> np.ndarray:
    """Return class probabilities of shape (n_samples, 2)."""
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    return self.estimator_.predict_proba(X)

predict_lapse_score(X)

Return P(lapse) × 100 rounded to 2 decimal places (0–100 scale).

Source code in philanthropy/models/_lapse.py
def predict_lapse_score(self, X: Any) -> np.ndarray:
    """Return P(lapse) × 100 rounded to 2 decimal places (0–100 scale)."""
    check_is_fitted(self)
    proba = self.predict_proba(X)
    if proba.shape[1] < 2:
        # Single-class training fold (e.g. no donor lapsed in the window):
        # P(lapse) is 1.0 iff the sole class is the positive one, else 0.0.
        proba_lapse = np.full(proba.shape[0], 1.0 if 1 in self.classes_ else 0.0)
    else:
        # Column 1 is P(class=1), i.e. P(lapse) when classes_ is [0, 1].
        proba_lapse = proba[:, 1]
    return np.round(proba_lapse * 100.0, 2)

predict_retention_score(X)

Return P(retained) x 100, the complement of :meth:predict_lapse_score.

Ranking donors by this score, highest first, is the useful list on a file where :attr:retention_read_ is True: ranking by lapse score barely beats random when almost everyone lapses, but the tail least likely to lapse still beats the rule (E.12d).

Source code in philanthropy/models/_lapse.py
def predict_retention_score(self, X: Any) -> np.ndarray:
    """Return P(retained) x 100, the complement of
    :meth:`predict_lapse_score`.

    Ranking donors by this score, highest first, is the useful list on a
    file where :attr:`retention_read_` is ``True``: ranking by lapse
    score barely beats random when almost everyone lapses, but the tail
    least likely to lapse still beats the rule (E.12d)."""
    return np.round(100.0 - self.predict_lapse_score(X), 2)

PlannedGivingIntentScorer

Bases: ClassifierMixin, BaseEstimator

Predicts bequest/planned giving intent. Wraps HistGradientBoostingClassifier with CalibratedClassifierCV.

Exposes .predict_intent_score(X) returning a 0-100 float array. Missing values are accepted and handled natively by the boosting backend, so a file with blank age or wealth-screening columns needs no imputation first.

Calibration uses cv=2, so every class in y must have at least 2 examples; fit raises a ValueError up front if that is not the case rather than surfacing scikit-learn's cross-validation error.

Parameters:

Name Type Description Default
n_estimators int

The maximum number of boosting iterations, passed as max_iter to the underlying :class:HistGradientBoostingClassifier.

100
random_state int, RandomState instance or None

Controls the randomness of the estimator.

None
class_weight (dict, 'balanced' or None)

Weight scheme for the classes, useful when bequest intent is rare. Applied as sample_weight (multiplied with any sample_weight passed to :meth:fit) rather than as the backend's own class_weight, for the same reason as :class:~philanthropy.models.MajorGiftClassifier: the calibration folds would otherwise wash the reweighting out. With it set, scores reflect the reweighted class balance, so read them as a ranking.

None
categorical_features list of int or str, bool mask, or None

Columns the backend should split on as unordered categories (a region, constituency or appeal code) instead of as numbers, so they need no one-hot step. Positions, a boolean mask, or column names when X is a DataFrame. The columns must hold non-negative integer codes below 255, as for HistGradientBoostingClassifier. None treats every column as numeric.

None
monotonic_cst array-like of int or dict

Monotonic constraint on each feature, passed to the underlying :class:HistGradientBoostingClassifier: 1 means the intent score never falls as that feature rises, -1 that it never rises, 0 no constraint. A dict sets only the features it names, keyed by position or, when X is a DataFrame, column name ({"total_giving": 1}). None applies no constraint (current behaviour).

None
Source code in philanthropy/models/_planned_giving.py
class PlannedGivingIntentScorer(ClassifierMixin, BaseEstimator):
    """
    Predicts bequest/planned giving intent. Wraps
    HistGradientBoostingClassifier with CalibratedClassifierCV.

    Exposes `.predict_intent_score(X)` returning a 0-100 float array.
    Missing values are accepted and handled natively by the boosting
    backend, so a file with blank age or wealth-screening columns needs no
    imputation first.

    Calibration uses ``cv=2``, so every class in ``y`` must have at least 2
    examples; ``fit`` raises a ``ValueError`` up front if that is not the
    case rather than surfacing scikit-learn's cross-validation error.

    Parameters
    ----------
    n_estimators : int, default=100
        The maximum number of boosting iterations, passed as ``max_iter`` to
        the underlying :class:`HistGradientBoostingClassifier`.
    random_state : int, RandomState instance or None, default=None
        Controls the randomness of the estimator.
    class_weight : dict, "balanced" or None, default=None
        Weight scheme for the classes, useful when bequest intent is rare.
        Applied as ``sample_weight`` (multiplied with any ``sample_weight``
        passed to :meth:`fit`) rather than as the backend's own
        ``class_weight``, for the same reason as
        :class:`~philanthropy.models.MajorGiftClassifier`: the calibration
        folds would otherwise wash the reweighting out. With it set, scores
        reflect the reweighted class balance, so read them as a ranking.
    categorical_features : list of int or str, bool mask, or None, default=None
        Columns the backend should split on as unordered categories (a
        region, constituency or appeal code) instead of as numbers, so they
        need no one-hot step. Positions, a boolean mask, or column names
        when ``X`` is a DataFrame. The columns must hold non-negative
        integer codes below 255, as for ``HistGradientBoostingClassifier``.
        ``None`` treats every column as numeric.
    monotonic_cst : array-like of int or dict, default=None
        Monotonic constraint on each feature, passed to the underlying
        :class:`HistGradientBoostingClassifier`: ``1`` means the intent score never falls as
        that feature rises, ``-1`` that it never rises, ``0`` no
        constraint. A dict sets only the features it names, keyed by
        position or, when ``X`` is a DataFrame, column name
        (``{"total_giving": 1}``). ``None`` applies no constraint
        (current behaviour).
    """

    def __init__(
        self,
        n_estimators: int = 100,
        random_state: int | None = None,
        class_weight: Any = None,
        categorical_features: Any = None,
        monotonic_cst: Any = None,
    ) -> None:
        self.n_estimators = n_estimators
        self.random_state = random_state
        self.class_weight = class_weight
        self.categorical_features = categorical_features
        self.monotonic_cst = monotonic_cst

    def fit(self: _Self, X: Any, y: Any, sample_weight: Any = None) -> _Self:
        """Fit the calibrated classifier to planned-giving intent labels.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.
        y : array-like of shape (n_samples,)
            Binary target labels.
        sample_weight : array-like of shape (n_samples,), default=None
            Per-donor weights, used by both the boosting backend and the
            calibration step.

        Returns
        -------
        self : PlannedGivingIntentScorer
            Fitted estimator. Sets ``classes_``, ``n_features_in_``, and
            ``estimator_``.

        Raises
        ------
        ValueError
            If ``y`` is not a classification target, if it contains fewer
            than 2 classes, or if any class has fewer than 2 examples,
            since calibration uses ``cv=2``.
        """
        X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
        # Reject continuous targets before counting classes, so a regression
        # target gets sklearn's standard "continuous" message instead of
        # being misread as one-example-per-class.
        check_classification_targets(y)

        self.classes_, counts = np.unique(y, return_counts=True)
        self.n_features_in_ = X.shape[1]

        if len(self.classes_) < 2:
            raise ValueError(
                "PlannedGivingIntentScorer requires at least 2 classes in "
                f"y, got {len(self.classes_)} class: {list(self.classes_)}."
            )
        if counts.min() < _CALIBRATION_CV_FOLDS:
            raise ValueError(
                "PlannedGivingIntentScorer calibrates with "
                f"cv={_CALIBRATION_CV_FOLDS}, so every class needs at least "
                f"{_CALIBRATION_CV_FOLDS} examples; the smallest class has "
                f"{counts.min()}."
            )

        base_estimator = HistGradientBoostingClassifier(
            max_iter=self.n_estimators,
            # The old GradientBoostingClassifier default depth: same tree
            # shape, so scores match it on sample data; only NaN handling changes.
            max_depth=3,
            categorical_features=resolve_columns(self, self.categorical_features),
            monotonic_cst=resolve_monotonic_cst(self, self.monotonic_cst),
            random_state=self.random_state
        )
        self.estimator_ = CalibratedClassifierCV(
            estimator=base_estimator,
            method="sigmoid",
            cv=_CALIBRATION_CV_FOLDS,
        )
        if sample_weight is not None:
            sample_weight = _check_sample_weight(sample_weight, X)
        if self.class_weight is not None:
            class_w = compute_sample_weight(self.class_weight, y)
            sample_weight = class_w if sample_weight is None else sample_weight * class_w
        self.estimator_.fit(X, y, sample_weight=sample_weight)
        return self

    def predict(self, X: Any) -> np.ndarray:
        """Predict bequest/planned-giving intent labels.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.

        Returns
        -------
        y_pred : ndarray of shape (n_samples,)
            Predicted class labels.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        return self.estimator_.predict(X)

    def predict_proba(self, X: Any) -> np.ndarray:
        """Return calibrated class probabilities.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix.

        Returns
        -------
        proba : ndarray of shape (n_samples, n_classes)
            Predicted probabilities for each class.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        return self.estimator_.predict_proba(X)

    def predict_intent_score(self, X: Any) -> np.ndarray:
        """
        Return P(planned giving intent) × 100, rounded to 2 decimal places.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)

        Returns
        -------
        scores : ndarray of shape (n_samples,)
            Values in range [0.0, 100.0].
        """
        proba = self.predict_proba(X)
        scores = np.round(proba[:, 1] * 100.0, 2)
        return scores

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.allow_nan = True
        return tags

fit(X, y, sample_weight=None)

Fit the calibrated classifier to planned-giving intent labels.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required
y array-like of shape (n_samples,)

Binary target labels.

required
sample_weight array-like of shape (n_samples,)

Per-donor weights, used by both the boosting backend and the calibration step.

None

Returns:

Name Type Description
self PlannedGivingIntentScorer

Fitted estimator. Sets classes_, n_features_in_, and estimator_.

Raises:

Type Description
ValueError

If y is not a classification target, if it contains fewer than 2 classes, or if any class has fewer than 2 examples, since calibration uses cv=2.

Source code in philanthropy/models/_planned_giving.py
def fit(self: _Self, X: Any, y: Any, sample_weight: Any = None) -> _Self:
    """Fit the calibrated classifier to planned-giving intent labels.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.
    y : array-like of shape (n_samples,)
        Binary target labels.
    sample_weight : array-like of shape (n_samples,), default=None
        Per-donor weights, used by both the boosting backend and the
        calibration step.

    Returns
    -------
    self : PlannedGivingIntentScorer
        Fitted estimator. Sets ``classes_``, ``n_features_in_``, and
        ``estimator_``.

    Raises
    ------
    ValueError
        If ``y`` is not a classification target, if it contains fewer
        than 2 classes, or if any class has fewer than 2 examples,
        since calibration uses ``cv=2``.
    """
    X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
    # Reject continuous targets before counting classes, so a regression
    # target gets sklearn's standard "continuous" message instead of
    # being misread as one-example-per-class.
    check_classification_targets(y)

    self.classes_, counts = np.unique(y, return_counts=True)
    self.n_features_in_ = X.shape[1]

    if len(self.classes_) < 2:
        raise ValueError(
            "PlannedGivingIntentScorer requires at least 2 classes in "
            f"y, got {len(self.classes_)} class: {list(self.classes_)}."
        )
    if counts.min() < _CALIBRATION_CV_FOLDS:
        raise ValueError(
            "PlannedGivingIntentScorer calibrates with "
            f"cv={_CALIBRATION_CV_FOLDS}, so every class needs at least "
            f"{_CALIBRATION_CV_FOLDS} examples; the smallest class has "
            f"{counts.min()}."
        )

    base_estimator = HistGradientBoostingClassifier(
        max_iter=self.n_estimators,
        # The old GradientBoostingClassifier default depth: same tree
        # shape, so scores match it on sample data; only NaN handling changes.
        max_depth=3,
        categorical_features=resolve_columns(self, self.categorical_features),
        monotonic_cst=resolve_monotonic_cst(self, self.monotonic_cst),
        random_state=self.random_state
    )
    self.estimator_ = CalibratedClassifierCV(
        estimator=base_estimator,
        method="sigmoid",
        cv=_CALIBRATION_CV_FOLDS,
    )
    if sample_weight is not None:
        sample_weight = _check_sample_weight(sample_weight, X)
    if self.class_weight is not None:
        class_w = compute_sample_weight(self.class_weight, y)
        sample_weight = class_w if sample_weight is None else sample_weight * class_w
    self.estimator_.fit(X, y, sample_weight=sample_weight)
    return self

predict(X)

Predict bequest/planned-giving intent labels.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required

Returns:

Name Type Description
y_pred ndarray of shape (n_samples,)

Predicted class labels.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/models/_planned_giving.py
def predict(self, X: Any) -> np.ndarray:
    """Predict bequest/planned-giving intent labels.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.

    Returns
    -------
    y_pred : ndarray of shape (n_samples,)
        Predicted class labels.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    return self.estimator_.predict(X)

predict_proba(X)

Return calibrated class probabilities.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix.

required

Returns:

Name Type Description
proba ndarray of shape (n_samples, n_classes)

Predicted probabilities for each class.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/models/_planned_giving.py
def predict_proba(self, X: Any) -> np.ndarray:
    """Return calibrated class probabilities.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix.

    Returns
    -------
    proba : ndarray of shape (n_samples, n_classes)
        Predicted probabilities for each class.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    return self.estimator_.predict_proba(X)

predict_intent_score(X)

Return P(planned giving intent) × 100, rounded to 2 decimal places.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)
required

Returns:

Name Type Description
scores ndarray of shape (n_samples,)

Values in range [0.0, 100.0].

Source code in philanthropy/models/_planned_giving.py
def predict_intent_score(self, X: Any) -> np.ndarray:
    """
    Return P(planned giving intent) × 100, rounded to 2 decimal places.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)

    Returns
    -------
    scores : ndarray of shape (n_samples,)
        Values in range [0.0, 100.0].
    """
    proba = self.predict_proba(X)
    scores = np.round(proba[:, 1] * 100.0, 2)
    return scores

FinancialForecastModel

Bases: RegressorMixin, BaseEstimator

Hybrid LSTM-ARIMA forecaster for nonprofit revenue / giving series.

FinancialForecastModel is a scikit-learn–compatible regressor that closes the loop with the LSTM-ARIMA hybrid forecasting literature. It fits two complementary sub-models on the training data:

  • a linear (ARIMA-surrogate) component: a :class:~sklearn.linear_model.LinearRegression mapping the feature matrix to giving revenue, capturing the linear / trend structure; and
  • a nonlinear (LSTM-surrogate) component: a :class:~sklearn.neural_network.MLPRegressor fitted on the residuals of the linear component, capturing the nonlinear structure a linear model leaves behind.

Point predictions (:meth:predict) are the additive hybrid linear(X) + nonlinear_residual(X). Forward-looking, multi-period forecasts (:meth:predict_revenue_forecast) are produced by seeding a frozen autoregressive model with the most recent hybrid predictions and rolling it forward over the requested horizon.

The model handles missing values natively: at :meth:fit time it freezes a per-column median fill (falling back to 0.0 for all-NaN columns) and applies it before either sub-model sees the data, so no upstream imputer is required and no test-set statistic can leak backwards into training.

Parameters:

Name Type Description Default
ar_order int

Order p of the autoregressive roll-forward used by :meth:predict_revenue_forecast. Each future period is predicted from the previous ar_order periods. 0 disables the autoregressive dynamics and produces a flat forecast at the last observed level.

3
hidden_layer_sizes tuple of int

Hidden-layer architecture of the nonlinear residual network, passed straight through to :class:~sklearn.neural_network.MLPRegressor. This is the LSTM stand-in; widen or deepen it for more expressive nonlinear structure at the cost of training time.

(64,)
max_iter int

Maximum optimisation iterations for the residual network.

300
alpha float

L2 regularisation strength of the residual network. Increase to combat overfitting on short giving histories.

1e-4
random_state int or None

Seed for the residual network's weight initialisation. Pass an integer for fully reproducible forecasts suitable for board-level audit trails.

None

Attributes:

Name Type Description
linear_model_ LinearRegression

The fitted linear (ARIMA-surrogate) component.

nonlinear_model_ MLPRegressor or None

The fitted nonlinear (LSTM-surrogate) residual component. None when the residuals are degenerate (fewer than two samples or zero variance), in which case predictions fall back to the linear component alone.

fill_values_ ndarray of shape (n_features_in_,)

Per-column median fill values frozen at :meth:fit time.

ar_coef_ ndarray of shape (ar_order,)

Frozen autoregressive coefficients used for the forecast roll-forward.

ar_intercept_ float

Frozen autoregressive intercept.

y_mean_ float

Mean of the training target, used to pad short forecast seeds.

n_features_in_ int

Number of features seen during :meth:fit.

Examples:

>>> import numpy as np
>>> from philanthropy.models import FinancialForecastModel
>>> rng = np.random.default_rng(0)
>>> X = rng.normal(size=(120, 4))
>>> # revenue with linear + mild nonlinear structure
>>> y = 5_000 + 800 * X[:, 0] + 300 * X[:, 1] ** 2 + rng.normal(0, 50, 120)
>>> model = FinancialForecastModel(random_state=0).fit(X, y)
>>> preds = model.predict(X)
>>> preds.shape
(120,)
>>> forecast = model.predict_revenue_forecast(X, horizon=4)
>>> forecast.shape
(4,)
See Also

philanthropy.models.ShareOfWalletRegressor : Cross-sectional capacity regressor; pair with this forecaster to move from per-donor capacity to portfolio-level revenue projections. philanthropy.preprocessing.WealthScreeningImputer : The leakage-safe fill contract this model mirrors internally.

References

.. [1] Zhang, G. P. (2003). Time series forecasting using a hybrid ARIMA and neural network model. Neurocomputing, 50, 159-175.

Source code in philanthropy/models/_forecast.py
class FinancialForecastModel(RegressorMixin, BaseEstimator):
    """Hybrid LSTM-ARIMA forecaster for nonprofit revenue / giving series.

    ``FinancialForecastModel`` is a scikit-learn–compatible regressor that
    closes the loop with the LSTM-ARIMA hybrid forecasting literature.  It fits
    two complementary sub-models on the training data:

    * a **linear (ARIMA-surrogate) component**: a
      :class:`~sklearn.linear_model.LinearRegression` mapping the feature
      matrix to giving revenue, capturing the linear / trend structure; and
    * a **nonlinear (LSTM-surrogate) component**: a
      :class:`~sklearn.neural_network.MLPRegressor` fitted on the *residuals*
      of the linear component, capturing the nonlinear structure a linear model
      leaves behind.

    Point predictions (:meth:`predict`) are the additive hybrid
    ``linear(X) + nonlinear_residual(X)``.  Forward-looking, multi-period
    forecasts (:meth:`predict_revenue_forecast`) are produced by seeding a
    frozen autoregressive model with the most recent hybrid predictions and
    rolling it forward over the requested ``horizon``.

    The model handles missing values natively: at :meth:`fit` time it freezes a
    per-column median fill (falling back to ``0.0`` for all-``NaN`` columns) and
    applies it before either sub-model sees the data, so no upstream imputer is
    required and no test-set statistic can leak backwards into training.

    Parameters
    ----------
    ar_order : int, default=3
        Order ``p`` of the autoregressive roll-forward used by
        :meth:`predict_revenue_forecast`.  Each future period is predicted from
        the previous ``ar_order`` periods.  ``0`` disables the autoregressive
        dynamics and produces a flat forecast at the last observed level.
    hidden_layer_sizes : tuple of int, default=(64,)
        Hidden-layer architecture of the nonlinear residual network, passed
        straight through to :class:`~sklearn.neural_network.MLPRegressor`.
        This is the LSTM stand-in; widen or deepen it for more expressive
        nonlinear structure at the cost of training time.
    max_iter : int, default=300
        Maximum optimisation iterations for the residual network.
    alpha : float, default=1e-4
        L2 regularisation strength of the residual network.  Increase to combat
        overfitting on short giving histories.
    random_state : int or None, default=None
        Seed for the residual network's weight initialisation.  Pass an integer
        for fully reproducible forecasts suitable for board-level audit trails.

    Attributes
    ----------
    linear_model_ : LinearRegression
        The fitted linear (ARIMA-surrogate) component.
    nonlinear_model_ : MLPRegressor or None
        The fitted nonlinear (LSTM-surrogate) residual component.  ``None`` when
        the residuals are degenerate (fewer than two samples or zero variance),
        in which case predictions fall back to the linear component alone.
    fill_values_ : ndarray of shape (n_features_in_,)
        Per-column median fill values frozen at :meth:`fit` time.
    ar_coef_ : ndarray of shape (ar_order,)
        Frozen autoregressive coefficients used for the forecast roll-forward.
    ar_intercept_ : float
        Frozen autoregressive intercept.
    y_mean_ : float
        Mean of the training target, used to pad short forecast seeds.
    n_features_in_ : int
        Number of features seen during :meth:`fit`.

    Examples
    --------
    >>> import numpy as np
    >>> from philanthropy.models import FinancialForecastModel
    >>> rng = np.random.default_rng(0)
    >>> X = rng.normal(size=(120, 4))
    >>> # revenue with linear + mild nonlinear structure
    >>> y = 5_000 + 800 * X[:, 0] + 300 * X[:, 1] ** 2 + rng.normal(0, 50, 120)
    >>> model = FinancialForecastModel(random_state=0).fit(X, y)
    >>> preds = model.predict(X)
    >>> preds.shape
    (120,)
    >>> forecast = model.predict_revenue_forecast(X, horizon=4)
    >>> forecast.shape
    (4,)

    See Also
    --------
    philanthropy.models.ShareOfWalletRegressor :
        Cross-sectional capacity regressor; pair with this forecaster to move
        from per-donor capacity to portfolio-level revenue projections.
    philanthropy.preprocessing.WealthScreeningImputer :
        The leakage-safe fill contract this model mirrors internally.

    References
    ----------
    .. [1] Zhang, G. P. (2003). Time series forecasting using a hybrid ARIMA
       and neural network model. *Neurocomputing*, 50, 159-175.
    """

    def __init__(
        self,
        ar_order: int = 3,
        hidden_layer_sizes: Tuple[int, ...] = (64,),
        max_iter: int = 300,
        alpha: float = 1e-4,
        random_state: Optional[int] = None,
    ) -> None:
        # scikit-learn rule: __init__ stores parameters and does NO logic.
        self.ar_order = ar_order
        self.hidden_layer_sizes = hidden_layer_sizes
        self.max_iter = max_iter
        self.alpha = alpha
        self.random_state = random_state

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.allow_nan = True
        tags.regressor_tags.poor_score = True
        return tags

    # ------------------------------------------------------------------
    # Private helpers
    # ------------------------------------------------------------------

    def _impute(self, X: Any) -> np.ndarray:
        """Fill NaNs with the frozen per-column training medians.

        Casting to ``float64`` guarantees ``np.isnan`` works on integer inputs
        and never mutates the caller's array.
        """
        X = np.asarray(X, dtype=np.float64)
        mask = np.isnan(X)
        if mask.any():
            rows, cols = np.where(mask)
            X = X.copy()
            X[rows, cols] = np.take(self.fill_values_, cols)
        return X

    def _fit_autoregressive(self, y: np.ndarray) -> None:
        """Fit and freeze an AR(``ar_order``) model on the training target."""
        p = int(self.ar_order)
        n = y.shape[0]
        self.y_mean_ = float(np.mean(y)) if n else 0.0

        if p <= 0 or n <= p:
            # Not enough history for the requested order: degenerate to a flat
            # forecast at the series mean.
            self.ar_coef_ = np.zeros(max(p, 0), dtype=float)
            self.ar_intercept_ = self.y_mean_
            return

        rows = n - p
        # Column k (1-indexed) holds y[t-k]; row t ranges over [p, n).
        lags = np.column_stack([y[p - k : n - k] for k in range(1, p + 1)])
        target = y[p:]
        design = np.column_stack([np.ones(rows), lags])
        coef, *_ = np.linalg.lstsq(design, target, rcond=None)
        self.ar_intercept_ = float(coef[0])
        self.ar_coef_ = coef[1:]

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def fit(self: _Self, X: Any, y: Any) -> _Self:
        """Fit the hybrid forecaster on labelled revenue data.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix describing each period (e.g. fiscal-year index,
            appeal counts, prior-period giving, macro indicators).  May contain
            ``NaN``; missing values are filled with frozen training medians.
        y : array-like of shape (n_samples,)
            Giving revenue for each period.

        Returns
        -------
        self : FinancialForecastModel
            Fitted estimator (enables method chaining).
        """
        X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
        self.n_features_in_ = X.shape[1]

        # Freeze leakage-safe fill values (per-column training median; all-NaN
        # columns fall back to 0.0) before either sub-model sees the data.
        Xf = np.asarray(X, dtype=np.float64)
        with np.errstate(all="ignore"):
            medians = np.nanmedian(Xf, axis=0)
        self.fill_values_ = np.where(np.isnan(medians), 0.0, medians)
        X_imp = self._impute(Xf)

        # Linear (ARIMA-surrogate) component.
        self.linear_model_ = LinearRegression()
        self.linear_model_.fit(X_imp, y)
        residuals = y - self.linear_model_.predict(X_imp)

        # Nonlinear (LSTM-surrogate) residual component.  A neural network is
        # only meaningful with >1 sample and non-constant residuals; otherwise
        # fall back to the linear component alone.
        self.nonlinear_model_ = None
        if X_imp.shape[0] > 1 and float(residuals.max() - residuals.min()) > 0.0:
            nn = MLPRegressor(
                hidden_layer_sizes=self.hidden_layer_sizes,
                alpha=self.alpha,
                max_iter=self.max_iter,
                random_state=self.random_state,
            )
            nn.fit(X_imp, residuals)
            self.nonlinear_model_ = nn

        # Freeze autoregressive roll-forward coefficients from the training
        # target only (used by predict_revenue_forecast).
        self._fit_autoregressive(np.asarray(y, dtype=float))

        # Iterations run by the residual network (1 when it was skipped).
        self.n_iter_ = (
            self.nonlinear_model_.n_iter_
            if self.nonlinear_model_ is not None
            else 1
        )
        return self

    def predict(self, X: Any) -> np.ndarray:
        """Predict revenue for each period in ``X`` (cross-sectional hybrid).

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix with the same number of columns as seen at
            :meth:`fit`.  May contain ``NaN``.

        Returns
        -------
        y_pred : ndarray of shape (n_samples,)
            Additive hybrid predictions ``linear(X) + nonlinear_residual(X)``.
        """
        check_is_fitted(self)
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        X_imp = self._impute(X)
        out = self.linear_model_.predict(X_imp)
        if self.nonlinear_model_ is not None:
            out = out + self.nonlinear_model_.predict(X_imp)
        return out

    def predict_revenue_forecast(self, X: Any, horizon: int) -> np.ndarray:
        """Forecast giving revenue for the next ``horizon`` periods.

        The supplied ``X`` provides the most recent observed context: its hybrid
        predictions seed a frozen autoregressive roll-forward that projects
        ``horizon`` periods into the future.  Because the autoregressive
        coefficients are frozen at :meth:`fit` time, no information from ``X``
        can contaminate the learned dynamics.

        The roll-forward sees only the target's own recent values, never the
        future periods' features, so it cannot follow a seasonal pattern.
        When the future periods' features are known in advance (fiscal
        month, planned appeal count), call :meth:`predict` on those rows
        instead. On the synthetic monthly benchmark
        (``scripts/benchmark_models_vs_baselines.py``, ``bench_forecast``)
        that beats a 12-month mean, and this method does not.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature matrix for the most recent periods, ordered oldest to
            newest.  May contain ``NaN``.
        horizon : int
            Number of future periods to forecast.  Must be a positive integer.

        Returns
        -------
        forecast : ndarray of shape (horizon,)
            Forecasted revenue for each of the next ``horizon`` periods.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        ValueError
            If ``horizon`` is not a positive integer.
        """
        check_is_fitted(self)
        if isinstance(horizon, bool) or not isinstance(horizon, (int, np.integer)):
            raise ValueError(
                f"`horizon` must be a positive integer, got {horizon!r}."
            )
        if horizon < 1:
            raise ValueError(f"`horizon` must be >= 1, got {horizon}.")

        history = np.asarray(self.predict(X), dtype=float)
        p = int(self.ar_order)

        if p <= 0:
            level = float(history[-1]) if history.size else self.y_mean_
            return np.full(horizon, level)

        # Seed most-recent-first: [y[t-1], y[t-2], ...]; pad short history with
        # the frozen training mean.
        if history.size >= p:
            window = list(history[-p:][::-1])
        else:
            window = list(history[::-1]) + [self.y_mean_] * (p - history.size)

        forecasts = []
        for _ in range(horizon):
            nxt = self.ar_intercept_ + float(np.dot(self.ar_coef_, window))
            forecasts.append(nxt)
            window = [nxt] + window[:-1]
        return np.asarray(forecasts, dtype=float)

fit(X, y)

Fit the hybrid forecaster on labelled revenue data.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix describing each period (e.g. fiscal-year index, appeal counts, prior-period giving, macro indicators). May contain NaN; missing values are filled with frozen training medians.

required
y array-like of shape (n_samples,)

Giving revenue for each period.

required

Returns:

Name Type Description
self FinancialForecastModel

Fitted estimator (enables method chaining).

Source code in philanthropy/models/_forecast.py
def fit(self: _Self, X: Any, y: Any) -> _Self:
    """Fit the hybrid forecaster on labelled revenue data.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix describing each period (e.g. fiscal-year index,
        appeal counts, prior-period giving, macro indicators).  May contain
        ``NaN``; missing values are filled with frozen training medians.
    y : array-like of shape (n_samples,)
        Giving revenue for each period.

    Returns
    -------
    self : FinancialForecastModel
        Fitted estimator (enables method chaining).
    """
    X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
    self.n_features_in_ = X.shape[1]

    # Freeze leakage-safe fill values (per-column training median; all-NaN
    # columns fall back to 0.0) before either sub-model sees the data.
    Xf = np.asarray(X, dtype=np.float64)
    with np.errstate(all="ignore"):
        medians = np.nanmedian(Xf, axis=0)
    self.fill_values_ = np.where(np.isnan(medians), 0.0, medians)
    X_imp = self._impute(Xf)

    # Linear (ARIMA-surrogate) component.
    self.linear_model_ = LinearRegression()
    self.linear_model_.fit(X_imp, y)
    residuals = y - self.linear_model_.predict(X_imp)

    # Nonlinear (LSTM-surrogate) residual component.  A neural network is
    # only meaningful with >1 sample and non-constant residuals; otherwise
    # fall back to the linear component alone.
    self.nonlinear_model_ = None
    if X_imp.shape[0] > 1 and float(residuals.max() - residuals.min()) > 0.0:
        nn = MLPRegressor(
            hidden_layer_sizes=self.hidden_layer_sizes,
            alpha=self.alpha,
            max_iter=self.max_iter,
            random_state=self.random_state,
        )
        nn.fit(X_imp, residuals)
        self.nonlinear_model_ = nn

    # Freeze autoregressive roll-forward coefficients from the training
    # target only (used by predict_revenue_forecast).
    self._fit_autoregressive(np.asarray(y, dtype=float))

    # Iterations run by the residual network (1 when it was skipped).
    self.n_iter_ = (
        self.nonlinear_model_.n_iter_
        if self.nonlinear_model_ is not None
        else 1
    )
    return self

predict(X)

Predict revenue for each period in X (cross-sectional hybrid).

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix with the same number of columns as seen at :meth:fit. May contain NaN.

required

Returns:

Name Type Description
y_pred ndarray of shape (n_samples,)

Additive hybrid predictions linear(X) + nonlinear_residual(X).

Source code in philanthropy/models/_forecast.py
def predict(self, X: Any) -> np.ndarray:
    """Predict revenue for each period in ``X`` (cross-sectional hybrid).

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix with the same number of columns as seen at
        :meth:`fit`.  May contain ``NaN``.

    Returns
    -------
    y_pred : ndarray of shape (n_samples,)
        Additive hybrid predictions ``linear(X) + nonlinear_residual(X)``.
    """
    check_is_fitted(self)
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    X_imp = self._impute(X)
    out = self.linear_model_.predict(X_imp)
    if self.nonlinear_model_ is not None:
        out = out + self.nonlinear_model_.predict(X_imp)
    return out

predict_revenue_forecast(X, horizon)

Forecast giving revenue for the next horizon periods.

The supplied X provides the most recent observed context: its hybrid predictions seed a frozen autoregressive roll-forward that projects horizon periods into the future. Because the autoregressive coefficients are frozen at :meth:fit time, no information from X can contaminate the learned dynamics.

The roll-forward sees only the target's own recent values, never the future periods' features, so it cannot follow a seasonal pattern. When the future periods' features are known in advance (fiscal month, planned appeal count), call :meth:predict on those rows instead. On the synthetic monthly benchmark (scripts/benchmark_models_vs_baselines.py, bench_forecast) that beats a 12-month mean, and this method does not.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Feature matrix for the most recent periods, ordered oldest to newest. May contain NaN.

required
horizon int

Number of future periods to forecast. Must be a positive integer.

required

Returns:

Name Type Description
forecast ndarray of shape (horizon,)

Forecasted revenue for each of the next horizon periods.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

ValueError

If horizon is not a positive integer.

Source code in philanthropy/models/_forecast.py
def predict_revenue_forecast(self, X: Any, horizon: int) -> np.ndarray:
    """Forecast giving revenue for the next ``horizon`` periods.

    The supplied ``X`` provides the most recent observed context: its hybrid
    predictions seed a frozen autoregressive roll-forward that projects
    ``horizon`` periods into the future.  Because the autoregressive
    coefficients are frozen at :meth:`fit` time, no information from ``X``
    can contaminate the learned dynamics.

    The roll-forward sees only the target's own recent values, never the
    future periods' features, so it cannot follow a seasonal pattern.
    When the future periods' features are known in advance (fiscal
    month, planned appeal count), call :meth:`predict` on those rows
    instead. On the synthetic monthly benchmark
    (``scripts/benchmark_models_vs_baselines.py``, ``bench_forecast``)
    that beats a 12-month mean, and this method does not.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Feature matrix for the most recent periods, ordered oldest to
        newest.  May contain ``NaN``.
    horizon : int
        Number of future periods to forecast.  Must be a positive integer.

    Returns
    -------
    forecast : ndarray of shape (horizon,)
        Forecasted revenue for each of the next ``horizon`` periods.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    ValueError
        If ``horizon`` is not a positive integer.
    """
    check_is_fitted(self)
    if isinstance(horizon, bool) or not isinstance(horizon, (int, np.integer)):
        raise ValueError(
            f"`horizon` must be a positive integer, got {horizon!r}."
        )
    if horizon < 1:
        raise ValueError(f"`horizon` must be >= 1, got {horizon}.")

    history = np.asarray(self.predict(X), dtype=float)
    p = int(self.ar_order)

    if p <= 0:
        level = float(history[-1]) if history.size else self.y_mean_
        return np.full(horizon, level)

    # Seed most-recent-first: [y[t-1], y[t-2], ...]; pad short history with
    # the frozen training mean.
    if history.size >= p:
        window = list(history[-p:][::-1])
    else:
        window = list(history[::-1]) + [self.y_mean_] * (p - history.size)

    forecasts = []
    for _ in range(horizon):
        nxt = self.ar_intercept_ + float(np.dot(self.ar_coef_, window))
        forecasts.append(nxt)
        window = [nxt] + window[:-1]
    return np.asarray(forecasts, dtype=float)

GiftInterval dataclass

A calibrated interval on a dollar amount, with the level it attained.

Attributes:

Name Type Description
lower, upper ndarray of shape (n_samples,)

The interval, in the target's units. lower is intersected with the support unless lower_bound=None was passed to the calibrator.

rank ndarray of int, shape (n_samples,)

The order statistic r used for each row. Constant unless the calibrator was fitted with groups, in which case it varies with the group's calibration size.

attained_level ndarray of shape (n_samples,)

r / (n + 1): the coverage this interval actually certifies. Read this, not requested_level.

requested_level float

1 - alpha, what the caller asked for.

Source code in philanthropy/models/_conformal_interval.py
@dataclass(frozen=True)
class GiftInterval:
    """A calibrated interval on a dollar amount, with the level it attained.

    Attributes
    ----------
    lower, upper : ndarray of shape (n_samples,)
        The interval, in the target's units. ``lower`` is intersected with the
        support unless ``lower_bound=None`` was passed to the calibrator.
    rank : ndarray of int, shape (n_samples,)
        The order statistic ``r`` used for each row. Constant unless the
        calibrator was fitted with ``groups``, in which case it varies with the
        group's calibration size.
    attained_level : ndarray of shape (n_samples,)
        ``r / (n + 1)``: the coverage this interval actually certifies. Read
        this, not ``requested_level``.
    requested_level : float
        ``1 - alpha``, what the caller asked for.
    """

    lower: np.ndarray
    upper: np.ndarray
    rank: np.ndarray
    attained_level: np.ndarray
    requested_level: float

GiftIntervalCalibrator

Bases: RegressorMixin, BaseEstimator

Turn a fitted dollar-valued regressor into calibrated intervals.

Split conformal prediction over one order statistic. fit calibrates on held-out rows and never touches estimator, which must already be fitted; :meth:predict forwards to it unchanged, so the point prediction a gift officer sees does not move when an interval is added around it.

Parameters:

Name Type Description Default
estimator object

A fitted regressor whose predict returns a dollar amount. Fitting it on the rows passed to :meth:fit would make the conformity scores in-sample and void the guarantee, so this class checks and refuses.

required
alpha (float, Fraction, Decimal or int)

Miscoverage. The interval targets 1 - alpha and reports what it attains. Read exactly: 0.05 means one twentieth, and an alpha with no finite decimal form must be passed as Fraction(1, 3) rather than 1 / 3.

0.05
score ('absolute', 'difficulty', 'log')

The conformity score, all three of them one-rank:

"absolute" |y - yhat|. Constant width, and the width every other score is judged against. "difficulty" |y - yhat| / sigma(X), with sigma from difficulty_estimator. Width scales with the difficulty estimate, so a well-understood annual donor gets a narrower interval than an unscreened prospect. "log" |log1p(y) - log1p(yhat)|, inverted back to dollars. Width scales with the amount, which is the right shape for a right-skewed gift distribution. log1p rather than log so a $0 outcome stays in the domain.

Equal-tailed two-rank intervals are deliberately absent: two order statistics at alpha / 2 more than double the floor (39 rows at the 95 % level against 19) and buy nothing these three do not. The ratio is (2 - alpha) / (1 - alpha), which is strictly above two at every level and reaches two only as alpha goes to zero.

"absolute"
difficulty_estimator object or callable

Required when score="difficulty". Either an object with predict or a callable, mapping X to strictly positive scale estimates. Fit it on the regressor's training rows, not on the calibration rows.

None
lower_bound float or None

Intersect the interval with [lower_bound, inf). A gift amount cannot be negative, and clipping to a bound the target respects leaves coverage unchanged while strictly narrowing width. None disables it, for a target that can legitimately go negative (a net change, a refund-adjusted total). Calibration targets below the bound raise, because they are evidence the bound is wrong.

0.0

Attributes:

Name Type Description
quantile_ float or dict

The calibrated score at rank rank_. A dict keyed by group label when fit received groups.

rank_ int or dict

r = ceil((n + 1) * (1 - alpha)).

attained_level_ float or dict

r / (n + 1). Not 1 - alpha; see the module docstring.

n_calibration_ int or dict

Calibration rows used.

requested_level_ float

1 - alpha.

groups_ ndarray or None

The distinct group labels calibrated for, or None when pooled.

n_features_in_ int

Features seen during :meth:fit.

Raises:

Type Description
NotFittedError

If estimator is not fitted when :meth:fit is called.

ValueError

If the calibration set, or any group in it, is below the floor for the requested level; if score="difficulty" without a positive difficulty_estimator; if a calibration target falls below lower_bound; or if a group at predict time was not calibrated for.

Examples:

Pooled calibration:

>>> import numpy as np
>>> from philanthropy.models import AskAmountRecommender, GiftIntervalCalibrator
>>> rng = np.random.default_rng(3)
>>> X = rng.uniform(0, 1, (240, 4))
>>> y = 20_000 + 40_000 * X[:, 0] + rng.normal(0, 3_000, 240)
>>> ask = AskAmountRecommender(max_iter=40, random_state=3).fit(X[:140], y[:140])
>>> cal = GiftIntervalCalibrator(ask, alpha=0.1).fit(X[140:200], y[140:200])
>>> cal.rank_, cal.n_calibration_
(55, 60)
>>> round(cal.attained_level_, 4)
0.9016

Per-segment calibration. Segments are calibrated independently, so the rank and the attained level differ between them:

>>> seg = np.where(X[140:200, 1] > 0.5, "principal", "annual")
>>> cal = GiftIntervalCalibrator(ask, alpha=0.1).fit(X[140:200], y[140:200], groups=seg)
>>> sorted(cal.n_calibration_.items())
[('annual', 33), ('principal', 27)]
>>> interval = cal.predict_gift_interval(X[200:], groups=np.where(
...     X[200:, 1] > 0.5, "principal", "annual"))
>>> bool((interval.upper >= interval.lower).all())
True
>>> sorted(set(interval.rank.tolist()))
[26, 31]
Notes

Why this is not a check_estimator-compliant estimator. The battery clones with default parameters and calls fit(X, y). This class cannot satisfy that: calibrating and training on the same rows is the one thing it exists to prevent. It is exempted in tests/test_sklearn_compliance.py with that reason, rather than gaining a parameter that carves training rows out of the calibration set to satisfy a test.

What exchangeability buys and what it does not. The guarantee is marginal over the calibration draw: coverage holds on average across calibration sets, not conditionally on a donor's features. Per-group calibration is the practical answer to that gap, and is why groups exists.

See Also

philanthropy.metrics.interval_report : Whether the interval is narrow enough to act on.

Source code in philanthropy/models/_conformal_interval.py
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
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
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
class GiftIntervalCalibrator(RegressorMixin, BaseEstimator):
    """Turn a fitted dollar-valued regressor into calibrated intervals.

    Split conformal prediction over one order statistic. ``fit`` calibrates on
    held-out rows and never touches ``estimator``, which must already be fitted;
    :meth:`predict` forwards to it unchanged, so the point prediction a gift
    officer sees does not move when an interval is added around it.

    Parameters
    ----------
    estimator : object
        A **fitted** regressor whose ``predict`` returns a dollar amount. Fitting
        it on the rows passed to :meth:`fit` would make the conformity scores
        in-sample and void the guarantee, so this class checks and refuses.
    alpha : float, Fraction, Decimal or int, default=0.05
        Miscoverage. The interval targets ``1 - alpha`` and reports what it
        attains. Read exactly: ``0.05`` means one twentieth, and an alpha with
        no finite decimal form must be passed as ``Fraction(1, 3)`` rather than
        ``1 / 3``.
    score : {"absolute", "difficulty", "log"}, default="absolute"
        The conformity score, all three of them one-rank:

        ``"absolute"``
            ``|y - yhat|``. Constant width, and the width every other score is
            judged against.
        ``"difficulty"``
            ``|y - yhat| / sigma(X)``, with ``sigma`` from
            ``difficulty_estimator``. Width scales with the difficulty estimate,
            so a well-understood annual donor gets a narrower interval than an
            unscreened prospect.
        ``"log"``
            ``|log1p(y) - log1p(yhat)|``, inverted back to dollars. Width scales
            with the amount, which is the right shape for a right-skewed gift
            distribution. ``log1p`` rather than ``log`` so a $0 outcome stays in
            the domain.

        Equal-tailed two-rank intervals are deliberately absent: two order
        statistics at ``alpha / 2`` more than double the floor (39 rows at the
        95 % level against 19) and buy nothing these three do not. The ratio is
        ``(2 - alpha) / (1 - alpha)``, which is strictly above two at every level
        and reaches two only as ``alpha`` goes to zero.
    difficulty_estimator : object or callable, default=None
        Required when ``score="difficulty"``. Either an object with ``predict``
        or a callable, mapping ``X`` to strictly positive scale estimates. Fit it
        on the regressor's training rows, not on the calibration rows.
    lower_bound : float or None, default=0.0
        Intersect the interval with ``[lower_bound, inf)``. A gift amount cannot
        be negative, and clipping to a bound the target respects leaves coverage
        unchanged while strictly narrowing width. ``None`` disables it, for a
        target that can legitimately go negative (a net change, a refund-adjusted
        total). Calibration targets below the bound raise, because they are
        evidence the bound is wrong.

    Attributes
    ----------
    quantile_ : float or dict
        The calibrated score at rank ``rank_``. A ``dict`` keyed by group label
        when ``fit`` received ``groups``.
    rank_ : int or dict
        ``r = ceil((n + 1) * (1 - alpha))``.
    attained_level_ : float or dict
        ``r / (n + 1)``. Not ``1 - alpha``; see the module docstring.
    n_calibration_ : int or dict
        Calibration rows used.
    requested_level_ : float
        ``1 - alpha``.
    groups_ : ndarray or None
        The distinct group labels calibrated for, or ``None`` when pooled.
    n_features_in_ : int
        Features seen during :meth:`fit`.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If ``estimator`` is not fitted when :meth:`fit` is called.
    ValueError
        If the calibration set, or any group in it, is below the floor for the
        requested level; if ``score="difficulty"`` without a positive
        ``difficulty_estimator``; if a calibration target falls below
        ``lower_bound``; or if a group at predict time was not calibrated for.

    Examples
    --------
    **Pooled calibration:**

    >>> import numpy as np
    >>> from philanthropy.models import AskAmountRecommender, GiftIntervalCalibrator
    >>> rng = np.random.default_rng(3)
    >>> X = rng.uniform(0, 1, (240, 4))
    >>> y = 20_000 + 40_000 * X[:, 0] + rng.normal(0, 3_000, 240)
    >>> ask = AskAmountRecommender(max_iter=40, random_state=3).fit(X[:140], y[:140])
    >>> cal = GiftIntervalCalibrator(ask, alpha=0.1).fit(X[140:200], y[140:200])
    >>> cal.rank_, cal.n_calibration_
    (55, 60)
    >>> round(cal.attained_level_, 4)
    0.9016

    **Per-segment calibration.** Segments are calibrated independently, so the
    rank and the attained level differ between them:

    >>> seg = np.where(X[140:200, 1] > 0.5, "principal", "annual")
    >>> cal = GiftIntervalCalibrator(ask, alpha=0.1).fit(X[140:200], y[140:200], groups=seg)
    >>> sorted(cal.n_calibration_.items())
    [('annual', 33), ('principal', 27)]
    >>> interval = cal.predict_gift_interval(X[200:], groups=np.where(
    ...     X[200:, 1] > 0.5, "principal", "annual"))
    >>> bool((interval.upper >= interval.lower).all())
    True
    >>> sorted(set(interval.rank.tolist()))
    [26, 31]

    Notes
    -----
    **Why this is not a ``check_estimator``-compliant estimator.** The battery
    clones with default parameters and calls ``fit(X, y)``. This class cannot
    satisfy that: calibrating and training on the same rows is the one thing it
    exists to prevent. It is exempted in ``tests/test_sklearn_compliance.py``
    with that reason, rather than gaining a parameter that carves training rows
    out of the calibration set to satisfy a test.

    **What exchangeability buys and what it does not.** The guarantee is
    marginal over the calibration draw: coverage holds on average across
    calibration sets, not conditionally on a donor's features. Per-group
    calibration is the practical answer to that gap, and is why ``groups``
    exists.

    See Also
    --------
    philanthropy.metrics.interval_report :
        Whether the interval is narrow enough to act on.
    """

    # Scalar when calibration is pooled, dict keyed by group label when it is
    # not; ``groups_`` says which. Annotated loosely because the shape is a
    # function of how ``fit`` was called, not of the class.
    quantile_: Any
    rank_: Any
    attained_level_: Any
    n_calibration_: Any

    def __init__(
        self,
        estimator: Any,
        alpha: _Alpha = 0.05,
        score: str = "absolute",
        difficulty_estimator: Any = None,
        lower_bound: Optional[float] = 0.0,
    ) -> None:
        # scikit-learn rule: __init__ stores parameters and does NO logic.
        self.estimator = estimator
        self.alpha = alpha
        self.score = score
        self.difficulty_estimator = difficulty_estimator
        self.lower_bound = lower_bound

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.allow_nan = True
        tags.regressor_tags.poor_score = True
        return tags

    # ------------------------------------------------------------------
    # Public API
    # ------------------------------------------------------------------

    def fit(self: _Self, X: Any, y: Any, groups: Any = None) -> _Self:
        """Calibrate on held-out rows.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Calibration features. Held out of ``estimator``'s training data and
            exchangeable with the rows to be scored. May contain ``NaN`` if
            ``estimator`` accepts it.
        y : array-like of shape (n_samples,)
            Realised dollar amounts for those rows.
        groups : array-like of shape (n_samples,), optional
            Segment label to calibrate within: capacity tier, sector, giving
            society, business unit. **Not** the donor identifier and not the
            fiscal year. Every distinct label is calibrated on its own rows and
            must clear the floor on its own; passing donor ids here gives one
            row per group and is refused.

        Returns
        -------
        self : GiftIntervalCalibrator
        """
        alpha = _exact_alpha(self.alpha)
        if not 0 < alpha < 1:
            raise ValueError(
                f"alpha must satisfy 0 < alpha < 1, got {self.alpha!r}."
            )
        if self.score not in _SCORES:
            raise ValueError(
                f"score must be one of {_SCORES}, got {self.score!r}."
            )
        self._check_prefit()

        _, y_valid = validate_data(
            self, X, y, ensure_all_finite="allow-nan", reset=True
        )
        y_cal = np.asarray(y_valid, dtype=float).ravel()

        if self.lower_bound is not None:
            below = int(np.count_nonzero(y_cal < self.lower_bound))
            if below:
                raise ValueError(
                    f"{below} calibration target(s) fall below "
                    f"lower_bound={self.lower_bound!r}. Clipping the interval "
                    "to a bound the target does not respect changes coverage "
                    "instead of leaving it alone. Pass lower_bound=None if the "
                    "target can legitimately go below it."
                )

        yhat = self._point(X, y_cal.size)
        scores, _ = self._conformity_scores(X, yhat, y_cal)

        self.requested_level_ = float(1 - alpha)
        self._alpha_ = alpha

        if groups is None:
            self.groups_ = None
            self.quantile_, self.rank_ = _calibrate(scores, alpha)
            self.n_calibration_ = int(scores.size)
            self.attained_level_ = float(Fraction(self.rank_, scores.size + 1))
            return self

        labels = self._group_labels(groups, y_cal.size)
        keys, counts = np.unique(labels, return_counts=True)
        floor = _min_calibration_size(alpha)
        short = [(_key(k), int(c)) for k, c in zip(keys, counts) if c < floor]
        if short:
            raise ValueError(
                "per-group calibration will not pool a group that cannot "
                f"certify a {self.requested_level_:.6g} interval on its own "
                f"({floor} row(s) needed per group). Short: "
                + ", ".join(f"{k!r} ({c} row(s))" for k, c in short)
                + ". Pooling them with a larger group calibrates them at "
                "another segment's capacity level, which is the failure this "
                "argument exists to avoid: drop the segment, merge it into "
                "another deliberately, or ask for a lower level."
            )

        self.groups_ = keys
        self.quantile_, self.rank_ = {}, {}
        self.n_calibration_, self.attained_level_ = {}, {}
        for k in keys:
            in_group = labels == k
            q, r = _calibrate(scores[in_group], alpha, where=f"group {_key(k)!r}: ")
            n_g = int(in_group.sum())
            self.quantile_[_key(k)] = q
            self.rank_[_key(k)] = r
            self.n_calibration_[_key(k)] = n_g
            self.attained_level_[_key(k)] = float(Fraction(r, n_g + 1))
        return self

    def predict(self, X: Any) -> np.ndarray:
        """Return ``estimator``'s point prediction, unchanged."""
        check_is_fitted(self, ["quantile_"])
        validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        return self._point(X, None)

    def predict_gift_interval(self, X: Any, groups: Any = None) -> GiftInterval:
        """Return a calibrated interval on the dollar amount for each row.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Features for the rows to be scored.
        groups : array-like of shape (n_samples,), optional
            Required, and only accepted, when :meth:`fit` received ``groups``.
            A label that was not calibrated for raises rather than falling back
            to a pooled quantile.

        Returns
        -------
        GiftInterval
            ``lower``, ``upper``, and the rank and attained level behind them.
        """
        check_is_fitted(self, ["quantile_"])
        validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        n = _n_rows(X)
        yhat = self._point(X, n)

        if self.groups_ is None:
            if groups is not None:
                raise ValueError(
                    "this calibrator was fitted without groups, so a pooled "
                    "quantile is all it has; passing groups here would imply a "
                    "per-segment guarantee it cannot make. Refit with groups."
                )
            q = np.full(n, self.quantile_, dtype=float)
            rank = np.full(n, self.rank_, dtype=int)
            level = np.full(n, self.attained_level_, dtype=float)
        else:
            if groups is None:
                raise ValueError(
                    "this calibrator was fitted with groups, so every row needs "
                    "the segment it belongs to. Pass groups= to "
                    "predict_gift_interval."
                )
            labels = self._group_labels(groups, n)
            unseen = {_key(v) for v in labels} - set(self.quantile_)
            if unseen:
                raise ValueError(
                    "no calibration rows for group(s) "
                    f"{sorted(unseen, key=repr)}; the calibrated segments are "
                    f"{sorted(self.quantile_, key=repr)}. A segment with no "
                    "calibration data has no certified interval, and borrowing "
                    "another segment's quantile is the pooling this argument "
                    "exists to prevent."
                )
            keys = [_key(v) for v in labels]
            q = np.array([self.quantile_[k] for k in keys], dtype=float)
            rank = np.array([self.rank_[k] for k in keys], dtype=int)
            level = np.array([self.attained_level_[k] for k in keys], dtype=float)

        lower, upper = self._bounds(X, yhat, q)
        if self.lower_bound is not None:
            lower = np.maximum(lower, float(self.lower_bound))
        return GiftInterval(
            lower=lower,
            upper=upper,
            rank=rank,
            attained_level=level,
            requested_level=self.requested_level_,
        )

    # ------------------------------------------------------------------
    # Internals
    # ------------------------------------------------------------------

    def _check_prefit(self) -> None:
        try:
            check_is_fitted(self.estimator)
        except NotFittedError:
            raise NotFittedError(
                "GiftIntervalCalibrator calibrates a regressor that is already "
                "fitted: fit `estimator` on training rows first, then pass "
                "held-out rows to this fit. Fitting both on the same rows would "
                "make every conformity score in-sample and the coverage "
                "guarantee void."
            ) from None
        if not hasattr(self.estimator, "predict"):
            raise TypeError(
                "estimator must have a predict method returning a dollar "
                f"amount; {type(self.estimator).__name__} does not."
            )

    def _point(self, X: Any, n: Optional[int]) -> np.ndarray:
        """Delegate to ``estimator``, passing X through untouched.

        ``validate_data`` has already checked the width; the original object
        goes to the inner estimator so a DataFrame keeps its column names.
        """
        yhat = np.asarray(self.estimator.predict(X), dtype=float).ravel()
        if n is not None and yhat.shape != (n,):
            raise ValueError(
                f"estimator.predict returned {yhat.shape} for {n} row(s); "
                "GiftIntervalCalibrator wraps per-row regressors only. "
                "FinancialForecastModel.predict_revenue_forecast, for instance, "
                "returns one value per horizon step rather than per row."
            )
        return yhat

    def _difficulty(self, X: Any, n: int) -> np.ndarray:
        est = self.difficulty_estimator
        if est is None:
            raise ValueError(
                'score="difficulty" needs a difficulty_estimator: an object '
                "with predict, or a callable, mapping X to a strictly positive "
                "scale. Fit it on the regressor's training rows."
            )
        raw = est.predict(X) if hasattr(est, "predict") else est(X)
        sigma = np.asarray(raw, dtype=float).ravel()
        if sigma.shape != (n,):
            raise ValueError(
                f"difficulty_estimator returned {sigma.shape} for {n} row(s); "
                "one positive scale per row is required."
            )
        if not np.all(np.isfinite(sigma)) or np.any(sigma <= 0):
            raise ValueError(
                "difficulty_estimator must return strictly positive finite "
                "scales: a zero divides, a negative one inverts the interval. "
                f"Got min {np.nanmin(sigma)!r}."
            )
        return sigma

    def _conformity_scores(self, X: Any, yhat: np.ndarray, y: np.ndarray) -> tuple:
        if self.score == "absolute":
            return np.abs(y - yhat), None
        if self.score == "difficulty":
            sigma = self._difficulty(X, y.size)
            return np.abs(y - yhat) / sigma, sigma
        self._check_log_domain(yhat, y)
        return np.abs(np.log1p(y) - np.log1p(yhat)), None

    def _check_log_domain(self, yhat: np.ndarray, y: Optional[np.ndarray]) -> None:
        bad = np.any(yhat < 0) or (y is not None and np.any(y < 0))
        if bad:
            raise ValueError(
                'score="log" works on log1p dollars, so it needs the point '
                "predictions and the targets to be non-negative. Clip the "
                "regressor (AskAmountRecommender has ask_floor), or use "
                'score="absolute" for a target that can go negative.'
            )

    def _bounds(self, X: Any, yhat: np.ndarray, q: np.ndarray) -> tuple:
        if self.score == "log":
            self._check_log_domain(yhat, None)
            base = np.log1p(yhat)
            return np.expm1(base - q), np.expm1(base + q)
        half = q * self._difficulty(X, yhat.size) if self.score == "difficulty" else q
        return yhat - half, yhat + half

    @staticmethod
    def _group_labels(groups: Any, n: int) -> np.ndarray:
        labels = np.asarray(groups)
        if labels.ndim != 1:
            raise ValueError(
                "groups must be one-dimensional: one segment label per row, "
                f"got shape {labels.shape}."
            )
        if labels.size != n:
            raise ValueError(
                f"groups has {labels.size} label(s) for {n} row(s)."
            )
        return labels

fit(X, y, groups=None)

Calibrate on held-out rows.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Calibration features. Held out of estimator's training data and exchangeable with the rows to be scored. May contain NaN if estimator accepts it.

required
y array-like of shape (n_samples,)

Realised dollar amounts for those rows.

required
groups array-like of shape (n_samples,)

Segment label to calibrate within: capacity tier, sector, giving society, business unit. Not the donor identifier and not the fiscal year. Every distinct label is calibrated on its own rows and must clear the floor on its own; passing donor ids here gives one row per group and is refused.

None

Returns:

Name Type Description
self GiftIntervalCalibrator
Source code in philanthropy/models/_conformal_interval.py
def fit(self: _Self, X: Any, y: Any, groups: Any = None) -> _Self:
    """Calibrate on held-out rows.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Calibration features. Held out of ``estimator``'s training data and
        exchangeable with the rows to be scored. May contain ``NaN`` if
        ``estimator`` accepts it.
    y : array-like of shape (n_samples,)
        Realised dollar amounts for those rows.
    groups : array-like of shape (n_samples,), optional
        Segment label to calibrate within: capacity tier, sector, giving
        society, business unit. **Not** the donor identifier and not the
        fiscal year. Every distinct label is calibrated on its own rows and
        must clear the floor on its own; passing donor ids here gives one
        row per group and is refused.

    Returns
    -------
    self : GiftIntervalCalibrator
    """
    alpha = _exact_alpha(self.alpha)
    if not 0 < alpha < 1:
        raise ValueError(
            f"alpha must satisfy 0 < alpha < 1, got {self.alpha!r}."
        )
    if self.score not in _SCORES:
        raise ValueError(
            f"score must be one of {_SCORES}, got {self.score!r}."
        )
    self._check_prefit()

    _, y_valid = validate_data(
        self, X, y, ensure_all_finite="allow-nan", reset=True
    )
    y_cal = np.asarray(y_valid, dtype=float).ravel()

    if self.lower_bound is not None:
        below = int(np.count_nonzero(y_cal < self.lower_bound))
        if below:
            raise ValueError(
                f"{below} calibration target(s) fall below "
                f"lower_bound={self.lower_bound!r}. Clipping the interval "
                "to a bound the target does not respect changes coverage "
                "instead of leaving it alone. Pass lower_bound=None if the "
                "target can legitimately go below it."
            )

    yhat = self._point(X, y_cal.size)
    scores, _ = self._conformity_scores(X, yhat, y_cal)

    self.requested_level_ = float(1 - alpha)
    self._alpha_ = alpha

    if groups is None:
        self.groups_ = None
        self.quantile_, self.rank_ = _calibrate(scores, alpha)
        self.n_calibration_ = int(scores.size)
        self.attained_level_ = float(Fraction(self.rank_, scores.size + 1))
        return self

    labels = self._group_labels(groups, y_cal.size)
    keys, counts = np.unique(labels, return_counts=True)
    floor = _min_calibration_size(alpha)
    short = [(_key(k), int(c)) for k, c in zip(keys, counts) if c < floor]
    if short:
        raise ValueError(
            "per-group calibration will not pool a group that cannot "
            f"certify a {self.requested_level_:.6g} interval on its own "
            f"({floor} row(s) needed per group). Short: "
            + ", ".join(f"{k!r} ({c} row(s))" for k, c in short)
            + ". Pooling them with a larger group calibrates them at "
            "another segment's capacity level, which is the failure this "
            "argument exists to avoid: drop the segment, merge it into "
            "another deliberately, or ask for a lower level."
        )

    self.groups_ = keys
    self.quantile_, self.rank_ = {}, {}
    self.n_calibration_, self.attained_level_ = {}, {}
    for k in keys:
        in_group = labels == k
        q, r = _calibrate(scores[in_group], alpha, where=f"group {_key(k)!r}: ")
        n_g = int(in_group.sum())
        self.quantile_[_key(k)] = q
        self.rank_[_key(k)] = r
        self.n_calibration_[_key(k)] = n_g
        self.attained_level_[_key(k)] = float(Fraction(r, n_g + 1))
    return self

predict(X)

Return estimator's point prediction, unchanged.

Source code in philanthropy/models/_conformal_interval.py
def predict(self, X: Any) -> np.ndarray:
    """Return ``estimator``'s point prediction, unchanged."""
    check_is_fitted(self, ["quantile_"])
    validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    return self._point(X, None)

predict_gift_interval(X, groups=None)

Return a calibrated interval on the dollar amount for each row.

Parameters:

Name Type Description Default
X array-like of shape (n_samples, n_features)

Features for the rows to be scored.

required
groups array-like of shape (n_samples,)

Required, and only accepted, when :meth:fit received groups. A label that was not calibrated for raises rather than falling back to a pooled quantile.

None

Returns:

Type Description
GiftInterval

lower, upper, and the rank and attained level behind them.

Source code in philanthropy/models/_conformal_interval.py
def predict_gift_interval(self, X: Any, groups: Any = None) -> GiftInterval:
    """Return a calibrated interval on the dollar amount for each row.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Features for the rows to be scored.
    groups : array-like of shape (n_samples,), optional
        Required, and only accepted, when :meth:`fit` received ``groups``.
        A label that was not calibrated for raises rather than falling back
        to a pooled quantile.

    Returns
    -------
    GiftInterval
        ``lower``, ``upper``, and the rank and attained level behind them.
    """
    check_is_fitted(self, ["quantile_"])
    validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    n = _n_rows(X)
    yhat = self._point(X, n)

    if self.groups_ is None:
        if groups is not None:
            raise ValueError(
                "this calibrator was fitted without groups, so a pooled "
                "quantile is all it has; passing groups here would imply a "
                "per-segment guarantee it cannot make. Refit with groups."
            )
        q = np.full(n, self.quantile_, dtype=float)
        rank = np.full(n, self.rank_, dtype=int)
        level = np.full(n, self.attained_level_, dtype=float)
    else:
        if groups is None:
            raise ValueError(
                "this calibrator was fitted with groups, so every row needs "
                "the segment it belongs to. Pass groups= to "
                "predict_gift_interval."
            )
        labels = self._group_labels(groups, n)
        unseen = {_key(v) for v in labels} - set(self.quantile_)
        if unseen:
            raise ValueError(
                "no calibration rows for group(s) "
                f"{sorted(unseen, key=repr)}; the calibrated segments are "
                f"{sorted(self.quantile_, key=repr)}. A segment with no "
                "calibration data has no certified interval, and borrowing "
                "another segment's quantile is the pooling this argument "
                "exists to prevent."
            )
        keys = [_key(v) for v in labels]
        q = np.array([self.quantile_[k] for k in keys], dtype=float)
        rank = np.array([self.rank_[k] for k in keys], dtype=int)
        level = np.array([self.attained_level_[k] for k in keys], dtype=float)

    lower, upper = self._bounds(X, yhat, q)
    if self.lower_bound is not None:
        lower = np.maximum(lower, float(self.lower_bound))
    return GiftInterval(
        lower=lower,
        upper=upper,
        rank=rank,
        attained_level=level,
        requested_level=self.requested_level_,
    )

BetaGeoModel

Bases: BaseEstimator

BG/NBD model of repeat giving: who is still giving, and how often.

Fit by maximum likelihood on one row per donor. No labels needed.

Parameters:

Name Type Description Default
max_iter int

Maximum optimiser iterations.

2000

Attributes:

Name Type Description
params_ dict

Fitted r, alpha (gift-rate gamma shape and scale) and a, b (drop-out beta). On the CDNOW sample the published values are r=0.243, alpha=4.414, a=0.793, b=2.426 [1]_.

log_likelihood_ float

Total log-likelihood at params_.

n_iter_ int

Optimiser iterations used.

n_features_in_ int

Always 3.

Notes

X is (frequency, recency, T) in that column order, as built by :func:repeat_giving_summary: repeat-gift count, time from first to last gift, and time from first gift to the end of the window, all in one time unit. recency must not exceed T.

Examples:

>>> import numpy as np
>>> from philanthropy.models import BetaGeoModel
>>> X = np.array([[0, 0, 5], [3, 4, 5], [1, 1, 5], [6, 4.8, 5]] * 10, float)
>>> model = BetaGeoModel().fit(X)
>>> model.predict_alive_score(X[:2]).shape
(2,)
Source code in philanthropy/models/_repeat_giving.py
class BetaGeoModel(BaseEstimator):
    """BG/NBD model of repeat giving: who is still giving, and how often.

    Fit by maximum likelihood on one row per donor. No labels needed.

    Parameters
    ----------
    max_iter : int, default=2000
        Maximum optimiser iterations.

    Attributes
    ----------
    params_ : dict
        Fitted ``r``, ``alpha`` (gift-rate gamma shape and scale) and ``a``,
        ``b`` (drop-out beta). On the CDNOW sample the published values are
        r=0.243, alpha=4.414, a=0.793, b=2.426 [1]_.
    log_likelihood_ : float
        Total log-likelihood at ``params_``.
    n_iter_ : int
        Optimiser iterations used.
    n_features_in_ : int
        Always 3.

    Notes
    -----
    ``X`` is ``(frequency, recency, T)`` in that column order, as built by
    :func:`repeat_giving_summary`: repeat-gift count, time from first to last
    gift, and time from first gift to the end of the window, all in one time
    unit. ``recency`` must not exceed ``T``.

    Examples
    --------
    >>> import numpy as np
    >>> from philanthropy.models import BetaGeoModel
    >>> X = np.array([[0, 0, 5], [3, 4, 5], [1, 1, 5], [6, 4.8, 5]] * 10, float)
    >>> model = BetaGeoModel().fit(X)
    >>> model.predict_alive_score(X[:2]).shape
    (2,)
    """

    def __init__(self, max_iter: int = 2000) -> None:
        self.max_iter = max_iter

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.positive_only = True
        return tags

    def _check_X(self, X: Any, reset: bool) -> np.ndarray:
        X = validate_data(self, X, reset=reset, dtype=np.float64)
        if X.shape[1] != 3:
            raise ValueError(
                f"BetaGeoModel expects 3 columns (frequency, recency, T), got "
                f"{X.shape[1]} feature(s)."
            )
        if (X < 0).any():
            raise ValueError("frequency, recency and T must be non-negative.")
        if (X[:, 1] > X[:, 2]).any():
            raise ValueError("recency cannot exceed T.")
        return X

    def fit(self: _BG, X: Any, y: Any = None) -> _BG:
        """Fit r, alpha, a, b by maximum likelihood.

        Parameters
        ----------
        X : array-like of shape (n_donors, 3)
            ``(frequency, recency, T)`` per donor.
        y : ignored

        Returns
        -------
        self : BetaGeoModel
        """
        X = self._check_X(X, reset=True)
        x, t_x, T = X.T

        def neg_ll(log_params: np.ndarray) -> float:
            return -float(self._log_likelihood(np.exp(log_params), x, t_x, T).sum())

        params, result = _fit_log_params(neg_ll, 4, self.max_iter)
        self.params_ = dict(zip(("r", "alpha", "a", "b"), params))
        self.log_likelihood_ = float(-result.fun)
        self.n_iter_ = int(result.nit)
        return self

    @staticmethod
    def _log_likelihood(
        params: np.ndarray, x: np.ndarray, t_x: np.ndarray, T: np.ndarray
    ) -> np.ndarray:
        r, alpha, a, b = params
        a1 = gammaln(r + x) - gammaln(r) + r * np.log(alpha)
        a2 = gammaln(a + b) + gammaln(b + x) - gammaln(b) - gammaln(a + b + x)
        a3 = -(r + x) * np.log(alpha + T)
        with np.errstate(divide="ignore", invalid="ignore"):
            a4 = np.where(
                x > 0,
                np.log(a) - np.log(b + x - 1) - (r + x) * np.log(alpha + t_x),
                -np.inf,
            )
        return a1 + a2 + np.logaddexp(a3, a4)

    def _dropout_odds(
        self, x: np.ndarray, t_x: np.ndarray, T: np.ndarray
    ) -> np.ndarray:
        """a/(b+x-1) * ((alpha+T)/(alpha+t_x))^(r+x) for x > 0, else 0."""
        p = self.params_
        with np.errstate(divide="ignore", invalid="ignore"):
            odds = (p["a"] / (p["b"] + x - 1)) * (
                (p["alpha"] + T) / (p["alpha"] + t_x)
            ) ** (p["r"] + x)
        return np.where(x > 0, odds, 0.0)

    def predict_alive_score(self, X: Any) -> np.ndarray:
        """P(the donor is still active) at the end of the window, in [0, 1].

        A donor with no repeat gift is always scored 1: the model only lets a
        donor drop out right after a gift.
        """
        check_is_fitted(self)
        x, t_x, T = self._check_X(X, reset=False).T
        return 1.0 / (1.0 + self._dropout_odds(x, t_x, T))

    def predict_gifts_forecast(self, X: Any, horizon: float = 1.0) -> np.ndarray:
        """Expected number of gifts each donor makes over the next ``horizon``.

        Parameters
        ----------
        X : array-like of shape (n_donors, 3)
            ``(frequency, recency, T)`` per donor.
        horizon : float, default=1.0
            Length of the forecast window, in the time unit of ``X`` (one year
            with :func:`repeat_giving_summary` defaults).

        Returns
        -------
        gifts : ndarray of shape (n_donors,)
        """
        check_is_fitted(self)
        if not horizon > 0:
            raise ValueError(f"horizon must be positive, got {horizon!r}.")
        x, t_x, T = self._check_X(X, reset=False).T
        p = self.params_
        r, alpha, a, b = p["r"], p["alpha"], p["a"], p["b"]
        hyp = hyp2f1(r + x, b + x, a + b + x - 1, horizon / (alpha + T + horizon))
        numer = (a + b + x - 1) / (a - 1) * (
            1 - ((alpha + T) / (alpha + T + horizon)) ** (r + x) * hyp
        )
        return numer / (1.0 + self._dropout_odds(x, t_x, T))

fit(X, y=None)

Fit r, alpha, a, b by maximum likelihood.

Parameters:

Name Type Description Default
X array-like of shape (n_donors, 3)

(frequency, recency, T) per donor.

required
y ignored
None

Returns:

Name Type Description
self BetaGeoModel
Source code in philanthropy/models/_repeat_giving.py
def fit(self: _BG, X: Any, y: Any = None) -> _BG:
    """Fit r, alpha, a, b by maximum likelihood.

    Parameters
    ----------
    X : array-like of shape (n_donors, 3)
        ``(frequency, recency, T)`` per donor.
    y : ignored

    Returns
    -------
    self : BetaGeoModel
    """
    X = self._check_X(X, reset=True)
    x, t_x, T = X.T

    def neg_ll(log_params: np.ndarray) -> float:
        return -float(self._log_likelihood(np.exp(log_params), x, t_x, T).sum())

    params, result = _fit_log_params(neg_ll, 4, self.max_iter)
    self.params_ = dict(zip(("r", "alpha", "a", "b"), params))
    self.log_likelihood_ = float(-result.fun)
    self.n_iter_ = int(result.nit)
    return self

predict_alive_score(X)

P(the donor is still active) at the end of the window, in [0, 1].

A donor with no repeat gift is always scored 1: the model only lets a donor drop out right after a gift.

Source code in philanthropy/models/_repeat_giving.py
def predict_alive_score(self, X: Any) -> np.ndarray:
    """P(the donor is still active) at the end of the window, in [0, 1].

    A donor with no repeat gift is always scored 1: the model only lets a
    donor drop out right after a gift.
    """
    check_is_fitted(self)
    x, t_x, T = self._check_X(X, reset=False).T
    return 1.0 / (1.0 + self._dropout_odds(x, t_x, T))

predict_gifts_forecast(X, horizon=1.0)

Expected number of gifts each donor makes over the next horizon.

Parameters:

Name Type Description Default
X array-like of shape (n_donors, 3)

(frequency, recency, T) per donor.

required
horizon float

Length of the forecast window, in the time unit of X (one year with :func:repeat_giving_summary defaults).

1.0

Returns:

Name Type Description
gifts ndarray of shape (n_donors,)
Source code in philanthropy/models/_repeat_giving.py
def predict_gifts_forecast(self, X: Any, horizon: float = 1.0) -> np.ndarray:
    """Expected number of gifts each donor makes over the next ``horizon``.

    Parameters
    ----------
    X : array-like of shape (n_donors, 3)
        ``(frequency, recency, T)`` per donor.
    horizon : float, default=1.0
        Length of the forecast window, in the time unit of ``X`` (one year
        with :func:`repeat_giving_summary` defaults).

    Returns
    -------
    gifts : ndarray of shape (n_donors,)
    """
    check_is_fitted(self)
    if not horizon > 0:
        raise ValueError(f"horizon must be positive, got {horizon!r}.")
    x, t_x, T = self._check_X(X, reset=False).T
    p = self.params_
    r, alpha, a, b = p["r"], p["alpha"], p["a"], p["b"]
    hyp = hyp2f1(r + x, b + x, a + b + x - 1, horizon / (alpha + T + horizon))
    numer = (a + b + x - 1) / (a - 1) * (
        1 - ((alpha + T) / (alpha + T + horizon)) ** (r + x) * hyp
    )
    return numer / (1.0 + self._dropout_odds(x, t_x, T))

GammaGammaModel

Bases: BaseEstimator

Gamma-gamma model of gift size: each donor's expected gift amount.

Fit by maximum likelihood on donors with at least one repeat gift; their mean repeat gift is shrunk toward the population mean, more so the fewer gifts they have. Assumes gift size is independent of how often a donor gives.

Parameters:

Name Type Description Default
max_iter int

Maximum optimiser iterations.

2000

Attributes:

Name Type Description
params_ dict

Fitted p, q and v. On the CDNOW sample the published values are p=6.25, q=3.74, v=15.44 [2]_.

log_likelihood_ float

Total log-likelihood at params_.

n_iter_ int

Optimiser iterations used.

n_features_in_ int

Always 2.

Notes

X is (frequency, monetary_value) in that column order, as built by :func:repeat_giving_summary. Rows with frequency 0 are ignored in :meth:fit and scored at the population mean.

Examples:

>>> import numpy as np
>>> from philanthropy.models import GammaGammaModel
>>> rng = np.random.default_rng(0)
>>> X = np.column_stack([rng.integers(1, 6, 200), rng.gamma(3, 30, 200)])
>>> GammaGammaModel().fit(X).predict_gift_size_forecast(X[:3]).shape
(3,)
Source code in philanthropy/models/_repeat_giving.py
class GammaGammaModel(BaseEstimator):
    """Gamma-gamma model of gift size: each donor's expected gift amount.

    Fit by maximum likelihood on donors with at least one repeat gift; their
    mean repeat gift is shrunk toward the population mean, more so the fewer
    gifts they have. Assumes gift size is independent of how often a donor
    gives.

    Parameters
    ----------
    max_iter : int, default=2000
        Maximum optimiser iterations.

    Attributes
    ----------
    params_ : dict
        Fitted ``p``, ``q`` and ``v``. On the CDNOW sample the published
        values are p=6.25, q=3.74, v=15.44 [2]_.
    log_likelihood_ : float
        Total log-likelihood at ``params_``.
    n_iter_ : int
        Optimiser iterations used.
    n_features_in_ : int
        Always 2.

    Notes
    -----
    ``X`` is ``(frequency, monetary_value)`` in that column order, as built by
    :func:`repeat_giving_summary`. Rows with ``frequency`` 0 are ignored in
    :meth:`fit` and scored at the population mean.

    Examples
    --------
    >>> import numpy as np
    >>> from philanthropy.models import GammaGammaModel
    >>> rng = np.random.default_rng(0)
    >>> X = np.column_stack([rng.integers(1, 6, 200), rng.gamma(3, 30, 200)])
    >>> GammaGammaModel().fit(X).predict_gift_size_forecast(X[:3]).shape
    (3,)
    """

    def __init__(self, max_iter: int = 2000) -> None:
        self.max_iter = max_iter

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.positive_only = True
        return tags

    def _check_X(self, X: Any, reset: bool) -> np.ndarray:
        X = validate_data(self, X, reset=reset, dtype=np.float64)
        if X.shape[1] != 2:
            raise ValueError(
                f"GammaGammaModel expects 2 columns (frequency, "
                f"monetary_value), got {X.shape[1]} feature(s)."
            )
        if (X < 0).any():
            raise ValueError("frequency and monetary_value must be non-negative.")
        return X

    def fit(self: _GG, X: Any, y: Any = None) -> _GG:
        """Fit p, q, v by maximum likelihood.

        Parameters
        ----------
        X : array-like of shape (n_donors, 2)
            ``(frequency, monetary_value)`` per donor.
        y : ignored

        Returns
        -------
        self : GammaGammaModel
        """
        X = self._check_X(X, reset=True)
        repeat = (X[:, 0] > 0) & (X[:, 1] > 0)
        if not repeat.any():
            raise ValueError(
                "GammaGammaModel needs at least one donor with a repeat gift "
                "and a positive monetary_value."
            )
        x, m = X[repeat].T

        def neg_ll(log_params: np.ndarray) -> float:
            p, q, v = np.exp(log_params)
            px = p * x
            return -float(np.sum(
                gammaln(px + q) - gammaln(px) - gammaln(q) + q * np.log(v)
                + (px - 1) * np.log(m) + px * np.log(x) - (px + q) * np.log(v + m * x)
            ))

        params, result = _fit_log_params(neg_ll, 3, self.max_iter)
        self.params_ = dict(zip(("p", "q", "v"), params))
        self.log_likelihood_ = float(-result.fun)
        self.n_iter_ = int(result.nit)
        return self

    def predict_gift_size_forecast(self, X: Any) -> np.ndarray:
        """Expected size of each donor's future gifts.

        ``p (v + x m) / (p x + q - 1)``: a weighted average of the population
        mean ``p v / (q - 1)`` and the donor's own mean ``m``. Infinite when
        ``q <= 1``, where the population mean does not exist.
        """
        check_is_fitted(self)
        x, m = self._check_X(X, reset=False).T
        p, q, v = (self.params_[k] for k in ("p", "q", "v"))
        m = np.where(x > 0, m, 0.0)
        return p * (v + x * m) / (p * x + q - 1)

fit(X, y=None)

Fit p, q, v by maximum likelihood.

Parameters:

Name Type Description Default
X array-like of shape (n_donors, 2)

(frequency, monetary_value) per donor.

required
y ignored
None

Returns:

Name Type Description
self GammaGammaModel
Source code in philanthropy/models/_repeat_giving.py
def fit(self: _GG, X: Any, y: Any = None) -> _GG:
    """Fit p, q, v by maximum likelihood.

    Parameters
    ----------
    X : array-like of shape (n_donors, 2)
        ``(frequency, monetary_value)`` per donor.
    y : ignored

    Returns
    -------
    self : GammaGammaModel
    """
    X = self._check_X(X, reset=True)
    repeat = (X[:, 0] > 0) & (X[:, 1] > 0)
    if not repeat.any():
        raise ValueError(
            "GammaGammaModel needs at least one donor with a repeat gift "
            "and a positive monetary_value."
        )
    x, m = X[repeat].T

    def neg_ll(log_params: np.ndarray) -> float:
        p, q, v = np.exp(log_params)
        px = p * x
        return -float(np.sum(
            gammaln(px + q) - gammaln(px) - gammaln(q) + q * np.log(v)
            + (px - 1) * np.log(m) + px * np.log(x) - (px + q) * np.log(v + m * x)
        ))

    params, result = _fit_log_params(neg_ll, 3, self.max_iter)
    self.params_ = dict(zip(("p", "q", "v"), params))
    self.log_likelihood_ = float(-result.fun)
    self.n_iter_ = int(result.nit)
    return self

predict_gift_size_forecast(X)

Expected size of each donor's future gifts.

p (v + x m) / (p x + q - 1): a weighted average of the population mean p v / (q - 1) and the donor's own mean m. Infinite when q <= 1, where the population mean does not exist.

Source code in philanthropy/models/_repeat_giving.py
def predict_gift_size_forecast(self, X: Any) -> np.ndarray:
    """Expected size of each donor's future gifts.

    ``p (v + x m) / (p x + q - 1)``: a weighted average of the population
    mean ``p v / (q - 1)`` and the donor's own mean ``m``. Infinite when
    ``q <= 1``, where the population mean does not exist.
    """
    check_is_fitted(self)
    x, m = self._check_X(X, reset=False).T
    p, q, v = (self.params_[k] for k in ("p", "q", "v"))
    m = np.where(x > 0, m, 0.0)
    return p * (v + x * m) / (p * x + q - 1)

suggest_ask(last_gift, avg_gift, stretch=0.1, round_to=25)

Suggest an ask from giving history alone: the simple rule, no model.

Takes the larger of the donor's last gift and average gift, raises it by stretch, and rounds up to the next multiple of round_to. With stretch=0 and round_to=None this is the rule the Results pages test; on no real file there does :class:AskAmountRecommender land within 25% of the next gift reliably more often than this rule, so it is the place to start.

Parameters:

Name Type Description Default
last_gift array-like of shape (n_samples,)

Each donor's most recent and average gift amount. A NaN in one is ignored in favour of the other; NaN in both gives NaN.

required
avg_gift array-like of shape (n_samples,)

Each donor's most recent and average gift amount. A NaN in one is ignored in favour of the other; NaN in both gives NaN.

required
stretch float

Fractional increase over the larger amount (0.10 asks for 10% more). A policy choice, not an estimate: no observational file can say what stretch works best.

0.10
round_to float or None

Round up to a multiple of this amount. None skips rounding.

25

Returns:

Type Description
ndarray of shape (n_samples,)

The suggested ask per donor, in the same currency as the input.

Raises:

Type Description
ValueError

If stretch is negative, round_to is not positive, or the two inputs differ in length.

Examples:

>>> from philanthropy.models import suggest_ask
>>> suggest_ask([100, 40, 260], [80, 55, 200])
array([125.,  75., 300.])
>>> suggest_ask([100], [80], stretch=0.0, round_to=None)
array([100.])
Source code in philanthropy/models/_ask.py
def suggest_ask(
    last_gift: Any,
    avg_gift: Any,
    stretch: float = 0.10,
    round_to: Optional[float] = 25,
) -> np.ndarray:
    """Suggest an ask from giving history alone: the simple rule, no model.

    Takes the larger of the donor's last gift and average gift, raises it by
    ``stretch``, and rounds **up** to the next multiple of ``round_to``. With
    ``stretch=0`` and ``round_to=None`` this is the rule the Results pages
    test; on no real file there does :class:`AskAmountRecommender` land
    within 25% of the next gift reliably more often than this rule, so it is
    the place to start.

    Parameters
    ----------
    last_gift, avg_gift : array-like of shape (n_samples,)
        Each donor's most recent and average gift amount. A ``NaN`` in one is
        ignored in favour of the other; ``NaN`` in both gives ``NaN``.
    stretch : float, default=0.10
        Fractional increase over the larger amount (0.10 asks for 10% more).
        A policy choice, not an estimate: no observational file can say what
        stretch works best.
    round_to : float or None, default=25
        Round up to a multiple of this amount. ``None`` skips rounding.

    Returns
    -------
    ndarray of shape (n_samples,)
        The suggested ask per donor, in the same currency as the input.

    Raises
    ------
    ValueError
        If ``stretch`` is negative, ``round_to`` is not positive, or the two
        inputs differ in length.

    Examples
    --------
    >>> from philanthropy.models import suggest_ask
    >>> suggest_ask([100, 40, 260], [80, 55, 200])
    array([125.,  75., 300.])
    >>> suggest_ask([100], [80], stretch=0.0, round_to=None)
    array([100.])
    """
    if stretch < 0:
        raise ValueError(f"`stretch` must be >= 0, got {stretch!r}.")
    if round_to is not None and not round_to > 0:
        raise ValueError(f"`round_to` must be > 0 or None, got {round_to!r}.")
    last = np.asarray(last_gift, dtype=float).ravel()
    avg = np.asarray(avg_gift, dtype=float).ravel()
    if last.shape != avg.shape:
        raise ValueError(
            f"`last_gift` and `avg_gift` differ in length: {last.size} vs {avg.size}."
        )
    ask = np.fmax(last, avg) * (1.0 + stretch)
    if round_to is not None:
        # Round to cents first so float noise (110.00000000000001) does not
        # push an exact multiple up a whole step.
        ask = np.ceil(np.round(ask / round_to, 6)) * round_to
    return ask

score_leadership_prospects(gifts, *, activities=None, donors=None, threshold=1000.0, band=(100.0, 999.0), fiscal_year_start=7, as_of=None, top_n=None, baseline_giving_threshold=None, random_state=None, include_momentum=False)

Fit an upgrade model on history, then score today's band-qualifying donors.

Every fiscal year T where both T and T+1 are fully resolved as of as_of is a labelled training row (via :func:~philanthropy.ingest.build_leadership_snapshots); a :class:~philanthropy.models.MajorGiftClassifier is fit on the stack of those years and evaluated on the most recent :class:~philanthropy.model_selection.FiscalYearGroupedSplitter fold, an honest, held-out read. A second copy of the model, refit on every historical row (more data, no held-out fold to protect), scores the CURRENT band-qualifying donors: one unlabelled row per donor, built the same way but for the fiscal year containing as_of and cut at as_of rather than at that year's end, since it may still be in progress.

Parameters:

Name Type Description Default
gifts iterable of mapping, or DataFrame

Gift-level rows with donor_id, gift_date and gift_amount, the same shape :func:~philanthropy.ingest.build_leadership_snapshots takes.

required
activities iterable of mapping, or DataFrame

A long activity log, forwarded to :func:~philanthropy.ingest.build_leadership_snapshots for the historical years and to the same feature logic for the current row.

None
donors DataFrame

Static donor attributes, indexed by donor id. Forwarded the same way. Only its numeric columns become model features (see Notes); it is still joined and returned for a non-numeric attribute a caller wants to inspect alongside the score.

None
threshold float or str

The leadership-giving level an upgrade crosses into. A string "pNN" (e.g. "p93") is the NNth percentile of every positive donor FY total in the historical training years instead of a dollar amount, so the same call asks the same question of a file in small dollars and one in large dollars; band stays in dollars. The resolved value is report["threshold"].

1000.0
band (float, float)

Inclusive bounds on FY giving that define the upgrade-candidate population, exactly as in build_leadership_snapshots.

(100.0, 999.0)
fiscal_year_start int

Month (1-12) the fiscal year begins.

7
as_of str or datetime - like

The scoring cutoff: nothing dated after it is ever read, in either the historical training rows or the current scored row. Defaults to the latest gift date in gifts, the same leakage-free default every other reference_date-style parameter in this package uses.

None
top_n int

How many of the held-out validation fold's highest-scored donors count as "the top" for model_upgrade_rate_top_n and the "top N by FY total" baseline. Defaults to roughly 10% of the validation fold (at least 1), rather than a fixed count: a fixed top_n reads as a near-perfect rate on a small fold and an uninformative one on a large real-world validation year (e.g. reporting 1.0 on a fixed top-10 against a 64,178-row fold whose true top-1% lift was 2.24x). Clipped to the fold size if larger.

None
baseline_giving_threshold float

The FY T giving level the "gave >= X last FY" naive baseline uses. Defaults to threshold / 2.

None
random_state int

Seed forwarded to the classifier fits and to the permutation importance call, for reproducible scores and reasons.

None
include_momentum bool

Forwarded to :func:~philanthropy.ingest.build_leadership_snapshots: trains and scores on trailing-slope momentum features too, not just the current feature set. Off by default so this function's output stays unchanged for existing callers.

False

Returns:

Name Type Description
scores DataFrame

One row per currently band-qualifying donor, indexed by donor_id, sorted by affinity_score descending. Columns: fiscal_year (the current, possibly still-open FY, reported for reference only -- it is not a model feature, see Notes); affinity_score (0-100, see :meth:~philanthropy.models.MajorGiftClassifier.predict_affinity_score); rank (1 = highest score); decile (1 = top 10% by rank, 10 = bottom); top_reasons (a tuple of up to 3 (feature_name, donor_value) pairs, see Notes); suggested_ask (NaN -- see Notes). Empty (but correctly typed) if no donor currently qualifies for band.

report dict

threshold (in dollars, after resolving a "pNN" string); n_training_rows, n_training_fiscal_years, n_scored; low_data_warning / low_data_message (flagged under roughly 500 training rows); activity_id_match_warnings (list of str, captured from activities_to_features's own low-match-rate warning, not recomputed here); validated (whether a walk-forward held-out fold existed at all), and, when it did:

  • validation_fiscal_year, n_validation_rows: which FY the held-out fold is, and how many rows it has.
  • top_n: how many of the fold's highest-scored donors count as "the top", see the top_n parameter.
  • model_upgrade_rate_top_n: the actual upgrade rate among the model's own top top_n donors by predicted score.
  • baseline_topn_fy_total_upgrade_rate / lift_topn_fy_total: the naive "top N by FY total" rule's upgrade rate over the same top_n, and the model rate's ratio to it (None if the baseline rate is 0).
  • baseline_giving_threshold, baseline_gave_threshold_upgrade_rate / lift_over_gave_threshold: the naive "gave >= X last FY" rule (X is baseline_giving_threshold), its upgrade rate among donors who cleared it, and the model rate's ratio to it (None if nobody in the fold cleared it, or the resulting rate is 0).
  • overall_upgrade_rate: the fold's overall positive rate.
  • deciles: a list of 10 dicts, one per predicted-score decile (1 = highest-scored 10% of the fold, 10 = lowest), each with decile, n (rows in that decile), actual_rate (observed upgrade rate) and mean_predicted (mean predicted probability). A finer-grained, fixed-top_n-independent view of the same ranking; see the top_n parameter for why a single fixed count is misleading on its own.
  • roc_auc, average_precision: from :mod:sklearn.metrics on the held-out fold; None if the fold has only one class.
  • recommended_list_fraction / recommended_list_size: the longest top slice of the held-out fold whose upgrade rate is still at least 1.5 times the fold's overall rate, as a fraction of the fold and as a count of today's scored donors (scores rows with rank <= recommended_list_size). None when no slice gets there. On a rare outcome this is the honest list length: past it the model is close to picking at random.

Always present: rule_check, the :func:~philanthropy.model_selection.compare_to_rule verdict read on the same held-out fold and model: a dict with verdict (the Results pages' words), method ("model" only when the model beats the best of the three upgrade rules on top-10% hit rate across a 1,000-resample bootstrap; otherwise "rule"), rule, n_rules_tried, metric, model_value, rule_value, difference_ci and label_floor. Without a held-out fold the verdict is "can't test (fewer than two labelled fiscal years)".

Raises:

Type Description
ValueError

If band[0] > band[1]; if there is not one historical (donor, fiscal year) row to train on as of as_of; if there are fewer than 5 historical rows (too few for :class:~philanthropy.models.MajorGiftClassifier's internal 5-fold calibration to split at all); or if a walk-forward training fold, or the full historical training set when a current donor still needs scoring, has only one target class (nothing to learn or nothing to score: no donor in it ever upgraded, or every one did).

KeyError

If gifts is missing donor_id, gift_date or gift_amount.

Notes

Feature columns. Only the numeric columns of the snapshot (the gift-derived features, any numeric activities_to_features columns, and any numeric donors columns) become model features; a non-numeric donors column (e.g. a wealth-rating letter grade) is still joined and returned for reference but dropped before fitting, the simplest rule that needs no per-column encoding policy for a feature nobody asked this function to build. fiscal_year is excluded from the model features even though it is numeric: it is not a donor-specific signal, and the current row's fiscal year always sits outside the range the model was trained on (every historical row is a strictly earlier FY), so it can only ever generalise as noise or, worse, an ordering artefact. It is still returned as an output column and left out of top_reasons for the same reason. A current-row feature column absent from history (or vice versa), e.g. an activity type that only shows up in one window, is reindexed to 0.0 rather than dropped, matching activities_to_features's own "no rows of that type -> 0" rule.

Top reasons. There is no per-instance explainer in this package (SHAP is out of scope for this whole series); this reuses the existing GLOBAL permutation importances (:func:~philanthropy.inspection.donor_feature_importance, computed once on the held-out fold, or in-sample with a warning if there was no fold to hold out) as fixed feature weights, then for each scored donor ranks their OWN feature columns by |z-score within the current scored population| * global_importance_weight (importance clipped at 0, since a negative permutation importance means shuffling that feature helped, not a reason to report), and keeps the top 3 as (feature_name, donor_value) pairs. This is a heuristic, not a causal attribution: it says which features are both unusual for this donor and generally predictive, not "if X were different the score would change by Y". It is deliberately simple rather than a general explainability framework.

Suggested ask. AskAmountRecommender needs its own ask-amount label (what a gift officer actually asked for, or a realistic proxy for it); nothing in build_leadership_snapshots's output is that. Training it on, say, the FY T+1 amount actually given would train on the very quantity the upgrade label is derived from, an honesty problem, not a convenience one. suggested_ask is left NaN, out of scope for this function until a real ask-amount training signal exists.

Examples:

Four donor archetypes across five fiscal years: a flat high-band donor, an early riser that crosses the threshold in FY2022, a later riser that crosses in FY2024, and a flat low-band donor. Only the two that never cross threshold are still band-qualifying "today" (FY2025):

>>> import pandas as pd
>>> from philanthropy.models import score_leadership_prospects
>>> years = ["2020-08-01", "2021-08-01", "2022-08-01", "2023-08-01", "2024-08-01"]
>>> archetypes = {
...     "flat_high": [900, 900, 900, 900, 900],
...     "early_riser": [600, 1050, 1200, 1400, 1600],
...     "late_riser": [300, 500, 700, 1100, 1300],
...     "flat_low": [400, 400, 400, 400, 400],
... }
>>> rows = [
...     {"donor_id": f"{name}_{i}", "gift_date": year, "gift_amount": amount}
...     for name, amounts in archetypes.items()
...     for i in range(20)
...     for year, amount in zip(years, amounts)
... ]
>>> gifts = pd.DataFrame(rows)
>>> scores, report = score_leadership_prospects(gifts, random_state=0)
>>> list(scores.columns)
['fiscal_year', 'affinity_score', 'rank', 'decile', 'top_reasons', 'suggested_ask']
>>> len(scores)
40
>>> bool((scores["affinity_score"] >= 0).all() and (scores["affinity_score"] <= 100).all())
True
>>> report["n_training_rows"]
200
>>> report["validated"]
True
Source code in philanthropy/models/_upgrade.py
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
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
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
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
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
def score_leadership_prospects(
    gifts: Union[Iterable[Mapping], pd.DataFrame],
    *,
    activities: Optional[Union[Iterable[Mapping], pd.DataFrame]] = None,
    donors: Optional[pd.DataFrame] = None,
    threshold: Union[float, str] = 1000.0,
    band: Tuple[float, float] = (100.0, 999.0),
    fiscal_year_start: int = 7,
    as_of: Optional[Union[str, pd.Timestamp]] = None,
    top_n: Optional[int] = None,
    baseline_giving_threshold: Optional[float] = None,
    random_state: Optional[int] = None,
    include_momentum: bool = False,
) -> Tuple[pd.DataFrame, Dict[str, Any]]:
    """Fit an upgrade model on history, then score today's band-qualifying donors.

    Every fiscal year ``T`` where both ``T`` and ``T+1`` are fully resolved as
    of ``as_of`` is a labelled training row (via
    :func:`~philanthropy.ingest.build_leadership_snapshots`); a
    :class:`~philanthropy.models.MajorGiftClassifier` is fit on the stack of
    those years and evaluated on the most recent
    :class:`~philanthropy.model_selection.FiscalYearGroupedSplitter` fold, an
    honest, held-out read. A second copy of the model, refit on every
    historical row (more data, no held-out fold to protect), scores the
    CURRENT band-qualifying donors: one unlabelled row per donor, built the
    same way but for the fiscal year containing ``as_of`` and cut at ``as_of``
    rather than at that year's end, since it may still be in progress.

    Parameters
    ----------
    gifts : iterable of mapping, or DataFrame
        Gift-level rows with ``donor_id``, ``gift_date`` and ``gift_amount``,
        the same shape :func:`~philanthropy.ingest.build_leadership_snapshots`
        takes.
    activities : iterable of mapping, or DataFrame, optional
        A long activity log, forwarded to
        :func:`~philanthropy.ingest.build_leadership_snapshots` for the
        historical years and to the same feature logic for the current row.
    donors : DataFrame, optional
        Static donor attributes, indexed by donor id. Forwarded the same way.
        Only its numeric columns become model features (see Notes); it is
        still joined and returned for a non-numeric attribute a caller wants
        to inspect alongside the score.
    threshold : float or str, default=1000.0
        The leadership-giving level an upgrade crosses into. A string
        ``"pNN"`` (e.g. ``"p93"``) is the NNth percentile of every positive
        donor FY total in the historical training years instead of a dollar
        amount, so the same call asks the same question of a file in small
        dollars and one in large dollars; ``band`` stays in dollars. The
        resolved value is ``report["threshold"]``.
    band : (float, float), default=(100.0, 999.0)
        Inclusive bounds on FY giving that define the upgrade-candidate
        population, exactly as in ``build_leadership_snapshots``.
    fiscal_year_start : int, default=7
        Month (1-12) the fiscal year begins.
    as_of : str or datetime-like, optional
        The scoring cutoff: nothing dated after it is ever read, in either
        the historical training rows or the current scored row. Defaults to
        the latest gift date in ``gifts``, the same leakage-free default
        every other ``reference_date``-style parameter in this package uses.
    top_n : int, optional
        How many of the held-out validation fold's highest-scored donors
        count as "the top" for ``model_upgrade_rate_top_n`` and the "top N
        by FY total" baseline. Defaults to roughly 10% of the validation
        fold (at least 1), rather than a fixed count: a fixed ``top_n`` reads
        as a near-perfect rate on a small fold and an uninformative one on a
        large real-world validation year (e.g. reporting 1.0 on a fixed
        top-10 against a 64,178-row fold whose true top-1% lift was 2.24x).
        Clipped to the fold size if larger.
    baseline_giving_threshold : float, optional
        The FY T giving level the "gave >= X last FY" naive baseline uses.
        Defaults to ``threshold / 2``.
    random_state : int, optional
        Seed forwarded to the classifier fits and to the permutation
        importance call, for reproducible scores and reasons.
    include_momentum : bool, default=False
        Forwarded to :func:`~philanthropy.ingest.build_leadership_snapshots`:
        trains and scores on trailing-slope momentum features too, not just
        the current feature set. Off by default so this function's output
        stays unchanged for existing callers.

    Returns
    -------
    scores : pandas.DataFrame
        One row per currently band-qualifying donor, indexed by ``donor_id``,
        sorted by ``affinity_score`` descending. Columns: ``fiscal_year`` (the
        current, possibly still-open FY, reported for reference only --
        it is not a model feature, see Notes); ``affinity_score`` (0-100, see
        :meth:`~philanthropy.models.MajorGiftClassifier.predict_affinity_score`);
        ``rank`` (1 = highest score); ``decile`` (1 = top 10% by rank, 10 =
        bottom); ``top_reasons`` (a tuple of up to 3 ``(feature_name,
        donor_value)`` pairs, see Notes); ``suggested_ask`` (``NaN`` -- see
        Notes). Empty (but correctly typed) if no donor currently qualifies
        for ``band``.
    report : dict
        ``threshold`` (in dollars, after resolving a ``"pNN"`` string);
        ``n_training_rows``, ``n_training_fiscal_years``, ``n_scored``;
        ``low_data_warning`` / ``low_data_message`` (flagged under roughly
        ``500`` training rows); ``activity_id_match_warnings`` (list of str,
        captured from ``activities_to_features``'s own low-match-rate
        warning, not recomputed here); ``validated`` (whether a walk-forward
        held-out fold existed at all), and, when it did:

        - ``validation_fiscal_year``, ``n_validation_rows``: which FY the
          held-out fold is, and how many rows it has.
        - ``top_n``: how many of the fold's highest-scored donors count as
          "the top", see the ``top_n`` parameter.
        - ``model_upgrade_rate_top_n``: the actual upgrade rate among the
          model's own top ``top_n`` donors by predicted score.
        - ``baseline_topn_fy_total_upgrade_rate`` / ``lift_topn_fy_total``:
          the naive "top N by FY total" rule's upgrade rate over the same
          ``top_n``, and the model rate's ratio to it (``None`` if the
          baseline rate is 0).
        - ``baseline_giving_threshold``, ``baseline_gave_threshold_upgrade_rate``
          / ``lift_over_gave_threshold``: the naive "gave >= X last FY" rule
          (``X`` is ``baseline_giving_threshold``), its upgrade rate among
          donors who cleared it, and the model rate's ratio to it (``None``
          if nobody in the fold cleared it, or the resulting rate is 0).
        - ``overall_upgrade_rate``: the fold's overall positive rate.
        - ``deciles``: a list of 10 dicts, one per predicted-score decile (1
          = highest-scored 10% of the fold, 10 = lowest), each with
          ``decile``, ``n`` (rows in that decile), ``actual_rate`` (observed
          upgrade rate) and ``mean_predicted`` (mean predicted probability).
          A finer-grained, fixed-``top_n``-independent view of the same
          ranking; see the ``top_n`` parameter for why a single fixed count
          is misleading on its own.
        - ``roc_auc``, ``average_precision``: from :mod:`sklearn.metrics`
          on the held-out fold; ``None`` if the fold has only one class.
        - ``recommended_list_fraction`` / ``recommended_list_size``: the
          longest top slice of the held-out fold whose upgrade rate is still
          at least 1.5 times the fold's overall rate, as a fraction of the
          fold and as a count of today's scored donors (``scores`` rows with
          ``rank <= recommended_list_size``). ``None`` when no slice gets
          there. On a rare outcome this is the honest list length: past it
          the model is close to picking at random.

        Always present: ``rule_check``, the
        :func:`~philanthropy.model_selection.compare_to_rule` verdict read on
        the same held-out fold and model: a dict with ``verdict`` (the
        Results pages' words), ``method`` (``"model"`` only when the model
        beats the best of the three upgrade rules on top-10% hit rate across
        a 1,000-resample bootstrap; otherwise ``"rule"``), ``rule``,
        ``n_rules_tried``, ``metric``, ``model_value``, ``rule_value``,
        ``difference_ci`` and ``label_floor``. Without a held-out fold the
        verdict is ``"can't test (fewer than two labelled fiscal years)"``.

    Raises
    ------
    ValueError
        If ``band[0] > band[1]``; if there is not one historical
        ``(donor, fiscal year)`` row to train on as of ``as_of``; if there
        are fewer than 5 historical rows (too few for
        :class:`~philanthropy.models.MajorGiftClassifier`'s internal 5-fold
        calibration to split at all); or if a walk-forward training fold, or
        the full historical training set when a current donor still needs
        scoring, has only one target class (nothing to learn or nothing to
        score: no donor in it ever upgraded, or every one did).
    KeyError
        If ``gifts`` is missing ``donor_id``, ``gift_date`` or
        ``gift_amount``.

    Notes
    -----
    **Feature columns.** Only the numeric columns of the snapshot (the
    gift-derived features, any numeric ``activities_to_features`` columns,
    and any numeric ``donors`` columns) become model features; a non-numeric
    ``donors`` column (e.g. a wealth-rating letter grade) is still joined and
    returned for reference but dropped before fitting, the simplest rule
    that needs no per-column encoding policy for a feature nobody asked this
    function to build. ``fiscal_year`` is excluded from the model features
    even though it is numeric: it is not a donor-specific signal, and the
    current row's fiscal year always sits outside the range the model was
    trained on (every historical row is a strictly earlier FY), so it can
    only ever generalise as noise or, worse, an ordering artefact. It is
    still returned as an output column and left out of ``top_reasons`` for
    the same reason. A current-row feature column absent from history (or
    vice versa), e.g. an activity type that only shows up in one window, is
    reindexed to 0.0 rather than dropped, matching
    ``activities_to_features``'s own "no rows of that type -> 0" rule.

    **Top reasons.** There is no per-instance explainer in this package
    (SHAP is out of scope for this whole series); this reuses the existing
    GLOBAL permutation importances (:func:`~philanthropy.inspection.donor_feature_importance`,
    computed once on the held-out fold, or in-sample with a warning if there
    was no fold to hold out) as fixed feature weights, then for each scored
    donor ranks their OWN feature columns by ``|z-score within the current
    scored population| * global_importance_weight`` (importance clipped at 0,
    since a negative permutation importance means shuffling that feature
    *helped*, not a reason to report), and keeps the top 3 as
    ``(feature_name, donor_value)`` pairs. This is a heuristic, not a causal
    attribution: it says which features are both unusual for this donor and
    generally predictive, not "if X were different the score would change by
    Y". It is deliberately simple rather than a general explainability
    framework.

    **Suggested ask.** ``AskAmountRecommender`` needs its own ask-amount
    label (what a gift officer actually asked for, or a realistic proxy for
    it); nothing in ``build_leadership_snapshots``'s output is that. Training it
    on, say, the FY T+1 amount actually given would train on the very
    quantity the upgrade label is derived from, an honesty problem, not a
    convenience one. ``suggested_ask`` is left ``NaN``, out of scope for this
    function until a real ask-amount training signal exists.

    Examples
    --------
    Four donor archetypes across five fiscal years: a flat high-band donor,
    an early riser that crosses the threshold in FY2022, a later riser that
    crosses in FY2024, and a flat low-band donor. Only the two that never
    cross ``threshold`` are still band-qualifying "today" (FY2025):

    >>> import pandas as pd
    >>> from philanthropy.models import score_leadership_prospects
    >>> years = ["2020-08-01", "2021-08-01", "2022-08-01", "2023-08-01", "2024-08-01"]
    >>> archetypes = {
    ...     "flat_high": [900, 900, 900, 900, 900],
    ...     "early_riser": [600, 1050, 1200, 1400, 1600],
    ...     "late_riser": [300, 500, 700, 1100, 1300],
    ...     "flat_low": [400, 400, 400, 400, 400],
    ... }
    >>> rows = [
    ...     {"donor_id": f"{name}_{i}", "gift_date": year, "gift_amount": amount}
    ...     for name, amounts in archetypes.items()
    ...     for i in range(20)
    ...     for year, amount in zip(years, amounts)
    ... ]
    >>> gifts = pd.DataFrame(rows)
    >>> scores, report = score_leadership_prospects(gifts, random_state=0)
    >>> list(scores.columns)
    ['fiscal_year', 'affinity_score', 'rank', 'decile', 'top_reasons', 'suggested_ask']
    >>> len(scores)
    40
    >>> bool((scores["affinity_score"] >= 0).all() and (scores["affinity_score"] <= 100).all())
    True
    >>> report["n_training_rows"]
    200
    >>> report["validated"]
    True
    """
    low, high = band
    if low > high:
        raise ValueError(f"band[0] ({low}) must be <= band[1] ({high}).")
    if isinstance(threshold, str):
        _resolve_threshold(threshold, np.array([1.0]))  # bad strings fail before any work

    df_all, _, _, _ = _prepare_gifts(gifts, fiscal_year_start)
    if df_all.empty:
        raise ValueError(
            "No usable gift rows (need donor_id, gift_date and gift_amount) "
            "to train or score an upgrade model."
        )
    as_of_ts = pd.Timestamp(as_of) if as_of is not None else df_all["_date"].max()

    # Nothing after as_of is ever read: re-derive the pivots from gifts cut at
    # as_of, so the historical training years and the current row share one
    # cutoff, and appending a future-dated row can never move either.
    cut = df_all[df_all["_date"] <= as_of_ts]
    if cut.empty:
        raise ValueError(f"No gift rows on or before as_of={as_of_ts.date()}.")
    df, pivot_sum, pivot_max, pivot_count = _prepare_gifts(cut, fiscal_year_start)

    current_fy = fiscal_year_for(as_of_ts.year, as_of_ts.month, fiscal_year_start)
    min_fy, max_fy = int(df["_fy"].min()), int(df["_fy"].max())
    historical_years = [
        t for t in range(min_fy, max_fy + 1)
        if _fy_end(t + 1, fiscal_year_start) <= as_of_ts
    ]

    threshold = _resolve_threshold(
        threshold, pivot_sum.reindex(columns=historical_years).to_numpy().ravel()
    )
    totals_current = _column(pivot_sum, current_fy)
    current_candidates = totals_current[
        (totals_current >= low) & (totals_current <= high) & (totals_current < threshold)
    ]

    donors_norm = None
    if donors is not None:
        donors_norm = donors.copy()
        donors_norm.index = donors_norm.index.astype("string").str.strip()

    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        historical_snap = build_leadership_snapshots(
            df, fiscal_years=historical_years, threshold=threshold, band=band,
            fiscal_year_start=fiscal_year_start, activities=activities, donors=donors,
            include_momentum=include_momentum,
        )
        current_snap = None
        if not current_candidates.empty:
            current_snap = _snapshot_features_for_year(
                df, pivot_sum, pivot_max, pivot_count, current_candidates.index,
                current_fy, fiscal_year_start, activities, donors, donors_norm,
                as_of=as_of_ts, include_momentum=include_momentum,
            )
    activity_id_match_warnings = [str(w.message) for w in caught]

    if historical_snap.empty:
        raise ValueError(
            f"No historical (donor, fiscal year) rows to train on as of "
            f"{as_of_ts.date()}: need at least one fiscal year T with both T "
            "and T+1 fully resolved by then."
        )

    feature_cols = [
        c for c in historical_snap.columns
        if c not in ("target", "fiscal_year")
        and pd.api.types.is_numeric_dtype(historical_snap[c])
    ]
    X = historical_snap[feature_cols].to_numpy(dtype="float64")
    y = historical_snap["target"].to_numpy()
    fys = historical_snap["fiscal_year"].to_numpy()
    n_unique_fys = int(np.unique(fys).size)

    low_data_warning = len(historical_snap) < _LOW_DATA_ROWS
    low_data_message = None
    if low_data_warning:
        low_data_message = (
            f"Only {len(historical_snap)} historical donor-year rows "
            f"(< {_LOW_DATA_ROWS}); scores and the validation report below "
            "are low-confidence."
        )
        warnings.warn(low_data_message, UserWarning, stacklevel=2)

    # Too few historical rows crashes deep inside CalibratedClassifierCV
    # (every downstream fit needs at least this many rows to split), with a
    # confusing error if left unchecked.
    _check_min_rows(len(historical_snap), as_of_ts)

    gave_threshold = (
        float(baseline_giving_threshold)
        if baseline_giving_threshold is not None else threshold / 2.0
    )

    report: Dict[str, Any] = {
        "threshold": threshold,
        "n_training_rows": int(len(historical_snap)),
        "n_training_fiscal_years": n_unique_fys,
        "low_data_warning": bool(low_data_warning),
        "low_data_message": low_data_message,
        "activity_id_match_warnings": activity_id_match_warnings,
        "current_fiscal_year": int(current_fy),
        "n_scored": 0,
        "validated": False,
        "validation_fiscal_year": None,
        "n_validation_rows": 0,
        "top_n": None,
        "model_upgrade_rate_top_n": None,
        "baseline_topn_fy_total_upgrade_rate": None,
        "lift_topn_fy_total": None,
        "baseline_giving_threshold": gave_threshold,
        "baseline_gave_threshold_upgrade_rate": None,
        "lift_over_gave_threshold": None,
        "overall_upgrade_rate": None,
        "deciles": None,
        "roc_auc": None,
        "average_precision": None,
        "recommended_list_fraction": None,
        "recommended_list_size": None,
        "rule_check": {
            "verdict": "can't test (fewer than two labelled fiscal years)",
            "method": "rule", "rule": None, "n_rules_tried": 0,
            "metric": "top10pct_hit_rate", "model_value": None, "rule_value": None,
            "difference_ci": None, "label_floor": None,
        },
    }

    if n_unique_fys >= 2:
        n_splits = min(5, n_unique_fys - 1)
        splitter = FiscalYearGroupedSplitter(n_splits=n_splits, drop_repeat_donors=False)
        train_idx, test_idx = list(splitter.split(X, groups=fys))[-1]

        eval_model = MajorGiftClassifier(random_state=random_state).fit(
            X[train_idx], y[train_idx]
        )
        # A single-class training fold fits fine but its predict_proba comes
        # back with one column, not two: guard the call site rather than let
        # the [:, 1] below raise a confusing IndexError.
        _check_two_classes(eval_model.classes_, as_of_ts, "The walk-forward training fold")
        y_test = y[test_idx]
        proba_test = eval_model.predict_proba(X[test_idx])[:, 1]
        fy_total_test = historical_snap["fy_total"].to_numpy()[test_idx]

        n_val = len(test_idx)
        resolved_top_n = top_n if top_n is not None else max(1, round(0.1 * n_val))
        top_n_eff = min(resolved_top_n, n_val)
        model_top_n = np.argsort(-proba_test)[:top_n_eff]
        baseline_top_n = np.argsort(-fy_total_test)[:top_n_eff]
        baseline_rate = float(y_test[baseline_top_n].mean())
        model_rate = float(y_test[model_top_n].mean())

        gave_threshold_mask = fy_total_test >= gave_threshold
        n_gave_threshold = int(gave_threshold_mask.sum())
        gave_threshold_rate = (
            float(y_test[gave_threshold_mask].mean()) if n_gave_threshold > 0 else None
        )

        multiclass_fold = np.unique(y_test).size >= 2
        roc_auc = float(roc_auc_score(y_test, proba_test)) if multiclass_fold else None
        average_precision = (
            float(average_precision_score(y_test, proba_test)) if multiclass_fold else None
        )

        report.update({
            "validated": True,
            "validation_fiscal_year": int(fys[test_idx][0]),
            "n_validation_rows": int(n_val),
            "top_n": int(top_n_eff),
            "model_upgrade_rate_top_n": model_rate,
            "baseline_topn_fy_total_upgrade_rate": baseline_rate,
            "lift_topn_fy_total": (
                float(model_rate / baseline_rate) if baseline_rate > 0 else None
            ),
            "baseline_gave_threshold_upgrade_rate": gave_threshold_rate,
            "lift_over_gave_threshold": (
                float(model_rate / gave_threshold_rate)
                if gave_threshold_rate else None
            ),
            "overall_upgrade_rate": float(y_test.mean()),
            "deciles": _decile_report(proba_test, y_test),
            "roc_auc": roc_auc,
            "average_precision": average_precision,
            "recommended_list_fraction": _recommended_list_fraction(proba_test, y_test),
        })
        report["rule_check"] = _rule_check(
            historical_snap.iloc[test_idx], y[train_idx], y_test, proba_test,
            0 if random_state is None else random_state,
        )
        importance_df = donor_feature_importance(
            eval_model, X[test_idx], y[test_idx], feature_names=feature_cols,
            random_state=random_state,
        )
    else:
        warnings.warn(
            "Fewer than 2 distinct historical fiscal years, so no "
            "walk-forward validation fold could be built. Scores are still "
            "produced (fit on all historical rows), but there is no "
            "held-out report, and the feature-importance weights behind "
            "top_reasons are computed in-sample.",
            UserWarning, stacklevel=2,
        )
        in_sample_model = MajorGiftClassifier(random_state=random_state).fit(X, y)
        importance_df = donor_feature_importance(
            in_sample_model, X, y, feature_names=feature_cols,
            random_state=random_state,
        )

    model = MajorGiftClassifier(random_state=random_state).fit(X, y)

    if current_snap is None or current_snap.empty:
        scores = _empty_scores_frame()
    else:
        # Same single-class guard as the walk-forward fold above: only
        # needed here (not unconditionally), since with no current row to
        # score there is nothing that would call predict_proba at all.
        _check_two_classes(model.classes_, as_of_ts, "The historical training set")
        X_current = current_snap.reindex(columns=feature_cols, fill_value=0.0)
        affinity = model.predict_affinity_score(X_current.to_numpy(dtype="float64"))
        reasons = _top_reasons(X_current, importance_df)

        scores = pd.DataFrame(
            {
                "fiscal_year": current_snap["fiscal_year"].to_numpy(),
                "affinity_score": affinity,
                "top_reasons": reasons,
                "suggested_ask": np.nan,
            },
            index=current_snap.index,
        )
        scores = scores.sort_values("affinity_score", ascending=False, kind="stable")
        scores["rank"] = np.arange(1, len(scores) + 1)
        scores["decile"] = np.ceil(scores["rank"] / len(scores) * 10).clip(upper=10).astype("int64")
        scores = scores[
            ["fiscal_year", "affinity_score", "rank", "decile", "top_reasons", "suggested_ask"]
        ]

    report["n_scored"] = int(len(scores))
    if report["recommended_list_fraction"] is not None:
        report["recommended_list_size"] = int(np.ceil(report["recommended_list_fraction"] * len(scores)))
    return scores, report

repeat_giving_summary(gifts, *, as_of=None, time_unit_days=365.25)

Build per-donor BG/NBD and gamma-gamma inputs from a gift table.

Gifts on the same day are merged into one gift (amounts summed), as in [1]_, so a split payment does not count as a repeat gift.

Parameters:

Name Type Description Default
gifts DataFrame

One row per gift with donor_id, gift_date and gift_amount. Rename a CRM export's own columns to these first: the philanthropy.ingest read_* functions keep the export's (normalised) column names, and read_gifts and the *_to_features bridges return one row per donor, not per gift.

required
as_of (str, datetime - like or None)

End of the observation window. Gifts after it are dropped. None uses the latest gift date.

None
time_unit_days float

Days per time unit for recency and T, so the default unit is years and a horizon of 1 later means "the next year". Use 7 for weeks.

365.25

Returns:

Name Type Description
summary pandas.DataFrame, indexed by ``donor_id``
  • frequency: repeat gifts, i.e. distinct gift days minus one.
  • recency: time from the first gift to the last.
  • T: time from the first gift to the end of the window.
  • monetary_value: mean amount of the repeat gifts (the first gift is excluded, as in [2]_); 0 when frequency is 0.

summary[["frequency", "recency", "T"]] is the input to :class:BetaGeoModel and summary[["frequency", "monetary_value"]] the input to :class:GammaGammaModel.

Source code in philanthropy/models/_repeat_giving.py
def repeat_giving_summary(
    gifts: pd.DataFrame,
    *,
    as_of: Any = None,
    time_unit_days: float = 365.25,
) -> pd.DataFrame:
    """Build per-donor BG/NBD and gamma-gamma inputs from a gift table.

    Gifts on the same day are merged into one gift (amounts summed), as in
    [1]_, so a split payment does not count as a repeat gift.

    Parameters
    ----------
    gifts : pandas.DataFrame
        One row per gift with ``donor_id``, ``gift_date`` and ``gift_amount``.
        Rename a CRM export's own columns to these first: the
        ``philanthropy.ingest`` ``read_*`` functions keep the export's
        (normalised) column names, and ``read_gifts`` and the
        ``*_to_features`` bridges return one row per donor, not per gift.
    as_of : str, datetime-like or None, default=None
        End of the observation window. Gifts after it are dropped. ``None``
        uses the latest gift date.
    time_unit_days : float, default=365.25
        Days per time unit for ``recency`` and ``T``, so the default unit is
        years and a ``horizon`` of 1 later means "the next year". Use 7 for
        weeks.

    Returns
    -------
    summary : pandas.DataFrame, indexed by ``donor_id``
        * ``frequency``: repeat gifts, i.e. distinct gift days minus one.
        * ``recency``: time from the first gift to the last.
        * ``T``: time from the first gift to the end of the window.
        * ``monetary_value``: mean amount of the repeat gifts (the first gift
          is excluded, as in [2]_); 0 when ``frequency`` is 0.

        ``summary[["frequency", "recency", "T"]]`` is the input to
        :class:`BetaGeoModel` and ``summary[["frequency", "monetary_value"]]``
        the input to :class:`GammaGammaModel`.
    """
    daily = (
        gifts.assign(gift_date=pd.to_datetime(gifts["gift_date"]).dt.normalize())
        .groupby(["donor_id", "gift_date"])["gift_amount"]
        .sum(min_count=1)
        .reset_index()
    )
    # RFMTransformer owns the as_of cut, the NaN-amount handling and the
    # recency / tenure clock; it counts each gift day once because of the
    # merge above.
    rfm = RFMTransformer(
        reference_date=as_of, agg_func="sum", include_tenure=True, as_of=as_of
    ).fit_transform(daily).set_index("donor_id")

    first_amount = (
        daily.dropna(subset=["gift_amount"])
        .sort_values("gift_date")
        .groupby("donor_id")["gift_amount"]
        .first()
        .reindex(rfm.index)
    )
    frequency = (rfm["frequency"] - 1).clip(lower=0)
    repeat_total = rfm["monetary"] - first_amount
    monetary = (repeat_total / frequency.where(frequency > 0)).fillna(0.0)
    return pd.DataFrame({
        "frequency": frequency.astype(float),
        "recency": (rfm["tenure"] - rfm["recency"]) / time_unit_days,
        "T": rfm["tenure"] / time_unit_days,
        "monetary_value": monetary,
    })