NLP自然语言处理的中间序列建模

Posted Sonhhxg_柒

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了NLP自然语言处理的中间序列建模相关的知识,希望对你有一定的参考价值。

   🔎大家好,我是Sonhhxg_柒,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流🔎

📝个人主页-Sonhhxg_柒的博客_CSDN博客 📃

🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝​

📣系列专栏 - 机器学习【ML】 自然语言处理【NLP】  深度学习【DL】

 🖍foreword

✔说明⇢本人讲解主要包括Python、机器学习(ML)、深度学习(DL)、自然语言处理(NLP)等内容。

如果你对这个系列感兴趣的话,可以关注订阅哟👋

本章的目标是序列预测。序列预测任务要求我们标记序列的每个项目。这样的任务在自然语言处理中很常见。一些例子包括语言建模见图 7-1),其中我们在每一步给定单词序列的情况下预测下一个单词;词性标注,我们预测每个词的语法词性;命名实体识别,我们预测每个单词是否是命名实体的一部分,例如PersonLocationProductOrganization;等等。有时,在 NLP 文献中,序列预测任务也被称为序列标记

虽然理论上我们可以使用第 6 章中介绍的 Elman 循环神经网络来完成序列预测任务,但它们无法很好地捕捉长期依赖关系,并且在实践中表现不佳。在本章中,我们会花一些时间了解为什么会这样,并了解一种称为门控网络的新型 RNN 架构。

我们还介绍了任务自然语言生成作为序列预测的一种应用,并探索了输出序列以某种方式受到约束的条件生成。

图 7-1。序列预测任务的两个示例: (a) 语言建模,其中任务是预测序列中的下一个单词;(b) 命名实体识别,旨在预测文本中实体字符串的边界及其类型。

Vanilla RNN(或 Elman RNN)的问题

甚至尽管第 6 章中讨论的 vanilla/Elman RNN非常适合建模序列,但它有两个问题使其不适用于许多任务:无法为长期预测保留信息,以及梯度稳定性。为了理解这两个问题,回想一下,RNN 的核心是使用前一个时间步的隐藏状态向量和当前时间步的输入向量来计算每个时间步的隐藏状态向量。正是这种核心计算使 RNN 如此强大,但它也产生了严重的数值问题。

Elman RNN 的第一个问题是难以保留远程信息。以第 6 章中的 RNN为例,在每个时间步,我们只是简单地更新了隐藏状态向量,而不管它是否有意义。因此,RNN 无法控制在隐藏状态中保留哪些值以及丢弃哪些值——这完全由输入决定。直觉上,这没有意义。RNN 需要某种方式来决定更新是否是可选的,或者更新是否发生,状态向量的数量和部分,等等。

Elman RNN 的第二个问题是它们倾向于导致梯度失控到零或无穷大。可能失控的不稳定梯度被称为消失的梯度爆炸梯度取决于梯度的绝对值缩小/增长的方向。非常大的梯度绝对值或非常小的(小于1)值会使优化过程不稳定(Hochreiter 等人,2001;Pascanu 等人,2013)。

在 vanilla RNN 中存在处理这些梯度问题的解决方案,例如使用整流线性单元 (ReLU)、梯度裁剪和仔细初始化。但是,所提出的解决方案中没有一个能像门控技术那样可靠。

门控作为 Vanilla RNN 挑战的解决方案

直观地理解门控,假设您要添加两个量ab ,但您想控制b中有多少进入总和。在数学上,您可以将总和a + b重写为:

a+λb

其中 λ 是 和 之间的01。如果 λ = 0,则b没有贡献,如果 λ = 1,则b贡献全部。以这种方式来看,您可以将 λ 解释为在控制进入总和的b数量时充当“开关”或“门” 。这就是门控机制背后的直觉。现在让我们重新审视 Elman RNN,看看如何将门控合并到普通 RNN 中以进行条件更新。如果先前的隐藏状态是t -1并且当前输入是t,Elman RNN 中的循环更新将如下所示:

ht=ht−1+F(ht−1,xt)

