一、先说说这玩意儿是干嘛的
学神经网络,大多数人的入门路径是:装Anaconda、pip install torch、调包、跑Demo。三行代码就能让模型在MNIST上跑到99%准确率。但问题是------你知道这99%是怎么来的吗?权重怎么初始化的?反向传播到底在算些什么?ReLU为什么有时候会让网络"死"掉?
这个项目干的事,就是把这些黑箱全部打开。只用C++标准库,从零实现一个全连接神经网络,让它自己去学MNIST手写数字的识别。从数据加载、神经元定义、层的前向传播、反向传播、参数更新,每一行代码都是手写的。
最后的结果挺有意思:同样的网络结构,换不同的激活函数,表现天差地别。ReLU直接"死"了,准确率卡在10%(跟瞎猜差不多);LeakyReLU慢慢爬到了87%;Sigmoid最猛,50个epoch干到了90%。这些差异不是调参能解释的,而是激活函数的数学性质决定的------只有亲手写过,你才能真正理解为什么。
二、具体含义:它到底包含哪些东西?
| 模块 | 干啥的 | 为什么重要 |
|---|---|---|
| 数据加载器 | 读取MNIST二进制文件,把28×28图片展成784维向量 | 神经网络的输入接口 |
| Neuron(神经元) | 单个计算单元:加权求和 + 偏置 + 激活 | 网络的最小积木 |
| DenseLayer(全连接层) | 一堆神经元排成一层,每个输入连每个输出 | 模型的"肌肉" |
| ReLU激活层 | max(0, x),负数变零 |
最常用,但有坑 |
| LeakyReLU激活层 | max(x, 0.25*x),负数保留一小部分 |
解决ReLU的"死亡"问题 |
| Sigmoid激活层 | 1/(1+e^-x),输出压到0~1 |
稳定,适合输出层 |
| MSE损失 | 算预测值和真实值的平方差 | 训练的目标函数 |
| Forward(前向传播) | 数据从输入流到输出 | 得到预测结果 |
| Backward(反向传播) | 从Loss往回传梯度 | 知道每个权重该怎么调 |
| 训练循环 | Forward → Loss → Backward → Update | 让网络"学"起来 |
| 评估循环 | 用测试集算准确率 | 看学得怎么样 |
三、代码实现原理:拆开来看
1. 神经元:网络的最小单元
一个神经元就干一件事:把输入加权求和,加个偏置,再过个激活函数。
cpp
class Neuron {
public:
std::vector<double> weights; // 每个输入对应的权重
double bias; // 偏置项
double output; // 前向输出
double grad_w_sum; // 权重梯度累加
double grad_b_sum; // 偏置梯度累加
// 前向:output = activation(w·x + b)
double forward(const std::vector<double>& inputs) {
double sum = bias;
for (size_t i = 0; i < inputs.size(); i++) {
sum += weights[i] * inputs[i];
}
output = sum; // 激活函数在外层处理
return output;
}
};
weights和bias一开始是随机数。训练的过程,就是不断调整这两个东西,让输出越来越接近正确答案。
2. DenseLayer:把神经元排成一排
一层DenseLayer就是一堆神经元,每个神经元都看到上一层的所有输出。
cpp
class DenseLayer {
public:
std::vector<Neuron> neurons;
std::vector<double> outputs;
std::vector<double> input_grads; // 传给上一层的梯度
DenseLayer(int input_size, int output_size) {
for (int i = 0; i < output_size; i++) {
Neuron n;
n.weights.resize(input_size);
// 随机初始化权重...
neurons.push_back(n);
}
outputs.resize(output_size);
input_grads.resize(input_size);
}
// 前向:每个神经元算自己的输出
std::vector<double> forward(const std::vector<double>& inputs) {
for (size_t i = 0; i < neurons.size(); i++) {
outputs[i] = neurons[i].forward(inputs);
}
return outputs;
}
};
比如第一层是784 → 100:784个输入(像素值),100个神经元,每个神经元有784个权重。总共784 * 100 = 78400个权重参数。
3. 激活函数:给网络加非线性
如果没有激活函数,不管堆多少层,本质上都是一个线性变换:y = W3 * W2 * W1 * x,跟一层没区别。激活函数引入了非线性,让网络能拟合复杂的决策边界。
ReLU:
cpp
class ReLULayer {
public:
std::vector<double> outputs;
std::vector<double> forward(const std::vector<double>& inputs) {
outputs.resize(inputs.size());
for (size_t i = 0; i < inputs.size(); i++) {
outputs[i] = std::max(0.0, inputs[i]); // 负数变零
}
return outputs;
}
// 反向:x>0时梯度为1,x<=0时梯度为0
std::vector<double> backward(const std::vector<double>& grad_from_above) {
std::vector<double> grad(inputs.size());
for (size_t i = 0; i < inputs.size(); i++) {
grad[i] = (inputs[i] > 0) ? grad_from_above[i] : 0;
}
return grad;
}
};
LeakyReLU:
cpp
std::vector<double> forward(const std::vector<double>& inputs) {
for (size_t i = 0; i < inputs.size(); i++) {
outputs[i] = (inputs[i] > 0) ? inputs[i] : 0.25 * inputs[i];
}
return outputs;
}
// 反向:x>0时梯度为1,x<=0时梯度为0.25
std::vector<double> backward(const std::vector<double>& grad_from_above) {
for (size_t i = 0; i < inputs.size(); i++) {
grad[i] = (inputs[i] > 0) ? grad_from_above[i] : 0.25 * grad_from_above[i];
}
return grad;
}
Sigmoid:
cpp
double sigmoid(double x) {
return 1.0 / (1.0 + std::exp(-x));
}
std::vector<double> forward(const std::vector<double>& inputs) {
for (size_t i = 0; i < inputs.size(); i++) {
outputs[i] = sigmoid(inputs[i]); // 压到 0~1
}
return outputs;
}
// 反向:sigmoid'(x) = sigmoid(x) * (1 - sigmoid(x))
std::vector<double> backward(const std::vector<double>& grad_from_above) {
for (size_t i = 0; i < inputs.size(); i++) {
double s = outputs[i]; // 复用Forward结果
grad[i] = grad_from_above[i] * s * (1.0 - s);
}
return grad;
}
4. MSE损失:算差多少
网络输出一个10维向量(one-hot编码),真实标签也是一个10维向量(比如数字5对应[0,0,0,0,0,1,0,0,0,0])。MSE算它们的平方差:
cpp
class MeanSquaredError {
public:
double loss;
std::vector<double> grad; // dL/dy_pred
double forward(const std::vector<double>& y_pred, const std::vector<double>& y_true) {
loss = 0;
grad.resize(y_pred.size());
for (size_t i = 0; i < y_pred.size(); i++) {
double diff = y_pred[i] - y_true[i];
loss += diff * diff;
grad[i] = 2.0 * diff; // dL/dy_pred = 2*(y_pred - y_true)
}
loss /= y_pred.size(); // 取平均
return loss;
}
};
5. Forward:数据怎么流
cpp
// 构建网络
DenseLayer layer1(784, 100);
ReLULayer relu1;
DenseLayer layer2(100, 10);
ReLULayer relu2;
MeanSquaredError loss_fn;
// 前向传播
auto& hidden = layer1.forward(image_pixels); // 784 -> 100
auto& activated1 = relu1.forward(hidden); // ReLU
auto& output = layer2.forward(activated1); // 100 -> 10
auto& final_out = relu2.forward(output); // ReLU
double loss = loss_fn.forward(final_out, label_onehot); // 算损失
6. Backward:梯度怎么传
这是整个项目最核心的部分。从Loss开始,一层一层往回传梯度,用链式法则。
cpp
// 反向传播
loss_fn.backward(); // 先算 dL/dy_pred
// 从最后一层往回传
auto grad2 = relu2.backward(loss_fn.grad); // dL/dz2
layer2.backward(grad2); // 算 layer2 的权重梯度,传回 dL/da1
auto grad1 = relu1.backward(layer2.input_grads); // dL/dz1
layer1.backward(grad1); // 算 layer1 的权重梯度
DenseLayer的Backward要干三件事:
cpp
void DenseLayer::backward(const std::vector<double>& grad_from_above) {
// grad_from_above 是 dL/doutput(这一层的输出梯度)
// 1. 算输入梯度(传给上一层)
for (int i = 0; i < input_size; i++) {
input_grads[i] = 0;
for (int j = 0; j < output_size; j++) {
input_grads[i] += grad_from_above[j] * neurons[j].weights[i];
}
}
// 2. 算每个权重的梯度
for (int j = 0; j < output_size; j++) {
for (int i = 0; i < input_size; i++) {
neurons[j].grad_w_sum += grad_from_above[j] * inputs[i];
}
neurons[j].grad_b_sum += grad_from_above[j];
}
}
7. 参数更新:梯度下降
cpp
double learning_rate = 0.0001; // LeakyReLU用这么小的,不然会NaN
for (auto& neuron : layer1.neurons) {
for (size_t i = 0; i < neuron.weights.size(); i++) {
neuron.weights[i] -= learning_rate * neuron.grad_w_sum;
}
neuron.bias -= learning_rate * neuron.grad_b_sum;
}
Sigmoid因为输出稳定在0~1,可以用更大的学习率(比如0.1),学得更快。
四、两种网络结构对比
结构1(单输出,踩坑版):
输入 784 → Dense 100 → ReLU → Dense 1 → ReLU → Loss
输出只有一个数字,直接跟标签(0~9的整数)比。结果:ReLU网络"死"了,输出永远是0,准确率10%。
结构2(10输出,正确版):
输入 784 → Dense 100 → 激活 → Dense 10 → 激活 → MSE Loss
输出是10维向量,one-hot编码。比如标签是5,目标向量就是[0,0,0,0,0,1,0,0,0,0]。网络学的是"哪个位置的概率最大"。
五、相关领域知识点
| 知识点 | 在这项目里的体现 |
|---|---|
| 全连接层(Dense/FC) | 每个输入连每个输出,矩阵乘法实现 |
| 激活函数 | ReLU、LeakyReLU、Sigmoid,引入非线性 |
| Dying ReLU | 负值梯度为0,神经元永久"死亡" |
| 链式法则 | 反向传播的核心数学原理 |
| MSE损失 | 回归和分类的常用损失函数 |
| 梯度下降 | w = w - lr * grad,逐步减小损失 |
| 学习率 | 控制更新步长,太大震荡/NaN,太小学得慢 |
| One-hot编码 | 把整数标签转成向量,适合分类任务 |
| 权重初始化 | 随机初始化,打破对称性 |
| MNIST | 手写数字数据集,深度学习界的"Hello World" |
六、设计思路:为什么这样设计?
| 设计选择 | 为什么这么做 | 生产框架会怎么做 |
|---|---|---|
| 纯C++标准库 | 零依赖,任何人都能编译 | Python + NumPy/PyTorch |
| 面向对象设计 | Neuron、Layer、Network类,结构清晰 | 张量运算,自动微分 |
| 手动实现Backward | 理解链式法则的每一个细节 | autograd自动算梯度 |
| 标量循环 | 可读性优先,方便调试 | 矩阵运算,调BLAS库 |
| 多种激活函数对比 | 直观感受不同函数的优劣 | 默认ReLU,很少换 |
| MSE做分类损失 | 教学用,简单直观 | 通常用CrossEntropy |
七、代码实现用途和目的
- 理解神经网络底层:不是调包,是亲手搭积木
- 理解反向传播:链式法则怎么一层一层传回去
- 理解激活函数的影响:ReLU会"死"、Sigmoid稳定、LeakyReLU折中
- 理解学习率的重要性:太大炸掉,太小爬不动
- 作为更复杂项目的起点:在这个骨架上加卷积、加Dropout、加BatchNorm
八、流程原理图
图1:整体架构

