Skip to content

Utils Reference

Model persistence (save_model, load_model), the label-count check run before fitting (check_label_floor), and the synthetic dataset helper used throughout the tutorials (make_donor_dataset).

philanthropy.utils

Generic helpers: model persistence.

save_model(model, path, *, features=None, target=None)

Persist model to path as a PhilanthroPy bundle.

Parameters:

Name Type Description Default
model fitted estimator

Any PhilanthroPy or scikit-learn estimator.

required
path str or Path

Output path (.joblib by convention).

required
features list of str

Ordered feature-column names the model was trained on.

None
target str

Name of the target column.

None

Returns:

Type Description
path

The path it was written to (for chaining).

Source code in philanthropy/utils/_persistence.py
def save_model(
    model: Any,
    path: PathLike,
    *,
    features: Optional[Sequence[str]] = None,
    target: Optional[str] = None,
) -> PathLike:
    """Persist ``model`` to ``path`` as a PhilanthroPy bundle.

    Parameters
    ----------
    model : fitted estimator
        Any PhilanthroPy or scikit-learn estimator.
    path : str or pathlib.Path
        Output path (``.joblib`` by convention).
    features : list of str, optional
        Ordered feature-column names the model was trained on.
    target : str, optional
        Name of the target column.

    Returns
    -------
    path
        The ``path`` it was written to (for chaining).
    """
    import sklearn

    bundle = {
        "model": model,
        "features": features,
        "target": target,
        "philanthropy_version": __version__,
        "sklearn_version": sklearn.__version__,
    }
    joblib.dump(bundle, path)
    return path

load_model(path)

Load a bundle written by :func:save_model.

Warns (does not raise) when the stored PhilanthroPy or scikit-learn version differs from the running environment, since an estimator un-pickled under a different scikit-learn can silently misbehave.

Parameters:

Name Type Description Default
path str or Path

Bundle path.

required

Returns:

Type Description
dict

The bundle: {"model", "features", "target", "philanthropy_version", "sklearn_version"}.

Raises:

Type Description
ValueError

If the file is not a PhilanthroPy bundle.

Source code in philanthropy/utils/_persistence.py
def load_model(path: PathLike) -> Dict[str, Any]:
    """Load a bundle written by :func:`save_model`.

    Warns (does not raise) when the stored PhilanthroPy or scikit-learn version
    differs from the running environment, since an estimator un-pickled under a
    different scikit-learn can silently misbehave.

    Parameters
    ----------
    path : str or pathlib.Path
        Bundle path.

    Returns
    -------
    dict
        The bundle: ``{"model", "features", "target", "philanthropy_version",
        "sklearn_version"}``.

    Raises
    ------
    ValueError
        If the file is not a PhilanthroPy bundle.
    """
    bundle = joblib.load(path)
    if not isinstance(bundle, dict) or "model" not in bundle:
        raise ValueError(f"{path} is not a PhilanthroPy model bundle.")
    _warn_on_version_mismatch(bundle)
    return bundle

trailing_slope_features(df, donor_ids, cutoff, *, date_col, value_col, agg, ks=(3, 5), prefix, donor_col='donor_id', period_months=12, data_start=None)

Trailing-period OLS slope and relative slope of one base series.

Parameters:

Name Type Description Default
df DataFrame

Long-format rows (gifts or activities) with donor_col, date_col and value_col. Rows after cutoff are ignored.

required
donor_ids Index

The population to return a row for.

required
cutoff Timestamp

The as-of date bin 0 (the most recent period) ends at.

required
date_col str

Column names in df.

required
value_col str

Column names in df.

required
agg str

Aggregation per period bin ("sum", "count", or "max").

required
ks sequence of int

Trailing window lengths, in periods.

(3, 5)
prefix str

Output columns are f"{prefix}_slope_{k}y" and f"{prefix}_rel_slope_{k}y" (the y suffix names the period as "year-like" regardless of period_months, matching the annual default every current caller uses).

required
period_months int

Width of one period, in months. The default is one year; a future caller on a biennial cadence (for example a two-year survey wave) passes 24.

12
data_start Timestamp

