From a folder of notebooks to one command that rebuilds every number, table, and report from raw data.
≈ 60 min concepts + 45 min refactoring workshop
Project work, at your own pace
Companion notebook: lesson-08a-pipeline-architecture.ipynb — worked examples and self-checking exercises.
After seven lessons you have seven notebooks. Next quarter, someone must:
Every manual step is a place for an error that nobody will spot — a stale date, a wrong file, a pasted number from the previous quarter.
A pipeline is code that goes from raw inputs to finished outputs with no manual steps. Run it twice on the same inputs: identical outputs. Run it on new inputs: the new answer.
Four stages, each with one job.
extract.pyfeatures.pycalculate.pyreport.pyNow you can test calculate.indicate() on a three-row DataFrame, swap the Excel report for a dashboard, or point extract at a database instead of CSVs — without touching the other stages.
rating_pipeline/
├── config.toml every assumption
├── pyproject.toml + [project.scripts]
├── README.md
├── src/rating_pipeline/
│ ├── __init__.py
│ ├── cli.py entry point: runs 1→4
│ ├── extract.py
│ ├── features.py
│ ├── calculate.py
│ ├── report.py
│ └── templates/
│ └── deck.html.j2 Reveal.js template
├── tests/
│ └── test_pipeline.py
└── output/ generated — git-ignored
output/ is disposable: delete it, rerun, get it backA complete pipeline like this is about 400 lines, including the slide template — built entirely from what you learned in Lessons 1–7.
Parameters in a file, not scattered through code.
# config.toml — every assumption lives here
[paths]
motor = "../../data/motor"
geo = "../../data/geo/nc_geo.gpkg"
output = "output"
[evaluation]
date = 2025-12-31 # a real date, not a string
[territory]
coastal_km = 25
[age_bands]
edges = [17, 25, 35, 50, 65, 75, 120]
labels = ["17-24", "25-34", "35-49", "50-64", "65-74", "75+"]
[indication]
target_loss_ratio = 0.68
annual_trend = 0.04
trend_years = 1.5
full_credibility_claims = 1082
max_change = 0.15
import tomllib # standard library, 3.11+
with open("config.toml", "rb") as f: # binary mode!
cfg = tomllib.load(f)
cfg["evaluation"]["date"] # datetime.date(2025, 12, 31)
cfg["indication"]["max_change"] # 0.15
pyproject.toml is TOML too — you already know the syntax| TOML | YAML | |
|---|---|---|
| Python support | built-in tomllib (read-only) | pyyaml package |
| Syntax | key = value, [sections] | indentation-based |
| Types | explicit: strings quoted, real dates | guessed: no, off → False; yes, on → True |
| Deep nesting | clumsier | natural |
| Seen in | pyproject.toml, Rust, uv | CI pipelines, Kubernetes, dbt |
# The same block in YAML
indication:
target_loss_ratio: 0.68
annual_trend: 0.04
max_change: 0.15
YAML’s type guessing has bitten real models: the country code NO (Norway) loads as False. Prefer TOML for parameter files; if you must use YAML, use yaml.safe_load.
from pathlib import Path
def run(config_path: Path) -> dict[str, Path]:
with open(config_path, "rb") as f:
cfg = tomllib.load(f)
base = config_path.parent
paths = {k: (base / v).resolve()
for k, v in cfg["paths"].items()}
paths["output"].mkdir(parents=True, exist_ok=True)
eval_date = pd.Timestamp(cfg["evaluation"]["date"])
params = cfg["indication"]
...
.resolve() turns them into absolute paths — log them so a reviewer knows exactly which files were read[indication] table straight into a function with **paramsUnpacking a config section into keyword arguments means the TOML keys and the function’s parameter names must match — a free consistency check.
A command-line entry point, logging, and built-in checks.
# src/rating_pipeline/cli.py
import argparse, logging
def main() -> None:
parser = argparse.ArgumentParser(description="Motor rating pipeline")
parser.add_argument("--config", type=Path, default=Path("config.toml"))
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
datefmt="%H:%M:%S")
run(args.config)
# pyproject.toml
[project.scripts]
rating-pipeline = "rating_pipeline.cli:main"
$ uv run rating-pipeline --config config.toml
INFO extract: loaded policies 22,185 rows
INFO extract: loaded vehicles 23,993 rows
INFO extract: loaded persons 12,351 rows
INFO extract: loaded policy_drivers 29,803 rows
INFO extract: loaded coverages 63,817 rows
INFO extract: loaded claims 3,354 rows
INFO extract: all primary and foreign keys valid
INFO features: ABT: 23,993 vehicles, 20100.95
car years, 3,354 claims
INFO features: territories: {'Urban inland': 11658,
'Rural inland': 7501, 'Coastal': 4834}
INFO rating_pipeline: loss ratio 74.6%,
proposed change +7.8%
INFO report: wrote output/rating_review.xlsx
INFO report: wrote output/rating_review.html
About 5 seconds, end to end. (Timestamps trimmed.)
logging, not printimport logging
log = logging.getLogger(__name__) # one logger per module
log.info("loaded %-15s %8s rows", name, f"{len(df):,}")
log.debug("columns: %s", list(df.columns)) # only with -v
log.warning("%d vehicles outside every county", n_missing)
DEBUG < INFO < WARNING < ERRORA run log with row counts, control totals, and file paths is exactly what a peer reviewer or auditor asks for. With logging it’s produced automatically, every run.
class DataQualityError(ValueError):
"""Raised when an extract breaks a key constraint."""
# extract.py — before anything else runs
dupes = df.duplicated(key).sum()
if dupes:
raise DataQualityError(
f"{name}: {dupes} duplicate primary keys on {key}")
# features.py — control totals after the joins
assert len(abt) == len(t["vehicles"]), "ABT grain changed"
assert abt["n_claims"].sum() == len(t["claims"]), \
"claims lost or duplicated in ABT"
A pipeline that runs to completion on bad data is worse than one that crashes: it produces a confident, formatted, wrong report.
def test_full_leap_year_earns_one():
start = pd.Series(pd.to_datetime(["2024-01-01"]))
end = pd.Series(pd.to_datetime(["2024-12-31"]))
assert calendar_exposure(start, end, 2024).iloc[0] \
== pytest.approx(1.0)
def test_loss_ratio_is_ratio_of_sums():
abt = pd.DataFrame({"seg": ["A", "A"],
"exposure": [1.0, 0.1], "n_claims": [0, 1],
"incurred": [0.0, 500.0],
"earned_premium": [1000.0, 100.0]})
seg = experience(abt, ["seg"])
assert seg.loc[0, "loss_ratio"] == pytest.approx(500 / 1100)
def test_proposed_change_is_capped_and_credibility_weighted():
...
$ uv run pytest -q
... [100%]
3 passed in 0.27s
uv init rating_pipeline --package; add the dependenciescli.py; register the script in pyproject.tomlconfig.toml; replace every hard-coded path, date, and thresholdprint with logging; add the key checks and control totalsuv run rating-pipeline end to end — even if the report stage is still a stubSearch your code for literal numbers and dates (2025, 0.68, 25). Each one is either a genuine constant or a config entry in disguise.
Get the skeleton running first, with stubs. Then fill in each stage and rerun. Never go long without a working end-to-end run.
logging, not printThe brief, the deliverables, and how it will be marked.