上一篇我们讲了线性神经网络,包括线性回归和softmax回归。它们都是单层神经网络,就像只有一层的"小网兜",虽然能捞些简单的鱼,但遇到复杂的情况就力不从心了。今天,我们要给这个网兜多加几层,编织成一张真正的"深度大网"——多层感知机(MLP)

深度学习的"深度",就藏在这层层叠叠的网络结构里。而多层感知机,就是这张大网最基础的编织方式——掌握了它,你就摸到了深度学习的核心脉络。

今天,我们会用最通俗的语言拆解多层感知机,还会帮你避开过拟合、欠拟合这两个深度学习最常见的陷阱,再教你权重衰减Dropout这两招防身术,最后用一个真实的Kaggle房价预测项目,把所有知识编织成一张完整的知识网!


一、多层感知机:从"小网兜"到"深度大网"

📊 与上一篇的对比:单层 vs 多层

还记得上一篇我们用线性神经网络(单层)来做Fashion-MNIST分类吗?让我们先对比一下:

+----------+------------------------+---------------------------+
| 内容     | 上一篇(线性神经网络) | 这一篇(多层感知机)      |
+----------+------------------------+---------------------------+
| 网络结构 | 只有1层(输入→输出)  | 有3层(输入→隐藏→输出) |
| 隐藏层   | 无                     | 有(256个神经元)         |
| 激活函数 | 无                     | 有(ReLU)                |
| 预期精度 | ~85%                   | ~88-90%(更高!)         |
| 能力     | 只能学习线性关系       | 可以学习非线性关系        |
+----------+------------------------+---------------------------+

线性神经网络虽然简单,但它有一个致命的短板:只能捕捉直线型的关系!但现实世界中,很多事情都不是"一条直线走到底"的。

1. 为什么需要隐藏层:线性模型的局限

举个简单的例子:假设我们想根据体温预测健康风险。

  • 体温高于37℃:温度越高,风险越大
  • 体温低于37℃:温度越高,风险越小

这就是一个非线性关系!不是一条直线能说清楚的!线性模型只会一根筋地认为"体温越高,风险越大"或者"体温越高,风险越小",根本无法理解这种"中间好,两头糟"的关系。

再比如图像分类:一个像素是不是重要,得看它周围是什么——这种"看上下文"的能力,线性模型也没有。

怎么破?加几层"过滤网"——也就是隐藏层!

2. 隐藏层:神经网络的"过滤层"

多层感知机(MLP)的结构就像一个"多层过滤网":

  • 输入层:原材料:就是我们的特征(比如图片像素)

  • 隐藏层:在中间,负责"思考"和"提取特征"(通俗讲就是:一层层过滤、提炼、加工)

  • 输出层:最终的产品(比如预测结果)

最重要的一点:每一层过滤之后,都得加个"激活开关"!(隐藏层后面必须加激活函数!)不然不管加多少层,都是白费功夫——因为直线+直线还是直线,变不出新花样来!(线性变换的线性变换还是线性的)

3. 激活函数:让网络"活"起来!

激活函数(Activation Function)就是给网络加入非线性的关键!没有它,多层感知机就退化成线性回归了。

常见的激活函数有三个:

(1)ReLU:最常用的激活函数!

ReLU(Rectified Linear Unit,修正线性单元)是目前最流行的激活函数,因为它简单又好用!

\text{ReLU}(x) = \max(x, 0)

通俗理解:把负数变成0,正数保持不变

让我们用代码画出来看看:

import torch
import matplotlib.pyplot as plt

# 1. 创建x轴数据:从-8到8,每隔0.1取一个点
x = torch.arange(-8.0, 8.0, 0.1, requires_grad=True)

# 2. 计算ReLU:把x代入ReLU函数
y = torch.relu(x)

# 3. 画图
# .detach()是因为x需要梯度,画图时不需要,所以"分离"出来
plt.plot(x.detach(), y.detach())
plt.xlabel('x')         # x轴标签
plt.ylabel('relu(x)')    # y轴标签
plt.show()                # 显示图片

你会看到:

  • 当x < 0时,ReLU(x) = 0(是一条横线)
  • 当x ≥ 0时,ReLU(x) = x(是一条45度的斜线)

为什么ReLU这么好用?

  • 计算简单,速度快
  • 缓解梯度消失问题(后面会讲)
  • 让网络变得稀疏(很多神经元输出为0)
(2)Sigmoid:把输出压缩到0-1之间

Sigmoid函数能把任意实数压缩到0和1之间:

\text{sigmoid}(x) = \frac{1}{1 + \exp(-x)}

以前常用在二分类问题的输出层,但现在在隐藏层用得少了。

(3)Tanh:把输出压缩到-1到1之间

Tanh(双曲正切)函数能把任意实数压缩到-1和1之间:

\text{tanh}(x) = \frac{1 - \exp(-2x)}{1 + \exp(-2x)}

4. 多层感知机的计算过程:通俗理解

别被数学符号吓住!其实多层感知机的计算过程很简单,就像流水线加工

假设我们用MLP来识别图片(Fashion-MNIST):

第一步:原材料→第一层加工
  • 原材料:图片像素(784个数字)
  • 加工:和权重W1相乘 + 偏置b1 → 然后过ReLU激活函数(把负数变成0)
  • 产出:隐藏层的256个神经元输出
第二步:第一层→第二层加工
  • 原材料:刚才的256个神经元输出
  • 加工:和权重W2相乘 + 偏置b2
  • 产出:最终的10个输出(对应10个类别)

用简单的话翻译数学公式:

如果非要用数学写出来(其实不用记,理解就行):

H = \text{ReLU}(XW_1 + b_1)

O = HW_2 + b_2

  • X:输入(图片像素)
  • W_1, b_1:第一层的权重和偏置
  • H:隐藏层输出
  • W_2, b_2:第二层的权重和偏置
  • O:最终输出

如果是分类问题,输出层后面还要加softmax(变成概率)。


🎯 通用近似定理:神经网络是"万能函数"

这里有个很牛的定理:只要有足够多的隐藏神经元,一个单隐藏层的MLP就能近似任意函数!

通俗理解:

  • 就像给你足够多的乐高积木,你能拼出任何形状
  • 就像C语言能写任何程序——但写得好不好就是另一回事了
  • 理论上,只要神经元够多,MLP能学会任何规律!

但注意:这只是"理论上",实际中我们还是需要合理设计网络结构!


二、多层感知机的从零开始实现:手搭一个深度网络!

光说不练假把式,我们来用PyTorch从零实现一个多层感知机。和上一篇的单层网络相比,我们只需要多加一个隐藏层和激活函数

🆚 与上一篇代码的对比:

上一篇(单层):

# 只有一层:输入→输出
net = nn.Sequential(nn.Linear(784, 10))

这一篇(多层):

# 有三层:输入→隐藏→输出
net = nn.Sequential(
    nn.Linear(784, 256),  # 多了这一层!
    nn.ReLU(),              # 多了激活函数!
    nn.Linear(256, 10)
)

就这么简单!我们开始吧!

import torch
from torch import nn
from d2l import torch as d2l

# ------------------------------------------------------
# 1. 加载数据:Fashion-MNIST数据集(10类服饰)
# ------------------------------------------------------
batch_size = 256  # 每批256张图片
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

# ------------------------------------------------------
# 2. 初始化模型参数:我们手搭的网有3层
# ------------------------------------------------------
# 定义各层的维度:
# - 输入层:784个神经元(28×28像素展平)
# - 隐藏层:256个神经元(我们自己定的超参数)
# - 输出层:10个神经元(对应10个类别)
num_inputs, num_outputs, num_hiddens = 784, 10, 256

# 输入层→隐藏层的权重和偏置
# randn:随机初始化,×0.01让初始值小一点,避免梯度爆炸
W1 = nn.Parameter(torch.randn(num_inputs, num_hiddens) * 0.01)
b1 = nn.Parameter(torch.zeros(num_hiddens))

# 隐藏层→输出层的权重和偏置
W2 = nn.Parameter(torch.randn(num_hiddens, num_outputs) * 0.01)
b2 = nn.Parameter(torch.zeros(num_outputs))

# 把所有参数放在一个列表里,方便后续优化
params = [W1, b1, W2, b2]

# ------------------------------------------------------
# 3. 定义激活函数:ReLU(自己写一个)
# ------------------------------------------------------
def relu(X):
    a = torch.zeros_like(X)  # 创建一个和X形状一样的全0张量
    return torch.max(X, a)   # 取X和0中较大的那个(就是把负数变成0)

# ------------------------------------------------------
# 4. 定义模型:前向传播的计算过程
# ------------------------------------------------------
def net(X):
    # 第一步:把图片展平(28×28 → 784)
    X = X.reshape((-1, num_inputs))
    
    # 第二步:计算隐藏层(输入→隐藏+ReLU)
    H = relu(X @ W1 + b1)  # @是矩阵乘法
    
    # 第三步:计算输出层(隐藏→输出)
    return H @ W2 + b2

# ------------------------------------------------------
# 5. 损失函数:分类问题用交叉熵
# ------------------------------------------------------
loss = nn.CrossEntropyLoss(reduction='none')

# ------------------------------------------------------
# 6. 训练:开始"织网"!
# ------------------------------------------------------
num_epochs, lr = 10, 0.1  # 训练10轮,学习率0.1
updater = torch.optim.SGD(params, lr=lr)  # 优化器用SGD

# 训练一个epoch(用完全部训练数据一次)
def train_epoch(net, train_iter, loss, updater):
    metric = d2l.Accumulator(3)  # 用来累加:损失总和、正确数、样本总数
    for X, y in train_iter:
        l = loss(net(X), y)  # 1. 前向传播,算损失
        updater.zero_grad()   # 2. 梯度清零
        l.mean().backward()   # 3. 反向传播,算梯度
        updater.step()        # 4. 更新参数
        # 记录指标
        metric.add(float(l.sum()), d2l.accuracy(net(X), y), y.numel())
    return metric[0] / metric[2], metric[1] / metric[2]

# 完整训练函数
def train(net, train_iter, test_iter, loss, num_epochs, updater):
    for epoch in range(num_epochs):
        # 训练一轮
        train_metrics = train_epoch(net, train_iter, loss, updater)
        # 在测试集上评估精度
        test_acc = d2l.evaluate_accuracy(net, test_iter)
        # 打印结果
        print(f'epoch {epoch + 1}, 训练损失: {train_metrics[0]:.3f}, 训练精度: {train_metrics[1]:.3f}, 测试精度: {test_acc:.3f}')

