4.1 卷积神经网络:权值共享的扫描工单 本节摘要:全连接层对每个输入位置配独立权重,图像一展平参数量就爆炸且丢失空间结构。卷积层的解法是让一个小卷积核带着同一组权重扫过整张图:参数量从"像素数乘神经元数"降到"核大小乘通道数",平移不变性白送。本节拆解卷积、池化、特征图三个核心概念对张量形状的影响,在 CIFAR-10 子集上搭一条标准主干并调通训练。这是视觉任务的第一张工单,也是 4.5 节之前理解"局部性先验"的样板。 学习目标 阅读完本节,你应当能够: 手算一次卷积的输出形状与参数量,说清 padding 与 strides 的作用; 解释池化为什么丢位置信息换鲁棒性,以及它对排程的收益; 搭一条 Conv、BatchNorm、池化、全连接的标准主干并跑通 CIFAR-10 子集;
本节摘要:全连接层对每个输入位置配独立权重,图像一展平参数量就爆炸且丢失空间结构。卷积层的解法是让一个小卷积核带着同一组权重扫过整张图:参数量从"像素数乘神经元数"降到"核大小乘通道数",平移不变性白送。本节拆解卷积、池化、特征图三个核心概念对张量形状的影响,在 CIFAR-10 子集上搭一条标准主干并调通训练。这是视觉任务的第一张工单,也是 4.5 节之前理解"局部性先验"的样板。
阅读完本节,你应当能够:
用第 3 章的全连接直接吃图像,账一算就露馅:一张 32 乘 32 的彩色图展平是 3072 维,接 512 个神经元就是 157 万参数;换成 224 乘 224 的图,单层过千万。更深的病是结构性的:展平把"相邻像素相关"这一先验抹掉了——把图平移一个像素,每个权重看到的输入全变,模型被迫重新学。卷积层的两个设计同时治这两个病:局部连接(每个输出只看一小块窗口)与权值共享(同一组权重扫遍全图)。参数量变成核大小乘输入通道乘输出通道,3 乘 3 的核配 32 通道只需 288 个权重;平移不变性由共享自动获得——核在哪儿看到的都是同一种局部图案。

卷积输出形状由三个参数决定:核大小、padding(边缘补零策略)、strides(步长)。valid 补零时输出尺寸缩小核减一;same 补零时保持尺寸不变。参数量公式与输出形状必须能手算——这是核对模型设计正确性的最廉价手段。
import tensorflow as tf x = tf.random.normal([1, 32, 32, 3]) # 批次 1,CIFAR 图规格 conv1 = tf.keras.layers.Conv2D(32, 3, padding="same", activation="relu") out1 = conv1(x) print(out1.shape) # 输出:(1, 32, 32, 32) —— same 保持尺寸,通道变 32 print(conv1.count_params()) # 输出:928 —— 3 乘 3 乘 3 乘 32 加 32 个偏置 = 896 加 32 pool = tf.keras.layers.MaxPooling2D(2) out2 = pool(out1) print(out2.shape) # 输出:(1, 16, 16, 32) —— 池化不增参数,纯尺寸减半 conv_valid = tf.keras.layers.Conv2D(64, 3) # 默认 valid,无补零 out3 = conv_valid(out2) print(out3.shape) # 输出:(1, 14, 14, 64) —— 16 减 3 加 1 = 14 print(conv_valid.count_params()) # 输出:18496 —— 3 乘 3 乘 32 乘 64 加 64
CIFAR-10 是 10 类 32 乘 32 小图(飞机、鸟、猫等),tf.keras 内置了它的加载器——不用第 2 章的目录流,直接拿到 NumPy 数组再入管道。搭一条"卷积块三连"的主干,每块是卷积、批归一、池化:
(xtr, ytr), (xte, yte) = tf.keras.datasets.cifar10.load_data() xtr = xtr[:8000].astype("float32") / 255.0 # 取子集加速,归一到 0 到 1 ytr = ytr[:8000].reshape(-1) xte = xte[:2000].astype("float32") / 255.0 yte = yte[:2000].reshape(-1) cnn = tf.keras.Sequential([ tf.keras.layers.Input(shape=(32, 32, 3)), tf.keras.layers.Conv2D(32, 3, padding="same", activation="relu"), tf.keras.layers.BatchNormalization(), tf.keras.layers.MaxPooling2D(2), # 32 到 16 tf.keras.layers.Conv2D(64, 3, padding="same", activation="relu"), tf.keras.layers.BatchNormalization(), tf.keras.layers.MaxPooling2D(2), # 16 到 8 tf.keras.layers.Conv2D(128, 3, padding="same", activation="relu"), tf.keras.layers.GlobalAveragePooling2D(), # 8 到 128:全局均值替代展平 tf.keras.layers.Dense(10), ]) cnn.compile(optimizer=tf.keras.optimizers.Adam(1e-3), loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True), metrics=["accuracy"]) cnn.summary() # 输出(摘要): # conv2d (Conv2D) (None, 32, 32, 32) 896 # batch_normalization (None, 32, 32, 32) 128 # max_pooling2d (None, 16, 16, 32) 0 # conv2d_1 (None, 16, 16, 64) 18496 # max_pooling2d_1 (None, 8, 8, 64) 0 # conv2d_2 (None, 8, 8, 128) 73856 # global_average_pooling (None, 128) 0 # dense (Dense) (None, 10) 1290
三个值得注意的设计决策。其一,GlobalAveragePooling2D 替代 Flatten:8 乘 8 乘 128 展平是 8192 维、接 10 类要八万参数,全局平均把它压成 128 维、只要 1290 参数——小数据集上显著抗过拟合。其二,BatchNormalization 插在每个卷积后面,把该层输出的均值方差归一,训练更稳、可用更大学习率。其三,输出层不带 softmax,损失配 from_logits=True(3.5 节的配对链)。
h = cnn.fit(xtr, ytr, validation_data=(xte, yte), epochs=15, batch_size=64, verbose=0) print(f"train acc {h.history['accuracy'][-1]:.3f}, val acc {h.history['val_accuracy'][-1]:.3f}") # 参考输出:train acc 0.881, val acc 0.694 # 八千条小数据上七十前后的验证准确率是合理起点; # 差距偏大提示过拟合,可加 3.6 节的 Dropout 或数据增强
形状类报错占了 CNN 调试的大头。反向传播要求整个链条形状自洽,错一处全线停摆:输入忘了 reshape 成四维(少批次轴或通道轴)、池化叠多了把 8 乘 8 池成 3 乘 3 再池就是 1 乘 1。排错工具就是 summary 的形状列——逐层对账,从第一个形状异常的层往下查。
变式一,感受野与深度的取舍:两层 3 乘 3 卷积的视野等价一层 5 乘 5,但参数更少、非线性更多——现代主干普遍堆小核。变式二,分离式卷积把空间卷积与通道混合拆成两步,参数量降一个数量级,是移动端模型的标配思路(5.3 节 Lite 部署的常客)。变式三,残差连接把"学完整变换"改成"学残差修正",深度网络的训练稳定性问题由此缓解,第 3 章子类化一节已有最小实现。
⚠️ 常见坑:BatchNormalization 层在保存与推理时有两个"工作模式"的移动均值变量,训练时被更新、推理时被使用。自定义循环里忘记传 training 标志,推理指标会莫名偏差——Keras 层的 call 都带 training 参数,手写前向时记得透传。
💡 关键直觉:卷积是"押注局部性"的结构化先验。数据真有局部结构(图像、频谱、棋盘),卷积白赚参数效率;数据没有(纯表格特征),卷积没有任何优势——先问先验再选结构。
下节进入时序家族:循环神经网络。