ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

PyTorch全连接层与全卷积层对比:从原理到实战

PyTorch全连接层与全卷积层对比:从原理到实战

全连接层和全卷积层,这两个东西刚学深度学习的时候特别容易混。我第一次看到“全卷积”这个词,脑子里第一反应是:不就是把全连接换成卷积吗,有什么好讲的。结果真到代码里一跑,发现事情没那么简单——参数量、输入尺寸、输出形状、能不能处理任意分辨率,处处都是坑。这篇就围绕 PyTorch 代码,把这两个层从定义、计算过程、参数量、实际用法到常见误区,一层一层拆开讲清楚。不管你是刚入门想搞明白nn.Linear和nn.Conv2d到底差在哪,还是已经写过几个模型但对“全卷积网络为什么能接受任意尺寸输入”一直似懂非懂,这篇都能帮你把这块知识补扎实。

1. 先把两个层放到同一张桌子上对比

很多人学的时候是分开学的:先学全连接,再学卷积,然后脑子里两套体系各自为政。要真正理解区别,最好的办法是把它们放在同一个输入上,用代码跑一遍,看看到底发生了什么。

1.1 从数学定义看本质差异

全连接层的核心运算就是矩阵乘法。假设输入是一个长度为in_features的向量,输出是长度为out_features的向量,那么这一层做的事情就是:

y = x @ W.T + b

其中W的形状是(out_features, in_features),b的形状是(out_features,)。每一个输出神经元都和每一个输入神经元相连,所以叫“全连接”。参数量就是in_features * out_features + out_features。

全卷积层,严格来说并不是一个 PyTorch 里的独立层类型,而是指整个网络全部由卷积层(以及池化、激活等)构成,不含全连接层。但日常交流中,大家说“全卷积层”往往指的是卷积层本身,尤其是那种用1x1卷积或者kernel_size覆盖整个输入空间的卷积操作,用来替代全连接的功能。

卷积层的核心运算是滑动窗口内的乘加。以nn.Conv2d为例,输入形状是(N, C_in, H, W),输出形状是(N, C_out, H_out, W_out)。每个输出位置的值,是卷积核在对应输入窗口上的加权求和。参数量是C_in * C_out * kH * kW + C_out,和输入的空间尺寸H、W无关。

这个“和空间尺寸无关”是卷积层最关键的属性,也是后面一切差异的根源。

1.2 用一段最小代码把两者跑通

先看全连接层在 PyTorch 里的基本用法:

import torch import torch.nn as nn # 全连接层示例 fc = nn.Linear(in_features=8, out_features=4) x = torch.randn(2, 8) # batch_size=2, 特征维度=8 y = fc(x) print("输入形状:", x.shape) # torch.Size([2, 8]) print("输出形状:", y.shape) # torch.Size([2, 4]) print("权重形状:", fc.weight.shape) # torch.Size([4, 8]) print("偏置形状:", fc.bias.shape) # torch.Size([4])

再看卷积层:

# 卷积层示例 conv = nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, padding=1) x = torch.randn(2, 3, 32, 32) # batch_size=2, 通道=3, 高=32, 宽=32 y = conv(x) print("输入形状:", x.shape) # torch.Size([2, 3, 32, 32]) print("输出形状:", y.shape) # torch.Size([2, 16, 32, 32]) print("权重形状:", conv.weight.shape) # torch.Size([16, 3, 3, 3]) print("偏置形状:", conv.bias.shape) # torch.Size([16])

跑完这两段,你应该能直观看到几个区别:全连接的输入是二维(N, features),卷积的输入是四维(N, C, H, W);全连接的权重是二维矩阵,卷积的权重是四维张量;全连接的输出维度由out_features决定,卷积的输出空间维度由输入尺寸、卷积核大小、padding、stride 共同决定。

1.3 参数量对比:一个让人肉疼的数字

拿一个具体场景算一下。假设输入是一张3 x 224 x 224的图片,要映射到 1000 维的输出。

如果用全连接层,需要先把图片展平成3*224*224 = 150528维的向量,然后接一个nn.Linear(150528, 1000)。参数量是:

150528 * 1000 + 1000 = 150,529,000

一亿五千万个参数,光这一层就 600MB 左右(float32)。这也是为什么早期的分类网络在进入全连接层之前要拼命做下采样,把空间尺寸降到7x7再展平。

