Knowledge BaseEngineering

Software Engineering for ML

Git, testing, environments, typing, and structuring code others can build on — taught from zero, then built up to the SWE bar the labs expect: runs you can reproduce, tests that catch the silent bugs unique to ML, and a project someone else can pick up without asking you a single question.

beginner#git#testing#tooling#swe

Start here — the code that isn't the model

You've written a script. It trains something, it prints a number, it works. Then one of these happens:

  • A week later you change one line to try an idea, the number gets worse, and you can't get the old number back.
  • A teammate runs your script and gets a different answer. Neither of you knows who is right.
  • Your model quietly trains on the test set for three days and nothing crashes, because nothing can crash — the code is doing exactly what you typed.

None of those are machine-learning problems. They're software problems, and they are the ones that actually eat your weeks.

The one-sentence version

The model is a small box in the middle of a much larger machine. Almost everything that determines whether your work is trustworthy — can you rerun it, can you change it safely, can anyone else use it — lives in the code around the model. Software engineering is the set of habits that keep that surrounding machine from collapsing on you.

That is not a metaphor someone invented for a lesson: a well-known Google paper on real production ML systems makes exactly this point, with a diagram in which the "ML code" is a small black box surrounded by a sprawl of data collection, configuration, serving, and monitoring infrastructure.

is often defined exactly that way: programming is writing the code; engineering is everything that keeps it alive afterwards.

Think of it like cooking dinner versus running a restaurant:

Cooking one meal, you can keep everything in your head — a pinch of this, taste, adjust. A restaurant can't work that way: recipes are written down with exact amounts, ingredients are labelled with dates, someone else can cook your dish on your day off, and if a plate goes out wrong you can trace which step failed. Your training script is the dinner. This page is how you turn it into a kitchen.

How to read this page

It starts from zero — no prior git, no prior testing. Each habit gets the plain-language version first, then a concrete example you can run right here in the browser, and only then the formal or advanced version behind the Depth switch at the top. Nothing here needs a GPU or a big project; every idea pays off on the first day of the smallest script.

There are four habits, and they build on each other:

  1. Version control — never lose a working state, ever again.
  2. Environments — make "it works on my machine" true on every machine.
  3. Tests — catch the silent, non-crashing bugs that ML is full of.
  4. Structure — arrange code so that changing it later is cheap.

1. Version control — a time machine for your work

Right now, your protection against breaking working code is probably a folder full of train_final.py, train_final2.py, train_final_ACTUALLY.py. That approach fails at the exact moment you need it: you know what the files are, but not why any of them changed, or which one produced the number in your notes.

Save points, with notes

A version-control system records a snapshot of your whole project every time you decide something is worth keeping, along with a note about why. You can jump back to any snapshot, compare any two, and — crucially — try a risky idea knowing that the last good state is untouchable. Fearlessness is the actual product. Everything else is bookkeeping.

Think of it like save points in a video game:

Before the boss fight, you save. Then you can try the reckless strategy, because failure costs you nothing — you reload. Programmers without version control play the whole game on one life, which is why they stop trying reckless strategies. That timidity, not the lost files, is the real cost.

The tool everyone uses is . Four words cover most of it:

  • A (repo) — the project, plus its whole history.
  • A — one snapshot, with a message.
  • A — a parallel line of work.
  • A — folding that work back in.
One commit, start to finish

You edit train.py to add a learning-rate schedule. Here is the entire loop, in four commands:

  1. git status — Git says train.py is modified. It sees the change but hasn't recorded it.
  2. git add train.py — you move that change into the : "this is part of my next snapshot."
  3. git commit -m "Add cosine LR schedule" — Git seals the snapshot and stores it forever with your message.
  4. git log --oneline — your commit is now on the list, with a short id like a3f1c9d.

Two days later the schedule turns out to hurt, and git revert a3f1c9d undoes exactly that change — not the eleven other things you did since.

The message matters more than beginners expect. "fix" tells future-you nothing. "Add cosine LR schedule; val loss 0.42 -> 0.39" turns your history into a lab notebook you can search.

The picture that makes branching click

A repository's history is not a straight line — it's a graph. Each commit points back at the commit it came from, so a branch is just a chain that split off, and a merge is a commit with two parents.

A repository history — commits, a branch, and a merge— interactive, drag & zoom
Loading chart…
Time runs left to right. The branch splits off from c2, collects two commits of its own while main keeps moving, and is folded back at c6 — a merge commit with two parents. Nothing on main was ever at risk while the experiment ran.
Why branches change how you work

