简介:卷积神经网络简单实现(C++)面向具备 C++ 编程基础与线性代数常识的深度学习初学者,用一套可编译运行的轻量工程拆解 CNN 的卷积、池化、激活与全连接流程,弥补 Python 之外高效实现的学习空白。压缩包共 29 个文件,约 24KB,以 13 个 .h 头文件与 13 个 .cpp 源文件为主体,卷积层、池化层、激活层、全连接层、Softmax 与网络主体各自成对封装,另附 readme、README.md 与一份 txt 说明,交代基于 Ubuntu 16.04 与 Eigen3 的构建方式,CMakeLists.txt 可直接组织编译。已有 3027 人学习下载。读者可据此理清层与层之间的数据流与梯度回传路径,对照权重初始化、批量归一化、Dropout、数据预处理等要点,并在 Minst、Cifar 示例中验证训练流程,适合作为课程设计、自学练手或二次开发的骨架。
1. 卷积神经网络用 C++ 从零写,难点从来不是数学
带过几届新人之后我发现一个规律:让一个人在 Python 里搭 CNN,十分钟能跑起来;让他用 C++ 从零写一个,一周之后还在调 loss 不降。差别不在公式,公式哪本教材都一样,难的是内存怎么排、卷积核按什么顺序广播、反向传播的梯度从哪一层开始传、训练一轮要几十秒还是几小时。这个纯 C++ 的卷积神经网络简单实现,干的正是把这些全部摊开的事:卷积层、ReLU、最大池化、全连接、Softmax 交叉熵、SGD 更新,一层一层手写,不引第三方深度学习框架,配套 IDX 格式数据的读取和训练/推理入口。
它不适合拿来刷榜,适合两类人:一类是刚学完反向传播公式、想看着代码把链式法则对上号的学生和转行者;另一类是要在 C++ 服务或资源受限设备里塞一个小模型、又不想拖进来一整套推理库的工程师。把它读透,你手里就有一份能单步调试的 CNN 拆解图,而不是只能看日志的玄学黑匣子。
2. 工程骨架:Tensor、Layer 和一份能编译的目录
2.1 目录分层:先定边界,再写算子
这个项目的体量不大,但分层不能糊。常见做法是按“数据—算子—网络—入口”四块切,别把所有东西堆进一个 main.cpp:
cnn_cpp/ ├── include/ │ ├── tensor.h # NCHW 张量与基础访问 │ ├── layer.h # 算子基类 + Sequential 容器 │ ├── layers/ # conv.h relu.h pool.h linear.h │ └── loss.h # softmax + 交叉熵 ├── src/ │ ├── layers/ # 各算子的 forward / backward 实现 │ ├── data_loader.cpp # IDX 格式读取与 batch 切分 │ └── train.cpp # 训练主循环 ├── tools/ │ └── grad_check.cpp # 数值梯度校验 └── CMakeLists.txt这样切的好处是,加一个新算子只需要在include/layers/声明、在src/layers/实现,再在训练脚本里注册,不用动已有代码。我第一次写这类项目时把所有层写成一个大 switch,后来想加 BatchNorm,改到怀疑人生,血泪经验就是:算子的边界要在动手写第一行卷积之前定死。
CMakeLists.txt保持最小可用即可,不开任何优化也能跑通,但建议训练时开-O2:
cmake_minimum_required(VERSION 3.16) project(cnn_cpp CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(cnn_core src/layers/conv.cpp src/layers/relu.cpp src/layers/pool.cpp src/layers/linear.cpp src/data_loader.cpp ) target_include_directories(cnn_core PUBLIC include) add_executable(train src/train.cpp) target_link_libraries(train PRIVATE cnn_core) # 训练时打开这个,Debug 下 im2col 会慢十倍以上 target_compile_options(cnn_core PRIVATE -O2)cnn_core编成静态库、train只做流程编排,是为了让tools/grad_check.cpp能直接链进来复用同一套算子。别把训练逻辑写进库里,否则以后写推理程序还得把它抠出来。
2.2 Tensor 的内存布局:为什么选 NCHW 连续数组
张量结构决定了后面每个算子的写法。这里用最朴素的 NCHW 加一段连续std::vector<float>,不做 stride、不做 view,够用而且好调试:
// include/tensor.h #pragma once #include <vector> #include <cassert> #include <cstddef> // 四维张量,内存按 N-C-H-W 顺序连续存放 struct Tensor { int n = 0, c = 0, h = 0, w = 0; std::vector<float> data; Tensor() = default; Tensor(int n_, int c_, int h_, int w_) : n(n_), c(c_), h(h_), w(w_), data(static_cast<size_t>(n_) * c_ * h_ * w_, 0.f) {} size_t size() const { return data.size(); } // 调试期保留 assert,定位越界比看崩溃栈快得多 float& at(int ni, int ci, int hi, int wi) { assert(ni >= 0 && ni < n && ci >= 0 && ci < c && hi >= 0 && hi < h && wi >= 0 && wi < w); return data[((static_cast<size_t>(ni) * c + ci) * h + hi) * w + wi]; } float at(int ni, int ci, int hi, int wi) const { assert(ni >= 0 && ni < n && ci >= 0 && ci < c && hi >= 0 && hi < h && wi >= 0 && wi < w); return data[((static_cast<size_t>(ni) * c + ci) * h + hi) * w + wi]; } };n/c/h/w四个字段明确写出形状,比只存一个vector加vector<int>要省心,因为后续写反传时需要知道输入的高宽来还原dx。索引表达式((ni*c+ci)*h+hi)*w+wi全篇统一,只要有一处用了别的顺序,卷积结果就会“看起来在训练但永远不收敛”——这是最典型的翻车现场。
提示:
at()里的assert在-DNDEBUG下会被裁掉,训练阶段建议保留编译选项,别为了那点性能把唯一的越界哨兵关掉。
把at()写成带引用的版本是为了让反向传播能直接累加dx.at(...) += ...,否则你得先算出下标再写data[idx],代码可读性会差很多。真实项目里更彻底的做法是拆一个TensorView出来,但对“简单实现”这个定位来说,多一层抽象反而是负担。
2.3 Layer 抽象与 Sequential 容器
所有算子继承同一个基类,接口只有forward/backward/step三个动作,params()负责把可训练参数暴露给优化器和保存逻辑:
// include/layer.h #pragma once #include "tensor.h" #include <memory> #include <string> #include <utility> #include <vector> class Layer { public: virtual ~Layer() = default; // 输入输出都是 Tensor,形状由各层自己解释 virtual Tensor forward(const Tensor& x) = 0; // grad 是上一层传回来的梯度,返回对本层输入的梯度 virtual Tensor backward(const Tensor& grad) = 0; // 只有带参数的层需要实现 virtual void step(float lr) { (void)lr; } // 名字用于权重落盘时的 key,避免加载时顺序对不上 virtual std::vector<std::pair<std::string, Tensor*>> params() { return {}; } }; class Sequential { public: void add(std::unique_ptr<Layer> l) { layers_.push_back(std::move(l)); } Tensor forward(const Tensor& x) { Tensor cur = x; for (auto& l : layers_) cur = l->forward(cur); return cur; } Tensor backward(const Tensor& g) { Tensor cur = g; // 反向必须倒序遍历,这是链式法则的直接体现 for (auto it = layers_.rbegin(); it != layers_.rend(); ++it) cur = (*it)->backward(cur); return cur; } void step(float lr) { for (auto& l : layers_) l->step(lr); } std::vector<std::pair<std::string, Tensor*>> params() { std::vector<std::pair<std::string, Tensor*>> all; for (size_t i = 0; i < layers_.size(); ++i) for (auto& p : layers_[i]->params()) all.emplace_back("L" + std::to_string(i) + "." + p.first, p.second); return all; } private: std::vector<std::unique_ptr<Layer>> layers_; };forward里cur = x是一次拷贝,对小模型可以忽略;如果输入是 64×1×28×28,单批不到 200KB,拷贝成本远低于你为了省它引入移动语义后带来的调试复杂度。backward从rbegin()开始是硬性要求,顺序写反了梯度照样能算出来,但数值全错,而且不会崩——这种“不报错但错”的 bug 最难查。
params()里给参数加L{index}.前缀,是为了权重文件用name -> tensor的映射保存,加载时按名字匹配而不是按顺序。常见做法就是这么干,因为一旦你调整了网络层数,按顺序读的文件会静默错位,训练直接失效。
2.4 数据加载:IDX 格式的读取与归一化
手写数字集常用 IDX 格式,文件头四字节是魔数,低字节是维度数,后面跟各维长度:
// src/data_loader.cpp #include <fstream> #include <vector> #include <string> #include <cstdint> static uint32_t readBE32(std::ifstream& f) { unsigned char b[4]; f.read(reinterpret_cast<char*>(b), 4); // 文件是大端,手工拼,避免依赖平台头文件 return (uint32_t(b[0]) << 24) | (uint32_t(b[1]) << 16) | (uint32_t(b[2]) << 8) | uint32_t(b[3]); } // 读入后统一归一化到 [0,1],这一步漏了后面 loss 基本不会动 std::vector<float> loadIDXImages(const std::string& path, int& num, int& rows, int& cols) { std::ifstream f(path, std::ios::binary); uint32_t magic = readBE32(f); int ndim = magic & 0xFF; // 低字节表示后续有几个维度 num = static_cast<int>(readBE32(f)); rows = static_cast<int>(readBE32(f)); cols = static_cast<int>(readBE32(f)); (void)ndim; std::vector<unsigned char> raw(static_cast<size_t>(num) * rows * cols); f.read(reinterpret_cast<char*>(raw.data()), raw.size()); std::vector<float> out(raw.size()); for (size_t i = 0; i < raw.size(); ++i) out[i] = raw[i] / 255.f; // 关键:像素值必须归一化 return out; }magic & 0xFF取维度数,是因为 IDX 的魔数高字节固定为0x00,第三字节是数据类型(0x08表示 unsigned byte),最低字节才是维数。readBE32手工拼字节而不是用ntohl,是为了少一个平台相关的头文件,代价是代码长四行。
归一化这步看起来是常识,但我在不止一个 C++ 实现的版本里见过直接拿 0~255 的float喂进去的。后果不是崩,是第一层卷积的输入量级比权重大了两个数量级,梯度爆炸或消失,表现为 loss 前几十个 batch 纹丝不动,然后突然变 NaN。
3. 前向链路:从 im2col 卷积到 Softmax 输出
3.1 im2col:把卷积展开成矩阵乘
直接写六层循环的卷积也能跑,但可读性和后续反传都难受。这里用 im2col:对每个输出位置,把它的感受野按C×k×k摊成一行,卷积就变成“权重矩阵 × 展开矩阵”。
// src/layers/conv.cpp #include "layers/conv.h" #include <cmath> #include <random> #include <algorithm> Conv2D::Conv2D(int inC, int outC, int k, int stride, int pad) : inC_(inC), outC_(outC), k_(k), stride_(stride), pad_(pad) { // He 初始化:fan_in = inC * k * k int fanIn = inC * k * k; std::normal_distribution<float> dist(0.f, std::sqrt(2.f / fanIn)); std::mt19937 rng(42); w_.resize(static_cast<size_t>(outC) * fanIn); for (auto& v : w_) v = dist(rng); b_.assign(outC, 0.f); dw_.assign(w_.size(), 0.f); db_.assign(b_.size(), 0.f); } Tensor Conv2D::forward(const Tensor& x) { const int N = x.n, C = x.c, H = x.h, W = x.w; const int OH = (H + 2 * pad_ - k_) / stride_ + 1; const int OW = (W + 2 * pad_ - k_) / stride_ + 1; inH_ = H; inW_ = W; // 反传还原 dx 时需要 const int K = C * k_ * k_; col_.assign(static_cast<size_t>(N) * OH * OW * K, 0.f); // 逐输出点填一行,越界补零就是 padding for (int ni = 0; ni < N; ++ni) for (int oh = 0; oh < OH; ++oh) for (int ow = 0; ow < OW; ++ow) { const size_t row = (static_cast<size_t>(ni) * OH + oh) * OW + ow; int p = 0; for (int ci = 0; ci < C; ++ci) for (int kh = 0; kh < k_; ++kh) for (int kw = 0; kw < k_; ++kw) { int ih = oh * stride_ - pad_ + kh; int iw = ow * stride_ - pad_ + kw; col_[row * K + p++] = (ih >= 0 && ih < H && iw >= 0 && iw < W) ? x.at(ni, ci, ih, iw) : 0.f; } } Tensor y(N, outC_, OH, OW); for (int ni = 0; ni < N; ++ni) for (int oc = 0; oc < outC_; ++oc) for (int oh = 0; oh < OH; ++oh) for (int ow = 0; ow < OW; ++ow) { const float* row = &col_[(static_cast<size_t>(ni) * OH + oh) * OW + ow) * K]; const float* wrow = &w_[static_cast<size_t>(oc) * K]; float s = b_[oc]; for (int j = 0; j < K; ++j) s += row[j] * wrow[j]; y.at(ni, oc, oh, ow) = s; } return y; }参数含义逐个说清:inC_/outC_是输入输出通道,k_是方形核边长,stride_和pad_控制下采样与边界;col_是缓存下来的展开矩阵,形状(N*OH*OW) × (C*k*k),反传时直接复用,省一次重算。
输出尺寸公式(H + 2*pad - k)/stride + 1必须和col_的填充逻辑完全对齐,任何一边写错,输出的最后一行/列会悄悄错位,准确率掉几个点但程序不报错。
col_用成员变量而不是局部变量,是因为反向传播要用。这个选择带来一个必须遵守的约定:backward之前必须已经调用过forward,否则col_是空的或上一批的。批量训练时每批都走一遍forward → backward,顺序保证了这一点。
3.2 ReLU 与最大池化的前向
ReLU 没有参数,但必须记住哪些位置被激活过,否则反传时没法判断梯度该不该给:
// src/layers/relu.cpp Tensor ReLU::forward(const Tensor& x) { Tensor y = x; // 同形状拷贝,保留 mask 用正负号判断即可 for (auto& v : y.data) v = v > 0.f ? v : 0.f; return y; } Tensor ReLU::backward(const Tensor& grad) { // 用 forward 的输出来判断,这里缓存的是激活后的输出 Tensor dx = grad; for (size_t i = 0; i < dx.data.size(); ++i) if (cached_.data[i] <= 0.f) dx.data[i] = 0.f; return dx; }之所以缓存输出而不是输入,是因为forward里可以直接把y存一份,省一次拷贝。注意x = 0的位置导数取 0,和常见框架一致;如果你希望0处放行,把<=改成<即可,差别很小但要在整个项目里统一。
最大池化要记录每个窗口里最大值的位置,反传时只把梯度还给那个位置:
// src/layers/pool.cpp Tensor MaxPool2D::forward(const Tensor& x) { const int N = x.n, C = x.c, H = x.h, W = x.w; const int OH = (H - k_) / k_ + 1; const int OW = (W - k_) / k_ + 1; Tensor y(N, C, OH, OW); argmax_.assign(static_cast<size_t>(N) * C * OH * OW, -1); for (int ni = 0; ni < N; ++ni) for (int ci = 0; ci < C; ++ci) for (int oh = 0; oh < OH; ++oh) for (int ow = 0; ow < OW; ++ow) { int best = -1; float bv = -1e30f; for (int kh = 0; kh < k_; ++kh) for (int kw = 0; kw < k_; ++kw) { int ih = oh * k_ + kh, iw = ow * k_ + kw; float v = x.at(ni, ci, ih, iw); if (v > bv) { bv = v; best = ih * W + iw; } } y.at(ni, ci, oh, ow) = bv; argmax_[(((static_cast<size_t>(ni) * C + ci) * OH + oh) * OW + ow)] = best; } inH_ = H; inW_ = W; return y; } Tensor MaxPool2D::backward(const Tensor& grad) { Tensor dx(grad.n, grad.c, inH_, inW_); // 全零,只有赢家拿到梯度 for (int ni = 0; ni < grad.n; ++ni) for (int ci = 0; ci < grad.c; ++ci) for (int oh = 0; oh < grad.h; ++oh) for (int ow = 0; ow < grad.w; ++ow) { int idx = argmax_[(((static_cast<size_t>(ni) * grad.c + ci) * grad.h + oh) * grad.w + ow)]; if (idx >= 0) dx.data[(static_cast<size_t>(ni) * grad.c + ci) * inH_ * inW_ + idx] += grad.at(ni, ci, oh, ow); } return dx; }池化这里默认stride == k(不重叠窗口),代码里没单独暴露 stride 参数,够简单实现用了。argmax_存的是ih * W + iw这种展平后的一维下标,反传时按n/c基址加上去,能避免再算一次二维坐标。用+=而不是=,是为将来支持重叠窗口留余地——真改成重叠池化时,一个输入位置可能被多个窗口选中,直接赋值会丢梯度。
3.3 Softmax 与交叉熵:把 logits 变成概率
损失函数和最后的全连接层绑在一起写,因为交叉熵对 logits 的梯度形式非常简洁,只差一个p - onehot:
// include/loss.h // logits: [N,1,1,classes];labels: 长度 N;dlogits 输出梯度 inline float softmaxCrossEntropy(const Tensor& logits, const std::vector<int>& labels, Tensor& dlogits) { const int N = logits.n; const int C = logits.w; dlogits = Tensor(N, 1, 1, C); float loss = 0.f; for (int i = 0; i < N; ++i) { // 减最大值,防止 exp 溢出 float mx = -1e30f; for (int c = 0; c < C; ++c) mx = std::max(mx, logits.at(i, 0, 0, c)); float sum = 0.f; for (int c = 0; c < C; ++c) sum += std::exp(logits.at(i, 0, 0, c) - mx); loss += -(logits.at(i, 0, 0, labels[i]) - mx) + std::log(sum); for (int c = 0; c < C; ++c) { float p = std::exp(logits.at(i, 0, 0, c) - mx) / sum; float t = (c == labels[i]) ? 1.f : 0.f; dlogits.at(i, 0, 0, c) = (p - t) / N; // 已在 batch 维求平均 } } return loss / N; }mx那一步是必须的。我见过有人省掉它,用中等学习率跑几百个 batch 之后 logits 涨到几十,exp直接上溢成inf,loss 变nan,然后花一整天怀疑反传写错了。
逻辑说明:先按行减最大值做数值稳定,再算 softmax 概率,交叉熵取-log p(label)。梯度的解析形式是p - t,再除以 batch 大小N。参数说明:labels是 int 下标,不是 one-hot,这样可以省一个[N, C]的临时张量;dlogits传引用写出,避免返回值拷贝,也保证调用方能直接拿去backward。
/ N这个位置容易踩坑:如果你在step()里再除一次 batch,等效学习率就变成原来的1/N²,表现为训练几乎不动。要么在 loss 里除,要么在 step 里除,只能选一个。
4. 反向链路:梯度怎么一层层传回去
4.1 全连接层的反向:一次外积
全连接层把[N,1,1,F]展平成N×F,前向是y = xW + b,反向是标准的矩阵乘:
// src/layers/linear.cpp Tensor Linear::forward(const Tensor& x) { const int N = x.n; const size_t F = x.size() / N; cached_ = x; // 反传算 dw 要用输入 Tensor y(N, 1, 1, outF_); for (int i = 0; i < N; ++i) for (int o = 0; o < outF_; ++o) { float s = b_[o]; for (size_t f = 0; f < F; ++f) s += x.data[i * F + f] * w_[o * F + f]; y.at(i, 0, 0, o) = s; } return y; } Tensor Linear::backward(const Tensor& grad) { const int N = grad.n; const size_t F = w_.size() / outF_; std::fill(dw_.begin(), dw_.end(), 0.f); // 每批必须清零 std::fill(db_.begin(), db_.end(), 0.f); Tensor dx(N, 1, 1, static_cast<int>(F)); for (int i = 0; i < N; ++i) for (int o = 0; o < outF_; ++o) { float g = grad.at(i, 0, 0, o); db_[o] += g; for (size_t f = 0; f < F; ++f) { dw_[o * F + f] += g * cached_.data[i * F + f]; dx.data[i * F + f] += g * w_[o * F + f]; } } // 梯度按 batch 平均,与 loss 里的 /N 对应 for (auto& v : dw_) v /= N; for (auto& v : db_) v /= N; return dx; }三个梯度不要搞混:dw是权重梯度,db是偏置梯度(等于该输出位置梯度之和),dx是回传给上一层的梯度。std::fill必须放在函数开头,不是循环里;放在循环里意味着只清最后一次的累加,等于没清。
参数形状上有个隐性约定:w_是outF × F的行优先数组,w_[o*F+f]对应输出o和输入f。如果你在前向里写的是w_[f*outF+o],反传也必须一致,否则又是那种“能跑但永远不收敛”的问题。
4.2 池化与 ReLU 的反传:只把梯度还给赢家
前面已经给过实现,这里补一句容易忽略的:MaxPool2D::backward返回的dx是新建的全零张量,尺寸用的是inH_/inW_,不是池化输出尺寸。如果你错写成Tensor(grad.n, grad.c, grad.h, grad.w),编译能过、运行能跑,但形状对不上,接下去的全连接层会读到错位的数据。
ReLU::backward里cached_必须是forward的输出。有人在forward里把输入存下来,然后反传时判断cached_.data[i] > 0——数学上等价,因为relu(x) > 0当且仅当x > 0;但如果你在forward里顺手改过cached_或者有 in-place 操作,两者就不等价了。这类细节不值得赌,老老实实存输出。
4.3 卷积层的 col2im 反传与参数更新
卷积反传要做两件事:用col_和输出梯度算权重梯度,再把梯度散射回输入空间(col2im):
Tensor Conv2D::backward(const Tensor& g) { const int N = g.n, OH = g.h, OW = g.w; const int C = inC_; const int K = C * k_ * k_; std::fill(dw_.begin(), dw_.end(), 0.f); std::fill(db_.begin(), db_.end(), 0.f); Tensor dx(N, C, inH_, inW_); for (int ni = 0; ni < N; ++ni) for (int oc = 0; oc < outC_; ++oc) for (int oh = 0; oh < OH; ++oh) for (int ow = 0; ow < OW; ++ow) { float go = g.at(ni, oc, oh, ow); db_[oc] += go; if (go == 0.f) continue; // 稀疏跳过,能省不少时间 const float* row = &col_[(((static_cast<size_t>(ni) * OH + oh) * OW + ow) * K)]; for (int ci = 0; ci < C; ++ci) for (int kh = 0; kh < k_; ++kh) for (int kw = 0; kw < k_; ++kw) { int j = (ci * k_ + kh) * k_ + kw; // 权重梯度:输出梯度 × 对应输入像素 dw_[static_cast<size_t>(oc) * K + j] += go * row[j]; // 输入梯度:散射回原坐标 int ih = oh * stride_ - pad_ + kh; int iw = ow * stride_ - pad_ + kw; if (ih >= 0 && ih < inH_ && iw >= 0 && iw < inW_) dx.at(ni, ci, ih, iw) += go * w_[static_cast<size_t>(oc) * K + j]; } } // 卷积参数同样按 batch 平均 for (auto& v : dw_) v /= N; for (auto& v : db_) v /= N; return dx; } void Conv2D::step(float lr) { for (size_t i = 0; i < w_.size(); ++i) w_[i] -= lr * dw_[i]; for (size_t i = 0; i < b_.size(); ++i) b_[i] -= lr * db_[i]; }dx用+=累加,是因为一个输入像素会被多个输出窗口覆盖(尤其是stride < k时),每个窗口都要把自己的贡献加进去。dw的累加同理,一个卷积核会被 batch 内所有样本和所有空间位置共享。
step就是最朴素的 SGD,没有动量、没有权重衰减。加动量只需要在Conv里多存一份vw_,更新式改成v = mu*v - lr*dw; w += v,五个改动点,想练手可以从这里下手。
4.4 训练主循环:把前面所有零件串起来
// src/train.cpp(节选) Sequential net; net.add(std::make_unique<Conv2D>(1, 8, 3, 1, 1)); net.add(std::make_unique<ReLU>()); net.add(std::make_unique<MaxPool2D>(2)); net.add(std::make_unique<Conv2D>(8, 16, 3, 1, 1)); net.add(std::make_unique<ReLU>()); net.add(std::make_unique<MaxPool2D>(2)); net.add(std::make_unique<Linear>(16 * 7 * 7, 10)); for (int ep = 0; ep < 5; ++ep) { std::shuffle(order.begin(), order.end(), rng); double lossSum = 0.0; int correct = 0; for (int b = 0; b < numBatch; ++b) { Tensor x = makeBatch(images, order, b, batchSize); // [B,1,28,28] std::vector<int> labels = makeLabels(labelData, order, b, batchSize); Tensor logits = net.forward(x); Tensor dlogits(logits.n, 1, 1, logits.w); lossSum += softmaxCrossEntropy(logits, labels, dlogits); net.backward(dlogits); net.step(0.01f); // 学习率先小后大 } // 每轮打印 avg loss 和训练集准确率,准确率要单独前向一次 printf("epoch %d loss %.4f\n", ep, lossSum / numBatch); }makeBatch负责按order里的下标把样本拷进[B,1,28,28]张量,这是 IDX 数据到网络输入的唯一转换点;makeLabels同理。把“取样”和“组装”分开写,是为了让shuffle只影响order,原始数据始终保持不动。
学习率这里写0.01f是经验值,用 He 初始化 + 交叉熵,这个量级在两层卷积的小网络上一般能稳定下降。如果你把 batch 调小到 8,建议同步把学习率降到0.005左右,不然一个 batch 的梯度噪声会大到让 loss 抖成锯齿。
5. 避坑与排查:五个把训练跑崩的真实事故
5.1 loss 卡在 2.3 附近不动
现象:训练了十个 epoch,loss 一直在2.30上下浮动,准确率稳定在 10% 左右(十分类的随机水平)。
原因:九成是 Softmax 输入全是同一个值,概率均匀分布,交叉熵恰好是-log(1/10) ≈ 2.302。常见来源有三个:卷积权重初始化成 0 或常数、最后一层全连接的输入被池化层展平成全零、或者数据归一化漏了导致卷积输出被 ReLU 全裁成 0。
解决:先在net.forward之后打印logits的前 10 个值和均值方差。如果方差接近 0,往上一层打印,逐层往上找第一个输出方差不正常的位置。权重初始化统一用std::normal_distribution(0, sqrt(2/fanIn));池化层之后做一次assert检查展平后的size()是否等于Linear构造时传的inF。
5.2 第一个 epoch 就出现 nan
现象:跑了十几个 batch 之后 loss 直接变nan,之后再也恢复不了。
原因:最常见的是 Softmax 里没减最大值,exp上溢;其次是学习率给太大,比如0.1配batch=8,一批里某个样本的梯度把权重推到了极端值;再其次是交叉熵里对概率取log时概率恰好是 0。
解决:Softmax 必须减行最大值,这一步不能省;交叉熵里对sum加一个1e-12的保护;学习率先用0.001跑通再往上调,别一上来就0.1。如果调完还出nan,在step之前遍历一次dw_,assert(std::isfinite(v)),能直接定位是哪一层的梯度先炸的。
5.3 内存翻倍、训练慢十倍
现象:batch=64时进程占用超过 2GB,一个 epoch 要跑十几分钟,top里看着内存还在缓慢上涨。
原因:forward的返回值、col_缓存、backward里新建的dx,每次调用都重新分配;如果col_在forward里用assign而不是resize并进行容量复用,每一批都在向系统要新内存。另一个来源是Sequential::forward里的Tensor cur = x拷贝,链路上每一层都复制一次。
解决:col_改用resize保留容量;forward的返回值用移动语义或者直接写输出参数;backward里的dx改成成员变量复用。这几条改完,通常能把内存压到原来的三分之一。至于训练慢,先确认编译时开了-O2,Debug 模式下 im2col 的嵌套循环会慢到让你以为算法写错了。
注意:
col_复用容量时不要用assign(n, 0.f),那会把容量也重置;用resize加std::fill分两步走。
5.4 训练集准确率 99%,测试集只有 10%
现象:打印训练集准确率几乎满分,换验证集立刻打回原形,loss 还比训练集高出一大截。
原因:不是过拟合,是数据划分或标签对齐错了。常见情况是makeBatch里用了order[i]取图像,但标签用的是labelData[i]而不是labelData[order[i]]——训练时因为样本顺序固定,恰好对得上,一轮shuffle之后就全乱了。另一种是测试时忘了切换成推理模式,池化层的argmax_还在复用训练时最后一层的缓存。
解决:把order同时传给图像和标签的组装函数,别让两处各自索引;测试时单独构造一次forward,不要复用训练中的任何缓存。验证集准确率建议每轮单独跑一遍前向,别跟训练混在一个循环里打印。
5.5 保存权重再加载,结果对不上
现象:训练完保存权重,重新加载后同样的输入输出完全不同的 logits。
原因:保存时按params()的顺序写文件,加载时却按另一套顺序读;或者只存了data没存形状,加载时用默认构造的Tensor尺寸不匹配,at()的assert在 Release 下被裁掉,读写越界不报错。也可能层名里的L{index}在某次改动后整体偏移了。
解决:保存时用name -> (n,c,h,w,data)的格式,加载时按名字找,找不到就报错退出。文件名里带上网络结构版本号,改结构就换一个新文件。我在本地习惯给Tensor加一个serialize辅助函数,把四个维度一起写进去,加载时先校验再memcpy,这样至少不会静默读错。
6. 二次开发:换数据、加算子和验证反传的三个抓手
6.1 加一层新算子要动哪几个地方
以加 BatchNorm 为例,先在include/layers/bn.h里继承Layer,实现forward/backward/step/params四个接口,params()返回gamma和beta两个可训练张量,forward里存下当前 batch 的均值和方差给反传用。然后在训练脚本里插到卷积和 ReLU 之间:
net.add(std::make_unique<Conv2D>(1, 8, 3, 1, 1)); net.add(std::make_unique<BatchNorm2D>(8, 1e-5f)); net.add(std::make_unique<ReLU>());接口约定只有一条:backward返回的张量形状必须和对应forward的输入完全一致。只要守住这条,插在哪一层都能跑。加动量优化器同理,改step的实现,不动网络结构。
6.2 换数据集和通道数
从 28×28 单通道换到 32×32 三通道,需要改三处:Conv2D构造的第一个参数从 1 改成 3;Linear的输入特征数要按(32/4)*(32/4)*16重新算,除以 4 是因为过了两次 2×2 池化;数据加载里的num/rows/cols从文件头读,但归一化之后记得按通道数重新解释data的布局。改完先拿 100 个样本过拟合一遍,能到 100% 训练准确率再上全量,否则就是通道或形状对错了。
6.3 用数值梯度校验反传,别再靠猜
反传出问题最难查,因为它不崩、只是数值错。最省时间的办法是中心差分对拍:
// tools/grad_check.cpp(节选) float eps = 1e-3f; float orig = w[idx]; // 解析梯度:先跑一次前向反向拿到 dw_[idx] net.forward(x); net.backward(dlogits); float analytic = dw_[idx]; // 数值梯度:中心差分 w[idx] = orig + eps; float lossP = net.forward(x).data[0]; // 简化:单独算 loss w[idx] = orig - eps; float lossM = net.forward(x).data[0]; w[idx] = orig; float numeric = (lossP - lossM) / (2 * epsf); printf("idx=%zu analytic=%.6f numeric=%.6f\n", idx, analytic, numeric);参数说明:eps取1e-3到1e-4之间,太小会被浮点误差淹没,太大会引入截断误差;比较的相对误差控制在1e-4量级以内算通过。抽 20 个随机下标的参数跑一遍,全过再继续训练。
我现在的习惯是,任何新写的backward不跑数值校验就不允许进主分支。有一次我偷懒跳过了,结果一个+=写成=,训练集准确率照样能到 90%,但验证集比随机猜还差,查了两天才发现。从那以后我每次改完反传都强制走一遍grad_check,不管改动多小。希望这些能帮你少走几段弯路。
本文还有配套的精品资源,点击获取