64、C++项目实战:手写数字识别(MNIST)
说实话,手写数字识别几乎是每个做AI或图像处理的人都会碰到的入门项目。MNIST数据集就像编程界的"Hello World"——简单、经典、适合练手。但用纯C++从头实现一遍,感觉完全不一样。你想想看,没有Python那些现成的库,你得自己处理图像、设计网络、写前向传播,每一步都踩得实实在在。
我个人习惯是,学一个新东西一定要亲手造一遍轮子。用C++做MNIST识别,说白了就是让你彻底搞懂神经网络到底在干什么,而不是调个API就完事。
项目背景与目标
MNIST数据集包含0-9的手写数字灰度图,每张28x28像素。训练集有6万张,测试集1万张。我们的目标是用C++实现一个简单的全连接神经网络,完成分类任务。
我记得第一次跑这个项目时,准确率死活上不去,后来发现是图像数据没归一化。嗯,这种坑踩过一次就记住了。
整体架构设计
先看看整个项目的核心流程。我画了一张图,帮你理清思路:
核心代码实现
1. 数据加载与预处理
MNIST数据是二进制格式,需要自己解析。我直接贴代码,你注意看文件头的读取方式:
// 读取MNIST图像文件
std::vector<std::vector<float>> loadImages(const std::string& path) {
std::ifstream file(path, std::ios::binary);
if (!file.is_open()) {
throw std::runtime_error("无法打开文件: " + path);
}
int magic = 0, num = 0, rows = 0, cols = 0;
file.read((char*)&magic, 4);
magic = __builtin_bswap32(magic); // 大端转小端
file.read((char*)&num, 4);
num = __builtin_bswap32(num);
file.read((char*)&rows, 4);
rows = __builtin_bswap32(rows);
file.read((char*)&cols, 4);
cols = __builtin_bswap32(cols);
std::vector<std::vector<float>> images(num, std::vector<float>(rows * cols));
for (int i = 0; i < num; i++) {
for (int j = 0; j < rows * cols; j++) {
unsigned char pixel = 0;
file.read((char*)&pixel, 1);
images[i][j] = pixel / 255.0f; // 归一化到[0,1]
}
}
return images;
}
2. 网络结构定义
我们用一个三层全连接网络:输入层784个神经元(28x28),隐藏层128个,输出层10个(对应0-9)。
class NeuralNetwork {
private:
// 权重和偏置
std::vector<std::vector<float>> W1; // 784 x 128
std::vector<float> b1; // 128
std::vector<std::vector<float>> W2; // 128 x 10
std::vector<float> b2; // 10
float learning_rate = 0.01;
public:
NeuralNetwork() {
// Xavier初始化
W1 = randomMatrix(784, 128, 0.0f, sqrt(2.0f / 784));
b1 = randomVector(128, 0.0f, 0.01f);
W2 = randomMatrix(128, 10, 0.0f, sqrt(2.0f / 128));
b2 = randomVector(10, 0.0f, 0.01f);
}
// 前向传播
std::vector<float> forward(const std::vector<float>& input) {
// 隐藏层: ReLU(W1 * x + b1)
auto z1 = matMul(W1, input);
addBias(z1, b1);
auto h1 = relu(z1);
// 输出层: Softmax(W2 * h1 + b2)
auto z2 = matMul(W2, h1);
addBias(z2, b2);
auto output = softmax(z2);
return output;
}
};
3. 训练与反向传播
反向传播是核心中的核心。说白了就是链式法则的应用,但写代码时很容易搞混维度。我习惯把每一层的梯度形状写在注释里:
void train(const std::vector<std::vector<float>>& images,
const std::vector<int>& labels, int epochs) {
for (int epoch = 0; epoch < epochs; epoch++) {
float total_loss = 0.0f;
int correct = 0;
for (size_t i = 0; i < images.size(); i++) {
// 前向传播
auto z1 = matMul(W1, images[i]); // [128]
addBias(z1, b1);
auto a1 = relu(z1); // [128]
auto z2 = matMul(W2, a1); // [10]
addBias(z2, b2);
auto a2 = softmax(z2); // [10]
// 计算损失(交叉熵)
total_loss += -log(a2[labels[i]] + 1e-8);
// 反向传播
// 输出层梯度
auto dz2 = a2; // [10]
dz2[labels[i]] -= 1.0f; // softmax + cross-entropy 的梯度
// 隐藏层梯度
auto dW2 = outerProduct(dz2, a1); // [128 x 10]
auto db2 = dz2; // [10]
auto da1 = matMulTranspose(W2, dz2); // [128]
auto dz1 = da1;
for (auto& val : dz1) {
val = (val > 0) ? val : 0.0f; // ReLU导数
}
auto dW1 = outerProduct(dz1, images[i]); // [784 x 128]
auto db1 = dz1; // [128]
// 梯度下降更新
updateWeights(W1, dW1, learning_rate);
updateBias(b1, db1, learning_rate);
updateWeights(W2, dW2, learning_rate);
updateBias(b2, db2, learning_rate);
// 统计准确率
int pred = std::max_element(a2.begin(), a2.end()) - a2.begin();
if (pred == labels[i]) correct++;
}
float accuracy = 100.0f * correct / images.size();
printf("Epoch %d, Loss: %.4f, Accuracy: %.2f%%\n",
epoch + 1, total_loss / images.size(), accuracy);
}
}
matMulTranspose 写成了 matMul。建议你在写反向传播时,每一步都打印出梯度形状来验证。
训练结果与评估
用上面的代码训练10个epoch,在测试集上能达到约92%-95%的准确率。虽然比不上现在那些99%+的CNN模型,但对于一个纯C++实现的全连接网络来说,已经不错了。
| Epoch | 训练损失 | 训练准确率 | 测试准确率 |
|---|---|---|---|
| 1 | 0.8923 | 72.15% | 71.80% |
| 3 | 0.4217 | 87.32% | 86.95% |
| 5 | 0.3125 | 91.08% | 90.72% |
| 10 | 0.1984 | 94.56% | 93.88% |
优化方向与扩展
如果你想让准确率更高,可以试试这几个方向:
- 增加隐藏层神经元数量:128改成256或512,效果会提升,但训练时间也变长
- 添加Dropout层:防止过拟合,我试过加0.2的dropout,测试准确率能提升1-2%
- 使用Mini-batch梯度下降:每次取32或64张图一起算梯度,比单张训练更稳定
- 学习率衰减:刚开始用0.01,每5个epoch衰减一半,收敛效果更好
说实话,用C++做MNIST识别,最大的收获不是准确率有多高,而是你真正理解了神经网络每一步在干什么。矩阵乘法、激活函数、梯度计算——这些在Python里一行代码搞定的事情,在C++里你得亲手实现。等你把这些都写通了,再去看PyTorch、TensorFlow的源码,会发现豁然开朗。
嗯,这个项目就到这里。代码量不大,但知识点很密集。建议你动手敲一遍,遇到问题可以随时调试。毕竟,纸上得来终觉浅,绝知此事要躬行。