# 开始训练!
train(net, train_iter, test_iter, loss, num_epochs, updater)

你看!虽然加了一个隐藏层,但核心逻辑还是一样的——前向传播算损失,反向传播算梯度,然后更新参数!


三、多层感知机的简洁实现:框架大法好!

当然,实际工作中我们还是用框架的高级API,更简洁、更高效!

import torch
from torch import nn
from d2l import torch as d2l

# ------------------------------------------------------
# 1. 加载数据:Fashion-MNIST数据集
# ------------------------------------------------------
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

# ------------------------------------------------------
# 2. 定义模型:用nn.Sequential搭积木!
# ------------------------------------------------------
net = nn.Sequential(
    nn.Flatten(),           # 第一层:把图片从28×28展平成784
    nn.Linear(784, 256),    # 第二层:隐藏层(784输入,256隐藏单元)
    nn.ReLU(),               # 第三层:ReLU激活函数
    nn.Linear(256, 10)      # 第四层:输出层(256输入,10输出)
)

# ------------------------------------------------------
# 3. 初始化参数:给网络一个"好的起跑线"
# ------------------------------------------------------
def init_weights(m):
    # 如果是线性层,就初始化权重
    if type(m) == nn.Linear:
        # 用正态分布初始化,标准差0.01
        nn.init.normal_(m.weight, std=0.01)

# apply函数会把init_weights应用到net的每一层
net.apply(init_weights)

# ------------------------------------------------------
# 4. 准备训练:损失函数、优化器、超参数
# ------------------------------------------------------
loss = nn.CrossEntropyLoss(reduction='none')  # 损失函数用交叉熵
trainer = torch.optim.SGD(net.parameters(), lr=0.1)  # 优化器用SGD
num_epochs = 10  # 训练10轮

# ------------------------------------------------------
# 5. 开始训练
# ------------------------------------------------------
def train_epoch(net, train_iter, loss, updater):
    metric = d2l.Accumulator(3)  # 累加:损失、正确数、样本数
    for X, y in train_iter:
        l = loss(net(X), y)  # 1. 前向传播
        updater.zero_grad()   # 2. 梯度清零
        l.mean().backward()   # 3. 反向传播
        updater.step()        # 4. 更新参数
        metric.add(float(l.sum()), d2l.accuracy(net(X), y), y.numel())
    return metric[0] / metric[2], metric[1] / metric[2]

# 训练循环
for epoch in range(num_epochs):
    train_metrics = train_epoch(net, train_iter, loss, trainer)
    test_acc = d2l.evaluate_accuracy(net, test_iter)
    print(f'epoch {epoch + 1}, 训练损失: {train_metrics[0]:.3f}, 训练精度: {train_metrics[1]:.3f}, 测试精度: {test_acc:.3f}')

看!简洁实现就是这么清爽——框架帮我们把底层细节都封装好了!


四、模型选择、欠拟合和过拟合:深度学习最常见的两个坑!

现在模型变复杂了,我们面临一个新问题:过拟合

1. 训练误差和泛化误差:你考100分不代表你真学会了!

  • 训练误差:模型在训练集上的误差(就像你做练习题的正确率)
  • 泛化误差:模型在新数据上的误差(就像你期末考试的成绩)

我们的目标是:泛化误差小,而不是训练误差小!

举个通俗的例子:

  • 你把练习题的答案都背下来了(训练误差0),但考试遇到新题就不会(泛化误差很大)——这就是过拟合
  • 你连练习题都不会做(训练误差很大),考试肯定也考不好——这就是欠拟合

2. 模型选择:验证集是你的"模拟考试"

怎么选择模型?我们不能用测试集来选,不然就相当于"提前看了期末考试题"!

正确的做法是:把数据分成三份

  • 训练集:用来训练模型(做练习题)
  • 验证集:用来选择模型和超参数(模拟考试)
  • 测试集:用来最终评估(期末考试)

如果数据太少,用不了验证集怎么办?用K折交叉验证

  • 把数据分成K份
  • 每次用K-1份训练,1份验证
  • 重复K次,取平均

3. 欠拟合还是过拟合?

  • 欠拟合:训练误差大,验证误差也大——模型太简单,学不会
  • 过拟合:训练误差小,验证误差大——模型太复杂,死记硬背了

通俗比喻

  • 欠拟合:小学生连乘法表都没背会
  • 过拟合:把答案连题号一起背,换个顺序就不会

4. 多项式回归:一个直观的例子

我们用多项式回归来直观感受一下欠拟合和过拟合:

import torch
from torch import nn
from d2l import torch as d2l

# ------------------------------------------------------
# 1. 生成数据:真实模型是3阶多项式
# ------------------------------------------------------
max_degree = 20  # 我们准备的多项式特征最高到20阶
n_train, n_test = 100, 100  # 训练集100个样本,测试集100个样本

# 真实的权重:只有前4个参数有用(0阶到3阶)
true_w = torch.zeros(max_degree)  # 先初始化一个全0的长向量
true_w[0:4] = torch.tensor([5, 1.2, -3.4, 5.6])  # 真实权重:5 + 1.2x - 3.4x² + 5.6x³

