Your First Model: MNIST Digit Classifier with GoMLX
Build, train, and evaluate a CNN in Go to classify MNIST digits, end to end.
Welcome to GoMLX! In this end-to-end tutorial, you will set up your environment, build, train, and evaluate a Convolutional Neural Network (CNN) in Go to classify handwritten digits from the MNIST dataset.
This guide bridges high-level machine learning principles with practical, idiomatic Go code, using GoMLX’s real, current APIs — every code sample below is synced directly from, and tested as part of, examples/gomlx.github.io/mnist/first-model/main.go in the gomlx/gomlx repository, so it can’t drift out of sync with the library.
1. Prerequisites & Environment Setup
Step 1: Verify Go Installation
GoMLX currently targets Go 1.26 or later (check the go directive in go.mod if you’re unsure which version a given release requires).
go version
If Go is not installed, download it from the official Go Downloads page.
CGO_ENABLED=1 (the default) and a C compiler (gcc or clang) available on your PATH. You don’t need to install XLA yourself — see the note on auto-installation below.Step 2: Initialize Your Go Project
mkdir gomlx-mnist-quickstart
cd gomlx-mnist-quickstart
go mod init gomlx-mnist-quickstart
Step 3: Fetch GoMLX Dependencies
go get github.com/gomlx/gomlx@latest
go get github.com/gomlx/compute@latest
go get github.com/gomlx/gomlx/examples/mnist@latest
gomlx is the machine learning library (layers, training loop, datasets). compute is the lower-level package — github.com/gomlx/compute — that owns the Backend, dtypes, and shapes types — you’ll import both directly in main.go, so both need to be in go.mod. (Running go mod tidy after you write the code below would also pull in compute automatically, since it’s a transitive dependency — but adding it explicitly up front is one less thing to think about.)
On first run, GoMLX auto-downloads and installs the correct XLA PJRT plugin for your OS/architecture (CPU by default; GPU/TPU if configured) — no manual XLA install needed. If you want to disable that behavior (e.g. for a locked-down CI environment), set the environment variable GOMLX_NO_AUTO_INSTALL=1.
2. Core GoMLX Concepts
GoMLX organizes machine learning workflows around five building blocks:
- Backend (
compute.Backend): Executes the actual numerical computation. GoMLX compiles execution graphs via OpenXLA, supporting CPU, GPU, and TPU. Created withcompute.MustNew(). - Store & Scope (
*model.Store/*model.Scope): TheStoreowns every trainable variable and hyperparameter in your model, organized hierarchically. AScopeis a named “directory” inside that tree (e.g.conv1/weights) — you get one fromstore.RootScope()and navigate it withscope.In("name"). See Variables, Hyperparameters & Checkpointing for the full picture. - Graph (
*graph.Graph/*graph.Node): Symbolic computation nodes, built once and compiled to XLA before any data flows through them. See Computation Graph. - Model function: A plain Go function with the signature
func(scope *model.Scope, spec any, inputs []*graph.Node) []*graph.Node, describing how inputs become predictions. - Trainer & Loop (
*train.Trainer/*train.Loop):Trainerwires together the model function, loss, optimizer, and metrics into a single training/eval step;Looprepeatedly calls that step over a dataset (by number of steps or by epochs). See Training Loop.
context.Context, note that the library has since moved to the model.Store/model.Scope pair described above — there is no ml/context package in the current API.3. Package Imports
import (
"fmt"
"log"
"github.com/gomlx/compute"
"github.com/gomlx/compute/dtypes"
. "github.com/gomlx/gomlx/core/graph"
"github.com/gomlx/gomlx/examples/mnist"
"github.com/gomlx/gomlx/ml/layers"
"github.com/gomlx/gomlx/ml/layers/activation"
"github.com/gomlx/gomlx/ml/model"
"github.com/gomlx/gomlx/ml/train"
"github.com/gomlx/gomlx/ml/train/loss"
"github.com/gomlx/gomlx/ml/train/metric"
"github.com/gomlx/gomlx/ml/train/optimizer"
"github.com/gomlx/gomlx/support/fsutil"
"github.com/gomlx/gomlx/ui/commandline"
_ "github.com/gomlx/gomlx/backends/default"
)
A few things worth calling out:
github.com/gomlx/gomlx/examples/mnistis GoMLX’s own MNIST downloader/loader — it’s a real importable package, not sample-only code, so we reuse it instead of writing IDX-file parsing from scratch.. "github.com/gomlx/gomlx/core/graph"is a dot import: it’s the idiomatic style used throughout GoMLX’s own examples so you can writeReshape(...)instead ofgraph.Reshape(...). It’s optional — a regular"github.com/gomlx/gomlx/core/graph"import works too, you’ll just prefix everything withgraph..activation.Relulives in its own package,ml/layers/activation, separate from bothcore/graphandml/layers— it’s easy to assume it’s a plain graph op (it doesn’t own variables), but GoMLX groups all activation functions there instead.- The blank import
_ "github.com/gomlx/gomlx/backends/default"registers the CPU/GPU (XLA) and pure-Go backends socompute.MustNew()has something to find. Forgetting this import is a common “no backend available” error.
4. Defining the Neural Network Architecture
We build a small Convolutional Neural Network (CNN):
- Two Conv2D → ReLU → MaxPool feature-extraction blocks.
- A flatten step (a reshape) to turn the 2D feature maps into a 1D vector per example.
- A Dense hidden layer with ReLU.
- An output Dense layer producing raw logits for the 10 digit classes (0–9).
// ConvModel defines a simple CNN architecture for MNIST digit classification.
// This is a "model function": GoMLX calls it once, while building the computation graph,
// not once per example -- the returned Nodes are symbolic, not actual numbers yet.
func ConvModel(scope *model.Scope, spec any, inputs []*Node) []*Node {
// inputs[0] shape: [BatchSize, 28, 28, 1] (grayscale).
images := inputs[0]
batchSize := images.Shape().Dimensions[0]
// Block 1: Conv2D (16 filters, 3x3 kernel, "same" padding keeps 28x28) -> ReLU -> MaxPool 2x2 -> 14x14.
x := layers.Convolution(scope.In("conv1"), images).Filters(16).KernelSize(3).PadSame().Done()
x = activation.Relu(x)
x = MaxPool(x).Window(2).Done()
// Block 2: Conv2D (32 filters) -> ReLU -> MaxPool 2x2 -> 7x7.
x = layers.Convolution(scope.In("conv2"), x).Filters(32).KernelSize(3).PadSame().Done()
x = activation.Relu(x)
x = MaxPool(x).Window(2).Done()
// Flatten: [BatchSize, 7, 7, 32] -> [BatchSize, 7*7*32]. GoMLX doesn't have a separate
// Flatten layer -- a Reshape with -1 for the last dimension does the job.
x = Reshape(x, batchSize, -1)
// Fully-connected hidden layer (128 units) + ReLU.
x = layers.Dense(scope.In("dense1"), x, true, 128)
x = activation.Relu(x)
// Output layer: logits for the 10 classes. No activation here -- the loss function
// (SparseCategoricalCrossEntropyLogits) applies softmax internally, on the logits, which is
// more numerically stable than applying softmax yourself and feeding probabilities to the loss.
logits := layers.Dense(scope.In("logits"), x, true, mnist.NumClasses)
return []*Node{logits}
}
Parameter scoping (scope.In): scope.In("conv1") creates a nested namespace for variables created inside it (e.g. conv1/weights, conv1/biases), preventing name collisions between layers that would otherwise both try to create a variable called weights.
MaxPool comes from core/graph, Relu from ml/layers/activation: pooling and reshaping are plain graph operations (they don’t own trainable variables), so they live alongside Reshape in the graph package. Activations get their own package, ml/layers/activation. Only operations that own variables — Convolution, Dense — live in ml/layers proper and take a *model.Scope as their first argument.
5. Preparing & Loading the Dataset
examples/mnist handles downloading and decoding the IDX files for you. We still need to (a) pick a cache directory, (b) trigger the download, and (c) turn the raw, unbatched dataset it returns into a batched, shuffled training stream and a batched evaluation stream.
const batchSize = 128
// prepareDatasets ensures MNIST is downloaded, then returns batched train/test datasets.
func prepareDatasets(backend compute.Backend, dataDir string) (trainDS, testDS train.Dataset) {
dataDir = fsutil.MustReplaceTildeInDir(dataDir)
// mnist.Download is idempotent: it verifies checksums and skips files already on disk.
if err := mnist.Download(dataDir); err != nil {
log.Fatalf("Failed to download MNIST dataset: %+v", err)
}
// mnist.NewDataset returns the *whole* split as a single in-memory, unbatched dataset --
// we still need to batch (and, for training, shuffle) it ourselves.
rawTrain, err := mnist.NewDataset(backend, "MNIST Train", dataDir, "train", dtypes.Float32)
if err != nil {
log.Fatalf("Failed to load training dataset: %+v", err)
}
rawTest, err := mnist.NewDataset(backend, "MNIST Test", dataDir, "test", dtypes.Float32)
if err != nil {
log.Fatalf("Failed to load test dataset: %+v", err)
}
// dropIncompleteBatch=true for training keeps every batch a fixed size, which XLA prefers
// (it recompiles the graph whenever it sees a new shape). Shuffle() reorders examples each epoch.
trainDS = rawTrain.Shuffle().BatchSize(batchSize, true)
// For evaluation we want to see every example, so we don't drop the last, short batch.
testDS = rawTest.BatchSize(batchSize, false)
return
}
Infinite(true)? You’ll see .Infinite(true) in some GoMLX examples that drive the loop with loop.RunSteps(ds, numSteps) (a fixed number of steps, dataset repeats forever). We’re using loop.RunEpochs(ds, epochs) instead, which iterates the dataset to exhaustion once per epoch — so the dataset must be finite.6. Training & Evaluation Pipeline
train.Trainer wires the model function, loss, optimizer, and metrics together into a single step function. train.Loop drives that step function over a dataset.
backend := compute.MustNew()
defer backend.Finalize()
fmt.Printf("Backend: %s (%s)\n", backend.Name(), backend.Description())
store := model.NewStore()
trainDS, testDS := prepareDatasets(backend, dataDir)
accuracyMetric := metric.NewSparseCategoricalAccuracy("Accuracy", "acc")
trainer := train.NewTrainer(
backend,
store,
ConvModel,
loss.SparseCategoricalCrossEntropyLogits, // cross-entropy loss for integer labels
optimizer.Adam().LearningRate(1e-3).Done(),
[]metric.Interface{accuracyMetric}, // metrics reported during training
[]metric.Interface{accuracyMetric}, // metrics reported during evaluation
)
const epochs = 5
loop := train.NewLoop(trainer)
// AttachProgressBar gives you a live progress bar with loss/metric values -- there's no
// need to print them by hand.
commandline.AttachProgressBar(loop)
fmt.Printf("Starting training for %d epochs...\n", epochs)
if _, err := loop.RunEpochs(trainDS, epochs); err != nil {
log.Fatalf("Training loop failed: %+v", err)
}
fmt.Println("\nEvaluating model performance on test set...")
if err := commandline.ReportEval(trainer, testDS); err != nil {
log.Fatalf("Evaluation failed: %+v", err)
}
Source references for this section
loss.SparseCategoricalCrossEntropyLogits, metric.NewSparseCategoricalAccuracy, optimizer.Adam, Loop.RunEpochs, Trainer.Eval (called internally by commandline.ReportEval), commandline.AttachProgressBar.
7. Running Your Code & Output
Clone the repo and run the tutorial’s source directly:
go run github.com/gomlx/gomlx/examples/gomlx.github.io/mnist/first-model@latest
Or copy the snippets from sections 3–6 above into a single main.go in your own module and run go run main.go.
The first run will download the MNIST files (~11MB) to ~/mnist_data and auto-install the XLA PJRT plugin if it isn’t already cached, so it’ll be slower than subsequent runs. commandline.AttachProgressBar gives you a live-updating table plus a progress bar during training, followed by the evaluation report. This is real, complete output from an actual 5-epoch run on CPU:
Backend: xla (xla:cpu - PJRT "cpu" plugin ...)
Starting training for 5 epochs...
╭────────────────────────────┬─────────────╮
│ Global Step │ 999 of 2_340│
│ Median train step duration │ 106.2ms │
│ Loss │ 0.0405 │
│ Moving Average Loss │ 0.0478 │
│ Accuracy │ 96.35% │
╰────────────────────────────┴─────────────╯
100% [========================================] (9 steps/s)
Evaluating model performance on test set...
Results on MNIST Test:
Mean Loss (#loss): 0.0494
Accuracy (acc): 98.43%
8. Checkpointing the Model
Training runs end when your program exits, and so far, so does the model — nothing is saved to disk. Two lines added to the imports and a few more around the Loop are all it takes to persist progress and resume it later.
Add the checkpoint import
"time"
"github.com/gomlx/gomlx/ml/model/checkpoint"
Save periodically during training
Insert this right after creating the Loop (before commandline.AttachProgressBar):
// Save a checkpoint every minute, keeping the 3 most recent ones. checkpoint.Build
// also *loads* an existing checkpoint from checkpointDir if one is already there, so
// re-running this program resumes training instead of starting over.
checkpointHandler, err := checkpoint.Build(store).
DirFromBase("checkpoint", dataDir).
Keep(3).
Done()
if err != nil {
log.Fatalf("Failed to create checkpoint handler: %+v", err)
}
train.PeriodicCallback(loop, time.Minute, true, "saving checkpoint", 100, checkpointHandler.SaveOnStepFn)
checkpoint.Build(store) does double duty: it loads an existing checkpoint from checkpointDir if one is already there (so re-running this program resumes training instead of starting from scratch), and prepares the handler used to save new ones. train.PeriodicCallback then triggers checkpointHandler.SaveOnStepFn on a timer — here, once a minute, plus immediately on startup (the true argument) — and Keep(3) prunes older checkpoints so disk usage doesn’t grow unbounded.
TempDir, FromEmbed, TakeMean, compression, lazy loading, and the gomlx_checkpoints inspection tool — see Variables, Hyperparameters & Checkpointing.9. Next Steps & Advanced Features
- Interactive plotting: Under GoNB (a Go kernel for Jupyter),
github.com/gomlx/gomlx/ui/gonb/plotlygives you live, inline training-curve plots — seeexamples/mnist/train.gofor a real usage example (plotly.New().WithCheckpoint(...).Dynamic()...). - Hyperparameter management:
model.StoresupportsStore.SetParam/SetParamsand reading params back withmodel.GetParamOr(scope, name, default), plus CLI flag wiring viagithub.com/gomlx/gomlx/ui/commandline— see howexamples/mnist/train.go’sCreateStore()predefines tunables likelearning_rateandcnn_dropout_rate, settable from the command line with-set="learning_rate=0.0005". See also Hyperparameters. - Regularization: Add dropout with
layers.DropoutStatic(scope, x, 0.2)(a fixed rate) orlayers.Dropout(scope, x, dropoutRateNode)(a rate you can vary, e.g. on/off between train and eval). - Inference from a checkpoint:
examples/mnist/classifier/classifier.gois a complete example of loading a checkpointed MNIST model and running inference on a single image.