3. d2l — 多层感知机(MLP)

0. 简介

在第三章中,我们已经学习了线性回归和softmax回归。它们其实都可以看作线性模型:
y^=wTx+b \hat{y} = \mathbf{w}^\mathrm T\mathbf{x} + b y^=wTx+b

或者对于多分类问题:
o=XW+b\mathbf{o} = \mathbf{X}\mathbf{W} + \mathbf{b} o=XW+b

然后再通过softmax将输出转换成概率分布。到这里为止,整个模型最核心的部分仍然是一个仿射变换,也就是"加权求和再加偏置"。

这类模型的好处是非常直观、容易理解、容易训练。但是它也有一个很强的限制:它默认输入特征和输出之间的关系,可以通过一条直线、一个平面,或者更高维空间中的超平面来描述。

换句话说,线性模型擅长表达这种关系:

text 复制代码
某个特征变大,输出就按照固定方向变化。

比如:

  • 面积越大,房价通常越高;
  • 收入越高,偿还贷款的可能性通常越高;
  • 某个类别对应的分数越大,模型越倾向于预测为该类别。

这些场景中,线性模型是比较合理的。但问题在于,现实世界中很多规律并不是这样简单的。

0.1 线性模型为什么可能会出错?

我最开始不太理解书中说的"线性模型可能会出错"是什么意思。后来我觉得可以这样理解:

线性模型只能做加权求和,它没有能力主动表达"组合条件""转弯边界""先升后降"这类复杂关系。

比如我们想根据温度预测一个人是否舒服。线性模型只能表达两种倾向:

text 复制代码
温度越高,越舒服

或者:

text 复制代码
温度越高,越不舒服

但真实情况很可能是:

text 复制代码
太冷不舒服,适中舒服,太热也不舒服。

这就不是一条直线能描述的关系,而是一个"中间高,两边低"的曲线关系。如果仍然强行使用线性模型,那么模型只能在"越高越好"和"越高越差"之间选一个方向,自然就会在一部分样本上出错。

再举一个更经典的例子:XOR问题。

x1 x_1 x1 x2 x_2 x2 输出
0 0 0
0 1 1
1 0 1
1 1 0

这个规则其实很简单:

text 复制代码
两个输入不同,输出为1;
两个输入相同,输出为0。

但如果把这四个点画在平面上,就会发现类别1在左上和右下,类别0在左下和右上。此时不存在一条直线可以把两类完全分开。

所以,问题不在于线性模型训练得不够好,而是它本身的表达能力就不够。它只能画一条直线,而这个问题需要的是"拐弯"的边界。

0.2 为什么加一层还不够?

既然线性模型表达能力不够,一个很自然的想法是:那我多加几层线性层可以吗?

比如:
H=XW(1)+b(1)\mathbf{H} = \mathbf{X}\mathbf{W}^{(1)} + \mathbf{b}^{(1)} H=XW(1)+b(1)
O=HW(2)+b(2)\mathbf{O} = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)} O=HW(2)+b(2)

看起来模型确实"变深"了,但是如果中间没有任何非线性操作,那么它本质上仍然是线性的。因为我们可以把上面两个式子合并:
O=(XW(1)+b(1))W(2)+b(2)\mathbf{O} = (\mathbf{X}\mathbf{W}^{(1)} + \mathbf{b}^{(1)})\mathbf{W}^{(2)} + \mathbf{b}^{(2)} O=(XW(1)+b(1))W(2)+b(2)

也就是:
O=X(W(1)W(2))+b(1)W(2)+b(2)\mathbf{O} = \mathbf{X}(\mathbf{W}^{(1)}\mathbf{W}^{(2)}) + \mathbf{b}^{(1)}\mathbf{W}^{(2)} + \mathbf{b}^{(2)} O=X(W(1)W(2))+b(1)W(2)+b(2)