# 生成x(特征)
features = torch.randn((n_train + n_test, 1))

# 生成多项式特征:x, x², x³, ..., x^20
poly_features = torch.pow(features, torch.arange(max_degree).reshape(1, -1))

# 生成y(标签):用真实模型计算 + 加一点噪声
labels = poly_features @ true_w
labels += torch.randn(labels.shape) * 0.1  # 噪声让问题更真实

# ------------------------------------------------------
# 2. 拆分训练集和测试集
# ------------------------------------------------------
train_features, test_features = poly_features[:n_train, :], poly_features[n_train:, :]
train_labels, test_labels = labels[:n_train], labels[n_train:]

# ------------------------------------------------------
# 3. 定义训练函数
# ------------------------------------------------------
def train(train_features, test_features, train_labels, test_labels, num_epochs=400):
    loss = nn.MSELoss(reduction='none')  # 损失函数用均方误差
    input_shape = train_features.shape[-1]  # 输入特征数
    net = nn.Sequential(nn.Linear(input_shape, 1, bias=False))  # 线性模型
    batch_size = min(10, train_labels.shape[0])
    
    # 加载数据
    train_iter = d2l.load_array((train_features, train_labels.reshape(-1,1)), batch_size)
    test_iter = d2l.load_array((test_features, test_labels.reshape(-1,1)), batch_size, is_train=False)
    
    trainer = torch.optim.SGD(net.parameters(), lr=0.01)  # 优化器用SGD
    
    # 训练循环
    for epoch in range(num_epochs):
        for X, y in train_iter:
            trainer.zero_grad()  # 梯度清零
            l = loss(net(X), y)  # 前向传播
            l.mean().backward()  # 反向传播
            trainer.step()  # 更新参数
        
        # 每20轮打印一次损失
        if epoch % 20 == 0:
            train_l = d2l.evaluate_loss(net, train_iter, loss)
            test_l = d2l.evaluate_loss(net, test_iter, loss)
            print(f'epoch {epoch}, 训练损失: {train_l:.3f}, 测试损失: {test_l:.3f}')

# ------------------------------------------------------
# 4. 实验1:用3阶多项式拟合(和真实模型一样)
# ------------------------------------------------------
print('=== 3阶多项式拟合(正好) ===')
train(train_features[:, :4], test_features[:, :4], train_labels, test_labels)

# ------------------------------------------------------
# 5. 实验2:用1阶多项式拟合(太简单,欠拟合)
# ------------------------------------------------------
print('\n=== 1阶多项式拟合(欠拟合) ===')
train(train_features[:, :1], test_features[:, :1], train_labels, test_labels)

# ------------------------------------------------------
# 6. 实验3:用20阶多项式拟合(太复杂,过拟合)
# ------------------------------------------------------
print('\n=== 20阶多项式拟合(过拟合) ===')
train(train_features, test_features, train_labels, test_labels)

运行后你会看到有趣的现象:

  • 3阶多项式:训练损失和测试损失都小——模型刚刚好,既能学习规律又能泛化
  • 1阶多项式:训练损失和测试损失都大——模型太简单,连训练集都学不会
  • 20阶多项式:训练损失很小,但测试损失很大——模型太复杂,把训练集的噪声都记住了

五、权重衰减:给权重"减负",防止过拟合!

知道了过拟合,我们来看看怎么对付它!第一个神器:权重衰减(Weight Decay)。

1. 高维线性回归:过拟合的重灾区

高维线性回归特别容易过拟合——因为特征太多,参数太多,模型太容易"死记硬背"。

2. 权重衰减的原理:L2正则化

权重衰减的核心思想:让权重不要太大

怎么实现?在损失函数后面加一个L2正则化项

其中\lambda是正则化系数,控制权重衰减的强度。

通俗理解:权重太大,损失就会变大——模型就会"自觉"地让权重变小。

3. 从零开始实现

import torch
from torch import nn
from d2l import torch as d2l

# ------------------------------------------------------
# 1. 生成高维线性回归数据(特征多,训练集小=容易过拟合)
# ------------------------------------------------------
n_train, n_test, num_inputs, batch_size = 20, 100, 200, 5
# 训练集只有20个样本,但有200个特征!这是故意制造的过拟合场景
true_w, true_b = torch.ones((num_inputs, 1)) * 0.01, 0.05

# 生成训练集和测试集
train_data = d2l.synthetic_data(true_w, true_b, n_train)
train_iter = d2l.load_array(train_data, batch_size)
test_data = d2l.synthetic_data(true_w, true_b, n_test)
test_iter = d2l.load_array(test_data, batch_size, is_train=False)

# ------------------------------------------------------
# 2. 初始化模型参数
# ------------------------------------------------------
def init_params():
    w = torch.normal(0, 1, size=(num_inputs, 1), requires_grad=True)
    b = torch.zeros(1, requires_grad=True)
    return [w, b]

# ------------------------------------------------------
# 3. 定义L2范数惩罚项(权重衰减的核心)
# ------------------------------------------------------
def l2_penalty(w):
    return torch.sum(w.pow(2)) / 2  # L2范数的一半(方便求导)

