"基于 numpy 的手写数字识别", 这一经典问题除了用作深度学习入门内容, 还被广泛作为各大课程的课程作业, 因此在各大搜索引擎上搜索率也是相当之高(代码复用率也是相当之高). 网上确实有挺多现成的可使用代码, 但是大部分都是造的全连接网络, 并且很多时候内部原理不是特别清晰. 因此决定自己也来造一次轮子, 使用 numpy
实现一个简单的卷积神经网络进行手写数字识别, 正好也能借此机会梳理一下神经网络的基本原理.
全文包含完整的卷积网络实现, 以及矩阵梯度和卷积矩阵化的推导过程, 由于全文过长, 因此分成了三部分, 内容上是完全连着的.
本文为第三篇, 也是最后一篇, 结合前两篇的内容搭建完整的卷积神经网络并完成训练和评估.
本系列文章传送门:
卷积神经网络
模型结构
因为只是一个简单的示例, 所以弄一个小一点的网络, 大概长这样.
然后就是按照这个结构用代码把前面实现的层串起来.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| class ConvolutionNeuralNetwork: """ Conv -> Pool -> ReLU -> Conv -> Pool -> ReLU -> Flatten -> Linear -> CrossEntropy """
def __init__(self) -> None: self.layers = [ ConvolutionLayer(1, 4, 5, 5), MaxPoolingLayer(2, 2), ReLULayer(), ConvolutionLayer(4, 16, 3, 3), MaxPoolingLayer(2, 2), ReLULayer(), FlattenLayer(), LinearLayer(16 * 5 * 5, 10) ] self.loss_func = CrossEntropyLoss() self.layer_inputs = []
def forward(self, x: np.ndarray, keepgrad: bool = False) -> np.ndarray: """ Args: x: (B, C_ch, H, W), images keepgrad: whether keep temp layer inputs
Returns: x: (B, C_cls), logits """
for layer in self.layers: if keepgrad: self.layer_inputs.append(x) x = layer.forward(x) return x
def backward(self, last_x_grad: np.ndarray) -> np.ndarray: """ Args: last_x_grad: (B, C_cls), computed by loss function
Returns: last_x_grad: (B, C_ch, H, W) """
for x, layer in zip(self.layer_inputs[::-1], self.layers[::-1]): last_x_grad = layer.backward(x, last_x_grad) return last_x_grad
def update(self, lr: float) -> None: for layer in self.layers: if isinstance(layer, ParamLayer): layer.update(lr)
|
这里网络同样需要实现 forward
和 backward
方法, 但是增加了 layer_inputs
成员, 用于保存网络的计算图中间节点, 在反向传播时能直接读取数据计算.
至于网络每层的参数填多大, 得视数据量和网络深度决定玄学问题, 这里就填了几个比较小的值, 方便下一步训练.
训练
然后实现网络的训练代码.
训练分几个固定步骤:
- 前向传播
- 计算损失
- 反向传播
- 更新参数
- 清空本次计算的中间值和梯度
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| class ConvolutionNeuralNetwork: """ Conv -> Pool -> ReLU -> Conv -> Pool -> ReLU -> Flatten -> Linear -> CrossEntropy """
def __init__(self) -> None: ... def forward(self, x: np.ndarray, keepgrad: bool = False) -> np.ndarray: ... def backward(self, last_x_grad: np.ndarray) -> np.ndarray: ... def update(self, lr: float) -> None: ...
def train(self, train_x: np.ndarray, train_y: np.ndarray, batch_size: int, epochs: int, lr: float) -> float: """
Returns: loss: mean loss for train_x """ losses = [] for i in range(0, train_x.shape[0], batch_size): inputs, targets = train_x[i:i+batch_size], train_y[i:i+batch_size]
logits = self.forward(inputs, True) loss = self.loss_func.forward(logits, targets) losses.append(loss)
last_x_grad = self.loss_func.backward(logits, targets) self.backward(last_x_grad)
self.update(lr)
self.layer_inputs.clear()
return sum(losses) / len(losses)
|
测试和预测
测试和训练步骤是类似的, 但是不需要计算损失和梯度, 同时也不需要保留中间计算结果.
而预测则是在 forward
的基础上, 将输出结果使用 Softmax
函数转换成概率值, 公式如下:
$$
\begin{aligned}
Softmax(x_{ij}) &= \frac{\exp\left( {x_{ij}} \right)}{\sum_{j=1}^{C}{\exp\left(x_{ij}\right)}} \\
~ &= \frac{\exp\left( {x_{ij} - \max_{j=1}^{C}{x_{ij}}} \right)}{\sum_{j=1}^{C}{\exp\left(x_{ij} - \max_{j=1}^{C}{x_{ij}}\right)}} \\
~ &= \frac{\exp\left( {x_{ij}'} \right)}{\sum_{j=1}^{C}{\exp\left(x_{ij}'\right)}}
\end{aligned}
$$
其实就是在计算交叉熵损失中间 $\log$ 括号内的内容, 并且同样可以减去最大值来防止数据溢出.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| class ConvolutionNeuralNetwork: """ Conv -> Pool -> ReLU -> Conv -> Pool -> ReLU -> Flatten -> Linear -> CrossEntropy """
def __init__(self) -> None: ... def forward(self, x: np.ndarray, keepgrad: bool = False) -> np.ndarray: ... def backward(self, last_x_grad: np.ndarray) -> np.ndarray: ... def update(self, lr: float) -> None: ... def train(self, train_x: np.ndarray, train_y: np.ndarray, batch_size: int, epochs: int, lr: float) -> float: ...
def test(self, test_x: np.ndarray, test_y: np.ndarray, batch_size: int) -> float: """ Returns: loss: mean loss for test_x """
losses = [] for i in range(0, test_x.shape[0], batch_size): inputs, targets = test_x[i:i+batch_size], test_y[i:i+batch_size]
logits = self.forward(inputs) loss = self.loss_func.forward(logits, targets) losses.append(loss)
return sum(losses) / len(losses)
def predict(self, x: np.ndarray) -> np.ndarray: """ Args: x: (B, C_ch, H, W), images
Returns: x: (B, C_cls), probs by softmax """
x = self.forward(x) exp_x = np.exp(x - x.max(-1, keepdims=True)) outputs = exp_x / exp_x.sum(-1, keepdims=True) return outputs
|
训练并评估网络
完成网络搭建后, 接下来就是进行训练和评估. 先贴上完整的代码.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49
| if __name__ == "__main__": np.random.seed(1234)
train_x, train_y = load_dataset("./cv-data/train", True) test_x, test_y = load_dataset("./cv-data/test")
print(train_x.shape, train_y.shape) print(test_x.shape, test_y.shape)
batch_size = 100 epochs = 200 lr = 0.1 cnn = ConvolutionNeuralNetwork()
train_losses = [] test_losses = [] print("=============== Begin Train ===============") start_time = time.time() for i in range(epochs): train_loss = cnn.train(train_x, train_y, batch_size, epochs, lr) test_loss = cnn.test(test_x, test_y, batch_size) print(f"Epoch: {i + 1:3d} Train Loss: {train_loss:.4f} Test Loss: {test_loss:.4f}")
train_losses.append(train_loss) test_losses.append(test_loss)
time_elapsed = time.time() - start_time print(f"=============== End Train: {time_elapsed / 60:.2f} min ===============")
train_losses = np.array(train_losses) test_losses = np.array(test_losses)
plt.plot(np.arange(train_losses.shape[0]-1), train_losses[1:], label="train") plt.plot(np.arange(test_losses.shape[0]-1), test_losses[1:], label="test") plt.legend() plt.savefig("loss.png")
y_true = train_y y_pred = cnn.predict(train_x).argmax(-1) report = classification_report(y_true, y_pred, digits=4) print("=============== Classification Report: Train ===============") print(report)
y_true = test_y y_pred = cnn.predict(test_x).argmax(-1) report = classification_report(y_true, y_pred, digits=4) print("=============== Classification Report: Test ===============") print(report)
|
这里为了稳定结果, 固定了一下随机种子为 1234
.
有三个训练的超参数:
batch_size
: 每一轮 mini-batch 的大小.
epochs
: 共训练多少轮.
lr
: 网络学习率.
具体填多少得反复尝试也是炼丹的精髓, 这里学习率是尝试后收敛比较快而且比较稳定的一个值.
中途把每一轮的损失记录一下, 并且用 matplotlib.pyplot
画个简单的曲线图, 对比一下训练集和测试集损失随轮数的变化关系.
最后借用一下 classification_report
来看看网络在训练集和测试集上的分类性能报告.
200 轮的训练大概跑了 20 分钟左右, 挺久的.
损失曲线图:
分类性能报告:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| =============== Classification Report: Train =============== precision recall f1-score support
0 0.9970 0.9900 0.9935 999 1 0.9955 0.9928 0.9941 1106 2 0.9871 0.9765 0.9818 1020 3 0.9814 0.9786 0.9800 1027 4 0.9958 0.9712 0.9834 973 5 0.9821 0.9854 0.9838 891 6 0.9959 0.9939 0.9949 977 7 0.9750 0.9887 0.9818 1063 8 0.9567 0.9888 0.9725 984 9 0.9781 0.9771 0.9776 960
accuracy 0.9844 10000 macro avg 0.9845 0.9843 0.9843 10000 weighted avg 0.9845 0.9844 0.9844 10000
=============== Classification Report: Test =============== precision recall f1-score support
0 0.9517 0.9848 0.9679 460 1 0.9723 0.9825 0.9774 571 2 0.9618 0.9491 0.9554 530 3 0.9298 0.9540 0.9418 500 4 0.9630 0.9360 0.9493 500 5 0.9538 0.9518 0.9528 456 6 0.9581 0.9416 0.9498 462 7 0.9384 0.9219 0.9300 512 8 0.9039 0.9427 0.9229 489 9 0.9324 0.9019 0.9169 520
accuracy 0.9466 5000 macro avg 0.9465 0.9466 0.9464 5000 weighted avg 0.9468 0.9466 0.9466 5000
|
效果还不错, 损失曲线也很经典, 大约从 15 轮开始收敛, 120 轮左右测试集损失就差不多到底了. 中途试过一些别的随机种子, 收敛速度有差异, 但是最终的损失值都差不多.
分类性能的话, 训练集的 F1 值和测试集差了 4% 左右, 看着也还不错, 正常表现.
进一步探索
到这里我们就已经彻底完成了基于纯 NumPy 手工搭建的卷积神经网络了, 从这个过程中我们可以了解到很多底层原理以及一些细节问题, 比如参数的初始化和训练超参数的调节. 大部分时间我们都是使用深度学习框架来完成这些事情, 我们只需要专注于搭积木即可. 这里联系一下我常用的 PyTorch 库, 里面内置了很多不同的模块, 分别对应整个网络搭建和训练过程中的基本环节.
torch.utils.data
: 数据处理模块, 控制数据的读取和迭代方式.
torch.nn
: 常用的网络模块, 例如 Linear
和 Conv2d
.
torch.nn.functional
: torch.nn
中模块的函数形式, 需要手动传入计算的参数.
torch.nn.init
: 不同的网络参数初始化方法.
torch.optim
: 优化器模块, 包含不同的学习率调整算法, 控制网络的优化过程.
这是一些常用的, 还有很多, 可以去看看 PyTorch 文档并加以实践.
后记
这份文档写了很久, 起因是觉得要是下次再碰到相关的问题, 自己有轮子和内容就不用上网找了属实是闲着没事. 前后花了一两周的时间, 因为要从头跑一份代码, 然后又是调公式又是画图, 不过算是彻底复习了一遍神经网络, 把很多深度学习框架的使用原理都串起来了, 还是挺好的.
这个系列就此圆满结束作业报告从此一劳永逸.