Skip to content

Preprocessing Reference

philanthropy.preprocessing

CRM data cleaning, Fiscal Year-aware feature engineering, and clinical-encounter feature engineering for medical philanthropy.

FiscalYearTransformer

Bases: TransformerMixin, BaseEstimator

Derive organisation-specific fiscal year and quarter from a date column.

transform replaces the input with exactly two columns, fiscal_year and fiscal_quarter; it does not append them to the input. This is what get_feature_names_out has always reported. To keep the original columns alongside the fiscal ones, wrap this transformer in a :class:~sklearn.compose.ColumnTransformer with remainder="passthrough" or a :class:~sklearn.pipeline.FeatureUnion.

Source code in philanthropy/preprocessing/_transformers.py
class FiscalYearTransformer(TransformerMixin, BaseEstimator):
    """Derive organisation-specific fiscal year and quarter from a date column.

    ``transform`` **replaces** the input with exactly two columns,
    ``fiscal_year`` and ``fiscal_quarter``; it does not append them to the
    input. This is what ``get_feature_names_out`` has always reported. To keep
    the original columns alongside the fiscal ones, wrap this transformer in a
    :class:`~sklearn.compose.ColumnTransformer` with ``remainder="passthrough"``
    or a :class:`~sklearn.pipeline.FeatureUnion`.
    """

    def __init__(self, date_col: str = "gift_date", fiscal_year_start: int = 7) -> None:
        self.date_col = date_col
        self.fiscal_year_start = fiscal_year_start

    def fit(self: _SelfF, X: Any, y: Any = None) -> _SelfF:
        """Validate configuration and input without learning state.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training-set feature matrix.
        y : ignored
            Present for scikit-learn API compatibility.

        Returns
        -------
        self : FiscalYearTransformer
            Fitted transformer. This transformer is stateless.

        Raises
        ------
        ValueError
            If ``fiscal_year_start`` is invalid or ``X`` contains complex data.
        """
        validate_fiscal_year_start(self.fiscal_year_start)
        try:
            X_validated = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)
        except Exception as e:
            if "Complex data not supported" in str(e):
                raise
            X_val = X.astype(object) if hasattr(X, "astype") else X
            X_validated = validate_data(self, X_val, dtype=None, ensure_all_finite="allow-nan", reset=True)

        if np.iscomplexobj(X_validated):
            raise ValueError("Complex data not supported")
        return self

    def transform(self, X: Any) -> np.ndarray | pd.DataFrame:
        """Return the fiscal year and quarter derived from ``date_col``.

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

        Returns
        -------
        X_out : np.ndarray or pd.DataFrame of shape (n_samples, 2)
            Exactly two columns, ``fiscal_year`` and ``fiscal_quarter``. The
            input columns are **not** carried through; see the class docstring
            for how to keep them. Both columns are ``NaN`` for rows whose date
            does not parse, and for every row when ``date_col`` is absent from
            ``X``. Returns a DataFrame when the transformer is configured with
            ``set_output(transform="pandas")``, otherwise an ndarray.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        ValueError
            If ``X`` contains complex data.
        """
        check_is_fitted(self)
        try:
            X_arr = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)
        except Exception as e:
            if "Complex data not supported" in str(e):
                raise
            X_val = X.astype(object) if hasattr(X, "astype") else X
            X_arr = validate_data(self, X_val, dtype=None, ensure_all_finite="allow-nan", reset=False)

        if np.iscomplexobj(X_arr):
            raise ValueError("Complex data not supported")

        X_df = pd.DataFrame(X_arr, columns=getattr(self, "feature_names_in_", None)).copy()

        if self.date_col not in X_df.columns:
            X_df["fiscal_year"] = np.nan
            X_df["fiscal_quarter"] = np.nan
        else:
            dates = pd.to_datetime(X_df[self.date_col], errors="coerce")
            X_df["fiscal_year"] = dates.apply(
                lambda d: np.nan if pd.isna(d) else float(d.year + 1 if d.month >= self.fiscal_year_start else d.year)
            )
            X_df["fiscal_quarter"] = dates.apply(
                lambda d: np.nan if pd.isna(d) else float(((d.month - self.fiscal_year_start) % 12) // 3 + 1)
            )

        out_df = pd.DataFrame({
            "fiscal_year": pd.to_numeric(X_df["fiscal_year"], errors="coerce").astype(float),
            "fiscal_quarter": pd.to_numeric(X_df["fiscal_quarter"], errors="coerce").astype(float)
        })

        if _get_pandas_output(self):
            return out_df
        return out_df.to_numpy()

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return the two generated fiscal-period feature names.

        Parameters
        ----------
        input_features : array-like of str or None, default=None
            Ignored because the transformer always emits the same two features.

        Returns
        -------
        feature_names_out : ndarray of str
            ``["fiscal_year", "fiscal_quarter"]``.

        Raises
        ------
        NotFittedError
            If the transformer has not been fitted.
        """
        check_is_fitted(self)
        return np.array(["fiscal_year", "fiscal_quarter"], dtype=object)

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

fit(X, y=None)

Validate configuration and input without learning state.

Parameters:

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

Training-set feature matrix.

required
y ignored

Present for scikit-learn API compatibility.

None

Returns:

Name Type Description
self FiscalYearTransformer

Fitted transformer. This transformer is stateless.

Raises:

Type Description
ValueError

If fiscal_year_start is invalid or X contains complex data.

Source code in philanthropy/preprocessing/_transformers.py
def fit(self: _SelfF, X: Any, y: Any = None) -> _SelfF:
    """Validate configuration and input without learning state.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Training-set feature matrix.
    y : ignored
        Present for scikit-learn API compatibility.

    Returns
    -------
    self : FiscalYearTransformer
        Fitted transformer. This transformer is stateless.

    Raises
    ------
    ValueError
        If ``fiscal_year_start`` is invalid or ``X`` contains complex data.
    """
    validate_fiscal_year_start(self.fiscal_year_start)
    try:
        X_validated = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)
    except Exception as e:
        if "Complex data not supported" in str(e):
            raise
        X_val = X.astype(object) if hasattr(X, "astype") else X
        X_validated = validate_data(self, X_val, dtype=None, ensure_all_finite="allow-nan", reset=True)

    if np.iscomplexobj(X_validated):
        raise ValueError("Complex data not supported")
    return self

transform(X)

Return the fiscal year and quarter derived from date_col.

Parameters:

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

Feature matrix (training or held-out).

required

Returns:

Name Type Description
X_out np.ndarray or pd.DataFrame of shape (n_samples, 2)

Exactly two columns, fiscal_year and fiscal_quarter. The input columns are not carried through; see the class docstring for how to keep them. Both columns are NaN for rows whose date does not parse, and for every row when date_col is absent from X. Returns a DataFrame when the transformer is configured with set_output(transform="pandas"), otherwise an ndarray.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

ValueError

If X contains complex data.

Source code in philanthropy/preprocessing/_transformers.py
def transform(self, X: Any) -> np.ndarray | pd.DataFrame:
    """Return the fiscal year and quarter derived from ``date_col``.

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

    Returns
    -------
    X_out : np.ndarray or pd.DataFrame of shape (n_samples, 2)
        Exactly two columns, ``fiscal_year`` and ``fiscal_quarter``. The
        input columns are **not** carried through; see the class docstring
        for how to keep them. Both columns are ``NaN`` for rows whose date
        does not parse, and for every row when ``date_col`` is absent from
        ``X``. Returns a DataFrame when the transformer is configured with
        ``set_output(transform="pandas")``, otherwise an ndarray.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    ValueError
        If ``X`` contains complex data.
    """
    check_is_fitted(self)
    try:
        X_arr = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)
    except Exception as e:
        if "Complex data not supported" in str(e):
            raise
        X_val = X.astype(object) if hasattr(X, "astype") else X
        X_arr = validate_data(self, X_val, dtype=None, ensure_all_finite="allow-nan", reset=False)

    if np.iscomplexobj(X_arr):
        raise ValueError("Complex data not supported")

    X_df = pd.DataFrame(X_arr, columns=getattr(self, "feature_names_in_", None)).copy()

    if self.date_col not in X_df.columns:
        X_df["fiscal_year"] = np.nan
        X_df["fiscal_quarter"] = np.nan
    else:
        dates = pd.to_datetime(X_df[self.date_col], errors="coerce")
        X_df["fiscal_year"] = dates.apply(
            lambda d: np.nan if pd.isna(d) else float(d.year + 1 if d.month >= self.fiscal_year_start else d.year)
        )
        X_df["fiscal_quarter"] = dates.apply(
            lambda d: np.nan if pd.isna(d) else float(((d.month - self.fiscal_year_start) % 12) // 3 + 1)
        )

    out_df = pd.DataFrame({
        "fiscal_year": pd.to_numeric(X_df["fiscal_year"], errors="coerce").astype(float),
        "fiscal_quarter": pd.to_numeric(X_df["fiscal_quarter"], errors="coerce").astype(float)
    })

    if _get_pandas_output(self):
        return out_df
    return out_df.to_numpy()

get_feature_names_out(input_features=None)

Return the two generated fiscal-period feature names.

Parameters:

Name Type Description Default
input_features array-like of str or None

Ignored because the transformer always emits the same two features.

None

Returns:

Name Type Description
feature_names_out ndarray of str

["fiscal_year", "fiscal_quarter"].

Raises:

Type Description
NotFittedError

If the transformer has not been fitted.

Source code in philanthropy/preprocessing/_transformers.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return the two generated fiscal-period feature names.

    Parameters
    ----------
    input_features : array-like of str or None, default=None
        Ignored because the transformer always emits the same two features.

    Returns
    -------
    feature_names_out : ndarray of str
        ``["fiscal_year", "fiscal_quarter"]``.

    Raises
    ------
    NotFittedError
        If the transformer has not been fitted.
    """
    check_is_fitted(self)
    return np.array(["fiscal_year", "fiscal_quarter"], dtype=object)

CRMCleaner

Bases: TransformerMixin, BaseEstimator

Standardise raw CRM exports.

CRMCleaner performs lightweight, defensive cleaning of CRM datasets exported from systems such as Salesforce NPSP, Raiser's Edge NXT, or Ellucian Advance. It is designed to be chained in a sklearn.pipeline.Pipeline along with WealthScreeningImputer to handle missing wealth values.

Parameters:

Name Type Description Default
date_col str

Column containing ISO-8601 gift dates. Parsed to datetime64 during :meth:transform.

"gift_date"
amount_col str

Column containing raw gift amounts. Forced to float64 during :meth:transform; currency symbols, thousands separators and parenthesised negatives ("$1,000.00", "($500.00)") are stripped before parsing. Values that still don't parse become NaN; a column where nothing parses raises instead.

"gift_amount"
fiscal_year_start int

Month (1–12) that begins the organisation's fiscal year. Validated in :meth:fit but not used by :meth:transform, which only coerces date_col and amount_col. It is carried here so a cleaner and the :class:FiscalYearTransformer downstream of it can share one fiscal-calendar setting via set_params.

7

Attributes:

Name Type Description
feature_names_in_ list of str

Column names of X seen at :meth:fit time.

n_features_in_ int

Number of columns in X at :meth:fit time.

Source code in philanthropy/preprocessing/_transformers.py
class CRMCleaner(TransformerMixin, BaseEstimator):
    """Standardise raw CRM exports.

    ``CRMCleaner`` performs lightweight, defensive cleaning of CRM datasets
    exported from systems such as Salesforce NPSP, Raiser's Edge NXT, or
    Ellucian Advance. It is designed to be chained in a `sklearn.pipeline.Pipeline`
    along with `WealthScreeningImputer` to handle missing wealth values.

    Parameters
    ----------
    date_col : str, default="gift_date"
        Column containing ISO-8601 gift dates.  Parsed to ``datetime64``
        during :meth:`transform`.
    amount_col : str, default="gift_amount"
        Column containing raw gift amounts.  Forced to ``float64`` during
        :meth:`transform`; currency symbols, thousands separators and
        parenthesised negatives (``"$1,000.00"``, ``"($500.00)"``) are
        stripped before parsing.  Values that still don't parse become
        ``NaN``; a column where *nothing* parses raises instead.
    fiscal_year_start : int, default=7
        Month (1–12) that begins the organisation's fiscal year.  Validated in
        :meth:`fit` but **not** used by :meth:`transform`, which only coerces
        ``date_col`` and ``amount_col``.  It is carried here so a cleaner and
        the :class:`FiscalYearTransformer` downstream of it can share one
        fiscal-calendar setting via ``set_params``.

    Attributes
    ----------
    feature_names_in_ : list of str
        Column names of ``X`` seen at :meth:`fit` time.
    n_features_in_ : int
        Number of columns in ``X`` at :meth:`fit` time.
    """

    def __init__(
        self,
        date_col: str = "gift_date",
        amount_col: str = "gift_amount",
        fiscal_year_start: int = 7,
    ) -> None:
        self.date_col = date_col
        self.amount_col = amount_col
        self.fiscal_year_start = fiscal_year_start

    def fit(self: _SelfC, X: Any, y: Any = None) -> _SelfC:
        """Validate configuration and input without learning state.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training-set feature matrix.
        y : ignored
            Present for scikit-learn API compatibility.

        Returns
        -------
        self : CRMCleaner
            Fitted transformer. This transformer is stateless.

        Raises
        ------
        ValueError
            If ``fiscal_year_start`` is invalid or ``X`` contains complex data.
        """
        validate_fiscal_year_start(self.fiscal_year_start)

        # Try standard validation, fallback to object for mixed-type DataFrames or promotion errors
        try:
            X_validated = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)
        except Exception as e:
            if "Complex data not supported" in str(e):
                raise
            X_val = X.astype(object) if hasattr(X, "astype") else X
            X_validated = validate_data(self, X_val, dtype=None, ensure_all_finite="allow-nan", reset=True)

        if np.iscomplexobj(X_validated):
            raise ValueError("Complex data not supported")

        return self

    def transform(self, X: Any) -> np.ndarray | pd.DataFrame:
        """Clean CRM dates and amounts using the fitted column configuration.

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

        Returns
        -------
        X_out : np.ndarray or pd.DataFrame
            Cleaned feature matrix. Returns a DataFrame when the transformer is
            configured with ``set_output(transform="pandas")``, otherwise an
            ndarray.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        ValueError
            If ``X`` contains complex data.
        """
        check_is_fitted(self)
        try:
            X_arr = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)
        except Exception as e:
            if "Complex data not supported" in str(e):
                raise
            X_val = X.astype(object) if hasattr(X, "astype") else X
            X_arr = validate_data(self, X_val, dtype=None, ensure_all_finite="allow-nan", reset=False)

        if np.iscomplexobj(X_arr):
            raise ValueError("Complex data not supported")

        X_df = pd.DataFrame(X_arr, columns=getattr(self, "feature_names_in_", None)).copy()

        if self.date_col in X_df.columns:
            X_df[self.date_col] = pd.to_datetime(X_df[self.date_col], errors="coerce")
        if self.amount_col in X_df.columns:
            X_df[self.amount_col] = _coerce_currency_to_float(X_df[self.amount_col])

        if _get_pandas_output(self):
            return X_df
        return X_df.to_numpy()

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return the CRM columns learned during fitting.

        Parameters
        ----------
        input_features : array-like of str or None, default=None
            Ignored. The output names are the input column names recorded by
            :meth:`fit`.

        Returns
        -------
        feature_names_out : ndarray of str
            The original CRM column names, in input order.

        Raises
        ------
        NotFittedError
            If the transformer has not been fitted.
        """
        check_is_fitted(self)
        names = list(self.feature_names_in_)
        return np.array(names, dtype=object)

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

fit(X, y=None)

Validate configuration and input without learning state.

Parameters:

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

Training-set feature matrix.

required
y ignored

Present for scikit-learn API compatibility.

None

Returns:

Name Type Description
self CRMCleaner

Fitted transformer. This transformer is stateless.

Raises:

Type Description
ValueError

If fiscal_year_start is invalid or X contains complex data.

Source code in philanthropy/preprocessing/_transformers.py
def fit(self: _SelfC, X: Any, y: Any = None) -> _SelfC:
    """Validate configuration and input without learning state.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Training-set feature matrix.
    y : ignored
        Present for scikit-learn API compatibility.

    Returns
    -------
    self : CRMCleaner
        Fitted transformer. This transformer is stateless.

    Raises
    ------
    ValueError
        If ``fiscal_year_start`` is invalid or ``X`` contains complex data.
    """
    validate_fiscal_year_start(self.fiscal_year_start)

    # Try standard validation, fallback to object for mixed-type DataFrames or promotion errors
    try:
        X_validated = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)
    except Exception as e:
        if "Complex data not supported" in str(e):
            raise
        X_val = X.astype(object) if hasattr(X, "astype") else X
        X_validated = validate_data(self, X_val, dtype=None, ensure_all_finite="allow-nan", reset=True)

    if np.iscomplexobj(X_validated):
        raise ValueError("Complex data not supported")

    return self

transform(X)

Clean CRM dates and amounts using the fitted column configuration.

Parameters:

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

Feature matrix (training or held-out).

required

Returns:

Name Type Description
X_out ndarray or DataFrame

Cleaned feature matrix. Returns a DataFrame when the transformer is configured with set_output(transform="pandas"), otherwise an ndarray.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

ValueError

If X contains complex data.

Source code in philanthropy/preprocessing/_transformers.py
def transform(self, X: Any) -> np.ndarray | pd.DataFrame:
    """Clean CRM dates and amounts using the fitted column configuration.

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

    Returns
    -------
    X_out : np.ndarray or pd.DataFrame
        Cleaned feature matrix. Returns a DataFrame when the transformer is
        configured with ``set_output(transform="pandas")``, otherwise an
        ndarray.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    ValueError
        If ``X`` contains complex data.
    """
    check_is_fitted(self)
    try:
        X_arr = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)
    except Exception as e:
        if "Complex data not supported" in str(e):
            raise
        X_val = X.astype(object) if hasattr(X, "astype") else X
        X_arr = validate_data(self, X_val, dtype=None, ensure_all_finite="allow-nan", reset=False)

    if np.iscomplexobj(X_arr):
        raise ValueError("Complex data not supported")

    X_df = pd.DataFrame(X_arr, columns=getattr(self, "feature_names_in_", None)).copy()

    if self.date_col in X_df.columns:
        X_df[self.date_col] = pd.to_datetime(X_df[self.date_col], errors="coerce")
    if self.amount_col in X_df.columns:
        X_df[self.amount_col] = _coerce_currency_to_float(X_df[self.amount_col])

    if _get_pandas_output(self):
        return X_df
    return X_df.to_numpy()

get_feature_names_out(input_features=None)

Return the CRM columns learned during fitting.

Parameters:

Name Type Description Default
input_features array-like of str or None

Ignored. The output names are the input column names recorded by :meth:fit.

None

Returns:

Name Type Description
feature_names_out ndarray of str

The original CRM column names, in input order.

Raises:

Type Description
NotFittedError

If the transformer has not been fitted.

Source code in philanthropy/preprocessing/_transformers.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return the CRM columns learned during fitting.

    Parameters
    ----------
    input_features : array-like of str or None, default=None
        Ignored. The output names are the input column names recorded by
        :meth:`fit`.

    Returns
    -------
    feature_names_out : ndarray of str
        The original CRM column names, in input order.

    Raises
    ------
    NotFittedError
        If the transformer has not been fitted.
    """
    check_is_fitted(self)
    names = list(self.feature_names_in_)
    return np.array(names, dtype=object)

WealthScreeningImputer

Bases: TransformerMixin, BaseEstimator

Leakage-safe median/constant imputation for wealth-screening columns.

This transformer learns fill statistics only from the training fold during :meth:fit and applies them in :meth:transform. It is designed to slot cleanly into a :class:sklearn.pipeline.Pipeline immediately after :class:~philanthropy.preprocessing.CRMCleaner and before any model that cannot natively handle NaN values.

Parameters:

Name Type Description Default
wealth_cols list of str or None

Column names containing third-party wealth-screening numeric values. If None, defaults to a canonical set (estimated_net_worth, real_estate_value, stock_holdings, charitable_capacity, planned_gift_inclination). Only columns that actually exist in X are imputed; missing columns are skipped with a warning.

None
strategy ('median', 'mean', 'zero')

Imputation strategy applied to each wealth column:

  • "median": Robust to the extreme right-skew and outliers common in wealth data. Strongly recommended for raw vendor exports.
  • "mean": Computationally equivalent to OLS; use only after outlier treatment.
  • "zero": Sets missing values to 0.0, which is semantically meaningful when absence of a record implies zero capacity (e.g., no real-estate holdings found).
"median"
add_indicator bool

If True, appends a binary indicator column <column_name>__was_missing (dtype uint8) for each imputed wealth column. Retaining missingness signals allows downstream models to learn that the absence of a vendor record itself carries information (e.g., very high-net-worth individuals are often not found in commercial databases because they actively shield their assets).

True

Attributes:

Name Type Description
fill_values_ dict of {str: float}

Mapping from column name to the computed fill value, frozen at :meth:fit time.

imputed_cols_ list of str

Wealth columns that were actually present in X at :meth:fit time and will be imputed.

n_features_in_ int

Number of columns in X at :meth:fit time.

feature_names_in_ ndarray of str

Column names of X at :meth:fit time.

Raises:

Type Description
ValueError

If strategy is not one of {"median", "mean", "zero"}.

Examples:

>>> import pandas as pd
>>> import numpy as np
>>> from philanthropy.preprocessing import WealthScreeningImputer
>>> X = pd.DataFrame({
...     "estimated_net_worth": [1e6, np.nan, 5e5, np.nan, 2e6],
...     "real_estate_value":   [np.nan, 3e5, np.nan, 4e5, np.nan],
...     "gift_amount":         [5000, 250, 1000, 750, 10000],
... })
>>> imp = WealthScreeningImputer(
...     wealth_cols=["estimated_net_worth", "real_estate_value"],
...     strategy="median",
...     add_indicator=True,
... )
>>> imp.set_output(transform="pandas")
WealthScreeningImputer(...)
>>> X_out = imp.fit_transform(X)
>>> bool(X_out["estimated_net_worth"].isna().any())
False
>>> "estimated_net_worth__was_missing" in X_out.columns
True
See Also

philanthropy.preprocessing.CRMCleaner : Upstream cleaner that standardises column dtypes before this imputer. philanthropy.models.ShareOfWalletRegressor : Downstream model that uses wealth-screening features to estimate philanthropic capacity.

Source code in philanthropy/preprocessing/_wealth.py
class WealthScreeningImputer(TransformerMixin, BaseEstimator):
    """Leakage-safe median/constant imputation for wealth-screening columns.

    This transformer learns fill statistics **only** from the training fold
    during :meth:`fit` and applies them in :meth:`transform`.  It is designed
    to slot cleanly into a :class:`sklearn.pipeline.Pipeline` immediately after
    :class:`~philanthropy.preprocessing.CRMCleaner` and before any model that
    cannot natively handle ``NaN`` values.

    Parameters
    ----------
    wealth_cols : list of str or None, default=None
        Column names containing third-party wealth-screening numeric values.
        If ``None``, defaults to a canonical set (``estimated_net_worth``,
        ``real_estate_value``, ``stock_holdings``, ``charitable_capacity``,
        ``planned_gift_inclination``).  Only columns that *actually exist* in
        ``X`` are imputed; missing columns are skipped with a warning.
    strategy : {"median", "mean", "zero"}, default="median"
        Imputation strategy applied to each wealth column:

        * ``"median"``: Robust to the extreme right-skew and outliers common
          in wealth data.  Strongly recommended for raw vendor exports.
        * ``"mean"``: Computationally equivalent to OLS; use only after
          outlier treatment.
        * ``"zero"``: Sets missing values to 0.0, which is semantically
          meaningful when absence of a record implies zero capacity (e.g., no
          real-estate holdings found).
    add_indicator : bool, default=True
        If ``True``, appends a binary indicator column
        ``<column_name>__was_missing`` (dtype ``uint8``) for each imputed
        wealth column.  Retaining missingness signals allows downstream models
        to learn that the absence of a vendor record itself carries information
        (e.g., very high-net-worth individuals are often *not* found in
        commercial databases because they actively shield their assets).

    Attributes
    ----------
    fill_values_ : dict of {str: float}
        Mapping from column name to the computed fill value, frozen at
        :meth:`fit` time.
    imputed_cols_ : list of str
        Wealth columns that were actually present in ``X`` at :meth:`fit`
        time and will be imputed.
    n_features_in_ : int
        Number of columns in ``X`` at :meth:`fit` time.
    feature_names_in_ : ndarray of str
        Column names of ``X`` at :meth:`fit` time.

    Raises
    ------
    ValueError
        If ``strategy`` is not one of ``{"median", "mean", "zero"}``.

    Examples
    --------
    >>> import pandas as pd
    >>> import numpy as np
    >>> from philanthropy.preprocessing import WealthScreeningImputer
    >>> X = pd.DataFrame({
    ...     "estimated_net_worth": [1e6, np.nan, 5e5, np.nan, 2e6],
    ...     "real_estate_value":   [np.nan, 3e5, np.nan, 4e5, np.nan],
    ...     "gift_amount":         [5000, 250, 1000, 750, 10000],
    ... })
    >>> imp = WealthScreeningImputer(
    ...     wealth_cols=["estimated_net_worth", "real_estate_value"],
    ...     strategy="median",
    ...     add_indicator=True,
    ... )
    >>> imp.set_output(transform="pandas")  # doctest: +ELLIPSIS
    WealthScreeningImputer(...)
    >>> X_out = imp.fit_transform(X)
    >>> bool(X_out["estimated_net_worth"].isna().any())
    False
    >>> "estimated_net_worth__was_missing" in X_out.columns
    True

    See Also
    --------
    philanthropy.preprocessing.CRMCleaner :
        Upstream cleaner that standardises column dtypes before this imputer.
    philanthropy.models.ShareOfWalletRegressor :
        Downstream model that uses wealth-screening features to estimate
        philanthropic capacity.
    """

    _VALID_STRATEGIES = frozenset({"median", "mean", "zero"})

    def __init__(
        self,
        wealth_cols: Optional[List[str]] = None,
        strategy: Literal["median", "mean", "zero"] = "median",
        add_indicator: bool = True,
    ) -> None:
        self.wealth_cols = wealth_cols
        self.strategy = strategy
        self.add_indicator = add_indicator

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

    def _resolve_cols(self, input_cols: List[str]) -> List[str]:
        """Return the wealth columns that actually exist in ``X``."""
        candidates = (
            self.wealth_cols
            if self.wealth_cols is not None
            else _DEFAULT_WEALTH_COLS
        )
        return [c for c in candidates if c in input_cols]

    def _compute_fill(self, array: np.ndarray) -> float:
        """Return the fill value for a single wealth column."""
        # An all-NaN column has no statistic. Short-circuit so nanmedian/nanmean
        # do not emit "Mean of empty slice" for a NaN we discard immediately.
        if np.all(np.isnan(array)):
            return 0.0
        if self.strategy == "median":
            val = np.nanmedian(array)
        elif self.strategy == "mean":
            val = np.nanmean(array)
        else:  # "zero"
            val = 0.0
        # If the column is entirely NaN, fall back to 0.0
        return float(val) if not np.isnan(val) else 0.0

    # ------------------------------------------------------------------
    # fit / transform
    # ------------------------------------------------------------------

    def fit(self: _Self, X: Any, y: Any = None) -> _Self:
        """Learn fill statistics from training data.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training-set feature matrix.  Missing wealth columns are silently
            skipped (a ``UserWarning`` is issued for each absent column).
        y : ignored
            Present for scikit-learn API compatibility.

        Returns
        -------
        self : WealthScreeningImputer
            Fitted imputer.

        Raises
        ------
        ValueError
            If ``strategy`` is not ``"median"``, ``"mean"``, or ``"zero"``.
        """
        import warnings

        if self.strategy not in self._VALID_STRATEGIES:
            raise ValueError(
                f"`strategy` must be one of {sorted(self._VALID_STRATEGIES)}, "
                f"got {self.strategy!r}."
            )

        # Extract column names BEFORE validate_data converts DataFrame → ndarray
        if hasattr(X, "columns"):
            input_cols = list(X.columns)
        else:
            input_cols = None  # Will resolve after validate_data

        X = validate_data(self, X, dtype="numeric",
                          ensure_all_finite="allow-nan",
                          reset=True)

        # After validate_data, use feature_names_in_ if it was set (DataFrame input),
        # otherwise fall back to generated names.
        if input_cols is None:
            n_cols = X.shape[1]
            if hasattr(self, "feature_names_in_"):
                input_cols = list(self.feature_names_in_)
            else:
                input_cols = [f"x{i}" for i in range(n_cols)]

        self.imputed_cols_ = self._resolve_cols(input_cols)

        # Warn about requested columns not found in X
        if self.wealth_cols is not None:
            missing = [c for c in self.wealth_cols if c not in input_cols]
            for col in missing:
                warnings.warn(
                    f"WealthScreeningImputer: column {col!r} was specified in "
                    f"`wealth_cols` but was not found in X.  It will be skipped.",
                    UserWarning,
                )

        computed_fills = {}
        for col in self.imputed_cols_:
            idx = input_cols.index(col)
            computed_fills[col] = self._compute_fill(X[:, idx])
        self.fill_values_ = computed_fills

        return self

    def transform(self, X: Any) -> np.ndarray:
        """Apply imputation with frozen fill values.

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

        Returns
        -------
        X_out : np.ndarray
            Copy of ``X`` with missing wealth columns filled and, if
            ``add_indicator=True``, binary missingness indicator columns
            appended.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self, ["fill_values_", "imputed_cols_"])

        # Extract column names BEFORE validate_data converts DataFrame → ndarray
        if hasattr(X, "columns"):
            input_cols = list(X.columns)
        else:
            input_cols = None  # Will resolve after validate_data

        X = validate_data(self, X, dtype="numeric",
                          ensure_all_finite="allow-nan",
                          reset=False)

        if input_cols is None:
            n_cols = X.shape[1]
            if hasattr(self, "feature_names_in_"):
                input_cols = list(self.feature_names_in_)
            else:
                input_cols = [f"x{i}" for i in range(n_cols)]

        X_out = X.copy()
        indicators = []

        for col in self.imputed_cols_:
            if col not in input_cols:
                continue
            idx = input_cols.index(col)

            mask = np.isnan(X_out[:, idx])

            if self.add_indicator:
                indicators.append(mask.astype(np.float64).reshape(-1, 1))

            X_out[mask, idx] = self.fill_values_[col]

        if indicators:
            return np.hstack([X_out] + indicators)
        return X_out

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return imputed feature names and optional missingness indicators.

        Parameters
        ----------
        input_features : array-like of str or None, default=None
            Input feature names to use. When omitted, fitted names are used, or
            ``x0``, ``x1``, ... for unnamed input.

        Returns
        -------
        feature_names_out : ndarray of str
            Base feature names, followed by ``<column>__was_missing`` for each
            imputed column when ``add_indicator=True``.

        Raises
        ------
        NotFittedError
            If the imputer has not been fitted.
        """
        check_is_fitted(self)
        if input_features is not None:
            out = list(input_features)
        elif hasattr(self, "feature_names_in_"):
            out = list(self.feature_names_in_)
        else:
            out = [f"x{i}" for i in range(self.n_features_in_)]

        if self.add_indicator:
            for col in self.imputed_cols_:
                if col in out:
                    out.append(f"{col}__was_missing")
        return np.array(out, dtype=object)

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