# ------------------------------------------------------
# 4. 训练函数:带权重衰减
# ------------------------------------------------------
def train(lambd):
    w, b = init_params()
    net, loss = lambda X: d2l.linreg(X, w, b), d2l.squared_loss
    num_epochs, lr = 100, 0.003
    
    for epoch in range(num_epochs):
        for X, y in train_iter:
            # 关键!:损失 = 原来的损失 + λ × L2惩罚项
            l = loss(net(X), y) + lambd * l2_penalty(w)
            l.sum().backward()  # 反向传播
            d2l.sgd([w, b], lr, batch_size)  # 更新参数
        
        # 每20轮打印一次损失
        if epoch % 20 == 0:
            train_l = d2l.evaluate_loss(net, train_iter, loss)
            test_l = d2l.evaluate_loss(net, test_iter, loss)
            print(f'epoch {epoch}, 训练损失: {train_l:.3f}, 测试损失: {test_l:.3f}')
    
    print('w的L2范数:', torch.norm(w).item())

# ------------------------------------------------------
# 5. 实验1:不用权重衰减(过拟合)
# ------------------------------------------------------
print('=== 不用权重衰减(过拟合) ===')
train(lambd=0)

# ------------------------------------------------------
# 6. 实验2:用权重衰减
# ------------------------------------------------------
print('\n=== 用权重衰减 ===')
train(lambd=3)

对比两个实验的结果:

  • 不用权重衰减:训练损失很小,但测试损失很大——过拟合了
  • 用权重衰减:测试损失变小了,w的L2范数也变小了——模型更"稳健"了!

4. 简洁实现

PyTorch已经帮我们封装好了,直接在优化器里设置weight_decay就行:

import torch
from torch import nn
from d2l import torch as d2l

# ------------------------------------------------------
# 1. 生成高维线性回归数据(和从零实现部分一样)
# ------------------------------------------------------
n_train, n_test, num_inputs, batch_size = 20, 100, 200, 5
true_w, true_b = torch.ones((num_inputs, 1)) * 0.01, 0.05
train_data = d2l.synthetic_data(true_w, true_b, n_train)
train_iter = d2l.load_array(train_data, batch_size)
test_data = d2l.synthetic_data(true_w, true_b, n_test)
test_iter = d2l.load_array(test_data, batch_size, is_train=False)

# ------------------------------------------------------
# 2. 权重衰减简洁实现:直接在优化器里设置weight_decay
# ------------------------------------------------------
def train_concise(wd):
    # 定义模型
    net = nn.Sequential(nn.Linear(num_inputs, 1))
    
    # 初始化参数
    for param in net.parameters():
        param.data.normal_()
    
    # 损失函数和超参数
    loss = nn.MSELoss(reduction='none')
    num_epochs, lr = 100, 0.003
    
    # 关键!:在优化器里设置weight_decay
    # 权重衰减只作用在权重上,不作用在偏置上
    trainer = torch.optim.SGD([
        {"params": net[0].weight, 'weight_decay': wd},
        {"params": net[0].bias}], lr=lr)
    
    # 训练循环
    for epoch in range(num_epochs):
        for X, y in train_iter:
            trainer.zero_grad()
            l = loss(net(X), y)
            l.mean().backward()
            trainer.step()
        
        # 每20轮打印一次
        if epoch % 20 == 0:
            train_l = d2l.evaluate_loss(net, train_iter, loss)
            test_l = d2l.evaluate_loss(net, test_iter, loss)
            print(f'epoch {epoch}, 训练损失: {train_l:.3f}, 测试损失: {test_l:.3f}')
    
    print('w的L2范数:', net[0].weight.norm().item())

# 开始训练,wd=3
train_concise(wd=3)

六、暂退法(Dropout):让神经元"随机失忆",防止过拟合!

对付过拟合的第二个神器:Dropout(暂退法)!

1. 重新审视过拟合:模型太"聪明"也不好

过拟合的另一个原因:神经元之间的共适应性太强——几个神经元"勾结"在一起,专门记训练数据的某些特征。

Dropout的思想:让神经元随机"失忆"——训练时随机把一些神经元的输出设为0,让它们不能"勾结"!

2. 扰动的稳健性:随机掉几个神经元也没事

Dropout相当于在训练时给网络加入了噪声,让网络变得更"稳健"——随机掉几个神经元也能正常工作。

通俗理解:就像考试前,你不知道会考哪道题,所以你必须全面复习,不能只押题!

3. 实践中的暂退法

  • 训练时:随机把一些神经元的输出设为0(通常dropout概率设为0.5)
  • 测试时:不用dropout,用所有神经元,但要把输出乘以(1 - dropout概率)

4. 从零开始实现

import torch
from torch import nn
from d2l import torch as d2l

# 定义dropout函数
def dropout_layer(X, dropout):
    assert 0 <= dropout <= 1
    if dropout == 1:
        return torch.zeros_like(X)
    if dropout == 0:
        return X
    mask = (torch.rand(X.shape) > dropout).float()
    return mask * X / (1.0 - dropout)

# 测试dropout
X = torch.arange(16, dtype = torch.float32).reshape((2, 8))
print('X:', X)
print('dropout 0:', dropout_layer(X, 0))
print('dropout 0.5:', dropout_layer(X, 0.5))
print('dropout 1:', dropout_layer(X, 1))

