基于三层神经网络实现手写数字分类
摘要
基于三层神经网络实现手写数字分类
环境
硬件平台:MLU 云平台环境。
软件环境:PyTorch 框架,CNNL 与 Torch-MLU-OPS 加速库,Python 3 及 Pillow、SciPy、
NumPy 等扩展库。
数据集:MNIST 手写数字库[78]。该数据集包含一个训练集和一个测试集,其中训练集
有 60000 个样本,测试集有 10000 个样本。每个样本都由灰度图像(即单通道图像)及其标记
组成,每个样本图像的大小为 28×28。MNIST 数据集包含 4 个文件,分别是训练集图像、训
练集标记、测试集图像、测试集标记。下载地址为http://yann.lecun.com/exdb/mnist/。、
MLU-OPS 是 CNNL 算子库的开源版本
新建运行环境
手册:https://paas.extrotec.com:30443/docs/guide/training/dev_environ.html
这里访问方式选ssh就是ssh连接。vscode可以用VSCode server,比较方便。时长是指环境会存在多久
选择镜像收藏:mlu370_ubuntu22.04-for-student:v6.1


Code Server连接到运行环境后
添加文件夹到workspace,选择/opt
实验文件都在这里

可以看到具体步骤:
补全 stu_upload 中的 layer_1.py、mnist_mlp_cpu.py 文件,执行 main_train_cpu.py 运行实验。
实验内容
基于 CPU 和 MLU 两种平台,设计并实现一个三层全连接神经网络,用于手写数字图像的分类
- 数据加载模块:从文件中读取数据,并进行预处理,包括归一化、维度变换等处理。
如果需要人为对数据进行随机数据扩增,则数据扩增处理也在数据加载模块中实现。 - 基本单元模块:实现神经网络中不同类型的网络层的定义、前向传播计算、反向传
播计算等功能。 - 网络结构模块:利用基本单元模块建立一个完整的神经网络。
- 网络训练模块:该模块实现用训练集进行神经网络训练的功能。在已建立的神经网
络结构基础上,实现神经网络的前向传播、神经网络的反向传播、神经网络参数更新、神
经网络参数保存等基本操作,以及训练函数主体。 - 网络推理模块:该模块实现使用训练得到的网络模型,对测试样本进行预测的过程。
具体实现的操作包括训练得到的网络模型参数的加载、神经网络的前向传播等。
神经网络:
三个全连接层,前两个接ReLU 激活函数,最后加Softmax 层计算交叉熵损失

本实验中需要实现的基本单元模块包括全连接层、ReLU 激活函数层和 Softmax
损失层。
在神经网络实现中,通常同类型的层用一个类来定义,多个同类型的层用类的实例来实现,层内的计算用类的成员函数来定义。类的成员函数通常包括层的初始化、参数的初始化、前向传播计算、反向传播计算、参数的更新、参数的加载和保存等。
其中层的初始化函数一般会根据实例化层时的输入确定该层的超参数,例如该层的输入神经元数量和输出神经元数量等;参数的初始化函数会对该层的参数(如全连接层中的权重和偏置)定义变量,并填充初始值;前向传播函数利用前一层的输出作为本层的输入,计算本层的输出结果;反向传播函数根据链式法则逆序逐层计算损失函数对权重和偏置的梯度;参数的更新函数利用反向传播函数计算的梯度对本层的参数进行更新;参数的加载函数从给定的文件中加载参数的值,参数的保存函数将当前层参数的值保存到指定的文件中。有些层,如激活函数层,可能没有参数,就不需要定义参数的初始化、更新、加载和保存函数。有些层,如激活函数层和损失函数层,其输出维度由输入维度决定,不需要人工设定,因此不需要层的初始化函数。
三层神经网络 CPU 训练
基本单元模块(网络层们)
全连接层
输入:一维向量 𝒙,维度为 𝑚
输出:一维向量 𝒚,维度为 𝑛
权重:是二维矩阵𝑾,维度为 𝑚 × 𝑛
偏置:是一维向量 𝒃,维度为 𝑛
神经网络损失函数: 𝐿
前向传播和反向传播
forward前向传播:𝒚 = 𝑾^𝑇^ 𝒙 + 𝒃
backward反向传播:
神经网络损失函数 𝐿 对当前全连接层的输出 𝒚 的偏导 ▽𝒚 𝐿 = $\frac{𝜕𝐿}{𝜕𝒚}$,其维度与全连接层的输出 𝒚 相同,均为 𝑛。根据链式法则,全连接层的权重和偏置的梯度 $▽_𝑾 𝐿 = 𝜕𝐿/𝜕𝑾 和 ▽_𝒃 𝐿 = 𝜕𝐿/𝜕𝒃 $,以及损失函数对输入的偏导 ▽𝒙 𝐿 = 𝜕𝐿/𝜕𝒙 的计算公式分别为:
$$
▽_𝑾𝐿 = 𝒙(▽_𝒚 𝐿)^𝑇 \
▽_𝒃𝐿 = ▽_𝒚 𝐿 \
▽_𝒙𝐿 = 𝑾^𝑇▽𝒚 𝐿
$$
推导:
(第一接触矩阵偏导,有种拆开了算,算完再合回去的感觉…)

