Skip to content

Commit d7a91e6

Browse files
committed
Add predict interval and tests, add files for CI/CD
1 parent 47e7327 commit d7a91e6

7 files changed

Lines changed: 229 additions & 2 deletions

File tree

.github/workflows/ci.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Generated from the shared OpenActuarial CI template -- keep the five
2+
# package repos' copies identical (edit the template, regenerate, commit).
3+
name: CI
4+
5+
on:
6+
push:
7+
branches: [main]
8+
pull_request:
9+
10+
jobs:
11+
test:
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
os: [ubuntu-latest, windows-latest]
16+
python-version: ["3.10", "3.11", "3.12", "3.13"]
17+
runs-on: ${{ matrix.os }}
18+
steps:
19+
- uses: actions/checkout@v4
20+
- uses: actions/setup-python@v5
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
- name: Install
24+
run: |
25+
python -m pip install --upgrade pip
26+
pip install -e ".[dev]"
27+
- name: Test
28+
run: pytest -q
29+
lint:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v4
33+
- uses: actions/setup-python@v5
34+
with:
35+
python-version: "3.12"
36+
- run: python -m pip install ruff
37+
- run: ruff check src tests examples
38+
build:
39+
runs-on: ubuntu-latest
40+
steps:
41+
- uses: actions/checkout@v4
42+
- uses: actions/setup-python@v5
43+
with:
44+
python-version: "3.12"
45+
- name: Build sdist and wheel
46+
run: |
47+
python -m pip install --upgrade pip build twine
48+
python -m build
49+
twine check dist/*
50+
- uses: actions/upload-artifact@v4
51+
with:
52+
name: dist
53+
path: dist/

.github/workflows/release.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Tag-driven release via PyPI Trusted Publishing (no tokens, no twine
2+
# passwords). One-time setup per package:
3+
# 1. On PyPI: project -> Publishing -> add a Trusted Publisher:
4+
# owner OpenActuarial, repository ratingmodels, workflow release.yml,
5+
# environment pypi.
6+
# 2. On GitHub: repo Settings -> Environments -> create "pypi".
7+
# Then: git tag v{version} && git push --tags
8+
name: Release
9+
10+
on:
11+
push:
12+
tags: ["v*"]
13+
14+
jobs:
15+
build:
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v4
19+
- uses: actions/setup-python@v5
20+
with:
21+
python-version: "3.12"
22+
- run: |
23+
python -m pip install --upgrade pip build twine
24+
python -m build
25+
twine check dist/*
26+
- uses: actions/upload-artifact@v4
27+
with:
28+
name: dist
29+
path: dist/
30+
31+
publish:
32+
needs: build
33+
runs-on: ubuntu-latest
34+
environment: pypi
35+
permissions:
36+
id-token: write
37+
steps:
38+
- uses: actions/download-artifact@v4
39+
with:
40+
name: dist
41+
path: dist/
42+
- uses: pypa/gh-action-pypi-publish@release/v1
43+
44+
github-release:
45+
needs: publish
46+
runs-on: ubuntu-latest
47+
permissions:
48+
contents: write
49+
steps:
50+
- uses: actions/checkout@v4
51+
- name: Extract latest changelog section
52+
run: |
53+
awk '/^## /{n++} n==1' CHANGELOG.md > release_notes.md
54+
- uses: softprops/action-gh-release@v2
55+
with:
56+
body_path: release_notes.md

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,30 @@
11
# Changelog
22

3+
## 0.7.0 - 2026-07-05
4+
5+
Frequency-severity parity with the GLM layer's uncertainty surface.
6+
7+
### Added
8+
- **Frequency-severity prediction intervals.**
9+
`FrequencySeverityModel.predict_interval` -- the same delta-method
10+
interval `GLMRelativities` has, for the composite: log-scale variances
11+
of the two component linear predictors add, under the stated (and it
12+
is an assumption, stated as such) independence of the frequency and
13+
severity coefficient estimates. `predicted` equals
14+
`pure_premium_prediction` exactly; switching from a GLM to a
15+
frequency-severity model no longer silently loses uncertainty.
16+
## 0.6.1 - 2026-07-04
17+
18+
### Added
19+
- **Frequency-severity interactions.** `FrequencySeverityModel.fit`
20+
gains `frequency_interactions` / `severity_interactions` (severity
21+
defaults to the frequency list, mirroring the predictor convention),
22+
threading straight through to the component GLMs. Categorical x
23+
categorical cells surface in `combined_relativities()` under an
24+
`"a:b"` key with a MultiIndex of level pairs (`combined` = frequency x
25+
severity per cell; a component without the interaction contributes
26+
1.0); `to_factor_tables()` excludes interactions like the GLM does, and
27+
`RatingPlan.from_model` warns accordingly.
328
## 0.6.0 - 2026-07-04
429

530
Validation and implementation release: a fitted model is now inspectable,

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# ratingmodels
22

3+
[![CI](https://github.com/OpenActuarial/ratingmodels/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenActuarial/ratingmodels/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/ratingmodels)](https://pypi.org/project/ratingmodels/)
4+
35
**Actuarial pricing and rate-indication tools for experience-rated insurance portfolios.**
46

57
`ratingmodels` covers the group rating workflow — the step that turns experience

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "ratingmodels"
7-
version = "0.6.1"
7+
version = "0.7.0"
88
description = "Rating and pricing models: manual and experience rate build-up, credibility blending, rate indications and decomposition, GLM relativities and evaluation, and pricing scenarios."
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -38,7 +38,7 @@ classifiers = [
3838
dependencies = [
3939
"numpy>=1.22",
4040
"pandas>=1.4",
41-
"actuarialpy~=0.39.0",
41+
"actuarialpy~=0.40.0",
4242
"statsmodels>=0.14",
4343
]
4444

src/ratingmodels/freqsev.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,65 @@ def pure_premium_prediction(
199199
"""
200200
return self.frequency_prediction(data, exposure=exposure) * self.severity_prediction(data)
201201

202+
def predict_interval(
203+
self,
204+
data: pd.DataFrame,
205+
confidence_level: float = 0.95,
206+
exposure: str | None = None,
207+
) -> pd.DataFrame:
208+
r"""Predicted pure premium with its confidence interval, per row.
209+
210+
The pure premium is :math:`\exp(\eta_f + \eta_s)`; on the log
211+
scale the variances of the two component linear predictors add,
212+
*assuming the frequency and severity coefficient estimates are
213+
independent* -- the standard frequency-severity assumption (the
214+
two GLMs are fit to different responses), stated here because it
215+
is an assumption, not a theorem. The interval is for the *mean*
216+
pure premium of a cell, not for an individual outcome; individual
217+
losses vary enormously more than their expectation.
218+
219+
Returns
220+
-------
221+
pandas.DataFrame
222+
Index-aligned with ``data``; columns ``predicted``, ``ci_low``,
223+
``ci_high``. With ``exposure``, all three are on the total
224+
scale. ``predicted`` equals :meth:`pure_premium_prediction`
225+
exactly.
226+
"""
227+
self._check_fit()
228+
from statistics import NormalDist
229+
230+
for name, model in (("frequency", self.frequency),
231+
("severity", self.severity)):
232+
if model.cov_params_ is None:
233+
raise RuntimeError(
234+
f"{name} model has no coefficient covariance "
235+
"(rank-deficient design); no interval is available"
236+
)
237+
if not 0 < confidence_level < 1:
238+
raise ValueError("confidence_level must be in (0, 1)")
239+
z = NormalDist().inv_cdf(0.5 + confidence_level / 2.0)
240+
eta = np.zeros(len(data))
241+
var = np.zeros(len(data))
242+
for model in (self.frequency, self.severity):
243+
x = model._design_matrix_from_info(data)
244+
eta += x @ model.coefficients_.to_numpy()
245+
var += np.einsum("ij,jk,ik->i", x, model.cov_params_.to_numpy(), x)
246+
se = np.sqrt(np.maximum(var, 0.0))
247+
out = pd.DataFrame(
248+
{
249+
"predicted": np.exp(np.clip(eta, -30, 30)),
250+
"ci_low": np.exp(np.clip(eta - z * se, -30, 30)),
251+
"ci_high": np.exp(np.clip(eta + z * se, -30, 30)),
252+
},
253+
index=data.index,
254+
)
255+
if exposure is not None:
256+
expo = data[exposure].to_numpy(dtype=float)
257+
for col in out.columns:
258+
out[col] = out[col] * expo
259+
return out
260+
202261
# ----- combined structure ----- #
203262
@property
204263
def base_value_(self) -> float:

tests/test_freqsev_interactions.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,35 @@ def test_to_factor_tables_excludes_interactions_and_plan_warns(fitted):
8080
assert set(model.to_factor_tables()) == {"area", "ind"}
8181
with pytest.warns(UserWarning, match="interaction terms"):
8282
rm.RatingPlan.from_model(model)
83+
84+
85+
def test_predict_interval_identities(fitted):
86+
df, model = fitted
87+
head = df.head(40)
88+
pi = model.predict_interval(head, exposure="exposure")
89+
np.testing.assert_allclose(
90+
pi["predicted"],
91+
model.pure_premium_prediction(head, exposure="exposure"),
92+
rtol=1e-12,
93+
)
94+
assert (pi["ci_low"] < pi["predicted"]).all()
95+
assert (pi["predicted"] < pi["ci_high"]).all()
96+
# log-scale variance is exactly the sum of the component variances:
97+
# reconstruct from the two submodel intervals
98+
z = 1.959963984540054
99+
hf = np.log(model.frequency.predict_interval(head)["ci_high"]
100+
/ model.frequency.predict_interval(head)["predicted"]) / z
101+
hs = np.log(model.severity.predict_interval(head)["ci_high"]
102+
/ model.severity.predict_interval(head)["predicted"]) / z
103+
combined = np.log(pi["ci_high"] / pi["predicted"]) / z
104+
np.testing.assert_allclose(combined, np.sqrt(hf**2 + hs**2), rtol=1e-10)
105+
# exposure scales all three columns linearly
106+
per_unit = model.predict_interval(head)
107+
np.testing.assert_allclose(
108+
pi["ci_low"], per_unit["ci_low"] * head["exposure"], rtol=1e-12)
109+
110+
111+
def test_predict_interval_guards(fitted):
112+
df, model = fitted
113+
with pytest.raises(ValueError, match="confidence_level"):
114+
model.predict_interval(df.head(), confidence_level=1.5)

0 commit comments

Comments
 (0)