动手学深度学习(十二、自然语言处理进阶知识)

动手学深度学习(十二、自然语言处理进阶知识)

一、求近义词和类比词

  • 在大规模语料上预训练的词向量常常可以应用于下游自然语言处理任务中。
  • 可以应用预训练的词向量求近义词和类比词。

word2vec词嵌入模型,通过词向量的余弦相似度搜索近义词。实际中,在大规模语料上预训练的词向量常常可以应用到下游自然语言处理任务中。本节将演示如何用这些预训练的词向量来求近义词和类比词。我们还将在后面两节中继续应用预训练的词向量。

使用预训练的词向量

基于PyTorch的关于自然语言处理的常用包有官方的torchtext以及第三方的pytorch-nlp等等。

下面查看查看该glove词嵌入提供了哪些预训练的模型。每个模型的词向量维度可能不同,或是在不同数据集上预训练得到的。

[key for key in vocab.pretrained_aliases.keys() if "glove" in key]

 预训练的GloVe模型的命名规范大致是“模型.(数据集.)数据集词数.词向量维度”。更多信息可以参考GloVe和fastText的项目网站

第一次创建预训练词向量实例时会自动下载相应的词向量到cache指定文件夹(默认为.vector_cache),因此需要联网。

cache_dir = "/Users/tangshusen/Datasets/glove"
# glove = vocab.pretrained_aliases["glove.6B.50d"](cache=cache_dir)
glove = vocab.GloVe(name='6B', dim=50, cache=cache_dir) # 与上面等价

返回的实例主要有以下三个属性 

  • stoi: 词到索引的字典:
  • itos: 一个列表,索引到词的映射;
  • vectors: 词向量。

应用预训练的词向量

我们可以通过词来获取它在词典中的索引,也可以通过索引获取词。

求近义词

使用余弦相似度来搜索近义词的算法。为了在求类比词时重用其中的求k近邻(k-nearest neighbors)的逻辑,我们将这部分逻辑单独封装在knn函数中。

求类比词

除了求近义词以外,我们还可以使用预训练词向量求词与词之间的类比关系。例如,“man”(男人): “woman”(女人):: “son”(儿子) : “daughter”(女儿)是一个类比例子:“man”之于“woman”相当于“son”之于“daughter”。求类比词问题可以定义为:对于类比关系中的4个词 a:b :: c:d,给定前3个词a、b和c,求d。设词w的词向量为vec(w)。求类比词的思路是,搜索与vec(c) + vec(b) - vec(a)的结果向量最相似的词向量。

import torch 
import torchtext.vocab as vocab

#一、使用预训练的词向量

#print(vocab.pretrained_aliases.keys())
#下面查看查看该glove词嵌入提供了哪些预训练的模型。每个模型的词向量维度可能不同,或是在不同数据集上预训练得到的。

Vocab = [key for key in vocab.pretrained_aliases.keys() if "glove" in key]
for key in Vocab:
    print(key)
    
cache_dir = "F:\数据\MRC\动手学深度学习\glove"
glove = vocab.GloVe(name = '6B', dim = 50, cache = cache_dir)

print("一共包含%d个词。" % len(glove.stoi))
# stoi: 词到索引的字典:
# itos: 一个列表,索引到词的映射;
# vectors: 词向量。

#我们可以通过词来获取它在词典中的索引,也可以通过索引获取词。
print(glove.stoi['beautiful'], glove.itos[3366])

#二、应用预训练的词向量
#返回最相似向量的位置序号、返回两个词的余弦值
def knn(W, x, k):
    #添加的1e-9是为了数值稳定性
    cos = torch.matmul(W, x.view((-1, ))) / ( (torch.sum(W * W, dim = 1) + 1e-9).sqrt() * torch.sum(x * x).sqrt() )
    #print(cos.shape)
    _, topk = torch.topk(cos, k = k)
#     print(_)
#     print(topk)
    topk = topk.cpu().numpy()
    #print(topk)
    
    return topk, [cos[i].item() for i in topk]
    
def get_similar_tokens(query_token, k, embed):
    topk, cos = knn(embed.vectors, embed.vectors[embed.stoi[query_token]], k + 1)
    for i, c in zip(topk[1:], cos[1:]):
        print('cosine sim = %.3f : %s' % (c, (embed.itos[i])) )
        
print("*" * 50)
get_similar_tokens('chip', 3, glove)
print("*" * 50)
get_similar_tokens('baby', 3, glove)
print("*" * 50)
get_similar_tokens('beautiful', 3, glove)

print("*" * 100)