# 定义模型
num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256

dropout1, dropout2 = 0.2, 0.5

class Net(nn.Module):
    def __init__(self, num_inputs, num_outputs, num_hiddens1, num_hiddens2, is_training=True):
        super(Net, self).__init__()
        self.num_inputs = num_inputs
        self.training = is_training
        self.lin1 = nn.Linear(num_inputs, num_hiddens1)
        self.lin2 = nn.Linear(num_hiddens1, num_hiddens2)
        self.lin3 = nn.Linear(num_hiddens2, num_outputs)
        self.relu = nn.ReLU()
    
    def forward(self, X):
        H1 = self.relu(self.lin1(X.reshape((-1, self.num_inputs))))
        if self.training:
            H1 = dropout_layer(H1, dropout1)
        H2 = self.relu(self.lin2(H1))
        if self.training:
            H2 = dropout_layer(H2, dropout2)
        out = self.lin3(H2)
        return out

net = Net(num_inputs, num_outputs, num_hiddens1, num_hiddens2)

# 训练
num_epochs, lr, batch_size = 10, 0.5, 256
loss = nn.CrossEntropyLoss(reduction='none')
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
trainer = torch.optim.SGD(net.parameters(), lr=lr)

def train_epoch(net, train_iter, loss, updater):
    metric = d2l.Accumulator(3)
    for X, y in train_iter:
        l = loss(net(X), y)
        updater.zero_grad()
        l.mean().backward()
        updater.step()
        metric.add(float(l.sum()), d2l.accuracy(net(X), y), y.numel())
    return metric[0] / metric[2], metric[1] / metric[2]

for epoch in range(num_epochs):
    train_metrics = train_epoch(net, train_iter, loss, trainer)
    test_acc = d2l.evaluate_accuracy(net, test_iter)
    print(f'epoch {epoch + 1}, 训练损失: {train_metrics[0]:.3f}, 训练精度: {train_metrics[1]:.3f}, 测试精度: {test_acc:.3f}')

5. 简洁实现

PyTorch已经帮我们封装好了,直接用nn.Dropout层就行:

import torch
from torch import nn
from d2l import torch as d2l

# 1. 加载数据
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

# 2. 定义模型
dropout1, dropout2 = 0.2, 0.5

net = nn.Sequential(
    nn.Flatten(),
    nn.Linear(784, 256),
    nn.ReLU(),
    nn.Dropout(dropout1),  # 第一个dropout层
    nn.Linear(256, 256),
    nn.ReLU(),
    nn.Dropout(dropout2),  # 第二个dropout层
    nn.Linear(256, 10)
)

# 3. 初始化参数
def init_weights(m):
    if type(m) == nn.Linear:
        nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights)

# 4. 训练
num_epochs, lr = 10, 0.5
loss = nn.CrossEntropyLoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=lr)

def train_epoch(net, train_iter, loss, updater):
    metric = d2l.Accumulator(3)
    for X, y in train_iter:
        l = loss(net(X), y)
        updater.zero_grad()
        l.mean().backward()
        updater.step()
        metric.add(float(l.sum()), d2l.accuracy(net(X), y), y.numel())
    return metric[0] / metric[2], metric[1] / metric[2]

for epoch in range(num_epochs):
    train_metrics = train_epoch(net, train_iter, loss, trainer)
    test_acc = d2l.evaluate_accuracy(net, test_iter)
    print(f'epoch {epoch + 1}, 训练损失: {train_metrics[0]:.3f}, 训练精度: {train_metrics[1]:.3f}, 测试精度: {test_acc:.3f}')

七、前向传播、反向传播和计算图:理解深度学习的"引擎"

现在我们来深入理解一下神经网络的训练过程:前向传播和反向传播。

1. 前向传播:从输入到输出

前向传播(Forward Propagation)就是把输入数据喂给网络,一层一层地计算,直到得到输出。

以单隐藏层的MLP为例:

  1. 输入:X
  2. 隐藏层:H = \sigma(XW^{(1)} + b^{(1)})
  3. 输出层:O = HW^{(2)} + b^{(2)}
  4. 损失:L = \text{loss}(O, y)

2. 前向传播计算图:把计算画成图

我们可以把前向传播的计算过程画成一张计算图(Computational Graph),每个节点代表一个计算。正方形表示变量,圆圈表示操作符。 左下角表示输入,右上角表示输出。 注意显示数据流的箭头方向主要是向右和向上的。

图片来源于《动手学深度学习》

这张图能帮我们理解数据是怎么流动的,也能帮框架自动计算梯度。

3. 反向传播:从损失到梯度

反向传播(Backward Propagation)就是根据计算图,用链式法则从损失开始,反向计算每个参数的梯度。

通俗理解:前向传播是"从前往后算结果",反向传播是"从后往前算每个参数对结果的影响"。

4. 训练神经网络:前向→反向→更新

完整的训练流程:

  1. 前向传播:计算输出和损失
  2. 反向传播:计算每个参数的梯度
  3. 更新参数:用梯度下降更新参数
  4. 重复:回到步骤1,直到收敛

这就是深度学习的"引擎"!


八、数值稳定性和模型初始化:让训练"稳"起来!

