Source code for ratingmodels.indication

r"""Rate indication: the central object that turns experience and manual
inputs into an indicated rate and a rate change.

Two standard methods are exposed.

**Build-up (loss-ratio loaded).**  Blend experience and manual *claims* by
credibility, then gross up by the target loss ratio:

.. math::
    \text{indicated rate} = \frac{Z\,\text{exp claims} + (1-Z)\,\text{man claims}}
                                 {\text{target LR}}, \qquad
    \text{change} = \frac{\text{indicated}}{\text{current}} - 1.

**Loss-ratio (credibility-weighted indication).**  Weight the experience
indication against a trend-only ("no experience") indication:

.. math::
    \text{change} = Z\!\left(\frac{\text{exp LR}}{\text{target LR}} - 1\right)
                    + (1 - Z)\,\big((1+t)^{\Delta}-1\big).
"""
from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from actuarialpy import Experience

from dataclasses import dataclass, field
from typing import Sequence

import numpy as np
import pandas as pd

from ._utils import (
    Numeric,
    as_numeric,
    maybe_float,
    require_positive,
    require_unit_interval,
)
from .blend import blend
from .decomposition import RateChangeDecomposition, decompose_rate_change
from .loading import RetentionLoad


[docs] @dataclass class RateIndication: r"""Develop an indicated rate from experience and manual inputs. Every numeric field follows the vectorization contract: pass columns (Series of loss costs, credibilities, current rates...) to run the whole book's indication in one object; each method returns a Series on the shared index, and :meth:`rate_change_decomposition` returns per-case driver tables. Parameters ---------- experience_loss_cost : float or array-like Trended, pooled, adjusted experience loss cost (per exposure unit) (see :class:`ratingmodels.ExperienceRate`). manual_loss_cost : float Manual loss cost at the rating-period level (see :class:`ratingmodels.ManualRate`). credibility : float Credibility ``Z`` assigned to experience, in [0, 1]. current_rate : float Current charged rate per exposure unit. target_loss_ratio : float Claims / premium target used to load claims to a charged rate. current_premium : float, optional On-level earned premium over the experience period; required only for the loss-ratio method. exposure : float, optional Exposure units over the experience period; required for the loss-ratio method (with ``current_premium``) to form an experience loss ratio. trend_total_factor : float Total claims trend factor :math:`(1+t)^\Delta`; used by the loss-ratio method's trend-only side and by the decomposition. Default 1.0. benefit_factor, demographic_factor : float Driver factors for the rate-change decomposition. Default 1.0. """ experience_loss_cost: Numeric manual_loss_cost: Numeric credibility: Numeric current_rate: Numeric target_loss_ratio: Numeric = 0.85 current_premium: Numeric | None = None exposure: Numeric | None = None trend_total_factor: Numeric = 1.0 benefit_factor: Numeric = 1.0 demographic_factor: Numeric = 1.0 retention: "RetentionLoad | None" = None def __post_init__(self) -> None: self.experience_loss_cost = as_numeric( self.experience_loss_cost, "experience_loss_cost" ) require_positive(self.experience_loss_cost + 1e-12, "experience_loss_cost") self.manual_loss_cost = require_positive(self.manual_loss_cost, "manual_loss_cost") self.credibility = require_unit_interval(self.credibility, "credibility") self.current_rate = require_positive(self.current_rate, "current_rate") self.target_loss_ratio = require_unit_interval( self.target_loss_ratio, "target_loss_ratio", closed=False ) # ----- claims-level blending ----- # def blended_loss_cost(self) -> Numeric: return blend( self.experience_loss_cost, self.manual_loss_cost, self.credibility ) # ----- charged rates ----- # def _gross(self, loss_cost: Numeric) -> Numeric: """Gross claims to a charged rate via retention, else target loss ratio.""" if self.retention is not None: return self.retention.gross_rate(loss_cost) return maybe_float(loss_cost / self.target_loss_ratio) def experience_rate(self) -> Numeric: return self._gross(self.experience_loss_cost) def manual_rate(self) -> Numeric: return self._gross(self.manual_loss_cost) def blended_rate(self) -> Numeric: return self._gross(self.blended_loss_cost()) # ----- indication (build-up) ----- #
[docs] def indicated_rate(self) -> Numeric: """Indicated charged rate (build-up method).""" return self.blended_rate()
[docs] def indicated_rate_change(self) -> Numeric: """Proportional change implied by the build-up indicated rate.""" return maybe_float(self.indicated_rate() / self.current_rate - 1.0)
# ----- indication (loss-ratio method) ----- # def experience_loss_ratio(self) -> Numeric: if self.current_premium is None or self.exposure is None: raise ValueError( "current_premium and exposure are required for the loss-ratio method" ) premium = require_positive(self.current_premium, "current_premium") exposure = require_positive(self.exposure, "exposure") experience_claims = self.experience_loss_cost * exposure return maybe_float(experience_claims / premium)
[docs] def loss_ratio_indication(self) -> Numeric: r"""Credibility-weighted loss-ratio rate change. Experience side: ``exp_LR / target_LR - 1``. Trend-only side: ``trend_total_factor - 1``. """ exp_side = self.experience_loss_ratio() / self.target_loss_ratio - 1.0 trend_side = self.trend_total_factor - 1.0 z = self.credibility return maybe_float(z * exp_side + (1 - z) * trend_side)
# ----- decomposition ----- #
[docs] def rate_change_decomposition(self) -> RateChangeDecomposition: r"""Decompose the build-up indicated change into named drivers. Drivers: * ``trend`` -- the claims trend factor, * ``experience`` -- the credibility effect, blended/manual claims (equals 1 when ``Z = 0``), * ``benefit`` and ``demographic`` -- supplied adjustment factors. Any remaining movement (rate adequacy / loading) is absorbed by an explicit ``residual`` factor so the parts reconcile to the total. """ experience_factor = self.blended_loss_cost() / self.manual_loss_cost drivers = { "trend": self.trend_total_factor, "experience": experience_factor, "benefit": self.benefit_factor, "demographic": self.demographic_factor, } total = self.indicated_rate() / self.current_rate return decompose_rate_change(drivers, total_factor=total)
def _as_period_array(value, n: int, name: str) -> np.ndarray: arr = np.asarray(value, dtype=float) if arr.ndim == 0: arr = np.full(n, float(arr)) if arr.shape != (n,): raise ValueError( f"{name} must be a scalar or a length-{n} sequence; got shape {arr.shape}" ) if not np.all(np.isfinite(arr)): raise ValueError(f"{name} has non-finite values") return arr
[docs] @dataclass class ExperienceExhibit: r"""Assemble experience periods into the inputs a rate indication takes. :class:`RateIndication` consumes *point* inputs -- a trended, developed experience loss cost and an on-level premium. This is the object that produces them from per-period columns, with every adjustment a visible column of the worksheet: premium times its on-level factor, losses times development and trend, a loss ratio per period, and the weighted total. The natural factor producers are :func:`~ratingmodels.on_level_factors` (the ``on_level_factor`` column) and ``actuarialpy.reserving.ChainLadder`` (per-origin development factors), but any source works -- this object composes, it does not re-derive. Parameters ---------- earned_premium : array-like Earned premium per experience period, at historical rate level. losses : array-like Incurred losses per period (at whatever development and trend level the factor arguments then adjust for). on_level_factors, trend_factors, development_factors : scalar or array Per-period multiplicative adjustments. Default 1.0. weights : array-like, optional Weights for the *diagnostic* weighted loss ratio. Default: on-level premium, making the weighted ratio identical to the aggregate ratio -- which is also the convention :meth:`to_indication` uses, since the indication is built from period totals. period_labels : sequence, optional Exhibit index; defaults to ``0..n-1``. """ earned_premium: object losses: object on_level_factors: object = 1.0 trend_factors: object = 1.0 development_factors: object = 1.0 weights: object | None = None period_labels: Sequence | None = None _n: int = field(init=False, repr=False) def __post_init__(self): premium = np.asarray(self.earned_premium, dtype=float) if premium.ndim == 0: premium = premium.reshape(1) self._n = n = premium.shape[0] if np.any(premium <= 0) or not np.all(np.isfinite(premium)): raise ValueError("earned_premium must be positive and finite") self.earned_premium = premium self.losses = _as_period_array(self.losses, n, "losses") if np.any(self.losses < 0): raise ValueError("losses must be nonnegative") for name in ("on_level_factors", "trend_factors", "development_factors"): arr = _as_period_array(getattr(self, name), n, name) if np.any(arr <= 0): raise ValueError(f"{name} must be positive") setattr(self, name, arr) if self.weights is not None: self.weights = _as_period_array(self.weights, n, "weights") if np.any(self.weights < 0) or self.weights.sum() <= 0: raise ValueError("weights must be nonnegative with positive sum") if self.period_labels is not None and len(self.period_labels) != n: raise ValueError("period_labels must match the number of periods") # ------------------------------------------------------------------ #
[docs] @classmethod def from_experience( cls, exp: "Experience", *, freq: str = "YE", on_level_factors: object = 1.0, trend_factors: object = 1.0, development_factors: object = 1.0, weights: object | None = None, ) -> "ExperienceExhibit": """Build the worksheet from the canonical Experience. Premium comes from the bound ``revenue`` role and losses from the bound ``expense`` role, summed per ``freq`` period of the bound ``date`` role (annual by default). The factor arguments stay explicit -- on-leveling, development, and trend are judgment this object composes, not derives. """ from actuarialpy import resolve_date, single_role revenue = single_role(exp.revenue, "revenue") expense = single_role(exp.expense, "expense") date_col = resolve_date(exp) dated = exp.data.assign(**{date_col: pd.to_datetime(exp.data[date_col])}) per = ( dated.set_index(date_col)[[revenue, expense]] .resample(freq) .sum() ) labels = ( list(per.index.year) if freq.upper().startswith(("Y", "A")) else list(per.index.astype(str)) ) return cls( earned_premium=per[revenue].to_numpy(), losses=per[expense].to_numpy(), on_level_factors=on_level_factors, trend_factors=trend_factors, development_factors=development_factors, weights=weights, period_labels=labels, )
[docs] def exhibit(self) -> pd.DataFrame: """The worksheet: one row per period, every adjustment a column.""" olp = self.earned_premium * self.on_level_factors adj = self.losses * self.development_factors * self.trend_factors w = self.weights if self.weights is not None else olp idx = pd.Index( self.period_labels if self.period_labels is not None else range(self._n), name="period", ) return pd.DataFrame( { "earned_premium": self.earned_premium, "on_level_factor": self.on_level_factors, "on_level_premium": olp, "losses": self.losses, "development_factor": self.development_factors, "trend_factor": self.trend_factors, "adjusted_losses": adj, "loss_ratio": adj / olp, "weight": w, }, index=idx, )
@property def on_level_premium(self) -> float: """Total on-level earned premium across the periods.""" return float((self.earned_premium * self.on_level_factors).sum()) @property def adjusted_losses(self) -> float: """Total developed, trended losses across the periods.""" return float( (self.losses * self.development_factors * self.trend_factors).sum() ) @property def experience_loss_ratio(self) -> float: """Weighted per-period loss ratio (aggregate ratio by default).""" ex = self.exhibit() return float(np.average(ex["loss_ratio"], weights=ex["weight"])) # ------------------------------------------------------------------ #
[docs] def to_indication( self, manual_loss_cost: float, credibility: float, current_rate: float, exposure: float, retention: RetentionLoad | None = None, target_loss_ratio: float = 0.85, **kwargs, ) -> RateIndication: """Wire the assembled totals into a :class:`RateIndication`. ``experience_loss_cost`` becomes ``adjusted_losses / exposure`` and ``current_premium`` becomes :attr:`on_level_premium`, so the indication's own ``experience_loss_ratio()`` reproduces this exhibit's aggregate ratio exactly. ``exposure`` is the total over the experience period, in the same units as ``manual_loss_cost`` and ``current_rate``. Remaining keyword arguments (``trend_total_factor``, ``benefit_factor``, ...) pass through. """ exposure = float(exposure) if exposure <= 0: raise ValueError("exposure must be positive") return RateIndication( experience_loss_cost=self.adjusted_losses / exposure, manual_loss_cost=manual_loss_cost, credibility=credibility, current_rate=current_rate, current_premium=self.on_level_premium, exposure=exposure, retention=retention, target_loss_ratio=target_loss_ratio, **kwargs, )