深度学习基础

该笔记基于 zh.d2l.ai

服务器相关

nfs 10.126.62.18
用户管理系统 172.31.41.142:8888
G138 172.31.41.138
生产平台 172.31.41.146
curl ‘http://10.10.43.3’ --data “DDDDD=账号&upass=密码&0MKKey=”

压缩 tar -czvf xxx.tar.gz abc
解压 tar -xzvf xxx.tar.gz
◦ top:cpu、内存、进程情况
◦ free -h:内存使用情况(看available即可)
◦ nvidia-smi (gpustat):显卡情况
◦ du -sh: 查看文件夹中文件大小

查询a4-1镜像服务器已有镜像
http://172.31.41.143:5000/v2/_catalog
http://172.31.41.143:5000/v2/镜像名/tags/list

insis-share-data
insis-data-pwd

cd /mnt/nfs-storage-node-18/Graph

前期准备

安装 Miniconda

Miniconda 的主要作用是用来管理 Python 的环境,可以方便地安装和卸载 Python 包,而且不会影响到系统的 Python 环境

由于网站的教学并没有提供 Windows 系统的安装教程,所以只好去百度了一下,最后按照这个教程安装了 Miniconda

在 cmd 中调用 conda init 命令初始化终端

重启终端之后调用 conda create --name d2l python=3.10 -y 创建一个名为 d2l 的 Python 环境,其中 -y 表示自动确认

之后调用 conda activate d2l 激活 d2l 环境

安装深度学习框架和d2l

我们主要学习 PyTorch 框架的学习

首先按如下方式安装PyTorch

1
2
pip install torch==1.12.0
pip install torchvision==0.13.0

下一步是安装d2l包

1
pip install d2l=0.17.6

这里安装失败,报错

1
2
3
4
note: This error originates from a subprocess, and is likely not a problem with pip.
ERROR: Failed building wheel for pandas
Failed to build pandas
ERROR: Could not build wheels for pandas, which is required to install pyproject.toml-based projects

貌似并不是 pip 的问题,查阅资料后得知好像是因为pandas版本不兼容,将命令修改为如下命令后安装成功

1
pip install d2l pandas==1.5.3

由于pip默认安装的是国外的源,下载速度较慢,这里我将pip源改为清华源

1
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple

最后通过链接下载 D2L Notebook

1
https://zh-v2.d2l.ai/d2l-zh-2.0.0.zip 

引言

机器学习中的关键组件

无论什么类型的机器学习问题,都会遇到这些组件:

  • 可以用来学习的数据(data)

  • 如何转换数据的模型(model)

  • 一个目标函数(objective function),用来量化模型的有效性

  • 调整模型参数以优化目标函数的算法(algorithm)

数据

数据集由一个个样本组成,通常每个样本由一组称为特征的属性组成

当每个样本的特征类别数量都是相同的时候,其特征向量是固定长度的,这个长度被称为数据的维数

并不是所有的数据都可以用固定长度的向量表示,深度学习的一个主要优势是可以处理不同长度的数据

仅仅拥有海量的数据是不够的,我们还需要正确的数据

Garbage in, garbage out

可用数据集通常可以分成两部分:训练数据集用于拟合模型参数,测试数据集用于评估拟合的模型

模型

深度学习关注的模型由神经网络错综复杂的交织在一起,包含层层数据转换,因此被称为深度学习

目标函数

在机器学习中,我们需要定义模型的优劣程度的度量,这个度量在大多数情况是"可优化"的,这被称之为目标函数,有时也被称为损失函数,我们需要最小化损失函数,或者最大化一个新的反函数

在试图预测数值时,最常见的损失函数是平方误差

当试图解决分类问题时,最常见的目标函数是最小化错误率

有些目标函数(如平方误差)很容易被优化,有些目标(如错误率)难以直接优化,此时通常会优化替代目标

算法

深度学习中,大多流行的优化算法通常基于一种基本方法——梯度下降

简而言之,在每个步骤中,梯度下降法都会检查每个参数,看看如果仅对该参数进行少量变动,训练集损失会朝哪个方向移动

然后,它在可以减少损失的方向上优化参数

常见机器学习问题

监督学习

监督学习擅长在"给定输入特征"的情况下预测标签

监督学习之所以能发挥作用,是因为在训练参数时,我们为模型提供了一个数据集,其中每个样本都有真实的标签

用概率论术语来说,我们希望预测"估计给定输入特征的标签"的条件概率

文中的这句话不太理解,感觉像是在说

预测在已知输入的特征和其对应的标签这个条件下,其它事件发生的概率

这样许多重要的事情都可以被看作是监督学习问题

无监督学习

数据中不含有"目标"的机器学习问题通常被为无监督学习

与环境互动

上述两类学习都是在算法与环境断开后进行的,被称为离线学习

考虑"与真实环境互动"将打开一整套新的建模问题。

强化学习

在强化学习问题中,智能体在一系列的时间步骤上与环境交互

智能体通过特定时间与环境交互从环境中获得奖励,以此来决策后续操作

不难看出,强化学习框架的通用性十分强大

当环境可被完全观察到时,强化学习问题被称为马尔可夫决策过程

当状态不依赖于之前的操作时,我们称该问题为上下文赌博机

当没有状态,只有一组最初未知回报的可用动作时,这个问题就是经典的多臂赌博机

一些其余的琐碎

神经网络的得名源于生物灵感,其核心是当今大多数网络中都可以找到的几个关键原则:

  • 线性和非线性处理单元的交替,通常称为层

  • 使用链式规则一次性调整网络中的全部参数。

核方法、决策树和图模型等强大的统计工具(在经验上)证明是更为优越的

“表示学习"目的是寻找表示本身,因此深度学习可以称为"多级表示学习”

深度学习的一个关键优势是它不仅取代了传统学习管道末端的浅层模型,而且还取代了劳动密集型的特征工程过程

预备知识

数据操作

通常,我们需要做两件重要的事

  • 获取数据
  • 将数据读入计算机后对其进行处理

入门

导入torch

1
import torch

使用 arange 创建一个行向量 x

1
x = torch.arange(12)

可以通过张量的shape属性来访问张量每个轴的长度

1
x.shape

查询张量中元素的总数

1
x.numel()

可以调用reshape函数改变张量的形状,但是元素的总数必须保持不变

1
X = x.reshape(3, 4)

可以通过-1来自动计算出维度

1
X = x.reshape(-1, 4)

使用全0、全1,或者从特定分布中随机采样的数字来初始化矩阵

1
2
3
torch.zeros((2, 3, 4))
torch.ones((2, 3, 4))
torch.randn(3, 4)

运算

在这些数据上执行数学运算,其中最简单且最有用的操作是按元素运算

1
2
3
x = torch.tensor([1.0, 2, 4, 8])
y = torch.tensor([2, 2, 2, 2])
x + y, x - y, x * y, x / y, x ** y

调用这些操作符的时候两个张量中的每个元素会依次运算

也可以把多个张量连结在一起

