Skip to content

Instantly share code, notes, and snippets.

@jonas1ara
Created August 1, 2026 05:52
Show Gist options
  • Select an option

  • Save jonas1ara/d284b04c8c039ce3dc7dcf3d2361f813 to your computer and use it in GitHub Desktop.

Select an option

Save jonas1ara/d284b04c8c039ce3dc7dcf3d2361f813 to your computer and use it in GitHub Desktop.
Mnist.cs
#:package System.Numerics.Tensors@11.0.0-*
using System;
using System.Buffers.Binary;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Numerics.Tensors;
// ---------------------------------------------------------------------------
// 3-layer MLP for MNIST, trained from scratch (manual backward pass, no
// autograd) built on real Tensor<float> layers -- same style as this repo's
// own cookbook/nn-linear.cs, with TensorPrimitives doing the elementwise and
// dot-product math instead of hand-rolled loops.
// Architecture: 784 -> 128 (ReLU) -> 64 (ReLU) -> 10 (Softmax), cross-entropy
// loss, mini-batch SGD with momentum (mu=0.9).
// Data is downloaded from the MNIST mirror on first run and cached under
// %LOCALAPPDATA%\mnist-dotnet (see MnistData below).
// ---------------------------------------------------------------------------
bool quick = args.Contains("quick");
Console.Write("Loading MNIST data...");
var (trainImagesFull, trainLabelsFull, testImagesFull, testLabelsFull) = await MnistData.LoadAsync();
Console.WriteLine(" Done!");
const int InputSize = 784;
int nTrain = quick ? Math.Min(2000, trainLabelsFull.Length) : trainLabelsFull.Length;
int nTest = quick ? Math.Min(1000, testLabelsFull.Length) : testLabelsFull.Length;
var trainImages = trainImagesFull;
var trainLabels = trainLabelsFull;
var testImages = testImagesFull;
var testLabels = testLabelsFull;
Console.WriteLine($"Train: {nTrain} samples, Test: {nTest} samples");
const int HiddenSize1 = 128;
const int HiddenSize2 = 64;
const int OutputSize = 10;
const int BatchSize = 128;
const float Momentum = 0.9f;
float lr = 0.1f;
int epochs = quick ? 1 : 5;
var rnd = new Random(42);
var net = new MnistNetwork(InputSize, HiddenSize1, HiddenSize2, OutputSize, rnd);
net.PrintArchitecture();
int[] indices = Enumerable.Range(0, nTrain).ToArray();
var sw = System.Diagnostics.Stopwatch.StartNew();
for (int epoch = 0; epoch < epochs; epoch++)
{
Shuffle(indices, rnd);
double epochLoss = 0;
int correct = 0;
for (int start = 0; start < nTrain; start += BatchSize)
{
int end = Math.Min(start + BatchSize, nTrain);
int bs = end - start;
net.ZeroGrad();
for (int b = start; b < end; b++)
{
int idx = indices[b];
ReadOnlySpan<float> x = Augment(trainImages, idx * InputSize, rnd);
byte label = trainLabels[idx];
float loss = net.TrainStep(x, label);
epochLoss += loss;
if (net.LastPrediction == label) correct++;
}
net.UpdateWeights(lr / bs, Momentum);
}
// Learning rate decay after epoch 5, same schedule as upstream.
if (epoch >= 4) lr *= 0.85f;
double trainAcc = 100.0 * correct / nTrain;
double avgLoss = epochLoss / nTrain;
Console.WriteLine($"Epoch {epoch + 1}/{epochs} loss={avgLoss:F4} train_acc={trainAcc:F2}% lr={lr:F5} ({sw.Elapsed.TotalSeconds:F1}s)");
}
// ---- test evaluation ----
int testCorrect = 0;
for (int n = 0; n < nTest; n++)
{
int predicted = net.Predict(testImages.AsSpan(n * InputSize, InputSize));
if (predicted == testLabels[n]) testCorrect++;
}
Console.WriteLine($"Test accuracy: {100.0 * testCorrect / nTest:F2}% ({testCorrect}/{nTest})");
if (!quick)
{
string weightsPath = "weights.bin"; // relative to the directory `dotnet run` was invoked from
net.SaveWeights(weightsPath);
Console.WriteLine($"Weights saved to {Path.GetFullPath(weightsPath)}");
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static float[] Augment(float[] images, int offset, Random rnd, int size = 28)
{
// Random +/-2px shift so the MLP tolerates imperfect centering
// (hand-drawn digits are never centered as cleanly as MNIST's).
int dx = rnd.Next(-2, 3);
int dy = rnd.Next(-2, 3);
var src = images.AsSpan(offset, size * size);
if (dx == 0 && dy == 0) return src.ToArray();
var shifted = new float[size * size];
for (int y = 0; y < size; y++)
{
int sy = y - dy;
if (sy < 0 || sy >= size) continue;
for (int x = 0; x < size; x++)
{
int sx = x - dx;
if (sx < 0 || sx >= size) continue;
shifted[y * size + x] = src[sy * size + sx];
}
}
return shifted;
}
static void Shuffle(int[] arr, Random rnd)
{
for (int i = arr.Length - 1; i > 0; i--)
{
int j = rnd.Next(i + 1);
(arr[i], arr[j]) = (arr[j], arr[i]);
}
}
// =============================================================================
// DENSE LAYER -- Forward & Backward with TensorPrimitives
//
// Forward : z = Wx + b (TensorPrimitives.Dot per output neuron)
// Backward: dW += dOut (x) input (outer product, accumulated over mini-batch)
// db += dOut
// dInput = W^T * dOut (for chain rule to previous layer)
// Update : v = mu*v - lr*dW; W += v (SGD with momentum)
// =============================================================================
class DenseLayer
{
public readonly int InDim, OutDim;
public readonly float[] W, B;
public readonly float[] dW, dB;
public readonly float[] vW, vB;
public readonly float[] CachedInput;
public readonly float[] PreActivation;
public readonly float[] GradInput;
private readonly float[] _tmp;
public DenseLayer(int inDim, int outDim, Random rng)
{
InDim = inDim;
OutDim = outDim;
W = new float[outDim * inDim];
B = new float[outDim];
dW = new float[outDim * inDim];
dB = new float[outDim];
vW = new float[outDim * inDim];
vB = new float[outDim];
CachedInput = new float[inDim];
PreActivation = new float[outDim];
GradInput = new float[inDim];
_tmp = new float[inDim];
// He initialization: W ~ N(0, sqrt(2/fan_in))
float scale = MathF.Sqrt(2.0f / inDim);
for (int i = 0; i < W.Length; i++)
W[i] = (float)NextGaussian(rng) * scale;
}
public void Forward(ReadOnlySpan<float> input, Span<float> output)
{
input.CopyTo(CachedInput);
for (int o = 0; o < OutDim; o++)
{
ReadOnlySpan<float> wRow = W.AsSpan(o * InDim, InDim);
output[o] = TensorPrimitives.Dot(input, wRow) + B[o];
}
output.CopyTo(PreActivation);
}
public void Backward(ReadOnlySpan<float> dOutput)
{
Array.Clear(GradInput);
Span<float> tmp = _tmp.AsSpan(0, InDim);
for (int o = 0; o < OutDim; o++)
{
float dOut = dOutput[o];
ReadOnlySpan<float> wRow = W.AsSpan(o * InDim, InDim);
TensorPrimitives.Multiply(wRow, dOut, tmp);
TensorPrimitives.Add<float>(GradInput, tmp, GradInput);
Span<float> dwRow = dW.AsSpan(o * InDim, InDim);
TensorPrimitives.Multiply((ReadOnlySpan<float>)CachedInput, dOut, tmp);
TensorPrimitives.Add<float>(dwRow, tmp, dwRow);
dB[o] += dOut;
}
}
public void ZeroGrad()
{
Array.Clear(dW);
Array.Clear(dB);
}
public void UpdateWeights(float lr, float mu)
{
for (int i = 0; i < W.Length; i++)
{
vW[i] = mu * vW[i] - lr * dW[i];
W[i] += vW[i];
}
for (int i = 0; i < B.Length; i++)
{
vB[i] = mu * vB[i] - lr * dB[i];
B[i] += vB[i];
}
}
static double NextGaussian(Random rnd)
{
double u1 = 1.0 - rnd.NextDouble();
double u2 = rnd.NextDouble();
return Math.Sqrt(-2.0 * Math.Log(u1)) * Math.Sin(2.0 * Math.PI * u2);
}
}
// =============================================================================
// MNIST NETWORK -- Composes three dense layers with ReLU activation
//
// Architecture: 784 -> hidden1 (ReLU) -> hidden2 (ReLU) -> 10 (Softmax)
//
// Uses TensorPrimitives for:
// - Dot() : matrix-vector multiply in dense layers
// - Max() : ReLU activation (max(0, x))
// - SoftMax() : output probability distribution
// - IndexOfMax() : argmax for prediction
// - Add/Multiply : gradient accumulation, weight updates
// =============================================================================
class MnistNetwork
{
readonly DenseLayer _l1, _l2, _l3;
readonly float[] _z1, _a1;
readonly float[] _z2, _a2;
readonly float[] _z3, _prob;
readonly float[] _dz3, _dz2, _dz1;
public int LastPrediction { get; private set; }
public MnistNetwork(int inputSize, int hidden1, int hidden2, int outputSize, Random rng)
{
_l1 = new DenseLayer(inputSize, hidden1, rng);
_l2 = new DenseLayer(hidden1, hidden2, rng);
_l3 = new DenseLayer(hidden2, outputSize, rng);
_z1 = new float[hidden1]; _a1 = new float[hidden1];
_z2 = new float[hidden2]; _a2 = new float[hidden2];
_z3 = new float[outputSize]; _prob = new float[outputSize];
_dz3 = new float[outputSize];
_dz2 = new float[hidden2];
_dz1 = new float[hidden1];
}
public void PrintArchitecture()
{
var w1 = Tensor.Create<float>(_l1.W, [_l1.OutDim, _l1.InDim]);
var w2 = Tensor.Create<float>(_l2.W, [_l2.OutDim, _l2.InDim]);
var w3 = Tensor.Create<float>(_l3.W, [_l3.OutDim, _l3.InDim]);
int totalParams = (int)(w1.FlattenedLength + w2.FlattenedLength + w3.FlattenedLength)
+ _l1.B.Length + _l2.B.Length + _l3.B.Length;
Console.WriteLine("Network Architecture:");
Console.WriteLine($" Input : [{_l1.InDim}] (28x28 pixels, normalized to [0,1])");
Console.WriteLine($" Dense + ReLU : [{string.Join(" x ", w1.Lengths.ToArray())}] ({w1.FlattenedLength + _l1.B.Length:N0} params)");
Console.WriteLine($" Dense + ReLU : [{string.Join(" x ", w2.Lengths.ToArray())}] ({w2.FlattenedLength + _l2.B.Length:N0} params)");
Console.WriteLine($" Dense + Softmax: [{string.Join(" x ", w3.Lengths.ToArray())}] ({w3.FlattenedLength + _l3.B.Length:N0} params)");
Console.WriteLine($" Total params : {totalParams:N0}");
}
void Forward(ReadOnlySpan<float> input)
{
_l1.Forward(input, _z1);
TensorPrimitives.Max<float>(_z1, 0f, _a1);
_l2.Forward(_a1, _z2);
TensorPrimitives.Max<float>(_z2, 0f, _a2);
_l3.Forward(_a2, _z3);
TensorPrimitives.SoftMax<float>(_z3, _prob);
LastPrediction = TensorPrimitives.IndexOfMax<float>(_prob);
}
public float TrainStep(ReadOnlySpan<float> input, byte label)
{
Forward(input);
float loss = -MathF.Log(MathF.Max(_prob[label], 1e-7f));
// Output gradient: softmax + cross-entropy combined = probs - one_hot(label)
_prob.AsSpan().CopyTo(_dz3);
_dz3[label] -= 1.0f;
_l3.Backward(_dz3);
for (int i = 0; i < _dz2.Length; i++)
_dz2[i] = _l2.PreActivation[i] > 0 ? _l3.GradInput[i] : 0f;
_l2.Backward(_dz2);
for (int i = 0; i < _dz1.Length; i++)
_dz1[i] = _l1.PreActivation[i] > 0 ? _l2.GradInput[i] : 0f;
_l1.Backward(_dz1);
return loss;
}
public int Predict(ReadOnlySpan<float> input)
{
Forward(input);
return LastPrediction;
}
public void ZeroGrad()
{
_l1.ZeroGrad();
_l2.ZeroGrad();
_l3.ZeroGrad();
}
public void UpdateWeights(float lr, float momentum)
{
_l1.UpdateWeights(lr, momentum);
_l2.UpdateWeights(lr, momentum);
_l3.UpdateWeights(lr, momentum);
}
public void SaveWeights(string path)
{
using var fs = new FileStream(path, FileMode.Create);
using var bw = new BinaryWriter(fs);
bw.Write(_l1.InDim);
bw.Write(_l1.OutDim);
bw.Write(_l2.OutDim);
bw.Write(_l3.OutDim);
foreach (var v in _l1.W) bw.Write(v);
foreach (var v in _l1.B) bw.Write(v);
foreach (var v in _l2.W) bw.Write(v);
foreach (var v in _l2.B) bw.Write(v);
foreach (var v in _l3.W) bw.Write(v);
foreach (var v in _l3.B) bw.Write(v);
}
}
// =============================================================================
// MNIST DATA LOADER
//
// Downloads the MNIST dataset from the PyTorch mirror (OSSCI S3 bucket),
// decompresses the .gz files, and parses the IDX binary format.
// Caches decompressed files in %LOCALAPPDATA%\mnist-dotnet for reuse, so the
// download only happens the first time the script runs.
//
// IDX format:
// Images: [magic:4][count:4][rows:4][cols:4][pixels... (uint8)]
// Labels: [magic:4][count:4][labels... (uint8)]
// All multi-byte integers are big-endian.
// =============================================================================
static class MnistData
{
const string BaseUrl = "https://ossci-datasets.s3.amazonaws.com/mnist/";
static readonly string CacheDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"mnist-dotnet");
public static async Task<(float[] trainImg, byte[] trainLbl,
float[] testImg, byte[] testLbl)> LoadAsync()
{
Directory.CreateDirectory(CacheDir);
var train = await LoadSplitAsync("train-images-idx3-ubyte", "train-labels-idx1-ubyte");
var test = await LoadSplitAsync("t10k-images-idx3-ubyte", "t10k-labels-idx1-ubyte");
return (train.images, train.labels, test.images, test.labels);
}
static async Task<(float[] images, byte[] labels)> LoadSplitAsync(string imgFile, string lblFile)
{
byte[] imgBytes = await FetchFileAsync(imgFile);
byte[] lblBytes = await FetchFileAsync(lblFile);
int numImages = BinaryPrimitives.ReadInt32BigEndian(imgBytes.AsSpan(4, 4));
int rows = BinaryPrimitives.ReadInt32BigEndian(imgBytes.AsSpan(8, 4));
int cols = BinaryPrimitives.ReadInt32BigEndian(imgBytes.AsSpan(12, 4));
int pixels = rows * cols;
// Normalize pixel values from [0, 255] to [0.0, 1.0]
float[] images = new float[numImages * pixels];
for (int i = 0; i < images.Length; i++)
images[i] = imgBytes[16 + i] / 255f;
int numLabels = BinaryPrimitives.ReadInt32BigEndian(lblBytes.AsSpan(4, 4));
byte[] labels = lblBytes.AsSpan(8, numLabels).ToArray();
return (images, labels);
}
static async Task<byte[]> FetchFileAsync(string name)
{
string cached = Path.Combine(CacheDir, name);
if (File.Exists(cached))
return await File.ReadAllBytesAsync(cached);
Console.Write($"\n Downloading {name}.gz ... ");
using var http = new HttpClient();
byte[] gz = await http.GetByteArrayAsync(BaseUrl + name + ".gz");
using var gzStream = new GZipStream(new MemoryStream(gz), CompressionMode.Decompress);
using var ms = new MemoryStream();
await gzStream.CopyToAsync(ms);
byte[] data = ms.ToArray();
await File.WriteAllBytesAsync(cached, data);
Console.Write("OK");
return data;
}
}
@jonas1ara

