纯C++手写卷积神经网络:从零实现CNN与反向传播
2026/9/2 6:11:23 网站建设 项目流程

简介:convnet 是一套纯 C++ 实现的卷积神经网络源码,面向希望在 FPGA、嵌入式设备上部署 CNN 模型的研究者与嵌入式开发者。该实现不依赖第三方深度学习框架,直接完成卷积、池化、全连接和 ReLU 激活等前向计算,能够帮助读者从底层掌握图像分类与手写数字识别的完整流程。资源共 29 个文件,压缩包约 82KB,以 18 个 .h 与 4 个 .hpp 头文件为主体,按模块划分卷积层、池化层、全连接层、输出层和 MNIST 数据解析器,头文件接口清晰、便于单独阅读;同时附带 CMake 构建脚本、README 说明、OpenCL 内核文件(.ocl)以及少量文本配置,可以在本地编译验证,也能按需裁剪移植到具体项目中,已有 439 人学习下载。通过查看卷积核实现和 OpenCL 内核,可以理解如何在 C++ 中组织多维数组运算、如何将卷积计算映射为 FPGA 上的并行数据流,并学习训练样本读取、前向传播调度和硬件加速的配合方式,对入门嵌入式 AI、硬件加速或异构计算的人提供了从算法到部署的直观示例,具有较强的参考价值。 最近把卷积神经网络(CNN)老老实实从零撸了一遍——纯 C++,不依赖 PyTorch、TensorFlow 任何深度学习框架,所有卷积、池化、全连接、反向传播全部手写实现,最后在 MNIST 数据集上跑通了手写数字的识别训练。整个过程走下来,收获确实比直接调框架 API 大得多,很多平时“记住就行”的知识点,比如卷积核反向传播时要旋转 180 度、MaxPool 的梯度该往哪个位置回传、跨步卷积的边界怎么处理,亲手实现一遍之后全都串起来了。

这篇博文就记录一下整个实现的核心思路、关键代码和踩坑过程。内容定位不是给你贴一份能直接跑的完整工程,而是把每次选型背后的“为什么”讲清楚,附带可以直接复现的代码片段。适合两类人看:一类是像我一样想彻底搞懂 CNN 底层的 C++ 开发者,另一类是刚入门深度学习、想摆脱“只会调框架”状态的初学者。

1. 为什么要在纯 C++ 里手写卷积神经网络

1.1 抛开框架,才能真正理解 CNN

平时用 PyTorch 写一个卷积层就是一行nn.Conv2d(),但这一行背后到底做了什么事情,很多人其实说不清楚。手写卷积神经网络的意义就在这:当你需要自己管理每一块内存、自己写每一个 for 循环、自己推导每条梯度公式的时候,那些抽象概念才会真正变成你自己的东西。

具体来说,手写实现至少能让你搞明白三个核心问题:

  • 卷积层的前向计算到底是什么样的多重循环,输出特征图的尺寸怎么由输入尺寸、卷积核大小、步长和填充共同决定。
  • 反向传播时,梯度是怎么从输出层一层一层往回传的,为什么卷积层的权重梯度可以用“输入和输出梯度做卷积”来理解。
  • 训练一个实际可用的模型需要哪些工程细节,比如数据归一化、权重初始化、batch 打乱、学习率设置,这些单独看都不难,但组合在一起很容易出问题。

1.2 项目整体目标与技术选型

这个项目的核心目标很明确:用标准 C++ 写一个可以直接训练的卷积神经网络,在 MNIST 数据集上达到可观的识别准确率。我给自己定的标准是,测试集准确率不低于 98%,因为 MNIST 本身是一个很简单的数据集,如果连这个都达不到,说明实现大概率有 bug。

技术选型上做了三件事,都是有意识的取舍:

  • 网络结构参考经典 LeNet-5 的简化版,选它不是因为新潮,而是因为它足够简单,在 MNIST 上用纯 CPU 训练也很快。
  • 卷积实现采用标题里说的“直接卷积”,也就是让初学者最容易理解的朴素四重循环写法,而不是 im2col + GEMM 或者 FFT 那套加速方案。先保证正确,再考虑效率。
  • 训练框架完全不用外部库,只用了标准库容器std::vector和文件流来读写数据,保证任何环境都能编译运行。

这个选择背后最重要的原因是:直接卷积虽然慢,但代码和数学公式一一对应,出错了也容易排查。你可以在一个 8x8 的小输入上手工计算一遍卷积结果,对比程序输出,几分钟就能定位问题。

