资讯详情

资讯详情

纯C++从零实现CNN:im2col卷积与反向传播

简介卷积神经网络简单实现C是一套面向深度学习入门者与C开发者的轻量级CNN基础框架重点解决从理论公式到工程代码的过渡问题适合在Ubuntu 16.04环境下借助Eigen3完成矩阵运算并理解卷积、池化、激活与全连接等模块的协作方式。压缩包共29个文件以13个头文件和13个源文件为主体分别承载网络层、损失与优化等组件的声明与具体实现另配说明文档与文本资料整体约24KB目录轻巧便于逐层阅读和二次修改。已有3027人学习下载作为C实现CNN的入门样例具有一定参考价值。读者可从中获得前向传播、反向传播、权重初始化、批量归一化与Dropout等关键环节的落地思路并结合CIFAR、MNIST示例理解训练与推理链路适合对照源码梳理CNN实现细节、完成课程实验或搭建个人项目原型。1. 从一次调试说起这套 C 卷积神经网络实现适合谁上周有人问我卷积神经网络到底在黑匣子里算的是什么我说你把它用 C 从零写一遍就清楚了。这份资源就是干这件事的不依赖任何深度学习框架纯 C17 手写卷积、池化、全连接、Softmax 和反向传播编译一条命令跑起来能看着 loss 一个 epoch 一个 epoch 往下掉。它不适合拿去刷榜但特别适合三类人刚学完 CNN 理论想验证公式对不对的、要给端侧或嵌入式场景裁剪推理代码的、以及受够了调库却说不清梯度怎么流的。整份代码压在几千行以内每一层的前向和反向都摆在明面上改一个算子立刻能看到结果这种反馈在封装好的框架里很难拿到。2. 工程骨架为什么用纯 C 手写目录怎么分层2.1 技术选型把依赖压到只剩一个编译器这份实现最激进的地方是零第三方依赖。不用 Eigen 做矩阵运算不用 OpenCV 读图不链接任何 BLAS 库一个 g 加标准库就能编。这么做不是为了炫技是为了让「一层网络」这个概念在代码里可数卷积层就是conv.h/conv.cpp两个文件池化就是pool.h/pool.cpp你能指着文件说清楚一个 batch 的数据被谁改了、改成了什么形状。代价当然有。手写 GEMM 的性能比不过高度优化的库在小图上差距不明显通道数一上去就露馅。常见做法是用 im2col 把卷积展开成矩阵乘再靠编译器的-O3 -marchnative自动向量化把差距拉回来一部分对教学和端侧裁剪来说这个折中很划算因为你真正要搬到板子上的往往就是这套展开逻辑而不是某个黑盒库。反向传播的实现策略也值得先说清楚。这个项目采用「每层自己管自己的梯度」的方式每个 Layer 子类同时实现forward和backwardbackward接收上游传下来的输出梯度dout算出本层对输入的梯度din往上返回同时把权重梯度dW、db存在自己身上。模型类只负责按顺序调用不关心层内细节。这样加一层新网络结构时你只需要写一个新类不用改训练循环。// layer.h —— 所有层的公共接口只约定了三个动作 class Layer { public: virtual ~Layer() default; // 前向in 进 out 出层内可缓存中间结果供反向使用 virtual void forward(const Tensor in, Tensor out) 0; // 反向dout 是上游梯度din 是本层对输入的梯度 virtual void backward(const Tensor dout, Tensor din) 0; // 参数更新无参层ReLU/Pool保持空实现即可 virtual void step(float lr) {} virtual std::vectorTensor* params() { return {}; } };这段接口设计的重点在params()它返回本层所有可训练张量的指针优化器遍历模型时统一收集不用为卷积层和全连接层各写一套更新逻辑。参数说明上lr是当前学习率由外部调度器传进来而不是层内写死这样后面想加余弦退火或者 warmup 都不用动层代码。2.2 目录结构九个小文件撑起一次完整训练工程拆得很碎但不乱按「数据 → 算子 → 模型 → 驱动」四层摆放cnn_cpp/ ├── CMakeLists.txt ├── include/ │ ├── tensor.h # NCHW 张量与索引 │ ├── layer.h # 层基类 │ ├── conv.h # 卷积层含 im2col/col2im │ ├── pool.h # 最大池化 │ ├── dense.h # 全连接 │ ├── relu.h # 激活 │ ├── loss.h # Softmax 交叉熵 │ ├── optimizer.h # SGD / Momentum │ └── model.h # 顺序容器 训练/评估循环 ├── src/ # 与 include 一一对应的 .cpp ├── tools/ │ └── make_dataset.py # 把原始图片打成二进制样本包 └── data/ └── train.bin # 自制格式见 4.2模块职责二次开发时最常改的地方tensor.h四维形状 连续内存加步长/视图支持conv.*im2col、GEMM、反向 col2im换分组卷积、空洞卷积pool.*最大池化与 argmax 回传换平均池化、全局池化dense.*展平后矩阵乘换权重初始化loss.*数值稳定 Softmax 交叉熵换 focal lossoptimizer.*SGD / Momentum加 Adam、权重衰减model.*层的顺序执行与 epoch 循环加验证集、早停这个分层的好处是每一层都能单独做单元测试。我一般在改完卷积反向之后先不跑整网直接拿一个 1×1×4×4 的随机输入和随机卷积核跑一遍 forward/backward用数值梯度对一遍——这一步能省掉大量「训练不收敛但不知道错在哪」的时间。2.3 张量布局NCHW 连续内存与一条样本的完整链路张量只用最朴素的结构一个std::vectorfloat加四个维度行主序排布最后一维 W 相邻。选 NCHW 而不是 NHWC是因为池化和卷积在实现里都是按行扫描的W 连续意味着同一行的像素在内存里挨着缓存命中率高。这在纯手写实现里不是小事同一个卷积核布局选错能差出两三倍耗时。// tensor.h —— 四维张量NCHW 连续存储 struct Tensor { int n 0, c 0, h 0, w 0; std::vectorfloat data; Tensor() default; Tensor(int n_, int c_, int h_, int w_) : n(n_), c(c_), h(h_), w(w_), data(static_castsize_t(n_) * c_ * h_ * w_, 0.f) {} // 四维索引换算成一维偏移最后一维连续 inline float at(int ni, int ci, int hi, int wi) { return data[((static_castsize_t(ni) * c ci) * h hi) * w wi]; } inline const float at(int ni, int ci, int hi, int wi) const { return data[((static_castsize_t(ni) * c ci) * h hi) * w wi]; } size_t size() const { return data.size(); } void zero() { std::fill(data.begin(), data.end(), 0.f); } };at()里的static_castsize_t是必须的不是洁癖。当 batch 稍大、通道数上百时ni*c*h*w很容易超过 32 位整数上限溢出后索引变成负数表现是随机段错误或者读到别的样本的数据这种 bug 查起来非常费劲。我见过不止一次因为漏了这个转换而在训练到第几十个 batch 时崩溃的情况。一条样本在网络里的完整链路是这样的从train.bin按 (N, C, H, W) 读出一批 float除以 255 归一化到 0~1进入第一层卷积形状从[N,1,28,28]变成[N,8,28,28]pad1、stride1、3×3 核ReLU 不改形状2×2 最大池化降到[N,8,14,14]第二层卷积升到[N,16,14,14]再池化到[N,16,7,7]展平成[N,784]送进全连接降到[N,10]Softmax 加交叉熵得到标量 loss。反向时这条链倒着走一遍每一层把梯度往上游推最后一层的dW更新完一个 step 结束。这条链路里任何一个形状算错都会在某一层抛出尺寸不匹配的断言所以我在每层 forward 开头都留了一行形状检查。3. 核心算子拆解im2col 卷积、池化与反向传播3.1 卷积层前向im2col 把四重循环压成一次矩阵乘朴素卷积要写四层嵌套输出通道、输出高、输出宽里面再套核高、核宽和输入通道。这种写法不仅慢更麻烦的是反向传播写起来极其容易错。工业界和教学实现里通用的做法是 im2col把每个输出位置对应的感受野摊平成一列输入图就变成了一个(C*kh*kw) × (outH*outW)的大矩阵卷积随之变成一次标准矩阵乘。// conv.cpp —— 把输入按感受野展开越界部分补 0 void im2col(const float* im, int C, int H, int W, int kh, int kw, int pad, int stride, int outH, int outW, float* col) { size_t idx 0; for (int c 0; c C; c) { for (int i 0; i kh; i) { for (int j 0; j kw; j) { // 该核元素在输入图上的起始坐标可能为负padding 区域 int h_im -pad i; for (int oh 0; oh outH; oh, h_im stride) { int w_im -pad j; for (int ow 0; ow outW; ow, w_im stride) { col[idx] (h_im 0 h_im H w_im 0 w_im W) ? im[(static_castsize_t(c) * H h_im) * W w_im] : 0.f; } } } } } }参数上outH (H 2*pad - kh) / stride 1outW同理这个公式在 5.1 的避坑里还会再提一次因为它是尺寸错误的第一来源。展开后列索引的顺序是(c, i, j, oh, ow)这一点必须和权重的排布严格对齐权重被摊成[outC, C*kh*kw]第 m 行第 k 列对应第 m 个输出通道的第 k 个核元素k 的展开顺序必须同样是(c, i, j)否则数学上完全正确但结果全错而且不会报错。矩阵乘这里有个小细节值得单独说。教科书式的三重循环写成m-n-k内层遍历 k 时 B 矩阵是按列跳着访问的缓存极不友好。// 行主序 GEMM循环顺序用 i-k-j让最内层连续读写 B 的行 static void gemm(const float* A, const float* B, float* C, int M, int K, int N) { std::memset(C, 0, sizeof(float) * static_castsize_t(M) * N); for (int m 0; m M; m) { for (int k 0; k K; k) { const float a A[static_castsize_t(m) * K k]; const float* b B static_castsize_t(k) * N; float* c C static_castsize_t(m) * N; for (int n 0; n N; n) c[n] a * b[n]; // 连续访存 } } }仅仅把内层循环换成遍历 n实测在 784 维这种尺寸上就有接近两倍的差距编译器还能把内层自动向量化。M是输出通道数K是单核元素总数C*kh*kwN是单张图输出位置数outH*outW。注意memset清零不能省因为这里用的是累加式写法。卷积层 forward 把上面两块拼起来逐样本做一次展开再乘然后加偏置、写回输出张量void Conv2D::forward(const Tensor in, Tensor out) { const int N in.n, C in.c, H in.h, W in.w; outH_ (H 2 * pad_ - kh_) / stride_ 1; outW_ (W 2 * pad_ - kw_) / stride_ 1; out Tensor(N, outC_, outH_, outW_); const int K C * kh_ * kw_, M outC_, P outH_ * outW_; col_.resize(static_castsize_t(K) * P); for (int n 0; n N; n) { im2col(in.data[static_castsize_t(n) * C * H * W], C, H, W, kh_, kw_, pad_, stride_, outH_, outW_, col_.data()); gemm(w_.data.data(), col_.data(), out.data.data() n * M * P, M, K, P); // 每个输出通道加同一个偏置 for (int m 0; m M; m) for (int p 0; p P; p) out.data[(static_castsize_t(n) * M m) * P p] b_.data[m]; } }col_声明成成员变量而不是局部变量是为了避免每个 batch 都重新分配同时反向传播还要用它——dW dout × col^T没有缓存的 col 就得重算一遍展开白白多花一倍时间。3.2 池化与全连接看着简单错起来最隐蔽最大池化前向要顺手记下 argmax 的位置反向只把梯度回传给那个位置其余置零。这一步如果不记录索引而是在反向时重新扫一遍比较数值遇到重复最大值就会把梯度分到错误的位置训练早期看不出来后期精度就是上不去。void MaxPool2D::forward(const Tensor in, Tensor out) { const int N in.n, C in.c, H in.h, W in.w; outH_ H / k_; outW_ W / k_; out Tensor(N, C, outH_, outW_); argmax_.resize(out.size()); for (int n 0; n N; n) for (int c 0; c C; c) for (int oh 0; oh outH_; oh) for (int ow 0; ow outW_; ow) { int best 0; float bestv -1e30f; for (int i 0; i k_; i) for (int j 0; j k_; j) { int hi oh * k_ i, wi ow * k_ j; float v in.at(n, c, hi, wi); if (v bestv) { bestv v; best i * k_ j; } } out.at(n, c, oh, ow) bestv; // 记录窗口内偏移反向时原样回传 argmax_[((static_castsize_t(n) * C c) * outH_ oh) * outW_ ow] static_castint16_t(best); } }argmax_用int16_t存窗口内偏移就够了池化核一般不超过 4一个字节都用不满比存全局坐标省内存也省一次除法。全连接层就是把输入展平成[N, inDim]做一次Y X·Wᵀ b权重形状[outDim, inDim]。这里最容易翻车的地方是初始化权重全零会让同一层所有神经元梯度完全相同训练等于白跑常见做法是用 He 初始化标准差取sqrt(2 / inDim)配合 ReLU 效果稳定。偏置初始化为 0 没有副作用。void Dense::init(int inDim, int outDim) { inDim_ inDim; outDim_ outDim; w_ Tensor(outDim, inDim, 1, 1); b_ Tensor(1, outDim, 1, 1); // He 初始化适配 ReLU 的方差缩放 std::normal_distributionfloat dist(0.f, std::sqrt(2.f / inDim)); for (auto v : w_.data) v dist(rng_); std::fill(b_.data.begin(), b_.data.end(), 0.f); }3.3 Softmax 与交叉熵数值稳定版和它的梯度Softmax 最经典的翻车方式是直接exp(x)再归一化。logits 稍微大一点exp就溢出成 inf除出来全是 nanloss 曲线瞬间拉平看起来像模型崩了其实只是少减了一个最大值。标准做法是先减每行的最大值这不改变结果因为分子分母同乘一个常数。// loss.cpp —— 数值稳定的 softmax 交叉熵同时输出梯度 float SoftmaxCrossEntropy::forward(const Tensor logits, const std::vectorint labels) { const int N logits.n, C logits.c; prob_ Tensor(N, C, 1, 1); float total 0.f; for (int n 0; n N; n) { const float* x logits.data[static_castsize_t(n) * C]; float mx *std::max_element(x, x C); // 防溢出关键一步 float sum 0.f; for (int c 0; c C; c) sum std::exp(x[c] - mx); for (int c 0; c C; c) prob_.data[static_castsize_t(n) * C c] std::exp(x[c] - mx) / sum; total - std::log(prob_.data[static_castsize_t(n) * C labels[n]] 1e-12f); // 加极小值防 log(0) } return total / N; }反向的梯度出奇地简单dlogits (prob - onehot) / NSoftmax 和交叉熵的导数在推导中互相抵消所以这一层不需要单独实现 Softmax 的反向。很多人第一次看会怀疑是不是漏了东西其实这正是把两者合并成一个类的意义——分开实现反而容易算错。3.4 反向传播梯度从 loss 一路回传到第一层卷积核卷积层的 backward 要算三个东西对输入图的梯度din往下游传、对权重的梯度dW、对偏置的梯度db。dW是dout × colᵀdin需要把dcol再散回原图位置——这就是 col2im它在重叠区域要做累加而不是覆盖因为同一个输入像素可能参与了多个输出位置的计算。void Conv2D::backward(const Tensor dout, Tensor din) { const int N din.n, C din.c, H din.h, W din.w; const int M outC_, P outH_ * outW_, K C * kh_ * kw_; din Tensor(N, C, H, W); dw_.zero(); db_.zero(); std::vectorfloat dcol(static_castsize_t(K) * P); for (int n 0; n N; n) { const float* d dout.data[static_castsize_t(n) * M * P]; // dW dout * col^T for (int m 0; m M; m) for (int p 0; p P; p) { const float g d[m * P p]; db_.data[m] g; for (int k 0; k K; k) dw_.data[m * K k] g * col_[k * P p]; } // dcol W^T * dout for (int k 0; k K; k) for (int p 0; p P; p) { float acc 0.f; for (int m 0; m M; m) acc w_.data[m * K k] * d[m * P p]; dcol[k * P p] acc; } // 散回原图pad 区域丢弃重叠区域累加 col2im(dcol.data(), C, H, W, kh_, kw_, pad_, stride_, outH_, outW_, din.data[static_castsize_t(n) * C * H * W]); } }这里有三处必须注意。一是din在col2im之前必须清零因为散回是累加写二是dw_、db_在每个 batch 开头清零在样本循环里累加最后统一step这样等价于对整个 batch 求平均梯度三是dout的形状必须和 forward 时的输出严格一致如果前一层的池化步长配错这里读越界不会立刻崩而是读进一段无意义数据训练就变成随机游走。参数更新用 Momentum 就够跑出稳定曲线公式是v μv - lr·gp vμ 取 0.9。相比纯 SGDMomentum 在 loss 曲面上能压掉一部分震荡对 4.1 里那些学习率不太好挑的场景宽容得多。4. 编译运行从空目录到看着 loss 降下去4.1 编译与第一次跑通三条命令CMake 里把优化选项固定住避免有人用默认的 Debug 编出来然后抱怨太慢。cmake_minimum_required(VERSION 3.16) project(cnn_cpp 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(cnn_train src/main.cpp src/conv.cpp src/pool.cpp src/dense.cpp src/relu.cpp src/loss.cpp src/model.cpp) # -marchnative 让编译器用上本机 SIMD跨机器分发时要删掉 target_compile_options(cnn_train PRIVATE -O3 -marchnative -fno-math-errno)编的时候注意-marchnative会在别的 CPU 上非法指令崩溃作品交付或者上板之前记得去掉。跑起来就三条命令mkdir -p build cd build cmake .. make -j ./cnn_train --data ../data/train.bin --epochs 5 --batch 32 --lr 0.01启动后终端会打印每个 batch 的 loss、每个 epoch 的训练准确率和耗时。第一次跑建议把 epochs 设成 1先确认 loss 从 2.3 附近10 类均匀分布时的理论值ln10开始下降再放开跑全量。4.2 数据准备自定义二进制格式怎么造为了不引第三方解码库数据用自定义二进制文件头 4 个 int32 存样本数 / 通道 / 高 / 宽其后紧接样本数 × 通道 × 高 × 宽个uint8灰度值标签单独放在文件末尾的样本数个uint8。读取时一次性fread进std::vector再转 float 归一化。// 读取自定义样本包返回值直接可用于训练 bool load_dataset(const std::string path, Tensor images, std::vectorint labels, int num) { std::ifstream f(path, std::ios::binary); if (!f) return false; int32_t head[4]; f.read(reinterpret_castchar*(head), sizeof(head)); num head[0]; images Tensor(head[0], head[1], head[2], head[3]); std::vectoruint8_t buf(images.size()); f.read(reinterpret_castchar*(buf.data()), buf.size()); for (size_t i 0; i buf.size(); i) images.data[i] buf[i] / 255.0f; // 归一化到 0~1 labels.resize(num); f.read(reinterpret_castchar*(labels.data()), num); return true; }归一化这一步和 5.3 的过拟合问题直接相关训练集和验证集必须用同一套归一化参数不能一边除 255 一边不除否则同样是「准确率上不去」但原因完全在数据侧。4.3 训练日志怎么看五个量决定要不要停我会在日志里固定打这几个数train_loss、train_acc、val_loss、val_acc、lr。判断优先级是先看 train_loss 是否单调下降不降就回头看 5.1降但慢就把学习率乘 3 试一次Momentum 场景下 0.01~0.05 是常用区间train_acc 高而 val_acc 卡住是过拟合加大数据扰动或者提前停两边都高但曲线抖得厉害把 batch 从 32 提到 64 通常能压平。另外建议把每个 epoch 的权重 dump 一次出问题时能回到上一个可用状态比事后猜「到底哪一步开始坏的」省太多时间。这套东西本质是给自己留后悔药小项目里也值得。5. 避坑与排查五条实际踩过的坑5.1 loss 前几个 step 就变成 nan现象训练刚起步第一或第二个 batch 的 loss 打印出nan之后再也不恢复准确率停在随机水平。原因九成是 Softmax 没有减最大值logits 稍大就exp溢出剩下的一成是学习率给大了某次更新把权重推到了极大值下一轮前向直接溢出。还有一种隐蔽情况是交叉熵里log(0)概率被算成严格 0 时梯度无穷。解决先确认exp(x - max)这一行确实存在且减的是行内最大值而不是全局最大值再把学习率降到 1e-3 试跑 20 个 step如果 nan 消失而 loss 缓慢下降就是学习率问题。日志里顺手加上权重的最大绝对值超过 1e3 就基本可以确定了。5.2 loss 卡在 2.30 附近不动现象10 分类任务loss 一直在 2.29~2.31 之间小幅波动准确率约等于 10%看起来像模型没学到任何东西。原因ln10 ≈ 2.302这个数值本身就是「输出接近均匀分布」的标志说明梯度没有有效回传。常见原因有三个全连接权重初始化为 0导致同层神经元梯度完全一致标签索引和输出通道顺序错位等于在学随机标签ReLU 之前的偏置被初始化为很大的负数整层进入死区。解决检查初始化权重用 He 正态、偏置用 0打印前 10 个样本的预测类别看是否全部集中在同一类如果是基本锁定标签错位把 ReLU 换成 LeakyReLU 跑一轮若能开始下降就说明是死区问题。5.3 训练集 99%验证集 60%现象训练准确率一路飙到 99%验证集在 60% 上下徘徊两者差距越拉越大。原因除了模型容量相对数据过大之外这个项目里更常见的是数据侧问题——训练集没打乱同一批里全是相似样本等于在用顺序做训练或者训练集归一化到 0~1 而验证集忘了除 255又或者验证集是从训练集里复制出来的子集指标虚高。解决在每个 epoch 开始前用固定种子的std::shuffle打乱索引把归一化参数抽成一个函数训练和验证都调它别在两处各写一遍检查两份文件的行数是否真的不重叠。确认无误后再考虑加数据增强或者减小通道数。5.4 训练一阵后 loss 突然爆炸现象前十几个 epoch 都正常某次开始 loss 陡然上升几次之后变成 inf。原因多半是梯度累加出了问题。col2im写回之前din没清零梯度被反复叠加或者dW在 batch 内没有清零跨 batch 一直累加等效学习率越跑越大。另一种可能是没做梯度裁剪遇到个别极端样本直接把权重打飞。解决在col2im调用前加一次std::fill(din...)在 batch 循环开头清零dw_/db_再加一行全局梯度范数打印超过阈值就按比例缩放这是最省事的一道保险。5.5 开 OpenMP 后两次训练结果不一致现象单线程跑出来的 loss 曲线可复现加了#pragma omp parallel for之后每次跑的结果都有细微差异几轮之后准确率能差好几个点。原因浮点加法不满足结合律多线程下累加顺序随机变化dW的最后几位有效数字每次不同训练本身是混沌系统这点扰动会被放大。解决调试阶段强制单线程确认逻辑正确后再开并行需要严格复现时把并行放在样本维度上每个线程算自己那份dW再按固定顺序归约而不是放在通道或像素维度上。对教学项目来说单线程通常已经够快不必为了快几秒牺牲可复现性。6. 二次开发加一层 BN、换一份数据、把权重导出去真正让这套代码有价值的是它能被改。我自己最常做的三个改造方向按难度从低到高排。第一个是换数据集。只要把tools/make_dataset.py里的读图逻辑换成新的来源输出保持「头 4 个 int、像素、标签」的格式训练端一行不用改。唯一要同步的是网络入口的形状输入从 28×28 变成 32×32 时展平后的维度、全连接的inDim都要跟着改否则第一层矩阵乘就会形状不匹配。改完先跑 1 个 epoch看 loss 是否在 10 个 step 内下降不降就回到 5.2 逐条排查。第二个是加 BatchNorm。层接口已经预留了params()新写一个BatchNorm2D类实现 forward/backward插在卷积和 ReLU 之间即可。训练时统计 batch 均值方差并滑动更新全局统计量推理时用全局值这个切换用bool training标志控制。加完之后通常能把学习率放大 3~5 倍而不炸收敛轮数能砍掉近一半代价是实现里多了一组gamma/beta参数要一起送进优化器。第三个是把训练好的权重导出去做推理。写一个dump_weights按层顺序把w_、b_的 float 原样二进制写出附一份简单的文本清单记录每层的形状和类型端侧加载时按清单顺序fread即可不需要任何依赖。这一步做完整套东西就从「学习用的玩具」变成了能塞进实际程序里的一小段推理代码。改完之后怎么验证没改坏我习惯用数值梯度做一次对拍取一个 2×2 的小输入对某个权重加 1e-4、减 1e-4 各算一次 loss用差分近似出梯度和反向传播算出的解析梯度比相对误差量级在 1e-4 以内算通过。// 梯度检查解析梯度 vs 数值梯度 float eps 1e-4f, orig w[k]; w[k] orig eps; float lp model_loss(); w[k] orig - eps; float lm model_loss(); w[k] orig; float numeric (lp - lm) / (2 * eps); float analytic dw[k]; assert(std::fabs(numeric - analytic) 1e-3f * std::max(1.f, std::fabs(numeric)));这段检查在写新层时能救命的。我自己的血泪经验是任何新算子先过这个对拍再去跑全量训练。以前有次省了这步直接上数据跑了两个小时才发现反向公式里少了个转置白等一场。从那以后我每次动到 backward不管多简单的层都强制先把数值梯度走一遍再谈训练。希望这套东西帮到你少走我走过的弯路。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →