ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++从零实现CNN与全连接网络:手写梯度、内存与MNIST训练

C++从零实现CNN与全连接网络:手写梯度、内存与MNIST训练

简介:本资源是一套面向深度学习初学者与C++开发者的实践型教学项目,聚焦神经网络底层原理与工程实现,帮助读者摆脱框架依赖,真正理解卷积神经网络(CNN)与全连接网络(FCN)的前向传播、反向更新及MNIST数据训练全流程。资源共30个文件,包含8个核心cpp实现文件(如convolutional_layer.cpp、neural_network.cpp)、5个头文件(含matrix_math_function.hpp等数学工具)、3个编译脚本(sh)及2套MNIST原始二进制数据集(train-images-idx3-ubyte等),辅以YAML配置与SConstruct构建系统,整体11MB,结构清晰、模块解耦,便于逐层调试与原理验证。已有85人学习下载,提供从零手写卷积核运算、池化逻辑、权重更新到精度评估的完整闭环代码,特别适合夯实AI底层基础、提升C++高性能数值计算能力的学习者。

1. 为什么用 C++ 从零手写 CNN 和全连接网络?——不是为了造轮子,而是为了看清梯度怎么流、内存怎么崩、MNIST 的 28×28 像素到底在哪个地址被乘了 32 次

你手头这个.zip文件,名字里没写“教学”“Demo”“玩具”,它直白地写着「学习深度学习:用 C++ 从零实现卷积神经网络、全连接神经网络,读取 MNIST 数据集训练模型,验证模型精度」。这不是 Python + PyTorch 的 5 行model.train(),也不是 Keras 的model.fit()封装黑匣子;这是用原生 C++ 在裸内存上分配权重、手动展开卷积核、逐像素做点积、自己写反向传播链式求导、把uint8_t[784]转成float*再喂进for (int i = 0; i < 784; ++i) input[i] = (float)data[i] / 255.0f;—— 每一步都可打断点、可打印地址、可valgrind检查越界。它解决的不是“怎么快速跑通一个 baseline”,而是“为什么 dropout 在前向时要除以 keep_prob、反向时却不用?为什么卷积层输出尺寸是(H−K+2P)/S + 1、而我的for (int i = 0; i < out_h; ++i)却总少算一行?为什么malloc分配的float*在cudaMalloc里根本不能用?”——这些问题,在 Python 封装层下是幽灵,在 C++ 实现里是变量名、指针偏移和段错误信号。适合刚学完吴恩达《深度学习专项》前三门课、能推导出 Softmax 导数但一写代码就卡在std::vector<std::vector<float>>和float**之间选哪个的人;也适合想把模型部署到嵌入式设备、必须掐着内存和 cache line 优化的工程师。它不追求 SOTA 精度,但要求你亲手把conv2d_forward()里的im2col展开逻辑写对,否则valgrind会立刻告诉你:Invalid write of size 4 at address 0x...。


2. 从零搭建骨架:C++ 工程结构设计与核心类职责划分

2.1 为什么不用 Eigen/TensorRT/ONNX Runtime?——先拒绝所有外部依赖,再决定要不要引入

很多初学者一上来就想用 Eigen 做矩阵乘法,或者直接#include <onnxruntime_cxx_api.h>。但本项目第一原则是:所有张量操作、内存管理、自动求导链,全部手写。原因有三:

  • 调试可见性:Eigen 的matrixA * matrixB是黑盒,你无法在 GDB 里单步进入operator*看它是用 SSE 还是 AVX,更没法在gemm中间插一句printf("weight[%d][%d] = %f\n", i, j, w[i][j]);;
  • 内存控制权:MNIST 单张图 28×28=784 字节,但训练时 batch=64,输入 tensor 就是64×1×28×28,即64×784个 float。若用std::vector<std::vector<float>>存储,每行额外 24 字节(vector 控制块),64 行就是 1536 字节浪费——这在嵌入式端不可接受;
  • 反向传播理解成本:PyTorch 的.backward()自动构建计算图,但你永远不知道Conv2d的grad_fn里ctx.save_for_backward(input, weight)具体存了什么。而手写时,你必须显式声明struct Conv2dLayer { float* input; float* weight; float* grad_input; float* grad_weight; },并手动在backward()中调用conv2d_backward_weight()和conv2d_backward_input()。

因此,本项目初始依赖仅限:

  • 标准库<vector><memory><cmath><fstream><iostream><algorithm>;
  • C 风格<stdio.h><stdlib.h><string.h>(用于二进制 MNIST 文件读取);
  • 编译器内置<immintrin.h>(后续加速用,初期禁用)。

提示:不要提前引入 OpenMP 或 CUDA。先让纯 CPU 版本在g++ -O2 -march=native下跑通,精度达到 95%+,再考虑并行化。过早引入并发,会让内存越界问题变成随机崩溃,debug 成本翻 5 倍。

2.2 四大核心类设计:Tensor、Layer、Network、Trainer —— 每个类只做一件事,且接口可测

我们不写class DeepLearningFramework这种上帝类。所有功能拆解为四个正交类,每个类职责清晰、可独立单元测试:

类名职责关键成员可测试点
Tensor内存管理 + 基础运算(+ − × ÷、reshape、transpose)float* data_; size_t size_; std::vector<size_t> shape_;Tensor a(2,3); a.fill(1.0f); assert(a.sum() == 6.0f);
Layer前向/反向接口抽象基类virtual void forward(const Tensor& input, Tensor& output) = 0; virtual void backward(const Tensor& input, const Tensor& output, const Tensor& grad_output, Tensor& grad_input) = 0;LinearLayer l(784,128); Tensor in(1,784); Tensor out(1,128); l.forward(in,out); assert(out.shape() == std::vector<size_t>{1,128});
NetworkLayer 序列管理 + 参数聚合std::vector<std::unique_ptr<Layer>> layers_; std::vector<float*> params_; std::vector<float*> grads_;net.add_layer(std::make_unique<Conv2dLayer>(1,32,3,1,1)); net.params_size() == 1*32*3*3 + 32
Trainer训练循环 + 优化器(SGD) + 精度验证float learning_rate_; int epochs_; int batch_size_;trainer.train(net, train_data, train_labels, 1); // 1 epoch, assert accuracy > 0.9

注意:Tensor不实现operator+,因为a + b语义模糊——是 in-place 还是 copy?是 broadcast 还是 strict shape match?统一用Tensor::add(const Tensor& other, Tensor& out)显式指定,避免隐式转换陷阱。Network不持有数据,只持参数指针;数据由Trainer传入,符合“数据与模型分离”原则。

2.3 MNIST 二进制格式解析:绕过 torchvision,手撕 idx 文件头与像素布局

PyTorch 用户习惯torchvision.datasets.MNIST,但本项目必须自己读.idx3-ubyte和.idx1-ubyte。原因:

  • 你得知道 label 文件前 4 字节是 magic number0x00000801(big-endian),后 4 字节是样本数num_items;
  • 图像文件前 16 字节是 header:4 字节 magic (0x00000803) + 4 字节 num_images + 4 字节 rows (28) + 4 字节 cols (28);
  • 每张图是连续 784 字节uint8_t,不是 RGB,不是 PNG,就是 raw pixel,值域 0~255,0=黑,255=白(注意:MNIST 官方定义是 background=0, digit=255,与常见图像相反)。
// mnist_loader.h struct MNISTLoader { static std::vector<std::vector<uint8_t>> load_images(const std::string& path) { std::ifstream file(path, std::ios::binary); if (!file.is_open()) throw std::runtime_error("Cannot open " + path); // Read header: 16 bytes uint32_t magic, num_images, rows, cols; file.read(reinterpret_cast<char*>(&magic), 4); magic = be32toh(magic); // convert big-endian to host file.read(reinterpret_cast<char*>(&num_images), 4); num_images = be32toh(num_images); file.read(reinterpret_cast<char*>(&rows), 4); rows = be32toh(rows); file.read(reinterpret_cast<char*>(&cols), 4); cols = be32toh(cols); // Allocate storage: num_images x (rows*cols) std::vector<std::vector<uint8_t>> images(num_images, std::vector<uint8_t>(rows * cols)); for (size_t i = 0; i < num_images; ++i) { file.read(reinterpret_cast<char*>(images[i].data()), rows * cols); } return images; } static std::vector<uint8_t> load_labels(const std::string& path) { std::ifstream file(path, std::ios::binary); uint32_t magic, num_labels; file.read(reinterpret_cast<char*>(&magic), 4); magic = be32toh(magic); file.read(reinterpret_cast<char*>(&num_labels), 4); num_labels = be32toh(num_labels); std::vector<uint8_t> labels(num_labels); file.read(reinterpret_cast<char*>(labels.data()), num_labels); return labels; } };

