要说深度学习里最容易被新手低估的东西,全连接层和激活函数绝对排得上号。很多人一上来就学卷积、注意力,真到自己搭网络时,却发现连一个全连接层的输入输出形状都搞不明白,也不知道为什么每个线性层后面几乎都要接一个激活函数。其实这两个模块是整个神经网络最底层的砖块,无论模型多花哨,最后大概率还是会落回“线性变换 + 非线性映射”的组合。这篇就把全连接层(也就是线性层)和激活函数掰开揉碎讲清楚:它们各自在做什么、为什么非要放在一起用、在代码里怎么写、训练时又会踩哪些坑。适合刚入门深度学习的读者,也适合那些会调库但理解还停留在表面的朋友。
有句话我经常跟人讲:全连接层决定了一个网络“看到哪些组合特征”,激活函数决定了一个网络“能不能弯过来”。把这层窗户纸捅破,后面看任何模型都会轻松很多。
1. 全连接层在数学上就是一次“带偏置的矩阵乘法”
很多人背公式会背,但不知道全连接层到底在算什么。我换个方式讲:你去菜市场买菜,每种菜有单价,你买的数量乘以单价再求和,最后加上一个“摊位费”,就是总价。全连接层做的就是这件事,只不过单价和摊位费不是定死的,而是网络自己学出来的。
输入是一堆特征数值,输出是另一个空间里的得分。权重矩阵里的每个元素可以理解成“这个输入特征对那个输出神经元的重要程度”,偏置则可以理解成“这个输出神经元的默认倾向”。如果输入是一张图片的像素,全连接层输出“猫”、“狗”、“车”的分数,本质上就是对每个类别做一次投票,而投票权重正在被训练不断调整。
1.1 为什么可以把“特征重要性”想象成菜价
以判断“一个人是否适合打篮球”为例。输入特征可能是身高、鞋码、臂展,输出是一个分数。线性层就是学习一个公式:分数 = 身高 × 权重1 + 鞋码 × 权重2 + 臂展 × 权重3 + 偏置。身高权重可能很大,鞋码权重可能很小,这说明模型认为身高更重要。
注意这里不是直接查表:不是“如果身高190,得分就100”。而是连续地计算加权和。因为输入特征基本都是连续值,网络希望得到一个平滑的映射,这样身高从190变到191时,分数不会跳变,而是平滑变化。全连接层就是这样一个“可学习的平滑映射表”。
1.2 一个 3×2 矩阵的手算过程,彻底拿捏形状
我们从一个最小例子开始。假设输入有 2 个特征,全连接层要输出 3 个神经元,那么权重矩阵的形状就是 3 行 2 列。我习惯把权重写成W: [out_features, in_features],也就是先写输出维度,再写输入维度。
设输入x = [1, 2],权重和偏置如下:
W = [[0.1, 0.4], [0.2, 0.5], [0.3, 0.6]] b = [0.1, 0.2, 0.3]计算第一个输出:
0.1 × 1 + 0.4 × 2 + 0.1 = 1.0
第二个输出:
0.2 × 1 + 0.5 × 2 + 0.2 = 1.4
第三个输出:
0.3 × 1 + 0.6 × 2 + 0.3 = 1.8
所以结果是[1.0, 1.4, 1.8]。手算一遍之后你会很清楚:每一行权重决定一个输出神经元的“口味”,它把输入特征按不同比例组合起来。
有个细节容易让人犯迷糊:很多教材写y = W^T x + b,但主流深度学习框架里,权重形状通常是[out_features, in_features],前向计算是x @ W.T + b。其实两者是一回事,只是权重矩阵的定义方式不同。你自己写代码时只要统一一种写法就行,不要一会儿转置一会儿不转置。
1.3 批处理时多出来的那个维度是什么
训练时不会一条一条地算,而是一次喂一批数据。假设一次喂 N 条样本,输入就是[N, in_features],输出就是[N, out_features]。权重不变,还是一样的形状,但矩阵乘法会把这一批样本同时投影到输出空间。
偏置的形状是[out_features],在批量计算时会自动广播到每一行上。可以理解为:同一套菜价规则对每个顾客都生效,每个顾客最终拿到的总价各自不同。
批处理维度也是新手最容易忽略的:全连接层只对“最后一维”做线性变换。输入[N, L, D]时,它会对每个位置的上都做同样的线性变换,最后得到[N, L, out],而不是把 L 也自动合并。如果你希望把 L 和 D 都当作特征展开,得先手动reshape成[N, L * D]。这个我在第 4 章还会重点展开。
2. 没有激活函数,深层网络和单层没有任何区别
全连接层本身是线性的,但深度学习要解决的大多数问题,比如图像分类、语音识别、游戏决策,都不是线性可分的问题。没有激活函数,堆再多全连接层也白搭。
我第一次给别人讲这个问题时,就只说了一句:你把两层线性层叠起来,算一算,会发现它还是线性层。对方回去推导一遍,立刻理解了为什么每个隐藏层后面都要接一个“非线性的东西”。
2.1 两层线性层叠加,数学上会变成什么
假设第一层是:
h1 = x W1 + b1
第二层是:
h2 = h1 W2 + b2
代入:
h2 = (x W1 + b1) W2 + b2 = x (W1 W2) + b1 W2 + b2
W1 和 W2 都是矩阵,乘起来还是一个矩阵;b1W2 加 b2 还是一个向量。所以两层线性层的组合,本质上仍然是一个线性变换,和一层没有区别。堆十层也是一样,最后都能压缩成一个矩阵乘法加一个偏置。
这意味着什么呢?线性模型只能做“旋转、缩放、平移”这类刚体变换,决策边界永远是一条直线、一个平面或者一个超平面。经典的异或问题,四个点分布在二维空间里,你永远画不出一条直线把两类点分开。但用带 ReLU 的两层网络,可以轻松画出一个方形区域把它们分开。
2.2 激活函数像“折纸”,折出来的是表达能力
我一直觉得“激活函数”这个翻译太文绉绉,不如叫“折弯函数”。线性变换是一根直铁丝,激活函数就是把这根铁丝折一下、压一下。ReLU 把所有负数变成 0,等于在零这个位置折了一下;再加上不同层之间的矩阵旋转,反复折叠,就能拟合出非常复杂的曲线。
这就是万能近似定理的直观解释:只要神经元数量足够多,神经网络可以逼近任意连续函数。关键在于“折”的次数和位置,而不是层数本身。当然,折太多也容易过拟合,把训练集里无意义的噪声都学进去,这就是另一个话题了。
2.3 主流激活函数横向对比:Sigmoid、Tanh、ReLU、Leaky ReLU、Swish
了解一个激活函数,主要看三件事:公式、输出范围、导数行为。我整理了一个表,方便大家对比:
| 名称 | 公式 | 输出范围 | 主要优点 | 主要缺点 |
|---|---|---|---|---|
| Sigmoid | σ(x)=1/(1+e^-x) | (0, 1) | 输出可理解为概率 | 两端饱和梯度趋近0,输出非零中心,计算稍慢 |
| Tanh | tanh(x) | (-1, 1) | 零中心,比 Sigmoid 收敛快 | 两端仍饱和,深层仍会梯度消失 |
| ReLU | max(0, x) | [0, +∞) | 正区间梯度恒为1,计算极快 | 负区间梯度为0,可能出现“死神经元” |
| Leaky ReLU | max(αx, x),α通常0.01 | (-∞, +∞) | 负区间有小梯度,缓解死亡 | 多了个超参数,需要调 |
| Swish/SiLU | x·sigmoid(x) | (-∞, +∞) | 平滑,很多任务效果优于ReLU | 计算量稍大,显存占用略高 |
我的日常建议是:隐藏层默认 ReLU,遇到训练不动或大量死神经元时换 Leaky ReLU 或 Swish;输出层按任务选,回归不用激活,二分类用 Sigmoid,多分类用 Softmax。至于 Sigmoid 和 Tanh,隐藏层尽量少用,尤其不要用在很深的全连接网络里,否则梯度消失会非常折磨人。
3. 手写前向和反向:把“链式法则”落地成代码
深度学习框架把一切都封装好了,但封装得越黑盒,出问题时越难排查。我自己习惯在遇到困惑时,用纯 Python 把全连接层和激活函数的“玩具版本”写一遍。这一步做完,很多关于形状、梯度、链式法则的疑问会瞬间消失。
3.1 前向传播:先用纯 Python 写一个“玩具版本”
我不打算用框架,直接拿 NumPy 实现最核心的四件事:线性层前向、ReLU 前向、Sigmoid 前向。先定义权重形状为[out_features, in_features],跟主流框架保持一致。
import numpy as np def linear_forward(x, W, b): # x: (N, in_features) # W: (out_features, in_features) # b: (out_features,) return x @ W.T + b def relu_forward(z): return np.maximum(z, 0.0) def sigmoid_forward(z): return 1.0 / (1.0 + np.exp(-z))这段代码虽然只有三行,但有几个点值得注意:x @ W.T把[N, in]变成[N, out],因为W.T的形状是[in, out]。偏置b的形状是[out],加的时候会自动广播到每一行。
很多新手在这一步容易犯迷糊:为什么不是x @ W?因为 W 的第一维是 out,第二维是 in,直接乘会让矩阵维度对不上。记住一点:矩阵乘法要求左边矩阵的列数等于右边矩阵的行数。x的最后一维是in_features,所以要让 W 的行数是in_features,而W.T正好满足。
3.2 反向传播:梯度是沿着计算图倒流的
反向传播看着复杂,其实就是在算每个参数的“责任”。我们定义一个简单的两层结构:
z = x @ W.T + b
a = relu(z)
loss = L(a)
如果损失对a的梯度是da,那么对z的梯度就是da * (z > 0)。ReLU 的导数很简单:输入大于 0 时导数为 1,小于等于 0 时导数为 0。这里的*是逐元素相乘。
得到dz之后,线性层的梯度就有三个:
def linear_backward(dz, x, W, b): # dz: (N, out_features) dW = dz.T @ x db = dz.sum(axis=0) dx = dz @ W return dx, dW, db def relu_backward(dz, z): return dz * (z > 0) def sigmoid_backward(dz, a): return dz * a * (1 - a)验证形状是最快的理解方式。dz是[N, out],x是[N, in],那么dz.T @ x就是[out, N] @ [N, in],结果[out, in],正好是 W 的形状。dz.sum(axis=0)得到[out],正好是 b 的形状。dz @ W是[N, out] @ [out, in],结果[N, in],正好是 x 梯度应有的形状。
所以反向传播并不神秘,它就是链式法则拿着每个变量的形状,一环一环往回推。你只要保证每一步结果形状和原变量形状一致,基本就不会错。
3.3 用主流框架的 Linear 做一次对照,顺便聊聊初始化
如果你用 PyTorch,创建一个全连接层非常直接:
import torch.nn as nn fc = nn.Linear(in_features=2, out_features=3) print(fc.weight.shape) # torch.Size([3, 2]) print(fc.bias.shape) # torch.Size([3, 2])? 不对,是 torch.Size([3])不用怀疑,权重形状就是[3, 2],也就是[out_features, in_features]。这种约定和W.T方式对应,和很多教材里的W^T x是同一件事。
初始化为什么重要?假设权重全为 0,所有神经元输出相同,反向传播时梯度也相同,网络永远不会“分化”。假设权重初始太大,ReLU 的输入可能全部落在负数区间,输出全是 0,梯度也为 0,神经元直接“死”掉。主流框架默认的初始化其实都经过精心设计:ReLU 类激活通常配 He 初始化,Sigmoid/Tanh 类通常配 Xavier 初始化。后面我专门说这个问题。
4. 训练MLP之前,参数初始化、输入形状和激活选择得先想明白
很多人在小数据集上跑不通模型,不是模型结构错了,而是输入形状、初始化、输出层激活这些细节没想清楚。这些细节不会让代码“立刻报错”,但会让训练过程变得极其痛苦。
4.1 输入维度到底怎么摆:从展平到最后一维
全连接层只接受至少两维的输入,最后一维是特征维度。图像数据通常是[N, C, H, W],如果想把一整张图输入到nn.Linear,必须先展平成[N, C*H*W]。
x = x.view(x.size(0), -1)这里-1会自动推断出C*H*W。很多人忘了这步,直接把四维张量传给 Linear,就会看到类似 “mat1 and mat2 shapes cannot be multiplied” 的报错。
另一个容易忽略的点是序列数据。比如输入是[N, seq_len, embedding_dim],你定义nn.Linear(embedding_dim, hidden),它会自动只在最后一维上做变换,输出变成[N, seq_len, hidden]。如果你想把整个序列信息合并成一个向量,要么手动reshape,要么在序列维度上做平均池化,要么取某个特殊位置的向量,然后再接全连接层。全连接层不会帮你自动做这件事。
4.2 初始化不是玄学:为什么 ReLU 配 He、Sigmoid 配 Xavier
先看一个现象:如果权重初始太大,线性层输出的方差会随层数指数增长,很快变成 NaN;如果权重初始太小,信号的方差又会在传播中不断缩小,深层几乎收不到梯度。
Xavier 初始化的思路是让每层输出的方差大致等于输入的方差,它假设激活函数在零点附近近似线性,所以比较适合 Sigmoid 和 Tanh。ReLU 会把一半的神经元输出置为 0,方差整体减半,直接用 Xavier 会导致深层信号越来越弱,于是有了 He 初始化,把权重方差调大一倍。这就是“ReLU 配 He、Sigmoid 配 Tanh 配 Xavier”的来历。
如果你用框架默认初始化和默认激活,一般问题不大。但当你自己实现网络或者做自定义算子时,就要记住这些匹配关系。最简单的验证方法是:初始化后跑一次前向,打印每一层输出的标准差。如果标准差在一层层之间急剧放大或缩到几乎为 0,先怀疑初始化,再怀疑激活函数。
4.3 输出层激活怎么选:回归、二分类、多分类各有各的规矩
这个坑我见人踩过很多次,尤其是把 ReLU 用在分类输出层,模型死活不收敛。
- 回归任务:输出层不用激活函数,直接输出连续值。目标范围是正值但希望预测值恒为正时,可以加 ReLU 或 Softplus,但这不是默认选项。
- 二分类任务:一般一个输出神经元,后面接 Sigmoid。注意框架里的
BCEWithLogitsLoss会把 Sigmoid 和损失函数合在一起算,数值上更稳定。不要在模型里手动加 Sigmoid,又在损失函数里用带 logits 的版本,那样会造成重复计算,梯度也不稳。 - 多分类任务:C 个输出神经元,接 Softmax。很多框架的
CrossEntropyLoss自带 Softmax,你只需要在模型里输出原始分数(logits),不要在模型里再写一次 Softmax。
我见过最经典的问题:模型最后一层用nn.Sigmoid(),损失函数用nn.CrossEntropyLoss(),结果训练时 loss 很奇怪,因为 CrossEntropyLoss 内部又要做一次 Softmax,等于在两个分布之间来回折腾。
4.4 梯度消失、梯度爆炸和死神经元的一线排查流程
当你的多层全连接网络训练停滞时,不要先急着改结构,按这个顺序查一遍:
- 打印损失曲线:如果 loss 从某个 epoch 开始几乎不动,先看是不是梯度出了问题。
- 打印每一层权重的梯度范数:数值大得离谱,通常是爆炸;数值小到
1e-12量级,通常是消失。 - 打印每个 ReLU 层输出里“恰好为 0”的比例:如果超过 70%,说明大量神经元死了。可以尝试把 ReLU 换成 Leaky ReLU,或者降低学习率。
- 梯度裁剪能缓解爆炸,残差连接能缓解消失。对全连接网络来说,跨层加一条“直连通道”往往很有效。
- 如果输入特征取值范围差距很大,先做标准化。这看起来像预处理,但对全连接层影响极大。
死神经元有个隐蔽性:网络不是完全死,只是大部分单元不再更新,训练速度越来越慢。你在训练早期不容易发现,等到 loss 开始停滞才反应过来。所以我会在训练前几个 epoch 打印一次激活分布,作为体检指标。
5. 我调试全连接网络时踩过的一些坑与验证技巧
写到最后,分享一些我实际调试中会反复用到的经验。这些经验不算高深,但真的能救命。
5.1 新手最容易踩的四个低级错误
第一个,输入输出维度写反。nn.Linear(in, out)里第一个参数是输入维度,第二个是输出维度,很多人写成nn.Linear(out, in),前向传播直接报错。这个错倒是容易发现,因为框架会提示维度不匹配。
第二个,忘记eval()。训练时全连接后面经常跟 Dropout,测试时如果忘了切到eval()模式,Dropout 还在随机丢神经元,导致测试结果忽高忽低。这个对全连接网络尤其明显,因为全连接层参数量大,Dropout 用得多。
第三个,把分类任务当成回归做。多分类问题用 MSE 损失,输出层不加 Softmax,或者加 ReLU,都会让训练变得异常困难。分类就用交叉熵类损失,别自己发明组合。
第四个,在同一个网络里既写LogSoftmax又写CrossEntropyLoss。交叉熵损失通常已经包含了 LogSoftmax,你再做一次,梯度路径就重复了。记住一句话:框架内置损失函数能合并的操作,尽量不要在模型里手写。
5.2 数值梯度验证:判断反向传播写没写错
如果你手写了反向传播,强烈建议做一次数值梯度检查。原理很简单:直接用导数的定义去逼近,然后再和你的解析梯度比较。
中心差分公式是:
f'(x) ≈ (f(x + eps) - f(x - eps)) / (2 * eps)
对应一个通用验证函数:
def numerical_grad(f, x, eps=1e-6): g = np.zeros_like(x) it = np.nditer(x, flags=['multi_index']) while not it.finished: idx = it.multi_index old = x[idx] x[idx] = old + eps fp = f(x) x[idx] = old - eps fm = f(x) x[idx] = old g[idx] = (fp - fm) / (2 * eps) it.iternext() return g然后在一个很小的随机输入上,把你的解析梯度和数值梯度做对比。相对误差小于1e-5基本可以认为正确;如果误差在1e-2量级,大概率是反向传播某个公式写错了。
注意eps不要取得太小,否则浮点数精度会造成严重的舍入误差。1e-6到1e-4之间比较合适。这个技巧是我手写网络时的标配。
5.3 两个让网络收敛更快的实操习惯
第一个习惯,先做一个“小样本过拟合”测试。拿 20 到 50 条样本,把 batch size 设成跟样本数一样,训练足够长时间,看 loss 能不能压到接近 0。如果模型连几十条样本都记不住,那大概率是模型结构或梯度代码有问题,而不是数据量不够。这个测试能在几分钟内暴露 bug,比直接上全量数据高效太多。
第二个习惯,训练前先检查输入分布。全连接层对特征尺度极其敏感。一个特征在 0 到 1 之间,另一个特征在 0 到 10000 之间,线性层在梯度更新时几乎只会关注那个大尺度的特征,小尺度特征很难学到东西。解决办法是把每个特征标准化到零均值、单位方差,或者至少缩放到相近的量级。很多人一开始把精力花在换激活函数、调学习率上,最后发现只是数据没归一化。
我对全连接层和激活函数的理解,是在一遍遍手算、调试、重写的过程中建立起来的。尤其是手写反向传播那一次,虽然代码只有几十行,但跑通之后,再看框架文档里的各种参数,思路会清晰很多。如果你也正在入门,不妨花一个晚上把这一章的例子亲手敲一遍,保证比看十遍教程都有用。