Bootstrap

动手学深度学习10.2. 注意力汇聚:Nadaraya-Watson 核回归-笔记&练习(PyTorch)

本节课程地址:无

本节教材地址:10.2. 注意力汇聚:Nadaraya-Watson 核回归 — 动手学深度学习 2.0.0 documentation

本节开源代码:...>d2l-zh>pytorch>chapter_multilayer-perceptrons>nadaraya-waston.ipynb


注意力汇聚:Nadaraya-Watson 核回归

上节介绍了框架下的注意力机制的主要成分 图10.1.3 : 查询(自主提示)和键(非自主提示)之间的交互形成了注意力汇聚; 注意力汇聚有选择地聚合了值(感官输入)以生成最终的输出。 本节将介绍注意力汇聚的更多细节, 以便从宏观上了解注意力机制在实践中的运作方式。 具体来说,1964年提出的Nadaraya-Watson核回归模型 是一个简单但完整的例子,可以用于演示具有注意力机制的机器学习。

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

[生成数据集]

简单起见,考虑下面这个回归问题: 给定的成对的“输入-输出”数据集 {{(x_1, y_1), \ldots, (x_n, y_n)}}, 如何学习 f 来预测任意新输入 x 的输出 \hat{y} = f(x)

根据下面的非线性函数生成一个人工数据集, 其中加入的噪声项为 \epsilon :

y_i = 2\sin(x_i) + x_i^{0.8} + \epsilon, (10.2.1)

其中 \epsilon 服从均值为0和标准差为0.5的正态分布。 在这里生成了50个训练样本和50个测试样本。 为了更好地可视化之后的注意力模式,需要将训练样本进行排序。

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

输出结果:
50

下面的函数将绘制所有的训练样本(样本由圆圈表示), 不带噪声项的真实数据生成函数 f (标记为“Truth”), 以及学习得到的预测函数(标记为“Pred”)。

def plot_kernel_reg(y_hat):
    d2l.plot(x_test, [y_truth, y_hat], 'x', 'y', legend=['Truth', 'Pred'],
             xlim=[0, 5], ylim=[-1, 5])
    d2l.plt.plot(x_train, y_train, 'o', alpha=0.5)

平均汇聚

先使用最简单的估计器来解决回归问题。 基于平均汇聚来计算所有训练样本输出值的平均值:

f(x) = \frac{1}{n}\sum_{i=1}^n y_i, (10.2.2)

如下图所示,这个估计器确实不够聪明。 真实函数 f (“Truth”)和预测函数(“Pred”)相差很大。

y_hat = torch.repeat_interleave(y_train.mean(), n_test)
plot_kernel_reg(y_hat)

 

[非参数注意力汇聚]

显然,平均汇聚忽略了输入 x_i 。 于是Nadaraya (Nadaraya, 1964) 和 Watson (Watson, 1964) 提出了一个更好的想法, 根据输入的位置对输出 y_i 进行加权:

f(x) = \sum_{i=1}^n \frac{K(x - x_i)}{\sum_{j=1}^n K(x - x_j)} y_i, (10.2.3)

其中 K 是(kernel)。 公式 (10.2.3)所描述的估计器被称为 Nadaraya-Watson核回归(Nadaraya-Watson kernel regression)。 这里不会深入讨论核函数的细节, 但受此启发, 我们可以从 图10.1.3 中的注意力机制框架的角度 重写 (10.2.3), 成为一个更加通用的注意力汇聚(attention pooling)公式:

f(x) = \sum_{i=1}^n \alpha(x, x_i) y_i, (10.2.4)

其中 x 是查询, (x_i, y_i) 是键值对。 比较 (10.2.4)和 (10.2.2), 注意力汇聚是 y_i 的加权平均。 将查询 x 和键 x_i 之间的关系建模为 注意力权重(attention weight) \alpha(x, x_i) , 如 (10.2.4)所示, 这个权重将被分配给每一个对应值 y_i 。 对于任何查询,模型在所有键值对注意力权重都是一个有效的概率分布: 它们是非负的,并且总和为1。