深度网络训练时,经常会遇到两个问题:梯度消失梯度爆炸。这和数值稳定性、参数初始化密切相关。

1. 梯度消失和梯度爆炸:梯度怎么了?

  • 梯度消失(Vanishing Gradients):梯度变得特别小,参数几乎不更新——网络"学不动"了
  • 梯度爆炸(Exploding Gradients):梯度变得特别大,参数更新太剧烈——网络"失控"了

举个简单的例子:假设每一层的梯度都乘以0.1,10层之后梯度就是原来的0.1^{10},几乎消失了!

2. 参数初始化:好的开始是成功的一半!

怎么缓解梯度消失和梯度爆炸?好的参数初始化很重要!

常见的初始化方法:

  • Xavier初始化:根据输入和输出的维度来设置初始方差
  • He初始化:针对ReLU激活函数设计的初始化方法

PyTorch已经帮我们封装好了,直接用就行:

# Xavier初始化
nn.init.xavier_uniform_(m.weight)

# He初始化
nn.init.kaiming_uniform_(m.weight, mode='fan_in', nonlinearity='relu')

九、环境和分布偏移:当训练和测试不一样时

现实中还有一个常见问题:训练数据和测试数据的分布不一样!这叫分布偏移(Distribution Shift)。

1. 分布偏移的类型

  • 协变量偏移:输入的分布变了,但输出的条件分布没变
  • 概念偏移:输出的条件分布变了

举个例子:

  • 你用猫和狗的照片训练模型,但测试时用的是卡通猫和卡通狗——这是协变量偏移
  • 你用夏天的数据训练预测穿什么衣服,但冬天测试时,同样的温度需要穿不同的衣服——这是概念偏移

2. 分布偏移的应对方法

  • 数据增强:让训练数据更多样化
  • 域适应:专门处理分布偏移的技术
  • 持续学习:模型能适应新的数据分布

十、实战Kaggle比赛:预测房价!

讲了这么多理论,我们来实战一下!用Kaggle的房价预测数据集,把所有知识串起来。

1. Kaggle:数据科学竞赛平台

Kaggle是一个著名的数据科学竞赛平台,上面有很多真实的数据集和比赛。我们选的是"House Prices: Advanced Regression Techniques"这个比赛。

2. 下载和读取数据集

import numpy as np
import pandas as pd
import torch
from torch import nn
from d2l import torch as d2l
import os

# 我们用d2l提供的数据集
train_data = pd.read_csv(d2l.download('kaggle_house_pred_train'))
test_data = pd.read_csv(d2l.download('kaggle_house_pred_test'))

print(f'训练集形状: {train_data.shape}')
print(f'测试集形状: {test_data.shape}')

3. 数据预处理:真实世界的数据很乱!

真实数据很乱,我们需要预处理:

# 把训练集和测试集放在一起预处理
all_features = pd.concat((train_data.iloc[:, 1:-1], test_data.iloc[:, 1:]))

# 1. 处理数值特征:标准化
numeric_features = all_features.dtypes[all_features.dtypes != 'object'].index
all_features[numeric_features] = all_features[numeric_features].apply(
    lambda x: (x - x.mean()) / (x.std()))

# 缺失值用0填充
all_features[numeric_features] = all_features[numeric_features].fillna(0)

# 2. 处理类别特征:独热编码
all_features = pd.get_dummies(all_features, dummy_na=True)

# 3. 转换为张量
n_train = train_data.shape[0]
train_features = torch.tensor(all_features[:n_train].values, dtype=torch.float32)
test_features = torch.tensor(all_features[n_train:].values, dtype=torch.float32)
train_labels = torch.tensor(train_data.SalePrice.values.reshape(-1, 1), dtype=torch.float32)

4. 训练:用对数损失!

房价预测是回归问题,我们用对数损失,这样更关注相对误差(比如预测100万的房子差10万,和预测1000万的房子差10万,重要性是不一样的)。

# 定义模型
def get_net():
    net = nn.Sequential(nn.Linear(train_features.shape[1], 1))
    return net

# 对数均方误差损失
def log_rmse(net, features, labels):
    # 把小于1的值设为1,防止log(0)
    clipped_preds = torch.clamp(net(features), 1, float('inf'))
    rmse = torch.sqrt(nn.MSELoss()(torch.log(clipped_preds), torch.log(labels)))
    return rmse.item()

# 训练函数
def train(net, train_features, train_labels, test_features, test_labels,
          num_epochs, learning_rate, weight_decay, batch_size):
    train_ls, test_ls = [], []
    train_iter = d2l.load_array((train_features, train_labels), batch_size)
    optimizer = torch.optim.Adam(net.parameters(), lr=learning_rate, weight_decay=weight_decay)
    loss_func = nn.MSELoss(reduction='none')
    for epoch in range(num_epochs):
        for X, y in train_iter:
            optimizer.zero_grad()
            l = loss_func(net(X), y)
            l.sum().backward()
            optimizer.step()
        train_ls.append(log_rmse(net, train_features, train_labels))
        if test_labels is not None:
            test_ls.append(log_rmse(net, test_features, test_labels))
    return train_ls, test_ls

5. K折交叉验证:小数据集的法宝!