1
2
3
X = torch.arange(12, dtype=torch.float32).reshape((3,4))
Y = torch.tensor([[2.0, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
torch.cat((X, Y), dim=0), torch.cat((X, Y), dim=1)

上面的代码中还修改了dtype,调用了reshape函数

对每个位置判断是否相等

1
X == Y

对所有元素求和

1
X.sum()

广播机制

如果对不同形状的张量进行运算,会触发广播机制

在大多数情况下,我们将沿着数组中长度为1的轴进行广播

广播机制主要通过复制元素来实现

索引和切片

与任何Python数组一样:第一个元素的索引是0,最后一个元素索引是-1

节省内存

如果我们用Y = X + Y,我们将取消引用Y指向的张量,而是指向新分配的内存处的张量

这可能是不可取的,原因有两个

  • 首先,我们不想总是不必要地分配内存。在机器学习中,我们可能有数百兆的参数,并且在一秒内多次更新所有参数。通常情况下,我们希望原地执行这些更新;

  • 如果我们不原地更新,其他引用仍然会指向旧的内存位置,这样我们的某些代码可能会无意中引用旧的参数

解决方法是使用 X[:] = X + Y

数据预处理

读取数据集

创建一个人工数据集,并存储在CSV文件

1
2
3
4
5
6
7
8
9
10
import os

os.makedirs(os.path.join('..', 'data'), exist_ok=True)
data_file = os.path.join('..', 'data', 'house_tiny.csv')
with open(data_file, 'w') as f:
f.write('NumRooms,Alley,Price\n') # 列名
f.write('NA,Pave,127500\n') # 每行表示一个数据样本
f.write('2,NA,106000\n')
f.write('4,NA,178100\n')
f.write('NA,NA,140000\n')

导入pandas包并调用read_csv函数读入CSV文件

1
2
3
4
import pandas as pd

data = pd.read_csv(data_file)
print(data)

处理缺失值

处理缺失的数据,典型的方法包括插值法和删除法

插值法通过位置索引iloc,将data分成inputs和outputs

对于inputs中缺少的数值,用同一列的均值替换"NaN"项

1
2
3
inputs, outputs = data.iloc[:, 0:2], data.iloc[:, 2]
inputs = inputs.fillna(inputs.mean())
print(inputs)

对于inputs中的类别值或离散值,将"NaN"视为一个类别

pandas可以自动将此列转换为两列"Alley_Pave"和"Alley_nan"

1
2
inputs = pd.get_dummies(inputs, dummy_na=True)
print(inputs)

转换为张量格式

现在inputs和outputs中的所有条目都是数值类型,它们可以转换为张量格式

1
2
3
4
import torch

X, y = torch.tensor(inputs.values), torch.tensor(outputs.values)
X, y

线性代数

标量

标量由只有一个元素的张量表示

1
2
3
4
5
6
import torch

x = torch.tensor(3.0)
y = torch.tensor(2.0)

x + y, x * y, x / y, x**y

向量

向量可以被视为标量值组成的列表

向量的长度通常称为向量的维度

可以通过调用Python的内置len()函数来访问张量的长度

可以通过.shape属性访问向量的长度

1
2
3
x = torch.arange(4)
len(x)
x.shape

矩阵

矩阵将向量从一阶推广到二阶,在代码中表示为具有两个轴的张量

当调用函数来实例化张量时,我们可以通过指定两个分量来创建一个矩阵

1
A = torch.arange(20).reshape(5, 4)

作为方阵的一种特殊类型,对称矩阵等于其转置

1
2
B = torch.tensor([[1, 2, 3], [2, 0, 4], [3, 4, 5]])
B == B.T

张量

就像向量是标量的推广,矩阵是向量的推广一样,我们可以构建具有更多轴的数据结构

张量算法的基本性质

将两个相同形状的矩阵相加,会在这两个矩阵上执行元素加法

两个矩阵的按元素乘法称为Hadamard积

将张量乘以或加上一个标量不会改变张量的形状,其中张量的每个元素都将与标量相加或相乘

1
2
3
4
5
6
A = torch.arange(20, dtype=torch.float32).reshape(5, 4)
B = A.clone()
A + B
A * B
a = 2
a * B

降维

在代码中可以调用计算求和的函数

默认情况下,调用求和函数会沿所有的轴降低张量的维度,使它变为一个标量

为了通过求和所有行的元素来降维(轴0),可以在调用函数时指定axis=0

1
2
3
4
x = torch.arange(4, dtype=torch.float32)
x, x.sum()
A_sum_axis0 = A.sum(axis=0)
A_sum_axis0, A_sum_axis0.shape

一个与求和相关的量是平均值

计算平均值的函数也可以沿指定轴降低张量的维度

1
2
A.mean(), A.sum() / A.numel()
A.mean(axis=0), A.sum(axis=0) / A.shape[0]

有时在调用函数来计算总和或均值时保持轴数不变会很有用

1
2
sum_A = A.sum(axis=1, keepdims=True)
sum_A

由于sum_A在对每行进行求和后仍保持两个轴,我们可以通过广播将A除以sum_A

1
A / sum_A

点积

一个最基本的操作之一是点积

执行按元素乘法,然后进行求和来表示两个向量的点积

点积可以用来表示加权

1
2
3
y = torch.ones(4, dtype = torch.float32)
x, y, torch.dot(x, y)
torch.sum(x * y)

矩阵-向量积

在代码中使用张量表示矩阵-向量积,我们使用mv函数

当我们为矩阵A和向量x调用 torch.mv(A, x) 时,会执行矩阵-向量积

注意,A的列维数(沿轴1的长度)必须与x的维数(其长度)相同

1
2
3
A.shape, x.shape, torch.mv(A, x)
B = torch.ones(4, 3)
torch.mm(A, B)

矩阵-矩阵乘法可以简单地称为矩阵乘法,不应与"Hadamard积"混淆

范数

线性代数中最有用的一些运算符是范数

向量的范数是表示一个向量有多大,这里考虑的大小概念不涉及维度,而是分量的大小

范数听起来很像距离的度量,实际上欧几里得距离是一个 L2L_2 范数

x2=i=1nxi2,\|\mathbf{x}\|_2 = \sqrt{\sum_{i=1}^n x_i^2},

其中,L2L_2 范数中常常省略下标,也就是说 x\|\mathbf{x}\| 等同于 x2\|\mathbf{x}\|_2

在代码中,我们可以按如下方式计算向量的范数

1
2
u = torch.tensor([3.0, -4.0])
torch.norm(u)

L1L_1 范数表示为向量元素的绝对值之和,它受异常值的影响较小

x1=i=1nxi\|\mathbf{x}\|_1 = \sum_{i=1}^n \left|x_i \right|

1
torch.abs(u).sum()

一般的,定义 LpL_p 范数

xp=(i=1nxip)1/p.\|\mathbf{x}\|_p = \left(\sum_{i=1}^n \left|x_i \right|^p \right)^{1/p}.

微积分

在微分学最重要的应用是优化问题,即考虑如何把事情做到最好, 这种问题在深度学习中是无处不在的

通常情况下,变得更好意味着最小化一个损失函数

导数和微分

在深度学习中,我们通常选择对于模型参数可微的损失函数

我们把这个参数增加或减少一个无穷小的量,可以知道损失会以多快的速度增加或减少

导数定义

f(x)=limh0f(x+h)f(x)h.f'(x) = \lim_{h \rightarrow 0} \frac{f(x+h) - f(x)}{h}.

常见的微分法则

ddx[Cf(x)]=Cddxf(x)ddx[f(x)+g(x)]=ddxf(x)+ddxg(x)\frac{d}{dx} [Cf(x)] = C \frac{d}{dx} f(x)\\ \frac{d}{dx} [f(x) + g(x)] = \frac{d}{dx} f(x) + \frac{d}{dx} g(x)

ddx[f(x)g(x)]=f(x)ddx[g(x)]+g(x)ddx[f(x)]\frac{d}{dx} [f(x)g(x)] = f(x) \frac{d}{dx} [g(x)] + g(x) \frac{d}{dx} [f(x)]

ddx[f(x)g(x)]=g(x)ddx[f(x)]f(x)ddx[g(x)][g(x)]2\frac{d}{dx} \left[\frac{f(x)}{g(x)}\right] = \frac{g(x) \frac{d}{dx} [f(x)] - f(x) \frac{d}{dx} [g(x)]}{[g(x)]^2}

导数本质上是曲线在某点的切线

我们将使用matplotlib对导数的这种解释进行可视化

use_svg_display函数指定matplotlib软件包输出svg图表以获得更清晰的图像

#@save是一个特殊的标记,会将对应的函数、类或语句保存在d2l包中

1
2
3
4
def use_svg_display():  #@save
"""使用svg格式在Jupyter中显示绘图"""
backend_inline.set_matplotlib_formats('svg')

定义set_figsize函数来设置图表大小

1
2
3
4
5
def set_figsize(figsize=(3.5, 2.5)):  #@save
"""设置matplotlib的图表大小"""
use_svg_display()
d2l.plt.rcParams['figure.figsize'] = figsize

set_axes函数用于设置由matplotlib生成图表的轴的属性

1
2
3
4
5
6
7
8
9
10
11
12
13
#@save
def set_axes(axes, xlabel, ylabel, xlim, ylim, xscale, yscale, legend):
"""设置matplotlib的轴"""
axes.set_xlabel(xlabel)
axes.set_ylabel(ylabel)
axes.set_xscale(xscale)
axes.set_yscale(yscale)
axes.set_xlim(xlim)
axes.set_ylim(ylim)
if legend:
axes.legend(legend)
axes.grid()

通过这三个用于图形配置的函数,定义一个plot函数来简洁地绘制多条曲线

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#@save
def plot(X, Y=None, xlabel=None, ylabel=None, legend=None, xlim=None,
ylim=None, xscale='linear', yscale='linear',
fmts=('-', 'm--', 'g-.', 'r:'), figsize=(3.5, 2.5), axes=None):
"""绘制数据点"""
if legend is None:
legend = []

set_figsize(figsize)
axes = axes if axes else d2l.plt.gca()

# 如果X有一个轴,输出True
def has_one_axis(X):
return (hasattr(X, "ndim") and X.ndim == 1 or isinstance(X, list)
and not hasattr(X[0], "__len__"))

if has_one_axis(X):
X = [X]
if Y is None:
X, Y = [[]] * len(X), X
elif has_one_axis(Y):
Y = [Y]
if len(X) != len(Y):
X = X * len(Y)
axes.cla()
for x, y, fmt in zip(X, Y, fmts):
if len(x):
axes.plot(x, y, fmt)
else:
axes.plot(y, fmt)
set_axes(axes, xlabel, ylabel, xlim, ylim, xscale, yscale, legend)

x = np.arange(0, 3, 0.1)
plot(x, [f(x), 2 * x - 3], 'x', 'f(x)', legend=['f(x)', 'Tangent line (x=1)'])

偏导数

在深度学习中,函数通常依赖于许多变量。 因此,我们需要将微分的思想推广到多元函数

yy 是一个具有 nn 个变量的函数

yxi=limh0f(x1,,xi1,xi+h,xi+1,,xn)f(x1,,xi,,xn)h.\frac{\partial y}{\partial x_i} = \lim_{h \rightarrow 0} \frac{f(x_1, \ldots, x_{i-1}, x_i+h, x_{i+1}, \ldots, x_n) - f(x_1, \ldots, x_i, \ldots, x_n)}{h}.

即将其余变量看成常数,对其中一个变量求导

梯度

函数的梯度是一个包含若干个偏导数的向量

xf(x)=[f(x)x1,f(x)x2,,f(x)xn]\nabla_{\mathbf{x}} f(\mathbf{x}) = \bigg[\frac{\partial f(\mathbf{x})}{\partial x_1}, \frac{\partial f(\mathbf{x})}{\partial x_2}, \ldots, \frac{\partial f(\mathbf{x})}{\partial x_n}\bigg]^\top

假设 x\mathbf{x}nn 维向量,在微分多元函数时经常使用以下规则

  • xAx=A\nabla_{\mathbf{x}} \mathbf{A} \mathbf{x} = \mathbf{A}^\top
  • xxA=A\nabla_{\mathbf{x}} \mathbf{x}^\top \mathbf{A} = \mathbf{A}
  • xxAx=(A+A)x\nabla_{\mathbf{x}} \mathbf{x}^\top \mathbf{A} \mathbf{x} = (\mathbf{A} + \mathbf{A}^\top)\mathbf{x}
  • xx2=xxx=2x\nabla_{\mathbf{x}} \|\mathbf{x} \|^2 = \nabla_{\mathbf{x}} \mathbf{x}^\top \mathbf{x} = 2\mathbf{x}

链式法则

链式法则可以被用来微分复合函数

函数具有任意数量的变量的情况下,链式法则的推广如下

yxi=yu1u1xi+yu2u2xi++yumumxi\frac{\partial y}{\partial x_i} = \frac{\partial y}{\partial u_1} \frac{\partial u_1}{\partial x_i} + \frac{\partial y}{\partial u_2} \frac{\partial u_2}{\partial x_i} + \cdots + \frac{\partial y}{\partial u_m} \frac{\partial u_m}{\partial x_i}

自动微分

深度学习框架通过自动计算导数,即自动微分来加快求导

一个简单的例子

1
2
3
4
5
6
7
8
9
10
import torch

x = torch.arange(4.0)
x.requires_grad_(True)
y = 2 * torch.dot(x, x)
y.backward()
# 在默认情况下,PyTorch会累积梯度,我们需要清除之前的值
x.grad.zero_()
y = x.sum()
y.backward()

非标量变量的反向传播

1
2
3
4
5
6
7
# 对非标量调用backward需要传入一个gradient参数,该参数指定微分函数关于self的梯度。
# 本例只想求偏导数的和,所以传递一个1的梯度是合适的
x.grad.zero_()
y = x * x
# 等价于y.backward(torch.ones(len(x)))
y.sum().backward()
x.grad

分离计算

可以分离y来返回一个新变量u,该变量与y具有相同的值, 但丢弃计算图中如何计算y的任何信息

即梯度不会向后流经u到x

1
2
3
4
5
6
7
x.grad.zero_()
y = x * x
u = y.detach()
z = u * x

z.sum().backward()
x.grad == u

Python控制流的梯度计算

即使构建函数的计算图需要通过Python控制流,我们仍然可以计算得到的变量的梯度

1
2
3
4
5
6
7
8
9
10
11
12
13
def f(a):
b = a * 2
while b.norm() < 1000:
b = b * 2
if b.sum() > 0:
c = b
else:
c = 100 * b
return c

a = torch.randn(size=(), requires_grad=True)
d = f(a)
d.backward()

深度学习框架可以自动计算导数:我们首先将梯度附加到想要对其计算偏导数的变量上,然后记录目标值的计算,执行它的反向传播函数,并访问得到的梯度

概率

机器学习就是做出预测,而概率是一种灵活的语言,可以用于说明我们的确定程度

基本概率论

对于事件概率的估计值,一种自然的方法是将它出现的次数除以投掷的总次数

大数定律告诉我们: 随着投掷次数的增加,这个估计值会越来越接近真实的潜在概率

估计一个骰子的公平性

我们希望从同一分布中生成多个样本,用for循环来完成这个任务,速度会慢得惊人,因此我们使用深度学习框架的函数同时抽取多个样本

1
2
3
4
5
6
7
8
import torch
from torch.distributions import multinomial
from d2l import torch as d2l

fair_probs = torch.ones([6]) / 6
counts = multinomial.Multinomial(1000, fair_probs).sample()
counts / 1000 # 相对频率作为估计值

也可以通过 plt 把结果可视化

1
2
3
4
5
6
7
8
9
10
11
12
counts = multinomial.Multinomial(10, fair_probs).sample((500,))
cum_counts = counts.cumsum(dim=0)
estimates = cum_counts / cum_counts.sum(dim=1, keepdims=True)

d2l.set_figsize((6, 4.5))
for i in range(6):
d2l.plt.plot(estimates[:, i].numpy(),
label=("P(die=" + str(i + 1) + ")"))
d2l.plt.axhline(y=0.167, color='black', linestyle='dashed')
d2l.plt.gca().set_xlabel('Groups of experiments')
d2l.plt.gca().set_ylabel('Estimated probability')
d2l.plt.legend();

在给定的样本空间 S\mathcal{S} 中,事件 A\mathcal{A} 的概率,表示为 P(A)P(\mathcal{A}),具有以下性质

  • 非负性:P(A)0P(A) \geq 0
  • P(S)=1P(\mathcal{S}) = 1
  • 对于互斥事件,序列中任意一个事件发生的概率等于它们各自发生的概率之和,即 P(i=1Ai)=i=1P(Ai)P(\bigcup_{i=1}^{\infty} \mathcal{A}_i) = \sum_{i=1}^{\infty} P(\mathcal{A}_i)

处理多个随机变量

联合概率 P(A=a,B=b)P(A=a,B=b) 可以回答:AABB 同时满足的概率是多少

条件概率 0P(A=a,B=b)P(A=a)10 \leq \frac{P(A=a, B=b)}{P(A=a)} \leq 1,用 P(B=bA=a)P(B=b \mid A=a) 来表示

贝叶斯定理 P(AB)=P(BA)P(A)P(B)P(A \mid B) = \frac{P(B \mid A) P(A)}{P(B)}

边际化将所有选择的联合概率聚合在一起 P(B)=AP(A,B)P(B) = \sum_{A} P(A, B)

独立性 如果两个随机变量是独立的,意味着两个事件的发生无关

期望和方差

一个随机变量的期望表示为

E[X]=xxP(X=x)E[X] = \sum_{x} x P(X = x)

ExP[f(x)]=xf(x)P(x)E_{x \sim P}[f(x)] = \sum_x f(x) P(x)

可以通过方差来量化随机变量与其期望值的偏置

Var[X]=E[(XE[X])2]=E[X2]E[X]2\mathrm{Var}[X] = E\left[(X - E[X])^2\right] = E[X^2] - E[X]^2

查阅文档

查找模块中的所有函数和类

1
2
3
import torch

print(dir(torch.distributions))

查找特定函数和类的用法

1
help(torch.ones)

或者在vscode中使用时,直接按住ctrl键,点击函数或类名,就可以查看文档

线性神经网络

线性回归

回归是能为一个或多个自变量与因变量之间关系建模的一类方法

当我们想预测一个数值时,就会涉及到回归问题

线性回归的基本元素

为了开发一个能预测的模型,我们需要收集一个真实的数据集

在机器学习的术语中,该数据集称为训练数据集

每行数据称为样本,也可以称为数据点

把试图预测的目标称为标签或目标

预测所依据的自变量称为特征或协变量

我们使用 nn 来表示数据集中的样本数,对索引为 ii 的样本,其输入表示为 x(i)=[x1(i),x2(i)]\mathbf{x}^{(i)} = [x_1^{(i)}, x_2^{(i)}]^\top, 其对应的标签是 y(i)y^{(i)}

线性假设是指目标(房屋价格)可以表示为特征(面积和房龄)的加权和,如下面的式子

price=wareaarea+wageage+b\mathrm{price} = w_{\mathrm{area}} \cdot \mathrm{area} + w_{\mathrm{age}} \cdot \mathrm{age} + b

ww 被称为权重,权重决定了每个特征对我们预测值的影响

bb 称为偏置,偏置是指当所有特征都取值为0时,预测值应该为多少

给定一个数据集,我们的目标是寻找模型的权重和偏置,使得根据模型做出的预测大体符合数据里的真实价格

在机器学习领域,我们通常使用的是高维数据集,建模时采用线性代数表示法会比较方便

y^=w1x1+...+wdxd+b    y^=wx+b\hat{y} = w_1 x_1 + ... + w_d x_d + b\\ \iff\\ \hat{y} = \mathbf{w}^\top \mathbf{x} + b

用符号 XRn×d\mathbf{X} \in \mathbb{R}^{n \times d} 表示的矩阵可以很方便地引用我们整个数据集的 nn 个样本,此时预测值为

y^=Xw+b{\hat{\mathbf{y}}} = \mathbf{X} \mathbf{w} + b

损失函数能够量化目标的实际值与预测值之间的差距

通常我们会选择非负数作为损失,且数值越小表示损失越小,完美预测时的损失为0

回归问题中最常用的损失函数是平方误差函数

l(i)(w,b)=12(y^(i)y(i))2l^{(i)}(\mathbf{w}, b) = \frac{1}{2} \left(\hat{y}^{(i)} - y^{(i)}\right)^2

为了度量模型在整个数据集上的质量,我们计算在训练集样本上的损失均值

L(w,b)=1ni=1nl(i)(w,b)=1ni=1n12(wx(i)+by(i))2.L(\mathbf{w}, b) =\frac{1}{n}\sum_{i=1}^n l^{(i)}(\mathbf{w}, b) =\frac{1}{n} \sum_{i=1}^n \frac{1}{2}\left(\mathbf{w}^\top \mathbf{x}^{(i)} + b - y^{(i)}\right)^2.

我们希望寻找一组参数 w,b\mathbf{w}^*, b^*

w,b=argminw,b L(w,b)\mathbf{w}^*, b^* = \operatorname*{argmin}_{\mathbf{w}, b}\ L(\mathbf{w}, b)

线性回归的解可以用一个公式简单地表达出来,这类解叫作解析解

首先将偏置合并到参数中,合并方法是在包含所有参数的矩阵中附加一列,最小化 yXw2\|\mathbf{y} - \mathbf{X}\mathbf{w}\|^2, 将损失关于 w\mathbf{w} 的导数设为0,得到解析解

w=(XX)1Xy\mathbf{w}^* = (\mathbf X^\top \mathbf X)^{-1}\mathbf X^\top \mathbf{y}

解析解对问题的限制很严格,导致它无法广泛应用在深度学习里

本书中我们用到一种名为梯度下降的方法,这种方法几乎可以优化所有深度学习模型,它通过不断地在损失函数递减的方向上更新参数来降低误差

梯度下降最简单的用法是计算损失函数, 但实际中的执行可能会非常慢,我们通常会在每次需要计算更新的时候随机抽取一小批样本, 这种变体叫做小批量随机梯度下降

用下面的数学公式来表示这一更新过程

(w,b)(w,b)ηBiB(w,b)l(i)(w,b)(\mathbf{w},b) \leftarrow (\mathbf{w},b) - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \partial_{(\mathbf{w},b)} l^{(i)}(\mathbf{w},b)

这个过程看上去比较高级,其实也比较简单,意会一下就是,每次随机抽取一小批样本,然后通过这些样本来估计总体,从而更新

算法的步骤如下

  1. 初始化模型参数的值,如随机初始化
  2. 从数据集中随机抽取小批量样本且在负梯度的方向上更新参数,并不断迭代这一步骤

wwηBiBwl(i)(w,b)=wηBiBx(i)(wx(i)+by(i))bbηBiBbl(i)(w,b)=bηBiB(wx(i)+by(i))\begin{split}\begin{aligned} \mathbf{w} &\leftarrow \mathbf{w} - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \partial_{\mathbf{w}} l^{(i)}(\mathbf{w}, b) = \mathbf{w} - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \mathbf{x}^{(i)} \left(\mathbf{w}^\top \mathbf{x}^{(i)} + b - y^{(i)}\right)\\ b &\leftarrow b - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \partial_b l^{(i)}(\mathbf{w}, b) = b - \frac{\eta}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} \left(\mathbf{w}^\top \mathbf{x}^{(i)} + b - y^{(i)}\right) \end{aligned}\end{split}

B|\mathcal{B}| 表示每个小批量中的样本数,这也称为批量大小,η\eta 表示学习率,批量大小和学习率的值通常是手动预先指定

这些可以调整但不在训练过程中更新的参数称为超参数,调参是选择超参数的过程

矢量化加速

在训练我们的模型时,需要我们对计算进行矢量化,从而利用线性代数库,而不是在Python中编写开销高昂的for循环

这里有一个小疑惑,矩阵运算在数学上已经被证明了不可优化性,一些经典的问题也经常会归约到矩阵乘法从而证明时间复杂度下限,那为什么线性代数库有加速的功能?

搜索之后得到了答案,简单来说,线性代数库的实现是由C/C++ 代码实现的,运行速度要比 Python 快很多

本节也提出了自带的计时器用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Timer:  #@save
"""记录多次运行时间"""
def __init__(self):
self.times = []
self.start()

def start(self):
"""启动计时器"""
self.tik = time.time()

def stop(self):
"""停止计时器并将时间记录在列表中"""
self.times.append(time.time() - self.tik)
return self.times[-1]

def avg(self):
"""返回平均时间"""
return sum(self.times) / len(self.times)

def sum(self):
"""返回时间总和"""
return sum(self.times)

def cumsum(self):
"""返回累计时间"""
return np.array(self.times).cumsum().tolist()

正态分布与平方损失

正态分布和线性回归之间的关系很密切,概率密度函数如下

p(x)=12πσ2exp(12σ2(xμ)2).p(x) = \frac{1}{\sqrt{2 \pi \sigma^2}} \exp\left(-\frac{1}{2 \sigma^2} (x - \mu)^2\right).

正态分布的计算与可视化

1
2
3
4
5
6
7
8
9
10
11
12
def normal(x, mu, sigma):
p = 1 / math.sqrt(2 * math.pi * sigma**2)
return p * np.exp(-0.5 / sigma**2 * (x - mu)**2)
# 再次使用numpy进行可视化
x = np.arange(-7, 7, 0.01)

# 均值和标准差对
params = [(0, 1), (0, 2), (3, 1)]
d2l.plot(x, [normal(x, mu, sigma) for mu, sigma in params], xlabel='x',
ylabel='p(x)', figsize=(4.5, 2.5),
legend=[f'mean {mu}, std {sigma}' for mu, sigma in params])

均方误差损失函数可以用于线性回归的一个原因是: 我们假设了观测中包含噪声,其中噪声服从正态分布。

噪声正态分布如下式

y=wx+b+ϵ,y = \mathbf{w}^\top \mathbf{x} + b + \epsilon,

可以写出通过给定 x\mathbf{x} 的观测到特定 yy 的似然

P(yx)=12πσ2exp(12σ2(ywxb)2)P(y \mid \mathbf{x}) = \frac{1}{\sqrt{2 \pi \sigma^2}} \exp\left(-\frac{1}{2 \sigma^2} (y - \mathbf{w}^\top \mathbf{x} - b)^2\right)

这个本质上是一个正态分布,其中 σ\sigma 是噪声的标准差,我们可以将其视为一个超参数

根据极大似然估计法,参数 w\mathbf{w}bb 的最优值是使整个数据集的似然最大的值

P(yX)=i=1np(y(i)x(i))P(\mathbf y \mid \mathbf X) = \prod_{i=1}^{n} p(y^{(i)}|\mathbf{x}^{(i)})

指数函数的乘积最大化看起来很困难,我们通过最大化似然对数来简化,由于习惯问题,我们通常最小化损失函数的负对数似然

logP(yX)=i=1n12log(2πσ2)+12σ2(y(i)wx(i)b)2.-\log P(\mathbf y \mid \mathbf X) = \sum_{i=1}^n \frac{1}{2} \log(2 \pi \sigma^2) + \frac{1}{2 \sigma^2} \left(y^{(i)} - \mathbf{w}^\top \mathbf{x}^{(i)} - b\right)^2.

这个式子就变得好看很多,因为 σ\sigma 是一个超参数,所以可以当成常数来看,式子的值只和后一项有关系,于是最小化均方误差等价于对线性模型的极大似然估计

从线性回归到深度网络

我们可以将线性回归模型视为仅由单个人工神经元组成的神经网络,或称为单层神经网络

它的运行流程类似于神经元,树突中接收到来自其他神经元的信息 xix_i

该信息通过突触权重 wiw_i 来加权,以确定输入的影响(即,通过 wixiw_ix_i 相乘来激活或抑制

来自多个源的加权输入以加权和 y=ixiwi+by = \sum_i x_i w_i + b 的形式汇聚在细胞核中, 然后将这些信息发送到轴突 yy 中进一步处理,通常会通过 σ(y)\sigma(y) 进行一些非线性处理。

线性回归的从零开始实现

生成数据集

我们使用线性模型参数 w=[2,3.4]\mathbf{w} = [2, -3.4]^\topb=4.2b = 4.2 和噪声项 ϵ\epsilon 生成数据集及其标签

y=Xw+b+ϵ\mathbf{y}= \mathbf{X} \mathbf{w} + b + \mathbf\epsilon

1
2
3
4
5
6
7
8
9
10
def synthetic_data(w, b, num_examples):  #@save
"""生成y=Xw+b+噪声"""
X = torch.normal(0, 1, (num_examples, len(w)))
y = torch.matmul(X, w) + b
y += torch.normal(0, 0.01, y.shape)
return X, y.reshape((-1, 1))

true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = synthetic_data(true_w, true_b, 1000)

作出特征与标签的散点图,可以直接观察到两者的线性关系

1
2
d2l.set_figsize()
d2l.plt.scatter(features[:, 1].detach().numpy(), labels.detach().numpy(), 1);

读取数据集

我们定义一个data_iter函数, 该函数接收批量大小、特征矩阵和标签向量作为输入,生成大小为batch_size的小批量

1
2
3
4
5
6
7
8
9
def data_iter(batch_size, features, labels):
num_examples = len(features)
indices = list(range(num_examples))
# 这些样本是随机读取的,没有特定的顺序
random.shuffle(indices)
for i in range(0, num_examples, batch_size):
batch_indices = torch.tensor(
indices[i: min(i + batch_size, num_examples)])
yield features[batch_indices], labels[batch_indices]

初始化模型参数

我们通过正态分布采样随机数来初始化权重

之后利用自动微分来计算梯度

1
2
w = torch.normal(0, 0.01, size=(2,1), requires_grad=True)
b = torch.zeros(1, requires_grad=True)

定义模型

广播机制,当我们用一个向量加一个标量时,标量会被加到向量的每个分量上

1
2
3
def linreg(X, w, b):  #@save
"""线性回归模型"""
return torch.matmul(X, w) + b

定义损失函数

1
2
3
def squared_loss(y_hat, y):  #@save
"""均方损失"""
return (y_hat - y.reshape(y_hat.shape)) ** 2 / 2

定义优化算法

从数据集中随机抽取的一个小批量,然后根据参数计算损失的梯度,接下来,朝着减少损失的方向更新我们的参数

1
2
3
4
5
6
def sgd(params, lr, batch_size):  #@save
"""小批量随机梯度下降"""
with torch.no_grad():
for param in params:
param -= lr * param.grad / batch_size
param.grad.zero_()

训练

我们将执行以下循环

  • 初始化参数
  • 重复以下训练,直到完成
    • 计算梯度 g(w,b)1BiBl(x(i),y(i),w,b)\mathbf{g} \leftarrow \partial_{(\mathbf{w},b)} \frac{1}{|\mathcal{B}|} \sum_{i \in \mathcal{B}} l(\mathbf{x}^{(i)}, y^{(i)}, \mathbf{w}, b)
    • 更新参数 (w,b)(w,b)ηg(\mathbf{w}, b) \leftarrow (\mathbf{w}, b) - \eta \mathbf{g}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
lr = 0.03
num_epochs = 3
# 设置超参数很棘手,需要通过反复试验进行调整
net = linreg
loss = squared_loss

for epoch in range(num_epochs):
for X, y in data_iter(batch_size, features, labels):
l = loss(net(X, w, b), y) # X和y的小批量损失
# 因为l形状是(batch_size,1),而不是一个标量。l中的所有元素被加到一起,
# 并以此计算关于[w,b]的梯度
l.sum().backward()
sgd([w, b], lr, batch_size) # 使用参数的梯度更新参数
with torch.no_grad():
train_l = loss(net(features, w, b), labels)
print(f'epoch {epoch + 1}, loss {float(train_l.mean()):f}')

线性回归的简洁实现

生成数据集

1
2
3
4
5
6
7
8
import numpy as np
import torch
from torch.utils import data
from d2l import torch as d2l

true_w = torch.tensor([2, -3.4])
true_b = 4.2
features, labels = d2l.synthetic_data(true_w, true_b, 1000)

读取数据集

1
2
3
4
5
6
7
def load_array(data_arrays, batch_size, is_train=True):  #@save
"""构造一个PyTorch数据迭代器"""
dataset = data.TensorDataset(*data_arrays)
return data.DataLoader(dataset, batch_size, shuffle=is_train)

batch_size = 10
data_iter = load_array((features, labels), batch_size)

定义模型

对于标准深度学习模型,我们可以使用框架的预定义好的层

我们首先定义一个模型变量net,它是一个Sequential类的实例

当给定输入数据时,Sequential实例将数据传入到第一层,然后将第一层的输出作为第二层的输入

1
2
3
4
# nn是神经网络的缩写
from torch import nn

net = nn.Sequential(nn.Linear(2, 1))

初始化模型参数

1
2
net[0].weight.data.normal_(0, 0.01)
net[0].bias.data.fill_(0)

我们还可以使用替换方法normal_和fill_来重写参数值

定义损失函数

计算均方误差使用的是MSELoss类,也称为 L2L_2 平方范数

1
loss = nn.MSELoss()

定义优化算法

小批量随机梯度下降只需要设置lr值

1
trainer = torch.optim.SGD(net.parameters(), lr=0.03)

训练

对于每一个小批量,我们会进行以下步骤

  • 通过调用net(X)生成预测并计算损失l(前向传播)
  • 通过进行反向传播来计算梯度
  • 通过调用优化器来更新模型参数
1
2
3
4
5
6
7
8
9
num_epochs = 3
for epoch in range(num_epochs):
for X, y in data_iter:
l = loss(net(X) ,y)
trainer.zero_grad()
l.backward()
trainer.step()
l = loss(net(features), labels)
print(f'epoch {epoch + 1}, loss {l:f}')

我们可以使用PyTorch的高级API更简洁地实现模型

softmax回归

分类问题

一般的分类问题并不与类别之间的自然顺序有关

统计学家很早以前发明了一种表示分类数据的简单方法,独热编码

独热编码是一个向量,它的分量和类别一样多,类别对应的分量设置为1,其他所有分量设置为0

网络架构

为了估计所有可能类别的条件概率,我们需要一个有多个输出的模型

o1=x1w11+x2w12+x3w13+x4w14+b1,o2=x1w21+x2w22+x3w23+x4w24+b2,o3=x1w31+x2w32+x3w33+x4w34+b3.\begin{split}\begin{aligned} o_1 &= x_1 w_{11} + x_2 w_{12} + x_3 w_{13} + x_4 w_{14} + b_1,\\ o_2 &= x_1 w_{21} + x_2 w_{22} + x_3 w_{23} + x_4 w_{24} + b_2,\\ o_3 &= x_1 w_{31} + x_2 w_{32} + x_3 w_{33} + x_4 w_{34} + b_3. \end{aligned}\end{split}

与线性回归一样,softmax回归也是一个单层神经网络

为了更简洁地表达模型,我们仍然使用线性代数符号

通过向量形式表达为 o=Wx+b\mathbf{o} = \mathbf{W} \mathbf{x} + \mathbf{b}

全连接层的参数开销

对于任何具有 dd 个输入和 qq 个输出的全连接层,根据乘法原理,参数开销为 O(dq)\mathcal{O}(dq)

nn 个输入转换为 qq 个输出的成本可以减少到 O(dqn)\mathcal{O}(\frac{dq}{n}),其中超参数 nn 可以由我们灵活指定,以在实际应用中平衡参数节约和模型有效性

softmax运算

我们希望模型的输出 y^j\hat{y}_j 可以视为属于类 jj 的概率, 然后选择具有最大输出值 argmaxjyj\operatorname*{argmax}_j y_j 的类别作为我们的预测

softmax函数能够将未规范化的预测变换为非负数并且总和为1,同时让模型保持可导的性质

y^=softmax(o)其中y^j=exp(oj)kexp(ok)\hat{\mathbf{y}} = \mathrm{softmax}(\mathbf{o})\quad \text{其中}\quad \hat{y}_j = \frac{\exp(o_j)}{\sum_k \exp(o_k)}

在预测过程中,我们仍然可以用下式来选择最有可能的类别

argmaxjy^j=argmaxjoj\operatorname*{argmax}_j \hat y_j = \operatorname*{argmax}_j o_j

尽管softmax是一个非线性函数,但softmax回归的输出仍然由输入特征的仿射变换决定,因此,softmax回归是一个线性模型

小批量样本的矢量化

为了提高计算效率并且充分利用GPU,我们通常会对小批量样本的数据执行矢量计算

softmax回归的矢量计算表达式为

O=XW+bY^=softmax(O)\begin{split}\begin{aligned} \mathbf{O} &= \mathbf{X} \mathbf{W} + \mathbf{b} \\ \hat{\mathbf{Y}} & = \mathrm{softmax}(\mathbf{O})\end{aligned}\end{split}

损失函数

将估计值与实际值进行比较

P(YX)=i=1nP(y(i)x(i))P(\mathbf{Y} \mid \mathbf{X}) = \prod_{i=1}^n P(\mathbf{y}^{(i)} \mid \mathbf{x}^{(i)})

根据最大似然估计,我们最大化 P(YX)P(\mathbf{Y} \mid \mathbf{X}) ,相当于最小化负对数似然

logP(YX)=i=1nlogP(y(i)x(i))=i=1nl(y(i),y^(i))-\log P(\mathbf{Y} \mid \mathbf{X}) = \sum_{i=1}^n -\log P(\mathbf{y}^{(i)} \mid \mathbf{x}^{(i)}) = \sum_{i=1}^n l(\mathbf{y}^{(i)}, \hat{\mathbf{y}}^{(i)})

其中损失函数为

l(y,y^)=j=1qyjlogy^jl(\mathbf{y}, \hat{\mathbf{y}}) = - \sum_{j=1}^q y_j \log \hat{y}_j

利用softmax的定义,我们得到

l(y,y^)=j=1qyjlogexp(oj)k=1qexp(ok)=j=1qyjlogk=1qexp(ok)j=1qyjoj=logk=1qexp(ok)j=1qyjoj.\begin{split}\begin{aligned} l(\mathbf{y}, \hat{\mathbf{y}}) &= - \sum_{j=1}^q y_j \log \frac{\exp(o_j)}{\sum_{k=1}^q \exp(o_k)} \\ &= \sum_{j=1}^q y_j \log \sum_{k=1}^q \exp(o_k) - \sum_{j=1}^q y_j o_j\\ &= \log \sum_{k=1}^q \exp(o_k) - \sum_{j=1}^q y_j o_j. \end{aligned}\end{split}

求偏导后可以得到

ojl(y,y^)=exp(oj)k=1qexp(ok)yj=softmax(o)jyj.\partial_{o_j} l(\mathbf{y}, \hat{\mathbf{y}}) = \frac{\exp(o_j)}{\sum_{k=1}^q \exp(o_k)} - y_j = \mathrm{softmax}(\mathbf{o})_j - y_j.

导数是我们softmax模型分配的概率与实际发生的情况之间的差异。 从这个意义上讲,这与我们在回归中看到的非常相似,其中梯度是观测值和估计值之间的差异

信息论基础

信息论的核心思想是量化数据中的信息内容,在信息论中,该数值被称为分布的熵

H[P]=jP(j)logP(j).H[P] = \sum_j - P(j) \log P(j).

信息论的基本定理之一指出,为了对从分布 pp 中随机抽取的数据进行编码, 我们至少需要 H[P]H[P] nat对其进行编码

香农决定用信息量 log1P(j)=logP(j)\log \frac{1}{P(j)} = -\log P(j) 来量化对于事件结果的惊异程度

当我们赋予一个事件较低的概率时,我们的惊异会更大,该事件的信息量也就更大

如果把熵想象为 “知道真实概率的人所经历的惊异程度”,那么什么是交叉熵

我们可以把交叉熵想象为"主观概率为 QQ 的观察者在看到根据概率 PP 生成的数据时的预期惊异"

模型预测和评估

在训练softmax回归模型后,给出任何样本特征,我们可以预测每个输出类别的概率

我们将使用精度来评估模型的性能,精度等于正确预测数与预测总数之间的比率

图像分类数据集

读取数据集

1
2
3
4
5
6
7
# 通过ToTensor实例将图像数据从PIL类型变换成32位浮点数格式,
# 并除以255使得所有像素的数值均在0~1之间
trans = transforms.ToTensor()
mnist_train = torchvision.datasets.FashionMNIST(
root="../data", train=True, transform=trans, download=True)
mnist_test = torchvision.datasets.FashionMNIST(
root="../data", train=False, transform=trans, download=True)

读取小批量

1
2
3
4
5
6
7
8
batch_size = 256

def get_dataloader_workers(): #@save
"""使用4个进程来读取数据"""
return 4

train_iter = data.DataLoader(mnist_train, batch_size, shuffle=True,
num_workers=get_dataloader_workers())

整合所有组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def load_data_fashion_mnist(batch_size, resize=None):  #@save
"""下载Fashion-MNIST数据集,然后将其加载到内存中"""
trans = [transforms.ToTensor()]
if resize:
trans.insert(0, transforms.Resize(resize))
trans = transforms.Compose(trans)
mnist_train = torchvision.datasets.FashionMNIST(
root="../data", train=True, transform=trans, download=True)
mnist_test = torchvision.datasets.FashionMNIST(
root="../data", train=False, transform=trans, download=True)
return (data.DataLoader(mnist_train, batch_size, shuffle=True,
num_workers=get_dataloader_workers()),
data.DataLoader(mnist_test, batch_size, shuffle=False,
num_workers=get_dataloader_workers()))

train_iter, test_iter = load_data_fashion_mnist(32, resize=64)
for X, y in train_iter:
print(X.shape, X.dtype, y.shape, y.dtype)
break

softmax回归的从零开始实现

初始化模型参数

在softmax回归中,我们的输出与类别一样多

与线性回归一样,我们将使用正态分布初始化我们的权重W,偏置初始化为0

1
2
3
4
5
num_inputs = 784
num_outputs = 10

W = torch.normal(0, 0.01, size=(num_inputs, num_outputs), requires_grad=True)
b = torch.zeros(num_outputs, requires_grad=True)

定义softmax操作

实现softmax由三个步骤组成:

  • 对每个项求幂(使用exp)

  • 对每一行求和(小批量中每个样本是一行),得到每个样本的规范化常数

  • 将每一行除以其规范化常数,确保结果的和为1

softmax(X)ij=exp(Xij)kexp(Xik).\mathrm{softmax}(\mathbf{X})_{ij} = \frac{\exp(\mathbf{X}_{ij})}{\sum_k \exp(\mathbf{X}_{ik})}.

1
2
3
4
def softmax(X):
X_exp = torch.exp(X)
partition = X_exp.sum(1, keepdim=True)
return X_exp / partition # 这里应用了广播机制

矩阵中的非常大或非常小的元素可能造成数值上溢或下溢

定义模型

下面的代码定义了输入如何通过网络映射到输出

1
2
def net(X):
return softmax(torch.matmul(X.reshape((-1, W.shape[0])), W) + b)

定义损失函数

交叉熵损失函数可能是深度学习中最常见的损失函数,因为目前分类问题的数量远远超过回归问题的数量

1
2
3
4
5
6
7
8
9
y = torch.tensor([0, 2])
y_hat = torch.tensor([[0.1, 0.3, 0.6], [0.3, 0.2, 0.5]])
y_hat[[0, 1], y]

def cross_entropy(y_hat, y):
return - torch.log(y_hat[range(len(y_hat)), y])

cross_entropy(y_hat, y)

分类精度

当预测与标签分类y一致时,即是正确的

分类精度即正确预测数量与总预测数量之比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def accuracy(y_hat, y):  #@save
"""计算预测正确的数量"""
if len(y_hat.shape) > 1 and y_hat.shape[1] > 1:
y_hat = y_hat.argmax(axis=1)
cmp = y_hat.astype(y.dtype) == y
return float(cmp.astype(y.dtype).sum())

def evaluate_accuracy(net, data_iter): #@save
"""计算在指定数据集上模型的精度"""
metric = Accumulator(2) # 正确预测数、预测总数
for X, y in data_iter:
metric.add(accuracy(net(X), y), d2l.size(y))
return metric[0] / metric[1]

class Accumulator: #@save
"""在n个变量上累加"""
def __init__(self, n):
self.data = [0.0] * n

def add(self, *args):
self.data = [a + float(b) for a, b in zip(self.data, args)]

def reset(self):
self.data = [0.0] * len(self.data)

def __getitem__(self, idx):
return self.data[idx]

训练

我们定义一个函数来训练一个迭代周期

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def train_epoch_ch3(net, train_iter, loss, updater):  #@save
"""训练模型一个迭代周期(定义见第3章)"""
# 训练损失总和、训练准确度总和、样本数
metric = Accumulator(3)
if isinstance(updater, gluon.Trainer):
updater = updater.step
for X, y in train_iter:
# 计算梯度并更新参数
with autograd.record():
y_hat = net(X)
l = loss(y_hat, y)
l.backward()
updater(X.shape[0])
metric.add(float(l.sum()), accuracy(y_hat, y), y.size)
# 返回训练损失和训练精度
return metric[0] / metric[2], metric[1] / metric[2]

作为一个从零开始的实现,我们使用小批量随机梯度下降来优化模型的损失函数

1
2
3
4
lr = 0.1

def updater(batch_size):
return d2l.sgd([W, b], lr, batch_size)

迭代周期(num_epochs)和学习率(lr)都是可调节的超参数。 通过更改它们的值,我们可以提高模型的分类精度。

预测

1
2
3
4
5
6
7
8
9
10
11
def predict_ch3(net, test_iter, n=6):  #@save
"""预测标签(定义见第3章)"""
for X, y in test_iter:
break
trues = d2l.get_fashion_mnist_labels(y)
preds = d2l.get_fashion_mnist_labels(net(X).argmax(axis=1))
titles = [true +'\n' + pred for true, pred in zip(trues, preds)]
d2l.show_images(
X[0:n].reshape((n, 28, 28)), 1, n, titles=titles[0:n])

predict_ch3(net, test_iter)

softmax回归的简洁实现

初始化模型参数

1
2
3
4
5
6
7
8
9
# PyTorch不会隐式地调整输入的形状。因此,
# 我们在线性层前定义了展平层(flatten),来调整网络输入的形状
net = nn.Sequential(nn.Flatten(), nn.Linear(784, 10))

def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights);

重新审视Softmax的实现

softmax函数 y^j=exp(oj)kexp(ok)\hat y_j = \frac{\exp(o_j)}{\sum_k \exp(o_k)} 在某个元素很大的时候会发生上溢情况,这将使得分子或者分母变得无穷大,我们无法得到一个明确定义的交叉熵值

为了解决这个问题,我们可以先从所有的输入元素中减去最大的那个值

y^j=exp(ojmax(ok))exp(max(ok))kexp(okmax(ok))exp(max(ok))=exp(ojmax(ok))kexp(okmax(ok))\begin{split}\begin{aligned} \hat y_j & = \frac{\exp(o_j - \max(o_k))\exp(\max(o_k))}{\sum_k \exp(o_k - \max(o_k))\exp(\max(o_k))} \\ & = \frac{\exp(o_j - \max(o_k))}{\sum_k \exp(o_k - \max(o_k))} \end{aligned}\end{split}

此时又可能产生下溢的情况,于是可以

log(y^j)=log(exp(ojmax(ok))kexp(okmax(ok)))=log(exp(ojmax(ok)))log(kexp(okmax(ok)))=ojmax(ok)log(kexp(okmax(ok)))\begin{split}\begin{aligned} \log{(\hat y_j)} & = \log\left( \frac{\exp(o_j - \max(o_k))}{\sum_k \exp(o_k - \max(o_k))}\right) \\ & = \log{(\exp(o_j - \max(o_k)))}-\log{\left( \sum_k \exp(o_k - \max(o_k)) \right)} \\ & = o_j - \max(o_k) -\log{\left( \sum_k \exp(o_k - \max(o_k)) \right)} \end{aligned}\end{split}

1
loss = nn.CrossEntropyLoss(reduction='none')

优化算法

1
trainer = torch.optim.SGD(net.parameters(), lr=0.1)

训练

1
2
num_epochs = 10
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

多层感知机

定义

隐藏层

线性模型可能会出错,因为它是一个线性函数,而线性函数的表达能力有限,在网络中加入隐藏层可以解决这个问题。

H=XW(1)+b(1),O=HW(2)+b(2)\begin{split}\begin{aligned} \mathbf{H} & = \mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)}, \\ \mathbf{O} & = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)} \end{aligned}\end{split}

