【动手学深度学习】笔记(NLP篇)

【动手学深度学习】笔记(NLP篇)

oyxy2019 326 2023-04-01

NLP篇

【51 序列模型【动手学深度学习v2】】 https://www.bilibili.com/video/BV1L44y1m768

第八章-循环神经网络RNN

RNN概念

隐状态:在给定步骤所做的任何事情(以技术⻆度来定义)的输⼊,并且这些状态只能通过先前时间步的数据来计算。

循环神经网络(recurrent neural networks,RNNs)是具有隐状态的神经网络。

1. 有隐状态的循环神经网络

与多层感知机不同的是,我们在这里保存了上一个时间步的隐变量Ht1\mathbf{H}_{t-1},并引入了一个新的权重参数WhhRh×h\mathbf{W}_{hh} \in \mathbb{R}^{h \times h},来描述如何在当前时间步中使用上一个时间步的隐变量。当前时间步隐藏变量Ht\mathbf{H}_{t}为:

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

与多层感知机相比,这里多添加了一项Ht1Whh\mathbf{H}_{t-1} \mathbf{W}_{hh}。这些变量捕获并保留了序列直到其当前时间步的历史信息,就如当前时间步下神经网络的状态或记忆,因此这样的隐变量被称为隐状态

下图展示了RNN在三个相邻时间步的计算逻辑。

在任意时间步t,隐状态的计算可以被视为:

  1. 拼接【当前时间步t的输入XtX_t】和【前一时间步t−1的隐状态Ht1\mathbf{H}_{t-1}】;
  2. 将拼接的结果送入【带有激活函数ϕ的全连接层】。全连接层的输出是【当前时间步t的隐状态Ht】。
矩阵计算

下面计算公式中的XtWxh+Ht1Whh\mathbf{X}_t \mathbf{W}_{xh} + \mathbf{H}_{t-1} \mathbf{W}_{hh},有两种方式:

假设X、W_xh、H、W_hh形状为:

X, W_xh = torch.normal(0, 1, (3, 1)), torch.normal(0, 1, (1, 4))
H, W_hh = torch.normal(0, 1, (3, 4)), torch.normal(0, 1, (4, 4))

方式1:

torch.matmul(X, W_xh) + torch.matmul(H, W_hh)

方式2:

torch.matmul(torch.cat((X, H), 1), torch.cat((W_xh, W_hh), 0))

这两种方式的计算结果是一样的。思考一下为什么?

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

下图演示了如何通过基于字符级语言建模的循环神经网络,使用当前的和先前的字符预测下一个字符。每个词元都由一个d维向量表示。

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

3. 困惑度(Perplexity)

困惑度用来度量语言模型的质量,用于评估基于RNN的模型。

根据信息论,如果想要压缩文本,我们可以根据当前词元集预测的下一个词元。一个更好的语言模型应该能让我们更准确地预测下一个词元。因此,它应该允许我们在压缩序列时花费更少的比特。所以我们可以通过一个序列中所有的n个词元的交叉熵损失的平均值来衡量:

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

  • P是语言模型的预测概率,xt是真实词。
  • 由于历史原因,困惑度被定义为上式的指数,即exp(π)exp(\pi)
  • 困惑度的好处是使得不同长度的文档的性能具有了可比性。
  • 在最好的情况下,模型的困惑度趋于1,反之趋于无穷大。

RNN的简洁实现

0. 加载时光机器数据集:

import torch
from torch import nn
from torch.nn import functional as F
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. 定义模型

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

num_hiddens = 256
rnn_layer = nn.RNN(len(vocab), num_hiddens)

初始化隐状态,它的形状是(隐藏层数,批量大小,隐藏单元数)。

state = torch.zeros((1, batch_size, num_hiddens))		# torch.Size([1, 32, 256])

通过一个隐状态state和一个输入X,我们就可以用更新后的隐状态计算输出。需要强调的是,rnn_layer的“输出”(Y)不涉及输出层的计算: 它是指每个时间步的隐状态,这些隐状态可以用作后续输出层的输入。

X = torch.rand(size=(num_steps, batch_size, len(vocab)))
Y, state_new = rnn_layer(X, state)		# (torch.Size([35, 32, 256]), torch.Size([1, 32, 256]))

一个完整的循环神经网络模型定义如下,rnn_layer只包含隐藏的循环层,我们还需要创建一个单独的输出层。

