3. Transfer to Python
Duration2hLearning Objectives
Prerequisites
Introduction
Your notebook from the previous activity already contains a correct pipeline. But look closely: you filled missing values the same way for every column, you will repeat the same cleaning and feature steps the next time you touch this data, and nothing in the notebook can be reused without copy-pasting cells. This activity turns the parts of the notebook you keep repeating into small, tested Python functions — and from here on, the notebook stops being the place where the pipeline lives. You will not come back to edit it after this activity: pytest and the functions themselves are how you verify your work from now on.
Before this activity, read Towards Production-Ready ML if you have not already: it explains why notebook code and production code solve different problems, and what a first, minimal separation of concerns looks like.
Warning
Read this whole page once, end to end, before writing any code. Each section builds on the one before it, and the last one changes how you should read the first.
Read workflows.py First
Before touching any file, open src/air_quality/workflows.py. It already exists, complete, and you will not need to change it — it is the map. It shows exactly which functions the pipeline needs and in which order; your job in this activity is to make those functions actually work.
|
|
A few things worth understanding before you go further, since none of this appeared in the notebook:
@dataclasswrites the boring part of a class for you: given typed attributes like the ones below, it generates the code that assigns them from constructor arguments. That is whyPipelineConfig(train_city="Nairobi", test_city="Kampala")already works — the same reversal you tried in the previous activity’s reflection question — without a single line of__init__written by hand. See thedataclassesdocumentation.field(default_factory=lambda: list(DEFAULT_COLUMNS))— a dataclass field can’t default directly to a mutable object like a list:columns: list[str] = DEFAULT_COLUMNSwould make everyPipelineConfigshare, and risk mutating, that exact same list — the same trap you’ll see again just below withconfig = config or PipelineConfig(), just inside a dataclass field this time instead of a function argument.default_factorytakes a function to call fresh for each new instance; here, that function builds a new list copied fromDEFAULT_COLUMNS.config = config or PipelineConfig()— why not write the default directly in the signature, asconfig: PipelineConfig = PipelineConfig()? A default argument value is built exactly once, when Python reads the function definition — not once per call. Writing it directly there would mean every call that omitsconfigshares that same single instance. Writingconfig: PipelineConfig | None = None, then substituting a fresh instance inside the function body, avoids this classic Python trap. See the Common Gotchas: mutable default arguments.train_df, _ = data.load_datasets()—load_datasets()returns two DataFrames, train and test; this pipeline only needs the train one._is the Python convention for “a value this line must unpack, but that nothing afterward will use.”- Every call is written as
data.load_datasets(...),features.add_temporal_features(...),evaluation.evaluate_manual_split(...)— module name first, function name second. That is deliberate: readingworkflows.py, you can always tell which file to open to find, or fix, a given function.
None of the functions run_baseline calls are implemented yet — each exists only as a signature and a docstring. That is what you write next.
Implementation
Files: src/air_quality/data.py, src/air_quality/features.py, src/air_quality/evaluation.py, tests/test_data.py, tests/test_features.py, tests/test_evaluation.py, tests/test_workflows.py, scripts/run_pipeline.py
The project already contains an (empty-of-your-code) package at src/air_quality/ and a matching test suite at tests/. You are not translating the notebook cell by cell: group what it does into three small modules, by responsibility rather than by the order the cells happen to appear in.
Read the test before you write the function
Each function below has a matching test in tests/. Read it first — the assertions tell you exactly what shape and properties the output should have, which is often clearer than a prose description. Implementing the function is then a matter of making its test pass.
src/air_quality/data.py
|
|
Info
Scrolling further down data.py, you will find two more functions behind a “Session 3” banner (columns_above_missing_threshold, drop_columns). They belong to a later, optional module — ignore them for this activity. The same pattern shows up again in evaluation.py: whenever a file has a “Session 3 — optional modules” section, its functions are out of scope here, and their tests always live in a separate tests/*_advanced.py file, never in the one this activity’s Testing Strategy tells you to run.
src/air_quality/features.py
|
|
src/air_quality/evaluation.py
|
|
Principles to follow
- One function, one responsibility — each function above does exactly one thing your notebook currently does inline.
- No notebook-global state — a function receives everything it needs as a parameter and returns its result; it must not silently depend on a variable defined three cells earlier.
- Not everything has to become a function. The manual train/test split (
df[df["city"] == TRAIN_CITY]) is simple enough to stay a plain expression, as you can see directly insiderun_baseline. Extracting it would add a layer of indirection without making anything clearer. Recognizing when not to extract is as much the point of this activity as extracting. - Test as you go, one module at a time, not everything at the end.
Testing Strategy
Run tests module by module as you complete each function — this is also how you will notice quickly if a function you have not implemented yet is blocking one you have:
|
|
Once all three pass, check that the whole pipeline works end to end, exactly as workflows.run_baseline assembles it:
|
|
Info
A test failing here is useful feedback, not a grade — it tells you exactly which behavior does not match yet. An unimplemented function returns None, so an early failure often looks like AttributeError: 'NoneType' object has no attribute ... — that error means “this function has no code yet,” not that something is broken. There is no cryptographic proof to generate or submit for this course anymore: your Git history is the evidence of your work.
Commit your work
You already introduced Conventional Commits to publish the Sales project in Session 1 — this is the moment to actually apply that discipline, not just remember it exists. Commit per module as you implement it, not as one pile at the end, so your history shows the transformation happening one reviewable step at a time:
|
|
Choose the type the same way you did in Session 1: refactor: for moving already-working logic out of the notebook, test: if you add a test for its own sake, fix: if implementing a function surfaces a bug the notebook was hiding.
Reflection
Files: reflection/session_2/module_3.md
Details
Question: Give at least three concrete reasons why a notebook alone is not enough here — what does moving this pipeline to src/air_quality/ actually solve that the notebook could not? What is a dataclass, and what does it save you from writing by hand for PipelineConfig? And what is the point of splitting responsibilities across data.py, features.py and evaluation.py instead of one long script?