O=(XW(1)+b(1))W(2)+b(2)=XW(1)W(2)+b(1)W(2)+b(2)=XW+b\mathbf{O} = (\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)})\mathbf{W}^{(2)} + \mathbf{b}^{(2)} = \mathbf{X} \mathbf{W}^{(1)}\mathbf{W}^{(2)} + \mathbf{b}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)} = \mathbf{X} \mathbf{W} + \mathbf{b}

H=σ(XW(1)+b(1)),O=HW(2)+b(2).\begin{split}\begin{aligned} \mathbf{H} & = \sigma(\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)}), \\ \mathbf{O} & = \mathbf{H}\mathbf{W}^{(2)} + \mathbf{b}^{(2)}.\\ \end{aligned}\end{split}

H(1)=σ1(XW(1)+b(1))H(2)=σ2(H(1)W(2)+b(2))\mathbf{H}^{(1)} = \sigma_1(\mathbf{X} \mathbf{W}^{(1)} + \mathbf{b}^{(1)})\\ \mathbf{H}^{(2)} = \sigma_2(\mathbf{H}^{(1)} \mathbf{W}^{(2)} + \mathbf{b}^{(2)})

激活函数

ReLU函数

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

1
2
3
x = torch.arange(-8.0, 8.0, 0.1, requires_grad=True)
y = torch.relu(x)
d2l.plot(x.detach(), y.detach(), 'x', 'relu(x)', figsize=(5, 2.5))

使用ReLU的原因是,它求导表现得特别好:要么让参数消失,要么让参数通过。 这使得优化表现得更好

sigmoid函数

sigmoid(x)=11+exp(x).\operatorname{sigmoid}(x) = \frac{1}{1 + \exp(-x)}.

1
2
y = torch.sigmoid(x)
d2l.plot(x.detach(), y.detach(), 'x', 'sigmoid(x)', figsize=(5, 2.5))

tanh函数

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

1
2
y = torch.tanh(x)
d2l.plot(x.detach(), y.detach(), 'x', 'tanh(x)', figsize=(5, 2.5))

多层感知机的从零开始实现

初始化模型参数

对于每一层我们都要记录一个权重矩阵和一个偏置向量

跟以前一样,我们要为损失关于这些参数的梯度分配内存

1
2
3
4
5
6
7
8
9
10
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]

激活函数

1
2
3
def relu(X):
a = torch.zeros_like(X)
return torch.max(X, a)

模型

1
2
3
4
def net(X):
X = X.reshape((-1, num_inputs))
H = relu(X@W1 + b1) # 这里“@”代表矩阵乘法
return (H@W2 + b2)

损失函数

1
loss = nn.CrossEntropyLoss(reduction='none')

训练

1
2
3
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)

多层感知机的简洁实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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))

def init_weights(m):
if type(m) == nn.Linear:
nn.init.normal_(m.weight, std=0.01)

net.apply(init_weights);

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

train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size)
d2l.train_ch3(net, train_iter, test_iter, loss, num_epochs, trainer)

模型选择、欠拟合和过拟合

训练误差和泛化误差

训练误差(training error)是指, 模型在训练数据集上计算得到的误差。 泛化误差(generalization error)是指, 模型应用在同样从原始样本的分布中抽取的无限多数据样本时,模型误差的期望

当我们逐渐增加数据量,我们的训练误差将与泛化误差相匹配

当我们训练模型时,我们试图找到一个能够尽可能拟合训练数据的函数。 但是如果它执行地“太好了”,而不能对看不见的数据做到很好泛化,就会导致过拟合。

几个倾向于影响模型泛化的因素

  • 可调整参数的数量。当可调整参数的数量(有时称为自由度)很大时,模型往往更容易过拟合。

  • 参数采用的值。当权重的取值范围较大时,模型可能更容易过拟合。

  • 训练样本的数量。即使模型很简单,也很容易过拟合只包含一两个样本的数据集。而过拟合一个有数百万个样本的数据集则需要一个极其灵活的模型。

模型选择

在机器学习中,我们通常在评估几个候选模型后选择最终的模型

原则上,在我们确定所有的超参数之前,我们不希望用到测试集

当训练数据稀缺时,我们甚至可能无法提供足够的数据来构成一个合适的验证集,这个问题的一个流行的解决方案是采用K折交叉验证

欠拟合还是过拟合

由于我们的训练和验证误差之间的泛化误差很小, 我们有理由相信可以用一个更复杂的模型降低训练误差

这种现象被称为欠拟合(underfitting)

当我们的训练误差明显低于验证误差时要小心

这表明严重的过拟合(overfitting)

多项式回归

我们将使用以下三阶多项式来生成训练和测试数据的标签

y=5+1.2x3.4x22!+5.6x33!+ϵ where ϵN(0,0.12).y = 5 + 1.2x - 3.4\frac{x^2}{2!} + 5.6 \frac{x^3}{3!} + \epsilon \text{ where } \epsilon \sim \mathcal{N}(0, 0.1^2).

1
2
3
4
5
6
7
8
9
10
11
12
13
max_degree = 20  # 多项式的最大阶数
n_train, n_test = 100, 100 # 训练和测试数据集大小
true_w = np.zeros(max_degree) # 分配大量的空间
true_w[0:4] = np.array([5, 1.2, -3.4, 5.6])

features = np.random.normal(size=(n_train + n_test, 1))
np.random.shuffle(features)
poly_features = np.power(features, np.arange(max_degree).reshape(1, -1))
for i in range(max_degree):
poly_features[:, i] /= math.gamma(i + 1) # gamma(n)=(n-1)!
# labels的维度:(n_train+n_test,)
labels = np.dot(poly_features, true_w)
labels += np.random.normal(scale=0.1, size=labels.shape)

实现一个函数来评估模型在给定数据集上的损失

1
2
3
4
5
6
7
8
9
def evaluate_loss(net, data_iter, loss):  #@save
"""评估给定数据集上模型的损失"""
metric = d2l.Accumulator(2) # 损失的总和,样本数量
for X, y in data_iter:
out = net(X)
y = y.reshape(out.shape)
l = loss(out, y)
metric.add(l.sum(), l.numel())
return metric[0] / metric[1]

定义训练函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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)
animator = d2l.Animator(xlabel='epoch', ylabel='loss', yscale='log',
xlim=[1, num_epochs], ylim=[1e-3, 1e2],
legend=['train', 'test'])
for epoch in range(num_epochs):
d2l.train_epoch_ch3(net, train_iter, loss, trainer)
if epoch == 0 or (epoch + 1) % 20 == 0:
animator.add(epoch + 1, (evaluate_loss(net, train_iter, loss),
evaluate_loss(net, test_iter, loss)))
print('weight:', net[0].weight.data.numpy())

权重衰减

暂退法

前向传播、反向传播和计算图

前向传播

z=W(1)xh=ϕ(z)o=W(2)hL=l(o,y)s=λ2(W(1)F2+W(2)F2)J=L+s\mathbf{z}= \mathbf{W}^{(1)} \mathbf{x}\\ \mathbf{h}= \phi (\mathbf{z})\\ \mathbf{o}= \mathbf{W}^{(2)} \mathbf{h}\\ L = l(\mathbf{o}, y)\\ s = \frac{\lambda}{2} \left(\|\mathbf{W}^{(1)}\|_F^2 + \|\mathbf{W}^{(2)}\|_F^2\right)\\ J = L + s

反向传播

ZX=prod(ZY,YX)JL=1  and  Js=1Jo=prod(JL,Lo)=LoRqsW(1)=λW(1)  and  sW(2)=λW(2)JW(2)=prod(Jo,oW(2))+prod(Js,sW(2))=Joh+λW(2)Jh=prod(Jo,oh)=W(2)JoJz=prod(Jh,hz)=Jhϕ(z)JW(1)=prod(Jz,zW(1))+prod(Js,sW(1))=Jzx+λW(1)\frac{\partial \mathsf{Z}}{\partial \mathsf{X}} = \text{prod}\left(\frac{\partial \mathsf{Z}}{\partial \mathsf{Y}}, \frac{\partial \mathsf{Y}}{\partial \mathsf{X}}\right)\\ \frac{\partial J}{\partial L} = 1 \; \text{and} \; \frac{\partial J}{\partial s} = 1\\ \frac{\partial J}{\partial \mathbf{o}} = \text{prod}\left(\frac{\partial J}{\partial L}, \frac{\partial L}{\partial \mathbf{o}}\right) = \frac{\partial L}{\partial \mathbf{o}} \in \mathbb{R}^q\\ \frac{\partial s}{\partial \mathbf{W}^{(1)}} = \lambda \mathbf{W}^{(1)} \; \text{and} \; \frac{\partial s}{\partial \mathbf{W}^{(2)}} = \lambda \mathbf{W}^{(2)}\\ \frac{\partial J}{\partial \mathbf{W}^{(2)}}= \text{prod}\left(\frac{\partial J}{\partial \mathbf{o}}, \frac{\partial \mathbf{o}}{\partial \mathbf{W}^{(2)}}\right) + \text{prod}\left(\frac{\partial J}{\partial s}, \frac{\partial s}{\partial \mathbf{W}^{(2)}}\right)= \frac{\partial J}{\partial \mathbf{o}} \mathbf{h}^\top + \lambda \mathbf{W}^{(2)}\\ \frac{\partial J}{\partial \mathbf{h}} = \text{prod}\left(\frac{\partial J}{\partial \mathbf{o}}, \frac{\partial \mathbf{o}}{\partial \mathbf{h}}\right) = {\mathbf{W}^{(2)}}^\top \frac{\partial J}{\partial \mathbf{o}}\\ \frac{\partial J}{\partial \mathbf{z}} = \text{prod}\left(\frac{\partial J}{\partial \mathbf{h}}, \frac{\partial \mathbf{h}}{\partial \mathbf{z}}\right) = \frac{\partial J}{\partial \mathbf{h}} \odot \phi'\left(\mathbf{z}\right)\\ \frac{\partial J}{\partial \mathbf{W}^{(1)}} = \text{prod}\left(\frac{\partial J}{\partial \mathbf{z}}, \frac{\partial \mathbf{z}}{\partial \mathbf{W}^{(1)}}\right) + \text{prod}\left(\frac{\partial J}{\partial s}, \frac{\partial s}{\partial \mathbf{W}^{(1)}}\right) = \frac{\partial J}{\partial \mathbf{z}} \mathbf{x}^\top + \lambda \mathbf{W}^{(1)}\\

