不用PyTorch,我用C++手写了一个能认手写数字的神经网络

一、先说说这玩意儿是干嘛的

学神经网络,大多数人的入门路径是:装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;
    }
};

weightsbias一开始是随机数。训练的过程,就是不断调整这两个东西,让输出越来越接近正确答案。

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

七、代码实现用途和目的

  1. 理解神经网络底层:不是调包,是亲手搭积木
  2. 理解反向传播:链式法则怎么一层一层传回去
  3. 理解激活函数的影响:ReLU会"死"、Sigmoid稳定、LeakyReLU折中
  4. 理解学习率的重要性:太大炸掉,太小爬不动
  5. 作为更复杂项目的起点:在这个骨架上加卷积、加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

程序会自动:

  1. 下载 MNIST 数据集(训练集 60000 张,测试集 10000 张)
  2. 用默认配置训练(Structure 1,ReLU,50 epochs)
  3. 输出每轮的 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 cmakebrew 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【程序猿编码

相关推荐
不会代码的小猴1 小时前
4. 控件学习2
开发语言·c++·笔记·qt
安逸sgr2 小时前
视觉 Token 是什么?图片是怎么送进大模型的?
人工智能·ai·大模型·agent·智能体
闻道且行之2 小时前
图片处理助手|C++ 手搓离线 AI 抠图工具,U2Net 原理到落地一次讲透
开发语言·c++·人工智能·神经网络·opencv·计算机视觉
前沿在线2 小时前
视频模型卷向第二个维度,生数科技 Vidu S1 打响第一枪
人工智能·ai·大模型
玖釉-2 小时前
nvpro_core2 源码与架构解析:NVIDIA Vulkan 图形开发基础框架
c++·windows·图形渲染
129Lab3 小时前
数据驱动电池SOH预测实战:用AI工具快速构建深度学习模型并调参
pytorch·深度学习·ai编程·数据驱动·特征工程·电池管理系统·电池健康状态
啦啦啦啦啦zzzz3 小时前
高级I/O函数(一)
linux·服务器·网络·c++·网络编程
软糯小野猫3 小时前
使用Pytorch从零开始构建StyleGAN
pytorch·深度学习·gan·图像生成·stylegan
笨鸟先飞的橘猫3 小时前
c++游戏后端开源框架学习——wukong(十、lua热更新)
c++·游戏·开源