把卷积在图像上的计算方法讲完之后,最常收到的困惑是:“卷积核滑动和特征图输出我懂了,可一个能用的卷积网络到底长什么样?从哪一层开始搭?每一层为什么非它不可?”这篇文章就拿来解决这个问题。上一讲我们专注于单个卷积层的计算,这次把视角拉到整个网络上,从单层卷积出发,把一个简单的卷积网络完整拆解、实现并跑通。适合已经理解卷积基本运算、想尽快上手机器学习框架做图像分类的读者。我会用一个能在 CPU 上几分钟跑完的手写数字识别网络作为贯穿全文的例子,把每个设计选择的原因、每个参数怎么定、训练中哪些坑必须躲开,一次讲清楚。
1. 从单层卷积到完整网络:中间缺了什么
1.1 单层卷积的局限
先说个容易被忽略的事实:单个卷积层本身并不是一个“网络”,它是一个带参数的局部特征检测器。你用一组卷积核扫描输入图像,得到一组特征图,这个过程回答的问题只有一个——“图像的各个局部位置,是否出现了某种特定的局部模式”。至于这些模式组合起来意味着什么,下一步该做什么决策,单层卷积层完全不管。
具体来说,单层卷积有三个绕不开的局限。第一个是感受野有限。一个 3×3 的卷积核,每次只能看到中心像素周围 3×3 范围内的信息。你要在一张 224×224 的图像上判断“这是不是一张人脸”,靠单个卷积层是做不到的,因为“眼睛在鼻子上方、嘴巴在鼻子下方”这种全局空间关系,必须依赖更大范围的信息才能判断。
第二个局限是输出仍然停留在“像素级响应”。卷积层的输出是一张张特征图,每个位置上的数值表示该位置和卷积核的匹配程度。它没有“这个图像里有数字 3 还是数字 7”这种类别概念。想从一个响应图直接得到类别决策,中间还隔着一层抽象。
第三个局限是参数爆炸问题。有人可能会想:既然单层卷积不够,那我直接把所有像素展平接一个全连接网络不就行了吗?假设输入是一张 28×28 的灰度图,展平后是 784 维。如果第一个隐藏层有 1000 个神经元,那这一层的权重矩阵就是 784×1000,差不多 78 万个参数。这还只是第一层。更关键的是,全连接层会把每个像素当成独立的特征,完全忽略了图像“相邻像素之间有强相关性”这个基本事实。卷积层的设计初衷恰恰就是利用这种局部相关性,用很小的参数量提取出具有平移不变性的特征。
1.2 网络要解决的三个问题
把单层卷积的局限倒过来看,一个能用的卷积网络其实要完成三件事:
- 特征提取:通过多层卷积,把像素逐步抽象成边缘、纹理、局部形状,再到高层语义特征。
- 特征筛选与压缩:从大量特征图中保留关键响应,丢弃冗余空间信息,为最终的决策提供紧凑的输入。
- 分类决策:把高层特征映射成各个类别的得分。
这三件事,对应到网络结构上就是三个部分:卷积层组、池化层组、全连接层组。后面我会逐个拆解每一部分存在的理由,以及它们之间如何衔接。
1.3 为什么“深度”这么重要
这里需要解释一个核心概念:为什么简单的卷积网络也要堆叠多层,而不是只用一个超大卷积核?两个 3×3 的卷积核堆叠,其感受野等效于一个 5×5 卷积核;三个 3×3 堆叠,等效于一个 7×7 卷积核。但堆叠小卷积核的方式参数更少,而且每两层之间都夹着非线性激活函数,网络的表达能力更强。这个结论是 VGG 那批工作验证过的,如今已经成了设计卷积网络的基本共识。
所以“简单卷积网络”这个“简单”,指的是结构清楚、容易理解、训练快捷,而不是只有一个卷积层。它有足够的深度去展示“低层特征到高层语义”的抽象过程。理解了这一点,后面看任何结构复杂的 CNN 都不会觉得陌生。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 经典骨架的每一块为什么必须存在
2.1 卷积层堆叠:特征抽象的关键步骤
一个卷积层的完整单元通常写作:卷积 → 激活函数 →(可选)池化。
第一层卷积直接在原始图像上工作。以 MNIST 手写数字为例,输入是 1×28×28 的灰度图。第一层用 6 个 5×5 的卷积核,输出 6 张 28×28 的特征图。每个卷积核就相当于一个“模式检测器”:有的对横向边缘响应强,有的对纵向边缘响应强,有的对圆润的弧线响应强。这个阶段提取的特征非常底层,但它们是后续所有抽象的基础。
第二层卷积的输入不再是原始像素,而是第一层输出的特征图。此时卷积核不再直接接触图像,而是对“边缘响应图”再做组合检测。比如,一个横向边缘加上两侧的纵向边缘,就可能组合成“拐角”的模式;两个拐角加一条线段,可能对应数字 4 的某一部分。这就是特征逐层抽象的本质:低层特征通过组合得到中高层特征,中高层特征再通过组合得到更抽象、更具判别性的语义。
在参数设计上,有一个经常被问到的点:padding 到底该不该加?我的建议是,在第一层卷积上适当使用 padding,保持特征图尺寸不要缩得太快。MNIST 图像只有 28×28,如果每层卷积都不加 padding,两层下来特征图就缩到 10×10,空间信息丢掉太多。但也不要无脑处处加 padding,习惯性在全网络保持尺寸不变同样有代价——后续池化层对空间维度的削减会被“稀释”。简单网络里比较稳妥的做法:第一层加 padding 维持尺寸,第二层不加,让池化完成主要的降采样任务。
2.2 激活函数:非线性从哪里来
如果卷积层之后不接非线性激活函数,无论堆多少层卷积,整个网络数学上仍然等价于一个线性变换,那样“深层”就没有意义了。激活函数的作用就是给网络注入非线性。
早期神经网络常用 sigmoid 或 tanh,但它们在深层网络里有个致命问题:导数在两端趋近于 0,反向传播时梯度连续相乘,很快就会消失,导致靠近输入层的参数几乎无法更新。ReLU 的提出是卷积网络能真正加深的关键因素之一。它的表达式极其简单:( f(x) = \max(0, x) )。正半轴导数为 1,不存在梯度饱和问题,计算也只是一个比较运算。
但 ReLU 不是没有缺点。它会把所有负数输入直接置 0,如果一个神经元的所有训练样本都给出负响应,那么这个神经元在反向传播时梯度永远为 0,再也无法恢复,这就是“神经元死亡”。在小学习率、合理初始化的情况下,这种问题不算严重,但如果你发现训练几轮之后大量特征图全是黑色的(全零),就要考虑是不是学习率设大了,或者初始化出了问题。
2.3 池化层:降维之外的真实价值
池化层通常被一句话带过:“用来降低特征图分辨率,减少计算量。”这当然没错,但它真正的价值在于提供一个局部的平移不变性。
拿最大池化举例:2×2、步长为 2 的最大池化,把一个区域内 4 个像素的最大值保留下来。如果图像里的特征位置整体平移了 1 个像素,这个最大值大概率仍然落在同一个池化窗口内,于是池化层的输出几乎不变。这种“允许目标在局部范围内移动而不影响识别”的能力,对图像分类非常重要——手写数字 5 和 8 不会因为笔画在几像素内抖动就变成另一个类别。
另一个容易被忽视的作用是扩大后续卷积的感受野。经过一次 2×2 池化之后,特征图尺寸减半,接下来一层的卷积核虽然物理大小没变,但它覆盖的范围对应原图就扩大了一倍。这就是为什么池化层和卷积层要交替出现,而不是把所有卷积堆完再一次性降采样。
池化方式上,最大池化比平均池化更常用。原因是最大池化关注“有没有这个特征”,而平均池化关注“这个特征出现的平均强度”。对于识别任务来说,特征的“存在性”通常比“平均强度”更有判别力。不过在最后的全局池化层,平均池化有时反而更受青睐,因为取平均可以平滑掉空间位置带来的噪声。
2.4 全连接层:让特征变成决策
经过几轮“卷积+激活+池化”之后,特征图已经非常紧凑。以我们即将实现的网络为例,最后的特征图尺寸是 16×5×5,展平后是 400 维,只占原始输入 784 维的一半左右,但信息密度远高于原始像素。
全连接层的任务,就是把这 400 维特征映射成 10 个类别的得分。它的运作方式就是普通的矩阵乘法加偏置:( y = Wx + b )。因为特征图已经很小,全连接层的参数量可以控制在可接受范围内。我们这里用的两层全连接(400→120→84→10),总参数量大概 6 万左右,比直接用全连接处理原始图像小两三个数量级。
最后一层的输出可以叫做 logits,也就是未归一化的类别得分。要转成概率,需要在后面接 Softmax,把 10 个得分变成总和为 1 的概率分布。这里有一个初学者常踩的坑:PyTorch 的 CrossEntropyLoss 内部已经做了 Softmax 计算,所以你在模型最后一层只需要输出 logits,不要手动加 Softmax,否则训练时既浪费计算,梯度还可能不稳定。
2.5 一个简单卷积网络的整体结构
把上面的模块串起来,一个简单卷积网络的典型结构可以概括为:
输入图像 → [卷积 → ReLU → 池化] × 2 → 展平 → 全连接 → ReLU → 全连接 → 输出
这是 LeNet 家族遗留下来的一套骨架,几乎适合所有入门级图像分类任务。结构公式化、容易理解、收敛快。我见过不少初学者一上来就想堆 ResNet 级别的结构,结果在 MNIST 这种小数据集上反而表现不佳,因为更大的模型在小数据上容易过拟合,训练耗时也被白白拉长。先跑通一个简单结构,再去加复杂度,这个顺序是最省时间的。
3. 一个能跑的简单卷积网络:架构设计与 PyTorch 实现
3.1 任务选型和数据准备
这一节我直接用 PyTorch 实现一个用于 MNIST 手写数字识别的卷积网络。MNIST 是 28×28 的单通道灰度图,训练集 6 万张,测试集 1 万张,类别数 10。它足够简单,普通笔记本电脑用 CPU 就能在几分钟内完成训练,非常适合用来建立“端到端”的直觉。
数据准备阶段最容易忽略的是归一化。transforms.ToTensor() 会把图像的像素值从 0~255 缩放到 0~1,但这还不够。MNIST 数据集有一个统计好的全局均值和标准差:0.1307 和 0.3081。用这两者对图像做标准化,相当于把像素分布拉回到零均值、单位方差附近,能让梯度下降过程更平稳。这一步不是可选项,实际测试下来,不做标准化的收敛速度会明显变慢。
python复制import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
transform = transforms.Compose([
transforms.ToTensor(),
transforms.Normalize((0.1307,), (0.3081,))
])
train_dataset = datasets.MNIST(root='./data', train=True, transform=transform, download=True)
test_dataset = datasets.MNIST(root='./data', train=False, transform=transform, download=True)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
test_loader = DataLoader(test_dataset, batch_size=256, shuffle=False)
shuffle=True 只用于训练集,测试集不需要打乱。shuffle 的作用是让每个 batch 中的数据尽可能覆盖不同类别,避免按原始顺序排序导致连续若干个 batch 全是同一个数字,那样梯度更新方向会很偏。
3.2 网络结构逐层推导
网络结构设计如下:
| 层 | 操作 | 输出尺寸 | 参数量 |
|---|---|---|---|
| 输入 | 原始图像 | 1×28×28 | 0 |
| Conv1 | 5×5 卷积,padding=2,6 个核 | 6×28×28 | 156 |
| ReLU | 非线性激活 | 6×28×28 | 0 |
| Pool1 | 2×2 最大池化 | 6×14×14 | 0 |
| Conv2 | 5×5 卷积,16 个核 | 16×10×10 | 2416 |
| ReLU | 非线性激活 | 16×10×10 | 0 |
| Pool2 | 2×2 最大池化 | 16×5×5 | 0 |
| Flatten | 展平 | 400 | 0 |
| FC1 | 全连接 400→120 | 120 | 48120 |
| ReLU | 非线性激活 | 120 | 0 |
| FC2 | 全连接 120→84 | 84 | 10164 |
| ReLU | 非线性激活 | 84 | 0 |
| FC3 | 全连接 84→10 | 10 | 850 |
逐层推导一下尺寸变化,这个能力很重要,后面设计任何 CNN 都离不开。输入是 28×28,Conv1 用的是 5×5 卷积核、padding=2、步长 1。根据输出尺寸公式:( (H - k + 2p)/s + 1 = (28 - 5 + 4)/1 + 1 = 28 ),所以输出仍是 28×28。MaxPool2d 的 2×2 池化把宽高都除以 2,变为 14×14。Conv2 没有 padding,( (14 - 5)/1 + 1 = 10 ),输出 10×10。再次池化得到 5×5。
参数量也可以手算验证。Conv1 有 6 个 5×5 卷积核,输入通道是 1,每个卷积核有 25 个权重加 1 个偏置,总参数量 ( 6 \times (25 + 1) = 156 )。Conv2 有 16 个 5×5 卷积核,输入通道是 6,所以每个卷积核有 ( 6 \times 25 + 1 = 151 ) 个参数,总参数量 ( 16 \times 151 = 2416 )。从参数量就能看出,整个网络的卷积部分只占 2572 个参数,大头全在全连接层,约 59134 个参数。这个数量级在 MNIST 上训练非常快,而且不容易过拟合。
这段尺寸推导建议自己手动算一遍,不要只依赖打印模型结构。很多框架会自动计算参数量,但理解“为什么这个张量是这么大”,能帮你在网络没法运行时快速定位到是哪里尺寸对不上。
3.3 模型定义与训练循环
模型定义如下:
python复制class SimpleCNN(nn.Module):
def __init__(self):
super().__init__()
self.features = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, padding=2),
nn.ReLU(),
nn.MaxPool2d(2),
nn.Conv2d(6, 16, kernel_size=5),
nn.ReLU(),
nn.MaxPool2d(2)
)
self.classifier = nn.Sequential(
nn.Flatten(),
nn.Linear(16 * 5 * 5, 120),
nn.ReLU(),
nn.Linear(120, 84),
nn.ReLU(),
nn.Linear(84, 10)
)
def forward(self, x):
x = self.features(x)
x = self.classifier(x)
return x
训练循环里几个细节值得注意。optimizer.zero_grad() 必须放在每次反向传播之前,否则梯度会跨 batch 累加,导致参数更新方向越来越离谱。损失函数直接使用 nn.CrossEntropyLoss(),它内部做了 Softmax 和交叉熵计算。模型在训练前要 .train(),进入评估前要 .eval(),这个习惯越早养成越好。
python复制device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = SimpleCNN().to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
def train_one_epoch(model, loader, optimizer, criterion, device):
model.train()
total_loss, correct, total = 0, 0, 0
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(images)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
total_loss += loss.item() * images.size(0)
correct += (outputs.argmax(dim=1) == labels).sum().item()
total += labels.size(0)
return total_loss / total, correct / total
def evaluate(model, loader, criterion, device):
model.eval()
total_loss, correct, total = 0, 0, 0
with torch.no_grad():
for images, labels in loader:
images, labels = images.to(device), labels.to(device)
outputs = model(images)
loss = criterion(outputs, labels)
total_loss += loss.item() * images.size(0)
correct += (outputs.argmax(dim=1) == labels).sum().item()
total += labels.size(0)
return total_loss / total, correct / total
for epoch in range(1, 11):
train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device)
test_loss, test_acc = evaluate(model, test_loader, criterion, device)
print(f'Epoch {epoch:02d} | Train Loss {train_loss:.4f} | Train Acc {train_acc:.4f} | '
f'Test Loss {test_loss:.4f} | Test Acc {test_acc:.4f}')
评估时用 torch.no_grad() 包裹是必须的。它告诉 PyTorch 不需要构建计算图,可以极大地减少显存占用并提升推理速度。如果不加,验证阶段也会保存中间变量,在小网络上影响不明显,但网络一深就会爆显存。
实际跑下来,使用 Adam 优化器、学习率 1e-3、batch size 64,10 个 epoch 后测试准确率通常能到 99% 左右。大约在第 3~4 个 epoch 时,测试准确率就会超过 98%,这个收敛速度在 CPU 上也就几分钟的事。
3.4 如何验证网络真的在工作
准确率达到 99% 之后,建议做两件可视化的事情,建立对网络的直观理解。
第一件是打印中间层特征图。选一张测试图像,把它输入到 model.features,观察第一层卷积输出的 6 张特征图。你会发现其中一些图对数字的锐利边缘响应强,另一些对背景噪声更敏感,每一张都像一种不同的“滤镜”。如果再深入一层看第二层卷积的 16 张特征图,它们普遍比第一层的更抽象,很多特征图上只能看到数字的骨架轮廓。
第二件是看分类错误的样本。MNIST 上最常见的混淆是 4 和 9、3 和 8、7 和 2,这些数字在笔画结构上确实有相似之处。看错误样本能让你直观感受到模型的决策边界在哪里,也能帮你判断后续是应该增加数据增强,还是调整网络结构。
4. 训练简单 CNN 时的参数选择与踩坑记录
4.1 学习率:最需要小心的一个参数
如果你只记住一个训练调参经验,那就是:学习率几乎决定了一切。Adam 优化器的默认学习率通常不需要改动太大,但它的推荐值 1e-3 是相对通用的起点,不是万能值。
几个典型现象对应关系如下:
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| Loss 一开始就剧烈震荡,不下降 | 学习率过大 | 降到 1e-4 或 3e-4 |
| Loss 缓慢下降,每轮只降一点点 | 学习率过小 | 升到 3e-3 或 1e-2 |
| 训练集准确率很高,测试集准确率停滞 | 过拟合 | 降低模型容量或加正则化 |
| Loss 突然跳到 NaN | 学习率过大或数据异常 | 调低学习率,检查归一化 |
除了固定学习率,一个简单有效的做法是每 5 个 epoch 将学习率乘以 0.5。PyTorch 里可以用 torch.optim.lr_scheduler.StepLR 实现。对 MNIST 这种小任务或许感觉不到明显差异,但换到 CIFAR-10 这类复杂一点的数据集,学习率衰减能显著改善最终精度。
4.2 batch size 与优化器的配合
batch size 的选择牵涉到梯度估计的噪声。小的 batch(如 16 或 32)梯度噪声大,训练初期反而有类似“随机扰动”的正则化效果;大的 batch(如 256 或 512)梯度更平滑,每个 step 的方向更可靠,但容易收敛到尖的局部极小值,泛化能力有时反而差一些。MNIST 上最省心的范围是 64 到 128,不需要额外调优。
优化器方面,入门阶段直接用 Adam 就好,它对学习率不那么敏感,收敛快。等想深入理解优化算法时,再回过头对比 SGD + Momentum。SGD 的泛化性能在部分任务上比 Adam 更好,但调参成本高,不适合用来作为初学阶段的默认选择。
4.3 过拟合的迹象与对策
MNIST 上的 SimpleCNN 大约 6 万个参数,训练集 6 万张图,参数和样本量接近 1:1,其实已经有过拟合的风险,但因为有测试集准确率 99% 的表现兜底,问题不突出。换到更小的数据集或者更大的模型,过拟合会立刻暴露。
常见迹象:训练集准确率持续上升并逼近 100%,但验证集准确率停在某个平台甚至下降;或者训练 loss 不断下降,验证 loss 先降后升。
对策按推荐顺序:增加数据增强(随机旋转、平移、缩放)、增加 Dropout 层、减小模型容量、加大权重衰减(weight_decay)。其中数据增强几乎总是最有效的,因为它直接从数据源头增加样本多样性。MNIST 上常用的增强是随机旋转 10 度以内、随机平移两个像素,太小没效果,太大则会扭曲数字的语义。
4.4 数据预处理的隐藏影响
ToTensor 和 Normalize 这两步看起来简单,但顺序不能颠倒:先转成 0~1 范围内的张量,再用均值和标准差归一化。如果先 Normalize 再 ToTensor,数值范围不会按预期归一化,因为 Normalize 的均值和标准差是针对 0~1 范围设计的。
还有一个容易忽略的点:PyTorch 的 CrossEntropyLoss 要求标签是整数索引形式(比如 0~9),而不是 one-hot 向量。MNIST 数据集的标签天然就是整数,所以不需要额外转换。如果你用自己的数据集,要注意标签格式必须一致。
4.5 几个经典报错与修复方法
现在跑起来出问题,最常遇到的就是张量维度不匹配。比如你会看到这样的报错:mat1 and mat2 shapes cannot be multiplied。这通常意味着 Flatten 之后得到 400 维,但 Linear 层期望的输入维度不是 16×5×5=400。修复方法是检查 nn.Linear 的第一个参数是否和特征图展平后的维度一致,不要凭感觉写,手动算一遍最靠谱。
另一个高频错误是忘记调用 optimizer.zero_grad()。表现是 loss 曲线一直下不去,甚至越训越高。排查这种问题最简单的方法:打印一个 batch 的 loss,看它是否在合理范围内。MNIST 十分类的初始 loss 大约在 2.3 附近(因为随机初始化的网络对 10 类的输出接近均匀分布),如果你看到几千或者负数,说明哪一步一定出了问题。
还有一个比较隐蔽的:model.eval() 和 torch.no_grad() 是两个不同的机制。前者影响 BatchNorm 和 Dropout 的行为,后者关闭自动求导。很多人以为两个是同一回事,实际上在评估时必须两个都用,缺一个都可能得到错误的验证结果。
5. 从简单 CNN 延伸出去的现代结构
5.1 深度可分离卷积:轻量化的突破口
当你理解了基础卷积的参数量计算方式,就能立刻体会到深度可分离卷积的效率优势。普通卷积处理一个 3×3、输入通道 64、输出通道 128 的卷积层,参数量是 ( 3 \times 3 \times 64 \times 128 = 73728 )。深度可分离卷积把这个过程拆成两步:先用 3×3 卷积对每个输入通道单独处理,得到 64 个中间特征图,再用 1×1 卷积把 64 个通道混合成 128 个输出通道。参数量降到 ( 3 \times 3 \times 64 + 1 \times 1 \times 64 \times 128 ),约 8768,只有原来的约 1/8。
MobileNet 系列就是靠这个设计把网络做到能在手机上实时运行。理解深度可分离卷积的关键,是明白它把“空间特征提取”和“跨通道信息融合”解耦了。这种思路在基础卷积网络学过一遍之后看起来非常自然,因为 1×1 卷积本质上就是通道维度的全连接。
5.2 转置卷积与三维卷积:任务决定结构
转置卷积经常被误解为卷积的逆运算。实际上它并不是数学上的逆运算,而是一种“可学习的上采样”操作。普通卷积把大特征图变小,转置卷积则把小特征图变大。在语义分割任务中,把像素分类到不同类别,需要输出和输入等尺寸的分割图,这时候编码阶段下采样的特征图要靠转置卷积逐步恢复到原分辨率。图像生成模型里也大量用到转置卷积来从噪声向量生成图片。
三维卷积是针对视频、医学影像这类三维数据的。它的卷积核是 3×3×3 的立方体,在空间维和时间维上同时滑动,能够同时捕捉空间结构和时序运动。三维卷积的参数量比二维卷积更大,训练成本也更高,但这是处理时空数据的标准选择。如果你之前只是处理单张图片,可以先不碰它,等需要处理视频分类时再回头看。
5.3 门控卷积与自适应图卷积:非典型场景
门控卷积是 Gated Convolution,它通过一个门控机制让网络自己决定“哪些信息值得继续进入下一层,哪些应该被拦住”。这类结构在语言建模中比较常见,也与 GLU(门控线性单元)联系紧密。它比普通卷积多了一条“门控分支”,算力开销增大,但能带来更强的表达能力。
自适应图卷积则处理完全不一样的数据。普通卷积的适用对象是网格状数据,比如图片、视频,它们有固定的邻居结构。但社交网络、分子结构、交通路网这类数据,节点之间的连接关系是不规则的,没法用固定大小的卷积核去“滑动扫描”。图卷积网络(GCN)就是把卷积的思想迁移到图结构数据上,通过聚合邻居节点的信息来更新每个节点的表示。理解普通卷积的“局部聚合”本质之后,GCN 的核心思想其实是同一个:每个节点的新特征 = 自身特征和邻居特征的一个加权组合,只不过这个“邻居”不再由卷积核窗口定义,而是由图结构定义。
5.4 后续学习路径建议
基础卷积网络掌握后,建议按这个顺序往下走:先认真读 LeNet 和 AlexNet 原文,理解早期卷积网络的演进逻辑;再看 VGG 和 ResNet,重点领会残差连接解决深层网络退化的思路;之后是轻量化方向,对比 MobileNet 的深度可分离卷积;接着可以接触目标检测里的骨干网络设计,理解分类网络如何被迁移到检测任务。每个阶段都建议动手修改一个开源代码,比如把 SimpleCNN 的卷积核换掉、把池化层换成步长为 2 的卷积,观察准确率和训练速度变化。只有自己亲手改过、跑过、对比过,那些“为什么这么设计”的答案才会真正变成你自己的经验。
我个人在最开始接触卷积网络时,也犯过一个典型错误:一口气买了很多本书、收藏了很多论文,但始终没有完整训练过一个像样的模型。后来强制自己先跑通一个最简单的手写数字识别,再逐步把网络加深、把数据集换大、把任务换复杂,整个知识体系才真正搭建起来。如果你想把这个系列继续往下学,最好的下一步不是去看更复杂的网络结构,而是把你自己的训练脚本跑起来,改一改卷积核数量,看看准确率怎么变化。