卷积神经网络

从全连接层到卷积

对于表格数据,多层感知机可能是最好的选择,然而对于高维感知数据,这种缺少结构的网络可能会变得不实用

不变性

卷积神经网络是将空间不变性的这一概念系统化,从而基于这个模型使用较少的参数来学习有用的表示

多层感知机的限制

将全连接层形式化地表示为

[H]i,j=[U]i,j+kl[W]i,j,k,l[X]k,l=[U]i,j+ab[V]i,j,a,b[X]i+a,j+b\begin{split}\begin{aligned} \left[\mathbf{H}\right]_{i, j} &= [\mathbf{U}]_{i, j} + \sum_k \sum_l[\mathsf{W}]_{i, j, k, l} [\mathbf{X}]_{k, l}\\ &= [\mathbf{U}]_{i, j} + \sum_a \sum_b [\mathsf{V}]_{i, j, a, b} [\mathbf{X}]_{i+a, j+b}\end{aligned}\end{split}

W\mathsf{W}V\mathsf{V} 的转换只是形式上的转换,因为在这两个四阶张量的元素之间存在一一对应的关系

利用平移不变性,可以简化定义为

[H]i,j=u+ab[V]a,b[X]i+a,j+b[\mathbf{H}]_{i, j} = u + \sum_a\sum_b [\mathbf{V}]_{a, b} [\mathbf{X}]_{i+a, j+b}

这就是卷积

利用局部性,可以将 [H]i,j[\mathbf{H}]_{i, j} 重写为

[H]i,j=u+a=ΔΔb=ΔΔ[V]a,b[X]i+a,j+b.[\mathbf{H}]_{i, j} = u + \sum_{a = -\Delta}^{\Delta} \sum_{b = -\Delta}^{\Delta} [\mathbf{V}]_{a, b} [\mathbf{X}]_{i+a, j+b}.

这是一个卷积层,而卷积神经网络是包含卷积层的一类特殊的神经网络

以前,多层感知机可能需要数十亿个参数来表示网络中的一层,而现在卷积神经网络通常只需要几百个参数,而且不需要改变输入或隐藏表示的维数

但如果偏置与现实不符时,比如当图像不满足平移不变时,我们的模型可能难以拟合我们的训练数据

卷积

(fg)(x)=f(z)g(xz)dz(fg)(i)=af(a)g(ia)(fg)(i,j)=abf(a,b)g(ia,jb)(f * g)(\mathbf{x}) = \int f(\mathbf{z}) g(\mathbf{x}-\mathbf{z}) d\mathbf{z}\\ (f * g)(i) = \sum_a f(a) g(i-a)\\ (f * g)(i, j) = \sum_a\sum_b f(a, b) g(i-a, j-b)

图像卷积

互相关运算

严格来说,卷积层是个错误的叫法,因为它所表达的运算其实是互相关运算,而不是卷积运算

1
2
3
4
5
6
7
8
9
10
11
12
import torch
from torch import nn
from d2l import torch as d2l

def corr2d(X, K): #@save
"""计算二维互相关运算"""
h, w = K.shape
Y = torch.zeros((X.shape[0] - h + 1, X.shape[1] - w + 1))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
Y[i, j] = (X[i:i + h, j:j + w] * K).sum()
return Y

卷积层

卷积层对输入和卷积核权重进行互相关运算,并在添加标量偏置之后产生输出

1
2
3
4
5
6
7
8
class Conv2D(nn.Module):
def __init__(self, kernel_size):
super().__init__()
self.weight = nn.Parameter(torch.rand(kernel_size))
self.bias = nn.Parameter(torch.zeros(1))

def forward(self, x):
return corr2d(x, self.weight) + self.bias

图像中目标的边缘检测

卷积层的一个简单应用:通过找到像素变化的位置,来检测图像中不同颜色的边缘

1
2
3
4
5
X = torch.ones((6, 8))
X[:, 2:6] = 0
K = torch.tensor([[1.0, -1.0]])
Y = corr2d(X, K)
corr2d(X.t(), K)

学习卷积核

当有了更复杂数值的卷积核,或者连续的卷积层时,我们不可能手动设计滤波器,可以学习由X生成Y的卷积核

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 构造一个二维卷积层,它具有1个输出通道和形状为(1,2)的卷积核
conv2d = nn.Conv2d(1,1, kernel_size=(1, 2), bias=False)

# 这个二维卷积层使用四维输入和输出格式(批量大小、通道、高度、宽度),
# 其中批量大小和通道数都为1
X = X.reshape((1, 1, 6, 8))
Y = Y.reshape((1, 1, 6, 7))
lr = 3e-2 # 学习率

for i in range(10):
Y_hat = conv2d(X)
l = (Y_hat - Y) ** 2
conv2d.zero_grad()
l.sum().backward()
# 迭代卷积核
conv2d.weight.data[:] -= lr * conv2d.weight.grad
if (i + 1) % 2 == 0:
print(f'epoch {i+1}, loss {l.sum():.3f}')

互相关和卷积

卷积运算和互相关运算差别不大,我们只需水平和垂直翻转二维卷积核张量,然后对输入张量执行互相关运算

由于卷积核是从数据中学习到的,因此无论这些层执行严格的卷积运算还是互相关运算,卷积层的输出都不会受到影响

特征映射和感受野

输出的卷积层有时被称为特征映射,因为它可以被视为一个输入映射到下一层的空间维度的转换器

在卷积神经网络中,对于某一层的任意元素,其感受野是指在前向传播期间可能影响计算的所有元素(来自所有先前层)

填充和步幅

填充

填充可以增加输出的高度和宽度。这常用来使输出与输入具有相同的高和宽

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import torch
from torch import nn


# 为了方便起见,我们定义了一个计算卷积层的函数。
# 此函数初始化卷积层权重,并对输入和输出提高和缩减相应的维数
def comp_conv2d(conv2d, X):
# 这里的(1,1)表示批量大小和通道数都是1
X = X.reshape((1, 1) + X.shape)
Y = conv2d(X)
# 省略前两个维度:批量大小和通道
return Y.reshape(Y.shape[2:])

# 请注意,这里每边都填充了1行或1列,因此总共添加了2行或2列
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1)
X = torch.rand(size=(8, 8))
comp_conv2d(conv2d, X).shape

conv2d = nn.Conv2d(1, 1, kernel_size=(5, 3), padding=(2, 1))
comp_conv2d(conv2d, X).shape

步幅

步幅可以减小输出的高和宽,例如输出的高和宽仅为输入的高和宽的 1n\frac{1}{n}

1
2
3
4
5
conv2d = nn.Conv2d(1, 1, kernel_size=3, padding=1, stride=2)
comp_conv2d(conv2d, X).shape

conv2d = nn.Conv2d(1, 1, kernel_size=(3, 5), padding=(0, 1), stride=(3, 4))
comp_conv2d(conv2d, X).shape

填充和步幅可用于有效地调整数据的维度

多输入多输出通道

多输入通道

当输入包含多个通道时,需要构造一个与输入数据具有相同输入通道数的卷积核,以便与输入数据进行互相关运算

1
2
3
4
5
6
7
8
9
10
11
12
13
import torch
from d2l import torch as d2l

def corr2d_multi_in(X, K):
# 先遍历“X”和“K”的第0个维度(通道维度),再把它们加在一起
return sum(d2l.corr2d(x, k) for x, k in zip(X, K))
X = torch.tensor([[[0.0, 1.0, 2.0], [3.0, 4.0, 5.0], [6.0, 7.0, 8.0]],
[[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]])

K = torch.tensor([[[0.0, 1.0], [2.0, 3.0]], [[1.0, 2.0], [3.0, 4.0]]])

corr2d_multi_in(X, K)

多输出通道

每一层有多个输出通道是至关重要的

在最流行的神经网络架构中,随着神经网络层数的加深,我们常会增加输出通道的维数,通过减少空间分辨率以获得更大的通道深度

我们实现一个计算多个通道的输出的互相关函数

1
2
3
4
5
6
7
8
9
10
def corr2d_multi_in_out(X, K):
# 迭代“K”的第0个维度,每次都对输入“X”执行互相关运算。
# 最后将所有结果都叠加在一起
return torch.stack([corr2d_multi_in(X, k) for k in K], 0)

K = torch.stack((K, K + 1, K + 2), 0)
K.shape

corr2d_multi_in_out(X, K)

1*1 卷积层

1×11\times 1 卷积层通常用于调整网络层的通道数量和控制模型复杂性

汇聚层

汇聚层具有双重目的:降低卷积层对位置的敏感性,同时降低对空间降采样表示的敏感性

最大汇聚层和平均汇聚层

我们通常计算汇聚窗口中所有元素的最大值或平均值。这些操作分别称为最大汇聚层和平均汇聚层

汇聚窗口从输入张量的左上角开始,从左往右、从上往下的在输入张量内滑动

计算最大值或平均值是取决于使用了最大汇聚层还是平均汇聚层

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import torch
from torch import nn
from d2l import torch as d2l

def pool2d(X, pool_size, mode='max'):
p_h, p_w = pool_size
Y = torch.zeros((X.shape[0] - p_h + 1, X.shape[1] - p_w + 1))
for i in range(Y.shape[0]):
for j in range(Y.shape[1]):
if mode == 'max':
Y[i, j] = X[i: i + p_h, j: j + p_w].max()
elif mode == 'avg':
Y[i, j] = X[i: i + p_h, j: j + p_w].mean()
return Y

填充和步幅

与卷积层一样,汇聚层也可以改变输出形状

多个通道

在处理多通道输入数据时,汇聚层在每个输入通道上单独运算,而不是像卷积层一样在通道上对输入进行汇总

卷积神经网络(LeNet)

总体来看,LeNet由两个部分组成:

  • 卷积编码器:由两个卷积层组成
  • 全连接层密集块:由三个全连接层组成

用深度学习框架实现此类模型非常简单,只需要实例化一个Sequential块并将需要的层连接在一起

1
2
3
4
5
6
7
8
9
10
11
12
13
import torch
from torch import nn
from d2l import torch as d2l

net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5, padding=2), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Flatten(),
nn.Linear(16 * 5 * 5, 120), nn.Sigmoid(),
nn.Linear(120, 84), nn.Sigmoid(),
nn.Linear(84, 10))

为了使用GPU,我们还需要一点小改动,在进行正向和反向传播之前,我们需要将每一小批量数据移动到我们指定的设备上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def evaluate_accuracy_gpu(net, data_iter, device=None): #@save
"""使用GPU计算模型在数据集上的精度"""
if isinstance(net, nn.Module):
net.eval() # 设置为评估模式
if not device:
device = next(iter(net.parameters())).device
# 正确预测的数量,总预测的数量
metric = d2l.Accumulator(2)
with torch.no_grad():
for X, y in data_iter:
if isinstance(X, list):
# BERT微调所需的(之后将介绍)
X = [x.to(device) for x in X]
else:
X = X.to(device)
y = y.to(device)
metric.add(d2l.accuracy(net(X), y), y.numel())
return metric[0] / metric[1]

与全连接层一样,我们使用交叉熵损失函数和小批量随机梯度下降

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#@save
def train_ch6(net, train_iter, test_iter, num_epochs, lr, device):
"""用GPU训练模型(在第六章定义)"""
def init_weights(m):
if type(m) == nn.Linear or type(m) == nn.Conv2d:
nn.init.xavier_uniform_(m.weight)
net.apply(init_weights)
print('training on', device)
net.to(device)
optimizer = torch.optim.SGD(net.parameters(), lr=lr)
loss = nn.CrossEntropyLoss()
animator = d2l.Animator(xlabel='epoch', xlim=[1, num_epochs],
legend=['train loss', 'train acc', 'test acc'])
timer, num_batches = d2l.Timer(), len(train_iter)
for epoch in range(num_epochs):
# 训练损失之和,训练准确率之和,样本数
metric = d2l.Accumulator(3)
net.train()
for i, (X, y) in enumerate(train_iter):
timer.start()
optimizer.zero_grad()
X, y = X.to(device), y.to(device)
y_hat = net(X)
l = loss(y_hat, y)
l.backward()
optimizer.step()
with torch.no_grad():
metric.add(l * X.shape[0], d2l.accuracy(y_hat, y), X.shape[0])
timer.stop()
train_l = metric[0] / metric[2]
train_acc = metric[1] / metric[2]
if (i + 1) % (num_batches // 5) == 0 or i == num_batches - 1:
animator.add(epoch + (i + 1) / num_batches,
(train_l, train_acc, None))
test_acc = evaluate_accuracy_gpu(net, test_iter)
animator.add(epoch + 1, (None, None, test_acc))
print(f'loss {train_l:.3f}, train acc {train_acc:.3f}, '
f'test acc {test_acc:.3f}')
print(f'{metric[2] * num_epochs / timer.sum():.1f} examples/sec '
f'on {str(device)}')

现代卷积神经网络

深度卷积神经网络(AlexNet)

学习表征

缺少的成分:数据

缺少的成分:硬件

GPU比CPU强在哪里?

中央处理器CPU的每个核心都拥有高时钟频率的运行能力,和高达数MB的三级缓存。 它们非常适合执行各种指令,然而,这种明显的优势也是它的致命弱点:通用核心的制造成本非常高,它们在任何单个任务上的性能都相对较差。

相比于CPU,GPU由100多个小的处理单元组成,虽然每个GPU核心都相对较弱,但庞大的核心数量使GPU比CPU快几个数量级

2012年的重大突破,卷积神经网络中的计算瓶颈:卷积和矩阵乘法,都是可以在硬件上并行化的操作。

AlexNet

AlexNet和LeNet的设计理念非常相似,但也存在显著差异。

  • AlexNet比相对较小的LeNet5要深得多
  • AlexNet使用ReLU而不是sigmoid作为其激活函数

AlexNet将sigmoid激活函数改为更简单的ReLU激活函数,当sigmoid激活函数的输出非常接近于0或1时,这些区域的梯度几乎为0,因此反向传播无法继续更新一些模型参数。

相反,ReLU激活函数在正区间的梯度总是1。 因此,如果模型参数没有正确初始化,sigmoid函数可能在正区间内得到几乎为0的梯度,从而使模型无法得到有效的训练。

AlexNet通过暂退法控制全连接层的模型复杂度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import torch
from torch import nn
from d2l import torch as d2l

net = nn.Sequential(
# 这里使用一个11*11的更大窗口来捕捉对象。
# 同时,步幅为4,以减少输出的高度和宽度。
# 另外,输出通道的数目远大于LeNet
nn.Conv2d(1, 96, kernel_size=11, stride=4, padding=1), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2),
# 减小卷积窗口,使用填充为2来使得输入与输出的高和宽一致,且增大输出通道数
nn.Conv2d(96, 256, kernel_size=5, padding=2), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2),
# 使用三个连续的卷积层和较小的卷积窗口。
# 除了最后的卷积层,输出通道的数量进一步增加。
# 在前两个卷积层之后,汇聚层不用于减少输入的高度和宽度
nn.Conv2d(256, 384, kernel_size=3, padding=1), nn.ReLU(),
nn.Conv2d(384, 384, kernel_size=3, padding=1), nn.ReLU(),
nn.Conv2d(384, 256, kernel_size=3, padding=1), nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2),
nn.Flatten(),
# 这里,全连接层的输出数量是LeNet中的好几倍。使用dropout层来减轻过拟合
nn.Linear(6400, 4096), nn.ReLU(),
nn.Dropout(p=0.5),
nn.Linear(4096, 4096), nn.ReLU(),
nn.Dropout(p=0.5),
# 最后是输出层。由于这里使用Fashion-MNIST,所以用类别数为10,而非论文中的1000
nn.Linear(4096, 10))

读取数据集

1
2
batch_size = 128
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224)

训练AlexNet

使用更小的学习速率训练,这是因为网络更深更广、图像分辨率更高,训练卷积神经网络就更昂贵。

1
2
lr, num_epochs = 0.01, 10
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

使用块的网络(VGG)

VGG块

经典卷积神经网络的基本组成部分是下面的这个序列

  • 带填充以保持分辨率的卷积层;

  • 非线性激活函数,如ReLU;

  • 汇聚层,如最大汇聚层。

一个VGG块与之类似,由一系列卷积层组成,后面再加上用于空间下采样的最大汇聚层

1
2
3
4
5
6
7
8
9
10
11
12
13
import torch
from torch import nn
from d2l import torch as d2l

def vgg_block(num_convs, in_channels, out_channels):
layers = []
for _ in range(num_convs):
layers.append(nn.Conv2d(in_channels, out_channels,
kernel_size=3, padding=1))
layers.append(nn.ReLU())
in_channels = out_channels
layers.append(nn.MaxPool2d(kernel_size=2,stride=2))
return nn.Sequential(*layers)

VGG网络

VGG网络可以分为两部分:第一部分主要由卷积层和汇聚层组成,第二部分由全连接层组成

超参数变量conv_arch指定了每个VGG块里卷积层个数和输出通道数

原始VGG网络有5个卷积块,其中前两个块各有一个卷积层,后三个块各包含两个卷积层

由于该网络使用8个卷积层和3个全连接层,因此它通常被称为VGG-11

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def vgg(conv_arch):
conv_blks = []
in_channels = 1
# 卷积层部分
for (num_convs, out_channels) in conv_arch:
conv_blks.append(vgg_block(num_convs, in_channels, out_channels))
in_channels = out_channels

return nn.Sequential(
*conv_blks, nn.Flatten(),
# 全连接层部分
nn.Linear(out_channels * 7 * 7, 4096), nn.ReLU(), nn.Dropout(0.5),
nn.Linear(4096, 4096), nn.ReLU(), nn.Dropout(0.5),
nn.Linear(4096, 10))

net = vgg(conv_arch)

训练模型

