Skip to content

Commit 2cf3a82

Browse files
committed
Training
1 parent 7e0006d commit 2cf3a82

4 files changed

Lines changed: 238 additions & 24 deletions

File tree

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
---
2+
# User change
3+
title: "Train the Digit Recognizer"
4+
5+
weight: 5
6+
7+
layout: "learningpathall"
8+
---
9+
10+
## Objective ##
11+
We will now train a small CNN to classify Sudoku cell crops into 10 classes (0=blank, 1..9=digit), verify accuracy, then export the model to ONNX using the Dynamo exporter and sanity-check parity with ONNX Runtime. This gives us a portable model ready for Arm64 inference and later Android deployment.
12+
13+
## Creating a model
14+
We use a tiny convolutional neural network (CNN) called DigitNet, designed to be both fast (so it runs efficiently on Arm64 and mobile) and accurate enough for recognizing 28×28 grayscale crops of Sudoku digits. It expects 1 input channel (in_channels=1) because we forced grayscale in the preprocessing step.
15+
16+
We start by creating a new file digitnet_model.py and defining the DigitNet class:
17+
```python
18+
import torch
19+
import torch.nn as nn
20+
21+
class DigitNet(nn.Module):
22+
"""
23+
Tiny CNN for Sudoku digit classification.
24+
Classes: 0..9 where 0 = blank.
25+
Input: (N,1,H,W) grayscale (default 28x28).
26+
"""
27+
def __init__(self, num_classes: int = 10):
28+
super().__init__()
29+
self.net = nn.Sequential(
30+
nn.Conv2d(1, 16, 3, padding=1), nn.ReLU(),
31+
nn.MaxPool2d(2),
32+
nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(),
33+
nn.AdaptiveAvgPool2d((1,1)),
34+
nn.Flatten(),
35+
nn.Linear(32, num_classes),
36+
)
37+
38+
def forward(self, x: torch.Tensor) -> torch.Tensor:
39+
return self.net(x)
40+
```
41+
42+
We use a very compact convolutional neural network (CNN), which we call DigitNet, to recognize Sudoku digits. The goal is to have a model that is simple enough to run efficiently on Arm64 and mobile devices, but still powerful enough to tell apart the ten classes we care about (0 for blank, and digits 1 through 9).
43+
44+
The network expects each input to be a 28×28 grayscale crop, so it begins with a convolution layer that has one input channel and sixteen filters. This first convolution is responsible for learning very low-level patterns such as strokes or edges. Immediately after, a ReLU activation introduces non-linearity, which allows the network to combine those simple features into more expressive ones. A max-pooling layer then reduces the spatial resolution by half, making the representation more compact and less sensitive to small translations.
45+
46+
At this point, the feature maps are passed through a second convolutional layer with thirty-two filters. This stage learns richer patterns, for example combinations of edges that form loops or intersections that distinguish an “8” from a “0” or a “6”. Another ReLU activation adds the necessary non-linearity to these higher-level features.
47+
48+
Instead of flattening the entire feature map, we apply an adaptive average pooling operation that squeezes each of the thirty-two channels down to a single number. This effectively summarizes the information across the whole image and ensures the model produces a fixed-length representation regardless of the exact input size. After pooling, the features are flattened into a one-dimensional vector.
49+
50+
The final step is a fully connected layer that maps the thirty-two features to ten output values, one for each class. These values are raw scores (logits) that indicate how strongly the model associates the input crop with each digit. During training, a cross-entropy loss will turn these logits into probabilities and guide the model to adjust its weights.
51+
52+
In practice, this means that when you feed in a batch of grayscale Sudoku cells of shape [N, 1, 28, 28], DigitNet transforms them step by step into a batch of [N, 10] outputs, where each row contains the scores for the ten possible classes. Despite its simplicity, this small CNN strikes a balance between speed and accuracy that makes it ideal for Sudoku digit recognition on resource-constrained devices.
53+
54+
## Training a model
55+
We will now prepare the self-containing script that trains the above model on the data prepared earlier. Start by creating the new file 03_Training.py and modify it as follows:
56+
```python
57+
import os, random, numpy as np
58+
import torch as tr
59+
import torch.nn as nn
60+
import torch.nn.functional as F
61+
from torch.utils.data import DataLoader
62+
from torchvision import datasets, transforms
63+
from tqdm import tqdm
64+
from torch.onnx import dynamo_export
65+
from torch.export import Dim
66+
import onnxruntime as ort
67+
68+
from digitnet_model import DigitNet
69+
70+
# Configuration
71+
random.seed(0); np.random.seed(0); tr.manual_seed(0)
72+
DEVICE = "cpu" # keep CPU for portability
73+
DATA_DIR = "data" # data/train/0..9, data/val/0..9
74+
ARTI_DIR = "artifacts"
75+
os.makedirs(ARTI_DIR, exist_ok=True)
76+
77+
BATCH = 256
78+
EPOCHS = 10
79+
LR = 1e-3
80+
WEIGHT_DECAY = 1e-4
81+
LABEL_SMOOTH = 0.05
82+
83+
# Datasets (force grayscale to match model)
84+
tfm_train = transforms.Compose([
85+
transforms.Grayscale(num_output_channels=1), # force 1-channel input
86+
transforms.ToTensor(),
87+
transforms.Normalize((0.5,), (0.5,)),
88+
transforms.RandomApply([transforms.GaussianBlur(3)], p=0.15),
89+
transforms.RandomAffine(degrees=5, translate=(0.02,0.02), scale=(0.95,1.05)),
90+
])
91+
tfm_val = transforms.Compose([
92+
transforms.Grayscale(num_output_channels=1), # force 1-channel input
93+
transforms.ToTensor(),
94+
transforms.Normalize((0.5,), (0.5,)),
95+
])
96+
97+
train_ds = datasets.ImageFolder(os.path.join(DATA_DIR, "train"), transform=tfm_train)
98+
val_ds = datasets.ImageFolder(os.path.join(DATA_DIR, "val"), transform=tfm_val)
99+
100+
train_loader = DataLoader(train_ds, batch_size=BATCH, shuffle=True, num_workers=0)
101+
val_loader = DataLoader(val_ds, batch_size=BATCH, shuffle=False, num_workers=0)
102+
103+
def evaluate(model: nn.Module, loader: DataLoader) -> float:
104+
model.eval()
105+
correct = total = 0
106+
with tr.no_grad():
107+
for x, y in loader:
108+
x, y = x.to(DEVICE), y.to(DEVICE)
109+
pred = model(x).argmax(1)
110+
correct += (pred == y).sum().item()
111+
total += y.numel()
112+
return correct / total if total else 0.0
113+
114+
def main():
115+
# Sanity: verify loader channels
116+
xb, _ = next(iter(train_loader))
117+
print("Train batch shape:", xb.shape) # expect [B, 1, 28, 28]
118+
119+
model = DigitNet(num_classes=10).to(DEVICE)
120+
opt = tr.optim.AdamW(model.parameters(), lr=LR, weight_decay=WEIGHT_DECAY)
121+
122+
best_acc, best_state = 0.0, None
123+
for ep in range(1, EPOCHS + 1):
124+
model.train()
125+
for x, y in tqdm(train_loader, desc=f"epoch {ep}/{EPOCHS}"):
126+
x, y = x.to(DEVICE), y.to(DEVICE)
127+
opt.zero_grad()
128+
logits = model(x)
129+
loss = F.cross_entropy(logits, y, label_smoothing=LABEL_SMOOTH)
130+
loss.backward()
131+
opt.step()
132+
133+
acc = evaluate(model, val_loader)
134+
print(f"val acc: {acc:.4f}")
135+
if acc > best_acc:
136+
best_acc = acc
137+
best_state = {k: v.cpu().clone() for k, v in model.state_dict().items()}
138+
139+
if best_state is not None:
140+
model.load_state_dict(best_state)
141+
print(f"Best val acc: {best_acc:.4f}")
142+
143+
# Save PyTorch weights (optional)
144+
tr.save(model.state_dict(), os.path.join(ARTI_DIR, "digitnet_best.pth"))
145+
146+
# Export to ONNX with dynamic batch using the Dynamo API
147+
model.eval()
148+
dummy = tr.randn(1, 1, 28, 28)
149+
onnx_path = os.path.join(ARTI_DIR, "sudoku_digitnet.onnx")
150+
151+
tr.onnx.export(
152+
model, # model
153+
dummy, # input tensor corresponds to arg name 'x'
154+
onnx_path, # output .onnx
155+
input_names=["input"], # ONNX *display* name (independent of arg name)
156+
output_names=["logits"],
157+
opset_version=19,
158+
do_constant_folding=True,
159+
keep_initializers_as_inputs=False,
160+
dynamo=True,
161+
dynamic_shapes={"x": {0: Dim("N")}}
162+
)
163+
164+
print("Exported:", onnx_path)
165+
166+
# quick parity with a big batch (proves dynamic batch works)
167+
sess = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"])
168+
x = tr.randn(512, 1, 28, 28)
169+
onnx_logits = sess.run(["logits"], {"input": x.numpy().astype(np.float32)})[0]
170+
pt_logits = model(x).detach().numpy()
171+
print("Parity MAE:", np.mean(np.abs(onnx_logits - pt_logits)))
172+
173+
if __name__ == "__main__":
174+
main()
175+
```
176+
177+
This file is a self-contained trainer for the Sudoku digit classifier. It starts by fixing random seeds for reproducibility and sets DEVICE="cpu" so the workflow runs the same on desktops and Arm64 boards. It expects the dataset from the previous step under data/train/0..9 and data/val/0..9, and creates an artifacts/ folder for all outputs.
178+
179+
The script builds two dataloaders (train/val) with a preprocessing stack that forces grayscale (Grayscale(num_output_channels=1)) so inputs match the model’s first convolution, converts to tensors, and normalizes to a centered range. Light augmentations on the training split—small affine jitter and occasional blur—mimic camera variability without distorting the digits. Batch size, epochs, and learning rate are set to conservative defaults so training is smooth on CPU; you can scale them up later.
180+
181+
Then, the script it instantiates DigitNet(num_classes=10) model. The optimizer is AdamW with mild weight decay to control overfitting. The loss is cross-entropy with label smoothing (e.g., 0.05), which reduces over-confidence and helps on easily confused shapes (like 6/8/9).
182+
183+
The training loop runs for a fixed number of epochs, iterating mini-batches from the training set. After each epoch, it evaluates on the validation split and logs the accuracy. The script keeps track of the best model state seen so far (based on val accuracy) and restores it at the end, ensuring the final model corresponds to your best epoch, not just the last one.
184+
185+
The file will create two artifacts:
186+
1. digitnet_best.pth — the best PyTorch weights (handy for quick experiments, fine-tuning, or debugging later).
187+
2. sudoku_digitnet.onnx — the exported ONNX model, produced with PyTorch’s Dynamo exporter and a dynamic batch dimension. Dynamic batch means the model accepts input of shape [N, 1, 28, 28] for any N, which is ideal for efficient batched inference on Arm64 and for Android integration.
188+
189+
Right after export, the script runs a parity test: it feeds the same randomly generated batch through both the PyTorch model and the ONNX model (executed by ONNX Runtime) and prints the mean absolute error between their logits. A tiny value confirms the exported graph faithfully matches your trained network.
190+
191+
## Running the script
192+
To run the training script, type:
193+
194+
```console
195+
python 03_Training.py
196+
```
197+
198+
The script will train, validate, export, and verify the digit recognizer in one go. After it finishes, you’ll have both a portable ONNX model and a PyTorch checkpoint ready for the next step—building the image processor that detects the Sudoku grid, rectifies it, segments cells, and performs batched ONNX inference to reconstruct the board for solving.
199+
200+
Here is a sample run:
201+
202+
```output
203+
python3 03_Training.py
204+
Train batch shape: torch.Size([256, 1, 28, 28])
205+
epoch 1/10: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 1597/1597 [03:24<00:00, 7.82it/s]
206+
val acc: 0.8099
207+
epoch 2/10: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 1597/1597 [03:18<00:00, 8.05it/s]
208+
val acc: 0.8378
209+
epoch 3/10: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 1597/1597 [03:17<00:00, 8.09it/s]
210+
val acc: 0.8855
211+
epoch 4/10: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 1597/1597 [03:20<00:00, 7.97it/s]
212+
val acc: 0.9180
213+
epoch 5/10: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 1597/1597 [03:20<00:00, 7.97it/s]
214+
val acc: 0.9527
215+
epoch 6/10: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 1597/1597 [03:22<00:00, 7.88it/s]
216+
val acc: 0.9635
217+
epoch 7/10: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 1597/1597 [03:22<00:00, 7.88it/s]
218+
val acc: 0.9777
219+
epoch 8/10: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 1597/1597 [03:21<00:00, 7.91it/s]
220+
val acc: 0.9854
221+
epoch 9/10: 100%|███████████████████████████████████████████████████████████████████████████████████████████████████| 1597/1597 [03:21<00:00, 7.91it/s]
222+
val acc: 0.9912
223+
epoch 10/10: 100%|██████████████████████████████████████████████████████████████████████████████████████████████████| 1597/1597 [03:21<00:00, 7.91it/s]
224+
val acc: 0.9928
225+
Best val acc: 0.9928
226+
[torch.onnx] Obtain model graph for `DigitNet([...]` with `torch.export.export(..., strict=False)`...
227+
[torch.onnx] Obtain model graph for `DigitNet([...]` with `torch.export.export(..., strict=False)`... ✅
228+
[torch.onnx] Run decomposition...
229+
[torch.onnx] Run decomposition... ✅
230+
[torch.onnx] Translate the graph into ONNX...
231+
[torch.onnx] Translate the graph into ONNX... ✅
232+
Applied 1 of general pattern rewrite rules.
233+
Exported: artifacts/sudoku_digitnet.onnx
234+
Parity MAE: 1.0251999e-05
235+
```
236+
237+
## Summary
238+
By running the training script you train the DigitNet CNN on the Sudoku digit dataset, steadily improving accuracy across epochs until the model surpasses 99% validation accuracy. The process builds on the earlier steps where we first defined the model architecture in digitnet_model.py and then prepared a dedicated training script to handle data loading, augmentation, optimization, and evaluation. During training the best-performing model state is saved, and at the end it is exported to the ONNX format with dynamic batch support. A parity check confirms that the ONNX and PyTorch versions produce virtually identical outputs (mean error ~1e-5). You now have a validated ONNX model (artifacts/sudoku_digitnet.onnx) and a PyTorch checkpoint (digitnet_best.pth), both ready for integration into the Sudoku image processing pipeline. Before moving on to grid detection and solving, however, we will first run standalone inference to confirm the model’s predictions on individual digit crops.

content/learning-paths/cross-platform/onnx/04_Inference.md renamed to content/learning-paths/cross-platform/onnx/05_Inference.md

File renamed without changes.

content/learning-paths/cross-platform/onnx/06_Profiling.md

Lines changed: 0 additions & 24 deletions
This file was deleted.

content/learning-paths/cross-platform/onnx/05_TrainingWorkflows.md renamed to content/learning-paths/cross-platform/onnx/06_SudokuProcessor.md

File renamed without changes.

0 commit comments

Comments
 (0)