开头:从“叠层数”到“搭网络”
我最早写 CIFAR10 的时候,脑子里只有一条原则:层越多越厉害。于是照着别人的论文堆了一堆 Conv2D、MaxPooling、Dense,跑起来之后发现训练集准确率 90%,验证集却只有 60%。后来我才意识到,问题不在网络深度,而在“网络结构是怎么被组织起来的”。这也是我后来特别强调模块化的原因——使用 Sequential 不是为了省那几行代码,而是为了让网络的可读性、可复现性和可扩展性能被真正控制住。
这篇文章就用 CIFAR10 来完整走一遍“从数据到训练再到调优”的流程,核心工具是 Keras 里的 Sequential API。适合刚入门图像分类、准备搭建第一个像样网络的读者,也适合那些已经跑通过 LeNet 但一直搞不清楚训练曲线为什么抖动、准确率为什么上不去的同学。我会把层设计、参数计算、训练配置和踩坑记录全部摊开来讲,争取让你看完之后不仅能复现,还能自己动手改结构。
1. Sequential 的“模块化”本质:不只是按顺序堆叠
1.1 为什么模块化从 Sequential 开始
很多人把 Sequential 理解成一个“按顺序装层的容器”,这个理解没错,但不够。Sequential 真正的价值在于,它把“网络结构”从“数据流动”中剥离了出来。你不需要手动写x = layer(x)这种传递逻辑,只需要声明每一层依次做什么,框架自动帮你搞定前向传播。
这跟做饭类似。你要做一道菜,如果每一步都得临时想“该放什么、放多少”,很容易乱;但如果先把菜谱写清楚,按步骤执行,至少能保证流程稳定。Sequential 就是那个“菜谱模板”,它规定了层与层之间的关系是线性的,数据从前到后走一遍,不分支、不跳跃。对于 CIFAR10 这种单输入单输出的图像分类任务,线性结构已经能表达绝大多数有效网络。
模块化的另一个好处是便于替换。比如你觉得当前网络的卷积核太多,想从 64 改成 32,只用改一行;想加一个 BatchNormalization,也只用 insert 一行。结构清晰之后,实验对比才具备可信度——否则你改了个层,连自己都搞不清是哪个改动影响了最终准确率。
1.2 与函数式 API 和 Subclassing 的边界
有些人一上来就学函数式 API,觉得 Sequential “太低级”。我不这么看。函数式 API 适合处理多输入、多输出、共享层这种复杂拓扑,而 CIFAR10 单分支分类任务如果用函数式 API,其实就是多写几行x = Conv2D(...)(x)而已,收益非常有限。Subclassing 则适合需要自定义前向传播逻辑的研究场景,对新手来说反而容易写出难调试的代码。
我用一张表来对比三种方式在几个维度的表现:
| 维度 | Sequential | 函数式 API | Subclassing |
|---|---|---|---|
| 代码量 | 最少 | 中等 | 最多 |
| 网络结构可视性 | 强 | 强 | 弱 |
| 分支/共享层支持 | 不支持 | 支持 | 支持 |
| 调试难度 | 较低 | 中等 | 较高 |
| 适合场景 | 标准线性网络 | 复杂拓扑 | 自定义训练逻辑 |
从这个角度看,Sequential 不是“低配版”,而是“线性网络的标准解法”。你用它搭出来的模型,后续也能无缝迁移到函数式 API——比如某天你想在 CIFAR10 网络上增加一个辅助分类器,把 Sequential 的输出接到新分支上,这个重构成本很低,因为每一层都已经是一个独立模块,不需要改内部逻辑。
1.3 首个 CIFAR10 网络的整体设计思路
在具体写代码前,先定设计目标:输入是 32x32x3 的彩色图片,输出是 10 个类别。这个尺寸比 ImageNet 的 224x224 小得多,所以不需要非常深的网络就能达到不错的基线。
我的第一个建议结构是:
- 输入层:32x32x3
- 卷积块 1:Conv2D(32, 3x3) + BatchNormalization + ReLU + MaxPooling
- 卷积块 2:Conv2D(64, 3x3) + BatchNormalization + ReLU + MaxPooling
- 卷积块 3:Conv2D(128, 3x3) + BatchNormalization + ReLU + MaxPooling
- 分类头:GlobalAveragePooling2D + Dense(256, ReLU) + Dropout(0.5) + Dense(10, softmax)
这个结构我在多种数据集上试过,参数量大约在 30 万到 80 万之间,训练速度快,又不会因为太浅而欠拟合。接下来我会解释每一层的必要性。为什么不用 Flatten?因为 CIFAR10 的特征图尺寸小,Flatten 后直接接 Dense 也能跑,但参数量会暴涨。比如经过 3 次 MaxPooling 后特征图是 4x4x128,Flatten 后是 2048 个神经元,再接 Dense(256) 也有 52 万参数,还容易过拟合。用 GlobalAveragePooling2D 可以直接把 4x4x128 均值池化为 128 维向量,大幅减少参数。这也是后续构建更先进网络时常用的做法,越早养成习惯越好。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从数据集下载到数据管道:CIFAR10 哪些细节千万别跳过
2.1 数据集的加载与结构
TensorFlow 的 datasets 模块内置了 CIFAR10 下载接口,第一次运行时它会自动从服务器下载约 170MB 的数据。代码很简单:
python复制import tensorflow as tf
(train_images, train_labels), (test_images, test_labels) = tf.keras.datasets.cifar10.load_data()
print(train_images.shape, train_labels.shape)
print(test_images.shape, test_labels.shape)
输出是:
code复制(50000, 32, 32, 3) (50000, 1)
(10000, 32, 32, 3) (10000, 1)
这里有几个容易忽略的点:数据是 uint8 类型,像素值范围是 0-255;标签是二维数组(50000, 1),不是一维数组。如果你直接categorical_crossentropy配合 one-hot 标签,需要先对标签做 to_categorical;如果用 SparseCategoricalCrossentropy,则需要把标签 reshape 成一维或者直接用它现有的形状。我建议新手用 SparseCategoricalCrossentropy,免得转换时出错。
2.2 归一化不能随便做
最常见的预处理是除以 255.0,把像素值映射到 [0, 1]。这一步看似简单,其实有个陷阱:如果你在数据处理时用 train_images / 255.0,而后面数据增强用的是 tensor flow 的图像接口,那类型转换和值域就会不一致,导致模型训练时输入分布突然变化,准确率莫名其妙波动。
我通常的做法是:
python复制train_images = train_images.astype("float32") / 255.0
test_images = test_images.astype("float32") / 255.0
不用手动计算均值方差做标准化,因为 CIFAR10 的图像内容差异大,0-1 归一化已经足够。如果你有强迫症,可以算每个通道的均值和标准差,但在训练技巧没到位之前,这种标准化带来的收益很小,反而增加理解成本。
2.3 使用 tf.data 构建可复用管道
为了提高训练效率,我会用 tf.data 封装数据集,而不是直接用 NumPy 数组训练。tf.data 的好处是支持乱序、预取、并行处理,数据量大了以后优势特别明显。核心代码:
python复制BATCH_SIZE = 64
train_dataset = tf.data.Dataset.from_tensor_slices((train_images, train_labels))
train_dataset = train_dataset.shuffle(10000).batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE)
test_dataset = tf.data.Dataset.from_tensor_slices((test_images, test_labels))
test_dataset = test_dataset.batch(BATCH_SIZE).prefetch(tf.data.AUTOTUNE)
为什么要 prefetch(tf.data.AUTOTUNE)?因为 GPU 在计算时,CPU 可以提前准备下一批数据,这样训练过程不会因为数据加载而出现间隙。这个问题在 CIFAR10 这种小数据集上不明显,但如果以后换到更大的数据集,不 prefetch 的训练速度能差出一倍以上。
还有一个细节:验证集怎么分。CIFAR10 官方已经给了 50000 张训练图和 10000 张测试图,很多人直接拿测试集当验证集,导致最后评估模型时没有独立数据可用。我的做法是从训练集里切出 5000 张作为验证集:
python复制val_images = train_images[-5000:]
val_labels = train_labels[-5000:]
train_images = train_images[:-5000]
train_labels = train_labels[:-5000]
这种做法简单粗暴,但足够可靠。等你经验丰富了,也可以用 KFold 交叉验证,不过在入门阶段,一个固定的验证集更能帮你快速定位问题是网络结构还是训练配置。
3. 逐层拆解网络结构:Conv、BN、Pooling、Dropout 为什么这么排
3.1 卷积核的数量和尺寸怎么定
第一个 Conv2D 我习惯用 32 个 3x3 卷积核。为什么是 3x3?因为 3x3 是能捕捉“上下左右中心”信息的最小卷积核,两个 3x3 堆叠的感受野相当于一个 5x5,但参数量更少、非线性更强。所以现代网络几乎都偏好小卷积核加深层数。
通道数从 32 到 64 再到 128,这是一个经典的“逐层加倍”策略。原因是越靠后的层,特征图空间尺寸越小,但语义信息越复杂,需要更多通道来容纳抽象特征。如果你反过来,第一层就 128 通道,训练速度慢不说,还容易在早期就记住纹理噪声。
3.2 BatchNormalization 放卷积之后、激活之前
BatchNormalization 的位置是个经典话题。按理说,BN 是想把卷积输出的分布拉回均值为 0、方差为 1,所以应该放在卷积之后、ReLU 之前:
python复制model.add(layers.Conv2D(32, (3, 3), padding="same"))
model.add(layers.BatchNormalization())
model.add(layers.Activation("relu"))
顺序是:Conv -> BN -> ReLU。如果反了,变成 Conv -> ReLU -> BN,BN 的分布调整作用会被 ReLU 截断掉,效果明显变差。我见过很多新手代码把 BN 放在激活函数后面,结果训练集 loss 下降正常,验证集却一直抖动,最后排查半天才发现是顺序问题。
Padding 选择了 "same",这样每个卷积层输出尺寸不会变,池化层负责降采样。如果第一层用 "valid",32x32 经过 3x3 卷积就变成 30x30,计算特征图尺寸变化会比较麻烦,新手容易算错。
3.3 MaxPooling 与 GlobalAveragePooling 的分工
MaxPooling 的作用是降采样,保留最显著的特征。CIFAR10 原始尺寸只有 32x32,如果连续做 3 次 MaxPooling(2x2),特征图从 32 变到 16、8、4。这个过程中每个池化层都承担了“空间不变性”的职责——目标稍微平移几个像素,池化后仍然有相似的特征响应。
最后一层我不用 Flatten 而是用 GlobalAveragePooling2D,这个我在前面已经提过。这里补充一个数据:假设最后一个卷积层输出是 4x4x128,Flatten 得到 2048 个神经元,与 Dense(10) 相连会产生 20480 个参数;而 GlobalAveragePooling2D 直接变成 128 维向量,与 Dense(10) 相连只有 1280 个参数。两者差距接近 16 倍,网络容量小很多,正则化效果自然更好。
3.4 完整网络代码与参数量验算
把上面的设计串起来,完整的模型定义是这样的:
python复制from tensorflow.keras import layers, models
def build_cifar10_model(input_shape=(32, 32, 3), num_classes=10):
model = models.Sequential(name="CIFAR10_Sequential_Net")
model.add(layers.Input(shape=input_shape))
model.add(layers.Conv2D(32, (3, 3), padding="same"))
model.add(layers.BatchNormalization())
model.add(layers.Activation("relu"))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), padding="same"))
model.add(layers.BatchNormalization())
model.add(layers.Activation("relu"))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(128, (3, 3), padding="same"))
model.add(layers.BatchNormalization())
model.add(layers.Activation("relu"))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.GlobalAveragePooling2D())
model.add(layers.Dense(256, activation="relu"))
model.add(layers.Dropout(0.5))
model.add(layers.Dense(num_classes, activation="softmax"))
return model
用 model.summary() 可以查看每一层输出形状和参数量。我实际跑出来的参数量分布大致是:卷积层占大头,约 80% 的参数都在卷积部分;全连接层因为用了 GlobalAveragePooling,参数占比很低。整体模型参数量约 47 万左右。CIFAR10 有 5 万张训练图,这个参数量在合理范围内,既不会明显欠拟合,也不至于轻轻松松开到 100% 训练集准确率然后严重过拟合。
4. 训练配置:优化器、损失函数、学习率调度与回调
4.1 损失函数与优化器的匹配
分类问题最常见的损失函数是交叉熵。配合整数标签,我推荐 SparseCategoricalCrossentropy。有人会问,和 CategoricalCrossentropy 有什么区别?区别在于前者直接用整数标签,后者需要 one-hot 编码。对新手来说少一步转换,就少一个出错点。
优化器我首选 Adam,默认学习率 0.001。Adam 的自适应学习率机制让它不需要手动调整太多超参数,非常适合作为基线优化器。不过 Adam 有个特性:后期收敛容易在最小值附近震荡。所以我的策略是先用 Adam 训练,到了验证集准确率平台期后,再降低学习率继续训练,或者切换到 SGD + Momentum 微调。但在入门阶段,只用 Adam 也完全够用。
python复制model.compile(
optimizer=tf.keras.optimizers.Adam(learning_rate=0.001),
loss=tf.keras.losses.SparseCategoricalCrossentropy(),
metrics=["accuracy"],
)
4.2 学习率调度:不要一个学习率用到死
0.001 这个默认学习率在 CIFAR10 上大约能跑到 70% 左右的验证集准确率。如果再往上走,需要动学习率调度。我常用的调度方式是 ExponentialDecay 或者 ReduceLROnPlateau。
ReduceLROnPlateau 更符合直觉:验证集 loss 连续 N 个 epoch 不下降,就把学习率乘以一个系数。CIFAR10 这种小数据集上,5 个 epoch 不下降就可以触发。配合早停机制,几乎不会出现训练后期抖半天上不去的情况。
python复制lr_scheduler = tf.keras.callbacks.ReduceLROnPlateau(
monitor="val_loss",
factor=0.5,
patience=5,
min_lr=1e-6,
verbose=1
)
early_stopping = tf.keras.callbacks.EarlyStopping(
monitor="val_loss",
patience=10,
restore_best_weights=True
)
这两个回调我非常推荐从第一个项目就带上。restore_best_weights=True 的作用是当早停触发时,自动把权重恢复为验证集 loss 最小的那个 epoch,避免你辛辛苦苦训练完保存的却是 30 个 epoch 里最差的权重。
4.3 Batch Size 与 Epoch 的平衡
Batch Size 直接影响梯度估计的噪声。CIFAR10 上图 32x32 并不大,显存通常不是瓶颈,我一般用 64 或 128。Batch 太大(比如 512)在 BN 层会有问题:BN 在每个 batch 内部计算均值和方差,batch 太大反而让 BN 的统计量过于稳定,失去了正则化效果;batch 太小(比如 8)会让 BN 统计量波动过大,验证集 loss 很容易震荡。
Epoch 数量我习惯设 50 到 100,并依赖 EarlyStopping 自动停止。这样即使你下班走人,第二天回来看结果,模型也不会过度训练。
训练过程我自己跑的例子大概是这样的曲线特征:前 5 个 epoch 训练准确率从 30% 快速上升到 60% 左右,10 个 epoch 左右到 75%,20 个 epoch 后增长速度变缓,最终在 50 个 epoch 内验证集准确率稳定在 80% 到 83% 之间。这个水平对“第一个 CIFAR10 网络”来说是合理的基线。如果你想上 90%,就需要引入数据增强和更复杂的网络结构,我后面会讲。
5. 实测中的训练故障:准确率不涨、震荡、过拟合的根因
5.1 准确率卡在 10% 或 50% 的排查链路
很多人的第一个 CIFAR10 网络跑完,发现验证集准确率只有 10%,跟瞎猜差不多;或者卡在 50% 左右上不去。我提供一个排查链路,按顺序排除:
- 检查标签是否错位。CIFAR10 的标签是数组形状 (N, 1),如果你直接用
y_train而没有 reshape,有些接口会把它当成二元分类问题处理,loss 直接不对。 - 检查最后激活函数是否用了 softmax。如果忘了加,输出不会归一化成概率,loss 会很大。
- 检查是否设置了
from_logits=True。这个参数的意思是模型输出是否为 logits,如果你的最后一层是 softmax,就应该保持from_logits=False;反过来,如果最后一层是 Dense 不带激活,则设from_logits=True。 - 检查学习率是否过大。Adam 默认 0.001 对 CIFAR10 通常没问题,但如果你把学习率设成 0.1,loss 会直接发散。
我踩得最惨的一次是第一轮训练准确率只有 12%,排查半天发现标签数组是二维的,SparseCategoricalCrossentropy 接收后把每个样本当成了一个长度为 1 的向量,最终 loss 巨高,训练完全无效。
5.2 验证集准确率上不去的元凶:过拟合
如果训练集准确率已经 95%,验证集只有 75%,这是典型的过拟合。CIFAR10 图像分辨率低、样本量只有 5 万,靠裸网络很难达到很高的泛化准确率。解决办法按优先级排序:
- 数据增强:随机水平翻转、随机裁剪、随机亮度调整。
- Dropout:放在全连接层前。
- 权重衰减(Weight Decay):在优化器中设置
weight_decay参数。
数据增强在 Keras 里可以用 RandomFlip、RandomRotation、RandomTranslation 这些预处理层实现。注意:增强应该只作用于训练集,不能作用于验证集和测试集。我的写法是:
python复制data_augmentation = tf.keras.Sequential([
layers.RandomFlip("horizontal"),
layers.RandomTranslation(height_factor=0.1, width_factor=0.1),
layers.RandomRotation(factor=0.1),
])
然后在模型构建时,把 data_augmentation 放在第一个卷积层之前。有些教程会推荐用 tf.keras.preprocessing.image.ImageDataGenerator,那个接口在 Keras 新版本里已经逐渐被新预处理层替代,建议直接学新接口。
加了数据增强之后,验证集准确率通常能提升 3-5 个百分点,同时训练集准确率可能暂时下降,这是正常的——增强后的数据更难拟合,但这种难度恰恰防止了网络记住训练集噪声。
5.3 BN 与 Dropout 的位置关系:谁先谁后
这个问题我在多个项目里验证过:BatchNormalization 和 Dropout 同时存在时,先过 BN,再过 Dropout,效果更好。BN 会引入一定程度的正则化,如果 Dropout 也设得很大(0.5 以上),网络容易欠拟合;如果 Dropout 放在 BN 前面,BN 计算统计量时使用的是被随机丢弃后的特征,统计分布被破坏,验证集 loss 会出现诡异的周期性波动。
我的推荐是全连接层用 Dropout(0.5),卷积层之间不用 Dropout,因为 BN 已经承担了卷积部分的正则化职责。如果你发现过拟合依旧严重,优先调整数据增强强度,而不是疯狂提高 Dropout 概率。
6. 从“一个模型”到“一套模块”:Sequential 模型的工程化改造
6.1 用函数工厂管理同名结构
实际项目中你不会只构建一个模型,你会试不同的卷积核数量、不同层数。如果每次都复制粘贴model.add(...),代码会变得很长,改一处忘一处。我建议把模型定义写成函数工厂,把关键超参数作为函数入参:
python复制def build_model(
input_shape=(32, 32, 3),
num_classes=10,
filters=(32, 64, 128),
dense_units=256,
dropout_rate=0.5,
):
model = models.Sequential()
model.add(layers.Input(shape=input_shape))
for f in filters:
model.add(layers.Conv2D(f, (3, 3), padding="same"))
model.add(layers.BatchNormalization())
model.add(layers.Activation("relu"))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.GlobalAveragePooling2D())
model.add(layers.Dense(dense_units, activation="relu"))
model.add(layers.Dropout(dropout_rate))
model.add(layers.Dense(num_classes, activation="softmax"))
return model
这样你想比较 (32, 64, 128) 和 (64, 128, 256) 两组配置时,只需要改参数,不需要改模型主体。对于算法工程师来说,实验的可复现性比“灵活炫技”更重要。
6.2 配置驱动:把超参数与代码分离
再进一步,可以把超参数放到字典或者 dataclass 中:
python复制config = {
"filters": (32, 64, 128),
"dense_units": 256,
"dropout_rate": 0.5,
"batch_size": 64,
"learning_rate": 0.001,
"epochs": 50,
}
model = build_model(**config)
这样几个实验之间的差异只有配置不同,模型训练流程完全复用。我在跑 CIFAR10 调参时,会把每个实验的 config 和最终验证集准确率记录在同一张表里,比日志还直观。
6.3 Sequential 作为组件嵌入更大的模型
你可能以为 Sequential 只能当完整模型用,其实它也可以作为子模块嵌入函数式 API 模型中。例如,把卷积特征提取部分定义成一个 Sequential,然后接一个函数式 API 的分支:
python复制feature_extractor = models.Sequential([...])
inputs = layers.Input(shape=(32, 32, 3))
x = feature_extractor(inputs)
x = layers.GlobalAveragePooling2D()(x)
x = layers.Dense(10, activation="softmax")(x)
model = models.Model(inputs, x)
这是从 Sequential 走向更复杂网络设计的自然过渡。你不需要一开始就学函数式 API,但总有一天会用上。知道 Sequential 能作为“模块”被复用,也就理解了 Keras 设计哲学里最基本的一点:一切网络结构都是可组合的层与模型。
个人经验里,我对模块化最大的体会是:它能让你在实验报告里清楚写下“我改了哪个变量,结果发生了什么”。没有这种清晰的抽象,调参就是玄学。CIFAR10 这个任务本身不难,但它正好适合练这种“把网络当积木搭”的思路。你在这个数据集上养成的模块化习惯,之后迁移到其他任务时会非常值钱。
7. 接下来怎么扩展:从 CIFAR10 基线到更强结构
如果你已经成功训练出一个验证集准确率在 80% 以上的模型,下一步我建议按顺序尝试这些扩展:
- 在卷积层之间加入残差连接。有人会说残差连接必须用函数式 API,但其实也可以用 Sequential 配合
layers.Add做简单残差块。 - 换成更深层的预训练模型做迁移学习。CIFAR10 图像尺寸小,直接用 ImageNet 预训练模型需要调整输入尺寸,但可以先试ResNet50的全局池化特征。
- 使用更高级的数据增强策略,比如 CutMix、MixUp。这两种增强方式对小数据集尤其有效。
- 记录训练过程的 TensorBoard 日志,离线可视化 loss、accuracy、学习率变化,比只看控制台输出直观得多。
我给自己的第一个 CIFAR10 项目定下的及格线就是:验证集准确率超过 85%,模型文件小于 5MB,单 GPU 训练时间不超过 10 分钟。达到这个线之后,才有资格去谈更复杂的网络设计。
最后分享一个很实际的技巧:在训练 CIFAR10 这种小数据集时,把模型训练过程写成“函数 + 回调 + 日志”的固定模板,换数据集时只改数据加载部分。这样做的好处是你可以在半小时内跑通一个新的图像分类任务,把省下来的时间花在分析错误样本上——分析哪个类容易被混淆,往往比盲目换网络结构更有帮助。