注意:be32toh()是 glibc 函数,Windows 需替换为_byteswap_ulong()或手动实现。file.read()直接写入std::vector<uint8_t>::data()是安全的,因 vector 保证连续内存;但切勿对std::vector<float>做同样操作——float对齐要求更高,应先malloc再reinterpret_cast。


3. 核心层实现:卷积层与全连接层的手动前向/反向推导与代码落地

3.1 全连接层(Linear):最简反向传播,但藏着 L2 正则化陷阱

LinearLayer是所有层的起点。它接收input(shape[batch, in_features]),输出output(shape[batch, out_features]),参数weight([in_features, out_features])和bias([out_features])。前向公式:
$$ \text{output} = \text{input} \cdot \text{weight} + \text{bias} $$

反向传播需计算:

  • grad_input = grad_output ⋅ weight^T
  • grad_weight = input^T ⋅ grad_output
  • grad_bias = sum(grad_output, axis=0)

关键细节:

  • 内存布局:input是 row-major,即input[i][j]对应第i行第j列。因此input^T ⋅ grad_output的i,j元素是sum_k input[k][i] * grad_output[k][j];
  • L2 正则化:若加λ||w||²,则grad_weight += 2λ * weight。陷阱在于:这个梯度必须加在grad_weight上,而不是weight更新时才加!否则grad_weight不反映真实损失梯度,导致收敛变慢甚至发散;
  • batch 维度处理:grad_bias是对 batch 维度求和,不是平均——因为 loss 是 batch 内所有样本 loss 之和,所以grad_bias[j] = Σ_i grad_output[i][j]。
// linear_layer.cpp void LinearLayer::forward(const Tensor& input, Tensor& output) { // input: [B, in], weight: [in, out], output: [B, out] const float* in_data = input.data(); const float* w_data = weight_.data(); float* out_data = output.data(); const int B = input.shape()[0]; const int in = input.shape()[1]; const int out = output.shape()[1]; for (int b = 0; b < B; ++b) { for (int j = 0; j < out; ++j) { float sum = bias_[j]; for (int i = 0; i < in; ++i) { sum += in_data[b * in + i] * w_data[i * out + j]; } out_data[b * out + j] = sum; } } } void LinearLayer::backward(const Tensor& input, const Tensor& output, const Tensor& grad_output, Tensor& grad_input) { const float* in_data = input.data(); const float* w_data = weight_.data(); const float* grad_out_data = grad_output.data(); float* grad_in_data = grad_input.data(); float* grad_w_data = grad_weight_.data(); float* grad_b_data = grad_bias_.data(); const int B = input.shape()[0]; const int in = input.shape()[1]; const int out = output.shape()[1]; // grad_bias: sum over batch for (int j = 0; j < out; ++j) { grad_b_data[j] = 0.0f; for (int b = 0; b < B; ++b) { grad_b_data[j] += grad_out_data[b * out + j]; } } // grad_weight: input^T @ grad_output for (int i = 0; i < in; ++i) { for (int j = 0; j < out; ++j) { float sum = 0.0f; for (int b = 0; b < B; ++b) { sum += in_data[b * in + i] * grad_out_data[b * out + j]; } grad_w_data[i * out + j] = sum; // L2 regularization: add 2 * lambda * weight[i][j] grad_w_data[i * out + j] += 2.0f * l2_lambda_ * w_data[i * out + j]; } } // grad_input: grad_output @ weight^T for (int b = 0; b < B; ++b) { for (int i = 0; i < in; ++i) { float sum = 0.0f; for (int j = 0; j < out; ++j) { sum += grad_out_data[b * out + j] * w_data[i * out + j]; } grad_in_data[b * in + i] = sum; } } }

参数说明:l2_lambda_是构造时传入的正则系数(如1e-4);grad_weight_和grad_bias_是LinearLayer内部Tensor,与weight_/bias_同 shape;grad_input由Trainer分配,大小与input一致。

3.2 卷积层(Conv2d):im2col 与 gemm 的手工等价实现

