PyTorch文本:04.深度学习NLP中使用Pytorch

一、使用PyTorch进行深度学习

1.深度学习构建模块:仿射变换, 非线性函数以及目标函数

深度学习表现为使用更巧妙的方法将线性函数和非线性函数进行组合。非线性函数的引入使得训练出来的模型更加强大。在本节中,我们将学 习这些核心组件,建立目标函数,并理解模型是如何构建的。

1.1 仿射变换

PyTorch以及大多数的深度学习框架所做的事情都与传统的线性代数有些不同。它的映射输入是行而不是列。也就是说,下面代码输出的第i行 是输入的第i行进行A变换,并加上偏移项的结果。看下面的例子:

# Author: Robert Guthrie
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
torch.manual_seed(1)
lin = nn.Linear(5, 3)  # maps from R^5 to R^3, parameters A, b
# data is 2x5.  A maps from 5 to 3... can we map "data" under A?
data = torch.randn(2, 5)
print(lin(data))  # yes

输出结果:

tensor([[ 0.1755, -0.3268, -0.5069],
        [-0.6602,  0.2260,  0.1089]], grad_fn=<AddmmBackward>)

1.2 非线性函数

由此可以看出,使用以上方法将多个仿射变换组合成的长链式的神经网络,相对于单个仿射变换并没有性能上的提升。

但是如果我们在两个仿射变换之间引入非线性,那么结果就大不一样了,我们可以构建出一个高性能的模型。

注意:尽管你可能在AI课程的介绍中学习了一些神经网络,在这些神经网络中是默认非线性的,但是通常 在实际使用的过程中都会避开它们。这是因为当参数的绝对值增长时,梯度会很快消失。小梯度意味着很难学习。因此大部分人默认选择 tanh或者ReLU。

# 在pytorch中,大多数非线性都在torch.函数中(我们将它导入为F)
# 请注意,非线性通常没有像仿射图那样的参数。
# 也就是说,他们没有在训练期间更新的权重。
data = torch.randn(2, 2)
print(data)
print(F.relu(data))

输出结果:

tensor([[-0.5404, -2.2102],
        [ 2.1130, -0.0040]])
tensor([[0.0000, 0.0000],
        [2.1130, 0.0000]])

1.3 Softmax和概率

Softmax(x)也是一个非线性函数,但它的特殊之处在于,它通常是神经网络的最后一个操作。这是因为它接受实数向量,并且返回一个概率分 布。它的定义如下。设x为实数向量(正、负,无论什么,没有约束)。然后Softmax(x)的第i个分量是:

很明显,输出的是一个概率分布:每一个元素都非负且和为1。

你也可以认为这只是一个对输入的元素进行的求幂运算符,使所有的内容都非负,然后除以规范化常量。

# Softmax也在torch.nn.functional中
data = torch.randn(5)
print(data)
print(F.softmax(data, dim=0))
print(F.softmax(data, dim=0).sum())  # 总和为1,因为它是一个分布!
print(F.log_softmax(data, dim=0))  # theres also log_softmax

输出结果:

tensor([ 1.3800, -1.3505,  0.3455,  0.5046,  1.8213])
tensor([0.2948, 0.0192, 0.1048, 0.1228, 0.4584])
tensor(1.)
tensor([-1.2214, -3.9519, -2.2560, -2.0969, -0.7801])

1.4 目标函数

目标函数正是神经网络通过训练来最小化的函数(因此,它常常被称作损失函数或者成本函数)。这需要首先选择一个训练数据实例,通过神 经网络运行它并计算输出的损失。然后通过损失函数的导数来更新模型的参数。因此直观来讲,如果它的结果是错误的,而模型完全信任他, 那么损失将会很高。反之,当模型信任计算结果而结果正确时,损失会很低。

在你的训练实例中最小化损失函数的目的是使你的网络拥有很好的泛化能力,可以在开发数据集,测试数据集以及实际生产中拥有很小的损失。 损失函数的一个例子是负对数似然损失函数,这个函数经常在多级分类中出现。在监督多级分类中,这意味着训练网络最小化正确输出的负对 数概率(等效的于最大化正确输出的对数概率)。

2.优化和训练

那么,我们该怎么计算函数实例的损失函数呢?我们应该做什么呢?我们在之前了解到 TensorFlow 中的 Tensor 知道如何计算梯度以及计算 梯度相关的东西。由于我们的损失正是一个 Tensor ,因此我们可以使用所有与梯度有关的参数来计算梯度。然后我们可以进行标准梯度更新。 设θ为我们的参数,为损失函数,η一个正的学习率。然后,

目前,有大量的算法和积极的研究试图做一些除了这种普通的梯度更新以外的事情。许多人尝试去基于训练时发生的事情来改变学习率。但是, 你不需要担心这些特殊的算法到底在干什么,除非你真的很感兴趣。Torch提供了大量的算法在torch.optim包中,且全部都是透明的。在语法 上使用复杂的算法和使用最简单的梯度更新一样简单。但是尝试不同的更新算法和在更新算法中使用不同的参数(例如不同的初始学习率)对 于优化你的网络的性能很重要。通常,仅仅将普通的SGD替换成一个例如Adam或者RMSProp优化器都可以显著的提升性能。

3.使用PyTorch创建网络组件

在我们继续关注 NLP 之前,让我们先使用PyTorch构建一个只用仿射变换和非线性函数组成的网络示例。我们也将了解如何计算损失函数,使 用PyTorch内置的负对数似然函数,以及通过反向传播更新参数。

所有的网络组件应该继承nn.Module并覆盖forward()方法。继承nn.Module提供给了一些方法给你的组件。例如,它可以跟踪可训练的 参数,你可以通过.to(device)方法在 CPU 和 GPU 之间交换它们。.to(device)方法中的 device 可以是CPU设备torch.device("cpu") 或者 CUDA 设备torch.device("cuda:0")。

让我们写一个神经网络的示例,它接受一些稀疏的BOW(词袋模式)表示,然后输出分布在两个标签上的概率:“English”和“Spanish”。这个模型只是一个逻辑回归。