如果用卷积层来做同样的映射,比如用nn.Conv2d(3, 1000, kernel_size=224),参数量是:

3 * 1000 * 224 * 224 + 1000 = 150,529,000

诶,一样?对,因为当卷积核大小等于输入空间尺寸时,卷积在数学上等价于全连接。这个等价关系后面会专门讲,它是理解两者关系的一把钥匙。

但如果用1x1卷积做通道映射,比如nn.Conv2d(3, 1000, kernel_size=1),参数量只有:

3 * 1000 * 1 * 1 + 1000 = 4000

四千个参数,差了将近四万倍。这就是卷积层“参数共享”带来的威力——同一个卷积核在空间上反复使用,参数量不随空间尺寸增长。

2. 全连接层在 PyTorch 里的那些细节

全连接层看起来简单,但实际用起来有几个点特别容易出错,尤其是维度匹配和 batch 处理。

2.1 nn.Linear 的输入维度规则

nn.Linear接受的输入可以是任意维度的张量,但它只对最后一维做线性变换,前面的维度全部当作 batch 维度保留。这一点很多人没注意到。

fc = nn.Linear(8, 4) # 输入是二维 x1 = torch.randn(2, 8) print(fc(x1).shape) # torch.Size([2, 4]) # 输入是三维 x2 = torch.randn(2, 5, 8) print(fc(x2).shape) # torch.Size([2, 5, 4]) # 输入是四维 x3 = torch.randn(2, 5, 6, 8) print(fc(x3).shape) # torch.Size([2, 5, 6, 4])

这个特性在 Transformer 里用得非常多,比如nn.Linear(d_model, d_ff)可以直接作用在(batch, seq_len, d_model)上,不需要手动 reshape。但反过来说,如果你以为它会自动展平前面的维度,就会踩坑。

2.2 从卷积输出到全连接输入的展平操作

经典 CNN 里,卷积部分输出的是(N, C, H, W),要接全连接层必须先展平。标准写法是:

class SimpleCNN(nn.Module): def __init__(self, num_classes=10): super().__init__() self.conv = nn.Sequential( nn.Conv2d(3, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.fc = nn.Linear(32 * 8 * 8, num_classes) def forward(self, x): x = self.conv(x) x = x.view(x.size(0), -1) # 展平,保留 batch 维度 x = self.fc(x) return x

这里x.view(x.size(0), -1)是关键。x.size(0)是 batch 大小,-1让 PyTorch 自动推断剩余维度。等价写法是torch.flatten(x, 1),从第 1 维开始展平。

注意:view要求张量在内存里是连续的。如果前面有permute、transpose之类的操作,需要先.contiguous(),否则会报错。reshape则会自动处理连续性,但可能触发一次拷贝。追求性能时用view,图省事用reshape。

2.3 全连接层的参数量计算与显存占用

nn.Linear(in_features, out_features)的参数量是in_features * out_features + out_features。显存占用(float32)大约是参数量的 4 倍字节数,再加上反向传播时保存的梯度、中间激活值等,实际占用会更大。

举个例子,nn.Linear(4096, 4096):

  • 参数量:4096 * 4096 + 4096 = 16,781,312
  • 权重显存:约 64MB
  • 加上梯度:约 128MB
  • 如果 batch 较大,中间激活值还会额外占用

这也是为什么大模型里全连接层的显存优化(比如 LoRA、量化)是个热门方向。

3. 全卷积层的核心机制拆解

卷积层的东西比全连接多得多,padding、stride、dilation、groups 每一个都会影响输出形状和计算方式。这里只挑和“全连接 vs 全卷积”这个主题最相关的部分讲。

3.1 卷积输出尺寸的计算公式

对于nn.Conv2d,输出空间尺寸的计算公式是:

H_out = floor((H_in + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1) W_out = floor((W_in + 2 * padding - dilation * (kernel_size - 1) - 1) / stride + 1)

大多数情况下dilation=1,公式简化为:

H_out = floor((H_in + 2 * padding - kernel_size) / stride + 1)

用代码验证一下:

conv = nn.Conv2d(3, 16, kernel_size=3, stride=1, padding=1) x = torch.randn(1, 3, 32, 32) print(conv(x).shape) # torch.Size([1, 16, 32, 32]) conv2 = nn.Conv2d(3, 16, kernel_size=3, stride=2, padding=1) print(conv2(x).shape) # torch.Size([1, 16, 16, 16]) conv3 = nn.Conv2d(3, 16, kernel_size=5, stride=1, padding=0) print(conv3(x).shape) # torch.Size([1, 16, 28, 28])

理解这个公式非常重要,因为全卷积网络要处理任意尺寸输入时,你得清楚每一层会把尺寸变成多少,最后怎么恢复到原尺寸。

3.2 1x1 卷积:最像全连接的卷积

1x1卷积是理解全连接和全卷积关系的最佳切入点。它的卷积核空间尺寸是 1,只在通道维度上做加权组合。

conv1x1 = nn.Conv2d(in_channels=64, out_channels=128, kernel_size=1) x = torch.randn(2, 64, 16, 16) y = conv1x1(x) print(y.shape) # torch.Size([2, 128, 16, 16])

从形状上看,空间尺寸16x16完全保留,通道数从 64 变成 128。如果把这个操作等价地看成全连接:对每一个空间位置(i, j),都有一个64 -> 128的全连接变换,而且所有位置共享同一组权重。

# 等价的全连接实现 fc = nn.Linear(64, 128) x_permuted = x.permute(0, 2, 3, 1) # (2, 16, 16, 64) y_fc = fc(x_permuted) # (2, 16, 16, 128) y_fc = y_fc.permute(0, 3, 1, 2) # (2, 128, 16, 16) # 验证两者是否等价(需要把 conv 的权重对应过去) print(y.shape, y_fc.shape) # 都是 torch.Size([2, 128, 16, 16])

这个等价关系说明:1x1卷积就是“在空间上共享权重的全连接层”。参数量从H*W*64*128降到64*128,省下来的就是空间维度上的重复。

3.3 卷积核覆盖整个输入:与全连接的严格等价

当卷积核的空间尺寸等于输入的空间尺寸,且没有 padding、stride=1 时,卷积的输出空间尺寸是1x1,此时卷积在数学上严格等价于全连接。

# 输入 3x8x8,用 8x8 的卷积核 conv = nn.Conv2d(3, 10, kernel_size=8) x = torch.randn(2, 3, 8, 8) y = conv(x) print(y.shape) # torch.Size([2, 10, 1, 1]) # 等价的全连接 fc = nn.Linear(3 * 8 * 8, 10) y_fc = fc(x.view(2, -1)) print(y_fc.shape) # torch.Size([2, 10])

把y展平后和y_fc形状一致。如果手动把conv.weightreshape 成(10, 3*8*8)赋给fc.weight,两者的输出会完全相同。这个等价关系是很多“用卷积替代全连接”技巧的理论基础。

4. 全卷积网络为什么能接受任意尺寸输入

这是全卷积最实用的特性,也是面试里经常被问到的点。理解它,需要先理解全连接层为什么“不能”。

4.1 全连接层的尺寸绑定问题

nn.Linear(in_features, out_features)里的in_features是写死的。一旦训练时用的是7*7*512的输入,推理时换成14*14*512,直接就会报维度不匹配的错。

fc = nn.Linear(512 * 7 * 7, 10) x = torch.randn(1, 512, 14, 14) try: y = fc(x.view(1, -1)) except RuntimeError as e: print("报错:", e) # 报错: mat1 and mat2 shapes cannot be multiplied (1x100352 and 25088x10)

这个限制在实际部署里很麻烦。比如你想让模型支持不同分辨率的输入,或者想用全卷积的思路做密集预测,全连接层就成了绊脚石。

4.2 卷积层对空间尺寸的天然适应

卷积层的权重形状是(C_out, C_in, kH, kW),和输入的空间尺寸H、W无关。所以同一个卷积层可以作用在32x32的输入上,也可以作用在64x64的输入上,只要通道数匹配就行。

conv = nn.Conv2d(3, 16, kernel_size=3, padding=1) x1 = torch.randn(1, 3, 32, 32) x2 = torch.randn(1, 3, 64, 64) x3 = torch.randn(1, 3, 128, 128) print(conv(x1).shape) # torch.Size([1, 16, 32, 32]) print(conv(x2).shape) # torch.Size([1, 16, 64, 64]) print(conv(x3).shape) # torch.Size([1, 16, 128, 128])

同一个conv对象,三组不同尺寸的输入,全部跑通,输出空间尺寸跟着输入走。这就是全卷积网络能处理任意尺寸的根本原因。

4.3 把分类网络改造成全卷积的实操

假设你有一个训练好的分类网络,最后几层是:

self.features = nn.Sequential( # ... 一堆卷积和池化 ... ) self.classifier = nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(), nn.Dropout(), nn.Linear(4096, 4096), nn.ReLU(), nn.Dropout(), nn.Linear(4096, 1000), )

要把它改成全卷积,核心操作是把nn.Linear换成等价的nn.Conv2d:

self.classifier = nn.Sequential( nn.Conv2d(512, 4096, kernel_size=7), # 等价于 Linear(512*7*7, 4096) nn.ReLU(), nn.Dropout(), nn.Conv2d(4096, 4096, kernel_size=1), # 等价于 Linear(4096, 4096) nn.ReLU(), nn.Dropout(), nn.Conv2d(4096, 1000, kernel_size=1), # 等价于 Linear(4096, 1000) )

改造之后,输入224x224得到1x1x1000的输出,输入448x448得到2x2x1000的输出,每个空间位置对应原图一个区域的分类结果。这就是全卷积网络做密集预测的基本思路。

提示:改造时要注意kernel_size的对应关系。第一个卷积的kernel_size要等于原来全连接输入的空间尺寸(这里是 7),后面的1x1卷积对应全连接。权重可以从原来的nn.Linear直接 reshape 过去,不需要重新训练。

5. 代码实战:同一任务两种实现方式的完整对比

光看理论不够,这里用一个完整的分类任务,分别用全连接和全卷积两种方式实现,对比它们的参数量、输出形状和推理灵活性。

5.1 任务设定与数据准备

任务很简单:输入3 x 32 x 32的图片,输出 10 类。我们用随机数据模拟,重点看网络结构和前向传播。

import torch import torch.nn as nn # 模拟数据 batch_size = 4 x = torch.randn(batch_size, 3, 32, 32) labels = torch.randint(0, 10, (batch_size,))

5.2 全连接版本实现

class FCNet(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 32 -> 16 nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 16 -> 8 nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 8 -> 4 ) self.classifier = nn.Sequential( nn.Linear(64 * 4 * 4, 128), nn.ReLU(), nn.Linear(128, num_classes), ) def forward(self, x): x = self.features(x) x = x.view(x.size(0), -1) x = self.classifier(x) return x fc_net = FCNet() out = fc_net(x) print("全连接版本输出:", out.shape) # torch.Size([4, 10]) # 统计参数量 total_params = sum(p.numel() for p in fc_net.parameters()) print("全连接版本总参数量:", total_params)

5.3 全卷积版本实现

class FCNNet(nn.Module): def __init__(self, num_classes=10): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.Conv2d(64, 128, kernel_size=4), # 等价于 Linear(64*4*4, 128) nn.ReLU(), nn.Conv2d(128, num_classes, kernel_size=1), # 等价于 Linear(128, 10) ) def forward(self, x): x = self.features(x) x = self.classifier(x) return x.view(x.size(0), -1) fcn_net = FCNNet() out = fcn_net(x) print("全卷积版本输出:", out.shape) # torch.Size([4, 10]) total_params = sum(p.numel() for p in fcn_net.parameters()) print("全卷积版本总参数量:", total_params)

两个版本的参数量应该非常接近,因为kernel_size=4的卷积和Linear(64*4*4, 128)参数量相同。差别在于全卷积版本可以接受更大的输入。

5.4 两种版本在变尺寸输入下的行为对比

这是最能体现差异的实验:

# 用 64x64 的输入测试 x_large = torch.randn(4, 3, 64, 64) # 全连接版本会报错 try: out_fc = fc_net(x_large) print("全连接版本输出:", out_fc.shape) except RuntimeError as e: print("全连接版本报错:", str(e)[:80]) # 全卷积版本正常运行 out_fcn = fcn_net(x_large) print("全卷积版本输出:", out_fcn.shape)

全连接版本会在view之后维度对不上而报错,全卷积版本则能正常跑通,输出形状是(4, 10)——因为最后view把空间维度展平了。如果你不做最后的view,全卷积版本会输出(4, 10, 2, 2),每个空间位置对应原图一个区域的分类结果。

这个实验说明:全卷积版本在保持相同参数量的同时,获得了处理任意尺寸输入的能力。代价是输出不再是单一向量,而是空间特征图,需要根据任务决定怎么聚合。

6. 实际项目里怎么选:场景与经验

理论和代码都清楚了,最后聊聊实际项目里怎么选。这不是非黑即白的问题,很多时候两者会混用。

6.1 什么时候必须用全连接

全连接层在以下场景仍然是首选:

  • 分类任务的最后一步:把特征图映射到类别 logits,全连接最直接。
  • 特征维度固定且不大:比如 tabular 数据、已经提取好的 embedding,全连接简单高效。
  • 需要严格的全局信息聚合:全连接对每个输入都赋予独立权重,表达能力更强。

6.2 什么时候全卷积更合适

  • 输入尺寸不固定:比如不同分辨率的图片、可变长度的序列(配合 1D 卷积)。
  • 密集预测任务:语义分割、目标检测的特征图输出,需要每个位置都有预测。
  • 参数量敏感:全卷积通过权重共享大幅减少参数。
  • 需要保留空间信息:全连接会丢失空间结构,全卷积保留。

6.3 混用时的注意事项

实际网络里经常是卷积 + 全连接混用。几个经验点:

第一,从卷积到全连接的过渡处,view或flatten之前要确认张量连续。如果前面用了permute,记得.contiguous()。

第二,全连接层的输入维度最好用nn.AdaptiveAvgPool2d(1)之类的自适应池化先固定下来,而不是硬编码H*W。这样换输入尺寸时不用改代码。

# 推荐写法:用自适应池化避免硬编码 self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Linear(512, num_classes) def forward(self, x): x = self.features(x) x = self.pool(x) # (N, 512, 1, 1) x = x.view(x.size(0), -1) # (N, 512) x = self.fc(x) return x

第三,如果要做全卷积改造,nn.Linear到nn.Conv2d的权重转换要小心维度顺序。nn.Linear的权重是(out, in),nn.Conv2d的权重是(out, in, kH, kW),reshape 时要把in维度拆成(C, H, W)。

# Linear(64*4*4, 128) -> Conv2d(64, 128, kernel_size=4) linear_weight = fc.weight.data # (128, 64*4*4) conv_weight = linear_weight.view(128, 64, 4, 4) conv.weight.data.copy_(conv_weight) conv.bias.data.copy_(fc.bias.data)

这个转换在模型迁移和知识蒸馏里很常用,值得记住。

6.4 一个容易忽略的坑:BatchNorm 和 Dropout 在全卷积里的行为

把全连接换成全卷积后,如果网络里有BatchNorm或Dropout,行为会变。BatchNorm2d在(N, C, H, W)上对每个通道做归一化,空间维度参与统计;而BatchNorm1d在(N, C)上做。全卷积版本里如果还用BatchNorm1d,需要先展平,就失去了全卷积的意义。

Dropout同理,Dropout2d会随机丢弃整个通道,Dropout是逐元素丢弃。全卷积网络里通常用Dropout2d,保持空间结构的一致性。

这些细节在改造网络时特别容易忽略,导致推理结果和预期不符。我的建议是:改造完先用一组已知输入对比改造前后的输出,确认数值一致后再上训练。

7. 几个高频误区的澄清

最后集中澄清几个我在交流中经常遇到的误区。

误区一:全卷积网络就是没有全连接层的网络。这个说法基本对,但要注意“全卷积”强调的是网络结构,不是某一层。一个网络只要全部由卷积、池化、激活等操作构成,就可以叫全卷积网络。

误区二:卷积层一定比全连接层参数少。不一定。当卷积核尺寸等于输入空间尺寸时,两者参数量相同。卷积省参数的前提是权重共享,如果卷积核覆盖整个输入,共享就无从谈起。

误区三:全卷积网络不能做分类。能做。只是输出是空间特征图,需要额外一步聚合(比如全局平均池化)才能得到分类结果。这一步可以用nn.AdaptiveAvgPool2d(1)完成。

误区四:全连接层不能处理变尺寸输入。严格说,nn.Linear本身只关心最后一维,如果前面的维度变化,只要最后一维匹配就能跑。问题出在从卷积输出到全连接的展平操作上,是展平把空间尺寸绑死了,不是nn.Linear本身。

理解这几个误区,你对两个层的认识就基本到位了。真正写代码的时候,多跑几组不同尺寸的输入,多打印形状和参数量,比看多少理论都管用。我自己就是踩过几次维度不匹配的报错之后,才把这块彻底搞明白的。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进