Advanced20 min

Production Python for AI teams

Tooling, project structure, CI, and the habits that separate a demo from a system people depend on.

Why this matters in AI / ML / GenAI

The gap between a notebook that works and a service on call is process: formatting, linting, typing, tests, pinned dependencies, and reproducible runs. Interviewers probe this because it is what actually breaks in production.

The toolchain

Four tools cover most of it:

ToolPurpose
ruffLint and format, extremely fast, replaces flake8/isort/black
mypyStatic type checking against your hints
pytestTests, fixtures, parametrization
uv or poetryDependency resolution and lockfiles

Add pre-commit so these run before code is committed rather than after review. Configure them in pyproject.toml so the editor, CLI, and CI all read one source of truth. Arguments about formatting disappear when a tool decides.

Reproducibility

An ML result you cannot reproduce is an anecdote. Five things to control:

  1. Pin dependencies with a committed lockfile.
  2. Seed every random source: Python, NumPy, and the framework.
  3. Version data, not just code — DVC, or at minimum a dataset hash recorded with the run.
  4. Log the config for every run: hyperparameters, data version, git commit.
  5. Track experiments with MLflow or Weights & Biases so results are comparable months later.

For LLM systems add: prompt version, model version, and temperature. "The model got worse" is unanswerable unless you know which prompt and which model version produced last week's output.

Performance, when it matters

Profile before optimizing. cProfile for a whole script, time.perf_counter for a block. Optimizing the wrong function is the most common wasted week in ML engineering.

Usual wins in order: vectorize instead of looping, batch API and GPU calls, cache repeated work, then reach for concurrency. Rewriting in a faster language is almost never the first answer.

Memory: stream with generators rather than building giant lists, use float32 instead of float64, and delete large intermediates. Out-of-memory kills in a container are usually a data-loading pattern, not the model.

Habits that compound

  • Write the function signature and its test before the body.
  • Small pull requests. A 2000-line PR gets a rubber stamp, not a review.
  • Log the event and the context, never a bare "error occurred".
  • Delete dead code — the repository is not an archive; git already is one.
  • Write the README as you build: what it does, how to run it, how to test it.
  • Read the source of the libraries you depend on. It is ordinary Python, and the answer is usually there.

Copy-paste examples

Copy into your own editor, or load one into the compiler below and press Run.

pyproject.toml for tooling

One config file that ruff, mypy, and pytest all read.

# pyproject.toml
[project]
name = "ai-service"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "fastapi==0.115.5",
    "uvicorn==0.32.1",
    "pydantic==2.10.3",
    "numpy==2.1.3",
    "pandas==2.2.3",
]

[tool.ruff]
line-length = 100
target-version = "py311"

[tool.ruff.lint]
select = ["E", "F", "I", "B", "UP", "SIM"]

[tool.mypy]
python_version = "3.11"
warn_unused_ignores = true
disallow_untyped_defs = true
ignore_missing_imports = true

[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q --strict-markers"

GitHub Actions CI

Lint, type-check, and test on every push. Cheap insurance.

# .github/workflows/ci.yml
name: ci

on:
  push:
    branches: [main]
  pull_request:

jobs:
  quality:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"
          cache: pip
      - run: pip install -r requirements.txt -r requirements-dev.txt
      - run: ruff check .
      - run: ruff format --check .
      - run: mypy src
      - run: pytest --cov=src --cov-report=term-missing

Seed everything for reproducibility

Runs here with NumPy. Add the torch lines in a real training script.

import os
import random

import numpy as np

def set_seed(seed: int = 42) -> None:
    os.environ["PYTHONHASHSEED"] = str(seed)
    random.seed(seed)
    np.random.seed(seed)
    # torch.manual_seed(seed)
    # torch.cuda.manual_seed_all(seed)
    # torch.backends.cudnn.deterministic = True

set_seed(42)
first = [random.random(), float(np.random.rand())]

set_seed(42)
second = [random.random(), float(np.random.rand())]

print("run 1:", [round(v, 6) for v in first])
print("run 2:", [round(v, 6) for v in second])
print("reproducible:", first == second)

Profile before you optimize

Measure first. The slow part is rarely where you assume.

import time
from contextlib import contextmanager

@contextmanager
def timer(label):
    start = time.perf_counter()
    try:
        yield
    finally:
        print(f"{label:14s} {(time.perf_counter() - start) * 1000:7.2f} ms")

data = [f"document number {i}" for i in range(50_000)]

with timer("naive concat"):
    joined = ""
    for d in data[:5000]:
        joined += d

with timer("str.join"):
    joined = "".join(data[:5000])

with timer("list comp"):
    lengths = [len(d) for d in data]

with timer("sum builtin"):
    total = sum(len(d) for d in data)

print("total chars:", total)

Run manifest for every experiment

Log config, data version, and git commit — future you needs this.

import json
import hashlib
from datetime import datetime, timezone

def data_fingerprint(rows):
    payload = json.dumps(rows, sort_keys=True).encode()
    return hashlib.sha256(payload).hexdigest()[:12]

rows = [{"text": "sample a", "label": 1}, {"text": "sample b", "label": 0}]

manifest = {
    "run_id": "exp-2026-09-03-01",
    "timestamp": datetime.now(timezone.utc).isoformat(),
    "git_commit": "a1b2c3d",
    "config": {"model": "bert-base", "epochs": 3, "lr": 2e-5, "seed": 42},
    "data": {"n_rows": len(rows), "fingerprint": data_fingerprint(rows)},
    "metrics": {"accuracy": 0.913, "f1": 0.897},
}
print(json.dumps(manifest, indent=2))

Score your own project

Try it — in-browser Python

Set your real answers to True or False and see where the gaps are.

Output

Python runs in your browser. First run downloads the runtime.

Press Run (or Ctrl+Enter) to execute.

CPython in WebAssembly. Stdlib works. NumPy and pandas load on demand. No input(), no GPU, no network installs.

Takeaways

  • ruff, mypy, pytest, and a lockfile in pre-commit and CI cover most quality problems.
  • Reproducibility needs pinned deps, seeds, versioned data, and a logged config per run.
  • Profile before optimizing; vectorize and batch before reaching for concurrency.