Source code for ratingmodels.scenarios

r"""Pricing scenarios: evaluate a case at any rate action and report margin.

The rate indication answers one question -- *what action does the formula
say?* Management pricing asks the surrounding ones: what margin falls out at
the action actually **issued**, after **concessions**, at the **plan** action;
what action produces **zero** margin or a **target** margin; and what uniform
uplift to a book's actions holds the aggregate margin when the achieved
actions slip below formula. This module answers those with the same expense
algebra the indication already uses (:class:`ratingmodels.RetentionLoad`).

All rates and costs are per unit of exposure -- whatever the caller's unit is
(member months, policy months, earned exposures). Dollar outputs are the
per-unit figures times ``exposure``.

**Forward.**  At charged rate :math:`P` with loss cost :math:`L`, LAE ratio
``lae``, fixed expense :math:`F` per exposure unit, and variable load
:math:`V` (percent of premium):

.. math::

    \text{gross margin} = P - L(1+\text{lae}), \qquad
    \text{margin} = P(1 - V) - L(1+\text{lae}) - F.

Gross margin is the loss-tier margin (operating expense excluded);
``margin`` is the underwriting gain after retention expense. At the
indicated rate the margin ratio equals the retention's ``profit_margin``
exactly.

**Inverse.**  The rate that yields margin ratio :math:`m` has the same form
as the gross-up itself, with :math:`m` in place of the profit provision:

.. math::

    P(m) = \frac{L(1+\text{lae}) + F}{1 - V - m}.

Zero-margin and plan-target premiums are this solve at :math:`m = 0` and
:math:`m = m_{\text{plan}}`; the standard indication is the special case
:math:`m = Q`.

Scenario names ("issued", "net concession", "select", ...) are the caller's
vocabulary: this module evaluates named actions and returns tidy rows; what
the names mean is business context that stays outside the library.
"""

from __future__ import annotations

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

import pandas as pd

from ._utils import require_nonnegative, require_positive, require_unit_interval
from .indication import RateIndication
from .loading import RetentionLoad

_MODES = ("multiplicative", "additive")