def get_analogy(token_a, token_b, token_c, embed):
    vecs = [embed.vectors[embed.stoi[t]] for t in [token_a, token_b, token_c]]
    print(len(vecs))
    #print(vecs)
    
    x = vecs[1] - vecs[0] + vecs[2]
    #print(x)
    topk, cos = knn(embed.vectors, x, 1)
    print(topk, cos)
    return embed.itos[topk[0]]

get_ans = get_analogy('man', 'woman', 'son', glove)
print(get_ans)
print('*' * 50)

get_ans = get_analogy('beijing', 'china', 'tokyo', glove)
print(get_ans)
print('*' * 50)

get_ans = get_analogy('bad', 'worst', 'big', glove)
print(get_ans)
print('*' * 50)

get_ans = get_analogy('football', 'basketball', 'eleven', glove)
print(get_ans)

输出:

glove.42B.300d
glove.840B.300d
glove.twitter.27B.25d
glove.twitter.27B.50d
glove.twitter.27B.100d
glove.twitter.27B.200d
glove.6B.50d
glove.6B.100d
glove.6B.200d
glove.6B.300d
一共包含400000个词。
3366 beautiful
**************************************************
cosine sim = 0.856 : chips
cosine sim = 0.749 : intel
cosine sim = 0.749 : electronics
**************************************************
cosine sim = 0.839 : babies
cosine sim = 0.800 : boy
cosine sim = 0.792 : girl
**************************************************
cosine sim = 0.921 : lovely
cosine sim = 0.893 : gorgeous
cosine sim = 0.830 : wonderful
****************************************************************************************************
3
[1131] [0.9658340811729431]
daughter
**************************************************
3
[361] [0.9054065942764282]
japan
**************************************************
3
[882] [0.8059625625610352]
biggest
**************************************************
3
[9154] [0.8493301868438721]
sixteen

二、文本情感分类:使用循环神经网络

  • 文本分类把一段不定长的文本序列变换为文本的类别。它属于词嵌入的下游应用。
  • 可以应用预训练的词向量和循环神经网络对文本的情感进行分类。

使用循环神经网络的模型

在这个模型中,每个词先通过嵌入层得到特征向量。然后,我们使用双向循环神经网络对特征序列进一步编码得到序列信息。最后,我们将编码的序列信息通过全连接层变换为输出。具体来说,我们可以将双向长短期记忆在最初时间步和最终时间步的隐藏状态连结,作为特征序列的表征传递给输出层分类。在下面实现的BiRNN类中,Embedding实例即嵌入层,LSTM实例即为序列编码的隐藏层,Linear实例即生成分类结果的输出层。

#2021-04-20 15:28  10.7
import collections
import os
import random
import tarfile
import torch
from torch import nn
import torchtext.vocab as Vocab
import torch.utils.data as Data

import sys
sys.path.append("F:\数据\MRC\动手学深度学习\Dive-into-DL-PyTorch-master\code")
import d2lzh_pytorch as d2l

os.environ["CUDA_VISIBLE_DEVICES"] = "0"
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

DATA_ROOT = "F:\数据\MRC\动手学深度学习"

#一、读取数据
fname = os.path.join(DATA_ROOT, "aclImdb_v1.tar.gz")
if not os.path.exists(os.path.join(DATA_ROOT, "aclImdb")):
    print("从压缩包解压缩...")
    with tarfile.open(fname, 'r') as f:
        f.extractall(DATA_ROOT)
        
from tqdm import tqdm

def read_imdb(folder = 'train', data_root = 'F:\数据\MRC\动手学深度学习'):
    data = []
#     for label in ['pos', 'neg']:
#         floder_name = os.path.join(data_root, 'aclImdb', folder, label)
#         print(floder_name)
#         for file in tqdm(os.listdir(folder_name)):#列出目标路径的文件夹以及文件
#             with open(os.path.join(floder_name, file), 'rb') as f:
#                 review = f.read().decode('utf-8').replace('\n', '').lower()
#                 data.append([review, 1 if label == 'pos' else 0])
    
#     random.shuffle(data)
#     return data

# train_data, test_data = read_imdb('train', 'F:\数据\MRC\动手学深度学习'), read_imdb('test', 'F:\数据\MRC\动手学深度学习')
    for label in ['pos', 'neg']:
        folder_name = os.path.join(data_root, 'aclImdb', folder, label)
        for file in tqdm(os.listdir(folder_name)):
            with open(os.path.join(folder_name, file), 'rb') as f:
                review = f.read().decode('utf-8').replace('\n', '').lower()
                data.append([review, 1 if label == 'pos' else 0])
    random.shuffle(data)
    return data

train_data, test_data = read_imdb('train'), read_imdb('test')
print(len(train_data), len(test_data))

