首页 / 资讯中心 / 文章详情

C++从零手写CNN与全连接网络:反向传播与MNIST实战

C++从零手写CNN与全连接网络:反向传播与MNIST实战 ★ FEATURED ARTICLE
简介面向深度学习初学者与进阶开发者的C实战资料包聚焦从零实现卷积神经网络与全连接神经网络并用MNIST手写数字数据集完成训练与精度验证。内容围绕卷积层、池化层、全连接层、权重初始化、ReLU激活、交叉熵损失及梯度下降/Adam优化等核心知识展开帮助读者理解前向传播、反向传播、批处理与模型评估的完整链路同时体会C在内存管理和计算效率方面的实践要点。压缩包共30个文件以8个cpp源文件、5个h与3个hpp头文件为主体另含3个sh构建脚本、配置文件及MNIST原始数据整体约11MB便于按模块阅读和编译调试。已有86人学习适合希望打通深度学习原理与C工程实现的读者对照研读、复现实验并查漏补缺。1. 从零手写一遍用 C 把卷积神经网络和全连接神经网络拆开看深度学习框架用久了很多人对反向传播的理解就停在“调个.backward()”这一层梯度到底怎么算、张量怎么在内存里排布全成了黑匣子。这份资源走的是另一条路不依赖任何深度学习库用 C 从零实现全连接神经网络和卷积神经网络自己去读 MNIST 的 IDX 二进制文件手写前向传播、反向传播、Softmax 交叉熵和 SGD 更新训练完再在测试集上跑一遍精度验证。适合两类人想搞懂框架底层到底在算什么的学生和转行者以及想把模型塞进 C 工程、不想拖一个庞大运行时环境的老手。代码量不大但每一行都得自己写对错一个维度就训练不动。2. 工程骨架目录结构、张量布局与第一次编译跑通拿到一个 C 项目我习惯先不急着看算法先看它怎么组织文件、怎么编译、数据放哪。这个项目的骨架非常朴素没有花哨的依赖注入也没有模板元编程炫技就是一套能跑的训练-评估流程。理解这套骨架后面看网络实现才不会迷路。2.1 目录结构与模块职责常见做法是按“数据、层、网络、训练、入口”五个职责切分目录大致长这样cpp-cnn-mnist/ ├── CMakeLists.txt ├── config/ │ └── train.cfg ├── data/ │ ├── train-images-idx3-ubyte │ ├── train-labels-idx1-ubyte │ ├── t10k-images-idx3-ubyte │ └── t10k-labels-idx1-ubyte ├── include/ │ ├── tensor.h │ ├── layer.h │ ├── fc_layer.h │ ├── conv_layer.h │ ├── pool_layer.h │ ├── loss.h │ └── network.h └── src/ ├── mnist_loader.cpp ├── fc_layer.cpp ├── conv_layer.cpp ├── pool_layer.cpp ├── softmax_ce.cpp ├── network.cpp └── main.cpp各模块的边界卡得比较清楚tensor.h只管一块连续内存加 shape不算梯度也不管设备layer.h定义所有层必须实现的两个虚函数forward和backwardnetwork.cpp负责把层按顺序串起来同时持有参数列表和学习率main.cpp负责解析配置、加载数据、跑 epoch 循环、打印精度。这种拆法最大的好处是加新层时不用碰训练循环只要实现接口再push_back进网络就行。config/train.cfg里通常放这些键值改超参不用重新编译lr0.01 batch_size64 epochs5 hidden_dim128 modelcnn seed42提示把model做成可切换的枚举全连接版和卷积版共用同一个训练循环对比两者精度时省事很多。2.2 张量表示与内存布局的取舍这个项目里最核心的数据结构就是张量。我的建议是别一上来就搞多维数组嵌套vectorvectorvectorfloat这种写法在反向传播里会把人折磨疯——每一层的输入输出 shape 都不一样索引写错一次就是段错误。用一块连续的std::vectorfloat加上 shape 描述是这类教学兼实战项目最稳妥的选择// include/tensor.h struct Tensor { std::vectorfloat data; // 行优先连续存储 std::vectorint shape; // 例如 {B, C, H, W} Tensor() default; Tensor(std::vectorint s) : shape(std::move(s)) { int total 1; for (int d : shape) total * d; data.assign(total, 0.0f); } int size() const { return static_castint(data.size()); } float at(int b, int c, int h, int w) { // NCHW 行优先索引 return data[((b * shape[1] c) * shape[2] h) * shape[3] w]; } const float at(int b, int c, int h, int w) const { return data[((b * shape[1] c) * shape[2] h) * shape[3] w]; } };这段代码的关键信息有三个。第一data是连续内存im2col展开卷积核时可以整块memcpy不用逐元素拷贝性能差距在几倍以上。第二索引顺序固定为 NCHW全篇统一不要这层用 NHWC 那层用 NCHW否则调试时你会怀疑人生。第三at()提供的是带边界语义的四维访问卷积和池化里到处用而全连接层只需要[B, in]二维视角直接用data[b * in i]更快。为什么不直接上 Eigen 或 OpenBLAS这个项目的目标是“看清每一步计算”引入第三方矩阵库后矩阵乘变成一行A * B反向传播的推导过程就被藏起来了。当然如果你后面要做大模型或者追性能把Tensor换成 Eigen 的Map是常规操作接口基本不用动。2.3 编译、超参配置与首次运行工程用 CMake 管理核心就是开优化、定标准cmake_minimum_required(VERSION 3.16) project(cpp_cnn_mnist CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) endif() add_executable(train src/main.cpp src/mnist_loader.cpp src/fc_layer.cpp src/conv_layer.cpp src/pool_layer.cpp src/softmax_ce.cpp src/network.cpp ) target_include_directories(train PRIVATE include)如果不想用 CMake一条命令也能编g -O2 -stdc17 -Iinclude src/*.cpp -o train-O2不能省。这类纯手写循环的代码在-O0下跑一个 epoch 可能要几分钟开了优化后能压到十几秒量级差距非常直观。我第一次跑的时候就忘了加盯着进度条以为代码写错了血泪经验。跑起来之前确认data/下的四个文件就位文件名要和加载器里写死的一致。运行./train --config config/train.cfg一个正常的训练日志大概长这样loss 从 2.3 左右10 类均匀分布对应-ln(0.1)一路往下降第一个 epoch 结束测试集精度就能到 90% 以上[epoch 1] batch 100 loss0.4213 acc88.42% [epoch 1] batch 200 loss0.2861 acc91.05% [epoch 1] test acc94.12% [epoch 2] test acc96.38%全连接网络跑五六个 epoch 一般能到 97% 到 98%卷积网络通常能摸到 99% 附近。如果你的曲线完全不是这个形状先别改代码直接翻到第 5 章的排查清单那里列的几种情况基本能覆盖九成问题。3. 数据与全连接IDX 解析、Layer 抽象与 Softmax 交叉熵这一章是整个项目的“地基加第一层楼”。数据读错了后面网络写得再漂亮也是白搭而全连接网络的实现一旦跑通、梯度验证正确卷积那部分就只是换个算子的事。所以别急着上卷积先把这条最短链路走完。3.1 IDX 文件头解析与字节序MNIST 的四份文件都是大端序的二进制格式不是文本也不是普通的序列化对象。图像文件头是 4 个 32 位整数魔数2051、图片数量、行数、列数标签文件头是 2 个整数魔数2049、标签数量。所有整数按大端存储而 x86 机器是小端必须手动转换。// src/mnist_loader.cpp static uint32_t readU32BE(std::ifstream in) { unsigned char b[4]; in.read(reinterpret_castchar*(b), 4); if (!in) throw std::runtime_error(IDX 文件读取越界); // 大端转本机小端 return (uint32_t(b[0]) 24) | (uint32_t(b[1]) 16) | (uint32_t(b[2]) 8) | uint32_t(b[3]); } std::vectorTensor loadImages(const std::string path) { std::ifstream in(path, std::ios::binary); if (!in) throw std::runtime_error(打不开 path); uint32_t magic readU32BE(in); if (magic ! 2051) throw std::runtime_error(图像魔数不对文件可能损坏); uint32_t n readU32BE(in); uint32_t row readU32BE(in); uint32_t col readU32BE(in); std::vectorunsigned char buf(static_castsize_t(n) * row * col); in.read(reinterpret_castchar*(buf.data()), buf.size()); std::vectorTensor out; out.reserve(n); for (uint32_t i 0; i n; i) { Tensor t({1, 1, (int)row, (int)col}); for (size_t j 0; j t.data.size(); j) { // 归一化到 [0,1]这一步不做后面必翻车 t.data[j] buf[i * t.data.size() j] / 255.0f; } out.push_back(std::move(t)); } return out; }readU32BE里的移位拼接是这套格式的标准解法读完后必须校验魔数这一步能挡掉“文件下载不全”和“路径指错”两类常见事故。归一化直接写在加载阶段除以 255这样网络内部看到的输入是 0 到 1 之间的小数梯度尺度才正常。如果你把 0 到 255 的原始像素直接喂进去第一层的权重梯度会大到让 loss 直接炸掉这是新手最常见的一次翻车。标签的读法同理只是头只有两个字段读完后是一个长度为n的uint8_t数组取值 0 到 9。3.2 归一化、one-hot 与 mini-batch 洗牌训练时不会一张一张图地喂那样梯度噪声太大、也跑不满 CPU 缓存。标准做法是按 batch 组装每个 epoch 开始前把样本顺序打乱。洗牌这件事看起来简单但它直接决定了模型能不能收敛到正常精度——如果数据是按类别排好序的MNIST 原始文件基本就是不洗牌的话每个 batch 里只有一两个类别梯度方向会剧烈摆动。// src/mnist_loader.cpp struct Batch { Tensor input; // {B, 1, 28, 28} std::vectorint label; // 长度 B取值 0..9 }; std::vectorBatch makeBatches(const std::vectorTensor images, const std::vectoruint8_t labels, int batchSize, std::mt19937 rng) { std::vectorint idx(images.size()); std::iota(idx.begin(), idx.end(), 0); std::shuffle(idx.begin(), idx.end(), rng); // 每个 epoch 调一次 std::vectorBatch batches; for (size_t start 0; start idx.size(); start batchSize) { size_t end std::min(idx.size(), start batchSize); Batch b; b.input Tensor({(int)(end - start), 1, 28, 28}); for (size_t k start; k end; k) { const auto img images[idx[k]]; std::copy(img.data.begin(), img.data.end(), b.input.data.begin() (k - start) * img.data.size()); b.label.push_back(labels[idx[k]]); } batches.push_back(std::move(b)); } return batches; }这里没有做 one-hot 编码标签保持整数。原因是在 Softmax 交叉熵的合并实现里我们只需要知道正确类别的下标去取-log(p[y])反向传播时也只需要在正确位置减 1省掉一次B×10的 one-hot 构造和一次乘法。这个技巧在工业代码里很常见既能省内存也能避免手写 one-hot 时把行列弄反——那是个非常隐蔽的 bug训练照样能跑只是精度永远上不去。3.3 全连接层Layer 基类与前向反向所有层共享一个抽象基类只要求实现两个方法外加参数访问接口。这样网络容器可以用vectorunique_ptrLayer统一管理训练循环里就是一句x layer-forward(x)。// include/layer.h class Layer { public: virtual ~Layer() default; virtual Tensor forward(const Tensor x) 0; // 保存反向所需中间量 virtual Tensor backward(const Tensor grad) 0; // 返回对输入的梯度 virtual std::vectorTensor* params() { return {}; } virtual std::vectorTensor* grads() { return {}; } virtual void zeroGrad() {} bool training true; };全连接层的前向是y xW bx形状[B, in]W形状[in, out]b形状[out]。反向按链式法则推dx dy · WᵀdW xᵀ · dydb是dy按 batch 求和。// src/fc_layer.cpp Tensor FCLayer::forward(const Tensor x) { in_ x; // 缓存输入反向要用 int B x.shape[0], in x.shape[1]; Tensor y({B, out_}); for (int b 0; b B; b) for (int o 0; o out_; o) { float sum bias_.data[o]; for (int i 0; i in; i) sum x.data[b * in i] * weight_.data[i * out_ o]; y.data[b * out_ o] sum; } return y; } Tensor FCLayer::backward(const Tensor dy) { int B in_.shape[0], in in_.shape[1]; Tensor dx({B, in}); // dW xᵀ · dy for (int i 0; i in; i) for (int o 0; o out_; o) { float g 0; for (int b 0; b B; b) g in_.data[b * in i] * dy.data[b * out_ o]; gradWeight_.data[i * out_ o] g; } // db Σ dy for (int o 0; o out_; o) { float g 0; for (int b 0; b B; b) g dy.data[b * out_ o]; gradBias_.data[o] g; } // dx dy · Wᵀ for (int b 0; b B; b) for (int i 0; i in; i) { float g 0; for (int o 0; o out_; o) g dy.data[b * out_ o] * weight_.data[i * out_ o]; dx.data[b * in i] g; } return dx; }三个循环对应三个梯度公式参数顺序和索引方式必须和 shape 严格对齐不然就会得到形状对但数值全错的梯度——这类 bug 最恶心程序不崩只是精度永远卡在 10% 左右。gradWeight_和gradBias_用的是累加所以每次optimizer step之后必须调zeroGrad()清零漏一次就会让梯度越滚越大几轮之后 loss 变成nan。3.4 Softmax-交叉熵合并层与 SGD 更新把 Softmax 和交叉熵拆成两层实现反向时要算 Softmax 的雅可比矩阵10×10的矩阵乘不仅慢还特别容易写错。合并成一层后对 logits 的梯度直接是p - onehot干净利落// src/softmax_ce.cpp float SoftmaxCrossEntropy::forward(const Tensor logits, const std::vectorint labels) { int B logits.shape[0], C logits.shape[1]; prob_ Tensor({B, C}); labels_ labels; float loss 0; for (int b 0; b B; b) { float maxLogit *std::max_element( logits.data.begin() b * C, logits.data.begin() (b 1) * C); float sum 0; for (int c 0; c C; c) { float e std::exp(logits.data[b * C c] - maxLogit); // 防溢出 prob_.data[b * C c] e; sum e; } for (int c 0; c C; c) prob_.data[b * C c] / sum; loss -std::log(prob_.data[b * C labels[b]] 1e-9f); } return loss / B; } Tensor SoftmaxCrossEntropy::backward() { int B prob_.shape[0], C prob_.shape[1]; Tensor g({B, C}); for (int b 0; b B; b) { for (int c 0; c C; c) g.data[b * C c] prob_.data[b * C c]; g.data[b * C labels_[b]] - 1.0f; // 正确类别减 1 for (int c 0; c C; c) g.data[b * C c] / B; } return g; }- maxLogit是防exp溢出的标准操作数学上等价但少了它 batch 稍微大一点就会出inf。最后除以B是因为 loss 取的是 batch 平均梯度也要同步缩放否则学习率的实际效果会随 batch size 变化调参时你会一直以为学习率设错了。参数更新就是最朴素的 SGD// src/network.cpp void Network::step(float lr) { for (auto* layer : layers_) for (size_t i 0; i layer-params().size(); i) { auto p *layer-params()[i]; auto g *layer-grads()[i]; for (size_t k 0; k p.data.size(); k) p.data[k] - lr * g.data[k]; std::fill(g.data.begin(), g.data.end(), 0.0f); // 梯度清零 } }学习率从0.01起步配合batch_size64全连接网络跑五轮基本就收敛了。如果 loss 在前几十个 batch 里下降得很慢先把学习率乘 10 试试如果直接震荡或nan除以 10。这个方向性的判断比任何调参教程都实用。4. 卷积网络im2col 卷积、池化与反向传播链路全连接网络在 MNIST 上能到 97% 以上但参数量大、丢掉了空间结构信息稍微复杂一点的数据集就撑不住了。卷积网络这部分是整个项目里最有含量的难点不在前向而在反向怎么把梯度准确地散回原图。4.1 im2col把卷积摊平成一次矩阵乘直接写六重循环做卷积batch、输出通道、输出高、输出宽、输入通道、卷积核高、卷积核宽在 C 里能跑但缓存命中率极差写反向时索引也会乱。工业界和教学实现里通用的做法是im2col把每个卷积窗口覆盖的输入块拉成一列卷积核也摊平成一行卷积就变成了一次标准矩阵乘。对于输入[B, C, H, W]、卷积核[OC, C, KH, KW]、步长S、填充P输出尺寸是Hout (H 2P - KH) / S 1 Wout (W 2P - KW) / S 1// src/conv_layer.cpp Tensor ConvLayer::im2col(const Tensor x) { int B x.shape[0], C x.shape[1], H x.shape[2], W x.shape[3]; int Hout (H 2 * pad_ - KH_) / stride_ 1; int Wout (W 2 * pad_ - KW_) / stride_ 1; int rowLen C * KH_ * KW_; Tensor cols({B * Hout * Wout, rowLen}); for (int b 0; b B; b) for (int oh 0; oh Hout; oh) for (int ow 0; ow Wout; ow) { int row (b * Hout oh) * Wout ow; int col 0; for (int c 0; c C; c) for (int kh 0; kh KH_; kh) for (int kw 0; kw KW_; kw) { int ih oh * stride_ - pad_ kh; int iw ow * stride_ - pad_ kw; // 越界位置补 0这是 padding 的本质 cols.data[row * rowLen col] (ih 0 ih H iw 0 iw W) ? x.at(b, c, ih, iw) : 0.0f; } } return cols; }展开后的cols形状是[B*Hout*Wout, C*KH*KW]卷积核摊平成[OC, C*KH*KW]两者一乘就得到[B*Hout*Wout, OC]再按[B, OC, Hout, Wout]的顺序摆回去。这样前向、反向都能复用同一套矩阵乘逻辑代码量能砍掉一半以上。参数上要盯住三个pad_决定边界补多少圈 0stride_决定窗口跳几步KH_/KW_是卷积核尺寸。三者定了输出尺寸就定了。每次构造卷积层时都该断言(H 2P - KH) % S 0整除不成立说明尺寸对不上早崩比晚崩好。4.2 卷积层反向col2im 与权重梯度反向传播要做两件事算卷积核的梯度dW以及把输出梯度散回输入dx。有了im2coldW就是一次矩阵乘colsᵀ · dydx则需要一个反向的col2im把每个窗口的梯度累加回原位置。注意是累加不是覆盖因为相邻窗口在stride KH时会重叠。// src/conv_layer.cpp void ConvLayer::col2im(const Tensor dcols, Tensor dx) { int B dx.shape[0], C dx.shape[1], H dx.shape[2], W dx.shape[3]; int Hout (H 2 * pad_ - KH_) / stride_ 1; int Wout (W 2 * pad_ - KW_) / stride_ 1; int rowLen C * KH_ * KW_; std::fill(dx.data.begin(), dx.data.end(), 0.0f); for (int b 0; b B; b) for (int oh 0; oh Hout; oh) for (int ow 0; ow Wout; ow) { int row (b * Hout oh) * Wout ow; int col 0; for (int c 0; c C; c) for (int kh 0; kh KH_; kh) for (int kw 0; kw KW_; kw) { int ih oh * stride_ - pad_ kh; int iw ow * stride_ - pad_ kw; if (ih 0 ih H iw 0 iw W) dx.at(b, c, ih, iw) dcols.data[row * rowLen col]; col; } } }这段最关键的细节是。如果写成重叠区域的梯度会被后面的窗口覆盖掉表现为学到的卷积核边缘发虚、精度比预期低一两个点。这种错误不会让程序崩溃只会让效果“看起来还行但就是差一口气”属于玄学级 bug只有对着数值梯度检查才能抓出来。dW的计算在前向已缓存cols_的前提下就是一次转置乘输出形状[OC, C*KH*KW]累加到gradWeight_上最后和全连接层一样交给Network::step统一更新。4.3 池化层与网络组装池化用 2×2 最大池化前向取窗口最大值同时记住最大值的位置下标反向时只把梯度送给当时胜出的那个位置其余位置为 0。这个“开关”语义保证了梯度不会凭空放大。// src/pool_layer.cpp Tensor MaxPool2D::forward(const Tensor x) { int B x.shape[0], C x.shape[1], H x.shape[2], W x.shape[3]; int Hout H / 2, Wout W / 2; argmax_.assign((size_t)B * C * Hout * Wout, 0); Tensor y({B, C, Hout, Wout}); for (int b 0; b B; b) for (int c 0; c C; c) for (int oh 0; oh Hout; oh) for (int ow 0; ow Wout; ow) { int bestIdx 0; float best -1e30f; for (int kh 0; kh 2; kh) for (int kw 0; kw 2; kw) { int ih oh * 2 kh, iw ow * 2 kw; float v x.at(b, c, ih, iw); if (v best) { best v; bestIdx kh * 2 kw; } } y.at(b, c, oh, ow) best; argmax_[((b * C c) * Hout oh) * Wout ow] bestIdx; } return y; } Tensor MaxPool2D::backward(const Tensor dy) { int B dy.shape[0], C dy.shape[1]; int Hout dy.shape[2], Wout dy.shape[3]; Tensor dx({B, C, Hout * 2, Wout * 2}); for (int b 0; b B; b) for (int c 0; c C; c) for (int oh 0; oh Hout; oh) for (int ow 0; ow Wout; ow) { int bestIdx argmax_[((b * C c) * Hout oh) * Wout ow]; dx.at(b, c, oh * 2 bestIdx / 2, ow * 2 bestIdx % 2) dy.at(b, c, oh, ow); // 只回传胜出位置 } return dx; }argmax_必须在forward里存下来这是池化层反向的唯一依据。如果为了省内存不存、反向时重新比一遍大小遇到相等值时分不清该给哪个位置虽然概率很低但一旦出现梯度就会静默变小训练后期精度爬不上去。网络组装就是按顺序push_back一份典型的 MNIST 卷积结构是Conv(1,8,3,3) → ReLU → MaxPool2x2 → Conv(8,16,3,3) → ReLU → MaxPool2x2 → Flatten → FC(16*4*4, 10)。这里4*4是经过两次 2×2 池化后从 28 降到 7不对28 先卷 3×3 无填充变成 26池化变 13再卷 3×3 变 11池化变 5。所以 Flatten 后是16*5*5。搭建时先用计算器把每一层的输出 shape 算清楚写死在注释里能省掉大量“形状不匹配”的调试时间。4.4 训练循环与精度验证训练循环的骨架在所有网络结构下都一样切 batch、前向、求 loss、反向、更新、清零梯度、周期性评估。// src/main.cpp for (int epoch 0; epoch cfg.epochs; epoch) { auto batches makeBatches(trainImages, trainLabels, cfg.batch_size, rng); // 自动洗牌 float totalLoss 0; for (int step 0; step (int)batches.size(); step) { const auto batch batches[step]; Tensor logits net.forward(batch.input); // 前向 float loss criterion.forward(logits, batch.label); // loss Tensor grad criterion.backward(); // dL/dlogits net.backward(grad); // 反向 net.step(cfg.lr); // 更新 清零 totalLoss loss; if ((step 1) % 100 0) std::cout [epoch epoch 1 ] batch step 1 loss totalLoss / 100 \n, totalLoss 0; } // 每轮结束在测试集上评估 int correct 0; for (size_t i 0; i testImages.size(); i) { Tensor out net.forward(testImages[i], /*training*/false); int pred std::max_element(out.data.begin(), out.data.end()) - out.data.begin(); if (pred testLabels[i]) correct; } std::cout [epoch epoch 1 ] test acc 100.0 * correct / testImages.size() %\n; }评估时要让网络处于推理模式主要是关掉 dropout 之类的训练专用行为并且不保存反向所需的中间量节省内存。精度统计就是argmax和标签比对逻辑简单但一定要拿测试集算用训练集算出来的数字没有参考价值——模型完全可能靠记忆把训练集刷到 100%。5. 排查清单训练不收敛、精度卡住时先看这五个现场这类手写项目最让人抓狂的地方是它不崩溃只是效果不对。你盯着代码看半天每一行都“看起来对”但 loss 就是不降。下面是我在复现和改造过程中实际遇到过的五类问题按“现象 → 原因 → 解决”整理照着顺序排查能省下大量时间。5.1 精度始终在 10% 附近打转现象loss 在2.3左右小幅波动测试精度一直在 10% 上下正好对应随机猜的水平。原因最可能是标签和网络输出对不上或者 Softmax 的反向多算了一层。比如把标签当成了 one-hot 向量去做索引取到的永远是第 0 类或者把 Softmax 的单层反向和交叉熵的反向各调了一次梯度被重复缩放方向偏了但范数正常。解决办法是加一处中间检查在第一个 batch 上打印prob_里正确类别的概率和预测类别// 调试用第一个 batch 打印前 5 个样本的预测与真实标签 for (int b 0; b std::min(5, B); b) { int pred std::max_element(prob_.data.begin() b * C, prob_.data.begin() (b 1) * C) - (prob_.data.begin() b * C); std::cout pred pred label labels_[b] p_true prob_.data[b * C labels_[b]] \n; }初始化阶段p_true应该在 0.1 附近如果打印出来是 0 或者 1说明 Softmax 的归一化写错了。另外顺手确认一下最后一层全连接的out_是不是等于类别数 10写错成 1 的时候损失函数不报错只是永远学不会。5.2 loss 直接变成 nan 或剧烈震荡现象训练跑了几十个 batchloss 从 2.3 突然飙到inf之后全是nan。原因通常有三个叠加输入没有归一化像素值还是 0 到 255学习率设得太大比如误填了1.0或者exp没有减去最大值导致溢出。优先检查归一化。在加载器里加一行断言// 加载完成后抽查像素范围正常应该在 [0,1] float mx *std::max_element(images[0].data.begin(), images[0].data.end()); assert(mx 1.0f 输入未归一化检查是否忘了除以 255);如果输入没问题把学习率降到0.001再试一次能收敛就说明是步长问题之后可以慢慢往上调。exp溢出的判断方法是打印 logits 的最大值正常应该在个位数如果出现几十甚至上百说明前面的层权重初始化太大换成std::normal_distribution(0, sqrt(2.0/fan_in))这种 He 初始化会稳很多。5.3 训练集精度很高测试集却明显掉一截现象训练到第三个 epoch训练集上的准确率已经 99%测试集只有 92%差距越拉越大。这种情况先确认测试流程有没有问题是不是忘了切换推理模式是不是把训练集的数据误当成测试集加载了文件名看串一个字母就会这样。确认无误后再看数据加载时有没有洗牌——如果makeBatches里的std::shuffle被注释掉或者随机种子写死了每次一样模型会按固定顺序反复看同样的样本组合泛化能力明显变差。另一个隐蔽原因是 batch 之间没有清零梯度。全连接层的gradWeight_是累加的如果step()里只更新不清零梯度会跨 batch 滚动效果相当于用了超大动量训练集拟合得飞快测试集直接崩。// 检查 step() 末尾是否有清零逻辑 std::fill(g.data.begin(), g.data.end(), 0.0f);这条如果漏了症状就是训练集一路高歌、测试集原地踏步非常典型。5.4 卷积输出尺寸对不上运行时段错误现象全连接网络能跑通切到卷积模型后直接Segmentation fault或者读到一堆随机值。原因基本是Hout/Wout算错。常见错误是忘了 padding 那一项或者整除不成立时直接截断。(H 2P - KH) / S 1这个公式里如果H 2P - KH是负数C 的整数除法会向零取整得到负数尺寸vector分配时转成size_t就变成一个巨大的数直接爆内存。在卷积层构造时加断言把问题暴露在第一现场ConvLayer::ConvLayer(int inC, int outC, int kh, int kw, int stride, int pad) : /* ... */ { // 这里的 H、W 由调用方传入或从配置读取构造时先校验 assert(H 2 * pad kh W 2 * pad kw 卷积核比带 padding 的输入还大); assert((H 2 * pad - kh) % stride 0 输出尺寸不能整除检查 stride 或 padding); }跑通之后把每一层的输出 shape 打印一遍和手算结果对一遍比对着崩溃现场猜快得多。5.5 训练慢到不可接受现象一个 epoch 要跑好几分钟CPU 风扇狂转。先看编译选项。-O0和-O2在这类纯循环代码上的差距能达到五倍以上CMAKE_BUILD_TYPE没设成Release是最常见的原因。确认后检查有没有在热循环里做无谓的拷贝比如forward(const Tensor x)按值传参每个 batch 都复制一整块图像数据改成const Tensor就正常了。如果还慢看看矩阵乘的内层循环顺序。for i, for o, for b和for i, for b, for o在缓存上的表现差很多内层循环访问的应该是连续内存。这一步优化完还嫌慢再考虑引入 OpenBLAS 替换手写矩阵乘但那就偏离“从零实现”的初衷了建议先把正确性跑通性能优化放到后面。6. 二次开发换数据集、加层与验证改造是否正确的三个落点跑通 MNIST 之后多数人下一步就是想把它改成自己的数据。改造之前先记一条任何改动都要能用“小样本过拟合”验证。拿 50 到 100 张图关掉正则、去掉数据增强训练几十轮如果训练精度到不了 99% 以上说明链路有问题别急着扩大数据量。换数据集主要改三处数据加载器、输入 shape、输出类别数。加载器把 IDX 解析换成通用图片读取stb_image一个头文件就够把Tensor({1,1,28,28})改成实际的通道和尺寸网络第一层卷积的inC跟着改最后一层全连接的out_改成你的类别数。三处改完用grep搜一遍硬编码的28和10漏一个就是隐雷。加新层比如 BatchNorm 或 Dropout时接口约定只有一条backward返回的梯度 shape 必须和forward的输入完全一致。写完立刻做数值梯度检查// 单层数值梯度检查对比解析梯度与中心差分 double eps 1e-4; for (int k 0; k 5; k) { // 抽查 5 个参数 float orig param.data[k]; param.data[k] orig eps; float lossPlus computeLoss(); param.data[k] orig - eps; float lossMinus computeLoss(); param.data[k] orig; double numeric (lossPlus - lossMinus) / (2 * eps); double analytic grad.data[k]; double relErr std::abs(numeric - analytic) / std::max(1e-8, std::abs(numeric) std::abs(analytic)); assert(relErr 1e-4 梯度不一致检查反向公式); }相对误差控制在1e-4以内基本就对了。我一般会把这段检查包成--gradcheck命令行开关只在调试时打开因为中心差分要把前向跑两遍正式训练时开着会慢一倍。换优化器也是常见需求。SGD 换成 Momentum 只需要在Network里给每个参数配一个速度缓冲v更新时v mu*v - lr*g; p vmu取 0.9。Adam 多两个状态量代码长一点但公式固定照着论文写不会错。改造后同样用上面的小样本过拟合验证能在一百张图上刷到 100% 精度说明优化器和梯度链路都是通的。改造点必改文件验证方式换数据集mnist_loader.cpp、网络首尾层小样本过拟合到 99%加自定义层新增xxx_layer.cpp 网络组装单层数值梯度检查换优化器network.cpp的step对比 loss 下降曲线这套流程我踩过一次坑改完数据加载器直接上全量数据训练跑了两个小时精度不涨回头查发现是图片通道顺序读反了红蓝对调模型学了个寂寞。从那以后我每次改完数据管线都强制先跑一遍小样本过拟合十秒钟就能判断链路通不通再也不敢跳过了。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站