We have the language (07-01), the base libraries (07-02) and the tool map (07-03). What is missing is what turns all of that into a workshop in which several people can work today and a year from now: the environment. In modules 3 to 6 we wrote loose scripts, with a novamarket_ml.py file from which we imported functions, without ever saying which version of Python or scikit-learn was needed, where each thing was stored or how to check that it still worked. This lesson puts that in order: virtual environments and dependency management (venv, pip, requirements.txt, conda, uv/poetry) and why pinning versions is part of reproducibility, together with seeds; Jupyter with its virtues and its traps (hidden state); Colab and Kaggle as laboratories with free GPU; the IDEs (VS Code, PyCharm); Git applied to AI projects (what gets versioned and what does not); a recommended project structure that we will actually apply: we will refactor NovaMarket's code into a src/novamarket/ package with data.py, train.py and tests with pytest, and run it; and, to close, when a GPU or the cloud is needed and what Docker is. It matters because the project Marta and Diego will tackle in module 8 does not fit in a notebook: it needs this structure to survive the first change of person, machine or version.

Contents

  1. The problem: "it works on my machine"
  2. Virtual environments with venv and dependencies with pip
  3. requirements.txt, conda/mamba, uv and poetry
  4. Reproducibility: versions + seeds
  5. Jupyter Notebook and JupyterLab: cells, kernel and hidden state
  6. Google Colab and Kaggle Notebooks
  7. IDEs: VS Code and PyCharm
  8. Git for AI projects: what to version and what not
  9. Recommended project structure
  10. Code: refactoring NovaMarket into src/novamarket/ with tests
  11. GPU, CPU and cloud: when they are needed
  12. Docker as a reproducible environment
  13. Common Mistakes and Tips
  14. Exercises
  15. Conclusion

  1. The problem: "it works on my machine"

Marta trains the returns predictor on her laptop, gets AUC 0.844 and passes the script to a colleague. For him it fails on import, or gives a different number, or the joblib does not load. The causes are always the same: another version of Python or of a library, another execution order of the notebook cells, another seed or none, different data in a file with the same name. The development environment is the set of decisions that removes those causes one by one: isolate dependencies, pin versions, fix seeds, organise the code, version it, test it and, when necessary, package the whole machine.

  1. Virtual environments with venv and dependencies with pip

A virtual environment is a folder with its own Python interpreter (linked to the system one) and its own collection of packages, isolated from the rest. Each project has its own, so project A can use one version of pandas and project B another without stepping on each other. venv ships with Python:

# In the project folder (Linux/macOS; on Windows: .venv\Scripts\activate)
python3 -m venv .venv                 # creates the .venv folder with a clean Python
source .venv/bin/activate             # activate: from here on, "python" and "pip" are the environment's
python -c "import sys; print(sys.executable)"   # .../.venv/bin/python
pip install numpy pandas scikit-learn matplotlib torch jupyterlab pytest   # installs ONLY in the environment
pip list                              # what is installed and in which version
deactivate                            # back to the system Python