Copy link
Copy Markdown
Author

MNIST from scratch in C#

A digit classifier trained from zero — no ML framework, no autograd. Just
System.Numerics.Tensors doing the dot products, and a hand-written forward

  • backward pass. Runs as a single-file .NET 10 app: dotnet run Mnist.cs.

What it is

A 3-layer MLP (784 → 128 (ReLU) → 64 (ReLU) → 10 (Softmax)), trained with
mini-batch SGD + momentum on the real MNIST dataset:

  • No PyTorch/TensorFlow/ML.NET — every forward, backward and weight
    update is written out by hand, using TensorPrimitives.Dot/Max/SoftMax/Add
    for the vector math.
  • Downloads its own data. First run pulls the 4 MNIST IDX files from the
    public S3 mirror and caches them under %LOCALAPPDATA%\mnist-dotnet
    nothing to download manually, nothing checked into the gist.
  • He init, cross-entropy loss, LR decay, momentum (μ=0.9) — the usual
    suspects, implemented directly instead of hidden behind a framework call.
$ dotnet run Mnist.cs
Loading MNIST data... Done!
Train: 60000 samples, Test: 10000 samples
Network Architecture:
  Input          : [784]  (28x28 pixels, normalized to [0,1])
  Dense + ReLU   : [128 x 784]  (100,480 params)
  Dense + ReLU   : [64 x 128]  (8,256 params)
  Dense + Softmax: [10 x 64]   (650 params)
  Total params   : 109,386
