这是一篇 CIFAR-10 Tiny CNN 教程。读完以后,你可以用一个单文件 C 程序完成小型卷积神经网络图像分类:读取 CIFAR-10 binary 数据、做 3 x 3 卷积、ReLU、2 x 2 最大池化、全连接、softmax,并运行一次小样本训练。
这篇文章基于本地项目里的 cifar10_tiny_cnn.c。它不是为了追求高准确率,而是为了把 CNN 的每一层变成能直接阅读和修改的 C 代码。
如果你刚开始学习神经网络,建议先读 神经网络基础:从感知机到多层网络。如果你已经看过 手写数字 Softmax 分类器,这篇就是从线性分类器进入卷积神经网络的下一步。
2026-09-08 更正:本文已区分历史日志与当前公开下载文件,并修正训练准确率的计分口径。重点不是只把 CNN 跑起来,而是让每个数值都能对应到具体文件和计算时点。
一、CIFAR-10 输入是什么样
CIFAR-10 的每张图片是 32 x 32 的彩色图像,有红、绿、蓝 3 个通道,所以程序里的输入形状是 3 x 32 x 32。每条样本对应一个类别标签,类别总数是 10。
源码里用几个常量把这个结构固定下来:
#define IMG_C 3
#define IMG_H 32
#define IMG_W 32
#define NCLASS 10
binary 文件每条记录为 1 字节标签加 3,072 字节像素,颜色通道分块存储,不是 RGB 交错。代码把每个像素从 0 至 255 映射到约 -1 至 1,没有使用逐通道均值标准化。
这里最值得注意的是:图像不再像手写数字项目那样拉平成一条 784 维向量后直接分类。CNN 会先保留二维空间结构,让卷积核在局部区域上滑动,提取边缘、颜色块和纹理模式。
二、Tiny CNN 模型结构和参数量
当前代码的 NF=16;源码顶部仍保留了 8 个卷积核的旧注释,但真正决定计算和权重布局的是宏定义。实际使用 16 个 3 x 3 卷积核。因为是 valid convolution,32 x 32 输入经过 3 x 3 卷积后会变成 30 x 30;再经过 2 x 2 最大池化后,空间大小变成 15 x 15。
#define NF 16
#define K 3
#define CONV_H 30
#define CONV_W 30
#define POOL_H 15
#define POOL_W 15
#define FEAT (NF * POOL_H * POOL_W)
所以完整路径是:
- 输入:3 x 32 x 32
- 卷积:16 个 3 x 3 卷积核,输出 16 x 30 x 30
- ReLU:把负数激活压到 0
- 最大池化:2 x 2,输出 16 x 15 x 15
- 全连接:把池化结果映射到 10 个类别 logit
- softmax:把 logit 转成类别概率
这份 tiny CNN 的参数量很小:卷积层有 16 * 3 * 3 * 3 + 16 = 448 个参数,全连接层有 10 * 3600 + 10 = 36010 个参数,总计 36458 个参数。这个规模适合教学和调试,不适合和现代 CIFAR-10 模型比较分数。
三、卷积层在 C 里怎么写
卷积层的核心就是多层循环。每个卷积核都会扫过输出网格中的每个位置,再对 3 个颜色通道和 3 x 3 局部窗口做乘加。
float sum = net->conv_b[f];
for (int c = 0; c < IMG_C; c++) {
for (int r = 0; r < K; r++) {
for (int t = 0; t < K; t++) {
sum += net->conv_w[f][c][r][t] *
get_pixel(s->x, c, i + r, j + t);
}
}
}
conv[f][i][j] = sum > 0.0f ? sum : 0.0f;
这里没有翻转卷积核,实际执行的是神经网络中通常称作卷积的互相关。最后一行同时完成了 ReLU。这样写很朴素,但能清楚看到卷积核、通道、局部窗口和输出位置之间的关系。
四、最大池化保留局部最强响应
池化层把每个 2 x 2 小窗口压成一个值,只保留最大激活。代码还记录了最大值的位置,因为反向传播时梯度只会传回当时胜出的那个位置。
float best = conv[f][base_i][base_j];
int best_idx = 0;
for (int di = 0; di < 2; di++) {
for (int dj = 0; dj < 2; dj++) {
float v = conv[f][base_i + di][base_j + dj];
if (v > best) {
best = v;
best_idx = di * 2 + dj;
}
}
}
相等最大值按严格大于比较保留扫描到的第一个位置;反向传播也沿这个位置回传。池化降低空间尺寸,但不保证任意输入平移后输出都保持不变。
五、全连接层和 softmax
池化后的特征会被展开成一维数组,再进入全连接层。每个类别有一组权重,输出 10 个 logit。
for (int k = 0; k < NCLASS; k++) {
float z = net->fc_b[k];
for (int p = 0; p < FEAT; p++) {
z += net->fc_w[k][p] * feat[p];
}
logits[k] = z;
}
softmax 会把这 10 个原始分数归一化成概率。训练时用交叉熵损失,预测时选择概率最大的类别。
六、反向传播更新了哪些参数
这个 C 程序没有把反向传播藏进框架里。它显式做了四件事:
- 从 softmax 概率向量减去 one-hot 标签向量:真实类别对应的概率减 1,其他类别保持原概率
- 先用更新前的全连接权重累积 dfeat,再更新全连接权重与偏置
- 把梯度通过最大池化和 ReLU 传回卷积输出
- 用输入局部窗口更新每个卷积核权重
net->fc_w[k][p] -= LR * dz[k] * feat[p];
net->fc_b[k] -= LR * dz[k];
...
net->conv_w[f][c][r][t] -= LR * dw;
net->conv_b[f] -= LR * db;
这也是这份代码最有学习价值的部分:你能看到 CNN 训练不是“神秘自动完成”,而是每一层都把自己的梯度算清楚,再沿负梯度更新参数;给定学习率并不保证每一步的损失都下降。
七、如何本地编译运行
站点只发布源码、权重样例、预测样例和说明文件,不上传完整 CIFAR-10 数据包。你需要从 CIFAR-10 官方页面下载 binary version(公布的 MD5 为 c32a1d4ab5d03f1284b67883e8d87530),解压出 cifar-10-batches-bin 后再运行。
gcc -O2 -std=c11 cifar10_tiny_cnn.c -lm -o cifar10_tiny_cnn
./cifar10_tiny_cnn ./cifar-10-batches-bin 1 2000 1000
这四个命令行参数分别表示:
- 数据目录
- 训练轮数
- 训练样本上限
- 测试样本上限
用小样本先跑通更适合学习和调试。确认流程没问题后,再逐步提高训练样本数量和轮数。
八、先确认日志与下载文件是否属于同一次实验
旧版文章展示过 2,000 条训练样本、1,000 条测试样本、1 个 epoch 的日志,其中 train_acc=0.835、test_acc=0.284。但截至 2026-09-08,网站实际公开的预测 CSV 有 5,000 行,其中 2,351 行预测正确,重算准确率为 47.02%。不能把这个文件当成那段 1,000 张图片日志的输出。
| 证据 | 能够确认的内容 |
|---|---|
| 历史日志文本 | 旧文章记录的配置是 2,000/1,000、1 个 epoch。没有找到与这段文本绑定的完整运行清单,因此不声称本轮复现了 28.4%。 |
| 当前预测 CSV | ID 连续为 0 至 4,999;标签与预测都在 0 至 9;2,351/5,000=47.02%。这是对当前公开文件的确定性重算。 |
| 当前权重文件 | 145,832 字节,可按本次匹配的 ABI 解释为 36,458 个有限 float32 值。文件长度和参数量一致,但它没有记录训练样本数、epoch 或模型选择过程。 |
| 来源锁定 | 本轮从普通公开网址取得源码、权重和 CSV,三者均与实验包的 legacy 副本逐字节一致。完整 SHA-256 写入 audit.json。 |
查看旧文章保存的历史日志文本
Loaded train=2000, test=1000
epoch 1 step 1000/2000 loss=2.0507 train_acc_recent_est=0.809
epoch 1 step 2000/2000 loss=1.9440 train_acc_recent_est=0.835
epoch 1 done: avg_loss=1.9440 train_acc=0.835 test_acc=0.284
Model saved to model_weights.bin
Predictions saved to test_predictions.csv
保留这段文本是为了说明差异,不是把未经重新绑定的历史输出当作本轮结果。下载权重与一份预测文件能否互相复现,也不能反推出它的完整训练历史。
九、83.5% 为什么不是最终模型的训练准确率
关键在原始训练循环的执行顺序,而不是准确率的名称:
sum_loss += train_one(&net, s);
int pred = predict(&net, s);
if (pred == s->label) correct++;
train_one 先对当前样本做前向计算、记录损失,再更新参数;后面的 predict 已经使用更新后的参数,并且仍然预测同一个样本。因此 loss 和 accuracy 的时点不同,accuracy 中的每一项也来自不同的模型状态。train_acc_recent_est 还不是最近窗口准确率:分母使用从 epoch 开始到当前步的累计样本数。
| 计分口径 | 应怎样读取 |
|---|---|
| 更新前逐样本 | 先预测再更新,描述在线学习过程中不同模型状态的表现,仍不等于最终模型的训练集准确率。 |
| 更新后同样本 | 原程序的 train_acc;当前标签刚参与更新,分数可能显得乐观。不能拿它与最终模型的 test_acc 直接推断泛化差距。 |
| 最终固定模型 | 全部训练更新结束后冻结同一份参数,分别遍历训练集和测试集。这两个分数的模型状态才一致,但数据选择和调参历史仍需另外交代。 |
本轮用一个完全可控的反例验证了这个区别:网络参数全为零,输入全为零,标签为 7。更新前所有 logit 相同,原程序按首个最大值预测类别 0;更新后类别 7 的偏置约为 0.009,其他类别约为 -0.001,于是立刻预测正确。初始损失是 log(10)=2.302585。一条样本的更新后准确率达到 100%,并不表示模型已经学会图像分类。
这只是人为构造的计分时序检查,不是 CIFAR-10 训练成绩。它说明需要修正的是实验解释,而不是把 83.5% 换一个更好看的名称。
十、平均准确率藏住了哪些错误