其中F是 RNN 的循环计算。显然,这是一个无条件的和,并且具有“Vanilla RNN(或 Elman RNN)的问题”中描述的弊端。现在想象一下,如果前一个例子中的 λ 不是一个常数,而是前一个隐藏状态向量t -1和当前输入t的函数,并且仍然产生了所需的门控行为;即 和 之间的01。使用这个门控函数,我们的 RNN 更新方程将如下所示:

ht=ht−1+λ(ht−1,xt)F(ht−1,xt)

现在很明显,函数 λ 控制了多少当前输入来更新状态t -1。此外,函数 λ 与上下文相关。这是所有门控网络背后的基本直觉。函数 λ 通常是一个 sigmoid 函数,我们从第 3 章中知道它会产生一个介于0和之间的值1

长短期记忆网络(LSTM;Hochreiter 和 Schmidhuber,1997)为例,这种基本直觉被仔细扩展,不仅包括条件更新,还包括有意忘记先前隐藏状态t -1中的值。这种“遗忘”是通过将先前的隐藏状态值t -1与另一个函数 μ 相乘而发生的,该函数也产生介于0和之间的值,1并且取决于当前输入:

ht=μ(ht−1,xt)ht−1+λ(ht−1,xt)F(ht−1,xt)

您可能已经猜到,μ 是另一个门控函数。在实际的 LSTM 描述中,这变得复杂,因为门控函数是参数化的,导致操作序列有点复杂(对于未启动的)。有了本节中的直觉,如果您想深入了解 LSTM 的更新机制,您现在就可以深入研究了。我们推荐Christopher Olah 的经典文章。我们将避免在本书中涉及任何内容,因为这些细节对于 LSTM 在 NLP 应用程序中的应用和使用并不是必不可少的。

LSTM 只是 RNN 的众多门控变体之一。另一个变得越来越流行的变体是 门控循环单元(GRU; Chung et al., 2015)。幸运的是,在 PyTorch 中,您可以简单地将nn.RNNornn.RNNCell替换为nn.LSTM或不更改nn.LSTMCell其他代码以切换到 LSTM(比对 GRU)!

门控机制是“The Problem with Vanilla RNNs (or Elman RNNs)”中列举的问题的有效解决方案。它不仅可以控制更新,还可以检查梯度问题并使训练相对容易。事不宜迟,我们将使用两个示例展示这些门控架构的实际应用。

示例:用于生成姓氏的字符 RNN

在这个例子中,1我们完成了一个简单的序列预测任务:使用 RNN 生成姓氏。在实践中,这意味着对于每个时间步,RNN 计算一个姓氏中可能字符集的概率分布。我们可以使用这些概率分布来优化网络以改进其预测(假设我们知道应该预测哪些字符),或者生成全新的姓氏。

尽管此任务的数据集已在前面的示例中使用并且看起来很熟悉,但每个数据样本为序列预测构建的方式存在一些差异。在描述了数据集和任务之后,我们概述了通过系统记账实现序列预测的支持数据结构。

然后我们介绍了两种生成姓氏的模型:无条件SurnameGenerationModel的和有条件的SurnameGenerationModel。无条件模型在不知道国籍的情况下预测姓氏字符的序列。相比之下,条件模型利用特定国籍嵌入作为 RNN 的初始隐藏状态,以允许模型对其序列的预测产生偏差。

SurnameDataset 类

第一的“示例:带有 MLP 的姓氏分类”中介绍,姓氏数据集是姓氏及其原籍国的集合。到目前为止,该数据集已用于分类任务——给定一个新姓氏,正确分类该姓氏起源于哪个国家。然而,在这个例子中,我们展示了如何使用数据集来训练一个模型,该模型可以为字符序列分配概率并生成新序列。

该类SurnameDataset与前几章基本相同:我们使用 Pandas DataFrame 加载数据集,并构造了一个矢量化器,它封装了手头模型和任务所需的令牌到整数的映射。为了适应任务的差异,该SurnameDataset.__getitem__()方法被修改为输出预测目标的整数序列,如示例 7-1所示。该方法引用Vectorizer用于计算用作输入的整数序列 (the from_vector) 和用作输出的整数序列 (the to_vector)。的实现vectorize()将在下一小节中描述。