3.1 示例: 基于逻辑回归与词袋模式的文本分类器

我们的模型将会把BOW表示映射成标签上的对数概率。我们为词汇中的每个词指定一个索引。例如,我们所有的词汇是两个单词“hello”和”world”, 用0和1表示。句子“hello hello hello hello”的表示是

[4,0]

对于“hello world world hello”, 则表示成

[2,2]

通常表示成

[Count(hello),Count(world)]

用x来表示这个BOW向量。网络的输出是:

也就是说,我们数据传入一个仿射变换然后做对数归一化logsoftmax。

data = [("me gusta comer en la cafeteria".split(), "SPANISH"),
        ("Give it to me".split(), "ENGLISH"),
        ("No creo que sea una buena idea".split(), "SPANISH"),
        ("No it is not a good idea to get lost at sea".split(), "ENGLISH")]
test_data = [("Yo creo que si".split(), "SPANISH"),
             ("it is lost on me".split(), "ENGLISH")]
# word_to_ix maps each word in the vocab to a unique integer, which will be its
# index into the Bag of words vector
word_to_ix = {}
for sent, _ in data + test_data:
    for word in sent:
        if word not in word_to_ix:
            word_to_ix[word] = len(word_to_ix)
print(word_to_ix)
VOCAB_SIZE = len(word_to_ix)
NUM_LABELS = 2
class BoWClassifier(nn.Module):  # inheriting from nn.Module!
    def __init__(self, num_labels, vocab_size):
        # calls the init function of nn.Module.  Dont get confused by syntax,
        # just always do it in an nn.Module
        super(BoWClassifier, self).__init__()
        # Define the parameters that you will need.  In this case, we need A and b,
        # the parameters of the affine mapping.
        # Torch defines nn.Linear(), which provides the affine map.
        # Make sure you understand why the input dimension is vocab_size
        # and the output is num_labels!
        self.linear = nn.Linear(vocab_size, num_labels)
        # NOTE! The non-linearity log softmax does not have parameters! So we don't need
        # to worry about that here
    def forward(self, bow_vec):
        # Pass the input through the linear layer,
        # then pass that through log_softmax.
        # Many non-linearities and other functions are in torch.nn.functional
        return F.log_softmax(self.linear(bow_vec), dim=1)
def make_bow_vector(sentence, word_to_ix):
    vec = torch.zeros(len(word_to_ix))
    for word in sentence:
        vec[word_to_ix[word]] += 1
    return vec.view(1, -1)
def make_target(label, label_to_ix):
    return torch.LongTensor([label_to_ix[label]])
model = BoWClassifier(NUM_LABELS, VOCAB_SIZE)
# 模型知道它的参数。 下面的第一个输出是A,第二个输出是b。 
# 无论何时将组件分配给模块的__init__函数中的类变量,都是使用self.linear = nn.Linear(...)行完成的。
# 然后通过PyTorch,你的模块(在本例中为BoWClassifier)将存储nn.Linear参数的知识
for param in model.parameters():
    print(param)
# 要运行模型,请传入BoW矢量
# 这里我们不需要训练,所以代码包含在torch.no_grad()中
with torch.no_grad():
    sample = data[0]
    bow_vector = make_bow_vector(sample[0], word_to_ix)
    log_probs = model(bow_vector)
    print(log_probs)

输出结果:

{'me': 0, 'gusta': 1, 'comer': 2, 'en': 3, 'la': 4, 'cafeteria': 5, 'Give': 6, 'it': 7, 'to': 8, 'No': 9, 'creo': 10, 'que': 11, 'sea': 12, 'una': 13, 'buena': 14, 'idea': 15, 'is': 16, 'not': 17, 'a': 18, 'good': 19, 'get': 20, 'lost': 21, 'at': 22, 'Yo': 23, 'si': 24, 'on': 25}
Parameter containing:
tensor([[ 0.1194,  0.0609, -0.1268,  0.1274,  0.1191,  0.1739, -0.1099, -0.0323,
         -0.0038,  0.0286, -0.1488, -0.1392,  0.1067, -0.0460,  0.0958,  0.0112,
          0.0644,  0.0431,  0.0713,  0.0972, -0.1816,  0.0987, -0.1379, -0.1480,
          0.0119, -0.0334],
        [ 0.1152, -0.1136, -0.1743,  0.1427, -0.0291,  0.1103,  0.0630, -0.1471,
          0.0394,  0.0471, -0.1313, -0.0931,  0.0669,  0.0351, -0.0834, -0.0594,
          0.1796, -0.0363,  0.1106,  0.0849, -0.1268, -0.1668,  0.1882,  0.0102,
          0.1344,  0.0406]], requires_grad=True)
Parameter containing:
tensor([0.0631, 0.1465], requires_grad=True)
tensor([[-0.5378, -0.8771]])

上面的哪一个值对应的是 ENGLISH 的对数概率,哪一个是SPANISH的对数概率?我们还没有定义,但是如果我必须要定义我们想要训练的东西。

label_to_ix = {"SPANISH": 0, "ENGLISH": 1}

让我们来训练吧! 我们将实例传入来获取对数概率,计算损失函数,计算损失函数的梯度,然后使用一个梯度步长来更新参数。在PyTorch的nn包里提供了损失函数。nn.NLLLoss()是我们想要的负对数似然损失函数。torch.optim中也定义了优化方法。这里,我们只使用SGD。

注意,因为NLLLoss的输入是一个对数概率的向量以及目标标签。它不会为我们计算对数概率。这也是为什么我们最后一层网络是log_softmax的原因。损失函数nn.CrossEntropyLoss()除了对结果额外计算了logsoftmax之外,和NLLLoss()没什么区别。

# 在我们训练之前运行测试数据,只是为了看到之前-之后
with torch.no_grad():
    for instance, label in test_data:
        bow_vec = make_bow_vector(instance, word_to_ix)
        log_probs = model(bow_vec)
        print(log_probs)
