Skip to content

Models Reference

philanthropy.models

Donor propensity, lapse prediction, and share-of-wallet capacity 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
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

Attributes:

Name Type Description
estimator_ HistGradientBoostingRegressor

The fitted backend estimator.

n_features_in_ int

Number of features seen during :meth:fit.

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.
    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.

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

    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,
        random_state: Optional[int] = None,
        ask_floor: float = 1.0,
    ) -> 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.ask_floor = ask_floor

    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) -> _Self:
        """Fit the ask-amount recommender to labelled prospect data."""
        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,
            random_state=self.random_state,
        )
        self.estimator_.fit(X, y)
        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)
        raw = self.estimator_.predict(X)
        return np.maximum(raw, self.ask_floor)

    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)

Fit the ask-amount recommender to labelled prospect data.

Source code in philanthropy/models/_ask.py
def fit(self: _Self, X: Any, y: Any) -> _Self:
    """Fit the ask-amount recommender to labelled prospect data."""
    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,
        random_state=self.random_state,
    )
    self.estimator_.fit(X, y)
    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)
    raw = self.estimator_.predict(X)
    return np.maximum(raw, self.ask_floor)

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): calibrated class probabilities in the standard sklearn two-column format.
  • 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.

1
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 well-calibrated probability estimates suitable for affinity scoring.
  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. ====== =================================

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``): calibrated class
      probabilities in the standard sklearn two-column format.
    * **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=1
        Minimum number of samples required to be at a leaf node.
    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 well-calibrated probability estimates
       suitable for affinity scoring.
    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.
    ====== =================================

    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
        return tags

    def __init__(
        self,
        n_estimators: int = 100,
        max_depth: Optional[int] = None,
        min_samples_split: int = 2,
        min_samples_leaf: int = 1,
        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, 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, 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, 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, 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
    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, 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, 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, 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, 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.

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.
    """
    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,
        random_state: Optional[int] = None,
    ) -> None:
        self.max_iter = max_iter
        self.learning_rate = learning_rate
        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,
            random_state=self.random_state
        )
        self.estimator_ = CalibratedClassifierCV(base_estimator)
        self.estimator_.fit(X, y)
        # 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_positive = self.predict_proba(X)[:, 1]
        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,
        random_state=self.random_state
    )
    self.estimator_ = CalibratedClassifierCV(base_estimator)
    self.estimator_.fit(X, y)
    # 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_positive = self.predict_proba(X)[:, 1]
    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

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.

    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,
    ) -> 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

    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) -> _Self:
        """Fit the share-of-wallet capacity model to labelled prospect data."""
        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,
            random_state=self.random_state,
        )
        self.estimator_.fit(X, y)
        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)

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

Source code in philanthropy/models/_wallet.py
def fit(self: _Self, X: Any, y: Any) -> _Self:
    """Fit the share-of-wallet capacity model to labelled prospect data."""
    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,
        random_state=self.random_state,
    )
    self.estimator_.fit(X, y)
    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.

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

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

    def fit(self: _Self, X: Any, y: Any) -> _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.

        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.
        """
        X, y = validate_data(self, X, y, 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)
        if hasattr(X, "columns"):
            self.feature_names_in_ = np.array(X.columns.tolist(), dtype=object)
        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_

        self.estimator_ = HistGradientBoostingClassifier(
            learning_rate=self.learning_rate,
            max_iter=self.max_iter,
            class_weight=self.class_weight,
            random_state=self.random_state,
        )
        self.estimator_.fit(X, y_encoded)
        # 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, 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, 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, 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)

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

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.

Source code in philanthropy/models/_moves.py
def fit(self: _Self, X: Any, y: Any) -> _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.

    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.
    """
    X, y = validate_data(self, X, y, 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)
    if hasattr(X, "columns"):
        self.feature_names_in_ = np.array(X.columns.tolist(), dtype=object)
    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_

    self.estimator_ = HistGradientBoostingClassifier(
        learning_rate=self.learning_rate,
        max_iter=self.max_iter,
        class_weight=self.class_weight,
        random_state=self.random_state,
    )
    self.estimator_.fit(X, y_encoded)
    # 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, 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, 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, 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.

Parameters:

Name Type Description Default
n_estimators int

Number of trees in the RandomForestClassifier.

100
max_depth int or None

Maximum depth of trees. None means nodes expand until pure.

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

Class weights for imbalanced lapse prediction.

None
random_state int or None

Random seed for reproducibility.

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

    Parameters
    ----------
    n_estimators : int, default=100
        Number of trees in the RandomForestClassifier.
    max_depth : int or None, default=None
        Maximum depth of trees. None means nodes expand until pure.
    class_weight : dict, "balanced", "balanced_subsample" or None, default=None
        Class weights for imbalanced lapse prediction.
    random_state : int or None, default=None
        Random seed for reproducibility.
    """

    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,
    ) -> None:
        self.n_estimators = n_estimators
        self.max_depth = max_depth
        self.class_weight = class_weight
        self.random_state = random_state

    def fit(self: _Self, X: Any, y: Any) -> _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.

        Returns
        -------
        self : LapsePredictor
        """
        X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
        self.classes_ = np.unique(y)
        self.n_features_in_ = X.shape[1]

        self.estimator_ = RandomForestClassifier(
            n_estimators=self.n_estimators,
            max_depth=self.max_depth,
            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 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)

fit(X, y)

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

Returns:

Name Type Description
self LapsePredictor
Source code in philanthropy/models/_lapse.py
def fit(self: _Self, X: Any, y: Any) -> _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.

    Returns
    -------
    self : LapsePredictor
    """
    X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
    self.classes_ = np.unique(y)
    self.n_features_in_ = X.shape[1]

    self.estimator_ = RandomForestClassifier(
        n_estimators=self.n_estimators,
        max_depth=self.max_depth,
        class_weight=self.class_weight,
        random_state=self.random_state,
    )
    self.estimator_.fit(X, y)
    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)

