4. A Workspace Someone Else Can Rerun
Why "it worked on my machine" happens
A notebook that runs on one laptop and fails on another usually has one of four ordinary causes: a different Python version, library versions that drift, hidden notebook state left in the kernel, or data fetched from somewhere nobody wrote down. From this point the course starts making claims about models, and each claim assumes someone else could rerun the work and get the same answer.
A workspace that another person can rebuild
Each cause gets its own fix. uv, from Astral, manages the Python version, an isolated environment and pinned dependencies. uv init creates the project and its pyproject file. uv add numpy matplotlib scikit-learn records the dependencies, builds the private .venv and writes uv.lock, which stores exact versions, hashes and sources. Jupyter tools are added as dev dependencies, and uv run jupyter lab opens a notebook inside the project environment. That setup follows uv's guide to using Jupyter. Git tracks everything that describes and rebuilds the project. A .gitignore keeps out the environment, data files, checkpoints, caches and any .env secrets.
First contact with MNIST
The data loads through one documented route, scikit-learn's fetch_openml, as mnist_784 version 1 (ID 554). The arrays come back as 70,000 × 784 images and 70,000 labels. The labels are text, not numbers. The standard split keeps the first 60,000 images for training and the last 10,000 for testing. The first image is displayed next to its label, a five, as a check that the two agree. The class counts are uneven: ones are the most common at just under 7,000, and fives the least at just over 5,400. A text cell in the notebook records where the data came from.
Proving it reruns
"Restart and run all" catches hidden state. The stronger test is a clean clone followed by uv sync. A fixed random seed makes a single run repeatable. It does not guarantee the same results across library versions or hardware, and it does not show whether a result is typical. The milestone is a written learning-problem statement for MNIST. Its baseline always predicts "1" and scores about 11% on the test set. A useful result must clearly beat that baseline on the held-out test images, and training accuracy does not count. A repository that describes a clean clone is not proof that one happened, so the exercise is to actually do it and compare the outputs line by line.
uv 0.12 changes the project uv init creates
Since 0.12.0, uv init sets up a packaged project with a src layout by default. Adding --no-package skips that structure. The release also refuses MD5 digests and strictly enforces required hashes. Later patch releases added a check command and support for downloading Python 3.15 release candidates. Python 3.15 is still a release candidate, and the stable line is 3.14 (see Python's release versions).
Picture this. You send a friend your notebook. It prints a neat chart on your laptop. On theirs it stops at the third cell with an error you have never seen. You both look at the same code, and you both say the same sentence: it worked on my machine.
That sentence is the failure this chapter guards against. Today is also the first time the course runs code. Everything so far has been a map: what machine learning is, where it came from, and what signal each kind of learning gets. From here on, the course makes claims about models. This model reached a certain score. That feature helped. This change made things worse. Each of those claims rests on one quiet assumption, which is that someone could run the same thing again and get the same answer. A result nobody else can rerun is not a result yet. It is a story about something that happened once. So before we fit anything, we build a workspace that another person can rerun.
Building the workspace and checking it reruns
Start with why "it worked on my machine" happens. There are four common causes, and each one is ordinary.
The first is a different Python version. Python changes between releases. A feature that exists in one version can be missing or can behave a little differently in another. If you wrote code on a newer Python and your friend runs an older one, some lines may simply fail.
The second is library versions that drift. Your code leans on libraries like NumPy, which handles arrays of numbers, and scikit-learn, which provides machine-learning tools and datasets. These libraries release new versions often. If you install one today and your friend installs it next month, you may have different versions without either of you choosing that. Most of the time nothing breaks. Sometimes a default changes, a function gets renamed, or a number comes out slightly different, and no warning tells you which of these happened.
The third is hidden notebook state, and it catches beginners most often. A notebook such as Jupyter lets you run code in small blocks called cells, in any order you like. Behind the notebook sits a running Python process called the kernel, and it remembers every variable you have ever created. Say you define a variable in cell five, then delete that cell. The kernel still holds the variable. Your later cells keep working because of something that is no longer written anywhere. Save the notebook, send it to a friend, and their fresh kernel has never seen that variable. Your notebook worked only because of its history, and the history did not travel with the file.
The fourth is data from somewhere undocumented. You downloaded a file months ago from a link you no longer remember and maybe cleaned it by hand. Your friend has no way to get the same file. Even if the code is perfect, it runs on different data, so it gives different results.
Each part of the workspace answers one of these causes. An isolated environment with a fixed Python version handles the first. A file that pins exact library versions handles the second. Restarting and running everything from the top catches the third. Loading the data through one documented route, with its origin written down, handles the fourth. Version control holds all of it together so another person can fetch the exact same thing.
Now the tool. We will use uv, a tool from a company called Astral. It manages a project's Python version, its isolated environment and its pinned dependencies with one command-line program. The obvious alternative is what ships with Python itself: the venv module, which creates an environment, plus pip, which installs packages. That route works, and plenty of projects use it. We pick uv because it records exact versions for you as you go, without a separate step you might forget. It writes them to a lock file that includes versions, checksums called hashes, and where each package came from. And one command can rebuild the whole environment on another machine, even downloading the right Python if that machine lacks it. With venv and pip you have to remember to freeze your versions into a file yourself, and to recreate the environment by hand.
Why isolate at all? Why not install everything once, globally, for the whole computer? Because a global install is shared by every project you will ever have. Say one project needs an older version of a library and a newer project needs the latest. Only one version can sit in the global space, so one project breaks. Worse, the global space fills up with things you installed and forgot. You can no longer tell which packages your project really needs. An isolated environment is a private folder of Python and packages for this one project. What is in it is exactly what the project asked for, and nothing it asked for is shared with anything else.
Step one is getting uv and a Python. Install uv by following the instructions on Astral's documentation site for your operating system. To check it worked, open a terminal and type uv, space, dash dash version. Success means it prints a version number instead of "command not found." You do not need to install Python separately first. uv can fetch a managed Python for you when a project needs one.
Now create the project. In your terminal, go to a folder where you keep code, and type uv, space, init, space, and a project name. We will call it ml-project. The init command creates a new folder with that name and puts a few starting files in it. The most important is a file called pyproject, short for Python project. It is a plain text description of the project: its name, the Python version it expects, and the packages it depends on. Then move into the folder with cd ml-project. Success here means that listing the folder shows the pyproject file along with a couple of starter files.
Step two is adding and pinning dependencies. Type uv add numpy matplotlib scikit-learn. That single command does three jobs. It writes those three names into the pyproject file as things the project needs. It creates the project's private environment, in a hidden folder named dot venv, and installs the packages there. And it writes the lock file, uv dot lock, which records the exact version of every package that got installed. That includes the packages those three depend on, which you never asked for by name. NumPy handles the arrays. Matplotlib draws images and charts. Scikit-learn gives us a documented way to fetch MNIST.
The difference between the two files matters. The pyproject file says what you want, in loose terms: "I need NumPy." The lock file says exactly what you got: this version of NumPy, with this hash, from this source. When your friend rebuilds the project, the lock file makes their NumPy the same as yours, not just some version of NumPy. That is what pinning means.
Next, add the notebook tools. Type uv add, dash dash dev, jupyterlab ipykernel. The dash dash dev flag files these under development tools. That means you need them to work on the project, but the analysis itself does not depend on them. JupyterLab is the notebook interface. Ipykernel is the piece that lets a notebook run code using this project's Python. To see what got installed, type uv tree. It prints every package as a tree, showing which package pulled in which. Success means you see numpy, matplotlib, scikit-learn and jupyterlab in that tree, with their dependencies hanging beneath them.
Step three is opening the notebook. Type uv run jupyter lab. The uv run part is worth understanding. It means "run this command inside the project's environment." You do not have to switch the environment on first. uv makes sure the private Python and packages are the ones in use. JupyterLab opens in your web browser. Create a new notebook. Success means that when you type import numpy in the first cell and run it, you get no error. That tells you the notebook is using the project's environment, because that is where NumPy lives.
Step four is version control, and it comes before we write analysis code. Git is a program that records snapshots of your project's files over time, so you can see what changed and when, and so another person can copy the exact state you had. Back in the terminal, inside the project folder, type git init. That turns the folder into a git repository, which just means git starts watching it. Success is a short message saying it initialized an empty repository.
Now a file called dot gitignore. This plain text file lists what git should never track. Two kinds of things belong in it for this project. The first is the environment folder, dot venv. It is large, it is specific to your machine, and it can always be rebuilt from the lock file. Committing it would be like shipping your whole kitchen instead of the recipe. The second is data files, such as a data folder, or files ending in dot csv or dot parquet. Datasets can be huge. Some cannot legally be copied around. And for this project you do not need to store them, because the code fetches them from a documented source. It is also worth ignoring dot ipynb checkpoints, the backup copies Jupyter makes on its own, and Python's cache folders, named double underscore pycache. And ignore any dot env file, which is where people tend to put passwords and keys that must never end up in a shared repository. The rule behind every line is the same. Track what describes and rebuilds the project. Ignore what can be regenerated or should not be shared.
Then make the first commit. Type git add, space, dot. The dot means "every file in this folder that is not ignored." Then type git commit, dash m, and a short message in quotes, such as "Set up project environment." Afterward, type git status. Success means it reports that there is nothing to commit and the working tree is clean. It also helps to run git log once to see the commit listed. Check one thing: the pyproject file, the lock file and the gitignore should all be in that commit, and the dot venv folder should not.
With the workspace in place, we can do the small data task. MNIST should feel familiar. You have met it as scanned handwritten digits, each a 28-by-28 grayscale image, so 784 brightness numbers, with one label from zero to nine chosen by a person. Today we touch it for real.
We load it through one documented route: scikit-learn's function called fetch openml. It downloads a dataset from OpenML, a public site that hosts datasets under stable names and ID numbers. MNIST is stored there as mnist 784, dataset ID 554. In your notebook's first real cell, you import fetch openml from sklearn dot datasets. Then you call it with the name mnist 784, version one, and two settings: return X y equals True, and as frame equals False. The first setting asks for the images and labels as two separate things. The second asks for plain NumPy arrays instead of a pandas table. By convention, X holds the features and y holds the labels.
The first time you run that cell, it downloads the data. Scikit-learn then keeps a copy in a folder in your home directory called scikit learn data, so later runs read from disk instead of downloading again. That cache lives outside your project folder, so it never gets near your repository.
Now print the shape. Type X dot shape and y dot shape. The shape of an array is its size along each direction. X comes back as seventy thousand by seven hundred eighty-four: seventy thousand images, each flattened into one long row of 784 numbers. And y comes back as seventy thousand: one label per image. Notice that it is seventy thousand, not sixty thousand. This copy puts the training and test images together in one array. The standard split is the first sixty thousand for training and the last ten thousand for testing, and we will keep that split. Also notice that the labels come back as text, the character "5" rather than the number five. Small surprises like these are why you inspect data before trusting it.
Next, look at one example's raw values. Take the first row, X at index zero, and reshape it into 28 rows of 28 numbers so it looks like the grid it came from. Print that. You will see a block of mostly zeros, the dark background. In the middle there is a cluster of larger numbers, up to 255, where the pen stroke was. If you squint you can almost see a shape in the numbers, but the array itself has no idea about shape. It is a grid of brightness values.
Then show the same example as a picture. Using Matplotlib, pass that 28-by-28 grid to the function imshow, with a gray colour map, and display it. A handwritten digit appears. Print y at index zero next to it. For this dataset the first label is a five, and the picture should look like a five. Checking that the image and the label agree is a basic sanity check. If they disagreed, something in your loading would be wrong.
Now count the labels per class. NumPy has a function called unique. Given the labels and the setting return counts equals True, it hands back each distinct label and how many times it appears. Do this on the training portion, the first sixty thousand labels. You will get ten classes, zero through nine, each with roughly six thousand examples. The counts are not exactly equal. Ones are the most common, at a little under seven thousand. Fives are the least common, at a little over five thousand four hundred. Keep that spread in mind. It matters in a moment, when we pick a baseline.
Last in this task, record where the data came from. In a text cell at the top of the notebook, write it plainly. The data is MNIST, created by Yann LeCun, Corinna Cortes and Christopher Burges from two older handwriting collections at NIST, the United States National Institute of Standards and Technology. It was loaded from OpenML as mnist 784, version one, dataset ID 554, through scikit-learn's fetch openml. Anyone reading your notebook now knows exactly what data it used and how to get the same copy. That is the fourth cause of irreproducibility dealt with in four sentences.
Now the reproducibility check itself. In JupyterLab, open the Kernel menu and choose to restart the kernel and run all cells. This throws away everything the kernel remembered and runs your notebook once from the top, in order. If any cell was quietly leaning on a variable you deleted or on a cell you ran out of order, this is where it fails. Success means every cell runs without error and prints the same shapes, the same first image, the same label and the same counts you saw before. Make this a habit before every commit. When it passes, commit the notebook with a message like "Inspect MNIST shapes and label counts."
The stronger check is a clean clone into a fresh environment. Push the repository to a hosting service such as GitHub, or simply copy it to another place on your machine. Then clone it into a new folder with git clone and the repository's address, and move into that folder. Type uv sync. Sync reads the lock file, fetches the right Python if needed, and installs exactly the locked packages into a brand-new dot venv folder. Then run uv run jupyter lab, open the notebook, and restart and run all. The shapes, the pixel values of the first example, the label and the class counts should match your original exactly. If they do, your project carries everything needed to rebuild itself, and nothing depends on your machine's history.
Today's notebook has no randomness in it. Loading and counting are the same every time. But the next lessons will split data and train models, and those use random numbers. So here is what a random seed does. Computers make random-looking numbers with a formula that starts from a value called the seed. The same seed gives the same sequence of numbers every time. In NumPy you create a random generator with a fixed seed, for example by calling default rng with the number 42. Then any random shuffle or sample you draw from that generator comes out the same on every run. That is why you fix a seed: a random step becomes repeatable.
Here is what a seed cannot guarantee. It does not make results the same across different library versions, because a new version may change how it uses random numbers. That is one more reason to pin. It does not always make results match across different hardware, since some calculations, especially on graphics cards, can come out slightly different in the last few digits. And it does not tell you whether a result is typical. If a model scores well with seed 42, you have learned that seed 42 gives that score. You have not learned how much the score moves with other seeds. Later lessons run several seeds for exactly that reason. A seed makes one run repeatable. It does not make that run representative.
That brings us to the milestone. Add a text cell to the notebook, and commit it, containing a short learning-problem statement for MNIST digit classification. Here is one, which you can adjust into your own words.
The unit being predicted is one image of a single handwritten digit. The features are its 784 pixel brightness values, from zero to 255, read in a fixed order across the 28-by-28 grid. The label is the digit a person assigned to that image, one of ten classes from zero to nine. The test split is the standard one: the model learns only from the first sixty thousand images, and the last ten thousand are held out. It never trains on them, and we score it on them only after all choices are made. The baseline to beat is the simplest honest guess. Always predict the most common digit in the training set, which is one. Because ones make up roughly eleven percent of the training images, this baseline scores about eleven percent accuracy on the test set. Anything that learns nothing about shape still gets that much.
Then comes the part that makes the statement useful: what counts as a useful result. A useful result is an accuracy on the ten thousand held-out test images that clearly beats that baseline of about eleven percent, reported next to the baseline so the gap is visible. Accuracy on the training images does not count. A model can memorise sixty thousand pictures and score perfectly on them while doing poorly on digits it has never seen. The number that matters is the one on images the model never learned from, because that is the situation it will face in use.
Notice also what this setup does not prove. The repository now shows that the project can be rerun. It does not show that you ran it, and it does not show that it reruns anywhere other than where you checked. The course describing a clean clone is not the same as a clean clone having happened. So here is the exercise. Ask a friend to clone your repository, run uv sync, and restart and run all. If no friend is around, do it yourself in a second folder. Then compare the two outputs line by line: both shapes, the first example's printed values, its label, and all ten class counts. If everything matches, you have evidence, not just intent. If something differs, find out which of the four causes explains it. That diagnosis teaches more than a clean match does.
News: a new uv release changes what uv init builds
Since this chapter leans on uv, the news concerns uv itself. Version 0.12.0 came out in early August 2026. It is generally available, meaning an ordinary stable release you can install, not a preview. Later fixes followed: 0.12.4 on August thirteenth and 0.12.9 on the first of September.
The change a newcomer will notice first is to uv init. In 0.12.0, init by default sets the project up as a package with a src layout. That means the code goes inside a folder named src, and the project gets its own build step, using Astral's builder called uv build, so it can be installed like a library. For a notebook-and-analysis project like ours, that extra structure is optional. You can skip it by adding dash dash no-package when you run init. Either way, the environment, the lock file and uv sync work the way this chapter described. The difference is only how the starting folder is arranged. If your project folder looked a bit different from what you expected when you ran init, this is the likely reason.
The same release also tightened security. It refuses MD5 digests, an old and weak kind of file checksum. It strictly enforces the setting that requires every package to match a recorded hash. That ties straight back to the lock file you just made. Those hashes are how uv confirms that the package your friend downloads is byte for byte the one you locked, and this release makes that check less forgiving.
Version 0.12.4 added a command called check. And 0.12.9 lets uv download release candidates of Python 3.15. Keep the announcement apart from what you can actually use here. Python 3.15 is still a release candidate, a near-final test version, aimed at a full release on the first of October 2026. The current stable line is 3.14, whose latest patch, 3.14.7, came out on August fifth. uv being able to fetch 3.15 is not a reason to build coursework on it before it is final.
One check to try in your new workspace: type uv, space, dash dash version. If it shows 0.12 or later, run uv init in a scratch folder, once plain and once with dash dash no-package, and compare the two folders. You will see the src layout change for yourself. Then look at your real project's lock file and find a hash line. That line is what makes your friend's rebuild the same as yours.