1
2
3
4
5
6
7
ratio = 4
small_conv_arch = [(pair[0], pair[1] // ratio) for pair in conv_arch]
net = vgg(small_conv_arch)

lr, num_epochs, batch_size = 0.05, 10, 128
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

网络中的网络(NiN)

NiN块

1
2
3
4
5
6
7
8
9
10
11
import torch
from torch import nn
from d2l import torch as d2l


def nin_block(in_channels, out_channels, kernel_size, strides, padding):
return nn.Sequential(
nn.Conv2d(in_channels, out_channels, kernel_size, strides, padding),
nn.ReLU(),
nn.Conv2d(out_channels, out_channels, kernel_size=1), nn.ReLU(),
nn.Conv2d(out_channels, out_channels, kernel_size=1), nn.ReLU())

NiN模型

NiN和AlexNet之间的一个显著区别是NiN完全取消了全连接层

优点是,它显著减少了模型所需参数的数量。然而,在实践中,这种设计有时会增加训练模型的时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
net = nn.Sequential(
nin_block(1, 96, kernel_size=11, strides=4, padding=0),
nn.MaxPool2d(3, stride=2),
nin_block(96, 256, kernel_size=5, strides=1, padding=2),
nn.MaxPool2d(3, stride=2),
nin_block(256, 384, kernel_size=3, strides=1, padding=1),
nn.MaxPool2d(3, stride=2),
nn.Dropout(0.5),
# 标签类别数是10
nin_block(384, 10, kernel_size=3, strides=1, padding=1),
nn.AdaptiveAvgPool2d((1, 1)),
# 将四维的输出转成二维的输出,其形状为(批量大小,10)
nn.Flatten())

训练模型

1
2
3
lr, num_epochs, batch_size = 0.1, 10, 128
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=224)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

含并行连结的网络(GoogLeNet)

Inception块

Inception块由四条并行路径组成,通常调整的超参数是每层输出通道数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import torch
from torch import nn
from torch.nn import functional as F
from d2l import torch as d2l


class Inception(nn.Module):
# c1--c4是每条路径的输出通道数
def __init__(self, in_channels, c1, c2, c3, c4, **kwargs):
super(Inception, self).__init__(**kwargs)
# 线路1,单1x1卷积层
self.p1_1 = nn.Conv2d(in_channels, c1, kernel_size=1)
# 线路21x1卷积层后接3x3卷积层
self.p2_1 = nn.Conv2d(in_channels, c2[0], kernel_size=1)
self.p2_2 = nn.Conv2d(c2[0], c2[1], kernel_size=3, padding=1)
# 线路31x1卷积层后接5x5卷积层
self.p3_1 = nn.Conv2d(in_channels, c3[0], kernel_size=1)
self.p3_2 = nn.Conv2d(c3[0], c3[1], kernel_size=5, padding=2)
# 线路43x3最大汇聚层后接1x1卷积层
self.p4_1 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
self.p4_2 = nn.Conv2d(in_channels, c4, kernel_size=1)

def forward(self, x):
p1 = F.relu(self.p1_1(x))
p2 = F.relu(self.p2_2(F.relu(self.p2_1(x))))
p3 = F.relu(self.p3_2(F.relu(self.p3_1(x))))
p4 = F.relu(self.p4_2(self.p4_1(x)))
# 在通道维度上连结输出
return torch.cat((p1, p2, p3, p4), dim=1)

滤波器的组合,可以用各种滤波器尺寸探索图像,这意味着不同大小的滤波器可以有效地识别不同范围的图像细节

GoogLeNet模型

GoogLeNet一共使用9个Inception块和全局平均汇聚层的堆叠来生成其估计值。Inception块之间的最大汇聚层可降低维度。

第一个模块类似于AlexNet和LeNet,Inception块的组合从VGG继承,全局平均汇聚层避免了在最后使用全连接层。

1
2
3
b1 = nn.Sequential(nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3),
nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

第二个模块使用两个卷积层

1
2
3
4
5
b2 = nn.Sequential(nn.Conv2d(64, 64, kernel_size=1),
nn.ReLU(),
nn.Conv2d(64, 192, kernel_size=3, padding=1),
nn.ReLU(),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

第三个模块串联两个完整的Inception块

1
2
3
b3 = nn.Sequential(Inception(192, 64, (96, 128), (16, 32), 32),
Inception(256, 128, (128, 192), (32, 96), 64),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

第四模块更加复杂, 它串联了5个Inception块

1
2
3
4
5
6
b4 = nn.Sequential(Inception(480, 192, (96, 208), (16, 48), 64),
Inception(512, 160, (112, 224), (24, 64), 64),
Inception(512, 128, (128, 256), (24, 64), 64),
Inception(512, 112, (144, 288), (32, 64), 64),
Inception(528, 256, (160, 320), (32, 128), 128),
nn.MaxPool2d(kernel_size=3, stride=2, padding=1))

第五模块包含两个Inception块

1
2
3
4
5
6
b5 = nn.Sequential(Inception(832, 256, (160, 320), (32, 128), 128),
Inception(832, 384, (192, 384), (48, 128), 128),
nn.AdaptiveAvgPool2d((1,1)),
nn.Flatten())

net = nn.Sequential(b1, b2, b3, b4, b5, nn.Linear(1024, 10))

GoogLeNet模型的计算复杂,而且不如VGG那样便于修改通道数

1
2
3
4
X = torch.rand(size=(1, 1, 96, 96))
for layer in net:
X = layer(X)
print(layer.__class__.__name__,'output shape:\t', X.shape)

训练模型

1
2
3
lr, num_epochs, batch_size = 0.1, 10, 128
train_iter, test_iter = d2l.load_data_fashion_mnist(batch_size, resize=96)
d2l.train_ch6(net, train_iter, test_iter, num_epochs, lr, d2l.try_gpu())

批量规范化

训练深层网络

使用真实数据时,我们的第一步是标准化输入特征,使其平均值为0,方差为1

直观地说,这种标准化可以很好地与我们的优化器配合使用,因为它可以将参数的量级进行统一

批量规范化应用于单个可选层,其原理如下:在每次训练迭代中,我们首先规范化输入,即通过减去其均值并除以其标准差,其中两者均基于当前小批量处理,接下来,我们应用比例系数和比例偏移

从形式上来说,用 xB\mathbf{x} \in \mathcal{B} 表示一个来自小批量 B\mathcal{B} 的输入,批量规范化 BN\mathrm{BN} 根据以下表达式转换 x\mathbf{x}

BN(x)=γxμ^Bσ^B+β\mathrm{BN}(\mathbf{x}) = \boldsymbol{\gamma} \odot \frac{\mathbf{x} - \hat{\boldsymbol{\mu}}_\mathcal{B}}{\hat{\boldsymbol{\sigma}}_\mathcal{B}} + \boldsymbol{\beta}

μ^B\hat{\boldsymbol{\mu}}_\mathcal{B} 是小批量的样本均值,σ^B\hat{\boldsymbol{\sigma}}_\mathcal{B} 是小批量的样本标准差

μ^B=1BxBxσ^B2=1BxB(xμ^B)2+ϵ\begin{split}\begin{aligned} \hat{\boldsymbol{\mu}}_\mathcal{B} &= \frac{1}{|\mathcal{B}|} \sum_{\mathbf{x} \in \mathcal{B}} \mathbf{x}\\ \hat{\boldsymbol{\sigma}}_\mathcal{B}^2 &= \frac{1}{|\mathcal{B}|} \sum_{\mathbf{x} \in \mathcal{B}} (\mathbf{x} - \hat{\boldsymbol{\mu}}_{\mathcal{B}})^2 + \epsilon \end{aligned}\end{split}

批量规范化层

h=ϕ(BN(Wx+b))\mathbf{h} = \phi(\mathrm{BN}(\mathbf{W}\mathbf{x} + \mathbf{b}) )

从零实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import torch
from torch import nn
from d2l import torch as d2l


def batch_norm(X, gamma, beta, moving_mean, moving_var, eps, momentum):
# 通过is_grad_enabled来判断当前模式是训练模式还是预测模式
if not torch.is_grad_enabled():
# 如果是在预测模式下,直接使用传入的移动平均所得的均值和方差
X_hat = (X - moving_mean) / torch.sqrt(moving_var + eps)
else:
assert len(X.shape) in (2, 4)
if len(X.shape) == 2:
# 使用全连接层的情况,计算特征维上的均值和方差
mean = X.mean(dim=0)
var = ((X - mean) ** 2).mean(dim=0)
else:
# 使用二维卷积层的情况,计算通道维上(axis=1)的均值和方差。
# 这里我们需要保持X的形状以便后面可以做广播运算
mean = X.mean(dim=(0, 2, 3), keepdim=True)
var = ((X - mean) ** 2).mean(dim=(0, 2, 3), keepdim=True)
# 训练模式下,用当前的均值和方差做标准化
X_hat = (X - mean) / torch.sqrt(var + eps)
# 更新移动平均的均值和方差
moving_mean = momentum * moving_mean + (1.0 - momentum) * mean
moving_var = momentum * moving_var + (1.0 - momentum) * var
Y = gamma * X_hat + beta # 缩放和移位
return Y, moving_mean.data, moving_var.data

简明实现

1
2
3
4
5
6
7
8
net = nn.Sequential(
nn.Conv2d(1, 6, kernel_size=5), nn.BatchNorm2d(6), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2),
nn.Conv2d(6, 16, kernel_size=5), nn.BatchNorm2d(16), nn.Sigmoid(),
nn.AvgPool2d(kernel_size=2, stride=2), nn.Flatten(),
nn.Linear(256, 120), nn.BatchNorm1d(120), nn.Sigmoid(),
nn.Linear(120, 84), nn.BatchNorm1d(84), nn.Sigmoid(),
nn.Linear(84, 10))

循环神经网络

序列模型

自回归模型

处理序列数据需要统计工具和新的深度神经网络架构

如果我们需要通过前几天的数据来预测第 tt 天的数据,即

xtP(xtxt1,,x1)x_t \sim P(x_t \mid x_{t-1}, \ldots, x_1)

可以选择自回归模型

整个序列的估计值都将通过以下的方式获得

P(x1,,xT)=t=1TP(xtxt1,,x1)P(x_1, \ldots, x_T) = \prod_{t=1}^T P(x_t \mid x_{t-1}, \ldots, x_1)

马尔可夫模型

使用 xt1,,xtτx_{t-1}, \ldots, x_{t-\tau} 而不是 xt1,,x1x_{t-1}, \ldots, x_1 来估计 xtx_t,只要这种是近似精确的,我们就说序列满足马尔可夫条件

τ=1\tau=1 的时候,我们得到一个一阶马尔可夫模型

P(x1,,xT)=t=1TP(xtxt1) 当 P(x1x0)=P(x1)P(x_1, \ldots, x_T) = \prod_{t=1}^T P(x_t \mid x_{t-1}) \text{ 当 } P(x_1 \mid x_0) = P(x_1)

这种情况下,使用动态规划可以沿着马尔可夫链精确地计算结果

P(xt+1xt1)=xtP(xt+1,xt,xt1)P(xt1)=xtP(xt+1xt,xt1)P(xt,xt1)P(xt1)=xtP(xt+1xt)P(xtxt1)\begin{split}\begin{aligned} P(x_{t+1} \mid x_{t-1}) &= \frac{\sum_{x_t} P(x_{t+1}, x_t, x_{t-1})}{P(x_{t-1})}\\ &= \frac{\sum_{x_t} P(x_{t+1} \mid x_t, x_{t-1}) P(x_t, x_{t-1})}{P(x_{t-1})}\\ &= \sum_{x_t} P(x_{t+1} \mid x_t) P(x_t \mid x_{t-1}) \end{aligned}\end{split}

训练

使用正弦函数和一些可加性噪声来生成序列数据

1
2
3
4
5
6
7
8
9
%matplotlib inline
import torch
from torch import nn
from d2l import torch as d2l

T = 1000 # 总共产生1000个点
time = torch.arange(1, T + 1, dtype=torch.float32)
x = torch.sin(0.01 * time) + torch.normal(0, 0.2, (T,))
d2l.plot(time, [x], 'time', 'x', xlim=[1, 1000], figsize=(6, 3))

我们仅使用前600个“特征-标签”对进行训练

1
2
3
4
5
6
7
8
9
10
tau = 4
features = torch.zeros((T - tau, tau))
for i in range(tau):
features[:, i] = x[i: T - tau + i]
labels = x[tau:].reshape((-1, 1))

batch_size, n_train = 16, 600
# 只有前n_train个样本用于训练
train_iter = d2l.load_array((features[:n_train], labels[:n_train]),
batch_size, is_train=True)

一个拥有两个全连接层的多层感知机,ReLU激活函数和平方损失

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 初始化网络权重的函数
def init_weights(m):
if type(m) == nn.Linear:
nn.init.xavier_uniform_(m.weight)

# 一个简单的多层感知机
def get_net():
net = nn.Sequential(nn.Linear(4, 10),
nn.ReLU(),
nn.Linear(10, 1))
net.apply(init_weights)
return net

# 平方损失。注意:MSELoss计算平方误差时不带系数1/2
loss = nn.MSELoss(reduction='none')

预测

检查模型预测下一个时间步的能力, 也就是单步预测

1
2
3
4
5
onestep_preds = net(features)
d2l.plot([time, time[tau:]],
[x.detach().numpy(), onestep_preds.detach().numpy()], 'time',
'x', legend=['data', '1-step preds'], xlim=[1, 1000],
figsize=(6, 3))

表现良好

如果使用 kk 步预测,经过几个预测步骤之后,预测的结果很快就会衰减到一个常数

当我们试图预测更远的未来时,预测的质量是下降的

文本预处理

词元化

下面的tokenize函数将文本行列表作为输入,列表中的每个元素是一个文本序列。每个文本序列又被拆分成一个词元列表,词元是文本的基本单位。

1
2
3
4
5
6
7
8
9
10
11
12
def tokenize(lines, token='word'):  #@save
"""将文本行拆分为单词或字符词元"""
if token == 'word':
return [line.split() for line in lines]
elif token == 'char':
return [list(line) for line in lines]
else:
print('错误:未知词元类型:' + token)

tokens = tokenize(lines)
for i in range(11):
print(tokens[i])

词表

词元的类型是字符串,而模型需要的输入是数字,因此这种类型不方便模型使用。

我们构建一个字典,通常也叫做词表,用来将字符串类型的词元映射到从开始的数字索引中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
class Vocab:  #@save
"""文本词表"""
def __init__(self, tokens=None, min_freq=0, reserved_tokens=None):
if tokens is None:
tokens = []
if reserved_tokens is None:
reserved_tokens = []
# 按出现频率排序
counter = count_corpus(tokens)
self._token_freqs = sorted(counter.items(), key=lambda x: x[1],
reverse=True)
# 未知词元的索引为0
self.idx_to_token = ['<unk>'] + reserved_tokens
self.token_to_idx = {token: idx
for idx, token in enumerate(self.idx_to_token)}
for token, freq in self._token_freqs:
if freq < min_freq:
break
if token not in self.token_to_idx:
self.idx_to_token.append(token)
self.token_to_idx[token] = len(self.idx_to_token) - 1

def __len__(self):
return len(self.idx_to_token)

def __getitem__(self, tokens):
if not isinstance(tokens, (list, tuple)):
return self.token_to_idx.get(tokens, self.unk)
return [self.__getitem__(token) for token in tokens]

def to_tokens(self, indices):
if not isinstance(indices, (list, tuple)):
return self.idx_to_token[indices]
return [self.idx_to_token[index] for index in indices]

@property
def unk(self): # 未知词元的索引为0
return 0

@property
def token_freqs(self):
return self._token_freqs

def count_corpus(tokens): #@save
"""统计词元的频率"""
# 这里的tokens是1D列表或2D列表
if len(tokens) == 0 or isinstance(tokens[0], list):
# 将词元列表展平成一个列表
tokens = [token for line in tokens for token in line]
return collections.Counter(tokens)

语言模型和数据集

学习语言模型

我们从基本概率规则开始

P(x1,x2,,xT)=t=1TP(xtx1,,xt1)P(x_1, x_2, \ldots, x_T) = \prod_{t=1}^T P(x_t \mid x_1, \ldots, x_{t-1})

例如,包含了四个单词的一个文本序列的概率是

P(deep,learning,is,fun)=P(deep)P(learningdeep)P(isdeep,learning)P(fundeep,learning,is)P(\text{deep}, \text{learning}, \text{is}, \text{fun}) = P(\text{deep}) P(\text{learning} \mid \text{deep}) P(\text{is} \mid \text{deep}, \text{learning}) P(\text{fun} \mid \text{deep}, \text{learning}, \text{is})

为了训练语言模型,我们需要计算单词的概率, 以及给定前面几个单词后出现某个单词的条件概率。 这些概率本质上就是语言模型的参数

一种(稍稍不太精确的)方法是统计单词“deep”在数据集中的出现次数, 然后将其除以整个语料库中的单词总数

P^(learningdeep)=n(deep, learning)n(deep)\hat{P}(\text{learning} \mid \text{deep}) = \frac{n(\text{deep, learning})}{n(\text{deep})}

一种常见的策略是执行某种形式的拉普拉斯平滑,具体方法是在所有计数中添加一个小常量

P^(x)=n(x)+ϵ1/mn+ϵ1,P^(xx)=n(x,x)+ϵ2P^(x)n(x)+ϵ2,P^(xx,x)=n(x,x,x)+ϵ3P^(x)n(x,x)+ϵ3\begin{split}\begin{aligned} \hat{P}(x) & = \frac{n(x) + \epsilon_1/m}{n + \epsilon_1}, \\ \hat{P}(x' \mid x) & = \frac{n(x, x') + \epsilon_2 \hat{P}(x')}{n(x) + \epsilon_2}, \\ \hat{P}(x'' \mid x,x') & = \frac{n(x, x',x'') + \epsilon_3 \hat{P}(x'')}{n(x, x') + \epsilon_3} \end{aligned}\end{split}

然而,这样的模型很容易变得无效

马尔可夫模型与n元语法

P(x1,x2,x3,x4)=P(x1)P(x2)P(x3)P(x4),P(x1,x2,x3,x4)=P(x1)P(x2x1)P(x3x2)P(x4x3),P(x1,x2,x3,x4)=P(x1)P(x2x1)P(x3x1,x2)P(x4x2,x3)\begin{split}\begin{aligned} P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2) P(x_3) P(x_4),\\ P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2 \mid x_1) P(x_3 \mid x_2) P(x_4 \mid x_3),\\ P(x_1, x_2, x_3, x_4) &= P(x_1) P(x_2 \mid x_1) P(x_3 \mid x_1, x_2) P(x_4 \mid x_2, x_3) \end{aligned}\end{split}

涉及一个、两个和三个变量的概率公式分别被称为一元语法、二元语法和三元语法模型

自然语言统计

最流行的词看起来很无聊,这些词通常被称为停用词,因此可以被过滤掉

单词的频率满足齐普夫定律, 即第 ii 个最常用单词的频率 nin_i 为:

ni1iαlogni=αlogi+cn_i \propto \frac{1}{i^\alpha} \\ \log n_i = -\alpha \log i + c

想要通过计数统计和平滑来建模单词是不可行的, 因为这样建模的结果会大大高估尾部单词的频率

随机采样

在随机采样中,每个样本都是在原始的长序列上任意捕获的子序列

下面的代码每次可以从数据中随机生成一个小批量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def seq_data_iter_random(corpus, batch_size, num_steps):  #@save
"""使用随机抽样生成一个小批量子序列"""
# 从随机偏移量开始对序列进行分区,随机范围包括num_steps-1
corpus = corpus[random.randint(0, num_steps - 1):]
# 减去1,是因为我们需要考虑标签
num_subseqs = (len(corpus) - 1) // num_steps
# 长度为num_steps的子序列的起始索引
initial_indices = list(range(0, num_subseqs * num_steps, num_steps))
# 在随机抽样的迭代过程中,
# 来自两个相邻的、随机的、小批量中的子序列不一定在原始序列上相邻
random.shuffle(initial_indices)

def data(pos):
# 返回从pos位置开始的长度为num_steps的序列
return corpus[pos: pos + num_steps]

num_batches = num_subseqs // batch_size
for i in range(0, batch_size * num_batches, batch_size):
# 在这里,initial_indices包含子序列的随机起始索引
initial_indices_per_batch = initial_indices[i: i + batch_size]
X = [data(j) for j in initial_indices_per_batch]
Y = [data(j + 1) for j in initial_indices_per_batch]
yield torch.tensor(X), torch.tensor(Y)

顺序分区

在迭代过程中,除了对原始序列可以随机抽样外, 我们还可以保证两个相邻的小批量中的子序列在原始序列上也是相邻的

这种策略在基于小批量的迭代过程中保留了拆分的子序列的顺序,因此称为顺序分区

1
2
3
4
5
6
7
8
9
10
11
12
13
def seq_data_iter_sequential(corpus, batch_size, num_steps):  #@save
"""使用顺序分区生成一个小批量子序列"""
# 从随机偏移量开始划分序列
offset = random.randint(0, num_steps)
num_tokens = ((len(corpus) - offset - 1) // batch_size) * batch_size
Xs = torch.tensor(corpus[offset: offset + num_tokens])
Ys = torch.tensor(corpus[offset + 1: offset + 1 + num_tokens])
Xs, Ys = Xs.reshape(batch_size, -1), Ys.reshape(batch_size, -1)
num_batches = Xs.shape[1] // num_steps
for i in range(0, num_steps * num_batches, num_steps):
X = Xs[:, i: i + num_steps]
Y = Ys[:, i: i + num_steps]
yield X, Y

循环神经网络

无隐状态的神经网络

只有单隐藏层的多层感知机,隐藏层的输出通过下式计算

H=ϕ(XWxh+bh)\mathbf{H} = \phi(\mathbf{X} \mathbf{W}_{xh} + \mathbf{b}_h)

输出层由下式给出

O=HWhq+bq\mathbf{O} = \mathbf{H} \mathbf{W}_{hq} + \mathbf{b}_q

如果是分类问题,我们可以用 softmax(O)\text{softmax}(\mathbf{O}) 来计算输出类别的概率分布。

有隐状态的循环神经网络

当前时间步隐藏变量由当前时间步的输入与前一个时间步的隐藏变量一起计算得出

Ht=ϕ(XtWxh+Ht1Whh+bh)\mathbf{H}_t = \phi(\mathbf{X}_t \mathbf{W}_{xh} + \mathbf{H}_{t-1} \mathbf{W}_{hh} + \mathbf{b}_h)

相邻时间步的隐藏变量捕获并保留了序列直到其当前时间步的历史信息, 就如当前时间步下神经网络的状态或记忆, 因此这样的隐藏变量被称为隐状态

在当前时间步中, 隐状态使用的定义与前一个时间步中使用的定义相同, 因此计算是循环的

于是基于循环计算的隐状态神经网络被命名为循环神经网络

对于时间步 tt,输出层的输出类似于多层感知机中的计算

Ot=HtWhq+bq\mathbf{O}_t = \mathbf{H}_t \mathbf{W}_{hq} + \mathbf{b}_q

基于循环神经网络的字符级语言模型

为了简化后续部分的训练,我们考虑使用字符级语言模型,将文本词元化为字符而不是单词

在训练过程中,我们对每个时间步的输出层的输出进行softmax操作, 然后利用交叉熵损失计算模型输出和标签之间的误差

通过基于字符级语言建模的循环神经网络, 使用当前的和先前的字符预测下一个字符

困惑度

我们可以通过计算序列的似然概率来度量模型的质量,通过一个序列中所有词元的交叉熵损失的平均值来衡量

1nt=1nlogP(xtxt1,,x1)\frac{1}{n} \sum_{t=1}^n -\log P(x_t \mid x_{t-1}, \ldots, x_1)

由于历史原因,自然语言处理的科学家更喜欢使用一个叫做困惑度的量

exp(1nt=1nlogP(xtxt1,,x1))\exp\left(-\frac{1}{n} \sum_{t=1}^n \log P(x_t \mid x_{t-1}, \ldots, x_1)\right)

困惑度的最好的理解是“下一个词元的实际选择数的调和平均数”

循环神经网络的从零开始实现

独热编码

我们通常将每个词元表示为更具表现力的特征向量,最简单的表示称为独热编码

简言之,将每个索引映射为相互不同的单位向量

初始化模型参数

隐藏单元数num_hiddens是一个可调的超参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def get_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size

def normal(shape):
return torch.randn(size=shape, device=device) * 0.01

# 隐藏层参数
W_xh = normal((num_inputs, num_hiddens))
W_hh = normal((num_hiddens, num_hiddens))
b_h = torch.zeros(num_hiddens, device=device)
# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 附加梯度
params = [W_xh, W_hh, b_h, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params

循环神经网络模型

我们首先需要一个init_rnn_state函数在初始化时返回隐状态

1
2
def init_rnn_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device), )