fit(X, y=None)

Learn fill statistics from training data.

Parameters:

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

Training-set feature matrix. Missing wealth columns are silently skipped (a UserWarning is issued for each absent column).

required
y ignored

Present for scikit-learn API compatibility.

None

Returns:

Name Type Description
self WealthScreeningImputer

Fitted imputer.

Raises:

Type Description
ValueError

If strategy is not "median", "mean", or "zero".

Source code in philanthropy/preprocessing/_wealth.py
def fit(self: _Self, X: Any, y: Any = None) -> _Self:
    """Learn fill statistics from training data.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Training-set feature matrix.  Missing wealth columns are silently
        skipped (a ``UserWarning`` is issued for each absent column).
    y : ignored
        Present for scikit-learn API compatibility.

    Returns
    -------
    self : WealthScreeningImputer
        Fitted imputer.

    Raises
    ------
    ValueError
        If ``strategy`` is not ``"median"``, ``"mean"``, or ``"zero"``.
    """
    import warnings

    if self.strategy not in self._VALID_STRATEGIES:
        raise ValueError(
            f"`strategy` must be one of {sorted(self._VALID_STRATEGIES)}, "
            f"got {self.strategy!r}."
        )

    # Extract column names BEFORE validate_data converts DataFrame → ndarray
    if hasattr(X, "columns"):
        input_cols = list(X.columns)
    else:
        input_cols = None  # Will resolve after validate_data

    X = validate_data(self, X, dtype="numeric",
                      ensure_all_finite="allow-nan",
                      reset=True)

    # After validate_data, use feature_names_in_ if it was set (DataFrame input),
    # otherwise fall back to generated names.
    if input_cols is None:
        n_cols = X.shape[1]
        if hasattr(self, "feature_names_in_"):
            input_cols = list(self.feature_names_in_)
        else:
            input_cols = [f"x{i}" for i in range(n_cols)]

    self.imputed_cols_ = self._resolve_cols(input_cols)

    # Warn about requested columns not found in X
    if self.wealth_cols is not None:
        missing = [c for c in self.wealth_cols if c not in input_cols]
        for col in missing:
            warnings.warn(
                f"WealthScreeningImputer: column {col!r} was specified in "
                f"`wealth_cols` but was not found in X.  It will be skipped.",
                UserWarning,
            )

    computed_fills = {}
    for col in self.imputed_cols_:
        idx = input_cols.index(col)
        computed_fills[col] = self._compute_fill(X[:, idx])
    self.fill_values_ = computed_fills

    return self

transform(X)

Apply imputation with frozen fill values.

Parameters:

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

Feature matrix (training or held-out).

required

Returns:

Name Type Description
X_out ndarray

Copy of X with missing wealth columns filled and, if add_indicator=True, binary missingness indicator columns appended.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/preprocessing/_wealth.py
def transform(self, X: Any) -> np.ndarray:
    """Apply imputation with frozen fill values.

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

    Returns
    -------
    X_out : np.ndarray
        Copy of ``X`` with missing wealth columns filled and, if
        ``add_indicator=True``, binary missingness indicator columns
        appended.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self, ["fill_values_", "imputed_cols_"])

    # Extract column names BEFORE validate_data converts DataFrame → ndarray
    if hasattr(X, "columns"):
        input_cols = list(X.columns)
    else:
        input_cols = None  # Will resolve after validate_data

    X = validate_data(self, X, dtype="numeric",
                      ensure_all_finite="allow-nan",
                      reset=False)

    if input_cols is None:
        n_cols = X.shape[1]
        if hasattr(self, "feature_names_in_"):
            input_cols = list(self.feature_names_in_)
        else:
            input_cols = [f"x{i}" for i in range(n_cols)]

    X_out = X.copy()
    indicators = []

    for col in self.imputed_cols_:
        if col not in input_cols:
            continue
        idx = input_cols.index(col)

        mask = np.isnan(X_out[:, idx])

        if self.add_indicator:
            indicators.append(mask.astype(np.float64).reshape(-1, 1))

        X_out[mask, idx] = self.fill_values_[col]

    if indicators:
        return np.hstack([X_out] + indicators)
    return X_out

get_feature_names_out(input_features=None)

Return imputed feature names and optional missingness indicators.

Parameters:

Name Type Description Default
input_features array-like of str or None

Input feature names to use. When omitted, fitted names are used, or x0, x1, ... for unnamed input.

None

Returns:

Name Type Description
feature_names_out ndarray of str

Base feature names, followed by <column>__was_missing for each imputed column when add_indicator=True.

Raises:

Type Description
NotFittedError

If the imputer has not been fitted.

Source code in philanthropy/preprocessing/_wealth.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return imputed feature names and optional missingness indicators.

    Parameters
    ----------
    input_features : array-like of str or None, default=None
        Input feature names to use. When omitted, fitted names are used, or
        ``x0``, ``x1``, ... for unnamed input.

    Returns
    -------
    feature_names_out : ndarray of str
        Base feature names, followed by ``<column>__was_missing`` for each
        imputed column when ``add_indicator=True``.

    Raises
    ------
    NotFittedError
        If the imputer has not been fitted.
    """
    check_is_fitted(self)
    if input_features is not None:
        out = list(input_features)
    elif hasattr(self, "feature_names_in_"):
        out = list(self.feature_names_in_)
    else:
        out = [f"x{i}" for i in range(self.n_features_in_)]

    if self.add_indicator:
        for col in self.imputed_cols_:
            if col in out:
                out.append(f"{col}__was_missing")
    return np.array(out, dtype=object)

EncounterTransformer

Bases: TransformerMixin, BaseEstimator

Merge clinical encounter history into philanthropic feature matrices.

Given a lookup encounter_df containing at least one discharge date per donor, this transformer enriches a gift-level DataFrame with two continuous temporal features:

days_since_last_discharge Days between the donor's most recent discharge date (observed at :meth:fit time) and the gift_date in X. float64, not an integer: the column has to carry NaN for donors absent from the encounter table and, when allow_negative_days=False, for gifts dated before discharge. Do not cast it to an integer type, because that discards the missingness, which is itself signal here. Negative values (gifts made before discharge) survive only when allow_negative_days=True. encounter_frequency_score log1p of the number of encounter rows for the donor. Two things this is not: it is not a count, because of the log transform, and it is not a count of distinct encounters, because repeated rows for the same donor each add one. A donor with three rows on two dates scores log1p(3), not log1p(2). The log transform is there because the distribution of encounter counts is strongly right-skewed in real AMC data. Donors with no encounters score 0.0.

Identifier columns (merge_key plus any column whose name contains a substring in :attr:PII_PATTERNS) are dropped from the output before it is returned, as a defense-in-depth guard against accidental downstream leakage. This is a name-based heuristic, not de-identification: it inspects column names only (never cell values) and can miss identifiers whose names it does not recognise. See docs/explanation/compliance_considerations.md. Extend or replace the patterns via the pii_patterns parameter.

Parameters:

Name Type Description Default
encounter_df DataFrame

Reference table of clinical encounters. Must contain merge_key and discharge_col. Additional columns are ignored.

None
discharge_col str

Column in encounter_df holding ISO-8601 discharge timestamps.

"discharge_date"
gift_date_col str

Column in X (the gift-level DataFrame) holding ISO-8601 gift dates.

"gift_date"
merge_key str

Column name present in both encounter_df and X used to join the two tables. This column is dropped from the output.

The join keys on the donor's latest discharge, so a donor with a later encounter than the gift being scored is measured against that later encounter (bounded by as_of, and coerced to NaN unless allow_negative_days). Per-gift index-encounter keying is not implemented: it would require the raw encounter rows at transform time, which is exactly what __getstate__ keeps out of saved bundles.

"donor_id"
allow_negative_days bool

If False (recommended), days_since_last_discharge values below zero are coerced to NaN, indicating that the gift predates the discharge. Set to True only for retrospective analyses where pre-admission gifts are meaningful.

False
id_cols_to_drop list of str or None

Additional column names to explicitly drop on output, beyond those detected via the PII heuristic. Useful when non-standard identifiers (e.g., "pledge_record_key") are present in X.

None
pii_patterns tuple of str or None

Case-insensitive substrings used to flag identifier-like column names for dropping. If None, the class-level :attr:PII_PATTERNS default is used. Provide your own tuple to broaden or narrow the heuristic: it replaces (does not extend) the default when set.

None
as_of (str, datetime - like or None)

As-of cutoff for the encounter table. Encounters discharged after this date are excluded from encounter_summary_ at :meth:fit time. None (the default) uses the whole table, which is only correct when every row of encounter_df was already observable at the point the solicitation decision is being modelled. For walk-forward evaluation, set this to the last day of the training window: without it, a gift dated 2020 is scored against encounters recorded in 2024, and days_since_last_discharge is measured from a discharge that had not happened yet.

None

Attributes:

Name Type Description
encounter_summary_ DataFrame

Per-donor summary table (indexed by merge_key) with columns last_discharge (Timestamp) and encounter_count (int), computed at :meth:fit time.

dropped_cols_ list of str

Names of the columns that were removed from X during the last :meth:transform call for audit/logging purposes.

n_features_in_ int

Number of columns seen in X at :meth:fit time.

feature_names_in_ ndarray of str

Column names of X at :meth:fit time.

Raises:

Type Description
ValueError

If merge_key is absent from encounter_df or from X.

ValueError

If discharge_col is absent from encounter_df.

Examples:

>>> import pandas as pd
>>> from philanthropy.preprocessing import EncounterTransformer
>>> enc = pd.DataFrame({
...     "donor_id":       [1, 1, 2],
...     "discharge_date": ["2022-01-01", "2023-06-15", "2022-09-30"],
... })
>>> gifts = pd.DataFrame({
...     "donor_id":    [1, 2, 3],
...     "gift_date":   ["2023-08-01", "2023-01-01", "2023-05-01"],
...     "gift_amount": [10000.0, 750.0, 250.0],
... })
>>> t = EncounterTransformer(encounter_df=enc, merge_key="donor_id")
>>> t.set_output(transform="pandas")
EncounterTransformer(...)
>>> out = t.fit_transform(gifts)
>>> "donor_id" not in out.columns
True
>>> "days_since_last_discharge" in out.columns
True
>>> "encounter_frequency_score" in out.columns
True
Source code in philanthropy/preprocessing/_encounters.py
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
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
class EncounterTransformer(TransformerMixin, BaseEstimator):
    """Merge clinical encounter history into philanthropic feature matrices.

    Given a lookup ``encounter_df`` containing at least one discharge date per
    donor, this transformer enriches a gift-level DataFrame with two continuous
    temporal features:

    ``days_since_last_discharge``
        Days between the donor's **most recent** discharge date (observed at
        :meth:`fit` time) and the ``gift_date`` in ``X``. ``float64``, not an
        integer: the column has to carry ``NaN`` for donors absent from the
        encounter table and, when ``allow_negative_days=False``, for gifts dated
        before discharge. Do not cast it to an integer type, because that
        discards the missingness, which is itself signal here. Negative values
        (gifts made before discharge) survive only when
        ``allow_negative_days=True``.
    ``encounter_frequency_score``
        ``log1p`` of the number of encounter **rows** for the donor. Two things
        this is not: it is not a count, because of the log transform, and it is
        not a count of *distinct* encounters, because repeated rows for the same
        donor each add one. A donor with three rows on two dates scores
        ``log1p(3)``, not ``log1p(2)``. The log transform is there because the
        distribution of encounter counts is strongly right-skewed in real AMC
        data. Donors with no encounters score ``0.0``.

    Identifier columns (``merge_key`` plus any column whose name contains a
    substring in :attr:`PII_PATTERNS`) are dropped from the output before it is
    returned, as a defense-in-depth guard against accidental downstream leakage.
    This is a **name-based heuristic, not de-identification**: it inspects column
    *names* only (never cell values) and can miss identifiers whose names it does
    not recognise. See ``docs/explanation/compliance_considerations.md``. Extend
    or replace the patterns via the ``pii_patterns`` parameter.

    Parameters
    ----------
    encounter_df : pd.DataFrame
        Reference table of clinical encounters.  Must contain ``merge_key``
        and ``discharge_col``.  Additional columns are ignored.
    discharge_col : str, default="discharge_date"
        Column in ``encounter_df`` holding ISO-8601 discharge timestamps.
    gift_date_col : str, default="gift_date"
        Column in ``X`` (the gift-level DataFrame) holding ISO-8601 gift
        dates.
    merge_key : str, default="donor_id"
        Column name present in **both** ``encounter_df`` and ``X`` used to
        join the two tables.  This column is dropped from the output.

        The join keys on the donor's **latest** discharge, so a donor with a
        later encounter than the gift being scored is measured against that
        later encounter (bounded by ``as_of``, and coerced to ``NaN`` unless
        ``allow_negative_days``). Per-gift index-encounter keying is not
        implemented: it would require the raw encounter rows at transform time,
        which is exactly what ``__getstate__`` keeps out of saved bundles.
    allow_negative_days : bool, default=False
        If ``False`` (recommended), ``days_since_last_discharge`` values
        below zero are coerced to ``NaN``, indicating that the gift predates
        the discharge.  Set to ``True`` only for retrospective analyses where
        pre-admission gifts are meaningful.
    id_cols_to_drop : list of str or None, default=None
        Additional column names to explicitly drop on output, beyond those
        detected via the PII heuristic.  Useful when non-standard identifiers
        (e.g., ``"pledge_record_key"``) are present in ``X``.
    pii_patterns : tuple of str or None, default=None
        Case-insensitive substrings used to flag identifier-like column names
        for dropping. If ``None``, the class-level :attr:`PII_PATTERNS` default
        is used. Provide your own tuple to broaden or narrow the heuristic: it
        replaces (does not extend) the default when set.
    as_of : str, datetime-like or None, default=None
        As-of cutoff for the encounter table. Encounters discharged **after**
        this date are excluded from ``encounter_summary_`` at :meth:`fit` time.
        ``None`` (the default) uses the whole table, which is only correct when
        every row of ``encounter_df`` was already observable at the point the
        solicitation decision is being modelled. For walk-forward evaluation,
        set this to the last day of the training window: without it, a gift dated
        2020 is scored against encounters recorded in 2024, and
        ``days_since_last_discharge`` is measured from a discharge that had not
        happened yet.

    Attributes
    ----------
    encounter_summary_ : pd.DataFrame
        Per-donor summary table (indexed by ``merge_key``) with columns
        ``last_discharge`` (Timestamp) and ``encounter_count`` (int), computed
        at :meth:`fit` time.
    dropped_cols_ : list of str
        Names of the columns that were removed from ``X`` during the last
        :meth:`transform` call for audit/logging purposes.
    n_features_in_ : int
        Number of columns seen in ``X`` at :meth:`fit` time.
    feature_names_in_ : ndarray of str
        Column names of ``X`` at :meth:`fit` time.

    Raises
    ------
    ValueError
        If ``merge_key`` is absent from ``encounter_df`` or from ``X``.
    ValueError
        If ``discharge_col`` is absent from ``encounter_df``.

    Examples
    --------
    >>> import pandas as pd
    >>> from philanthropy.preprocessing import EncounterTransformer
    >>> enc = pd.DataFrame({
    ...     "donor_id":       [1, 1, 2],
    ...     "discharge_date": ["2022-01-01", "2023-06-15", "2022-09-30"],
    ... })
    >>> gifts = pd.DataFrame({
    ...     "donor_id":    [1, 2, 3],
    ...     "gift_date":   ["2023-08-01", "2023-01-01", "2023-05-01"],
    ...     "gift_amount": [10000.0, 750.0, 250.0],
    ... })
    >>> t = EncounterTransformer(encounter_df=enc, merge_key="donor_id")
    >>> t.set_output(transform="pandas")  # doctest: +ELLIPSIS
    EncounterTransformer(...)
    >>> out = t.fit_transform(gifts)
    >>> "donor_id" not in out.columns
    True
    >>> "days_since_last_discharge" in out.columns
    True
    >>> "encounter_frequency_score" in out.columns
    True
    """

    # Heuristic substrings used to detect PII-like column names (case-insensitive).
    # Defense-in-depth, NOT a de-identification guarantee: matches column *names*
    # only (never cell values) and can miss identifiers whose names it does not
    # recognise. See docs/explanation/compliance_considerations.md.
    PII_PATTERNS = (
        "_id", "mrn", "ssn", "name", "dob", "birth", "zip",
        "patient", "phone", "email", "address",
    )

    def __init__(
        self,
        encounter_df: pd.DataFrame | None = None,
        encounter_path: str | None = None,
        discharge_col: str = "discharge_date",
        gift_date_col: str = "gift_date",
        merge_key: str = "donor_id",
        allow_negative_days: bool = False,
        id_cols_to_drop: list[str] | None = None,
        pii_patterns: tuple[str, ...] | None = None,
        as_of: Any = None,
    ) -> None:
        self.encounter_df = encounter_df
        self.encounter_path = encounter_path
        self.discharge_col = discharge_col
        self.gift_date_col = gift_date_col
        self.merge_key = merge_key
        self.allow_negative_days = allow_negative_days
        self.id_cols_to_drop = id_cols_to_drop
        self.pii_patterns = pii_patterns
        self.as_of = as_of

    def __getstate__(self) -> dict:
        """Drop the raw encounter table from pickles and joblib bundles.

        ``transform`` reads only ``encounter_summary_``, the per-donor aggregate
        frozen at :meth:`fit` time. ``encounter_df`` is the PHI-bearing *input*,
        so persisting it would make every saved model a patient-data disclosure:
        a bundle handed to a vendor, attached to a ticket, or copied to a laptop
        would carry the raw clinical rows with it. It is therefore replaced with
        ``None`` on serialisation.

        A round-tripped instance can still ``transform``. It cannot ``fit``
        again until it is given the table back, which is the intended
        trade-off. :func:`sklearn.base.clone` is unaffected, because clone goes
        through ``get_params`` rather than pickle.

        The bundle still contains ``encounter_summary_``: per-donor aggregates
        keyed by ``merge_key``. That is the minimum ``transform`` needs, and it
        is derived rather than raw, but it is not nothing. Treat a saved bundle
        as donor data.
        """
        state = dict(super().__getstate__())
        state["encounter_df"] = None
        return state

    # ------------------------------------------------------------------
    # Validation helpers
    # ------------------------------------------------------------------

    def _validate_encounter_df(self, raw_enc: pd.DataFrame) -> None:
        """Raise ``ValueError`` if ``encounter_df`` is structurally invalid."""
        if not isinstance(raw_enc, pd.DataFrame):
            raise TypeError(
                f"`encounter_df` must be a pd.DataFrame, "
                f"got {type(raw_enc).__name__!r}."
            )
        for col, label in [
            (self.merge_key, "merge_key"),
            (self.discharge_col, "discharge_col"),
        ]:
            if col not in raw_enc.columns:
                raise ValueError(
                    f"Column {col!r} (specified as `{label}`) was not found "
                    f"in `encounter_df`. Available columns: "
                    f"{list(raw_enc.columns)}."
                )

    def _validate_X(self, X: pd.DataFrame) -> None:
        """Raise ``ValueError`` if gift DataFrame ``X`` lacks required columns."""
        if not isinstance(X, pd.DataFrame):
            return  # validate_data will handle non-DataFrame inputs
        for col, label in [
            (self.merge_key, "merge_key"),
            (self.gift_date_col, "gift_date_col"),
        ]:
            if col not in X.columns:
                raise ValueError(
                    f"Required column {col!r} (specified as `{label}`) was not found "
                    f"in input X. Please ensure X contains this column or update "
                    f"the `{label}` parameter in EncounterTransformer."
                )

    def _parse_gift_dates(self, X: pd.DataFrame) -> pd.Series:
        """Return parsed gift dates or raise a column-specific error."""
        values = X[self.gift_date_col]
        parsed = pd.to_datetime(values, errors="coerce")
        invalid = values.notna() & parsed.isna()
        if invalid.any():
            raise ValueError(
                f"Column {self.gift_date_col!r} must contain date-like values; "
                f"could not parse {int(invalid.sum())} non-missing value(s)."
            )
        return parsed


    # ------------------------------------------------------------------
    # Column-drop utilities
    # ------------------------------------------------------------------

    def _identify_pii_columns(self, columns: pd.Index) -> List[str]:
        """Return column names that match PII heuristics or explicit drop list."""
        explicit = list(self.id_cols_to_drop or [])
        patterns = (
            self.pii_patterns if self.pii_patterns is not None else self.PII_PATTERNS
        )
        heuristic = [
            c for c in columns
            if any(sub in c.lower() for sub in patterns)
        ]
        # Always include the merge key itself
        merge_key_set = {self.merge_key}
        combined = set(explicit) | set(heuristic) | merge_key_set
        # Only drop columns that actually exist
        return [c for c in columns if c in combined]

    # ------------------------------------------------------------------
    # fit / transform
    # ------------------------------------------------------------------

    def fit(self: _Self, X: pd.DataFrame, y: Any = None) -> _Self:
        """Compute per-donor encounter summaries from ``encounter_df``.

        The fitted artefact ``encounter_summary_`` is a lightweight per-donor
        lookup containing the most-recent discharge date and total encounter
        count.  No information from ``X`` flows into this summary, so the
        summary is identical whether it is fitted on a training split or the
        full frame, and ``transform`` is idempotent.

        .. warning::
           That is the only leakage guarantee here. With the default
           ``as_of=None`` the summary aggregates **every** row of
           ``encounter_df``, so a gift dated 2020 is scored against encounters
           recorded in 2024 if the table contains them. Set ``as_of`` to the end
           of your training window, or restrict ``encounter_df`` yourself before
           calling ``fit``. When ``as_of`` is ``None`` and the table does contain
           discharges later than the latest gift date in ``X``, ``fit`` emits a
           :class:`UserWarning` naming the row count rather than proceeding
           silently.

        Parameters
        ----------
        X : pd.DataFrame
            Gift-level DataFrame.  Used only to infer ``feature_names_in_``
            and ``n_features_in_``; no target statistics are extracted.
        y : ignored
            Present for scikit-learn API compatibility.

        Returns
        -------
        self : EncounterTransformer
            Fitted transformer instance.

        Raises
        ------
        ValueError
            If required columns are missing from ``encounter_df`` or ``X``.
        """
        if self.encounter_path is not None:
            from ..utils._validation import ensure_local_path

            ensure_local_path(self.encounter_path, "encounter_path")
            raw_enc = pd.read_parquet(self.encounter_path)
        elif self.encounter_df is not None:
            raw_enc = self.encounter_df.copy()
        else:
            raise ValueError(
                "EncounterTransformer requires either encounter_df or "
                "encounter_path to be set."
            )

        self._validate_encounter_df(raw_enc)

        self._validate_X(X)
        gift_dates = (
            self._parse_gift_dates(X)
            if isinstance(X, pd.DataFrame) and self.gift_date_col in X.columns
            else None
        )
        if gift_dates is not None:
            X = X.copy()
            X[self.gift_date_col] = gift_dates.astype(object)
        X = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)
        self.n_features_in_ = X.shape[1]

        # --- Build encounter summary (fit-time only, no leakage from X) ---
        enc = raw_enc[[self.merge_key, self.discharge_col]].copy()
        enc[self.discharge_col] = pd.to_datetime(
            enc[self.discharge_col], errors="coerce"
        )

        missing_discharge = enc[self.discharge_col].isna().sum()
        if missing_discharge > 0:
            warnings.warn(
                f"{missing_discharge} encounter row(s) had unparseable "
                f"`discharge_col` values and were excluded from the summary.",
                UserWarning,
            )

        enc = enc.dropna(subset=[self.discharge_col])
        if self.as_of is None:
            _warn_if_unbounded(
                enc, self.discharge_col, gift_dates, "EncounterTransformer"
            )
        enc = _apply_as_of_cutoff(
            enc, self.discharge_col, self.as_of, "EncounterTransformer"
        )

        self.encounter_summary_ = enc.groupby(self.merge_key).agg(
            last_discharge=(self.discharge_col, "max"),
            encounter_count=(self.discharge_col, "count"),
        )

        if self.allow_negative_days:
            warnings.warn(
                "EncounterTransformer(allow_negative_days=True) retains gifts "
                "dated before discharge, which can model solicitation before or "
                "during active treatment. Review "
                "docs/explanation/compliance_considerations.md and your donor-"
                "relations policy before using this in production.",
                UserWarning,
            )

        return self

    def transform(self, X: pd.DataFrame) -> np.ndarray:
        """Append encounter features and strip identifying columns.

        Parameters
        ----------
        X : pd.DataFrame
            Gift-level DataFrame.  Must contain ``merge_key`` and
            ``gift_date_col``.

        Returns
        -------
        X_out : np.ndarray
            Enriched array with two new columns:

            * ``days_since_last_discharge``: ``float64`` days elapsed between
              the donor's latest discharge and the gift date.  ``NaN`` for
              donors absent from the encounter table or (when
              ``allow_negative_days=False``) for gifts dated before discharge.
            * ``encounter_frequency_score``: ``log1p`` of the donor's encounter
              **row** count, not a count and not a distinct count.  ``0.0`` for
              donors with no recorded encounters.

            All identifier-like columns (including ``merge_key``) are removed.

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        ValueError
            If ``merge_key`` or ``gift_date_col`` is absent from ``X``.
        """
        check_is_fitted(self)

        if hasattr(X, "columns"):
            input_cols = list(X.columns)
            self._validate_X(X)
            gift_dates = self._parse_gift_dates(X)
            X = X.copy()
            X[self.gift_date_col] = gift_dates.astype(object)
        else:
            n_cols = np.shape(X)[1] if len(np.shape(X)) > 1 else 1
            input_cols = [f"x{i}" for i in range(n_cols)]

        X = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)
        X_out = pd.DataFrame(X, columns=input_cols)

        self._validate_X(X_out)
        X_out[self.gift_date_col] = self._parse_gift_dates(X_out)

        # --- Merge the encounter summary ---
        X_out = X_out.merge(
            self.encounter_summary_.reset_index(),
            on=self.merge_key,
            how="left",
        )

        # --- days_since_last_discharge ---
        days_delta = (
            X_out[self.gift_date_col] - X_out["last_discharge"]
        ).dt.days.astype("float64")

        if not self.allow_negative_days:
            days_delta = days_delta.where(days_delta >= 0, other=np.nan)

        X_out["days_since_last_discharge"] = days_delta

        # --- encounter_frequency_score: log1p-scaled count ---
        X_out["encounter_frequency_score"] = np.log1p(
            X_out["encounter_count"].fillna(0).astype("float64")
        )

        # --- Drop temporary merge columns ---
        X_out = X_out.drop(columns=["last_discharge", "encounter_count"], errors="ignore")

        # --- Strip identifiers (privacy firewall) ---
        cols_to_drop = self._identify_pii_columns(X_out.columns)
        if cols_to_drop:
            X_out = X_out.drop(columns=cols_to_drop, errors="ignore")

        # --- Also drop the gift_date column (datetime, not modellable directly) ---
        if self.gift_date_col in X_out.columns:
            X_out = X_out.drop(columns=[self.gift_date_col])
            # dropped_cols_ is the operator's audit trail (see
            # docs/explanation/compliance_considerations.md), so it has to name
            # every column that left, not only the PII-heuristic matches.
            cols_to_drop = cols_to_drop + [self.gift_date_col]

        self.dropped_cols_ = cols_to_drop

        # Convert back to numpy array float64 as instructed
        return X_out.to_numpy(dtype=np.float64)

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return privacy-filtered donor and generated encounter feature names.

        Parameters
        ----------
        input_features : array-like of str or None, default=None
            Ignored. Names are derived from the columns recorded by :meth:`fit`.

        Returns
        -------
        feature_names_out : ndarray of str
            Fitted input columns excluding detected PII and ``gift_date_col``,
            followed by ``"days_since_last_discharge"`` and
            ``"encounter_frequency_score"``.

        Raises
        ------
        NotFittedError
            If the transformer has not been fitted.
        """
        check_is_fitted(self)
        features = list(self.feature_names_in_)
        dropped = set(self._identify_pii_columns(self.feature_names_in_))
        if self.gift_date_col in features:
            dropped.add(self.gift_date_col)

        out = [f for f in features if f not in dropped]
        out.extend(["days_since_last_discharge", "encounter_frequency_score"])
        return np.array(out, dtype=object)

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

__getstate__()

Drop the raw encounter table from pickles and joblib bundles.

transform reads only encounter_summary_, the per-donor aggregate frozen at :meth:fit time. encounter_df is the PHI-bearing input, so persisting it would make every saved model a patient-data disclosure: a bundle handed to a vendor, attached to a ticket, or copied to a laptop would carry the raw clinical rows with it. It is therefore replaced with None on serialisation.

A round-tripped instance can still transform. It cannot fit again until it is given the table back, which is the intended trade-off. :func:sklearn.base.clone is unaffected, because clone goes through get_params rather than pickle.

The bundle still contains encounter_summary_: per-donor aggregates keyed by merge_key. That is the minimum transform needs, and it is derived rather than raw, but it is not nothing. Treat a saved bundle as donor data.

Source code in philanthropy/preprocessing/_encounters.py
def __getstate__(self) -> dict:
    """Drop the raw encounter table from pickles and joblib bundles.

    ``transform`` reads only ``encounter_summary_``, the per-donor aggregate
    frozen at :meth:`fit` time. ``encounter_df`` is the PHI-bearing *input*,
    so persisting it would make every saved model a patient-data disclosure:
    a bundle handed to a vendor, attached to a ticket, or copied to a laptop
    would carry the raw clinical rows with it. It is therefore replaced with
    ``None`` on serialisation.

    A round-tripped instance can still ``transform``. It cannot ``fit``
    again until it is given the table back, which is the intended
    trade-off. :func:`sklearn.base.clone` is unaffected, because clone goes
    through ``get_params`` rather than pickle.

    The bundle still contains ``encounter_summary_``: per-donor aggregates
    keyed by ``merge_key``. That is the minimum ``transform`` needs, and it
    is derived rather than raw, but it is not nothing. Treat a saved bundle
    as donor data.
    """
    state = dict(super().__getstate__())
    state["encounter_df"] = None
    return state

fit(X, y=None)

Compute per-donor encounter summaries from encounter_df.

The fitted artefact encounter_summary_ is a lightweight per-donor lookup containing the most-recent discharge date and total encounter count. No information from X flows into this summary, so the summary is identical whether it is fitted on a training split or the full frame, and transform is idempotent.

.. warning:: That is the only leakage guarantee here. With the default as_of=None the summary aggregates every row of encounter_df, so a gift dated 2020 is scored against encounters recorded in 2024 if the table contains them. Set as_of to the end of your training window, or restrict encounter_df yourself before calling fit. When as_of is None and the table does contain discharges later than the latest gift date in X, fit emits a :class:UserWarning naming the row count rather than proceeding silently.

Parameters:

Name Type Description Default
X DataFrame

Gift-level DataFrame. Used only to infer feature_names_in_ and n_features_in_; no target statistics are extracted.

required
y ignored

Present for scikit-learn API compatibility.

None

Returns:

Name Type Description
self EncounterTransformer

Fitted transformer instance.

Raises:

Type Description
ValueError

If required columns are missing from encounter_df or X.

Source code in philanthropy/preprocessing/_encounters.py
def fit(self: _Self, X: pd.DataFrame, y: Any = None) -> _Self:
    """Compute per-donor encounter summaries from ``encounter_df``.

    The fitted artefact ``encounter_summary_`` is a lightweight per-donor
    lookup containing the most-recent discharge date and total encounter
    count.  No information from ``X`` flows into this summary, so the
    summary is identical whether it is fitted on a training split or the
    full frame, and ``transform`` is idempotent.

    .. warning::
       That is the only leakage guarantee here. With the default
       ``as_of=None`` the summary aggregates **every** row of
       ``encounter_df``, so a gift dated 2020 is scored against encounters
       recorded in 2024 if the table contains them. Set ``as_of`` to the end
       of your training window, or restrict ``encounter_df`` yourself before
       calling ``fit``. When ``as_of`` is ``None`` and the table does contain
       discharges later than the latest gift date in ``X``, ``fit`` emits a
       :class:`UserWarning` naming the row count rather than proceeding
       silently.

    Parameters
    ----------
    X : pd.DataFrame
        Gift-level DataFrame.  Used only to infer ``feature_names_in_``
        and ``n_features_in_``; no target statistics are extracted.
    y : ignored
        Present for scikit-learn API compatibility.

    Returns
    -------
    self : EncounterTransformer
        Fitted transformer instance.

    Raises
    ------
    ValueError
        If required columns are missing from ``encounter_df`` or ``X``.
    """
    if self.encounter_path is not None:
        from ..utils._validation import ensure_local_path

        ensure_local_path(self.encounter_path, "encounter_path")
        raw_enc = pd.read_parquet(self.encounter_path)
    elif self.encounter_df is not None:
        raw_enc = self.encounter_df.copy()
    else:
        raise ValueError(
            "EncounterTransformer requires either encounter_df or "
            "encounter_path to be set."
        )

    self._validate_encounter_df(raw_enc)

    self._validate_X(X)
    gift_dates = (
        self._parse_gift_dates(X)
        if isinstance(X, pd.DataFrame) and self.gift_date_col in X.columns
        else None
    )
    if gift_dates is not None:
        X = X.copy()
        X[self.gift_date_col] = gift_dates.astype(object)
    X = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)
    self.n_features_in_ = X.shape[1]

    # --- Build encounter summary (fit-time only, no leakage from X) ---
    enc = raw_enc[[self.merge_key, self.discharge_col]].copy()
    enc[self.discharge_col] = pd.to_datetime(
        enc[self.discharge_col], errors="coerce"
    )

    missing_discharge = enc[self.discharge_col].isna().sum()
    if missing_discharge > 0:
        warnings.warn(
            f"{missing_discharge} encounter row(s) had unparseable "
            f"`discharge_col` values and were excluded from the summary.",
            UserWarning,
        )

    enc = enc.dropna(subset=[self.discharge_col])
    if self.as_of is None:
        _warn_if_unbounded(
            enc, self.discharge_col, gift_dates, "EncounterTransformer"
        )
    enc = _apply_as_of_cutoff(
        enc, self.discharge_col, self.as_of, "EncounterTransformer"
    )

    self.encounter_summary_ = enc.groupby(self.merge_key).agg(
        last_discharge=(self.discharge_col, "max"),
        encounter_count=(self.discharge_col, "count"),
    )

    if self.allow_negative_days:
        warnings.warn(
            "EncounterTransformer(allow_negative_days=True) retains gifts "
            "dated before discharge, which can model solicitation before or "
            "during active treatment. Review "
            "docs/explanation/compliance_considerations.md and your donor-"
            "relations policy before using this in production.",
            UserWarning,
        )

    return self

transform(X)

Append encounter features and strip identifying columns.

Parameters:

Name Type Description Default
X DataFrame

Gift-level DataFrame. Must contain merge_key and gift_date_col.

required

Returns:

Name Type Description
X_out ndarray

Enriched array with two new columns:

  • days_since_last_discharge: float64 days elapsed between the donor's latest discharge and the gift date. NaN for donors absent from the encounter table or (when allow_negative_days=False) for gifts dated before discharge.
  • encounter_frequency_score: log1p of the donor's encounter row count, not a count and not a distinct count. 0.0 for donors with no recorded encounters.

All identifier-like columns (including merge_key) are removed.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

ValueError

If merge_key or gift_date_col is absent from X.

Source code in philanthropy/preprocessing/_encounters.py
def transform(self, X: pd.DataFrame) -> np.ndarray:
    """Append encounter features and strip identifying columns.

    Parameters
    ----------
    X : pd.DataFrame
        Gift-level DataFrame.  Must contain ``merge_key`` and
        ``gift_date_col``.

    Returns
    -------
    X_out : np.ndarray
        Enriched array with two new columns:

        * ``days_since_last_discharge``: ``float64`` days elapsed between
          the donor's latest discharge and the gift date.  ``NaN`` for
          donors absent from the encounter table or (when
          ``allow_negative_days=False``) for gifts dated before discharge.
        * ``encounter_frequency_score``: ``log1p`` of the donor's encounter
          **row** count, not a count and not a distinct count.  ``0.0`` for
          donors with no recorded encounters.

        All identifier-like columns (including ``merge_key``) are removed.

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    ValueError
        If ``merge_key`` or ``gift_date_col`` is absent from ``X``.
    """
    check_is_fitted(self)

    if hasattr(X, "columns"):
        input_cols = list(X.columns)
        self._validate_X(X)
        gift_dates = self._parse_gift_dates(X)
        X = X.copy()
        X[self.gift_date_col] = gift_dates.astype(object)
    else:
        n_cols = np.shape(X)[1] if len(np.shape(X)) > 1 else 1
        input_cols = [f"x{i}" for i in range(n_cols)]

    X = validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)
    X_out = pd.DataFrame(X, columns=input_cols)

    self._validate_X(X_out)
    X_out[self.gift_date_col] = self._parse_gift_dates(X_out)

    # --- Merge the encounter summary ---
    X_out = X_out.merge(
        self.encounter_summary_.reset_index(),
        on=self.merge_key,
        how="left",
    )

    # --- days_since_last_discharge ---
    days_delta = (
        X_out[self.gift_date_col] - X_out["last_discharge"]
    ).dt.days.astype("float64")

    if not self.allow_negative_days:
        days_delta = days_delta.where(days_delta >= 0, other=np.nan)

    X_out["days_since_last_discharge"] = days_delta

    # --- encounter_frequency_score: log1p-scaled count ---
    X_out["encounter_frequency_score"] = np.log1p(
        X_out["encounter_count"].fillna(0).astype("float64")
    )

    # --- Drop temporary merge columns ---
    X_out = X_out.drop(columns=["last_discharge", "encounter_count"], errors="ignore")

    # --- Strip identifiers (privacy firewall) ---
    cols_to_drop = self._identify_pii_columns(X_out.columns)
    if cols_to_drop:
        X_out = X_out.drop(columns=cols_to_drop, errors="ignore")

    # --- Also drop the gift_date column (datetime, not modellable directly) ---
    if self.gift_date_col in X_out.columns:
        X_out = X_out.drop(columns=[self.gift_date_col])
        # dropped_cols_ is the operator's audit trail (see
        # docs/explanation/compliance_considerations.md), so it has to name
        # every column that left, not only the PII-heuristic matches.
        cols_to_drop = cols_to_drop + [self.gift_date_col]

    self.dropped_cols_ = cols_to_drop

    # Convert back to numpy array float64 as instructed
    return X_out.to_numpy(dtype=np.float64)

get_feature_names_out(input_features=None)

Return privacy-filtered donor and generated encounter feature names.

Parameters:

Name Type Description Default
input_features array-like of str or None

Ignored. Names are derived from the columns recorded by :meth:fit.

None

Returns:

Name Type Description
feature_names_out ndarray of str

Fitted input columns excluding detected PII and gift_date_col, followed by "days_since_last_discharge" and "encounter_frequency_score".

Raises:

Type Description
NotFittedError

If the transformer has not been fitted.

Source code in philanthropy/preprocessing/_encounters.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return privacy-filtered donor and generated encounter feature names.

    Parameters
    ----------
    input_features : array-like of str or None, default=None
        Ignored. Names are derived from the columns recorded by :meth:`fit`.

    Returns
    -------
    feature_names_out : ndarray of str
        Fitted input columns excluding detected PII and ``gift_date_col``,
        followed by ``"days_since_last_discharge"`` and
        ``"encounter_frequency_score"``.

    Raises
    ------
    NotFittedError
        If the transformer has not been fitted.
    """
    check_is_fitted(self)
    features = list(self.feature_names_in_)
    dropped = set(self._identify_pii_columns(self.feature_names_in_))
    if self.gift_date_col in features:
        dropped.add(self.gift_date_col)

    out = [f for f in features if f not in dropped]
    out.extend(["days_since_last_discharge", "encounter_frequency_score"])
    return np.array(out, dtype=object)

RFMTransformer

Bases: TransformerMixin, BaseEstimator

Transforms transaction logs into Recency, Frequency, and Monetary (RFM) features.

This is a pre-pipeline aggregation step, not a pipeline member. It takes one row per gift and returns one row per donor, so the sample count changes across transform. Putting it inside a :class:~sklearn.pipeline.Pipeline ahead of an estimator raises ValueError: Found input variables with inconsistent numbers of samples, because y is still gift-shaped. Run it first, then build the pipeline on its donor-level output. It is exempt from the check_estimator battery for the same reason, with hand-written coverage in tests/test_sklearn_compliance.py.

Parameters:

Name Type Description Default
reference_date str or datetime - like

The date used as the reference point to calculate recency. If None, the maximum gift_date in the dataframe is used.

None
agg_func str or callable

The aggregation function to calculate the monetary value. Typical values are 'sum' (cumulative) or 'mean' (average).

'sum'
include_tenure bool

Emit a fifth column, tenure: days from the donor's first gift to the frozen reference date. Recency-frequency-monetary alone cannot feed a buy-till-you-die model, which needs the observation window T as well [Fader, Hardie and Lee 2005]; tenure is that T. Defaults to False so the output shape does not change under existing callers, and will become the default in the next major release.

False
Source code in philanthropy/preprocessing/_rfm.py
class RFMTransformer(TransformerMixin, BaseEstimator):
    """
    Transforms transaction logs into Recency, Frequency, and Monetary (RFM) features.

    This is a **pre-pipeline aggregation step, not a pipeline member.** It takes
    one row per gift and returns one row per donor, so the sample count changes
    across ``transform``. Putting it inside a
    :class:`~sklearn.pipeline.Pipeline` ahead of an estimator raises
    ``ValueError: Found input variables with inconsistent numbers of samples``,
    because ``y`` is still gift-shaped. Run it first, then build the pipeline on
    its donor-level output. It is exempt from the ``check_estimator`` battery
    for the same reason, with hand-written coverage in
    ``tests/test_sklearn_compliance.py``.

    Parameters
    ----------
    reference_date : str or datetime-like, default=None
        The date used as the reference point to calculate recency.
        If None, the maximum gift_date in the dataframe is used.
    agg_func : str or callable, default='sum'
        The aggregation function to calculate the monetary value. 
        Typical values are 'sum' (cumulative) or 'mean' (average).
    include_tenure : bool, default=False
        Emit a fifth column, ``tenure``: days from the donor's *first* gift to
        the frozen reference date. Recency-frequency-monetary alone cannot feed
        a buy-till-you-die model, which needs the observation window T as well
        [Fader, Hardie and Lee 2005]; ``tenure`` is that T. Defaults to False so
        the output shape does not change under existing callers, and will become
        the default in the next major release.
    """
    def __init__(
        self,
        reference_date: Any = None,
        agg_func: Any = 'sum',
        include_tenure: bool = False,
    ) -> None:
        self.reference_date = reference_date
        self.agg_func = agg_func
        self.include_tenure = include_tenure

    def fit(self: _Self, X: Any, y: Any = None) -> _Self:
        """
        Fits the transformer. This simply validates the input and returns self.
        """
        # Manual validation to avoid name/length strictness during fit
        if hasattr(X, "columns"):
            self.feature_names_in_ = np.array(X.columns.tolist(), dtype=object)
            self.n_features_in_ = len(self.feature_names_in_)
        else:
            X_arr = np.asarray(X)
            self.n_features_in_ = X_arr.shape[1]
            self.feature_names_in_ = np.array([f"x{i}" for i in range(self.n_features_in_)], dtype=object)

        self._validate_input(X)

        # Freeze the recency reference date from TRAINING data (leakage-safety
        # contract: fitted statistics are computed in fit and frozen before
        # transform). Mirrors EncounterRecencyTransformer.reference_date_.
        if self.reference_date is not None:
            self.reference_date_ = pd.to_datetime(self.reference_date)
        else:
            X_df = X if hasattr(X, "columns") else pd.DataFrame(
                X, columns=self.feature_names_in_
            )
            self.reference_date_ = pd.to_datetime(X_df["gift_date"]).max()
        return self

    def transform(self, X: Any) -> pd.DataFrame:
        """
        Transforms the transaction logs into RFM features.
        """
        check_is_fitted(self)
        if not hasattr(X, "columns") and not isinstance(X, pd.DataFrame):
             raise TypeError("X must be a pandas DataFrame")
        # Manual validation
        self._validate_input(X)

        X_df = X.copy() if hasattr(X, "columns") else pd.DataFrame(X, columns=self.feature_names_in_)
        X_df['gift_date'] = pd.to_datetime(X_df['gift_date'])

        # Use the reference date frozen in fit, never the transform batch's
        # max, which would make recency depend on which rows share the batch.
        ref_date = self.reference_date_

        grouped = X_df.groupby('donor_id')

        # Recency: Days since the last gift relative to reference_date
        last_gift = grouped['gift_date'].max()
        recency = (ref_date - last_gift).dt.days

        # Frequency: Total number of gifts
        frequency = grouped['gift_date'].count()

        # Monetary: Average or cumulative gift amount depending on agg_func
        monetary = grouped['gift_amount'].agg(self.agg_func)

        rfm_df = pd.DataFrame({
            'donor_id': recency.index,
            'recency': recency.values,
            'frequency': frequency.values,
            'monetary': monetary.values
        })

        if self.include_tenure:
            # T for a buy-till-you-die model: the donor's observation window,
            # measured from the first gift to the same frozen reference date
            # recency uses, so the two are on one clock.
            first_gift = grouped['gift_date'].min()
            rfm_df['tenure'] = (ref_date - first_gift).dt.days.values

        return rfm_df

    def _validate_input(self, X: Any) -> None:
        cols = X.columns if hasattr(X, "columns") else self.feature_names_in_
        required_cols = {"donor_id", "gift_date", "gift_amount"}
        if not required_cols.issubset(cols):
            raise ValueError(f"X must contain columns: {required_cols}")

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return the donor identifier and generated RFM feature names.

        Parameters
        ----------
        input_features : array-like of str or None, default=None
            Ignored because the output columns are fixed by
            ``include_tenure``, not by the input.

        Returns
        -------
        feature_names_out : ndarray of str
            ``["donor_id", "recency", "frequency", "monetary"]``, plus
            ``"tenure"`` when ``include_tenure=True``.

        Raises
        ------
        NotFittedError
            If the transformer has not been fitted.
        """
        check_is_fitted(self)
        names = ['donor_id', 'recency', 'frequency', 'monetary']
        if self.include_tenure:
            names.append('tenure')
        return np.array(names, dtype=object)

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

fit(X, y=None)

Fits the transformer. This simply validates the input and returns self.

Source code in philanthropy/preprocessing/_rfm.py
def fit(self: _Self, X: Any, y: Any = None) -> _Self:
    """
    Fits the transformer. This simply validates the input and returns self.
    """
    # Manual validation to avoid name/length strictness during fit
    if hasattr(X, "columns"):
        self.feature_names_in_ = np.array(X.columns.tolist(), dtype=object)
        self.n_features_in_ = len(self.feature_names_in_)
    else:
        X_arr = np.asarray(X)
        self.n_features_in_ = X_arr.shape[1]
        self.feature_names_in_ = np.array([f"x{i}" for i in range(self.n_features_in_)], dtype=object)

    self._validate_input(X)

    # Freeze the recency reference date from TRAINING data (leakage-safety
    # contract: fitted statistics are computed in fit and frozen before
    # transform). Mirrors EncounterRecencyTransformer.reference_date_.
    if self.reference_date is not None:
        self.reference_date_ = pd.to_datetime(self.reference_date)
    else:
        X_df = X if hasattr(X, "columns") else pd.DataFrame(
            X, columns=self.feature_names_in_
        )
        self.reference_date_ = pd.to_datetime(X_df["gift_date"]).max()
    return self

transform(X)

Transforms the transaction logs into RFM features.

Source code in philanthropy/preprocessing/_rfm.py
def transform(self, X: Any) -> pd.DataFrame:
    """
    Transforms the transaction logs into RFM features.
    """
    check_is_fitted(self)
    if not hasattr(X, "columns") and not isinstance(X, pd.DataFrame):
         raise TypeError("X must be a pandas DataFrame")
    # Manual validation
    self._validate_input(X)

    X_df = X.copy() if hasattr(X, "columns") else pd.DataFrame(X, columns=self.feature_names_in_)
    X_df['gift_date'] = pd.to_datetime(X_df['gift_date'])

    # Use the reference date frozen in fit, never the transform batch's
    # max, which would make recency depend on which rows share the batch.
    ref_date = self.reference_date_

    grouped = X_df.groupby('donor_id')

    # Recency: Days since the last gift relative to reference_date
    last_gift = grouped['gift_date'].max()
    recency = (ref_date - last_gift).dt.days

    # Frequency: Total number of gifts
    frequency = grouped['gift_date'].count()

    # Monetary: Average or cumulative gift amount depending on agg_func
    monetary = grouped['gift_amount'].agg(self.agg_func)

    rfm_df = pd.DataFrame({
        'donor_id': recency.index,
        'recency': recency.values,
        'frequency': frequency.values,
        'monetary': monetary.values
    })

    if self.include_tenure:
        # T for a buy-till-you-die model: the donor's observation window,
        # measured from the first gift to the same frozen reference date
        # recency uses, so the two are on one clock.
        first_gift = grouped['gift_date'].min()
        rfm_df['tenure'] = (ref_date - first_gift).dt.days.values

    return rfm_df

get_feature_names_out(input_features=None)

Return the donor identifier and generated RFM feature names.

Parameters:

Name Type Description Default
input_features array-like of str or None

Ignored because the output columns are fixed by include_tenure, not by the input.

None

Returns:

Name Type Description
feature_names_out ndarray of str

["donor_id", "recency", "frequency", "monetary"], plus "tenure" when include_tenure=True.

Raises:

Type Description
NotFittedError

If the transformer has not been fitted.

Source code in philanthropy/preprocessing/_rfm.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return the donor identifier and generated RFM feature names.

    Parameters
    ----------
    input_features : array-like of str or None, default=None
        Ignored because the output columns are fixed by
        ``include_tenure``, not by the input.

    Returns
    -------
    feature_names_out : ndarray of str
        ``["donor_id", "recency", "frequency", "monetary"]``, plus
        ``"tenure"`` when ``include_tenure=True``.

    Raises
    ------
    NotFittedError
        If the transformer has not been fitted.
    """
    check_is_fitted(self)
    names = ['donor_id', 'recency', 'frequency', 'monetary']
    if self.include_tenure:
        names.append('tenure')
    return np.array(names, dtype=object)

PlannedGivingSignalTransformer

Bases: TransformerMixin, BaseEstimator

Extract features for bequest / planned-giving intent classification.

Planned giving (bequests, charitable remainder trusts) requires a separate predictive model from major gifts. Key drivers are donor age ≥ 65, giving tenure ≥ 10 years, and a wealth-screening vendor "charitable inclination" score. This transformer extracts a four-column feature vector optimised for bequest/legacy gift intent classifiers.

Parameters:

Name Type Description Default
age_col str

Column containing donor age in years.

"donor_age"
tenure_col str

Column containing number of years the donor has been active.

"years_active"
planned_gift_inclination_col str

Column containing the wealth-screening vendor's charitable inclination score, expected to be in [0, 1]. Missing values are treated as a sentinel value (-1.0) to distinguish "vendor data absent" from a genuine 0 score.

"planned_gift_inclination"
age_threshold int

Minimum age (inclusive) for the is_legacy_age flag.

65
tenure_threshold_years int

Minimum years active (inclusive) for the is_loyal_donor flag.

10

Attributes:

Name Type Description
n_features_in_ int

Number of input features seen at fit time.

feature_names_in_ ndarray of str

Column names of X at fit time (set when X is a DataFrame).

Notes

Output columns ~~~~~~~~~~~~~~ ========================= ================================================ Col Name Description ========================= ================================================ 0 is_legacy_age uint8: 1 if age >= age_threshold, else 0. NaN age → 0. 1 is_loyal_donor uint8: 1 if tenure >= tenure_threshold_years. NaN tenure → 0. 2 inclination_score float64: raw planned_gift_inclination value, clipped to [0, 1]. Missing → -1.0 sentinel (distinguishable from a genuine 0 score). 3 composite_score float64: is_legacy_age + is_loyal_donor + max(inclination_score, 0). Range [0.0, 3.0]. ========================= ================================================

Examples:

>>> import pandas as pd
>>> import numpy as np
>>> from philanthropy.preprocessing import PlannedGivingSignalTransformer
>>> X = pd.DataFrame({
...     "donor_age": [70, 60, None],
...     "years_active": [15, 5, 12],
...     "planned_gift_inclination": [0.8, 0.3, None],
... })
>>> t = PlannedGivingSignalTransformer()
>>> out = t.fit_transform(X)
>>> out.shape
(3, 4)
Source code in philanthropy/preprocessing/_planned_giving.py
class PlannedGivingSignalTransformer(TransformerMixin, BaseEstimator):
    """Extract features for bequest / planned-giving intent classification.

    Planned giving (bequests, charitable remainder trusts) requires a separate
    predictive model from major gifts. Key drivers are donor age ≥ 65, giving
    tenure ≥ 10 years, and a wealth-screening vendor "charitable inclination"
    score. This transformer extracts a four-column feature vector optimised for
    bequest/legacy gift intent classifiers.

    Parameters
    ----------
    age_col : str, default="donor_age"
        Column containing donor age in years.
    tenure_col : str, default="years_active"
        Column containing number of years the donor has been active.
    planned_gift_inclination_col : str, default="planned_gift_inclination"
        Column containing the wealth-screening vendor's charitable inclination
        score, expected to be in [0, 1]. Missing values are treated as a
        sentinel value (-1.0) to distinguish "vendor data absent" from a
        genuine 0 score.
    age_threshold : int, default=65
        Minimum age (inclusive) for the is_legacy_age flag.
    tenure_threshold_years : int, default=10
        Minimum years active (inclusive) for the is_loyal_donor flag.

    Attributes
    ----------
    n_features_in_ : int
        Number of input features seen at fit time.
    feature_names_in_ : ndarray of str
        Column names of X at fit time (set when X is a DataFrame).

    Notes
    -----
    Output columns
    ~~~~~~~~~~~~~~
    ========================= ================================================
    Col  Name                  Description
    ========================= ================================================
    0    ``is_legacy_age``     uint8: 1 if age >= age_threshold, else 0.
                               NaN age → 0.
    1    ``is_loyal_donor``    uint8: 1 if tenure >= tenure_threshold_years.
                               NaN tenure → 0.
    2    ``inclination_score`` float64: raw planned_gift_inclination value,
                               clipped to [0, 1]. Missing → -1.0 sentinel
                               (distinguishable from a genuine 0 score).
    3    ``composite_score``   float64: is_legacy_age + is_loyal_donor
                               + max(inclination_score, 0). Range [0.0, 3.0].
    ========================= ================================================

    Examples
    --------
    >>> import pandas as pd
    >>> import numpy as np
    >>> from philanthropy.preprocessing import PlannedGivingSignalTransformer
    >>> X = pd.DataFrame({
    ...     "donor_age": [70, 60, None],
    ...     "years_active": [15, 5, 12],
    ...     "planned_gift_inclination": [0.8, 0.3, None],
    ... })
    >>> t = PlannedGivingSignalTransformer()
    >>> out = t.fit_transform(X)
    >>> out.shape
    (3, 4)
    """

    def __init__(
        self,
        age_col: str = "donor_age",
        tenure_col: str = "years_active",
        planned_gift_inclination_col: str = "planned_gift_inclination",
        age_threshold: int = 65,
        tenure_threshold_years: int = 10,
    ) -> None:
        self.age_col = age_col
        self.tenure_col = tenure_col
        self.planned_gift_inclination_col = planned_gift_inclination_col
        self.age_threshold = age_threshold
        self.tenure_threshold_years = tenure_threshold_years

    def fit(self: _Self, X: Any, y: Any = None) -> _Self:
        """Validate input schema and record n_features_in_.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Donor-level feature matrix.
        y : ignored

        Returns
        -------
        self : PlannedGivingSignalTransformer
        """
        validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)
        return self

    def transform(self, X: Any, y: Any = None) -> np.ndarray:
        """Compute the 4-column planned-giving feature vector.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Donor-level feature matrix. Accepts pd.DataFrame (columns may or
            may not exist; missing columns are handled gracefully with NaN / 0).

        Returns
        -------
        X_out : np.ndarray of shape (n_samples, 4), dtype float64
            Columns: [is_legacy_age, is_loyal_donor, inclination_score,
            composite_score].

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

        # Work with a DataFrame for convenient column access
        if isinstance(X, pd.DataFrame):
            df = X
        elif hasattr(self, "feature_names_in_"):
            df = pd.DataFrame(
                np.asarray(X, dtype=float), columns=self.feature_names_in_
            )
        else:
            df = pd.DataFrame(np.asarray(X, dtype=float))

        n = len(df)

        # --- col 0: is_legacy_age ---
        if self.age_col in df.columns:
            age = pd.to_numeric(df[self.age_col], errors="coerce")
            is_legacy_age = np.where(age.isna(), 0, (age >= self.age_threshold).astype(int))
        else:
            is_legacy_age = np.zeros(n, dtype=int)

        # --- col 1: is_loyal_donor ---
        if self.tenure_col in df.columns:
            tenure = pd.to_numeric(df[self.tenure_col], errors="coerce")
            is_loyal_donor = np.where(
                tenure.isna(), 0, (tenure >= self.tenure_threshold_years).astype(int)
            )
        else:
            is_loyal_donor = np.zeros(n, dtype=int)

        # --- col 2: inclination_score ---
        if self.planned_gift_inclination_col in df.columns:
            raw_incl = pd.to_numeric(
                df[self.planned_gift_inclination_col], errors="coerce"
            )
            inclination_score = np.where(
                raw_incl.isna(),
                -1.0,  # sentinel: vendor data absent
                np.clip(raw_incl.to_numpy(dtype=float), 0.0, 1.0),
            )
        else:
            inclination_score = np.full(n, -1.0, dtype=float)  # vendor data absent

        # --- col 3: composite_score ---
        # is_legacy_age + is_loyal_donor + max(inclination_score, 0)
        incl_clipped = np.maximum(inclination_score, 0.0)
        composite_score = is_legacy_age.astype(float) + is_loyal_donor.astype(float) + incl_clipped

        return np.column_stack(
            [
                is_legacy_age.astype(np.float64),
                is_loyal_donor.astype(np.float64),
                inclination_score.astype(np.float64),
                composite_score.astype(np.float64),
            ]
        )

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return the generated planned-giving signal names.

        Parameters
        ----------
        input_features : array-like of str or None, default=None
            Ignored because the transformer always emits the same four features.

        Returns
        -------
        feature_names_out : ndarray of str
            ``["is_legacy_age", "is_loyal_donor", "inclination_score",
            "composite_score"]``.

        Raises
        ------
        NotFittedError
            If the transformer has not been fitted.
        """
        check_is_fitted(self)
        return np.array(
            ["is_legacy_age", "is_loyal_donor", "inclination_score", "composite_score"],
            dtype=object,
        )

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.allow_nan = True
        # This transformer extracts named columns from mixed-type DataFrames
        # and handles non-numeric input gracefully. Setting string=True suppresses
        # check_dtype_object's strict TypeError requirement.
        tags.input_tags.string = True
        return tags

fit(X, y=None)

Validate input schema and record n_features_in_.

Parameters:

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

Donor-level feature matrix.

required
y ignored
None

Returns:

Name Type Description
self PlannedGivingSignalTransformer
Source code in philanthropy/preprocessing/_planned_giving.py
def fit(self: _Self, X: Any, y: Any = None) -> _Self:
    """Validate input schema and record n_features_in_.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Donor-level feature matrix.
    y : ignored

    Returns
    -------
    self : PlannedGivingSignalTransformer
    """
    validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)
    return self

transform(X, y=None)

Compute the 4-column planned-giving feature vector.

Parameters:

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

Donor-level feature matrix. Accepts pd.DataFrame (columns may or may not exist; missing columns are handled gracefully with NaN / 0).

required

Returns:

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

Columns: [is_legacy_age, is_loyal_donor, inclination_score, composite_score].

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/preprocessing/_planned_giving.py
def transform(self, X: Any, y: Any = None) -> np.ndarray:
    """Compute the 4-column planned-giving feature vector.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Donor-level feature matrix. Accepts pd.DataFrame (columns may or
        may not exist; missing columns are handled gracefully with NaN / 0).

    Returns
    -------
    X_out : np.ndarray of shape (n_samples, 4), dtype float64
        Columns: [is_legacy_age, is_loyal_donor, inclination_score,
        composite_score].

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

    # Work with a DataFrame for convenient column access
    if isinstance(X, pd.DataFrame):
        df = X
    elif hasattr(self, "feature_names_in_"):
        df = pd.DataFrame(
            np.asarray(X, dtype=float), columns=self.feature_names_in_
        )
    else:
        df = pd.DataFrame(np.asarray(X, dtype=float))

    n = len(df)

    # --- col 0: is_legacy_age ---
    if self.age_col in df.columns:
        age = pd.to_numeric(df[self.age_col], errors="coerce")
        is_legacy_age = np.where(age.isna(), 0, (age >= self.age_threshold).astype(int))
    else:
        is_legacy_age = np.zeros(n, dtype=int)

    # --- col 1: is_loyal_donor ---
    if self.tenure_col in df.columns:
        tenure = pd.to_numeric(df[self.tenure_col], errors="coerce")
        is_loyal_donor = np.where(
            tenure.isna(), 0, (tenure >= self.tenure_threshold_years).astype(int)
        )
    else:
        is_loyal_donor = np.zeros(n, dtype=int)

    # --- col 2: inclination_score ---
    if self.planned_gift_inclination_col in df.columns:
        raw_incl = pd.to_numeric(
            df[self.planned_gift_inclination_col], errors="coerce"
        )
        inclination_score = np.where(
            raw_incl.isna(),
            -1.0,  # sentinel: vendor data absent
            np.clip(raw_incl.to_numpy(dtype=float), 0.0, 1.0),
        )
    else:
        inclination_score = np.full(n, -1.0, dtype=float)  # vendor data absent

    # --- col 3: composite_score ---
    # is_legacy_age + is_loyal_donor + max(inclination_score, 0)
    incl_clipped = np.maximum(inclination_score, 0.0)
    composite_score = is_legacy_age.astype(float) + is_loyal_donor.astype(float) + incl_clipped

    return np.column_stack(
        [
            is_legacy_age.astype(np.float64),
            is_loyal_donor.astype(np.float64),
            inclination_score.astype(np.float64),
            composite_score.astype(np.float64),
        ]
    )

get_feature_names_out(input_features=None)

Return the generated planned-giving signal names.

Parameters:

Name Type Description Default
input_features array-like of str or None

Ignored because the transformer always emits the same four features.

None

Returns:

Name Type Description
feature_names_out ndarray of str

["is_legacy_age", "is_loyal_donor", "inclination_score", "composite_score"].

Raises:

Type Description
NotFittedError

If the transformer has not been fitted.

Source code in philanthropy/preprocessing/_planned_giving.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return the generated planned-giving signal names.

    Parameters
    ----------
    input_features : array-like of str or None, default=None
        Ignored because the transformer always emits the same four features.

    Returns
    -------
    feature_names_out : ndarray of str
        ``["is_legacy_age", "is_loyal_donor", "inclination_score",
        "composite_score"]``.

    Raises
    ------
    NotFittedError
        If the transformer has not been fitted.
    """
    check_is_fitted(self)
    return np.array(
        ["is_legacy_age", "is_loyal_donor", "inclination_score", "composite_score"],
        dtype=object,
    )

GratefulPatientFeaturizer

Bases: TransformerMixin, BaseEstimator

Featurize clinical signals from grateful-patient encounter data.

This transformer bridges EHR service-line and treating-physician data with the advancement CRM to produce clinical-depth features for major gift propensity models.

Parameters:

Name Type Description Default
encounter_df DataFrame | None

Reference table of clinical encounters. Must contain merge_key and discharge_col columns. Stored verbatim for get_params compatibility; snapshotted via .copy() at fit time to prevent mutation leakage.

None
encounter_path str | None

Path to a Parquet or CSV file containing clinical encounters. Alternative to encounter_df. If both are provided, encounter_path takes precedence.

None
service_line_col str

Column in the encounter table holding service line / department name.

"service_line"
physician_col str

Column in the encounter table holding the attending physician ID.

"attending_physician_id"
drg_weight_col str | None

Optional column holding DRG (Diagnosis Related Group) relative weights. If present, total DRG weight per donor is computed. Off by default, and it should stay off unless your governance process has cleared it: a DRG weight is derived from the discharge diagnosis, and diagnosis is not in the element list the HIPAA fundraising carve-out permits (45 CFR 164.514(f) covers demographics, dates and department of service, treating physician, outcome, and insurance status). Setting it emits a :class:UserWarning. See docs/explanation/compliance_considerations.md.

None
use_capacity_weights bool

If True, apply service-line capacity weights to scale the clinical gravity score. Weights come from capacity_weights (or the illustrative defaults in :data:_SERVICE_LINE_CAPACITY_WEIGHTS when unset).

Defaults to False. The built-in multipliers have no published source, and defaulting them on meant the headline clinical_gravity_score silently carried unsourced 2.7x to 3.2x weighting on cardiac, oncology and neuroscience encounters. Turn it on deliberately, and pass capacity_weights your institution has reviewed.

False
capacity_weights dict of {str: float} or None

Per-service-line multipliers applied when use_capacity_weights=True. Keys are normalised service-line names (lowercased, non-alpha collapsed to _); unknown lines fall back to 1.0. If None, illustrative AMC defaults are used; override with your foundation's board-approved values.

None
merge_key str

Column name present in both the encounter table and X used to merge.

"donor_id"
as_of (str, datetime - like or None)

As-of cutoff for the encounter table. Encounters discharged after this date are excluded from encounter_summary_ at :meth:fit time. None (the default) uses the whole table, which is only correct when every encounter was already observable at the point being modelled. For walk-forward evaluation, set this to the last day of the training window; otherwise the clinical-gravity score for a 2020 gift counts encounters from 2024.

None
discharge_col str

Column in the encounter table holding discharge dates.

"discharge_date"

Attributes:

Name Type Description
encounter_summary_ DataFrame

Per-donor aggregated encounter features, indexed by merge_key. Set at fit time.

n_features_in_ int

Number of features seen at fit time (set by _validate_data).

feature_names_in_ ndarray of str

Column names of X at fit time (set by _validate_data when X is a DataFrame).

Raises:

Type Description
ValueError

If neither encounter_df nor encounter_path is provided.

Notes

The four output columns are:

========================= ================================================ Column Description ========================= ================================================ clinical_gravity_score Encounter count × service-line capacity weight. distinct_service_lines Number of unique service lines. distinct_physicians Number of unique attending physicians. total_drg_weight Sum of DRG relative weights (NaN if unavailable). ========================= ================================================

Donors absent from the encounter table receive zeros for all columns.

Examples:

>>> import pandas as pd
>>> import numpy as np
>>> from philanthropy.preprocessing import GratefulPatientFeaturizer
>>> enc = pd.DataFrame({
...     "donor_id": [1, 1, 2],
...     "discharge_date": ["2022-01-01", "2023-06-15", "2022-09-30"],
...     "service_line": ["cardiac", "cardiac", "oncology"],
...     "attending_physician_id": ["P1", "P2", "P3"],
... })
>>> X = pd.DataFrame({"donor_id": [1, 2, 3]})
>>> gpf = GratefulPatientFeaturizer(encounter_df=enc)
>>> gpf.fit(X)
GratefulPatientFeaturizer(...)
>>> out = gpf.transform(X)
>>> out.shape
(3, 4)
Source code in philanthropy/preprocessing/_grateful_patient.py
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
class GratefulPatientFeaturizer(TransformerMixin, BaseEstimator):
    """Featurize clinical signals from grateful-patient encounter data.

    This transformer bridges EHR service-line and treating-physician data with
    the advancement CRM to produce clinical-depth features for major gift
    propensity models.

    Parameters
    ----------
    encounter_df : pd.DataFrame | None, default=None
        Reference table of clinical encounters. Must contain ``merge_key``
        and ``discharge_col`` columns. Stored verbatim for ``get_params``
        compatibility; snapshotted via ``.copy()`` at fit time to prevent
        mutation leakage.
    encounter_path : str | None, default=None
        Path to a Parquet or CSV file containing clinical encounters.
        Alternative to ``encounter_df``. If both are provided,
        ``encounter_path`` takes precedence.
    service_line_col : str, default="service_line"
        Column in the encounter table holding service line / department name.
    physician_col : str, default="attending_physician_id"
        Column in the encounter table holding the attending physician ID.
    drg_weight_col : str | None, default=None
        Optional column holding DRG (Diagnosis Related Group) relative weights.
        If present, total DRG weight per donor is computed. Off by default, and
        it should stay off unless your governance process has cleared it: a DRG
        weight is derived from the discharge diagnosis, and diagnosis is not in
        the element list the HIPAA fundraising carve-out permits (45 CFR
        164.514(f) covers demographics, dates and department of service,
        treating physician, outcome, and insurance status). Setting it emits a
        :class:`UserWarning`. See
        ``docs/explanation/compliance_considerations.md``.
    use_capacity_weights : bool, default=False
        If True, apply service-line capacity weights to scale the clinical
        gravity score. Weights come from ``capacity_weights`` (or the illustrative
        defaults in :data:`_SERVICE_LINE_CAPACITY_WEIGHTS` when unset).

        Defaults to False. The built-in multipliers have no published source,
        and defaulting them on meant the headline ``clinical_gravity_score``
        silently carried unsourced 2.7x to 3.2x weighting on cardiac, oncology
        and neuroscience encounters. Turn it on deliberately, and pass
        ``capacity_weights`` your institution has reviewed.
    capacity_weights : dict of {str: float} or None, default=None
        Per-service-line multipliers applied when ``use_capacity_weights=True``.
        Keys are normalised service-line names (lowercased, non-alpha collapsed to
        ``_``); unknown lines fall back to ``1.0``. If ``None``, illustrative AMC
        defaults are used; override with your foundation's board-approved values.
    merge_key : str, default="donor_id"
        Column name present in both the encounter table and ``X`` used to merge.
    as_of : str, datetime-like or None, default=None
        As-of cutoff for the encounter table. Encounters discharged **after**
        this date are excluded from ``encounter_summary_`` at :meth:`fit` time.
        ``None`` (the default) uses the whole table, which is only correct when
        every encounter was already observable at the point being modelled. For
        walk-forward evaluation, set this to the last day of the training window;
        otherwise the clinical-gravity score for a 2020 gift counts encounters
        from 2024.
    discharge_col : str, default="discharge_date"
        Column in the encounter table holding discharge dates.

    Attributes
    ----------
    encounter_summary_ : pd.DataFrame
        Per-donor aggregated encounter features, indexed by ``merge_key``.
        Set at fit time.
    n_features_in_ : int
        Number of features seen at fit time (set by ``_validate_data``).
    feature_names_in_ : ndarray of str
        Column names of ``X`` at fit time (set by ``_validate_data`` when X
        is a DataFrame).

    Raises
    ------
    ValueError
        If neither ``encounter_df`` nor ``encounter_path`` is provided.

    Notes
    -----
    The four output columns are:

    ========================= ================================================
    Column                    Description
    ========================= ================================================
    ``clinical_gravity_score`` Encounter count × service-line capacity weight.
    ``distinct_service_lines`` Number of unique service lines.
    ``distinct_physicians``    Number of unique attending physicians.
    ``total_drg_weight``       Sum of DRG relative weights (NaN if unavailable).
    ========================= ================================================

    Donors absent from the encounter table receive zeros for all columns.

    Examples
    --------
    >>> import pandas as pd
    >>> import numpy as np
    >>> from philanthropy.preprocessing import GratefulPatientFeaturizer
    >>> enc = pd.DataFrame({
    ...     "donor_id": [1, 1, 2],
    ...     "discharge_date": ["2022-01-01", "2023-06-15", "2022-09-30"],
    ...     "service_line": ["cardiac", "cardiac", "oncology"],
    ...     "attending_physician_id": ["P1", "P2", "P3"],
    ... })
    >>> X = pd.DataFrame({"donor_id": [1, 2, 3]})
    >>> gpf = GratefulPatientFeaturizer(encounter_df=enc)
    >>> gpf.fit(X)
    GratefulPatientFeaturizer(...)
    >>> out = gpf.transform(X)
    >>> out.shape
    (3, 4)
    """

    def __init__(
        self,
        encounter_df: pd.DataFrame | None = None,
        encounter_path: str | None = None,
        service_line_col: str = "service_line",
        physician_col: str = "attending_physician_id",
        drg_weight_col: str | None = None,
        use_capacity_weights: bool = False,
        capacity_weights: dict[str, float] | None = None,
        merge_key: str = "donor_id",
        discharge_col: str = "discharge_date",
        as_of: Any = None,
    ) -> None:
        self.encounter_df = encounter_df
        self.encounter_path = encounter_path
        self.service_line_col = service_line_col
        self.physician_col = physician_col
        self.drg_weight_col = drg_weight_col
        self.use_capacity_weights = use_capacity_weights
        self.capacity_weights = capacity_weights
        self.merge_key = merge_key
        self.discharge_col = discharge_col
        self.as_of = as_of

    def __getstate__(self) -> dict:
        """Drop the raw encounter table from pickles and joblib bundles.

        ``transform`` reads only ``encounter_summary_``, the per-donor aggregate
        frozen at :meth:`fit` time. ``encounter_df`` is the PHI-bearing *input*,
        so persisting it would make every saved model a patient-data disclosure:
        a bundle handed to a vendor, attached to a ticket, or copied to a laptop
        would carry the raw clinical rows with it. It is therefore replaced with
        ``None`` on serialisation.

        A round-tripped instance can still ``transform``. It cannot ``fit``
        again until it is given the table back, which is the intended
        trade-off. :func:`sklearn.base.clone` is unaffected, because clone goes
        through ``get_params`` rather than pickle.

        The bundle still contains ``encounter_summary_``: per-donor aggregates
        keyed by ``merge_key``. That is the minimum ``transform`` needs, and it
        is derived rather than raw, but it is not nothing. Treat a saved bundle
        as donor data.
        """
        state = dict(super().__getstate__())
        state["encounter_df"] = None
        return state

    def fit(self: _Self, X: Any, y: Any = None) -> _Self:
        """Build per-donor encounter summaries from encounter data.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Donor-level feature matrix. Used only for schema registration via
            ``_validate_data``; no target leakage occurs here.
        y : ignored

        Returns
        -------
        self : GratefulPatientFeaturizer

        Raises
        ------
        ValueError
            If neither ``encounter_df`` nor ``encounter_path`` is set.
        """
        # Step 1: Load encounter data (snapshot it; never store raw_enc)
        if self.encounter_path is not None:
            from ..utils._validation import ensure_local_path

            ensure_local_path(self.encounter_path, "encounter_path")
            raw_enc = pd.read_parquet(self.encounter_path)
        elif self.encounter_df is not None:
            raw_enc = self.encounter_df.copy()  # critical: snapshot here
        else:
            raise ValueError(
                "GratefulPatientFeaturizer requires either encounter_df or "
                "encounter_path to be set."
            )

        # Step 2: Coerce discharge dates
        raw_enc = raw_enc.copy()
        raw_enc[self.discharge_col] = pd.to_datetime(
            raw_enc[self.discharge_col], errors="coerce"
        )

        raw_enc = _apply_as_of_cutoff(
            raw_enc, self.discharge_col, self.as_of, "GratefulPatientFeaturizer"
        )

        # Step 3: Normalise service_line values
        if self.service_line_col in raw_enc.columns:
            raw_enc[self.service_line_col] = (
                raw_enc[self.service_line_col]
                .astype(str)
                .apply(_normalise_service_line)
            )

        # Step 4: Groupby merge_key
        grouped = raw_enc.groupby(self.merge_key)

        summary_parts: dict[str, pd.Series] = {}

        if self.service_line_col in raw_enc.columns:
            # Mode (most frequent) service line per donor
            summary_parts["primary_service_line"] = grouped[
                self.service_line_col
            ].agg(lambda x: x.mode().iloc[0] if len(x) > 0 else "general")
            summary_parts["distinct_service_lines"] = grouped[
                self.service_line_col
            ].nunique()
        else:
            summary_parts["primary_service_line"] = pd.Series(
                "general", index=grouped.groups.keys()
            )
            summary_parts["distinct_service_lines"] = pd.Series(
                1, dtype=int, index=grouped.groups.keys()
            )

        if self.physician_col in raw_enc.columns:
            summary_parts["distinct_physicians"] = grouped[
                self.physician_col
            ].nunique()
        else:
            summary_parts["distinct_physicians"] = pd.Series(
                0, dtype=int, index=grouped.groups.keys()
            )

        summary_parts["total_encounters"] = grouped[self.discharge_col].count()
        summary_parts["last_discharge"] = grouped[self.discharge_col].max()

        # Step 5: DRG weight column
        if self.drg_weight_col is not None:
            warnings.warn(
                f"drg_weight_col={self.drg_weight_col!r} aggregates a "
                "diagnosis-derived field. Diagnosis is outside the element "
                "list permitted for fundraising by 45 CFR 164.514(f); confirm "
                "your governance approval before using it. See "
                "docs/explanation/compliance_considerations.md.",
                UserWarning,
                stacklevel=2,
            )
        if (
            self.drg_weight_col is not None
            and self.drg_weight_col in raw_enc.columns
        ):
            summary_parts["total_drg_weight"] = grouped[
                self.drg_weight_col
            ].sum()
        else:
            # Sentinel: will become NaN for all donors
            keys = list(grouped.groups.keys())
            summary_parts["total_drg_weight"] = pd.Series(
                np.nan, index=keys, dtype=float
            )

        encounter_summary = pd.DataFrame(summary_parts)

        # Step 6: Clinical gravity score
        if self.capacity_weights is not None and not self.use_capacity_weights:
            warnings.warn(
                "capacity_weights was provided but use_capacity_weights=False "
                "(the default since service-line multipliers have no published "
                "source), so the weights are ignored. Pass "
                "use_capacity_weights=True to apply them.",
                UserWarning,
                stacklevel=2,
            )
        if self.use_capacity_weights:
            weights = (
                self.capacity_weights
                if self.capacity_weights is not None
                else _SERVICE_LINE_CAPACITY_WEIGHTS
            )
            encounter_summary["clinical_gravity_score"] = (
                encounter_summary["total_encounters"].astype(float)
                * encounter_summary["primary_service_line"].map(
                    lambda s: weights.get(s, 1.0)
                )
            )
        else:
            encounter_summary["clinical_gravity_score"] = (
                encounter_summary["total_encounters"].astype(float)
            )

        # Step 7: Store fitted attribute
        self.encounter_summary_ = encounter_summary

        # Step 8: Register feature schema
        validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)

        return self

    def transform(self, X: Any, y: Any = None) -> np.ndarray:
        """Merge clinical features into the donor feature matrix.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Donor-level feature matrix. Must contain ``merge_key`` column
            (or that column as the first column for ndarray input).

        Returns
        -------
        X_out : np.ndarray of shape (n_samples, 4), dtype float64
            Columns in order:
            ``clinical_gravity_score``, ``distinct_service_lines``,
            ``distinct_physicians``, ``total_drg_weight``.
            Donors absent from encounter table get 0.0 for all columns.
        """
        validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)
        check_is_fitted(self)

        _FEATURE_COLS = [
            "clinical_gravity_score",
            "distinct_service_lines",
            "distinct_physicians",
            "total_drg_weight",
        ]

        # Build a DataFrame to merge on merge_key
        if isinstance(X, pd.DataFrame) and self.merge_key in X.columns:
            X_df = X[[self.merge_key]].copy()
        elif isinstance(X, pd.DataFrame) and hasattr(self, "feature_names_in_"):
            # merge_key must be in the feature names
            if self.merge_key in self.feature_names_in_:
                X_df = X[[self.merge_key]].copy()
            else:
                # No merge key available: return zeros
                warnings.warn(
                    f"merge_key {self.merge_key!r} is not in X (columns: "
                    f"{list(X.columns)}); every clinical feature is 0.0. Route "
                    f"this featurizer with a ColumnTransformer that keeps "
                    f"{self.merge_key!r}, or set merge_key to a column that exists.",
                    UserWarning,
                    stacklevel=2,
                )
                n = len(X)
                return np.zeros((n, 4), dtype=np.float64)
        elif hasattr(self, "feature_names_in_") and self.merge_key in list(
            self.feature_names_in_
        ):
            arr = np.asarray(X)
            col_idx = list(self.feature_names_in_).index(self.merge_key)
            X_df = pd.DataFrame(
                {self.merge_key: arr[:, col_idx]}
            )
        else:
            # No merge key: cannot join, return zeros
            warnings.warn(
                f"merge_key {self.merge_key!r} could not be located in X; every "
                f"clinical feature is 0.0. Pass a DataFrame carrying "
                f"{self.merge_key!r}, or fit on one so feature_names_in_ records it.",
                UserWarning,
                stacklevel=2,
            )
            n = np.asarray(X).shape[0]
            return np.zeros((n, 4), dtype=np.float64)

        # Left-merge with encounter_summary_
        merged = X_df.merge(
            self.encounter_summary_[_FEATURE_COLS],
            left_on=self.merge_key,
            right_index=True,
            how="left",
        )

        # Step 5: fillna(0.0) so unknown donors get zeros
        result = merged[_FEATURE_COLS].fillna(0.0)

        return result.to_numpy(dtype=np.float64)

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return the generated grateful-patient feature names.

        Parameters
        ----------
        input_features : array-like of str or None, default=None
            Ignored because the featurizer always emits the same four features.

        Returns
        -------
        feature_names_out : ndarray of str
            ``["clinical_gravity_score", "distinct_service_lines",
            "distinct_physicians", "total_drg_weight"]``.

        Raises
        ------
        NotFittedError
            If the featurizer has not been fitted.
        """
        check_is_fitted(self)
        return np.array(
            [
                "clinical_gravity_score",
                "distinct_service_lines",
                "distinct_physicians",
                "total_drg_weight",
            ],
            dtype=object,
        )

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

__getstate__()

Drop the raw encounter table from pickles and joblib bundles.

transform reads only encounter_summary_, the per-donor aggregate frozen at :meth:fit time. encounter_df is the PHI-bearing input, so persisting it would make every saved model a patient-data disclosure: a bundle handed to a vendor, attached to a ticket, or copied to a laptop would carry the raw clinical rows with it. It is therefore replaced with None on serialisation.

A round-tripped instance can still transform. It cannot fit again until it is given the table back, which is the intended trade-off. :func:sklearn.base.clone is unaffected, because clone goes through get_params rather than pickle.

The bundle still contains encounter_summary_: per-donor aggregates keyed by merge_key. That is the minimum transform needs, and it is derived rather than raw, but it is not nothing. Treat a saved bundle as donor data.

Source code in philanthropy/preprocessing/_grateful_patient.py
def __getstate__(self) -> dict:
    """Drop the raw encounter table from pickles and joblib bundles.

    ``transform`` reads only ``encounter_summary_``, the per-donor aggregate
    frozen at :meth:`fit` time. ``encounter_df`` is the PHI-bearing *input*,
    so persisting it would make every saved model a patient-data disclosure:
    a bundle handed to a vendor, attached to a ticket, or copied to a laptop
    would carry the raw clinical rows with it. It is therefore replaced with
    ``None`` on serialisation.

    A round-tripped instance can still ``transform``. It cannot ``fit``
    again until it is given the table back, which is the intended
    trade-off. :func:`sklearn.base.clone` is unaffected, because clone goes
    through ``get_params`` rather than pickle.

    The bundle still contains ``encounter_summary_``: per-donor aggregates
    keyed by ``merge_key``. That is the minimum ``transform`` needs, and it
    is derived rather than raw, but it is not nothing. Treat a saved bundle
    as donor data.
    """
    state = dict(super().__getstate__())
    state["encounter_df"] = None
    return state

fit(X, y=None)

Build per-donor encounter summaries from encounter data.

Parameters:

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

Donor-level feature matrix. Used only for schema registration via _validate_data; no target leakage occurs here.

required
y ignored
None

Returns:

Name Type Description
self GratefulPatientFeaturizer

Raises:

Type Description
ValueError

If neither encounter_df nor encounter_path is set.

Source code in philanthropy/preprocessing/_grateful_patient.py
def fit(self: _Self, X: Any, y: Any = None) -> _Self:
    """Build per-donor encounter summaries from encounter data.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Donor-level feature matrix. Used only for schema registration via
        ``_validate_data``; no target leakage occurs here.
    y : ignored

    Returns
    -------
    self : GratefulPatientFeaturizer

    Raises
    ------
    ValueError
        If neither ``encounter_df`` nor ``encounter_path`` is set.
    """
    # Step 1: Load encounter data (snapshot it; never store raw_enc)
    if self.encounter_path is not None:
        from ..utils._validation import ensure_local_path

        ensure_local_path(self.encounter_path, "encounter_path")
        raw_enc = pd.read_parquet(self.encounter_path)
    elif self.encounter_df is not None:
        raw_enc = self.encounter_df.copy()  # critical: snapshot here
    else:
        raise ValueError(
            "GratefulPatientFeaturizer requires either encounter_df or "
            "encounter_path to be set."
        )

    # Step 2: Coerce discharge dates
    raw_enc = raw_enc.copy()
    raw_enc[self.discharge_col] = pd.to_datetime(
        raw_enc[self.discharge_col], errors="coerce"
    )

    raw_enc = _apply_as_of_cutoff(
        raw_enc, self.discharge_col, self.as_of, "GratefulPatientFeaturizer"
    )

    # Step 3: Normalise service_line values
    if self.service_line_col in raw_enc.columns:
        raw_enc[self.service_line_col] = (
            raw_enc[self.service_line_col]
            .astype(str)
            .apply(_normalise_service_line)
        )

    # Step 4: Groupby merge_key
    grouped = raw_enc.groupby(self.merge_key)

    summary_parts: dict[str, pd.Series] = {}

    if self.service_line_col in raw_enc.columns:
        # Mode (most frequent) service line per donor
        summary_parts["primary_service_line"] = grouped[
            self.service_line_col
        ].agg(lambda x: x.mode().iloc[0] if len(x) > 0 else "general")
        summary_parts["distinct_service_lines"] = grouped[
            self.service_line_col
        ].nunique()
    else:
        summary_parts["primary_service_line"] = pd.Series(
            "general", index=grouped.groups.keys()
        )
        summary_parts["distinct_service_lines"] = pd.Series(
            1, dtype=int, index=grouped.groups.keys()
        )

    if self.physician_col in raw_enc.columns:
        summary_parts["distinct_physicians"] = grouped[
            self.physician_col
        ].nunique()
    else:
        summary_parts["distinct_physicians"] = pd.Series(
            0, dtype=int, index=grouped.groups.keys()
        )

    summary_parts["total_encounters"] = grouped[self.discharge_col].count()
    summary_parts["last_discharge"] = grouped[self.discharge_col].max()

    # Step 5: DRG weight column
    if self.drg_weight_col is not None:
        warnings.warn(
            f"drg_weight_col={self.drg_weight_col!r} aggregates a "
            "diagnosis-derived field. Diagnosis is outside the element "
            "list permitted for fundraising by 45 CFR 164.514(f); confirm "
            "your governance approval before using it. See "
            "docs/explanation/compliance_considerations.md.",
            UserWarning,
            stacklevel=2,
        )
    if (
        self.drg_weight_col is not None
        and self.drg_weight_col in raw_enc.columns
    ):
        summary_parts["total_drg_weight"] = grouped[
            self.drg_weight_col
        ].sum()
    else:
        # Sentinel: will become NaN for all donors
        keys = list(grouped.groups.keys())
        summary_parts["total_drg_weight"] = pd.Series(
            np.nan, index=keys, dtype=float
        )

    encounter_summary = pd.DataFrame(summary_parts)

    # Step 6: Clinical gravity score
    if self.capacity_weights is not None and not self.use_capacity_weights:
        warnings.warn(
            "capacity_weights was provided but use_capacity_weights=False "
            "(the default since service-line multipliers have no published "
            "source), so the weights are ignored. Pass "
            "use_capacity_weights=True to apply them.",
            UserWarning,
            stacklevel=2,
        )
    if self.use_capacity_weights:
        weights = (
            self.capacity_weights
            if self.capacity_weights is not None
            else _SERVICE_LINE_CAPACITY_WEIGHTS
        )
        encounter_summary["clinical_gravity_score"] = (
            encounter_summary["total_encounters"].astype(float)
            * encounter_summary["primary_service_line"].map(
                lambda s: weights.get(s, 1.0)
            )
        )
    else:
        encounter_summary["clinical_gravity_score"] = (
            encounter_summary["total_encounters"].astype(float)
        )

    # Step 7: Store fitted attribute
    self.encounter_summary_ = encounter_summary

    # Step 8: Register feature schema
    validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)

    return self

transform(X, y=None)

Merge clinical features into the donor feature matrix.

Parameters:

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

Donor-level feature matrix. Must contain merge_key column (or that column as the first column for ndarray input).

required

Returns:

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

Columns in order: clinical_gravity_score, distinct_service_lines, distinct_physicians, total_drg_weight. Donors absent from encounter table get 0.0 for all columns.

Source code in philanthropy/preprocessing/_grateful_patient.py
def transform(self, X: Any, y: Any = None) -> np.ndarray:
    """Merge clinical features into the donor feature matrix.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Donor-level feature matrix. Must contain ``merge_key`` column
        (or that column as the first column for ndarray input).

    Returns
    -------
    X_out : np.ndarray of shape (n_samples, 4), dtype float64
        Columns in order:
        ``clinical_gravity_score``, ``distinct_service_lines``,
        ``distinct_physicians``, ``total_drg_weight``.
        Donors absent from encounter table get 0.0 for all columns.
    """
    validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)
    check_is_fitted(self)

    _FEATURE_COLS = [
        "clinical_gravity_score",
        "distinct_service_lines",
        "distinct_physicians",
        "total_drg_weight",
    ]

    # Build a DataFrame to merge on merge_key
    if isinstance(X, pd.DataFrame) and self.merge_key in X.columns:
        X_df = X[[self.merge_key]].copy()
    elif isinstance(X, pd.DataFrame) and hasattr(self, "feature_names_in_"):
        # merge_key must be in the feature names
        if self.merge_key in self.feature_names_in_:
            X_df = X[[self.merge_key]].copy()
        else:
            # No merge key available: return zeros
            warnings.warn(
                f"merge_key {self.merge_key!r} is not in X (columns: "
                f"{list(X.columns)}); every clinical feature is 0.0. Route "
                f"this featurizer with a ColumnTransformer that keeps "
                f"{self.merge_key!r}, or set merge_key to a column that exists.",
                UserWarning,
                stacklevel=2,
            )
            n = len(X)
            return np.zeros((n, 4), dtype=np.float64)
    elif hasattr(self, "feature_names_in_") and self.merge_key in list(
        self.feature_names_in_
    ):
        arr = np.asarray(X)
        col_idx = list(self.feature_names_in_).index(self.merge_key)
        X_df = pd.DataFrame(
            {self.merge_key: arr[:, col_idx]}
        )
    else:
        # No merge key: cannot join, return zeros
        warnings.warn(
            f"merge_key {self.merge_key!r} could not be located in X; every "
            f"clinical feature is 0.0. Pass a DataFrame carrying "
            f"{self.merge_key!r}, or fit on one so feature_names_in_ records it.",
            UserWarning,
            stacklevel=2,
        )
        n = np.asarray(X).shape[0]
        return np.zeros((n, 4), dtype=np.float64)

    # Left-merge with encounter_summary_
    merged = X_df.merge(
        self.encounter_summary_[_FEATURE_COLS],
        left_on=self.merge_key,
        right_index=True,
        how="left",
    )

    # Step 5: fillna(0.0) so unknown donors get zeros
    result = merged[_FEATURE_COLS].fillna(0.0)

    return result.to_numpy(dtype=np.float64)

get_feature_names_out(input_features=None)

Return the generated grateful-patient feature names.

Parameters:

Name Type Description Default
input_features array-like of str or None

Ignored because the featurizer always emits the same four features.

None

Returns:

Name Type Description
feature_names_out ndarray of str

["clinical_gravity_score", "distinct_service_lines", "distinct_physicians", "total_drg_weight"].

Raises:

Type Description
NotFittedError

If the featurizer has not been fitted.

Source code in philanthropy/preprocessing/_grateful_patient.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return the generated grateful-patient feature names.

    Parameters
    ----------
    input_features : array-like of str or None, default=None
        Ignored because the featurizer always emits the same four features.

    Returns
    -------
    feature_names_out : ndarray of str
        ``["clinical_gravity_score", "distinct_service_lines",
        "distinct_physicians", "total_drg_weight"]``.

    Raises
    ------
    NotFittedError
        If the featurizer has not been fitted.
    """
    check_is_fitted(self)
    return np.array(
        [
            "clinical_gravity_score",
            "distinct_service_lines",
            "distinct_physicians",
            "total_drg_weight",
        ],
        dtype=object,
    )