Conv2dLayer是本项目最难啃的骨头。MNIST 输入是1×28×28,我们用32个3×3卷积核,stride=1,padding=1,则输出为32×28×28。若暴力实现(三重循环遍历输出位置 + 二重循环遍历 kernel),时间复杂度O(B×C_out×H_out×W_out×C_in×K_h×K_w),64 batch 下约64×32×28×28×1×3×3 ≈ 48M次乘加,太慢。故采用im2col+gemm方案:

  • 将输入 feature map 的每个3×3patch 展开为一列,得到9×(28×28)的col矩阵;
  • 将32个3×3kernel 展开为32×9的kernel_col矩阵;
  • output_col = kernel_col × col,再col2im重构为32×28×28。

但本项目不调用 BLAS,而是手写gemm。im2col代码必须严格匹配 padding 和 stride:

// conv2d_layer.cpp void Conv2dLayer::im2col(const float* input, float* col, int batch_size, int in_channels, int h, int w, int k_h, int k_w, int pad, int stride) { const int out_h = (h + 2 * pad - k_h) / stride + 1; const int out_w = (w + 2 * pad - k_w) / stride + 1; const int channels_per_kernel = in_channels * k_h * k_w; for (int b = 0; b < batch_size; ++b) { for (int c = 0; c < in_channels; ++c) { for (int i = 0; i < out_h; ++i) { for (int j = 0; j < out_w; ++j) { const int input_row = i * stride - pad; const int input_col = j * stride - pad; const int col_idx = ((b * in_channels + c) * out_h * out_w + i * out_w + j); for (int di = 0; di < k_h; ++di) { for (int dj = 0; dj < k_w; ++dj) { const int src_row = input_row + di; const int src_col = input_col + dj; const int src_idx = b * in_channels * h * w + c * h * w; float val = 0.0f; if (src_row >= 0 && src_row < h && src_col >= 0 && src_col < w) { val = input[src_idx + src_row * w + src_col]; } col[(di * k_w + dj) * (batch_size * in_channels * out_h * out_w) + col_idx] = val; } } } } } } }

注意:col内存布局是(k_h*k_w) × (batch_size * in_channels * out_h * out_w),即 kernel 元素在前,每个 patch 一列。col_idx计算必须包含batch和channel维度,否则多 channel 时会错位。src_row/col可能为负(padding 区域),此时填 0 —— 这就是 zero-padding 的实现。

3.3 ReLU 与 Softmax:非线性激活与输出归一化的数值稳定性

ReLULayer极简:y = max(0, x),反向grad_x = grad_y if x > 0 else 0。但SoftmaxLayer必须处理数值溢出:exp(x_i)在x_i > 88时 overflow 为inf,导致nan。标准解法是减去max(x):

$$ \text{softmax}(x)_i = \frac{\exp(x_i - \max_j x_j)}{\sum_k \exp(x_k - \max_j x_j)} $$

// softmax_layer.cpp void SoftmaxLayer::forward(const Tensor& input, Tensor& output) { const float* in_data = input.data(); float* out_data = output.data(); const int B = input.shape()[0]; const int C = input.shape()[1]; for (int b = 0; b < B; ++b) { // find max float max_val = in_data[b * C]; for (int i = 1; i < C; ++i) { if (in_data[b * C + i] > max_val) max_val = in_data[b * C + i]; } // compute exp(x - max) float sum_exp = 0.0f; for (int i = 0; i < C; ++i) { float exp_val = std::exp(in_data[b * C + i] - max_val); out_data[b * C + i] = exp_val; sum_exp += exp_val; } // normalize for (int i = 0; i < C; ++i) { out_data[b * C + i] /= sum_exp; } } } void SoftmaxLayer::backward(const Tensor& input, const Tensor& output, const Tensor& grad_output, Tensor& grad_input) { const float* out_data = output.data(); const float* grad_out_data = grad_output.data(); float* grad_in_data = grad_input.data(); const int B = input.shape()[0]; const int C = input.shape()[1]; for (int b = 0; b < B; ++b) { // grad_input[i] = output[i] * (grad_output[i] - sum_j output[j] * grad_output[j]) float sum_out_grad = 0.0f; for (int j = 0; j < C; ++j) { sum_out_grad += out_data[b * C + j] * grad_out_data[b * C + j]; } for (int i = 0; i < C; ++i) { grad_in_data[b * C + i] = out_data[b * C + i] * (grad_out_data[b * C + i] - sum_out_grad); } } }

关键点:backward中sum_out_grad是Σ_j output[j] * grad_output[j],即output与grad_output的点积,这是 softmax 导数的核心项。若漏掉此步,梯度将全为 0 或爆炸。


