Skip to content

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:

.ooconfig
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
.ooconfig
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.

mnist.py
"""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:

$ o-o run --list
85p3ouxyq4 (mnist-data) prepare MNIST dataset
opqk19asw9 train MNIST classifier

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):

$ o-o tag model-v01 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.