Epoch 1/5  loss=0.4489  train_acc=85.79%  lr=0.10000  (3.9s)
...
Epoch 5/5  loss=0.1321  train_acc=96.00%  lr=0.08500  (19.6s)
Test accuracy: 97.25%  (9725/10000)
Weights saved to ...\weights.bin

Pass quick as an argument (dotnet run Mnist.cs quick) to train on a
2,000-sample slice for 1 epoch — good enough to sanity-check the code in a
couple of seconds instead of ~20.

Draw-and-predict companion (draw.cs)

A gist can only really carry one runnable entry point, so this second file
lives here as documentation rather than as a second gist file — copy it out
if you want to try it. It's a small WinForms app (Windows-only, file-based
app with #:sdk Microsoft.NET.Sdk + #:property UseWindowsForms=true) that
loads the weights.bin produced by Mnist.cs and lets you draw a digit with
the mouse and see the model's live prediction + per-class probability bars.

Mnist-dotnet

draw.cs

Run order:

  1. dotnet run Mnist.cs (without quick) — trains and writes weights.bin.
  2. dotnet run draw.cs — opens the canvas, loads those weights.

The interesting bit is DownsampleToInput(): it crops the drawn strokes to
their bounding box, scales into a 20×20 box preserving aspect ratio, and
centers it in the 28×28 grid — mirroring MNIST's own preprocessing. Skip
that and the MLP (which has no translation invariance) barely recognizes
anything, since it only ever saw pre-centered digits during training.