例 7-1。序列预测任务的 SurnameDataset.__getitem__() 方法

class SurnameDataset(Dataset):
    @classmethod
    def load_dataset_and_make_vectorizer(cls, surname_csv):
        """加载数据集并从头开始制作一个新的矢量化器
        参数: 
            surname_csv (str): 数据集的位置
        返回:
            SurnameDataset 的一个实例
        """
        
        surname_df = pd.read_csv(surname_csv)
        return cls(surname_df, SurnameVectorizer.from_dataframe(surname_df))

    def __getitem__(self, index):
        """PyTorch 数据集的主要入口点方法
        
        参数: 
            index (int): 数据点的索引
        returns:
        
            保存数据点的字典: (x_data, y_target, class_index) 
        """
        row = self._target_df.iloc[index]
        
        from_vector, to_vector = \\
            self._vectorizer.vectorize(row.surname, self._max_seq_length)
        
        nationality_index = \\
            self._vectorizer.nationality_vocab.lookup_token(row.nationality)

        return 'x_data': from_vector, 
                'y_target': to_vector, 
                'class_index': nationality_index

矢量化数据结构

作为在前面的示例中,有三个主要的数据结构将每个姓氏的字符序列转换为其矢量化形式:SequenceVocabulary将单个标记映射到整数,SurnameVectorizer坐标整数映射,以及将's 结果DataLoader分组为小批量。SurnameVectorizer因为DataLoader在这个例子中实现和它的使用保持不变,我们将跳过它的实现细节。2

SurnameVectorizer 和 END-OF-SEQUENCE

对于序列预测任务,编写训练例程以期望两个整数序列,它们代表每个时间步的标记观察和标记目标。通常,我们只想预测我们正在训练的序列,例如本例中的姓氏。这意味着我们只有一个令牌序列可以使用,并通过交错单个序列来构建观察和目标。

将其转换为序列预测问题,每个标记都使用SequenceVocabulary. 然后,BEGIN-OF-SEQUENCE标记索引, 被附加到begin_seq_index序列的开头,END-OF-SEQUENCE标记索引end_seq_index, 被附加到序列的末尾。此时,每个数据点都是一个索引序列,并且具有相同的第一个和最后一个索引。为了创建训练例程所需的输入和输出序列,我们只需使用索引序列的两个切片:第一个切片包括除最后一个之外的所有标记索引,第二个切片包括除第一个之外的所有标记索引。当对齐和配对在一起时,序列是正确的输入输出索引。

明确地说,我们展示了SurnameVectorizer.vectorize()示例7-2中的代码。第一步是将surname一串字符映射到indices表示这些字符的整数列表。然后,indices用序列的开始和结束索引包装:具体来说,begin_seq_index被添加到indices并附end_seq_index加到indices. 接下来,我们测试vector_length通常在运行时提供的 (尽管编写的代码允许任何长度的向量)。在训练期间,重要的是vector_length要提供,因为小批量是由堆叠的向量表示构成的。如果向量的长度不同,则它们不能堆叠在单个矩阵中。在测试 之后vector_length,创建了两个向量:from_vectorto_vector. 不包括最后一个索引的索引from_vector切片放在里面,不包括第一个索引的索引切片放在里面to_vector。每个向量的剩余位置都用mask_index. 将序列向右填充(或填充)很重要,因为空位置会改变输出向量,我们希望在看到序列之后发生这些变化。

示例 7-2。序列预测任务中 SurnameVectorizer.vectorize() 的代码

