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.
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 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.
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.
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:
- Version control — never lose a working state, ever again.
- Environments — make "it works on my machine" true on every machine.
- Tests — catch the silent, non-crashing bugs that ML is full of.
- 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.
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.
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.
You edit train.py to add a learning-rate schedule. Here is the entire loop, in four commands:
git status— Git saystrain.pyis modified. It sees the change but hasn't recorded it.git add train.py— you move that change into the : "this is part of my next snapshot."git commit -m "Add cosine LR schedule"— Git seals the snapshot and stores it forever with your message.git log --oneline— your commit is now on the list, with a short id likea3f1c9d.
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 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.
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?
- 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
.gitignoreand 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
.ipynbfiles 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.
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.
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.1rather thantorchis 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.
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.26— at least 1.26, because you use a feature introduced there.<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.
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.
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.
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 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.
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.
You can't test "is there leakage?" directly — that's a mood, not a claim. So convert it into something checkable:
- State the property. If the training data were computed honestly, it should depend only on the training data.
- Turn it into an experiment. Run the function twice with the same training set but a wildly different test set.
- 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.
- 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.
Why can this bug not be caught by looking at the loss curve?
Hint: What does leakage do to the numbers you see?
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 of breaking something, then after changes:
- — the chance that any single change breaks something. Even a careful engineer is not at zero; call it 2 in 100.
- — the chance one change is fine. The complement: everything that isn't a break.
- — the chance all changes are fine. The power means "multiply that chance by itself 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.
- — 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 , 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."
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.
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:]
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.
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.yamlit'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.ipynbcallfrom 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
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 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.
- 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. mainthat 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 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.
- 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 (
requirementsfor 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 , 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
mainthat always runs.
Practice — and how to make it stick
• 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.
- 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.gitignorefor data and checkpoints. You now have a project you cannot lose. - Pin it. Run
pip freeze > requirements.lockin that project, commit it, and write a one-line README section showing the command that reproduces your last result. - 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).
- 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.
- 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.
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.