# 打印与“creo”对应的矩阵列
print(next(model.parameters())[:, word_to_ix["creo"]])
loss_function = nn.NLLLoss()
optimizer = optim.SGD(model.parameters(), lr=0.1)
# 通常,您希望多次传递训练数据.
# 100比实际数据集大得多,但真实数据集有两个以上的实例。 
# 通常,在5到30个epochs之间是合理的。
for epoch in range(100):
    for instance, label in data:
        # 步骤1: 请记住,PyTorch会累积梯度。
        # We need to clear them out before each instance
        model.zero_grad()
        # 步骤2:制作我们的BOW向量,并且我们必须将目标作为整数包装在Tensor中。 
        # 例如,如果目标是SPANISH,那么我们包装整数0.
        # 然后,loss函数知道对数概率的第0个元素是对应于SPANISH的对数概率
        bow_vec = make_bow_vector(instance, word_to_ix)
        target = make_target(label, label_to_ix)
        # 步骤3:运行我们的前向传递.
        log_probs = model(bow_vec)
        # 步骤4: 通过调用optimizer.step()来计算损失,梯度和更新参数
        loss = loss_function(log_probs, target)
        loss.backward()
        optimizer.step()
with torch.no_grad():
    for instance, label in test_data:
        bow_vec = make_bow_vector(instance, word_to_ix)
        log_probs = model(bow_vec)
        print(log_probs)
# 对应西班牙语的指数上升,英语下降!
print(next(model.parameters())[:, word_to_ix["creo"]])

输出结果:

tensor([[-0.9297, -0.5020]])
tensor([[-0.6388, -0.7506]])
tensor([-0.1488, -0.1313], grad_fn=<SelectBackward>)
tensor([[-0.2093, -1.6669]])
tensor([[-2.5330, -0.0828]])
tensor([ 0.2803, -0.5605], grad_fn=<SelectBackward>)

我们得到了正确的结果!你可以看到Spanish的对数概率比第一个例子中的高的多,English的对数概率在第二个测试数据中更高,结果也应该 是这样。

现在你了解了如何创建一个PyTorch组件,将数据传入并进行梯度更新。现在我们已经可以开始进行深度学习上的自然语言处理了。

二、词嵌入:编码形式的词汇语义

词嵌入是一种由真实数字组成的稠密向量,每个向量都代表了单词表里的一个单词。在自然语言处理中,总会遇到这样的情况:特征全是单词!

但是,如何在电脑上表述一个单词呢?你在电脑上存储的单词的 ASCII 码,但是它仅仅代表单词怎么拼写,没有说明单词的内在含义(你也许 能够从词缀中了解它的词性,或者从大小写中得到一些属性,但仅此而已)。更重要的是,你能把这些 ASCII 码字符组合成什么含义?

其中,1表示的独有位置,其他位置全是0。其他的词都类似,在另外不一样的位置有一个1代表它,其他位置也都是0。这种表达除了占用巨大 的空间外,还有个很大的缺陷。它只是简单的把词看做一个单独个体,认为它们之间毫无联系。我们真正想要的是能够表达单词之间一些相似 的含义。为什么要这样做呢?来看下面的例子:

假如我们正在搭建一个语言模型,训练数据有下面一些句子:

The mathematician ran to the store.
The physicist ran to the store.
The mathematician solved the open problem.

现在又得到一个没见过的新句子:

The physicist solved the open problem.

我们的模型可能在这个句子上表现的还不错,但是,如果利用了下面两个事实,模型会表现更佳:

我们发现数学家和物理学家在句子里有相同的作用,所以在某种程度上,他们有语义的联系。

当看见物理学家在新句子中的作用时,我们发现数学家也有起着相同的作用。

然后我们就推测,物理学家在上面的句子里也类似于数学家吗?这就是我们所指的相似性理念: 指的是语义相似,而不是简单的拼写相似。 这就是一种通过连接我们发现的和没发现的一些内容相似点、用于解决语言数据稀疏性的技术。这个例子依赖于一个基本的语言假设:那些在 相似语句中出现的单词,在语义上也是相互关联的。这就叫做distributional hypothesis(分布式假设)。

1. Getting Dense Word Embeddings(密集词嵌入)

我们如何解决这个问题呢?也就是,怎么编码单词中的语义相似性? 也许我们会想到一些语义属性。 举个例子,我们发现数学家和物理学家 都能跑,所以也许可以给含有“能跑”语义属性的单词打高分,考虑一下其他的属性,想象一下你可能会在这些属性上给普通的单词打什么分。

如果每个属性都表示一个维度,那我们也许可以用一个向量表示一个单词,就像这样:

那么,我们就这可以通过下面的方法得到这些单词之间的相似性:

尽管通常情况下需要进行长度归一化:

你可以把本章开头介绍的 one-hot 稀疏向量看做是我们新定义向量的一种特殊形式,那里的单词相似度为0,现在我们给每个单词一些独特的 语义属性。这些向量数据密集,也就是说它们数字通常都非零。

但是新的这些向量存在一个严重的问题:你可以想到数千种不同的语义属性,它们可能都与决定相似性有关,而且,到底如何设置不同属性的 值呢?深度学习的中心思想是用神经网络来学习特征的表示,而不是程序员去设计它们。所以为什么不把词嵌入只当做模型参数,而是通过训 练来更新呢?这就才是我们要确切做的事。我们将用神经网络做一些潜在语义属性,但是原则上,学习才是关键。 注意,词嵌入可能无法解释。

就是说,尽管使用我们上面手动制作的向量,能够发现数学家和物理学家都喜欢喝咖啡的相似性,如果我们允许神经网络来学习词嵌入,那么 就会发现数学家和物理学家在第二维度有个较大的值,它所代表的含义很不清晰。它们在一些潜在语义上是相似的,但是对我们来说无法解释。

2. Pytorch中的词嵌入