class SurnameVectorizer(object):
    """ 协调词汇并使用它们的矢量化器"""    
    def vectorize(self, surname, vector_length=-1):
        """将姓氏向量化为观察向量并以为目标
        参数: 
            surname (str): 被向量化的姓氏
            vector_length (int): 强制索引向量长度的参数
        返回:
            一个元组: (from_vector, to_vector) 
                from_vector (numpy.ndarray): 观察向量
                to_vector (numpy.ndarray ): 目标预测向量
        """
        indices = [self.char_vocab.begin_seq_index] 
        indices.extend(self.char_vocab.lookup_token(token) for token in surname)
        indices.append(self.char_vocab.end_seq_index)

        if vector_length < 0:
            vector_length = len(indices) - 1

        from_vector = np.zeros(vector_length, dtype=np.int64)         
        from_indices = indices[:-1]
        from_vector[:len(from_indices)] = from_indices
        from_vector[len(from_indices):] = self.char_vocab.mask_index

        to_vector = np.empty(vector_length, dtype=np.int64)
        to_indices = indices[1:]
        to_vector[:len(to_indices)] = to_indices
        to_vector[len(to_indices):] = self.char_vocab.mask_index
        
        return from_vector, to_vector

    @classmethod
    def from_dataframe(cls, surname_df):
        """从数据集数据帧实例化矢量化器
        参数: 
            surname_df (pandas.DataFrame): 姓氏数据集
        返回:
            SurnameVectorizer 的一个实例
        """
        char_vocab = SequenceVocabulary()
        nationality_vocab = Vocabulary()

        for index, row in surname_df.iterrows():
            for char in row.surname:
                char_vocab.add_token(char)
            nationality_vocab.add_token(row.nationality)

        return cls(char_vocab, nationality_vocab)

从 ElmanRNN 到 GRU

实践中,从 vanilla RNN 切换到门控变体非常容易。在下面的模型中,虽然我们使用 GRU 代替 vanilla RNN,但使用 LSTM 也同样简单。为了使用 GRU,我们实例化 torch.nn.GRUElmanRNN模块使用与第 6 章相同的参数。

模型 1:无条件姓氏生成模型

两个模型中的第一个是无条件的:它在生成姓氏之前不观察国籍。在实践中,无条件意味着 GRU 不会将其计算偏向于任何国籍。在下一个示例(示例 7-4)中,计算偏差是通过初始隐藏向量引入的​​。在此示例中,我们使用所有0s 的向量,以便初始隐藏状态向量对计算没有贡献。3

一般来说,SurnameGenerationModel例 7-3)嵌入字符索引,使用 GRU 计算它们的顺序状态,并使用Linear层计算标记预测的概率。更明确地说,无条件SurnameGenerationModel从初始化一个Embedding层、一个 GRU 和一个Linear层开始。类似于第 6 章的序列模型,一个整数矩阵被输入到模型中。我们使用 PyTorchEmbedding实例 thechar_embedding将整数转换为三维张量(每个批次项目的向量序列)。这个张量被传递给 GRU,它为每个序列中的每个位置计算一个状态向量。

示例 7-3。无条件姓氏生成模型

class SurnameGenerationModel(nn.Module):
    def __init__(self, char_embedding_size, char_vocab_size, rnn_hidden_size, 
                 batch_first=True, padding_idx=0, dropout_p=0.5):
        """
        参数: 
            char_embedding_size (int): 字符嵌入的大小
            char_vocab_size ( int): 要嵌入的字符数
            rnn_hidden_​​size (int): RNN 的隐藏状态的大小
            batch_first (bool): 告知输入张量
                在第 0 维上是否会有批或序列
            padding_idx (int): 的索引张量填充;
                参见 torch.nn.Embedding
        
            dropout_p (float): 使用dropout方法
        """
        super(SurnameGenerationModel, self).__init__()
        
        self.char_emb = nn.Embedding(num_embeddings=char_vocab_size,
                                     embedding_dim=char_embedding_size,
                                     padding_idx=padding_idx)
        self.rnn = nn.GRU(input_size=char_embedding_size, 
                          hidden_size=rnn_hidden_size,
                          batch_first=batch_first)
        self.fc = nn.Linear(in_features=rnn_hidden_size, 
                            out_features=char_vocab_size)
        self._dropout_p = dropout_p

    def forward(self, x_in, apply_softmax=False):
        """模型的前向传递
        参数: 
            x_in (torch.Tensor): 一个输入数据张量
                x_in.shape应该是(batch, input_dim) 
            apply_softmax (bool):在训练期间,softmax 激活的标志
                应为 False
        返回:
            生成的张量。tensor.shape 应为 (batch, output_dim)。
        """
        x_embedded = self.char_emb(x_in)

        y_out, _ = self.rnn(x_embedded)

        batch_size, seq_size, feat_size = y_out.shape
        y_out = y_out.contiguous().view(batch_size * seq_size, feat_size)

        y_out = self.fc(F.dropout(y_out, p=self._dropout_p))
                         
        if apply_softmax:
            y_out = F.softmax(y_out, dim=1)
            
        new_feat_size = y_out.shape[-1]
        y_out = y_out.view(batch_size, seq_size, new_feat_size)
            
        return y_out

