experience-rating 0.1.0__tar.gz

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.
@@ -0,0 +1,14 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ *.egg
9
+ .pytest_cache/
10
+ .coverage
11
+ htmlcov/
12
+ _databricks_test_runner.py
13
+ _run_tests_databricks.py
14
+ uv.lock
@@ -0,0 +1,222 @@
1
+ Metadata-Version: 2.4
2
+ Name: experience-rating
3
+ Version: 0.1.0
4
+ Summary: NCD/bonus-malus systems, experience modification factors, and schedule rating for UK insurance pricing
5
+ Project-URL: Repository, https://github.com/burning-cost/experience-rating
6
+ Project-URL: Issues, https://github.com/burning-cost/experience-rating/issues
7
+ Author-email: Burning Cost <pricing.frontier@gmail.com>
8
+ License: MIT
9
+ Keywords: NCD,actuarial,bonus-malus,experience-modification,experience-rating,insurance,no-claims-discount,pricing
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Financial and Insurance Industry
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Office/Business :: Financial
19
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: numpy>=1.21
22
+ Requires-Dist: polars>=0.20
23
+ Requires-Dist: scipy>=1.9
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-cov; extra == 'dev'
26
+ Requires-Dist: pytest>=7.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # experience-rating
30
+
31
+ NCD/bonus-malus systems, experience modification factors, and schedule rating for UK non-life insurance pricing.
32
+
33
+ ## The problem
34
+
35
+ Every UK motor insurer runs an NCD system, but almost no one has a clean Python implementation that lets you ask: "what is the steady-state distribution of our book across NCD levels at 10% claim frequency?" or "at what claim amount should a 65% NCD customer absorb the loss rather than claim?". These questions come up in pricing, reserving, and customer communications — and they're currently answered with spreadsheets that break when a colleague changes a tab name.
36
+
37
+ On the commercial side, experience modification factors require getting the credibility weight and ballast right. Too little ballast and a single large loss blows up the mod; too much and you've lost all experience rating signal. This library makes the parameter choices explicit and auditable.
38
+
39
+ ## What this library does not do
40
+
41
+ It does not calibrate BM scales from data (that requires a GLM pipeline and historical claims). It does not model policyholder heterogeneity (see the `credibility` library for that). It does not optimise NCD system design — it analyses a system you've already specified.
42
+
43
+ ## Installation
44
+
45
+ ```bash
46
+ uv add experience-rating
47
+ ```
48
+
49
+ Requires Python 3.10+. Dependencies: `polars`, `numpy`, `scipy`.
50
+
51
+ ## Quick start
52
+
53
+ ### NCD scale and stationary distribution
54
+
55
+ ```python
56
+ from experience_rating import BonusMalusScale, BonusMalusSimulator
57
+
58
+ # ABI-style UK motor NCD: levels 0%-65%, step up on claim-free year,
59
+ # back two on one claim, back to zero on two or more claims.
60
+ scale = BonusMalusScale.from_uk_standard()
61
+
62
+ sim = BonusMalusSimulator(scale, claim_frequency=0.10)
63
+
64
+ # Analytical stationary distribution (left eigenvector of transition matrix)
65
+ dist = sim.stationary_distribution(method="analytical")
66
+ print(dist)
67
+
68
+ # Expected premium factor at steady state
69
+ epf = sim.expected_premium_factor()
70
+ print(f"Average NCD at steady state: {(1 - epf) * 100:.1f}%")
71
+ ```
72
+
73
+ ### Optimal claiming threshold
74
+
75
+ ```python
76
+ from experience_rating import ClaimThreshold
77
+
78
+ ct = ClaimThreshold(scale, discount_rate=0.05)
79
+
80
+ # Customer at 65% NCD paying £280/year after discount
81
+ # Over a 3-year horizon, should they claim a £450 repair?
82
+ threshold = ct.threshold(current_level=9, annual_premium=280.0, years_horizon=3)
83
+ print(f"Claim only if loss exceeds £{threshold:.0f}")
84
+
85
+ should = ct.should_claim(
86
+ current_level=9, claim_amount=450, annual_premium=280.0, years_horizon=3
87
+ )
88
+ print("Claiming is rational" if should else "Better to pay out of pocket")
89
+ ```
90
+
91
+ ### Experience modification factor
92
+
93
+ ```python
94
+ import polars as pl
95
+ from experience_rating import ExperienceModFactor
96
+ from experience_rating.experience_mod import CredibilityParams
97
+
98
+ params = CredibilityParams(credibility_weight=0.65, ballast=8_000.0)
99
+ emod = ExperienceModFactor(params)
100
+
101
+ portfolio = pl.DataFrame({
102
+ "risk_id": ["ABC Ltd", "XYZ Ltd"],
103
+ "expected_losses": [25_000.0, 80_000.0],
104
+ "actual_losses": [32_000.0, 65_000.0],
105
+ })
106
+
107
+ result = emod.predict_batch(portfolio, cap=2.0, floor=0.5)
108
+ print(result)
109
+ ```
110
+
111
+ ### Schedule rating
112
+
113
+ ```python
114
+ from experience_rating import ScheduleRating
115
+
116
+ sr = ScheduleRating(max_total_debit=0.25, max_total_credit=0.25)
117
+ sr.add_factor("Premises", min_credit=-0.10, max_debit=0.10, description="Premises condition")
118
+ sr.add_factor("Management", min_credit=-0.07, max_debit=0.07, description="Management quality")
119
+ sr.add_factor("Risk_Controls", min_credit=-0.08, max_debit=0.08, description="Risk controls")
120
+
121
+ factor = sr.rate({"Premises": 0.05, "Management": -0.03, "Risk_Controls": 0.02})
122
+ print(f"Schedule rating factor: {factor:.4f}") # 1.0400
123
+ ```
124
+
125
+ ## API reference
126
+
127
+ ### `BonusMalusScale`
128
+
129
+ | Method | Description |
130
+ |--------|-------------|
131
+ | `from_uk_standard()` | ABI-style 10-level NCD scale (0%–65%) |
132
+ | `from_dict(spec)` | Build from a dictionary specification |
133
+ | `transition_matrix(claim_frequency)` | Row-stochastic transition matrix (Poisson claims) |
134
+ | `summary()` | Polars DataFrame of level definitions |
135
+
136
+ ### `BonusMalusSimulator`
137
+
138
+ | Method | Description |
139
+ |--------|-------------|
140
+ | `simulate(n_policyholders, n_years)` | Monte Carlo simulation of level flows |
141
+ | `stationary_distribution(method)` | `"analytical"` (eigenvector) or `"simulation"` |
142
+ | `expected_premium_factor(method)` | Probability-weighted average premium factor at steady state |
143
+
144
+ ### `ClaimThreshold`
145
+
146
+ | Method | Description |
147
+ |--------|-------------|
148
+ | `threshold(current_level, annual_premium, years_horizon)` | Minimum loss amount that makes claiming rational |
149
+ | `should_claim(current_level, claim_amount, annual_premium, years_horizon)` | Boolean claiming decision |
150
+ | `threshold_curve(current_level, annual_premium, max_horizon)` | Threshold vs horizon DataFrame |
151
+ | `full_analysis(annual_premium, years_horizon)` | Thresholds for every level in the scale |
152
+
153
+ ### `ExperienceModFactor`
154
+
155
+ | Method | Description |
156
+ |--------|-------------|
157
+ | `from_exposure(actual, full_credibility, ballast, formula)` | Construct from exposure-based credibility |
158
+ | `predict(expected_losses, actual_losses, cap, floor)` | Single-risk mod factor |
159
+ | `predict_batch(df, cap, floor)` | Portfolio mod factors (Polars DataFrame) |
160
+ | `sensitivity(expected_losses, actual_range, n_points)` | Mod vs actual loss curve |
161
+
162
+ ### `ScheduleRating`
163
+
164
+ | Method | Description |
165
+ |--------|-------------|
166
+ | `add_factor(name, min_credit, max_debit, description)` | Register a rating factor (chainable) |
167
+ | `rate(features)` | Multiplicative schedule factor for one risk |
168
+ | `rate_batch(df)` | Schedule factors for a portfolio DataFrame |
169
+ | `summary()` | Registered factors as a Polars DataFrame |
170
+
171
+ ## Custom BM scale
172
+
173
+ ```python
174
+ spec = {
175
+ "levels": [
176
+ {
177
+ "index": 0, "name": "No NCD", "premium_factor": 1.00, "ncd_percent": 0,
178
+ "transitions": {"claim_free_level": 1, "claim_levels": {"1": 0, "2": 0}}
179
+ },
180
+ {
181
+ "index": 1, "name": "20% NCD", "premium_factor": 0.80, "ncd_percent": 20,
182
+ "transitions": {"claim_free_level": 2, "claim_levels": {"1": 0, "2": 0}}
183
+ },
184
+ {
185
+ "index": 2, "name": "40% NCD", "premium_factor": 0.60, "ncd_percent": 40,
186
+ "transitions": {"claim_free_level": 2, "claim_levels": {"1": 1, "2": 0}}
187
+ },
188
+ ]
189
+ }
190
+ scale = BonusMalusScale.from_dict(spec)
191
+ ```
192
+
193
+ ## Design notes
194
+
195
+ **Why eigenvector for stationary distribution?** It is exact (no simulation noise) and
196
+ fast. The simulation method exists as a sanity check — if the two disagree by more than
197
+ a few percent, the transition matrix is probably not ergodic.
198
+
199
+ **Why additive schedule rating (not multiplicative)?** UK commercial practice is
200
+ additive: factors are debits/credits expressed as percentage adjustments summed together.
201
+ The aggregate cap is where you control total swing. Multiplicative schedule rating is used
202
+ in some US lines but is not standard in UK admitted business.
203
+
204
+ **Why expose `ballast` directly rather than deriving it?** Because the choice of
205
+ ballast is a deliberate actuarial decision that affects which risks get charged more
206
+ and which get discounted. Hiding it inside a calibration function obscures a
207
+ regulatory-facing choice.
208
+
209
+ ## Tests
210
+
211
+ ```bash
212
+ uv add "experience-rating[dev]"
213
+ pytest
214
+ ```
215
+
216
+ 52 tests covering scale construction, transition matrix properties, stationary
217
+ distribution (analytical vs simulation agreement), claiming thresholds, experience
218
+ modification formula, and schedule rating bounds validation.
219
+
220
+ ## Licence
221
+
222
+ MIT
@@ -0,0 +1,194 @@
1
+ # experience-rating
2
+
3
+ NCD/bonus-malus systems, experience modification factors, and schedule rating for UK non-life insurance pricing.
4
+
5
+ ## The problem
6
+
7
+ Every UK motor insurer runs an NCD system, but almost no one has a clean Python implementation that lets you ask: "what is the steady-state distribution of our book across NCD levels at 10% claim frequency?" or "at what claim amount should a 65% NCD customer absorb the loss rather than claim?". These questions come up in pricing, reserving, and customer communications — and they're currently answered with spreadsheets that break when a colleague changes a tab name.
8
+
9
+ On the commercial side, experience modification factors require getting the credibility weight and ballast right. Too little ballast and a single large loss blows up the mod; too much and you've lost all experience rating signal. This library makes the parameter choices explicit and auditable.
10
+
11
+ ## What this library does not do
12
+
13
+ It does not calibrate BM scales from data (that requires a GLM pipeline and historical claims). It does not model policyholder heterogeneity (see the `credibility` library for that). It does not optimise NCD system design — it analyses a system you've already specified.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ uv add experience-rating
19
+ ```
20
+
21
+ Requires Python 3.10+. Dependencies: `polars`, `numpy`, `scipy`.
22
+
23
+ ## Quick start
24
+
25
+ ### NCD scale and stationary distribution
26
+
27
+ ```python
28
+ from experience_rating import BonusMalusScale, BonusMalusSimulator
29
+
30
+ # ABI-style UK motor NCD: levels 0%-65%, step up on claim-free year,
31
+ # back two on one claim, back to zero on two or more claims.
32
+ scale = BonusMalusScale.from_uk_standard()
33
+
34
+ sim = BonusMalusSimulator(scale, claim_frequency=0.10)
35
+
36
+ # Analytical stationary distribution (left eigenvector of transition matrix)
37
+ dist = sim.stationary_distribution(method="analytical")
38
+ print(dist)
39
+
40
+ # Expected premium factor at steady state
41
+ epf = sim.expected_premium_factor()
42
+ print(f"Average NCD at steady state: {(1 - epf) * 100:.1f}%")
43
+ ```
44
+
45
+ ### Optimal claiming threshold
46
+
47
+ ```python
48
+ from experience_rating import ClaimThreshold
49
+
50
+ ct = ClaimThreshold(scale, discount_rate=0.05)
51
+
52
+ # Customer at 65% NCD paying £280/year after discount
53
+ # Over a 3-year horizon, should they claim a £450 repair?
54
+ threshold = ct.threshold(current_level=9, annual_premium=280.0, years_horizon=3)
55
+ print(f"Claim only if loss exceeds £{threshold:.0f}")
56
+
57
+ should = ct.should_claim(
58
+ current_level=9, claim_amount=450, annual_premium=280.0, years_horizon=3
59
+ )
60
+ print("Claiming is rational" if should else "Better to pay out of pocket")
61
+ ```
62
+
63
+ ### Experience modification factor
64
+
65
+ ```python
66
+ import polars as pl
67
+ from experience_rating import ExperienceModFactor
68
+ from experience_rating.experience_mod import CredibilityParams
69
+
70
+ params = CredibilityParams(credibility_weight=0.65, ballast=8_000.0)
71
+ emod = ExperienceModFactor(params)
72
+
73
+ portfolio = pl.DataFrame({
74
+ "risk_id": ["ABC Ltd", "XYZ Ltd"],
75
+ "expected_losses": [25_000.0, 80_000.0],
76
+ "actual_losses": [32_000.0, 65_000.0],
77
+ })
78
+
79
+ result = emod.predict_batch(portfolio, cap=2.0, floor=0.5)
80
+ print(result)
81
+ ```
82
+
83
+ ### Schedule rating
84
+
85
+ ```python
86
+ from experience_rating import ScheduleRating
87
+
88
+ sr = ScheduleRating(max_total_debit=0.25, max_total_credit=0.25)
89
+ sr.add_factor("Premises", min_credit=-0.10, max_debit=0.10, description="Premises condition")
90
+ sr.add_factor("Management", min_credit=-0.07, max_debit=0.07, description="Management quality")
91
+ sr.add_factor("Risk_Controls", min_credit=-0.08, max_debit=0.08, description="Risk controls")
92
+
93
+ factor = sr.rate({"Premises": 0.05, "Management": -0.03, "Risk_Controls": 0.02})
94
+ print(f"Schedule rating factor: {factor:.4f}") # 1.0400
95
+ ```
96
+
97
+ ## API reference
98
+
99
+ ### `BonusMalusScale`
100
+
101
+ | Method | Description |
102
+ |--------|-------------|
103
+ | `from_uk_standard()` | ABI-style 10-level NCD scale (0%–65%) |
104
+ | `from_dict(spec)` | Build from a dictionary specification |
105
+ | `transition_matrix(claim_frequency)` | Row-stochastic transition matrix (Poisson claims) |
106
+ | `summary()` | Polars DataFrame of level definitions |
107
+
108
+ ### `BonusMalusSimulator`
109
+
110
+ | Method | Description |
111
+ |--------|-------------|
112
+ | `simulate(n_policyholders, n_years)` | Monte Carlo simulation of level flows |
113
+ | `stationary_distribution(method)` | `"analytical"` (eigenvector) or `"simulation"` |
114
+ | `expected_premium_factor(method)` | Probability-weighted average premium factor at steady state |
115
+
116
+ ### `ClaimThreshold`
117
+
118
+ | Method | Description |
119
+ |--------|-------------|
120
+ | `threshold(current_level, annual_premium, years_horizon)` | Minimum loss amount that makes claiming rational |
121
+ | `should_claim(current_level, claim_amount, annual_premium, years_horizon)` | Boolean claiming decision |
122
+ | `threshold_curve(current_level, annual_premium, max_horizon)` | Threshold vs horizon DataFrame |
123
+ | `full_analysis(annual_premium, years_horizon)` | Thresholds for every level in the scale |
124
+
125
+ ### `ExperienceModFactor`
126
+
127
+ | Method | Description |
128
+ |--------|-------------|
129
+ | `from_exposure(actual, full_credibility, ballast, formula)` | Construct from exposure-based credibility |
130
+ | `predict(expected_losses, actual_losses, cap, floor)` | Single-risk mod factor |
131
+ | `predict_batch(df, cap, floor)` | Portfolio mod factors (Polars DataFrame) |
132
+ | `sensitivity(expected_losses, actual_range, n_points)` | Mod vs actual loss curve |
133
+
134
+ ### `ScheduleRating`
135
+
136
+ | Method | Description |
137
+ |--------|-------------|
138
+ | `add_factor(name, min_credit, max_debit, description)` | Register a rating factor (chainable) |
139
+ | `rate(features)` | Multiplicative schedule factor for one risk |
140
+ | `rate_batch(df)` | Schedule factors for a portfolio DataFrame |
141
+ | `summary()` | Registered factors as a Polars DataFrame |
142
+
143
+ ## Custom BM scale
144
+
145
+ ```python
146
+ spec = {
147
+ "levels": [
148
+ {
149
+ "index": 0, "name": "No NCD", "premium_factor": 1.00, "ncd_percent": 0,
150
+ "transitions": {"claim_free_level": 1, "claim_levels": {"1": 0, "2": 0}}
151
+ },
152
+ {
153
+ "index": 1, "name": "20% NCD", "premium_factor": 0.80, "ncd_percent": 20,
154
+ "transitions": {"claim_free_level": 2, "claim_levels": {"1": 0, "2": 0}}
155
+ },
156
+ {
157
+ "index": 2, "name": "40% NCD", "premium_factor": 0.60, "ncd_percent": 40,
158
+ "transitions": {"claim_free_level": 2, "claim_levels": {"1": 1, "2": 0}}
159
+ },
160
+ ]
161
+ }
162
+ scale = BonusMalusScale.from_dict(spec)
163
+ ```
164
+
165
+ ## Design notes
166
+
167
+ **Why eigenvector for stationary distribution?** It is exact (no simulation noise) and
168
+ fast. The simulation method exists as a sanity check — if the two disagree by more than
169
+ a few percent, the transition matrix is probably not ergodic.
170
+
171
+ **Why additive schedule rating (not multiplicative)?** UK commercial practice is
172
+ additive: factors are debits/credits expressed as percentage adjustments summed together.
173
+ The aggregate cap is where you control total swing. Multiplicative schedule rating is used
174
+ in some US lines but is not standard in UK admitted business.
175
+
176
+ **Why expose `ballast` directly rather than deriving it?** Because the choice of
177
+ ballast is a deliberate actuarial decision that affects which risks get charged more
178
+ and which get discounted. Hiding it inside a calibration function obscures a
179
+ regulatory-facing choice.
180
+
181
+ ## Tests
182
+
183
+ ```bash
184
+ uv add "experience-rating[dev]"
185
+ pytest
186
+ ```
187
+
188
+ 52 tests covering scale construction, transition matrix properties, stationary
189
+ distribution (analytical vs simulation agreement), claiming thresholds, experience
190
+ modification formula, and schedule rating bounds validation.
191
+
192
+ ## Licence
193
+
194
+ MIT