令:
W=W(1)W(2)\mathbf{W} = \mathbf{W}^{(1)}\mathbf{W}^{(2)} W=W(1)W(2)
b=b(1)W(2)+b(2)\mathbf{b} = \mathbf{b}^{(1)}\mathbf{W}^{(2)} + \mathbf{b}^{(2)} b=b(1)W(2)+b(2)

那么最终还是:
O=XW+b\mathbf{O} = \mathbf{X}\mathbf{W} + \mathbf{b} O=XW+b

所以,多层线性层叠在一起,本质上仍然只是一层线性层。它并没有真正获得新的表达能力。

这也是我觉得第四章最关键的一句话:

多层感知机真正重要的不是"多层",而是在线性层之间加入了非线性激活函数

0.3 多层感知机解决了什么?

多层感知机的结构大致可以理解为:

text 复制代码
线性变换 -> 非线性激活 -> 线性变换 -> 输出

用公式表示就是:
H=σ(XW(1)+b(1))\mathbf{H} = \sigma(\mathbf{X}\mathbf{W}^{(1)} + \mathbf{b}^{(1)}) H=σ(XW(1)+b(1))
O=HW(2)+b(2)\mathbf{O} = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)} O=HW(2)+b(2)

其中, σ\sigma σ就是激活函数,比如后面会学习到的ReLU

激活函数的作用,可以先简单理解为:给模型制造"拐弯"的能力

如果没有激活函数,模型不管堆多少层,最后都只能表达一条直线或一个超平面;而加入激活函数之后,模型就可以表达分段、弯曲、组合条件等更复杂的关系。

比如:

  • 对于XOR问题,模型可以先在隐藏层中学到一些中间判断,再组合出最终结果;
  • 对于温度和舒适度问题,模型可以表达"中间舒服,两边不舒服"的关系;
  • 对于图像分类问题,模型可以不再只看单个像素的强弱,而是逐渐学习边缘、纹理、形状等更复杂的组合特征。

因此,多层感知机可以看作是从线性模型走向深度学习模型的第一步。它保留了线性模型中"矩阵乘法 + 参数学习"的基本框架,但通过隐藏层和非线性激活函数,让模型拥有了更强的表达能力。

0.4 激活函数应该怎么选?

理解了"需要非线性激活函数"之后,我马上又有了另一个疑问:激活函数这么多,到底应该选哪一个?

这件事情如果直接看各种函数图像,很容易变成一种"炼丹"的感觉:

text 复制代码
ReLU试一下,sigmoid试一下,tanh试一下,GELU再试一下......

但实际工程里并不是完全瞎试。更合理的方式是:先有一个默认选择,再根据任务和训练现象调整。

对第四章的多层感知机来说,可以先记住一个非常实用的经验:

text 复制代码
隐藏层:默认先用 ReLU。
输出层:根据任务决定。

ReLU的定义很简单:
ReLU⁡(x)=max⁡(x,0)\operatorname{ReLU}(x) = \max(x, 0) ReLU(x)=max(x,0)

也就是说:

text 复制代码
x > 0 时,原样通过;
x <= 0 时,直接变成 0。

它看起来甚至有点粗暴,但是好处非常明显:

  • 计算简单;
  • 正数区域的梯度为1,反向传播时比较直接;
  • 相比sigmoidtanh,更不容易在大范围输入上出现梯度消失;
  • 在MLP、CNN这类网络中,通常是一个很强的默认选择。

所以,如果现在只是学习多层感知机,隐藏层激活函数可以先不用纠结太多:

不知道隐藏层用什么时,先用ReLU

但是输出层不能这样随便选。输出层的激活方式通常由任务决定:

任务 常见处理方式
回归问题 输出层通常不加激活函数
二分类问题 训练时常用BCEWithLogitsLoss,不手动加sigmoid
多分类问题 训练时常用CrossEntropyLoss,不手动加softmax

这里要特别注意:在PyTorch里,很多损失函数已经把数值稳定性考虑进去了。例如多分类任务中,我们通常直接把未归一化的输出,也就是logits,传给CrossEntropyLoss,而不是先手动做softmax

