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)$,以 0 为中心 | RNN、需要正负输出的隐藏表示 |
| GELU | 按输入大小平滑地保留信息 | Transformer、较深的 MLP |
| Softmax | 多类别概率归一化 | 多分类输出解释 |
在 MLP 的隐藏层中,最常见的是 ReLU 或 GELU。
如果你希望隐藏层输出既有正值又有负值,可以考虑 Tanh;如果你在写 Transformer 风格的前馈网络,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 实现神经网络:从手动参数到 nn.Module
单独看 Tensor、优化器、nn.Linear 和 nn.Module 很容易觉得它们是几个无关的 API。
实际上,它们只是对同一个训练过程进行了逐层封装:
手动创建权重 Tensor
-> nn.Linear 管理一层的权重和偏置
-> nn.Module 管理整个网络的子层和参数
下面使用同一个 XOR 二分类任务,分三步实现一个包含隐藏层的神经网络。
6.1 准备训练数据
XOR 有 4 个样本:两个输入相同时输出 0,不同时输出 1。
import torch
import torch.nn.functional as F
from torch import nn
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])
X 的形状是 [4, 2]:
- 4 表示 4 个样本。
- 2 表示每个样本有 2 个输入特征。
y 的形状是 [4],其中每个整数是对应样本的类别索引。
6.2 第一版:手动创建参数,使用 PyTorch 优化器训练
先不使用 nn.Linear 和 nn.Module,直接创建权重矩阵和偏置向量。
网络结构为:
2 个输入 -> 8 个隐藏单元 -> 2 个分类 logits
因此各个参数的形状是:
W1: [2, 8]
b1: [8]
W2: [8, 2]
b2: [2]
完整代码如下:
torch.manual_seed(42)
W1 = torch.randn(2, 8, requires_grad=True)
b1 = torch.zeros(8, requires_grad=True)
W2 = torch.randn(8, 2, requires_grad=True)
b2 = torch.zeros(2, requires_grad=True)
# 优化器可以直接接收需要更新的 Tensor 列表
optimizer = torch.optim.SGD([W1, b1, W2, b2], lr=0.1)
for epoch in range(1000):
# 前向传播:手动写出两层网络的矩阵运算
hidden = torch.tanh(X @ W1 + b1)
logits = hidden @ W2 + b2
# CrossEntropy 直接接收 logits 和类别索引
loss = F.cross_entropy(logits, y)
# PyTorch 默认累积梯度,每轮更新前要清空旧梯度
optimizer.zero_grad()
loss.backward()
optimizer.step()
with torch.no_grad():
hidden = torch.tanh(X @ W1 + b1)
logits = hidden @ W2 + b2
pred = logits.argmax(dim=1)
print(pred.tolist())
训练正常时,最终预测应该接近:
[0, 1, 1, 0]
这个版本已经是一个完整的神经网络训练过程:
W1、b1、W2、b2是可学习参数。torch.tanh提供非线性。F.cross_entropy计算分类损失。loss.backward()通过自动求导填充参数的.grad。optimizer.step()根据梯度更新参数。
优化器并不要求参数必须来自模型类。只要它们是可求导的叶子 Tensor,就可以交给优化器。
这种写法有助于理解原理,但参数很多时会带来几个问题:
- 必须自己管理权重和偏置。
- 必须自己保证矩阵形状正确。
- 初始化策略需要自己选择。
- 保存、加载和设备迁移都需要单独处理。
这就是 nn.Linear 要解决的第一层问题。
6.3 第二版:用 nn.Linear 封装每一层
nn.Linear(in_features, out_features) 会在内部自动创建:
weight:可学习权重。bias:可学习偏置。- 合适的默认初始化。
因此可以用两个 nn.Linear 替代刚才手动创建的四个 Tensor:
torch.manual_seed(42)
linear1 = nn.Linear(in_features=2, out_features=8)
linear2 = nn.Linear(in_features=8, out_features=2)
# 此时还没有整体 model,所以手动合并两层的参数
parameters = list(linear1.parameters()) + list(linear2.parameters())
optimizer = torch.optim.SGD(parameters, lr=0.1)
for epoch in range(1000):
hidden = torch.tanh(linear1(X))
logits = linear2(hidden)
loss = F.cross_entropy(logits, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
with torch.no_grad():
logits = linear2(torch.tanh(linear1(X)))
pred = logits.argmax(dim=1)
print(pred.tolist())
两种写法的对应关系是:
| 手动矩阵运算 | nn.Linear 写法 |
|---|---|
X @ W1 + b1 |
linear1(X) |
hidden @ W2 + b2 |
linear2(hidden) |
[W1, b1, W2, b2] |
两层的 parameters() |
需要注意,nn.Linear 只表示一层仿射变换,它不会自动加入 Tanh、ReLU 等激活函数。
所以代码中仍然要显式写出:
hidden = torch.tanh(linear1(X))
这个版本已经不需要手动管理每层的权重和偏置,但仍然有一个问题:两个线性层还是分散的对象,并没有被组织成一个完整模型。
这就需要 nn.Module。
6.4 第三版:用 nn.Module 组织完整网络
nn.Module 可以把多个层、激活函数和前向传播逻辑组织成一个模型。
class XORNet(nn.Module):
def __init__(self):
super().__init__()
self.linear1 = nn.Linear(2, 8)
self.linear2 = nn.Linear(8, 2)
def forward(self, x):
hidden = torch.tanh(self.linear1(x))
logits = self.linear2(hidden)
return logits
__init__ 负责定义网络包含哪些子层,forward 负责定义数据如何流过这些层。
完整训练代码为:
torch.manual_seed(42)
model = XORNet()
optimizer = torch.optim.SGD(model.parameters(), lr=0.1)
for epoch in range(1000):
logits = model(X)
loss = F.cross_entropy(logits, y)
optimizer.zero_grad()
loss.backward()
optimizer.step()
model.eval()
with torch.no_grad():
logits = model(X)
pred = logits.argmax(dim=1)
print(pred.tolist())
与第二版相比,最关键的变化是:
# 第二版:自己收集每层参数
parameters = list(linear1.parameters()) + list(linear2.parameters())
# 第三版:nn.Module 递归收集所有子层参数
parameters = model.parameters()
只要在 __init__ 中把子层赋值给 self.xxx,nn.Module 就会注册它们。
于是 model.parameters() 能够递归找到 linear1 和 linear2 的所有权重与偏置。
6.5 三个版本的本质没有变
| 版本 | 参数由谁管理 | 前向传播如何定义 | 优化器接收什么 |
|---|---|---|---|
| 手动 Tensor | 开发者自己管理 | 直接写矩阵运算 | [W1, b1, W2, b2] |
nn.Linear |
每个 Linear 层管理 | 手动连接各层 | 合并多层的 parameters() |
nn.Module |
整个模型递归管理 | 定义在 forward 中 |
model.parameters() |
三个版本的数学计算实际上都是:
$$
h=\tanh(XW_1+b_1)
$$
$$
logits=hW_2+b_2
$$
不变的训练主线始终是:
前向传播 -> 计算损失 -> 清空梯度
-> 反向传播 -> 优化器更新参数
PyTorch 的封装没有改变神经网络的数学本质,它只是让参数管理、模型复用、设备迁移和保存加载变得更可靠。
这里的输出 logits 是模型对每个类别给出的原始分数。
如果只在推理时需要概率,可以使用:
prob = torch.softmax(logits, dim=1)
但训练时使用 F.cross_entropy 或 nn.CrossEntropyLoss 时,不要先手动执行 softmax,因为损失函数内部已经包含了相应计算。
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。
它不只是一个“写模型的父类”,更像是 PyTorch 对神经网络的统一管理容器。
一个 nn.Module 至少负责下面几件事:
- 保存可训练参数。
- 保存子模块。
- 定义前向传播。
- 在训练模式和推理模式之间切换。
- 递归地把模型移动到 CPU 或 GPU。
- 保存和加载模型状态。
你通常需要做两件事:
- 在
__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
这里有一个很重要的细节:
只要把子层赋值给
self.xxx,PyTorch 就会自动把它注册为当前模块的子模块。
例如:
self.fc1 = nn.Linear(2, 4)
这一句不仅仅是在对象上挂了一个属性,还会让 fc1 被 nn.Module 记录下来。
因此后面调用 model.parameters()、model.to(device)、model.train()、model.eval() 时,PyTorch 才能递归地找到这些子层。
可以打印模型参数:
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() 会返回模型中所有可训练参数。
10.1 __call__ 和 forward 的关系
平时我们通常这样调用模型:
out = model(x)
而不是直接写:
out = model.forward(x)
原因是 model(x) 会先进入 nn.Module 的 __call__ 逻辑,再由 PyTorch 调用你写的 forward。
在这个过程中,PyTorch 会处理 hook、自动混合精度、模块状态等框架级逻辑。
所以一般建议:
out = model(x) # 推荐
不要直接调用:
out = model.forward(x) # 不推荐作为常规写法
可以把关系理解成:
model(x)
-> nn.Module.__call__()
-> 处理 PyTorch 内部逻辑
-> 调用 forward(x)
-> 返回输出
10.2 参数是怎么被注册的
不是所有 Tensor 都会自动成为模型参数。
只有下面这类对象会被 nn.Module 当作可训练参数管理:
nn.Parameter- 子模块中的
nn.Parameter
例如:
class MyScale(nn.Module):
def __init__(self):
super().__init__()
self.scale = nn.Parameter(torch.tensor(1.0))
self.cache = torch.tensor(0.0)
def forward(self, x):
return x * self.scale
这里:
self.scale会出现在model.parameters()中。self.cache只是普通张量,不会被优化器更新。
可以验证:
model = MyScale()
for name, param in model.named_parameters():
print(name, param)
输出中会看到 scale,但不会看到 cache。
如果你希望某个张量跟随模型一起保存、移动设备,但又不希望它被训练,可以使用 register_buffer。
例如 BatchNorm 中的均值、方差统计量就属于这类状态。
class RunningMean(nn.Module):
def __init__(self):
super().__init__()
self.register_buffer("mean", torch.zeros(1))
def forward(self, x):
return x - self.mean
mean 不会被优化器更新,但会出现在 state_dict() 中,也会跟随 model.to(device) 移动。
10.3 state_dict() 保存的是什么
训练完成后,一般不会直接保存整个模型对象,而是保存 state_dict():
torch.save(model.state_dict(), "mlp.pth")
state_dict() 本质上是一个字典,里面保存了模型中各层参数和 buffer 的名字与数值。
state = model.state_dict()
for key in state:
print(key, state[key].shape)
可能输出:
fc1.weight torch.Size([4, 2])
fc1.bias torch.Size([4])
fc2.weight torch.Size([2, 4])
fc2.bias torch.Size([2])
加载时需要先创建同样结构的模型,再把参数填进去:
model = SimpleNet()
model.load_state_dict(torch.load("mlp.pth"))
model.eval()
10.4 train() 和 eval() 为什么重要
model.train() 和 model.eval() 不会直接训练或停止训练,它们只是切换模型内部某些层的工作模式。
最典型的是:
Dropout:训练时随机丢弃一部分神经元,推理时不丢弃。BatchNorm:训练时使用当前 batch 的统计量,推理时使用累计统计量。
所以验证和推理时通常要写:
model.eval()
with torch.no_grad():
pred = model(x)
如果忘记 eval(),带有 Dropout 或 BatchNorm 的模型在验证时可能表现不稳定。
11. nn.Linear:全连接层
PyTorch 官方文档中,nn.Linear 表示对输入做仿射线性变换:
$$
y=xA^T+b
$$
其中:
- $x$ 是输入。
- $A$ 是权重矩阵。
- $b$ 是偏置。
nn.Linear内部会自动创建linear.weight和linear.bias,并且nn.Linear的返回结果就是上面中的y,x为需要传入的参数。
例子:
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(x)=\frac{e^x-e^{-x}}{e^x+e^{-x}}
$$
tanh = nn.Tanh()
x = torch.tensor([-2.0, 0.0, 2.0])
print(tanh(x))
输出大致为:
tensor([-0.9640, 0.0000, 0.9640])
它和 Sigmoid 的一个重要区别是:Tanh 的输出以 0 为中心。
也就是说,输出可以是负数、0 或正数。
这对隐藏层表示有时更友好,因为下一层接收到的信号不总是偏向正数。
在早期 RNN、LSTM 的一些门控计算中,Tanh 很常见。
不过 Tanh 也有一个问题:当输入绝对值很大时,输出会接近 -1 或 1,梯度会变得很小。
这叫饱和区间,可能导致深层网络训练变慢。
可以这样理解:
x 很大 -> tanh(x) 接近 1 -> 曲线变平 -> 梯度变小
x 很小 -> tanh(x) 接近 -1 -> 曲线变平 -> 梯度变小
x 接近 0 -> tanh(x) 变化明显 -> 梯度较大
所以现在普通 MLP 的隐藏层更常用 ReLU 或 GELU,而不是大量堆叠 Tanh。
12.4 GELU
GELU 是 Transformer 中常见的激活函数,比 ReLU 更平滑。
它的全称是 Gaussian Error Linear Unit,可以粗略理解为:
根据输入值的大小,用一个平滑的概率门控决定保留多少输入。
常见公式写成:
$$
\text{GELU}(x)=x\Phi(x)
$$
其中 $\Phi(x)$ 是标准正态分布的累积分布函数。
PyTorch 写法:
gelu = nn.GELU()
x = torch.tensor([-2.0, -0.5, 0.0, 1.0, 2.0])
print(gelu(x))
输出大致为:
tensor([-0.0455, -0.1543, 0.0000, 0.8413, 1.9545])
注意 GELU 和 ReLU 的差别:
- ReLU 对负数直接截断为 0。
- GELU 对负数不是完全丢弃,而是平滑地压小。
- ReLU 在 0 点有明显折角。
- GELU 曲线更平滑,梯度变化更连续。
这也是为什么 BERT、GPT、ViT 等 Transformer 风格模型中经常使用 GELU。
如果只是在普通表格数据上训练一个小 MLP,ReLU 通常已经足够;
如果网络较深,或者结构接近 Transformer 的前馈网络,可以优先考虑 GELU。
12.5 激活函数怎么选
可以先用下面这个经验表:
| 场景 | 推荐激活函数 | 原因 |
|---|---|---|
| 普通 MLP 隐藏层 | ReLU | 简单、高效、训练稳定 |
| 输出二分类概率 | Sigmoid | 输出范围是 $(0,1)$ |
| 希望隐藏表示有正有负 | Tanh | 输出以 0 为中心 |
| Transformer 风格前馈层 | GELU | 平滑,保留负值信息 |
| 多分类概率解释 | Softmax | 把 logits 归一化为概率 |
需要注意:多分类训练时,PyTorch 的 nn.CrossEntropyLoss() 内部已经包含了 log-softmax,
所以模型最后一层通常直接输出 logits,不需要手动加 Softmax。
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()
参考文献
本文主要参考了以下资料:
- 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