#二、数据预处理
#基于空格进行分词
def get_tokenized_imdb(data):
    def tokenizer(text):
        return [tok.lower() for tok in text.split(' ')]
    return [tokenizer(review) for review, _ in data] 

#根据分好词的训练数据集来创建词典了。我们在这里过滤掉了出现次数少于5的词。
def get_vocab_imdb(data):
    tokenized_data = get_tokenized_imdb(data)
    print(len(tokenized_data))
    counter = collections.Counter([tk for st in tokenized_data for tk in st])
    print(len(counter))
    return Vocab.Vocab(counter, min_freq = 5)
#torchtext.vocab.Vocab() 建立字典的函数 #stoi 把字符映射成数字 #itos 把数字映射成字符

vocab = get_vocab_imdb(train_data)

print('# words in vocab', len(vocab))

#因为每条评论长度不一致所以不能直接组合成小批量,我们定义preprocess_imdb函数对每条评论进行分词,并通过词典转换成词索引,然后通过截断或者补0来将每条评论长度固定成500。
def preprocess_imdb(data, vocab):
    max_l = 500 #将每条评论的长度截断或者补长, 使得长度变成500
    
    def pad(x):
        return x[:max_l] if len(x) > max_l else x + [0] * (max_l - len(x))
    
    tokenized_data = get_tokenized_imdb(data)
    features = torch.tensor( [pad([vocab.stoi[word] for word in words]) for words in tokenized_data] )
    labels = torch.tensor([score for _, score in data])
    return features, labels
    
# train_data_, vocab_ = preprocess_imdb(train_data, vocab)
# print(len(train_data_), len(vocab_))
# print(train_data_[0], vocab_[0])

# #三、创建数据迭代器
batch_size = 64
train_set = Data.TensorDataset(*preprocess_imdb(train_data, vocab))
test_set = Data.TensorDataset(*preprocess_imdb(test_data, vocab))
# print(len(train_set))
# print(train_set[0][1])
train_iter = Data.DataLoader(train_set, batch_size, shuffle = True)
test_iter = Data.DataLoader(test_set, batch_size)

for X, y in train_iter:
    print('X', X.shape, 'y', y.shape)
    break
    
print('#batches:', len(train_iter))

#使用循环神经网络的模型
class BiRNN(nn.Module):
    def __init__(self, vocab, embed_size, num_hiddens, num_layers):
        super(BiRNN, self).__init__()
        #一个保存了固定字典和大小的简单查找表
        #参数: 嵌入字典的大小、每个嵌入向量的大小
        self.embedding = nn.Embedding(len(vocab), embed_size)#将每个单词训练成词向量
        
        #bidirectional设为True即得到双向循环神经网络
        self.encoder = nn.LSTM(input_size = embed_size, hidden_size = num_hiddens, num_layers = num_layers, bidirectional = True)
        
                #初始时间步 和 最终时间步 的隐藏状态 作为全连接层 输入
        self.decoder = nn.Linear(4 * num_hiddens, 2)
        
    def forward(self, inputs):
        #inputs的形状是(批量大小, 词数), 因为LSTM需要将序列长度(seq_len)作为第一维, 将输入进行转置
        embeddings = self.embedding(inputs.permute(1, 0))
        outputs, _ = self.encoder(embeddings)
        #print(outputs, _)
        
        encoding = torch.cat((outputs[0], outputs[-1]), -1)
        outs = self.decoder(encoding)
        return outs
    
embed_size, num_hiddens, num_layers = 100, 100, 2
print("*" * 100)
print(len(vocab))
net = BiRNN(vocab, embed_size, num_hiddens, num_layers)


#加载预训练的词向量
glove_vocab = Vocab.GloVe(name = '6B', dim = 100, cache = os.path.join(DATA_ROOT, "glove"))

def load_pretrained_embedding(words, pretrained_vocab):
    #从预训练好的vocab中提取出words对应的词向量
    embed = torch.zeros(len(words), pretrained_vocab.vectors[0].shape[0])#初始化为0, (词的个数、词表每个词的长度)
    
    oov_count = 0 # out of vocabulary
    for i, word in enumerate(words):
        try:
            idx = pretrained_vocab.stoi[word]
            embed[i, :] = pretrained_vocab.vectors[idx]
        except KeyError:
            oov_count += 1
    if oov_count > 0:
        print("There are %d oov words." % oov_count)
    
    return embed

net.embedding.weight.data.copy_(load_pretrained_embedding(vocab.itos, glove_vocab))

net.embedding.weight.requires_grad = False    #不需要更新它

print(net.embedding.weight.shape)

lr, num_epochs = 0.01, 5
optimizer = torch.optim.Adam(filter(lambda p : p.requires_grad, net.parameters()), lr = lr)

