Source code for ratingmodels.loading

r"""Retention and expense loading: turning a claims cost into a charged rate.

The charged (gross) rate is built from the **fundamental insurance equation**.
With loss & LAE per exposure unit :math:`L(1+\text{lae})`, a flat fixed
expense per unit :math:`F`, a variable load :math:`V` (expenses that are a percentage of
premium -- commission, premium tax, percent-of-premium fees and admin), and a
profit / contingency provision :math:`Q` (also a percentage of premium):

.. math::
    P = L(1+\text{lae}) + F + V P + Q P
      \;\Longrightarrow\;
    P = \frac{L(1+\text{lae}) + F}{1 - V - Q}.

The variable load sits in the denominator because premium tax (and commission)
are levied on the premium that already contains them. The target / permissible
loss ratio is then an **output**, not an input:

.. math::
    \text{PLR} = \frac{L}{P} = \frac{L\,(1 - V - Q)}{L(1+\text{lae}) + F}.

Because the fixed expense :math:`F` is added per exposure unit (not scaled by a risk's
relativity), grossing ``base_loss_cost * relativities`` with this formula keeps fixed
expense flat across all rate cells, which is the correct treatment.

Some contracts run the equation in reverse: a loss ratio is pinned by contract and the
premium is solved from it. Both standard pins are parameterizations of the same closed
form — :meth:`RetentionLoad.from_gross_loss_ratio` for :math:`C/P = \text{LR}^*` and
:meth:`RetentionLoad.from_net_loss_ratio` for :math:`C/(P - E) = \text{LR}^*`.
"""
from __future__ import annotations

from dataclasses import dataclass
from typing import Mapping

import numpy as np

from ._utils import Numeric, maybe_float, require_nonnegative, require_unit_interval, safe_divide


