0. 简介
在第三章中,我们已经学习了线性回归和softmax回归。它们其实都可以看作线性模型:
y^=wTx+b
或者对于多分类问题:
o=XW+b
然后再通过softmax将输出转换成概率分布。到这里为止,整个模型最核心的部分仍然是一个仿射变换,也就是"加权求和再加偏置"。
这类模型的好处是非常直观、容易理解、容易训练。但是它也有一个很强的限制:它默认输入特征和输出之间的关系,可以通过一条直线、一个平面,或者更高维空间中的超平面来描述。
换句话说,线性模型擅长表达这种关系:
text
某个特征变大,输出就按照固定方向变化。
比如:
- 面积越大,房价通常越高;
- 收入越高,偿还贷款的可能性通常越高;
- 某个类别对应的分数越大,模型越倾向于预测为该类别。
这些场景中,线性模型是比较合理的。但问题在于,现实世界中很多规律并不是这样简单的。
0.1 线性模型为什么可能会出错?
我最开始不太理解书中说的"线性模型可能会出错"是什么意思。后来我觉得可以这样理解:
线性模型只能做加权求和,它没有能力主动表达"组合条件""转弯边界""先升后降"这类复杂关系。
比如我们想根据温度预测一个人是否舒服。线性模型只能表达两种倾向:
text
温度越高,越舒服
或者:
text
温度越高,越不舒服
但真实情况很可能是:
text
太冷不舒服,适中舒服,太热也不舒服。
这就不是一条直线能描述的关系,而是一个"中间高,两边低"的曲线关系。如果仍然强行使用线性模型,那么模型只能在"越高越好"和"越高越差"之间选一个方向,自然就会在一部分样本上出错。
再举一个更经典的例子:XOR问题。
| x1 | 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)
O=HW(2)+b(2)
看起来模型确实"变深"了,但是如果中间没有任何非线性操作,那么它本质上仍然是线性的。因为我们可以把上面两个式子合并:
O=(XW(1)+b(1))W(2)+b(2)
也就是:
O=X(W(1)W(2))+b(1)W(2)+b(2)
令:
W=W(1)W(2)
b=b(1)W(2)+b(2)
那么最终还是:
O=XW+b
所以,多层线性层叠在一起,本质上仍然只是一层线性层。它并没有真正获得新的表达能力。
这也是我觉得第四章最关键的一句话:
多层感知机真正重要的不是"多层",而是在线性层之间加入了非线性激活函数。
0.3 多层感知机解决了什么?
多层感知机的结构大致可以理解为:
text
线性变换 -> 非线性激活 -> 线性变换 -> 输出
用公式表示就是:
H=σ(XW(1)+b(1))
O=HW(2)+b(2)
其中, σ就是激活函数,比如后面会学习到的ReLU。
激活函数的作用,可以先简单理解为:给模型制造"拐弯"的能力。
如果没有激活函数,模型不管堆多少层,最后都只能表达一条直线或一个超平面;而加入激活函数之后,模型就可以表达分段、弯曲、组合条件等更复杂的关系。
比如:
- 对于
XOR问题,模型可以先在隐藏层中学到一些中间判断,再组合出最终结果; - 对于温度和舒适度问题,模型可以表达"中间舒服,两边不舒服"的关系;
- 对于图像分类问题,模型可以不再只看单个像素的强弱,而是逐渐学习边缘、纹理、形状等更复杂的组合特征。
因此,多层感知机可以看作是从线性模型走向深度学习模型的第一步。它保留了线性模型中"矩阵乘法 + 参数学习"的基本框架,但通过隐藏层和非线性激活函数,让模型拥有了更强的表达能力。
0.4 激活函数应该怎么选?
理解了"需要非线性激活函数"之后,我马上又有了另一个疑问:激活函数这么多,到底应该选哪一个?
这件事情如果直接看各种函数图像,很容易变成一种"炼丹"的感觉:
text
ReLU试一下,sigmoid试一下,tanh试一下,GELU再试一下......
但实际工程里并不是完全瞎试。更合理的方式是:先有一个默认选择,再根据任务和训练现象调整。
对第四章的多层感知机来说,可以先记住一个非常实用的经验:
text
隐藏层:默认先用 ReLU。
输出层:根据任务决定。
ReLU的定义很简单:
ReLU(x)=max(x,0)
也就是说:
text
x > 0 时,原样通过;
x <= 0 时,直接变成 0。
它看起来甚至有点粗暴,但是好处非常明显:
- 计算简单;
- 正数区域的梯度为1,反向传播时比较直接;
- 相比
sigmoid和tanh,更不容易在大范围输入上出现梯度消失; - 在MLP、CNN这类网络中,通常是一个很强的默认选择。
所以,如果现在只是学习多层感知机,隐藏层激活函数可以先不用纠结太多:
不知道隐藏层用什么时,先用
ReLU。
但是输出层不能这样随便选。输出层的激活方式通常由任务决定:
| 任务 | 常见处理方式 |
|---|---|
| 回归问题 | 输出层通常不加激活函数 |
| 二分类问题 | 训练时常用BCEWithLogitsLoss,不手动加sigmoid |
| 多分类问题 | 训练时常用CrossEntropyLoss,不手动加softmax |
这里要特别注意:在PyTorch里,很多损失函数已经把数值稳定性考虑进去了。例如多分类任务中,我们通常直接把未归一化的输出,也就是logits,传给CrossEntropyLoss,而不是先手动做softmax。
如果把"隐藏层"和"输出层"混在一起看,就容易产生误解。隐藏层激活函数主要是为了增强模型表达能力,而输出层的处理方式更多是为了匹配具体任务和损失函数。
那什么时候不用ReLU呢?
如果训练过程中发现很多神经元长期输出0,对应梯度也很少更新,这种情况通常被称为"死亡ReLU"。此时可以尝试LeakyReLU:
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×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
第二层做的事情是:
O=HW2+b2
不过注意,这里真正的隐藏层输出不是直接使用 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)
也就是说:
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×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 acc和test 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 loss、train acc、test acc和训练时间。
可以这样理解结果:
| 结果 | 说明 |
|---|---|
| 训练精度提升,测试精度也提升 | 添加隐藏层确实带来了有效收益 |
| 训练精度提升,测试精度不变 | 模型更会拟合训练集了,但泛化收益不明显 |
| 训练精度提升,测试精度下降 | 可能开始过拟合 |
| 指标几乎不变,但训练更慢 | 当前任务下加深网络不划算 |
所以,这个练习的结论可以总结为:
添加隐藏层会改变模型结构,增加参数量和表达能力,但不保证测试效果一定明显变好。对于这个Fashion-MNIST例子来说,如果只加一层、其它超参数都不变,最后看不出明显差异是正常的。
从零实现的好处是:每个参数、每次矩阵乘法、每个激活函数都看得很清楚。但真正写工程代码时,我们通常不会手动维护W1、b1、W2、b2这些参数,而是把这些重复工作交给深度学习框架。
所以,下一节的"简洁实现"并不是换了一个新模型,而是用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会自动创建自己的weight和bias。所以我们不再直接写W1、b1,而是定义一个初始化函数,让它作用到网络中的线性层上:
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)内部其实就有一组权重和偏置,只是我们不再手动命名成W1和b1。
如果想看得更直观,可以先从等价计算的角度这样理解:
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,没有显式初始化bias。bias会保留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() |
| 参数创建 | 手动创建W1、b1、W2、b2 |
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))
相比从零实现,这里不用再手动新增W3、b3,也不用手动修改params列表。只要这些层放在net里面,net.parameters()就能自动找到它们。
2.5 小结
简洁实现这一节,我觉得可以总结成一句话:
从零实现是在学习"MLP到底怎么算",简洁实现是在学习"如何用框架正确表达同一个MLP"。
因此,简洁实现并不是跳过原理,而是把已经理解的计算过程封装起来。后面再继续学习过拟合、权重衰减、Dropout等内容时,其实都是在围绕一个核心问题展开:
如何让模型既有足够强的表达能力,又不至于在训练数据上学得过头。