Course 4 · Lesson 8 · Part A Integration, Delivery & Monitoring
Python for Actuarial Students · Course 4
Lesson 8 · Part A

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
75 min
Concepts & worked examples
75 min
Lab: pipeline and monitoring

Companion notebook: lesson-08a-integration.ipynb — worked examples and self-checking exercises.

ArchitectureThe rating platform

Callersweb form · agents · batch
↓ JSON, X-API-Key
APIvalidates, rates, logs
↕
PostgreSQLquotes · in-force book
↑
Portalscenarios, uploads
PartOwnsLesson
schemas, ratingthe contract, the rates1, 4
model artifactthe champion GLM, verified8A
APIone source of quotes4
images, tests, CIrunning it anywhere, with evidence2, 7
monitoringnoticing when it stops fitting8A
Part 1

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 /model returns 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.291yes
A/E 1.043, Gini 0.235 (the Course 3 GBM)no: less lift
A/E 0.930, Gini 0.300no: under-predicts 7%
A/E 1.000, Gini 0.278no: not enough better
A/E 1.000, Gini 0.320, tests failingno

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.

Part 2

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 as app
  • 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 fileTestsCovers
test_api.py12status codes, auth, batch, OpenAPI
test_rating.py10strategies; vectorized = single
test_db.py5rollback, time zones, secret URL
test_model.py5balance, manifest, tampering
test_monitoring.py5PSI by hand, a planted shift
test_portfolio.py5amendment, cap, empty book
test_dashboard.py4portal via AppTest
test_cli.py2train → 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.

Part 3

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)

PSIReading
below 0.10stable
0.10 to 0.25watch
above 0.25investigate
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 book963 new-business quotesAfter the campaign
driver_age0.00610.6742
annual_mileage0.01020.0127
vehicle_style0.00520.9005
urban0.00280.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

SignalWhere
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

  1. Train with a holdout check; save the model with a manifest
  2. load that refuses a tampered file or another scikit-learn
  3. A promotion rule; test it on five challengers
  4. PSI and a drift report; a /monitoring/drift endpoint with tests
  5. Simulate a campaign; confirm the alert fires
  6. A two-job workflow: checks, then images with a smoke test
  7. Run the smoke test yourself against your image

Answer key

Holdout A/E · Gini1.0143 · 0.2753
Genuine / tampered / oldload / refuse / refuse
Promotions1 of 5
Normal week, max PSI< 0.02
Campaign: age · styleinvestigate · 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
Up next
Lesson 8 · Part B

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.