卷积神经网络(CNN)是图卷积神经网络(GCN)的直接来源。理解 CNN 的卷积操作如何从规则网格推广到任意拓扑结构的图,是掌握 GNN 的关键。本文系统梳理 CNN 的数学基础、经典架构和与 GNN 的核心联系。
一、卷积的数学定义 1.1 连续域中的卷积 对于连续函数 f 和 g,卷积定义为:
(f * g)(t) = ∫_{-∞}^{∞} f(τ) g(t - τ) dτ
物理直觉:g 是一个”滤波器”或”模板”,在 f 上滑动,计算每个位置的重叠积分。这可以理解为:f 在每一点的值,被它周围的邻居值通过 g 的加权平均所替换。
1.2 离散域中的卷积 对于离散序列 x[n] 和滤波器 w[k]:
(x * w)[n] = Σ_k x[k] w[n - k]
对于二维图像 I 和卷积核 K:
(I * K)[i, j] = Σ_m Σ_n I[i+m, j+n] K[m, n]
这就是 CNN 中卷积层的基础操作:在图像的每个像素位置,取一个局部邻域(卷积核大小),与卷积核进行逐元素乘法并求和。
1.3 CNN 中的卷积与数学卷积的区别 CNN 中实际执行的操作在数学上称为互相关(Cross-correlation) 而非卷积:
# CNN 中实际执行的(互相关): output[i,j] = Σ_m Σ_n input[i+m, j+n] * kernel[m, n] # 数学上的严格卷积(需要翻转 kernel): output[i,j] = Σ_m Σ_n input[i+m, j+n] * kernel[-m, -n]
但由于卷积核是可学习的参数,是否翻转不影响结果(网络会学到相应的参数值)。因此深度学习社区习惯性地称这个操作为”卷积”。
二、CNN 的核心组件 2.1 卷积层 一个标准卷积层包含以下超参数:
参数
含义
常见取值
kernel_size
卷积核大小
3, 5, 7
stride
滑动步长
1, 2
padding
边缘填充
“same”(保持尺寸), “valid”(缩小尺寸)
in_channels
输入通道数
3(RGB), 64, 128, …
out_channels
输出通道数(卷积核数量)
32, 64, 128, 256, …
dilation
膨胀率
1(正常), 2(膨胀卷积)
import torchimport torch.nn as nnconv = nn.Conv2d( in_channels=3 , out_channels=64 , kernel_size=3 , stride=1 , padding=1 ) x = torch.randn(16 , 3 , 224 , 224 ) out = conv(x)
2.2 卷积的两个关键性质 权重共享(Weight Sharing) :卷积核在整个图像上共享同一组参数。无论目标特征在图像中的哪个位置,卷积核都能检测到它。这大大减少了参数量(全连接层的参数需要 O(H·W·C_in·C_out),卷积层只需要 O(K^2·C_in·C_out))。
平移等变性(Translation Equivariance) :如果输入图像平移,输出的特征图也做相同的平移。数学表达:
(L_shift I) * K = shift (I * K)
这是卷积最核心的性质,也是它为何能高效处理图像的原因。但这也限制了 CNN 处理其他不规则数据(如图结构)的能力。
2.3 池化层(Pooling) 池化层对特征图进行下采样,减小空间分辨率:
最大池化(Max Pooling) :取每个窗口的最大值。保留最突出的特征,适合检测纹理。
平均池化(Average Pooling) :取每个窗口的平均值。平滑特征,适合保留背景信息。
全局平均池化(Global Average Pooling) :将整个特征图压缩为一个值。通常用在分类网络末尾,替代全连接层。
max_pool = nn.MaxPool2d(kernel_size=2 , stride=2 ) avg_pool = nn.AvgPool2d(kernel_size=2 , stride=2 ) gap = nn.AdaptiveAvgPool2d((1 , 1 ))
2.4 全连接层(Fully Connected Layer) 在 CNN 的末端,通常将特征图展平(flatten)后接全连接层进行分类。全连接层的数学:
其中 W ∈ R^{out_dim × in_dim},b ∈ R^{out_dim}。全连接层的参数量很大(in_dim × out_dim),这也是为什么现代 CNN 倾向于用全局平均池化替代 FC 层。
三、CNN 的数学视角:邻居聚合 3.1 卷积 = 局部邻居特征聚合 将 CNN 视为在每个像素位置,聚合其空间邻居的特征:
h'_{i,j} = Σ_{(m,n)∈N(i,j)} w_{m-i, n-j} · h_{m,n}
其中 N(i,j) 是像素 (i,j) 的卷积核覆盖的局部邻域(由 kernel_size 和 dilation 决定)。
关键洞察 :图像的像素天然排布在一个规则网格上,每个像素的邻居位置是固定的(上、下、左、右、对角)。卷积利用这个规则网格结构定义了”邻居”和”聚合权重”。
3.2 卷积的多通道视角 对于有 C_in 个通道的输入和 C_out 个卷积核:
h'_{i,j,c_out} = Σ_{c_in=1}^{C_in} Σ_{(m,n)∈N(i,j)} W_{c_out, c_in, m-i, n-j} · h_{m,n,c_in}
这个公式在形式上与 GNN 的消息传递公式惊人地相似:
h'_v = Σ_{u∈N(v)} W · h_u
区别仅在于:
CNN 的邻居是规则的(kernel_size 个位置)
GNN 的邻居是图结构定义的(每个节点可能不同)
CNN 的权重 W 由空间位置决定(左上角、右下角等)
GNN 的权重 W 通常是共享的(或由注意力机制动态计算)
四、激活函数与归一化 4.1 ReLU 与变体 ReLU(Rectified Linear Unit)是最常用的 CNN 激活函数:
优点:计算简单,缓解梯度消失(x > 0 时梯度恒为 1),产生稀疏激活。
Leaky ReLU :解决”dying ReLU”问题(x < 0 时梯度为 0,神经元可能永久失效):
LeakyReLU(x) = max(αx, x) # α 通常为 0.01
GELU(Gaussian Error Linear Unit) :在 Transformer 和现代 CNN 中广泛使用:
GELU(x) = x · Φ(x) = x · P(X ≤ x), X ~ N(0,1) ≈ 0.5 x (1 + tanh(√(2/π)(x + 0.044715 x^3))) # 近似公式
4.2 Batch Normalization BN 对每个 mini-batch 的每个通道进行标准化:
x̂ = (x - μ_B) / √(σ_B^2 + ε) y = γ x̂ + β
其中 μ_B 和 σ_B^2 是 mini-batch 的均值和方差,γ 和 β 是可学习的缩放和偏移参数。
BN 的作用:加速训练收敛(允许更大的学习率),缓解梯度消失/爆炸,提供部分正则化效果。
五、经典 CNN 架构 5.1 LeNet-5(1998) class LeNet5 (nn.Module): def __init__ (self, num_classes=10 ): super ().__init__() self .conv1 = nn.Conv2d(1 , 6 , kernel_size=5 , padding=2 ) self .pool1 = nn.AvgPool2d(2 , 2 ) self .conv2 = nn.Conv2d(6 , 16 , kernel_size=5 ) self .pool2 = nn.AvgPool2d(2 , 2 ) self .fc1 = nn.Linear(16 * 5 * 5 , 120 ) self .fc2 = nn.Linear(120 , 84 ) self .fc3 = nn.Linear(84 , num_classes) def forward (self, x ): x = torch.tanh(self .conv1(x)) x = self .pool1(x) x = torch.tanh(self .conv2(x)) x = self .pool2(x) x = x.view(x.size(0 ), -1 ) x = torch.tanh(self .fc1(x)) x = torch.tanh(self .fc2(x)) x = self .fc3(x) return x
LeNet-5 用于手写数字识别(MNIST),是现代 CNN 的起点。它确立了 conv-pool-fc 的基本范式。
5.2 AlexNet(2012) ImageNet 2012 冠军,标志着深度学习时代的到来。关键创新:
ReLU 激活 :替代 tanh/sigmoid,缓解梯度消失
Dropout :全连接层使用 dropout 防止过拟合
数据增强 :随机裁剪、水平翻转、PCA 颜色增强
GPU 训练 :模型分两半在两块 GTX 580 上并行训练
class AlexNet (nn.Module): def __init__ (self, num_classes=1000 ): super ().__init__() self .features = nn.Sequential( nn.Conv2d(3 , 96 , kernel_size=11 , stride=4 , padding=2 ), nn.ReLU(inplace=True ), nn.MaxPool2d(3 , stride=2 ), nn.Conv2d(96 , 256 , kernel_size=5 , padding=2 ), nn.ReLU(inplace=True ), nn.MaxPool2d(3 , stride=2 ), nn.Conv2d(256 , 384 , kernel_size=3 , padding=1 ), nn.ReLU(inplace=True ), nn.Conv2d(384 , 384 , kernel_size=3 , padding=1 ), nn.ReLU(inplace=True ), nn.Conv2d(384 , 256 , kernel_size=3 , padding=1 ), nn.ReLU(inplace=True ), nn.MaxPool2d(3 , stride=2 ), ) self .classifier = nn.Sequential( nn.Dropout(0.5 ), nn.Linear(256 * 6 * 6 , 4096 ), nn.ReLU(inplace=True ), nn.Dropout(0.5 ), nn.Linear(4096 , 4096 ), nn.ReLU(inplace=True ), nn.Linear(4096 , num_classes), )
5.3 VGG(2014) VGG 的核心哲学:使用小的卷积核(3×3),堆叠更深的网络 。
两个 3×3 卷积的感受野等于一个 5×5 卷积,但参数量更少(2×9 = 18 vs 25),且有更多的非线性变换。这个理念影响了后续几乎所有 CNN 设计。
def vgg_block (in_channels, out_channels, num_convs ): layers = [] for _ in range (num_convs): layers.append(nn.Conv2d(in_channels, out_channels, 3 , padding=1 )) layers.append(nn.ReLU(inplace=True )) in_channels = out_channels layers.append(nn.MaxPool2d(2 , 2 )) return nn.Sequential(*layers)
5.4 ResNet(2015) ResNet 解决了深层网络训练困难(退化问题:更深的网络训练误差反而更高)的核心瓶颈。引入残差连接(Skip Connection) :
其中 F(x) 是残差函数(由 conv-bn-relu 堆叠),x 是恒等映射(identity mapping)。
class ResidualBlock (nn.Module): def __init__ (self, in_channels, out_channels, stride=1 ): super ().__init__() self .conv1 = nn.Conv2d(in_channels, out_channels, 3 , stride, 1 , bias=False ) self .bn1 = nn.BatchNorm2d(out_channels) self .conv2 = nn.Conv2d(out_channels, out_channels, 3 , 1 , 1 , bias=False ) self .bn2 = nn.BatchNorm2d(out_channels) self .shortcut = nn.Sequential() if stride != 1 or in_channels != out_channels: self .shortcut = nn.Sequential( nn.Conv2d(in_channels, out_channels, 1 , stride, bias=False ), nn.BatchNorm2d(out_channels) ) def forward (self, x ): out = F.relu(self .bn1(self .conv1(x))) out = self .bn2(self .conv2(out)) out += self .shortcut(x) out = F.relu(out) return out
ResNet 的深远影响 :残差连接是 GraphSAGE 中自连接(self-connection)的灵感来源,也是 GCN 中 Ã = A + I(添加自环)背后的直觉:节点在聚合邻居信息时应保留自身的信息。
5.5 Inception / GoogLeNet(2014) Inception 模块并行使用不同大小的卷积核(1×1, 3×3, 5×5)和一个池化分支,然后将所有分支的输出在通道维度上拼接:
class InceptionModule (nn.Module): def __init__ (self, in_channels, out_1x1, out_3x3_reduce, out_3x3, out_5x5_reduce, out_5x5, out_pool ): super ().__init__() self .branch1 = nn.Sequential( nn.Conv2d(in_channels, out_1x1, 1 ), nn.BatchNorm2d(out_1x1), nn.ReLU(inplace=True ) ) self .branch2 = nn.Sequential( nn.Conv2d(in_channels, out_3x3_reduce, 1 ), nn.BatchNorm2d(out_3x3_reduce), nn.ReLU(inplace=True ), nn.Conv2d(out_3x3_reduce, out_3x3, 3 , padding=1 ), nn.BatchNorm2d(out_3x3), nn.ReLU(inplace=True ) ) self .branch3 = nn.Sequential( nn.Conv2d(in_channels, out_5x5_reduce, 1 ), nn.BatchNorm2d(out_5x5_reduce), nn.ReLU(inplace=True ), nn.Conv2d(out_5x5_reduce, out_5x5, 5 , padding=2 ), nn.BatchNorm2d(out_5x5), nn.ReLU(inplace=True ) ) self .branch4 = nn.Sequential( nn.MaxPool2d(3 , stride=1 , padding=1 ), nn.Conv2d(in_channels, out_pool, 1 ), nn.BatchNorm2d(out_pool), nn.ReLU(inplace=True ) ) def forward (self, x ): return torch.cat([ self .branch1(x), self .branch2(x), self .branch3(x), self .branch4(x), ], dim=1 )
Inception 的多尺度聚合思想在 GAT(Graph Attention Networks)中体现为多头注意力(Multi-head Attention):不同注意力头学习不同的邻居加权方式,最后拼接或平均多个头的输出。
六、CNN 与 GNN 的核心联系 6.1 卷积 = 图上的消息传递 图像可以看作一种特殊的图:
每个像素是一个节点
每个像素与其周围 kernel_size 范围内的像素相连(规则网格的邻接关系)
节点特征 = 像素值(RGB 通道)
在这个视角下:
CNN 卷积层 GNN 消息传递层 ────────────────────────── ────────────────────────── 邻居 N(i,j) 由网格决定 邻居 N(v) 由图结构 A 定义 权重 W 由位置偏移决定 权重通常是共享或由注意力决定 聚合:加权求和 聚合:mean/sum/max/attention 输出:新的特征图 输出:新的节点嵌入
6.2 从图像到图:概念的推广
CNN 概念
GNN 对应概念
图像像素的规则网格
图的任意拓扑结构
卷积核(3×3, 5×5)
邻居聚合函数(AGGREGATE)
padding=”same”
GCN 的自环(A + I)
stride > 1
图池化(Graph Pooling)
多通道
多特征维度
pooling(2×2)
图粗化(Graph Coarsening)
Batch Norm
GraphNorm / NodeNorm
感受野随层数线性增长
K 层 GNN 覆盖 K-hop 邻居
6.3 关键公式对比 CNN 的卷积层:
H^{(l+1)}_{i,j} = σ( Σ_{m,n∈N} W^{(l)}_{m,n} · H^{(l)}_{i+m,j+n} )
GCN 的图卷积层:
H^{(l+1)} = σ( D^{-1/2} Ã D^{-1/2} H^{(l)} W^{(l)} )
两者形式惊人地相似:都在聚合局部邻居的信息,然后通过可学习的权重矩阵 W 进行变换。
根本区别 :CNN 的邻居关系由固定的网格结构决定(每个像素的邻居数量固定),GCN 的邻居关系由图的拓扑结构决定(每个节点的邻居数量可变)。GCN 通过邻接矩阵 A 来”告诉”网络谁是邻居。
七、从 CNN 到 GNN 的演变路径 1990s: CNN 在图像处理中取得成功(LeNet) ↓ 2012: AlexNet 引发深度学习革命 ↓ 2014: Bruna et al. 将 CNN 的谱域视角扩展到图(Spectral GCN) ↓ 2016: Defferrard et al. 使用切比雪夫多项式避免特征分解(ChebNet) ↓ 2017: Kipf & Welling 一阶近似 + 重归一化 → GCN(革命性简化) ↓ 2018: Velickovic et al. 引入注意力机制 → GAT ↓ 2019+: 更多变体:GraphSAGE, GIN, GatedGCN, ...
核心演变思路始终是:如何将 CNN 中”聚合邻居特征”的思想从规则网格推广到任意图结构 。
八、CNN 训练技巧与 GNN 的对应
CNN 技巧
GNN 对应
说明
Data Augmentation
DropEdge, Feature Masking
防止过拟合
BatchNorm
GraphNorm / LayerNorm
稳定训练
Skip Connection
GCN 自环 / JK-Net
缓解过平滑
Dropout
DropNode / DropEdge
正则化
Learning Rate Warmup
同样适用
Transformer 中的成功经验
Label Smoothing
同样适用
提升泛化能力
Stochastic Depth
DropGNN
随机丢弃层
面试/自查问题 Q1:CNN 的平移等变性在 GNN 中对应什么?
CNN 的平移等变性对应 GNN 的置换等变性(Permutation Equivariance) :如果对输入图的节点重新编号(置换),GNN 输出的嵌入也会做相同的重编号。这意味着 GNN 的输出不依赖于节点的输入顺序。这是所有 GNN 的基本设计约束。数学表达:
f(P·X, P·A·P^T) = P·f(X, A)
其中 P 是置换矩阵。
Q2:为什么 GCN 不能简单地像 CNN 那样堆叠很多层?
CNN 可以堆叠到 152 层(ResNet-152)甚至更多。但 GCN 通常只用 2-4 层。原因是过平滑(Over-smoothing) :随着 GCN 层数增加,所有节点的嵌入趋于相同(收敛到图的”平均”状态)。这是因为:
图拉普拉斯的平滑效应:每次聚合都在”模糊”节点特征
随着层数增加,每个节点的感受野指数增长,迅速覆盖全图
信息在大量”宽而浅”的路径上传播,失去区分性
解决方案包括:JK-Net(层间跳跃连接)、DropEdge(随机丢弃边)、GraphNorm(图专用归一化)。
Q3:ResNet 的残差连接在 GNN 中如何体现?
两种方式:
GCN 的自环 :Ã = A + I,相当于在聚合邻居时始终保留自身特征(h_v^{new} = h_v + Σ_{u∈N(v)} h_u)
GraphSAGE 的显式自连接 :h_v^{new} = W_1·h_v + W_2·AGGREGATE({h_u, u∈N(v)}),这与 ResNet 的 H(x) = F(x) + x 形式完全一致
九、CNN 的实现:从零搭建 9.1 完整的训练 Pipeline import torchimport torch.nn as nnimport torchvisionimport torchvision.transforms as transformstransform = transforms.Compose([ transforms.RandomHorizontalFlip(), transforms.RandomCrop(32 , padding=4 ), transforms.ToTensor(), transforms.Normalize((0.4914 , 0.4822 , 0.4465 ), (0.2023 , 0.1994 , 0.2010 )), ]) trainset = torchvision.datasets.CIFAR10(root='./data' , train=True , download=True , transform=transform) trainloader = torch.utils.data.DataLoader(trainset, batch_size=128 , shuffle=True , num_workers=2 ) testset = torchvision.datasets.CIFAR10(root='./data' , train=False , download=True , transform=transform) testloader = torch.utils.data.DataLoader(testset, batch_size=100 , shuffle=False , num_workers=2 ) class BasicBlock (nn.Module): expansion = 1 def __init__ (self, in_planes, planes, stride=1 ): super ().__init__() self .conv1 = nn.Conv2d(in_planes, planes, kernel_size=3 , stride=stride, padding=1 , bias=False ) self .bn1 = nn.BatchNorm2d(planes) self .conv2 = nn.Conv2d(planes, planes, kernel_size=3 , stride=1 , padding=1 , bias=False ) self .bn2 = nn.BatchNorm2d(planes) self .shortcut = nn.Sequential() if stride != 1 or in_planes != self .expansion * planes: self .shortcut = nn.Sequential( nn.Conv2d(in_planes, self .expansion * planes, kernel_size=1 , stride=stride, bias=False ), nn.BatchNorm2d(self .expansion * planes) ) def forward (self, x ): out = F.relu(self .bn1(self .conv1(x))) out = self .bn2(self .conv2(out)) out += self .shortcut(x) out = F.relu(out) return out device = torch.device('cuda' if torch.cuda.is_available() else 'cpu' ) model = ResNet18().to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1 , momentum=0.9 , weight_decay=5e-4 ) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=200 ) for epoch in range (200 ): model.train() for inputs, targets in trainloader: inputs, targets = inputs.to(device), targets.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, targets) loss.backward() optimizer.step() scheduler.step()
9.2 特征图可视化 了解 CNN 学到了什么:
import matplotlib.pyplot as pltdef visualize_feature_maps (model, image, layer_name ): """可视化指定卷积层的特征图""" activations = {} def hook_fn (module, input , output ): activations['output' ] = output for name, module in model.named_modules(): if name == layer_name: handle = module.register_forward_hook(hook_fn) break model.eval () with torch.no_grad(): model(image.unsqueeze(0 )) handle.remove() feature_maps = activations['output' ].squeeze(0 ) fig, axes = plt.subplots(4 , 4 , figsize=(10 , 10 )) for i, ax in enumerate (axes.flat): if i < feature_maps.shape[0 ]: ax.imshow(feature_maps[i].cpu(), cmap='viridis' ) ax.axis('off' ) plt.show()
早期层的特征图检测边缘、纹理;中间层检测角和更复杂的纹理图案;深层检测语义意义上的目标部件(眼睛、轮子、文字区域)。
十、CNN 到 GNN 的数学桥梁 10.1 图上的卷积定义 回顾 CNN 的卷积公式:
h'_{i,j} = Σ_{(m,n)∈N(i,j)} w_{m,n} · h_{i+m, j+n}
在图上,设每个节点 v 有特征向量 x_v ∈ R^{d_in},节点 v 的邻居集合为 N(v)。图卷积的通用形式:
h'_v = AGGREGATE( { TRANSFORM(x_u) : u ∈ N(v) ∪ {v} } )
其中 AGGREGATE 是聚合函数(sum, mean, max, attention-weighted sum),TRANSFORM 是特征变换(通常是线性变换 x_u W)。
GCN 的具体形式 (Kipf & Welling, 2017):
H^{(l+1)} = σ( D̃^{-1/2} Ã D̃^{-1/2} H^{(l)} W^{(l)} )
这与 CNN 的卷积层在数学本质上完全平行:
CNN:规则网格邻域 + 权重共享
GCN:任意图邻域 + 权重共享 + 邻接矩阵归一化
10.2 感受野的对比
CNN 的感受野 :随层数线性增长。感受野 = 2KL + 1(K 层,kernel_size=L,stride=1)
GNN 的感受野 :随层数指数增长。K 层覆盖 K-hop 邻居,对稠密图几乎等于全图
这就是为什么 CNN 可以轻松训练 100+ 层(感受野逐步扩大,渐进地学习更大尺度的特征),而 GNN 通常不超过 4 层(2-3 层已经覆盖了大多数节点的大部分近邻)。