PlannedGivingIntentScorer

Bases: ClassifierMixin, BaseEstimator

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

Exposes .predict_intent_score(X) returning a 0-100 float array.

Parameters:

Name Type Description Default
n_estimators int

The number of boosting stages to perform.

100
random_state int, RandomState instance or None

Controls the randomness of the estimator.

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

    Exposes `.predict_intent_score(X)` returning a 0-100 float array.

    Parameters
    ----------
    n_estimators : int, default=100
        The number of boosting stages to perform.
    random_state : int, RandomState instance or None, default=None
        Controls the randomness of the estimator.
    """

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

    def fit(self: _Self, X: Any, y: Any) -> _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.

        Returns
        -------
        self : PlannedGivingIntentScorer
            Fitted estimator. Sets ``classes_``, ``n_features_in_``, and
            ``estimator_``.
        """
        X, y = validate_data(self, X, y, reset=True)

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

        base_estimator = GradientBoostingClassifier(
            n_estimators=self.n_estimators,
            random_state=self.random_state
        )
        self.estimator_ = CalibratedClassifierCV(
            estimator=base_estimator,
            method="sigmoid",
            cv=2,
        )
        self.estimator_.fit(X, y)
        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, 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, 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)
        if proba.shape[1] < 2:
            scores = np.zeros(proba.shape[0], dtype=float)
        else:
            scores = np.round(proba[:, 1] * 100.0, 2)
        return scores

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

fit(X, y)

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

Returns:

Name Type Description
self PlannedGivingIntentScorer

Fitted estimator. Sets classes_, n_features_in_, and estimator_.

Source code in philanthropy/models/_planned_giving.py
def fit(self: _Self, X: Any, y: Any) -> _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.

    Returns
    -------
    self : PlannedGivingIntentScorer
        Fitted estimator. Sets ``classes_``, ``n_features_in_``, and
        ``estimator_``.
    """
    X, y = validate_data(self, X, y, reset=True)

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

    base_estimator = GradientBoostingClassifier(
        n_estimators=self.n_estimators,
        random_state=self.random_state
    )
    self.estimator_ = CalibratedClassifierCV(
        estimator=base_estimator,
        method="sigmoid",
        cv=2,
    )
    self.estimator_.fit(X, y)
    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, 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, 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)
    if proba.shape[1] < 2:
        scores = np.zeros(proba.shape[0], dtype=float)
    else:
        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.

        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.

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.

    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_,
    )