Python for Actuarial Students · Course 4
Lesson 1

Software Architecture: from Script to Package

Package layout, validated inputs, and the design patterns that let a rating engine change without breaking: refactoring a monolithic pricing script into a clean Python package.

Lesson 1 · OverviewBy the end of this lesson you can…

  • Lay out a project as a package: src/ layout, pyproject.toml, imports
  • Use type hints to say what a function accepts and returns
  • Validate every input record with pydantic, and report what is wrong
  • Keep the rating manual as data, not code
  • Apply the Strategy and Factory patterns to rating algorithms
  • Refactor safely, proving with a golden master that nothing changed by accident
75 min
Concepts & worked examples
75 min
Lab: refactor the monolith

Companion notebook: lesson-01-software-architecture.ipynb — worked examples and self-checking exercises.

Part 1

From Script to Package

What a working script is missing.

The starting pointprice_book.py: 116 lines that work

LIAB_BASE = 938.09
style_factors = {"Sedan": 1.0, "SUV": 1.05, ...}

for row in reader:
    age = int(float(row["driver_age"]))
    style = row["vehicle_style"]
    miles = float(row["annual_mileage"])

    # age factor
    if age <= 25:
        af = 1.6
    elif age < 30:
        af = 1.15
    ...
    sf = style_factors[style]
    liab = round(LIAB_BASE * sf * af * mf, 2)
    ...
    row["change"] = round(new_prem / old_prem - 1, 4)

It reprices the 2025 book: 18,249 vehicles, $27,268,008.65 of manual premium. The author has used it for a year.

$ python price_book.py quote_requests.csv q.csv
Traceback (most recent call last):
  ...
KeyError: 'liab_premium'

Pricing, file handling, validation, and the renewal report are one loop. It cannot quote a vehicle that has no current premium, which is every new-business quote.

DiagnosisWhat makes a script hard to trust

Symptom in price_book.pyWhy it mattersThis lesson’s fix
Factors typed into if chainsChanging a rate means editing code; nobody can review the manualManual as a TOML file, validated on load
No input checks“sedan”, −13,600 miles, and age 3 are all priced (or crash mid-file)pydantic models
One loop does everythingCan’t reuse the rating for quotes, an API, or a dashboardModules with one job each
One algorithm per coverage, hard-wiredA filed amendment means a copy of the scriptStrategy + Factory
No testsTwo boundary bugs have been charging customers for a yearGolden master now; full tests in Lesson 2

LayoutThe src/ layout

ratingkit/
├── pyproject.toml      # name, dependencies, tools
├── uv.lock             # exact versions, committed
├── README.md
├── src/
│   └── ratingkit/
│       ├── __init__.py   # the public API
│       ├── schemas.py    # validated inputs
│       ├── manual.py     # the rating manual
│       ├── manuals/motor_2025.toml
│       ├── algorithms.py # one rater per coverage
│       ├── factory.py    # raters chosen by name
│       ├── engine.py     # RatingEngine, Quote
│       ├── book.py       # files in, files out
│       └── cli.py        # the ratingkit command
└── tests/
  • Code lives under src/, so tests import the installed package, never a stray file in the current folder
  • One module, one responsibility: the rating logic never opens a file
  • The manual ships inside the package: code and rates are versioned together
  • cli.py is the only place that knows about the command line

Course 2 and 3 capstones already used this layout. This lesson explains why each piece is there.

pyproject.tomlOne file describes the project

[project]
name = "ratingkit"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["pandas>=2.2", "pydantic>=2.9"]

[project.scripts]
ratingkit = "ratingkit.cli:main"     # a command

[build-system]
requires = ["uv_build>=0.11,<0.12"]
build-backend = "uv_build"

[dependency-groups]
dev = ["pytest>=8", "hypothesis>=6.100",
       "ruff>=0.8", "mypy>=1.13"]
$ uv sync                  # .venv + locked versions
$ uv run ratingkit book_2025.csv priced.csv
18,249 vehicles priced, 0 rejected
$ uv run ratingkit quote_requests.csv q.csv \
      --id-column quote_id
963 vehicles priced, 37 rejected
  • dependencies: what users need; the dev group: what developers need
  • [project.scripts] turns a function into a command
  • uv.lock pins every version, so every machine gets the same environment

ImportsA package has a front door

# src/ratingkit/__init__.py
"""ratingkit: the motor rating manual as a tested,
typed Python package."""

from ratingkit.engine import Quote, RatingEngine
from ratingkit.manual import RatingManual, load_manual
from ratingkit.schemas import Vehicle

__all__ = ["Quote", "RatingEngine", "RatingManual",
           "Vehicle", "load_manual"]
# a user of the package
from ratingkit import Vehicle, load_manual
from ratingkit.factory import build_engine

engine = build_engine(load_manual())
  • Absolute imports (from ratingkit.engine import …) work the same from tests, scripts, and notebooks
  • __all__ is the public API: everything else may change
  • Modules import in one direction: schemas ← manual ← algorithms ← factory. A cycle means two modules should be one, or a third is missing