4. 训练流程与精度验证:SGD 优化器、交叉验证与精度统计的 C++ 实现

4.1 Trainer 类:从数据加载、前向传播、loss 计算到参数更新的完整闭环

Trainer是 glue code,但它决定了训练是否稳定。核心逻辑:

  • 每 epoch 随机 shuffle 训练集索引(避免 batch 顺序 bias);
  • 每 batch 执行:forward → compute_loss → backward → update_params;
  • update_params使用 SGD:param -= lr * grad_param;
  • 每 epoch 结束后,在 validation set 上跑一次forward,统计 accuracy。
// trainer.cpp void Trainer::train(Network& net, const std::vector<std::vector<uint8_t>>& train_images, const std::vector<uint8_t>& train_labels, int epochs) { const int num_samples = train_images.size(); const int num_classes = 10; std::vector<int> indices(num_samples); std::iota(indices.begin(), indices.end(), 0); for (int epoch = 0; epoch < epochs; ++epoch) { // shuffle indices std::random_shuffle(indices.begin(), indices.end()); float total_loss = 0.0f; int correct = 0; for (int start = 0; start < num_samples; start += batch_size_) { const int end = std::min(start + batch_size_, num_samples); const int actual_batch = end - start; // prepare batch input: [B, 1, 28, 28] -> flatten to [B, 784] Tensor input(actual_batch, 784); Tensor labels(actual_batch, num_classes); for (int i = 0; i < actual_batch; ++i) { const auto& img = train_images[indices[start + i]]; float* in_ptr = input.data() + i * 784; for (int j = 0; j < 784; ++j) { in_ptr[j] = static_cast<float>(img[j]) / 255.0f; // normalize to [0,1] } // one-hot label const uint8_t lbl = train_labels[indices[start + i]]; float* lbl_ptr = labels.data() + i * num_classes; for (int j = 0; j < num_classes; ++j) { lbl_ptr[j] = (j == lbl) ? 1.0f : 0.0f; } } // forward Tensor output; net.forward(input, output); // cross-entropy loss: -sum(y_true * log(y_pred)) float loss = 0.0f; const float* out_ptr = output.data(); const float* lbl_ptr = labels.data(); for (int i = 0; i < actual_batch; ++i) { for (int j = 0; j < num_classes; ++j) { if (lbl_ptr[i * num_classes + j] > 0.5f) { loss -= std::log(std::max(out_ptr[i * num_classes + j], 1e-8f)); } } } total_loss += loss; // backward Tensor grad_output = output; // reuse memory for (int i = 0; i < actual_batch * num_classes; ++i) { grad_output.data()[i] = (out_ptr[i] - lbl_ptr[i]) / actual_batch; // dCE/dz = y_pred - y_true } Tensor grad_input; net.backward(input, output, grad_output, grad_input); // update params net.update_params(learning_rate_); // accuracy for (int i = 0; i < actual_batch; ++i) { int pred = 0; for (int j = 1; j < num_classes; ++j) { if (out_ptr[i * num_classes + j] > out_ptr[i * num_classes + pred]) { pred = j; } } if (pred == train_labels[indices[start + i]]) { ++correct; } } } printf("Epoch %d: loss=%.4f, acc=%.4f%%\n", epoch + 1, total_loss / (num_samples / batch_size_), 100.0f * correct / num_samples); } }

注意:grad_output初始化为y_pred - y_true,这是 cross-entropy + softmax 的复合导数简化结果(logits 层的梯度),比先算 softmax 再算 CE 更高效;std::max(..., 1e-8f)防止log(0);actual_batch处理最后一组不足batch_size_的情况。

4.2 精度验证:不只是 print,而是生成 confusion matrix 与 per-class recall

Trainer::validate()不应只返回一个 scalar accuracy。我们生成混淆矩阵(confusion matrix),便于定位哪类数字易混淆(如 4 和 9):