draw.cs — click to expand
#:sdk Microsoft.NET.Sdk
#:package System.Numerics.Tensors@11.0.0-*
#:property UseWindowsForms=true
#:property TargetFramework=net10.0-windows
#:property PublishTrimmed=false

using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.IO;
using System.Numerics.Tensors;
using System.Windows.Forms;

string weightsPath = ResolveWeightsPath();
var (layer1, layer2, layer3) = LoadWeights(weightsPath);

Application.EnableVisualStyles();
Application.SetColorMode(SystemColorMode.Dark);
Application.Run(new MainForm(layer1, layer2, layer3));

static string ResolveWeightsPath()
{
    string[] candidates =
    {
        "weights.bin",
        Path.Combine(AppContext.BaseDirectory, "weights.bin"),
    };
    foreach (var c in candidates)
        if (File.Exists(c)) return c;

    MessageBox.Show(
        "weights.bin not found. Run mnist.cs first (without 'quick') to train and save the weights.",
        "Model not found", MessageBoxButtons.OK, MessageBoxIcon.Error);
    Environment.Exit(1);
    return "";
}

static (Dense layer1, Dense layer2, Dense layer3) LoadWeights(string path)
{
    using var fs = new FileStream(path, FileMode.Open, FileAccess.Read);
    using var br = new BinaryReader(fs);

    // Header written by MnistNetwork.SaveWeights: InDim, hidden1, hidden2, output
    int input = br.ReadInt32();
    int hidden1 = br.ReadInt32();
    int hidden2 = br.ReadInt32();
    int output = br.ReadInt32();

    var layer1 = new Dense(input, hidden1);
    for (int i = 0; i < layer1.WeightData.Length; i++) layer1.WeightData[i] = br.ReadSingle();
    for (int i = 0; i < layer1.BiasData.Length; i++) layer1.BiasData[i] = br.ReadSingle();

    var layer2 = new Dense(hidden1, hidden2);
    for (int i = 0; i < layer2.WeightData.Length; i++) layer2.WeightData[i] = br.ReadSingle();
    for (int i = 0; i < layer2.BiasData.Length; i++) layer2.BiasData[i] = br.ReadSingle();

    var layer3 = new Dense(hidden2, output);
    for (int i = 0; i < layer3.WeightData.Length; i++) layer3.WeightData[i] = br.ReadSingle();
    for (int i = 0; i < layer3.BiasData.Length; i++) layer3.BiasData[i] = br.ReadSingle();

    return (layer1, layer2, layer3);
}