为了更好地理解注意力汇聚, 下面考虑一个高斯核(Gaussian kernel),其定义为:

K(u) = \frac{1}{\sqrt{2\pi}} \exp(-\frac{u^2}{2}). (10.2.5)

将高斯核代入(10.2.4)和 (10.2.3)可以得到:

\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} (10.2.6)

在 (10.2.6)中, 如果一个键 x_i 越是接近给定的查询 x , 那么分配给这个键对应值 y_i 的注意力权重就会越大, 也就“获得了更多的注意力”。

值得注意的是,Nadaraya-Watson核回归是一个非参数模型。 因此, (10.2.6)是 非参数的注意力汇聚(nonparametric attention pooling)模型。 接下来,我们将基于这个非参数的注意力汇聚模型来绘制预测结果。 从绘制的结果会发现新的模型预测线是平滑的,并且比平均汇聚的预测更接近真实。

# 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)

 

这张图描述了上述矩阵变换过程

来自于https://wangxl12.github.io/2021/12/14/%E6%B7%B1%E5%BA%A6%E5%AD%A6%E4%B9%A0/%E6%B3%A8%E6%84%8F%E5%8A%9B%E6%9C%BA%E5%88%B6/

现在来观察注意力的权重。 这里测试数据的输入相当于查询,而训练数据的输入相当于键。 因为两个输入都是经过排序的,因此由观察可知“查询-键”对越接近, 注意力汇聚的[注意力权重]就越高。

d2l.show_heatmaps(attention_weights.unsqueeze(0).unsqueeze(0),
                  xlabel='Sorted training inputs',
                  ylabel='Sorted testing inputs')

 

[带参数注意力汇聚]

非参数的Nadaraya-Watson核回归具有一致性(consistency)的优点: 如果有足够的数据,此模型会收敛到最优结果。 尽管如此,我们还是可以轻松地将可学习的参数集成到注意力汇聚中。

例如,与 (10.2.6)略有不同, 在下面的查询 x 和键 x_i 之间的距离乘以可学习参数 w :

\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}(10.2.7)

本节的余下部分将通过训练这个模型 (10.2.7)来学习注意力汇聚的参数。

批量矩阵乘法

为了更有效地计算小批量数据的注意力, 我们可以利用深度学习开发框架中提供的批量矩阵乘法。

假设第一个小批量数据包含 n 个矩阵 \mathbf{X}_1,\ldots, \mathbf{X}_n , 形状为 a\times b , 第二个小批量包含 n 个矩阵 \mathbf{Y}_1, \ldots, \mathbf{Y}_n , 形状为 b\times c 。 它们的批量矩阵乘法得到 n 个矩阵 \mathbf{X}_1\mathbf{Y}_1, \ldots, \mathbf{X}_n\mathbf{Y}_n , 形状为 a\times c 。 因此,[假定两个张量的形状分别是 (n,a,b)  (n,b,c) , 它们的批量矩阵乘法输出的形状为 (n,a,c) ]。

X = torch.ones((2, 1, 4))
Y = torch.ones((2, 4, 6))
torch.bmm(X, Y).shape

输出结果:
torch.Size([2, 1, 6])

在注意力机制的背景中,我们可以[使用小批量矩阵乘法来计算小批量数据中的加权平均值]。

weights = torch.ones((2, 10)) * 0.1
values = torch.arange(20.0).reshape((2, 10))
# weights.unsqueeze(1)形状变为 (2, 1, 10)
# values.unsqueeze(-1)形状变为 (2, 10, 1)
# torch.bmm对这两个张量进行批量矩阵乘法, 结果形状为 (2, 1, 1) 
torch.bmm(weights.unsqueeze(1), values.unsqueeze(-1))

输出结果:
tensor([[[ 4.5000]],

[[14.5000]]])