如果把"隐藏层"和"输出层"混在一起看,就容易产生误解。隐藏层激活函数主要是为了增强模型表达能力,而输出层的处理方式更多是为了匹配具体任务和损失函数。

那什么时候不用ReLU呢?

如果训练过程中发现很多神经元长期输出0,对应梯度也很少更新,这种情况通常被称为"死亡ReLU"。此时可以尝试LeakyReLU
LeakyReLU⁡(x)= { x, x>0 αx, x≤0 \operatorname{LeakyReLU}(x)= \begin{cases} x, & x > 0 \\ \alpha x, & x \le 0 \end{cases} LeakyReLU(x)={x,αx,x>0x≤0

它和ReLU的区别是:负数区域不会完全砍掉,而是保留一个很小的斜率。这样即使输入为负,仍然有一点梯度可以继续传播。

所以,我目前对激活函数选择的理解可以总结成:

text 复制代码
1. 先根据任务确定输出层。
2. 隐藏层默认先用 ReLU。
3. 如果训练不稳定,优先检查学习率、初始化、数据归一化。
4. 如果 ReLU 死亡明显,再考虑 LeakyReLU、GELU、SiLU 等替代方案。
5. 最后用验证集效果比较,而不是只看训练集损失。

也就是说,激活函数不是毫无章法地一个个试,而是先用成熟默认值,再根据现象有针对性地替换。

对我来说,这一章最核心的理解就是:

text 复制代码
线性模型的问题:只能表达直线式关系。
多层线性层的问题:合并后仍然是线性模型。
多层感知机的关键:在线性层之间加入非线性激活函数。
激活函数的选择:隐藏层先用 ReLU,输出层根据任务决定。

1. 从零开始实现MLP

前面我们已经知道了多层感知机为什么需要隐藏层和激活函数。接下来就需要把这个想法落到代码里。

书上的4.2. 多层感知机的从零开始实现整体流程其实和第三章的softmax回归非常像:

text 复制代码
读取数据 -> 初始化参数 -> 定义激活函数 -> 定义模型 -> 定义损失函数 -> 训练模型 -> 预测

区别在于,softmax回归是直接从输入层到输出层:

text 复制代码
输入 -> 输出

而MLP中间多了一层隐藏层:

text 复制代码
输入 -> 隐藏层 -> ReLU -> 输出

也就是说,MLP从零实现的核心并不是训练流程变复杂了,而是模型结构从一层线性变换变成了"两层线性变换 + 中间的非线性激活函数"。

1.1 导入依赖和读取数据

首先导入依赖:

python 复制代码
import torch
from torch import nn
from d2l import torch as d2l

然后继续使用Fashion-MNIST数据集:

python 复制代码
batch_size = 256
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)

这里和第三章是一样的。每张图片的大小是 28×2828 \times 28 28×28,展开之后就是一个长度为784的向量;Fashion-MNIST一共有10个类别,所以最终输出维度是10

也就是说,我们后面的模型要完成这样一个映射:

text 复制代码
784维输入 -> 10维输出

1.2 初始化模型参数

MLP相比softmax回归多了一个隐藏层,所以参数也会多一组。

python 复制代码
num_inputs, num_outputs, num_hiddens = 784, 10, 256

W1 = nn.Parameter(torch.randn(
    num_inputs, num_hiddens, requires_grad=True) * 0.01)
b1 = nn.Parameter(torch.zeros(num_hiddens, requires_grad=True))
W2 = nn.Parameter(torch.randn(
    num_hiddens, num_outputs, requires_grad=True) * 0.01)
b2 = nn.Parameter(torch.zeros(num_outputs, requires_grad=True))

params = [W1, b1, W2, b2]

这里可以先从形状上理解:

参数 形状 作用
W1 (784, 256) 从输入层映射到隐藏层
b1 (256,) 隐藏层偏置
W2 (256, 10) 从隐藏层映射到输出层
b2 (10,) 输出层偏置

所以第一层做的事情是:
H=XW1+b1 \mathbf{H} = \mathbf{X}\mathbf{W_1} + \mathbf{b_1} H=XW1+b1

第二层做的事情是:
O=HW2+b2 \mathbf{O} = \mathbf{H}\mathbf{W_2} + \mathbf{b_2} O=HW2+b2

不过注意,这里真正的隐藏层输出不是直接使用 H\mathbf{H} H,而是要先经过ReLU

1.3 定义激活函数

从零实现时,书中没有直接调用nn.ReLU(),而是手动实现了一个relu函数:

python 复制代码
def relu(X):
    a = torch.zeros_like(X)
    return torch.max(X, a)

这个实现非常直观。torch.zeros_like(X)会生成一个和X形状完全一样、元素全为0的张量。然后torch.max(X, a)会逐元素比较X和0,保留较大的那个值。

所以它等价于:
ReLU⁡(x)=max⁡(x,0)\operatorname{ReLU}(x) = \max(x, 0) ReLU(x)=max(x,0)

也就是说:

text 复制代码
正数保留,负数变成0。

这一步就是MLP区别于"多层线性模型"的关键。如果没有这一层ReLU,前后两个线性层最终仍然可以合并成一个线性层;有了ReLU之后,模型才真正有了表达非线性关系的能力。

1.4 定义模型

模型代码如下:

python 复制代码
def net(X):
    X = X.reshape((-1, num_inputs))
    H = relu(X@W1 + b1)
    return (H@W2 + b2)

这里一步一步拆开看。

首先:

python 复制代码
X = X.reshape((-1, num_inputs))

Fashion-MNIST读出来的图片形状通常是:

text 复制代码
(batch_size, 1, 28, 28)

但是全连接层需要的是二维矩阵:

text 复制代码
(batch_size, 784)

所以这里用reshape把每张图片展平成一个向量。-1表示让PyTorch自动推断批量大小。

接着:

python 复制代码
H = relu(X@W1 + b1)

这一步是隐藏层计算:

text 复制代码
输入特征 -> 线性变换 -> ReLU激活 -> 隐藏表示

其中@表示矩阵乘法。假设当前批量大小是256,那么形状变化是:

text 复制代码
X:  (256, 784)
W1: (784, 256)
H:  (256, 256)

最后:

python 复制代码
return (H@W2 + b2)

这一步是输出层计算:

text 复制代码
隐藏表示 -> 线性变换 -> 10个类别的logits

对应的形状变化是:

text 复制代码
H:  (256, 256)
W2: (256, 10)
O:  (256, 10)

这里输出的是logits,也就是还没有经过softmax的原始分数。后面使用CrossEntropyLoss时,不需要手动做softmax

1.5 定义损失函数

损失函数仍然使用交叉熵:

python 复制代码
loss = nn.CrossEntropyLoss(reduction='none')

这里要注意一点:nn.CrossEntropyLoss内部已经包含了log_softmax和负对数似然损失,所以传入的应该是模型原始输出,也就是logits,而不是softmax之后的概率。

这也是为什么前面的net(X)最后直接返回:

python 复制代码
H@W2 + b2

而不是:

python 复制代码
softmax(H@W2 + b2)

如果这里手动加了softmax,反而可能破坏CrossEntropyLoss内部的数值稳定性设计。

1.6 训练模型

训练代码如下:

python 复制代码
num_epochs, lr = 10, 0.1
updater = torch.optim.SGD(params, lr=lr)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, updater)