rnn函数定义了如何在一个时间步内计算隐状态和输出

1
2
3
4
5
6
7
8
9
10
11
def rnn(inputs, state, params):
# inputs的形状:(时间步数量,批量大小,词表大小)
W_xh, W_hh, b_h, W_hq, b_q = params
H, = state
outputs = []
# X的形状:(批量大小,词表大小)
for X in inputs:
H = torch.tanh(torch.mm(X, W_xh) + torch.mm(H, W_hh) + b_h)
Y = torch.mm(H, W_hq) + b_q
outputs.append(Y)
return torch.cat(outputs, dim=0), (H,)

创建一个类来包装这些函数, 并存储从零开始实现的循环神经网络模型的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class RNNModelScratch: #@save
"""从零开始实现的循环神经网络模型"""
def __init__(self, vocab_size, num_hiddens, device,
get_params, init_state, forward_fn):
self.vocab_size, self.num_hiddens = vocab_size, num_hiddens
self.params = get_params(vocab_size, num_hiddens, device)
self.init_state, self.forward_fn = init_state, forward_fn

def __call__(self, X, state):
X = F.one_hot(X.T, self.vocab_size).type(torch.float32)
return self.forward_fn(X, state, self.params)

def begin_state(self, batch_size, device):
return self.init_state(batch_size, self.num_hiddens, device)

预测

定义预测函数来生成prefix之后的新字符, 在循环遍历prefix中的开始字符时, 我们不断地将隐状态传递到下一个时间步,但是不生成任何输出,这被称为预热期

预热期结束后,隐状态的值通常比刚开始的初始值更适合预测, 从而预测字符并输出它们

1
2
3
4
5
6
7
8
9
10
11
12
def predict_ch8(prefix, num_preds, net, vocab, device):  #@save
"""在prefix后面生成新字符"""
state = net.begin_state(batch_size=1, device=device)
outputs = [vocab[prefix[0]]]
get_input = lambda: torch.tensor([outputs[-1]], device=device).reshape((1, 1))
for y in prefix[1:]: # 预热期
_, state = net(get_input(), state)
outputs.append(vocab[y])
for _ in range(num_preds): # 预测num_preds步
y, state = net(get_input(), state)
outputs.append(int(y.argmax(dim=1).reshape(1)))
return ''.join([vocab.idx_to_token[i] for i in outputs])

梯度裁剪

对于长度为 TT 的序列,我们在迭代中计算 TT 这个时间步上的梯度, 将会在反向传播过程中产生长度为 O(T)O(T) 的矩阵乘法链

TT 较大时,它可能导致数值不稳定, 例如可能导致梯度爆炸或梯度消失

通过将梯度 g\mathbf{g} 投影回给定半径 (例如 θ\theta)的球来裁剪梯度 g\mathbf{g},如下式

gmin(1,θg)g\mathbf{g} \leftarrow \min\left(1, \frac{\theta}{\|\mathbf{g}\|}\right) \mathbf{g}

1
2
3
4
5
6
7
8
9
10
def grad_clipping(net, theta):  #@save
"""裁剪梯度"""
if isinstance(net, nn.Module):
params = [p for p in net.parameters() if p.requires_grad]
else:
params = net.params
norm = torch.sqrt(sum(torch.sum((p.grad ** 2)) for p in params))
if norm > theta:
for param in params:
param.grad[:] *= theta / norm

训练

我们定义一个函数在一个迭代周期内训练模型

为了降低计算量,在处理任何一个小批量数据之前, 我们先分离梯度,使得隐状态的梯度计算总是限制在一个小批量数据的时间步内

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#@save
def train_epoch_ch8(net, train_iter, loss, updater, device, use_random_iter):
"""训练网络一个迭代周期(定义见第8章)"""
state, timer = None, d2l.Timer()
metric = d2l.Accumulator(2) # 训练损失之和,词元数量
for X, Y in train_iter:
if state is None or use_random_iter:
# 在第一次迭代或使用随机抽样时初始化state
state = net.begin_state(batch_size=X.shape[0], device=device)
else:
if isinstance(net, nn.Module) and not isinstance(state, tuple):
# state对于nn.GRU是个张量
state.detach_()
else:
# state对于nn.LSTM或对于我们从零开始实现的模型是个张量
for s in state:
s.detach_()
y = Y.T.reshape(-1)
X, y = X.to(device), y.to(device)
y_hat, state = net(X, state)
l = loss(y_hat, y.long()).mean()
if isinstance(updater, torch.optim.Optimizer):
updater.zero_grad()
l.backward()
grad_clipping(net, 1)
updater.step()
else:
l.backward()
grad_clipping(net, 1)
# 因为已经调用了mean函数
updater(batch_size=1)
metric.add(l * y.numel(), y.numel())
return math.exp(metric[0] / metric[1]), metric[1] / timer.stop()

循环神经网络的简洁实现

定义模型

我们构造一个具有256个隐藏单元的单隐藏层的循环神经网络层rnn_layer

使用张量来初始化隐状态

通过一个隐状态和一个输入,我们就可以用更新后的隐状态计算输出

1
2
3
4
5
6
7
8
9
num_hiddens = 256
rnn_layer = nn.RNN(len(vocab), num_hiddens)

state = torch.zeros((1, batch_size, num_hiddens))

X = torch.rand(size=(num_steps, batch_size, len(vocab)))
Y, state_new = rnn_layer(X, state)
Y.shape, state_new.shape

训练与预测

1
2
num_epochs, lr = 500, 1
d2l.train_ch8(net, train_iter, vocab, lr, num_epochs, device)

通过时间反向传播

通过时间反向传播实际上是循环神经网络中反向传播技术的一个特定应用。 它要求我们将循环神经网络的计算图一次展开一个时间步, 以获得模型变量和参数之间的依赖关系, 然后,基于链式法则,应用反向传播来计算和存储梯度

对于时间步 tt,计算隐状态

ht=Whxxt+Whhht1,ot=Wqhht\begin{split}\begin{aligned}\mathbf{h}_t &= \mathbf{W}_{hx} \mathbf{x}_t + \mathbf{W}_{hh} \mathbf{h}_{t-1},\\ \mathbf{o}_t &= \mathbf{W}_{qh} \mathbf{h}_{t}\end{aligned}\end{split}

目标函数总损失为

L=1Tt=1Tl(ot,yt)L = \frac{1}{T} \sum_{t=1}^T l(\mathbf{o}_t, y_t)

现代循环神经网络

门控循环单元

门控隐状态

门控循环单元与普通的循环神经网络之间的关键区别在于: 前者支持隐状态的门控

门控循环单元的数学表达

重置门 RtRn×h\mathbf{R}_t \in \mathbb{R}^{n \times h} 和更新门 ZtRn×h\mathbf{Z}_t \in \mathbb{R}^{n \times h} 的计算如下所示

Rt=σ(XtWxr+Ht1Whr+br)Zt=σ(XtWxz+Ht1Whz+bz)\begin{split}\begin{aligned} \mathbf{R}_t = \sigma(\mathbf{X}_t \mathbf{W}_{xr} + \mathbf{H}_{t-1} \mathbf{W}_{hr} + \mathbf{b}_r)\\ \mathbf{Z}_t = \sigma(\mathbf{X}_t \mathbf{W}_{xz} + \mathbf{H}_{t-1} \mathbf{W}_{hz} + \mathbf{b}_z) \end{aligned}\end{split}

候选隐状态

H~t=tanh(XtWxh+(RtHt1)Whh+bh)\tilde{\mathbf{H}}_t = \tanh(\mathbf{X}_t \mathbf{W}_{xh} + \left(\mathbf{R}_t \odot \mathbf{H}_{t-1}\right) \mathbf{W}_{hh} + \mathbf{b}_h)

我们使用tanh非线性激活函数来确保候选隐状态中的值保持在区间中 (1,1)(-1, 1)

门控循环单元的最终更新公式

Ht=ZtHt1+(1Zt)H~t\mathbf{H}_t = \mathbf{Z}_t \odot \mathbf{H}_{t-1} + (1 - \mathbf{Z}_t) \odot \tilde{\mathbf{H}}_t

从零开始实现

我们从标准差为 0.010.01 的高斯分布中提取权重, 并将偏置项设为 00 ,超参数num_hiddens定义隐藏单元的数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def get_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size

def normal(shape):
return torch.randn(size=shape, device=device)*0.01

def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
torch.zeros(num_hiddens, device=device))

W_xz, W_hz, b_z = three() # 更新门参数
W_xr, W_hr, b_r = three() # 重置门参数
W_xh, W_hh, b_h = three() # 候选隐状态参数
# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 附加梯度
params = [W_xz, W_hz, b_z, W_xr, W_hr, b_r, W_xh, W_hh, b_h, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params

定义隐状态的初始化函数init_gru_state

1
2
def init_gru_state(batch_size, num_hiddens, device):
return (torch.zeros((batch_size, num_hiddens), device=device), )

训练与预测

1
2
3
4
5
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_params,
init_gru_state, gru)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

简洁实现

1
2
3
4
5
num_inputs = vocab_size
gru_layer = nn.GRU(num_inputs, num_hiddens)
model = d2l.RNNModel(gru_layer, len(vocab))
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

长短期记忆网络

门控记忆元

隐变量模型存在着长期信息保存和短期输入缺失的问题,解决这一问题的最早方法之一是长短期存储器

长短期记忆网络的数学表达

It=σ(XtWxi+Ht1Whi+bi),Ft=σ(XtWxf+Ht1Whf+bf),Ot=σ(XtWxo+Ht1Who+bo),\begin{split}\begin{aligned} \mathbf{I}_t &= \sigma(\mathbf{X}_t \mathbf{W}_{xi} + \mathbf{H}_{t-1} \mathbf{W}_{hi} + \mathbf{b}_i),\\ \mathbf{F}_t &= \sigma(\mathbf{X}_t \mathbf{W}_{xf} + \mathbf{H}_{t-1} \mathbf{W}_{hf} + \mathbf{b}_f),\\ \mathbf{O}_t &= \sigma(\mathbf{X}_t \mathbf{W}_{xo} + \mathbf{H}_{t-1} \mathbf{W}_{ho} + \mathbf{b}_o), \end{aligned}\end{split}

候选记忆元

C~t=tanh(XtWxc+Ht1Whc+bc)\tilde{\mathbf{C}}_t = \text{tanh}(\mathbf{X}_t \mathbf{W}_{xc} + \mathbf{H}_{t-1} \mathbf{W}_{hc} + \mathbf{b}_c)

记忆元使用按元素乘法

Ct=FtCt1+ItC~t\mathbf{C}_t = \mathbf{F}_t \odot \mathbf{C}_{t-1} + \mathbf{I}_t \odot \tilde{\mathbf{C}}_t

从零开始实现

1
2
3
4
5
6
import torch
from torch import nn
from d2l import torch as d2l

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

定义和初始化模型参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def get_lstm_params(vocab_size, num_hiddens, device):
num_inputs = num_outputs = vocab_size

def normal(shape):
return torch.randn(size=shape, device=device)*0.01

def three():
return (normal((num_inputs, num_hiddens)),
normal((num_hiddens, num_hiddens)),
torch.zeros(num_hiddens, device=device))

W_xi, W_hi, b_i = three() # 输入门参数
W_xf, W_hf, b_f = three() # 遗忘门参数
W_xo, W_ho, b_o = three() # 输出门参数
W_xc, W_hc, b_c = three() # 候选记忆元参数
# 输出层参数
W_hq = normal((num_hiddens, num_outputs))
b_q = torch.zeros(num_outputs, device=device)
# 附加梯度
params = [W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc,
b_c, W_hq, b_q]
for param in params:
param.requires_grad_(True)
return params

实际模型的定义与我们前面讨论的一样: 提供三个门和一个额外的记忆元

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def lstm(inputs, state, params):
[W_xi, W_hi, b_i, W_xf, W_hf, b_f, W_xo, W_ho, b_o, W_xc, W_hc, b_c,
W_hq, b_q] = params
(H, C) = state
outputs = []
for X in inputs:
I = torch.sigmoid((X @ W_xi) + (H @ W_hi) + b_i)
F = torch.sigmoid((X @ W_xf) + (H @ W_hf) + b_f)
O = torch.sigmoid((X @ W_xo) + (H @ W_ho) + b_o)
C_tilda = torch.tanh((X @ W_xc) + (H @ W_hc) + b_c)
C = F * C + I * C_tilda
H = O * torch.tanh(C)
Y = (H @ W_hq) + b_q
outputs.append(Y)
return torch.cat(outputs, dim=0), (H, C)

训练

1
2
3
4
5
vocab_size, num_hiddens, device = len(vocab), 256, d2l.try_gpu()
num_epochs, lr = 500, 1
model = d2l.RNNModelScratch(len(vocab), num_hiddens, device, get_lstm_params,
init_lstm_state, lstm)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

简洁实现

1
2
3
4
5
num_inputs = vocab_size
lstm_layer = nn.LSTM(num_inputs, num_hiddens)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

深度循环神经网络

函数依赖关系

隐藏层的隐状态使用激活函数

Ht(l)=ϕl(Ht(l1)Wxh(l)+Ht1(l)Whh(l)+bh(l))\mathbf{H}_t^{(l)} = \phi_l(\mathbf{H}_t^{(l-1)} \mathbf{W}_{xh}^{(l)} + \mathbf{H}_{t-1}^{(l)} \mathbf{W}_{hh}^{(l)} + \mathbf{b}_h^{(l)})

输出层的计算仅基于隐藏层最终的隐状态

Ot=Ht(L)Whq+bq\mathbf{O}_t = \mathbf{H}_t^{(L)} \mathbf{W}_{hq} + \mathbf{b}_q

与多层感知机一样,隐藏层数目和隐藏单元数目都是超参数

简洁实现

1
2
3
4
5
6
7
8
9
10
11
12
13
import torch
from torch import nn
from d2l import torch as d2l

batch_size, num_steps = 32, 35
train_iter, vocab = d2l.load_data_time_machine(batch_size, num_steps)

vocab_size, num_hiddens, num_layers = len(vocab), 256, 2
num_inputs = vocab_size
device = d2l.try_gpu()
lstm_layer = nn.LSTM(num_inputs, num_hiddens, num_layers)
model = d2l.RNNModel(lstm_layer, len(vocab))
model = model.to(device)

训练与预测

1
2
num_epochs, lr = 500, 2
d2l.train_ch8(model, train_iter, vocab, lr*1.0, num_epochs, device)

双向循环神经网络

隐马尔可夫模型中的动态规划

对于观测值的序列, 我们在观测状态和隐状态上具有以下联合概率分布

P(x1,,xT,h1,,hT)=t=1TP(htht1)P(xtht), where P(h1h0)=P(h1)P(x_1, \ldots, x_T, h_1, \ldots, h_T) = \prod_{t=1}^T P(h_t \mid h_{t-1}) P(x_t \mid h_t), \text{ where } P(h_1 \mid h_0) = P(h_1)

为了对这个式子进行求解,我们要用到动态规划

动态规划在icpc竞赛中经常用到,就不再过多展开

这里只举个例子

P(x1,,xT)=h1,,hTP(x1,,xT,h1,,hT)=h1,,hTt=1TP(htht1)P(xtht)=h2,,hT[h1P(h1)P(x1h1)P(h2h1)]π2(h2)=defP(x2h2)t=3TP(htht1)P(xtht)=h3,,hT[h2π2(h2)P(x2h2)P(h3h2)]π3(h3)=defP(x3h3)t=4TP(htht1)P(xtht)==hTπT(hT)P(xThT)\begin{split}\begin{aligned} &P(x_1, \ldots, x_T) \\ =& \sum_{h_1, \ldots, h_T} P(x_1, \ldots, x_T, h_1, \ldots, h_T) \\ =& \sum_{h_1, \ldots, h_T} \prod_{t=1}^T P(h_t \mid h_{t-1}) P(x_t \mid h_t) \\ =& \sum_{h_2, \ldots, h_T} \underbrace{\left[\sum_{h_1} P(h_1) P(x_1 \mid h_1) P(h_2 \mid h_1)\right]}_{\pi_2(h_2) \stackrel{\mathrm{def}}{=}} P(x_2 \mid h_2) \prod_{t=3}^T P(h_t \mid h_{t-1}) P(x_t \mid h_t) \\ =& \sum_{h_3, \ldots, h_T} \underbrace{\left[\sum_{h_2} \pi_2(h_2) P(x_2 \mid h_2) P(h_3 \mid h_2)\right]}_{\pi_3(h_3)\stackrel{\mathrm{def}}{=}} P(x_3 \mid h_3) \prod_{t=4}^T P(h_t \mid h_{t-1}) P(x_t \mid h_t)\\ =& \dots \\ =& \sum_{h_T} \pi_T(h_T) P(x_T \mid h_T) \end{aligned}\end{split}

通常,我们将前向递归写为:

πt+1(ht+1)=htπt(ht)P(xtht)P(ht+1ht)\pi_{t+1}(h_{t+1}) = \sum_{h_t} \pi_t(h_t) P(x_t \mid h_t) P(h_{t+1} \mid h_t)

与前向递归一样,我们也可以使用后向递归对同一组隐变量求和

P(x1,,xT)=h1,,hTP(x1,,xT,h1,,hT)=h1,,hTt=1T1P(htht1)P(xtht)P(hThT1)P(xThT)=h1,,hT1t=1T1P(htht1)P(xtht)[hTP(hThT1)P(xThT)]ρT1(hT1)=def=h1,,hT2t=1T2P(htht1)P(xtht)[hT1P(hT1hT2)P(xT1hT1)ρT1(hT1)]ρT2(hT2)=def==h1P(h1)P(x1h1)ρ1(h1)\begin{split}\begin{aligned} & P(x_1, \ldots, x_T) \\ =& \sum_{h_1, \ldots, h_T} P(x_1, \ldots, x_T, h_1, \ldots, h_T) \\ =& \sum_{h_1, \ldots, h_T} \prod_{t=1}^{T-1} P(h_t \mid h_{t-1}) P(x_t \mid h_t) \cdot P(h_T \mid h_{T-1}) P(x_T \mid h_T) \\ =& \sum_{h_1, \ldots, h_{T-1}} \prod_{t=1}^{T-1} P(h_t \mid h_{t-1}) P(x_t \mid h_t) \cdot \underbrace{\left[\sum_{h_T} P(h_T \mid h_{T-1}) P(x_T \mid h_T)\right]}_{\rho_{T-1}(h_{T-1})\stackrel{\mathrm{def}}{=}} \\ =& \sum_{h_1, \ldots, h_{T-2}} \prod_{t=1}^{T-2} P(h_t \mid h_{t-1}) P(x_t \mid h_t) \cdot \underbrace{\left[\sum_{h_{T-1}} P(h_{T-1} \mid h_{T-2}) P(x_{T-1} \mid h_{T-1}) \rho_{T-1}(h_{T-1}) \right]}_{\rho_{T-2}(h_{T-2})\stackrel{\mathrm{def}}{=}} \\ =& \ldots \\ =& \sum_{h_1} P(h_1) P(x_1 \mid h_1)\rho_{1}(h_{1}) \end{aligned}\end{split}

因此,我们可以将后向递归写为:

ρt1(ht1)=htP(htht1)P(xtht)ρt(ht)\rho_{t-1}(h_{t-1})= \sum_{h_{t}} P(h_{t} \mid h_{t-1}) P(x_{t} \mid h_{t}) \rho_{t}(h_{t})

结合前向和后向递归,我们能够计算

P(xjxj)hjπj(hj)ρj(hj)P(xjhj)P(x_j \mid x_{-j}) \propto \sum_{h_j} \pi_j(h_j) \rho_j(h_j) P(x_j \mid h_j)

双向模型

如果我们希望在循环神经网络中拥有一种机制, 使之能够提供与隐马尔可夫模型类似的前瞻能力, 我们就需要修改循环神经网络的设计

双向循环神经网络添加了反向传递信息的隐藏层,以便更灵活地处理此类信息

前向和反向隐状态的更新如下

Ht=ϕ(XtWxh(f)+Ht1Whh(f)+bh(f)),Ht=ϕ(XtWxh(b)+Ht+1Whh(b)+bh(b))\begin{split}\begin{aligned} \overrightarrow{\mathbf{H}}_t &= \phi(\mathbf{X}_t \mathbf{W}_{xh}^{(f)} + \overrightarrow{\mathbf{H}}_{t-1} \mathbf{W}_{hh}^{(f)} + \mathbf{b}_h^{(f)}),\\ \overleftarrow{\mathbf{H}}_t &= \phi(\mathbf{X}_t \mathbf{W}_{xh}^{(b)} + \overleftarrow{\mathbf{H}}_{t+1} \mathbf{W}_{hh}^{(b)} + \mathbf{b}_h^{(b)}) \end{aligned}\end{split}