// trainer.cpp void Trainer::validate(Network& net, const std::vector<std::vector<uint8_t>>& test_images, const std::vector<uint8_t>& test_labels) { const int num_samples = test_images.size(); const int num_classes = 10; std::vector<std::vector<int>> conf_mat(num_classes, std::vector<int>(num_classes, 0)); for (int i = 0; i < num_samples; ++i) { // single sample forward Tensor input(1, 784); float* in_ptr = input.data(); for (int j = 0; j < 784; ++j) { in_ptr[j] = static_cast<float>(test_images[i][j]) / 255.0f; } Tensor output; net.forward(input, output); const float* out_ptr = output.data(); int pred = 0; for (int j = 1; j < num_classes; ++j) { if (out_ptr[j] > out_ptr[pred]) pred = j; } int true_label = test_labels[i]; conf_mat[true_label][pred]++; } // print per-class recall printf("\nValidation Confusion Matrix:\n"); for (int i = 0; i < num_classes; ++i) { int total_true = 0; for (int j = 0; j < num_classes; ++j) total_true += conf_mat[i][j]; float recall = (total_true > 0) ? (float)conf_mat[i][i] / total_true : 0.0f; printf("Class %d: recall=%.3f (%d/%d)\n", i, recall, conf_mat[i][i], total_true); } // overall accuracy int total_correct = 0; for (int i = 0; i < num_classes; ++i) total_correct += conf_mat[i][i]; printf("Overall accuracy: %.4f%% (%d/%d)\n", 100.0f * total_correct / num_samples, total_correct, num_samples); }

输出示例:
Class 4: recall=0.921 (921/1000)
Class 9: recall=0.897 (897/1000)
若Class 4的conf_mat[4][9]很高,说明模型常把 4 误判为 9,提示需增强 4 的旋转/扭曲数据增强。


5. 避坑指南:C++ 深度学习手写项目中 5 个血泪经验换来的硬核排查清单

5.1 现象:训练 loss 不下降,甚至 nan;原因:float 初始化范围过大;解决:用 Xavier 初始化

  • 现象:LinearLayer权重全初始化为rand() % 100 / 100.0f,loss 从第一 batch 就nan;
  • 原因:输入x ∈ [0,1],权重w ∈ [0,1],但w矩阵乘法后z = x·w方差随in_features增大而爆炸。例如in=784,z标准差 ≈sqrt(784 * 1/12) ≈ 8.1,经ReLU和Softmax后exp(z)溢出;
  • 解决:Xavier 初始化:w ~ Uniform(-sqrt(6/(in+out)), sqrt(6/(in+out)))。对Conv2d,in = in_channels * k_h * k_w,out = out_channels * k_h * k_w,故limit = sqrt(6/(in+out))。代码:
    void LinearLayer::init_weights() { const int in = in_features_; const int out = out_features_; const float limit = std::sqrt(6.0f / (in + out)); std::uniform_real_distribution<float> dist(-limit, limit); std::random_device rd; std::mt19937 gen(rd()); float* w_ptr = weight_.data(); for (int i = 0; i < in * out; ++i) { w_ptr[i] = dist(gen); } // bias init to 0 std::fill(bias_.data(), bias_.data() + out, 0.0f); }

5.2 现象:valgrind 报Invalid read of size 4;原因:im2col 中 padding 边界计算错误;解决:手写边界检查并打日志

  • 现象:Conv2d前向时valgrind报Address 0x... is 0 bytes after a block of size 784 alloc'd;
  • 原因:im2col中src_row = input_row + di,当input_row = -1,di = 0时src_row = -1,但if (src_row >= 0 && ...)检查正确;然而src_idx = b * in_channels * h * w + c * h * w中h*w=784,c * h * w当c=1时为784,src_idx + src_row * w + src_col若src_row=-1则src_idx - w + src_col可能为负,但input数组起始地址是0x...,负偏移触发越界;
  • 解决:在im2col内加断言,并打印非法src_row/src_col:
    if (src_row < 0 || src_row >= h || src_col < 0 || src_col >= w) { // padding: set to 0, but log first 10 occurrences static int pad_count = 0; if (pad_count < 10) { printf("Padding at b=%d,c=%d,i=%d,j=%d,di=%d,dj=%d -> src_row=%d,src_col=%d\n", b,c,i,j,di,dj,src_row,src_col); pad_count++; } val = 0.0f; } else { val = input[src_idx + src_row * w + src_col]; }

5.3 现象:accuracy 停在 10%,远低于随机猜测(10%);原因:MNIST label 读取后未转为整数;解决:确认uint8_t到int的无符号解释

  • 现象:Trainer::train中pred == train_labels[...]永远 false;
  • 原因:train_labels是std::vector<uint8_t>,值为0~9,但若用%d打印,可能显示为 ASCII

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进