这里的训练流程和第三章softmax回归完全一样。因为从优化角度看,不管模型是线性模型还是MLP,都遵循这几步:

text 复制代码
前向传播 -> 计算损失 -> 反向传播 -> 更新参数

只是这次参与更新的参数变成了:

python 复制代码
params = [W1, b1, W2, b2]

也就是说,优化器会同时更新隐藏层和输出层的参数。

这里还有一个我实际踩到的坑:如果执行时报错:

text 复制代码
AttributeError: module 'd2l.torch' has no attribute 'train_ch3'

需要先确认安装的d2l版本和当前notebook是否匹配。比如这套notebook使用的是较早版本的D2L接口,d2l==0.17.6中有train_ch3,而d2l==1.0.3中已经没有这个函数了。

可以在notebook中执行:

python 复制代码
import d2l as root
from d2l import torch as d2l

print(root.__version__)
print(d2l.__file__)
print(hasattr(d2l, "train_ch3"))

如果已经重新安装过d2l,但notebook里还是找不到train_ch3,通常是因为Jupyter kernel还缓存着旧模块,需要重启kernel后从头执行。

1.7 预测结果

训练完成后,可以在测试集上看一下预测效果:

python 复制代码
d2l.predict_ch3(net, test_iter)

这一步会取出一批测试图片,展示真实标签和模型预测标签。它不是训练过程的一部分,而是帮助我们直观检查模型到底学得怎么样。