定义模型

基于(10.2.7)中的 [带参数的注意力汇聚],使用小批量矩阵乘法, 定义Nadaraya-Watson核回归的带参数版本为:

class NWKernelRegression(nn.Module):
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.w = nn.Parameter(torch.rand((1,), requires_grad=True))

    def forward(self, queries, keys, values):
        # queries和attention_weights的形状为(查询个数,“键-值”对个数)
        queries = queries.repeat_interleave(keys.shape[1]).reshape((-1, keys.shape[1]))
        self.attention_weights = nn.functional.softmax(
            -((queries - keys) * self.w)**2 / 2, dim=1)
        # values的形状为(查询个数,“键-值”对个数),计算加权和
        return torch.bmm(self.attention_weights.unsqueeze(1),
                         values.unsqueeze(-1)).reshape(-1)

训练

接下来,[将训练数据集变换为键和值]用于训练注意力模型。 在带参数的注意力汇聚模型中, 任何一个训练样本的输入都会和除自己以外的所有训练样本的“键-值”对进行计算, 从而得到其对应的预测输出。

# 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))

[训练带参数的注意力汇聚模型]时,使用平方损失函数和随机梯度下降。

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()
    animator.add(epoch + 1, float(l.sum()))
    print(f'epoch {epoch + 1}, loss {float(l.sum()):.6f}')
epoch 5, loss 14.277093

如下所示,训练完带参数的注意力汇聚模型后可以发现: 在尝试拟合带噪声的训练数据时, [预测结果绘制]的线不如之前非参数模型的平滑。

# keys的形状:(n_test,n_train),每一行包含着相同的训练输入(例如,相同的键)
keys = x_train.repeat((n_test, 1))
# value的形状:(n_test,n_train)
values = y_train.repeat((n_test, 1))
y_hat = net(x_test, keys, values).unsqueeze(1).detach()
plot_kernel_reg(y_hat)

 

为什么新的模型更不平滑了呢? 下面看一下输出结果的绘制图: 与非参数的注意力汇聚模型相比, 带参数的模型加入可学习的参数后, [曲线在注意力权重较大的区域变得更不平滑]。

d2l.show_heatmaps(net.attention_weights.unsqueeze(0).unsqueeze(0),
                  xlabel='Sorted training inputs',
                  ylabel='Sorted testing inputs')

 

小结

  • Nadaraya-Watson核回归是具有注意力机制的机器学习范例。
  • Nadaraya-Watson核回归的注意力汇聚是对训练数据中输出的加权平均。从注意力的角度来看,分配给每个值的注意力权重取决于将值所对应的键和查询作为输入的函数。
  • 注意力汇聚可以分为非参数型和带参数型。

练习

  1. 增加训练数据的样本数量,能否得到更好的非参数的Nadaraya-Watson核回归模型?

解:
增加训练数据的样本数量,并没有得到更好的非参数的NW核回归模型,因为单纯增加数据量只是增加了注意力权重矩阵的规模,但仍是由x_test和x_train的差值经softmax变换得到,模型的表达能力不够,欠拟合。代码如下:

n_train = 200
x_train, _ = torch.sort(torch.rand(n_train) * 5) 
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)  

X_repeat = x_test.repeat_interleave(n_train).reshape((-1, n_train))
attention_weights = nn.functional.softmax(-(X_repeat - x_train)**2 / 2, dim=1)
y_hat = torch.matmul(attention_weights, y_train)
plot_kernel_reg(y_hat)

 

2. 在带参数的注意力汇聚的实验中学习得到的参数 w 的价值是什么?为什么在可视化注意力权重时,它会使加权区域更加尖锐?
解:
在带参数的注意力汇聚中,学习得到的参数
 w 的价值在于它能够调整模型对输入数据的敏感度。参数 w 直接影响了注意力权重的计算,因为它是核函数中用于衡量查询和键之间相似度的权重因子。通过优化这个参数,模型可以更好地聚焦于与当前查询最相关的信息,从而提高预测的准确性。
