☰
纯仓颉实现 MNIST 手写数字识别:BP 神经网络 + Adam 优化器 + spawn 协程并行加速
2026/10/14 20:23:40 网站建设 项目流程
  • 示例工程
  • 教程

【免费下载链接】Cangjie-Examples

本仓将收集和展示高质量的仓颉示例代码,欢迎大家投稿,让全世界看到您的妙趣设计,也让更多人通过您的编码理解和喜爱仓颉语言。

项目地址:https://gitcode.com/Cangjie/Cangjie-Examples
点击查看免费下载

本文以仓库 MINIST 项目为主体,完整剖析一个不依赖任何深度学习框架、纯仓颉(Cangjie)语言编写的 BP 神经网络手写数字识别系统:从 MNIST 二进制数据集解析、网络结构与超参数设定,到前向/反向传播、Adam 优化器与spawn {}协程并行矩阵运算的源码级实现,并给出可复现的训练与评估流程。读完本文,你将掌握如何在仓颉中从零搭建一个可收敛、可并行加速的神经网络训练管线,并理解其约 4 倍训练提速与单 epoch 收敛背后的工程细节。

项目定位:纯仓颉的神经网络实践

MINIST 是一个运行于仓颉语言之上的完整机器学习示例:手写数字识别(MNIST 分类)全程使用仓颉实现,无需 Python、TensorFlow、PyTorch 等任何外部深度学习框架。项目的核心目标并非追求超越主流框架的性能,而是展示仓颉语言在数值计算、并发编程与面向对象设计上的综合能力。

从 cjpm.toml 可以看到,这是一个标准的仓颉可执行程序包:

[dependencies] [package] cjc-version = "0.53.21" compile-option = "" description = "nothing here" link-option = "" name = "MiniST" output-type = "executable" src-dir = "" target-dir = "" version = "1.0.0" package-configuration = {}
  • 包名MiniST,输出类型为executable(可执行程序);
  • 编译目标为cjc-version = "0.53.21",即需要该版本(或兼容版本)的仓颉编译器环境;
  • 无任何第三方依赖,数据加载、网络训练、评估全部由src目录下的 7 个仓颉源文件完成。

项目在 README.md 中自我描述的两大工程亮点是:利用spawn {}协程并行化矩阵运算,使训练耗时相比串行实现减少约 4 倍;采用 Adam 优化器,一个 epoch 即可基本收敛,非常适合快速验证与实验。后续章节将逐一从源码层面验证这些结论。

网络结构与超参数设定

项目的神经网络是一个三层全连接 BP 网络,结构与超参数在 main.cj 中集中定义:

层类型输入大小输出大小激活函数
输入层784--
隐藏层784500ReLU
输出层50010Softmax

对应的关键超参数:

参数值说明
学习率(LEARNING_RATE)0.005Adam 优化器的全局步长
正则化系数(REGULARIZATION)0.01L2 正则项强度
Batch Size(BATCH_SIZE)200每个批次处理的样本数
Epochs(EPOCHS)1完整训练轮数
  • 输入层 784:MNIST 每张图像为 28×28 灰度图,展平后恰为 784 维特征向量;
  • 隐藏层 500 + ReLU:作为特征变换层,激活函数采用f(x) = max(0, x);
  • 输出层 10 + Softmax:对应 0-9 十个数字类别,Softmax 将网络输出归一化为概率分布,概率最大的索引即预测类别。

值得说明的是,这些超参数并非硬编码在BPNetwork类内部,而是由main函数以构造参数传入(BPNetwork 构造函数),因此可以很方便地调整隐藏层宽度、学习率、正则系数等来实验不同配置。

数据集:MNIST 二进制格式解析

项目使用经典的MNIST 手写数字数据集,仓库 MINIST/dataset 下已直接附带四个文件:

文件内容
train-images-idx3-ubyte训练集图像,60,000 张 28×28 灰度图
train-labels-idx1-ubyte训练集标签
t10k-images-idx3-ubyte测试集图像,10,000 张 28×28 灰度图
t10k-labels-idx1-ubyte测试集标签

大端序头部解析

MNIST 官方文件是 IDX 二进制格式,头部为大端序(Big-Endian)整数。MiniSTLoader.cj 手工实现了 32 位大端整数的解包:

// 解析大端格式32位整数 public func unpackI32(buffer: Array<UInt8>, offset: Int32): Int32 { return (Int32(buffer[Int64(offset)])) << 24 | (Int32(buffer[Int64(offset + 1)])) << 16 | (Int32(buffer[Int64(offset + 2)])) << 8 | (Int32(buffer[Int64(offset + 3)])) }

图像文件头部结构为:magic(4字节) + 图像数(4字节) + 行数(4字节) + 列数(4字节),随后是像素数据;标签文件头部为magic(4字节) + 标签数(4字节),随后每个字节一个标签。loadImages 从偏移 4/8/12 处读取图像数、宽、高,并从偏移 16 开始按像素值 / 255.0将灰度归一化到 0-1 区间;loadLabels 则将整数标签转换为one-hot 编码(10 维向量,对应位置为 1.0,其余为 0.0),直接匹配 Softmax 输出的形状,方便计算交叉熵损失。

加载时使用了"预分配内存避免动态扩容"的策略(Array预先指定长度),并一次性readToEnd()读取整个文件,避免频繁 IO。整份数据集的加载入口是loadData()(MiniSTLoader.cj),返回(images, labels)二元组。

前向传播:从 784 维输入到 10 类概率

权重初始化:截断 Xavier

网络权重采用Xavier(Glorot)初始化,实现在 MatrixUtils.randomInit:标准差取sqrt(2 / (fan_in + fan_out)),再使用仓颉标准库的Random().nextGaussianFloat32(mean: 0.0, sigma: stddev)生成正态分布随机数,并截断到[-2σ, 2σ]区间,防止极端初始值导致梯度爆炸或消失。两个偏置向量则统一初始化为0.01(BPNetwork.cj)。

批次前向传播

训练采用批次(batch)模式,BPNetwork.forwardBatch 完成如下计算链:

输入 batch (m×784) → 隐藏层线性变换: batch × W1 (784×500) → 加偏置 b1 → ReLU 激活 → 输出层线性变换: hidden × W2 (500×10) → 加偏置 b2 → 返回 (hiddenOut, outputAct)

其中批次化的矩阵乘法、偏置广播、ReLU 与 Softmax 均由 MatrixUtils.cj 提供:

  • batchVecMatMul(batch, mat):批量向量×矩阵乘法,外层按chunkSize切分列维度并用spawn并行,内层在累加时跳过值为 0 的输入(针对 MNIST 大量背景像素为 0 的稀疏优化,见 MatrixUtils.cj);
  • batchAddBias:将偏置向量按行广播到批次矩阵;
  • batchRelu:x > 0 ? x : 0的逐元素激活;
  • batchSoftmax:数值稳定版(MatrixUtils.cj)——先求每行最大值maxVal,再计算exp(x - maxVal),避免大数值指数溢出,这是 README 中提到的"修复数值稳定性问题"之一。

损失函数:交叉熵 + L2 正则

computeBatchLoss 在批次上计算两类损失的加权和:

  1. 交叉熵损失:-Σ y_j · log(p_j),其中p_j为 Softmax 概率,为避免log(0)引入epsilon = 1e-10下限保护;
  2. L2 正则化:对所有权重的平方和乘以0.5 * λ / m(λ 即 REGULARIZATION=0.01,m 为批次样本数),用于抑制过拟合。

最终totalLoss / m + regLoss作为批次平均损失。

反向传播与参数更新

backwardBatch 是训练的核心,按六步完成误差计算与参数更新:

  1. 输出层误差:softmaxOutput - labels(Softmax 交叉熵的梯度恰为此形式);
  2. 隐藏层误差:输出层误差 × W2ᵀ ⊙ ReLU',其中 ReLU 导数为hiddenOut > 0 ? 1 : 0(BPNetwork.cj);
  3. 更新隐藏→输出权重 W2:梯度 = 平均梯度 + 正则项(gradient/batchSize + λ·w);
  4. 更新输出层偏置 b2:无正则项的平均梯度;
  5. 更新输入→隐藏权重 W1:梯度 = 平均梯度 + 正则项;
  6. 更新隐藏层偏置 b1:无正则项的平均梯度。

源码注释明确标注了第五步"更新输入→隐藏权重"是全流程的计算瓶颈(BPNetwork.cj),因此对它施加了并发提升最明显的并行切分——这正是下一节协程加速的着力点。

