MLP多层感知机:从原理到PyTorch实现
MLP(Multilayer Perceptron,多层感知机)是最基础也最重要的神经网络之一。
如果说线性回归、逻辑回归只有一层线性变换,那么 MLP 就是在多层线性变换之间加入非线性激活函数,让模型能够拟合更复杂的关系。
它的结构并不神秘:
输入特征 -> 隐藏层 -> 激活函数 -> 隐藏层 -> 激活函数 -> 输出层
一句话概括:
MLP 用多个全连接层和非线性激活函数,把输入特征一步步变换成适合分类或回归的输出。
1. 为什么需要 MLP
前面学习线性回归时,模型大致是:
$$
\hat{y}=wx+b
$$
对于多维输入,可以写成:
$$
\hat{y}=xW+b
$$
这类模型只能表达线性关系。
例如下面这种问题,线性模型就比较吃力:
两个特征单独看都不能决定类别,
但它们组合起来以后才有意义。
经典例子是 XOR:
| $x_1$ | $x_2$ | 标签 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
这四个点无法用一条直线完美分开。
MLP 的作用就是:先通过隐藏层把原始特征映射到新的空间,再在新空间里完成分类或回归。
2. MLP 的基本结构
一个最简单的 MLP 可以写成:
$$
h_1 = \sigma(xW_1+b_1)
$$
$$
h_2 = \sigma(h_1W_2+b_2)
$$
$$
\hat{y}=h_2W_3+b_3
$$
其中:
- $x$ 是输入特征。
- $W_1,W_2,W_3$ 是权重矩阵。
- $b_1,b_2,b_3$ 是偏置。
- $\sigma$ 是非线性激活函数。
- $h_1,h_2$ 是隐藏层表示。
如果是分类任务,最后通常会输出每个类别的 logits,再接 softmax 或交叉熵损失。
如果是回归任务,最后通常直接输出一个连续数值。
3. 为什么一定要激活函数
如果没有激活函数,多层线性层叠在一起仍然只是线性变换。
例如:
$$
h=xW_1+b_1
$$
$$
y=hW_2+b_2
$$
代入得到:
$$
y=(xW_1+b_1)W_2+b_2
$$
展开:
$$
y=x(W_1W_2)+(b_1W_2+b_2)
$$
这仍然可以看成:
$$
y=xW+b
$$
也就是说,如果没有非线性激活函数,堆再多层也没有本质提升。
激活函数的作用就是打破线性,让网络可以拟合复杂曲线、复杂边界和复杂特征组合。
常见激活函数包括:
| 激活函数 | 公式或含义 | 常见用途 |
|---|---|---|
| ReLU | $\max(0,x)$ | 默认常用,简单高效 |
| Sigmoid | 输出在 $(0,1)$ | 二分类概率、门控结构 |
| Tanh | 输出在 $(-1,1)$ | 早期神经网络、RNN |
| GELU | 平滑非线性 | Transformer 中常见 |
| Softmax | 多类别概率归一化 | 多分类输出解释 |
在 MLP 的隐藏层中,最常见的是 ReLU 或 GELU。
4. 手工算一次 MLP 前向传播
为了直观理解 MLP,我们手工算一个很小的网络。
假设输入为:
$$
x=[2,3]
$$
第一层有两个神经元:
$$
W_1=
\begin{bmatrix}
1 & -1 \
2 & 1
\end{bmatrix},
\quad
b_1=[0,1]
$$
先算线性部分:
$$
z_1=xW_1+b_1
$$
逐项计算:
$$
z_{1,1}=2\times1+3\times2+0=8
$$
$$
z_{1,2}=2\times(-1)+3\times1+1=2
$$
所以:
$$
z_1=[8,2]
$$
经过 ReLU:
$$
h_1=\text{ReLU}([8,2])=[8,2]
$$
第二层输出一个数:
$$
W_2=
\begin{bmatrix}
1 \
-2
\end{bmatrix},
\quad
b_2=0.5
$$
则:
$$
\hat{y}=h_1W_2+b_2=8\times1+2\times(-2)+0.5=4.5
$$
这就是一次完整前向传播:
输入 [2, 3]
-> 第一层线性变换 [8, 2]
-> ReLU [8, 2]
-> 第二层线性变换 4.5
如果某个中间值是负数,ReLU 会把它变成 0,这就是非线性发生的地方。
5. MLP 如何训练
MLP 的训练流程和其他神经网络类似:
- 前向传播:输入样本,得到预测值。
- 计算损失:比较预测值和真实标签。
- 反向传播:根据损失计算每个参数的梯度。
- 参数更新:优化器根据梯度更新权重和偏置。
- 重复很多轮,直到模型效果稳定。
用公式概括,目标是最小化损失函数:
$$
\min_\theta \mathcal{L}(\hat{y},y)
$$
其中 $\theta$ 表示所有可学习参数。
参数更新可以写成:
$$
\theta := \theta-\alpha\nabla_\theta \mathcal{L}
$$
这和梯度下降的思想完全一致,只是神经网络里的参数更多,计算图更复杂。
6. 用 PyTorch 实现一个 MLP
下面用 PyTorch 写一个二分类 MLP。
假设输入有 2 个特征,输出有 2 个类别:
import torch
from torch import nn
class MLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(2, 16),
nn.ReLU(),
nn.Linear(16, 16),
nn.ReLU(),
nn.Linear(16, 2),
)
def forward(self, x):
return self.net(x)
model = MLP()
x = torch.tensor([[2.0, 3.0]])
logits = model(x)
print(logits)
这里的输出 logits 不是概率,而是模型对每个类别给出的原始分数。
如果想转成概率,可以使用:
prob = torch.softmax(logits, dim=1)
print(prob)
但训练时如果使用 nn.CrossEntropyLoss,通常不需要自己先手动 softmax,因为它内部会处理 logits。
7. 完整训练例子:拟合 XOR
XOR 是理解 MLP 的经典小例子。
import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
torch.manual_seed(42)
X = torch.tensor([
[0.0, 0.0],
[0.0, 1.0],
[1.0, 0.0],
[1.0, 1.0],
])
y = torch.tensor([0, 1, 1, 0])
dataset = TensorDataset(X, y)
loader = DataLoader(dataset, batch_size=4, shuffle=True)
class XORMLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(
nn.Linear(2, 8),
nn.Tanh(),
nn.Linear(8, 2),
)
def forward(self, x):
return self.net(x)
model = XORMLP()
criterion = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.05)
for epoch in range(1000):
for batch_x, batch_y in loader:
logits = model(batch_x)
loss = criterion(logits, batch_y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
if epoch % 200 == 0:
print(f"epoch={epoch}, loss={loss.item():.4f}")
model.eval()
with torch.no_grad():
logits = model(X)
pred = torch.argmax(logits, dim=1)
print("prediction:", pred.tolist())
如果训练正常,最终输出大致会接近:
prediction: [0, 1, 1, 0]
这个例子说明:MLP 通过隐藏层和非线性激活函数,可以学到线性模型无法直接表达的决策边界。
8. PyTorch 神经网络组件总览
PyTorch 官方基础教程把机器学习工作流拆成数据、模型、优化和保存等环节;写神经网络时,常见组件也基本围绕这些环节展开。
常用模块可以先按功能分组:
| 组件 | 作用 |
|---|---|
torch.Tensor |
存储数据和梯度相关信息 |
nn.Module |
所有神经网络模块的基类 |
nn.Linear |
全连接层,也就是仿射变换 |
nn.ReLU、nn.Sigmoid、nn.GELU |
激活函数 |
nn.Sequential |
顺序堆叠多个层 |
nn.CrossEntropyLoss、nn.MSELoss |
损失函数 |
torch.optim.SGD、torch.optim.Adam |
优化器 |
Dataset、DataLoader |
数据集和小批量加载 |
model.train()、model.eval() |
切换训练和推理模式 |
torch.no_grad() |
推理时关闭梯度记录 |
下面逐个解释常用函数和它们背后的原理。
9. torch.Tensor:数据的基本容器
Tensor 可以理解为 PyTorch 里的多维数组。
import torch
x = torch.tensor([[1.0, 2.0], [3.0, 4.0]])
print(x.shape) # torch.Size([2, 2])
如果设置:
x.requires_grad_(True)
PyTorch 就会追踪后续操作,用于自动求导。
例如:
x = torch.tensor(2.0, requires_grad=True)
y = x ** 2 + 3 * x
y.backward()
print(x.grad)
手工计算:
$$
y=x^2+3x
$$
$$
\frac{dy}{dx}=2x+3
$$
当 $x=2$ 时:
$$
\frac{dy}{dx}=7
$$
所以 x.grad 会是 7。
10. nn.Module:神经网络的基本单元
在 PyTorch 中,几乎所有模型和层都继承自 nn.Module。
你通常需要做两件事:
- 在
__init__中定义层。 - 在
forward中定义前向传播逻辑。
例如:
class SimpleNet(nn.Module):
def __init__(self):
super().__init__()
self.fc1 = nn.Linear(2, 4)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(4, 2)
def forward(self, x):
x = self.fc1(x)
x = self.relu(x)
x = self.fc2(x)
return x
nn.Module 会自动管理子模块和参数。
model = SimpleNet()
for name, param in model.named_parameters():
print(name, param.shape)
可能输出:
fc1.weight torch.Size([4, 2])
fc1.bias torch.Size([4])
fc2.weight torch.Size([2, 4])
fc2.bias torch.Size([2])
这就是为什么优化器可以直接写:
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
因为 model.parameters() 会返回模型中所有可训练参数。
11. nn.Linear:全连接层
PyTorch 官方文档中,nn.Linear 表示对输入做仿射线性变换:
$$
y=xA^T+b
$$
其中:
- $x$ 是输入。
- $A$ 是权重矩阵。
- $b$ 是偏置。
例子:
layer = nn.Linear(3, 2)
x = torch.tensor([[1.0, 2.0, 3.0]])
y = layer(x)
print(y.shape) # torch.Size([1, 2])
如果输入形状是:
[batch_size, in_features]
输出形状就是:
[batch_size, out_features]
例如:
nn.Linear(3, 2)
表示每个样本有 3 个输入特征,经过这一层后变成 2 个输出特征。
它内部可学习的参数包括:
weight:形状为[out_features, in_features]。bias:形状为[out_features]。
12. 激活函数:ReLU、Sigmoid、Tanh、GELU
12.1 ReLU
ReLU 是最常见的隐藏层激活函数:
$$
\text{ReLU}(x)=\max(0,x)
$$
PyTorch 写法:
relu = nn.ReLU()
x = torch.tensor([-2.0, 0.5, 3.0])
print(relu(x))
输出:
tensor([0.0000, 0.5000, 3.0000])
12.2 Sigmoid
Sigmoid 常用于二分类概率或门控结构:
$$
\sigma(x)=\frac{1}{1+e^{-x}}
$$
sigmoid = nn.Sigmoid()
print(sigmoid(torch.tensor([0.0])))
输出为 0.5。
12.3 Tanh
Tanh 输出范围是 $(-1,1)$:
tanh = nn.Tanh()
print(tanh(torch.tensor([0.0])))
输出为 0。
12.4 GELU
GELU 是 Transformer 中常见的激活函数,比 ReLU 更平滑。
gelu = nn.GELU()
x = torch.randn(4)
print(gelu(x))
对于普通 MLP,ReLU 通常已经足够;如果是更深的网络或 Transformer 风格模型,可以考虑 GELU。
13. nn.Sequential:顺序堆叠网络
如果模型就是一层接一层地执行,可以用 nn.Sequential 简化代码:
model = nn.Sequential(
nn.Linear(2, 16),
nn.ReLU(),
nn.Linear(16, 16),
nn.ReLU(),
nn.Linear(16, 2),
)
它等价于:
x -> Linear -> ReLU -> Linear -> ReLU -> Linear
适合结构简单的 MLP。
如果模型中有分支、多个输入、条件判断,就更适合自定义 nn.Module 并手写 forward。
14. 损失函数:模型到底错在哪里
损失函数用于衡量预测值和真实标签之间的差距。
14.1 nn.MSELoss
均方误差常用于回归:
$$
\text{MSE}=\frac{1}{n}\sum_{i=1}^{n}(\hat{y}_i-y_i)^2
$$
loss_fn = nn.MSELoss()
pred = torch.tensor([2.5, 3.0])
target = torch.tensor([2.0, 4.0])
loss = loss_fn(pred, target)
print(loss)
手工计算:
$$
\frac{(2.5-2.0)^2+(3.0-4.0)^2}{2}
=
\frac{0.25+1}{2}
=0.625
$$
14.2 nn.CrossEntropyLoss
交叉熵常用于多分类。
PyTorch 官方文档说明,CrossEntropyLoss 用于计算输入 logits 和目标标签之间的交叉熵;当目标是类别索引时,它等价于先做 LogSoftmax,再做 NLLLoss。
所以训练多分类模型时,通常这样写:
criterion = nn.CrossEntropyLoss()
logits = model(x)
loss = criterion(logits, y)
不要提前写:
prob = torch.softmax(logits, dim=1)
loss = criterion(prob, y) # 不推荐
因为 CrossEntropyLoss 期望输入是未归一化的 logits。
一个简单例子:
logits = torch.tensor([[2.0, 1.0, 0.1]])
target = torch.tensor([0])
criterion = nn.CrossEntropyLoss()
loss = criterion(logits, target)
print(loss)
这里标签 0 表示正确类别是第 0 类。第 0 类 logit 最大,所以损失会相对较小。
14.3 nn.BCEWithLogitsLoss
二分类任务常用 BCEWithLogitsLoss。
它把 sigmoid 和二元交叉熵结合到一起,数值稳定性比手动 Sigmoid + BCELoss 更好。
criterion = nn.BCEWithLogitsLoss()
logit = torch.tensor([0.8])
target = torch.tensor([1.0])
loss = criterion(logit, target)
如果是单标签二分类,也可以用两个输出神经元加 CrossEntropyLoss;如果是多标签分类,通常用 BCEWithLogitsLoss。
15. 自动求导:loss.backward()
PyTorch 的自动求导系统叫 autograd。
在前向传播时,PyTorch 会记录张量之间的计算关系;调用 loss.backward() 时,它会根据链式法则自动计算梯度。
例子:
x = torch.tensor(2.0, requires_grad=True)
w = torch.tensor(3.0, requires_grad=True)
b = torch.tensor(1.0, requires_grad=True)
y = w * x + b
loss = (y - 10) ** 2
loss.backward()
print(w.grad)
print(b.grad)
手工理解:
$$
y=wx+b=3\times2+1=7
$$
$$
loss=(7-10)^2=9
$$
对 $w$ 求导:
$$
\frac{\partial loss}{\partial w}
=
2(y-10)\frac{\partial y}{\partial w}
=
2(7-10)\times2
=-12
$$
对 $b$ 求导:
$$
\frac{\partial loss}{\partial b}
=
2(y-10)\frac{\partial y}{\partial b}
=
2(7-10)\times1
=-6
$$
所以 w.grad 是 -12,b.grad 是 -6。
16. 优化器:SGD 和 Adam
优化器负责根据梯度更新参数。
最基础的是 SGD:
optimizer = torch.optim.SGD(model.parameters(), lr=0.01)
它的思想是:
$$
\theta := \theta-\alpha\nabla_\theta \mathcal{L}
$$
Adam 更常用:
optimizer = torch.optim.Adam(model.parameters(), lr=0.001)
Adam 会维护梯度的一阶矩估计和二阶矩估计,可以理解为同时考虑“梯度方向”和“梯度变化尺度”,通常比普通 SGD 更省心。
典型训练步骤是:
optimizer.zero_grad()
loss.backward()
optimizer.step()
为什么要 zero_grad()?
因为 PyTorch 默认会累积梯度。如果不清零,下一次 backward() 的梯度会加到上一次梯度上。
这在某些梯度累积场景有用,但普通训练中通常每个 batch 都要清零。
17. Dataset 和 DataLoader
神经网络通常使用 mini-batch 训练。
PyTorch 中:
Dataset负责定义“如何取一个样本”。DataLoader负责批量加载、打乱和并行读取。
最简单可以用 TensorDataset:
from torch.utils.data import TensorDataset, DataLoader
X = torch.randn(100, 2)
y = torch.randint(0, 2, (100,))
dataset = TensorDataset(X, y)
loader = DataLoader(
dataset,
batch_size=16,
shuffle=True,
)
for batch_x, batch_y in loader:
print(batch_x.shape, batch_y.shape)
break
输出形状大致是:
torch.Size([16, 2]) torch.Size([16])
这说明每次取出 16 个样本。
18. train()、eval() 和 no_grad()
训练和推理时,模型行为可能不同。
例如:
- Dropout 训练时会随机丢弃部分神经元,推理时不会。
- BatchNorm 训练时使用当前 batch 统计量,推理时使用累计统计量。
所以训练时要写:
model.train()
验证或推理时要写:
model.eval()
推理时还应关闭梯度记录:
model.eval()
with torch.no_grad():
logits = model(x)
pred = torch.argmax(logits, dim=1)
这样可以减少显存和计算开销。
19. Dropout 和 BatchNorm
19.1 Dropout
Dropout 是一种正则化方法。训练时,它会随机把一部分神经元输出置 0,减少模型对某些特征的过度依赖。
nn.Dropout(p=0.5)
表示训练时每个元素有 50% 概率被置 0。
常见写法:
self.net = nn.Sequential(
nn.Linear(20, 64),
nn.ReLU(),
nn.Dropout(0.5),
nn.Linear(64, 2),
)
19.2 BatchNorm
BatchNorm 会对一批样本的中间激活做标准化,再学习缩放和平移参数。
nn.BatchNorm1d(64)
常见写法:
self.net = nn.Sequential(
nn.Linear(20, 64),
nn.BatchNorm1d(64),
nn.ReLU(),
nn.Linear(64, 2),
)
它可以让训练更稳定,但小 batch 或分布变化明显时要谨慎使用。
20. 保存和加载模型
训练完模型后,通常保存参数字典:
torch.save(model.state_dict(), "mlp.pth")
加载时:
model = MLP()
model.load_state_dict(torch.load("mlp.pth"))
model.eval()
state_dict 本质上是一个字典,保存了每一层的权重和偏置。
相比直接保存整个模型对象,保存 state_dict 更常见,也更灵活。
21. 常见错误和排查
21.1 CrossEntropyLoss 前手动 softmax
错误写法:
prob = torch.softmax(logits, dim=1)
loss = nn.CrossEntropyLoss()(prob, y)
正确写法:
loss = nn.CrossEntropyLoss()(logits, y)
21.2 标签类型不对
CrossEntropyLoss 的类别索引标签应是 LongTensor:
y = y.long()
如果标签是 float,可能会报错或行为不符合预期。
21.3 忘记清空梯度
普通训练中,每个 batch 前要:
optimizer.zero_grad()
否则梯度会累积。
21.4 训练和推理模式没切换
训练:
model.train()
验证:
model.eval()
尤其是模型中有 Dropout 或 BatchNorm 时,这一点很重要。
21.5 维度不匹配
nn.Linear(in_features, out_features) 的 in_features 必须和输入最后一维一致。
例如输入是:
[batch_size, 20]
第一层就应该是:
nn.Linear(20, hidden_dim)
22. MLP 的优点和局限
MLP 的优点:
- 结构简单,容易理解。
- 适合表格数据、向量特征和小型任务。
- 可以作为很多复杂模型的基础模块。
- PyTorch 实现非常直接。
MLP 的局限:
- 对图片、序列、图这类有结构的数据,不能天然利用局部关系或拓扑关系。
- 参数量可能较大。
- 对特征缩放比较敏感。
- 缺少 CNN、RNN、Transformer、GNN 那样的结构先验。
所以:
表格/向量特征:MLP 常常是强 baseline。
图片:CNN 或 ViT 往往更合适。
序列:RNN、Transformer 更自然。
图数据:GNN 更适合。
23. 总结
MLP 是理解深度学习和 PyTorch 的好入口。
它的核心可以概括为:
线性层负责特征变换,
激活函数负责引入非线性,
损失函数衡量预测错误,
自动求导计算梯度,
优化器更新参数。
从公式上看,一层 MLP 就是:
$$
h=\sigma(xW+b)
$$
多层堆叠后,就能表达比线性模型复杂得多的函数。
从 PyTorch 角度看,最重要的是掌握这条训练主线:
logits = model(x)
loss = criterion(logits, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
理解这几行代码背后的原理后,再学习 CNN、RNN、Transformer、GNN 或强化学习里的神经网络,都会顺很多。
参考文献
本文主要参考了以下资料:
- PyTorch Documentation,
torch.nn.Linear: https://docs.pytorch.org/docs/stable/generated/torch.nn.Linear.html - PyTorch Documentation,
torch.nn.CrossEntropyLoss: https://docs.pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html - PyTorch Tutorials, Learn the Basics: https://docs.pytorch.org/tutorials/beginner/basics/intro.html
- PyTorch Tutorials, Automatic Differentiation with torch.autograd: https://docs.pytorch.org/tutorials/beginner/basics/autogradqs_tutorial.html
- PyTorch Tutorials, Optimizing Model Parameters: https://docs.pytorch.org/tutorials/beginner/basics/optimization_tutorial.html
