一、这玩意儿到底是干嘛的?
说白了,这是一个用C++17 + Eigen矩阵库手写的卷积神经网络(CNN)。没有PyTorch,没有TensorFlow,就靠着C++的面向对象编程和Eigen的矩阵运算,从头实现了一个能识别MNIST手写数字的完整神经网络。
MNIST是啥?就是机器学习界的"Hello World"——6万张28×28像素的手写数字训练图,1万张测试图,0到9一共10个类别。这个项目训练15轮后,在测试集上的准确率能到**98.55%**。
作为对比,同样结构的网络用PyTorch写(带自动微分),训练10轮能到99.31%。自己手写的C++版本虽然差了一点点,但要知道:每一行反向传播的梯度公式都是手算的,每一个权重更新都是手动实现的。这个差距完全在可接受范围内。
二、为什么要自己手写一个?
现在市面上已经有PyTorch、TensorFlow这些成熟框架了,一行代码就能搭个CNN,为什么还要自己用C++从零写?
原因有几个:
第一,搞懂原理。 很多人用PyTorch调包调得飞起,但问到"卷积层的反向传播到底怎么算"、"MaxPool的梯度怎么传"、"BatchNorm训练和推理时行为为什么不一样",就答不上来了。自己手写一遍,这些细节就全明白了。
第二,C++性能。 如果你要在嵌入式设备、游戏引擎、或者对延迟敏感的场景里跑神经网络,Python+PyTorch的启动开销和内存占用可能太大了。C++实现更轻量。
第三,练代码能力。 手写CNN能同时练到:面向对象设计、模板编程、矩阵运算、文件IO、数值稳定性等基本功。这是很多计算机视觉和深度学习课程的硬核作业。
三、网络架构:9层网络识别手写数字
这个网络的结构很经典,是图像分类任务的标配套路:
层 | 输入 | 输出 | 作用 |
输入 | 1×28×28 | - | 一张灰度手写数字图 |
Conv1 | 1×28×28 | 32×24×24 | 32个5×5卷积核,提取边缘、拐角等低级特征 |
MaxPool1 | 32×24×24 | 32×12×12 | 2×2最大池化,图缩小一半 |
Conv2 | 32×12×12 | 64×8×8 | 64个5×5卷积核,提取笔画、圆圈等高级特征 |
MaxPool2 | 64×8×8 | 64×4×4 | 2×2最大池化,再缩小一半 |
展平 | 64×4×4 | 1024×1 | 把三维特征图拉成一维向量 |
FC1 | 1024 | 512 | 全连接层,+ ReLU + Dropout |
FC2 | 512 | 10 | 全连接层,+ Softmax |
输出 | 10 | - | 10个概率值,哪个最大就是预测的数字 |
大白话解释一下:
卷积层就像给图片加"滤镜"。第一层32个滤镜,有的专门找横线,有的专门找竖线,有的专门找拐角。第二层64个滤镜在第一层的基础上,组合出更复杂的模式——比如一个"0"字需要上下两个弧线,第二层就能学到这种组合。
池化层就像"缩略图"。2×2的最大池化,把每4个像素里最大的那个留下来,图就变小了一半。这样做有两个好处:一是减少计算量,二是让网络对图片位置变化更鲁棒(数字稍微偏一点也能认出来)。
全连接层就像"投票器"。前面卷积层把图片的特征都提取好了,展平成1024维向量,然后两层全连接网络根据这些特征投票,最后Softmax给出10个数字各自的概率。
四、设计思路:面向对象,像搭积木一样拼网络
这个项目的代码结构非常清晰,用面向对象的方式把CNN拆成了独立的模块:
4.1 层(Layers)
Convolution2D:二维卷积层,管前向卷积和反向传播
MaxPooling:最大池化层,前向找最大值,反向记住最大值位置传梯度
FullyConnected:全连接层,前向矩阵乘法,反向矩阵转置
每个层都有三个标准接口:
forward():前向传播
backward():反向传播
updateParameters():用优化器更新权重
4.2 激活函数(Activations)
用模板类实现,支持不同数据类型(单张图是MatrixXd,一批图是vector):
ReLU:负数变0,正数不变。反向传播时,负数位置梯度清零
Softmax:把10个分数转成概率。配合交叉熵损失,反向传播梯度公式特别简洁:预测概率 - 真实标签
4.3 损失函数(Loss)
CrossEntropy(交叉熵):分类任务标配。预测越准,损失越小
MSE(均方误差):回归任务用。这里主要用交叉熵
4.4 优化器(Optimizers)
每个层内部持有一个优化器对象:
SGD:随机梯度下降,最基础的优化方法
Adam:带自适应学习率的优化器,收敛更快更稳
4.5 正则化(Regularization)
BatchNormalization:批归一化。训练时用当前批次的均值和方差,推理时用训练时累积的均值和方差。防止梯度消失,加速收敛
Dropout:随机丢弃一部分神经元。训练时启用,推理时关闭。防止过拟合
4.6 数据加载(MNISTLoader)
专门负责读MNIST的IDX二进制格式文件,把原始字节流转成Eigen矩阵,并自动分成训练集、验证集、测试集。
这种设计的最大好处是模块化。想加一个层?写一个类,实现forward/backward/updateParameters三个接口,然后往SimpleCNN里一塞就行。想换一个激活函数?改一行模板参数。想换一个优化器?改层的构造函数。主逻辑完全不用动。
五、核心算法原理:卷积、池化、反向传播
5.1 卷积层前向传播
卷积的本质就是滑动窗口做内积。一个5×5的卷积核,在输入图上从左到右、从上到下滑动,每滑到一个位置,就把卷积核里的25个数和对应位置的25个像素相乘再相加,得到一个输出值。
输出[j] = Σ(卷积核[m][n] × 输入[i+m][j+n])32个卷积核就产生32张输出特征图。每个卷积核学一种不同的特征模式。
5.2 卷积层反向传播(最难的部分)
卷积层的反向传播是CNN手写实现中最容易出错的地方。它要算三样东西:
1. 对输入的梯度(传给前一层)
把输出梯度"反卷积"回输入尺寸。具体做法是用转置后的卷积核对输出梯度做卷积。大白话:如果某个filter对总误差"贡献大",那它"看"过的输入区域也要承担相应的责任。
2. 对卷积核的梯度(用来更新filter)
用输入图和输出梯度做互相关运算。哪个位置的输入值大、同时该位置的输出梯度也大,说明这个filter在这个位置的权重应该调整得多。
3. 对偏置的梯度
把输出梯度在所有空间位置上求和,就是偏置的梯度。
5.3 MaxPool反向传播
MaxPool前向传播时,每2×2区域只保留最大的那个值。反向传播时,梯度只传给最大值所在的位置,其他三个位置梯度为0。
所以MaxPool层需要在前向传播时记住每个池化区域的最大值位置,反向传播时才能把梯度"送"对地方。
5.4 全连接层反向传播
全连接层就是矩阵乘法,反向传播用链式法则:
对输入的梯度 = Wᵀ × 输出梯度对权重的梯度 = 输出梯度 × 输入ᵀ这个公式非常对称,也很好记。
5.5 BatchNorm反向传播
BatchNorm在训练时,对每个批次的数据做归一化:
x_norm = (x - batch_mean) / sqrt(batch_var + epsilon)y = gamma * x_norm + beta反向传播要分别算对x、gamma、beta的梯度。推理时不再用batch统计量,而是用训练时累积的running_mean和running_var。
5.6 训练循环
整个训练流程就是一个大循环:
for epoch = 1 to 15: for 每张训练图: ① 前向传播 → 得到10个概率 ② 算交叉熵损失 ③ 反向传播 → 从Softmax一直传回Conv1 ④ Adam优化器更新所有权重 用验证集算loss和准确率(不调权重,只看效果) 用测试集算最终准确率(完全没见过的数据)每轮结束会把训练loss、训练准确率、验证loss、验证准确率存到一个CSV文件里,方便后续画图分析。
六、相关领域知识点速览
知识点 | 一句话解释 |
| 卷积(Convolution) | 用一个小filter在图上滑动做内积,提取局部特征 |
| 卷积核(Kernel/Filter) | 一个小权重矩阵,相当于"特征检测器" |
| 特征图(Feature Map) | 卷积后得到的输出图,每个值代表某位置某特征的强度 |
| 池化(Pooling) | 把图缩小,保留最重要信息,减少计算量 |
| MaxPool | 每2×2区域只留最大值 |
| ReLU | 负数变0,正数不变。简单高效,防止梯度消失 |
| Softmax | 把任意实数转成概率分布,总和为1 |
| 交叉熵损失(Cross-Entropy) | 衡量预测概率和真实标签的差距 |
| 反向传播(Backpropagation) | 用链式法则把误差从输出层传回输入层 |
| Adam优化器 | 结合动量和自适应学习率,收敛快且稳 |
| BatchNorm | 对每层输出做归一化,让训练更稳定 |
| Dropout | 训练时随机关掉一些神经元,防止过拟合 |
| He初始化 | 根据输入维度初始化权重,防止梯度消失/爆炸 |
| Eigen | C++的矩阵运算库,类似NumPy |
七、怎么跑起来?编译和运行手把手教
7.1 环境要求
C++17 编译器(GCC/Clang/MSVC)
CMake 或 Make
Eigen 3.4(唯一的第三方依赖,header-only,不需要编译)
MNIST数据集的二进制文件
7.2 下载MNIST数据
从Yann LeCun的网站或Kaggle下载这四个文件:
MNIST/├── train-images.idx3-ubyte # 训练图片,6万张├── train-labels.idx1-ubyte # 训练标签,6万个├── t10k-images.idx3-ubyte # 测试图片,1万张└── t10k-labels.idx1-ubyte # 测试标签,1万个把文件放到项目的 Data/MNIST/ 目录下。
7.3 安装Eigen
Eigen是header-only库,不需要编译。你可以:
方式一:包管理器安装
# Ubuntu/Debiansudo apt-get install libeigen3-dev# macOSbrew install eigen方式二:手动下载从Eigen官网下载源码,解压后把路径记下来,编译时指定。
7.4 编译
方式一:CMake(推荐)
mkdir buildcd buildcmake ..cmake --build .方式二:Makefile
make如果Eigen装在了非标准路径,需要指定:
make EIGEN_PATH=/path/to/eigen7.5 运行
CMake编译后的运行:
cd build./SimpleCNNMakefile编译后的运行:
make run程序会自动:
读取MNIST数据
把训练集的20%划出来做验证集(剩下的80%训练)
训练15轮,每轮打印训练loss和验证准确率
训练结束后用测试集评估,输出最终准确率
把训练历史导出到 training_data.csv
正常输出大概长这样:
Epoch 1/15 - Train Loss: 0.8234 - Train Acc: 78.52% - Val Loss: 0.5432 - Val Acc: 85.11%Epoch 2/15 - Train Loss: 0.4521 - Train Loss: 0.4521 - Train Acc: 87.23% - Val Loss: 0.3891 - Val Acc: 89.45%...Epoch 15/15 - Train Loss: 0.1234 - Train Acc: 96.78% - Val Loss: 0.0987 - Val Acc: 97.89%Test Accuracy: 98.55%训练loss从0.8左右慢慢降到0.1以下,准确率从78%慢慢爬到98%以上,说明网络学到位了。
7.6 查看训练曲线
训练结束后,training_data.csv 文件里有四列数据:
训练loss
训练准确率
验证loss
验证准确率
你可以用Python、Excel、或者任何绘图工具画曲线,观察:
loss是否持续下降(有没有收敛)
训练准确率和验证准确率差距大不大(有没有过拟合)
哪一轮验证准确率最高(可以用来做早停)
7.7 调参数玩
main.cpp 里有几个参数可以改:
const size_t epochs = 10; // 训练轮数const size_t classes = 10; // 类别数(MNIST是10个数字)const double validationRatio = 0.2; // 验证集比例(20%训练图做验证)你也可以改SimpleCNN的构造函数里的网络结构参数,比如:
卷积核数量(32/64)
卷积核大小(5×5)
全连接层神经元数(512)
Dropout概率
7.8 换成自己的数据
如果你想用这个网络跑自己的图像分类任务:
准备图片数据,转成Eigen::MatrixXd格式(单通道灰度图)
写一个自己的DataLoader,替代MNISTLoader
根据你的图片尺寸和类别数,调整网络结构参数
重新编译运行
If you need the complete source code, please add the WeChat number (c17865354792)
八、总结
这个项目虽然代码量不大,但把CNN的核心机制都覆盖到了:
完整的卷积网络:两层卷积 + 两层池化 + 两层全连接
手写反向传播:从Softmax一直传回Conv1,每一层的梯度公式都是手算的
现代正则化:BatchNorm + Dropout,防止过拟合
多种优化器:SGD和Adam,随你挑
完整的训练流程:训练、验证、测试、导出指标,一条龙
面向对象设计:模块化、可扩展,新增层或激活函数很容易
如果你一直好奇"卷积神经网络底层到底怎么工作的",但又不想啃PyTorch几十万行源码,这就是一个很好的切入点。代码结构清晰、能跑通、有结果、还能调参数玩,读完之后你会对"卷积"、"池化"、"反向传播"这些词有完全不同的理解。
Welcome to follow WeChat official account【程序猿编码】
---
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!