"""
Exposure Rating Tools
Comprehensive rating functions for computing rates and applying class plans.
Features:
- Rate per exposure computation with loadings
- Class plan creation from factor relativities
- Rating table generation for production systems
- Relativity application and aggregation
Author: Michael Watson
License: MPL-2.0
"""
import logging
from typing import Any, Dict, List, Optional, Union
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
[docs]
def compute_rate_per_exposure(
pure_premium: Union[pd.Series, np.ndarray, float],
exposure: Union[pd.Series, np.ndarray, float],
loadings: Optional[Dict[str, float]] = None
) -> Union[pd.Series, np.ndarray, float]:
"""
Compute rate per unit exposure.
Rate = (Pure Premium / Exposure) × Loading Factor
Parameters
----------
pure_premium : array-like or float
Pure premium (total expected loss)
exposure : array-like or float
Exposure (e.g., policy years, sales, payroll)
loadings : dict, optional
Loading factors (if None, uses pure premium rate)
Returns
-------
rate : array-like or float
Rate per unit exposure
Examples
--------
>>> rate = compute_rate_per_exposure(
... pure_premium=1000,
... exposure=10,
... loadings={'profit': 0.05, 'expenses': 0.15}
... )
"""
# Base rate
rate = pure_premium / np.maximum(exposure, 1e-10)
# Apply loadings if provided
if loadings:
loading_factor = 1.0
for key, value in loadings.items():
if 'ratio' in key or 'commission' in key or 'tax' in key:
# These are divisors
loading_factor /= max(1 - value, 0.01)
elif 'margin' in key or 'inflation' in key or 'profit' in key:
# These are multipliers
loading_factor *= (1 + value)
rate = rate * loading_factor
return rate
[docs]
def apply_exposure_curve(
base_rate: Union[float, pd.Series, np.ndarray],
exposure: Union[float, pd.Series, np.ndarray],
exposure_curve: 'ExposureCurve',
) -> Union[float, pd.Series, np.ndarray]:
"""Adjust a base rate using an ExposureCurve factor."""
try:
from actuaflow.exposure.exposure_curves import ExposureCurve
except ImportError:
raise ImportError("ExposureCurve class is required for apply_exposure_curve")
if not isinstance(exposure_curve, ExposureCurve):
raise TypeError("exposure_curve must be an ExposureCurve instance")
factor = exposure_curve.exposure_factor(exposure)
if isinstance(base_rate, pd.Series):
return base_rate * factor
if isinstance(base_rate, np.ndarray):
return base_rate * np.asarray(factor)
return float(base_rate) * float(np.asarray(factor))
[docs]
def create_class_plan(
data: pd.DataFrame,
rating_factors: List[str],
base_rate: float,
relativities: Dict[str, Dict[str, float]],
exposure_col: str = 'exposure',
min_rate: Optional[float] = None,
max_rate: Optional[float] = None
) -> pd.DataFrame:
"""
Create a class plan rate table by applying factor relativities to base rate.
Class Plan Formula:
Rate = Base Rate × Factor_1_Relativity × Factor_2_Relativity × ...
Parameters
----------
data : pd.DataFrame
Data with rating factor values
rating_factors : list of str
Rating factor column names
base_rate : float
Base rate per unit exposure
relativities : dict
Nested dict of {factor: {level: relativity}}
exposure_col : str
Exposure column name
min_rate : float, optional
Minimum allowed rate
max_rate : float, optional
Maximum allowed rate
Returns
-------
pd.DataFrame
Data with added 'rate' and 'premium' columns
Examples
--------
>>> relativities = {
... 'age_group': {'18-25': 1.5, '26-35': 1.0, '36+': 0.8},
... 'vehicle_type': {'sedan': 1.0, 'suv': 1.2, 'sports': 1.8}
... }
>>> rates = create_class_plan(
... data=policies,
... rating_factors=['age_group', 'vehicle_type'],
... base_rate=100.0,
... relativities=relativities
... )
"""
result = data.copy()
result['rate'] = base_rate
# Validate inputs
try:
from actuaflow.exceptions import MissingColumnError as MissingColumnError
except ImportError:
class MissingColumnError(ValueError):
"""Raised when a required rating factor column is missing in the input data."""
for factor in rating_factors:
if factor not in result.columns:
raise MissingColumnError(
column=factor,
available_columns=result.columns.tolist()
)
# Apply each factor's relativities
for factor in rating_factors:
if factor not in relativities:
continue
# Map relativities
factor_rel = relativities[factor]
result['rate'] *= result[factor].map(factor_rel).fillna(1.0)
# Apply min/max caps
if min_rate is not None:
result['rate'] = result['rate'].clip(lower=min_rate)
if max_rate is not None:
result['rate'] = result['rate'].clip(upper=max_rate)
# Compute premium
if exposure_col in result.columns:
result['premium'] = result['rate'] * result[exposure_col]
return result
[docs]
def apply_relativities(
base_value: float,
factor_relativities: Dict[str, float]
) -> float:
"""
Apply multiplicative relativities to a base value.
Result = Base × Rel_1 × Rel_2 × ...
Parameters
----------
base_value : float
Base value (rate, premium, etc.)
factor_relativities : dict
{factor_name: relativity_value}
Returns
-------
float
Adjusted value
Examples
--------
>>> rate = apply_relativities(
... base_value=100.0,
... factor_relativities={'age': 1.2, 'territory': 0.9}
... )
>>> # Result: 100 × 1.2 × 0.9 = 108.0
"""
result = base_value
for relativity in factor_relativities.values():
result *= relativity
return result
[docs]
def create_rating_table(
factor_combinations: pd.DataFrame,
base_rate: float,
relativities: Dict[str, Dict[str, float]]
) -> pd.DataFrame:
"""
Create a complete rating table for all factor combinations.
Useful for creating lookup tables for production rating systems.
Parameters
----------
factor_combinations : pd.DataFrame
All combinations of rating factors
base_rate : float
Base rate
relativities : dict
Nested relativities dict
Returns
-------
pd.DataFrame
Rating table with rate for each combination
Examples
--------
>>> import itertools
>>> ages = ['18-25', '26-35', '36+']
>>> vehicles = ['sedan', 'suv', 'sports']
>>> combos = pd.DataFrame(
... list(itertools.product(ages, vehicles)),
... columns=['age_group', 'vehicle_type']
... )
>>> rating_table = create_rating_table(combos, 100.0, relativities)
"""
result = factor_combinations.copy()
result['base_rate'] = base_rate
result['combined_relativity'] = 1.0
# Apply each factor
for factor, levels in relativities.items():
if factor not in result.columns:
continue
result[f'{factor}_rel'] = result[factor].map(levels).fillna(1.0)
result['combined_relativity'] *= result[f'{factor}_rel']
result['final_rate'] = result['base_rate'] * result['combined_relativity']
return result
[docs]
def compute_credibility_weighted_rate(
manual_rate: float,
experience_rate: float,
credibility: float
) -> float:
"""
Compute credibility-weighted rate.
Blends manual (a priori) rate with experience (a posteriori) rate.
Credibility Formula:
Rate = Z × Experience_Rate + (1 - Z) × Manual_Rate
where Z is the credibility factor (0 to 1).
Parameters
----------
manual_rate : float
Manual (class plan) rate
experience_rate : float
Experience-based rate
credibility : float
Credibility factor (0 = full manual, 1 = full experience)
Returns
-------
float
Credibility-weighted rate
Examples
--------
>>> rate = compute_credibility_weighted_rate(
... manual_rate=100.0,
... experience_rate=120.0,
... credibility=0.3
... )
>>> # Result: 0.3 × 120 + 0.7 × 100 = 106.0
"""
if not 0 <= credibility <= 1:
raise ValueError("Credibility must be between 0 and 1")
return credibility * experience_rate + (1 - credibility) * manual_rate
[docs]
def compute_experience_mod(
actual_losses: float,
expected_losses: float,
credibility: Optional[float] = None,
cap: Optional[float] = None
) -> float:
"""
Compute experience modification factor.
Experience Mod = [Z × (Actual / Expected) + (1 - Z) × 1.0]
Parameters
----------
actual_losses : float
Actual incurred losses
expected_losses : float
Expected losses (from class rate)
credibility : float, optional
Credibility factor. If None, uses full credibility (Z=1)
cap : float, optional
Maximum allowed modification (e.g., 2.0 for 200% cap)
Returns
-------
float
Experience modification factor
Examples
--------
>>> exp_mod = compute_experience_mod(
... actual_losses=120000,
... expected_losses=100000,
... credibility=0.5,
... cap=2.0
... )
>>> # Result: 0.5 × (120/100) + 0.5 × 1.0 = 1.1
"""
if credibility is None:
credibility = 1.0
if expected_losses <= 0:
return 1.0
loss_ratio = actual_losses / expected_losses
mod = credibility * loss_ratio + (1 - credibility) * 1.0
if cap is not None:
mod = min(mod, cap)
return mod