def get_k_fold_data(k, i, X, y):
    assert k > 1
    fold_size = X.shape[0] // k
    X_train, y_train = None, None
    for j in range(k):
        idx = slice(j * fold_size, (j + 1) * fold_size)
        X_part, y_part = X[idx, :], y[idx]
        if j == i:
            X_valid, y_valid = X_part, y_part
        elif X_train is None:
            X_train, y_train = X_part, y_part
        else:
            X_train = torch.cat([X_train, X_part], 0)
            y_train = torch.cat([y_train, y_part], 0)
    return X_train, y_train, X_valid, y_valid

def k_fold(k, X_train, y_train, num_epochs, learning_rate, weight_decay, batch_size):
    train_l_sum, valid_l_sum = 0, 0
    for i in range(k):
        data = get_k_fold_data(k, i, X_train, y_train)
        net = get_net()
        train_ls, valid_ls = train(net, *data, num_epochs, learning_rate,
                                       weight_decay, batch_size)
        train_l_sum += train_ls[-1]
        valid_l_sum += valid_ls[-1]
        print(f'折{i + 1},训练log rmse{float(train_ls[-1]):f}, 验证log rmse{float(valid_ls[-1]):f}')
    return train_l_sum / k, valid_l_sum / k

6. 模型选择:调参是个技术活!

k, num_epochs, lr, weight_decay, batch_size = 5, 100, 5, 0, 64
train_l, valid_l = k_fold(k, train_features, train_labels, num_epochs, lr, weight_decay, batch_size)
print(f'{k}-折验证: 平均训练log rmse: {float(train_l):f}, 平均验证log rmse: {float(valid_l):f}')

7. 提交Kaggle预测!

def train_and_pred(train_features, test_features, train_labels, test_data,
                   num_epochs, lr, weight_decay, batch_size):
    net = get_net()
    train_ls, _ = train(net, train_features, train_labels, None, None,
                        num_epochs, lr, weight_decay, batch_size)
    print(f'训练log rmse: {float(train_ls[-1]):f}')
    # 预测
    preds = net(test_features).detach().numpy()
    # 保存提交文件
    test_data['SalePrice'] = pd.Series(preds.reshape(1, -1)[0])
    submission = pd.concat([test_data['Id'], test_data['SalePrice']], axis=1)
    submission.to_csv('submission.csv', index=False)

train_and_pred(train_features, test_features, train_labels, test_data,
               num_epochs, lr, weight_decay, batch_size)

恭喜你!你已经完成了一个完整的Kaggle实战项目!


十一、小结:把知识编织成网

📊 与上一篇的最终对比

从第一篇到现在,我们的进步:

+------------+--------------------+------------------------+------------------------+
| 内容       | 第一篇(预备知识) | 第二篇(线性神经网络) | 这一篇(多层感知机)   |
+------------+--------------------+------------------------+------------------------+
| 网络层数   | 无(只是讲概念)   | 1层(线性)            | 2-3层(深度)          |
| 隐藏层     | -                  | 无                     | 有                     |
| 激活函数   | -                  | 无                     | 有(ReLU等)           |
| 能力       | 理解预备知识       | 只能学线性关系         | 可以学非线性关系       |
| 解决的问题 | -                  | 简单分类/回归          | 复杂分类/回归 + 过拟合 |
+------------+--------------------+------------------------+------------------------+

知识网络图:

  1. 多层感知机(MLP)

    • 隐藏层 + 激活函数 = 深度网络的起点
    • ReLU、Sigmoid、Tanh——三个好用的"非线性开关"
    • 只要神经元够多,单隐藏层就能"记住"任意函数
  2. 过拟合与欠拟合

    • 过拟合:在训练集上"考满分",在测试集上"考砸"
    • 欠拟合:在训练集上都"考不及格"
    • 验证集和K折交叉验证——帮你找到"正好"的模型
  3. 正则化两大法宝

    • 权重衰减:给权重"上紧箍咒",不让它太"放飞自我"
    • Dropout:让神经元随机"请假",避免它们"抱团作弊"
  4. 深度学习的引擎

    • 前向传播:数据从输入流到输出
    • 反向传播:梯度从输出流回输入
    • 梯度下降:顺着梯度的反方向,一步步"下山"
  5. 让训练更稳定

    • 梯度消失/爆炸——深度网络的"隐形杀手"
    • 好的参数初始化——给网络一个"好的起跑线"

给初学者的建议:

  • 不要被"深度"吓住——它就是多层简单逻辑的叠加
  • 遇到过拟合别慌——试试权重衰减和Dropout
  • 验证集是你的"好朋友"——别让测试集"剧透"
  • 数据少就用K折——充分利用每一个样本
  • 初始化很关键——好的开始往往事半功倍

写在最后

多层感知机就像深度学习的"十字绣"——看似复杂的图案,其实是由一个个简单的针脚(神经元)编织而成。后面的卷积神经网络、循环神经网络、Transformer,本质上都是这门手艺的延伸——只是用了更精巧的编织方式。

下一篇,我们将学习卷积神经网络(CNN)——计算机视觉领域的"织网大师"!敬请期待~

如果有疑问,欢迎留言交流,让我们一起编织深度学习的知识网络!

(注:文档部分内容参考《动手学深度学习》)
 动手学深度学习多层感知机: https://zh.d2l.ai/chapter_multilayer-perceptrons/index.html
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