例如,automobile 类为 309/505=61.19%,cat 类为 138/497=27.77%。cat 的 497 条记录中有 107 条被预测成 dog,值得先定位这些 CSV 行再查看输入。这个统计本身不能证明错误来自背景、纹理相似或某个特定卷积核。
另一个容易混淆的例子是 deer:真实 deer 有 507 条,正确 254 条,召回率为 50.10%;模型总共预测了 722 次 deer,因此这个预测类别的精确率只有 35.18%。召回率和精确率分母不同。不能用一个总准确率替代全部类别检查。
逐类统计 CSV保存支持数、预测数、正确数、召回率和精确率;混淆矩阵 CSV按真实标签分行、预测标签分列。所有值都能从原预测文件重新计数,不依赖模型训练代码。
十一、loss 的数值表达也需要检查
原程序先减去最大 logit 再计算 softmax,这是稳定化操作;但它记录的损失是 -logf(prob[y] + 1e-8f)。当真实类别的概率极小时,这个加法会把显示的损失压到约 18.42,而不是继续反映更差的预测。
本轮另构造了一个边界测试:网络其他参数为零,只把类别 0 的偏置设为 100,真实标签设为 1。原程序返回的损失约为 18.420681,而从 logit 直接算出的交叉熵为 100。这个测试用于暴露日志的截断效应,不表示公开模型已经在真实样本上出现同样情况。
m = max(logits)
cross_entropy = m + log(sum(exp(logits - m))) - logits[y]
如果后续修订训练程序,应该让日志损失与所使用的梯度目标一致,并增加有限值检查。本轮为保留原始文件的可核对性,没有悄悄替换旧源码;新增实验包在独立测试程序中计算这个稳定参考值。
十二、独立实验包与重放边界
下载本轮复核包。它包含原始三文件的哈希锁定副本、C 测试程序、Python 标准库检查、逐类数据及绘图脚本,不含完整 CIFAR-10 数据集,也不含预编译可执行文件。
页面顶部的运行说明仍对应原 cifar10-cnn 项目。下面的命令应在新增 cifar-audit-lab 包内运行,不要把两个目录混用:
python3 audit_cifar.py --output reproduced
# Optional: official binary archive with the published checksum
python3 audit_cifar.py --output reproduced-full \
--archive /path/to/cifar-10-binary.tar.gz
第一条命令只需要 Python 3.11+ 和 C11 编译器,验证公开 CSV、权重载荷和两个合成反例,并拒绝 8 类非法 CSV 与 5 类非法样本数。成功标记是 CIFAR_ARTIFACT_AUDIT_OK;这不表示已经做了官方数据重放。
第二条命令先核对完整官方压缩包的 MD5,再提取所需批次。它将公开 CSV 的标签与官方测试文件前 5,000 条记录核对,并用公开权重重新生成预测。只有全部行一致才输出 CIFAR_DATA_REPLAY_OK;否则会明确报告差异。实际结果与环境记录在 audit.json,不能用文件大小吻合来代替这个检查。
完整模式还会另做一次 128 条训练、128 条测试、1 个 epoch 的小实验,记录更新前、更新后和训练结束后固定模型的不同分数,并在同一机器上重复。它不会更新公开权重,也不尝试复原旧的 2,000/1,000 日志。srand(1) 不保证不同平台的 C 标准库产生相同随机序列。
2026-09-08 实际重放结果
以下结果来自 arm64 macOS、Apple clang 21、Python 3.13.9、NumPy 2.3.5。编译时禁用 fast-math 和浮点乘加收缩;完整环境、文件哈希及未四舍五入的结果保存在实验包中。
| 检查 | 本次实际结果与适用范围 |
|---|---|
| 公开模型重放 | 官方测试批次前 5,000 条标签全部吻合;公开权重重算的 5,000 条预测全部与旧 CSV 一致,准确率仍为 2,351/5,000=47.02%。从 logit 计算的平均交叉熵为 2.035592。这是固定模型重放,不是新训练。 |
| 独立前向计算 | 前 16 张图像的 160 个 logit 用 NumPy float64 另算,和 C float32 的最大绝对差为 9.07e-06,16 个预测类别全部一致。声明容差为 atol=1e-3、rtol=2e-4;这里只核对前向传播,不核对全部训练梯度。 |
| 错误布局对照 | 故意把分块通道当成 RGB 交错数据,最大 logit 误差变为 32.393047;故意错误重排池化窗口,误差变为 13.720984。两个对照都超过预设的 0.01 检出阈值。 |
| 新训练的计分时点 | 只用首批训练数据前 128 条训练一个 epoch:在线更新前为 19/128,更新后同样本为 92/128;训练结束后固定模型的训练得分为 88/128,前 128 条测试记录得分为 17/128。同机重复两次的计数和逐步轨迹完全一致。这些数值不是公开权重或历史日志的训练成绩。 |
独立前向检查需要额外安装 NumPy,可在解压后的实验包目录中运行:
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements-numpy.txt
.venv/bin/python audit_cifar.py --output reproduced-numpy \
--archive /path/to/cifar-10-binary.tar.gz --numpy-reference
逐步计分 CSV保留样本 ID、更新前预测、更新后预测和更新前损失;固定模型计分 JSON记录最后两次遍历的分子与分母。重放一致说明这些公开文件彼此对应,不代表原模型未经测试集调参,也不补全缺失的训练历史。
原权重是直接写出的 C 结构体,没有文件头、架构版本和字节序声明。当前复核匹配 little-endian、4 字节 float 的布局;这不是通用可移植模型格式。原始数据读取器也没有完善的异常文件校验,因此不要把它作为接收任意上传文件的服务。
十三、改进模型之前先改进实验记录
- 保存源码、数据文件和最终权重的哈希,同时记录编译器、编译参数、样本范围、随机种子与超参数。只保存一个 .bin 不足以复现训练过程。
- 冻结同一份最终参数,分别评价训练集与测试集;把在线更新前后分数另起名字,避免与固定模型分数混淆。
- 在训练数据内划分验证集,再比较学习率、训练轮数或数据增强。每次只改一个主要变量,不把已经反复查看的公开测试输出当成未使用过的验证依据。
- 报告逐类统计和失败样本,并说明覆盖的是哪部分数据。当前 5,000 条记录不能自动代表完整 10,000 张测试集。
- 增加 mini-batch 或更深网络之前,先做小规模梯度检查和输入校验。本文的哈希、统计与反例测试不等于已证明全部反向传播正确,也没有提供硬件速度比较。
提高数据量、增加层数或改用框架都是待验证的实验选择,不是“必然提高准确率”的承诺。当前公开权重的训练配方与选择历史没有完整记录,因此不能只凭 47.02% 给失败原因下定论。
十四、配套资源和继续阅读
原始C 源码、模型权重、预测文件和历史说明 PDF仍保留。本文讨论的实际参数量以源码宏、编译后的布局和权重载荷为准,不只看简介或文件名。
下一步可以对照卷积与感受野的可复现实验检查局部计算,再结合两层 MLP 反向传播学习梯度验证。这个项目的价值在于把输入、更新时点、结果文件和解释对应起来,而不是给一个教学实现包装成高性能模型。