1.8 小结

从零实现MLP之后,我觉得可以把这一节压缩成一句话:

softmax回归的基础上,加一个隐藏层和一个ReLU激活函数,就得到了最简单的多层感知机。

代码结构上,它仍然是我们熟悉的训练套路:

text 复制代码
数据 -> 参数 -> 模型 -> 损失 -> 优化器 -> 训练

但模型结构上,它已经从:

text 复制代码
输入 -> 输出

变成了:

text 复制代码
输入 -> 隐藏层 -> ReLU -> 输出

这就是这一节最重要的变化。

1.9 Practice

1.9.1 隐藏单元数应该怎么设计?

在做课后练习时,我尝试把隐藏单元数从256调大到512

python 复制代码
num_inputs, num_outputs, num_hiddens = 784, 10, 512

从训练曲线上看,我的直观感受是:模型好像收敛得更快了。这个现象其实是合理的,因为num_hiddens控制的是隐藏层的宽度,也就是模型容量。

对于单隐藏层MLP来说,参数量大致为:
784×num_hiddens+num_hiddens×10784 \times num\_hiddens + num\_hiddens \times 10 784×num_hiddens+num_hiddens×10

如果num_hiddens = 256,参数量约为:

text 复制代码
784 * 256 + 256 * 10 = 203264

如果num_hiddens = 512,参数量约为:

text 复制代码
784 * 512 + 512 * 10 = 406528

可以看到,隐藏单元数翻倍后,参数量也几乎翻倍。模型有了更多参数,自然就更容易拟合训练集,因此训练损失下降更快、训练精度上升更快,并不奇怪。

但是,这里不能只看"收敛得快不快"。因为模型容量变大之后,它可能学到的是更好的规律,也可能只是更快地记住训练数据。

所以,num_hiddens不是越大越好,而是一个需要权衡的超参数:

text 复制代码
隐藏单元数太小:模型表达能力不够,训练集和测试集表现都可能不好。
隐藏单元数适中:模型有足够表达能力,同时泛化能力也较好。
隐藏单元数太大:训练集表现继续提升,但测试集可能不再提升,甚至下降。

比较合理的实验方式是:固定其它条件,只改变num_hiddens,然后观察训练损失、训练精度和测试精度。

比如可以尝试:

python 复制代码
for num_hiddens in [64, 128, 256, 512, 1024]:
    ...

然后重点看下面几种情况:

现象 说明
train acctest acc都提高 原模型容量可能不够,增大隐藏单元数有收益
train acc提高,但test acc不变或下降 可能开始过拟合
test acc只提升一点,但训练时间明显增加 继续加大可能不划算

所以,对于这个练习,我觉得更准确的结论不是"512一定比256好",而是:

隐藏单元数是模型容量的旋钮,调大后通常更容易拟合训练集,但最终是否值得,要看测试集效果和训练成本。