双向循环神经网络的一个关键特性是:使用来自序列两端的信息来估计输出

我们使用来自过去和未来的观测信息来预测当前的观测

在测试期间,我们只有过去的数据,因此精度将会很差,另一个严重问题是,双向循环神经网络的计算速度非常慢

机器翻译与数据集

词元化

在机器翻译中,我们更喜欢单词级词元化

下面的tokenize_nmt函数对前num_examples个文本序列对进行词元, 其中每个词元要么是一个词,要么是一个标点符号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#@save
def tokenize_nmt(text, num_examples=None):
"""词元化“英语-法语”数据数据集"""
source, target = [], []
for i, line in enumerate(text.split('\n')):
if num_examples and i > num_examples:
break
parts = line.split('\t')
if len(parts) == 2:
source.append(parts[0].split(' '))
target.append(parts[1].split(' '))
return source, target

source, target = tokenize_nmt(text)
source[:6], target[:6]

词表

由于机器翻译数据集由语言对组成, 因此我们可以分别为源语言和目标语言构建两个词表

1
2
3
src_vocab = d2l.Vocab(source, min_freq=2,reserved_tokens=['<pad>', '<bos>', '<eos>'])
len(src_vocab)

加载数据集

为了提高计算效率,我们仍然可以通过截断和填充方式实现一次只处理一个小批量的文本序列

1
2
3
4
5
6
7
8
#@save
def truncate_pad(line, num_steps, padding_token):
"""截断或填充文本序列"""
if len(line) > num_steps:
return line[:num_steps] # 截断
return line + [padding_token] * (num_steps - len(line)) # 填充

truncate_pad(src_vocab[source[0]], 10, src_vocab['<pad>'])

编码器-解码器架构

机器翻译是序列转换模型的一个核心问题, 其输入和输出都是长度可变的序列。

为了处理这种类型的输入和输出, 我们可以设计一个包含两个主要组件的架构: 第一个组件是一个编码器: 它接受一个长度可变的序列作为输入, 并将其转换为具有固定形状的编码状态。 第二个组件是解码器: 它将固定形状的编码状态映射到长度可变的序列。

编码器

1
2
3
4
5
6
7
8
9
10
11
from torch import nn


#@save
class Encoder(nn.Module):
"""编码器-解码器架构的基本编码器接口"""
def __init__(self, **kwargs):
super(Encoder, self).__init__(**kwargs)

def forward(self, X, *args):
raise NotImplementedError

解码器

新增一个init_state函数, 用于将编码器的输出转换为编码后的状态

1
2
3
4
5
6
7
8
9
10
11
#@save
class Decoder(nn.Module):
"""编码器-解码器架构的基本解码器接口"""
def __init__(self, **kwargs):
super(Decoder, self).__init__(**kwargs)

def init_state(self, enc_outputs, *args):
raise NotImplementedError

def forward(self, X, state):
raise NotImplementedError

合并编码器和解码器

1
2
3
4
5
6
7
8
9
10
11
12
#@save
class EncoderDecoder(nn.Module):
"""编码器-解码器架构的基类"""
def __init__(self, encoder, decoder, **kwargs):
super(EncoderDecoder, self).__init__(**kwargs)
self.encoder = encoder
self.decoder = decoder

def forward(self, enc_X, dec_X, *args):
enc_outputs = self.encoder(enc_X, *args)
dec_state = self.decoder.init_state(enc_outputs, *args)
return self.decoder(dec_X, dec_state)

序列到序列学习

编码器

编码器将长度可变的输入序列转换成形状固定的上下文变量, 并且将输入序列的信息在该上下文变量中进行编码

使用一个函数来描述循环神经网络的循环层所做的变换

ht=f(xt,ht1)\mathbf{h}_t = f(\mathbf{x}_t, \mathbf{h}_{t-1})

编码器通过选定的函数,将所有时间步的隐状态转换为上下文变量

c=q(h1,,hT)\mathbf{c} = q(\mathbf{h}_1, \ldots, \mathbf{h}_T)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#@save
class Seq2SeqEncoder(d2l.Encoder):
"""用于序列到序列学习的循环神经网络编码器"""
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
dropout=0, **kwargs):
super(Seq2SeqEncoder, self).__init__(**kwargs)
# 嵌入层
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.GRU(embed_size, num_hiddens, num_layers,
dropout=dropout)

def forward(self, X, *args):
# 输出'X'的形状:(batch_size,num_steps,embed_size)
X = self.embedding(X)
# 在循环神经网络模型中,第一个轴对应于时间步
X = X.permute(1, 0, 2)
# 如果未提及状态,则默认为0
output, state = self.rnn(X)
# output的形状:(num_steps,batch_size,num_hiddens)
# state的形状:(num_layers,batch_size,num_hiddens)
return output, state

解码器

可以使用函数来表示解码器的隐藏层的变换

st=g(yt1,c,st1)\mathbf{s}_{t^\prime} = g(y_{t^\prime-1}, \mathbf{c}, \mathbf{s}_{t^\prime-1})

在获得解码器的隐状态之后, 我们可以使用输出层和softmax操作来计算在时间步时输出的条件概率分布

为了预测输出词元的概率分布, 在循环神经网络解码器的最后一层使用全连接层来变换隐状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Seq2SeqDecoder(d2l.Decoder):
"""用于序列到序列学习的循环神经网络解码器"""
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
dropout=0, **kwargs):
super(Seq2SeqDecoder, self).__init__(**kwargs)
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.GRU(embed_size + num_hiddens, num_hiddens, num_layers,
dropout=dropout)
self.dense = nn.Linear(num_hiddens, vocab_size)

def init_state(self, enc_outputs, *args):
return enc_outputs[1]

def forward(self, X, state):
# 输出'X'的形状:(batch_size,num_steps,embed_size)
X = self.embedding(X).permute(1, 0, 2)
# 广播context,使其具有与X相同的num_steps
context = state[-1].repeat(X.shape[0], 1, 1)
X_and_context = torch.cat((X, context), 2)
output, state = self.rnn(X_and_context, state)
output = self.dense(output).permute(1, 0, 2)
# output的形状:(batch_size,num_steps,vocab_size)
# state的形状:(num_layers,batch_size,num_hiddens)
return output, state

损失函数

可以使用softmax来获得分布, 并通过计算交叉熵损失函数来进行优化

使用下面的sequence_mask函数通过零值化屏蔽不相关的项

1
2
3
4
5
6
7
8
9
10
11
#@save
def sequence_mask(X, valid_len, value=0):
"""在序列中屏蔽不相关的项"""
maxlen = X.size(1)
mask = torch.arange((maxlen), dtype=torch.float32,
device=X.device)[None, :] < valid_len[:, None]
X[~mask] = value
return X

X = torch.tensor([[1, 2, 3], [4, 5, 6]])
sequence_mask(X, torch.tensor([1, 2]))

通过扩展softmax交叉熵损失函数来遮蔽不相关的预测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#@save
class MaskedSoftmaxCELoss(nn.CrossEntropyLoss):
"""带遮蔽的softmax交叉熵损失函数"""
# pred的形状:(batch_size,num_steps,vocab_size)
# label的形状:(batch_size,num_steps)
# valid_len的形状:(batch_size,)
def forward(self, pred, label, valid_len):
weights = torch.ones_like(label)
weights = sequence_mask(weights, valid_len)
self.reduction='none'
unweighted_loss = super(MaskedSoftmaxCELoss, self).forward(
pred.permute(0, 2, 1), label)
weighted_loss = (unweighted_loss * weights).mean(dim=1)
return weighted_loss

训练

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#@save
def train_seq2seq(net, data_iter, lr, num_epochs, tgt_vocab, device):
"""训练序列到序列模型"""
def xavier_init_weights(m):
if type(m) == nn.Linear:
nn.init.xavier_uniform_(m.weight)
if type(m) == nn.GRU:
for param in m._flat_weights_names:
if "weight" in param:
nn.init.xavier_uniform_(m._parameters[param])

net.apply(xavier_init_weights)
net.to(device)
optimizer = torch.optim.Adam(net.parameters(), lr=lr)
loss = MaskedSoftmaxCELoss()
net.train()
animator = d2l.Animator(xlabel='epoch', ylabel='loss',
xlim=[10, num_epochs])
for epoch in range(num_epochs):
timer = d2l.Timer()
metric = d2l.Accumulator(2) # 训练损失总和,词元数量
for batch in data_iter:
optimizer.zero_grad()
X, X_valid_len, Y, Y_valid_len = [x.to(device) for x in batch]
bos = torch.tensor([tgt_vocab['<bos>']] * Y.shape[0],
device=device).reshape(-1, 1)
dec_input = torch.cat([bos, Y[:, :-1]], 1) # 强制教学
Y_hat, _ = net(X, dec_input, X_valid_len)
l = loss(Y_hat, Y, Y_valid_len)
l.sum().backward() # 损失函数的标量进行“反向传播”
d2l.grad_clipping(net, 1)
num_tokens = Y_valid_len.sum()
optimizer.step()
with torch.no_grad():
metric.add(l.sum(), num_tokens)
if (epoch + 1) % 10 == 0:
animator.add(epoch + 1, (metric[0] / metric[1],))
print(f'loss {metric[0] / metric[1]:.3f}, {metric[1] / timer.stop():.1f} '
f'tokens/sec on {str(device)}')

embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 300, d2l.try_gpu()

train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
encoder = Seq2SeqEncoder(len(src_vocab), embed_size, num_hiddens, num_layers,
dropout)
decoder = Seq2SeqDecoder(len(tgt_vocab), embed_size, num_hiddens, num_layers,
dropout)
net = d2l.EncoderDecoder(encoder, decoder)
train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

预测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#@save
def predict_seq2seq(net, src_sentence, src_vocab, tgt_vocab, num_steps,
device, save_attention_weights=False):
"""序列到序列模型的预测"""
# 在预测时将net设置为评估模式
net.eval()
src_tokens = src_vocab[src_sentence.lower().split(' ')] + [
src_vocab['<eos>']]
enc_valid_len = torch.tensor([len(src_tokens)], device=device)
src_tokens = d2l.truncate_pad(src_tokens, num_steps, src_vocab['<pad>'])
# 添加批量轴
enc_X = torch.unsqueeze(
torch.tensor(src_tokens, dtype=torch.long, device=device), dim=0)
enc_outputs = net.encoder(enc_X, enc_valid_len)
dec_state = net.decoder.init_state(enc_outputs, enc_valid_len)
# 添加批量轴
dec_X = torch.unsqueeze(torch.tensor(
[tgt_vocab['<bos>']], dtype=torch.long, device=device), dim=0)
output_seq, attention_weight_seq = [], []
for _ in range(num_steps):
Y, dec_state = net.decoder(dec_X, dec_state)
# 我们使用具有预测最高可能性的词元,作为解码器在下一时间步的输入
dec_X = Y.argmax(dim=2)
pred = dec_X.squeeze(dim=0).type(torch.int32).item()
# 保存注意力权重(稍后讨论)
if save_attention_weights:
attention_weight_seq.append(net.decoder.attention_weights)
# 一旦序列结束词元被预测,输出序列的生成就完成了
if pred == tgt_vocab['<eos>']:
break
output_seq.append(pred)
return ' '.join(tgt_vocab.to_tokens(output_seq)), attention_weight_seq

预测序列的评估

将BLEU定义为

exp(min(0,1lenlabellenpred))n=1kpn1/2n\exp\left(\min\left(0, 1 - \frac{\mathrm{len}_{\text{label}}}{\mathrm{len}_{\text{pred}}}\right)\right) \prod_{n=1}^k p_n^{1/2^n}

根据中BLEU的定义,当预测序列与标签序列完全相同时,BLEU为 11

此外,由于语法越长则匹配难度越大,所以BLEU为更长的元语法的精确度分配更大的权重。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def bleu(pred_seq, label_seq, k):  #@save
"""计算BLEU"""
pred_tokens, label_tokens = pred_seq.split(' '), label_seq.split(' ')
len_pred, len_label = len(pred_tokens), len(label_tokens)
score = math.exp(min(0, 1 - len_label / len_pred))
for n in range(1, k + 1):
num_matches, label_subs = 0, collections.defaultdict(int)
for i in range(len_label - n + 1):
label_subs[' '.join(label_tokens[i: i + n])] += 1
for i in range(len_pred - n + 1):
if label_subs[' '.join(pred_tokens[i: i + n])] > 0:
num_matches += 1
label_subs[' '.join(pred_tokens[i: i + n])] -= 1
score *= math.pow(num_matches / (len_pred - n + 1), math.pow(0.5, n))
return score

束搜索

贪心搜索

基于贪心搜索找到具有最高条件概率的词元

yt=argmaxyYP(yy1,,yt1,c)y_{t'} = \operatorname*{argmax}_{y \in \mathcal{Y}} P(y \mid y_1, \ldots, y_{t'-1}, \mathbf{c})

贪心搜索无法保证得到最优序列

穷举搜索

穷举地列举所有可能的输出序列及其条件概率, 然后计算输出条件概率最高的一个

其计算量可能高的惊人

束搜索

束搜索是贪心搜索的一个改进版本。 它有一个超参数,名为束宽。 在时间步,我们选择具有最高条件概率的词元。 这个词元将分别是候选输出序列的第一个词元

通过灵活地选择束宽,束搜索可以在正确率和计算代价之间进行权衡

注意力机制

注意力提示

生物学中的注意力提示

双组件的框架中,受试者基于非自主性提示和自主性提示有选择地引导注意力的焦点

自主性的与非自主性的注意力提示解释了人类的注意力的方式

可以用神经网络来设计注意力机制的框架

注意力的可视化

为了可视化注意力权重,需要定义一个show_heatmaps函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#@save
def show_heatmaps(matrices, xlabel, ylabel, titles=None, figsize=(2.5, 2.5),
cmap='Reds'):
"""显示矩阵热图"""
d2l.use_svg_display()
num_rows, num_cols = matrices.shape[0], matrices.shape[1]
fig, axes = d2l.plt.subplots(num_rows, num_cols, figsize=figsize,
sharex=True, sharey=True, squeeze=False)
for i, (row_axes, row_matrices) in enumerate(zip(axes, matrices)):
for j, (ax, matrix) in enumerate(zip(row_axes, row_matrices)):
pcm = ax.imshow(matrix.detach().numpy(), cmap=cmap)
if i == num_rows - 1:
ax.set_xlabel(xlabel)
if j == 0:
ax.set_ylabel(ylabel)
if titles:
ax.set_title(titles[j])
fig.colorbar(pcm, ax=axes, shrink=0.6);

注意力汇聚:Nadaraya-Watson 核回归

生成数据集

根据下面的非线性函数生成一个人工数据集

yi=2sin(xi)+xi0.8+ϵy_i = 2\sin(x_i) + x_i^{0.8} + \epsilon

1
2
3
4
5
6
7
8
9
10
11
n_train = 50  # 训练样本数
x_train, _ = torch.sort(torch.rand(n_train) * 5) # 排序后的训练样本

def f(x):
return 2 * torch.sin(x) + x**0.8

y_train = f(x_train) + torch.normal(0.0, 0.5, (n_train,)) # 训练样本的输出
x_test = torch.arange(0, 5, 0.1) # 测试样本
y_truth = f(x_test) # 测试样本的真实输出
n_test = len(x_test) # 测试样本数
n_test

平均汇聚

基于平均汇聚来计算所有训练样本输出值的平均值

f(x)=1ni=1nyif(x) = \frac{1}{n}\sum_{i=1}^n y_i

非参数注意力汇聚

平均汇聚忽略了输入,我们可以从注意力机制框架的角度重写,成为一个更加通用的注意力汇聚公式

f(x)=i=1nα(x,xi)yif(x) = \sum_{i=1}^n \alpha(x, x_i) y_i

为了更好地理解注意力汇聚,下面考虑一个高斯核

K(u)=12πexp(u22)K(u) = \frac{1}{\sqrt{2\pi}} \exp(-\frac{u^2}{2})

将高斯核代入

f(x)=i=1nα(x,xi)yi=i=1nexp(12(xxi)2)j=1nexp(12(xxj)2)yi=i=1nsoftmax(12(xxi)2)yi\begin{split}\begin{aligned} f(x) &=\sum_{i=1}^n \alpha(x, x_i) y_i\\ &= \sum_{i=1}^n \frac{\exp\left(-\frac{1}{2}(x - x_i)^2\right)}{\sum_{j=1}^n \exp\left(-\frac{1}{2}(x - x_j)^2\right)} y_i \\&= \sum_{i=1}^n \mathrm{softmax}\left(-\frac{1}{2}(x - x_i)^2\right) y_i \end{aligned}\end{split}

1
2
3
4
5
6
7
8
9
# X_repeat的形状:(n_test,n_train),
# 每一行都包含着相同的测试输入(例如:同样的查询)
X_repeat = x_test.repeat_interleave(n_train).reshape((-1, n_train))
# x_train包含着键。attention_weights的形状:(n_test,n_train),
# 每一行都包含着要在给定的每个查询的值(y_train)之间分配的注意力权重
attention_weights = nn.functional.softmax(-(X_repeat - x_train)**2 / 2, dim=1)
# y_hat的每个元素都是值的加权平均值,其中的权重是注意力权重
y_hat = torch.matmul(attention_weights, y_train)
plot_kernel_reg(y_hat)

带参数注意力汇聚

我们可以轻松地将可学习的参数集成到注意力汇聚中。

f(x)=i=1nα(x,xi)yi=i=1nexp(12((xxi)w)2)j=1nexp(12((xxj)w)2)yi=i=1nsoftmax(12((xxi)w)2)yi\begin{split}\begin{aligned}f(x) &= \sum_{i=1}^n \alpha(x, x_i) y_i \\&= \sum_{i=1}^n \frac{\exp\left(-\frac{1}{2}((x - x_i)w)^2\right)}{\sum_{j=1}^n \exp\left(-\frac{1}{2}((x - x_j)w)^2\right)} y_i \\&= \sum_{i=1}^n \mathrm{softmax}\left(-\frac{1}{2}((x - x_i)w)^2\right) y_i\end{aligned}\end{split}

为了更有效地计算小批量数据的注意力, 我们可以利用深度学习开发框架中提供的批量矩阵乘法

1
2
3
X = torch.ones((2, 1, 4))
Y = torch.ones((2, 4, 6))
torch.bmm(X, Y).shape

训练

在带参数的注意力汇聚模型中, 任何一个训练样本的输入都会和除自己以外的所有训练样本的“键-值”对进行计算, 从而得到其对应的预测输出。

1
2
3
4
5
6
7
8
# X_tile的形状:(n_train,n_train),每一行都包含着相同的训练输入
X_tile = x_train.repeat((n_train, 1))
# Y_tile的形状:(n_train,n_train),每一行都包含着相同的训练输出
Y_tile = y_train.repeat((n_train, 1))
# keys的形状:('n_train''n_train'-1)
keys = X_tile[(1 - torch.eye(n_train)).type(torch.bool)].reshape((n_train, -1))
# values的形状:('n_train''n_train'-1)
values = Y_tile[(1 - torch.eye(n_train)).type(torch.bool)].reshape((n_train, -1))

训练带参数的注意力汇聚模型时,使用平方损失函数和随机梯度下降

1
2
3
4
5
6
7
8
9
10
11
12
net = NWKernelRegression()
loss = nn.MSELoss(reduction='none')
trainer = torch.optim.SGD(net.parameters(), lr=0.5)
animator = d2l.Animator(xlabel='epoch', ylabel='loss', xlim=[1, 5])

for epoch in range(5):
trainer.zero_grad()
l = loss(net(x_train, keys, values), y_train)
l.sum().backward()
trainer.step()
print(f'epoch {epoch + 1}, loss {float(l.sum()):.6f}')
animator.add(epoch + 1, float(l.sum()))

注意力评分函数

掩蔽softmax操作

正如上面提到的,softmax操作用于输出一个概率分布作为注意力权重

在某些情况下,并非所有的值都应该被纳入到注意力汇聚中

为了仅将有意义的词元作为值来获取注意力汇聚, 可以指定一个有效序列长度, 以便在计算softmax时过滤掉超出指定范围的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#@save
def masked_softmax(X, valid_lens):
"""通过在最后一个轴上掩蔽元素来执行softmax操作"""
# X:3D张量,valid_lens:1D或2D张量
if valid_lens is None:
return nn.functional.softmax(X, dim=-1)
else:
shape = X.shape
if valid_lens.dim() == 1:
valid_lens = torch.repeat_interleave(valid_lens, shape[1])
else:
valid_lens = valid_lens.reshape(-1)
# 最后一轴上被掩蔽的元素使用一个非常大的负值替换,从而其softmax输出为0
X = d2l.sequence_mask(X.reshape(-1, shape[-1]), valid_lens,
value=-1e6)
return nn.functional.softmax(X.reshape(shape), dim=-1)

加性注意力

加性注意力的评分函数为

a(q,k)=wvtanh(Wqq+Wkk)Ra(\mathbf q, \mathbf k) = \mathbf w_v^\top \text{tanh}(\mathbf W_q\mathbf q + \mathbf W_k \mathbf k) \in \mathbb{R}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#@save
class AdditiveAttention(nn.Module):
"""加性注意力"""
def __init__(self, key_size, query_size, num_hiddens, dropout, **kwargs):
super(AdditiveAttention, self).__init__(**kwargs)
self.W_k = nn.Linear(key_size, num_hiddens, bias=False)
self.W_q = nn.Linear(query_size, num_hiddens, bias=False)
self.w_v = nn.Linear(num_hiddens, 1, bias=False)
self.dropout = nn.Dropout(dropout)

