ratingmodels 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ratingmodels/__init__.py +157 -0
- ratingmodels/_utils.py +53 -0
- ratingmodels/base_rate.py +151 -0
- ratingmodels/blend.py +20 -0
- ratingmodels/buildup.py +253 -0
- ratingmodels/constraints.py +58 -0
- ratingmodels/credibility.py +129 -0
- ratingmodels/datasets.py +78 -0
- ratingmodels/decomposition.py +102 -0
- ratingmodels/experience_rate.py +131 -0
- ratingmodels/indication.py +156 -0
- ratingmodels/loading.py +126 -0
- ratingmodels/manual_rate.py +110 -0
- ratingmodels/relativity.py +300 -0
- ratingmodels/renewal.py +84 -0
- ratingmodels/trend.py +74 -0
- ratingmodels-0.1.0.dist-info/METADATA +230 -0
- ratingmodels-0.1.0.dist-info/RECORD +20 -0
- ratingmodels-0.1.0.dist-info/WHEEL +4 -0
- ratingmodels-0.1.0.dist-info/licenses/LICENSE +21 -0
ratingmodels/__init__.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
r"""ratingmodels -- actuarial pricing and rate-indication tools.
|
|
2
|
+
|
|
3
|
+
A small, dependency-light toolkit for the group rating workflow: credibility,
|
|
4
|
+
trend, manual and experience rate construction, credibility blending, rate
|
|
5
|
+
indication, rate-change decomposition, GLM relativity estimation, and renewal
|
|
6
|
+
constraints. Part of the OpenActuarial ecosystem.
|
|
7
|
+
|
|
8
|
+
Quick start
|
|
9
|
+
-----------
|
|
10
|
+
>>> import ratingmodels as rm
|
|
11
|
+
>>> exp = rm.ExperienceRate(
|
|
12
|
+
... incurred_claims=4_200_000, exposure=96_000,
|
|
13
|
+
... trend_annual=0.075, trend_years=1.5,
|
|
14
|
+
... pooled_excess=350_000, pooling_charge_pmpm=4.0,
|
|
15
|
+
... target_loss_ratio=0.85,
|
|
16
|
+
... )
|
|
17
|
+
>>> man = rm.ManualRate(base_pmpm=480, factors={"area": 1.05, "industry": 0.97})
|
|
18
|
+
>>> z = rm.limited_fluctuation_credibility(n=96_000, n_full=120_000)
|
|
19
|
+
>>> ind = rm.RateIndication(
|
|
20
|
+
... experience_claims_pmpm=exp.claims_pmpm(),
|
|
21
|
+
... manual_claims_pmpm=man.claims_pmpm(),
|
|
22
|
+
... credibility=z, current_rate=560, target_loss_ratio=0.85,
|
|
23
|
+
... trend_total_factor=exp.trend_factor(),
|
|
24
|
+
... )
|
|
25
|
+
>>> round(ind.indicated_rate_change(), 4) # doctest: +SKIP
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from .base_rate import (
|
|
30
|
+
BaseRateResult,
|
|
31
|
+
average_relativity,
|
|
32
|
+
base_rate_from_experience,
|
|
33
|
+
off_balance_factor,
|
|
34
|
+
rebalance_base_rate,
|
|
35
|
+
)
|
|
36
|
+
from .blend import blend
|
|
37
|
+
from .buildup import (
|
|
38
|
+
BuildUp,
|
|
39
|
+
BuildUpResult,
|
|
40
|
+
Step,
|
|
41
|
+
add,
|
|
42
|
+
checkpoint,
|
|
43
|
+
combine_streams,
|
|
44
|
+
evaluate,
|
|
45
|
+
multiply,
|
|
46
|
+
participation_blend,
|
|
47
|
+
segment_multiply,
|
|
48
|
+
start,
|
|
49
|
+
)
|
|
50
|
+
from .constraints import apply_cap, band, cap_change, corridor, round_rate
|
|
51
|
+
from .credibility import (
|
|
52
|
+
BuhlmannStraubResult,
|
|
53
|
+
buhlmann_credibility,
|
|
54
|
+
buhlmann_straub,
|
|
55
|
+
full_credibility_standard,
|
|
56
|
+
limited_fluctuation_credibility,
|
|
57
|
+
)
|
|
58
|
+
from .decomposition import RateChangeDecomposition, decompose_rate_change
|
|
59
|
+
from .experience_rate import (
|
|
60
|
+
ExperienceRate,
|
|
61
|
+
expected_excess_charge,
|
|
62
|
+
pool_claims,
|
|
63
|
+
)
|
|
64
|
+
from .indication import RateIndication
|
|
65
|
+
from .loading import (
|
|
66
|
+
RetentionLoad,
|
|
67
|
+
gross_rate,
|
|
68
|
+
permissible_loss_ratio,
|
|
69
|
+
)
|
|
70
|
+
from .manual_rate import (
|
|
71
|
+
ManualRate,
|
|
72
|
+
aggregate_demographic_factor,
|
|
73
|
+
manual_pmpm,
|
|
74
|
+
)
|
|
75
|
+
from .relativity import (
|
|
76
|
+
FactorTable,
|
|
77
|
+
GLMRelativities,
|
|
78
|
+
one_way_relativities,
|
|
79
|
+
)
|
|
80
|
+
from .renewal import RenewalAction, member_level_renewal, renew
|
|
81
|
+
from .trend import (
|
|
82
|
+
apply_trend,
|
|
83
|
+
combine_trend,
|
|
84
|
+
period_midpoint,
|
|
85
|
+
split_total_trend,
|
|
86
|
+
trend_factor,
|
|
87
|
+
trend_factor_between,
|
|
88
|
+
years_between,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
__version__ = "0.1.0"
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"__version__",
|
|
95
|
+
# credibility
|
|
96
|
+
"full_credibility_standard",
|
|
97
|
+
"limited_fluctuation_credibility",
|
|
98
|
+
"buhlmann_credibility",
|
|
99
|
+
"buhlmann_straub",
|
|
100
|
+
"BuhlmannStraubResult",
|
|
101
|
+
# trend
|
|
102
|
+
"trend_factor",
|
|
103
|
+
"trend_factor_between",
|
|
104
|
+
"apply_trend",
|
|
105
|
+
"combine_trend",
|
|
106
|
+
"split_total_trend",
|
|
107
|
+
"period_midpoint",
|
|
108
|
+
"years_between",
|
|
109
|
+
# relativity
|
|
110
|
+
"FactorTable",
|
|
111
|
+
"one_way_relativities",
|
|
112
|
+
"GLMRelativities",
|
|
113
|
+
# manual / experience
|
|
114
|
+
"ManualRate",
|
|
115
|
+
"manual_pmpm",
|
|
116
|
+
"aggregate_demographic_factor",
|
|
117
|
+
"ExperienceRate",
|
|
118
|
+
"pool_claims",
|
|
119
|
+
"expected_excess_charge",
|
|
120
|
+
# base rate / off-balance
|
|
121
|
+
"base_rate_from_experience",
|
|
122
|
+
"BaseRateResult",
|
|
123
|
+
"average_relativity",
|
|
124
|
+
"off_balance_factor",
|
|
125
|
+
"rebalance_base_rate",
|
|
126
|
+
# build-up engine
|
|
127
|
+
"Step",
|
|
128
|
+
"start",
|
|
129
|
+
"multiply",
|
|
130
|
+
"add",
|
|
131
|
+
"segment_multiply",
|
|
132
|
+
"checkpoint",
|
|
133
|
+
"evaluate",
|
|
134
|
+
"BuildUp",
|
|
135
|
+
"BuildUpResult",
|
|
136
|
+
"participation_blend",
|
|
137
|
+
"combine_streams",
|
|
138
|
+
# retention / loading
|
|
139
|
+
"RetentionLoad",
|
|
140
|
+
"gross_rate",
|
|
141
|
+
"permissible_loss_ratio",
|
|
142
|
+
# blend / indication
|
|
143
|
+
"blend",
|
|
144
|
+
"RateIndication",
|
|
145
|
+
# decomposition
|
|
146
|
+
"decompose_rate_change",
|
|
147
|
+
"RateChangeDecomposition",
|
|
148
|
+
# constraints / renewal
|
|
149
|
+
"cap_change",
|
|
150
|
+
"apply_cap",
|
|
151
|
+
"band",
|
|
152
|
+
"round_rate",
|
|
153
|
+
"corridor",
|
|
154
|
+
"renew",
|
|
155
|
+
"RenewalAction",
|
|
156
|
+
"member_level_renewal",
|
|
157
|
+
]
|
ratingmodels/_utils.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Internal helpers: validation and small numeric utilities."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Iterable
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def as_float_array(x, name: str = "value") -> np.ndarray:
|
|
10
|
+
"""Coerce to a 1-D float array, raising a clear error on bad input."""
|
|
11
|
+
arr = np.asarray(x, dtype=float)
|
|
12
|
+
if arr.ndim == 0:
|
|
13
|
+
arr = arr.reshape(1)
|
|
14
|
+
if arr.ndim != 1:
|
|
15
|
+
raise ValueError(f"{name} must be scalar or 1-D, got shape {arr.shape}")
|
|
16
|
+
if not np.all(np.isfinite(arr)):
|
|
17
|
+
raise ValueError(f"{name} contains non-finite values")
|
|
18
|
+
return arr
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def require_positive(x: float, name: str) -> float:
|
|
22
|
+
x = float(x)
|
|
23
|
+
if not np.isfinite(x) or x <= 0:
|
|
24
|
+
raise ValueError(f"{name} must be a positive finite number, got {x!r}")
|
|
25
|
+
return x
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def require_nonnegative(x: float, name: str) -> float:
|
|
29
|
+
x = float(x)
|
|
30
|
+
if not np.isfinite(x) or x < 0:
|
|
31
|
+
raise ValueError(f"{name} must be non-negative and finite, got {x!r}")
|
|
32
|
+
return x
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def require_unit_interval(x: float, name: str, *, closed: bool = True) -> float:
|
|
36
|
+
x = float(x)
|
|
37
|
+
lo_ok = (x >= 0) if closed else (x > 0)
|
|
38
|
+
hi_ok = (x <= 1) if closed else (x < 1)
|
|
39
|
+
if not (np.isfinite(x) and lo_ok and hi_ok):
|
|
40
|
+
bound = "[0, 1]" if closed else "(0, 1)"
|
|
41
|
+
raise ValueError(f"{name} must lie in {bound}, got {x!r}")
|
|
42
|
+
return x
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def product(values: Iterable[float]) -> float:
|
|
46
|
+
"""Numerically stable product via logs when all positive, else direct."""
|
|
47
|
+
vals = list(values)
|
|
48
|
+
if not vals:
|
|
49
|
+
return 1.0
|
|
50
|
+
arr = np.asarray(vals, dtype=float)
|
|
51
|
+
if np.all(arr > 0):
|
|
52
|
+
return float(np.exp(np.sum(np.log(arr))))
|
|
53
|
+
return float(np.prod(arr))
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
r"""Base-rate construction from book experience, with off-balancing.
|
|
2
|
+
|
|
3
|
+
The base rate is the cost level for the reference cell (all relativities equal
|
|
4
|
+
1). It is backed out from the book so that base times relativities reproduces
|
|
5
|
+
the book's losses. For risks :math:`i` with exposure :math:`e_i`, relativity
|
|
6
|
+
:math:`r_i = \prod_k f_{ki}`, and trended/developed loss :math:`L_i`:
|
|
7
|
+
|
|
8
|
+
.. math::
|
|
9
|
+
\bar r = \frac{\sum_i e_i r_i}{\sum_i e_i}, \qquad
|
|
10
|
+
B = \frac{\sum_i L_i}{\sum_i e_i r_i} = \frac{\bar L}{\bar r}.
|
|
11
|
+
|
|
12
|
+
By construction :math:`\sum_i e_i\, B r_i = \sum_i L_i`. When relativities are
|
|
13
|
+
revised, the average relativity moves and the overall premium level drifts
|
|
14
|
+
unless the base is **off-balanced**. Moving from average :math:`\bar r_0` to
|
|
15
|
+
:math:`\bar r_1`, with an intended overall change :math:`\Delta`:
|
|
16
|
+
|
|
17
|
+
.. math::
|
|
18
|
+
B_1 = B_0 \, \frac{\bar r_0}{\bar r_1} \, (1 + \Delta),
|
|
19
|
+
|
|
20
|
+
where :math:`\bar r_0 / \bar r_1` is the off-balance correction that holds the
|
|
21
|
+
book level neutral.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from typing import Sequence
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
import pandas as pd
|
|
30
|
+
|
|
31
|
+
from ._utils import product, require_positive
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class BaseRateResult:
|
|
36
|
+
"""Result of :func:`base_rate_from_experience`."""
|
|
37
|
+
|
|
38
|
+
base_loss_cost: float
|
|
39
|
+
average_relativity: float
|
|
40
|
+
average_loss_cost: float
|
|
41
|
+
total_exposure: float
|
|
42
|
+
|
|
43
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
44
|
+
return (
|
|
45
|
+
f"BaseRateResult(base_loss_cost={self.base_loss_cost:.4f}, "
|
|
46
|
+
f"average_relativity={self.average_relativity:.4f}, "
|
|
47
|
+
f"average_loss_cost={self.average_loss_cost:.4f})"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _relativity_vector(
|
|
52
|
+
data: pd.DataFrame,
|
|
53
|
+
relativity: str | None,
|
|
54
|
+
factor_cols: Sequence[str] | None,
|
|
55
|
+
) -> np.ndarray:
|
|
56
|
+
if relativity is not None:
|
|
57
|
+
rel = data[relativity].to_numpy(dtype=float)
|
|
58
|
+
elif factor_cols:
|
|
59
|
+
rel = np.array(
|
|
60
|
+
[product(row[c] for c in factor_cols) for _, row in data.iterrows()],
|
|
61
|
+
dtype=float,
|
|
62
|
+
)
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError("provide either `relativity` or `factor_cols`")
|
|
65
|
+
if np.any(rel <= 0):
|
|
66
|
+
raise ValueError("relativities must be positive")
|
|
67
|
+
return rel
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def average_relativity(
|
|
71
|
+
data: pd.DataFrame,
|
|
72
|
+
exposure: str,
|
|
73
|
+
relativity: str | None = None,
|
|
74
|
+
factor_cols: Sequence[str] | None = None,
|
|
75
|
+
) -> float:
|
|
76
|
+
r"""Exposure-weighted average relativity :math:`\bar r = \sum e_i r_i / \sum e_i`.
|
|
77
|
+
|
|
78
|
+
Supply relativities either as a single ``relativity`` column or as
|
|
79
|
+
``factor_cols`` (per-row factors that are multiplied together).
|
|
80
|
+
"""
|
|
81
|
+
e = data[exposure].to_numpy(dtype=float)
|
|
82
|
+
if np.any(e <= 0):
|
|
83
|
+
raise ValueError("exposures must be positive")
|
|
84
|
+
rel = _relativity_vector(data, relativity, factor_cols)
|
|
85
|
+
return float(np.sum(e * rel) / np.sum(e))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def base_rate_from_experience(
|
|
89
|
+
data: pd.DataFrame,
|
|
90
|
+
exposure: str,
|
|
91
|
+
loss: str,
|
|
92
|
+
relativity: str | None = None,
|
|
93
|
+
factor_cols: Sequence[str] | None = None,
|
|
94
|
+
) -> BaseRateResult:
|
|
95
|
+
r"""Indicated base loss cost from book experience (off-balance method).
|
|
96
|
+
|
|
97
|
+
Returns :math:`B = \sum_i L_i / \sum_i e_i r_i` together with the average
|
|
98
|
+
relativity and average loss cost. Gross ``base_loss_cost`` to a charged base
|
|
99
|
+
rate with a :class:`ratingmodels.RetentionLoad`.
|
|
100
|
+
|
|
101
|
+
Parameters
|
|
102
|
+
----------
|
|
103
|
+
data : DataFrame
|
|
104
|
+
One row per risk or rating cell.
|
|
105
|
+
exposure, loss : str
|
|
106
|
+
Column names for exposure (e.g. member-months) and trended/developed
|
|
107
|
+
loss.
|
|
108
|
+
relativity : str, optional
|
|
109
|
+
Column of precomputed relativities :math:`r_i`.
|
|
110
|
+
factor_cols : sequence of str, optional
|
|
111
|
+
Columns of individual rating factors to multiply into :math:`r_i`
|
|
112
|
+
(used when ``relativity`` is not supplied).
|
|
113
|
+
"""
|
|
114
|
+
e = data[exposure].to_numpy(dtype=float)
|
|
115
|
+
if np.any(e <= 0):
|
|
116
|
+
raise ValueError("exposures must be positive")
|
|
117
|
+
losses = data[loss].to_numpy(dtype=float)
|
|
118
|
+
rel = _relativity_vector(data, relativity, factor_cols)
|
|
119
|
+
|
|
120
|
+
total_exposure = float(np.sum(e))
|
|
121
|
+
exposure_weighted_rel = float(np.sum(e * rel)) # = sum(e_i r_i)
|
|
122
|
+
base = float(np.sum(losses) / exposure_weighted_rel)
|
|
123
|
+
return BaseRateResult(
|
|
124
|
+
base_loss_cost=base,
|
|
125
|
+
average_relativity=exposure_weighted_rel / total_exposure,
|
|
126
|
+
average_loss_cost=float(np.sum(losses) / total_exposure),
|
|
127
|
+
total_exposure=total_exposure,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def off_balance_factor(current_avg_relativity: float, new_avg_relativity: float) -> float:
|
|
132
|
+
r"""Off-balance correction :math:`\bar r_0 / \bar r_1` from revising relativities."""
|
|
133
|
+
require_positive(current_avg_relativity, "current_avg_relativity")
|
|
134
|
+
require_positive(new_avg_relativity, "new_avg_relativity")
|
|
135
|
+
return current_avg_relativity / new_avg_relativity
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def rebalance_base_rate(
|
|
139
|
+
current_base: float,
|
|
140
|
+
current_avg_relativity: float,
|
|
141
|
+
new_avg_relativity: float,
|
|
142
|
+
overall_change: float = 0.0,
|
|
143
|
+
) -> float:
|
|
144
|
+
r"""Off-balanced new base rate :math:`B_1 = B_0 (\bar r_0/\bar r_1)(1+\Delta)`.
|
|
145
|
+
|
|
146
|
+
Holds the overall premium level neutral when relativities change, then
|
|
147
|
+
applies the intended overall rate change ``overall_change`` (:math:`\Delta`).
|
|
148
|
+
"""
|
|
149
|
+
require_positive(current_base, "current_base")
|
|
150
|
+
factor = off_balance_factor(current_avg_relativity, new_avg_relativity)
|
|
151
|
+
return current_base * factor * (1.0 + overall_change)
|
ratingmodels/blend.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
r"""Credibility blending of experience and manual quantities.
|
|
2
|
+
|
|
3
|
+
.. math::
|
|
4
|
+
\text{blended} = Z \cdot \text{experience} + (1 - Z)\cdot \text{manual}.
|
|
5
|
+
|
|
6
|
+
This is the atomic credibility-weighting operation; it delegates to
|
|
7
|
+
:func:`actuarialpy.credibility_weighted_estimate` so the primitive lives in one
|
|
8
|
+
place across the ecosystem.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import actuarialpy as ap
|
|
13
|
+
|
|
14
|
+
from ._utils import require_unit_interval
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def blend(experience: float, manual: float, credibility: float) -> float:
|
|
18
|
+
r""":math:`Z \cdot \text{experience} + (1-Z)\cdot \text{manual}`."""
|
|
19
|
+
z = require_unit_interval(credibility, "credibility")
|
|
20
|
+
return ap.credibility_weighted_estimate(experience, manual, z)
|
ratingmodels/buildup.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
r"""Ordered rate build-up with an audit trail.
|
|
2
|
+
|
|
3
|
+
Every group rate is assembled the same way: start from a base claim cost and
|
|
4
|
+
apply an ordered sequence of operations -- multiply by a relativity, add or
|
|
5
|
+
subtract a dollar amount (a copay credit, a per-member fee), apply a factor to
|
|
6
|
+
only a segment of the cost -- recording labeled subtotals along the way, then
|
|
7
|
+
combine streams (in-/out-of-network by participation, medical + drug). This
|
|
8
|
+
module provides that grammar; it ships **no factor values**. The numbers are
|
|
9
|
+
yours (filed tables, state amounts, vendor fees); the engine just applies them
|
|
10
|
+
and produces a reconciling, auditable breakdown.
|
|
11
|
+
|
|
12
|
+
Operations
|
|
13
|
+
----------
|
|
14
|
+
* ``start(label, value)`` -- set the running total.
|
|
15
|
+
* ``multiply(label, factor)`` -- ``running *= factor`` (a relativity / trend).
|
|
16
|
+
* ``add(label, amount)`` -- ``running += amount`` (copay credit < 0, fee > 0).
|
|
17
|
+
* ``segment_multiply(label, factor, weight)`` -- apply ``factor`` to a fraction
|
|
18
|
+
``weight`` of the running total:
|
|
19
|
+
:math:`\text{running} \leftarrow \text{running}\,(1 - w + w f)`.
|
|
20
|
+
* ``checkpoint(label)`` -- record a labeled subtotal; total unchanged.
|
|
21
|
+
|
|
22
|
+
Combining streams
|
|
23
|
+
-----------------
|
|
24
|
+
* :func:`participation_blend` -- :math:`\text{par}\,p + \text{nonpar}\,(1-p)`.
|
|
25
|
+
* :func:`combine_streams` -- additive combine (e.g. medical + drug).
|
|
26
|
+
|
|
27
|
+
Both return a :class:`BuildUpResult`, so intermediate results carry their own
|
|
28
|
+
breakdown and can be fed into credibility, trend, and retention.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Mapping, Sequence, Union
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
import pandas as pd
|
|
37
|
+
|
|
38
|
+
_BREAKDOWN_COLUMNS = ["step", "operation", "label", "operand", "running_total"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Step:
|
|
43
|
+
"""A single build-up operation. ``operand`` is the factor (multiply /
|
|
44
|
+
segment), amount (add), or value (start); ``weight`` is used by
|
|
45
|
+
``segment_multiply`` only."""
|
|
46
|
+
|
|
47
|
+
op: str
|
|
48
|
+
label: str
|
|
49
|
+
operand: float = 1.0
|
|
50
|
+
weight: float = 1.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def start(label: str, value: float) -> Step:
|
|
54
|
+
"""Set the running total to ``value`` (normally the first step)."""
|
|
55
|
+
return Step("start", label, float(value))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def multiply(label: str, factor: float) -> Step:
|
|
59
|
+
"""Multiply the running total by ``factor`` (a relativity or trend)."""
|
|
60
|
+
return Step("multiply", label, float(factor))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def add(label: str, amount: float) -> Step:
|
|
64
|
+
"""Add ``amount`` to the running total (negative for a copay credit)."""
|
|
65
|
+
return Step("add", label, float(amount))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def segment_multiply(label: str, factor: float, weight: float) -> Step:
|
|
69
|
+
r"""Apply ``factor`` to a fraction ``weight`` of the running total.
|
|
70
|
+
|
|
71
|
+
:math:`\text{running} \leftarrow \text{running}\,(1 - w + w f)`.
|
|
72
|
+
"""
|
|
73
|
+
if not 0.0 <= weight <= 1.0:
|
|
74
|
+
raise ValueError("weight must lie in [0, 1]")
|
|
75
|
+
return Step("segment_multiply", label, float(factor), float(weight))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def checkpoint(label: str) -> Step:
|
|
79
|
+
"""Record a labeled subtotal without changing the running total."""
|
|
80
|
+
return Step("checkpoint", label, float("nan"))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class BuildUpResult:
|
|
85
|
+
"""Result of evaluating a build-up.
|
|
86
|
+
|
|
87
|
+
Attributes
|
|
88
|
+
----------
|
|
89
|
+
value : float
|
|
90
|
+
Final running total.
|
|
91
|
+
breakdown : pandas.DataFrame
|
|
92
|
+
One row per step: ``step, operation, label, operand, running_total``.
|
|
93
|
+
For ``segment_multiply`` the ``operand`` shown is the *effective*
|
|
94
|
+
factor :math:`(1 - w + w f)`, so the column reconciles by multiplication.
|
|
95
|
+
subtotals : dict
|
|
96
|
+
Ordered mapping of checkpoint label -> running total at that point.
|
|
97
|
+
steps : list[Step]
|
|
98
|
+
The raw steps (nominal factor and weight preserved).
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
value: float
|
|
102
|
+
breakdown: pd.DataFrame
|
|
103
|
+
subtotals: dict
|
|
104
|
+
steps: list = field(default_factory=list, repr=False)
|
|
105
|
+
|
|
106
|
+
def subtotal(self, label: str) -> float:
|
|
107
|
+
"""Running total recorded at the named checkpoint."""
|
|
108
|
+
if label not in self.subtotals:
|
|
109
|
+
raise KeyError(f"no checkpoint labeled {label!r}")
|
|
110
|
+
return self.subtotals[label]
|
|
111
|
+
|
|
112
|
+
def to_frame(self) -> pd.DataFrame:
|
|
113
|
+
return self.breakdown
|
|
114
|
+
|
|
115
|
+
def __repr__(self) -> str: # pragma: no cover - cosmetic
|
|
116
|
+
return f"BuildUpResult(value={self.value:.4f}, steps={len(self.steps)})"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def evaluate(steps: Sequence[Step]) -> BuildUpResult:
|
|
120
|
+
"""Run an ordered sequence of :class:`Step` and return a :class:`BuildUpResult`.
|
|
121
|
+
|
|
122
|
+
The running total starts at 0; a leading :func:`start` sets the base.
|
|
123
|
+
"""
|
|
124
|
+
running = 0.0
|
|
125
|
+
rows = []
|
|
126
|
+
subtotals: dict = {}
|
|
127
|
+
for i, s in enumerate(steps, start=1):
|
|
128
|
+
if s.op == "start":
|
|
129
|
+
running = s.operand
|
|
130
|
+
shown = s.operand
|
|
131
|
+
elif s.op == "multiply":
|
|
132
|
+
running *= s.operand
|
|
133
|
+
shown = s.operand
|
|
134
|
+
elif s.op == "add":
|
|
135
|
+
running += s.operand
|
|
136
|
+
shown = s.operand
|
|
137
|
+
elif s.op == "segment_multiply":
|
|
138
|
+
effective = 1.0 - s.weight + s.weight * s.operand
|
|
139
|
+
running *= effective
|
|
140
|
+
shown = effective
|
|
141
|
+
elif s.op == "checkpoint":
|
|
142
|
+
subtotals[s.label] = running
|
|
143
|
+
shown = np.nan
|
|
144
|
+
else:
|
|
145
|
+
raise ValueError(f"unknown operation {s.op!r}")
|
|
146
|
+
rows.append(
|
|
147
|
+
{
|
|
148
|
+
"step": i,
|
|
149
|
+
"operation": s.op,
|
|
150
|
+
"label": s.label,
|
|
151
|
+
"operand": shown,
|
|
152
|
+
"running_total": running,
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
breakdown = pd.DataFrame(rows, columns=_BREAKDOWN_COLUMNS)
|
|
156
|
+
return BuildUpResult(
|
|
157
|
+
value=float(running),
|
|
158
|
+
breakdown=breakdown,
|
|
159
|
+
subtotals=subtotals,
|
|
160
|
+
steps=list(steps),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class BuildUp:
|
|
165
|
+
"""Fluent builder for a build-up; sugar over a list of :class:`Step`.
|
|
166
|
+
|
|
167
|
+
>>> r = (BuildUp()
|
|
168
|
+
... .start("Par Base", 941.63)
|
|
169
|
+
... .add("$30 specialist copay", -11.44)
|
|
170
|
+
... .multiply("Rating Region", 1.083)
|
|
171
|
+
... .checkpoint("Medical Par Base Claim Cost")
|
|
172
|
+
... .evaluate())
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self) -> None:
|
|
176
|
+
self._steps: list[Step] = []
|
|
177
|
+
|
|
178
|
+
def start(self, label: str, value: float) -> "BuildUp":
|
|
179
|
+
self._steps.append(start(label, value))
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
def multiply(self, label: str, factor: float) -> "BuildUp":
|
|
183
|
+
self._steps.append(multiply(label, factor))
|
|
184
|
+
return self
|
|
185
|
+
|
|
186
|
+
def add(self, label: str, amount: float) -> "BuildUp":
|
|
187
|
+
self._steps.append(add(label, amount))
|
|
188
|
+
return self
|
|
189
|
+
|
|
190
|
+
def segment_multiply(self, label: str, factor: float, weight: float) -> "BuildUp":
|
|
191
|
+
self._steps.append(segment_multiply(label, factor, weight))
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
def checkpoint(self, label: str) -> "BuildUp":
|
|
195
|
+
self._steps.append(checkpoint(label))
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
def steps(self) -> list:
|
|
199
|
+
return list(self._steps)
|
|
200
|
+
|
|
201
|
+
def evaluate(self) -> BuildUpResult:
|
|
202
|
+
return evaluate(self._steps)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# --------------------------------------------------------------------------- #
|
|
206
|
+
# combining streams
|
|
207
|
+
# --------------------------------------------------------------------------- #
|
|
208
|
+
ValueLike = Union[BuildUpResult, float]
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _val(x: ValueLike) -> float:
|
|
212
|
+
return float(x.value) if isinstance(x, BuildUpResult) else float(x)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def combine_streams(
|
|
216
|
+
streams: Mapping[str, ValueLike],
|
|
217
|
+
label: str = "Combined",
|
|
218
|
+
) -> BuildUpResult:
|
|
219
|
+
"""Additively combine named streams (e.g. ``{"Medical": ..., "Drug": ...}``).
|
|
220
|
+
|
|
221
|
+
Implemented as a build-up (start + adds) so the result carries a running
|
|
222
|
+
total and an audit trail.
|
|
223
|
+
"""
|
|
224
|
+
items = list(streams.items())
|
|
225
|
+
if not items:
|
|
226
|
+
raise ValueError("provide at least one stream")
|
|
227
|
+
steps = [start(items[0][0], _val(items[0][1]))]
|
|
228
|
+
for lab, v in items[1:]:
|
|
229
|
+
steps.append(add(lab, _val(v)))
|
|
230
|
+
steps.append(checkpoint(label))
|
|
231
|
+
return evaluate(steps)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def participation_blend(
|
|
235
|
+
par: ValueLike,
|
|
236
|
+
nonpar: ValueLike,
|
|
237
|
+
participation_rate: float,
|
|
238
|
+
label: str = "PPO Claim Cost",
|
|
239
|
+
) -> BuildUpResult:
|
|
240
|
+
r"""In-/out-of-network blend :math:`\text{par}\,p + \text{nonpar}\,(1-p)`.
|
|
241
|
+
|
|
242
|
+
``participation_rate`` is the in-network (par) share ``p``.
|
|
243
|
+
"""
|
|
244
|
+
if not 0.0 <= participation_rate <= 1.0:
|
|
245
|
+
raise ValueError("participation_rate must lie in [0, 1]")
|
|
246
|
+
p = participation_rate
|
|
247
|
+
return combine_streams(
|
|
248
|
+
{
|
|
249
|
+
f"Par x {p:.1%}": _val(par) * p,
|
|
250
|
+
f"Non-Par x {1 - p:.1%}": _val(nonpar) * (1 - p),
|
|
251
|
+
},
|
|
252
|
+
label=label,
|
|
253
|
+
)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
r"""Constraints applied to indicated rates before they become rate actions.
|
|
2
|
+
|
|
3
|
+
Indicated rates are rarely charged as-is. Common adjustments:
|
|
4
|
+
|
|
5
|
+
* **Caps / floors** on the rate change to limit renewal shock.
|
|
6
|
+
* **Banding** -- snapping small changes to zero, or to discrete steps.
|
|
7
|
+
* **Rounding** to a filed precision.
|
|
8
|
+
* **Corridors** -- limiting how far a rate may move over successive renewals.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cap_change(change: float, cap: float | None = None, floor: float | None = None) -> float:
|
|
16
|
+
"""Clip a proportional rate change to ``[floor, cap]`` (either may be None)."""
|
|
17
|
+
out = float(change)
|
|
18
|
+
if cap is not None:
|
|
19
|
+
out = min(out, float(cap))
|
|
20
|
+
if floor is not None:
|
|
21
|
+
out = max(out, float(floor))
|
|
22
|
+
return out
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def apply_cap(
|
|
26
|
+
current_rate: float,
|
|
27
|
+
indicated_rate: float,
|
|
28
|
+
cap: float | None = None,
|
|
29
|
+
floor: float | None = None,
|
|
30
|
+
) -> float:
|
|
31
|
+
"""Return the charged rate after capping the implied change."""
|
|
32
|
+
if current_rate <= 0:
|
|
33
|
+
raise ValueError("current_rate must be positive")
|
|
34
|
+
change = indicated_rate / current_rate - 1.0
|
|
35
|
+
return current_rate * (1.0 + cap_change(change, cap, floor))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def band(change: float, deadband: float = 0.0, step: float | None = None) -> float:
|
|
39
|
+
"""Snap a change to zero within ``deadband``; optionally to ``step`` grid."""
|
|
40
|
+
out = 0.0 if abs(change) <= deadband else float(change)
|
|
41
|
+
if step is not None and step > 0:
|
|
42
|
+
out = round(out / step) * step
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def round_rate(rate: float, ndigits: int = 2) -> float:
|
|
47
|
+
"""Round a rate to a filed precision (default cents)."""
|
|
48
|
+
return float(np.round(rate, ndigits))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def corridor(
|
|
52
|
+
current_rate: float,
|
|
53
|
+
indicated_rate: float,
|
|
54
|
+
max_up: float,
|
|
55
|
+
max_down: float,
|
|
56
|
+
) -> float:
|
|
57
|
+
"""Limit a single renewal move to ``[-max_down, +max_up]`` proportionally."""
|
|
58
|
+
return apply_cap(current_rate, indicated_rate, cap=max_up, floor=-abs(max_down))
|