#@save
class RNNModel(nn.Module):
    """循环神经网络模型"""
    def __init__(self, rnn_layer, vocab_size, **kwargs):
        super(RNNModel, self).__init__(**kwargs)
        self.rnn = rnn_layer
        self.vocab_size = vocab_size
        self.num_hiddens = self.rnn.hidden_size
        # 如果RNN是双向的(之后将介绍),num_directions应该是2,否则应该是1
        if not self.rnn.bidirectional:
            self.num_directions = 1
            self.linear = nn.Linear(self.num_hiddens, self.vocab_size)
        else:
            self.num_directions = 2
            self.linear = nn.Linear(self.num_hiddens * 2, self.vocab_size)

    def forward(self, inputs, state):
        X = F.one_hot(inputs.T.long(), self.vocab_size)
        X = X.to(torch.float32)
        Y, state = self.rnn(X, state)
        # 全连接层首先将Y的形状改为(时间步数*批量大小,隐藏单元数)
        # 它的输出形状是(时间步数*批量大小,词表大小)。
        output = self.linear(Y.reshape((-1, Y.shape[-1])))
        return output, state

    def begin_state(self, device, batch_size=1):
        if not isinstance(self.rnn, nn.LSTM):
            # nn.GRU以张量作为隐状态
            return  torch.zeros((self.num_directions * self.rnn.num_layers,
                                 batch_size, self.num_hiddens),
                                device=device)
        else:
            # nn.LSTM以元组作为隐状态
            return (torch.zeros((
                self.num_directions * self.rnn.num_layers,
                batch_size, self.num_hiddens), device=device),
                    torch.zeros((
                        self.num_directions * self.rnn.num_layers,
                        batch_size, self.num_hiddens), device=device))

2. 训练与预测

在训练模型之前,让我们基于一个具有随机权重的模型进行预测。

device = d2l.try_gpu()
net = RNNModel(rnn_layer, vocab_size=len(vocab))
net = net.to(device)
d2l.predict_ch8('time traveller', 10, net, vocab, device)

接下来,我们使用高级API训练模型。

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

perplexity 1.3, 286908.2 tokens/sec on cuda:0
time traveller came the time traveller but now you begin to spen
traveller pork acong wa canome precable thig thit lepanchat

第九章-现代循环神经网络

门控循环单元(GRU)

重置门更新门的计算如下,他们的维度都是(样本个数n×隐藏单元个数h),参数可学习。使用sigmoid函数将输入值转换到区间(0,1)。

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

  • 符号\odot表示,两个向量按位相乘

  • 当重置门Rt中的项接近1时,就恢复成普通的RNN。当重置门Rt中的项接近0时,候选隐状态是以Xt作为输入的多层感知机的输出。

隐状态

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

  • 当更新门Zt接近1时,模型就倾向只保留旧状态。此时,来自Xt的信息基本上被忽略跳过。相反,当Zt接近0时,新的隐状态Ht就会接近候选隐状态。

  • 重置门有助于捕获序列中的短期依赖关系。重置门允许我们控制“可能还想记住”的过去状态的数量。
  • 更新门有助于捕获序列中的长期依赖关系。更新门允许我们控制新状态中有多少个是旧状态的副本。

GRU简洁实现:

gru_layer = rnn.GRU(num_hiddens)
model = d2l.RNNModel(gru_layer, len(vocab))
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

长短期记忆网络(LSTM)

未完,先跳过

LSTM简洁实现:

lstm_layer = rnn.LSTM(num_hiddens)
model = d2l.RNNModel(lstm_layer, len(vocab))
d2l.train_ch8(model, train_iter, vocab, lr, num_epochs, device)

深度循环神经网络

双向循环神经网络

机器翻译数据集

机器翻译指的是将文本序列从一种语言自动翻译成另一种语言。

通过截断和填充文本序列,可以保证所有的文本序列都具有相同的长度,以便以小批量的方式加载。

“英语-法语”数据集中的第一个batch示例:

train_iter, src_vocab, tgt_vocab = load_data_nmt(batch_size=2, num_steps=8)
for X, X_valid_len, Y, Y_valid_len in train_iter:
    print('X:', X.type(torch.int32))
    print('X的有效长度:', X_valid_len)
    print('Y:', Y.type(torch.int32))
    print('Y的有效长度:', Y_valid_len)
    break