2. 网络结构设计与核心数据表示

2.1 网络架构:经典 LeNet-5 风格

网络结构采用了一个非常经典的小型 CNN,整体流程如下:

  • 输入:28x28 的灰度图像,像素值归一化到 [0, 1]
  • 第一个卷积层:1 个输入通道,6 个输出通道,卷积核大小 5x5,步长 1,无填充,输出特征图 24x24
  • ReLU 激活
  • 2x2 最大池化,输出特征图 12x12
  • 第二个卷积层:6 个输入通道,16 个输出通道,卷积核大小 5x5,步长 1,输出特征图 8x8
  • ReLU 激活
  • 2x2 最大池化,输出特征图 4x4
  • Flatten,展平为 256 维向量
  • 全连接层:256 -> 120,ReLU 激活
  • 全连接层:120 -> 10,接 softmax 输出

特征图尺寸变化的计算公式是out = (in - kernel) / stride + 1,这里 stride 是 1,padding 是 0,所以第一层卷积后是(28 - 5) / 1 + 1 = 24,池化后减半变成 12,第二层卷积后变成(12 - 5) / 1 + 1 = 8,池化后是 4。

注意:卷积层没有用填充(padding),所以特征图会逐渐变小。如果你希望特征图尺寸不变,需要设置padding = kernel_size / 2。这里选择无填充,单纯是为了保持实现最简单。

选择这样的结构还有一个深层原因:它每一步的特征图尺寸都比较小,反向传播时有足够大的中间激活值需要保存,但又不至于把内存撑爆,适合在 C++ 里用朴素的std::vector存储。

2.2 C++ 中如何高效表示多维张量

C++ 本身没有 Python 那种原生多维数组,实现神经网络的第一件事就是选一种张量表示方式。有两个常见方案:

  • std::vector<std::vector<...>>表示多维数组,写起来直观,但内存不连续,缓存命中率低,而且层数一多代码会非常丑陋。
  • 用一维std::vector<float>存储所有数据,通过索引计算模拟多维访问,内存紧凑,而且批量复制、初始化时性能更优。

我选择的是第二种方案。具体做法就是先算好每个张量需要的总元素个数,然后分配一块连续内存,通过一个offset函数把多维索引映射到一维索引。比如一个形状为[batch, channels, height, width]的特征图,batch为 1 时,索引计算方式是((c * H) + h) * W + w

实际写代码时,可以封装一个轻量级的Tensor结构体,只存三个字段:形状、总长度、数据指针,并提供at(c, h, w)这样的访问方法。这样所有层的实现都统一走这个接口,后续加新层时改动最小。

3. 直接卷积的底层实现与反向传播

3.1 前向卷积:四重循环背后的原理

直接卷积的前向计算,本质上是把一个卷积核在输入特征图上滑动,每滑动到一个位置就做一次内积。这个操作对应四重循环:遍历输出通道、遍历输入通道、遍历输出特征图的高、遍历输出特征图的宽,再加上卷积核内部的乘法累加,总共有六层循环嵌套。

核心代码长这样:

// input: [in_ch, in_h, in_w], kernel: [out_ch, in_ch, kh, kw] // output: [out_ch, out_h, out_w] for (int oc = 0; oc < out_ch; oc++) { for (int ic = 0; ic < in_ch; ic++) { for (int oh = 0; oh < out_h; oh++) { for (int ow = 0; ow < out_w; ow++) { float sum = 0.0f; for (int kh = 0; kh < kernel_h; kh++) { for (int kw = 0; kw < kernel_w; kw++) { int ih = oh + kh; int iw = ow + kw; sum += input[ic][ih][iw] * kernel[oc][ic][kh][kw]; } } output[oc][oh][ow] += sum; } } } }

这段代码把卷积核在输入特征图上滑动时,对应位置的输入像素和卷积核权重做乘法累加。注意这里ihiw的计算:输出位置(oh, ow)对应的输入区域左上角正好就是(oh, ow),因为步长为 1 时没有偏移。

这个实现的计算量是out_ch * in_ch * out_h * out_w * kh * kw,对于 MNIST 这种 28x28 的小图来说完全没问题,但如果你想扩展到 ImageNet 级别的图像,这个写法会慢到无法忍受。那时候就得考虑 im2col 把卷积转成矩阵乘法,或者用 FFT 加速。不过那是另一个故事了,至少在理解原理这个层面,朴素实现是最好的老师。

3.2 反向传播:卷积层和池化层的梯度回传