// Same Tensor<float>-backed Dense layer as mnist.cs (kept standalone since
// file-based apps don't share code between .cs entry points).
class Dense
{
    public Tensor<float> Weight { get; }   // shape [OutFeatures, InFeatures]
    public Tensor<float> Bias { get; }     // shape [OutFeatures]
    public float[] WeightData { get; }
    public float[] BiasData { get; }
    public int InFeatures { get; }
    public int OutFeatures { get; }

    public Dense(int inFeatures, int outFeatures)
    {
        InFeatures = inFeatures;
        OutFeatures = outFeatures;
        WeightData = new float[outFeatures * inFeatures];
        Weight = Tensor.Create<float>(WeightData, [outFeatures, inFeatures]);
        BiasData = new float[outFeatures];
        Bias = Tensor.Create<float>(BiasData, [outFeatures]);
    }

    public ReadOnlySpan<float> WeightRow(int outIdx) => WeightData.AsSpan(outIdx * InFeatures, InFeatures);

    public void Forward(ReadOnlySpan<float> input, Span<float> output)
    {
        for (int o = 0; o < OutFeatures; o++)
            output[o] = TensorPrimitives.Dot(input, WeightRow(o));
        TensorPrimitives.Add(output, BiasData, output);
    }
}

class MainForm : Form
{
    const int CanvasSize = 280;   // 10x scale of the 28x28 MNIST grid
    const int GridSize = 28;

