MLP(Multilayer Perceptron,多层感知机)是最基础也最重要的神经网络之一。

如果说线性回归、逻辑回归只有一层线性变换,那么 MLP 就是在多层线性变换之间加入非线性激活函数,让模型能够拟合更复杂的关系。

它的结构并不神秘:

输入特征 -> 隐藏层 -> 激活函数 -> 隐藏层 -> 激活函数 -> 输出层

一句话概括:

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 或交叉熵损失。

如果是回归任务,最后通常直接输出一个连续数值。

MLP 单层计算可以拆成线性变换和激活函数

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 的训练流程和其他神经网络类似:

  1. 前向传播:输入样本,得到预测值。
  2. 计算损失:比较预测值和真实标签。
  3. 反向传播:根据损失计算每个参数的梯度。
  4. 参数更新:优化器根据梯度更新权重和偏置。
  5. 重复很多轮,直到模型效果稳定。

PyTorch 训练循环的核心步骤

用公式概括,目标是最小化损失函数:

$$
\min_\theta \mathcal{L}(\hat{y},y)
$$

其中 $\theta$ 表示所有可学习参数。

参数更新可以写成:

$$
\theta := \theta-\alpha\nabla_\theta \mathcal{L}
$$

这和梯度下降的思想完全一致,只是神经网络里的参数更多,计算图更复杂。

6. 用 PyTorch 实现神经网络:从手动参数到 nn.Module

单独看 Tensor、优化器、nn.Linearnn.Module 很容易觉得它们是几个无关的 API。
实际上,它们只是对同一个训练过程进行了逐层封装:

手动创建权重 Tensor
  -> nn.Linear 管理一层的权重和偏置
  -> nn.Module 管理整个网络的子层和参数

PyTorch 构造神经网络的三个抽象层次

下面使用同一个 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.Linearnn.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]

这个版本已经是一个完整的神经网络训练过程:

  1. W1b1W2b2 是可学习参数。
  2. torch.tanh 提供非线性。
  3. F.cross_entropy 计算分类损失。
  4. loss.backward() 通过自动求导填充参数的 .grad
  5. 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.xxxnn.Module 就会注册它们。
于是 model.parameters() 能够递归找到 linear1linear2 的所有权重与偏置。

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_entropynn.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 官方基础教程把机器学习工作流拆成数据、模型、优化和保存等环节;写神经网络时,常见组件也基本围绕这些环节展开。

PyTorch 神经网络常用组件

常用模块可以先按功能分组:

组件 作用
torch.Tensor 存储数据和梯度相关信息
nn.Module 所有神经网络模块的基类
nn.Linear 全连接层,也就是仿射变换
nn.ReLUnn.Sigmoidnn.GELU 激活函数
nn.Sequential 顺序堆叠多个层
nn.CrossEntropyLossnn.MSELoss 损失函数
torch.optim.SGDtorch.optim.Adam 优化器
DatasetDataLoader 数据集和小批量加载
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。
  • 保存和加载模型状态。

你通常需要做两件事:

  1. __init__ 中定义层。
  2. 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)

这一句不仅仅是在对象上挂了一个属性,还会让 fc1nn.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.weightlinear.bias,并且nn.Linear的返回结果就是上面中的yx为需要传入的参数。

例子:

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. 激活函数:ReLUSigmoidTanhGELU

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() 时,它会根据链式法则自动计算梯度。

Autograd 会记录前向计算图并在 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-12b.grad-6

16. 优化器:SGDAdam

优化器负责根据梯度更新参数。

最基础的是 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. DatasetDataLoader

神经网络通常使用 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()

参考文献

本文主要参考了以下资料:

  1. PyTorch Documentation, torch.nn.Linear: https://docs.pytorch.org/docs/stable/generated/torch.nn.Linear.html
  2. PyTorch Documentation, torch.nn.CrossEntropyLoss: https://docs.pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html
  3. PyTorch Tutorials, Learn the Basics: https://docs.pytorch.org/tutorials/beginner/basics/intro.html
  4. PyTorch Tutorials, Automatic Differentiation with torch.autograd: https://docs.pytorch.org/tutorials/beginner/basics/autogradqs_tutorial.html
  5. PyTorch Tutorials, Optimizing Model Parameters: https://docs.pytorch.org/tutorials/beginner/basics/optimization_tutorial.html