A branch costs nothing to create and nothing to throw away. So the expensive question — is this idea good enough to risk the working code? — disappears. You branch, you try it, and either you merge it or you delete the branch and lose nothing but the time you were going to spend anyway.

Feel it — how Git names a snapshot

Git identifies every piece of content by a hash: a short fingerprint computed from the bytes themselves. Change one character anywhere and the fingerprint changes completely. This cell computes a real Git object id — the number it prints is genuinely what git hash-object prints for that file.

Python · runs in your browser
What this does: Computes the exact object id Git gives a file's contents, using Git's real recipe (the word blob, a space, the byte length, a zero byte, then the content, all fed to SHA-1). Run it, then change one letter of v2 and watch the entire fingerprint change — that avalanche is what makes Git history tamper-evident and what makes commit ids trustworthy.
Try to recall

Your teammate says they changed one comment in an old commit but the commit id stayed the same. Why is that impossible?

Hint: Think about what goes into the hash.

You are about to try a risky refactor of your training loop. What is the cheapest way to protect the working version?

Three things never to commit
  • Data and model checkpoints. Git stores full snapshots of every version; a 2 GB checkpoint committed ten times is a 20 GB repository forever. Put them in .gitignore and track them with data versioning or object storage instead.
  • Secrets. API keys, tokens, credentials. Deleting them in a later commit does not remove them — the old commit still has them, and the fix is a painful history rewrite plus rotating the key.
  • Notebook outputs. Committed .ipynb files carry embedded images and execution counts, so every run looks like a huge diff and reviews become unreadable.

2. Environments — making "it works on my machine" true everywhere

Your code doesn't run on Python. It runs on Python plus a specific set of libraries at specific versions, on a specific operating system. Change any of those and the same script can produce a different number, or no number at all.

Think of it like a recipe that says a splash of milk:

Written for yourself, "a splash" is fine — your hand knows the amount. Handed to someone else, it's useless, and a year later it's useless to you too. A dependency list is the same recipe written with exact quantities and brands, so anyone can produce the same dish.

are managed with two ideas:

  • A — one project, one sandbox. Without it, upgrading a library for project A silently breaks project B.
  • — writing versions down, exactly.
The version you did not write down is the version you will lose

A result you cannot reproduce is, scientifically, not a result. The most common reason a six-month-old script won't reproduce its own number is not exotic — a library upgraded underneath it and changed a default. Writing the versions down is a ten-second habit that protects months of work.

Reading a version constraint

Library versions follow . The statement numpy==1.26.4 decodes as:

  • 1 — the MAJOR number. It increases only when a release makes backward-incompatible changes to the public API; code written for major version 1 may simply break on version 2.
  • 26 — the MINOR number. It increases when new features are added in a backward-compatible way. Upgrading should not break you; your existing calls keep working.
  • 4 — the PATCH number. It increases for backward-compatible bug fixes only. This is the safest upgrade there is.
  • The dots are just separators, and each part is compared as a number, not text — which is why version 1.26 comes after 1.9, even though the string "1.9" looks bigger alphabetically.
  • Why it matters for ML: pinning torch==2.3.1 rather than torch is the difference between a paper's code running in two years and it not running at all. Major-version bumps are where defaults change silently — the exact class of change that alters your loss curve without raising an error.
What a range constraint actually allows

A requirements file line like numpy>=1.26,<2.0 is a range, not a single version. Read it as two conditions joined by "and":

  1. >=1.26 — at least 1.26, because you use a feature introduced there.
  2. <2.0 — but stop before the next major version, because 2.0 is allowed to break you.

So 1.26.0 and 1.29.7 both qualify; 1.25.2 is too old and 2.0.0 is off-limits. The pattern "any compatible upgrade, but never a major one" is the standard way to depend on a library you don't control.

Python · runs in your browser
What this does: Implements the version comparison a package installer does — turn a version string into a tuple of numbers, then check it against a lower and upper bound. Run it to see which numpy versions the constraint numpy>=1.26.0 with an upper bound of 2.0.0 accepts, and note that 2.0.0 is rejected precisely because a major bump is allowed to break your code.

The other half of reproducibility — randomness

Even with a perfect environment, ML code is deliberately random: initial weights, shuffling, dropout, augmentation. Unseeded randomness means two runs of identical code give different numbers — and then you cannot tell whether your change helped or you just got a luckier shuffle.