loss = nn.CrossEntropyLoss()

d2l.train(train_iter, test_iter, net, loss, optimizer, device, num_epochs)

# 本函数已保存在d2lzh_pytorch包中方便以后使用
def predict_sentiment(net, vocab, sentence):
    """sentence是词语的列表"""
    device = list(net.parameters())[0].device
    sentence = torch.tensor([vocab.stoi[word] for word in sentence], device=device)
    label = torch.argmax(net(sentence.view((1, -1))), dim=1)
    return 'positive' if label.item() == 1 else 'negative'


predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'great']) # positive
predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'bad']) # negative

输出:

25000 25000
25000
252192
# words in vocab 46152
X torch.Size([64, 500]) y torch.Size([64])
#batches: 391
****************************************************************************************************
46152
There are 21202 oov words.
torch.Size([46152, 100])
training on  cuda
epoch 1, loss 0.6061, train acc 0.667, test acc 0.791, time 106.7 sec
epoch 2, loss 0.2088, train acc 0.813, test acc 0.834, time 106.4 sec
epoch 3, loss 0.1201, train acc 0.843, test acc 0.842, time 106.2 sec
epoch 4, loss 0.0814, train acc 0.861, test acc 0.820, time 105.6 sec
epoch 5, loss 0.0624, train acc 0.866, test acc 0.841, time 106.1 sec
'negative'

三、文本情感分类:使用卷积神经网络

  • 可以使用一维卷积来表征时序数据。
  • 多输入通道的一维互相关运算可以看作单输入通道的二维互相关运算。
  • 时序最大池化层的输入在各个通道上的时间步数可以不同。
  • textCNN主要使用了一维卷积层和时序最大池化层。

在“卷积神经网络”一章中我们探究了如何使用二维卷积神经网络来处理二维图像数据。在之前的语言模型和文本分类任务中,我们将文本数据看作是只有一个维度的时间序列,并很自然地使用循环神经网络来表征这样的数据。其实,我们也可以将文本当作一维图像,从而可以用一维卷积神经网络来捕捉临近词之间的关联。本节将介绍将卷积神经网络应用到文本分析的开创性工作之一:textCNN

一维卷积层

时序最大池化层

textCNN中使用的时序最大池化(max-over-time pooling)层实际上对应一维全局最大池化层:假设输入包含多个通道,各通道由不同时间步上的数值组成,各通道的输出即该通道所有时间步中最大的数值。因此,时序最大池化层的输入在各个通道上的时间步数可以不同。

为提升计算性能,我们常常将不同长度的时序样本组成一个小批量,并通过在较短序列后附加特殊字符(如0)令批量中各时序样本长度相同。这些人为添加的特殊字符当然是无意义的。由于时序最大池化的主要目的是抓取时序中最重要的特征,它通常能使模型不受人为添加字符的影响。

读取和预处理IMDb数据集

textCNN模型

textCNN模型主要使用了一维卷积层和时序最大池化层。假设输入的文本序列由n个词组成,每个词用d维的词向量表示。那么输入样本的宽为n,高为1,输入通道数为d。textCNN的计算主要分为以下几步。

  1. 定义多个一维卷积核,并使用这些卷积核对输入分别做卷积计算。宽度不同的卷积核可能会捕捉到不同个数的相邻词的相关性。
  2. 对输出的所有通道分别做时序最大池化,再将这些通道的池化输出值连结为向量。
  3. 通过全连接层将连结后的向量变换为有关各类别的输出。这一步可以使用丢弃层应对过拟合。

下图用一个例子解释了textCNN的设计。这里的输入是一个有11个词的句子,每个词用6维词向量表示。因此输入序列的宽为11,输入通道数为6。给定2个一维卷积核,核宽分别为2和4,输出通道数分别设为4和5。因此,一维卷积计算后,4个输出通道的宽为11−2+1=10,而其他5个通道的宽为11−4+1=8。尽管每个通道的宽不同,我们依然可以对各个通道做时序最大池化,并将9个通道的池化输出连结成一个9维向量。最终,使用全连接将9维向量变换为2维输出,即正面情感和负面情感的预测。

#2021-04-21 22:34 10.8

import os
import torch
from torch import nn
import torchtext.vocab as Vocab
import torch.utils.data as Data
import torch.nn.functional as F

import sys
sys.path.append("F:\数据\MRC\动手学深度学习\Dive-into-DL-PyTorch-master\code")
import d2lzh_pytorch as d2l

os.environ["CUDA_VISIBLE_DEVICES"] = "0"
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')

DATA_ROOT = "F:\数据\MRC\动手学深度学习"