对于Fashion-MNIST这种教学任务,256已经是一个比较合理的默认值。512可能让训练曲线看起来更快、更强,但是否真正更好,还需要看最终的测试精度,而不是只凭训练过程中的体感。

1.9.2 添加更多隐藏层有什么区别?

练习2里提到可以尝试添加更多隐藏层。我改的是"从零开始实现MLP"这个版本,所以真正需要改的地方主要有两处:

text 复制代码
1. 初始化更多参数
2. 在net函数里多写一层前向传播

原来的模型结构是:

text 复制代码
输入 -> 隐藏层1 -> ReLU -> 输出

添加一个隐藏层之后,结构变成:

text 复制代码
输入 -> 隐藏层1 -> ReLU -> 隐藏层2 -> ReLU -> 输出

如果两个隐藏层都设为256个单元,那么参数初始化大概会变成这样:

python 复制代码
num_inputs, num_outputs, num_hiddens1, num_hiddens2 = 784, 10, 256, 256

W1 = nn.Parameter(torch.randn(num_inputs, num_hiddens1) * 0.01)
b1 = nn.Parameter(torch.zeros(num_hiddens1))
W2 = nn.Parameter(torch.randn(num_hiddens1, num_hiddens2) * 0.01)
b2 = nn.Parameter(torch.zeros(num_hiddens2))
W3 = nn.Parameter(torch.randn(num_hiddens2, num_outputs) * 0.01)
b3 = nn.Parameter(torch.zeros(num_outputs))

params = [W1, b1, W2, b2, W3, b3]

对应的模型函数也要多算一层:

python 复制代码
def net(X):
    X = X.reshape((-1, num_inputs))
    H1 = relu(X @ W1 + b1)
    H2 = relu(H1 @ W2 + b2)
    return H2 @ W3 + b3

所以,这个改动不是"多写几行代码"这么简单,它实际改变了模型能表示的函数范围。单隐藏层模型只做了一次"线性变换 + 非线性激活",两隐藏层模型则做了两次这样的组合:

text 复制代码
线性 -> ReLU -> 线性 -> ReLU -> 线性

从参数量上看,原来的单隐藏层模型只看权重,大约是:

text 复制代码
784 * 256 + 256 * 10 = 203264

添加第二个隐藏层后,只看权重,大约是:

text 复制代码
784 * 256 + 256 * 256 + 256 * 10 = 268800

多出来的主要就是中间这部分:

text 复制代码
256 * 256 = 65536

也就是说,第二个隐藏层带来了更多参数,也带来了更强的表达能力。直观理解是:第一层可以先学一些比较基础的特征,第二层再把这些特征重新组合,形成更复杂的判断依据。

但是,我改完之后没有明显感觉到训练结果变好,这也很正常。原因不是"加隐藏层没用",而是当前这个任务和训练设置下,差异不一定容易体现出来。

可能的原因有几个:

现象 解释
Fashion-MNIST本身不算特别复杂 单隐藏层256个单元已经能学到不少规律
只训练10 更深的模型不一定在很短训练中马上体现优势
学习率、初始化方式没变 更深的网络可能需要重新调学习率、训练轮数等超参数
只看曲线体感 曲线差一点点时,用肉眼很难判断
测试精度接近瓶颈 模型容量增加后,训练集可能更好,但测试集不一定提升

这里还要注意一个Jupyter里的细节:如果只是改了代码单元,但没有重新执行"参数初始化"和"模型定义"这两个单元,那么后面的训练单元可能仍然在用内存里的旧params和旧net。所以比较实验前,最好从参数初始化开始重新运行一遍。

更合理的比较方式不是只看图像变化,而是固定其它条件,只改变隐藏层数量,然后记录最后的train losstrain acctest acc和训练时间。

可以这样理解结果:

结果 说明
训练精度提升,测试精度也提升 添加隐藏层确实带来了有效收益
训练精度提升,测试精度不变 模型更会拟合训练集了,但泛化收益不明显
训练精度提升,测试精度下降 可能开始过拟合
指标几乎不变,但训练更慢 当前任务下加深网络不划算

