Integration, Delivery & Monitoring
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.
Lesson 8A · OverviewBy the end of this lesson you can…
- Draw the platform’s architecture and say what each part owns
- Package a model as a verifiable artifact with a manifest
- Write a promotion rule for champion and challenger models
- Extend CI into delivery: build images, smoke-test containers
- Monitor inputs for drift with the Population Stability Index
- Plan a release and a rollback
Companion notebook: lesson-08a-integration.ipynb — worked examples and self-checking exercises.
ArchitectureThe rating platform
| 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 |
Models as Artifacts
A file that can prove what it is.
TrainingValidate first, then refit for production
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
ManifestWhat the model is, written next to it
{
"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
- The version is the checksum: two different files can’t share a version
- One extra byte in the file, or a different scikit-learn, and the API refuses to start: tested both ways
GET /modelreturns the manifest; every stored quote carries the version
GovernanceWhen may a new model replace the champion?
def 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.
Continuous Delivery
From a green test run to a tested image.
The pipelineTwo jobs: checks, then images
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- The smoke test runs the real image: it starts, loads and verifies the model, answers
/health, prices a quote, and runs asapp - The GitHub Actions cache keeps dependency layers between runs
- The workflow passes
actionlint; every step was run locally against the real image
Next step, per your organization: push the tested image to a registry with a version tag, and deploy that exact image to test, then production.
EvidenceWhat the checks job runs: 48 tests in 4 seconds
| 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.
Monitoring
A model is right about the world it was trained on.
DriftAre we still quoting the risks the model knows?
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."""
MeasuredA normal week, then a marketing campaign
| 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?
OperationsRelease, observe, roll back
| 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.
Actuarial Lab · 75 minPipeline and monitoring
- Train with a holdout check; save the model with a manifest
loadthat refuses a tampered file or another scikit-learn- A promotion rule; test it on five challengers
- PSI and a drift report; a
/monitoring/driftendpoint with tests - Simulate a campaign; confirm the alert fires
- A two-job workflow: checks, then images with a smoke test
- Run the smoke test yourself against your image
Answer key
| 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 |
RecapLesson 8A checklist
- My model is an artifact with a checksum and a manifest
- The service refuses a model it can’t verify
- Promotion follows a written rule
- Images are built only from code that passed its checks
- A smoke test runs the real image
- Every quote records the model version
- Input drift is measured and alerted on
- I can roll back, and know what each version priced
Capstone: An Enterprise Actuarial Rating Platform
Everything together: the champion model behind an API, a database, an underwriting portal, containers, and a CI/CD pipeline.