    readonly Dense _layer1, _layer2, _layer3;

    readonly Bitmap _canvasBmp = new(CanvasSize, CanvasSize);
    readonly PictureBox _pictureBox = new() { Size = new Size(CanvasSize, CanvasSize), Location = new Point(20, 20), BorderStyle = BorderStyle.FixedSingle };
    readonly Label _predictionLabel = new() { Location = new Point(20, 320), AutoSize = true, Font = new Font("Segoe UI", 20, FontStyle.Bold) };
    readonly ProgressBar[] _bars = new ProgressBar[10];
    readonly Label[] _barLabels = new Label[10];

    Point _lastPoint;
    bool _isDrawing;

    public MainForm(Dense layer1, Dense layer2, Dense layer3)
    {
        _layer1 = layer1; _layer2 = layer2; _layer3 = layer3;

        Text = "MNIST - Draw a digit";
        ClientSize = new Size(520, 420);
        FormBorderStyle = FormBorderStyle.FixedDialog;
        MaximizeBox = false;

        ClearCanvas();
        _pictureBox.Image = _canvasBmp;
        _pictureBox.MouseDown += (s, e) => { _isDrawing = true; _lastPoint = e.Location; };
        _pictureBox.MouseMove += OnCanvasMouseMove;
        _pictureBox.MouseUp += (s, e) => { _isDrawing = false; Predict(); };
        Controls.Add(_pictureBox);

        var clearBtn = new Button { Text = "Clear", Location = new Point(20, 370), Size = new Size(100, 30) };
        clearBtn.Click += (s, e) => { ClearCanvas(); ResetBars(); };
        Controls.Add(clearBtn);

        var predictBtn = new Button { Text = "Predict", Location = new Point(130, 370), Size = new Size(100, 30) };
        predictBtn.Click += (s, e) => Predict();
        Controls.Add(predictBtn);

        Controls.Add(_predictionLabel);

        for (int d = 0; d < 10; d++)
        {
            var lbl = new Label { Text = $"{d}:", Location = new Point(320, 20 + d * 30), Size = new Size(20, 20) };
            var bar = new ProgressBar { Location = new Point(345, 20 + d * 30), Size = new Size(150, 20), Minimum = 0, Maximum = 100 };
            _barLabels[d] = lbl;
            _bars[d] = bar;
            Controls.Add(lbl);
            Controls.Add(bar);
        }
    }

