1. 从全连接层到卷积:为什么CNN依然是视觉任务的起点
很多刚接触深度学习的朋友会有一个疑问:Transformer都这么火了,CNN还有必要学吗?我的答案是,非常有必要。CNN里蕴含的归纳偏置——局部连接、权值共享、平移不变性——是理解一切现代视觉模型的基石。你只有把CNN的卷积核、步长、填充、感受野这些概念吃透了,后面看Transformer里的Patch Embedding、看胶囊网络里的动态路由,才会有那种“原来如此”的顿悟感。
这一篇我们聚焦CNN的设计与实现。和上一篇的全连接网络不同,CNN处理的是具有空间结构的数据,比如图像。一张28x28的灰度图,展平后是784维向量,全连接层会直接把这784个像素当作独立特征。但图像不是这样工作的——相邻像素之间有极强的相关性,一个物体的特征不会因为平移几个像素就消失。CNN通过卷积核在空间上滑动,提取局部特征,再通过池化层逐步降低空间分辨率、增大感受野,最终得到具有语义信息的特征向量。
1.1 卷积层的关键参数与计算逻辑
先看一个具体的卷积层配置。假设输入是28x28x1的灰度图,我们定义一个卷积层:
import tensorflow as tf from tensorflow.keras import layers, models model = models.Sequential([ layers.Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1)), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation='relu'), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation='relu'), ])这里有几个关键点需要拆开讲。第一,Conv2D(32, (3, 3))表示使用32个3x3的卷积核。每个卷积核在输入上滑动,每次覆盖3x3的区域,做逐元素乘法再求和,得到一个标量输出。32个卷积核就得到32个特征图。第二,input_shape=(28, 28, 1)中的1是通道数,灰度图是1,彩色图是3。第三,MaxPooling2D((2, 2))在2x2的窗口里取最大值,把空间尺寸减半。
输出尺寸的计算公式是:output = (input - kernel + 2 * padding) / stride + 1。以第一个卷积层为例,输入28,卷积核3,padding默认是'valid'即0,stride默认是1,所以输出是(28-3+0)/1+1=26。经过2x2最大池化后变成13。第二个卷积层输入13,输出(13-3)/1+1=11,池化后变成5。第三个卷积层输入5,输出(5-3)/1+1=3。最终得到一个3x3x64的特征图。
注意:很多教程会忽略padding的设置。如果你想让输出尺寸和输入保持一致,需要设置
padding='same',此时Keras会自动计算需要填充的像素数。但要注意,'same'填充在stride大于1时行为会有所不同,建议初学时先用'valid'把尺寸变化算清楚。
1.2 池化层的选择与替代方案
最大池化是最常用的下采样方式,因为它保留了每个区域中最强的激活信号,对纹理和边缘特征特别有效。平均池化则更平滑,适合需要保留整体背景信息的场景。但近年来,很多现代架构开始用步长为2的卷积层替代池化层,比如在GAN的判别器中就经常这么做。原因是池化层没有可学习的参数,而步长卷积可以在下采样的同时学习如何压缩信息。
我个人的经验是:分类任务中最大池化依然很稳,但在生成任务中,用步长卷积或者PixelShuffle(亚像素卷积)做上采样和下采样效果更好。PixelShuffle的原理是把通道维度的信息重新排列到空间维度,比如把CxHxW的张量变成C/4x2Hx2W,这样可以在不引入棋盘伪影的情况下放大图像。
1.3 批归一化与Dropout的配合使用
在卷积层之后、激活函数之前,通常会加一个批归一化层(BatchNormalization)。它的作用是对每个mini-batch的数据做标准化,让均值接近0、方差接近1,然后再用一个可学习的缩放和平移参数恢复表达能力。这样做的好处是加速收敛、允许更大的学习率、减少对初始化的敏感度。
layers.Conv2D(64, (3, 3), padding='same'), layers.BatchNormalization(), layers.Activation('relu'), layers.Dropout(0.25),注意这里的顺序:卷积 -> 批归一化 -> 激活 -> Dropout。有些实现会把Dropout放在池化之后,这取决于具体任务。Dropout在卷积层中的效果不如在全连接层中明显,因为卷积层的参数共享本身就有一定的正则化效果。我一般只在全连接层前用较大的Dropout率(0.5),卷积层中用较小的(0.1-0.25)。
2. 从零搭建一个完整的CNN分类器
光看层定义不够,我们把它串成一个完整的、可运行的训练流程。这里用Fashion-MNIST作为示例数据集,因为它比MNIST稍微难一点,但又不会像ImageNet那样需要大量计算资源。
2.1 数据加载与预处理
import tensorflow as tf from tensorflow.keras import datasets, layers, models import matplotlib.pyplot as plt (train_images, train_labels), (test_images, test_labels) = datasets.fashion_mnist.load_data() train_images = train_images.reshape((60000, 28, 28, 1)).astype('float32') / 255.0 test_images = test_images.reshape((10000, 28, 28, 1)).astype('float32') / 255.0 train_labels = tf.keras.utils.to_categorical(train_labels, 10) test_labels = tf.keras.utils.to_categorical(test_labels, 10)这里做了三件事:reshape把(N, 28, 28)变成(N, 28, 28, 1),因为Keras的卷积层要求输入有通道维度;归一化把像素值从0-255压缩到0-1,这是为了让梯度下降更稳定;to_categorical把整数标签变成one-hot向量,配合categorical_crossentropy损失函数使用。
实操心得:如果你用的是sparse_categorical_crossentropy,就不需要做one-hot编码,直接传整数标签即可。我一般推荐用sparse版本,省内存而且不容易出错。另外,归一化时一定要用训练集的均值和方差,而不是整个数据集的,否则会造成数据泄露。
2.2 模型定义与编译
model = models.Sequential([ layers.Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1)), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation='relu'), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation='relu'), layers.Flatten(), layers.Dense(64, activation='relu'), layers.Dropout(0.5), layers.Dense(10, activation='softmax') ]) model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy']) model.summary()model.summary()会打印每一层的输出形状和参数数量。你可以看到第一个卷积层有32331+32=320个参数,第二个卷积层有643332+64=18496个参数。全连接层之前有一个Flatten,把3x3x64=576维的向量展平。最后的Dense层有576*64+64=36928个参数。总参数量大概在12万左右,对于这个任务来说足够了。
2.3 训练过程与回调函数
history = model.fit(train_images, train_labels, epochs=20, batch_size=64, validation_split=0.2, callbacks=[ tf.keras.callbacks.EarlyStopping(patience=3, restore_best_weights=True), tf.keras.callbacks.ReduceLROnPlateau(factor=0.5, patience=2) ])EarlyStopping在验证损失连续3个epoch不下降时停止训练,并恢复最佳权重。ReduceLROnPlateau在验证损失连续2个epoch不下降时把学习率减半。这两个回调配合使用,可以省去手动调学习率的麻烦。
训练完成后,用测试集评估:
test_loss, test_acc = model.evaluate(test_images, test_labels) print(f'Test Accuracy: {test_acc:.4f}')在Fashion-MNIST上,这个简单CNN通常能跑到92%左右的准确率。如果你把卷积核数量加倍、加更多层、用数据增强,可以推到94%以上。但要注意,模型越复杂,过拟合的风险越大,训练时间也越长。
3. 从CNN到Transformer:注意力机制的引入
CNN的局限在于它的感受野是局部的,虽然通过堆叠层可以扩大感受野,但对于长距离依赖的建模能力有限。Transformer的自注意力机制可以直接计算任意两个位置之间的关系,不受距离限制。在视觉任务中,Vision Transformer(ViT)把图像切成固定大小的Patch,每个Patch展平后当作一个Token,然后送入标准的Transformer编码器。
3.1 Patch Embedding的实现细节
假设输入图像是224x224x3,Patch大小是16x16。那么Patch数量是(224/16)(224/16)=1414=196。每个Patch展平后是16163=768维。用一个Dense层把768维映射到模型维度D,比如768。再加上一个可学习的分类Token和位置编码,就得到了Transformer的输入序列。
class PatchEmbedding(layers.Layer): def __init__(self, patch_size, embed_dim): super().__init__() self.patch_size = patch_size self.projection = layers.Dense(embed_dim) def call(self, images): batch_size = tf.shape(images)[0] patches = tf.image.extract_patches( images=images, sizes=[1, self.patch_size, self.patch_size, 1], strides=[1, self.patch_size, self.patch_size, 1], rates=[1, 1, 1, 1], padding='VALID' ) patch_dims = patches.shape[-1] patches = tf.reshape(patches, [batch_size, -1, patch_dims]) return self.projection(patches)tf.image.extract_patches是TensorFlow提供的一个高效操作,它按照指定的窗口大小和步长从图像中提取Patch。注意这里的输出形状是(batch, num_patches, patch_dims),然后通过Dense层投影到embed_dim。
3.2 多头自注意力的计算过程
自注意力的核心公式是:Attention(Q, K, V) = softmax(QK^T / sqrt(d_k)) V。Q、K、V分别是Query、Key、Value矩阵,它们都是输入序列经过不同的线性变换得到的。除以sqrt(d_k)是为了防止点积结果过大导致softmax梯度消失。
多头注意力就是把Q、K、V分成h个头,每个头独立计算注意力,然后把结果拼接起来再投影一次。这样做的好处是每个头可以关注不同的子空间,比如一个头关注颜色,另一个头关注形状。
class MultiHeadSelfAttention(layers.Layer): def __init__(self, embed_dim, num_heads): super().__init__() self.embed_dim = embed_dim self.num_heads = num_heads self.head_dim = embed_dim // num_heads assert self.head_dim * num_heads == embed_dim self.q_proj = layers.Dense(embed_dim) self.k_proj = layers.Dense(embed_dim) self.v_proj = layers.Dense(embed_dim) self.out_proj = layers.Dense(embed_dim) def call(self, x): batch_size = tf.shape(x)[0] q = self.q_proj(x) k = self.k_proj(x) v = self.v_proj(x) q = tf.reshape(q, [batch_size, -1, self.num_heads, self.head_dim]) k = tf.reshape(k, [batch_size, -1, self.num_heads, self.head_dim]) v = tf.reshape(v, [batch_size, -1, self.num_heads, self.head_dim]) q = tf.transpose(q, [0, 2, 1, 3]) k = tf.transpose(k, [0, 2, 1, 3]) v = tf.transpose(v, [0, 2, 1, 3]) scale = tf.math.sqrt(tf.cast(self.head_dim, tf.float32)) attn = tf.matmul(q, k, transpose_b=True) / scale attn = tf.nn.softmax(attn, axis=-1) out = tf.matmul(attn, v) out = tf.transpose(out, [0, 2, 1, 3]) out = tf.reshape(out, [batch_size, -1, self.embed_dim]) return self.out_proj(out)这段代码里最容易出错的地方是维度变换。reshape之后是(batch, seq_len, num_heads, head_dim),transpose之后是(batch, num_heads, seq_len, head_dim)。注意力矩阵的形状是(batch, num_heads, seq_len, seq_len)。最后再转置回来、reshape、投影。
注意:在混合精度训练时,softmax之前一定要把logits转成float32,否则数值不稳定。我踩过这个坑,loss直接变成NaN,排查了半天才发现是精度问题。
4. GAN与胶囊网络:两种不同的生成与表示思路
GAN的核心思想是让生成器和判别器相互博弈。生成器试图生成逼真的假样本,判别器试图区分真假。两者交替训练,最终生成器能生成以假乱真的样本。胶囊网络则是为了解决CNN对空间关系建模不足的问题,用向量神经元代替标量神经元,通过动态路由机制传递信息。
4.1 GAN的损失函数与训练技巧
标准的GAN损失函数是:
cross_entropy = tf.keras.losses.BinaryCrossentropy(from_logits=True) def discriminator_loss(real_output, fake_output): real_loss = cross_entropy(tf.ones_like(real_output), real_output) fake_loss = cross_entropy(tf.zeros_like(fake_output), fake_output) return real_loss + fake_loss def generator_loss(fake_output): return cross_entropy(tf.ones_like(fake_output), fake_output)训练时,先更新判别器,再更新生成器。注意在更新生成器时,要冻结判别器的权重,否则会把判别器也一起更新了。
@tf.function def train_step(images): noise = tf.random.normal([BATCH_SIZE, noise_dim]) with tf.GradientTape() as gen_tape, tf.GradientTape() as disc_tape: generated_images = generator(noise, training=True) real_output = discriminator(images, training=True) fake_output = discriminator(generated_images, training=True) gen_loss = generator_loss(fake_output) disc_loss = discriminator_loss(real_output, fake_output) gradients_of_generator = gen_tape.gradient(gen_loss, generator.trainable_variables) gradients_of_discriminator = disc_tape.gradient(disc_loss, discriminator.trainable_variables) generator_optimizer.apply_gradients(zip(gradients_of_generator, generator.trainable_variables)) discriminator_optimizer.apply_gradients(zip(gradients_of_discriminator, discriminator.trainable_variables))GAN训练中最常见的问题是模式崩溃——生成器只生成少数几种样本。解决方法包括:使用Wasserstein损失、加梯度惩罚、使用小批量判别、调整生成器和判别器的更新频率等。我个人的经验是,判别器不要太强,否则生成器学不到东西。可以给判别器加Dropout或者降低它的学习率。
4.2 胶囊网络的动态路由机制
胶囊网络的输出是一个向量,向量的模长表示实体存在的概率,方向表示实体的属性(比如位置、大小、方向)。低层胶囊通过动态路由把信息传递给高层胶囊。路由的过程是迭代的:先计算低层胶囊对高层胶囊的预测向量,然后根据一致性调整耦合系数。
class CapsuleLayer(layers.Layer): def __init__(self, num_capsules, dim_capsules, routings=3): super().__init__() self.num_capsules = num_capsules self.dim_capsules = dim_capsules self.routings = routings def build(self, input_shape): self.W = self.add_weight( shape=(input_shape[-1], self.num_capsules, input_shape[2], self.dim_capsules), initializer='glorot_uniform', trainable=True ) def call(self, inputs): inputs_expand = tf.expand_dims(inputs, 2) inputs_tiled = tf.tile(inputs_expand, [1, 1, self.num_capsules, 1, 1]) u_hat = tf.einsum('bijkl,ijkl->bikl', inputs_tiled, self.W) b = tf.zeros_like(u_hat) for i in range(self.routings): c = tf.nn.softmax(b, axis=2) s = tf.reduce_sum(c * u_hat, axis=1) v = self.squash(s) if i < self.routings - 1: b += tf.reduce_sum(u_hat * tf.expand_dims(v, 1), axis=-1) return v def squash(self, s): norm = tf.norm(s, axis=-1, keepdims=True) return (norm / (1 + norm**2)) * (s / (norm + 1e-8))动态路由的核心是b的更新:如果低层胶囊的预测和高层胶囊的输出一致,就增加它们之间的耦合系数。squash函数把向量的模长压缩到0到1之间,同时保持方向不变。
实操心得:胶囊网络在MNIST上能跑到99%以上,但在复杂数据集上训练很慢。动态路由的迭代次数一般设为3,再多收益不大。另外,胶囊网络的参数量比同等规模的CNN大不少,显存不够的话要减小胶囊维度。
5. 常见问题与排查技巧实录
在实现这些网络的过程中,我踩过不少坑。下面整理成速查表,方便你遇到问题时快速定位。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Loss变成NaN | 学习率太大、梯度爆炸、精度问题 | 检查学习率、加梯度裁剪、softmax前转float32 | 降低学习率、加tf.clip_by_norm、混合精度时注意数值稳定性 |
| 验证准确率不上升 | 过拟合、数据泄露、标签错误 | 检查训练集和验证集的划分、可视化样本 | 加正则化、数据增强、重新划分数据集 |
| GAN生成器只输出一种样本 | 模式崩溃 | 观察生成样本的多样性 | 用WGAN-GP、调整判别器强度、加噪声 |
| Transformer训练极慢 | 序列太长、注意力矩阵太大 | 检查序列长度和batch size | 减小Patch大小、用混合精度、梯度累积 |
| 胶囊网络显存不足 | 参数量大、路由迭代多 | 用model.summary()看参数量 | 减小胶囊维度、减少路由次数、用梯度检查点 |
还有一个容易被忽略的问题:数据管道的效率。如果你的GPU利用率很低,大概率是数据加载成了瓶颈。用tf.data.Dataset的prefetch和cache可以显著提升吞吐量。
train_ds = tf.data.Dataset.from_tensor_slices((train_images, train_labels)) train_ds = train_ds.shuffle(10000).batch(64).prefetch(tf.data.AUTOTUNE)prefetch让CPU在GPU计算时提前准备下一批数据,AUTOTUNE让TensorFlow自动选择最优的预取数量。这个改动通常能把训练速度提升20%到50%。
最后分享一个调试技巧:当你不确定某个层的输出形状时,用tf.keras.Model的子类化写法,然后在call方法里打印形状。或者直接用model.summary(),它会显示每一层的输出形状和参数数量。对于复杂的自定义层,可以先用小的随机张量跑一遍前向传播,确认形状正确后再接入完整模型。