Part 2

Types & Validation

Say what a value may be, then enforce it.

Type hintsDocumentation the computer can check

from typing import Literal, Protocol

Coverage = Literal["LIAB", "COLL", "COMP"]
Style = Literal["Sedan", "SUV", "Hatchback",
                "Pickup", "Minivan", "Sports"]

def earned(total: float, exposure: float) -> float: ...

coll_deductible: int | None = None     # optional

class CoverageRater(Protocol):         # any object with
    def premium(self, vehicle: Vehicle) -> float: ...
HintMeans
float, str, boolthat type
int | Nonean int, or nothing
Literal["LIAB", …]exactly one of these values
dict[Coverage, float]coverage codes to premiums
Protocolanything with these methods

Python ignores hints at run time. mypy checks them before the code runs (Lesson 2); pydantic enforces them on data as it arrives.

pydanticA record that reaches the engine is ratable

from pydantic import BaseModel, ConfigDict, Field, model_validator

class Vehicle(BaseModel):
    model_config = ConfigDict(frozen=True, extra="ignore")

    driver_age: float = Field(ge=16, le=110)
    vehicle_style: Style
    vehicle_value: float = Field(gt=0, le=500_000)
    annual_mileage: float = Field(gt=0, le=150_000)
    urban: bool
    coverages: Annotated[frozenset[Coverage],
                         BeforeValidator(_split)]  # "LIAB|COLL"
    coll_deductible: Deductible | None = None
    comp_deductible: Deductible | None = None

    @model_validator(mode="after")
    def _coverages_consistent(self) -> Self:
        if "LIAB" not in self.coverages:
            raise ValueError("liability (LIAB) is compulsory")
        ...  # a deductible if and only if the cover is bought
        return self
  • Field rules: ranges (ge, gt, le), allowed values (Literal)
  • Record rules: model_validator sees all the fields at once
  • Text from files is converted: "40" → 40.0, "False" → False
  • frozen=True: a validated vehicle can’t be changed afterwards into an invalid one
Vehicle.model_validate({"driver_age": "3", ...})
# ValidationError: driver_age
#   Input should be greater than or equal to 16

Result1,000 new-business quote requests

FieldErrorsExample
driver_age103.0; blank
vehicle_style7“sedan”, “Coupe”, “SUV␣”
annual_mileage5−13,600
coll_deductible52,000; 750
(record)4COLL deductible, no COLL
vehicle_value40
urban2“city”, “maybe”
Rejected37963 priced

Every rejected row comes back with its field and the reason, so it can be fixed at the source rather than guessed at.

38 errors were planted; 37 were caught. One row says urban = "Y", and pydantic’s default (lax) mode reads “Y”, “yes” and “1” as True. That may be exactly what you want, or not: ConfigDict(strict=True) accepts only real booleans. Decide deliberately.

GotchasFiles are text

# CSV gives "500"; Literal[250, 500, 1000] wants an int
def _whole_number(value):
    if isinstance(value, str) and value.replace(".", "", 1).isdigit() \
            and float(value).is_integer():
        return int(float(value))
    return value

Deductible = Annotated[Literal[250, 500, 1000],
                       BeforeValidator(_whole_number)]
raw = pd.read_csv(path, dtype=str, keep_default_na=False)
clean = {k: (None if v == "" else v) for k, v in row.items()}
  • Without the converter, every deductible in the file was rejected: pydantic won’t turn the text “500” into the number 500 to match a Literal
  • Let pandas guess, and a blank age becomes NaN: a valid float that compares false with everything. Read text, and make blanks None, which a required field rejects
  • A BeforeValidator runs before the type check, an AfterValidator after
Part 3

Design Patterns for Rating

The manual as data; algorithms that can be swapped.

ConfigurationThe rating manual is data

# motor_2025.toml
[base_rate]          # annual premium, base risk
LIAB = 938.09
COLL = 576.85
COMP = 158.35

[style]              # LIAB and COLL
Sedan = 1.00
Sports = 1.40        # ...

[driver_age]         # bands are [lower, upper)
upper  = [25, 30, 65, 75, 200]
factor = [1.60, 1.15, 1.00, 1.10, 1.35]

[deductible]         # COLL and COMP
250 = 1.10
500 = 1.00
1000 = 0.88
class BandTable(BaseModel):
    upper: tuple[float, ...]
    factor: tuple[float, ...]

    @model_validator(mode="after")
    def _check(self) -> Self:   # bounds must increase
        ...

    def lookup(self, value: float) -> float:
        for upper, factor in zip(self.upper, self.factor,
                                 strict=True):
            if value < upper:
                return factor
        raise ValueError(f"{value} is above the last band")

A rate change is now a reviewed edit to one file, validated when loaded: bounds out of order, or a missing factor, stop the engine before it prices anything.

Strategy patternOne algorithm per coverage, same shape

class CoverageRater(Protocol):
    @property
    def coverage(self) -> Coverage: ...
    def premium(self, vehicle: Vehicle) -> float: ...