[docs] @dataclass(frozen=True) class ScenarioOutcome: """Result of evaluating one case at one rate action. Per-exposure fields are always present. Dollar fields require ``exposure`` on the evaluation and are ``None`` otherwise; ``expected_*`` fields additionally require ``persistency`` and are the renewal-probability-weighted expectations (the deterministic counterpart of a retention Bernoulli). """ name: str | None rate_change: float premium_rate: float loss_cost: float loss_and_lae: float expense_rate: float loss_ratio: float gross_margin_rate: float margin_rate: float margin_ratio: float exposure: float | None = None persistency: float | None = None premium: float | None = None gross_margin: float | None = None margin: float | None = None expected_premium: float | None = None expected_margin: float | None = None
[docs] def as_dict(self) -> dict[str, Any]: """Plain-dict view, one tidy row.""" return { "scenario": self.name, "rate_change": self.rate_change, "premium_rate": self.premium_rate, "loss_cost": self.loss_cost, "loss_and_lae": self.loss_and_lae, "expense_rate": self.expense_rate, "loss_ratio": self.loss_ratio, "gross_margin_rate": self.gross_margin_rate, "margin_rate": self.margin_rate, "margin_ratio": self.margin_ratio, "exposure": self.exposure, "persistency": self.persistency, "premium": self.premium, "gross_margin": self.gross_margin, "margin": self.margin, "expected_premium": self.expected_premium, "expected_margin": self.expected_margin, }
[docs] @dataclass class PricingEvaluation: """A case's pricing state, evaluable at arbitrary rate actions. Parameters ---------- loss_cost : float Expected loss cost per exposure unit over the rating period (trended, pooled, credibility-blended -- e.g. ``RateIndication.blended_loss_cost()``). current_rate : float Current charged rate per exposure unit that rate changes apply to. retention : RetentionLoad, optional Expense structure. When omitted, no expenses are modeled: ``margin`` equals ``gross margin`` (premium less losses) and the inverse solve reduces to :math:`P = L / (1 - m)`. exposure : float, optional Rating-period exposure units; enables dollar outputs. persistency : float in [0, 1], optional Renewal probability; enables ``expected_*`` outputs (premium and margin scaled by the probability the case is still on the books). """ loss_cost: float current_rate: float retention: RetentionLoad | None = None exposure: float | None = None persistency: float | None = None def __post_init__(self) -> None: require_nonnegative(self.loss_cost, "loss_cost") require_positive(self.current_rate, "current_rate") if self.exposure is not None: require_positive(self.exposure, "exposure") if self.persistency is not None: require_unit_interval(self.persistency, "persistency")
[docs] @classmethod def from_indication( cls, indication: RateIndication, *, exposure: float | None = None, persistency: float | None = None, ) -> "PricingEvaluation": """Adopt a :class:`RateIndication`'s blended loss cost, rate, and retention. With a retention on the indication, evaluating at ``indicated_rate_change()`` returns a margin ratio equal to the retention's ``profit_margin``. Without one the indication grosses by target loss ratio, expenses are unmodeled here, and margin equals gross margin. """ return cls( loss_cost=indication.blended_loss_cost(), current_rate=indication.current_rate, retention=indication.retention, exposure=exposure, persistency=persistency, )
# ----- expense algebra ----- # def _pieces(self) -> tuple[float, float, float]: """(loss_and_lae, fixed_expense, variable_ratio) under the retention.""" if self.retention is None: return self.loss_cost, 0.0, 0.0 r = self.retention return self.loss_cost * (1.0 + r.lae_ratio), r.fixed_expense, r.variable_expense_ratio # ----- forward ----- #
[docs] def at(self, rate_change: float, *, name: str | None = None) -> ScenarioOutcome: """Evaluate the case at a given proportional rate change.""" premium_rate = self.current_rate * (1.0 + float(rate_change)) require_positive(premium_rate, "premium at rate_change") loss_and_lae, fixed, variable = self._pieces() expense_rate = fixed + variable * premium_rate gross_margin_rate = premium_rate - loss_and_lae margin_rate = gross_margin_rate - expense_rate units = self.exposure p = self.persistency return ScenarioOutcome( name=name, rate_change=float(rate_change), premium_rate=premium_rate, loss_cost=self.loss_cost, loss_and_lae=loss_and_lae, expense_rate=expense_rate, loss_ratio=self.loss_cost / premium_rate, gross_margin_rate=gross_margin_rate, margin_rate=margin_rate, margin_ratio=margin_rate / premium_rate, exposure=units, persistency=p, premium=None if units is None else premium_rate * units, gross_margin=None if units is None else gross_margin_rate * units, margin=None if units is None else margin_rate * units, expected_premium=None if units is None or p is None else premium_rate * units * p, expected_margin=None if units is None or p is None else margin_rate * units * p, )
# ----- inverse ----- #
[docs] def premium_for_margin(self, target_margin: float) -> float: r"""Charged rate (per exposure unit) at which the margin ratio equals the target. Closed form: :math:`P = (L(1+\text{lae}) + F) / (1 - V - m)`. The target may be negative (a planned loss) but must satisfy :math:`m < 1 - V` for a positive, finite rate. """ loss_and_lae, fixed, variable = self._pieces() m = float(target_margin) denominator = 1.0 - variable - m if not denominator > 0: raise ValueError( f"target_margin must be less than 1 - variable_expense_ratio " f"= {1.0 - variable:.6g}, got {m!r}" ) numerator = loss_and_lae + fixed if numerator <= 0: raise ValueError( "premium_for_margin requires positive loss cost or fixed " "expense; with both zero every rate yields the target" ) return numerator / denominator
[docs] def rate_change_for_margin(self, target_margin: float) -> float: """Proportional rate change that yields the target margin ratio.""" return self.premium_for_margin(target_margin) / self.current_rate - 1.0
[docs] def zero_margin_rate_change(self) -> float: """Rate change at which the underwriting margin is exactly zero.""" return self.rate_change_for_margin(0.0)
[docs] def scenario_frame( cases: Mapping[Any, PricingEvaluation], scenarios: Mapping[str, float | Mapping[Any, float]], ) -> pd.DataFrame: """Evaluate named rate actions across cases into one tidy long table. Parameters ---------- cases : Mapping[case_id, PricingEvaluation] The book, keyed however the caller identifies cases. scenarios : Mapping[str, float | Mapping[case_id, float]] Each scenario is a rate change: a single float applied to every case, or a per-case mapping. A per-case mapping must cover every case -- a missing action is an error, not a silent skip. Returns ------- pd.DataFrame One row per ``(case, scenario)``: ``case``, ``scenario``, ``rate_change``, per-exposure economics, and dollar / persistency-weighted columns where the evaluation carries exposure and persistency. Any summary view -- a cohort rollup, a key-case exhibit -- is a pivot or groupby of this table. """ if not cases: raise ValueError("cases must contain at least one PricingEvaluation") if not scenarios: raise ValueError("scenarios must contain at least one rate change") rows: list[dict[str, Any]] = [] for scenario_name, action in scenarios.items(): for case_id, evaluation in cases.items(): if isinstance(action, Mapping): if case_id not in action: raise KeyError( f"scenario {scenario_name!r} has no rate change for " f"case {case_id!r}" ) change = action[case_id] else: change = action row = evaluation.at(change, name=scenario_name).as_dict() rows.append({"case": case_id, **row}) frame = pd.DataFrame(rows) optional = [ "exposure", "persistency", "premium", "gross_margin", "margin", "expected_premium", "expected_margin", ] drop = [c for c in optional if frame[c].isna().all()] return frame.drop(columns=drop)
[docs] def uplift_for_target_margin( cases: Mapping[Any, PricingEvaluation], base_changes: Mapping[Any, float] | float, target_margin: float, *, mode: str = "multiplicative", weight_by_persistency: bool = True, ) -> float: r"""Uniform uplift to a book's rate actions that holds an aggregate margin. Answers the exhibit input "to achieve the same target margin, rate actions must be X% higher": when achieved actions slip below formula (concessions, caps), this is the across-the-board adjustment that restores the book's aggregate margin ratio to the target. Let case :math:`g` have base premium :math:`P_g` (at its base change), per-unit cost :math:`K_g = L_g(1+\text{lae}_g) + F_g`, variable load :math:`V_g`, and weight :math:`w_g` = exposure units (times persistency when ``weight_by_persistency``). The aggregate margin ratio is .. math:: m(P) = \frac{\sum_g w_g \left(P_g (1 - V_g) - K_g\right)} {\sum_g w_g P_g}, which is a ratio of functions **affine in the uplift**, so the solve is closed-form -- no iteration: * ``multiplicative`` -- new change :math:`a_g' = (1 + a_g)(1 + u) - 1`, so :math:`P_g(u) = P_g (1+u)` and with :math:`A = \sum w P (1-V)`, :math:`B = \sum w K`, :math:`C = \sum w P`: .. math:: 1 + u = \frac{B}{A - m^\* C}. * ``additive`` -- new change :math:`a_g' = a_g + u`, so :math:`P_g(u) = P_g + r_g u` with current rate :math:`r_g`, and with :math:`A' = \sum w r (1-V)`, :math:`C' = \sum w r`: .. math:: u = \frac{B + m^\* C - A}{A' - m^\* C'}. Returns the uplift ``u``. Feasibility (a positive solution exists and every resulting premium is positive) is validated with explicit errors. """ if mode not in _MODES: raise ValueError(f"mode must be one of {_MODES}, got {mode!r}") if not cases: raise ValueError("cases must contain at least one PricingEvaluation") m_star = float(target_margin) a = b = c = a_prime = c_prime = 0.0 entries: list[tuple[PricingEvaluation, float, float]] = [] for case_id, evaluation in cases.items(): if isinstance(base_changes, Mapping): if case_id not in base_changes: raise KeyError(f"base_changes has no rate change for case {case_id!r}") change = float(base_changes[case_id]) else: change = float(base_changes) weight = 1.0 if evaluation.exposure is None else float(evaluation.exposure) if weight_by_persistency and evaluation.persistency is not None: weight *= evaluation.persistency if weight <= 0: continue base_premium = evaluation.current_rate * (1.0 + change) require_positive(base_premium, f"base premium for case {case_id!r}") loss_and_lae, fixed, variable = evaluation._pieces() entries.append((evaluation, change, weight)) a += weight * base_premium * (1.0 - variable) b += weight * (loss_and_lae + fixed) c += weight * base_premium a_prime += weight * evaluation.current_rate * (1.0 - variable) c_prime += weight * evaluation.current_rate if not entries: raise ValueError("all cases have zero weight; nothing to solve") if b <= 0: raise ValueError( "aggregate loss and fixed expense are zero; the margin ratio " "does not depend on the uplift" ) if mode == "multiplicative": denominator = a - m_star * c if not denominator > 0: raise ValueError( f"target_margin {m_star!r} is not attainable by scaling " "these premiums: it is at or above the book's asymptotic " "margin ratio" ) uplift = b / denominator - 1.0 else: denominator = a_prime - m_star * c_prime if not denominator > 0: raise ValueError( f"target_margin {m_star!r} is not attainable by an additive " "uplift on these cases" ) uplift = (b + m_star * c - a) / denominator for evaluation, change, _ in entries: if mode == "multiplicative": new_change = (1.0 + change) * (1.0 + uplift) - 1.0 else: new_change = change + uplift if evaluation.current_rate * (1.0 + new_change) <= 0: raise ValueError( "solved uplift drives at least one case's premium non-positive" ) return uplift