The pieces become a system: model artifacts that prove what they are, a rule for promoting a new model, a pipeline that builds and smoke-tests every change, and monitoring that notices when the business drifts away from the model.
Companion notebook: lesson-08a-integration.ipynb — worked examples and self-checking exercises.
| Part | Owns | Lesson |
|---|---|---|
schemas, rating | the contract, the rates | 1, 4 |
| model artifact | the champion GLM, verified | 8A |
| API | one source of quotes | 4 |
| images, tests, CI | running it anywhere, with evidence | 2, 7 |
| monitoring | noticing when it stops fitting | 8A |
A file that can prove what it is.
def train(frame, holdout_year=2025):
check = PurePremiumModel().fit(
frame[frame.policy_year < holdout_year])
hold = frame[frame.policy_year == holdout_year]
pp = check.pure_premium(hold)
metrics = {
"holdout_actual_to_expected":
hold.inc_liab.sum() / (pp * hold.exposure).sum(),
"holdout_gini": gini(hold.inc_liab, pp, hold.exposure)}
model = PurePremiumModel().fit(frame) # all five years
return model, metrics
saved glm-dcbe5b5dbb to models/champion
holdout_year: 2025
holdout_actual_to_expected: 1.0143
holdout_gini: 0.2753
trained_rows: 84346
trained_car_years: 72,780.5092
trained_claims: 4565
{
"name": "motor-liability-glm",
"version": "glm-dcbe5b5dbb",
"sha256": "dcbe5b5dbbc32b2e72874ee7c05421e6...",
"created_at": "2026-09-30T14:54:51+00:00",
"sklearn_version": "1.9.1",
"features": ["vehicle_style", "driver_age",
"urban", "annual_mileage"],
"metrics": {
"holdout_year": 2025,
"holdout_actual_to_expected": 1.0143,
"holdout_gini": 0.2753,
"trained_rows": 84346,
"trained_claims": 4565
}
}
def load(directory):
manifest = json.loads((directory / "manifest.json").read_text())
if sha256(directory / "model.joblib") != manifest["sha256"]:
raise ModelIntegrityError("does not match its manifest "
"checksum: refusing to load")
if sklearn_major_minor() != manifest_major_minor(manifest):
raise ModelIntegrityError("model trained with "
"scikit-learn 0.24.2, ...")
return joblib.load(directory / "model.joblib"), manifest
GET /model returns the manifest; every stored quote carries the versiondef promote(champion, challenger) -> bool:
return (0.95 <= challenger["ae"] <= 1.05
and challenger["gini"] >= champion["gini"] + 0.005
and challenger["tests_passed"])
| Challenger (champion: A/E 1.014, Gini 0.275) | Promote? |
|---|---|
| A/E 1.002, Gini 0.291 | yes |
| A/E 1.043, Gini 0.235 (the Course 3 GBM) | no: less lift |
| A/E 0.930, Gini 0.300 | no: under-predicts 7% |
| A/E 1.000, Gini 0.278 | no: not enough better |
| A/E 1.000, Gini 0.320, tests failing | no |
Writing the rule down before the results arrive stops the decision being made to fit the results. The thresholds are a business decision; encoding them makes each promotion reviewable and repeatable.
From a green test run to a tested image.
jobs:
checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v10
- run: uv sync --locked
- run: uv run ruff check . && uv run mypy src tests
- run: uv run pytest --cov
images:
needs: checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with: {target: api, tags: "rating-api:ci", load: true,
cache-from: type=gha, cache-to: "type=gha,mode=max"}
- uses: docker/build-push-action@v7
with: {target: portal, tags: "rating-portal:ci"}
- name: Smoke test the API container
run: |
docker run -d -p 8000:8000 -e RP_API_KEY=ci-key \
-e RP_DATABASE_URL=sqlite:////tmp/ci.db rating-api:ci
# wait for /health, POST a quote, check the user is "app"
needs: checks: no image is built from code that fails its tests/health, prices a quote, and runs as appactionlint; every step was run locally against the real imageNext step, per your organization: push the tested image to a registry with a version tag, and deploy that exact image to test, then production.
| Test file | Tests | Covers |
|---|---|---|
test_api.py | 12 | status codes, auth, batch, OpenAPI |
test_rating.py | 10 | strategies; vectorized = single |
test_db.py | 5 | rollback, time zones, secret URL |
test_model.py | 5 | balance, manifest, tampering |
test_monitoring.py | 5 | PSI by hand, a planted shift |
test_portfolio.py | 5 | amendment, cap, empty book |
test_dashboard.py | 4 | portal via AppTest |
test_cli.py | 2 | train → init-db; API client |
Coverage: 96% of 507 statements; the gate is 90%. (The portal script runs under AppTest but is left out of the count.)
Small fixtures ship with the repository (2,000 vehicles, a 20,000-row training sample), so CI needs neither the full data nor a database server.
A model is right about the world it was trained on.
The Population Stability Index compares the share of quotes in each band with the share in a reference population (the in-force book):
PSI = Σ (current − reference) × ln(current ÷ reference)
| PSI | Reading |
|---|---|
| below 0.10 | stable |
| 0.10 to 0.25 | watch |
| above 0.25 | investigate |
BINS = {"driver_age": [16, 25, 30, 65, 75, 121], # manual bands
"annual_mileage": [0, 7_500, 15_000, float("inf")],
"vehicle_style": None, "urban": None} # categories
def psi(reference, current, floor=1e-4):
ref, cur = reference.align(current, fill_value=0.0)
ref, cur = ref.clip(lower=floor), cur.clip(lower=floor)
return float(((cur - ref) * np.log(cur / ref)).sum())
@app.get("/monitoring/drift")
def drift(s: Db, last: int = 1_000):
"""PSI of the last `last` quote requests against the
in-force book."""
| PSI vs the 2025 book | 963 new-business quotes | After the campaign |
|---|---|---|
| driver_age | 0.0061 | 0.6742 |
| annual_mileage | 0.0102 | 0.0127 |
| vehicle_style | 0.0052 | 0.9005 |
| urban | 0.0028 | 0.0028 |
The campaign: 400 of the last 1,000 quotes are Sports cars with drivers aged 19–23. Both flags go to investigate the moment the quotes are logged: no claims need to arrive first.
Drift isn’t an error; it’s a question. Is the model reliable for this mix (the Course 3 GLM has few young Sports drivers, and ignores their interaction)? Is the campaign attracting the risks the manual underprices?
| Signal | Where |
|---|---|
| Is it up? | /health, container health check |
| Which model? | /model; model_version on every quote |
| How fast? | X-Process-Time-Ms on every response |
| What happened? | logs to standard output: “quote 17 total 7008.88” |
| Still fitting? | /monitoring/drift |
| Still accurate? | actual ÷ expected as claims arrive (Course 3) |
SELECT model_version, liability_basis, COUNT(*),
SUM(total_premium) FROM quotes GROUP BY 1, 2;
glm-dcbe5b5dbb | model | 1363 | 3290185.25
Rollback = run the previous image tag. Because the model is inside the image and every quote records the version, you know exactly which quotes each version priced.
load that refuses a tampered file or another scikit-learn/monitoring/drift endpoint with tests| Holdout A/E · Gini | 1.0143 · 0.2753 |
| Genuine / tampered / old | load / refuse / refuse |
| Promotions | 1 of 5 |
| Normal week, max PSI | < 0.02 |
| Campaign: age · style | investigate · investigate |
Everything together: the champion model behind an API, a database, an underwriting portal, containers, and a CI/CD pipeline.