> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tuneplane.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Train with PyTorch

> Turn a folder of labelled images into a classifier: data shape, submit, tuning, bringing your own model

You have a folder of images sorted into class directories and want a model that says which class
a picture belongs to. Three commands:

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
tp new my-cnn --method pytorch/image-classification
tp dataset push my-images v1 <your image directory>
tp submit my-cnn --profile <card>:1 --train-dataset <you>/my-images@v1
```

Native PyTorch **2.14.0**, its own runtime. It is the platform's only `supervised` method:
labelled input/target pairs, trained from initialisation, with no base model anywhere in the spec.

A runnable example lives in the sample project `tuneplane-examples`, under
`experiments/pytorch-imgcls_simple-cnn_digit-images_v1`.

## What the data looks like

**The directory name is the class name.** No label file, no CSV:

```text theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
train/scratch/0001.png      this image is labelled scratch
train/dent/0002.png         this one is labelled dent
train/ok/0003.png
val/scratch/0101.png        validation set, same class names
val/ok/0102.png
```

That is the `torchvision.datasets.ImageFolder` convention. Put the number of classes in
`config.yaml` as `num_classes` and **not a line of code changes**.

`val/` is required and must not overlap `train/` — the accuracy the platform reports is measured
on it, and mixing training images in makes that number mean nothing.

## Create

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
tp new my-cnn --method pytorch/image-classification
```

You get `train.py` and `config.yaml`, both yours. In `train.py` only `build_model` and
`build_datasets` are meant to be edited; the rest is the platform contract — how the arguments
arrive, reporting from rank zero, where artifacts are written.

## Configure

`config.yaml` is flat, with no inheritance. Every key is declared by the recipe, so a typo or an
out-of-range value fails at `tp validate` rather than after queueing for a GPU:

```yaml theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
input_pipeline: imagefolder   # reads <data_dir>/{train,val}/<class>/*
num_classes: 3
arch: simple_cnn              # or any torchvision.models constructor, e.g. resnet18
epochs: 5
batch_size: 64                # per process; the global batch is this times the GPU count
learning_rate: 1.0e-3
optimizer: adamw              # adamw | sgd
lr_scheduler: cosine          # constant | step | cosine
amp: true
```

`--set` overrides the same keys and is checked against the same declaration:

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
tp submit my-cnn --profile <card>:1 --set epochs=20 --set arch=resnet18
```

Every tunable parameter and its range: `tp methods pytorch/image-classification`.

<Note>
  A torchvision architecture (`resnet18` and friends) needs three-channel input. A single-channel
  greyscale pipeline is refused explicitly, with the two config keys that disagree named — rather
  than by the first convolution raising an error that mentions only a tensor shape.
</Note>

## Where the data comes from

An image corpus is a *directory*, so unlike the text frameworks this method is handed a mount
point rather than a file. Two ways:

**A platform dataset** — immutable and versioned, read from the shared cache with no egress:

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
tp dataset push my-images v1 <directory>
tp submit my-cnn --profile <card>:1 --train-dataset <you>/my-images@v1
```

**A volume** — a directory maintained in place, no versions, mounted read-only:

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
tp volume push <directory> --name my-images
tp submit my-cnn --profile <card>:1 --volume <you>/my-images
```

```yaml theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
data_dir: ${VOLUMES_DIR}/my-images   # the platform chooses the path; name it, never hard-code it
```

**Which one**: material that keeps changing and growing belongs in a volume; material you need to
reproduce from, and to say *which version* was trained on, belongs in a dataset. See
[datasets and volumes](/en/guides/datasets).

Declare neither and `input_pipeline: imagefolder` reads `data/{train,val}` under the working
directory, which is for local debugging; `mnist` / `cifar10` try to download instead — fine on a
laptop, and usually no route out of a cluster node. Declare one of the two for a real run.

## Launch

The adapter compiles `torchrun` from the topology the server billed. One card is enough; with
several, `batch_size` is per process. Two machines or more become a process group with a
rendezvous, one launcher per machine — the experiment never reads the GPU count, because `RANK`,
`LOCAL_RANK` and `WORLD_SIZE` are already set when `train.py` starts.

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
tp submit my-cnn --profile <card>:1     # one card
tp submit my-cnn --profile <card>:8     # eight on one node
```

Multi-node needs the kuberay or slurm backend, the same as every other process-group framework here.

## After training

* Console curves: `train/loss`, `validation/loss`, `validation/accuracy`, `train/lr`
* `checkpoints/best.pt`, plus a snapshot of every evaluated epoch
* The accuracy is `correct / total` over `val/`

This method has **no** `tp export` and no `tp eval`, stated rather than omitted: a classifier has
no HuggingFace export that would mean anything, and there is no benchmark harness here to score it
against. `tp submit --then export` is refused at compile.

## Bringing your own model

Two functions in `train.py`, and that is all:

```python theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
def build_model(config, in_channels):
    """Return any nn.Module."""

def build_datasets(config, data_dir, *, may_download=True):
    """Return (train_set, val_set, in_channels)."""
```

Leave the rest alone — it is what lets the console draw the curves and the artifact registry find
your weights.

## Why not custom/custom

A shell script can call `torchrun` too, so the difference is worth naming:

|                 | `custom/custom`                                        | `pytorch/image-classification`                       |
| --------------- | ------------------------------------------------------ | ---------------------------------------------------- |
| Multi-node      | one container, no coordinator — each node trains alone | a real rendezvous, compiled from the billed topology |
| Hyperparameters | `--set` reaches nothing                                | declared, type- and range-checked before the queue   |
| Image           | `--image` required on every submit                     | the platform's, resolved by `runtime_id`             |
| Observability   | `external`; bring a URL and install the SDK            | `platform`, curves out of the box                    |

Reach for [custom](/en/guides/custom-training) when your trainer is genuinely not this shape.

## Image (an operator does this once)

`pytorch-2.14.0` is built by the deployment; the catalog embeds no address:

```bash theme={"theme":{"light":"github-light","dark":"github-dark-dimmed"}}
./deploy/docker/build-runtimes.sh pytorch --push
```

Then name it in the console: **Settings → Runtime → Images → "Default PyTorch image"**. A tag is
enough and it takes effect without a restart. `TUNEPLANE_IMAGE_PYTORCH` sets the same field from the
environment and is the baseline a saved value overrides. Without it a submission fails with
`runtime_id='pytorch-2.14.0' has no built-in OCI source`.