Adam 优化器:自适应学习率的核心

Adam(Adaptive Moment Estimation,出自论文 arXiv:1412.6980)为每个参数维护一阶动量m(均值)与二阶动量v(方差),并对二者做偏差修正。AdamOptimizer.cj 的实现要点:

  • 默认超参数(AdamOptimizerConfig):
参数默认值含义
adamBeta10.9一阶动量衰减系数
adamBeta20.999二阶动量衰减系数
adamEpsilon1e-8数值稳定项(防除零)
  • 动量更新:m = β1·m + (1-β1)·g,v = β2·v + (1-β2)·g²;
  • 偏差修正:m̂ = m / (1 - β1^t),v̂ = v / (1 - β2^t),其中t为adamIteration(在每次反向传播时递增 1,见 BPNetwork.cj);
  • 参数更新(以 W2 为例,BPNetwork.cj):
weightHiddenToOutput[iIndex][jIndex] -= (learningRate / (sqrt(vHat) + optimizer.adamEpsilon)) * mHat

即w ← w - lr · m̂ / (√v̂ + ε)。由于 Adam 对每个参数自动缩放有效步长,即使全局学习率仅 0.005,也足以在一个 epoch 内基本收敛。类中为 W1/b1/W2/b2 四组参数分别维护了独立的m、v动量矩阵/数组,初始化全为 0(AdamOptimizer.cj)。

spawn 协程并行加速:约 4 倍提速的实现原理

项目最大的工程特色是使用仓颉spawn {}协程并行化矩阵运算。AsyncTask.cj 提供了一个极简的并行执行助手:

public func AsyncTask(n: Int64, lam: (Int64) -> Future<Unit>) { for (future in Array<Future<Unit>>(n, lam)) { future.get() } }

