One command from raw policy and claim records to spatial features, fitted models, a champion-vs-challenger test, rate indications, and a management report.
uv run pricing-engineCompanion notebook: lesson-08b-capstone.ipynb — prototype each stage before you package it.
A production-ready Python pricing engine that:
Scope: motor liability. Train on policy years 2021–2024, test on 2025, then refit the champion on all five years and rate the 2025 in-force book: 18,249 vehicles, $19.9 million of annual liability premium.
Everything you need is in Lessons 2, 3, 7, and 8A, and the Course 2 capstone showed the package pattern. This lesson is about assembling it reliably.
Seven stages, one module each.
pricing_engine/
├── config.toml # every assumption
├── pyproject.toml
├── src/pricing_engine/
│ ├── extract.py # 1 load + validate keys
│ ├── features.py # 2 exposure, factors, spatial
│ ├── models.py # 3 pipelines, balance, joblib
│ ├── evaluate.py # 4 A/B test on the holdout
│ ├── indicate.py # 5–6 refit + indications
│ ├── report.py # 7 Excel + HTML
│ ├── cli.py # the one command
│ └── templates/report.html.j2
├── tests/test_engine.py
└── output/ # generated, never edited
Each stage takes data in and hands data out: no hidden global state. Each can be tested alone and rerun without the others.
config.toml[paths]
motor = "../../data/motor"
geo = "../../data/geo/nc_geo.gpkg"
output = "output"
[dates]
exposure_end = 2025-12-31
holdout_year = 2025
[age_bands]
edges = [16, 21, 25, 30, 40, 50, 65, 75, 120]
labels = ["17-20", "21-24", "25-29", "30-39",
"40-49", "50-64", "65-74", "75+"]
[glm]
alpha = 0.0001
[champion]
model = "glm"
[indication]
target_loss_ratio = 0.68
annual_trend = 0.04
trend_years = 1.5
max_change = 0.25
NaNRead it with the standard library: tomllib.load(open(path, "rb")). TOML dates arrive as datetime.date.
Raw records to scored policies.
def earned_exposure(start, expiry, cancel, end):
stop = pd.concat([expiry, cancel], axis=1).min(axis=1) \
.clip(upper=end)
return ((stop - start).dt.days + 1).clip(lower=0) / 365.25
def spatial_features(policies, geo_path):
pts = gpd.GeoDataFrame(policies[["policy_id"]], crs="EPSG:4326",
geometry=gpd.points_from_xy(policies.longitude,
policies.latitude)
).to_crs("EPSG:32119") # meters
counties = gpd.read_file(geo_path, layer="counties")
places = gpd.read_file(geo_path, layer="places")
coast = gpd.read_file(geo_path, layer="coastline")
out = gpd.sjoin_nearest(pts, counties, how="left")
out["urban"] = out.within(places.union_all())
out["coast_km"] = out.distance(coast.union_all()) / 1000
return pd.DataFrame(out.drop(columns="geometry"))
extract.load_tables refuses to continue on a duplicate key or an orphan foreign key (Course 2)Result: 84,346 vehicle-terms, 72,781 car-years, 4,565 liability claims, one row each, saved as output/model_frame.parquet.
train = frame[frame.policy_year < holdout_year]
hold = frame[frame.policy_year == holdout_year]
candidates = models.build_models(cfg["glm"], cfg["gbm"])
for m in candidates.values():
m.fit(train) # freq + sev + balance
metrics = evaluate.holdout_metrics(candidates, hold)
lift = evaluate.loss_ratio_lift(candidates, hold)
| 2025 holdout | GLM | GBM |
|---|---|---|
| A/E losses | 1.014 | 1.042 |
| Gini | 0.275 | 0.237 |
| Loss ratio, quintile 1 → 5 | 39.3% → 119.1% | 49.0% → 101.7% |
Same verdict as Lesson 8A: the GLM is champion. Record it in config.toml (model = "glm").
Numbers differ slightly from 8A’s because the engine rebuilds its features from raw records: ages aren’t rounded, for instance. A pipeline that reproduces its own inputs is the point.
champion = build_models(...)[name].fit(frame) # all 5 years
champion.metadata.update(trained_through="2025-12-31",
holdout=metrics.loc[name].to_dict())
champion.save(out / "models" / "glm_pure_premium.joblib")
trend = (1 + annual_trend) ** trend_years # 1.04 ** 1.5
indicated = champion.predict_pure_premium(book) \
* trend / target_loss_ratio
change = indicated.sum() / book.liab_premium.sum() - 1
proposed = change.clip(-max_change, max_change)
$$\text{Indicated premium} = \frac{\text{Pure premium} \times (1+\text{trend})^{t}}{\text{Target loss ratio}}$$
| Segment | Vehicles | Indicated | Proposed |
|---|---|---|---|
| All | 18,249 | +14.3% | — |
| Urban | 10,902 | +33.6% | +25.0% |
| Non-urban | 7,347 | −14.3% | −14.3% |
| Sports | 1,238 | +51.7% | +25.0% |
| SUV | 4,972 | +21.0% | +21.0% |
| Sedan | 5,583 | +2.1% | +2.1% |
| Minivan | 1,272 | −10.2% | −10.2% |
| Age 17–20 | 666 | +45.3% | +25.0% |
| Age 65–74 | 1,258 | +0.0% | +0.0% |
| Age 75+ | 876 | +33.0% | +25.0% |
rate_review.xlsx: Summary cards, Model comparison, Loss ratio lift, Indications, Rating factorsrate_review.html: one self-contained page (charts embedded as base64 PNG), rendered from a Jinja2 templateThe headline for the committee: overall +14.3%, driven by urban risks the current plan doesn’t rate. The ±25% cap leaves the urban, Sports, and youngest-driver indications partly unaddressed, a decision for the committee, not the code.
Trustworthy because it’s checked.
$ uv run pricing-engine --config config.toml
INFO pricing_engine.extract: loaded policies 78,062 rows
INFO pricing_engine.extract: loaded claims 11,562 rows
INFO pricing_engine.extract: all primary and foreign keys valid
INFO pricing_engine.features: frame: 84,346 vehicle-terms, 72,781 car-years, 4,565 liability claims
INFO pricing_engine.models: glm fitted: balance factors frequency 1.0000, severity 1.0000
INFO pricing_engine.models: gbm fitted: balance factors frequency 0.9950, severity 1.0516
INFO pricing_engine.evaluate: glm: A/E 1.014, Gini 0.275
INFO pricing_engine.evaluate: gbm: A/E 1.042, Gini 0.237
INFO pricing_engine.models: saved glm model to glm_pure_premium.joblib
INFO pricing_engine: overall indicated change +14.3%
INFO pricing_engine.report: wrote rate_review.xlsx
INFO pricing_engine.report: wrote rate_review.html
The log is an audit trail: row counts, key checks, balance factors, and the A/B result, every run. Add -v for debug detail.
def synthetic_book(n=20_000, seed=0):
"""Known truth: urban claims 30% more often;
Sports claims cost 40% more."""
...
def test_glm_recovers_known_relativities_and_balances():
book = synthetic_book()
glm = build_models(GLM, GBM)["glm"].fit(book)
assert (glm.predict_frequency(book) * book.exposure).sum() \
== pytest.approx(book.n_liab.sum())
...
assert urban == pytest.approx(1.3, abs=0.08)
assert sports == pytest.approx(1.4, abs=0.1)
| Test | Proves |
|---|---|
| exposure stops at cancellation and evaluation date | Earned exposure is right |
| GLM recovers known relativities and balances | The model can find a planted truth |
| GBM is rebalanced to training totals | Calibration works |
| proposed changes are capped | The cap is enforced |
| downtown Raleigh is urban, in Wake County | The spatial join is right |
$ uv run pytest -q
..... [100%]
5 passed
uv init --package pricing-engine; add dependencies; create the modulesPurePremiumModels with balance factors| Car-years · liability claims | 72,781 · 4,565 |
| Holdout A/E: GLM · GBM | 1.014 · 1.042 |
| Holdout Gini: GLM · GBM | 0.275 · 0.237 |
| Current premium (in-force) | 19,875,948 |
| Overall indicated change | +14.3% |
| Urban indicated change | +33.6% |
TweedieRegressor(power=1.6) or HistGradientBoostingRegressor(loss="tweedie") as a third modelscore command that loads the saved model and reports A/E on new data each quarterA capstone reviewer will ask: can I rerun it, does it reproduce your numbers, and do the tests fail when a calculation breaks? Build for those three questions.
From working code to robust software: design patterns, automated testing, CI/CD, dashboards, and actuarial simulation engines.