Source code for ratingmodels.manual_rate

r"""Manual rate construction.

The manual (book) rate is a base cost level scaled by the product of rating
relativities and then loaded for expenses and margin:

.. math::
    \text{manual loss cost} = \text{base} \times \prod_i f_i, \qquad
    \text{manual rate} = \frac{\text{manual loss cost}}{\text{target loss ratio}}.

For a group, unit-level demographic factors are aggregated to a single
relativity (exposure-weighted) before composing with group-level factors
(area, industry, group size, network, plan/benefit).
"""
from __future__ import annotations

from dataclasses import dataclass, field
from typing import Mapping, Sequence

import numpy as np
import pandas as pd

from ._utils import product, require_positive, require_unit_interval
from .buildup import BuildUpResult, checkpoint, evaluate, multiply, start
from .loading import RetentionLoad


[docs] def manual_loss_cost(base_loss_cost: float, factors: Sequence[float]) -> float: r"""Base loss cost scaled by the product of relativities.""" require_positive(base_loss_cost, "base_loss_cost") return base_loss_cost * product(factors)
[docs] def aggregate_demographic_factor( census: pd.DataFrame, factor_col: str, weight_col: str = "count", ) -> float: """Weighted average of a unit-level demographic factor (e.g. an age/sex factor weighted by member counts).""" w = census[weight_col].to_numpy(dtype=float) f = census[factor_col].to_numpy(dtype=float) if w.sum() <= 0: raise ValueError("total weight must be positive") return float(np.average(f, weights=w))
[docs] @dataclass class ManualRate: """Build a manual rate from a base and a set of named relativities. Parameters ---------- base_loss_cost : float Base loss cost (per exposure unit) at the rating-period level (see :func:`ratingmodels.base_rate_from_experience` to derive it). factors : mapping Named relativities, e.g. ``{"area": 1.05, "industry": 0.97, ...}``. target_loss_ratio : float Claims / premium target used to gross up to a charged rate. Ignored when ``retention`` is supplied. retention : RetentionLoad, optional Full expense / profit loading. When provided, the charged rate is built with the fundamental insurance equation instead of a single loss ratio, and fixed expense is applied per exposure unit (flat across cells). """ base_loss_cost: float factors: Mapping[str, float] = field(default_factory=dict) target_loss_ratio: float = 0.85 retention: "RetentionLoad | None" = None def __post_init__(self) -> None: require_positive(self.base_loss_cost, "base_loss_cost") if self.retention is None: require_unit_interval(self.target_loss_ratio, "target_loss_ratio", closed=False) def total_relativity(self) -> float: return product(self.factors.values())
[docs] def loss_cost(self) -> float: """Expected manual loss cost (before expense/margin loading).""" return self.base_loss_cost * self.total_relativity()
[docs] def steps(self) -> list: """The manual claims build-up as an ordered list of steps.""" s = [start("Base claims cost", self.base_loss_cost)] s += [multiply(name, factor) for name, factor in self.factors.items()] s.append(checkpoint("Manual loss cost")) return s
[docs] def breakdown(self) -> "BuildUpResult": """Audit trail of the manual claims build-up (base x each relativity). The final running total equals :meth:`loss_cost` up to floating point. """ return evaluate(self.steps())
[docs] def rate(self) -> float: """Charged manual 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
def with_factor(self, name: str, value: float) -> "ManualRate": new = dict(self.factors) new[name] = value return ManualRate(self.base_loss_cost, new, self.target_loss_ratio)