在我们举例或练习之前,这里有一份关于如何在Pytorch和常见的深度学习中使用词嵌入的简要介绍。与制作 one-hot 向量时对每个单词定义 一个特殊的索引类似,当我们使用词向量时也需要为每个单词定义一个索引。这些索引将是查询表的关键点。意思就是,词嵌入被被存储在一 个的向量中,其中是词嵌入的维度。词被被分配的索引 i,表示在向量的第i行存储它的嵌入。 在所有的代码中,从单词到索引的映射是一个叫 word_to_ix 的字典。

能使用词嵌入的模块是torch.nn.Embedding,这里面有两个参数:词汇表的大小和词嵌入的维度。

索引这张表时,你必须使用torch.LongTensor(因为索引是整数,不是浮点数)。

# 作者: Robert Guthrie
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
torch.manual_seed(1)
word_to_ix = {"hello": 0, "world": 1}
embeds = nn.Embedding(2, 5)  # 2 words in vocab, 5 dimensional embeddings
lookup_tensor = torch.tensor([word_to_ix["hello"]], dtype=torch.long)
hello_embed = embeds(lookup_tensor)
print(hello_embed)

输出结果:

tensor([[ 0.6614,  0.2669,  0.0617,  0.6213, -0.4519]],
       grad_fn=<EmbeddingBackward>)

3.例子: N-Gram语言模型

回想一下,在 n-gram 语言模型中,给定一个单词序列向量,我们要计算的是:

是单词序列的第 i 个单词。 在本例中,我们将在训练样例上计算损失函数,并且用反向传播算法更新参数。

CONTEXT_SIZE = 2
EMBEDDING_DIM = 10
# 我们用莎士比亚的十四行诗 Sonnet 2
test_sentence = """When forty winters shall besiege thy brow,
And dig deep trenches in thy beauty's field,
Thy youth's proud livery so gazed on now,
Will be a totter'd weed of small worth held:
Then being asked, where all thy beauty lies,
Where all the treasure of thy lusty days;
To say, within thine own deep sunken eyes,
Were an all-eating shame, and thriftless praise.
How much more praise deserv'd thy beauty's use,
If thou couldst answer 'This fair child of mine
Shall sum my count, and make my old excuse,'
Proving his beauty by succession thine!
This were to be new made when thou art old,
And see thy blood warm when thou feel'st it cold.""".split()
# 应该对输入变量进行标记,但暂时忽略。
# 创建一系列的元组,每个元组都是([ word_i-2, word_i-1 ], target word)的形式。
trigrams = [([test_sentence[i], test_sentence[i + 1]], test_sentence[i + 2])
            for i in range(len(test_sentence) - 2)]
# 输出前3行,先看下是什么样子。
print(trigrams[:3])
vocab = set(test_sentence)
word_to_ix = {word: i for i, word in enumerate(vocab)}
class NGramLanguageModeler(nn.Module):
    def __init__(self, vocab_size, embedding_dim, context_size):
        super(NGramLanguageModeler, self).__init__()
        self.embeddings = nn.Embedding(vocab_size, embedding_dim)
        self.linear1 = nn.Linear(context_size * embedding_dim, 128)
        self.linear2 = nn.Linear(128, vocab_size)
    def forward(self, inputs):
        embeds = self.embeddings(inputs).view((1, -1))
        out = F.relu(self.linear1(embeds))
        out = self.linear2(out)
        log_probs = F.log_softmax(out, dim=1)
        return log_probs
losses = []
loss_function = nn.NLLLoss()
model = NGramLanguageModeler(len(vocab), EMBEDDING_DIM, CONTEXT_SIZE)
optimizer = optim.SGD(model.parameters(), lr=0.001)
for epoch in range(10):
    total_loss = 0
    for context, target in trigrams:
        # 步骤 1\. 准备好进入模型的数据 (例如将单词转换成整数索引,并将其封装在变量中)
        context_idxs = torch.tensor([word_to_ix[w] for w in context], dtype=torch.long)
        # 步骤 2\. 回调torch累乘梯度
        # 在传入一个新实例之前,需要把旧实例的梯度置零。
        model.zero_grad()
        # 步骤 3\. 继续运行代码,得到单词的log概率值。
        log_probs = model(context_idxs)
        # 步骤 4\. 计算损失函数(再次注意,Torch需要将目标单词封装在变量里)。
        loss = loss_function(log_probs, torch.tensor([word_to_ix[target]], dtype=torch.long))
        # 步骤 5\. 反向传播更新梯度
        loss.backward()
        optimizer.step()
        # 通过调tensor.item()得到单个Python数值。
        total_loss += loss.item()
    losses.append(total_loss)
print(losses)  # 用训练数据每次迭代,损失函数都会下降。

输出结果:

[(['When', 'forty'], 'winters'), (['forty', 'winters'], 'shall'), (['winters', 'shall'], 'besiege')]
[523.1487259864807, 520.6150465011597, 518.0996162891388, 515.6003141403198, 513.1156675815582, 510.645352602005, 508.1888840198517, 505.74565410614014, 503.314866065979, 500.8949146270752]

4.练习:计算连续词袋模型的词向量

连续词袋模型(CBOW)在NLP深度学习中使用很频繁。它是一个模型,尝试通过目标词前后几个单词的文本,来预测目标词。这有别于语言模型, 因为CBOW不是序列的,也不必是概率性的。CBOW常用于快速地训练词向量,得到的嵌入用来初始化一些复杂模型的嵌入。通常情况下,这被称 为预训练嵌入。它几乎总能帮忙把模型性能提升几个百分点。

在 Pytorch 中,通过填充下面的类来实现这个模型,有两条需要注意:

考虑下你需要定义哪些参数。

确保你知道每步操作后的结构,如果想重构,请使用.view()。