#一、
#一维卷积层: 输入数组X、核数组K、输出数组Y
def corr1d(X, K):
    w = K.shape[0]
    Y = torch.zeros((X.shape[0] - w + 1))
    
    for i in range(Y.shape[0]):
        Y[i] = (X[i : i + w] * K).sum()
    
    return Y

X, K = torch.tensor([0, 1, 2, 3, 4, 5, 6]), torch.tensor([1, 2])
ans = corr1d(X, K)
print(ans)

#多输入通道的一维互相关运算
def corr1d_multi_in(X, K):
    print(torch.stack([corr1d(x, k) for x, k in zip(X, K)]))
    return torch.stack([corr1d(x, k) for x, k in zip(X, K)]).sum(dim = 0)

X = torch.tensor([[0, 1, 2, 3, 4, 5, 6],
                 [1, 2, 3, 4, 5, 6, 7],
                 [2, 3, 4, 5, 6, 7, 8]])
K = torch.tensor([[1, 2], [3, 4], [-1, -3]])
ans = corr1d_multi_in(X, K)
print(ans)

#二、时序最大池化层
class GlobalMaxPool1d(nn.Module):
    def __init__(self):
        super(GlobalMaxPool1d, self).__init__()
    
    def forward(self, x):
        return F.max_pool1d(x, kernel_size = x.shape[2])
    
# x = torch.Tensor([[[1, 2, 3, 4, 5], [2, 3, 2, 5, 1]]])
# print(x.shape[2])
# pool1d = GlobalMaxPool1d()
# ans = pool1d(x)

#三、读取和预处理IMDb数据集
batch_size = 64
train_data = d2l.read_imdb('train', data_root=os.path.join(DATA_ROOT, "aclImdb"))
test_data = d2l.read_imdb('test', data_root=os.path.join(DATA_ROOT, "aclImdb"))

vocab = d2l.get_vocab_imdb(train_data)

#需要学习一下Data的使用
train_set = Data.TensorDataset(*d2l.preprocess_imdb(train_data, vocab))
test_set = Data.TensorDataset(*d2l.preprocess_imdb(test_data, vocab))
train_iter = Data.DataLoader(train_set, batch_size, shuffle=True)
test_iter = Data.DataLoader(test_set, batch_size)

#textCNN模型
class TextCNN(nn.Module):
    def __init__(self, vocab, embed_size, kernel_sizes, num_channels):
        super(TextCNN, self).__init__()
        
        self.embedding = nn.Embedding(len(vocab), embed_size)
        #不参与训练的嵌入层
        self.constant_embedding = nn.Embedding(len(vocab), embed_size)
        
        
        self.dropout = nn.Dropout(0.5)
        self.decoder = nn.Linear(sum(num_channels), 2)
        
        #时序最大池化层没有权重, 所以可以共用一个实例
        self.pool = GlobalMaxPool1d()
        self.convs = nn.ModuleList()#创建多个一维卷积层
        
        for c, k in zip(num_channels, kernel_sizes):
            self.convs.append(nn.Conv1d(in_channels = 2 * embed_size, out_channels = c, kernel_size = k))
            
    def forward(self, inputs):
        # 将两个形状是(批量大小, 词数, 词向量维度)的嵌入层的输出按词向量连结
        embeddings = torch.cat((self.embedding(inputs), self.constant_embedding(inputs)), dim = 2)#(batch, seq_len, 2 * embed_size)
        
        embeddings = embeddings.permute(0, 2, 1)
        #print(embeddings.shape)#输出  torch.Size([64, 200, 500])
        
        encoding = torch.cat([self.pool(F.relu(conv(embeddings))).squeeze(-1) for conv in self.convs], dim = 1)
        
        outputs = self.decoder(self.dropout(encoding))
        return outputs
        
#单词向量维度 100, 卷积核宽度 3, 4, 5, 输出通道数 100, 100, 100
embed_size, kernel_sizes, nums_channels = 100, [3, 4, 5], [100, 100, 100]
net = TextCNN(vocab, embed_size, kernel_sizes, nums_channels)


#4、加载预训练的词向量
glove_vocab = Vocab.GloVe(name='6B', dim=100, cache=os.path.join(DATA_ROOT, "glove"))
print("词表长度:", len(glove_vocab))
#嵌入层权重参与训练
net.embedding.weight.data.copy_(d2l.load_pretrained_embedding(vocab.itos, glove_vocab))
#嵌入层权重固定
net.constant_embedding.weight.data.copy_(d2l.load_pretrained_embedding(vocab.itos, glove_vocab))
net.constant_embedding.weight.requires_grad = False

#5、训练并评价模型
lr, num_epochs = 0.001, 5
#使用filter函数,对所有可以进行更新的参数进行更新
optimizer = torch.optim.Adam(filter(lambda p : p.requires_grad, net.parameters()), lr = lr)