def forward(self, queries, keys, values, valid_lens):
queries, keys = self.W_q(queries), self.W_k(keys)
# 在维度扩展后,
# queries的形状:(batch_size,查询的个数,1,num_hidden)
# key的形状:(batch_size,1,“键-值”对的个数,num_hiddens)
# 使用广播方式进行求和
features = queries.unsqueeze(2) + keys.unsqueeze(1)
features = torch.tanh(features)
# self.w_v仅有一个输出,因此从形状中移除最后那个维度。
# scores的形状:(batch_size,查询的个数,“键-值”对的个数)
scores = self.w_v(features).squeeze(-1)
self.attention_weights = masked_softmax(scores, valid_lens)
# values的形状:(batch_size,“键-值”对的个数,值的维度)
return torch.bmm(self.dropout(self.attention_weights), values)

缩放点积注意力

则缩放点积注意力评分函数为:

a(q,k)=qk/da(\mathbf q, \mathbf k) = \mathbf{q}^\top \mathbf{k} /\sqrt{d}

下面的缩放点积注意力的实现使用了暂退法进行模型正则化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#@save
class DotProductAttention(nn.Module):
"""缩放点积注意力"""
def __init__(self, dropout, **kwargs):
super(DotProductAttention, self).__init__(**kwargs)
self.dropout = nn.Dropout(dropout)

# queries的形状:(batch_size,查询的个数,d)
# keys的形状:(batch_size,“键-值”对的个数,d)
# values的形状:(batch_size,“键-值”对的个数,值的维度)
# valid_lens的形状:(batch_size,)或者(batch_size,查询的个数)
def forward(self, queries, keys, values, valid_lens=None):
d = queries.shape[-1]
# 设置transpose_b=True为了交换keys的最后两个维度
scores = torch.bmm(queries, keys.transpose(1,2)) / math.sqrt(d)
self.attention_weights = masked_softmax(scores, valid_lens)
return torch.bmm(self.dropout(self.attention_weights), values)

Bahdanau 注意力

模型

解码时间步的上下文变量是注意力集中的输出

ct=t=1Tα(st1,ht)ht\mathbf{c}_{t'} = \sum_{t=1}^T \alpha(\mathbf{s}_{t' - 1}, \mathbf{h}_t) \mathbf{h}_t

定义注意力解码器

定义Bahdanau注意力,我们只需重新定义解码器即可

在每个解码时间步骤中,解码器上一个时间步的最终层隐状态将用作查询

因此,注意力输出和输入嵌入都连结为循环神经网络解码器的输入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Seq2SeqAttentionDecoder(AttentionDecoder):
def __init__(self, vocab_size, embed_size, num_hiddens, num_layers,
dropout=0, **kwargs):
super(Seq2SeqAttentionDecoder, self).__init__(**kwargs)
self.attention = d2l.AdditiveAttention(
num_hiddens, num_hiddens, num_hiddens, dropout)
self.embedding = nn.Embedding(vocab_size, embed_size)
self.rnn = nn.GRU(
embed_size + num_hiddens, num_hiddens, num_layers,
dropout=dropout)
self.dense = nn.Linear(num_hiddens, vocab_size)

def init_state(self, enc_outputs, enc_valid_lens, *args):
# outputs的形状为(batch_size,num_steps,num_hiddens).
# hidden_state的形状为(num_layers,batch_size,num_hiddens)
outputs, hidden_state = enc_outputs
return (outputs.permute(1, 0, 2), hidden_state, enc_valid_lens)

def forward(self, X, state):
# enc_outputs的形状为(batch_size,num_steps,num_hiddens).
# hidden_state的形状为(num_layers,batch_size,
# num_hiddens)
enc_outputs, hidden_state, enc_valid_lens = state
# 输出X的形状为(num_steps,batch_size,embed_size)
X = self.embedding(X).permute(1, 0, 2)
outputs, self._attention_weights = [], []
for x in X:
# query的形状为(batch_size,1,num_hiddens)
query = torch.unsqueeze(hidden_state[-1], dim=1)
# context的形状为(batch_size,1,num_hiddens)
context = self.attention(
query, enc_outputs, enc_outputs, enc_valid_lens)
# 在特征维度上连结
x = torch.cat((context, torch.unsqueeze(x, dim=1)), dim=-1)
# 将x变形为(1,batch_size,embed_size+num_hiddens)
out, hidden_state = self.rnn(x.permute(1, 0, 2), hidden_state)
outputs.append(out)
self._attention_weights.append(self.attention.attention_weights)
# 全连接层变换后,outputs的形状为
# (num_steps,batch_size,vocab_size)
outputs = self.dense(torch.cat(outputs, dim=0))
return outputs.permute(1, 0, 2), [enc_outputs, hidden_state,
enc_valid_lens]

@property
def attention_weights(self):
return self._attention_weights

训练

1
2
3
4
5
6
7
8
9
10
11
embed_size, num_hiddens, num_layers, dropout = 32, 32, 2, 0.1
batch_size, num_steps = 64, 10
lr, num_epochs, device = 0.005, 250, d2l.try_gpu()

train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)
encoder = d2l.Seq2SeqEncoder(
len(src_vocab), embed_size, num_hiddens, num_layers, dropout)
decoder = Seq2SeqAttentionDecoder(
len(tgt_vocab), embed_size, num_hiddens, num_layers, dropout)
net = d2l.EncoderDecoder(encoder, decoder)
d2l.train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

多头注意力

模型

用数学语言将这个模型形式化地描述出来

hi=f(Wi(q)q,Wi(k)k,Wi(v)v)Rpv\mathbf{h}_i = f(\mathbf W_i^{(q)}\mathbf q, \mathbf W_i^{(k)}\mathbf k,\mathbf W_i^{(v)}\mathbf v) \in \mathbb R^{p_v}

实现

在实现过程中通常选择缩放点积注意力作为每一个注意力头

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#@save
class MultiHeadAttention(nn.Module):
"""多头注意力"""
def __init__(self, key_size, query_size, value_size, num_hiddens,
num_heads, dropout, bias=False, **kwargs):
super(MultiHeadAttention, self).__init__(**kwargs)
self.num_heads = num_heads
self.attention = d2l.DotProductAttention(dropout)
self.W_q = nn.Linear(query_size, num_hiddens, bias=bias)
self.W_k = nn.Linear(key_size, num_hiddens, bias=bias)
self.W_v = nn.Linear(value_size, num_hiddens, bias=bias)
self.W_o = nn.Linear(num_hiddens, num_hiddens, bias=bias)

def forward(self, queries, keys, values, valid_lens):
# queries,keys,values的形状:
# (batch_size,查询或者“键-值”对的个数,num_hiddens)
# valid_lens 的形状:
# (batch_size,)或(batch_size,查询的个数)
# 经过变换后,输出的queries,keys,values 的形状:
# (batch_size*num_heads,查询或者“键-值”对的个数,
# num_hiddens/num_heads)
queries = transpose_qkv(self.W_q(queries), self.num_heads)
keys = transpose_qkv(self.W_k(keys), self.num_heads)
values = transpose_qkv(self.W_v(values), self.num_heads)

if valid_lens is not None:
# 在轴0,将第一项(标量或者矢量)复制num_heads次,
# 然后如此复制第二项,然后诸如此类。
valid_lens = torch.repeat_interleave(
valid_lens, repeats=self.num_heads, dim=0)

# output的形状:(batch_size*num_heads,查询的个数,
# num_hiddens/num_heads)
output = self.attention(queries, keys, values, valid_lens)

# output_concat的形状:(batch_size,查询的个数,num_hiddens)
output_concat = transpose_output(output, self.num_heads)
return self.W_o(output_concat)

为了能够使多个头并行计算, 上面的MultiHeadAttention类将使用下面定义的两个转置函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#@save
def transpose_qkv(X, num_heads):
"""为了多注意力头的并行计算而变换形状"""
# 输入X的形状:(batch_size,查询或者“键-值”对的个数,num_hiddens)
# 输出X的形状:(batch_size,查询或者“键-值”对的个数,num_heads,
# num_hiddens/num_heads)
X = X.reshape(X.shape[0], X.shape[1], num_heads, -1)

# 输出X的形状:(batch_size,num_heads,查询或者“键-值”对的个数,
# num_hiddens/num_heads)
X = X.permute(0, 2, 1, 3)

# 最终输出的形状:(batch_size*num_heads,查询或者“键-值”对的个数,
# num_hiddens/num_heads)
return X.reshape(-1, X.shape[2], X.shape[3])


#@save
def transpose_output(X, num_heads):
"""逆转transpose_qkv函数的操作"""
X = X.reshape(-1, num_heads, X.shape[1], X.shape[2])
X = X.permute(0, 2, 1, 3)
return X.reshape(X.shape[0], X.shape[1], -1)

自注意力和位置编码

自注意力

一个由词元组成的输入序列的自注意力输出为一个长度相同的序列

yi=f(xi,(x1,x1),,(xn,xn))Rd\mathbf{y}_i = f(\mathbf{x}_i, (\mathbf{x}_1, \mathbf{x}_1), \ldots, (\mathbf{x}_n, \mathbf{x}_n)) \in \mathbb{R}^d

下面的代码片段是基于多头注意力对一个张量完成自注意力的计算

1
2
3
4
num_hiddens, num_heads = 100, 5
attention = d2l.MultiHeadAttention(num_hiddens, num_hiddens, num_hiddens,
num_hiddens, num_heads, 0.5)
attention.eval()

比较卷积神经网络、循环神经网络和自注意力

卷积神经网络和自注意力都拥有并行计算的优势, 而且自注意力的最大路径长度最短, 但是因为其计算复杂度是关于序列长度的二次方,所以在很长的序列中计算会非常慢

位置编码

为了使用序列的顺序信息,通过在输入表示中添加位置编码来注入绝对的或相对的位置信息

位置编码使用相同形状的位置嵌入矩阵

pi,2j=sin(i100002j/d),pi,2j+1=cos(i100002j/d)\begin{split}\begin{aligned} p_{i, 2j} &= \sin\left(\frac{i}{10000^{2j/d}}\right),\\p_{i, 2j+1} &= \cos\left(\frac{i}{10000^{2j/d}}\right)\end{aligned}\end{split}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#@save
class PositionalEncoding(nn.Module):
"""位置编码"""
def __init__(self, num_hiddens, dropout, max_len=1000):
super(PositionalEncoding, self).__init__()
self.dropout = nn.Dropout(dropout)
# 创建一个足够长的P
self.P = torch.zeros((1, max_len, num_hiddens))
X = torch.arange(max_len, dtype=torch.float32).reshape(
-1, 1) / torch.pow(10000, torch.arange(
0, num_hiddens, 2, dtype=torch.float32) / num_hiddens)
self.P[:, :, 0::2] = torch.sin(X)
self.P[:, :, 1::2] = torch.cos(X)

def forward(self, X):
X = X + self.P[:, :X.shape[1], :].to(X.device)
return self.dropout(X)

除了捕获绝对位置信息之外,上述的位置编码还允许模型学习得到输入序列中相对位置信息

[cos(δωj)sin(δωj)sin(δωj)cos(δωj)][pi,2jpi,2j+1]=[cos(δωj)sin(iωj)+sin(δωj)cos(iωj)sin(δωj)sin(iωj)+cos(δωj)cos(iωj)]=[sin((i+δ)ωj)cos((i+δ)ωj)]=[pi+δ,2jpi+δ,2j+1]\begin{split}\begin{aligned} &\begin{bmatrix} \cos(\delta \omega_j) & \sin(\delta \omega_j) \\ -\sin(\delta \omega_j) & \cos(\delta \omega_j) \\ \end{bmatrix} \begin{bmatrix} p_{i, 2j} \\ p_{i, 2j+1} \\ \end{bmatrix}\\ =&\begin{bmatrix} \cos(\delta \omega_j) \sin(i \omega_j) + \sin(\delta \omega_j) \cos(i \omega_j) \\ -\sin(\delta \omega_j) \sin(i \omega_j) + \cos(\delta \omega_j) \cos(i \omega_j) \\ \end{bmatrix}\\ =&\begin{bmatrix} \sin\left((i+\delta) \omega_j\right) \\ \cos\left((i+\delta) \omega_j\right) \\ \end{bmatrix}\\ =& \begin{bmatrix} p_{i+\delta, 2j} \\ p_{i+\delta, 2j+1} \\ \end{bmatrix} \end{aligned}\end{split}

Transformer

模型

Transformer是由编码器和解码器组成的

与基于Bahdanau注意力实现的序列到序列的学习相比,Transformer的编码器和解码器是基于自注意力的模块叠加而成的

源(输入)序列和目标(输出)序列的嵌入表示将加上位置编码,再分别输入到编码器和解码器中。

从宏观角度来看,Transformer的编码器是由多个相同的层叠加而成的,每个层都有两个子层

Transformer解码器也是由多个相同的层叠加而成的,并且层中使用了残差连接和层规范化

基于位置的前馈网络

基于位置的前馈网络对序列中的所有位置的表示进行变换时使用的是同一个多层感知机

1
2
3
4
5
6
7
8
9
10
11
12
#@save
class PositionWiseFFN(nn.Module):
"""基于位置的前馈网络"""
def __init__(self, ffn_num_input, ffn_num_hiddens, ffn_num_outputs,
**kwargs):
super(PositionWiseFFN, self).__init__(**kwargs)
self.dense1 = nn.Linear(ffn_num_input, ffn_num_hiddens)
self.relu = nn.ReLU()
self.dense2 = nn.Linear(ffn_num_hiddens, ffn_num_outputs)

def forward(self, X):
return self.dense2(self.relu(self.dense1(X)))

残差连接和层规范化

加法和规范化组件由残差连接和紧随其后的层规范化组成的

可以使用残差连接和层规范化来实现AddNorm类

1
2
3
4
5
6
7
8
9
10
#@save
class AddNorm(nn.Module):
"""残差连接后进行层规范化"""
def __init__(self, normalized_shape, dropout, **kwargs):
super(AddNorm, self).__init__(**kwargs)
self.dropout = nn.Dropout(dropout)
self.ln = nn.LayerNorm(normalized_shape)

def forward(self, X, Y):
return self.ln(self.dropout(Y) + X)

编码器

下面的EncoderBlock类包含两个子层:多头自注意力和基于位置的前馈网络,这两个子层都使用了残差连接和紧随的层规范化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#@save
class EncoderBlock(nn.Module):
"""Transformer编码器块"""
def __init__(self, key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
dropout, use_bias=False, **kwargs):
super(EncoderBlock, self).__init__(**kwargs)
self.attention = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout,
use_bias)
self.addnorm1 = AddNorm(norm_shape, dropout)
self.ffn = PositionWiseFFN(
ffn_num_input, ffn_num_hiddens, num_hiddens)
self.addnorm2 = AddNorm(norm_shape, dropout)

def forward(self, X, valid_lens):
Y = self.addnorm1(X, self.attention(X, X, X, valid_lens))
return self.addnorm2(Y, self.ffn(Y))

下面实现的Transformer编码器的代码中,堆叠了num_layers个EncoderBlock类的实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#@save
class TransformerEncoder(d2l.Encoder):
"""Transformer编码器"""
def __init__(self, vocab_size, key_size, query_size, value_size,
num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, num_layers, dropout, use_bias=False, **kwargs):
super(TransformerEncoder, self).__init__(**kwargs)
self.num_hiddens = num_hiddens
self.embedding = nn.Embedding(vocab_size, num_hiddens)
self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout)
self.blks = nn.Sequential()
for i in range(num_layers):
self.blks.add_module("block"+str(i),
EncoderBlock(key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, dropout, use_bias))

def forward(self, X, valid_lens, *args):
# 因为位置编码值在-1和1之间,
# 因此嵌入值乘以嵌入维度的平方根进行缩放,
# 然后再与位置编码相加。
X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
self.attention_weights = [None] * len(self.blks)
for i, blk in enumerate(self.blks):
X = blk(X, valid_lens)
self.attention_weights[
i] = blk.attention.attention.attention_weights
return X

解码器

Transformer解码器也是由多个相同的层组成

在DecoderBlock类中实现的每个层包含了三个子层:解码器自注意力、"编码器-解码器"注意力和基于位置的前馈网络。

这些子层也都被残差连接和紧随的层规范化围绕

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class DecoderBlock(nn.Module):
"""解码器中第i个块"""
def __init__(self, key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
dropout, i, **kwargs):
super(DecoderBlock, self).__init__(**kwargs)
self.i = i
self.attention1 = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout)
self.addnorm1 = AddNorm(norm_shape, dropout)
self.attention2 = d2l.MultiHeadAttention(
key_size, query_size, value_size, num_hiddens, num_heads, dropout)
self.addnorm2 = AddNorm(norm_shape, dropout)
self.ffn = PositionWiseFFN(ffn_num_input, ffn_num_hiddens,
num_hiddens)
self.addnorm3 = AddNorm(norm_shape, dropout)

def forward(self, X, state):
enc_outputs, enc_valid_lens = state[0], state[1]
# 训练阶段,输出序列的所有词元都在同一时间处理,
# 因此state[2][self.i]初始化为None。
# 预测阶段,输出序列是通过词元一个接着一个解码的,
# 因此state[2][self.i]包含着直到当前时间步第i个块解码的输出表示
if state[2][self.i] is None:
key_values = X
else:
key_values = torch.cat((state[2][self.i], X), axis=1)
state[2][self.i] = key_values
if self.training:
batch_size, num_steps, _ = X.shape
# dec_valid_lens的开头:(batch_size,num_steps),
# 其中每一行是[1,2,...,num_steps]
dec_valid_lens = torch.arange(
1, num_steps + 1, device=X.device).repeat(batch_size, 1)
else:
dec_valid_lens = None

# 自注意力
X2 = self.attention1(X, key_values, key_values, dec_valid_lens)
Y = self.addnorm1(X, X2)
# 编码器-解码器注意力。
# enc_outputs的开头:(batch_size,num_steps,num_hiddens)
Y2 = self.attention2(Y, enc_outputs, enc_outputs, enc_valid_lens)
Z = self.addnorm2(Y, Y2)
return self.addnorm3(Z, self.ffn(Z)), state

通过一个全连接层计算所有vocab_size个可能的输出词元的预测值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class TransformerDecoder(d2l.AttentionDecoder):
def __init__(self, vocab_size, key_size, query_size, value_size,
num_hiddens, norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, num_layers, dropout, **kwargs):
super(TransformerDecoder, self).__init__(**kwargs)
self.num_hiddens = num_hiddens
self.num_layers = num_layers
self.embedding = nn.Embedding(vocab_size, num_hiddens)
self.pos_encoding = d2l.PositionalEncoding(num_hiddens, dropout)
self.blks = nn.Sequential()
for i in range(num_layers):
self.blks.add_module("block"+str(i),
DecoderBlock(key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens,
num_heads, dropout, i))
self.dense = nn.Linear(num_hiddens, vocab_size)

def init_state(self, enc_outputs, enc_valid_lens, *args):
return [enc_outputs, enc_valid_lens, [None] * self.num_layers]

def forward(self, X, state):
X = self.pos_encoding(self.embedding(X) * math.sqrt(self.num_hiddens))
self._attention_weights = [[None] * len(self.blks) for _ in range (2)]
for i, blk in enumerate(self.blks):
X, state = blk(X, state)
# 解码器自注意力权重
self._attention_weights[0][
i] = blk.attention1.attention.attention_weights
# “编码器-解码器”自注意力权重
self._attention_weights[1][
i] = blk.attention2.attention.attention_weights
return self.dense(X), state

@property
def attention_weights(self):
return self._attention_weights

训练

依照Transformer架构来实例化编码器-解码器模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
num_hiddens, num_layers, dropout, batch_size, num_steps = 32, 2, 0.1, 64, 10
lr, num_epochs, device = 0.005, 200, d2l.try_gpu()
ffn_num_input, ffn_num_hiddens, num_heads = 32, 64, 4
key_size, query_size, value_size = 32, 32, 32
norm_shape = [32]

train_iter, src_vocab, tgt_vocab = d2l.load_data_nmt(batch_size, num_steps)

encoder = TransformerEncoder(
len(src_vocab), key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
num_layers, dropout)
decoder = TransformerDecoder(
len(tgt_vocab), key_size, query_size, value_size, num_hiddens,
norm_shape, ffn_num_input, ffn_num_hiddens, num_heads,
num_layers, dropout)
net = d2l.EncoderDecoder(encoder, decoder)
d2l.train_seq2seq(net, train_iter, lr, num_epochs, tgt_vocab, device)

训练结束后,使用Transformer模型将一些英语句子翻译成法语,并且计算它们的BLEU分数

1
2
3
4
5
6
7
engs = ['go .', "i lost .", 'he\'s calm .', 'i\'m home .']
fras = ['va !', 'j\'ai perdu .', 'il est calme .', 'je suis chez moi .']
for eng, fra in zip(engs, fras):
translation, dec_attention_weight_seq = d2l.predict_seq2seq(
net, eng, src_vocab, tgt_vocab, num_steps, device, True)
print(f'{eng} => {translation}, ',
f'bleu {d2l.bleu(translation, fra, k=2):.3f}')

尽管Transformer架构是为了序列到序列的学习而提出的,但Transformer编码器或Transformer解码器通常被单独用于不同的深度学习任务中。


深度学习基础
https://suzipei.github.io/2023/03/28/pytorch/
作者
Su_Zipei
发布于
2023年3月28日
许可协议