    void ClearCanvas()
    {
        using var g = Graphics.FromImage(_canvasBmp);
        g.Clear(Color.Black);
        _pictureBox.Invalidate();
        _predictionLabel.Text = "";
    }

    void ResetBars()
    {
        foreach (var b in _bars) b.Value = 0;
    }

    void OnCanvasMouseMove(object? sender, MouseEventArgs e)
    {
        if (!_isDrawing) return;
        using var g = Graphics.FromImage(_canvasBmp);
        using var pen = new Pen(Color.White, 18) { StartCap = LineCap.Round, EndCap = LineCap.Round };
        g.DrawLine(pen, _lastPoint, e.Location);
        _lastPoint = e.Location;
        _pictureBox.Invalidate();
    }

    void Predict()
    {
        float[] x = DownsampleToInput();

        var z1 = new float[_layer1.OutFeatures];
        var a1 = new float[_layer1.OutFeatures];
        _layer1.Forward(x, z1);
        TensorPrimitives.Max(z1, 0f, a1); // ReLU

        var z2 = new float[_layer2.OutFeatures];
        var a2 = new float[_layer2.OutFeatures];
        _layer2.Forward(a1, z2);
        TensorPrimitives.Max(z2, 0f, a2); // ReLU

        var z3 = new float[_layer3.OutFeatures];
        _layer3.Forward(a2, z3);

        var probs = new float[_layer3.OutFeatures];
        TensorPrimitives.SoftMax(z3, probs);

        int best = TensorPrimitives.IndexOfMax(probs);

        _predictionLabel.Text = $"Prediction: {best}  ({probs[best] * 100f:F1}%)";
        for (int d = 0; d < 10; d++)
        {
            _bars[d].Value = Math.Clamp((int)(probs[d] * 100), 0, 100);
            _barLabels[d].Font = d == best ? new Font(_barLabels[d].Font, FontStyle.Bold) : new Font("Segoe UI", 8.25f, FontStyle.Regular);
        }
    }

    // Mirrors MNIST's own preprocessing: crop to the drawn content, fit it into a
    // 20x20 box preserving aspect ratio, then center it in the 28x28 grid.
    // Without this step a hand-drawn digit rarely lines up with what the MLP
    // (which has no translation invariance) learned from centered MNIST digits.
    float[] DownsampleToInput()
    {
        int minX = CanvasSize, minY = CanvasSize, maxX = -1, maxY = -1;
        for (int y = 0; y < CanvasSize; y++)
        {
            for (int x = 0; x < CanvasSize; x++)
            {
                if (_canvasBmp.GetPixel(x, y).R > 20)
                {
                    if (x < minX) minX = x;
                    if (x > maxX) maxX = x;
                    if (y < minY) minY = y;
                    if (y > maxY) maxY = y;
                }
            }
        }

        var result = new float[GridSize * GridSize];
        if (maxX < 0) return result; // nothing drawn yet

        int boxW = maxX - minX + 1;
        int boxH = maxY - minY + 1;

        using var cropped = _canvasBmp.Clone(new Rectangle(minX, minY, boxW, boxH), _canvasBmp.PixelFormat);

        float scale = 20f / Math.Max(boxW, boxH);
        int newW = Math.Max(1, (int)Math.Round(boxW * scale));
        int newH = Math.Max(1, (int)Math.Round(boxH * scale));

        using var resized = new Bitmap(newW, newH);
        using (var g = Graphics.FromImage(resized))
        {
            g.InterpolationMode = InterpolationMode.HighQualityBilinear;
            g.PixelOffsetMode = PixelOffsetMode.HighQuality;
            g.DrawImage(cropped, 0, 0, newW, newH);
        }

        using var final = new Bitmap(GridSize, GridSize);
        using (var g = Graphics.FromImage(final))
        {
            g.Clear(Color.Black);
            g.DrawImage(resized, (GridSize - newW) / 2, (GridSize - newH) / 2);
        }

        for (int row = 0; row < GridSize; row++)
            for (int col = 0; col < GridSize; col++)
                result[row * GridSize + col] = final.GetPixel(col, row).R / 255f;
        return result;
    }
}

Requirements

.NET 10 SDK (file-based apps — no .csproj needed, the #:package /
#:sdk directives at the top of each file handle restore).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment