先聊点实在的。很多接触PyTorch的人,搭第一个网络时十有八九会碰到这行代码:torch.nn.Linear(in_features, out_features, bias=True)。有人叫它全连接层,有人叫它线性层,还有人干脆叫Dense层(换个框架的叫法)。它在各种分类模型、Transformer、甚至推荐系统里都极其常见,可恰恰因为它太基础,很多人在使用中反而容易一直没搞透:in_features到底跟输入张量哪一维对齐?bias什么时候该关掉?参数量到底怎么算?为什么模型最后一层都喜欢接一个Linear?
这篇文章就围绕这几个问题展开。会先讲清楚torch.nn.Linear的数学本质和设计动机,然后深入到参数细节、初始化和维度规则,再给出一套可直接复现的代码实操流程,最后把实际训练中经常遇到的那些坑梳理一遍。适合刚入门想扎实基础的人,也适合已经跑通不少模型但对某些细节理解模糊的进阶者——看完至少能帮你省下几次调试维度报错的抓狂时间。
1. 全连接层背后的数学和设计动机
1.1 一个Linear层到底在做什么变换
torch.nn.Linear做的事情,从数学上可以用一个很朴素的式子概括:
y = x·W^T + b
这里x是输入,形状通常是(batch, in_features),W是权重矩阵,形状是(out_features, in_features),b是偏置向量,形状是(out_features,)。整个变换就是一次矩阵乘法再加一个逐元素加法。更直观一点,把输入看成in_features个特征值,输出看成out_features个新特征值,那么每个输出特征都是所有输入特征的加权求和,最后再加上一个偏移量。
所以“全连接”这个名字是很有画面感的——前一层每个节点和后一层每个节点之间都有一条权重连线,全都有连接,一个不漏。这种密集连接的形态天然适合做特征混合和维度转换,比如把一个高维特征压缩成几个逻辑类别,或者把某个中间表示投影到更高维空间去增强表达能力。Transformer里的FFN模块,本质就是两个Linear层中间夹一个激活函数,这说明了Linear层在现代网络结构中的普适性。
和卷积层对比会更清晰:卷积层通过局部感受野做特征提取,保留空间结构;而全连接层是全局地把每个位置的特征“揉”在一起,不考虑空间位置关系。因此它通常被放在网络尾部,把前面卷积或注意力结构提取出来的语义特征映射到最终输出维度上。
1.2 参数顺序为什么是(in_features, out_features)
这是很多人刚上手时会愣一下的问题。图形上也经常有人写成Linear(in, out),但真正要注意的是PyTorch把这个顺序定义成了输入维度在前、输出维度在后。一个常见的困惑是:“为什么不是out先?”原因在于torch.nn.Linear的定义方式是仿射变换y = x A^T + b,参数名直接对应数学符号里的in_features和out_features,和输入张量的最后一维大小保持一致。
举个例子,如果有一个特征向量维度是128,你想映射成10类得分,那写法就是nn.Linear(128, 10)。写成nn.Linear(10, 128)就反了,运行时立刻报错。所以记住一个口诀:第一个数字吃输入,第二个数字吐输出。这个参数顺序在PyTorch各个模块中基本是统一的——nn.Conv2d是(in_channels, out_channels, kernel_size),nn.BatchNorm1d是(num_features,),都遵循同样的心智模型。
这个设计的直接好处是当你看到一段模型代码时,不用去翻文档就能通过形状逐层推导出张量流动的路径。比如从64 -> 128 -> 10,你立刻就知道输入特征是64,最后分类数是10。这种可读性对调试和团队协作都很重要。
2. 容易被忽视的三个核心细节
2.1 输入张量形状与最后一维对齐规则
nn.Linear层对输入形状有一个看似宽松实则严格的约束:它要求输入张量的最后一个维度大小等于in_features。如果输入是二维(batch, in_features),很好理解;但如果输入是三维、四维呢?比如图像特征图(batch, channels, height, width)直接送进Linear层,很多新手就懵了。
实际规则是:Linear层会从最后一个维度上做仿射变换,其余所有维度一律当作batch维度保留。也就是说,输入(batch, seq_len, hidden)经过nn.Linear(hidden, out)后,输出是(batch, seq_len, out)。听起来很棒,但常见的报错也由此而来——如果把一个(B, C, H, W)的图像特征图直接喂给nn.Linear,其中最后一位W必须等于in_features才行,而PyTorch在处理这个输入时会把(B, C, H)整体视为batch,导致实际batch维度远大于预期,这与图像分类网络的做法相去甚远。
所以,在实践中如果你处理的是特征图,通常需要先做一个全局池化或者view操作,把特征张量压扁成(B, features)之后再接Linear层。比如x = x.mean(dim=[2,3])或者x = x.view(B, -1)。这条经验在写CNN分类头时几乎每次都会用上。
2.2 bias什么时候默认打开,什么时候建议关掉
bias参数默认是True,大多数情况下你应该保持默认。bias的作用相当于一次平移变换,比如y = kx + b里的b,没有它,线性变换的直线永远过原点,模型的拟合能力会打折扣。对于大多数全连接任务,不加偏置几乎总是会导致训练效果变差——除非你明确知道自己在做什么。
常见的两个例外让我提一下。第一个是当Linear层后面马上接nn.BatchNorm层时,bias可能冗余,因为BatchNorm本身包括了平移和缩放操作,两个连续的偏置项会浪费参数。严格来说此时可以把bias设为False。第二个是做某些矩阵分解或正交初始化实验时,为了构造特定的权重结构,也可能主动关闭bias。
一个比较隐蔽的坑是:有些模型为了省参数,把某几个Linear层的bias都关了,但后续层却忘了调整均值偏移,结果训练早期损失下降突然变慢。我的经验是除非对网络结构有明确把握,否则别轻易关bias。在写自定义模型时,每次决定关bias之前都先反问一下:这层的输出紧接着什么处理?它真的需要去掉偏移吗?
2.3 权重初始化和随机种子:一眼看出问题
nn.Linear在创建时就会初始化权重和偏置,不需要手动介入,但理解它的默认策略很重要。PyTorch对Linear层的默认初始化方式是kaiming_uniform_,权重矩阵取值在[-bound, bound]之间,其中bound等于sqrt(6 / (in_features + out_features))关联到激活函数选择。为什么很多老手训练时对初始化的敏感度这么高?因为深层网络一旦初始值范围不当,前向传播的方差会被逐层放大或缩小,轻则训练慢,重则梯度消失或爆炸。
还有个容易被忽略的细节是随机种子。如果你的实验复现需要固定初始权重,别忘了在创建模型前设置好seed,比如:
import torch torch.manual_seed(42) model = nn.Linear(128, 10)如果你在模型初始化之后才设置seed,训练多次会发现结果差异特别大,搞不清是随机性还是代码bug。这里我通常的做法是:把seed设置放在所有模型实例化之前,并且每次实验记录下seed值,方便回溯问题。
3. 实操:从定义一个网络到参数量估算
3.1 最小可运行的Linear网络示例
这部分我直接给一个可以复制跑通的例子。假设我们要构建一个两层全连接网络,输入维度是128,隐藏层维度是64,输出维度是10,中间使用ReLU激活,同时为了缓解过拟合加一个Dropout。
import torch import torch.nn as nn class SimpleMLP(nn.Module): def __init__(self, in_features=128, hidden_features=64, out_features=10): super().__init__() self.fc1 = nn.Linear(in_features, hidden_features) self.relu = nn.ReLU() self.dropout = nn.Dropout(p=0.2) self.fc2 = nn.Linear(hidden_features, out_features) def forward(self, x): x = self.fc1(x) x = self.relu(x) x = self.dropout(x) x = self.fc2(x) return x model = SimpleMLP() print(model) # 模拟一个batch=32、特征数=128的输入 x = torch.randn(32, 128) y = model(x) print(y.shape) # 期待输出 torch.Size([32, 10])这个例子展示了最典型的Linear层使用方式:输入先降维到隐藏层,非线性激活,再映射到最终类别数。实际训练中可能还会在fc1后面加BatchNorm,但放在这里为了让原始代码尽量简单。你可以复制这段代码在本地跑一下,感受输出形状的变化。
3.2 参数量和FLOPs怎么算,心里要有一本账
现在谈谈参数量。nn.Linear的参数量由两部分组成:权重矩阵(out_features × in_features)和偏置向量(out_features)。如果bias=False,就只算权重部分。公式非常简单:
参数个数 =
in_features × out_features + (out_features if bias else 0)
举个例子:nn.Linear(1024, 512),参数量就是1024 × 512 + 512 = 524800,约0.5M。听起来还好,但如果你在Transformer的FFN里用两个Linear(768, 3072),那光这一层就有768 × 3072 × 2 ≈ 4.7M个参数。所以全连接层的参数量常常在模型总参数中占很大比例,这也解释了为什么卷积网络的设计者到后期特别喜欢用1x1卷积或全局平均池化来替代全连接层。
FLOPs的计算则是:每个输入特征和每个输出神经元之间有一次乘法加一次加法,所以矩阵乘法的FLOPs大约是2 × in_features × out_features乘以batch中的有效样本数。在做模型轻量化对比时,给自己准备一张小的速查表会很有帮助。我习惯在训练前先打印模型参数量,和预估的显存数字对照,一旦差距过好几倍就要重新检查模型结构。
3.3 forward传播中的张量流动过程
在SimpleMLP的forward里,整个过程可以分解为几个数学步骤,我来逐步拆解一下:
- 输入
(32, 128),进入fc1。fc1的权重形状为(64, 128)。PyTorch内部执行x @ W.T + b,输出(32, 64)。 - ReLU激活函数把负值全部置零,形状不变。
- Dropout以概率
p随机丢弃部分激活值。这个操作在训练和推理阶段行为不同,model.eval()之后会自动关闭,形状仍然不变。 - 输入
(32, 64),进入fc2,其权重形状为(10, 64),输出(32, 10)。
你可能会疑惑:为什么输出层不用加softmax?因为在PyTorch中,交叉熵损失函数nn.CrossEntropyLoss()内部已经把softmax和log计算合并进去了。直接输出logits即可,不需要在网络里手动加softmax。如果加了反而可能在数值稳定性和梯度传播上给自己找麻烦。这个细节容易忽视,我见过有人在最后的Linear后面又强行接一个nn.Softmax,再配合CrossEntropyLoss,结果训练状态变得很诡异。
3.4 动态推导in_features的一种实用写法
很多时候,in_features并不像例子中那么固定。比如在一个自定义模块中,前面经过卷积层后特征图大小是变化的,手动记录数值很容易出错。一种相对稳妥的写法是先为输入创建一个随机张量,通过一次临时前向计算出中间特征形状,再实例化Linear层。
import torch import torch.nn as nn class DynamicModel(nn.Module): def __init__(self, in_channels=3, num_classes=10): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_channels, 16, kernel_size=3, stride=1, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d((1, 1)) ) # 先占位,后续具体确定 self.classifier = None self.num_classes = num_classes def forward(self, x): x = self.conv(x) x = x.view(x.size(0), -1) if self.classifier is None: in_features = x.size(1) self.classifier = nn.Linear(in_features, self.num_classes) # 注意:这里把新模块注册到当前模块中 self.add_module("classifier", self.classifier) out = self.classifier(x) return out这种方式在构建某些复杂结构(比如先在forward里做多分支拼接再决定全连接输入维度)时确实能省不少事。不过要提醒一句:动态注册模块在某些场景下可能带来模型加载和导出的麻烦,最好在确认结构稳定后还是写死维度并重建模型。
4. 常见问题与排查技巧实录
4.1 经典报错:“mat1 and mat2 shapes cannot be multiplied”
这个问题几乎是所有PyTorch初学者的必经之路。报错信息通常是:
RuntimeError: mat1 and mat2 shapes cannot be multiplied (32x20 and 10x64)含义是:当前输入张量形状是(32, 20),而你定义的Linear层期望输入维度是64(因为权重形状是(10, 64))。发生这种情况,一般有两个原因:
第一个原因是in_features定义错误,你期望传入的是64维特征,但实际上一路forward下来最后一维变成了20。这种错误经常出现在前面有卷积或reshape操作的时候。第二个原因是输入张量在进入Linear之前缺少一次扁平化操作,本来形状是(B, C, H, W),应该view(B, -1)之后才能进Linear,结果忘了处理,最后一维成了W。
排查方法也没有太高深的:用print(x.shape)在每个关键层前面打印调试信息,或者使用现成的torchsummary库直接输出每层的输出形状。我自己更倾向于手动打印,尤其是网络结构不复杂时,效率反而高。如果你描述一下自己网络每一层的输出shape,问题几乎立刻就能定位。
4.2 为什么网络层数不深但参数量巨大,训练速度慢
另一个高频问题是:模型层数没几层,但参数量很大,训练一个batch都慢。最可能的元凶就是从某个大维度的in_features到另一个大维度的out_features的全连接变换。比如图像特征2048维直接映射到1024维,权重矩阵就有200万参数。
解决思路分几种。一是降维再映射,比如先Linear(2048, 512)再Linear(512, 1024),参数量立刻降不少,还顺带增强了非线性。二是改用1x1卷积替代全连接,因为当时输入特征图的空间分辨率正好是1x1时,nn.Conv2d(in, out, kernel_size=1)和nn.Linear(in, out)在数学上是等价的。三是使用低秩分解,即把一个大矩阵分解成两个小矩阵相乘,这在某些场景下效果出乎意料地好。
这里我也想顺带提一下显存问题。全连接层因为权重矩阵大、中间激活也大,在训练时占显存尤其明显。如果发现显存不够,第一个该考虑的就是把最后一两组全连接层的隐藏维度缩小,或者干脆改用混合精度训练,大部分时候见效很快。
4.3 线性层容易过拟合?Dropout要用在点子上
全连接层参数量多,拟合能力强,但也极其容易过拟合,尤其是在数据集不太大的情况下。训练集损失一路下降,验证集损失先降后升,这时候除了考虑减小模型容量之外,最直接的介入手段就是加Dropout。Dropout的作用是在训练时随机丢弃一部分神经元的输出,迫使网络学习更鲁棒的特征而不是依赖特定几个节点的组合。
实际使用时有几个细节需要注意:Dropout的p参数表示的是神经元被丢弃的概率,p=0.5是常见的起点,不是越高越好。Dropout层通常放在全连接层之间,且只在训练期间生效。如果你发现验证集上模型效果不佳,先确认在验证阶段有调用model.eval(),否则Dropout会继续保持开启,导致验证结果出现不必要的噪声。
一个我踩过的坑是:在推理部署时忘了设置model.eval(),结果同一批数据每次前向结果不同。排查了好久才发现是Dropout没有关闭。这算是毫不起眼但相当致命的一个细节,建议你把model.eval()和with torch.no_grad()这种基本功固化成习惯。
4.4 要不要在Linear层前做标准化
理论上,全连接层对输入特征的尺度比较敏感。如果输入特征的数值范围波动很大,训练初期权重更新会很震荡。所以一个常见做法是在全连接层之前加nn.BatchNorm1d(对一维特征)或nn.LayerNorm(对序列特征),让输入分布更稳定。
但加了标准化之后也需要注意:BatchNorm在batch size比较小时效果很差,因为均值和方差估计不稳定;LayerNorm对batch大小不敏感,更适合序列类和Transformer结构。所以选哪种要看你的数据形态。当你在Transformer的FFN前后都加了LayerNorm时,中间的Linear层会非常稳定,这也是为什么现在很多大模型在训练时即使不加额外的trick,也能维持比较平滑的loss曲线。
4.5 模型保存加载和设备相关的几个坑
全连接层权重矩阵较大,保存下来也偏大,所以保存模型时尽量只保存state_dict而不是整个模型对象。加载时需要注意in_features和out_features必须和保存时完全一致,否则会报类似“size mismatch”的错误。这种错误在实际中经常出现在你稍微改了模型结构(比如把隐藏层维度从64改成128)但没有重新训练,就直接加载旧权重。
设备相关的坑也很常见。nn.Linear创建的权重都在CPU上,如果你把输入x放在了GPU上,但忘记执行model = model.cuda()(或者model.to(device)),那么前向传播会报“Expected all tensors to be on the same device”之类的错误。反过来也一样。我的做法是定义一个device变量,在模型实例化之后立刻调用model.to(device),输入输出也统一用.to(device),这样能有效避免设备不匹配的情况。
结尾:分享两个我常用的小技巧
写到最后,我再分享一个我在写全连接网络时常用的小技巧:在实例化Linear层时,顺手给每一层起一个有语义的名字并注释它的输入输出形状,比如:
self.projection = nn.Linear(768, 512) # (B, 768) -> (B, 512) self.classifier = nn.Linear(512, num_classes) # (B, 512) -> (B, num_classes)这样代码过了几个月再回来看,不用从头推,效率高很多。另一个技巧是,如果要快速验证模型搭建是否正确,可以构造一个形状随机的输入,跑一次前向,尤其对用了动态形状推导和多种中间操作拼接的网络特别有效。如果模型前向能顺利通过,再开始写训练循环,调试成本会低很多。
全连接层看着简单,但它和卷积、注意力、归一化等模块互相配合时仍有不少细节值得多琢磨。这个模块能延伸出去的内容也很多:从它可以看到深度学习框架中“层”的定义方式、参数初始化和梯度传播的基本逻辑,还能延伸到模型压缩、量化、低秩分解等进阶方向。希望这些经验和踩坑记录能帮你少走一点弯路。