CIFAR-10 Tiny CNN 教程:用 C 语言实现小型卷积神经网络图像分类
CIFAR-10 Tiny CNN 教程:用 C 语言实现小型卷积神经网络图像分类
站内搜索
直接问 AI

CIFAR-10 Tiny CNN 教程:用 C 语言实现小型卷积神经网络图像分类

这是一篇 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% 换一个更好看的名称。

十、平均准确率藏住了哪些错误

当前五千行预测文件的逐类召回率,标注各类正确数和真实样本数
图表直接来自当前公开预测 CSV。条形表示正确数除以该真实类别的样本数;旁边保留分子和分母。它不是一次新训练或完整一万张测试集的成绩。

查看原尺寸图表。

例如,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 反向传播学习梯度验证。这个项目的价值在于把输入、更新时点、结果文件和解释对应起来,而不是给一个教学实现包装成高性能模型。

发表回复

向下探索