CONTEXT_SIZE = 2  # 左右各两个词
raw_text = """We are about to study the idea of a computational process.
Computational processes are abstract beings that inhabit computers.
As they evolve, processes manipulate other abstract things called data.
The evolution of a process is directed by a pattern of rules
called a program. People create programs to direct processes. In effect,
we conjure the spirits of the computer with our spells.""".split()
# 通过对`raw_text`使用set()函数,我们进行去重操作
vocab = set(raw_text)
vocab_size = len(vocab)
word_to_ix = {word: i for i, word in enumerate(vocab)}
data = []
for i in range(2, len(raw_text) - 2):
    context = [raw_text[i - 2], raw_text[i - 1],
               raw_text[i + 1], raw_text[i + 2]]
    target = raw_text[i]
    data.append((context, target))
print(data[:5])
class CBOW(nn.Module):
    def __init__(self):
        pass
    def forward(self, inputs):
        pass
# 创建模型并且训练。这里有些函数帮你在使用模块之前制作数据。
def make_context_vector(context, word_to_ix):
    idxs = [word_to_ix[w] for w in context]
    return torch.tensor(idxs, dtype=torch.long)
make_context_vector(data[0][0], word_to_ix)  # example

输出结果:

[(['We', 'are', 'to', 'study'], 'about'), (['are', 'about', 'study', 'the'], 'to'), (['about', 'to', 'the', 'idea'], 'study'), (['to', 'study', 'idea', 'of'], 'the'), (['study', 'the', 'of', 'a'], 'idea')]

三、序列模型和长短句记忆(LSTM)模型

前馈网络

之前我们已经学过了许多的前馈网络。所谓前馈网络, 就是网络中不会保存状态。然而有时这并不是我们想要的效果。在自然语言处理 (NLP, Natural Language Processing) 中, 序列模型是一个核心的概念。

序列模型

所谓序列模型, 即输入依赖于时间信息的模型。一个典型的序列模型是隐马尔科夫模型 (HMM, Hidden Markov Model)。另一个序列模型的例 子是条件随机场 (CRF, Conditional Random Field)。

循环神经网络

循环神经网络是指可以保存某种状态的神经网络。比如说, 神经网络中上个时刻的输出可以作为下个 时刻的输入的一部分, 以此信息就可以 通过序列在网络中一直往后传递。对于LSTM (Long-Short Term Memory) 来说, 序列中的每个元素都有一个相应的隐状态, 该隐状态原则上可以包含序列当前结点之前的任一节点的信息。我们可以使用隐藏状态来预测语言模型中的单词, 词性标签以及其他。

1.Pytorch中的LSTM

在正式学习之前,有几个点要说明一下,Pytorch中 LSTM 的输入形式是一个 3D 的Tensor,每一个维度都有重要的意义,第一个维度就是序列本身, 第二个维度是mini-batch中实例的索引,第三个维度是输入元素的索引,我们之前没有接触过mini-batch,所以我们就先忽略它并假设第 二维的维度是1。如果要用”The cow jumped”这个句子来运行一个序列模型,那么就应该把它整理成如下的形式:

除了有一个额外的大小为1的第二维度。

此外, 你还可以向网络逐个输入序列, 在这种情况下, 第一个轴的大小也是1。

来看一个简单的例子。

# Author: Robert Guthrie
import torch
import torch.nn as nn
import torch.nn.functional as F
import torch.optim as optim
torch.manual_seed(1)
lstm = nn.LSTM(3, 3)  # 输入维度为3维,输出维度为3维
inputs = [torch.randn(1, 3) for _ in range(5)]  # 生成一个长度为5的序列
# 初始化隐藏状态.
hidden = (torch.randn(1, 1, 3),
          torch.randn(1, 1, 3))
for i in inputs:
    # 将序列中的元素逐个输入到LSTM.
    # 经过每步操作,hidden 的值包含了隐藏状态的信息.
    out, hidden = lstm(i.view(1, 1, -1), hidden)
# 另外我们可以对一整个序列进行训练.
# LSTM第一个返回的第一个值是所有时刻的隐藏状态
# 第二个返回值是最后一个时刻的隐藏状态
#(所以"out"的最后一个和"hidden"是一样的)
# 之所以这样设计:
# 通过"out"你能取得任何一个时刻的隐藏状态,而"hidden"的值是用来进行序列的反向传播运算, 具体方式就是将它作为参数传入后面的 LSTM 网络.
# 增加额外的第二个维度.
inputs = torch.cat(inputs).view(len(inputs), 1, -1)
hidden = (torch.randn(1, 1, 3), torch.randn(1, 1, 3))  # 清空隐藏状态. 
out, hidden = lstm(inputs, hidden)
print(out)
print(hidden)

输出结果:

tensor([[[-0.0187,  0.1713, -0.2944]],
        [[-0.3521,  0.1026, -0.2971]],
        [[-0.3191,  0.0781, -0.1957]],
        [[-0.1634,  0.0941, -0.1637]],
        [[-0.3368,  0.0959, -0.0538]]], grad_fn=<StackBackward>)
(tensor([[[-0.3368,  0.0959, -0.0538]]], grad_fn=<StackBackward>), tensor([[[-0.9825,  0.4715, -0.0633]]], grad_fn=<StackBackward>))

2.例子:用LSTM来进行词性标注

在这部分, 我们将会使用一个 LSTM 网络来进行词性标注。在这里我们不会用到维特比算法, 前向-后向算法或者任何类似的算法,而是将这部 分内容作为一个 (有挑战) 的练习留给读者, 希望读者在了解了这部分的内容后能够实现如何将维特比算法应用到 LSTM 网络中来。

即先对隐状态进行一个仿射变换, 然后计算一个对数 softmax, 最后得到的预测标签即为对数 softmax 中最大的值对应的标签. 注意, 这也 意味着 A 空间的维度是|T|​。

2.1 准备数据

def prepare_sequence(seq, to_ix):
    idxs = [to_ix[w] for w in seq]
    return torch.tensor(idxs, dtype=torch.long)
training_data = [
    ("The dog ate the apple".split(), ["DET", "NN", "V", "DET", "NN"]),
    ("Everybody read that book".split(), ["NN", "V", "DET", "NN"])
]
word_to_ix = {}
for sent, tags in training_data:
    for word in sent:
        if word not in word_to_ix:
            word_to_ix[word] = len(word_to_ix)
print(word_to_ix)
tag_to_ix = {"DET": 0, "NN": 1, "V": 2}
# 实际中通常使用更大的维度如32维, 64维.
# 这里我们使用小的维度, 为了方便查看训练过程中权重的变化.
EMBEDDING_DIM = 6
HIDDEN_DIM = 6

输出结果:

{'The': 0, 'dog': 1, 'ate': 2, 'the': 3, 'apple': 4, 'Everybody': 5, 'read': 6, 'that': 7, 'book': 8}

2.2 创建模型

class LSTMTagger(nn.Module):
    def __init__(self, embedding_dim, hidden_dim, vocab_size, tagset_size):
        super(LSTMTagger, self).__init__()
        self.hidden_dim = hidden_dim
        self.word_embeddings = nn.Embedding(vocab_size, embedding_dim)
        # LSTM以word_embeddings作为输入, 输出维度为 hidden_dim 的隐藏状态值
        self.lstm = nn.LSTM(embedding_dim, hidden_dim)
        # 线性层将隐藏状态空间映射到标注空间
        self.hidden2tag = nn.Linear(hidden_dim, tagset_size)
        self.hidden = self.init_hidden()
    def init_hidden(self):
        # 一开始并没有隐藏状态所以我们要先初始化一个
        # 关于维度为什么这么设计请参考Pytoch相关文档
        # 各个维度的含义是 (num_layers, minibatch_size, hidden_dim)
        return (torch.zeros(1, 1, self.hidden_dim),
                torch.zeros(1, 1, self.hidden_dim))
    def forward(self, sentence):
        embeds = self.word_embeddings(sentence)
        lstm_out, self.hidden = self.lstm(
            embeds.view(len(sentence), 1, -1), self.hidden)
        tag_space = self.hidden2tag(lstm_out.view(len(sentence), -1))
        tag_scores = F.log_softmax(tag_space, dim=1)
        return tag_scores

2.3 训练模型

model = LSTMTagger(EMBEDDING_DIM, HIDDEN_DIM, len(word_to_ix), len(tag_to_ix))
loss_function = nn.NLLLoss()
optimizer = optim.SGD(model.parameters(), lr=0.1)
# 查看训练前的分数
# 注意: 输出的 i,j 元素的值表示单词 i 的 j 标签的得分
# 这里我们不需要训练不需要求导,所以使用torch.no_grad()
with torch.no_grad():
    inputs = prepare_sequence(training_data[0][0], word_to_ix)
    tag_scores = model(inputs)
    print(tag_scores)
for epoch in range(300):  # 实际情况下你不会训练300个周期, 此例中我们只是随便设了一个值
    for sentence, tags in training_data:
        # 第一步: 请记住Pytorch会累加梯度.
        # 我们需要在训练每个实例前清空梯度
        model.zero_grad()
        # 此外还需要清空 LSTM 的隐状态,
        # 将其从上个实例的历史中分离出来.
        model.hidden = model.init_hidden()
        # 准备网络输入, 将其变为词索引的 Tensor 类型数据
        sentence_in = prepare_sequence(sentence, word_to_ix)
        targets = prepare_sequence(tags, tag_to_ix)
        # 第三步: 前向传播.
        tag_scores = model(sentence_in)
        # 第四步: 计算损失和梯度值, 通过调用 optimizer.step() 来更新梯度
        loss = loss_function(tag_scores, targets)
        loss.backward()
        optimizer.step()
# 查看训练后的得分
with torch.no_grad():
    inputs = prepare_sequence(training_data[0][0], word_to_ix)
    tag_scores = model(inputs)
    # 句子是 "the dog ate the apple", i,j 表示对于单词 i, 标签 j 的得分.
    # 我们采用得分最高的标签作为预测的标签. 从下面的输出我们可以看到, 预测得
    # 到的结果是0 1 2 0 1. 因为 索引是从0开始的, 因此第一个值0表示第一行的
    # 最大值, 第二个值1表示第二行的最大值, 以此类推. 所以最后的结果是 DET
    # NOUN VERB DET NOUN, 整个序列都是正确的!
    print(tag_scores)

输出结果:

tensor([[-1.1389, -1.2024, -0.9693],
        [-1.1065, -1.2200, -0.9834],
        [-1.1286, -1.2093, -0.9726],
        [-1.1190, -1.1960, -0.9916],
        [-1.0137, -1.2642, -1.0366]])
tensor([[-0.0858, -2.9355, -3.5374],
        [-5.2313, -0.0234, -4.0314],
        [-3.9098, -4.1279, -0.0368],
        [-0.0187, -4.7809, -4.5960],
        [-5.8170, -0.0183, -4.1879]])

3.练习:使用字符级特征来增强 LSTM 词性标注器

在上面的例子中, 每个词都有一个词嵌入, 作为序列模型的输入. 接下来让我们使用每个的单词的 字符级别的表达来增强词嵌入。

我们期望这 个操作对结果能有显著提升, 因为像词缀这样的字符级信息对于词性有很大的影响。比如说, 像包含词缀 -ly 的单词基本上都是被标注为副词。

新模型中需要两个 LSTM, 一个跟之前一样, 用来输出词性标注的得分, 另外一个新增加的用来获取每个单词的字符级别表达。

为了在字符级别上运行序列模型,你需要用嵌入的字符来作为字符 LSTM 的输入。

四、高级:制定动态决策和BI-LSTM CRF

1.动态与静态深度学习工具包

Pytorch是一种动态神经网络套件。另一个动态套件的例子是Dynet(我之所以提到这一点,因为与 Pytorch和Dynet一起使用是相似的。如果你在Dynet中看到一个例子,它可能会帮助你在Pytorch中实现它)。相反的是静态工具包,其中包 括Theano,Keras,TensorFlow等。核心区别如下:

在静态工具包中,您可以定义一次计算图,对其进行编译,然后将实例流式传输给它。

在动态工具包中,为每个实例定义计算图。它永远不会被编译并且是即时执行的。