权重的调整导致了模型对输入数据的某些部分赋予更高的关注度,因此,可视化注意力权重时,参数
 w 会使加权区域变得更加尖锐。具体而言,因为 w ​​​加在 (x - x_i) ​​​外和平方计算之内,再乘以 -\frac{1}{2} ​​​之后就相当于绝对值扩大了 -\frac{1}{2}w^2 倍左右,当 w 值较大时,模型会更加关注与查询相近的键,而忽略那些距离较远的键(softmax函数在x趋向负无穷的时候值无限趋近于0),从而达到注意力效果,使得预测结果更加准确。这种尖锐化的权重分布意味着模型的预测更多地依赖于与查询非常接近的键值对,从而使得模型能够更精确地捕捉到数据的局部特征。

3. 如何将超参数添加到非参数的Nadaraya-Watson核回归中以实现更好地预测结果?
解:
获取带参数的NW核回归网络的
 w 值,并按照公式(10.2.7)加入超参数 w 的值,即可实现非参数的Nadaraya-Watson核回归更好的预测。代码如下:

net.w

Parameter containing:
tensor([14.0799], requires_grad=True)

n_train = 50
x_train, _ = torch.sort(torch.rand(n_train) * 5) 
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)  

X_repeat = x_test.repeat_interleave(n_train).reshape((-1, n_train))
attention_weights = nn.functional.softmax(-((X_repeat - x_train)*14.0799)**2 / 2, dim=1)
y_hat = torch.matmul(attention_weights, y_train)
plot_kernel_reg(y_hat)

 

4. 为本节的核回归设计一个新的带参数的注意力汇聚模型。训练这个新模型并可视化其注意力权重。

解:
尝试利用多项式核函数,增加超参数d,是核函数的度数。

\alpha(x, x_i) y_i = \frac{((x - x_i)w)^d}{\sum_{j=1}^n ((x - x_j)w)^d} y_i

代码如下,但效果没有L2范数的好。

class PolynomialNWKernelRegression(nn.Module):
    def __init__(self, degree=2):
        super().__init__()
        self.w = nn.Parameter(torch.rand((1,)), requires_grad=True)
        self.b = nn.Parameter(torch.zeros((1,)), requires_grad=True)
        self.degree = degree

    def forward(self, queries, keys, values):
        queries = queries.repeat_interleave(keys.shape[1]).reshape((-1, keys.shape[1]))
        kernel_values = torch.exp((-0.5 * ((queries - keys) * self.w + self.b).abs().pow(self.degree)))
        self.attention_weights = nn.functional.softmax(kernel_values, dim=1)
        return torch.bmm(self.attention_weights.unsqueeze(1), values.unsqueeze(-1)).reshape(-1)
X_tile = x_train.repeat((n_train, 1))
Y_tile = y_train.repeat((n_train, 1))
keys = X_tile[(1 - torch.eye(n_train)).type(torch.bool)].reshape((n_train, -1))
values = Y_tile[(1 - torch.eye(n_train)).type(torch.bool)].reshape((n_train, -1))

net = PolynomialNWKernelRegression()
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()
    animator.add(epoch + 1, float(l.sum()))
    print(f'epoch {epoch + 1}, loss {float(l.sum()):.6f}')

输出结果:
epoch 5, loss 39.624405

# keys的形状:(n_test,n_train),每一行包含着相同的训练输入(例如,相同的键)
keys = x_train.repeat((n_test, 1))
# value的形状:(n_test,n_train)
values = y_train.repeat((n_test, 1))
y_hat = net(x_test, keys, values).unsqueeze(1).detach()
plot_kernel_reg(y_hat)

 

d2l.show_heatmaps(net.attention_weights.unsqueeze(0).unsqueeze(0),
                  xlabel='Sorted training inputs',
                  ylabel='Sorted testing inputs')

 

;