Python · runs in your browser
What this does: Shows what seeding buys you — two generators built with the same seed produce identical numbers, a different seed produces different ones. This is why an unseeded experiment cannot answer did my change help: the run-to-run noise is mixed in with the effect you are trying to measure.
The seeding habit that actually works

Seed everything (Python's random, NumPy, and your framework), log the seed with the results, and — this is the part people skip — run your important comparisons at three or more seeds. A single seeded run is reproducible but not necessarily true: the gap between two methods is only real if it survives the spread across seeds. Reproducible and reliable are different properties, and you want both.

Try to recall

Your training script is fully seeded and pinned, and a colleague reruns it and gets your exact number. Have you shown that your new method is better than the baseline?

Hint: Reproducible is not the same as significant.


3. Tests — the safety net that lets you move fast

Here is the thing that makes ML code unusually dangerous: most bugs don't crash. A wrong axis, a leaked statistic, a label that's off by one — the code runs, the loss goes down, and you get a number. It's just the wrong number, and nothing anywhere will tell you.

A test is a claim you have written down and can re-check for free

A test is a small piece of code that states something you believe — "splitting 100 examples gives back 100 examples," "the training data doesn't depend on the test set" — and fails loudly if it stops being true. You write it once; it re-checks every time, forever, on every future change. That's it. That's the whole idea.

Think of it like a smoke alarm, not a fire inspection:

You don't install a smoke alarm because you expect a fire tonight. You install it because the cost of not noticing is catastrophic and the cost of the alarm is ten minutes. Tests are the same trade: cheap to write once, and they run while you sleep. The point isn't that they find bugs today — it's that they notice the day your change breaks something you'd forgotten about.

An is the atom, and a is an assertion with a name and a setup.

The bug you cannot see, and the test that catches it

is the classic silent ML bug. Standardizing your features using the mean and standard deviation of the whole dataset feels harmless. It isn't: the test set's statistics have leaked into training, your reported score is optimistic, and nothing errors.

Turning a vague worry into a test you can run

You can't test "is there leakage?" directly — that's a mood, not a claim. So convert it into something checkable:

  1. State the property. If the training data were computed honestly, it should depend only on the training data.
  2. Turn it into an experiment. Run the function twice with the same training set but a wildly different test set.
  3. Predict the outcome. If the function is clean, the training output is identical both times. If the test set leaked in, the training output moves.
  4. Assert it. assert np.allclose(out_a, out_b) — with a message explaining what a failure means.

That's the whole craft of testing: take the property you actually care about, find an experiment whose outcome differs when the property is violated, and assert the outcome.

Python · runs in your browser
What this does: Two versions of the same standardize function — one leaks test-set statistics into the training data, one does not — checked by a single property test that changes only the test set and demands the training output stay identical. Watch the leaky version FAIL. Neither version crashes on its own; only the test tells them apart, which is exactly why silent ML bugs need tests rather than careful reading.
Try to recall

Why can this bug not be caught by looking at the loss curve?

Hint: What does leakage do to the numbers you see?

Python · runs in your browser
What this does: A real gradient check on a least-squares loss. It compares two analytic gradients — the correct one and a buggy one missing its factor of 2 — against a numerical estimate. The correct one matches to about 1e-11; the buggy one is off by 0.333, which is exactly (g - g/2)/(g + g/2) = 1/3. Note that the buggy gradient still points downhill, so training with it would run happily and just learn at half speed — a silent bug that only this test exposes.

Why a test suite pays for itself — the arithmetic

You might reasonably ask whether all this is worth it on a small project. It is, and you can see why with one line of probability. If each change you make has a probability pp of breaking something, then after nn changes:

P(at least one break)=1(1p)nP(\text{at least one break}) = 1 - (1 - p)^n
  • pp — the chance that any single change breaks something. Even a careful engineer is not at zero; call it 2 in 100.
  • 1p1 - p — the chance one change is fine. The complement: everything that isn't a break.
  • (1p)n(1 - p)^n — the chance all nn changes are fine. The power means "multiply that chance by itself nn times," which is valid when the changes are roughly independent. This is the term that collapses: repeatedly multiplying by a number below 1 drives it toward zero fast.
  • 1()1 - (\cdot) — flip it back around: "not all fine" is the same as "at least one broke." Computing it this way is far easier than adding up every way one, two, or seventeen changes could break.
  • Why it matters: safety per change is not the right thing to look at — safety compounds against you. At p=0.02p = 0.02, a hundred changes gives you an 87% chance that something is broken. The question is never "will I introduce a bug" but "how long until I notice."
The chance something is broken, as changes accumulate— interactive, drag & zoom
Loading chart…
The curve 1 - (1 - p)^n for two levels of per-change risk. Both reach near-certainty long before a project feels large — 100 changes is a couple of weeks of work. Tests do not lower p; they collapse the time between breaking something and finding out.

Which test is most likely to catch a genuine bug in a new training script, per minute spent writing it?


4. Types and shapes — making code say what it means

Python will happily let you pass a list where an array was expected, or a (4, 1) array where a (4,) array was expected. NumPy's broadcasting then quietly produces a result of a completely different shape — and the arithmetic works, so you get a number.

Python · runs in your browser
What this does: The most common silent bug in all of numerical ML. A predictions array of shape (4,) is subtracted from a labels array of shape (4, 1); broadcasting turns that into a 4x4 matrix of every pairwise difference, and the mean squared error comes out 0.355 instead of the correct 0.055 — over six times too large, with no error, no warning, nothing. Then see the one-line assertion that would have caught it.
In ML, the shape IS the type

Regular type errors (a string where a number goes) crash immediately and cost you five minutes. Shape errors don't crash — they broadcast — and cost you a day. So assert shapes at every boundary: when data comes in, when a batch leaves the loader, when a model returns. It's one line and it converts a whole category of silent bugs into loud ones.

do the same job for ordinary types, and they cost nothing at runtime:

def split(data: np.ndarray, frac: float = 0.8) -> tuple[np.ndarray, np.ndarray]:
    """Split data into (train, test) along the first axis.

    Args:
        data: array of shape (n_examples, ...).
        frac: fraction of examples that go to train, in (0, 1).

    Returns:
        (train, test), together containing every original example exactly once.
    """
    n = int(len(data) * frac)
    return data[:n], data[n:]
Try to recall

You add type hints to your whole training script. Which class of bug does that catch, and which does it miss entirely?

Hint: What does Python do with hints at runtime?


5. Structure — from one notebook to a project

Notebooks are excellent for exploring and terrible for keeping. Cells run out of order, state hides in memory, half the logic exists only in a cell someone deleted. The moment a piece of code is worth running twice, it should move into a file.

Think of it like a workbench versus a toolbox:

A workbench is where you make a mess on purpose — that's the point of it. But you don't leave the tools scattered on it; when you're done you put each one where it belongs so you can find it next time. Notebook to module is the same move, and takes about as long.

A layout that scales from a weekend project to a lab:

myproject/
├── pyproject.toml        # dependencies + project metadata
├── requirements.lock     # exact versions, generated — commit this
├── README.md             # what this is, how to run it, in that order
├── .gitignore            # data/, checkpoints/, __pycache__/, .env
├── configs/
│   └── baseline.yaml     # hyperparameters live here, not in the code
├── src/mypkg/
│   ├── data.py           # loading, splitting, preprocessing
│   ├── model.py          # architecture
│   └── train.py          # the training loop + a CLI entry point
├── tests/
│   ├── test_data.py      # the leakage and split tests from above
│   └── test_model.py     # shape tests, overfit-one-batch
└── notebooks/
    └── explore.ipynb     # exploration only; nothing depends on it

Four rules do most of the work:

  • Config, not constants. A learning rate buried on line 87 is invisible; in configs/baseline.yaml it's a knob, and the config file gets logged alongside the results so you know what produced them.
  • Functions, not scripts. Code inside a function can be imported, tested, and reused. Code at module top-level runs the instant anything imports it.
  • Notebooks import, never define. Let explore.ipynb call from mypkg.data import split. The moment a notebook defines something important, that thing exists in exactly one person's kernel.
  • A README that starts with the command. The first thing a reader needs is the line that runs it. Everything else is second.

6. Working with others — review, CI, and the loop that runs itself

Everything above is done alone. These last two habits are what make the work shared.

A is a change offered for review rather than pushed straight to main. A is what happens there, and its most underrated benefit isn't bug-catching: it's that a second person now knows how that code works.

is the automation. On every push, a machine checks out your branch and runs the checks:

on every push:
  1. install the locked dependencies        # environment reproduces?
  2. ruff check .                           # lint: unused imports, dead code, bugs
  3. ruff format --check .                  # format: consistent style, no debate
  4. mypy src/                              # types: contradictions before runtime
  5. pytest -q                              # tests: everything you have claimed
Why automation beats discipline

Every check in that list is something you could do by hand and sometimes will. CI's advantage isn't capability — it's that it never has a deadline, never assumes this change is too small to matter, and never forgets. Look back at the compounding-risk curve: you don't lower pp by trying harder, you shorten the time to detection by making detection automatic.

A and a round it out. The formatter's real value is social: nobody argues about line length in a review ever again, and diffs contain only real changes. Run both through a pre-commit hook so they fire before the commit rather than after the CI failure.

The mistakes that cost beginners the most time
  • A pull request with 40 changed files. Reviewers skim what they can't hold in their head, so big PRs get less scrutiny, not more. Small and frequent beats large and careful.
  • Tests that need the real dataset or a GPU. They stop running, and a test that doesn't run is worse than none — it grants false confidence.
  • Committing generated things — checkpoints, __pycache__, notebook outputs, .env.
  • main that doesn't run. If the default branch is ever broken, everyone stops trusting CI, and then it may as well not exist.
  • Fixing a bug without adding the test. The bug will come back. That's what regression tests are for, and the cheapest moment to write one is while you still remember the bug.

Your CI takes 45 minutes, so people push and go do something else, and broken code sits on main for hours. What is the highest-value fix?


Explain it yourself

Explain to a friend who writes Python scripts but has never used git or written a test: what problem does each of the four habits actually solve, and what specifically goes wrong without it? Then explain why an ML bug is more dangerous than an ordinary one. If you stall on the ML-bug part, that is the section to reread.

Recap — the key ideas
  • The model is a small box; almost everything determining whether your work is trustworthy lives in the code around it.
  • Version control — commits are snapshots named by a hash of their content, which is why history is tamper-evident. Branch freely, merge often, and never commit data, secrets, or notebook outputs.
  • Environments — pin your dependencies (requirements for intent, a lockfile for exactness), read version numbers as MAJOR.MINOR.PATCH, and seed every source of randomness. Reproducible is not the same as reliable: compare across several seeds.
  • Tests — most ML bugs don't crash, they flatter. Turn worries into properties you can assert: leakage checks, shape checks, overfit-one-batch, gradient checks. Risk compounds as 1(1p)n1-(1-p)^n, so what matters is time-to-detection.
  • Types and shapes — in ML, the shape is the type. One assert x.shape == ... at each boundary converts silent broadcasting bugs into loud ones.
  • Structure — config not constants, functions not scripts, notebooks import rather than define, and version the data as well as the code.
  • Review and CI — small pull requests, fast automated checks on every push, and a main that always runs.

Practice — and how to make it stick

Learn it the way that actually works


Retrieval practice: before scrolling back, try to name the four habits and the one failure each one prevents — pulling it from memory beats rereading it.
Spaced repetition: mark this topic complete to add it to your Review queue, so it resurfaces right before you'd forget.
Interleaving: mix these exercises with Python & NumPy work rather than doing them in a block — these habits only stick when practised on real code, not in isolation.

  1. Do it for real, today (20 minutes). Take your messiest existing script. git init, commit it as-is, then make three commits with messages that say why. Add a .gitignore for data and checkpoints. You now have a project you cannot lose.
  2. Pin it. Run pip freeze > requirements.lock in that project, commit it, and write a one-line README section showing the command that reproduces your last result.
  3. Write the two tests that matter. For any preprocessing function you have: a shape test and a leakage test (the property trick from above — change only the test set, assert the training output doesn't move).
  4. Break something on purpose. Introduce a subtle bug — swap an axis, drop a factor of 2 — and check whether any test you own catches it. Whatever slips through tells you exactly which test to write next.
  5. Overfit one batch. On any model you have, train on 8 examples until the loss approaches zero. If it can't, you've found a bug — and you found it in a minute instead of a week.

Try it right here — edit and run the code, and if you get stuck or hit an error, ask Ada on the right: she can see your code and terminal output.

Practice lab
Your task: The split function below has a real bug on the marked line — the two halves overlap, so one example ends up in both train and test. That is data leakage, and notice that the function raises no error at all. First run it and read which assertion fails and which passes. Then (1) fix split so train and test together contain every example exactly once, (2) add a test asserting the two halves are disjoint, and (3) add a test that 7 examples with a fraction of 0.5 still yields 7 examples in total. Ada can help — ask her for a hint rather than the answer.
editor
terminal
Press Run (⌘/Ctrl+Enter) to execute.
Ask Ada — she can read your terminal

Next, put these habits to work on real code: Python & NumPy for the arrays every test above asserts on, then Reproducing Results — which is this page applied to somebody else's paper.

Key papers