DischargeToSolicitationWindowTransformer

Bases: TransformerMixin, BaseEstimator

Flag donors in the clinical fundraising post-discharge solicitation window.

This transformer outputs two features: - in_solicitation_window (col 0): 1 if within window, 0 otherwise. - window_position_score (col 1): strength of the timing signal, in [0.0, 1.0], or NaN when the days-since-discharge input is missing.

min_days_post_discharge is an ethical cooling-off floor, not the start of a ramp: soliciting a patient the week after discharge is the thing the floor exists to prevent. So the score is highest immediately after the floor is cleared and decays with elapsed time, which is also how grateful-patient propensity is understood to behave. That is window_shape="decay", the default.

Parameters:

Name Type Description Default
min_days_post_discharge int

Start of the solicitation window, in days post-discharge (inclusive).

90
max_days_post_discharge int

End of the solicitation window, in days post-discharge (inclusive).

365
days_since_discharge_col str

Column name containing days since last discharge.

"days_since_last_discharge"
window_shape ('decay', 'triangle')

Shape of window_position_score inside the window.

"decay" Linear decay from 1.0 at min_days_post_discharge to 0.0 at max_days_post_discharge. Monotone non-increasing. "triangle" The legacy symmetric triangle, peaking at the window midpoint. It treats the ethical floor as a propensity minimum: with the default window, day 91 and day 364 both score about 0.007 while day 227 scores 1.0. Kept only to reproduce results computed before the default changed.

"decay"
Notes

A missing days-since-discharge value yields in_solicitation_window=0 and window_position_score=NaN, so "no discharge on record" is distinguishable downstream from "discharged, but outside the window", which scores a hard 0.0. Both estimators that consume this column handle NaN natively.

Source code in philanthropy/preprocessing/_discharge_window.py
class DischargeToSolicitationWindowTransformer(TransformerMixin, BaseEstimator):
    """Flag donors in the clinical fundraising post-discharge solicitation window.

    This transformer outputs two features:
    - ``in_solicitation_window`` (col 0): 1 if within window, 0 otherwise.
    - ``window_position_score`` (col 1): strength of the timing signal, in
      [0.0, 1.0], or ``NaN`` when the days-since-discharge input is missing.

    ``min_days_post_discharge`` is an **ethical cooling-off floor**, not the
    start of a ramp: soliciting a patient the week after discharge is the thing
    the floor exists to prevent. So the score is highest immediately after the
    floor is cleared and decays with elapsed time, which is also how
    grateful-patient propensity is understood to behave. That is
    ``window_shape="decay"``, the default.

    Parameters
    ----------
    min_days_post_discharge : int, default=90
        Start of the solicitation window, in days post-discharge (inclusive).
    max_days_post_discharge : int, default=365
        End of the solicitation window, in days post-discharge (inclusive).
    days_since_discharge_col : str, default="days_since_last_discharge"
        Column name containing days since last discharge.
    window_shape : {"decay", "triangle"}, default="decay"
        Shape of ``window_position_score`` inside the window.

        ``"decay"``
            Linear decay from 1.0 at ``min_days_post_discharge`` to 0.0 at
            ``max_days_post_discharge``. Monotone non-increasing.
        ``"triangle"``
            The legacy symmetric triangle, peaking at the window midpoint. It
            treats the ethical floor as a propensity minimum: with the default
            window, day 91 and day 364 both score about 0.007 while day 227
            scores 1.0. Kept only to reproduce results computed before the
            default changed.

    Notes
    -----
    A missing days-since-discharge value yields ``in_solicitation_window=0``
    and ``window_position_score=NaN``, so "no discharge on record" is
    distinguishable downstream from "discharged, but outside the window", which
    scores a hard 0.0. Both estimators that consume this column handle NaN
    natively.
    """

    _WINDOW_SHAPES = ("decay", "triangle")

    def __init__(
        self,
        min_days_post_discharge: int = 90,
        max_days_post_discharge: int = 365,
        days_since_discharge_col: str = "days_since_last_discharge",
        window_shape: str = "decay",
    ) -> None:
        self.min_days_post_discharge = min_days_post_discharge
        self.max_days_post_discharge = max_days_post_discharge
        self.days_since_discharge_col = days_since_discharge_col
        self.window_shape = window_shape

    def fit(self: _Self, X: Any, y: Any = None) -> _Self:
        """Fit the transformer (no-op, validates parameters).

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training data.
        y : Ignored
            Not used, present for API consistency.

        Returns
        -------
        self : DischargeToSolicitationWindowTransformer
        """
        if self.min_days_post_discharge >= self.max_days_post_discharge:
            raise ValueError(
                f"min_days_post_discharge ({self.min_days_post_discharge}) must be "
                f"strictly less than max_days_post_discharge ({self.max_days_post_discharge})."
            )
        if self.window_shape not in self._WINDOW_SHAPES:
            raise ValueError(
                f"window_shape must be one of {self._WINDOW_SHAPES}, got "
                f"{self.window_shape!r}."
            )
        validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)
        return self

    def transform(self, X: Any, y: Any = None) -> np.ndarray:
        """Transform X to two columns: in_window, window_position_score.

        Parameters
        ----------
        X : array-like or DataFrame of shape (n_samples, n_features)
            A DataFrame must carry ``days_since_discharge_col``; a bare ndarray
            is read positionally (first column, or the array itself if 1-D).

        Returns
        -------
        out : ndarray of shape (n_samples, 2)
            Columns: in_window (0/1), window_position_score [0,1].

        Raises
        ------
        ValueError
            If X is a DataFrame without ``days_since_discharge_col``.
        """
        check_is_fitted(self)

        if isinstance(X, pd.DataFrame) and self.days_since_discharge_col in X.columns:
            days_raw = X[self.days_since_discharge_col].to_numpy(dtype=float)
        elif isinstance(X, pd.DataFrame):
            raise ValueError(
                f"{self.days_since_discharge_col!r} not found in X; columns are "
                f"{list(X.columns)}. Route this transformer with a ColumnTransformer "
                f"so it receives the days-since-discharge column, or set "
                f"days_since_discharge_col to the correct name."
            )
        else:
            arr = np.asarray(X, dtype=float)
            if arr.ndim == 1:
                days_raw = arr
            else:
                days_raw = arr[:, 0]

        validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)

        min_d = float(self.min_days_post_discharge)
        max_d = float(self.max_days_post_discharge)
        span = max_d - min_d

        days = np.asarray(days_raw, dtype=np.float64)
        missing = np.isnan(days)
        inside = ~missing & (days >= min_d) & (days <= max_d)

        in_window = inside.astype(np.float64)

        if self.window_shape == "triangle":
            midpoint = (min_d + max_d) / 2.0
            raw = 1.0 - np.abs(np.where(missing, min_d, days) - midpoint) / (span / 2.0)
        else:
            raw = 1.0 - (np.where(missing, min_d, days) - min_d) / span

        # Out of window scores a hard 0; a missing input stays NaN so the two
        # are distinguishable downstream.
        window_score = np.where(inside, np.clip(raw, 0.0, 1.0), 0.0)
        window_score = np.where(missing, np.nan, window_score)

        return np.column_stack([in_window, window_score])

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Get output feature names."""
        check_is_fitted(self)
        return np.array(
            ["in_solicitation_window", "window_position_score"],
            dtype=object,
        )

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

fit(X, y=None)

Fit the transformer (no-op, validates parameters).

Parameters:

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

Training data.

required
y Ignored

Not used, present for API consistency.

None

Returns:

Name Type Description
self DischargeToSolicitationWindowTransformer
Source code in philanthropy/preprocessing/_discharge_window.py
def fit(self: _Self, X: Any, y: Any = None) -> _Self:
    """Fit the transformer (no-op, validates parameters).

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Training data.
    y : Ignored
        Not used, present for API consistency.

    Returns
    -------
    self : DischargeToSolicitationWindowTransformer
    """
    if self.min_days_post_discharge >= self.max_days_post_discharge:
        raise ValueError(
            f"min_days_post_discharge ({self.min_days_post_discharge}) must be "
            f"strictly less than max_days_post_discharge ({self.max_days_post_discharge})."
        )
    if self.window_shape not in self._WINDOW_SHAPES:
        raise ValueError(
            f"window_shape must be one of {self._WINDOW_SHAPES}, got "
            f"{self.window_shape!r}."
        )
    validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)
    return self

transform(X, y=None)

Transform X to two columns: in_window, window_position_score.

Parameters:

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

A DataFrame must carry days_since_discharge_col; a bare ndarray is read positionally (first column, or the array itself if 1-D).

required

Returns:

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

Columns: in_window (0/1), window_position_score [0,1].

Raises:

Type Description
ValueError

If X is a DataFrame without days_since_discharge_col.

Source code in philanthropy/preprocessing/_discharge_window.py
def transform(self, X: Any, y: Any = None) -> np.ndarray:
    """Transform X to two columns: in_window, window_position_score.

    Parameters
    ----------
    X : array-like or DataFrame of shape (n_samples, n_features)
        A DataFrame must carry ``days_since_discharge_col``; a bare ndarray
        is read positionally (first column, or the array itself if 1-D).

    Returns
    -------
    out : ndarray of shape (n_samples, 2)
        Columns: in_window (0/1), window_position_score [0,1].

    Raises
    ------
    ValueError
        If X is a DataFrame without ``days_since_discharge_col``.
    """
    check_is_fitted(self)

    if isinstance(X, pd.DataFrame) and self.days_since_discharge_col in X.columns:
        days_raw = X[self.days_since_discharge_col].to_numpy(dtype=float)
    elif isinstance(X, pd.DataFrame):
        raise ValueError(
            f"{self.days_since_discharge_col!r} not found in X; columns are "
            f"{list(X.columns)}. Route this transformer with a ColumnTransformer "
            f"so it receives the days-since-discharge column, or set "
            f"days_since_discharge_col to the correct name."
        )
    else:
        arr = np.asarray(X, dtype=float)
        if arr.ndim == 1:
            days_raw = arr
        else:
            days_raw = arr[:, 0]

    validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)

    min_d = float(self.min_days_post_discharge)
    max_d = float(self.max_days_post_discharge)
    span = max_d - min_d

    days = np.asarray(days_raw, dtype=np.float64)
    missing = np.isnan(days)
    inside = ~missing & (days >= min_d) & (days <= max_d)

    in_window = inside.astype(np.float64)

    if self.window_shape == "triangle":
        midpoint = (min_d + max_d) / 2.0
        raw = 1.0 - np.abs(np.where(missing, min_d, days) - midpoint) / (span / 2.0)
    else:
        raw = 1.0 - (np.where(missing, min_d, days) - min_d) / span

    # Out of window scores a hard 0; a missing input stays NaN so the two
    # are distinguishable downstream.
    window_score = np.where(inside, np.clip(raw, 0.0, 1.0), 0.0)
    window_score = np.where(missing, np.nan, window_score)

    return np.column_stack([in_window, window_score])

get_feature_names_out(input_features=None)

Get output feature names.

Source code in philanthropy/preprocessing/_discharge_window.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Get output feature names."""
    check_is_fitted(self)
    return np.array(
        ["in_solicitation_window", "window_position_score"],
        dtype=object,
    )

WealthPercentileTransformer

Bases: TransformerMixin, BaseEstimator

Compute wealth percentile ranks.

Parameters:

Name Type Description Default
wealth_cols list of str or None

Explicit wealth columns to rank. If none of the requested columns are present during :meth:fit, a ValueError is raised. None keeps automatic name-based detection, where finding no wealth columns is valid.

None
output_suffix str

Suffix appended to generated percentile columns.

"_pct_rank"
Source code in philanthropy/preprocessing/_wealth_percentile.py
class WealthPercentileTransformer(TransformerMixin, BaseEstimator):
    """Compute wealth percentile ranks.

    Parameters
    ----------
    wealth_cols : list of str or None, default=None
        Explicit wealth columns to rank. If none of the requested columns are
        present during :meth:`fit`, a ``ValueError`` is raised. ``None`` keeps
        automatic name-based detection, where finding no wealth columns is valid.
    output_suffix : str, default="_pct_rank"
        Suffix appended to generated percentile columns.
    """

    def __init__(
        self,
        wealth_cols: list[str] | None = None,
        output_suffix: str = "_pct_rank"
    ):
        self.wealth_cols = wealth_cols
        self.output_suffix = output_suffix

    def fit(self: _Self, X: Any, y: Any = None) -> _Self:
        """Learn the training wealth distribution.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training-set feature matrix.
        y : ignored
            Present for scikit-learn API compatibility.

        Returns
        -------
        self : WealthPercentileTransformer
            Fitted transformer. Freezes ``feature_names_in_``,
            ``imputed_cols_``, and ``percentile_lookup_``.

        Raises
        ------
        ValueError
            If ``wealth_cols`` was provided and none of those columns exist in
            the training data.

        Notes
        -----
        ``percentile_lookup_`` stores the sorted training values per wealth
        column. :meth:`transform` ranks held-out data against this frozen
        training distribution, not against the batch being transformed.
        """
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=True)

        if not hasattr(self, "feature_names_in_"):
            self.feature_names_in_ = np.array([f"x{i}" for i in range(X.shape[1])], dtype=object)

        # Use feature_names_in_ to resolve columns
        if self.wealth_cols is not None:
            self.imputed_cols_ = [c for c in self.wealth_cols if c in self.feature_names_in_]
            if not self.imputed_cols_:
                raise ValueError(
                    "none of the requested wealth_cols are present; "
                    f"requested={self.wealth_cols!r}, available={list(self.feature_names_in_)!r}"
                )
        else:
            targets = ("net_worth", "real_estate", "stock", "capacity")
            self.imputed_cols_ = [c for c in self.feature_names_in_ if any(t in str(c) for t in targets)]

        self.percentile_lookup_ = {}
        for col in self.imputed_cols_:
            # Find index of column
            col_idx = list(self.feature_names_in_).index(col)
            # Use X as numpy array
            s = pd.to_numeric(pd.Series(X[:, col_idx]), errors="coerce")
            valid_vals = s.dropna().to_numpy()
            self.percentile_lookup_[col] = np.sort(valid_vals)

        return self

    def transform(self, X: Any) -> np.ndarray:
        """Rank features against the fitted training distribution.

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

        Returns
        -------
        X_out : np.ndarray of float64
            Numeric feature matrix with wealth percentile columns appended.

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

        Notes
        -----
        Percentiles are relative to the training cohort captured by
        ``percentile_lookup_``, which is the leakage-safety guarantee.
        """
        check_is_fitted(self, "percentile_lookup_")
        X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
        X_out = pd.DataFrame(X, columns=self.feature_names_in_)

        for col in self.imputed_cols_:
            if col in X_out.columns:
                ref = self.percentile_lookup_[col]
                s = pd.to_numeric(X_out[col], errors="coerce").to_numpy(dtype=float)
                out_col = f"{col}{self.output_suffix}"

                if len(ref) == 0:
                    X_out[out_col] = np.nan
                    continue

                ranks = np.searchsorted(ref, s, side="right") / float(len(ref)) * 100.0
                ranks = np.where(np.isnan(s), np.nan, ranks)
                X_out[out_col] = ranks

        # Rule 5: transform() MUST return np.ndarray (float64)
        X_final = X_out.select_dtypes(include=[np.number])
        return X_final.to_numpy(dtype=np.float64)

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return input names followed by generated wealth-percentile names.

        Parameters
        ----------
        input_features : array-like of str or None, default=None
            Ignored. Names are derived from the columns recorded by :meth:`fit`.

        Returns
        -------
        feature_names_out : ndarray of str
            Fitted input names followed by ``<column><output_suffix>`` for each
            selected wealth column.

        Raises
        ------
        NotFittedError
            If the transformer has not been fitted.
        """
        check_is_fitted(self)
        out = list(self.feature_names_in_)
        for col in self.imputed_cols_:
            if col in self.feature_names_in_:
                out.append(f"{col}{self.output_suffix}")
        return np.array(out, dtype=object)

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

fit(X, y=None)

Learn the training wealth distribution.

Parameters:

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

Training-set feature matrix.

required
y ignored

Present for scikit-learn API compatibility.

None

Returns:

Name Type Description
self WealthPercentileTransformer

Fitted transformer. Freezes feature_names_in_, imputed_cols_, and percentile_lookup_.

Raises:

Type Description
ValueError

If wealth_cols was provided and none of those columns exist in the training data.

Notes

percentile_lookup_ stores the sorted training values per wealth column. :meth:transform ranks held-out data against this frozen training distribution, not against the batch being transformed.

Source code in philanthropy/preprocessing/_wealth_percentile.py
def fit(self: _Self, X: Any, y: Any = None) -> _Self:
    """Learn the training wealth distribution.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Training-set feature matrix.
    y : ignored
        Present for scikit-learn API compatibility.

    Returns
    -------
    self : WealthPercentileTransformer
        Fitted transformer. Freezes ``feature_names_in_``,
        ``imputed_cols_``, and ``percentile_lookup_``.

    Raises
    ------
    ValueError
        If ``wealth_cols`` was provided and none of those columns exist in
        the training data.

    Notes
    -----
    ``percentile_lookup_`` stores the sorted training values per wealth
    column. :meth:`transform` ranks held-out data against this frozen
    training distribution, not against the batch being transformed.
    """
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=True)

    if not hasattr(self, "feature_names_in_"):
        self.feature_names_in_ = np.array([f"x{i}" for i in range(X.shape[1])], dtype=object)

    # Use feature_names_in_ to resolve columns
    if self.wealth_cols is not None:
        self.imputed_cols_ = [c for c in self.wealth_cols if c in self.feature_names_in_]
        if not self.imputed_cols_:
            raise ValueError(
                "none of the requested wealth_cols are present; "
                f"requested={self.wealth_cols!r}, available={list(self.feature_names_in_)!r}"
            )
    else:
        targets = ("net_worth", "real_estate", "stock", "capacity")
        self.imputed_cols_ = [c for c in self.feature_names_in_ if any(t in str(c) for t in targets)]

    self.percentile_lookup_ = {}
    for col in self.imputed_cols_:
        # Find index of column
        col_idx = list(self.feature_names_in_).index(col)
        # Use X as numpy array
        s = pd.to_numeric(pd.Series(X[:, col_idx]), errors="coerce")
        valid_vals = s.dropna().to_numpy()
        self.percentile_lookup_[col] = np.sort(valid_vals)

    return self

transform(X)

Rank features against the fitted training distribution.

Parameters:

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

Feature matrix (training or held-out).

required

Returns:

Name Type Description
X_out np.ndarray of float64

Numeric feature matrix with wealth percentile columns appended.

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Notes

Percentiles are relative to the training cohort captured by percentile_lookup_, which is the leakage-safety guarantee.

Source code in philanthropy/preprocessing/_wealth_percentile.py
def transform(self, X: Any) -> np.ndarray:
    """Rank features against the fitted training distribution.

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

    Returns
    -------
    X_out : np.ndarray of float64
        Numeric feature matrix with wealth percentile columns appended.

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

    Notes
    -----
    Percentiles are relative to the training cohort captured by
    ``percentile_lookup_``, which is the leakage-safety guarantee.
    """
    check_is_fitted(self, "percentile_lookup_")
    X = validate_data(self, X, ensure_all_finite="allow-nan", reset=False)
    X_out = pd.DataFrame(X, columns=self.feature_names_in_)

    for col in self.imputed_cols_:
        if col in X_out.columns:
            ref = self.percentile_lookup_[col]
            s = pd.to_numeric(X_out[col], errors="coerce").to_numpy(dtype=float)
            out_col = f"{col}{self.output_suffix}"

            if len(ref) == 0:
                X_out[out_col] = np.nan
                continue

            ranks = np.searchsorted(ref, s, side="right") / float(len(ref)) * 100.0
            ranks = np.where(np.isnan(s), np.nan, ranks)
            X_out[out_col] = ranks

    # Rule 5: transform() MUST return np.ndarray (float64)
    X_final = X_out.select_dtypes(include=[np.number])
    return X_final.to_numpy(dtype=np.float64)

get_feature_names_out(input_features=None)

Return input names followed by generated wealth-percentile names.

Parameters:

Name Type Description Default
input_features array-like of str or None

Ignored. Names are derived from the columns recorded by :meth:fit.

None

Returns:

Name Type Description
feature_names_out ndarray of str

Fitted input names followed by <column><output_suffix> for each selected wealth column.

Raises:

Type Description
NotFittedError

If the transformer has not been fitted.

Source code in philanthropy/preprocessing/_wealth_percentile.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return input names followed by generated wealth-percentile names.

    Parameters
    ----------
    input_features : array-like of str or None, default=None
        Ignored. Names are derived from the columns recorded by :meth:`fit`.

    Returns
    -------
    feature_names_out : ndarray of str
        Fitted input names followed by ``<column><output_suffix>`` for each
        selected wealth column.

    Raises
    ------
    NotFittedError
        If the transformer has not been fitted.
    """
    check_is_fitted(self)
    out = list(self.feature_names_in_)
    for col in self.imputed_cols_:
        if col in self.feature_names_in_:
            out.append(f"{col}{self.output_suffix}")
    return np.array(out, dtype=object)