第 6 章的序列分类任务和本章的序列预测任务之间的主要区别在于RNN 计算的状态向量是如何处理的。在第 6 章中,我们检索每个批次索引的单个向量,并使用这些单个向量进行预测。在这个例子中,我们将三维张量重塑为二维张量(矩阵),以便行维度代表每个样本(批次和序列索引)。使用这个矩阵和Linear层,我们计算每个样本的预测向量。我们通过将矩阵重新整形为三维张量来完成计算。因为通过整形操作保留了排序信息,所以每个批次和序列索引仍然在相同的位置。我们需要重塑的原因是因为该Linear层需要一个矩阵作为输入。

模型 2:条件姓氏生成模型

第二个模型考虑了要生成的姓氏的国籍。在实践中,这意味着有一些机制允许模型相对于特定姓氏来偏向其行为。在这个例子中,我们通过将每个国籍嵌入为隐藏状态大小的向量来参数化 RNN 的初始隐藏状态。这意味着当模型调整其参数时,它也会调整嵌入矩阵中的值,从而使预测偏向于对其特定国籍和姓氏规律更敏感。例如,爱尔兰国籍向量偏向于起始序列“Mc”和“O”。

示例 7-4显示了条件模型中的差异。具体来说,引入了一个额外Embedding的方法来将国籍索引映射到与 RNN 的隐藏层大小相同的向量。然后,在前向函数中,国籍索引被嵌入并简单地作为 RNN 的初始隐藏层传入。虽然这是对第一个模型的非常简单的修改,但它对让 RNN 根据生成的姓氏的国籍改变其行为具有深远的影响。

示例 7-4。条件姓氏生成模型

class SurnameGenerationModel(nn.Module):
    def __init__(self, char_embedding_size, char_vocab_size, num_nationalities,
                 rnn_hidden_size, batch_first=True, padding_idx=0, dropout_p=0.5):
        # ...
        self.nation_embedding = nn.Embedding(embedding_dim=rnn_hidden_size, 
                                             num_embeddings=num_nationalities)

    def forward(self, x_in, nationality_index, apply_softmax=False):
        # ...
        x_embedded = self.char_embedding(x_in)
        # hidden_size: (num_layers * num_directions, batch_size, rnn_hidden_size)
        nationality_embedded = self.nation_emb(nationality_index).unsqueeze(0)
        y_out, _ = self.rnn(x_embedded, nationality_embedded)
        # ...

训练程序和结果

在这个例子中,我们介绍了预测字符序列以生成姓氏的任务。尽管在许多方面实现细节和训练例程与第 6 章中的序列分类示例相似,但还是有一些主要区别。在本节中,我们将重点介绍差异、使用的超参数和结果。

与之前的示例相比,在此示例中计算损失需要进行两个更改,因为我们在序列中的每个时间步进行预测。首先,我们将三维张量4重塑为二维张量(矩阵)以满足计算约束。其次,我们将允许可变长度序列的掩蔽索引与损失函数协调,以便损失在其计算中不使用掩蔽位置。

我们通过使用示例 7-5中显示的代码片段来处理这两个问题——三维张量和可变长度序列。首先,将预测和目标标准化为损失函数期望的大小(预测为二维,目标为一维)。现在,每一行代表一个样本:一个序列中的一个时间步长。那么,交叉熵损失为ignore_index设置为mask_index. 这具有损失函数的效果,它忽略了目标中匹配的任何位置ignore_index

