PyTorch training¶
In this example, we will use o‑o to train a PyTorch model for classification of hand written digits from the MNIST dataset. The example will build a pipeline consisting of three steps, dataset preparation, model training, and model evaluation, all easily run with cloud compute using o‑o.
Before getting started, be sure to complete the o‑o installation instructions.
uv project setup¶
This example uses uv to manage the Python project and its dependencies. Refer to uv's installation instructions and then create a new uv project:
$ uv init pytorch-example
Initialized project `pytorch-example` at `/home/user/pytorch-example`
$ cd pytorch-example
Next, add the required Python dependencies using uv add. We will need torch
and torchvision, as well as typer for easy
command line argument parsing, and
progressbar2 for nice
looking progress bars:
$ uv add progressbar2 torch torchvision typer
...
+ rich==15.0.0
+ setuptools==84.0.0
+ shellingham==1.5.4
+ sympy==1.14.0
+ torch==2.14.0
+ torchvision==0.29.0
+ triton==3.8.0
+ typer==0.27.2
+ typing-extensions==4.16.0
o‑o configuration¶
Ok, now create a .ooconfig file with two environments cpu, and gpu.
cpu is a simple 4 core machine and gpu is a larger machine with a NVIDIA L4
GPU attached. Both environments use a docker image with uv pre-installed:
project: pytorch-example
sourcecode: true
environments:
- name: cpu
provider: scaleway
image: ghcr.io/astral-sh/uv:debian
machinetype: COMPUTE3-X4C-8G
region: fr-par-1
default: True
- name: gpu
provider: scaleway
image: ghcr.io/astral-sh/uv:debian
machinetype: L4-1-24G
region: fr-par-1
datastores:
- name: my-datastore
provider: Scaleway
bucket: o-o-data
region: fr-par
default: true
project: pytorch-example
sourcecode: true
environments:
- name: cpu
provider: gcp
image: ghcr.io/astral-sh/uv:debian
machinetype: c2d-highcpu-4
region: northamerica-northeast1-a
default: True
- name: gpu
provider: gcp
image: ghcr.io/astral-sh/uv:debian
machinetype: g2-standard-4
region: northamerica-northeast1-b
datastores:
- name: my-datastore
provider: gcp
bucket: o-o-data
default: true
Adding source code¶
Now create a new file, mnist.py with the following source code. This script
contains three sub-commands: mnist.py prepare-dataset, mnist.py train and
mnist.py evaluate, for preparing the dataset, training the model, and
evaluating the model.
"""Example MNIST training script"""
import pathlib
import tempfile
import progressbar
import torch
from torch import nn
from torchvision import datasets, transforms
import typer
app = typer.Typer()
class Model(nn.Module):
def __init__(self):
super(Model, self).__init__()
self.conv1 = nn.Conv2d(1, 32, 3, 1)
self.conv2 = nn.Conv2d(32, 64, 3, 1)
self.dropout1 = nn.Dropout(0.25)
self.dropout2 = nn.Dropout(0.5)
self.fc1 = nn.Linear(9216, 128)
self.fc2 = nn.Linear(128, 10)
def forward(self, x):
x = self.conv1(x)
x = nn.functional.relu(x)
x = self.conv2(x)
x = nn.functional.relu(x)
x = nn.functional.max_pool2d(x, 2)
x = self.dropout1(x)
x = torch.flatten(x, 1)
x = self.fc1(x)
x = nn.functional.relu(x)
x = self.dropout2(x)
x = self.fc2(x)
return nn.functional.log_softmax(x, dim=1)
@app.command()
def prepare_dataset(output: pathlib.Path):
with tempfile.TemporaryDirectory() as download_path:
print("Downloading MNIST dataset...")
transform = transforms.Compose(
[
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,)),
]
)
train_tune = datasets.MNIST(download_path, download=True, transform=transform)
test = datasets.MNIST(download_path, train=False, transform=transform)
print("Preprocessing and saving train/tune/test splits...")
train, tune = torch.utils.data.random_split(train_tune, [0.8, 0.2])
torch.save(list(train), output / "train.pt")
torch.save(list(tune), output / "tune.pt")
torch.save(list(test), output / "test.pt")
@app.command()
def train(
dataset: pathlib.Path,
model_output: pathlib.Path,
batch_size: int = 64,
epochs: int = 20,
learning_rate: float = 1.0,
):
if torch.accelerator.is_available():
device = torch.accelerator.current_accelerator()
else:
device = torch.device("cpu")
train_loader = torch.utils.data.DataLoader(
torch.load(dataset / "train.pt"),
batch_size=batch_size,
shuffle=True,
)
tune_loader = torch.utils.data.DataLoader(
torch.load(dataset / "tune.pt"),
batch_size=batch_size,
)
model = Model().to(device)
optimizer = torch.optim.Adadelta(model.parameters(), lr=learning_rate)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=1, gamma=0.7)
for epoch in range(1, epochs + 1):
model.train()
bar = progressbar.progressbar(
train_loader,
desc=f"Epoch {epoch}",
fast=False,
min_poll_interval=2,
)
for data, target in bar:
data, target = data.to(device), target.to(device)
optimizer.zero_grad()
output = model(data)
loss = nn.functional.nll_loss(output, target)
loss.backward()
optimizer.step()
scheduler.step()
model.eval()
loss = 0
correct = 0
with torch.no_grad():
for data, target in tune_loader:
data, target = data.to(device), target.to(device)
output = model(data)
loss += nn.functional.nll_loss(output, target, reduction="sum").item()
pred = output.argmax(dim=1, keepdim=True)
correct += pred.eq(target.view_as(pred)).sum().item()
loss /= len(tune_loader.dataset)
accuracy = 100 * correct / len(tune_loader.dataset)
print(f"Tune set: average loss: {loss:.4f}, accuracy: {accuracy:.1f}%")
torch.save(model.cpu().state_dict(), model_output)
@app.command()
def evaluate(model_path: pathlib.Path, dataset: pathlib.Path):
test_loader = torch.utils.data.DataLoader(torch.load(dataset / "test.pt"))
model = Model()
model.load_state_dict(torch.load(model_path, weights_only=True))
model.eval()
correct = 0
for data, target in test_loader:
output = model(data)
pred = output.argmax(dim=1, keepdim=True)
correct += pred.eq(target.view_as(pred)).sum().item()
accuracy = 100 * correct / len(test_loader.dataset)
print(f"Test set accuracy: {accuracy:.1f}%")
if __name__ == "__main__":
app()
Commit the source code to the Git repository:
$ git add mnist.py pyproject.toml uv.lock
$ git commit --message "MNIST training"
[main (root-commit) bf15eaa] MNIST training
3 files changed, 1027 insertions(+)
create mode 100644 mnist.py
create mode 100644 pyproject.toml
create mode 100644 uv.lock
MNIST dataset preparation¶
With everything setup, we are ready to use o‑o to run our
pipeline. The first job will download the MNIST dataset, preprocess the images, and
split the images into train, tune, and test dataset. We tag this run with
mnist-data for easy reference by downstream jobs. We also set the output
directory to the special o://output/ folder so the data files will be stored
as an output of this run (in your configured datastore).
$ o-o run --tag mnist-data --message "prepare MNIST dataset" -- \
uv -q run mnist.py prepare-dataset o://output/
Downloading MNIST dataset...
Preprocessing and saving train/tune/test splits...
Once completed, we can inspect the outputs of the run with o-o show. Include
the --outputs flag to show the generated files:
$ o-o show mnist-data --outputs
Run 85p3ouxyq497f56k3qa6p8x7dfdyim6wsomxkgutw6dhe (mnist-data)
Creator: Jon Doe <mail@example.com>
Commit: bf15eaac58d0e6731d23f58461d75e801ac094b6
Started: Sun, 1 Feb 10:00:00 2026 -0500
Ended: Sun, 1 Feb 10:01:23 2026 -0500
Command: uv -q run mnist.py prepare-dataset o://output/
prepare MNIST dataset
Output files:
test.pt
train.pt
tune.pt
Tip
We could have easily downloaded the MNIST data within the training step, but by generating the dataset in its own o‑o run, we are storing and freezing the dataset. It is good practice to use o‑o to generate inputs to subsequent training runs, thereby ensuring multiple training runs all use the same input data, the same train/tune/test split, and are fully reproducible.
Model training¶
Now we are ready to train our PyTorch model. Simply start a new job with the
gpu environment. Pass in the dataset location using our mnist-data tag as
o://mnist-data/ and set the model output filename to o://output/model.pt,
so it is stored as an output. Finally, include any desired command line
arguments of mnist.py train (batch-size, epochs, learning-rate). For
this run, let's use --epochs 4 just to shorten the run time:
$ o-o run --environment gpu --message "train MNIST classifier" -- \
uv -q run mnist.py train o://mnist-data/ o://output/model.pt --epochs 4
Epoch 1: 0% (0 of 750) | | Elapsed Time: 0:00:00 ETA: --:--:--
Epoch 1: 100% (750 of 750) |#############| Elapsed Time: 0:00:02 Time: 0:00:02
Tune set: average loss: 0.0573, accuracy: 98.2%
Epoch 2: 0% (0 of 750) | | Elapsed Time: 0:00:00 ETA: --:--:--
Epoch 2: 100% (750 of 750) |#############| Elapsed Time: 0:00:01 Time: 0:00:01
Tune set: average loss: 0.0502, accuracy: 98.5%
Epoch 3: 0% (0 of 750) | | Elapsed Time: 0:00:00 ETA: --:--:--
Epoch 3: 100% (750 of 750) |#############| Elapsed Time: 0:00:01 Time: 0:00:01
Tune set: average loss: 0.0380, accuracy: 98.9%
Epoch 4: 0% (0 of 750) | | Elapsed Time: 0:00:00 ETA: --:--:--
Epoch 4: 100% (750 of 750) |#############| Elapsed Time: 0:00:01 Time: 0:00:01
Tune set: average loss: 0.0363, accuracy: 98.9%
Let's take a look at our two runs:
If satisfied with a particular training run, we can tag it after the fact.
Let's tag our model with model-v01 (remember, your run will have a different
id and will not be opqk19asw9):
Model evaluation¶
For the final step, evaluate our trained model (o://model-v01/model.pt) with
the test dataset we previously generated (in the run tagged as mnist-data):
$ o-o run --message "evaluate MNIST classifier" -- \
uv -q run mnist.py evaluate o://model-v01/model.pt o://mnist-data/
Test set accuracy: 99.1%
We can now see all the steps in our run history:
$ o-o run --list
85p3ouxyq4 (mnist-data) prepare MNIST dataset
opqk19asw9 (model-v01) train MNIST classifier
bbkfn1bcus evaluate MNIST classifier
Inspect the evaluation step with o-o show. By adding the --inputs flag, we
can see the full pipeline and trace how the inputs to this step were generated:
$ o-o show bbkfn1bcus --inputs
Run bbkfn1bcusnihzag67aif3u76irt9z68bz38qgck1ntwg
Creator: Jon Doe <mail@example.com>
Commit: bf15eaac58d0e6731d23f58461d75e801ac094b6
Started: Sun, 1 Feb 10:10:00 2026 -0500
Ended: Sun, 1 Feb 10:11:22 2026 -0500
Command: uv -q run mnist.py evaluate o://model-v01/model.pt o://mnist-data/
evaluate MNIST classifier
Inputs:
|\
| o opqk19asw9 (model-v01) train MNIST classifier
|/
o 85p3ouxyq4 (mnist-data) prepare MNIST dataset
And that's it! From here, you could experiment with the model training to improve its accuracy. o‑o will record the Git commit and command line arguments so you have a record of any changes made during this experimentation. You could also extend the pipeline by adding additional steps, such as a model deployment step. o‑o will orchestrate all of it and keep a record of your entire pipeline.