Source code for lossmodels.severity.tables

r"""Increased limits factors and loss elimination ratios, with error bars.

Both tables are ratios of limited expected values from a fitted severity
model:

.. math::
    \mathrm{ILF}(x; b) = \frac{E[\min(X, x)]}{E[\min(X, b)]},
    \qquad
    \mathrm{LER}(d) = \frac{E[\min(X, d)]}{E[X]}.

The point values need only :meth:`~lossmodels.severity.base.SeverityModel.limited_expected_value`;
the *error bars* need the parameter covariance from
:func:`~lossmodels.estimation.uncertainty.fit_uncertainty` -- the delta
method carries fit uncertainty through to the factor actually filed, which
is where it matters: an ILF quoted to four decimals off 300 claims is
false precision, and now the table says so.
"""
from __future__ import annotations

from statistics import NormalDist

import numpy as np
import pandas as pd

from ..estimation.uncertainty import FitUncertainty, model_parameters

__all__ = ["increased_limits_table", "loss_elimination_table"]


def _delta_se(model, uncertainty: FitUncertainty, func, step: float = 1e-5):
    """Delta-method standard error of ``func(model)`` under the fit covariance."""
    params = model_parameters(model)
    names = list(params)
    if names != list(uncertainty.param_names):
        raise ValueError(
            "uncertainty was computed for different parameters "
            f"({list(uncertainty.param_names)!r}) than this model exposes "
            f"({names!r}); pass the FitUncertainty of the same fitted model"
        )
    theta = np.array([params[n] for n in names], dtype=float)
    cls = type(model)
    grad = np.empty(len(theta))
    for i in range(len(theta)):
        # step scaled to the parameter's own magnitude: a fixed floor of 1.0
        # would make the step enormous relative to small parameters (an
        # exponential *rate* is ~1e-5) and the truncation error dominates
        h = step * abs(theta[i]) if theta[i] != 0.0 else step
        up, dn = theta.copy(), theta.copy()
        up[i] += h
        dn[i] -= h
        grad[i] = (
            func(cls(**dict(zip(names, up, strict=True)))) - func(cls(**dict(zip(names, dn, strict=True))))
        ) / (2.0 * h)
    var = float(grad @ np.asarray(uncertainty.covariance, dtype=float) @ grad)
    return float(np.sqrt(max(var, 0.0)))


def _with_bands(rows, values, ses, confidence_level, value_col):
    z = NormalDist().inv_cdf(0.5 + confidence_level / 2.0)
    out = pd.DataFrame(rows)
    out[f"{value_col}_se"] = ses
    out["ci_low"] = np.asarray(values) - z * np.asarray(ses)
    out["ci_high"] = np.asarray(values) + z * np.asarray(ses)
    return out


[docs] def increased_limits_table( model, limits, base_limit: float, uncertainty: FitUncertainty | None = None, confidence_level: float = 0.95, ) -> pd.DataFrame: """ILFs at each limit relative to a base limit, optionally with bands. Parameters ---------- model A fitted severity model (anything with ``limited_expected_value``). limits : array-like Policy limits to tabulate, each positive. base_limit : float The limit the factors are relative to; its own row has ``ilf = 1.0`` (and, when bands are requested, ``se = 0`` exactly -- the ratio's gradient vanishes at the base). uncertainty : FitUncertainty, optional Output of :func:`fit_uncertainty` for *this* model; enables delta-method ``ilf_se`` / ``ci_low`` / ``ci_high`` columns. confidence_level : float Wald level for the bands. Returns ------- pandas.DataFrame Indexed by ``limit``: ``lev``, ``ilf`` (+ bands when requested). """ lims = np.atleast_1d(np.asarray(limits, dtype=float)) if np.any(lims <= 0) or base_limit <= 0: raise ValueError("limits and base_limit must be positive") if not 0 < confidence_level < 1: raise ValueError("confidence_level must be in (0, 1)") base_lev = float(model.limited_expected_value(float(base_limit))) if base_lev <= 0: raise ValueError("limited expected value at the base limit is zero") levs = np.array( [float(model.limited_expected_value(float(x))) for x in lims] ) rows = {"limit": lims, "lev": levs, "ilf": levs / base_lev} if uncertainty is None: return pd.DataFrame(rows).set_index("limit") ses = [ _delta_se( model, uncertainty, lambda m, _x=float(x): m.limited_expected_value(_x) / m.limited_expected_value(float(base_limit)), ) for x in lims ] return _with_bands( rows, rows["ilf"], ses, confidence_level, "ilf" ).set_index("limit")
[docs] def loss_elimination_table( model, deductibles, uncertainty: FitUncertainty | None = None, confidence_level: float = 0.95, ) -> pd.DataFrame: """Loss elimination ratios at each deductible, optionally with bands. ``LER(d) = E[min(X, d)] / E[X]`` -- the share of ground-up loss a deductible removes. Requires a finite mean: a severity with an infinite mean has no loss to eliminate a share *of*, and the function raises rather than tabulating ratios of infinity. Returns ------- pandas.DataFrame Indexed by ``deductible``: ``lev``, ``ler`` (+ bands when requested, as in :func:`increased_limits_table`). """ ded = np.atleast_1d(np.asarray(deductibles, dtype=float)) if np.any(ded < 0): raise ValueError("deductibles must be nonnegative") if not 0 < confidence_level < 1: raise ValueError("confidence_level must be in (0, 1)") mean = float(model.mean()) if not np.isfinite(mean) or mean <= 0: raise ValueError( "loss elimination ratios require a positive finite mean; this " f"severity's mean is {mean!r}" ) levs = np.array([float(model.limited_expected_value(float(d))) for d in ded]) rows = {"deductible": ded, "lev": levs, "ler": levs / mean} if uncertainty is None: return pd.DataFrame(rows).set_index("deductible") ses = [ _delta_se( model, uncertainty, lambda m, _d=float(d): m.limited_expected_value(_d) / m.mean(), ) for d in ded ] return _with_bands( rows, rows["ler"], ses, confidence_level, "ler" ).set_index("deductible")