Engineering Foundations for ML

The part of MLOps that gets skipped in most ML courses, and is the actual difference between a notebook and a system someone else can run, debug, and trust at 3am.

Why "It Works On My Machine" Isn't Good Enough

A model in a Jupyter notebook has implicit dependencies everywhere: cell execution order, global variables set three cells up, a specific package version installed by hand. None of that survives contact with a second person, a second machine, or six months of time passing. Everything below exists to remove that implicitness.

Cell execution order matters
Global variable set 3 cells up
Package version installed by hand
Works only on this one kernel session
None of this survives contact with a second person, a second machine, or six months of time passing.

Clean Code & Architecture

  • SOLID principles, applied to ML code specifically: a Model class shouldn't also handle data loading and API serialization (Single Responsibility) — split them so any one piece can change without breaking the others.
DataLoader
Model
ApiSerializer
A change to the API response format now touches only ApiSerializer -- DataLoader and Model are untouched, and can't accidentally break.
  • Modular architecture: separate data loading, preprocessing, model definition, training loop, and evaluation into distinct modules — the same separation of concerns that makes any software maintainable, just applied to a domain (ML) that often skips it under deadline pressure.
  • Configuration management: hyperparameters, file paths, and environment-specific values belong in config files (YAML/JSON) or environment variables — never hardcoded, so the same code runs in dev/staging/production by changing config, not code.
dev
config.yaml swap
staging
config.yaml swap
production
config.yaml swap
The same code runs in all three environments -- only the config file changes.
  • Dependency management: pin exact versions (pyproject.toml + a lockfile via uv or poetry) — an ML pipeline that silently picks up a newer NumPy/PyTorch and produces different numbers is a debugging nightmare that pinned dependencies prevent outright.
PackageResolved version
torch2.4.0
↳ numpy1.26.0
↳ typing-extensions4.9.0
Unpinned ranges resolve to *some* version that satisfies the range -- today, that happens to be these versions. Toggle "3 months later" to see what changes.
[project]
name = "fraud-model"
dependencies = [
    "torch==2.4.0",
    "numpy==1.26.4",
    "scikit-learn==1.5.1",
]
uv sync             # installs exactly what's in uv.lock -- no version resolution surprises
uv lock --upgrade   # a deliberate, reviewable step to bump versions, not an implicit side effect of installing
  • API design: if your model is served behind an API (see APIs & Model Serving), the contract (request/response schema, error format, versioning) is a design decision made deliberately, not whatever FastAPI happens to auto-generate from your first draft.

Testing an ML System

Testing ML code is genuinely harder than testing normal software, because "correct" isn't just "matches an exact expected value" — a model's output is expected to vary. The test types below cover different layers of that problem:

  • Unit tests: test individual functions in isolation — a data-cleaning function, a feature-computation function, a custom loss function's math.
  • Integration tests: test that pieces work together — does the full preprocessing pipeline correctly feed the model's expected input shape?
  • End-to-end tests: run the entire pipeline (data in, prediction out) on a small fixture dataset, checking it completes without error and produces reasonable output.
  • Regression tests: does a code change accidentally degrade a metric that used to pass? Run the model against a fixed benchmark dataset and assert performance hasn't dropped below a threshold.
  • Data validation tests: assert incoming data matches expected schema, ranges, and types before it reaches the model — see Data Engineering & Versioning for the pipeline stage this belongs to.
  • Model tests: beyond accuracy — does the model handle an empty input gracefully? Does it produce the same output for the same input (determinism, where expected)? Does a known edge case (e.g. a specific adversarial input) behave as expected?
  • API tests: does the serving endpoint return the correct schema, correct status codes, and handle malformed requests without crashing?
  • Load tests: does the serving endpoint hold up under realistic (or peak) concurrent request volume, and what's the latency distribution under load?
Unit
Integration
End-to-end
Regression
Data validation
Model
API
Load
Incoming data matches expected schema, ranges, and types before it reaches the model.

Tools

  • pytest: the standard Python test runner — fixtures, parametrized tests, and plugins cover nearly everything above.
  • unittest: Python's built-in test framework — less ergonomic than pytest, but zero extra dependencies, and you'll see it in older codebases.
  • mock: replace expensive or external dependencies (a real API call, a real database) with a fake stand-in during tests, so tests run fast and don't depend on external services being up.
  • Locust: a load-testing tool — define realistic user behavior in Python, and it simulates many concurrent users hitting your API, reporting latency/throughput/error rate under load.
  • Postman: manual and scripted API testing/exploration — useful for exploring and documenting an API's actual behavior before automating tests against it.
pytest
unittest
mock
Locust
Postman
The standard Python test runner -- fixtures, parametrized tests, and plugins cover nearly everything in the test-type list above.

A real parametrized unit test, and a Locust load test for the serving endpoint from APIs & Model Serving:

import pytest
from features import clean_transaction_amount

@pytest.mark.parametrize("raw, expected", [
    (" $1,200.50 ", 1200.50),
    ("0", 0.0),
    ("-42", None),   # negative amounts are invalid -- should be dropped, not coerced
])
def test_clean_transaction_amount(raw, expected):
    assert clean_transaction_amount(raw) == expected
from locust import HttpUser, task, between

class ModelApiUser(HttpUser):
    wait_time = between(0.1, 0.5)

    @task
    def predict(self):
        self.client.post("/v1/predict", json={"features": [5.1, 3.5, 1.4, 0.2]})
pytest tests/ -v
locust -f locustfile.py --host=http://localhost:8000 --users 100 --spawn-rate 10

The Standard You're Aiming For

A production-grade ML codebase should let a new engineer clone the repo, run one setup command, and have tests, linting, and a working local pipeline — no tribal knowledge required, no "ask Sarah how the data loads." Every practice above exists to make that true.

git clone
→
one setup command
→
tests pass
→
linting passes
→
local pipeline runs
No "ask Sarah how the data loads" step hides anywhere in this sequence -- every practice on this page exists to make that true.

Next: Data Engineering & Versioning — MLOps starts with data, and data needs the same engineering discipline as code.

Last updated Oct 23, 2026Edit this pageReport an issue