所以,这个练习的结论可以总结为:

添加隐藏层会改变模型结构,增加参数量和表达能力,但不保证测试效果一定明显变好。对于这个Fashion-MNIST例子来说,如果只加一层、其它超参数都不变,最后看不出明显差异是正常的。

从零实现的好处是:每个参数、每次矩阵乘法、每个激活函数都看得很清楚。但真正写工程代码时,我们通常不会手动维护W1b1W2b2这些参数,而是把这些重复工作交给深度学习框架。

所以,下一节的"简洁实现"并不是换了一个新模型,而是用PyTorch的高级API把同一个MLP写得更像工程代码。

2. 多层感知机的简洁实现

从零开始实现MLP时,我们自己做了这些事情:

text 复制代码
手动展平输入
手动创建W和b
手动写矩阵乘法
手动调用ReLU
手动把参数放进params

而简洁实现的核心思想就是:模型结构仍然一样,但这些底层细节交给nn模块管理。

2.1 定义模型

简洁实现里,模型只需要几行代码:

python 复制代码
import torch
from torch import nn
from d2l import torch as d2l

net = nn.Sequential(nn.Flatten(),
                    nn.Linear(784, 256),
                    nn.ReLU(),
                    nn.Linear(256, 10))

这里的nn.Sequential可以理解成一个"按顺序执行的容器"。数据进入net之后,会依次经过里面的每一层:

text 复制代码
Flatten -> Linear(784, 256) -> ReLU -> Linear(256, 10)

它和从零实现中的代码是一一对应的。

从零实现时,我们写的是:

python 复制代码
def net(X):
    X = X.reshape((-1, num_inputs))
    H = relu(X @ W1 + b1)
    return H @ W2 + b2

简洁实现中,对应关系如下:

从零实现 简洁实现 作用
X.reshape((-1, num_inputs)) nn.Flatten() 把图片展平成向量
X @ W1 + b1 nn.Linear(784, 256) 输入层到隐藏层
relu(...) nn.ReLU() 引入非线性
H @ W2 + b2 nn.Linear(256, 10) 隐藏层到输出层

所以,简洁实现并没有改变模型本身。它仍然是:

text 复制代码
输入 -> 隐藏层 -> ReLU -> 输出

只是从"我自己手写每一步",变成了"我声明每一层是什么,让PyTorch帮我执行"。

2.2 初始化参数

从零实现时,我们自己创建参数:

python 复制代码
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))

简洁实现里,nn.Linear会自动创建自己的weightbias。所以我们不再直接写W1b1,而是定义一个初始化函数,让它作用到网络中的线性层上:

python 复制代码
def init_weights(m):
    if type(m) == nn.Linear:
        nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights)

这里有两个点需要注意:

text 复制代码
nn.Linear会自动持有参数。
net.apply会递归遍历net里的每个模块,并对它们调用init_weights。

也就是说,nn.Linear(784, 256)内部其实就有一组权重和偏置,只是我们不再手动命名成W1b1

如果想看得更直观,可以先从等价计算的角度这样理解:

text 复制代码
nn.Linear(784, 256) 约等于 W1: (784, 256), b1: (256,)
nn.Linear(256, 10)  约等于 W2: (256, 10),  b2: (10,)

不过如果真的打印nn.Linear内部的weight.shape,会发现方向是反过来的:

text 复制代码
nn.Linear(784, 256).weight.shape = (256, 784)
nn.Linear(256, 10).weight.shape  = (10, 256)

原因是PyTorch的nn.Linear内部计算形式是:

text 复制代码
output = input @ weight.T + bias

而从零实现里我们手写的是:

text 复制代码
output = input @ W + b

所以两者表达的是同一个线性变换,只是参数矩阵的存放方向不同。