在没有很多经验的情况下,很难理解其中的差异。一个例子是假设我们想要构建一个深层组成解析器。假设我们的模型大致涉及以下步骤:

我们想要形成的成分将取决于实例。如果我们只编译计算图一次,就像在静态工具包中那样,但编写这个逻辑将非常困难或者说是不可能的。 但是,在动态工具包中,不仅有1个预定义的计算图。每个实例都可以有一个新的计算图,所以这个问题就消失了。

动态工具包还具有易于调试和代码更接近宿主语言的优点(我的意思是Pytorch和Dynet看起来更像是比Keras或Theano更实际的Python代码)。

2.Bi-LSTM条件随机场讨论

对于本节,我们将看到用于命名实体识别的Bi-LSTM条件随机场的完整复杂示例。虽然上面的LSTM标记符通常足以用于词性标注,但是像CRF这样的 序列模型对于NER上的强大性能非常重要。CRF,虽然这个名字听起来很可怕,但所有模型都是CRF,在LSTM中提供了这些功能。CRF是一个高级模型, 比本教程中的任何早期模型复杂得多。如果你想跳过它,也可以。要查看您是否准备好,请查看是否可以:

在步骤i中为标记k写出维特比变量的递归。

修改上述重复以计算转发变量。

再次修改上面的重复计算以计算日志空间中的转发变量(提示:log-sum-exp)

如果上面的讨论过于简短,你可以查看这个,是迈克尔柯林斯写的关于CRF的文章。

3.实现说明

下面的示例实现了日志空间中的前向算法来计算分区函数,以及用于解码的维特比算法。反向传播将自动为我们计算梯度。我们不需要手工做任何事情。

这个实现冰未优化。如果您了解发生了什么,您可能会很快发现在前向算法中迭代下一个标记可能是在一个大的操作中完成的。我想编码更具可读性。 如果您想进行相关更改,可以将此标记器用于实际任务。

3.1 导包

# Author: Robert Guthrie
import torch
import torch.autograd as autograd
import torch.nn as nn
import torch.optim as optim
torch.manual_seed(1)

3.2 辅助函数

辅助函数的功能是使代码更具可读性。

def argmax(vec):
    # 将argmax作为python int返回
    _, idx = torch.max(vec, 1)
    return idx.item()
def prepare_sequence(seq, to_ix):
    idxs = [to_ix[w] for w in seq]
    return torch.tensor(idxs, dtype=torch.long)
# 以正向算法的数值稳定方式计算log sum exp
def log_sum_exp(vec):
    max_score = vec[0, argmax(vec)]
    max_score_broadcast = max_score.view(1, -1).expand(1, vec.size()[1])
    return max_score + \
        torch.log(torch.sum(torch.exp(vec - max_score_broadcast)))

3.3 创建模型

