Created
August 1, 2026 05:52
-
-
Save jonas1ara/d284b04c8c039ce3dc7dcf3d2361f813 to your computer and use it in GitHub Desktop.
Mnist.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #: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; | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
MNIST from scratch in C#
A digit classifier trained from zero — no ML framework, no autograd. Just
System.Numerics.Tensorsdoing the dot products, and a hand-written forwarddotnet run Mnist.cs.What it is
A 3-layer MLP (
784 → 128 (ReLU) → 64 (ReLU) → 10 (Softmax)), trained withmini-batch SGD + momentum on the real MNIST dataset:
update is written out by hand, using
TensorPrimitives.Dot/Max/SoftMax/Addfor the vector math.
public S3 mirror and caches them under
%LOCALAPPDATA%\mnist-dotnet—nothing to download manually, nothing checked into the gist.
suspects, implemented directly instead of hidden behind a framework call.
Pass
quickas an argument (dotnet run Mnist.cs quick) to train on a2,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) thatloads the
weights.binproduced byMnist.csand lets you draw a digit withthe mouse and see the model's live prediction + per-class probability bars.
draw.cs
Run order:
dotnet run Mnist.cs(withoutquick) — trains and writesweights.bin.dotnet run draw.cs— opens the canvas, loads those weights.The interesting bit is
DownsampleToInput(): it crops the drawn strokes totheir 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 expandRequirements
.NET 10 SDK (file-based apps — no
.csprojneeded, the#:package/#:sdkdirectives at the top of each file handle restore).