EncounterRecencyTransformer

Bases: TransformerMixin, BaseEstimator

Transform encounter-date columns into predictive recency features.

Given one or more date-only columns (no PHI, dates only), this transformer produces three downstream-model-ready features per date column:

days_since_last_encounter Integer days between reference_date and the encounter date. NaN for missing/unparseable dates. Always non-negative when reference_date >= encounter_date; negative values indicate future dates (rare in production) and are left as-is to allow models to detect data-quality anomalies.

encounter_in_last_90d Float64 0.0 / 1.0 flag: 1.0 if days_since_last_encounter <= 90. Missing dates → 0.0.

fiscal_year_of_encounter Integer fiscal year in which the encounter ends (e.g., a July-start FY convention assigns a June-30 encounter to the current year, while a July-1 encounter starts the next FY). Missing → np.nan (returned as float64).

Parameters:

Name Type Description Default
date_col str or list of str

Column name(s) in X containing ISO-8601 encounter/discharge dates. If a list is provided, one set of three output features is produced per column (columns are prefixed by <col>__).

"last_encounter_date"
fiscal_year_start int

Month (1–12) on which the organisation's fiscal year begins. 7 = July fiscal-year start (common in US academic medical centres and universities).

7
reference_date str, datetime-like, or None

The anchor date used to compute days_since_last_encounter. If None, it is determined at :meth:fit time as the maximum observed date in the training data (i.e., the most recent clinical encounter in the training fold). Setting an explicit reference date is recommended for production scoring runs to ensure consistency between training and inference time.

None
timezone str or None

Optional timezone name (e.g., "America/Chicago"). When provided, timezone-naive datetimes in X are localised to this timezone before difference computation, preventing offset errors for hospitals that cross daylight-saving boundaries. If None, all dates are kept timezone-naive (recommended when the exact timezone is unknown).

None

Attributes:

Name Type Description
reference_date_ Timestamp

The reference date frozen at :meth:fit time.

n_features_in_ int

Number of columns in X at :meth:fit time (set by :func:~sklearn.utils.validation.validate_data).

feature_names_in_ ndarray of str

Column names of X at :meth:fit time.

Raises:

Type Description
ValueError

If fiscal_year_start is not an integer in [1, 12].

TypeError

If the resolved date_col columns cannot be coerced to datetime64.

Examples:

>>> import pandas as pd
>>> from philanthropy.preprocessing import EncounterRecencyTransformer
>>> X = pd.DataFrame({
...     "last_encounter_date": ["2023-06-01", "2022-12-15", None],
... })
>>> t = EncounterRecencyTransformer(fiscal_year_start=7, reference_date="2023-09-01")
>>> t.set_output(transform="pandas")
EncounterRecencyTransformer(...)
>>> out = t.fit_transform(X)
>>> out.shape
(3, 3)
>>> int(out.iloc[0, 0])  # days since 2023-06-01 from 2023-09-01 = 92
92
>>> bool((out.iloc[:, 1] >= 0).all())
True
Notes

HIPAA note: This transformer accepts only date columns. Ensure that no PHI fields (MRN, patient name, diagnosis code) are included in X. Date-only is not de-identified: see the module docstring and docs/explanation/compliance_considerations.md.

Fiscal year convention: With fiscal_year_start=7, the fiscal year is identified by the calendar year in which it ends. A date of 2023-07-01 belongs to FY 2024; a date of 2023-06-30 belongs to FY 2023. This matches the convention used by most US research universities and many hospital foundations.

Source code in philanthropy/preprocessing/_encounter_recency.py
class EncounterRecencyTransformer(TransformerMixin, BaseEstimator):
    """Transform encounter-date columns into predictive recency features.

    Given one or more date-only columns (no PHI, dates only), this
    transformer produces three downstream-model-ready features per date
    column:

    ``days_since_last_encounter``
        Integer days between ``reference_date`` and the encounter date.
        ``NaN`` for missing/unparseable dates.  Always non-negative when
        ``reference_date >= encounter_date``; negative values indicate
        future dates (rare in production) and are left as-is to allow models
        to detect data-quality anomalies.

    ``encounter_in_last_90d``
        Float64 0.0 / 1.0 flag: 1.0 if ``days_since_last_encounter <= 90``.
        Missing dates → 0.0.

    ``fiscal_year_of_encounter``
        Integer fiscal year in which the encounter ends (e.g., a July-start
        FY convention assigns a June-30 encounter to the current year, while
        a July-1 encounter starts the *next* FY).  Missing → ``np.nan``
        (returned as ``float64``).

    Parameters
    ----------
    date_col : str or list of str, default="last_encounter_date"
        Column name(s) in ``X`` containing ISO-8601 encounter/discharge
        dates.  If a list is provided, one set of three output features is
        produced per column (columns are prefixed by ``<col>__``).
    fiscal_year_start : int, default=7
        Month (1–12) on which the organisation's fiscal year begins.
        ``7`` = July fiscal-year start (common in US academic medical
        centres and universities).
    reference_date : str, datetime-like, or None, default=None
        The anchor date used to compute ``days_since_last_encounter``.
        If ``None``, it is determined at :meth:`fit` time as the **maximum
        observed date** in the training data (i.e., the most recent
        clinical encounter in the training fold).  Setting an explicit
        reference date is recommended for production scoring runs to ensure
        consistency between training and inference time.
    timezone : str or None, default=None
        Optional timezone name (e.g., ``"America/Chicago"``).  When
        provided, timezone-naive datetimes in ``X`` are localised to this
        timezone before difference computation, preventing offset errors for
        hospitals that cross daylight-saving boundaries.  If ``None``,
        all dates are kept timezone-naive (recommended when the exact
        timezone is unknown).

    Attributes
    ----------
    reference_date_ : pd.Timestamp
        The reference date frozen at :meth:`fit` time.
    n_features_in_ : int
        Number of columns in ``X`` at :meth:`fit` time (set by
        :func:`~sklearn.utils.validation.validate_data`).
    feature_names_in_ : ndarray of str
        Column names of ``X`` at :meth:`fit` time.

    Raises
    ------
    ValueError
        If ``fiscal_year_start`` is not an integer in [1, 12].
    TypeError
        If the resolved ``date_col`` columns cannot be coerced to
        ``datetime64``.

    Examples
    --------
    >>> import pandas as pd
    >>> from philanthropy.preprocessing import EncounterRecencyTransformer
    >>> X = pd.DataFrame({
    ...     "last_encounter_date": ["2023-06-01", "2022-12-15", None],
    ... })
    >>> t = EncounterRecencyTransformer(fiscal_year_start=7, reference_date="2023-09-01")
    >>> t.set_output(transform="pandas")  # doctest: +ELLIPSIS
    EncounterRecencyTransformer(...)
    >>> out = t.fit_transform(X)
    >>> out.shape
    (3, 3)
    >>> int(out.iloc[0, 0])  # days since 2023-06-01 from 2023-09-01 = 92
    92
    >>> bool((out.iloc[:, 1] >= 0).all())
    True

    Notes
    -----
    **HIPAA note:** This transformer accepts only date columns.  Ensure that
    no PHI fields (MRN, patient name, diagnosis code) are included in ``X``.
    Date-only is not de-identified: see the module docstring and
    ``docs/explanation/compliance_considerations.md``.

    **Fiscal year convention:** With ``fiscal_year_start=7``, the fiscal year
    is identified by the calendar year in which it *ends*.  A date of
    2023-07-01 belongs to FY **2024**; a date of 2023-06-30 belongs to FY
    **2023**.  This matches the convention used by most US research universities
    and many hospital foundations.
    """

    def __init__(
        self,
        date_col: str | list[str] = "last_encounter_date",
        fiscal_year_start: int = 7,
        reference_date: Any = None,
        timezone: Optional[str] = None,
    ) -> None:
        # scikit-learn rule: __init__ ONLY assigns; no validation, no side-effects.
        self.date_col = date_col
        self.fiscal_year_start = fiscal_year_start
        self.reference_date = reference_date
        self.timezone = timezone

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

    def _validate_fiscal_year_start(self) -> None:
        """Raise ValueError if fiscal_year_start is not an integer in [1, 12].

        The type check stays here: ``validate_fiscal_year_start`` is also called
        by ``CRMCleaner`` and ``FiscalYearTransformer``, which do not reject
        non-integers today, and tightening a shared validator would change two
        classes this method does not own.  The *range* rule is shared.
        """
        if not isinstance(self.fiscal_year_start, (int, np.integer)):
            raise ValueError(
                f"`fiscal_year_start` must be an integer in [1, 12], "
                f"got {self.fiscal_year_start!r}."
            )
        validate_fiscal_year_start(int(self.fiscal_year_start))

    def _resolve_date_cols(self) -> list[str]:
        """Return the date column(s) as a list of strings."""
        if isinstance(self.date_col, str):
            return [self.date_col]
        return list(self.date_col)

    def _parse_dates(self, series: pd.Series) -> pd.Series:
        """Parse a date series to datetime64[ns], optionally localising timezone."""
        parsed = pd.to_datetime(series, errors="coerce", utc=(self.timezone is not None))
        if self.timezone is not None:
            # Convert to the target timezone; if already tz-aware, convert.
            try:
                parsed = parsed.dt.tz_convert(self.timezone)
            except Exception:
                parsed = parsed.dt.tz_localize(self.timezone)
        return parsed

    def _fiscal_year(self, dt: pd.Timestamp) -> int:
        """Return the fiscal year for a single Timestamp."""
        fys = int(self.fiscal_year_start)
        if dt.month >= fys:
            # Encounter is in the opening half of fiscal year → FY ends next calendar year
            return dt.year + 1
        return dt.year

    def _compute_recency_features(
        self, dates: pd.Series, prefix: str
    ) -> pd.DataFrame:
        """Compute (days_since, in_last_90d, fiscal_year) for a date series."""
        ref = self.reference_date_

        # days_since_last_encounter
        # Timezone strip for subtraction when tz-naive reference vs tz-aware series
        if dates.dt.tz is not None and ref.tzinfo is None:
            ref_ts = pd.Timestamp(ref).tz_localize(self.timezone or "UTC")
        elif dates.dt.tz is None and ref.tzinfo is not None:
            dates = dates.dt.tz_localize("UTC")
            ref_ts = ref
        else:
            ref_ts = ref

        try:
            delta_days = (ref_ts - dates).dt.days.astype("float64")
        except (OverflowError, OutOfBoundsTimedelta):
            # A datetime64[ns] timedelta overflows int64 once two representable
            # dates span >~292 years: never real encounter data, but don't crash
            # on it. Day-resolution differencing always fits in int64 days.
            delta_days = self._days_since_day_resolution(ref_ts, dates)

        # encounter_in_last_90d: 1.0 if <=90 days ago and non-NaN
        in_90d = np.where(dates.isna(), 0.0, (delta_days <= 90.0).astype(np.float64))

        # fiscal_year_of_encounter: float64 (NaN for missing)
        fy = dates.apply(
            lambda d: np.nan if pd.isna(d) else float(self._fiscal_year(d))
        ).astype("float64")

        cols = {}
        p = f"{prefix}__" if prefix else ""
        cols[f"{p}days_since_last_encounter"] = delta_days.values
        cols[f"{p}encounter_in_last_90d"] = in_90d
        cols[f"{p}fiscal_year_of_encounter"] = fy.values

        return pd.DataFrame(cols)

    @staticmethod
    def _days_since_day_resolution(ref_ts: Any, dates: pd.Series) -> pd.Series:
        """Overflow-safe ``days_since`` for extreme date spans (>~292 years).

        Differences at day resolution so the delta stays inside int64; ``NaT``
        maps to ``NaN``, matching the nanosecond path.
        """
        d = dates
        if d.dt.tz is not None:
            d = d.dt.tz_convert("UTC").dt.tz_localize(None)
        ref = pd.Timestamp(ref_ts)
        if ref.tzinfo is not None:
            ref = ref.tz_convert("UTC").tz_localize(None)
        delta = np.datetime64(ref, "D") - d.to_numpy(dtype="datetime64[D]")
        # numpy stubs mis-infer the datetime64 subtraction above as datetime64
        # rather than timedelta64, so the division reads as an invalid operand.
        days = delta / np.timedelta64(1, "D")  # type: ignore[operator]
        return pd.Series(days, index=dates.index).astype("float64")

    # ------------------------------------------------------------------
    # fit / transform
    # ------------------------------------------------------------------

    def fit(self: _Self, X: Any, y: Any = None) -> _Self:
        """Validate parameters and freeze the reference date from training data.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Input data.  Must contain the column(s) specified in ``date_col``
            when passed as a pd.DataFrame.
        y : ignored

        Returns
        -------
        self : EncounterRecencyTransformer
        """
        self._validate_fiscal_year_start()

        # Register the input schema via validate_data; allow NaN and string types.
        validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)

        # Freeze the reference date:
        if self.reference_date is not None:
            self.reference_date_ = pd.Timestamp(self.reference_date)
        else:
            # Infer from training data: max observed date across all date columns.
            if isinstance(X, pd.DataFrame):
                cols = self._resolve_date_cols()
                max_dates = []
                for col in cols:
                    if col in X.columns:
                        parsed = self._parse_dates(X[col])
                        mx = parsed.max()
                        if not pd.isna(mx):
                            max_dates.append(mx)
                if max_dates:
                    self.reference_date_ = max(max_dates)
                else:
                    warnings.warn(
                        "EncounterRecencyTransformer: no parseable dates found in "
                        "training data; defaulting reference_date_ to today.",
                        UserWarning,
                    )
                    self.reference_date_ = pd.Timestamp.today().normalize()
            else:
                # Cannot infer from ndarray without column names; default to today.
                warnings.warn(
                    "EncounterRecencyTransformer: X is not a DataFrame, "
                    "defaulting reference_date_ to today. Provide reference_date "
                    "explicitly for reproducibility.",
                    UserWarning,
                )
                self.reference_date_ = pd.Timestamp.today().normalize()

        return self

    def transform(self, X: Any, y: Any = None) -> np.ndarray:
        """Compute encounter recency features.

        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Input data containing the date column(s).

        Returns
        -------
        X_out : np.ndarray of shape (n_samples, 3 * n_date_cols), dtype float64
            Feature columns, in order, for each ``date_col``:

            * ``[<col>__]days_since_last_encounter``
            * ``[<col>__]encounter_in_last_90d``
            * ``[<col>__]fiscal_year_of_encounter``

        Raises
        ------
        sklearn.exceptions.NotFittedError
            If :meth:`fit` has not been called yet.
        """
        check_is_fitted(self, ["reference_date_"])
        validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)

        # Build a working DataFrame from X
        if isinstance(X, pd.DataFrame):
            df = X
        elif hasattr(self, "feature_names_in_"):
            df = pd.DataFrame(
                np.asarray(X, dtype=object), columns=self.feature_names_in_
            )
        else:
            # Fallback: cannot resolve column names; produce NaN output.
            n = np.asarray(X).shape[0]
            cols = self._resolve_date_cols()
            n_out = len(cols) * 3
            return np.full((n, n_out), np.nan, dtype=np.float64)

        cols = self._resolve_date_cols()
        parts: list[pd.DataFrame] = []
        prefix_needed = len(cols) > 1

        for col in cols:
            prefix = col if prefix_needed else ""
            if col in df.columns:
                parsed = self._parse_dates(df[col])
            else:
                warnings.warn(
                    f"EncounterRecencyTransformer: date column {col!r} not found "
                    f"in X; filling recency features with NaN.",
                    UserWarning,
                )
                n = len(df)
                parsed = pd.Series(pd.NaT, index=df.index)
                parsed = self._parse_dates(pd.Series([None] * n))

            parts.append(self._compute_recency_features(parsed, prefix=prefix))

        out_df = pd.concat(parts, axis=1) if parts else pd.DataFrame()
        return out_df.to_numpy(dtype=np.float64)

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return output feature names.

        Returns
        -------
        feature_names : ndarray of str
        """
        check_is_fitted(self, ["reference_date_"])
        cols = self._resolve_date_cols()
        prefix_needed = len(cols) > 1
        names: list[str] = []
        for col in cols:
            p = f"{col}__" if prefix_needed else ""
            names += [
                f"{p}days_since_last_encounter",
                f"{p}encounter_in_last_90d",
                f"{p}fiscal_year_of_encounter",
            ]
        return np.array(names, dtype=object)

    def __sklearn_tags__(self) -> Tags:
        tags = super().__sklearn_tags__()
        tags.input_tags.allow_nan = True
        tags.input_tags.string = True  # Date columns are string-like on entry
        return tags

fit(X, y=None)

Validate parameters and freeze the reference date from training data.

Parameters:

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

Input data. Must contain the column(s) specified in date_col when passed as a pd.DataFrame.

required
y ignored
None

Returns:

Name Type Description
self EncounterRecencyTransformer
Source code in philanthropy/preprocessing/_encounter_recency.py
def fit(self: _Self, X: Any, y: Any = None) -> _Self:
    """Validate parameters and freeze the reference date from training data.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Input data.  Must contain the column(s) specified in ``date_col``
        when passed as a pd.DataFrame.
    y : ignored

    Returns
    -------
    self : EncounterRecencyTransformer
    """
    self._validate_fiscal_year_start()

    # Register the input schema via validate_data; allow NaN and string types.
    validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=True)

    # Freeze the reference date:
    if self.reference_date is not None:
        self.reference_date_ = pd.Timestamp(self.reference_date)
    else:
        # Infer from training data: max observed date across all date columns.
        if isinstance(X, pd.DataFrame):
            cols = self._resolve_date_cols()
            max_dates = []
            for col in cols:
                if col in X.columns:
                    parsed = self._parse_dates(X[col])
                    mx = parsed.max()
                    if not pd.isna(mx):
                        max_dates.append(mx)
            if max_dates:
                self.reference_date_ = max(max_dates)
            else:
                warnings.warn(
                    "EncounterRecencyTransformer: no parseable dates found in "
                    "training data; defaulting reference_date_ to today.",
                    UserWarning,
                )
                self.reference_date_ = pd.Timestamp.today().normalize()
        else:
            # Cannot infer from ndarray without column names; default to today.
            warnings.warn(
                "EncounterRecencyTransformer: X is not a DataFrame, "
                "defaulting reference_date_ to today. Provide reference_date "
                "explicitly for reproducibility.",
                UserWarning,
            )
            self.reference_date_ = pd.Timestamp.today().normalize()

    return self

transform(X, y=None)

Compute encounter recency features.

Parameters:

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

Input data containing the date column(s).

required

Returns:

Name Type Description
X_out np.ndarray of shape (n_samples, 3 * n_date_cols), dtype float64

Feature columns, in order, for each date_col:

  • [<col>__]days_since_last_encounter
  • [<col>__]encounter_in_last_90d
  • [<col>__]fiscal_year_of_encounter

Raises:

Type Description
NotFittedError

If :meth:fit has not been called yet.

Source code in philanthropy/preprocessing/_encounter_recency.py
def transform(self, X: Any, y: Any = None) -> np.ndarray:
    """Compute encounter recency features.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Input data containing the date column(s).

    Returns
    -------
    X_out : np.ndarray of shape (n_samples, 3 * n_date_cols), dtype float64
        Feature columns, in order, for each ``date_col``:

        * ``[<col>__]days_since_last_encounter``
        * ``[<col>__]encounter_in_last_90d``
        * ``[<col>__]fiscal_year_of_encounter``

    Raises
    ------
    sklearn.exceptions.NotFittedError
        If :meth:`fit` has not been called yet.
    """
    check_is_fitted(self, ["reference_date_"])
    validate_data(self, X, dtype=None, ensure_all_finite="allow-nan", reset=False)

    # Build a working DataFrame from X
    if isinstance(X, pd.DataFrame):
        df = X
    elif hasattr(self, "feature_names_in_"):
        df = pd.DataFrame(
            np.asarray(X, dtype=object), columns=self.feature_names_in_
        )
    else:
        # Fallback: cannot resolve column names; produce NaN output.
        n = np.asarray(X).shape[0]
        cols = self._resolve_date_cols()
        n_out = len(cols) * 3
        return np.full((n, n_out), np.nan, dtype=np.float64)

    cols = self._resolve_date_cols()
    parts: list[pd.DataFrame] = []
    prefix_needed = len(cols) > 1

    for col in cols:
        prefix = col if prefix_needed else ""
        if col in df.columns:
            parsed = self._parse_dates(df[col])
        else:
            warnings.warn(
                f"EncounterRecencyTransformer: date column {col!r} not found "
                f"in X; filling recency features with NaN.",
                UserWarning,
            )
            n = len(df)
            parsed = pd.Series(pd.NaT, index=df.index)
            parsed = self._parse_dates(pd.Series([None] * n))

        parts.append(self._compute_recency_features(parsed, prefix=prefix))

    out_df = pd.concat(parts, axis=1) if parts else pd.DataFrame()
    return out_df.to_numpy(dtype=np.float64)

get_feature_names_out(input_features=None)

Return output feature names.

Returns:

Name Type Description
feature_names ndarray of str
Source code in philanthropy/preprocessing/_encounter_recency.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return output feature names.

    Returns
    -------
    feature_names : ndarray of str
    """
    check_is_fitted(self, ["reference_date_"])
    cols = self._resolve_date_cols()
    prefix_needed = len(cols) > 1
    names: list[str] = []
    for col in cols:
        p = f"{col}__" if prefix_needed else ""
        names += [
            f"{p}days_since_last_encounter",
            f"{p}encounter_in_last_90d",
            f"{p}fiscal_year_of_encounter",
        ]
    return np.array(names, dtype=object)

WealthScreeningImputerKNN

Bases: TransformerMixin, BaseEstimator

Leakage-safe KNN imputation for wealth-screening vendor columns.

Extends the median/mean/zero strategy of :class:~philanthropy.preprocessing.WealthScreeningImputer with a "knn" strategy using :class:sklearn.impute.KNNImputer. KNN imputation is recommended when wealth columns cluster meaningfully (e.g., by zip-code based real-estate quartile), which is common in curated hospital prospect pools where WealthEngine / DonorSearch data has geographic structure.

This estimator delegates to sklearn.impute.KNNImputer internally and inherits its Pipeline composability and clone-safety.

Parameters:

Name Type Description Default
wealth_cols list of str or None

Subset of columns to impute. If None, all columns whose names contain substrings from a canonical set (net_worth, real_estate, stock, capacity, charitable) are imputed.

None
strategy ('median', 'mean', 'zero', 'knn')

Imputation strategy. "knn" uses :class:sklearn.impute.KNNImputer with n_neighbors. The other strategies use columnwise statistics identical to :class:~philanthropy.preprocessing.WealthScreeningImputer.

"median"
n_neighbors int

Number of neighbours used when strategy="knn". Ignored for other strategies.

5
add_indicator bool

Append a binary <col>__was_missing column for each imputed wealth column. Strongly recommended: absence of vendor records itself carries predictive signal.

True
group_col_idx int or None

.. deprecated:: 0.7.0 Passing group_col_idx emits a DeprecationWarning and the parameter is removed in 0.8.0. It still works meanwhile; there is no replacement, because there is nothing to replace.

The reason is measurement, not tidiness. Across several synthetic
two-group pools, and on five Python versions in CI, the grouped and
global fits produce **bit-identical** output: 50263.48615163204 both
ways. That is not a near miss. A donor's nearest neighbours by
feature distance almost always share their group already, so
restricting the fit to the group changes nothing, and
:class:`~sklearn.impute.KNNImputer` weights distance by column
magnitude, so a 0/1 group flag contributes almost nothing on its own.
The parameter costs a per-group imputer, three fallback paths and a
documented contract, and buys no measurable accuracy. If you need
per-group behaviour, split the frame by group and fit one imputer per
part, which is explicit and costs nothing here.

Column index of a group variable (for example a zip code encoded as an int) to stratify KNN imputation. When set and strategy="knn", a separate :class:~sklearn.impute.KNNImputer is fitted per group, so a donor's missing wealth is filled from neighbours inside their own group rather than from the whole database. Ignored for the other strategies, which are columnwise statistics with no notion of a neighbourhood.

Two fallbacks, both frozen at :meth:fit time so nothing is learned at transform time:

  • A group with fewer than n_neighbors + 1 training rows gets no imputer of its own, because KNN over too few neighbours is worse than the global fit. Its rows use the global imputer.
  • A group value not seen during fit, or a row whose group value is missing, also uses the global imputer. This is the leakage-safe choice: the alternative is fitting on the data being transformed.

The global imputer is always fitted, so the output is never NaN regardless of grouping.

None

Attributes:

Name Type Description
imputed_cols_ list of str

Wealth columns that were actually present in X at fit time.

fill_values_ dict of {str: float}

Fill statistics (only populated for non-KNN strategies).

group_imputers_ dict of {float: tuple}

Maps each group value that qualified for its own imputer to (fitted KNNImputer, boolean mask of columns entirely missing within that group). Empty when group_col_idx is None or strategy is not "knn".

knn_imputer_ KNNImputer or None

The fitted :class:~sklearn.impute.KNNImputer instance (only populated for strategy="knn").

n_features_in_ int

Number of columns in X at fit time.

feature_names_in_ ndarray of str

Column names of X at fit time.

Examples:

>>> import numpy as np
>>> from philanthropy.preprocessing._share_of_wallet import WealthScreeningImputerKNN
>>> rng = np.random.default_rng(42)
>>> X = rng.uniform(0, 1e6, (50, 3))
>>> X[rng.random((50, 3)) < 0.3] = np.nan
>>> imp = WealthScreeningImputerKNN(strategy="knn", n_neighbors=3, add_indicator=False)
>>> imp.fit(X)
WealthScreeningImputerKNN(...)
>>> out = imp.transform(X)
>>> bool(np.isnan(out).any())
False
Source code in philanthropy/preprocessing/_share_of_wallet.py
class WealthScreeningImputerKNN(TransformerMixin, BaseEstimator):
    """Leakage-safe KNN imputation for wealth-screening vendor columns.

    Extends the median/mean/zero strategy of
    :class:`~philanthropy.preprocessing.WealthScreeningImputer` with a
    ``"knn"`` strategy using :class:`sklearn.impute.KNNImputer`.  KNN
    imputation is recommended when wealth columns cluster meaningfully
    (e.g., by zip-code based real-estate quartile), which is common in
    curated hospital prospect pools where WealthEngine / DonorSearch
    data has geographic structure.

    This estimator **delegates** to ``sklearn.impute.KNNImputer`` internally
    and inherits its Pipeline composability and clone-safety.

    Parameters
    ----------
    wealth_cols : list of str or None, default=None
        Subset of columns to impute.  If ``None``, all columns whose
        names contain substrings from a canonical set (``net_worth``,
        ``real_estate``, ``stock``, ``capacity``, ``charitable``) are
        imputed.
    strategy : {"median", "mean", "zero", "knn"}, default="knn"
        Imputation strategy.  ``"knn"`` uses
        :class:`sklearn.impute.KNNImputer` with ``n_neighbors``.
        The other strategies use columnwise statistics identical to
        :class:`~philanthropy.preprocessing.WealthScreeningImputer`.
    n_neighbors : int, default=5
        Number of neighbours used when ``strategy="knn"``.  Ignored for
        other strategies.
    add_indicator : bool, default=True
        Append a binary ``<col>__was_missing`` column for each imputed
        wealth column.  Strongly recommended: absence of vendor records
        itself carries predictive signal.
    group_col_idx : int or None, default=None
        .. deprecated:: 0.7.0
            Passing ``group_col_idx`` emits a ``DeprecationWarning`` and the
            parameter is removed in 0.8.0. It still works meanwhile; there is no
            replacement, because there is nothing to replace.

            The reason is measurement, not tidiness. Across several synthetic
            two-group pools, and on five Python versions in CI, the grouped and
            global fits produce **bit-identical** output: 50263.48615163204 both
            ways. That is not a near miss. A donor's nearest neighbours by
            feature distance almost always share their group already, so
            restricting the fit to the group changes nothing, and
            :class:`~sklearn.impute.KNNImputer` weights distance by column
            magnitude, so a 0/1 group flag contributes almost nothing on its own.
            The parameter costs a per-group imputer, three fallback paths and a
            documented contract, and buys no measurable accuracy. If you need
            per-group behaviour, split the frame by group and fit one imputer per
            part, which is explicit and costs nothing here.

        Column index of a group variable (for example a zip code encoded as an
        int) to stratify KNN imputation. When set and ``strategy="knn"``, a
        separate :class:`~sklearn.impute.KNNImputer` is fitted per group, so a
        donor's missing wealth is filled from neighbours inside their own group
        rather than from the whole database. Ignored for the other strategies,
        which are columnwise statistics with no notion of a neighbourhood.

        Two fallbacks, both frozen at :meth:`fit` time so nothing is learned at
        transform time:

        * A group with fewer than ``n_neighbors + 1`` training rows gets no
          imputer of its own, because KNN over too few neighbours is worse than
          the global fit. Its rows use the global imputer.
        * A group value **not seen during fit**, or a row whose group value is
          missing, also uses the global imputer. This is the leakage-safe
          choice: the alternative is fitting on the data being transformed.

        The global imputer is always fitted, so the output is never ``NaN``
        regardless of grouping.

    Attributes
    ----------
    imputed_cols_ : list of str
        Wealth columns that were actually present in ``X`` at fit time.
    fill_values_ : dict of {str: float}
        Fill statistics (only populated for non-KNN strategies).
    group_imputers_ : dict of {float: tuple}
        Maps each group value that qualified for its own imputer to
        ``(fitted KNNImputer, boolean mask of columns entirely missing within
        that group)``. Empty when ``group_col_idx`` is ``None`` or ``strategy``
        is not ``"knn"``.
    knn_imputer_ : KNNImputer or None
        The fitted :class:`~sklearn.impute.KNNImputer` instance
        (only populated for ``strategy="knn"``).
    n_features_in_ : int
        Number of columns in ``X`` at fit time.
    feature_names_in_ : ndarray of str
        Column names of ``X`` at fit time.

    Examples
    --------
    >>> import numpy as np
    >>> from philanthropy.preprocessing._share_of_wallet import WealthScreeningImputerKNN
    >>> rng = np.random.default_rng(42)
    >>> X = rng.uniform(0, 1e6, (50, 3))
    >>> X[rng.random((50, 3)) < 0.3] = np.nan
    >>> imp = WealthScreeningImputerKNN(strategy="knn", n_neighbors=3, add_indicator=False)
    >>> imp.fit(X)
    WealthScreeningImputerKNN(...)
    >>> out = imp.transform(X)
    >>> bool(np.isnan(out).any())
    False
    """

    _VALID_STRATEGIES = frozenset({"median", "mean", "zero", "knn"})
    _CANONICAL_SUBSTRINGS = ("net_worth", "real_estate", "stock", "capacity", "charitable")

    def __init__(
        self,
        wealth_cols: list[str] | None = None,
        strategy: Literal["median", "mean", "zero", "knn"] = "knn",
        n_neighbors: int = 5,
        add_indicator: bool = True,
        group_col_idx: Optional[int] = None,
    ) -> None:
        self.wealth_cols = wealth_cols
        self.strategy = strategy
        self.n_neighbors = n_neighbors
        self.add_indicator = add_indicator
        self.group_col_idx = group_col_idx

    def _resolve_cols(self, input_cols: list[str]) -> list[str]:
        if self.wealth_cols is not None:
            return [c for c in self.wealth_cols if c in input_cols]
        return [c for c in input_cols
                if any(sub in c.lower() for sub in self._CANONICAL_SUBSTRINGS)]

    def fit(self: _SelfK, X: Any, y: Any = None) -> _SelfK:
        """Learn fill statistics or fit the KNN imputer from training data.

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

        Returns
        -------
        self : WealthScreeningImputerKNN
        """
        if self.group_col_idx is not None:
            warnings.warn(
                "WealthScreeningImputerKNN(group_col_idx=...) is deprecated "
                "since 0.7.0 and will be removed in 0.8.0. It has no "
                "replacement: measured across several synthetic pools and five "
                "Python versions in CI, per-group and global KNN imputation "
                "produce bit-identical output, so the parameter buys no "
                "accuracy. Split the frame by group and fit one imputer per part "
                "if you need that behaviour.",
                DeprecationWarning,
                stacklevel=2,
            )

        if self.strategy not in self._VALID_STRATEGIES:
            raise ValueError(
                f"`strategy` must be one of {sorted(self._VALID_STRATEGIES)}, "
                f"got {self.strategy!r}."
            )
        if self.strategy == "knn" and self.n_neighbors < 1:
            raise ValueError(f"`n_neighbors` must be >= 1, got {self.n_neighbors}.")

        # Capture column names before validate_data converts DF → ndarray
        if hasattr(X, "columns"):
            input_cols = list(X.columns)
        else:
            input_cols = None

        X_arr = validate_data(
            self, X, dtype="numeric", ensure_all_finite="allow-nan", reset=True
        )

        if input_cols is None:
            input_cols = (
                list(self.feature_names_in_)
                if hasattr(self, "feature_names_in_")
                else [f"x{i}" for i in range(X_arr.shape[1])]
            )

        self.imputed_cols_ = self._resolve_cols(input_cols)

        # Warn about columns requested but absent
        if self.wealth_cols is not None:
            for col in self.wealth_cols:
                if col not in input_cols:
                    warnings.warn(
                        f"WealthScreeningImputerKNN: column {col!r} not found in X.",
                        UserWarning,
                    )

        col_indices = {col: input_cols.index(col) for col in self.imputed_cols_}

        if self.strategy == "knn":
            # Fit KNNImputer on ALL columns (preserves inter-column structure).
            # Always fitted, even when grouping: it is the fallback for small and
            # unseen groups, and it is what guarantees the output has no NaN.
            self.knn_imputer_: Optional[KNNImputer] = KNNImputer(
                n_neighbors=self.n_neighbors,
                weights="distance",
                keep_empty_features=True,
            )
            self.knn_imputer_.fit(X_arr)
            self.fill_values_: dict[str, float] = {}
            self.group_imputers_: dict = {}
            if self.group_col_idx is not None:
                gidx = int(self.group_col_idx)
                if not -X_arr.shape[1] <= gidx < X_arr.shape[1]:
                    raise ValueError(
                        f"`group_col_idx` {gidx} is out of range for X with "
                        f"{X_arr.shape[1]} columns."
                    )
                groups = X_arr[:, gidx]
                # A group needs more rows than neighbours for KNN to mean
                # anything; smaller groups deliberately get no imputer and fall
                # back to the global one.
                min_rows = self.n_neighbors + 1
                for value in np.unique(groups[~np.isnan(groups)]):
                    rows = groups == value
                    if int(rows.sum()) < min_rows:
                        continue
                    sub_imputer = KNNImputer(
                        n_neighbors=self.n_neighbors,
                        weights="distance",
                        keep_empty_features=True,
                    )
                    sub_imputer.fit(X_arr[rows])
                    # Columns entirely missing WITHIN this group. KNNImputer with
                    # keep_empty_features=True fills those with a hard 0.0, not
                    # NaN, so a NaN check at transform time cannot see them. For a
                    # wealth column, 0.0 reads as "no capacity", which is a
                    # materially wrong answer rather than a missing one. Record
                    # them so transform defers to the global imputer instead.
                    self.group_imputers_[float(value)] = (
                        sub_imputer,
                        np.isnan(X_arr[rows]).all(axis=0),
                    )
        else:
            self.knn_imputer_ = None
            self.group_imputers_ = {}
            fills: dict[str, float] = {}
            for col in self.imputed_cols_:
                idx = col_indices[col]
                col_data = X_arr[:, idx]
                if self.strategy == "median":
                    val = np.nanmedian(col_data)
                elif self.strategy == "mean":
                    val = np.nanmean(col_data)
                else:  # "zero"
                    val = 0.0
                fills[col] = float(val) if not np.isnan(val) else 0.0
            self.fill_values_ = fills

        self._col_indices_ = col_indices
        return self

    def transform(self, X: Any, y: Any = None) -> np.ndarray:
        """Apply imputation and optionally append missingness indicators.

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

        Returns
        -------
        X_out : np.ndarray
            Imputed array (float64), with indicator columns appended if
            ``add_indicator=True``.

        Raises
        ------
        sklearn.exceptions.NotFittedError
        """
        check_is_fitted(self, ["imputed_cols_"])

        if hasattr(X, "columns"):
            input_cols = list(X.columns)
        else:
            input_cols = None

        X_arr = validate_data(
            self, X, dtype="numeric", ensure_all_finite="allow-nan", reset=False
        )

        if input_cols is None:
            input_cols = (
                list(self.feature_names_in_)
                if hasattr(self, "feature_names_in_")
                else [f"x{i}" for i in range(X_arr.shape[1])]
            )

        # Collect missingness masks BEFORE imputation
        indicators: list[np.ndarray] = []
        if self.add_indicator:
            for col in self.imputed_cols_:
                if col in input_cols:
                    idx = input_cols.index(col)
                    indicators.append(np.isnan(X_arr[:, idx]).astype(np.float64).reshape(-1, 1))

        if self.strategy == "knn" and self.knn_imputer_ is not None:
            # Global result first: this is the fallback for small groups, unseen
            # groups and missing group labels, and it guarantees no NaN survives.
            X_out = self.knn_imputer_.transform(X_arr)
            group_imputers = getattr(self, "group_imputers_", {})
            if group_imputers and self.group_col_idx is not None:
                groups = X_arr[:, int(self.group_col_idx)]
                for value, (sub_imputer, empty_cols) in group_imputers.items():
                    rows = groups == value
                    if not rows.any():
                        continue
                    X_group = sub_imputer.transform(X_arr[rows])
                    # Prefer the group-local value, except where it cannot be
                    # trusted: a NaN it failed to fill, or a column entirely
                    # missing inside this group, where KNNImputer returns a hard
                    # 0.0 that would read as real data.
                    reject = np.isnan(X_group) | empty_cols[None, :]
                    X_out[rows] = np.where(reject, X_out[rows], X_group)
        else:
            X_out = X_arr.copy()
            for col in self.imputed_cols_:
                if col not in input_cols:
                    continue
                idx = input_cols.index(col)
                mask = np.isnan(X_out[:, idx])
                X_out[mask, idx] = self.fill_values_.get(col, 0.0)

        if indicators:
            return np.hstack([X_out] + indicators)
        return X_out

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return imputed feature names and optional missingness indicators.

        Parameters
        ----------
        input_features : array-like of str or None, default=None
            Input feature names to use. When omitted, fitted names are used, or
            ``x0``, ``x1``, ... for unnamed input.

        Returns
        -------
        feature_names_out : ndarray of str
            Base feature names, followed by ``<column>__was_missing`` for each
            imputed column when ``add_indicator=True``.

        Raises
        ------
        NotFittedError
            If the imputer has not been fitted.
        """
        check_is_fitted(self)
        if input_features is not None:
            base = list(input_features)
        elif hasattr(self, "feature_names_in_"):
            base = list(self.feature_names_in_)
        else:
            base = [f"x{i}" for i in range(self.n_features_in_)]

        out = list(base)
        if self.add_indicator:
            for col in self.imputed_cols_:
                if col in base:
                    out.append(f"{col}__was_missing")
        return np.array(out, dtype=object)

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

fit(X, y=None)

Learn fill statistics or fit the KNN imputer from training data.

Parameters:

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

Returns:

Name Type Description
self WealthScreeningImputerKNN
Source code in philanthropy/preprocessing/_share_of_wallet.py
def fit(self: _SelfK, X: Any, y: Any = None) -> _SelfK:
    """Learn fill statistics or fit the KNN imputer from training data.

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

    Returns
    -------
    self : WealthScreeningImputerKNN
    """
    if self.group_col_idx is not None:
        warnings.warn(
            "WealthScreeningImputerKNN(group_col_idx=...) is deprecated "
            "since 0.7.0 and will be removed in 0.8.0. It has no "
            "replacement: measured across several synthetic pools and five "
            "Python versions in CI, per-group and global KNN imputation "
            "produce bit-identical output, so the parameter buys no "
            "accuracy. Split the frame by group and fit one imputer per part "
            "if you need that behaviour.",
            DeprecationWarning,
            stacklevel=2,
        )

    if self.strategy not in self._VALID_STRATEGIES:
        raise ValueError(
            f"`strategy` must be one of {sorted(self._VALID_STRATEGIES)}, "
            f"got {self.strategy!r}."
        )
    if self.strategy == "knn" and self.n_neighbors < 1:
        raise ValueError(f"`n_neighbors` must be >= 1, got {self.n_neighbors}.")

    # Capture column names before validate_data converts DF → ndarray
    if hasattr(X, "columns"):
        input_cols = list(X.columns)
    else:
        input_cols = None

    X_arr = validate_data(
        self, X, dtype="numeric", ensure_all_finite="allow-nan", reset=True
    )

    if input_cols is None:
        input_cols = (
            list(self.feature_names_in_)
            if hasattr(self, "feature_names_in_")
            else [f"x{i}" for i in range(X_arr.shape[1])]
        )

    self.imputed_cols_ = self._resolve_cols(input_cols)

    # Warn about columns requested but absent
    if self.wealth_cols is not None:
        for col in self.wealth_cols:
            if col not in input_cols:
                warnings.warn(
                    f"WealthScreeningImputerKNN: column {col!r} not found in X.",
                    UserWarning,
                )

    col_indices = {col: input_cols.index(col) for col in self.imputed_cols_}

    if self.strategy == "knn":
        # Fit KNNImputer on ALL columns (preserves inter-column structure).
        # Always fitted, even when grouping: it is the fallback for small and
        # unseen groups, and it is what guarantees the output has no NaN.
        self.knn_imputer_: Optional[KNNImputer] = KNNImputer(
            n_neighbors=self.n_neighbors,
            weights="distance",
            keep_empty_features=True,
        )
        self.knn_imputer_.fit(X_arr)
        self.fill_values_: dict[str, float] = {}
        self.group_imputers_: dict = {}
        if self.group_col_idx is not None:
            gidx = int(self.group_col_idx)
            if not -X_arr.shape[1] <= gidx < X_arr.shape[1]:
                raise ValueError(
                    f"`group_col_idx` {gidx} is out of range for X with "
                    f"{X_arr.shape[1]} columns."
                )
            groups = X_arr[:, gidx]
            # A group needs more rows than neighbours for KNN to mean
            # anything; smaller groups deliberately get no imputer and fall
            # back to the global one.
            min_rows = self.n_neighbors + 1
            for value in np.unique(groups[~np.isnan(groups)]):
                rows = groups == value
                if int(rows.sum()) < min_rows:
                    continue
                sub_imputer = KNNImputer(
                    n_neighbors=self.n_neighbors,
                    weights="distance",
                    keep_empty_features=True,
                )
                sub_imputer.fit(X_arr[rows])
                # Columns entirely missing WITHIN this group. KNNImputer with
                # keep_empty_features=True fills those with a hard 0.0, not
                # NaN, so a NaN check at transform time cannot see them. For a
                # wealth column, 0.0 reads as "no capacity", which is a
                # materially wrong answer rather than a missing one. Record
                # them so transform defers to the global imputer instead.
                self.group_imputers_[float(value)] = (
                    sub_imputer,
                    np.isnan(X_arr[rows]).all(axis=0),
                )
    else:
        self.knn_imputer_ = None
        self.group_imputers_ = {}
        fills: dict[str, float] = {}
        for col in self.imputed_cols_:
            idx = col_indices[col]
            col_data = X_arr[:, idx]
            if self.strategy == "median":
                val = np.nanmedian(col_data)
            elif self.strategy == "mean":
                val = np.nanmean(col_data)
            else:  # "zero"
                val = 0.0
            fills[col] = float(val) if not np.isnan(val) else 0.0
        self.fill_values_ = fills

    self._col_indices_ = col_indices
    return self

transform(X, y=None)

Apply imputation and optionally append missingness indicators.

Parameters:

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

Returns:

Name Type Description
X_out ndarray

Imputed array (float64), with indicator columns appended if add_indicator=True.

Raises:

Type Description
NotFittedError
Source code in philanthropy/preprocessing/_share_of_wallet.py
def transform(self, X: Any, y: Any = None) -> np.ndarray:
    """Apply imputation and optionally append missingness indicators.

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

    Returns
    -------
    X_out : np.ndarray
        Imputed array (float64), with indicator columns appended if
        ``add_indicator=True``.

    Raises
    ------
    sklearn.exceptions.NotFittedError
    """
    check_is_fitted(self, ["imputed_cols_"])

    if hasattr(X, "columns"):
        input_cols = list(X.columns)
    else:
        input_cols = None

    X_arr = validate_data(
        self, X, dtype="numeric", ensure_all_finite="allow-nan", reset=False
    )

    if input_cols is None:
        input_cols = (
            list(self.feature_names_in_)
            if hasattr(self, "feature_names_in_")
            else [f"x{i}" for i in range(X_arr.shape[1])]
        )

    # Collect missingness masks BEFORE imputation
    indicators: list[np.ndarray] = []
    if self.add_indicator:
        for col in self.imputed_cols_:
            if col in input_cols:
                idx = input_cols.index(col)
                indicators.append(np.isnan(X_arr[:, idx]).astype(np.float64).reshape(-1, 1))

    if self.strategy == "knn" and self.knn_imputer_ is not None:
        # Global result first: this is the fallback for small groups, unseen
        # groups and missing group labels, and it guarantees no NaN survives.
        X_out = self.knn_imputer_.transform(X_arr)
        group_imputers = getattr(self, "group_imputers_", {})
        if group_imputers and self.group_col_idx is not None:
            groups = X_arr[:, int(self.group_col_idx)]
            for value, (sub_imputer, empty_cols) in group_imputers.items():
                rows = groups == value
                if not rows.any():
                    continue
                X_group = sub_imputer.transform(X_arr[rows])
                # Prefer the group-local value, except where it cannot be
                # trusted: a NaN it failed to fill, or a column entirely
                # missing inside this group, where KNNImputer returns a hard
                # 0.0 that would read as real data.
                reject = np.isnan(X_group) | empty_cols[None, :]
                X_out[rows] = np.where(reject, X_out[rows], X_group)
    else:
        X_out = X_arr.copy()
        for col in self.imputed_cols_:
            if col not in input_cols:
                continue
            idx = input_cols.index(col)
            mask = np.isnan(X_out[:, idx])
            X_out[mask, idx] = self.fill_values_.get(col, 0.0)

    if indicators:
        return np.hstack([X_out] + indicators)
    return X_out

get_feature_names_out(input_features=None)

Return imputed feature names and optional missingness indicators.

Parameters:

Name Type Description Default
input_features array-like of str or None

Input feature names to use. When omitted, fitted names are used, or x0, x1, ... for unnamed input.

None

Returns:

Name Type Description
feature_names_out ndarray of str

Base feature names, followed by <column>__was_missing for each imputed column when add_indicator=True.

Raises:

Type Description
NotFittedError

If the imputer has not been fitted.

Source code in philanthropy/preprocessing/_share_of_wallet.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return imputed feature names and optional missingness indicators.

    Parameters
    ----------
    input_features : array-like of str or None, default=None
        Input feature names to use. When omitted, fitted names are used, or
        ``x0``, ``x1``, ... for unnamed input.

    Returns
    -------
    feature_names_out : ndarray of str
        Base feature names, followed by ``<column>__was_missing`` for each
        imputed column when ``add_indicator=True``.

    Raises
    ------
    NotFittedError
        If the imputer has not been fitted.
    """
    check_is_fitted(self)
    if input_features is not None:
        base = list(input_features)
    elif hasattr(self, "feature_names_in_"):
        base = list(self.feature_names_in_)
    else:
        base = [f"x{i}" for i in range(self.n_features_in_)]

    out = list(base)
    if self.add_indicator:
        for col in self.imputed_cols_:
            if col in base:
                out.append(f"{col}__was_missing")
    return np.array(out, dtype=object)

ShareOfWalletScorer

Bases: TransformerMixin, BaseEstimator

Compute a normalised Share-of-Wallet score and capacity-tier label.

This transformer is designed as the final stage of a major-gift capacity scoring pipeline. It consumes a numeric feature matrix and produces two outputs per row:

capacity_utilisation_ratio (float64, [0, 1]) Capacity utilisation:

    score = estimated_capacity / (clipped_modelled_wealth + epsilon)

where ``clipped_modelled_wealth`` is the row-wise sum of the columns in
``wealth_col_indices`` (or all columns if unspecified), clipped at the
95th percentile of that sum frozen at ``fit`` time, and
``estimated_capacity`` is the column at ``capacity_col_idx``.

.. warning::
   Despite the class name this is **not** share of wallet in the
   fundraising sense. No term for giving to *your* institution appears
   anywhere in the formula, so the score cannot say what fraction of a
   donor's philanthropy you receive. A true share of wallet needs
   institutional giving in the numerator and total estimated giving in
   the denominator, and this transformer is given neither. Read it as
   "how much of this donor's modelled wealth is estimated to be
   philanthropic capacity". Until 0.8.0 the column was named
   ``sow_score``, which claimed a quantity the formula does not
   compute; :meth:`get_legacy_feature_names_out` still spells it that
   way, under a `DeprecationWarning`, until 0.9.0 removes it.

The denominator is clipped at the fit-time 95th percentile, so the
wealthiest rows share one denominator and are pushed toward the top of
the range. That keeps the ratio in a usable band, but it means
top-tier membership is partly an artefact of the clip, in exactly the
tier a principal-gift program cares about most. Inspect
``wealth_scale_`` before trusting the tiers.

capacity_tier (float64, categorical encoding) A numeric encoding of the human-readable tier label, usable by downstream sklearn estimators (e.g., a classifier trained to predict tier upgrades). The cut points are the major_tier_threshold and principal_tier_threshold parameters, which have no published source and are institution-specific:

================================ ============
Score                            Tier label
================================ ============
>= ``principal_tier_threshold``  Principal
>= ``major_tier_threshold``      Major
otherwise                        Leadership
================================ ============

What a gift officer should do with each tier is a program decision, not
a property of the score, so no action is prescribed here.

Parameters:

Name Type Description Default
capacity_col_idx int

Column index (0-based) in X containing the estimated philanthropic capacity (in dollars or any consistent currency unit).

0
wealth_col_indices list of int or None

Column indices to sum as "total modelled wealth". If None, all columns are summed (including capacity_col_idx).

None
epsilon float

Small constant added to the denominator to prevent division by zero when all wealth columns are zero.

1.0
capacity_floor float

Minimum value to enforce on estimated_capacity before scoring (prevents negative capacity from distorting the SoW score).

0.0

Attributes:

Name Type Description
wealth_scale_ float

95th-percentile total modelled wealth observed at fit time, used to clip outlier wealth sums during :meth:transform. This prevents a single ultra-high-net-worth outlier from compressing all other scores near 0.

n_features_in_ int
feature_names_in_ ndarray of str

Examples:

