Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,21 @@ emitting a `DeprecationWarning` naming this version.
the input carried column names. It now falls back to `x0`, `x1`, ... for an
array fit, matching what `WealthScreeningImputer` and `WealthScreeningImputerKNN`
already did. Closes #157.
- `MovesManagementClassifier` rejected NaN even though its
`HistGradientBoostingClassifier` backend handles missing values natively;
`fit`/`predict`/`predict_proba`/`action_priority` now pass NaN through.
Fitting on a single-class `y` used to silently produce a classifier whose
`predict_proba` returned only 1 column; it now raises a clear `ValueError`
at fit time. Also removed a dead, already-redundant `feature_names_in_`
assignment (`validate_data` sets it) and documented that
`action_priority`'s confidence is an uncalibrated max probability.
- `PlannedGivingIntentScorer.fit` raised scikit-learn's raw "Requesting
2-fold cross-validation..." error when a class had fewer than 2 examples,
because calibration uses a fixed `cv=2`. It now raises a clear `ValueError`
before calibration runs. Also removed an unreachable branch in
`predict_intent_score` (`predict_proba` always returns 2 columns once
`fit` requires at least 2 classes) and documented that NaN features are
rejected.

## [0.7.1] - 2026-09-08

Expand Down
82 changes: 74 additions & 8 deletions philanthropy/models/_moves.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import numpy as np
from sklearn.base import ClassifierMixin, BaseEstimator
from sklearn.utils import Tags
from sklearn.utils.validation import check_is_fitted, validate_data
from sklearn.utils.multiclass import check_classification_targets
from sklearn.preprocessing import LabelEncoder
Expand All @@ -17,8 +18,69 @@
class MovesManagementClassifier(ClassifierMixin, BaseEstimator):
"""
Predicts the next best moves management stage for a donor.

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

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

Parameters
----------
learning_rate : float, default=0.1
Learning rate of the underlying HistGradientBoostingClassifier.
max_iter : int, default=200
Maximum number of boosting iterations.
class_weight : str, dict or None, default="balanced"
Class weights passed to the backend. ``"balanced"`` reweights
inversely proportional to stage frequency.
random_state : int or None, default=None
Random seed for reproducibility.

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

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

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

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

def __init__(
self,
learning_rate: float = 0.1,
Expand Down Expand Up @@ -51,19 +113,23 @@ def fit(self: _Self, X: Any, y: Any) -> _Self:
Raises
------
ValueError
If ``y`` is not a classification target.
If ``y`` is not a classification target, or if it contains fewer
than 2 classes.
"""
X, y = validate_data(self, X, y, reset=True)
X, y = validate_data(self, X, y, ensure_all_finite="allow-nan", reset=True)
# Reject continuous targets: this is a classifier, so a regression
# target must not be silently label-encoded into pseudo-classes.
check_classification_targets(y)
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_
if len(self.classes_) < 2:
raise ValueError(
"MovesManagementClassifier requires at least 2 classes in "
f"y, got {len(self.classes_)} class: {list(self.classes_)}."
)

self.estimator_ = HistGradientBoostingClassifier(
learning_rate=self.learning_rate,
Expand Down Expand Up @@ -96,7 +162,7 @@ def predict(self, X: Any) -> np.ndarray:
If :meth:`fit` has not been called yet.
"""
check_is_fitted(self)
X = validate_data(self, X, reset=False)
X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
y_pred = self.estimator_.predict(X)
return self.label_encoder_.inverse_transform(y_pred)

Expand All @@ -119,7 +185,7 @@ def predict_proba(self, X: Any) -> np.ndarray:
If :meth:`fit` has not been called yet.
"""
check_is_fitted(self)
X = validate_data(self, X, reset=False)
X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
return self.estimator_.predict_proba(X)

def action_priority(self, X: Any) -> dict:
Expand All @@ -131,8 +197,8 @@ def action_priority(self, X: Any) -> dict:
``"portfolio_summary"`` (dict mapping each stage to its donor count).
"""
check_is_fitted(self)
X = validate_data(self, X, reset=False)
X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)

probas = self.estimator_.predict_proba(X)
pred_idx = np.argmax(probas, axis=1)
confidences = np.max(probas, axis=1)
Expand Down
47 changes: 39 additions & 8 deletions philanthropy/models/_planned_giving.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,27 @@
from sklearn.calibration import CalibratedClassifierCV
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.utils import Tags
from sklearn.utils.multiclass import check_classification_targets
from sklearn.utils.validation import check_is_fitted, validate_data

_Self = TypeVar("_Self", bound="PlannedGivingIntentScorer")

_CALIBRATION_CV_FOLDS = 2


class PlannedGivingIntentScorer(ClassifierMixin, BaseEstimator):
"""
Predicts bequest/planned giving intent. Wraps GradientBoostingClassifier
with CalibratedClassifierCV.

Exposes `.predict_intent_score(X)` returning a 0-100 float array.
Exposes `.predict_intent_score(X)` returning a 0-100 float array. NaN
features are rejected: GradientBoostingClassifier, the backend this
class calibrates, does not support missing values, so ``fit``/``predict``
raise on NaN input rather than passing it through.

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

Parameters
----------
Expand Down Expand Up @@ -56,20 +66,44 @@ def fit(self: _Self, X: Any, y: Any) -> _Self:
self : PlannedGivingIntentScorer
Fitted estimator. Sets ``classes_``, ``n_features_in_``, and
``estimator_``.

Raises
------
ValueError
If ``y`` is not a classification target, if it contains fewer
than 2 classes, or if any class has fewer than 2 examples,
since calibration uses ``cv=2``.
"""
X, y = validate_data(self, X, y, reset=True)

self.classes_ = np.unique(y)
# Reject continuous targets before counting classes, so a regression
# target gets sklearn's standard "continuous" message instead of
# being misread as one-example-per-class.
check_classification_targets(y)

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

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

base_estimator = GradientBoostingClassifier(
n_estimators=self.n_estimators,
random_state=self.random_state
)
self.estimator_ = CalibratedClassifierCV(
estimator=base_estimator,
method="sigmoid",
cv=2,
cv=_CALIBRATION_CV_FOLDS,
)
self.estimator_.fit(X, y)
return self
Expand Down Expand Up @@ -132,10 +166,7 @@ def predict_intent_score(self, X: Any) -> np.ndarray:
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)
scores = np.round(proba[:, 1] * 100.0, 2)
return scores

def __sklearn_tags__(self) -> Tags:
Expand Down
25 changes: 25 additions & 0 deletions tests/test_moves.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,28 @@ def test_array_fit_records_no_feature_names(stage_Xy):

assert not hasattr(clf, "feature_names_in_")
assert clf.n_features_in_ == X.shape[1]


def test_fit_accepts_nan_features(stage_Xy):
"""HistGradientBoostingClassifier handles NaN; fit/predict must not
reject it."""
X, y = stage_Xy
X = X.copy()
X[0, 0] = np.nan

clf = MovesManagementClassifier(max_iter=10, random_state=0).fit(X, y)
preds = clf.predict(X)
assert len(preds) == len(X)

proba = clf.predict_proba(X)
assert proba.shape == (30, 3)


def test_fit_raises_clear_error_on_single_class(stage_Xy):
"""A single-class y must raise a clear ValueError, not silently produce
a classifier whose predict_proba returns only 1 column."""
X, _ = stage_Xy
y = np.asarray(["IDENTIFY"] * 30)

with pytest.raises(ValueError, match="at least 2 classes"):
MovesManagementClassifier(max_iter=10, random_state=0).fit(X, y)
39 changes: 14 additions & 25 deletions tests/test_planned_giving.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@

from philanthropy.preprocessing import PlannedGivingSignalTransformer
from philanthropy.models import PlannedGivingIntentScorer
from unittest.mock import patch

# ---------------------------------------------------------------------------
# Shared helper
Expand Down Expand Up @@ -231,34 +230,24 @@ def test_composite_score_ignores_negative_sentinel(self):

class TestPlannedGivingIntentScorer:

def test_predict_intent_score_single_class_returns_zeros(self):
"""Single-class probability output must return all zeros."""
def test_fit_raises_clear_error_when_a_class_has_too_few_examples(self):
"""A class with < 2 examples must raise a clear ValueError, not
sklearn's raw cv=2 error."""
rng = np.random.default_rng(0)
X = rng.random((20, 4))

# Fit with multi-class data because CalibratedClassifierCV
# requires at least two classes during fitting.
m = PlannedGivingIntentScorer(n_estimators=5, random_state=0).fit(
X,
np.array([
0, 0, 0, 0, 1, 1, 1, 1,
0, 0, 0, 0, 1, 1, 1, 1,
0, 0, 0, 1,
]),
)
X = rng.random((11, 4))
y = np.array([0] * 10 + [1])

# Simulate predict_proba() returning one column,
# which triggers the single-class fallback.
with patch.object(
m,
"predict_proba",
return_value=np.zeros((20, 1)),
):
scores = m.predict_intent_score(X)
with pytest.raises(ValueError, match="at least 2 examples"):
PlannedGivingIntentScorer(n_estimators=5, random_state=0).fit(X, y)

assert scores.shape == (20,)
np.testing.assert_array_equal(scores, np.zeros(20))
def test_fit_raises_clear_error_on_single_class(self):
"""A single-class y must raise a clear ValueError."""
rng = np.random.default_rng(0)
X = rng.random((10, 4))
y = np.zeros(10)

with pytest.raises(ValueError, match="at least 2 classes"):
PlannedGivingIntentScorer(n_estimators=5, random_state=0).fit(X, y)


def test_predict_intent_score_multi_class(self):
Expand Down
Loading