手写数字识别(MNIST)
在 MNIST 上训练的神经网络,由 Rust + WebAssembly 在浏览器本地实时识别你写的数字
?
写一个 0~9 的数字
网络看到的 28×28 输入
隐藏层 64 个神经元
🖱️ 按住拖动 / 👆 手指 在黑色区域写一个数字,边写边识别 | 数字写大一点、居中效果最好
📖 原理说明
MNIST 是机器学习的「Hello World」:7 万张 28×28 的手写数字灰度图。这里用它训练了一个两层神经网络,权重压缩成 int8 后只有约 50 KB,直接编译进 .wasm。你在画板上写的每一笔都会实时送进网络,识别完全在浏览器本地完成,不需要服务器。
🧮算法原理
h = ReLU(W1x + b1),p = softmax(W2h + b2)784 个像素 → 64 个隐藏神经元 → 10 个数字的概率
w ≈ q · s,q ∈ [−127, 127]int8 量化:每个神经元一组权重共用一个缩放系数 s
- 预处理:和 MNIST 对齐
- MNIST 的数字都被缩放到 20×20 的框内,再按「重心」放到 28×28 画面的正中。画板上的数字位置、大小各不相同,所以先裁出笔迹的边界框,按长边缩放到 20 像素(用区域平均,自然产生灰度边缘),再把重心平移到中心。右边的小图就是网络实际看到的输入。
- 隐藏层
- 64 个神经元各自对某种笔画特征敏感(比如左侧竖线、上方弧线)。小方格里越亮的神经元被激活得越强,写不同的数字时亮起的组合完全不同。
- 训练与数据增强
- 用 Rust 写的训练程序(
examples/train.rs)在 CPU 上做小批量随机梯度下降,30 轮约 70 秒。手写的数字没有 MNIST 那么规整,所以每一轮都给每张训练图随机旋转、缩放、错切、平移一点,网络学到的是「数字的形状」而不是固定的像素位置。
- int8 量化
- 训练出的 5 万多个权重原本是 32 位浮点数(约 200 KB)。每个神经元找出绝对值最大的权重,把整组权重线性映射到 −127~127 存成 1 个字节,推理时再乘回缩放系数。体积缩小到 1/4,准确率基本不变。
🔄Rust 与 JavaScript 的分工
- JS每画一笔,JS 读出画板像素,取一个通道作为「墨迹」数组,调用
digits_preprocess(墨迹, 宽, 高)。
- RustRust 裁剪、缩放、按重心居中,得到 784 个 0~1 的值;
digits_classify(输入) 用 int8 权重做两层矩阵乘法,返回 10 个概率和 64 个隐藏层激活值。
- JSJS 更新概率条和预测结果,并把网络输入和隐藏层画成小图。一次预处理加推理约 0.5ms,所以可以边写边识别。
⚡性能要点
- 在 MNIST 的 1 万张测试图上,int8 量化后的准确率为 98.49%(量化前 98.48%)。自己写的数字比测试集更多样,写得太小、太偏或笔画太细时仍可能认错。
- 训练是确定性的:同样的数据和轮数,每次得到完全相同的权重文件。重新训练需要先下载 MNIST 并解压,然后运行
cargo run --release -p digits --example train -- <目录>,会覆盖 src/weights.bin。
- 这个 .wasm 约 74 KB,比其他演示大,主要是嵌入的 50 KB 权重。
源码crates/games/digits/src/lib.rscrates/games/digits/examples/train.rswww/digits/index.js