X: tensor([[ 6, 143, 4, 3, 1, 1, 1, 1],
[ 54, 5, 3, 1, 1, 1, 1, 1]], dtype=torch.int32)
X的有效长度: tensor([4, 3])
Y: tensor([[ 6, 0, 4, 3, 1, 1, 1, 1],
[93, 5, 3, 1, 1, 1, 1, 1]], dtype=torch.int32)
Y的有效长度: tensor([4, 3])

编码器-解码器架构

如上节所述,机器翻译是序列转换模型的一个核心问题,其输入和输出都是长度可变的序列。为了处理这种类型的输入和输出,我们可以将模型概括为一个包含两个主要组件的架构:

  • 编码器(encoder):它接受一个长度可变的序列作为输入,并将其转换为具有固定形状的编码状态。
  • 解码器(decoder):它将固定形状的编码状态映射到长度可变的序列。

这被称为编码器-解码器(encoder-decoder)架构,如下图所示。

img

代码实现

from torch import nn


class Encoder(nn.Module):
    """编码器接口"""
    def __init__(self, **kwargs):
        super(Encoder, self).__init__(**kwargs)

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

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

序列到序列学习(seq2seq)

本节,我们将使用两个循环神经网络的编码器和解码器, 并将其应用于**序列到序列(seq2seq)**类的学习任务。

遵循encoder-decoder的设计原则, encoder使用长度可变的序列作为输入,将其转换为固定形状的隐状态。 换句话说,输入序列的信息被encode到encoder的隐状态中。 为了连续生成输出序列的词元, decoder是基于输入序列的编码信息和输出序列已经看见的或者生成的词元来预测下一个词元。下图演示了如何在机器翻译中使用两个循环神经网络进行序列到序列学习。

  • ""表示序列结束词元(end of seq),一旦输出序列生成它,模型就会停止预测。
  • 在decoder的初始化时间步,有两个特定的设计决定:
    • 首先,""表示序列开始词元(begin of seq)。
    • 其次,使用encoder最终的隐状态来初始化decoder的隐状态。

1. 编码器

现在,让我们实现循环神经网络编码器。注意,我们使用了嵌入层来获得输入序列中每个词元的embedding。嵌入层的权重W是一个矩阵,形状为 输入词表的大小vocab_size×特征向量的维度embed_size。对于任意输入词元的索引i,获取W的第i行以返回其embedding。 另外,本文选择了一个多层门控循环单元来实现编码器。

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)
        # 在循环神经网络模型中,第一个轴应为时间步,permute()函数可以将Tensor的维度重新排列
        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

下面,实例化上述编码器:我们使用一个两层门控循环单元编码器,其隐藏单元数为16。给定一小批量的输入序列X(批量大小为4,时间步为7)。 在完成所有时间步后, 最后一层的隐状态的输出是一个张量(output由编码器的循环层返回,编码器不需要输出层),其形状为(时间步数,批量大小,隐藏单元数)。

encoder = Seq2SeqEncoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
encoder.eval()		# 预测前停止dropout
X = torch.zeros((4, 7), dtype=torch.long)
output, state = encoder(X)
output.shape		# torch.Size([7, 4, 16])
state.shape			# torch.Size([2, 4, 16])

2. 解码器

当实现解码器时,我们直接使用编码器最后一个时间步的隐状态来初始化解码器的隐状态。 这就要求使用循环神经网络实现的编码器和解码器具有相同数量的层和隐藏单元。为了进一步包含经过编码的输入序列的信息, 上下文变量在所有的时间步与解码器的输入进行拼接(concatenate)。 为了预测输出词元的概率分布, 在循环神经网络解码器的最后一层使用全连接层来变换隐状态。

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

实例化解码器:

decoder = Seq2SeqDecoder(vocab_size=10, embed_size=8, num_hiddens=16, num_layers=2)
decoder.eval()
state = decoder.init_state(encoder(X))
output, state = decoder(X, state)
output.shape		# torch.Size([4, 7, 10])
state.shape			# torch.Size([2, 4, 16])

3. 损失函数

在每个时间步,解码器预测了输出词元的概率分布。类似于语言模型,可以使用softmax来获得分布, 并通过计算交叉熵损失函数来进行优化。回想一下之前 , 我们将特定的填充词元添加到序列的末尾,因此使得不同长度的序列可以以相同形状的小批量加载。 但是,我们应该将填充词元的预测排除在损失函数的计算之外。

零值化屏蔽不相关的项:

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]])
X = sequence_mask(X, torch.tensor([1, 2]))		# X屏蔽后变为tensor([[1, 0, 0], [4, 5, 0]])

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

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

