Python for Actuarial Students · Course 3
Lesson 8 · Part B

Capstone: A Production Pricing Engine

One command from raw policy and claim records to spatial features, fitted models, a champion-vs-challenger test, rate indications, and a management report.

Lesson 8B · OverviewBy the end of the capstone you will have…

  • A Python package with a command-line entry point: uv run pricing-engine
  • Validated extracts, earned exposure, and spatial features from raw records
  • GLM and gradient-boosted pure-premium models in scikit-learn Pipelines
  • A holdout A/B test that picks a champion on loss ratios
  • A persisted production model with its metadata
  • Capped rate indications in an Excel workbook and an HTML report
  • Tests that prove the key calculations
6–10 h
Build, at your own pace
1 command
Raw data → report

Companion notebook: lesson-08b-capstone.ipynb — prototype each stage before you package it.

The briefWhat the pricing committee asked for

Capstone deliverable

A production-ready Python pricing engine that:

  1. ingests raw policy, vehicle, driver, coverage, and claim records
  2. processes exposures and spatial features
  3. scores policies with a regularized GLM or a gradient-boosted model, chosen by an A/B test
  4. exports rate indications into an automated management report

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.

Part 1

Architecture

Seven stages, one module each.

DesignStages and modules

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
1 Extract6 CSVs, keys checked
2 Features84,346 vehicle-terms
3 ModelsGLM, GBM
4 Evaluate2025 holdout
5–6 Indicatechampion refit
7 Reportxlsx + html

Each stage takes data in and hands data out: no hidden global state. Each can be tested alone and rerun without the others.

ConfigurationAssumptions live in 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
  • Changing a trend, a cap, or the champion is a config change, reviewed like any other, with no code edit
  • The first age edge is 16, not 17: an unrounded age of 16.998 would otherwise fall outside every band and silently become NaN
  • The champion is a decision made from the A/B test, then recorded

Read it with the standard library: tomllib.load(open(path, "rb")). TOML dates arrive as datetime.date.

Part 2

Building the Stages

Raw records to scored policies.

Stages 1–2Extract, exposure, and spatial features

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)
  • Exposure stops at the earliest of expiry, cancellation, and the evaluation date
  • Driver features: youngest driver’s age and years licensed at inception
  • Spatial: county, urban (inside an incorporated place), distance to the coast

Result: 84,346 vehicle-terms, 72,781 car-years, 4,565 liability claims, one row each, saved as output/model_frame.parquet.

Stages 3–4Two candidates, one holdout

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 holdoutGLMGBM
A/E losses1.0141.042
Gini0.2750.237
Loss ratio, quintile 1 → 539.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.

Stages 5–6Production refit and rate indications

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}}$$

SegmentVehiclesIndicatedProposed
All18,249+14.3%—
Urban10,902+33.6%+25.0%
Non-urban7,347−14.3%−14.3%
Sports1,238+51.7%+25.0%
SUV4,972+21.0%+21.0%
Sedan5,583+2.1%+2.1%
Minivan1,272−10.2%−10.2%
Age 17–20666+45.3%+25.0%
Age 65–741,258+0.0%+0.0%
Age 75+876+33.0%+25.0%

Stage 7The management report

  • rate_review.xlsx: Summary cards, Model comparison, Loss ratio lift, Indications, Rating factors
  • rate_review.html: one self-contained page (charts embedded as base64 PNG), rendered from a Jinja2 template
  • Both regenerated by every run; nobody edits them by hand

The 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.

The generated HTML rate review: four KPI cards, the champion-versus-challenger table, the holdout loss-ratio lift chart, and indicated changes by segment
Part 3

Run It, Test It

Trustworthy because it’s checked.

One commandFrom raw data to report in about 20 seconds

$ 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.

TestsProve the calculations, not just the plumbing

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)
TestProves
exposure stops at cancellation and evaluation dateEarned exposure is right
GLM recovers known relativities and balancesThe model can find a planted truth
GBM is rebalanced to training totalsCalibration works
proposed changes are cappedThe cap is enforced
downtown Raleigh is urban, in Wake CountyThe spatial join is right
$ uv run pytest -q
.....                                  [100%]
5 passed

Capstone · at your own paceBuild steps and checkpoints

  1. Prototype each stage in the companion notebook
  2. uv init --package pricing-engine; add dependencies; create the modules
  3. Extract + features: match 84,346 rows and 4,565 claims
  4. Models: GLM and GBM PurePremiumModels with balance factors
  5. Evaluate: holdout A/E, Gini, and loss-ratio lift for both
  6. Refit the champion on all years; save it with metadata
  7. Indications by urban, style, and age band, capped at ±25%
  8. Excel + HTML report; the CLI; at least five tests

Checkpoints

Car-years · liability claims72,781 · 4,565
Holdout A/E: GLM · GBM1.014 · 1.042
Holdout Gini: GLM · GBM0.275 · 0.237
Current premium (in-force)19,875,948
Overall indicated change+14.3%
Urban indicated change+33.6%

Going furtherExtensions

  • Interactions: add young × sports to the GLM (Lesson 7) and re-run the A/B test
  • Tweedie challenger: TweedieRegressor(power=1.6) or HistGradientBoostingRegressor(loss="tweedie") as a third model
  • Territories: county relativities with a penalized GLM, so thin counties shrink toward the state average
  • Off-balance: after capping, rebalance so the total change equals the committee’s approved figure
  • Monitoring: a score command that loads the saved model and reports A/E on new data each quarter
  • Other coverages: collision and comprehensive, with coverage-specific exposure (Course 2, Lesson 4)
  • Explanations: a SHAP waterfall per policy for the GBM challenger
  • Model card: a Markdown page with data, method, validation, and limitations, generated with the report

A 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.

RecapCourse 3 checklist

  • OLS and its failure modes; log models and robust SEs
  • Poisson and negative binomial frequency with exposure offsets
  • Gamma severity, frequency × severity, and Tweedie pure premium
  • Validation, cross-validation, Lasso, Ridge, and ElasticNet
  • K-means risk groups and how to choose and explain k
  • Surrogate trees that turn clusters into rules and tiers
  • Boosting with actuarial losses, and SHAP explanations
  • Pipelines, persistence, A/B tests, and a working pricing engine
Python for Actuarial Students
Course 3 complete · Next: Course 4

Production Actuarial Systems, Software Engineering & Enterprise Deployment

From working code to robust software: design patterns, automated testing, CI/CD, dashboards, and actuarial simulation engines.