简介:面向希望用 C++ 理解卷积神经网络底层实现的学习者,这份“卷积神经网络简单实现(C++)”提供了一套可编译运行的轻量级 CNN 基础框架。项目在 Ubuntu 16.04 下开发,借助 Eigen3 完成矩阵与向量运算,围绕卷积层、池化层、激活层、全连接层、Softmax、损失函数与优化器等核心模块组织代码,并配有 CIFAR 与 MNIST 相关示例,便于对照理解前向传播、反向传播、权重初始化、批量归一化、Dropout 与数据预处理等关键环节。压缩包共 29 个文件,以 13 个 .h 头文件和 13 个 .cpp 源文件为主体,另含构建配置、readme 与 md 说明,整体约 24KB,结构紧凑,适合按模块阅读。已有 3027 人学习,可作为初学者梳理 CNN 运算过程、C++ 开发者学习高效数值计算实现的参考样例。
1. 用 C++ 重写 CNN:为什么“简单实现”反而最难敷衍
很多人第一次接触 CNN 是在 Python 里几行代码搞定,卷积、池化、反向传播全被框架接管;一旦要把它挪到 C++ 侧——没有解释器的产线机、嵌入式板子,或者只想在纯 C++ 工程里嵌一个图像分类头——就会发现卷积核怎么摆、梯度怎么回传、padding 怎么对齐,全都得自己想。这份卷积神经网络简单实现(C++)就是针对这个空档:一份不依赖任何深度学习框架的纯 C++ 代码,张量、im2col、卷积、最大池化、全连接、Softmax 交叉熵、SGD 训练循环全部手写,编译完就是一个能跑的 CNN。它不适合想刷精度的人,适合已经知道 CNN 大概长什么样、却没亲手把前向反向打通的人,也适合要把小模型嵌进 C++ 工程的从业者拿来当骨架。
2. 张量布局与 im2col:把卷积摊成矩阵乘法的四步落地
2.1 先定 NCHW,别让四层下标把你绕进去
手写 CNN 里最容易出现的低级事故是内存布局不统一。有人前半段用vector<vector<vector<float>>>,写到卷积层发现切片传参爆炸,临时改成一维数组,索引全错位,查三天最后发现是h和w写反了。我的习惯是:整个工程只允许一个四维索引函数存在,其余地方一律调它,绝不手写((n*C+c)*H+h)*W+w这种展开式。
布局选 NCHW 而不是 NHWC,原因很实际:im2col 之后,同一通道c、同一核偏移(kh, kw)的整个滑动窗口会变成矩阵的一整行,行内元素在原始内存里是连续的一段,缓存友好;如果选 NHWC,通道在最内层,一次卷积要跨C个元素取一个点,步长跳跃,性能会掉得很厉害。代价是后面接全连接层时要手动把C*H*W摊平,但那只是一次memcpy级别的事。
// tensor.h —— 一维连续内存 + 四个维度描述符,所有下标只在这里展开一次 struct Tensor { int n = 1, c = 1, h = 1, w = 1; std::vector<float> data; Tensor() = default; Tensor(int n_, int c_, int h_, int w_, float v = 0.f) : n(n_), c(c_), h(h_), w(w_), data(size_t(n_) * c_ * h_ * w_, v) {} size_t size() const { return data.size(); } float* ptr() { return data.data(); } const float* ptr() const { return data.data(); } // 四维下标压成一维:只在这两个函数里出现,别处一律走 at() float& at(int ni, int ci, int hi, int wi) { return data[((size_t(ni) * c + ci) * h + hi) * w + wi]; } const float& at(int ni, int ci, int hi, int wi) const { return data[((size_t(ni) * c + ci) * h + hi) * w + wi]; } };几个参数的含义要钉死,后面所有算子都按这个约定走:n是 batch 维,c是通道,h/w是单张图的平面尺寸。权重张量复用同一个结构,但语义变成n=输出通道数 OC、c=输入通道数 C、h/w=核高宽 KH/KW,展平后下标是oc * (C*KH*KW) + k,其中k = ((c*KH+kh)*KW+kw)。这个展平顺序必须和 im2col 的行顺序完全一致——这是后面排查章节里出现频率最高的一类 bug,形状看着全对,loss 就是不动。
Tensor的构造函数里data直接给size_t乘法,原因是int乘法在中等尺寸上就会溢出:128 × 64 × 56 × 56已经接近 2.6e7,乘上sizeof(float)还在安全区,但再做一次核展开就容易踩线。
2.2 im2col 的三个边界:pad、stride 与行序
im2col 干的事很朴素:把每个输出位置对应的输入窗口,拉直成矩阵的一列。输出尺寸由经典公式给出,OH = (H + 2*pad - KH) / stride + 1,OW同理。注意这里是整除,不是四舍五入——如果(H + 2*pad - KH)不能被stride整除,多出来的那几行像素会被直接丢弃,这是常见做法,但你要知道自己在丢东西。
// im2col: 输入 [C, H, W] -> 矩阵 [C*KH*KW, OH*OW] // 行序固定为 c -> kh -> kw,与权重展平顺序必须一致 void im2col(const float* in, int C, int H, int W, int KH, int KW, int pad, int stride, int OH, int OW, float* col) { int row = 0; for (int c = 0; c < C; ++c) for (int kh = 0; kh < KH; ++kh) for (int kw = 0; kw < KW; ++kw) { int j = 0; for (int oh = 0; oh < OH; ++oh) { int ih = oh * stride - pad + kh; // 先减 pad 再加核偏移,顺序不能反 for (int ow = 0; ow < OW; ++ow, ++j) { int iw = ow * stride - pad + kw; // 越界补 0,就是 padding 的全部含义 col[size_t(row) * OH * OW + j] = (ih >= 0 && ih < H && iw >= 0 && iw < W) ? in[(size_t(c) * H + ih) * W + iw] : 0.0f; } } ++row; } }三个边界值得单独说。第一个是ih = oh * stride - pad + kh的运算顺序,写成oh * stride + kh - pad结果一样,但写成(oh - pad) * stride + kh就错得很隐蔽:pad=1, stride=1时两者恰好等价,你换成stride=2立刻全盘错位,而且在形状正确的掩护下不会报任何错。第二个是越界判断用>= 0 && < H,不能只判上界,ih是int且有负数。第三个是行序,c在外层还是kh在外层都行,但必须和权重布局一致,一旦不一致,等价于把滤波器打乱重排,网络还是能训练,只是永远学不到东西。
参数上,pad一般取KH/2(3×3核取 1,5×5核取 2)来保持H/W不变;stride取 1 做特征提取,取 2 做降采样。这份实现里pad是显式传参,不做SAME自动推导,好处是所有形状你能在纸上算出来,坏处是每加一层都要自己核一遍。
2.3 GEMM 与偏置:卷积前向的拼装
有了列矩阵,卷积就退化成Y[OC, OH*OW] = W[OC, K] × col[K, OH*OW],K = C*KH*KW。我一般先写一个朴素三重循环版本把数值跑对,再考虑优化,因为朴素版本是最好的参照物,任何“优化后结果不对”都能靠它二分定位。
// 卷积前向:权重按 [OC, C, KH, KW] 展平,与 im2col 的行序对齐 void conv_forward(const Tensor& x, const Tensor& w, const float* b, int pad, int stride, Tensor& y) { const int N = x.n, C = x.c, H = x.h, W = x.w; const int OC = w.n, KH = w.h, KW = w.w; const int OH = (H + 2 * pad - KH) / stride + 1; const int OW = (W + 2 * pad - KW) / stride + 1; const int K = C * KH * KW, Ncol = OH * OW; std::vector<float> col(size_t(K) * Ncol); // 每个样本复用一块缓冲,别在循环里反复 new y = Tensor(N, OC, OH, OW); for (int ni = 0; ni < N; ++ni) { im2col(x.ptr() + size_t(ni) * C * H * W, C, H, W, KH, KW, pad, stride, OH, OW, col.data()); for (int oc = 0; oc < OC; ++oc) { const float* wrow = w.data.data() + size_t(oc) * K; float* out = y.ptr() + size_t(ni) * OC * Ncol + size_t(oc) * Ncol; for (int j = 0; j < Ncol; ++j) { float acc = b[oc]; // 偏置作为累加初值,省一趟加法 for (int k = 0; k < K; ++k) acc += wrow[k] * col[size_t(k) * Ncol + j]; out[j] = acc; } } } }关键点有三个。col缓冲在样本循环外分配一次,因为它是整个前向里唯一的大块动态内存,反复分配会把训练速度拖垮一半。权重行指针wrow提到内层循环外,-O2一般也能做这个优化,但写出来对读代码的人更友好。偏置塞进累加初值,数学上等价于Wx + b,省掉一趟Ncol长度的加法。
参数怎么定:K是每次内积的长度,3×3核 + 32 通道输入就是 288,Ncol是输出平面大小,输入28×28、pad=1、stride=1时是 784。这组数字决定了朴素实现的耗时——单层OC=32, K=288, Ncol=784大约 720 万次乘加,两个卷积层加上全连接,单张图前向在普通笔记本上是个位数毫秒级,batch 128 一轮几百毫秒,是能接受的实验节奏。
3. 反向传播手推与落码:让 loss 真的往下走
3.1 卷积层梯度:dX、dW、db 三条通路
卷积前向是y = W·col(x) + b,所以反向也是三条:db是对dY在 batch 和空间维上求和;dW是dY × col^T;dX要先算dcol = W^T × dY,再把dcol散射回输入平面。前两条是标准矩阵乘,第三条是唯一有坑的地方——因为滑动窗口重叠,同一个输入像素会被多个输出位置引用,散射时必须累加,不能赋值。
// col2im:dcol -> dX 的散射,重叠区域必须 += void col2im(const float* dcol, int C, int H, int W, int KH, int KW, int pad, int stride, int OH, int OW, float* dx) { std::fill(dx, dx + size_t(C) * H * W, 0.f); int row = 0; for (int c = 0; c < C; ++c) for (int kh = 0; kh < KH; ++kh) for (int kw = 0; kw < KW; ++kw) { int j = 0; for (int oh = 0; oh < OH; ++oh) { int ih = oh * stride - pad + kh; for (int ow = 0; ow < OW; ++ow, ++j) { int iw = ow * stride - pad + kw; if (ih >= 0 && ih < H && iw >= 0 && iw < W) dx[(size_t(c) * H + ih) * W + iw] += dcol[size_t(row) * OH * OW + j]; // 这里是 += ,写成 = 就是血泪教训 } } ++row; } }散射函数里的+=是这份实现最容易写错的一行。写成=之后不会崩、不会报错、形状全对,但stride < KH的层梯度会丢掉大部分贡献,表现为 loss 前几百步还能降,之后就平在某个高位不动——你会怀疑学习率、怀疑初始化,最后发现是这一行。
主反传函数把三条通路拼起来,注意dW和db是跨 batch 累加的,所以要在样本循环外清零,循环内一直累加。
// 卷积反传:dw/db 跨样本累加,dcol 每个样本算完立刻散射回 dx void conv_backward(const Tensor& x, const Tensor& w, const Tensor& dy, int pad, int stride, Tensor& dx, Tensor& dw, std::vector<float>& db) { const int N = x.n, C = x.c, H = x.h, W = x.w; const int OC = w.n, KH = w.h, KW = w.w; const int OH = dy.h, OW = dy.w; const int K = C * KH * KW, Ncol = OH * OW; dx = Tensor(N, C, H, W); dw = Tensor(OC, C, KH, KW); db.assign(OC, 0.f); std::vector<float> col(size_t(K) * Ncol), dcol(size_t(K) * Ncol); for (int ni = 0; ni < N; ++ni) { im2col(x.ptr() + size_t(ni) * C * H * W, C, H, W, KH, KW, pad, stride, OH, OW, col.data()); const float* dyn = dy.ptr() + size_t(ni) * OC * Ncol; std::fill(dcol.begin(), dcol.end(), 0.f); // 通路一、二:db 和 dW for (int oc = 0; oc < OC; ++oc) { const float* g = dyn + size_t(oc) * Ncol; float* dwrow = dw.data.data() + size_t(oc) * K; for (int j = 0; j < Ncol; ++j) { const float gj = g[j]; db[oc] += gj; for (int k = 0; k < K; ++k) dwrow[k] += gj * col[size_t(k) * Ncol + j]; } } // 通路三:dcol = W^T * dY ,随后散射成 dX for (int k = 0; k < K; ++k) for (int j = 0; j < Ncol; ++j) { float acc = 0.f; for (int oc = 0; oc < OC; ++oc) acc += w.data[size_t(oc) * K + k] * dyn[size_t(oc) * Ncol + j]; dcol[size_t(k) * Ncol + j] = acc; } col2im(dcol.data(), C, H, W, KH, KW, pad, stride, OH, OW, dx.ptr() + size_t(ni) * C * H * W); } }dW的累加顺序和im2col的行序是同一套约定,这也是为什么我在第 2 章强调行序。如果你用的权重初始化把KH和KW换了位置,形状上是3×3看不出差别,结果是转置的核,网络仍能收敛到某个次优解,你只会觉得“这个模型效果一般”。
3.2 池化与全连接的反传:记录路由,别猜
最大池化的反向必须依赖前向时记录下来的 argmax,不能靠“哪个值等于输出值”去反查——因为输入里可能有重复值,反查会把梯度分给错误的位置,更常见的情况是浮点比较不相等,梯度直接静默消失。
// 前向:argmax 记录的是平面内一维下标 ih*W+iw void maxpool_forward(const Tensor& x, int k, int s, Tensor& y, std::vector<int>& argmax) { const int N = x.n, C = x.c, H = x.h, W = x.w; const int OH = (H - k) / s + 1, OW = (W - k) / s + 1; y = Tensor(N, C, OH, OW); argmax.assign(y.size(), -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) { const int ih = oh * s + kh, iw = ow * s + kw; const 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[((size_t(ni) * C + ci) * OH + oh) * OW + ow] = best; } } // 反传:只把梯度还给胜出的那个位置,其余位置拿 0 void maxpool_backward(const Tensor& dy, int C, int H, int W, int OH, int OW, const std::vector<int>& argmax, Tensor& dx) { dx = Tensor(dy.n, C, H, W); for (size_t i = 0; i < dy.size(); ++i) { const size_t plane = i / (size_t(OH) * OW); const int pos = argmax[i]; if (pos >= 0) dx.data[plane * H * W + pos] += dy.data[i]; } }dx这里用+=的原因和col2im一样:当k > s时窗口重叠,同一输入位置可能被多个窗口选为最大值。k=2, s=2是标准做法,不重叠,但你若为了省参数改成k=3, s=2,就要靠这个+=兜底。
全连接层就是y = Wx + b,反向是dx = W^T dy、dW = dy x^T、db = dy,摊平/还原形状只影响memcpy,不影响数学。Softmax 交叉熵建议做成融合算子,直接从 logits 出 loss 和dlogits,中间不保存概率矩阵,能省一块N×K内存,也避免 log 域数值问题。
// 融合 Softmax + 交叉熵:先减最大值再 exp,防止 overflow float softmax_cross_entropy(const std::vector<float>& logits, const std::vector<int>& label, int N, int K, std::vector<float>& dlogits) { float loss = 0.f; dlogits.assign(logits.size(), 0.f); for (int i = 0; i < N; ++i) { const float* z = logits.data() + size_t(i) * K; const float m = *std::max_element(z, z + K); // 数值稳定关键 float sum = 0.f; for (int k = 0; k < K; ++k) sum += std::exp(z[k] - m); loss += -(z[label[i]] - m - std::log(sum)) / N; for (int k = 0; k < K; ++k) { const float p = std::exp(z[k] - m) / sum; dlogits[size_t(i) * K + k] = (p - (k == label[i] ? 1.f : 0.f)) / N; } } return loss; }m这一项不能省。float的exp在参数超过 88 就溢出成inf,logits 稍微大一点就 NaN,而且 NaN 会顺着梯度污染全部参数,你看到的只是“loss 变 nan”,根因在几层之外。
3.3 数值梯度检查:30 行代码省三天
手推完梯度,第一件事不是跑训练,是抽查。中心差分(f(θ+ε) - f(θ-ε)) / 2ε相比单侧差分精度高一个量级,配合相对误差判据就够用了。参数规模上万,不可能全查,随机抽 20 到 50 维即可,只要有一处不对,就说明公式或代码有问题。
// 梯度检查:随机抽查若干维,相对误差超过 tol 就打印现场 bool grad_check(const std::function<float()>& forward, std::vector<float>& param, const std::vector<float>& grad, int samples, float eps = 1e-3f, float tol = 1e-2f) { std::mt19937 rng(1234); // 固定种子,保证可复现 std::uniform_int_distribution<int> pick(0, int(param.size()) - 1); for (int t = 0; t < samples; ++t) { const int i = pick(rng); const float old = param[i]; param[i] = old + eps; const float lp = forward(); param[i] = old - eps; const float lm = forward(); param[i] = old; // 一定要还原,否则参数被污染 const float num = (lp - lm) / (2 * eps); const float rel = std::abs(num - grad[i]) / std::max(1e-6f, std::abs(num) + std::abs(grad[i])); if (rel > tol) { printf("mismatch idx=%d num=%g ana=%g rel=%g\n", i, num, grad[i], rel); return false; } } return true; }eps的取法有讲究:float只有 7 位有效数字,eps=1e-3是常用折中,太小会被舍入误差淹没,太大则差商的截断误差上来。tol用相对误差1e-2,比绝对误差稳,因为不同参数的梯度量级差好几个数量级。检查顺序建议从 Softmax 往上倒推:先单测 Softmax 的dlogits,再接全连接,再接池化,最后接卷积。这样一旦卷积检查失败,你可以确信底层算子是对的,问题只在自己写的col2im或dW累加上。
4. 训练循环工程化:优化器、数据管道与编译开关
4.1 优化器:带动量的 SGD 与学习率的取法
这份实现不追求花哨优化器,带动量加权重衰减的 SGD 已经足够把一个小 CNN 训到能用,而且每一步都能手算验证。写法上把 L2 正则并进梯度,比单独写一个衰减项更好对账。
// 带动量 + L2 的 SGD:p = p + v, v = momentum*v - lr*(g + wd*p) void sgd_update(std::vector<float>& p, const std::vector<float>& g, std::vector<float>& v, float lr, float momentum, float wd) { for (size_t i = 0; i < p.size(); ++i) { const float gi = g[i] + wd * p[i]; // L2 并入梯度 v[i] = momentum * v[i] - lr * gi; // 动量累积,注意是减号 p[i] += v[i]; } }参数取值上,momentum一律 0.9,wd在1e-4到5e-4之间试。真正难定的是lr。血泪经验是先做一次“梯度标定”:拿一个 batch 前向反传,打印全局梯度范数||g||,如果||g||在几十量级,lr=0.01起;在几百量级,lr要压到1e-3;如果打印出来是nan或inf,别调 lr,回去查softmax有没有减最大值。
每层单独设 lr 没必要,但卷积层和最后全连接层分开看是值得的:全连接层参数量大、梯度容易大,我在小数据集上经常把全连接层的 lr 打三折,收敛曲线会平滑很多。
4.2 数据管道:整库入内存 + 下标 shuffle
小规模数据集(几万张28×28灰度图)完全放得进内存,float存储也就几十 MB,没必要写复杂的按需读取。真正的坑在 shuffle:直接打乱文件顺序代价高,标准做法是维护一个下标数组,每个 epoch 洗牌一次,按批取。
struct Batch { Tensor x; std::vector<int> y; }; // 按 epoch 重排下标,再按 batch 切片拷贝;不移动原始数据本身 void make_batches(const Tensor& all_x, const std::vector<int>& all_y, int batch_size, std::mt19937& rng, std::vector<Batch>& out) { const int total = int(all_y.size()); std::vector<int> idx(total); std::iota(idx.begin(), idx.end(), 0); std::shuffle(idx.begin(), idx.end(), rng); // 每个 epoch 调一次 out.clear(); for (int s = 0; s < total; s += batch_size) { const int m = std::min(batch_size, total - s); // 最后一批可能不满 Batch b; b.x = Tensor(m, all_x.c, all_x.h, all_x.w); b.y.resize(m); for (int i = 0; i < m; ++i) { const int src = idx[s + i]; std::memcpy(b.x.ptr() + size_t(i) * all_x.c * all_x.h * all_x.w, all_x.ptr() + size_t(src) * all_x.c * all_x.h * all_x.w, sizeof(float) * all_x.c * all_x.h * all_x.w); b.y[i] = all_y[src]; } out.push_back(std::move(b)); } }两个细节:最后一批数量不足时不要丢,小数据集上一个 batch 的样本可能决定这一轮收敛;归一化在数据入内存时做一次,x = (x/255 - 0.13) / 0.30这类均值方差用训练集统计,别用整个数据集(包括验证集)去算,那是典型的数据泄漏,也是后面排查章节的一条。
4.3 训练循环骨架与日志指标
训练循环本身很短,但日志必须够用,否则调参全靠蒙。我固定打印四个东西:当前 lr、本批 loss、滑动平均 loss、每 N 批一次的训练/验证准确率。
for (int epoch = 0; epoch < epochs; ++epoch) { make_batches(train_x, train_y, batch_size, rng, batches); float run = 0.f; for (size_t it = 0; it < batches.size(); ++it) { const Batch& b = batches[it]; Tensor logits = net_forward(b.x); // 前向,内部缓存 col/argmax std::vector<float> dlogits; const float loss = softmax_cross_entropy(logits.data, b.y, b.x.n, num_classes, dlogits); net_backward(dlogits); // 反传,累计各层梯度 net_step(optimizer, lr_now); // 更新,更新后清零梯度 run = 0.9f * run + 0.1f * loss; // 滑动平均,抗单批抖动 if (it % 20 == 0) printf("ep %d it %zu/%zu loss %.4f avg %.4f lr %.5f\n", epoch, it, batches.size(), loss, run, lr_now); } printf("val_acc %.4f\n", evaluate(val_x, val_y)); }net_forward内部要把 im2col 的列矩阵和池化的 argmax 缓存下来给反传用,最简单的做法是让每一层持有一个workspace成员,前向填、反传读,不要用全局变量(多线程下必炸)。net_step更新完参数之后必须清零梯度,这是新手最常漏的一行,症状是 loss 前几批降得飞快然后剧烈震荡,因为梯度在跨批累加。
日志判读的几条经验:loss在头 100 批内应当明显下降,如果只是抖动不降,多半是 lr 大了或者梯度断链;avg平稳在ln(K)附近(10 类就是 2.30)完全不动,几乎可以断定梯度没传回去;val_acc在训练集acc到 95% 时还停在 20%,去查归一化和数据泄漏。
4.4 编译与性能开关:先测再开
这份实现是纯标准库,没有外部依赖,g++一条命令就能编。但开关顺序有讲究,开错地方会得到“优化后结果变了”这种最难查的问题。
# 先保证正确性:不开任何激进优化,-O0 加 sanitizer 找越界 g++ -std=c++17 -O0 -g -fsanitize=address,undefined main.cpp -o cnn_dbg # 正确性确认后再上性能和并行 g++ -std=c++17 -O2 -march=native -fopenmp main.cpp -o cnn| 开关 | 作用 | 什么时候开 | 风险 |
|---|---|---|---|
-O0 -g | 不优化 + 调试符号 | 首轮跑通与梯度检查 | 慢 20 倍以上 |
-fsanitize=address,undefined | 越界与未定义行为检测 | 新写一个算子之后 | 内存翻倍 |
-O2 | 常规优化,含循环向量化 | 默认开 | 几乎无风险 |
-march=native | 用本机指令集(AVX 等) | 本机实验 | 换机器就跑不了 |
-fopenmp | 多核并行 | batch 循环可并行时 | 共享变量竞态 |
-march=native一定要慎用:编出来的二进制绑死本机 CPU 指令集,拷到另一台机器上直接Illegal instruction。生产构建里我一般写-march=x86-64-v2这类保守目标。
OpenMP 的并行维度只能加在 batch 循环上——每个样本的前向反传完全独立,除了dW/db的累加。所以并行版本必须把dw/db改成每个线程一份私有缓冲,最后归约,直接在里面+=就是数据竞争:现象是每次跑出来的 loss 曲线都不一样,甚至同一份代码两次结果差一个百分点。
5. 排查手册:六个把 CNN 训成玄学的现场
5.1 形状全对但 loss 不动:行序与散射
现象一:编译通过、形状打印全对、前向 loss 稳定在ln(10)≈2.30附近,几百批一点不降,val_acc等于随机猜。改成 2 类任务也是停在0.69。
原因:im2col 的行序是c -> kh -> kw,权重展平时写成了kh -> kw -> c,两者形状一致(都是C*KH*KW),但语义上是把每个滤波器打乱重排。梯度照样能算、参数照样能更新,只是这个“重排关系”在训练中恒定不变,网络永远学不到空间结构。类似地,KW和KH写反在3×3核上完全不可见。
解决:在conv_forward开头加一行断言,把权重展平后与col的第k行做一次手工比对:取oc=0, k=0和k=K-1打印两个值,用单通道单样本的手算结果核对。更彻底的做法是写一个conv_reference(),用六层嵌套循环直接算卷积(不走 im2col),和conv_forward对同一组输入比最大绝对误差,1e-5以内才算通过。这个参照实现只要 20 行,但能一次性排掉行序类问题。
现象二:前 200 批 loss 正常下降,之后平在一个明显偏高的位置,加大 lr 会震荡、减小 lr 只是更慢地平。
原因:col2im里用了=而不是+=。stride=1、KH=3时每个输入像素被 9 个窗口引用,赋值只保留最后一次写入的梯度,等于把dX缩水了近一个数量级且方向错乱。前几百批靠dW通路还能勉强降,之后就卡死。
解决:把col2im和maxpool_backward的累加语义当成硬约束写进注释。验证方法是梯度检查时把eps调到1e-4再跑一遍,如果只有卷积层参数不通过、且误差在 5 到 10 倍之间,基本就是散射丢项。
5.2 loss 变 NaN 或剧烈震荡:数值与初始化
现象三:第一批 loss 就是nan,之后所有参数全变nan,日志里avg也是nan。
原因:两种最常见。一是softmax没减最大值,logits 稍大exp直接溢出;二是权重初始化用了N(0, 1)这种大方差,第一层输出量级到几十,连乘几层后溢出。全零初始化不会 NaN,但会让所有滤波器保持对称,永远学不出差异。
解决:softmax减最大值是硬性要求,不能省。初始化用 He 方案,std = sqrt(2 / fan_in),fan_in = C*KH*KW,代码里就是std::normal_distribution<float>(0.f, std::sqrt(2.f / K))。偏置初始化为 0。另外在前向里加一层“数值哨兵”:每层输出后检查std::isfinite,一旦出现非有限值立刻打印层名退出,比等到最后看 loss 是 nan 快得多。
现象四:loss 不是 nan,但每批之间上下大幅跳动,avg缓慢下降,验证集准确率来回摆。
原因:学习率偏大,或者 batch 太小(比如 8)导致梯度噪声大。也有一种情况是梯度没清零,跨批累加后等效 lr 翻了好几倍。
解决:先把 lr 除以 5 跑 200 批看曲线是否平滑,再二分回找。batch 小于 32 时把momentum从 0.9 提到 0.95 能压一部分噪声。确认net_step末尾有清零梯度的调用,这一条我见过不止一次。
5.3 训练集能过、验证集崩:泄漏与过拟合的边界
现象五:训练集准确率 99%,验证集常年在 30% 到 40% 徘徊,且验证 loss 从第 2 个 epoch 开始单调上升。
原因:分两种。一种是真过拟合,模型参数量相对数据量太大,加上没有正则;另一种是数据泄漏——归一化的均值方差用了全量数据算,或者先按类别排序再切分训练/验证,导致验证集分布和训练集高度重合后又突然变化。还有人把验证集也做了随机增强(翻转、裁剪),评估时的输入分布和训练时不一致,指标自然崩。
解决:先查泄漏:统计量只在训练集上算,切分前必须整体 shuffle 一次,验证集不做任何随机增强。确认无泄漏后加正则:wd=5e-4,或者在池化后加 dropout(训练时按比例置零、推理时缩放),这是手写实现里最容易加的一层。还有一个低成本手段是早停,按验证集 loss 而不是准确率来选最优 epoch,因为准确率是阶梯函数,早停点会跳。
现象六:开了 OpenMP 之后,同一份代码、同一个种子,两次训练的 loss 曲线不一样,最终准确率差 1 到 3 个百分点。
原因:dW/db的累加在并行区域内直接+=,多个线程同时写同一块内存,属于数据竞争。浮点加法不满足结合律,累加顺序不同结果就有微小差异,经过多层放大后变成可见的指标漂移。更糟的情况是写坏了内存,程序在某次迭代后静默算错。
解决:每个线程一份私有的dw/db缓冲,并行区结束后串行归约。归约顺序固定(按线程编号),保证结果可复现。验证方法是OMP_NUM_THREADS=1和=4各跑 50 批,打印参数校验和,两者差异应当在1e-6量级以内,否则就是还有竞争。
6. 从 demo 到能用:一次可复现的小步验证路线
把网络训起来只是第一步,真正决定这份实现能不能进工程的是“可复现”。我给自己定了一条固定的验证路线,每次改完算子都走一遍,顺序不能乱。
| 步骤 | 检查项 | 通过标准 |
|---|---|---|
| 1 | 单层卷积参照实现对比 | 最大绝对误差 < 1e-5 |
| 2 | 梯度检查(Softmax → FC → Pool → Conv 倒推) | 相对误差 < 1e-2 |
| 3 | 10 个样本过拟合 | 200 步内 loss 降到 1e-3 以下 |
| 4 | 全量训练 5 epoch | 训练准确率单调上升 |
| 5 | 权重落盘再加载 | 前向输出逐位一致 |
第 3 步是最容易被跳过、又最有价值的一步:拿 10 张图反复训,如果 loss 降不下去,说明实现有 bug,跟数据量和正则一点关系都没有。这一步能把 90% 的问题挡在调参之前。
权重保存我倾向于最朴素的二进制格式,前面加魔数和形状,读的时候校验,避免加载错模型还在跑。
// 权重落盘:魔数 + 层数 + 每层形状 + 裸 float,加载时逐项校验 void save_weights(const std <p> <a href="https://download.csdn.net/download/bxhj3014/10511424" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>