不过这里有一个细节:书里的初始化函数只显式初始化了weight,没有显式初始化biasbias会保留nn.Linear创建时的默认初始化。对于这个教学例子影响不大,如果想更贴近从零实现,也可以把偏置清零:

python 复制代码
def init_weights(m):
    if type(m) == nn.Linear:
        nn.init.normal_(m.weight, std=0.01)
        if m.bias is not None:
            nn.init.zeros_(m.bias)

2.3 训练过程

训练部分和从零实现几乎一样:

python 复制代码
batch_size, lr, num_epochs = 256, 0.1, 10
loss = nn.CrossEntropyLoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=lr)

然后读取数据并训练:

python 复制代码
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

这里最重要的变化是优化器的参数来源。

从零实现时,我们要手动把参数收集起来:

python 复制代码
params = [W1, b1, W2, b2]
updater = torch.optim.SGD(params, lr=lr)

简洁实现时,参数都已经被net管理了,所以直接写:

python 复制代码
trainer = torch.optim.SGD(net.parameters(), lr=lr)

net.parameters()会自动把nn.Linear里面的所有可学习参数取出来。这样一来,后续即使我们继续添加隐藏层,也不需要手动维护params列表。

2.4 和从零实现的区别

学习这两种实现时,我觉得最重要的是不要把它们看成两个模型。它们本质上是同一个模型,只是抽象层级不同。

对比点 从零开始实现 简洁实现
模型结构 自己写矩阵乘法和激活函数 nn.Sequential声明网络结构
输入展平 X.reshape((-1, num_inputs)) nn.Flatten()
参数创建 手动创建W1b1W2b2 nn.Linear自动创建
参数初始化 创建参数时直接乘0.01 net.apply(init_weights)统一初始化
前向传播 自己写X @ W + b nn.Linear内部完成
参数传给优化器 手动写params列表 使用net.parameters()
扩展网络 要自己新增参数和前向逻辑 Sequential里继续加层

从零实现更适合学习原理,因为它把MLP的内部计算完全展开了。简洁实现更适合实际编码,因为它减少了重复代码,也更不容易漏掉参数。

比如,如果要在简洁实现里添加一个隐藏层,只需要把模型改成:

python 复制代码
net = nn.Sequential(nn.Flatten(),
                    nn.Linear(784, 256),
                    nn.ReLU(),
                    nn.Linear(256, 256),
                    nn.ReLU(),
                    nn.Linear(256, 10))

相比从零实现,这里不用再手动新增W3b3,也不用手动修改params列表。只要这些层放在net里面,net.parameters()就能自动找到它们。

2.5 小结

简洁实现这一节,我觉得可以总结成一句话:

从零实现是在学习"MLP到底怎么算",简洁实现是在学习"如何用框架正确表达同一个MLP"。

因此,简洁实现并不是跳过原理,而是把已经理解的计算过程封装起来。后面再继续学习过拟合、权重衰减、Dropout等内容时,其实都是在围绕一个核心问题展开:

如何让模型既有足够强的表达能力,又不至于在训练数据上学得过头。

相关推荐
用户723429355332 天前
训练任务从提交到运行:完整链路和排障地图
aiops
用户723429355332 天前
Kubernetes 基础对象:Pod、Job、Deployment、Service 等
aiops
析数塔3 天前
AI 时代测试开发新范式:从用例验证到 Agent 评测体系
agent·测试·aiops
SRETalk5 天前
如何使用 AI 解答开源项目的问题,其实只需要一句话
aiops·categraf
SRETalk6 天前
夜莺开源监控如何使用 Docker 部署,有哪些注意事项?
aiops·夜莺监控
小猿姐17 天前
MySQL Top 10 热点问题 AI 运维实战:从内核诊断到云原生运维
mysql·云原生·aiops
属鼠哥18 天前
一场正在发生的范式转变:Loop Engineering(循环工程)
人工智能·aiops
AIOps打工人19 天前
新人用 AI 30 分钟,我并不高兴
aiops