"""Core actuarial metric primitives."""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
def _is_pandas(obj: Any) -> bool:
return isinstance(obj, (pd.Series, pd.DataFrame))
[docs]
def safe_divide(numerator: Any, denominator: Any, *, fill_value: float = np.nan) -> Any:
"""Safely divide numerator by denominator.
The return type mirrors the input: scalars return scalars, array-likes
return NumPy arrays, and pandas inputs return pandas objects with their
index (and name) preserved -- so results can be assigned straight back
onto the source DataFrame. Zero denominators are returned as
``fill_value``.
"""
if isinstance(numerator, (int, float, np.number)) and isinstance(denominator, (int, float, np.number)):
return fill_value if denominator == 0 else numerator / denominator
if _is_pandas(numerator) or _is_pandas(denominator):
with np.errstate(divide="ignore", invalid="ignore"):
out = numerator / denominator
if _is_pandas(denominator):
zero_mask = denominator.eq(0).reindex_like(out).fillna(False)
else:
zero_mask = np.broadcast_to(np.asarray(denominator) == 0, np.shape(out))
return out.mask(zero_mask, fill_value)
numerator_arr = np.asarray(numerator, dtype=float)
denominator_arr = np.asarray(denominator, dtype=float)
numerator_b, denominator_b = np.broadcast_arrays(numerator_arr, denominator_arr)
return np.divide(
numerator_b,
denominator_b,
out=np.full(numerator_b.shape, fill_value, dtype=float),
where=denominator_b != 0,
)
[docs]
def ratio(numerator: Any, denominator: Any) -> Any:
"""Calculate a generic ratio as numerator divided by denominator."""
return safe_divide(numerator, denominator)
[docs]
def loss_ratio(losses_or_expenses: Any, revenue: Any) -> Any:
"""Calculate a loss ratio: losses or expenses divided by revenue."""
return ratio(losses_or_expenses, revenue)
[docs]
def expense_ratio(expenses: Any, revenue: Any) -> Any:
"""Calculate an expense ratio: expenses divided by revenue."""
return ratio(expenses, revenue)
[docs]
def combined_ratio(losses: Any, expenses: Any, revenue: Any) -> Any:
"""Calculate combined ratio: (losses + expenses) divided by revenue."""
if _is_pandas(losses) or _is_pandas(expenses):
return ratio(losses + expenses, revenue)
return ratio(np.asarray(losses) + np.asarray(expenses), revenue)
[docs]
def actual_to_expected(actual: Any, expected: Any) -> Any:
"""Calculate actual-to-expected: actual divided by expected."""
return ratio(actual, expected)
[docs]
def per_exposure(amount: Any, exposure: Any) -> Any:
"""Calculate amount per exposure unit."""
return ratio(amount, exposure)
[docs]
def frequency(claim_count: Any, exposure: Any) -> Any:
"""Calculate claim frequency: claim count divided by exposure."""
return ratio(claim_count, exposure)
[docs]
def severity(losses: Any, claim_count: Any) -> Any:
"""Calculate severity: losses divided by claim count."""
return ratio(losses, claim_count)
[docs]
def pure_premium(losses: Any, exposure: Any) -> Any:
"""Calculate pure premium: losses divided by exposure."""
return per_exposure(losses, exposure)
[docs]
def required_revenue(expense: Any, target_ratio: Any) -> Any:
"""Revenue needed for an expense amount to hit a target ratio."""
return safe_divide(expense, target_ratio)
[docs]
def indicated_change(required: Any, current: Any) -> Any:
"""Indicated change from current to required amount."""
return safe_divide(required, current) - 1
[docs]
def permissible_loss_ratio(expense_ratio: Any, profit_provision: Any = 0.0) -> Any:
"""Permissible (target / break-even) loss ratio.
``PLR = 1 - expense_ratio - profit_provision`` where both loadings are
expressed as a fraction of premium. Also called the zero-margin or target
loss ratio: the loss ratio at which premium exactly covers losses, expenses,
and the profit/contingency provision. Works element-wise on scalars or
Series. (Shops that load fixed expenses on a loss basis instead use
``(1 - V - Q) / (1 + G)``; this implements the premium-basis form.)
"""
return 1.0 - expense_ratio - profit_provision