class BiLSTM_CRF(nn.Module):
    def __init__(self, vocab_size, tag_to_ix, embedding_dim, hidden_dim):
        super(BiLSTM_CRF, self).__init__()
        self.embedding_dim = embedding_dim
        self.hidden_dim = hidden_dim
        self.vocab_size = vocab_size
        self.tag_to_ix = tag_to_ix
        self.tagset_size = len(tag_to_ix)
        self.word_embeds = nn.Embedding(vocab_size, embedding_dim)
        self.lstm = nn.LSTM(embedding_dim, hidden_dim // 2,
                            num_layers=1, bidirectional=True)
        # 将LSTM的输出映射到标记空间。
        self.hidden2tag = nn.Linear(hidden_dim, self.tagset_size)
        # 转换参数矩阵。 输入i,j是得分从j转换到i。
        self.transitions = nn.Parameter(
            torch.randn(self.tagset_size, self.tagset_size))
        # 这两个语句强制执行我们从不转移到开始标记的约束
        # 并且我们永远不会从停止标记转移
        self.transitions.data[tag_to_ix[START_TAG], :] = -10000
        self.transitions.data[:, tag_to_ix[STOP_TAG]] = -10000
        self.hidden = self.init_hidden()
    def init_hidden(self):
        return (torch.randn(2, 1, self.hidden_dim // 2),
                torch.randn(2, 1, self.hidden_dim // 2))
    def _forward_alg(self, feats):
        # 使用前向算法来计算分区函数
        init_alphas = torch.full((1, self.tagset_size), -10000.)
        # START_TAG包含所有得分.
        init_alphas[0][self.tag_to_ix[START_TAG]] = 0.
        # 包装一个变量,以便我们获得自动反向提升
        forward_var = init_alphas
        # 通过句子迭代
        for feat in feats:
            alphas_t = []  # The forward tensors at this timestep
            for next_tag in range(self.tagset_size):
                # 广播发射得分:无论以前的标记是怎样的都是相同的
                emit_score = feat[next_tag].view(
                    1, -1).expand(1, self.tagset_size)
                # trans_score的第i个条目是从i转换到next_tag的分数
                trans_score = self.transitions[next_tag].view(1, -1)
                # next_tag_var的第i个条目是我们执行log-sum-exp之前的边(i -> next_tag)的值
                next_tag_var = forward_var + trans_score + emit_score
                # 此标记的转发变量是所有分数的log-sum-exp。
                alphas_t.append(log_sum_exp(next_tag_var).view(1))
            forward_var = torch.cat(alphas_t).view(1, -1)
        terminal_var = forward_var + self.transitions[self.tag_to_ix[STOP_TAG]]
        alpha = log_sum_exp(terminal_var)
        return alpha
    def _get_lstm_features(self, sentence):
        self.hidden = self.init_hidden()
        embeds = self.word_embeds(sentence).view(len(sentence), 1, -1)
        lstm_out, self.hidden = self.lstm(embeds, self.hidden)
        lstm_out = lstm_out.view(len(sentence), self.hidden_dim)
        lstm_feats = self.hidden2tag(lstm_out)
        return lstm_feats
    def _score_sentence(self, feats, tags):
        # Gives the score of a provided tag sequence
        score = torch.zeros(1)
        tags = torch.cat([torch.tensor([self.tag_to_ix[START_TAG]], dtype=torch.long), tags])
        for i, feat in enumerate(feats):
            score = score + \
                self.transitions[tags[i + 1], tags[i]] + feat[tags[i + 1]]
        score = score + self.transitions[self.tag_to_ix[STOP_TAG], tags[-1]]
        return score
    def _viterbi_decode(self, feats):
        backpointers = []
        # Initialize the viterbi variables in log space
        init_vvars = torch.full((1, self.tagset_size), -10000.)
        init_vvars[0][self.tag_to_ix[START_TAG]] = 0
        # forward_var at step i holds the viterbi variables for step i-1
        forward_var = init_vvars
        for feat in feats:
            bptrs_t = []  # holds the backpointers for this step
            viterbivars_t = []  # holds the viterbi variables for this step
            for next_tag in range(self.tagset_size):
                # next_tag_var [i]保存上一步的标签i的维特比变量
                # 加上从标签i转换到next_tag的分数。
                # 我们这里不包括emission分数,因为最大值不依赖于它们(我们在下面添加它们)
                next_tag_var = forward_var + self.transitions[next_tag]
                best_tag_id = argmax(next_tag_var)
                bptrs_t.append(best_tag_id)
                viterbivars_t.append(next_tag_var[0][best_tag_id].view(1))
            # 现在添加emission分数,并将forward_var分配给我们刚刚计算的维特比变量集
            forward_var = (torch.cat(viterbivars_t) + feat).view(1, -1)
            backpointers.append(bptrs_t)
        # 过渡到STOP_TAG
        terminal_var = forward_var + self.transitions[self.tag_to_ix[STOP_TAG]]
        best_tag_id = argmax(terminal_var)
        path_score = terminal_var[0][best_tag_id]
        # 按照后退指针解码最佳路径。
        best_path = [best_tag_id]
        for bptrs_t in reversed(backpointers):
            best_tag_id = bptrs_t[best_tag_id]
            best_path.append(best_tag_id)
        # 弹出开始标记(我们不想将其返回给调用者)
        start = best_path.pop()
        assert start == self.tag_to_ix[START_TAG]  # Sanity check
        best_path.reverse()
        return path_score, best_path
    def neg_log_likelihood(self, sentence, tags):
        feats = self._get_lstm_features(sentence)
        forward_score = self._forward_alg(feats)
        gold_score = self._score_sentence(feats, tags)
        return forward_score - gold_score
    def forward(self, sentence):  # dont confuse this with _forward_alg above.
        # 获取BiLSTM的emission分数
        lstm_feats = self._get_lstm_features(sentence)
        # 根据功能,找到最佳路径。
        score, tag_seq = self._viterbi_decode(lstm_feats)
        return score, tag_seq

3.4 进行训练

START_TAG = "<START>"
STOP_TAG = "<STOP>"
EMBEDDING_DIM = 5
HIDDEN_DIM = 4
# 弥补一些训练数据
training_data = [(
    "the wall street journal reported today that apple corporation made money".split(),
    "B I I I O O O B I O O".split()
), (
    "georgia tech is a university in georgia".split(),
    "B I O O O O B".split()
)]
word_to_ix = {}
for sentence, tags in training_data:
    for word in sentence:
        if word not in word_to_ix:
            word_to_ix[word] = len(word_to_ix)
tag_to_ix = {"B": 0, "I": 1, "O": 2, START_TAG: 3, STOP_TAG: 4}
model = BiLSTM_CRF(len(word_to_ix), tag_to_ix, EMBEDDING_DIM, HIDDEN_DIM)
optimizer = optim.SGD(model.parameters(), lr=0.01, weight_decay=1e-4)
# 在训练前检查预测
with torch.no_grad():
    precheck_sent = prepare_sequence(training_data[0][0], word_to_ix)
    precheck_tags = torch.tensor([tag_to_ix[t] for t in training_data[0][1]], dtype=torch.long)
    print(model(precheck_sent))
# 确保加载LSTM部分中较早的prepare_sequence
for epoch in range(
        300):  # again, normally you would NOT do 300 epochs, it is toy data
    for sentence, tags in training_data:
        # 步骤1. 请记住,Pytorch积累了梯度
        # We need to clear them out before each instance
        model.zero_grad()
        # 步骤2. 为我们为网络准备的输入,即将它们转换为单词索引的张量.
        sentence_in = prepare_sequence(sentence, word_to_ix)
        targets = torch.tensor([tag_to_ix[t] for t in tags], dtype=torch.long)
        # 步骤3. 向前运行
        loss = model.neg_log_likelihood(sentence_in, targets)
        # 步骤4.通过调用optimizer.step()来计算损失,梯度和更新参数
        loss.backward()
        optimizer.step()
# 训练后检查预测
with torch.no_grad():
    precheck_sent = prepare_sequence(training_data[0][0], word_to_ix)
    print(model(precheck_sent))
# 得到结果

输出结果

(tensor(2.6907), [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1])
(tensor(20.4906), [0, 1, 1, 1, 2, 2, 2, 0, 1, 2, 2])

4.练习:区分标记的新损失函数

我们没有必要在进行解码时创建计算图,因为我们不会从维特比路径得分反向传播。因为无论如何我们都有它,尝试训练标记器,其中损失函 数是Viterbi path得分和gold-standard得分之间的差异。应该清楚的是,当预测的标签序列是正确的标签序列时,该功能是非负值和0。这基 本上是结构感知器。

由于已经实现了Viterbi和score_sentence,因此这种修改应该很短。这是一个关于计算图形的形状取决于训练实例的示例。虽然我没有尝 试在静态工具包中实现它,但我想它是可以的但可能没有那么容易。

拿起一些真实数据并进行比较!

文章来源:https://zhiya360.com/docs/pytorchstudy/pytorch-txt/04-nlp-pytorch

猜你喜欢

转载自blog.csdn.net/aizhushou/article/details/108324568