反向传播是手写 CNN 最劝退的部分,但其实只要理解两条规则就不难:

  • 权重梯度等于输入和输出梯度的卷积。
  • 输入梯度等于输出梯度和旋转 180 度后的卷积核的卷积(需要完整卷积模式,也就是带边界填充的那种)。

具体到代码,卷积层的反向传播分为两步:

第一步计算权重梯度,遍历每个卷积核位置,把对应的输入区域和输出梯度逐元素相乘再累加。

// d_kernel[oc][ic][kh][kw] += input[ic][ih][iw] * d_output[oc][oh][ow] for (int oc = 0; oc < out_ch; oc++) for (int ic = 0; ic < in_ch; ic++) for (int oh = 0; oh < out_h; oh++) for (int ow = 0; ow < out_w; ow++) for (int kh = 0; kh < kernel_h; kh++) for (int kw = 0; kw < kernel_w; kw++) d_kernel[oc][ic][kh][kw] += input[ic][oh + kh][ow + kw] * d_output[oc][oh][ow];

第二步计算输入梯度,这一步最容易出错。对于输出特征图上的每一个位置(oh, ow),它会通过卷积核的每一个位置(kh, kw)影响输入位置(oh + kh, ow + kw),所以反向时要把输出梯度累加到对应的输入位置上。写成代码就是:

for (int oc = 0; oc < out_ch; oc++) for (int ic = 0; ic < in_ch; ic++) for (int oh = 0; oh < out_h; oh++) for (int ow = 0; ow < out_w; ow++) for (int kh = 0; kh < kernel_h; kh++) for (int kw = 0; kw < kernel_w; kw++) d_input[ic][oh + kh][ow + kw] += kernel[oc][ic][kh][kw] * d_output[oc][oh][ow];

你会发现这两段代码结构几乎一模一样,只是累加的目标从d_kernel变成了d_input。这就是卷积反向传播的对称美:权重梯度和输入梯度,本质上用的是同一个操作。

池化层的反向传播相对简单。MaxPool 需要在前向时记录下每个池化窗口内最大值的位置,反向时把梯度只传给那个位置,其他位置梯度为 0;AveragePool 则把梯度平均分给窗口内所有位置。我用的 MaxPool,所以每个池化窗口只会有一个非零梯度,实现起来非常直接。

3.3 全连接层与 softmax 交叉熵

全连接层本质上就是矩阵乘法加偏置,前向是y = Wx + b,反向时对权重的梯度是dW = d_y * x^T,对输入的梯度是dx = W^T * d_y。这个在吴恩达课程里讲过无数遍,真正手写一遍体会更深。

softmax 加交叉熵的梯度形式很优雅。如果模型输出是z,经过 softmax 得到概率p,真实标签是 one-hot 编码的y,那么损失对z的梯度恰好就是p - y。这个结论第一次看到会觉得很神奇,但推导并不复杂:交叉熵损失L = -sum(y * log(p)),对z_j求偏导,利用 softmax 的导数性质,最终化简出来就是p_j - y_j

这个梯度形式让最后一层实现异常简洁,也成了训练过程中最好的调试信号。如果反向传播实现正确,损失下降曲线应该比较平滑;如果出现剧烈震荡,多半是梯度算错了。

4. MNIST 数据加载与训练配置

4.1 解析 IDX 文件格式

MNIST 数据集官方提供的是 IDX 格式的文件,一共四个:训练图像、训练标签、测试图像、测试标签。IDX 格式的优势是结构简单,缺点是文件头是大端序(big-endian),而绝大多数 x86 机器是小端序(little-endian),所以读取时必须手动转换字节序。

文件头格式如下:

  • 前 4 字节:magic number,训练图像是 2051,训练标签是 2049。
  • 再 4 字节:样本数量。
  • 图像文件额外有 4 字节的行数和 4 字节的列数。
  • 之后就是原始像素数据或标签数据。

读取代码可以这样写:

uint32_t read_big_endian_u32(std::ifstream& f) { char buf[4]; f.read(buf, 4); return (static_cast<uint8_t>(buf[0]) << 24) | (static_cast<uint8_t>(buf[1]) << 16) | (static_cast<uint8_t>(buf[2]) << 8) | static_cast<uint8_t>(buf[3]); }

读出 magic number 后校验一下是否符合预期,然后把图像数据读到std::vector<float>里,这里可以选择把像素值归一化到 [0, 1]。归一化看着不起眼,实际上非常重要。如果不归一化,输入像素值是 0 到 255,和初始化权重做卷积后,激活值很容易落在激活函数的饱和区,导致梯度消失。归一化本质上是把输入分布拉到一个合适范围,让训练一开始就处于激活函数的敏感区间。