loss = nn.CrossEntropyLoss()#交叉熵
d2l.train(train_iter, test_iter, net, loss, optimizer, device, num_epochs)
#训练函数 : 训练的批次循环, 在训练集上, 计算了损失函数, 梯度下降, 在测试集上进行一轮验证, 输出信息
#在测试集上验证函数:使用评估模式会关闭dropout


#6、进行预测
def predict_sentiment(net, vocab, sentence):
    """sentence是词语的列表"""
    device = list(net.parameters())[0].device
    sentence = torch.tensor([vocab.stoi[word] for word in sentence], device=device)
    label = torch.argmax(net(sentence.view((1, -1))), dim=1)
    return 'positive' if label.item() == 1 else 'negative'

predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'great']) # positive
predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'bad']) # negative

输出:

tensor([ 2.,  5.,  8., 11., 14., 17.])
tensor([[  2.,   5.,   8.,  11.,  14.,  17.],
        [ 11.,  18.,  25.,  32.,  39.,  46.],
        [-11., -15., -19., -23., -27., -31.]])
tensor([ 2.,  8., 14., 20., 26., 32.])
100%|██████████████████████████████████████████████████████████████████████████| 12500/12500 [00:01<00:00, 9639.32it/s]
100%|███████████████████████████████████████████████████████████████████████████| 12500/12500 [00:16<00:00, 744.66it/s]
100%|███████████████████████████████████████████████████████████████████████████| 12500/12500 [01:25<00:00, 145.67it/s]
100%|███████████████████████████████████████████████████████████████████████████| 12500/12500 [01:18<00:00, 159.63it/s]
词表长度: 400000
There are 21202 oov words.
There are 21202 oov words.
training on  cuda
epoch 1, loss 0.4869, train acc 0.755, test acc 0.851, time 54.1 sec
epoch 2, loss 0.1612, train acc 0.862, test acc 0.870, time 57.6 sec
epoch 3, loss 0.0699, train acc 0.916, test acc 0.867, time 57.0 sec
epoch 4, loss 0.0301, train acc 0.955, test acc 0.877, time 56.9 sec
epoch 5, loss 0.0123, train acc 0.980, test acc 0.864, time 57.1 sec
'negative'

四、编码器--解码器(seq2seq)

  • 编码器-解码器(seq2seq)可以输入并输出不定长的序列。
  • 编码器—解码器使用了两个循环神经网络。
  • 在编码器—解码器的训练中,可以采用强制教学。

当输入和输出都是不定长序列时,我们可以使用编码器—解码器(encoder-decoder)或者seq2seq模型。这两个模型本质上都用到了两个循环神经网络,分别叫做编码器和解码器。编码器用来分析输入序列,解码器用来生成输出序列。

下图描述了使用编码器—解码器将英语句子翻译成法语句子的一种方法。在训练数据集中,我们可以在每个句子后附上特殊符号“<eos>”(end of sequence)以表示序列的终止。编码器每个时间步的输入依次为英语句子中的单词、标点和特殊符号“<eos>”。下图使用了编码器在最终时间步的隐藏状态作为输入句子的表征或编码信息。解码器在各个时间步中使用输入句子的编码信息和上个时间步的输出以及隐藏状态作为输入。我们希望解码器在各个时间步能正确依次输出翻译后的法语单词、标点和特殊符号"<eos>"。需要注意的是,解码器在最初时间步的输入用到了一个表示序列开始的特殊符号"

编码器

编码器的作用是把一个不定长的输入序列变换成一个定长的背景变量c,并在该背景变量中编码输入序列信息。常用的编码器是循环神经网络。在时间步t, 循环神经网络将输入x_{t}的特征向量x_{t}和上个时间步的隐藏状态h_{t - 1}变换为当前时间步的隐藏状态h_{t}。可以使用函数f表达循环神经网络隐藏层的变换:h_{t} = f(x_{t}, h_{t- 1})

接下来, 编码器通过自定义函数q将各个时间步的隐藏状态转换为背景变量: c = q(h_{1}, ... h_{T})

以上描述的编码器是一个单向的循环神经网络,每个时间步的隐藏状态只取决于该时间步及之前的输入子序列。我们也可以使用双向循环神经网络构造编码器。在这种情况下,编码器每个时间步的隐藏状态同时取决于该时间步之前和之后的子序列(包括当前时间步的输入),并编码了整个序列的信息。

解码器