其语义是:按块数n生成n个协程任务,每个任务由闭包lam(chunkIndex)内嵌的spawn {}创建,随后逐个get()等待全部完成。这一"分块-并行-汇合"的模式被大量复用于:

  • 批次矩阵乘法:按矩阵列数n / chunkSize切分,各协程独立累加各自的输出列(MatrixUtils.cj);
  • 偏置广播、ReLU、Softmax:按行数(m + chunkSize - 1) / chunkSize切分,行间互不依赖([MatrixUtils.cj](https://link.gitcode.com/i/246622b55f1d1da98e2e138ce23d6918#L52-L66, L75-L95, L103-L136));
  • 反向传播:输出层误差、隐藏层误差的计算,以及 W2、W1 两个权重矩阵的梯度与 Adam 更新,均按chunkSize分块并行(BPNetwork.cj);
  • 准确率评估:预测循环按块并行,正确计数使用AtomicInt32的fetchAdd保证线程安全(Trainer.cj)。

分块粒度由 BPNetwork.chunkSize = 20 统一控制:块太小则协程调度开销占比上升,块太大则并行度不足,20 是项目针对当前网络规模选取的折中值。项目在 README 中给出的实测结论是:协程优化使训练耗时相比串行实现下降约 4 倍(该数据为项目自述,实际收益与 CPU 核数、硬件环境相关)。

训练器:批次训练、数据打乱与验证集评估

Trainer.cj 负责训练全流程的组织:

  • 数据打乱(Fisher-Yates):shuffleArray 以引用方式原地打乱索引数组,每个 epoch 开始前重新洗牌,保证批次间样本分布的随机性;
  • 训练/验证集拆分:trainValSplit 按valRatio(主程序中为0.1,即 10%)从 60,000 张训练图中划出验证集,用于训练过程中的实时评估;
  • 批次主循环:train 按batchesPerEpoch = (total + batchSize - 1) / batchSize(向上取整)遍历批次,每个批次依次执行forwardBatch → computeBatchLoss → backwardBatch,并使用MonoTime统计每批耗时;每 10 个批次打印平均损失与耗时,每批的日志中还包含验证集准确率;
  • 准确率计算:accuracy 并行遍历数据集,对每张图调用network.predict取 Softmax 最大概率索引,与 one-hot 标签比对,最终返回正确数 / 总数。

单样本推理路径由 BPNetwork.predict 完成:使用单样本版本的vecMatMul、vecAdd、relu、softmax辅助函数做前向传播,然后扫描 10 维概率向量返回最大值的索引作为预测类别。

主程序流程与运行方式

main.cj 串起完整的训练评估流水线:

  1. 加载数据:创建两个MiniSTLoader,分别读取./dataset/train-images-idx3-ubyte与./dataset/t10k-images-idx3-ubyte(路径相对于 MINIST 项目目录,仓库已内置数据集文件);
  2. 拆分验证集:Trainer.trainValSplit(..., 0.1),得到 54,000 训练样本与 6,000 验证样本;
  3. 初始化网络与训练器:BPNetwork(784, 500, 10, 0.005, 0.01)+Trainer(network, 200, 1);
  4. 启动训练:trainer.train(...),一个 epoch 内打印批次级损失/耗时与验证集准确率;
  5. 测试集评估:trainer.accuracy(testImages, testLabels)输出最终测试集准确率。

运行前提与方式:

  • 需要仓颉编译器/包管理器环境(cjpm.toml指定cjc-version = "0.53.21");
  • 在 MINIST 项目目录下执行cjpm build(编译)或cjpm run(编译并运行);
  • 数据集文件需位于dataset/目录下(仓库 MINIST/dataset 已自带,无需另行下载);
  • 若要修改网络结构或超参数,直接调整 main.cj 中对应的常量即可。

关于最终效果,README 给出的参考结论是:一个 epoch 即可基本收敛,测试集准确率约 90%-92%(具体数值与权重随机初始化、运行环境相关,项目将其定位为"快速验证和实验"用途,而非追求 SOTA)。

文件结构与快速导航

MINIST/ ├─ cjpm.toml # 包配置(MiniST,executable,cjc 0.53.21) ├─ README.md # 项目说明文档 ├─ dataset/ # MNIST 数据集(训练 60,000 + 测试 10,000) └─ src/ ├─ main.cj # 主程序:数据加载 → 训练 → 测试集评估 ├─ BPNetwork.cj # BP 神经网络:前向/反向传播、损失计算、预测 ├─ Trainer.cj # 训练器:批次循环、洗牌、验证集拆分、准确率 ├─ MatrixUtils.cj # 矩阵运算工具:并行乘法、ReLU、Softmax、Xavier 初始化 ├─ AdamOptimizer.cj # Adam 优化器:动量与偏差修正 ├─ MiniSTLoader.cj # MNIST 二进制数据集解析与 one-hot 编码 └─ AsyncTask.cj # spawn 协程并行执行助手

若想进一步研究,推荐按如下顺序阅读源码:AsyncTask.cj(并行原语)→ MiniSTLoader.cj(数据管线)→ MatrixUtils.cj(数值核心)→ BPNetwork.cj(网络主体)→ AdamOptimizer.cj(优化算法)→ Trainer.cj(训练流程)→ main.cj(整体编排)。

小结

MINIST 用约 7 个仓颉源文件完整复现了"数据加载 → 网络构建 → 训练 → 评估"的神经网络标准流程,其价值体现在三个层面:一是纯仓颉实现,不依赖任何深度学习框架,展示了仓颉在数值计算与文件解析上的实用性;二是工程优化扎实,数值稳定的 Softmax、截断 Xavier 初始化、稀疏跳过、批次化向量运算与spawn {}协程并行层层叠加,最终带来约 4 倍的训练提速;三是算法选择合理,Adam 优化器配合 0.005 学习率让单 epoch 训练即可达到 90% 以上的测试准确率,适合作为仓颉语言机器学习实践与教学的良好起点。参考学习材料可关注 BP(反向传播)算法原理与 Adam 优化器原始论文(arXiv:1412.6980)。

  • 示例工程
  • 教程

【免费下载链接】Cangjie-Examples

本仓将收集和展示高质量的仓颉示例代码,欢迎大家投稿,让全世界看到您的妙趣设计,也让更多人通过您的编码理解和喜爱仓颉语言。

项目地址:https://gitcode.com/Cangjie/Cangjie-Examples
点击查看免费下载
上一篇:在 CMake 中禁用彩色输出:NO_COLOR 环境变量完全指南
下一篇:Jasmine 2.8.0 发布解析:nothing 匹配器、arrayWithExactContents 与相等性比较的全面增强

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询