The earliest date this series can be considered observed from (for example a frozen training-set minimum). A period whose window ends before this date is not "observed" -- it lies entirely before the data begins, so a 0 there is unknown, not measured -- and the slope over a window with fewer than 2 observed periods is NaN. A period whose window ends on or after data_start is observed and contributes its value, 0 if it has no rows (a period with no rows, inside the data's own span, is a known zero, not a missing value). Defaults to df[date_col].min().

None

Returns:

Type Description
DataFrame

Indexed by donor_ids, two columns per k in ks.

Source code in philanthropy/utils/_momentum.py
def trailing_slope_features(
    df: pd.DataFrame,
    donor_ids: pd.Index,
    cutoff: pd.Timestamp,
    *,
    date_col: str,
    value_col: str,
    agg: str,
    ks: Sequence[int] = (3, 5),
    prefix: str,
    donor_col: str = "donor_id",
    period_months: int = 12,
    data_start: Optional[pd.Timestamp] = None,
) -> pd.DataFrame:
    """Trailing-period OLS slope and relative slope of one base series.

    Parameters
    ----------
    df : pandas.DataFrame
        Long-format rows (gifts or activities) with ``donor_col``,
        ``date_col`` and ``value_col``. Rows after ``cutoff`` are ignored.
    donor_ids : pandas.Index
        The population to return a row for.
    cutoff : pandas.Timestamp
        The as-of date bin 0 (the most recent period) ends at.
    date_col, value_col : str
        Column names in ``df``.
    agg : str
        Aggregation per period bin (``"sum"``, ``"count"``, or ``"max"``).
    ks : sequence of int, default=(3, 5)
        Trailing window lengths, in periods.
    prefix : str
        Output columns are ``f"{prefix}_slope_{k}y"`` and
        ``f"{prefix}_rel_slope_{k}y"`` (the ``y`` suffix names the period as
        "year-like" regardless of ``period_months``, matching the annual
        default every current caller uses).
    period_months : int, default=12
        Width of one period, in months. The default is one year; a future
        caller on a biennial cadence (for example a two-year survey wave)
        passes 24.
    data_start : pandas.Timestamp, optional
        The earliest date this series can be considered observed from (for
        example a frozen training-set minimum). A period whose window ends
        before this date is not "observed" -- it lies entirely before the
        data begins, so a 0 there is unknown, not measured -- and the slope
        over a window with fewer than 2 observed periods is ``NaN``. A
        period whose window ends on or after ``data_start`` is observed and
        contributes its value, 0 if it has no rows (a period with no rows,
        inside the data's own span, is a known zero, not a missing value).
        Defaults to ``df[date_col].min()``.

    Returns
    -------
    pandas.DataFrame
        Indexed by ``donor_ids``, two columns per ``k`` in ``ks``.
    """
    df = df[df[date_col] <= cutoff]
    if data_start is None:
        data_start = df[date_col].min() if len(df) else cutoff

    max_k = max(ks)
    bins = np.zeros((max_k, len(donor_ids)), dtype="float64")
    observed = np.zeros(max_k, dtype=bool)
    for j in range(max_k):
        hi = cutoff - pd.DateOffset(months=period_months * j)
        lo = cutoff - pd.DateOffset(months=period_months * (j + 1))
        window = df[(df[date_col] > lo) & (df[date_col] <= hi)]
        if len(window):
            agg_vals = window.groupby(donor_col)[value_col].agg(agg)
            bins[j] = agg_vals.reindex(donor_ids, fill_value=0.0).to_numpy()
        observed[j] = pd.notna(data_start) and hi >= data_start

    out = {}
    for k in ks:
        n_observed = int(observed[:k].sum())
        if n_observed < 2:
            slope = np.full(len(donor_ids), np.nan)
            rel_slope = np.full(len(donor_ids), np.nan)
        else:
            # observed periods are the most recent n_observed bins (bin 0 is
            # most recent); chronological order is oldest first. Bins before
            # data_start are excluded entirely rather than treated as zero,
            # so a short history never reads as a fabricated trend.
            window_vals = bins[n_observed - 1::-1]
            slope = _ols_slope(window_vals)
            rel_slope = slope / (window_vals.mean(axis=0) + 1.0)
        out[f"{prefix}_slope_{k}y"] = slope
        out[f"{prefix}_rel_slope_{k}y"] = rel_slope
    return pd.DataFrame(out, index=donor_ids)

check_label_floor(y, task)

Say whether a file has enough labels to fit a model for task.

Counts the rarer of the two classes in y (lapsed vs retained for lapse, major donors vs everyone else for major gift) and compares it to LABEL_FLOORS[task]. Below the floor, at least one training draw in ten scored below the simple rule on held-out data (lapse), or most did (major gift), so the rule is the safer choice.

Parameters:

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

Binary training labels.

required
task (lapse, major_gift)

Which floor to apply.

"lapse"

Returns:

Type Description
str

"run" or "not enough labels".

Raises:

Type Description
ValueError

If task is unknown, y has missing labels, or y has more than two classes.

Examples:

>>> from philanthropy.utils import check_label_floor
>>> check_label_floor([1] * 150 + [0] * 850, "lapse")
'run'
>>> check_label_floor([1] * 150 + [0] * 850, "major_gift")
'not enough labels'
Source code in philanthropy/utils/_label_floor.py
def check_label_floor(y: Any, task: str) -> str:
    """Say whether a file has enough labels to fit a model for ``task``.

    Counts the rarer of the two classes in ``y`` (lapsed vs retained for
    lapse, major donors vs everyone else for major gift) and compares it to
    ``LABEL_FLOORS[task]``. Below the floor, at least one training draw in
    ten scored below the simple rule on held-out data (lapse), or most did
    (major gift), so the rule is the safer choice.

    Parameters
    ----------
    y : array-like of shape (n_samples,)
        Binary training labels.
    task : {"lapse", "major_gift"}
        Which floor to apply.

    Returns
    -------
    str
        ``"run"`` or ``"not enough labels"``.

    Raises
    ------
    ValueError
        If ``task`` is unknown, ``y`` has missing labels, or ``y`` has more
        than two classes.

    Examples
    --------
    >>> from philanthropy.utils import check_label_floor
    >>> check_label_floor([1] * 150 + [0] * 850, "lapse")
    'run'
    >>> check_label_floor([1] * 150 + [0] * 850, "major_gift")
    'not enough labels'
    """
    if task not in LABEL_FLOORS:
        raise ValueError(
            f"`task` must be one of {sorted(LABEL_FLOORS)}, got {task!r}."
        )
    y = np.asarray(y).ravel()
    if pd.isna(y).any():
        raise ValueError(
            "`y` has missing labels; drop or fill the unlabelled rows first."
        )
    _, counts = np.unique(y, return_counts=True)
    if len(counts) > 2:
        raise ValueError(f"`y` must be binary, got {len(counts)} classes.")
    rarer = counts.min() if len(counts) == 2 else 0
    return "run" if rarer >= LABEL_FLOORS[task] else "not enough labels"