@dataclass(frozen=True)
class ManualLiability:
    manual: RatingManual
    coverage: Coverage = "LIAB"

    def premium(self, v: Vehicle) -> float:
        m = self.manual
        return money(m.base_rate["LIAB"] * m.style[v.vehicle_style]
                     * m.driver_age.lookup(v.driver_age)
                     * m.annual_mileage.lookup(v.annual_mileage))

@dataclass(frozen=True)
class UrbanAmendedLiability:     # the Course 3 rate review
    manual: RatingManual
    urban_factor: float = 1.25
    non_urban_factor: float = 0.857
    ...
Vehiclevalidated
→
RatingEngineone rater per coverage
→
Quote{LIAB, COLL, COMP}

The engine asks each rater for a premium and never asks which kind of rater it is. A new algorithm, whether a filed amendment or a GLM (Lesson 4), is a new class, not an edit to the engine.

A Protocol describes the shape; classes don’t inherit from it. mypy checks that each rater fits.

Factory patternAlgorithms chosen by name

ALGORITHMS = {
    ("LIAB", "manual"): ManualLiability,
    ("LIAB", "urban_amendment"): UrbanAmendedLiability,
    ("COLL", "manual"): ManualCollision,
    ("COMP", "manual"): ManualComprehensive,
}

def build_engine(manual, choices=None) -> RatingEngine:
    names = {**DEFAULT, **(choices or {})}
    raters = {}
    for coverage, name in names.items():
        try:
            make = ALGORITHMS[(coverage, name)]
        except KeyError:
            raise ValueError(f"no {coverage} algorithm "
                             f"called {name!r}") from None
        raters[coverage] = make(manual)
    return RatingEngine(raters)
$ uv run ratingkit book_2025.csv amended.csv \
      --liability urban_amendment
2025 book, LIABVehiclesChange
Urban (× 1.25)10,902+25.0%
Non-urban (× 0.857)7,347−14.3%
All liability18,249+9.18%
All coverages+6.71%

The configuration names the algorithm; the factory builds it. Unknown names fail at start-up with the list of valid ones.

Refactoring safelyThe golden master: price the book both ways

18,249 vehiclesCountPremium effect
Identical17,854
Driver aged 25.x254−$151,078.71
Exactly 15,000 miles72+$20,464.48
One cent (rounding)71+$0.71

Two vehicles are in both bug groups. The monolith charged $129,999.63 more than its own manual.

age = int(float(row["driver_age"]))   # 25.7 -> 25
if age <= 25:                          # manual: under 25
    af = 1.6
...
elif miles <= 15000:                   # manual: under 15,000
    mf = 1.0

VEH000130: a 25.7-year-old in a Hatchback paid the under-25 factor, LIAB $1,981.25 instead of $1,424.02. Refactoring means same behavior; every difference must be explained, and here each one traces to a bug in the old script.

MoneyRound the way the manual says

>>> round(836.645, 2)
836.64          # 836.645 is stored as 836.64499999...

from decimal import ROUND_HALF_UP, Decimal

def money(amount: float) -> float:
    """Round half up, to the cent."""
    return float(Decimal(repr(amount))
                 .quantize(Decimal("0.01"),
                           rounding=ROUND_HALF_UP))

>>> money(836.645)
836.65

The 71 one-cent differences are premiums that end in exactly half a cent. round() works on the binary float, which is a hair below the half; money() rounds the decimal number the user sees.

A cent matters when a regulator asks you to reproduce a filed premium. Write the rounding rule down, put it in one function, and test it (Lesson 2).

Actuarial Lab · 75 minRefactor the monolith

  1. Run starter/monolith/price_book.py on the book; save its output as the golden master
  2. Create the package with uv init --package in a src/ layout
  3. Write Vehicle with every field and record rule; validate the quote requests
  4. Move the factors into a TOML manual; load it into validated band tables
  5. One rater class per coverage; a RatingEngine that combines them
  6. A factory, and the urban amendment as a second LIAB algorithm
  7. A ratingkit command; compare with the golden master and explain every difference

Answer key

Valid / rejected requests963 / 37
Quote: 22, Sports, 16,000 miLIAB 2,521.59
… COLL ($45k, $1,000)2,046.76
… COMP ($250)313.53
Urban amendment, LIAB+9.18%
Vehicles priced differently324 (+71 cents)

RecapLesson 1 checklist

  • My code is a package in a src/ layout with a pyproject.toml
  • Each module has one job; the rating logic never touches files
  • Every input is validated, and rejections say which field and why
  • I know what lax mode accepts
  • The rating manual is a file, validated when loaded
  • Each coverage has a rater with the same shape (Strategy)
  • Algorithms are chosen by name from configuration (Factory)
  • I refactor against a golden master and explain every difference
Up next
Lesson 2

Automated Testing, Code Quality & CI

pytest, test-driven development, property-based tests, ruff, mypy, and GitHub Actions: catching the boundary bugs before customers pay for them.