Source code for reservingmodels.diagnostics
r"""Diagnostics for a chain-ladder / ODP reserve fit.
The bootstrap's predictive distribution is only as trustworthy as the model
assumptions behind it: that the Pearson residuals are patternless in origin,
development, and calendar direction. These functions surface those residuals
as tidy tables so the assumptions can be checked before the numbers are used.
All functions take the same cumulative triangle the rest of the package takes.
"""
from __future__ import annotations
import numpy as np
import pandas as pd
from .bootstrap import _as_cumulative_array, _fitted_incrementals
[docs]
def pearson_residuals(triangle: pd.DataFrame | np.ndarray) -> pd.DataFrame:
r"""Unscaled Pearson residuals of the ODP fit, one tidy row per cell.
Columns: ``origin``, ``development`` (0-based development period),
``calendar`` (origin + development, the diagonal a cell sits on),
``actual``, ``fitted``, and ``residual`` =
:math:`(C_{ij}-\hat m_{ij})/\sqrt{\hat m_{ij}}`. A trend across ``calendar``
is the classic warning that a single development pattern does not describe
every diagonal (a claims-inflation or process change), which the bootstrap
would not capture.
"""
cum, origins = _as_cumulative_array(triangle)
K = cum.shape[0]
obs = ~np.isnan(cum)
fitted = _fitted_incrementals(cum, obs)
incr = np.column_stack([cum[:, 0], np.diff(np.nan_to_num(cum), axis=1)])
rows = []
for i in range(K):
for j in range(K):
if not obs[i, j] or fitted[i, j] <= 0:
continue
rows.append(
{
"origin": origins[i],
"development": j,
"calendar": i + j,
"actual": float(incr[i, j]),
"fitted": float(fitted[i, j]),
"residual": float((incr[i, j] - fitted[i, j]) / np.sqrt(fitted[i, j])),
}
)
return pd.DataFrame(rows)
[docs]
def residual_summary(triangle: pd.DataFrame | np.ndarray) -> pd.Series:
"""Scalar summary of the Pearson residuals: count, mean, std, min, max.
A mean far from zero or a heavy tail flags a poor ODP fit; the standard
deviation is close to :math:`\\sqrt{\\phi}` when the model holds.
"""
r = pearson_residuals(triangle)["residual"]
return pd.Series(
{
"n": int(r.size),
"mean": float(r.mean()),
"std": float(r.std(ddof=1)),
"min": float(r.min()),
"max": float(r.max()),
}
)
[docs]
def calendar_year_effects(triangle: pd.DataFrame | np.ndarray) -> pd.DataFrame:
"""Mean Pearson residual by calendar diagonal.
One row per calendar period (``origin + development``) with the residual
``mean``, ``std``, and cell ``count``. Systematic sign by diagonal is the
signature of a calendar-year (inflation) effect the chain ladder ignores.
"""
res = pearson_residuals(triangle)
g = res.groupby("calendar")["residual"]
return pd.DataFrame(
{"mean": g.mean(), "std": g.std(ddof=0), "count": g.size()}
).reset_index()