If you need the complete source code, please add the WeChat number (c17865354792)
图2:Dying ReLU vs LeakyReLU

图3:三种激活函数训练效果对比

图4:反向传播流程

九、怎么跑起来?一步步来
1. 编译
bash
cmake .
make
要求:
- CMake 3.10+
- C++11 或更高版本的编译器
2. 运行
bash
./main
程序会自动:
- 下载 MNIST 数据集(训练集 60000 张,测试集 10000 张)
- 用默认配置训练(Structure 1,ReLU,50 epochs)
- 输出每轮的 loss 和准确率
3. 修改网络结构
打开 src/main.cpp,找到网络定义部分:
cpp
// 结构1:单输出(会踩Dying ReLU的坑)
DenseLayer layer1(784, 100);
ReLULayer relu1;
DenseLayer layer2(100, 1); // 注意:输出只有1个
ReLULayer relu2;
// 改成结构2:10输出分类
DenseLayer layer1(784, 100);
LeakyReLULayer leaky1(0.25); // leak=0.25
DenseLayer layer2(100, 10); // 10个输出
SigmoidLayer sigmoid2; // 输出层用Sigmoid
4. 修改训练参数
cpp
// 学习率
double learning_rate = 0.0001; // LeakyReLU用这个
// double learning_rate = 0.1; // Sigmoid可以用这个,学得更快
// 训练轮数
int epochs = 50;
// 激活函数
// 把 ReLULayer 换成 LeakyReLULayer 或 SigmoidLayer
5. 观察结果
ReLU + 单输出(踩坑):
Epoch: 0 | Loss: 1.0 | Train Accuracy: 0.1022
Epoch: 1 | Loss: 1.0 | Train Accuracy: 0.1022
...
loss不动,acc卡在10%。因为网络"死"了,所有输出都是0。
LeakyReLU + 10输出:
Epoch: 0 | Loss: 1.79 | Train Accuracy: 0.1022
Epoch: 10 | Loss: 1.2 | Train Accuracy: 0.45
Epoch: 50 | Loss: 0.3 | Train Accuracy: 0.8766
Test Accuracy: 87.66%
Sigmoid + 10输出:
Epoch: 0 | Loss: 1.79 | Train Accuracy: 0.1022
Epoch: 5 | Loss: 0.4 | Train Accuracy: 0.80
Epoch: 50 | Loss: 0.05 | Train Accuracy: 0.9042
Test Accuracy: 90.42%
6. 可视化输出
训练结束后,程序会随机选几张测试图片,打印ASCII艺术图和预测结果:
,$,
.$$
#$,
O$#
.$$.
#$O
O$#
.$$:
O$#
.$$o
O@#
X$o
$$oo#o
o$$$$$$o
O$$$O,$O
#$$X .$X
X$$o :$X
:$$O:$$:
X$$$@#
o##X.
Prediction: 6 | Label: 6
Probabilities: [3.6e-05, 0.008, 0.019, 0.014, 0.0001, 0.003, 0.503, 0.002, 0.046, 0.001]
可以看到,Sigmoid的输出概率集中在正确类别(0.503),其他都接近0。
7. 常见问题
- 编译报错找不到CMake :安装CMake,
sudo apt-get install cmake或brew install cmake - 训练loss变成NaN:学习率太大,权重爆炸。减小学习率(比如从0.1减到0.0001)
- 准确率卡在10%:检查是不是用了单输出+ReLU的组合,换成10输出+Sigmoid
- 训练太慢:C++实现没有GPU加速,MNIST 60000张图跑50轮确实慢。可以减小训练集(比如只用10000张)或减小epoch
十、总结
这个项目最大的价值,不是让你拿它去跟PyTorch比速度(肯定比不过),而是让你亲手摸一遍神经网络的每一根骨头。
它用纯C++实现了:
- 单个神经元的Forward和Backward
- DenseLayer的矩阵运算和梯度传播
- 三种激活函数(ReLU、LeakyReLU、Sigmoid)的前向和反向
- MSE损失的计算和梯度
- 完整的训练循环(Forward → Loss → Backward → Update)
而且通过实验,你亲眼看到了:
- ReLU的"死亡陷阱":负数梯度为0,神经元永久休眠
- LeakyReLU的救赎:给负数留条小缝,网络能继续学
- Sigmoid的稳定优势:输出压到0~1,训练又快又稳
如果你想真正理解"神经网络是怎么学会的",而不是只会调包,这个项目就是最好的起点。把代码通读一遍,亲手改改激活函数、学习率、网络结构,你会对深度学习的底层原理有一个完全不一样的体感。
Welcome to follow WeChat official account【程序猿编码】