>>> import numpy as np
>>> from philanthropy.preprocessing._share_of_wallet import ShareOfWalletScorer
>>> rng = np.random.default_rng(0)
>>> X = rng.uniform(0, 1e6, (20, 4))
>>> scorer = ShareOfWalletScorer(capacity_col_idx=0, epsilon=1.0)
>>> scorer.fit(X)
ShareOfWalletScorer(...)
>>> out = scorer.transform(X)
>>> out.shape
(20, 2)
>>> bool(((out[:, 0] >= 0) & (out[:, 0] <= 1)).all())
True
Source code in philanthropy/preprocessing/_share_of_wallet.py
class ShareOfWalletScorer(TransformerMixin, BaseEstimator):
    """Compute a normalised Share-of-Wallet score and capacity-tier label.

    This transformer is designed as the **final stage** of a major-gift
    capacity scoring pipeline.  It consumes a numeric feature matrix and
    produces two outputs per row:

    ``capacity_utilisation_ratio`` (float64, [0, 1])
        Capacity utilisation:

            score = estimated_capacity / (clipped_modelled_wealth + epsilon)

        where ``clipped_modelled_wealth`` is the row-wise sum of the columns in
        ``wealth_col_indices`` (or all columns if unspecified), clipped at the
        95th percentile of that sum frozen at ``fit`` time, and
        ``estimated_capacity`` is the column at ``capacity_col_idx``.

        .. warning::
           Despite the class name this is **not** share of wallet in the
           fundraising sense. No term for giving to *your* institution appears
           anywhere in the formula, so the score cannot say what fraction of a
           donor's philanthropy you receive. A true share of wallet needs
           institutional giving in the numerator and total estimated giving in
           the denominator, and this transformer is given neither. Read it as
           "how much of this donor's modelled wealth is estimated to be
           philanthropic capacity". Until 0.8.0 the column was named
           ``sow_score``, which claimed a quantity the formula does not
           compute; :meth:`get_legacy_feature_names_out` still spells it that
           way, under a `DeprecationWarning`, until 0.9.0 removes it.

        The denominator is clipped at the fit-time 95th percentile, so the
        wealthiest rows share one denominator and are pushed toward the top of
        the range. That keeps the ratio in a usable band, but it means
        top-tier membership is partly an artefact of the clip, in exactly the
        tier a principal-gift program cares about most. Inspect
        ``wealth_scale_`` before trusting the tiers.

    ``capacity_tier`` (float64, categorical encoding)
        A numeric encoding of the human-readable tier label, usable by
        downstream sklearn estimators (e.g., a classifier trained to
        predict tier upgrades).  The cut points are the
        ``major_tier_threshold`` and ``principal_tier_threshold`` parameters,
        which have no published source and are institution-specific:

        ================================ ============
        Score                            Tier label
        ================================ ============
        >= ``principal_tier_threshold``  Principal
        >= ``major_tier_threshold``      Major
        otherwise                        Leadership
        ================================ ============

        What a gift officer should do with each tier is a program decision, not
        a property of the score, so no action is prescribed here.

    Parameters
    ----------
    capacity_col_idx : int, default=0
        Column index (0-based) in ``X`` containing the estimated
        philanthropic capacity (in dollars or any consistent currency unit).
    wealth_col_indices : list of int or None, default=None
        Column indices to sum as "total modelled wealth".  If ``None``,
        all columns are summed (including ``capacity_col_idx``).
    epsilon : float, default=1.0
        Small constant added to the denominator to prevent division by
        zero when all wealth columns are zero.
    capacity_floor : float, default=0.0
        Minimum value to enforce on ``estimated_capacity`` before scoring
        (prevents negative capacity from distorting the SoW score).

    Attributes
    ----------
    wealth_scale_ : float
        95th-percentile total modelled wealth observed at fit time, used to
        clip outlier wealth sums during :meth:`transform`.  This prevents a
        single ultra-high-net-worth outlier from compressing all other
        scores near 0.
    n_features_in_ : int
    feature_names_in_ : ndarray of str

    Examples
    --------
    >>> import numpy as np
    >>> from philanthropy.preprocessing._share_of_wallet import ShareOfWalletScorer
    >>> rng = np.random.default_rng(0)
    >>> X = rng.uniform(0, 1e6, (20, 4))
    >>> scorer = ShareOfWalletScorer(capacity_col_idx=0, epsilon=1.0)
    >>> scorer.fit(X)
    ShareOfWalletScorer(...)
    >>> out = scorer.transform(X)
    >>> out.shape
    (20, 2)
    >>> bool(((out[:, 0] >= 0) & (out[:, 0] <= 1)).all())
    True
    """

    # Public tier-label mapping for callers who need string labels
    TIER_LABELS = {0: "Leadership", 1: "Major", 2: "Principal"}
    TIER_ENCODING = {"Leadership": 0, "Major": 1, "Principal": 2}

    def __init__(
        self,
        capacity_col_idx: int = 0,
        wealth_col_indices: Optional[list[int]] = None,
        epsilon: float = 1.0,
        capacity_floor: float = 0.0,
        major_tier_threshold: float = 0.40,
        principal_tier_threshold: float = 0.75,
    ) -> None:
        self.capacity_col_idx = capacity_col_idx
        self.wealth_col_indices = wealth_col_indices
        self.epsilon = epsilon
        self.capacity_floor = capacity_floor
        self.major_tier_threshold = major_tier_threshold
        self.principal_tier_threshold = principal_tier_threshold

    def fit(self: _SelfS, X: Any, y: Any = None) -> _SelfS:
        """Fit the scorer: record wealth scale from training data.

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

        Returns
        -------
        self : ShareOfWalletScorer
        """
        if self.epsilon < 0:
            raise ValueError(f"`epsilon` must be >= 0, got {self.epsilon}.")
        if not (0 <= self.capacity_col_idx):
            raise ValueError(f"`capacity_col_idx` must be >= 0, got {self.capacity_col_idx}.")

        X_arr = validate_data(
            self, X, dtype="numeric", ensure_all_finite="allow-nan", reset=True
        )

        # Compute total wealth denominator columns
        w_indices = (
            list(range(X_arr.shape[1]))
            if self.wealth_col_indices is None
            else [int(i) for i in self.wealth_col_indices]
        )
        wealth_sum = np.nansum(X_arr[:, w_indices], axis=1)

        # 95th-percentile scale to clip outliers and keep SoW in [0, 1]
        if len(wealth_sum) > 0:
            p95 = np.nanpercentile(wealth_sum, 95)
            self.wealth_scale_ = float(p95) if p95 > 0 else 1.0
        else:
            self.wealth_scale_ = 1.0

        return self

    def transform(self, X: Any, y: Any = None) -> np.ndarray:
        """Compute SoW score and numeric capacity tier.

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

        Returns
        -------
        X_out : np.ndarray of shape (n_samples, 2), dtype float64
            Column 0: ``capacity_utilisation_ratio`` in [0, 1].
            Column 1: ``capacity_tier`` (0 = Leadership, 1 = Major, 2 = Principal).

        Raises
        ------
        sklearn.exceptions.NotFittedError
        """
        check_is_fitted(self, ["wealth_scale_"])
        X_arr = validate_data(
            self, X, dtype="numeric", ensure_all_finite="allow-nan", reset=False
        )

        w_indices = (
            list(range(X_arr.shape[1]))
            if self.wealth_col_indices is None
            else [int(i) for i in self.wealth_col_indices]
        )

        # Capacity column
        cap_idx = int(self.capacity_col_idx)
        if cap_idx >= X_arr.shape[1]:
            raise ValueError(
                f"`capacity_col_idx` ({cap_idx}) exceeds number of columns "
                f"({X_arr.shape[1]})."
            )
        capacity = np.maximum(
            np.nan_to_num(X_arr[:, cap_idx], nan=0.0),
            self.capacity_floor,
        )

        # Wealth sum: clip at 95th-percentile scale from fit to prevent score collapse
        wealth_raw = np.nansum(X_arr[:, w_indices], axis=1)
        wealth_clipped = np.clip(wealth_raw, 0.0, self.wealth_scale_)

        # SoW = capacity / (wealth + epsilon), then clip to [0, 1]
        sow = np.clip(
            capacity / (wealth_clipped + float(self.epsilon)), 0.0, 1.0
        )

        # Tier encoding (vectorised)
        tiers = np.zeros(len(sow), dtype=np.float64)
        tiers[sow >= float(self.major_tier_threshold)] = float(
            self.TIER_ENCODING["Major"]
        )
        tiers[sow >= float(self.principal_tier_threshold)] = float(
            self.TIER_ENCODING["Principal"]
        )

        return np.column_stack([sow, tiers])

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return the capacity-utilisation ratio and encoded tier names.

        Parameters
        ----------
        input_features : array-like of str or None, default=None
            Ignored because the scorer always emits the same two features.

        Returns
        -------
        feature_names_out : ndarray of str
            ``["capacity_utilisation_ratio", "capacity_tier"]``.

        Raises
        ------
        NotFittedError
            If the scorer has not been fitted.
        """
        check_is_fitted(self)
        return np.array(
            ["capacity_utilisation_ratio", "capacity_tier"], dtype=object
        )

    def get_legacy_feature_names_out(self) -> np.ndarray:
        """Return the pre-0.8.0 output names, ``sow_score`` first.

        The formula behind column 0 is capacity utilisation, not share of
        wallet, so the column is now named ``capacity_utilisation_ratio``
        (see :meth:`get_feature_names_out`). This method keeps the old
        spelling reachable for one published minor release: it emits a
        `DeprecationWarning` and returns ``["sow_score", "capacity_tier"]``.
        Removed in 0.9.0.

        Returns
        -------
        feature_names_out : ndarray of str
            ``["sow_score", "capacity_tier"]``.

        Raises
        ------
        NotFittedError
            If the scorer has not been fitted.
        """
        check_is_fitted(self)
        warnings.warn(
            "ShareOfWalletScorer output name 'sow_score' is deprecated since "
            "0.8.0 and will be removed in 0.9.0; the column measures capacity "
            "utilisation, not share of wallet. Use "
            "get_feature_names_out(), which reports "
            "'capacity_utilisation_ratio' first.",
            DeprecationWarning,
            stacklevel=2,
        )
        return np.array(["sow_score", "capacity_tier"], dtype=object)

    def get_tier_labels(self, X: Any) -> np.ndarray:
        """Return human-readable tier labels for each row.

        Parameters
        ----------
        X : array-like compatible with :meth:`transform`

        Returns
        -------
        labels : ndarray of str, shape (n_samples,)
            One of ``"Principal"``, ``"Major"``, or ``"Leadership"`` per row.
        """
        out = self.transform(X)
        tier_ints = out[:, 1].astype(int)
        return np.array([self.TIER_LABELS[t] for t in tier_ints], dtype=object)

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

fit(X, y=None)

Fit the scorer: record wealth scale from training data.

Parameters:

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

Returns:

Name Type Description
self ShareOfWalletScorer
Source code in philanthropy/preprocessing/_share_of_wallet.py
def fit(self: _SelfS, X: Any, y: Any = None) -> _SelfS:
    """Fit the scorer: record wealth scale from training data.

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

    Returns
    -------
    self : ShareOfWalletScorer
    """
    if self.epsilon < 0:
        raise ValueError(f"`epsilon` must be >= 0, got {self.epsilon}.")
    if not (0 <= self.capacity_col_idx):
        raise ValueError(f"`capacity_col_idx` must be >= 0, got {self.capacity_col_idx}.")

    X_arr = validate_data(
        self, X, dtype="numeric", ensure_all_finite="allow-nan", reset=True
    )

    # Compute total wealth denominator columns
    w_indices = (
        list(range(X_arr.shape[1]))
        if self.wealth_col_indices is None
        else [int(i) for i in self.wealth_col_indices]
    )
    wealth_sum = np.nansum(X_arr[:, w_indices], axis=1)

    # 95th-percentile scale to clip outliers and keep SoW in [0, 1]
    if len(wealth_sum) > 0:
        p95 = np.nanpercentile(wealth_sum, 95)
        self.wealth_scale_ = float(p95) if p95 > 0 else 1.0
    else:
        self.wealth_scale_ = 1.0

    return self

transform(X, y=None)

Compute SoW score and numeric capacity tier.

Parameters:

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

Returns:

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

Column 0: capacity_utilisation_ratio in [0, 1]. Column 1: capacity_tier (0 = Leadership, 1 = Major, 2 = Principal).

Raises:

Type Description
NotFittedError
Source code in philanthropy/preprocessing/_share_of_wallet.py
def transform(self, X: Any, y: Any = None) -> np.ndarray:
    """Compute SoW score and numeric capacity tier.

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

    Returns
    -------
    X_out : np.ndarray of shape (n_samples, 2), dtype float64
        Column 0: ``capacity_utilisation_ratio`` in [0, 1].
        Column 1: ``capacity_tier`` (0 = Leadership, 1 = Major, 2 = Principal).

    Raises
    ------
    sklearn.exceptions.NotFittedError
    """
    check_is_fitted(self, ["wealth_scale_"])
    X_arr = validate_data(
        self, X, dtype="numeric", ensure_all_finite="allow-nan", reset=False
    )

    w_indices = (
        list(range(X_arr.shape[1]))
        if self.wealth_col_indices is None
        else [int(i) for i in self.wealth_col_indices]
    )

    # Capacity column
    cap_idx = int(self.capacity_col_idx)
    if cap_idx >= X_arr.shape[1]:
        raise ValueError(
            f"`capacity_col_idx` ({cap_idx}) exceeds number of columns "
            f"({X_arr.shape[1]})."
        )
    capacity = np.maximum(
        np.nan_to_num(X_arr[:, cap_idx], nan=0.0),
        self.capacity_floor,
    )

    # Wealth sum: clip at 95th-percentile scale from fit to prevent score collapse
    wealth_raw = np.nansum(X_arr[:, w_indices], axis=1)
    wealth_clipped = np.clip(wealth_raw, 0.0, self.wealth_scale_)

    # SoW = capacity / (wealth + epsilon), then clip to [0, 1]
    sow = np.clip(
        capacity / (wealth_clipped + float(self.epsilon)), 0.0, 1.0
    )

    # Tier encoding (vectorised)
    tiers = np.zeros(len(sow), dtype=np.float64)
    tiers[sow >= float(self.major_tier_threshold)] = float(
        self.TIER_ENCODING["Major"]
    )
    tiers[sow >= float(self.principal_tier_threshold)] = float(
        self.TIER_ENCODING["Principal"]
    )

    return np.column_stack([sow, tiers])

get_feature_names_out(input_features=None)

Return the capacity-utilisation ratio and encoded tier names.

Parameters:

Name Type Description Default
input_features array-like of str or None

Ignored because the scorer always emits the same two features.

None

Returns:

Name Type Description
feature_names_out ndarray of str

["capacity_utilisation_ratio", "capacity_tier"].

Raises:

Type Description
NotFittedError

If the scorer has not been fitted.

Source code in philanthropy/preprocessing/_share_of_wallet.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return the capacity-utilisation ratio and encoded tier names.

    Parameters
    ----------
    input_features : array-like of str or None, default=None
        Ignored because the scorer always emits the same two features.

    Returns
    -------
    feature_names_out : ndarray of str
        ``["capacity_utilisation_ratio", "capacity_tier"]``.

    Raises
    ------
    NotFittedError
        If the scorer has not been fitted.
    """
    check_is_fitted(self)
    return np.array(
        ["capacity_utilisation_ratio", "capacity_tier"], dtype=object
    )

get_legacy_feature_names_out()

Return the pre-0.8.0 output names, sow_score first.

The formula behind column 0 is capacity utilisation, not share of wallet, so the column is now named capacity_utilisation_ratio (see :meth:get_feature_names_out). This method keeps the old spelling reachable for one published minor release: it emits a DeprecationWarning and returns ["sow_score", "capacity_tier"]. Removed in 0.9.0.

Returns:

Name Type Description
feature_names_out ndarray of str

["sow_score", "capacity_tier"].

Raises:

Type Description
NotFittedError

If the scorer has not been fitted.

Source code in philanthropy/preprocessing/_share_of_wallet.py
def get_legacy_feature_names_out(self) -> np.ndarray:
    """Return the pre-0.8.0 output names, ``sow_score`` first.

    The formula behind column 0 is capacity utilisation, not share of
    wallet, so the column is now named ``capacity_utilisation_ratio``
    (see :meth:`get_feature_names_out`). This method keeps the old
    spelling reachable for one published minor release: it emits a
    `DeprecationWarning` and returns ``["sow_score", "capacity_tier"]``.
    Removed in 0.9.0.

    Returns
    -------
    feature_names_out : ndarray of str
        ``["sow_score", "capacity_tier"]``.

    Raises
    ------
    NotFittedError
        If the scorer has not been fitted.
    """
    check_is_fitted(self)
    warnings.warn(
        "ShareOfWalletScorer output name 'sow_score' is deprecated since "
        "0.8.0 and will be removed in 0.9.0; the column measures capacity "
        "utilisation, not share of wallet. Use "
        "get_feature_names_out(), which reports "
        "'capacity_utilisation_ratio' first.",
        DeprecationWarning,
        stacklevel=2,
    )
    return np.array(["sow_score", "capacity_tier"], dtype=object)

get_tier_labels(X)

Return human-readable tier labels for each row.

Parameters:

Name Type Description Default
X array-like compatible with :meth:`transform`
required

Returns:

Name Type Description
labels ndarray of str, shape (n_samples,)

One of "Principal", "Major", or "Leadership" per row.

Source code in philanthropy/preprocessing/_share_of_wallet.py
def get_tier_labels(self, X: Any) -> np.ndarray:
    """Return human-readable tier labels for each row.

    Parameters
    ----------
    X : array-like compatible with :meth:`transform`

    Returns
    -------
    labels : ndarray of str, shape (n_samples,)
        One of ``"Principal"``, ``"Major"``, or ``"Leadership"`` per row.
    """
    out = self.transform(X)
    tier_ints = out[:, 1].astype(int)
    return np.array([self.TIER_LABELS[t] for t in tier_ints], dtype=object)

MatchingGiftFeaturizer

Bases: TransformerMixin, BaseEstimator

Derive corporate matching-gift features for each donor row.

For every row the transformer emits three features: whether an employer is on file, the known corporate match ratio for that employer, and the potential matched dollars (gift amount times match ratio). The match-ratio lookup is normalised once at fit time (keys lowercased and stripped) and frozen, so transform depends only on each row's own values and never on which rows happen to share the batch.

Parameters:

Name Type Description Default
employer_col str

Column in X holding the donor's employer name.

"employer"
gift_col str

Column in X holding the gift amount used to size the potential matched dollars. Coerced to numeric at transform time; non-numeric or missing values are treated as 0.

"gift_amount"
match_ratios dict of {str: float} or None

Mapping of employer name to corporate match ratio (e.g. {"Boeing": 1.0, "Microsoft": 2.0}). Keys are matched case-insensitively (lowercased and stripped). None means no known employers, so every match_ratio is 0.0.

None

Attributes:

Name Type Description
match_ratios_ dict of {str: float}

Normalised copy of match_ratios (keys lowercased/stripped, values cast to float), frozen at fit time. Empty dict when match_ratios is None.

n_features_in_ int

Number of columns seen at fit time.

feature_names_in_ ndarray of str

Column names of X at fit time.

Raises:

Type Description
TypeError

If X is not a pandas DataFrame.

ValueError

If employer_col or gift_col is missing from X.

Notes

The three output columns, in order, are:

========================== =============================================== Column Description ========================== =============================================== has_employer 1.0 if the employer cell is non-null and a non-empty string, else 0.0. match_ratio match_ratios_ lookup for the normalised employer, 0.0 when unknown. potential_matched_amount Numeric gift amount (NaN -> 0) times match_ratio. ========================== ===============================================

Examples:

>>> import pandas as pd
>>> from philanthropy.preprocessing import MatchingGiftFeaturizer
>>> X = pd.DataFrame({
...     "employer": ["Boeing", "", "Acme"],
...     "gift_amount": [100.0, 50.0, 200.0],
... })
>>> feat = MatchingGiftFeaturizer(match_ratios={"Boeing": 1.0})
>>> feat.fit(X).transform(X)
array([[  1.,   1., 100.],
       [  0.,   0.,   0.],
       [  1.,   0.,   0.]])
Source code in philanthropy/preprocessing/_matching_gift.py
class MatchingGiftFeaturizer(TransformerMixin, BaseEstimator):
    """Derive corporate matching-gift features for each donor row.

    For every row the transformer emits three features: whether an employer is
    on file, the known corporate match ratio for that employer, and the
    potential matched dollars (gift amount times match ratio). The match-ratio
    lookup is normalised once at fit time (keys lowercased and stripped) and
    frozen, so ``transform`` depends only on each row's own values and never on
    which rows happen to share the batch.

    Parameters
    ----------
    employer_col : str, default="employer"
        Column in ``X`` holding the donor's employer name.
    gift_col : str, default="gift_amount"
        Column in ``X`` holding the gift amount used to size the potential
        matched dollars. Coerced to numeric at transform time; non-numeric or
        missing values are treated as ``0``.
    match_ratios : dict of {str: float} or None, default=None
        Mapping of employer name to corporate match ratio (e.g.
        ``{"Boeing": 1.0, "Microsoft": 2.0}``). Keys are matched
        case-insensitively (lowercased and stripped). ``None`` means no known
        employers, so every ``match_ratio`` is ``0.0``.

    Attributes
    ----------
    match_ratios_ : dict of {str: float}
        Normalised copy of ``match_ratios`` (keys lowercased/stripped, values
        cast to ``float``), frozen at fit time. Empty dict when
        ``match_ratios`` is ``None``.
    n_features_in_ : int
        Number of columns seen at fit time.
    feature_names_in_ : ndarray of str
        Column names of ``X`` at fit time.

    Raises
    ------
    TypeError
        If ``X`` is not a pandas DataFrame.
    ValueError
        If ``employer_col`` or ``gift_col`` is missing from ``X``.

    Notes
    -----
    The three output columns, in order, are:

    ========================== ===============================================
    Column                     Description
    ========================== ===============================================
    ``has_employer``           ``1.0`` if the employer cell is non-null and a
                               non-empty string, else ``0.0``.
    ``match_ratio``            ``match_ratios_`` lookup for the normalised
                               employer, ``0.0`` when unknown.
    ``potential_matched_amount`` Numeric gift amount (NaN -> 0) times
                               ``match_ratio``.
    ========================== ===============================================

    Examples
    --------
    >>> import pandas as pd
    >>> from philanthropy.preprocessing import MatchingGiftFeaturizer
    >>> X = pd.DataFrame({
    ...     "employer": ["Boeing", "", "Acme"],
    ...     "gift_amount": [100.0, 50.0, 200.0],
    ... })
    >>> feat = MatchingGiftFeaturizer(match_ratios={"Boeing": 1.0})
    >>> feat.fit(X).transform(X)
    array([[  1.,   1., 100.],
           [  0.,   0.,   0.],
           [  1.,   0.,   0.]])
    """

    def __init__(
        self,
        employer_col: str = "employer",
        gift_col: str = "gift_amount",
        match_ratios: dict[str, float] | None = None,
    ) -> None:
        self.employer_col = employer_col
        self.gift_col = gift_col
        self.match_ratios = match_ratios

    def _check_columns(self, X: pd.DataFrame) -> None:
        """Raise if the required schema columns are absent from ``X``."""
        missing = [
            col
            for col in (self.employer_col, self.gift_col)
            if col not in X.columns
        ]
        if missing:
            raise ValueError(f"X is missing required columns: {missing}")

    def fit(self: _Self, X: Any, y: Any = None) -> _Self:
        """Register the input schema and freeze the match-ratio lookup.

        Parameters
        ----------
        X : pandas DataFrame of shape (n_samples, n_features)
            Donor-level feature matrix. Must contain ``employer_col`` and
            ``gift_col``.
        y : ignored

        Returns
        -------
        self : MatchingGiftFeaturizer

        Raises
        ------
        TypeError
            If ``X`` is not a pandas DataFrame.
        ValueError
            If ``employer_col`` or ``gift_col`` is missing from ``X``.
        """
        if not isinstance(X, pd.DataFrame):
            raise TypeError("X must be a pandas DataFrame")
        self._check_columns(X)

        self.feature_names_in_ = np.array(X.columns.tolist(), dtype=object)
        self.n_features_in_ = len(self.feature_names_in_)

        # Freeze the normalised lookup from constructor params (leakage-safety
        # contract: fitted statistics are computed in fit and frozen before
        # transform). Nothing here depends on the contents of X.
        self.match_ratios_ = {
            str(k).strip().lower(): float(v)
            for k, v in (self.match_ratios or {}).items()
        }
        return self

    def transform(self, X: Any) -> np.ndarray:
        """Emit matching-gift features for each row of ``X``.

        Parameters
        ----------
        X : pandas DataFrame of shape (n_samples, n_features)
            Donor-level feature matrix. Must contain ``employer_col`` and
            ``gift_col``.

        Returns
        -------
        X_out : np.ndarray of shape (n_samples, 3), dtype float64
            Columns in order: ``has_employer``, ``match_ratio``,
            ``potential_matched_amount``.

        Raises
        ------
        TypeError
            If ``X`` is not a pandas DataFrame.
        ValueError
            If ``employer_col`` or ``gift_col`` is missing from ``X``.
        """
        check_is_fitted(self)
        if not isinstance(X, pd.DataFrame):
            raise TypeError("X must be a pandas DataFrame")
        self._check_columns(X)

        norm = X[self.employer_col].map(_normalise_employer)
        has_employer = (norm != "").to_numpy(dtype=np.float64)
        match_ratio = norm.map(
            lambda key: self.match_ratios_.get(key, 0.0)
        ).to_numpy(dtype=np.float64)

        gift = (
            pd.to_numeric(X[self.gift_col], errors="coerce")
            .fillna(0.0)
            .to_numpy(dtype=np.float64)
        )
        potential = gift * match_ratio

        return np.column_stack([has_employer, match_ratio, potential]).astype(
            np.float64
        )

    def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
        """Return the generated matching-gift feature names.

        Parameters
        ----------
        input_features : array-like of str or None, default=None
            Ignored because the featurizer always emits the same three features.

        Returns
        -------
        feature_names_out : ndarray of str
            ``["has_employer", "match_ratio",
            "potential_matched_amount"]``.

        Raises
        ------
        NotFittedError
            If the featurizer has not been fitted.
        """
        check_is_fitted(self)
        return np.array(
            ["has_employer", "match_ratio", "potential_matched_amount"],
            dtype=object,
        )

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

fit(X, y=None)

Register the input schema and freeze the match-ratio lookup.

Parameters:

Name Type Description Default
X pandas DataFrame of shape (n_samples, n_features)

Donor-level feature matrix. Must contain employer_col and gift_col.

required
y ignored
None

Returns:

Name Type Description
self MatchingGiftFeaturizer

Raises:

Type Description
TypeError

If X is not a pandas DataFrame.

ValueError

If employer_col or gift_col is missing from X.

Source code in philanthropy/preprocessing/_matching_gift.py
def fit(self: _Self, X: Any, y: Any = None) -> _Self:
    """Register the input schema and freeze the match-ratio lookup.

    Parameters
    ----------
    X : pandas DataFrame of shape (n_samples, n_features)
        Donor-level feature matrix. Must contain ``employer_col`` and
        ``gift_col``.
    y : ignored

    Returns
    -------
    self : MatchingGiftFeaturizer

    Raises
    ------
    TypeError
        If ``X`` is not a pandas DataFrame.
    ValueError
        If ``employer_col`` or ``gift_col`` is missing from ``X``.
    """
    if not isinstance(X, pd.DataFrame):
        raise TypeError("X must be a pandas DataFrame")
    self._check_columns(X)

    self.feature_names_in_ = np.array(X.columns.tolist(), dtype=object)
    self.n_features_in_ = len(self.feature_names_in_)

    # Freeze the normalised lookup from constructor params (leakage-safety
    # contract: fitted statistics are computed in fit and frozen before
    # transform). Nothing here depends on the contents of X.
    self.match_ratios_ = {
        str(k).strip().lower(): float(v)
        for k, v in (self.match_ratios or {}).items()
    }
    return self

transform(X)

Emit matching-gift features for each row of X.

Parameters:

Name Type Description Default
X pandas DataFrame of shape (n_samples, n_features)

Donor-level feature matrix. Must contain employer_col and gift_col.

required

Returns:

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

Columns in order: has_employer, match_ratio, potential_matched_amount.

Raises:

Type Description
TypeError

If X is not a pandas DataFrame.

ValueError

If employer_col or gift_col is missing from X.

Source code in philanthropy/preprocessing/_matching_gift.py
def transform(self, X: Any) -> np.ndarray:
    """Emit matching-gift features for each row of ``X``.

    Parameters
    ----------
    X : pandas DataFrame of shape (n_samples, n_features)
        Donor-level feature matrix. Must contain ``employer_col`` and
        ``gift_col``.

    Returns
    -------
    X_out : np.ndarray of shape (n_samples, 3), dtype float64
        Columns in order: ``has_employer``, ``match_ratio``,
        ``potential_matched_amount``.

    Raises
    ------
    TypeError
        If ``X`` is not a pandas DataFrame.
    ValueError
        If ``employer_col`` or ``gift_col`` is missing from ``X``.
    """
    check_is_fitted(self)
    if not isinstance(X, pd.DataFrame):
        raise TypeError("X must be a pandas DataFrame")
    self._check_columns(X)

    norm = X[self.employer_col].map(_normalise_employer)
    has_employer = (norm != "").to_numpy(dtype=np.float64)
    match_ratio = norm.map(
        lambda key: self.match_ratios_.get(key, 0.0)
    ).to_numpy(dtype=np.float64)

    gift = (
        pd.to_numeric(X[self.gift_col], errors="coerce")
        .fillna(0.0)
        .to_numpy(dtype=np.float64)
    )
    potential = gift * match_ratio

    return np.column_stack([has_employer, match_ratio, potential]).astype(
        np.float64
    )

get_feature_names_out(input_features=None)

Return the generated matching-gift feature names.

Parameters:

Name Type Description Default
input_features array-like of str or None

Ignored because the featurizer always emits the same three features.

None

Returns:

Name Type Description
feature_names_out ndarray of str

["has_employer", "match_ratio", "potential_matched_amount"].

Raises:

Type Description
NotFittedError

If the featurizer has not been fitted.

Source code in philanthropy/preprocessing/_matching_gift.py
def get_feature_names_out(self, input_features: Any = None) -> np.ndarray:
    """Return the generated matching-gift feature names.

    Parameters
    ----------
    input_features : array-like of str or None, default=None
        Ignored because the featurizer always emits the same three features.

    Returns
    -------
    feature_names_out : ndarray of str
        ``["has_employer", "match_ratio",
        "potential_matched_amount"]``.

    Raises
    ------
    NotFittedError
        If the featurizer has not been fitted.
    """
    check_is_fitted(self)
    return np.array(
        ["has_employer", "match_ratio", "potential_matched_amount"],
        dtype=object,
    )

__getattr__(name)

Resolve deprecated aliases lazily, warning once per access (PEP 562).

Kept as an alias rather than a subclass on purpose: a subclass would be a different class object, which changes type() for existing callers and would need its own get_feature_names_out to satisfy the public-API contract test. This way the object handed back is the canonical class.

Source code in philanthropy/preprocessing/__init__.py
def __getattr__(name: str) -> Any:
    """Resolve deprecated aliases lazily, warning once per access (PEP 562).

    Kept as an alias rather than a subclass on purpose: a subclass would be a
    different class object, which changes ``type()`` for existing callers and
    would need its own ``get_feature_names_out`` to satisfy the public-API
    contract test. This way the object handed back is the canonical class.
    """
    canonical = _DEPRECATED_ALIASES.get(name)
    if canonical is None:
        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
    import warnings

    warnings.warn(
        f"{name} is a deprecated alias for {canonical} and is removed in 1.0.0. "
        f"Import {canonical} instead.",
        DeprecationWarning,
        stacklevel=2,
    )
    return globals()[canonical]