提示:如果你在下载 MNIST 时遇到网络问题或链接失效,可以换一个网络条件再试,也可以从开源镜像站获取。文件下载后注意检查大小,训练图像约 47MB,测试图像约 7.8MB,如果文件大小对不上,说明下载不完整。

4.2 训练循环与超参数选择

训练循环的核心逻辑很简单:迭代若干轮(epoch),每轮内把训练集按 batch 大小切分,对每个 batch 做前向传播、计算损失、反向传播、更新参数。关键超参数我选了这样一组:

  • batch size:32,相对较小的 batch 能引入一些随机性,帮助跳出局部最优。
  • 学习率:0.01,SGD 优化器。
  • 权重初始化:均值为 0、标准差为 0.1 的高斯随机数。这个选择有一个玄机:如果标准差太大,初始输出很容易饱和;太小则训练太慢。0.1 在 MNIST 上实测效果不错。
  • 训练轮数:10 轮左右。

参数更新规则就是最朴素的 SGD 加学习率衰减:

for (int i = 0; i < weights.size(); i++) { weights[i] -= learning_rate * grad_weights[i] / batch_size; }

这里的除以 batch_size 是在做梯度平均。每个样本都会产生一份梯度,如果不平均,batch 越大梯度就越大,学习率就得不断调整。除以 batch 后,梯度规模就和 batch 大小无关了,这也是调参时经常被忽略的细节。

5. 训练排查与踩坑实录

5.1 常见问题速查表

训练过程中遇到的问题,我整理成了一张表,基本覆盖了纯手写 CNN 最常见的坑:

现象可能原因解决方案
损失一开始就在 2.3 左右不下降softmax 初始概率均匀分布,交叉熵正好是 log(10) 约 2.3026正常现象,继续训练即可
训练一会儿后损失变成 NaN学习率太大导致梯度爆炸调低学习率,或对梯度做裁剪
准确率一直停留在 10% 左右相当于随机猜测,大概率梯度没有正确回传检查反向传播每一层梯度的数值范围
训练集准确率很高,测试集很低过拟合增加训练轮数的同时,观察验证集指标
前向输出和手工计算不一致卷积索引算错用极小的输入和卷积核,手工推一遍验证
现象可能原因解决方案
损失下降后突然反弹学习率过大,参数跳过最优点降低学习率或加入学习率衰减
CPU 训练特别慢直接卷积本身计算量大开启编译器优化(-O2),或减小 batch 大小
读取 MNIST 数据结果错乱字节序没转换确认使用大端序读取文件头

5.2 个人实测训练记录

在我的机器上(普通笔记本电脑 CPU),这个网络训练 10 轮大约用时一两分钟,最终测试集准确率能到 98% 到 99% 之间。训练过程中损失曲线的变化大致是:

  • 第 1 轮结束:损失从初始的 2.3 左右降到 0.4 左右,测试准确率约 90%。
  • 第 3 轮结束:损失降到 0.1 左右,准确率约 97%。
  • 第 7 轮往后:损失在 0.05 以下小幅波动,准确率约 98.5%。

如果你训练出来的曲线和这个差得太远,比如前几轮损失下降非常慢,先检查数据归一化;如果损失完全没有下降,优先检查反向传播的梯度值是否合理。一个很有效的调试方法是梯度检查(gradient check),用数值差分法算一遍梯度,和你的反向传播结果对比,误差在 1e-6 量级说明实现正确。这个方法虽然笨重,但在手写神经网络时是最靠谱的验证手段。

另外还有一点容易被忽略:每个 epoch 开始前要把训练集打乱,否则模型可能学到样本顺序里的假规律。打乱操作可以用std::shuffle配一个随机数生成器,每次训练使用不同的随机种子,让结果更有说服力。

我在整个实现过程中最大的体会是:手写卷积神经网络并不需要多高深的数学功底,它考察的是你能不能把一个抽象的数学公式,一步一步转化成可运行的代码,并且在出错的时候有能力定位问题。这个能力恰恰是调框架练不出来的。如果你也想试试,建议从一个更小的网络开始,比如只有一层卷积加一层全连接,先把整个训练流程跑通,再逐步加深网络,这样每一步出错都更容易定位。

本文还有配套的精品资源,点击获取

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

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

立即咨询