代码健全性检查:

loss = MaskedSoftmaxCELoss()
loss(torch.ones(3, 4, 10), torch.ones((3, 4), dtype=torch.long), torch.tensor([4, 2, 0]))

tensor([2.3026, 1.1513, 0.0000])

4. 训练

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

loss 0.019, 11451.2 tokens/sec on cuda:0

5. 预测

为了采用一个接着一个词元的方式预测输出序列, 每个解码器【当前时间步的输入】都将来自于【前一时间步的预测词元】。“” 在初始时间步被输入到解码器中。预测过程如下图所示,当输出序列的预测遇到""时,预测结束。

下一节将介绍不同的序列生成策略。

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

6. 评估

  • 对于预测序列中的任意n元语法(n-grams),BLEU评估是这个n元语法是否出现在标签序列中。
    • 例如:标签序列 ABCDEF 和预测序列 ABBCD ,有:
    • p1 = 4/5, p2 = 3/4, p3 = 1/3, p4 = 0.
  • BLEU(bilingual evaluation understudy)定义:

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越大越好,最大值为1。
  • BLEU的代码实现如下:
def bleu(pred_seq, label_seq, k):
    """计算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

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, attention_weight_seq = predict_seq2seq(
        net, eng, src_vocab, tgt_vocab, num_steps, device)
    print(f'{eng} => {translation}, bleu {bleu(translation, fra, k=2):.3f}')

go . => va !, bleu 1.000
i lost . => j’ai perdu ., bleu 1.000
he’s calm . => il est bon ?, bleu 0.537
i’m home . => je suis chez moi debout ., bleu 0.803

束搜索

序列搜索策略包括贪心搜索穷举搜索束搜索

  • 贪心搜索(greedy search)所选取序列的计算量最小,但精度相对较低。
  • 穷举搜索(exhaustive search)所选取序列的精度最高,但计算量最大。
  • 束搜索(beam search)通过灵活选择束宽,在正确率和计算代价之间进行权衡。

1. 贪心搜索

贪心搜索是我们之前用的策略,即每个时间步,都选取概率最大的那个词输出。但这并不一定是最佳序列。

2. 穷举搜索

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

3. 束搜索

束搜索则介于以上两种搜索之间。 它有一个超参数,名为束宽(beam size)k

  • 时间复杂度:O(kYT)\mathcal{O}(k\left|\mathcal{Y}\right|T'),束宽、词表大小、时间步数
  • 束搜索在每次搜索时保存k个最好的候选
    • k = 1 时退化为贪心搜索
    • k = n 时退化为穷举搜索

第十章-注意力机制

注意力机制

注意力机制通过注意力汇聚(attention pooling)将查询query(自主性提示)和键key(非自主性提示)结合在一起,实现对值value(感官输入)的选择倾向(加权)。

  • q:
  • k:
  • v:

关于注意力机制的一个理解:

1. 平均汇聚

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

2. 非参数注意力汇聚

早在60年代,Nadaraya-Watson核回归就被提出:

f(x)=i=1nK(xxi)j=1nK(xxj)yif(x) = \sum_{i=1}^n \frac{K(x - x_i)}{\sum_{j=1}^n K(x - x_j)} y_i

其中K函数是核kernel;x是query,xi是key,yi是value。

如果核是一个高斯核,那么公式会变为带有softmax:

\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}

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

3. 带参数注意力汇聚

可以在【查询x】和【键xi】之间的距离上,乘以可学习的参数w:

\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}

注意力评分函数

下图说明,如何将注意力汇聚的输出计算成为值的加权和, 其中a表示注意力评分函数。

注意力汇聚函数f被表示成值的加权和:

f(q,(k1,v1),,(km,vm))=i=1mα(q,ki)viRv,f(\mathbf{q}, (\mathbf{k}_1, \mathbf{v}_1), \ldots, (\mathbf{k}_m, \mathbf{v}_m)) = \sum_{i=1}^m \alpha(\mathbf{q}, \mathbf{k}_i) \mathbf{v}_i \in \mathbb{R}^v,

其中查询q和键ki的注意力权重(标量) 是通过注意力评分函数a将两个向量映射成标量, 再经过softmax运算得到的:

α(q,ki)=softmax(a(q,ki))=exp(a(q,ki))j=1mexp(a(q,kj))R.\alpha(\mathbf{q}, \mathbf{k}_i) = \mathrm{softmax}(a(\mathbf{q}, \mathbf{k}_i)) = \frac{\exp(a(\mathbf{q}, \mathbf{k}_i))}{\sum_{j=1}^m \exp(a(\mathbf{q}, \mathbf{k}_j))} \in \mathbb{R}.

选择不同的注意力评分函数a会导致不同的注意力汇聚操作。

1. 掩蔽softmax操作

为了仅将有意义的词元作为值来获取注意力汇聚, 可以指定一个有效序列长度(即词元的个数), 以便在计算softmax时过滤掉超出指定范围的位置。 由masked_softmax()函数来实现。

2. 加性注意力

a(q,k)=wvtanh(Wqq+Wkk)R,a(\mathbf q, \mathbf k) = \mathbf w_v^\top \text{tanh}(\mathbf W_q\mathbf q + \mathbf W_k \mathbf k) \in \mathbb{R},

3. 缩放点积注意力

a(q,k)=qk/d.a(\mathbf q, \mathbf k) = \mathbf{q}^\top \mathbf{k} /\sqrt{d}.

当查询和键是不同长度的矢量时,可以使用可加性注意力评分函数。当它们的长度相同时,使用缩放点积注意力评分函数的计算效率更高。

多头注意力

自注意力

由于q、k、v来自于同一输入I(q=W_q·I、k=W_k·I、v=W_v·I),因此被称为 自注意力(self-attention)。

1. self-attention与CNN/RNN对比

这里引用李宏毅老师的话来说:

  • CNN是self-attention的特例,是self-attention的子集,但self-attention泛化更强假设更少以致于需要更多的数据来训练
  • RNN把信息压缩到隐状态里,不能平行处理,self-attention可以平行处理序列,把序列里信息一次抽取出来。

2. 位置编码

为了使用序列的顺序信息,可以通过在输入表示中添加位置编码(positional encoding),来注入绝对的或相对的位置信息。

位置编码可以通过学习得到也可以直接固定得到。 接下来描述的是基于正弦函数和余弦函数的固定位置编码。

假设输入表示X∈Rn×d 包含一个序列中n个词元的d维嵌入表示。 位置编码使用相同形状的位置嵌入矩阵 P∈Rn×d输出X+P, 矩阵第i行、第2j列和2j+1列上的元素为:

\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}

2.1 绝对位置信息
2.2 相对位置信息

Transformer

【台大李宏毅自注意力机制和Transformer详解】
【68 Transformer动手学深度学习v2】
【Transformer论文李沐逐段精读】
【Transformer注释(Pytorch版)】
【官方源码仓库(TensorFlow版)】

Transformer模型完全基于self-attention机制,没有任何CNN层或RNN层。尽管一开始只是应用在seq2seq机器翻译上,但但现在已经推广深度学习的各个领域中。

图中概述了Transformer的架构。

  • Transformer的编码器是由多个相同的层叠加而成的,每个层都有两个子层(sublayer):

    • 第一个子层是多头自注意力(multi-head self-attention)

    • 第二个子层是基于位置的前馈网络(positionwise feed-forward network)

    • 具体来说,在计算编码器的自注意力时,q、k、v都来自前一个编码器层的输出。受残差网络的启发,每个子层都采用了残差连接(residual connection)。在Transformer中,对于序列中任何位置的任何输入x∈Rd,都要求满足sublayer(x)∈Rd,以便残差连接满足x+sublayer(x)∈Rd。

    • 在残差连接的加法计算之后,紧接着应用层规范化(layer normalization)。因此,输入序列对应的每个位置,Transformer编码器都将输出一个d维表示向量。

  • Transformer解码器也是由多个相同的层叠加而成的,并且层中使用了残差连接和层规范化。除了编码器中描述的两个子层之外,解码器还在这两个子层之间插入了第三个子层,称为编码器-解码器注意力(encoder-decoder attention)层。在编码器-解码器注意力中,查询来自前一个解码器层的输出,而键和值来自整个编码器的输出。在解码器自注意力中,查询、键和值都来自上一个解码器层的输出。但是,解码器中的每个位置只能考虑该位置之前的所有位置。这种掩蔽(masked)注意力保留了自回归(auto-regressive)属性,确保预测仅依赖于已生成的输出词元。

第14章 自然语言处理:预训练

BERT

缩写:(Bidirectional Encoder Representations from Transformers)

【BERT 论文逐段精读【论文精读】】

NPL的迁移学习——预训练

  • 使用预训练好的模型来抽取特征
  • 不更新或微调参数
  • 改变输出层以适应新任务

BERT架构

  • BERT保留了Transformer的编码器部分,将解码器部分砍掉(故意的还是不小心的)
  • BERT的模型更大,两个版本如下:
    • Base: blocks=12, hidden_size=768, heads=12, Parameter=1亿
    • Large: blocks=24, hidden_size=1024, heads=16, Parameter=3亿
  • 训练数据更多,训练集大小达30亿词

BERT对输入的修改

BERT的输入序列可以是一个文本序列或两个文本序列。将两个文本拼起来然后embedding作为输入。

  • 词元嵌入,特殊词元:

    • <cls>特殊类别词元,加在开头

    • <sep>特殊分隔词元,加在文本序列之间

  • 段嵌入:为了区分文本对,不同文本序列再加上eA、eB的段嵌入

  • 位置嵌入:BERT的位置编码可学习

总之,BERT输入序列的embedding是三者的和。

预训练任务

预训练时,如何构造训练集,如何计算BERT的损失函数?所以,预训练包括以下两个任务:掩蔽语言模型(Masked Language Modeling)和下一句预测(Next Sentence Prediction)。

1. 掩蔽语言模型
  • 标准语言模型使用左侧的上下文预测下一个词元,是单向的。但是,为了双向编码上下文以表示每个词元,BERT随机掩蔽词元并使用来自双向上下文的词元以自监督的方式预测掩蔽词元(类似做完形填空)。

  • 带掩码的语言模型每次随机(15%概率)将一些词元换成<mask>

  • 然而,因为微调任务中不会出现<mask>

    • 80%概率下,将选中的词元变成<mask>
    • 10%概率下,换成一个随机词元
    • 10%概率下,保持原有的词元
2. 下一句预测

为了建模文本对之间的逻辑关系。预训练任务是:预测一个句子对中,两个句子是否相邻(二分类问题)。

  • 训练样本构造:
    • 50%概率选择相邻句子对(正样本)
    • 50%概率选择随机句子对(负样本)

值得注意的是,不同于计算机视觉任务,上述两个预训练任务中的所有标签都可以从预训练语料库中获得,而无需人工标注。

BERT微调

微调即将预训练好的BERT模型参数拿过来,针对不同下游任务,增加额外输出层或其他额外参数,除了这类参数需要从零开始训练外,BERT的参数只需要通过训练微调。

自然语言处理应用大致可分为【序列级】和【词元级】。

  • 单文本 分类:将单个文本序列作为输入,并输出其分类结果。(例如,情感分析和测试语言可接受性)
  • 文本对 分类或回归:比较两句话的相似度(例如,自然语言推断和语义文本相似性)
  • 文本标记:为每个单词分配词性标记(例如,词性标记)
  • 问答:(我更倾向于叫做阅读理解)由阅读段落和问题组成,其中每个问题的答案只是段落中的一段文本。

自然语言推断与数据集

自然语言推断(natural language inference)主要研究假设(hypothesis)是否可以从前提(premise)中推断出来, 其中两者都是文本序列。 换言之,自然语言推断决定了一对文本序列之间的逻辑关系。这类关系通常分为三种类型:

蕴涵(entailment):假设可以从前提中推断出来。例如:

  • 前提:两个女人拥抱在一起。
  • 假设:两个女人在示爱。

矛盾(contradiction):假设的否定可以从前提中推断出来。例如:

  • 前提:一名男子正在运行代码。
  • 假设:该男子正在睡觉。

中性(neutral):所有其他情况。例如:

  • 前提:音乐家们正在为我们表演。
  • 假设:音乐家很有名。

斯坦福自然语言推断语料库(Stanford Natural Language Inference,SNLI)是由500000多个带标签的英语句子对组成的集合。下面打印前3对前提和假设,以及它们的标签(“0”“1”和“2”分别对应于“蕴涵”“矛盾”和“中性”):

前提: A person on a horse jumps over a broken down airplane .
假设: A person is training his horse for a competition .
标签: 2
前提: A person on a horse jumps over a broken down airplane .
假设: A person is at a diner , ordering an omelette .
标签: 1
前提: A person on a horse jumps over a broken down airplane .
假设: A person is outdoors , on a horse .
标签: 0

微调

用于自然语言推断的微调BERT只需要一个额外的多层感知机,该多层感知机由两个全连接层组成。

load_state_dict()

很多地方偷懒省略了,到后面代码越来越多,这里相当于列个提纲,总之理解最重要吧…