pip installs packages from PyPI (Python's public index) resolving dependencies: asking for scikit-learn brings in NumPy, SciPy, joblib and threadpoolctl. The golden rule: never install libraries in the system Python; always in a project environment. The course environment is exactly this: a venv with NumPy, pandas, scikit-learn, Matplotlib, PyTorch (CPU) and, from this lesson on, pytest.

  1. requirements.txt, conda/mamba, uv and poetry

So that someone else (or you a year from now) can recreate the environment, the list of packages with versions is written in a file:

# requirements.txt for the NovaMarket project (the versions are those of the course environment at the time of writing;
# in your project, the ones you use)
numpy==2.5.2
pandas==3.0.5
scikit-learn==1.9.0
matplotlib==3.11.1
torch==2.13.0
joblib==1.5.3
jupyterlab
pytest
pip install -r requirements.txt       # installs exactly that
pip freeze > requirements-lock.txt    # dumps EVERYTHING installed with its exact version (dependencies included)

Usual convention: a requirements.txt with what you decide (direct packages, pinned with == where it matters) and a lock file generated with freeze to reproduce to the bit. Alternatives you will see in teams:

Tool What it adds File When
venv + pip Nothing: the standard, nothing to install requirements.txt By default; enough for almost everything
conda / mamba Also manages Python and non-Python libraries (CUDA, compilers, GDAL); environments by name; mamba is fast conda environment.yml Data science with native dependencies, GPU, Windows
uv Very fast installer and manager (written in Rust), creates environments, locks versions, manages Python versions pyproject.toml + uv.lock New projects that want speed and rigorous locking
poetry Dependency management and packaging with locking pyproject.toml + poetry.lock Libraries and projects that get published
pyenv Only installs and switches Python versions When you need several Python versions

An equivalent conda environment.yml, illustrative:

name: novamarket
channels: [conda-forge]
dependencies:
  - python=3.12
  - numpy
  - pandas
  - scikit-learn
  - matplotlib
  - pytorch-cpu
  - jupyterlab
  - pytest

conda env create -f environment.yml and conda activate novamarket. Choose one tool per project and do not mix them.

  1. Reproducibility: versions + seeds

A result is reproducible if someone else gets the same number. Two things are needed that we have kept separate in the course and now bring together:

  • Seeds: np.random.default_rng(42), random_state=42 in scikit-learn, torch.manual_seed(42), and seeded shuffle in train_test_split and DataLoader. Without them, the 3,000 orders, the split and the network initialisation change on every run. (On GPU, some operations are non-deterministic even if you fix the seed; PyTorch documents how to force it at the cost of speed.)
  • Versions: the same seed on another version of NumPy or scikit-learn can give another sequence or another numerical result, and a joblib saved with one version may not load with the next (07-01, 07-03). Hence the requirements.txt with == and hence, in section 10, the training script will save next to the model a JSON with the Python and scikit-learn versions it was trained with.

Reproducible = versioned code + identified data + pinned versions + fixed seeds. All four; with three, the number changes.

  1. Jupyter Notebook and JupyterLab: cells, kernel and hidden state

Jupyter is the interactive environment par excellence of data science: a document (.ipynb) with code and text (Markdown) cells, executed one by one against a kernel (a live Python process that holds the variables). JupyterLab is the modern interface with tabs, file explorer and terminal. It is launched with jupyter lab from the activated environment, and opens the browser. Its virtues are obvious: you see the head() and the plot next to the code, you iterate fast, you document while exploring. Its traps come from the same thing:

  • Hidden state: the variables live in the kernel, not in the document. If you run cell 5, then cell 2, then edit and run cell 5 again, the notebook you see does not correspond to the kernel's state. The typical case: you delete the cell that defined orders and everything keeps working... until you restart, and then NameError. Another: you change test_size in cell 3 but do not re-run cell 4, and the AUC you see is the old one.
  • Execution order: the numbers in brackets [7] to the left of each cell say the real order; if they do not go top to bottom, be suspicious.
  • Good practices that avoid almost every upset: (1) before accepting a result, "Restart Kernel and Run All" (restart and run everything top to bottom; if it fails, the notebook was lying); (2) imports and parameters in the first cells; (3) do not leave scratch cells lying around out of order: delete them or move them to an appendix; (4) functions that get repeated go to a .py module of the project (section 9) and are imported, not copied from notebook to notebook; (5) export to a script when the code matures: jupyter nbconvert --to script exploration.ipynb generates exploration.py, or better, copy by hand what is worth keeping into the module; (6) clear outputs before pushing to Git (.ipynb files with outputs store images and tables and make diffs unreadable): jupyter nbconvert --clear-output --inplace, or tools like nbstripout.
  • A notebook is for exploring and telling; the code that runs every day (training, serving) goes in modules and scripts. That is the border we will draw in section 10.

  1. Google Colab and Kaggle Notebooks

When you have no GPU, or want to share a notebook without anyone installing anything, there are two free laboratories in the browser:

  • Google Colab: Jupyter notebooks hosted by Google, with Python and the usual libraries preinstalled, and access to a free GPU (and TPU) with session-time, hours-per-day and memory limits that change and that in the free version are not guaranteed; paid tiers extend the quota. The session is ephemeral: when it closes, whatever is on disk is lost, so data is uploaded in each session or read from Google Drive.
  • Kaggle Notebooks: similar, integrated with Kaggle's datasets and competitions, with a weekly quota of GPU hours and many public notebooks to learn from.
  • How to upload a CSV (description, not runnable here): in Colab, the side file panel has an upload button, or from code from google.colab import files; files.upload() opens a dialog; for large files, mount Drive with drive.mount('/content/drive') and read with pd.read_csv('/content/drive/MyDrive/orders.csv'). In Kaggle, "Add Data" adds a dataset (your own or a public one) that appears under /kaggle/input/.
  • Cautions: do not upload personal data of real customers to an external service without the 02-04 assessment; pin versions because the preinstalled environment changes; and keep the notebook and the models outside the session (Drive, Git) because they disappear.

  1. IDEs: VS Code and PyCharm

An editor with Python support multiplies productivity compared with the notebook for module code:

  • Visual Studio Code with the Python and Jupyter extensions: autocompletion, code navigation, formatting, running notebooks inside the editor and "cells" in .py files with # %% (the best of both worlds: a versionable script that runs in chunks), a debugger with breakpoints (much better than scattering print to see why the ColumnTransformer gives 22 columns instead of 21), integrated terminal, integrated Git, and selection of the virtual environment's interpreter (Python: Select Interpreter.venv).
  • PyCharm: the JetBrains IDE, more "all included" (the Community edition is free; Professional adds scientific and notebook support). Excellent refactoring and debugging.
  • Others: Spyder (MATLAB/RStudio-style interface, with conda), Positron, or any editor with a language server. The choice is personal; what is not optional is using the project environment's interpreter and having a debugger and tests at hand.

  1. Git for AI projects: what to version and what not

Git stores the history of the code and lets several people work without stepping on each other; GitHub/GitLab host it. In an AI project there is a peculiarity: data and models are large, change for reasons different from the code and are sometimes confidential. The rule:

Versioned in Git Not versioned in Git (ignored with .gitignore)
Code (src/, tests/, scripts) Raw and processed data (data/)
Dependency files (requirements.txt, pyproject.toml) Large trained models (models/*.joblib, *.pt)
Notebooks without outputs or with light outputs Virtual environments (.venv/), caches (__pycache__/, .ipynb_checkpoints/)
Small metadata: metrics in JSON, configuration Secrets: API keys, passwords (.env)
README.md, documentation Generated files that are recreated with the code

For data and models, DVC (Data Version Control: it stores in Git a small file with the hash of the data and the data itself in external storage, S3, Drive, a network drive) or Git LFS (Large File Storage) are used. We only mention them: for NovaMarket we will start with .gitignore and a shared folder, and DVC when it is needed.

Minimal flow:

git init                                  # once, at the project root
git add .                                 # stages the files (ignored ones do not go in)
git commit -m "Project structure and data module"
git status                                # what has changed
git log --oneline                         # history
git checkout -b boosting-experiment       # one branch per experiment or feature

  1. Recommended project structure

From the loose scripts of modules 3-6 to a project that someone else understands in a minute. Structure for NovaMarket:

flowchart TB
    R["novamarket_ai/"] --> RM["README.md<br/>what it is, how to install, how to run"]
    R --> RQ["requirements.txt / pyproject.toml<br/>dependencies with versions"]
    R --> GI[".gitignore"]
    R --> D["data/<br/>raw/ (as they arrive)<br/>processed/ (clean) — NOT in Git"]
    R --> NB["notebooks/<br/>01_orders_exploration.ipynb<br/>02_returns_model.ipynb"]
    R --> S["src/novamarket/<br/>__init__.py<br/>data.py<br/>train.py<br/>predict.py"]
    R --> M["models/<br/>returns_model.joblib (ignored)<br/>returns_model.json (metrics, versions)"]
    R --> T["tests/<br/>test_data.py<br/>test_train.py"]

Principles: data/raw/ is untouchable (what arrives is stored as is; everything else is recomputed with code); notebooks are numbered and tell a story; reusable code lives in the package src/novamarket/ (the src/ folder stops Python accidentally importing an uninstalled copy); models/ stores artefacts with their metrics next to them; tests/ checks what must not break; and README.md says how to get started. There are templates (Cookiecutter Data Science is the best known) that generate this with more folders (reports/, configs/, docs/); start small and add when you need it.

  1. Code: refactoring NovaMarket into src/novamarket/ with tests

Let's actually build it. We create the structure, move the functions of novamarket_ml.py (modules 4-5) to src/novamarket/data.py, write train.py as a runnable script, a minimal test with pytest and run it all.

1. Create the structure (in a terminal, with the environment activated):

mkdir -p novamarket_ai/{data/raw,data/processed,notebooks,src/novamarket,models,tests}
cd novamarket_ai
touch data/raw/.gitkeep data/processed/.gitkeep models/.gitkeep   # so that Git keeps empty folders

2. src/novamarket/__init__.py (turns the folder into a package):

"""novamarket package: reusable code for NovaMarket's AI project."""
__version__ = "0.1.0"

3. src/novamarket/data.py: the functions you already know, unchanged, with a header. We reproduce the first one in full; dirty_orders, prepare_orders and build_preprocessing (with their lists NUMERIC, BINARY, NOMINAL, ORDINAL) are copied as they are from 04-03:

"""NovaMarket data: synthetic generation (module 4), cleaning (04-03) and preprocessing (ColumnTransformer)."""
import numpy as np
import pandas as pd

def generate_orders_ml(n=3000, seed=42):
    """Generates n fictional NovaMarket orders with the label 'returned' (1 = returned)."""
    rng = np.random.default_rng(seed)
    amount = np.round(rng.gamma(shape=2.0, scale=60.0, size=n) + 5, 2)
    num_items = rng.integers(1, 6, size=n)
    delivery_days = rng.integers(1, 8, size=n)
    new_customer = rng.random(n) < 0.30
    category = rng.choice(["electronics", "home", "computing", "accessories"],
                          size=n, p=[0.35, 0.30, 0.20, 0.15])
    zone = rng.choice(["A", "B", "C"], size=n, p=[0.40, 0.35, 0.25])
    z = (-3.4 + 0.010 * (amount - 100) + 1.6 * new_customer + 0.35 * (delivery_days - 4)
         + 1.0 * (category == "electronics") + 0.5 * (category == "computing")
         - 0.2 * (num_items - 2) + 1.2 * new_customer * (amount - 100) / 100)
    prob = 1 / (1 + np.exp(-z))
    returned = (rng.random(n) < prob).astype(int)
    return pd.DataFrame({"amount": amount, "num_items": num_items, "delivery_days": delivery_days,
                         "new_customer": new_customer.astype(int), "category": category,
                         "postcode_zone": zone, "returned": returned})

def generate_weekly_demand(weeks=104, seed=42): ...          # identical to 04-02
def dirty_orders(orders, seed=42): ...                        # identical to 04-03
def prepare_orders(dirty): ...                                # identical to 04-03: returns X, y
def build_preprocessing(): ...                                # identical to 04-03: ColumnTransformer -> 21 columns

4. src/novamarket/train.py: importable functions and a runnable script, separated by if __name__ == "__main__":. Next to the model it saves a JSON with metric, parameters and versions (section 4):

"""Trains the returns predictor (04-03 pipeline + logistic regression) and saves it.

Usage:  python -m novamarket.train --n 3000 --seed 42 --output models/returns_model.joblib
"""
import argparse, json, platform
from pathlib import Path

import joblib, sklearn
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import roc_auc_score
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline

from novamarket.data import (build_preprocessing, dirty_orders,
                             generate_orders_ml, prepare_orders)


def train(n=3000, seed=42):
    """Generates the data, trains and returns (trained pipeline, test AUC)."""
    X, y = prepare_orders(dirty_orders(generate_orders_ml(n, seed), seed))
    Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.25, random_state=seed, stratify=y)
    pipe = Pipeline([("prep", build_preprocessing()),
                     ("model", LogisticRegression(max_iter=1000))]).fit(Xtr, ytr)
    auc = roc_auc_score(yte, pipe.predict_proba(Xte)[:, 1])
    return pipe, auc


def save(pipe, auc, path, n, seed):
    """Saves the model and, next to it, a JSON with metric, parameters and versions (reproducibility)."""
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    joblib.dump(pipe, path)
    meta = {"test_auc": round(float(auc), 4), "n": n, "seed": seed,
            "python": platform.python_version(), "scikit_learn": sklearn.__version__}
    path.with_suffix(".json").write_text(json.dumps(meta, indent=2))
    return meta


if __name__ == "__main__":                       # runs only when the file is launched as a script
    parser = argparse.ArgumentParser(description="Trains NovaMarket's returns predictor")
    parser.add_argument("--n", type=int, default=3000)
    parser.add_argument("--seed", type=int, default=42)
    parser.add_argument("--output", default="models/returns_model.joblib")
    args = parser.parse_args()
    pipe, auc = train(args.n, args.seed)
    meta = save(pipe, auc, args.output, args.n, args.seed)
    print(f"Test AUC: {auc:.3f}  ->  {args.output}")
    print(meta)

if __name__ == "__main__": is the line that makes both things possible: when you run the file, __name__ is "__main__" and the block runs; when another module (or a test) does from novamarket.train import train, __name__ is "novamarket.train" and the block does not run. argparse turns --n 3000 into args.n, with default values and --help for free.

5. pyproject.toml: the standard project metadata file; here it also tells pytest where the code is:

[project]
name = "novamarket"
version = "0.1.0"
description = "NovaMarket AI project (AI Fundamentals course)"
requires-python = ">=3.10"

[build-system]
requires = ["setuptools>=64"]
build-backend = "setuptools.build_meta"

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

pip install -e . (editable install) registers the package in the environment pointing at src/, so that import novamarket works from any folder and changes to the code are seen without reinstalling. Alternative without installing: PYTHONPATH=src python -m novamarket.train.

6. tests/test_data.py and tests/test_train.py: pytest discovers test_* functions and checks asserts. We test what must not break: reproducibility, shape, the 21 columns, absence of NaN, a minimum AUC and that saving writes both files:

# tests/test_data.py
import numpy as np
from novamarket.data import generate_orders_ml, dirty_orders, prepare_orders, build_preprocessing

def test_generation_reproducible():
    a = generate_orders_ml(500, seed=42)
    b = generate_orders_ml(500, seed=42)
    assert a.equals(b)                                   # same seed -> same data
    assert not a.equals(generate_orders_ml(500, seed=7))

def test_shape_and_columns():
    orders = generate_orders_ml(3000, seed=42)
    assert orders.shape == (3000, 7)
    assert set(orders.columns) >= {"amount", "category", "returned"}
    assert set(orders["returned"].unique()) == {0, 1}
    assert 0.10 < orders["returned"].mean() < 0.25       # plausible return rate (~16 %)

def test_preprocessing_produces_21_columns():
    X, y = prepare_orders(dirty_orders(generate_orders_ml(1000, 42), 42))
    matrix = build_preprocessing().fit_transform(X)
    assert matrix.shape == (len(X), 21)                  # the 21 columns of 04-03
    assert not np.isnan(matrix).any()                    # no NaN after imputing
    assert len(X) == len(y)
# tests/test_train.py
from novamarket.train import train, save

def test_train_reasonable_auc(tmp_path):                 # tmp_path: temporary folder that pytest creates and deletes
    pipe, auc = train(n=1500, seed=42)
    assert auc > 0.75                                    # minimum quality threshold
    meta = save(pipe, auc, tmp_path / "m.joblib", 1500, 42)
    assert (tmp_path / "m.joblib").exists() and (tmp_path / "m.json").exists()
    assert meta["test_auc"] == round(auc, 4)

7. requirements.txt, .gitignore and README.md as in sections 3, 8 and 9 (the .gitignore ignores .venv/, __pycache__/, .ipynb_checkpoints/, data/raw/* and data/processed/* except the .gitkeep files, models/*.joblib, models/*.pt and .env).

8. Run. Tests, training and Git check, with the real outputs:

$ python -m pytest -v
tests/test_data.py::test_generation_reproducible PASSED                  [ 25%]
tests/test_data.py::test_shape_and_columns PASSED                        [ 50%]
tests/test_data.py::test_preprocessing_produces_21_columns PASSED        [ 75%]
tests/test_train.py::test_train_reasonable_auc PASSED                    [100%]
============================== 4 passed in 0.92s ===============================

$ pip install -e .
Successfully installed novamarket-0.1.0

$ python -m novamarket.train --n 3000 --seed 42
Test AUC: 0.844  ->  models/returns_model.joblib
{'test_auc': 0.8444, 'n': 3000, 'seed': 42, 'python': '3.13.5', 'scikit_learn': '1.9.0'}

$ git init && git add -A && git status --short
A  .gitignore
A  README.md
A  data/processed/.gitkeep
A  data/raw/.gitkeep
A  models/.gitkeep
A  models/returns_model.json               <- the small JSON YES; the .joblib does NOT appear (ignored)
A  pyproject.toml
A  requirements.txt
A  src/novamarket/__init__.py
A  src/novamarket/data.py
A  src/novamarket/train.py
A  tests/test_data.py
A  tests/test_train.py

Four tests in under a second, the same AUC 0.844 of 04-04 obtained with one command from the terminal, a JSON that says which versions it was trained with, and a repository into which the binary model does not go but its metrics do. This is the starting point of module 8: when in 08-01 we add predict.py (which loads models/returns_model.joblib as in 07-03) or change the model, the tests will say in a second whether something has broken.

  1. GPU, CPU and cloud: when they are needed

The whole course has run on CPU, and that is the answer for most of what NovaMarket does: tables of thousands or hundreds of thousands of rows with scikit-learn, the 497-parameter MLP, the rules, the optimisation. The GPU is needed when the computation is large repeated matrix multiplications: training CNNs on real images, fine-tuning a transformer, running local LLMs; there it speeds things up 10 to 100 times. Checking for it and using it in PyTorch:

import torch
print(torch.cuda.is_available())                             # False in the course environment (CPU PyTorch)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
net = net.to(device); xb = xb.to(device)                     # model and data must be in the same place

(On a Mac with Apple Silicon the equivalent is torch.backends.mps.is_available() and "mps".) Options, from lowest to highest cost: the laptop's CPU; Colab/Kaggle to try with a free GPU (section 6); a virtual machine with a GPU in the cloud (AWS, Azure, Google Cloud and specialised providers) paid by the hour and switched off when done; managed ML services from those providers; or your own GPU if usage is continuous. Diego's question ("is it needed?") is answered by measuring: if training on CPU takes minutes, no; if it takes days, yes, and even then it is better first to reduce data or model to iterate fast and use the GPU only for the final run.

  1. Docker as a reproducible environment

The last step of reproducibility is packaging the whole machine: operating system, Python, versioned libraries, code and start-up command, into an image that runs the same on Marta's laptop, on NovaMarket's server and in the cloud. That is Docker (or Podman). An illustrative Dockerfile to serve the predictor with the 07-03 API:

# Illustrative: image to serve NovaMarket's returns predictor
FROM python:3.12-slim                        # base: minimal Linux with Python
WORKDIR /app                                 # working folder inside the container
COPY requirements.txt .                      # dependencies first (cached if they do not change)
RUN pip install --no-cache-dir -r requirements.txt fastapi uvicorn
COPY src/ src/                               # the novamarket package
COPY models/returns_model.joblib models/
COPY serve.py .                              # the FastAPI API of 07-03
ENV PYTHONPATH=/app/src
EXPOSE 8000
CMD ["uvicorn", "serve:app", "--host", "0.0.0.0", "--port", "8000"]

docker build -t novamarket-returns . builds the image and docker run -p 8000:8000 novamarket-returns starts it; NovaMarket's website calls http://server:8000/return-risk. Docker does not replace venv in day-to-day development (it is heavier), but it is the standard way to deliver a service and to guarantee that "it works on my machine" means "on all of them". Kubernetes (07-01) orchestrates many containers; outside the scope of the course.

Common Mistakes and Tips

  • Installing everything in the system Python. Sooner or later two projects ask for incompatible versions and something in the system stops working. One environment per project, always.
  • requirements.txt without versions, or no requirements.txt. Six months later, pip install brings new versions and the joblib does not load or the number changes. Pin with == what matters and keep a freeze.
  • Notebooks that only work in the order they were run. "Restart Kernel and Run All" before accepting anything; functions to modules; outputs cleared before pushing to Git.
  • Data and models in Git. The repository swells until it is unusable and you may publish personal data. .gitignore from the first commit; DVC or external storage for the large stuff.
  • Seeds without versions or versions without seeds. Both, and written down next to the model (the JSON of section 10).
  • Tests that check the trivial and not the fragile. Test the shape of the matrix, the absence of NaN, reproducibility, a metric threshold; those are the failures that really happen when you touch prepare_orders.
  • Buying a GPU before measuring. Time it on CPU, shrink the problem, try on Colab; then decide.
  • Absolute paths in the code (/home/marta/orders.csv). Paths relative to the project root or configurable by argument; pathlib.Path instead of concatenating strings.

Exercises

Exercise 1. Add to the project a module src/novamarket/predict.py with a function load(path="models/returns_model.joblib") and another predict_risk(model, order: dict) -> float that builds a one-row DataFrame and returns the return probability, plus an if __name__ == "__main__": block that predicts for an example order. Write a test that trains with train(n=1500), saves to tmp_path, loads with load and checks that predict_risk returns a number between 0 and 1 equal to pipe.predict_proba on the same order.

Exercise 2. Simulate the hidden-state problem: in a notebook (or mentally, describing the cells), define in cell 1 threshold = 0.5, in cell 2 a function that uses threshold, run cell 3 which calls it; then change cell 1 to threshold = 0.3 without re-running it and run cell 3 again. Which threshold is applied? What does the notebook show? What happens when you do "Restart and Run All"? Propose two changes to the notebook's organisation that avoid the problem.

Exercise 3. Write the .gitignore, the requirements.txt (with the versions you use) and an illustrative Dockerfile for a project that serves the incident-diagnosis Bayesian network of 06-03 as an API. Justify what goes into Git and what does not, and why in this case the "model" (the conditional probability tables) probably should be versioned in Git, unlike returns_model.joblib.

Solutions

Solution 1.

# src/novamarket/predict.py
"""Loads the returns predictor and predicts the risk of an order."""
import joblib, pandas as pd

def load(path="models/returns_model.joblib"):
    return joblib.load(path)

def predict_risk(model, order):
    """order: dict with the 15 columns of prepare_orders. Returns the return probability."""
    X = pd.DataFrame([order])
    return float(model.predict_proba(X)[0, 1])

if __name__ == "__main__":
    model = load()
    example = {"amount": 72.99, "num_items": 4, "delivery_days": 6.0, "new_customer": 0,
               "category": "computing", "postcode_zone": "A", "payment_method": "card",
               "shipping_type": "standard", "weekday": 3, "month": 11, "weekend": 0,
               "days_since_start": 87, "amount_per_item": 18.25, "previous_orders": 3,
               "previous_return_rate": 0.0}
    print(f"return risk: {predict_risk(model, example):.3f}")
# tests/test_predict.py
from novamarket.train import train, save
from novamarket.predict import load, predict_risk
from novamarket.data import generate_orders_ml, dirty_orders, prepare_orders

def test_predict_matches_pipeline(tmp_path):
    pipe, auc = train(n=1500, seed=42)
    save(pipe, auc, tmp_path / "m.joblib", 1500, 42)
    model = load(tmp_path / "m.joblib")
    X, _ = prepare_orders(dirty_orders(generate_orders_ml(500, 3), 3))
    order = X.iloc[0].to_dict()
    p = predict_risk(model, order)
    assert 0.0 <= p <= 1.0
    assert abs(p - pipe.predict_proba(X.iloc[[0]])[0, 1]) < 1e-9

Run in the course environment: pytest passes 5 tests, and python -m novamarket.predict prints return risk: 0.048 (the same order as in 07-03). With this the project has the three pieces of the cycle (data, train, predict) tested.

Solution 2. 0.5 is applied: cell 1 was edited but not run, so the kernel's threshold variable is still 0.5; the notebook shows threshold = 0.3 in cell 1 and a result computed with 0.5, that is, it lies. On "Restart and Run All" the kernel is emptied, cell 1 runs with 0.3 and the result changes; if someone had copied the previous result into a report, it would be irreproducible. Two changes: (1) parameters and functions at the top, and the rule of re-running from the top after any parameter change (or simply "Run All Above" before the results cell); (2) move the function out to src/novamarket/ with threshold as an explicit argument (decide(prob, threshold=0.5)) and call it with the value visible in the cell, so that there is no global variable to depend on.

Solution 3. .gitignore: .venv/, __pycache__/, .ipynb_checkpoints/, .env, and data/raw/* (the real incident histories, confidential); src/novamarket/incidents_network.py is not ignored, nor is a configs/incidents_cpt.json with the probability tables. requirements.txt: numpy==..., pandas==..., fastapi, uvicorn, pytest (and pgmpy==... if the enumeration of 06-03 is replaced by pgmpy, 07-03). Dockerfile: the same as in section 12, copying src/, configs/ and serve_incidents.py, with CMD ["uvicorn", "serve_incidents:app", ...]. Why the "model" does go into Git: the conditional probability tables of the 06-03 network are a few dozen numbers written or reviewed by people (explicit knowledge, like the rules of 06-02), small, readable as text and with audit value (who changed the probability of "factory defect" and when?): exactly what Git does well. returns_model.joblib is a binary generated by code from data: it is regenerated with train.py, it is not read or reviewed line by line, and its place is an artefact store with the metrics-and-versions JSON in Git.

Conclusion

With this lesson Marta's workshop is set up. We have seen why "it works on my machine" is the enemy and how it is fought: virtual environments (venv) with one interpreter and one set of packages per project; pinned dependencies in requirements.txt (or environment.yml, uv, poetry) and the reproducibility equation, versions + seeds + code + identified data; Jupyter to explore and tell, with the discipline of restarting and running everything and of moving mature code to modules; Colab and Kaggle as laboratories with free GPU and ephemeral sessions; VS Code and PyCharm with a debugger and the environment's interpreter; Git with a clear border between what is versioned (code, dependencies, small metrics) and what is not (data, large models, secrets), and DVC on the horizon; a project structure (data/, notebooks/, src/novamarket/, models/, tests/) that we have actually built, with data.py, train.py runnable from the terminal (AUC 0.844 and a JSON with versions), a pyproject.toml and four pytest tests that pass in a second; and the infrastructure decisions: CPU unless proven otherwise, GPU and cloud when the computation demands it, Docker to deliver the service the same everywhere.

And with this we close module 7. We started in 07-01 by choosing Python and SQL among the languages of AI; in 07-02 we learned to really handle NumPy, pandas and Matplotlib with NovaMarket's orders and demand; in 07-03 we placed each library in its task and saved and reloaded the returns predictor and the MLP; and in 07-04 we have organised it all into a reproducible project with environment, structure, Git and tests. Marta has the language, the tools and the workshop; Diego has a train.py that anyone can run and a repository that does not swell with data. What they do not have yet is a method to take a use case from idea to production: define the business problem, decide the metric, assemble the data, iterate on the model, validate it, deploy it and monitor it, learning from the projects that went well and from those that did not. That is module 8, Projects and Case Studies, which begins in 08-01, Developing an AI Project, with Marta and Diego tackling from start to finish, inside the novamarket_ai/ structure we have just created, the returns predictor project.

Fundamentals of Artificial Intelligence (AI)

Module 1: Introduction to Artificial Intelligence

Module 2: Basic Principles of AI

Module 3: Algorithms in AI

Module 4: Machine Learning

Module 5: Neural Networks and Deep Learning

Module 6: Logic and Expert Systems

Module 7: Tools and Programming Languages in AI

Module 8: Projects and Case Studies

Module 9: Exercises and Practice

Module 10: Additional Resources

© Copyright 2026. All rights reserved