示例 7-5。处理三维张量和序列范围的损失计算

def normalize_sizes(y_pred, y_true):
    ""归一化张量大小
    参数: 
        y_pred (torch.Tensor): 模型的输出
            如果是3维张量,则重塑为矩阵
        y_true (torch.Tensor): 目标预测
    """
    if len(y_pred.size()) == 3:
        y_pred = y_pred.contiguous().view(-1, y_pred.size(2))
    if len(y_true.size()) == 2:
        y_true = y_true.contiguous().view(-1)
    return y_pred, y_true


def sequence_loss(y_pred, y_true, mask_index):
    y_pred, y_true = normalize_sizes(y_pred, y_true)
    return F.cross_entropy(y_pred, y_true, ignore_index=mask_index)

使用这个修改损失计算后,我们构建了一个与本书中所有其他示例相似的训练例程。它开始于一次迭代一个小批量的训练数据集。对于每个小批量,模型的输出是根据输入计算的。因为我们在每个时间步执行预测,所以模型的输出是一个三维张量。使用前面描述sequence_loss()的和优化器,计算模型预测的误差信号并用于更新模型参数。

大部分型号超参数由字符词汇的大小决定。该大小是可以作为模型输入观察到的离散标记的数量以及每个时间步的输出分类中的类数。剩下的模型超参数是字符嵌入的大小和内部 RNN 隐藏状态的大小。例 7-6给出了这些超参数和训练选项。

示例 7-6。姓氏生成的超参数

args = Namespace(
    # 数据和路径信息
    surname_csv="data/surnames/surnames_with_splits.csv",
    vectorizer_file="vectorizer.json",
    model_state_file="model.pth",
    save_dir="model_storage/ch7/model1_unconditioned_surname_generation",
    # 或: save_dir="model_storage/ch7/model2_condition_surname_generation", 
    # 模型超参数
    char_embedding_size=32,
    rnn_hidden_size=32,
    # Training hyperparameters
    seed=1337,
    learning_rate=0.001,
    batch_size=128,
    num_epochs=100,
    early_stopping_criteria=5,
    # 运行时选项省略空间
)

尽管预测的每个字符的准确性是衡量模型性能的指标,但在此示例中,最好通过检查模型将生成什么样的姓氏来进行定性评估。为此,我们在步骤的修改版本上编写一个新循环forward()计算每个时间步的预测并将这些预测用作下一个时间步的输入的方法中。我们展示了示例 7-7中的代码。模型在每个时间步的输出是一个预测向量,它变成一个使用 softmax 函数的概率分布。使用概率分布,我们利用torch.multinomial()采样函数,它以与索引概率成比例的速率选择索引。抽样是一个随机过程,每次产生不同的输出。

例 7-7。从无条件生成模型中采样

def sample_from_model(model, vectorizer, num_samples=1, sample_size=20,
                      temperature=1.0):
    """从模型中采样一个索引序列
    参数: 
        model (SurnameGenerationModel): 训练好的模型
        vectorizer (SurnameVectorizer): 对应的vectorizer 
        num_samples (int): 样本的数量
        sample_size (int): 样本的最大长度
        temperature (float): 强调或拉平分布
            0.0 < temperature < 1.0 将使其更峰值
            温度 > 1.0 将使其更均匀
    返回:
        指数 ( torch.Tensor): 索引矩阵
    
        shape = (num_samples, sample_size)
    """
    begin_seq_index = [vectorizer.char_vocab.begin_seq_index 
                       for _ in range(num_samples)]
    begin_seq_index = torch.tensor(begin_seq_index,
                                   dtype=torch.int64).unsqueeze(dim=1)
    indices = [begin_seq_index]
    h_t = None

    for time_step in range(sample_size):
        x_t = indices[time_step]
        x_emb_t = model.char_emb(x_t)
        rnn_out_t, h_t = model.rnn(x_emb_t, h_t)
        prediction_vector = model.fc(rnn_out_t.squeeze(dim=1))
        probability_vector = F.softmax(prediction_vector / temperature, dim=1)
        indices.append(torch.multinomial(probability_vector, num_samples=1))
    indices = torch.stack(indices).squeeze().permute(1, 0)
    return indices

我们需要转换采样索引sample_from_model()函数转换为人类可读输出的字符串。如示例 7-8所示,为此,我们使用SequenceVocabulary用于向量化姓氏的 。在创建字符串时,我们只使用到索引的END-OF-SEQUENCE索引。这假设模型了解姓氏何时应该结束。

示例 7-8。将采样索引映射到姓氏字符串

def decode_samples(sampled_indices, vectorizer):
    """将索引转换为姓氏的字符串形式
    
    Args: 
        sampled_indices (torch.Tensor): 来自`sample_from_model`的索引
        向量化器(SurnameVectorizer): 对应的向量化器
    """
    decoded_surnames = []
    vocab = vectorizer.char_vocab
    
    for sample_index in range(sampled_indices.shape[0]):
        surname = ""
        for time_step in range(sampled_indices.shape[1]):
            sample_item = sampled_indices[sample_index, time_step].item()
            if sample_item == vocab.begin_seq_index:
                continue
            elif sample_item == vocab.end_seq_index:
                break
            else:
                surname += vocab.lookup_index(sample_item)
        decoded_surnames.append(surname)
    return decoded_surnames

使用这些函数,您可以检查模型的输出,如示例 7-9所示,以了解模型是否正在学习生成合理的姓氏。我们可以从检查输出中学到什么?我们可以看到,虽然这些姓氏似乎遵循了几种形态模式,但这些名字并没有明显地表现出来自一个民族或另一个民族。一种可能性是学习姓氏的一般模型会混淆不同国籍之间的字符分布。有条件SurnameGenerationModel的旨在处理这种情况。

示例 7-9。从无条件模型中采样

Input[0]
samples = sample_from_model(uncondition_model, vectorizer, 
                            num_samples=10) 
decode_samples(samples, vectorizer)
Output[0]
['Aqtaliby',
 'Yomaghev',
 'Mauasheev',
 'Unander',
 'Virrovo',
 'NInev',
 'Bukhumohe',
 'Can',
 'Rati',
 'Jzirmar']

对于有条件的SurnameGenerationModel,我们修改sample_from_model()函数以接受国籍指数列表而不是指定数量的样本。在示例 7-10中,修改后的函数使用带有国籍嵌入的国籍索引来构建 GRU 的初始隐藏状态。之后,采样过程与无条件模型完全相同。

示例 7-10。从序列模型中采样

def sample_from_model(model, vectorizer, nationalities, sample_size=20, 
                      temperature=1.0):
    """从模型中采样一个索引序列
    参数: 
        model (SurnameGenerationModel): 训练好的模型
        vectorizer (SurnameVectorizer): 对应的vectorizer 
        nationalities (list ): 代表国籍的整数列表
        sample_size (int): 样本的最大长度
        temperature (float): 强调或拉平分布
            0.0 < temperature < 1.0 将使其更峰值
            温度 > 1.0 将使其更均匀
    返回: 
        indices ( torch.Tensor): 索引矩阵
        shape = (num_samples, sample_size) 
    """
    num_samples = len(nationalities)
    begin_seq_index = [vectorizer.char_vocab.begin_seq_index 
                       for _ in range(num_samples)]
    begin_seq_index = torch.tensor(begin_seq_index, 
                                   dtype=torch.int64).unsqueeze(dim=1)
    indices = [begin_seq_index]
    nationality_indices = torch.tensor(nationalities, 
                                       dtype=torch.int64).unsqueeze(dim=0)
    h_t = model.nation_emb(nationality_indices)
    
    for time_step in range(sample_size):
        x_t = indices[time_step]
        x_emb_t = model.char_emb(x_t)
        rnn_out_t, h_t = model.rnn(x_emb_t, h_t)
        prediction_vector = model.fc(rnn_out_t.squeeze(dim=1))
        probability_vector = F.softmax(prediction_vector / temperature, dim=1)
        indices.append(torch.multinomial(probability_vector, num_samples=1))
    indices = torch.stack(indices).squeeze().permute(1, 0)
    return indices

使用条件向量进行采样的有用性意味着我们对生成的输出有影响。在示例 7-11中,我们迭代了国籍指数并从每个指数中取样。为了节省空间,我们只展示了一些输出。从这些输出中,我们可以看到该模型确实采用了一些姓氏的拼写模式。

示例 7-11。从有条件的 SurnameGenerationModel 采样(未显示所有输出)

Input[0]
for index in range(len(vectorizer.nationality_vocab)):
    nationality = vectorizer.nationality_vocab.lookup_index(index)

    print("Sampled for : ".format(nationality))

    sampled_indices = sample_from_model(model=conditioned_model,
                                        vectorizer=vectorizer,  
                                        nationalities=[index] * 3, 
                                        temperature=0.7)

    for sampled_surname in decode_samples(sampled_indices, 
                                          vectorizer):
        print("-  " + sampled_surname)
Output[0]
Sampled for Arabic: 
-  Khatso
-  Salbwa
-  Gadi
Sampled for Chinese: 
-  Lie
-  Puh
-  Pian
Sampled for German: 
-  Lenger
-  Schanger
-  Schumper
Sampled for Irish: 
-  Mcochin
-  Corran
-  O'Baintin
Sampled for Russian: 
-  Mahghatsunkov
-  Juhin
-  Karkovin
Sampled for Vietnamese: 
-  Lo
-  Tham
-  Tou

训练序列模型的提示和技巧

序列模型的训练可能具有挑战性,并且在此过程中会出现许多问题。在这里,我们总结了一些我们发现在我们的工作中有用的技巧和窍门,以及其他人在文献中报道的技巧。

如果可能,请使用门控变体

门控架构通过解决非门控变体的许多数值稳定性问题来简化训练。

如果可能的话,更喜欢 GRUs 而不是 LSTMs

GRU 提供性能几乎与 LSTM 相当,并且使用的参数和计算资源要少得多。幸运的是,从 PyTorch 的角度来看,使用 GRU 而不是 LSTM 只需要使用不同的Module类。

使用 Adam 作为优化器

在第6、78中,我们只使用Adam 作为优化器,有充分的理由:它是可靠的,并且通常比替代方案收敛得更快。对于序列模型尤其如此。如果由于某种原因你的模型没有与 Adam 收敛,切换到随机梯度下降可能会有所帮助。

渐变剪裁

如果你注意应用这些章节中学到的概念时的数值错误,在训练期间使用代码绘制梯度值。知道范围后,剪裁任何异常值。这将确保更顺畅的训练。在 PyTorch 中有一个有用的实用程序clip_grad_norm()可以为您执行此操作,如示例 7-12所示。一般来说,你应该养成剪裁渐变的习惯。

示例 7-12。在 PyTorch 中应用渐变裁剪

# 定义你的序列模型
model = ..
# 定义损失函数
loss_function = ..

# 训练循环
for _ in ...:
   ...
   model.zero_grad()
   output, hidden = model(data, hidden)
   loss = loss_function(output, targets)
   loss.backward()
   torch.nn.utils.clip_grad_norm(model.parameters(), 0.25)
   ...

提前停止

序列模型,很容易过拟合。我们建议您尽早停止训练程序,当在开发集上测量的评估错误开始上升。

第 8 章中,我们继续讨论序列模型,探索如何使用序列到序列模型预测和生成长度与输入不同的序列,并考虑其他变体。

 

以上是关于NLP自然语言处理的中间序列建模的主要内容,如果未能解决你的问题,请参考以下文章

NLP自然语言处理的前馈网络

NLP 类问题建模方案探索实践

NLP⚠️学不会打我! 半小时学会基本操作 7⚠️ Word2vec 电影影评建模

NLP⚠️学不会打我! 半小时学会基本操作 7⚠️ Word2vec 电影影评建模

自然语言处理(NLP)基于序列到序列的中-英机器翻译

学习笔记TF017:自然语言处理RNNLSTM