第 23 章 · 代码实战
MNIST 实战:把第一、二部分跑起来
第一、二部分你已经把神经元 → 网络 → 损失 → 梯度下降 → 反向传播 → 优化器 → 训练技巧拆了个遍。
这一章不引入任何新概念,而是带你逐段读一个真能跑、还真能到 ~98% 准确率的完整程序:
让网络学会认手写数字 0–9。整个程序就是仓库里的
src/demo/mnist/main.cpp,不到 200 行、不依赖任何大框架。读完你会发现:
前面那些概念,在这里都对应着实实在在的几行代码。
读完这一章,你会明白
- 一个真实训练程序从头到尾有哪五步:数据 → 建网 → 训练 → 评估 → 保存;
- 每一步分别在“兑现”前面哪一章的哪个概念;
- 那一行不起眼的
Train(...)背后,到底在循环做什么; - 怎么亲手把它编译、跑起来,亲眼看到准确率一轮轮往上爬。
1. 任务与全景:一张图片,一个数字
MNIST 是深度学习界的“Hello World”:7 万张 28×28 的手写数字灰度图 (6 万张训练、1 万张测试),每张图是 0–9 中的一个数字。我们的任务就是:看一张图,说出它是几。 这是个标准的十分类问题,正好把第一、二部分全用上。
整条流水线,就是第 3 章“前向传播”加第 4 章“softmax”。训练好后,测试准确率约 97.8%。
左边是本书讲过的道理,右边是它在 main.cpp 里对应的真身。你不需要背代码,
只要能把“这几行在干第几章那件事”对上号,就说明前面真的懂了。
2. 第一步 · 数据:把图片变成网络能吃的数字
回忆第 0 章的词:一张图片是一个样本,描述它的数字是特征, 正确数字是标签。网络只认数字,所以要先把图片摊平: 28×28 的像素拉成一条 784 维的向量,每个像素的灰度归一化到 [0,1] (0=纯黑,1=纯白)。这 784 个数,就是喂给第 3 章那个输入层的特征。
标签也要变形。数字 3 不能直接当目标(否则模型会以为 3 比 1 “大三倍”),
而是写成独热(one-hot)向量——只有第 3 个位置是 1,其余是 0。这正好对上第 4 章 softmax + 交叉熵想要的目标形状。
MnistData mnist_data;
mnist_data.LoadMnistData(train_image, train_label,
test_image, test_label); 1
// 每张图 = 784 个已归一化到 [0,1] 的像素
int N_train = mnist_data.train_data().size();
// 把标签数字变成 one-hot: 数字 d -> 第 d 位为 1
vector<vector<double>> train_target(N_train, vector<double>(10, 0.0));
for (int i = 0; i < N_train; i++)
train_target[i][ mnist_data.train_labels()[i] ] = 1.0; 2
3. 第二步 · 建网:把积木一块块拼起来
这是最能体现“前面白学没白学”的一段。一行 Init 定层数,五行 set_* 选积木,
每一行都是前面某一章的主角:
NeuralNetwork demo_network;
demo_network.Init(vector<int>{784, 128, 64, 10}); 1
demo_network.set_random_seed(42); 2
demo_network.set_param_init_function(PARAM_INIT_HE); 3
demo_network.set_activate_function(ACTIVATE_RELU); 4
demo_network.set_softmax_function(SOFTMAX_STD); 5
demo_network.set_loss_function(LOSS_CROSS_ENTROPY); 6
demo_network.set_optimizer_function(OPTIMIZER_ADAM); 7
- 网络结构(第 3 章):输入 784,两个隐藏层 128、64,输出 10。数据会一层层向前流动。
- 固定随机种子:让每次运行的初始旋钮一样,结果可复现(方便调试)。
- He 初始化(第 9 章):给旋钮一个合适的随机起点。搭配 ReLU 的标配,避免一开始就“信号消失”。
- 激活函数 ReLU(第 2 章):每个神经元加权求和之后过一道 ReLU,给网络“掰弯”的非线性能力。
- softmax(第 4 章):把输出层的 10 个分数变成加起来为 1 的概率。
- 交叉熵损失(第 4 章):衡量“预测的概率分布”和 one-hot 答案差多少——分类任务的黄金搭档。
- 优化器 Adam(第 8 章):决定拿到梯度后“怎么拧旋钮”,比朴素 SGD 收敛更快更稳。
层(第 3 章)+ 激活(第 2 章)+ softmax/损失(第 4 章)+ 初始化(第 9 章)/优化器(第 8 章)。
这就是“可插拔策略”的好处:换个激活、换个优化器,只需改一行。第 8 章的 optimizer_bench
demo 就是靠这种一行切换,把 SGD / Momentum / Adam 拉出来赛跑的。
4. 第三步 · 学习率调度:先热身,再缓降
第 9 章说过,学习率(每次拧旋钮的步子)不该一成不变。这里用 WarmupCosineLR: 开头一个 epoch 从很小慢慢升到设定值(热身,防止一上来步子太大把网络“踹飞”), 之后沿余弦曲线平滑下降(后期小步微调,稳稳落地)。
int batch_size = 64, epochs = 5;
int steps_per_epoch = (N_train + batch_size - 1) / batch_size;
int total_steps = epochs * steps_per_epoch;
int warmup_steps = steps_per_epoch; // 用 1 个 epoch 热身 1
demo_network.set_lr_scheduler(std::make_shared<WarmupCosineLR>(
base_lr, warmup_steps, total_steps, min_lr)); 2
- step 的定义(第 5、9 章):一个 batch 更新一次参数叫一 step;跑完一遍全部数据(
steps_per_epoch步)叫一个 epoch。这里 batch=64、训练 5 个 epoch。 - 把调度器挂到网络上,训练时每一步都会先问它“这一步该用多大的学习率”,再更新参数。
5. 第四步 · 训练:这一行 Train 背后的循环
真正的训练只有一句话:Train(...)。但它内部,正是第 0 章那个“预测 → 对答案 → 算损失 → 微调”的循环,
把 6 万张图反复过 5 遍。传进去的那个 each_step 回调,只是让我们每个 epoch 末偷看一眼成绩。
auto each_step = [&](NeuralNetwork &net, int step, bool &) {
// 只在每个 epoch 末算一次 loss / 准确率, 打印出来
double test_loss = 0;
net.CalcLoss(test_data, test_target, test_loss);
double acc = Accuracy(net, test_data, test_labels); 1
cout << "epoch ... test_acc=" << acc << endl;
};
demo_network.Train(train_data, train_target,
each_step, total_steps, batch_size, base_lr); 2
- 回调里顺便调
CalcLoss看损失、调下面的Accuracy看准确率,让你亲眼看到它俩一个降、一个升。 - 核心就这一句。下面这张图拆开它每一步在做什么。
前向对应“①预测”,交叉熵对应“②对答案 / ③算差距”,反向 + Adam 对应“④微调旋钮”。 你在第 0 章看到的那个抽象循环,到这里终于变成了会真的把准确率从 ~10%(瞎猜)拉到 ~98% 的具体代码。
6. 第五步 · 评估:准确率到底怎么算
损失小不等于“认得准”,我们更关心准确率。做法很直接:让网络对一批图前向一遍,
每张图得到 10 个概率,取概率最高的那个下标(argmax)当预测,再和真标签比,数数对了几张。
net.PredictBatch(x, pred); // 一次前向一大批, 比逐张快 1
int correct = 0;
for (int i = 0; i < pred.size(); i++) {
int pi = argmax(pred[i]); // 概率最高的那个数字 2
if (pi == labels[i]) correct++;
}
return correct * 1.0 / pred.size(); 3
PredictBatch就是第 3 章的前向传播,只是一次算一整批(一个大矩阵乘法),比循环逐张调Predict快得多。argmax:10 个概率里选最大的下标。这个下标就是模型“猜的数字”。- 对的张数 / 总张数 = 准确率。跑完 5 个 epoch,你会看到测试准确率稳定在 97%~98%。
7. 收尾 · 保存与续训
训练很贵,当然要把学好的旋钮存下来。程序最后把所有参数导出到 demo.v2.param;
下次再运行会自动检测到它、加载续训(并把学习率自动降到很小做微调)。想从零重来,删掉这个文件即可。
NeuralNetwork::NetworkParam param;
NeuralNetwork::NetworkOption option;
demo_network.ExportNetworkParam(param, option); 1
NeuralNetworkLoader::ExportParamToFile(param, option, "demo.v2.param");
- 把网络里的层大小、权重、偏置全部序列化成一个二进制文件。第 19 章那个语言模型也是同样的套路存模型。
在仓库的 src/ 目录下:
./build.sh → 编译
./bin/mnist → 训练 + 评估
你会看到每个 epoch 打印 train_loss / test_loss / test_acc / lr——
损失一路下降、准确率一路上升。试着改改 epochs、把 ACTIVATE_RELU 换成
ACTIVATE_SIGMOID、或把 OPTIMIZER_ADAM 换成 OPTIMIZER_SGD,亲眼感受前几章讲的差别。
小结
动手与思考
问题 1:为什么标签数字要变成 one-hot,而不是直接把数字当目标?
因为类别之间没有“大小/远近”关系(数字 8 不比 1 “大八倍”)。直接用数字会给模型错误的顺序假设。one-hot 把它变成 10 个类的概率目标,正好和 softmax + 交叉熵(第 4 章)配套。
问题 2:那一行 Train(...) 内部,每一步(step)依次做了哪几件事?
问题 3:准确率是怎么从网络的 10 个输出算出来的?
网络对每张图输出 10 个概率,取概率最高的下标(argmax)作为预测数字,与真实标签比较,统计预测对的比例。这一步只用前向传播(PredictBatch),不需要反向。
你已经把第一、二部分的所有概念,在一份真实代码里对上了号。下一章我们如法炮制, 去读那个字符级语言模型的完整程序——把第四、五部分(注意力、Transformer、生成、采样)也一并落到代码上。