实际应用中通常使用批量(batch)随机梯度下降算法进行反向传播计算,即选择若干个样本同时计算。假设选择的样本量为 𝑝,此时
输入:二维矩阵 𝑿,维度为 𝑝 × 𝑚,每行代表一个样本。
输出:二维矩阵 𝒀,维度为 𝑝 × 𝑛。
此时全连接层的前向传播计算公式由公式(6.1)变为
𝒀 = 𝑿𝑾 + 𝒃 (6.3)
其中的 + 代表广播运算,表示偏置 𝒃 中的元素会被加到 𝑿𝑾 的乘积矩阵对应的一行元素中。
权重和偏置的梯度以及损失函数对输入的偏导的计算公式由公式(6.2)变为
$$
▽_𝑾 𝐿 = 𝑿^𝑇 ▽_𝒀 𝐿 \
▽_𝒃 𝐿 = 1▽_𝒀 𝐿 \
▽_𝑿 𝐿 = ▽_𝒀 𝐿𝑾^𝑇
$$
其中计算偏置的梯度 ▽𝒃 𝐿 时,为确保维度正确,用 $▽_𝒀 𝐿$ 与维度为 1 × 𝑝 的全 1 向量相乘
参数更新
给定学习率 $\eta$,利用反向传播计算得到的权重梯度 $\nabla_{\boldsymbol{W}} L$ 和偏置梯度 $\nabla_{\boldsymbol{b}} L$ 对本层的权重 $\boldsymbol{W}$ 和偏置 $\boldsymbol{b}$ 进行更新:
$$
\boldsymbol{W} \leftarrow \boldsymbol{W} - \eta \nabla_{\boldsymbol{W}} L \tag{6.20}
$$
$$
\boldsymbol{b} \leftarrow \boldsymbol{b} - \eta \nabla_{\boldsymbol{b}} L
$$
实现
很多不知道的东西,就边做边了解了
一些numpy的矩阵操作:
1 | # 从列表创建矩阵 |
在神经网络层的反向传播中,top 和 bottom 指的是数据流动的方向。
top:靠近输出端(损失函数方向)
- 对于当前层,
top_diff是从上一层(靠近输出侧)传回来的梯度 - 即损失函数对当前层输出的梯度:$\frac{\partial L}{\partial \boldsymbol{y}}$
bottom:靠近输入端(数据源方向)
bottom_diff是要传给下一层(靠近输入侧)的梯度- 即损失函数对当前层输入的梯度:$\frac{\partial L}{\partial \boldsymbol{x}}$
1 | def forward(self, input): |
lr 是 学习率(Learning Rate) 的缩写。
1 | def update_param(self, lr): |
ReLU 激活函数层
前向传播
ReLU 激活函数:按元素运算操作(每个元素单独计算),其输出向量 𝒚 的维度与输入向量 𝒙 的维度相同。计算公式:
𝒚(𝑖) = max(0, 𝒙(𝑖))
反向传播
由于 ReLU 激活函数不包含参数,在反向传播计算过程中仅需根据损失函数对输出的偏导 $\nabla_{\boldsymbol{y}} L$ 计算损失函数对输入的偏导 $\nabla_{\boldsymbol{x}} L$。损失函数对本层的第 $i$ 个输入的偏导 $\nabla_{\boldsymbol{x}^{(i)}} L$ 的计算公式为
$$
\nabla_{\boldsymbol{x}^{(i)}} L =
\begin{cases}
\nabla_{\boldsymbol{y}^{(i)}} L, & \boldsymbol{x}^{(i)} \geq 0 \
0, & \boldsymbol{x}^{(i)} < 0
\end{cases} \tag{6.6}
$$
实现
1 | def forward(self, input): |
Softmax 损失层
输入:向量 𝒙
维度: 𝑘。其中 𝑘 对应分类的类别数
Softmax 分类概率 $\hat{\boldsymbol{y}}$
标记:维度为 $k$ 的 one-hot 向量 $\boldsymbol{y}$,
前向传播
在前向传播的计算过程中,对 $\boldsymbol{x}$ 计算 $e$ 指数并进行归一化,即得到 Softmax 分类概率。
假设 $\boldsymbol{x}$ 在位置 $i$ 的值为 $\boldsymbol{x(i)}$,$\hat{\boldsymbol{y}(i)}$ 为 Softmax 分类概率 $\hat{\boldsymbol{y}}$ 在位置 $i$ 的值,$i \in [1, k]$ 且为整数,
则 $\hat{\boldsymbol{y}}^{(i)}$ 的计算公式为
$$
\hat{\boldsymbol{y}}{(i)} = \frac{e^{\boldsymbol{x}{(i)}}}{\sum_j e^{\boldsymbol{x}{(j)}}} \tag{6.7}
$$
反向传播
完成 Softmax 损失层的前向传播之后,取 Softmax 分类概率 $\hat{\boldsymbol{y}}$ 中最大概率对应的类别作为预测的分类类别。
Softmax 损失层还需要根据给定的标记(label,也称为真实值或实际值)计算总的损失函数值。在分类任务中,标记通常表示为一个维度为 $k$ 的 one-hot 向量 $\boldsymbol{y}$,该向量中对应真实类别的分量值为 1,其他值为 0。
Softmax损失层使用交叉熵损失函数 $L$,其计算公式为
$$
L = -\sum_i \boldsymbol{y(i)} \ln \hat{\boldsymbol{y}}{(i)} \tag{6.8}
$$
在反向传播的计算过程中,对于 Softmax 损失层,损失函数对输入的偏导 $\nabla_{\boldsymbol{x}} L$ 的计算公式为
$$
\nabla_{\boldsymbol{x}} L = \frac{\partial L}{\partial \boldsymbol{x}} = \hat{\boldsymbol{y}} - \boldsymbol{y} \tag{6.9}
$$
由于工程实现中使用批量随机梯度下降算法,假设选择的样本量为 $p$,Softmax 损失层
的输入变为二维矩阵 $\boldsymbol{X}$,维度为 $p \times k$,$\boldsymbol{X}$ 的每个行向量代表一个样本,则对每个输入计算
$e$ 指数并进行行归一化得到
$$
\hat{\boldsymbol{Y}} (i, j) = \frac{e^{\boldsymbol{X} (i, j)}}{\sum_k e^{\boldsymbol{X} (i, k)}} \tag{6.10}
$$
其中 $\boldsymbol{X} (i, j)$ 代表 $\boldsymbol{X}$ 中对应第 $i$ 个样本 $j$ 位置的值。当 $\boldsymbol{X} (i, j)$ 数值较大时,求 $e$ 指数可能
会出现数值上溢的问题。因此在实际工程实现时,为确保数值稳定性,会在求 $e$ 指数前先
进行减最大值处理,此时 $\hat{\boldsymbol{Y}} (i, j)$ 的计算公式变为
$$
\hat{\boldsymbol{Y}} (i, j) = \frac{e^{\boldsymbol{X} (i, j) - \max_n \boldsymbol{X} (i, n)}}{\sum_k e^{\boldsymbol{X} (i, k) - \max_n \boldsymbol{X} (i, n)}} \tag{6.11}
$$
在神经网络推理时,取 Softmax 分类概率 $\hat{\boldsymbol{Y}} (i, j)$ 的每个样本,即每个行向量中最大概
率对应的类别作为预测的分类类别。
做神经网络训练时,标记通常表示为一组 one-hot 向量 $\boldsymbol{Y}$,维度为 $p \times k$,其中每行是
一个 one-hot 向量,对应一个样本的标记。则计算损失值的公式 (6.8) 变为
$$
L = -\frac{1}{p} \sum_{i, j} \boldsymbol{Y} (i, j) \ln \hat{\boldsymbol{Y}} (i, j) \tag{6.12}
$$
其中损失值是所有样本的平均损失,因此对样本数量 $p$ 取平均。
在反向传播时,当选择的样本量为 $p$ 时,损失函数对输入的偏导 $\nabla_{\boldsymbol{X}} L$ 的计算公式 (6.9) 变
为
$$
\nabla_{\boldsymbol{X}} L = \frac{1}{p} (\hat{\boldsymbol{Y}} - \boldsymbol{Y}) \tag{6.13}
$$
太复杂了我算不来了直接看结论好了()
实现
1 | def forward(self, input): |
网络结构模块
三层神经网络的网络结构模块的 MLU 实现
定义
1 | class MNIST_MLP(object): |
输入输出的维度由MNIST 数据集的特性决定,隐藏层神经元数量是基于经验设定的超参数。
输入维度 input_size = 784:MNIST 数据集中的每个样本是一张 28×28 像素的灰度手写数字图像。被展平为一维向量,因此特征数量为 28 × 28 = 784
输出维度 out_classes = 10:MNIST 包含数字 0 到 9,共 10 个类别。
隐藏层神经元数量 hidden1=32, hidden2=16:
32、64、128 等 2 的幂次是神经网络设计中的常用选择,便于计算和内存对齐。
从 784 → 32 → 16 → 10 形成“瓶颈”结构,强迫网络学习更紧凑的特征表示,有助于提取核心模式。
| 参数 | 值 | 说明 |
|---|---|---|
batch_size=100 |
每个批次包含 100 个样本 | 兼顾训练速度和内存占用,是常用选择 |
lr=0.01 |
学习率 | 常用初始学习率,对于 SGD 通常设置为 0.01 或 0.1 |
max_epoch=1 |
最大训练轮数为 1 | 通常是为了快速测试,实际训练会设置更大(如 10~30) |
print_iter=100 |
每 100 个迭代打印一次损失 | 用于监控训练过程 |
建立模型
1 | def build_model(self): |
传播
1 | def forward(self, input): |
实验运行
补全 stu_upload 中的 layers_1.py, mnist_mlp_cpu.py 文件。然后运行
1 | python main_train_cpu.py |
调参
| 隐藏层维度 | MaxEpoch | 学习率 | 特殊设置 | 测试准确率 | 结论 |
|---|---|---|---|---|---|
| 32→16 | 1 | 0.01 | 无 | 0.963500 | |
| 128-64 | 5 | 0.01 | 无 | 0.964500 | 增加宽度已经到了瓶颈 |
| 128-64 | 5 | 0.01 | Kaiming Initialization | 0.113500 | 乱猜了,loss一直2.3左右梯度爆炸 |
| 128-64 | 5 | 0.001 | Kaiming Initialization | 0.196100 | Kaiming 不行 |
| 128-64 | 15 | 0.01 | Xavier,loss log(prob + 1e-10) | 0.948800 | |
| 128-64 | 15 | 0.05 | Xavier,loss log(prob + 1e-10) | 0.756600 | |
| 128-32 | 15 | 0.01 | Xavier,loss log(prob + 1e-10) | 0.941100 | |
| 128-32 | 20 | 0.01 | Xavier,loss log(prob + 1e-10) | 0.950300 | |
| 512-128 | 20 | 0.01 | Xavier *0.1,loss log(prob + 1e-10) | 0.977900 | |
| 512-128 | 20 | 0.01 | Xavier *0.1,loss log(prob + 1e-10),十轮学习率减半,leaky relu(0.1) | 0.971800 | |
| 512-128 | 20 | 0.01 | Xavier *0.1,loss log(prob + 1e-10),学习率0.05-0.001,leaky relu(0.1) | 0.971800 | |
| 512-128 | 20 | 0.01 | Xavier,loss log(prob + 1e-10) | ||
| 512-128 | 30 | 0.001 | Xavier *0.1,loss log(prob + 1e-10),学习率0.0005-0.001,leaky relu(0.1) | 0.977200 | |
| 512-128 | 30 | 0.01 | Xavier *0.1,loss log(prob + 1e-10),学习率0.005-0.001,leaky relu(0.1) | 0.977800/0.984000 | |
| 512-128 | 30 | 0.01 | Xavier,loss log(prob + 1e-10),学习率0.005-0.001,leaky relu(0.1) | 0.957800 | |
| 512-128 | 30 | 0.01 | Xavier *0.1,loss log(prob + 1e-10),学习率0.005-0.001,leaky relu(0.01) | 0.976400 |
测试环节非常混乱。。最后居然达到了0.984000的好成绩。但是也不知道为啥之前那次测的就没有这么好,估计还是哪个参数忘记改了
最后采用的方案是:维度512-128,epoch30,Xavier *0.1,loss log(prob + 1e-10),学习率0.01-0.005-0.001,leaky relu(0.1)
主要改动:
1 | def init_param(self, std=0.01): |
leaky relu(0.1)
1 | def forward(self, input): |
参数
1 | def build_mnist_mlp(param_dir='weight.npy'): |
学习率递减
1 | def train(self): |
get_loss加偏移
1 | def get_loss(self,label): |
三层神经网络 MLU 推理
在/opt/code_chap6/exp_6_1_NNclassification/exp_6_1_2_inferdlp目录
数据加载模块
1 | def load_data(self, data_path, label_path): |
网络结构模块
直接使用 pycnnl 封装好的基本单元接口来搭建
1 | def build_model(self, batch_size=10000, input_size=784, |
网络推理模块
1 | def load_model(self, param_dir): |
实验运行
补全 stu_upload 中的 mnist_mlp_demo.py 文件, 并复制实验exp_6_1_1_traincpu中实现的layer_1.py、mnist_mlp_cpu.py 以及训练得到的参数复制到 stu_upload 目录下,执行 main_infer_dlp.py 运行实验。
修改mnist_mlp_cpu.py 文件:加batchsize为10000,注释掉mlp.train()和mlp.save_model(‘mlp-%d-%d-%depoch.npy’ % (h1, h2, e))两句,将mlp.load_model(param_dir)取消注释
1 | def build_mnist_mlp(param_dir='weight.npy'): |
改mnist_mlp_demo.py里的参数设置
1 | HIDDEN1 = 512 |
发现hidden1是512会爆。导致乱读了,然后准确率就相当于乱猜
在
fc1层,你定义的权重张量总元素个数为:10000×1×784×512≈4.01×109
这个数字大于 2^31^−1(约 21 亿)。CNNL 明确提示张量元素个数不能超过 2^31^。
- CPU 没报错 是因为 CPU 内存管理机制不同。
- DLP 报错 是因为硬件加速卡的张量描述符(Descriptor)在处理超过 2G 规模的数据时会发生整数溢出,导致后续所有计算地址全部错误。
没招了,换回了32-16才六十分
偷看了同学的,只是改成256-512,居然能达到0.98的准确率