Source code for ratingmodels.splits
r"""Validation splits for pricing data: random, group-preserving, temporal.
A pricing model should be judged on data it did not see. For insurance data
the *shape* of the split matters as much as its existence:
* rows belonging to the same policy/group are correlated, so scattering a
group's rows across train and test leaks its risk level into validation --
:func:`group_split` keeps each group whole on one side;
* the deployed model always predicts *forward*, so the honest test is
out-of-time -- :func:`temporal_split` cuts at a date;
* :func:`random_split` is the plain rows-at-random baseline, appropriate
only when rows are genuinely independent.
Each function returns a ``(train, test)`` pair of DataFrames with the
original row order preserved, and raises rather than silently returning an
empty side. Downstream, score the held-out side with
:func:`ratingmodels.compare_models`, :func:`ratingmodels.calibration_table`,
:func:`ratingmodels.actual_expected_table`, :func:`ratingmodels.gini_coefficient`,
or :func:`ratingmodels.lift_table`.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
__all__ = ["random_split", "group_split", "temporal_split"]
def _check_fraction(test_fraction: float) -> float:
if not 0 < test_fraction < 1:
raise ValueError("test_fraction must be strictly between 0 and 1")
return float(test_fraction)
[docs]
def random_split(
data: pd.DataFrame,
test_fraction: float = 0.25,
random_state=None,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Rows-at-random split into ``(train, test)``.
Appropriate only when rows are independent; with repeated observations of
the same policy or group, use :func:`group_split` instead.
Parameters
----------
data : DataFrame
test_fraction : float
Target share of *rows* in the test side.
random_state : optional
Seed or Generator for :func:`numpy.random.default_rng`.
"""
test_fraction = _check_fraction(test_fraction)
n = len(data)
if n < 2:
raise ValueError("need at least 2 rows to split")
n_test = int(round(n * test_fraction))
n_test = min(max(n_test, 1), n - 1)
rng = np.random.default_rng(random_state)
test_pos = np.sort(rng.permutation(n)[:n_test])
mask = np.zeros(n, dtype=bool)
mask[test_pos] = True
return data.iloc[~mask].copy(), data.iloc[mask].copy()
[docs]
def group_split(
data: pd.DataFrame,
group: str,
test_fraction: float = 0.25,
weights: str | None = None,
random_state=None,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Group-preserving random split: every group lands whole on one side.
Groups are shuffled and assigned to the test side until it holds at least
``test_fraction`` of the total weight, so the realized share slightly
overshoots the target by up to one group.
Parameters
----------
data : DataFrame
group : str
Column identifying the unit that must not straddle the split
(policy, employer group, account, ...).
test_fraction : float
Target share of total weight in the test side.
weights : str, optional
Column whose per-group totals define "share" -- typically exposure or
premium. Rows count equally when omitted.
random_state : optional
Seed or Generator for :func:`numpy.random.default_rng`.
"""
test_fraction = _check_fraction(test_fraction)
if group not in data.columns:
raise ValueError(f"group column {group!r} not found")
keys = data[group]
if weights is None:
totals = keys.value_counts()
else:
w = data[weights].astype(float)
if (w < 0).any():
raise ValueError("weights must be nonnegative")
totals = w.groupby(keys.to_numpy()).sum()
if len(totals) < 2:
raise ValueError("need at least 2 distinct groups to split")
rng = np.random.default_rng(random_state)
order = rng.permutation(len(totals))
shuffled = totals.iloc[order]
target = test_fraction * float(totals.sum())
test_groups: set = set()
cum = 0.0
for g, wt in shuffled.items():
test_groups.add(g)
cum += float(wt)
if cum >= target:
break
if len(test_groups) >= len(totals): # keep at least one group in train
test_groups.discard(shuffled.index[-1])
mask = keys.isin(test_groups).to_numpy()
if not mask.any() or mask.all():
raise ValueError(
"split produced an empty side; adjust test_fraction for this "
"group structure"
)
return data.iloc[~mask].copy(), data.iloc[mask].copy()
[docs]
def temporal_split(
data: pd.DataFrame,
date: str,
cutoff,
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Out-of-time split at ``cutoff``: train strictly before, test at/after.
The honest validation shape for a model that will predict forward in
time. ``train`` holds rows with ``data[date] < cutoff`` and ``test``
the rest.
Parameters
----------
data : DataFrame
date : str
Column to cut on. Datetime-like columns coerce ``cutoff`` through
:class:`pandas.Timestamp` (so ``"2025-01-01"`` works); other ordered
columns (period strings, year integers) compare as-is.
cutoff
The boundary value; the first value belonging to the test side.
"""
if date not in data.columns:
raise ValueError(f"date column {date!r} not found")
col = data[date]
if pd.api.types.is_datetime64_any_dtype(col):
cutoff = pd.Timestamp(cutoff)
mask = (col >= cutoff).to_numpy()
n_test = int(mask.sum())
if n_test == 0 or n_test == len(data):
raise ValueError(
f"cutoff {cutoff!r} puts all {len(data)} rows on one side "
f"({'test' if n_test else 'train'}); choose a cutoff inside the "
"data's date range"
)
return data.iloc[~mask].copy(), data.iloc[mask].copy()