编码器输出的背景变量c编码了整个输入序列x_{1}, ..., x_{T}的信息。给定训练样本中的输出序列y_{1}, y_{2}, ... y_{T^{'}},  对每个时间步t^{'}(符号与输入序列或编码器的时间步t有区别), 解码器输出y_{t^{'}}的条件概率将基于之前的输出序列y_{1}, ... ,y_{t^{'}}和背景变量c, 即P(y_{t^{'}} | y_{1}, ... , y_{t^{'} - 1}, c)

为此,我们可以使用另一个循环神经网络作为解码器。在输出序列的时间步t^{'},解码器将上一时间步的输出y_{t^{'} - 1}​以及背景变量c作为输入,并将它们与上一时间步的隐藏状态s_{t^{'} - 1}​变换为当前时间步的隐藏状态s_{t^{'}}​。因此,我们可以用函数g表达解码器隐藏层的变换:s_{t^{'}} = g(y_{t^{'}-1}, c, s_{t^{'} - 1})

有了解码器的隐藏状态后,我们可以使用自定义的输出层和softmax运算来计算P(y_{t^{'}} | y_{1}, ..., y_{t^{'} - 1}, c),例如,基于当前时间步的解码器隐藏状态 s_{t^{'}}​、上一时间步的输出y_{t^{'} - 1}以及背景变量c来计算当前时间步输出y_{t^{'}}​的概率分布。

训练模型 

根据最大似然估计,我们可以最大化输出序列基于输入序列的条件概率

并得到该输出序列的损失 

 在模型训练中,所有输出序列损失的均值通常作为需要最小化的损失函数。在上图所描述的模型预测中,我们需要将解码器在上一个时间步的输出作为当前时间步的输入。与此不同,在训练中我们也可以将标签序列(训练集的真实输出序列)在上一个时间步的标签作为解码器在当前时间步的输入。这叫作强制教学(teacher forcing)。

五、束搜索

  • 预测不定长序列的方法包括贪婪搜索、穷举搜索和束搜索。
  • 束搜索通过灵活的束宽来权衡计算开销和搜索质量。

本节介绍如何使用编码器—解码器来预测不定长的序列。在准备训练数据集时,我们通常会在样本的输入序列和输出序列后面分别附上一个特殊符号"<eos>"表示序列的终止。我们在接下来的讨论中也将沿用上一节的全部数学符号。为了便于讨论,假设解码器的输出是一段文本序列。设输出文本词典Y(包含特殊符号"<eos>")的大小为|Y|,输出序列的最大长度为T^{'}。所有可能的输出序列一共有O(|Y|^{T^{'}})种。这些输出序列中所有特殊符号"<eos>"后面的子序列将被舍弃。

贪婪搜索

让我们先来看一个简单的解决方案:贪婪搜索(greedy search)。对于输出序列任一时间步t^{'},我们从|Y|个词中搜索出条件概率最大的词

作为输出。一旦搜索出"<eos>"符号,或者输出序列长度已经达到了最大长度T^{'},便完成输出。我们在描述解码器时提到,基于输入序列生成输出序列的条件概率是

                                                                                                                                      \prod_{t^{'} = 1}^{T^{'}} P(y_{t^{'}} | y_{1}, ... , y_{^{'} - 1}, c)

我们将该条件概率最大的输出序列称为最优输出序列。而贪婪搜索的主要问题是不能保证得到最优输出序列。

穷举搜索

如果目标是得到最优输出序列,我们可以考虑穷举搜索(exhaustive search):穷举所有可能的输出序列,输出条件概率最大的序列。虽然穷举搜索可以得到最优输出序列,但它的计算开销很容易过大。而贪婪搜索的计算开销通常显著小于穷举搜索的计算开销。

束搜索

束搜索(beam search)是对贪婪搜索的一个改进算法。它有一个束宽(beam size)超参数。我们将它设为k。在时间步1时,选取当前时间步条件概率最大的k个词,分别组成k个候选输出序列的首词。在之后的每个时间步,基于上个时间步的k个候选输出序列,从k|Y|个可能的输出序列中选取条件概率最大的k个,作为该时间步的候选输出序列。最终,我们从各个时间步的候选输出序列中筛选出包含特殊符号“<eos>”的序列,并将它们中所有特殊符号“<eos>”后面的子序列舍弃,得到最终候选输出序列的集合。

 我们将根据这6个序列得出最终候选输出序列的集合。在最终候选输出序列的集合中,我们取以下分数最高的序列作为输出序列:

 其中L为最终候选序列长度,\alpha一般可选为0.75。分母上的L^{\alpha }是为了惩罚较长序列在以上分数中较多的对数相加项。分析可知,束搜索的计算开销为O(k|Y|T^{'})。这介于贪婪搜索和穷举搜索的计算开销之间。此外,贪婪搜索可看作是束宽为1的束搜索。束搜索通过灵活的束宽k来权衡计算开销和搜索质量。

六、注意力机制

  • 可以在解码器的每个时间步使用不同的背景变量,并对输入序列中不同时间步编码的信息分配不同的注意力。
  • 广义上,注意力机制的输入包括查询项以及一一对应的键项和值项。
  • 注意力机制可以采用更为高效的矢量化计算。

在(编码器—解码器(seq2seq))里,解码器在各个时间步依赖相同的背景变量来获取输入序列信息。当编码器为循环神经网络时,背景变量来自它最终时间步的隐藏状态。在解码器的每一时间步对输入序列中不同时间步的表征或编码信息分配不同的注意力一样。这也是注意力机制的由来。

仍然以循环神经网络为例,注意力机制通过对编码器所有时间步的隐藏状态做加权平均来得到背景变量。解码器在每一时间步调整这些权重,即注意力权重,从而能够在不同时间步分别关注输入序列中的不同部分并编码进相应时间步的背景变量。本节我们将讨论注意力机制是怎么工作的。在注意力机制中,解码器的每一时间步将使用可变的背景变量。记c_{t^{'}}是解码器在时间步t^{'}的背景变量,那么解码器在该时间步的隐藏状态可以改写为 s_{t^{'}} = g(y_{t^{'}-1}, c_{t^{'}}, s_{t^{'} - 1}), 这里的关键是如何计算背景变量c_{t^{'}}和如何利用它来更新隐藏状态s_{t^{'}}​。

计算背景变量

我们先描述第一个关键点,即计算背景变量。下图描绘了注意力机制如何为解码器在时间步2计算背景变量。首先,函数a根据解码器在时间步1的隐藏状态和编码器在各个时间步的隐藏状态计算softmax运算的输入。softmax运算输出概率分布并对编码器各个时间步的隐藏状态做加权平均,从而得到背景变量。

 具体来说,令编码器在时间步t的隐藏状态为h_{t}​,且总时间步数为T。那么解码器在时间步t^{'}的背景变量为所有编码器隐藏状态的加权平均:

 由于e_{t^{'}t}​同时取决于解码器的时间步t^{'}和编码器的时间步t, 我们不妨以解码器在时间步t^{'} - 1的隐藏状态s_{t^{'} - 1}与编码器在时间步t的隐藏状态h_{t}​为输入,并通过函数a计算e_{t^{'}t}​:

 这里函数a有多种选择,如果两个输入向量长度相同,一个简单的选择是计算它们的内积, 而最早提出注意力机制的论文则将输入连结后通过含单隐藏层的多层感知机变换

 矢量化计算

我们还可以对注意力机制采用更高效的矢量化计算。广义上,注意力机制的输入包括查询项以及一一对应的键项和值项,其中值项是需要加权平均的一组项。在加权平均中,值项的权重来自查询项以及与该值项对应的键项的计算。在上面的例子中,查询项为解码器的隐藏状态,键项和值项均为编码器的隐藏状态。此时,我们只需要通过矢量化计算 

 

(Q维度 1 * h, K, V维度 T* h, 最后输出为 1 * h)算出转置后的背景向量。当查询项矩阵Q的行数为n时, 上式将得到n行的输出矩阵。输出矩阵与查询矩阵在相同行上一一对应。

更新隐藏状态

现在我们描述第二个关键点,即更新隐藏状态。以门控循环单元为例,在解码器中我们可以对(门控循环单元(GRU))中门控循环单元的设计稍作修改,其中的重置门、更新门和候选隐藏状态分别为

发展

本质上,注意力机制能够为表征中较有价值的部分分配较多的计算资源。这个有趣的想法自提出后得到了快速发展,特别是启发了依靠注意力机制来编码输入序列并解码出输出序列的变换器(Transformer)模型的设计。Transformer抛弃了卷积神经网络和循环神经网络的架构。它在计算效率上比基于循环神经网络的编码器—解码器模型通常更具明显优势。含注意力机制的Transformer的编码结构在后来的BERT预训练模型中得以应用并令后者大放异彩:微调后的模型在多达11项自然语言处理任务中取得了当时最先进的结果。不久后,同样是基于变换器设计的GPT-2模型于新收集的语料数据集预训练后,在7个未参与训练的语言模型数据集上均取得了当时最先进的结果。除了自然语言处理领域,注意力机制还被广泛用于图像分类、自动图像描述、唇语解读以及语音识别。

七、机器翻译(略)

  • 可以将编码器—解码器和注意力机制应用于机器翻译中。
  • BLEU可以用来评价翻译结果。

猜你喜欢

转载自blog.csdn.net/jiangchao98/article/details/115874132
今日推荐