[docs] @dataclass class RetentionLoad: """Expense and profit loads used to gross claims up to a charged rate. Parameters ---------- fixed_expense : float Flat operating expense per exposure unit (a dollar amount, not a percentage of premium). Default 0. variable_expense_ratio : float Sum of percent-of-premium loads: commission, premium tax, exchange / regulatory fees, and any admin expressed as a percentage of premium. Default 0. profit_margin : float Target underwriting profit / contribution to surplus, as a percentage of premium. Default 0. lae_ratio : float Loss adjustment expense as a percentage of claims. Default 0. """ fixed_expense: Numeric = 0.0 variable_expense_ratio: Numeric = 0.0 profit_margin: Numeric = 0.0 lae_ratio: Numeric = 0.0 def __post_init__(self) -> None: self.fixed_expense = require_nonnegative(self.fixed_expense, "fixed_expense") self.variable_expense_ratio = require_nonnegative( self.variable_expense_ratio, "variable_expense_ratio" ) self.profit_margin = require_nonnegative(self.profit_margin, "profit_margin") self.lae_ratio = require_nonnegative(self.lae_ratio, "lae_ratio") if np.any(np.asarray(self.variable_and_profit) >= 1.0): raise ValueError( "variable_expense_ratio + profit_margin must be < 1; " "the rate would be undefined or negative" )
[docs] @classmethod def from_items( cls, fixed_expense: float = 0.0, variable_items: Mapping[str, float] | None = None, profit_margin: float = 0.0, lae_ratio: float = 0.0, ) -> "RetentionLoad": """Construct from an itemized mapping of percent-of-premium loads. ``variable_items`` (e.g. ``{"commission": 0.04, "premium_tax": 0.023, "aca_fees": 0.005, "admin_pct": 0.06}``) is summed into the variable expense ratio. """ total_variable = maybe_float(sum((variable_items or {}).values())) return cls( fixed_expense=fixed_expense, variable_expense_ratio=total_variable, profit_margin=profit_margin, lae_ratio=lae_ratio, )
[docs] @classmethod def from_gross_loss_ratio( cls, loss_ratio: Numeric, variable_items: Mapping[str, float] | None = None, ) -> "RetentionLoad": r"""Retention for a contract that pins the gross loss ratio. The contract fixes :math:`C/P = \text{LR}^*`, so the premium is fully determined by claims: :math:`P = C/\text{LR}^*`. In the fundamental equation this is :math:`F = 0` with the whole percent-of-premium retention pinned at :math:`V + Q = 1 - \text{LR}^*`. Parameters ---------- loss_ratio : float or array-like The contractual claims / premium ratio, in (0, 1). A Series rates a book of pinned-ratio groups elementwise. variable_items : mapping, optional Known percent-of-premium components inside the retention (e.g. ``{"commission": 0.03, "premium_tax": 0.023}``). Itemizing does not change the premium — the contract pins the total — it only splits the retention: the remainder ``(1 - loss_ratio) - sum(items)`` lands in ``profit_margin``. Items exceeding the contractual retention raise, since the contract cannot cover them. Notes ----- Dollar expenses (a flat fee per exposure unit) do not belong here: a gross-ratio contract leaves no degree of freedom for them to move the premium. Project them separately and reconcile against the retention :math:`P(1 - \text{LR}^*)`; what remains is the margin. """ loss_ratio = require_unit_interval(loss_ratio, "loss_ratio", closed=False) total_variable = maybe_float(sum((variable_items or {}).values())) profit = maybe_float(1.0 - loss_ratio - total_variable) if np.any(np.asarray(profit) < 0): raise ValueError( "variable_items total exceeds the contractual retention share " "1 - loss_ratio; the contract cannot cover the named loads" ) return cls(variable_expense_ratio=total_variable, profit_margin=profit)
[docs] @classmethod def from_net_loss_ratio( cls, loss_ratio: Numeric, fixed_expense: Numeric = 0.0, variable_items: Mapping[str, float] | None = None, ) -> "RetentionLoad": r"""Retention for a contract that pins the loss ratio net of expenses. The contract fixes :math:`C/(P - E) = \text{LR}^*` with expenses :math:`E = F + V P`. Solving: .. math:: P - F - V P = C/\text{LR}^* \;\Longrightarrow\; P = \frac{C/\text{LR}^* + F}{1 - V}. The claims gross-up :math:`1/\text{LR}^*` is carried through the percent-of-claims slot — :math:`C\,(1 + \tfrac{1-\text{LR}^*}{\text{LR}^*}) = C/\text{LR}^*` — so ``lae_ratio`` on the returned instance holds :math:`(1-\text{LR}^*)/\text{LR}^*`, not a loss adjustment expense. If the contract's claims measure includes LAE, pass ``loss_cost`` inclusive of LAE rather than setting ``lae_ratio``. Parameters ---------- loss_ratio : float or array-like The contractual claims / (premium − expenses) ratio, in (0, 1). fixed_expense : float or array-like Dollar expenses per exposure unit netted out by the contract (e.g. a flat admin fee). Default 0. variable_items : mapping, optional Percent-of-premium expenses netted out by the contract, summed into :math:`V`. Notes ----- The margin under this contract is claims-proportional: :math:`P - E - C = C\,(1-\text{LR}^*)/\text{LR}^*` — in contrast with the gross form, where expenses plus margin are premium-proportional. :meth:`implied_net_loss_ratio` returns ``loss_ratio`` identically for instances built here. """ loss_ratio = require_unit_interval(loss_ratio, "loss_ratio", closed=False) total_variable = maybe_float(sum((variable_items or {}).values())) return cls( fixed_expense=fixed_expense, variable_expense_ratio=total_variable, lae_ratio=maybe_float((1.0 - loss_ratio) / loss_ratio), )
@property def variable_and_profit(self) -> float: """Combined percent-of-premium load :math:`V + Q`.""" return self.variable_expense_ratio + self.profit_margin
[docs] def gross_rate(self, loss_cost: Numeric) -> Numeric: r"""Gross a loss cost up to a charged rate via :math:`(L(1+\text{lae})+F)/(1-V-Q)`. Elementwise: a Series of loss costs (and/or Series-valued loads for per-row retention structures) returns a Series of charged rates. """ loss_cost = require_nonnegative(loss_cost, "loss_cost") numerator = loss_cost * (1.0 + self.lae_ratio) + self.fixed_expense return maybe_float(numerator / (1.0 - self.variable_and_profit))
[docs] def implied_loss_ratio(self, loss_cost: Numeric) -> Numeric: """Loss ratio implied at a given claims level (claims / gross rate). With a non-zero fixed expense this varies with the claims level; with only percentage loads it equals ``1 - variable_expense_ratio - profit_margin``. """ loss_cost = require_nonnegative(loss_cost, "loss_cost") return safe_divide(loss_cost, self.gross_rate(loss_cost))
[docs] def implied_net_loss_ratio(self, loss_cost: Numeric) -> Numeric: r"""Loss ratio net of expenses: :math:`C / (P - F - V P)`. Expenses are the fixed and variable loads; the profit provision is the carrier's and stays inside the denominator's premium. For a retention built with :meth:`from_net_loss_ratio` this returns the contractual ratio identically; for any other retention it is the net-basis counterpart of :meth:`implied_loss_ratio`. """ loss_cost = require_nonnegative(loss_cost, "loss_cost") premium = self.gross_rate(loss_cost) expenses = self.fixed_expense + self.variable_expense_ratio * premium return safe_divide(loss_cost, premium - expenses)
[docs] def expense_and_profit_ratio(self, loss_cost: Numeric) -> Numeric: """Share of the gross rate going to expense and profit (1 - loss ratio).""" return maybe_float(1.0 - self.implied_loss_ratio(loss_cost))
[docs] def gross_rate(loss_cost: Numeric, retention: RetentionLoad) -> Numeric: """Functional form of :meth:`RetentionLoad.gross_rate`.""" return retention.gross_rate(loss_cost)
[docs] def permissible_loss_ratio(retention: RetentionLoad, loss_cost: Numeric) -> Numeric: """Functional form of :meth:`RetentionLoad.implied_loss_ratio`.""" return retention.implied_loss_ratio(loss_cost)