Source code for ratingmodels.experience_rate

r"""Experience rate construction.

The experience rate develops a group's own claims into a charged rate:

1. **Pool** large claims at a pooling point :math:`P`, removing the excess
   :math:`\sum_i \max(0, c_i - P)` so a few catastrophic claims don't distort
   the manual-comparable base.
2. **Normalize** to a loss cost by dividing pooled claims by exposure units.
3. **Trend** forward to the rating-period cost level.
4. **Add back** a pooling charge (the expected cost of the excess layer,
   spread across the book) and apply benefit/demographic adjustments.
5. **Load** for expenses and margin via the target loss ratio.

.. math::
    \text{exp loss cost}
      = \frac{C - \text{excess}}{E}\cdot(1+t)^{\Delta}
        \cdot f_{\text{ben}} f_{\text{demo}} + \text{pooling charge}.
"""
from __future__ import annotations

from dataclasses import dataclass

import numpy as np

from ._utils import (
    as_float_array,
    require_nonnegative,
    require_positive,
    require_unit_interval,
)
from .trend import trend_factor
from .loading import RetentionLoad


[docs] def pool_claims(claims, pooling_point: float) -> tuple[float, float]: r"""Split claims into a pooled (capped) total and the excess above ``P``. Returns ``(capped_total, excess)`` where ``excess = sum(max(0, claim - pooling_point))``. """ require_positive(pooling_point, "pooling_point") arr = as_float_array(claims, "claims") if np.any(arr < 0): raise ValueError("claims must be non-negative") excess = float(np.sum(np.maximum(0.0, arr - pooling_point))) return float(arr.sum() - excess), excess
[docs] def expected_excess_charge(claims, pooling_point: float, exposure: float) -> float: """Naive pooling charge per exposure unit: observed excess spread over exposure. A filed pooling charge is normally derived from book-wide excess experience or an EVT tail model (see the ``extremeloss`` package); this helper gives the simple group-level estimate. """ _, excess = pool_claims(claims, pooling_point) return excess / require_positive(exposure, "exposure")
[docs] @dataclass class ExperienceRate: """Develop an experience rate from incurred claims and exposure. Parameters ---------- incurred_claims : float Total incurred (completed) claims over the experience period. exposure : float Exposure units (member-months, policy months, earned exposures, ...). trend_annual : float Annual claims trend. trend_years : float Years from experience midpoint to rating midpoint. pooled_excess : float Claim dollars removed by pooling (from :func:`pool_claims`). Default 0. pooling_charge : float Pooling charge added back, per exposure unit. Default 0. benefit_factor, demographic_factor : float Multiplicative adjustments for benefit/demographic changes between the experience and rating periods. Default 1.0. target_loss_ratio : float Claims / premium target used to load to a charged rate. """ incurred_claims: float exposure: float trend_annual: float = 0.0 trend_years: float = 1.0 pooled_excess: float = 0.0 pooling_charge: float = 0.0 benefit_factor: float = 1.0 demographic_factor: float = 1.0 target_loss_ratio: float = 0.85 retention: "RetentionLoad | None" = None def __post_init__(self) -> None: require_nonnegative(self.incurred_claims, "incurred_claims") require_positive(self.exposure, "exposure") require_nonnegative(self.pooled_excess, "pooled_excess") require_nonnegative(self.pooling_charge, "pooling_charge") require_positive(self.benefit_factor, "benefit_factor") require_positive(self.demographic_factor, "demographic_factor") if self.retention is None: require_unit_interval(self.target_loss_ratio, "target_loss_ratio", closed=False)
[docs] def pooled_loss_cost(self) -> float: """Pooled (capped) claims per exposure unit, before trend.""" return (self.incurred_claims - self.pooled_excess) / self.exposure
def trend_factor(self) -> float: return trend_factor(self.trend_annual, self.trend_years)
[docs] def loss_cost(self) -> float: """Trended, pooled, adjusted experience loss cost (charge added back).""" trended = ( self.pooled_loss_cost() * self.trend_factor() * self.benefit_factor * self.demographic_factor ) return trended + self.pooling_charge
[docs] def rate(self) -> float: """Charged experience rate per exposure unit. Uses ``retention`` (the full gross-up) when supplied, otherwise ``loss cost / target_loss_ratio``. """ if self.retention is not None: return self.retention.gross_rate(self.loss_cost()) return self.loss_cost() / self.target_loss_ratio