资讯动态

纯C++手写CNN:im2col卷积与反向传播工程实现

发布时间:2026/10/26 18:26:42 来源:尧图企业网站定制
简介面向希望用 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 里最容易出现的低级事故是内存布局不统一。有人前半段用vectorvectorvectorfloat写到卷积层发现切片传参爆炸临时改成一维数组索引全错位查三天最后发现是h和w写反了。我的习惯是整个工程只允许一个四维索引函数存在其余地方一律调它绝不手写((n*Cc)*Hh)*Ww这种展开式。布局选 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::vectorfloat 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*KHkh)*KWkw)。这个展平顺序必须和 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 1OW同理。注意这里是整除不是四舍五入——如果(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就错得很隐蔽pad1, stride1时两者恰好等价你换成stride2立刻全盘错位而且在形状正确的掩护下不会报任何错。第二个是越界判断用 0 H不能只判上界ih是int且有负数。第三个是行序c在外层还是kh在外层都行但必须和权重布局一致一旦不一致等价于把滤波器打乱重排网络还是能训练只是永远学不到东西。参数上pad一般取KH/23×3核取 15×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::vectorfloat 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 通道输入就是 288Ncol是输出平面大小输入28×28、pad1、stride1时是 784。这组数字决定了朴素实现的耗时——单层OC32, K288, Ncol784大约 720 万次乘加两个卷积层加上全连接单张图前向在普通笔记本上是个位数毫秒级batch 128 一轮几百毫秒是能接受的实验节奏。3. 反向传播手推与落码让 loss 真的往下走3.1 卷积层梯度dX、dW、db 三条通路卷积前向是y W·col(x) b所以反向也是三条db是对dY在 batch 和空间维上求和dW是dY × col^TdX要先算dcol W^T × dY再把dcol散射回输入平面。前两条是标准矩阵乘第三条是唯一有坑的地方——因为滑动窗口重叠同一个输入像素会被多个输出位置引用散射时必须累加不能赋值。// col2imdcol - 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::vectorfloat 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::vectorfloat 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*Wiw void maxpool_forward(const Tensor x, int k, int s, Tensor y, std::vectorint 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::vectorint 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时窗口重叠同一输入位置可能被多个窗口选为最大值。k2, s2是标准做法不重叠但你若为了省参数改成k3, s2就要靠这个兜底。全连接层就是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::vectorfloat logits, const std::vectorint label, int N, int K, std::vectorfloat 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 就溢出成inflogits 稍微大一点就 NaN而且 NaN 会顺着梯度污染全部参数你看到的只是“loss 变 nan”根因在几层之外。3.3 数值梯度检查30 行代码省三天手推完梯度第一件事不是跑训练是抽查。中心差分(f(θε) - f(θ-ε)) / 2ε相比单侧差分精度高一个量级配合相对误差判据就够用了。参数规模上万不可能全查随机抽 20 到 50 维即可只要有一处不对就说明公式或代码有问题。// 梯度检查随机抽查若干维相对误差超过 tol 就打印现场 bool grad_check(const std::functionfloat() forward, std::vectorfloat param, const std::vectorfloat grad, int samples, float eps 1e-3f, float tol 1e-2f) { std::mt19937 rng(1234); // 固定种子保证可复现 std::uniform_int_distributionint 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 位有效数字eps1e-3是常用折中太小会被舍入误差淹没太大则差商的截断误差上来。tol用相对误差1e-2比绝对误差稳因为不同参数的梯度量级差好几个数量级。检查顺序建议从 Softmax 往上倒推先单测 Softmax 的dlogits再接全连接再接池化最后接卷积。这样一旦卷积检查失败你可以确信底层算子是对的问题只在自己写的col2im或dW累加上。4. 训练循环工程化优化器、数据管道与编译开关4.1 优化器带动量的 SGD 与学习率的取法这份实现不追求花哨优化器带动量加权重衰减的 SGD 已经足够把一个小 CNN 训到能用而且每一步都能手算验证。写法上把 L2 正则并进梯度比单独写一个衰减项更好对账。// 带动量 L2 的 SGDp p v, v momentum*v - lr*(g wd*p) void sgd_update(std::vectorfloat p, const std::vectorfloat g, std::vectorfloat 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.9wd在1e-4到5e-4之间试。真正难定的是lr。血泪经验是先做一次“梯度标定”拿一个 batch 前向反传打印全局梯度范数||g||如果||g||在几十量级lr0.01起在几百量级lr要压到1e-3如果打印出来是nan或inf别调 lr回去查softmax有没有减最大值。每层单独设 lr 没必要但卷积层和最后全连接层分开看是值得的全连接层参数量大、梯度容易大我在小数据集上经常把全连接层的 lr 打三折收敛曲线会平滑很多。4.2 数据管道整库入内存 下标 shuffle小规模数据集几万张28×28灰度图完全放得进内存float存储也就几十 MB没必要写复杂的按需读取。真正的坑在 shuffle直接打乱文件顺序代价高标准做法是维护一个下标数组每个 epoch 洗牌一次按批取。struct Batch { Tensor x; std::vectorint y; }; // 按 epoch 重排下标再按 batch 切片拷贝不移动原始数据本身 void make_batches(const Tensor all_x, const std::vectorint all_y, int batch_size, std::mt19937 rng, std::vectorBatch out) { const int total int(all_y.size()); std::vectorint 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::vectorfloat 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 -stdc17 -O0 -g -fsanitizeaddress,undefined main.cpp -o cnn_dbg # 正确性确认后再上性能和并行 g -stdc17 -O2 -marchnative -fopenmp main.cpp -o cnn开关作用什么时候开风险-O0 -g不优化 调试符号首轮跑通与梯度检查慢 20 倍以上-fsanitizeaddress,undefined越界与未定义行为检测新写一个算子之后内存翻倍-O2常规优化含循环向量化默认开几乎无风险-marchnative用本机指令集AVX 等本机实验换机器就跑不了-fopenmp多核并行batch 循环可并行时共享变量竞态-marchnative一定要慎用编出来的二进制绑死本机 CPU 指令集拷到另一台机器上直接Illegal instruction。生产构建里我一般写-marchx86-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行做一次手工比对取oc0, k0和kK-1打印两个值用单通道单样本的手算结果核对。更彻底的做法是写一个conv_reference()用六层嵌套循环直接算卷积不走 im2col和conv_forward对同一组输入比最大绝对误差1e-5以内才算通过。这个参照实现只要 20 行但能一次性排掉行序类问题。现象二前 200 批 loss 正常下降之后平在一个明显偏高的位置加大 lr 会震荡、减小 lr 只是更慢地平。原因col2im里用了而不是。stride1、KH3时每个输入像素被 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_distributionfloat(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 一次验证集不做任何随机增强。确认无泄漏后加正则wd5e-4或者在池化后加 dropout训练时按比例置零、推理时缩放这是手写实现里最容易加的一层。还有一个低成本手段是早停按验证集 loss 而不是准确率来选最优 epoch因为准确率是阶梯函数早停点会跳。现象六开了 OpenMP 之后同一份代码、同一个种子两次训练的 loss 曲线不一样最终准确率差 1 到 3 个百分点。原因dW/db的累加在并行区域内直接多个线程同时写同一块内存属于数据竞争。浮点加法不满足结合律累加顺序不同结果就有微小差异经过多层放大后变成可见的指标漂移。更糟的情况是写坏了内存程序在某次迭代后静默算错。解决每个线程一份私有的dw/db缓冲并行区结束后串行归约。归约顺序固定按线程编号保证结果可复现。验证方法是OMP_NUM_THREADS1和4各跑 50 批打印参数校验和两者差异应当在1e-6量级以内否则就是还有竞争。6. 从 demo 到能用一次可复现的小步验证路线把网络训起来只是第一步真正决定这份实现能不能进工程的是“可复现”。我给自己定了一条固定的验证路线每次改完算子都走一遍顺序不能乱。步骤检查项通过标准1单层卷积参照实现对比最大绝对误差 1e-52梯度检查Softmax → FC → Pool → Conv 倒推相对误差 1e-2310 个样本过拟合200 步内 loss 降到 1e-3 以下4全量训练 5 epoch训练准确率单调上升5权重落盘再加载前向输出逐位一致第 3 步是最容易被跳过、又最有价值的一步拿 10 张图反复训如果 loss 降不下去说明实现有 bug跟数据量和正则一点关系都没有。这一步能把 90% 的问题挡在调参之前。权重保存我倾向于最朴素的二进制格式前面加魔数和形状读的时候校验避免加载错模型还在跑。// 权重落盘魔数 层数 每层形状 裸 float加载时逐项校验 void save_weights(const std p a hrefhttps://download.csdn.net/download/bxhj3014/10511424 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