『7x24小时有问必答』

一、这玩意儿到底是干嘛的?

说白了,这是一个用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/Debian

sudo apt-get install libeigen3-dev

# macOS

brew install eigen

方式二:手动下载从Eigen官网下载源码,解压后把路径记下来,编译时指定。

7.4 编译

方式一:CMake(推荐)
mkdir build

cd  build

cmake ..

cmake --build .

方式二:Makefile
make

如果Eigen装在了非标准路径,需要指定:
make EIGEN_PATH=/path/to/eigen

7.5 运行

CMake编译后的运行:
cd  build

./SimpleCNN

Makefile编译后的运行:
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【程序猿编码】

---

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

上一主题上一主题         下一主题下一主题
QQ手机版小黑屋粤ICP备17165530号

关于我们·投诉举报· 用户帮助· 联系我们 · 本站服务 · 版权声明· 隐私政策 · 投搞指南

法律保护:PLC技术网,plcjs.com,plcjs.net等字样
Copyright 2010-2030. All rights reserved. 


微信公众号二维码 抖音二维码 百家号二维码 今日头条二维码哔哩哔哩二维码