【DL】第11 章:文本深度学习

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

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

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

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

 🖍foreword

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

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

文章目录

11.1 自然语言处理:鸟瞰

11.2 准备文本数据

11.2.1 文本标准化

11.2.2 文本拆分(分词)

11.2.3 词汇索引

11.2.4 使用 TextVectorization 层

11.3 表示词组的两种方法:集合和序列

11.3.1 准备 IMDB 影评数据

11.3.2 将单词作为一个集合处理:词袋方法

11.3.3 将单词作为序列处理:序列模型方法

11.4 Transformer 架构

11.4.1 理解自注意力

11.4.2 多头注意力

11.4.3 Transformer 编码器

11.4.4 何时使用序列模型而不是词袋模型

11.5 超越文本分类:序列到序列学习

11.5.1 机器翻译示例

11.5.2 RNN 的序列到序列学习

11.5.3 使用 Transformer 进行序列到序列学习

概括


本章涵盖

  • 为机器学习应用预处理文本数据
  • 用于文本处理的词袋方法和序列建模方法
  • 变压器架构
  • 序列到序列的学习

11.1 自然语言处理:鸟瞰

在计算机科学中,我们将人类语言(如英语或普通话)称为“自然”语言,以区别于为机器设计的语言,如 Assembly、LISP 或 XML。每种机器语言都是经过设计的:它的出发点是一位人类工程师写下一套正式的规则来描述你可以用那种语言做出哪些陈述以及它们的含义。规则是第一位的,人们只有在规则集完成后才开始使用该语言。对于人类语言,情况正好相反:使用在先,规则在后出现。自然语言是由进化过程塑造的,就像生物有机体一样——这就是它“自然”的原因。它的“规则”,就像英语的语法一样,是事后形成的,经常被用户忽略或破坏。结果,虽然机器可读语言是高度结构化和严格的,使用精确的句法规则将来自固定词汇表的精确定义的概念编织在一起,但自然语言是混乱的——模棱两可、混乱、蔓延和不断变化。

创建可以理解自然语言的算法是一件大事:语言,尤其是文本,是我们大部分交流和文化生产的基础。互联网主要是文字。语言是我们存储几乎所有知识的方式。我们的思想很大程度上建立在语言之上。然而,理解自然语言的能力长期以来一直躲避机器。有些人曾经天真地认为你可以简单地写下“英语规则集”,就像写下 LISP 的规则集一样。构建自然语言处理 (NLP) 系统的早期尝试是因此,通过“应用语言学”的镜头。工程师和语言学家会手工制作复杂的规则集来执行基本的机器翻译或创建简单的聊天机器人——比如 1960 年代著名的 ELIZA 程序,它使用模式匹配来维持非常基本的对话。但是语言是一种反叛的东西:它不容易被形式化。经过几十年的努力,这些系统的能力仍然令人失望。

手工制定的规则一直是 1990 年代的主要方法。但从 1980 年代后期开始,更快的计算机和更大的数据可用性开始使更好的替代方案变得可行。当您发现自己在构建包含大量临时规则的系统时,作为一名聪明的工程师,您可能会开始问:“我可以使用数据集来自动化查找这些规则的过程吗?我可以在某种规则空间中搜索规则,而不必自己想出它们吗?” 就这样,你已经毕业做机器学习了。因此,在 1980 年代后期,我们开始看到自然语言处理的机器学习方法。最早的那些是基于决策树的——其目的实际上是为了自动开发先前系统的那种 if/then/else 规则。然后统计方法开始加速,从逻辑回归开始。随着时间的推移,学习的参数模型完全占据了主导地位,语言学被视为更多的障碍而不是有用的工具。早期的语音识别研究员 Frederick Jelinek 在 1990 年代开玩笑说:“每次我解雇一名语言学家,语音识别器的性能都会提高。”

这就是现代 NLP 的意义所在:使用机器学习和大型数据集赋予计算机不理解语言的能力,这是一个更崇高的目标,而是将一段语言作为输入并返回一些有用的东西,比如预测以下内容:

  • “这篇文章的主题是什么?” (文字分类)

  • “这段文字是否包含滥用行为?” (内容过滤)

  • “这段文字听起来是正面的还是负面的?” (情绪分析)

  • “这个不完整的句子中的下一个词应该是什么?” (语言建模)

  • “这个在德语里怎么说?” (翻译)

  • “你会如何用一段话来概括这篇文章?” (总结)

  • 等等

当然,在本章中请记住,您将训练的文本处理模型不会像人类一样理解语言。相反,他们只是在输入数据中寻找统计规律,结果证明这足以在许多简单的任务上表现良好。就像计算机视觉是应用于像素的模式识别一样,NLP 是应用于单词、句子和段落的模式识别。

NLP 的工具集——决策树、逻辑回归——从 1990 年代到 2010 年代初只看到了缓慢的演变。大多数研究重点是特征工程。当我在 2013 年赢得我在 Kaggle 上的第一次 NLP 比赛时,你猜对了,我的模型是基于决策树和逻辑回归的。然而,在 2014-2015 年左右,事情终于开始发生变化。多名研究人员开始研究循环神经网络的语言理解能力,特别是 LSTM——一种 1990 年代后期的序列处理算法,在此之前一直处于低调状态。

2015 年初,Keras 提供了第一个开源的、易于使用的 LSTM 实现,这正是对循环神经网络重新产生兴趣的大规模浪潮的开始——在此之前,只有“研究代码”可以不能轻易重复使用。然后从 2015 年到 2017 年,循环神经网络主导了蓬勃发展的 NLP 场景。尤其是双向 LSTM 模型,在许多重要任务(从摘要到问答再到机器翻译)上设置了最先进的技术。

最后,在 2017-2018 年左右,出现了一种新的架构来取代 RNN:Transformer,您将在本章的后半部分了解它。Transformers 在短时间内在该领域取得了相当大的进展,如今大多数 NLP 系统都基于它们。

让我们深入了解细节。本章将带您从最基础的内容到使用 Transformer 进行机器翻译。

11.2 准备文本数据

作为可微函数的深度学习模型只能处理数字张量:它们不能将原始文本作为输入。矢量化文本是将文本转换为数字张量的过程。文本向量化过程有多种形式和形式,但它们都遵循相同的模板(见图 11.1):

  • 首先,您将文本标准化以使其更易于处理,例如将其转换为小写或删除标点符号。

  • 您将文本拆分为单元(称为标记),例如字符、单词或单词组。这是称为标记化

  • 您将每个此类标记转换为数字向量。这通常涉及首先索引数据中存在的所有标记。

图 11.1 从原始文本到向量

让我们回顾一下这些步骤中的每一个。

11.2.1 文本标准化

考虑以下两句话:

  • “sunset came. i was staring at the Mexico sky. Isnt nature splendid??”

  • “Sunset came; I stared at the México sky. Isn’t nature splendid?”

它们非常相似——事实上,它们几乎相同。然而,如果你要将它们转换为字节字符串,它们最终会得到非常不同的表示,因为“i”和“I”是两个不同的字符,“Mexico”和“México”是两个不同的词,“isnt”不是'不是“不是”等等。机器学习模型不知道“i”和“I”是同一个字母,“é”是带重音的“e”,或者“凝视”和“凝视”是同一个动词。

文本标准化是特征工程的一种基本形式,旨在消除您不希望模型必须处理的编码差异。它也不是机器学习独有的——如果你正在构建一个搜索引擎,你必须做同样的事情。

最简单和最广泛的标准化方案之一是“转换为小写并删除标点符号”。我们的两句话会变成

  • “sunset came i was staring at the mexico sky isnt nature splendid”

  • “sunset came i stared at the méxico sky isnt nature splendid”

已经更近了。另一种常见的转换是将特殊字符转换为标准形式,例如将“é”替换为“e”,将“æ”替换为“ae”等。我们的代币“méxico”将变成“mexico”。

最后,在机器学习上下文中很少使用的更高级的标准化模式是词干提取:将术语的变体(例如动词的不同共轭形式)转换为单个共享表示,例如将“caught”和“been catch”变成“[catch]”或“cats”变成“[cat]”。使用词干,“was staring”和“stared”会变成“[stare]”,我们两个相似的句子最终会得到相同的编码:

  • “sunset came i [stare] at the mexico sky isnt nature splendid”

使用这些标准化技术,您的模型将需要更少的训练数据并且可以更好地泛化——它不需要大量的“日落”和“日落”示例来了解它们的含义相同,并且能够有意义“México”,即使它在其训练集中只看到“mexico”。当然,标准化也可能会删除一些信息,因此请始终牢记上下文:例如,如果您正在编写一个从采访文章中提取问题的模型,那么它肯定应该处理“?” 作为一个单独的标记而不是丢弃它,因为它是这个特定任务的有用信号。

11.2.2 文本拆分(分词)

文本标准化后,您需要将其分解为要矢量化的单元(标记),这一步称为标记化。您可以通过三种不同的方式执行此操作:

  • 词级标记化——标记在哪里是空格分隔(或标点分隔)的子字符串。这种方法的一个变体是在适用时将单词进一步拆分为子词——例如,将“staring”视为“star+ing”或将“called”视为“call+ed”。

  • N-gram 标记化——标记在哪里N个连续单词的组。例如,“the cat”或“he was”将是 2-gram 标记(也称为 bigrams)。

  • 字符级标记化——每个字符是它自己的令牌。在实践中,这种方案很少使用,您只能在特定的上下文中真正看到它,例如文本生成或语音识别。

通常,您将始终使用词级或 N-gram 标记化。有两种文本处理模型:一种是关心词序的模型,称为序列模型,另一种是把输入的词当作一个集合,丢弃它们的原始词。顺序,称为词袋模型。如果您正在构建一个序列模型,您将使用词级标记化,如果您正在构建一个词袋模型,您将使用 N-gram 标记化。N-gram 是一种人为地将少量本地词序信息注入模型的方法。在本章中,您将了解更多关于每种类型的模型以及何时使用它们的信息。

理解 N-gram 和词袋

单词 N-gram 是可以从句子中提取的N (或更少)个连续单词的组。相同的概念也可以应用于字符而不是单词。

这是一个简单的例子。考虑“猫坐在垫子上”这句话。它可以分解为以下 2-gram 集合:

{"the", "the cat", "cat", "cat sat", "sat",
 "sat on", "on", "on the", "the mat", "mat"}

它也可以分解为以下一组 3-gram:

{"the", "the cat", "cat", "cat sat", "the cat sat",
 "sat", "sat on", "on", "cat sat on", "on the",
 "sat on the", "the mat", "mat", "on the mat"}

这样的一套分别称为bag-of-2-gramsbag-of-3-grams。这里的术语“包”是指您正在处理一组标记而不是列表或序列的事实:标记没有特定的顺序。这一系列标记化方法称为袋(或N-gram 袋)。

因为bag-of-words不是一种保序分词方法(生成的分词被理解为一个集合,而不是一个序列,句子的一般结构丢失了),它倾向于用于浅层语言处理模型而不是深度学习模型。提取 N-gram 是特征工程的一种形式,深度学习序列模型摒弃了这种手动方法,取而代之的是分层特征学习。一维卷积网络、循环神经网络和 Transformer 能够通过查看连续的单词或字符序列来学习单词和字符组的表示,而无需明确告知这些组的存在。

11.2.3 词汇索引

将文本拆分为标记后,您需要将每个标记编码为数字表示。您可能会以无状态的方式执行此操作,例如将每个标记散列到一个固定的二进制向量中,但实际上,您要做的方式是为训练数据中找到的所有术语建立一个索引(“词汇表”),并为词汇表中的每个条目分配一个唯一的整数。

像这样的东西:

vocabulary = {} 
for text in dataset:
    text = standardize(text)
    tokens = tokenize(text)
    for token in tokens:
        if token not in vocabulary:
            vocabulary[token] = len(vocabulary)

然后,您可以将该整数转换为可由神经网络处理的向量编码,例如 one-hot 向量:

def one_hot_encode_token(token):
    vector = np.zeros((len(vocabulary),))
    token_index = vocabulary[token]
    vector[token_index] = 1 
    return vector

请注意,在此步骤中,通常将词汇表限制为仅在训练数据中找到的前 20,000 或 30,000 个最常见的单词。任何文本数据集都倾向于包含极其大量的独特术语,其中大多数只出现一次或两次——索引这些稀有术语会导致特征空间过大,其中大多数特征几乎没有信息内容。

还记得第 4 章和第 5 章中您在 IMDB 数据集上训练第一个深度学习模型的时候吗?您使用的数据keras.datasets.imdb已经预处理成整数序列,其中每个整数代表一个给定的单词。那时,我们使用设置num_words=10000,以便将我们的词汇表限制在训练数据中发现的前 10,000 个最常见的单词。

现在,这里有一个重要的细节我们不应该忽视:当我们在词汇索引中查找新标记时,它可能不一定存在。您的训练数据可能不包含“cherimoya”一词的任何实例(或者您可能将其从索引中排除,因为它太罕见了),因此这样做token_index = vocabulary["cherimoya"]可能会导致KeyError. 要处理这个问题,您应该使用“词汇表外”索引(缩写为OOV 索引)——一个不包含在索引中的任何标记的包罗万象。它通常是索引 1:你实际上在做token_index = vocabulary.get(token, 1). 在将整数序列解码回单词时,您将用“[UNK]”(您称之为“OOV 令牌”)之类的内容替换 1。

“为什么使用 1 而不是 0?” 你可能会问。那是因为 0 已经被占用了。您通常会使用两种特殊标记:OOV 标记(索引 1)和掩码标记(索引 0)。虽然OOV 标记的意思是“这是一个我们不认识的词”,掩码标记告诉我们“忽略我,我不是一个词”。您将特别使用它来填充序列数据:因为数据批次需要是连续的,所以一批序列数据中的所有序列必须具有相同的长度,因此应将较短的序列填充到最长序列的长度。[5, 7, 124, 4, 89]如果你想用序列和制作一批数据[8, 34, 21],它必须看起来像这样:

[[5,  7, 124, 4, 89]
 [8, 34,  21, 0,  0]]

您在第 4 章和第 5 章中使用的 IMDB 数据集的整数序列批次以这种方式填充了零。

11.2.4 使用 TextVectorization 层

到目前为止,我介绍的每一步都非常容易在纯 Python 中实现。也许你可以写这样的东西:

import string
  
class Vectorizer:
    def standardize(self, text):
        text = text.lower()
        return "".join(char for char in text 
                       if char not in string.punctuation)
  
    def tokenize(self, text):
        text = self.standardize(text)
        return text.split()
  
    def make_vocabulary(self, dataset):
        self.vocabulary = {"": 0, "[UNK]": 1}
        for text in dataset:
            text = self.standardize(text)
            tokens = self.tokenize(text)
            for token in tokens:
                if token not in self.vocabulary:
                    self.vocabulary[token] = len(self.vocabulary)
        self.inverse_vocabulary = dict(
            (v, k) for k, v in self.vocabulary.items())
  
    def encode(self, text):
        text = self.standardize(text)
        tokens = self.tokenize(text)
        return [self.vocabulary.get(token, 1) for token in tokens]
  
    def decode(self, int_sequence):
        return " ".join(
            self.inverse_vocabulary.get(i, "[UNK]") for i in int_sequence)
  
vectorizer = Vectorizer()
dataset = [           
    "I write, erase, rewrite",   ❶
    "Erase again, and then",     ❶
    "A poppy blooms.",           ❶
]
vectorizer.make_vocabulary(dataset)

诗人北志俳句

它完成了这项工作:

>>> test_sentence = "I write, rewrite, and still rewrite again" 
>>> encoded_sentence = vectorizer.encode(test_sentence)
>>> print(encoded_sentence)
[2, 3, 5, 7, 1, 5, 6]
>>> decoded_sentence = vectorizer.decode(encoded_sentence)
>>> print(decoded_sentence)
"i write rewrite and [UNK] rewrite again" 

但是,使用这样的东西不会很高效。在实践中,您将使用KerasTextVectorization层,快速高效,可以直接放入tf.data管道或 Keras 模型中。

这是TextVectorization图层的样子:

from tensorflow.keras.layers import TextVectorization
text_vectorization = TextVectorization(
    output_mode="int",                   ❶
)

将层配置为返回编码为整数索引的单词序列。还有其他几种可用的输出模式,稍后您将看到它们的实际作用。

默认情况下,该TextVectorization层将使用设置“转换为小写并删除标点符号”进行文本标准化,并使用“拆分空格”进行标记化。但重要的是,您可以为标准化和标记化提供自定义功能,这意味着该层足够灵活,可以处理任何用例。请注意,此类自定义函数应运行tf.string张量上,而不是常规的 Python 字符串!例如,默认层行为等价于以下内容:

import re 
import string 
import tensorflow as tf
  
def custom_standardization_fn(string_tensor):
    lowercase_string = tf.strings.lower(string_tensor)              ❶
    return tf.strings.regex_replace(                                ❷
        lowercase_string, f"[{re.escape(string.punctuation)}]", "")
  
def custom_split_fn(string_tensor):
    return tf.strings.split(string_tensor)                          ❸
 
text_vectorization = TextVectorization(
    output_mode="int",
    standardize=custom_standardization_fn,
    split=custom_split_fn,
)

将字符串转换为小写。

用空字符串替换标点符号。

在空白处拆分字符串。

要索引文本语料库的词汇,只需adapt()调用具有Dataset对象的图层产生字符串,或者只是一个 Python 字符串列表:

dataset = [
    "I write, erase, rewrite",
    "Erase again, and then",
    "A poppy blooms.",
]
text_vectorization.adapt(dataset)

请注意,您可以通过get_vocabulary()—this检索计算出的词汇表如果您需要将编码为整数序列的文本转换回单词,这将很有用。词汇表中的前两个条目是掩码标记(索引 0)和 OOV 标记(索引 1)。词汇表中的条目按频率排序,因此对于真实世界的数据集,“the”或“a”等非常常见的词会排在第一位。

清单 11.1 显示词汇表

>>> text_vectorization.get_vocabulary()
["", "[UNK]", "erase", "write", ...]

为了演示,让我们尝试编码然后解码一个例句:

>>> vocabulary = text_vectorization.get_vocabulary()
>>> test_sentence = "I write, rewrite, and still rewrite again" 
>>> encoded_sentence = text_vectorization(test_sentence)
>>> print(encoded_sentence)
tf.Tensor([ 7  3  5  9  1  5 10], shape=(7,), dtype=int64)
>>> inverse_vocab = dict(enumerate(vocabulary))
>>> decoded_sentence = " ".join(inverse_vocab[int(i)] for i in encoded_sentence)
>>> print(decoded_sentence)
"i write rewrite and [UNK] rewrite again" 

在管道中使用TextVectorizationtf.data或作为模型的一部分

重要的是,由于TextVectorization主要是字典查找操作,它不能在 GPU(或 TPU)上执行——只能在 CPU 上执行。因此,如果您在 GPU 上训练模型,您的TextVectorization层将在 CPU 上运行,然后再将其输出发送到 GPU。这具有重要的性能影响。

有两种方法可以使用我们的TextVectorization层。第一种选择是将其放入tf.data管道中,如下所示:

int_sequence_dataset = string_dataset.map(   ❶
    text_vectorization,
    num_parallel_calls=4)                    ❷

 string_dataset 将是一个产生字符串张量的数据集。

 num_parallel_calls 参数用于跨​​多个 CPU 内核并行化 map() 调用。

第二种选择是让它成为模型的一部分(毕竟它是一个 Keras 层),像这样:

text_input = keras.Input(shape=(), dtype="string")             ❶
vectorized_text = text_vectorization(text_input)               ❷
embedded_input = keras.layers.Embedding(...)(vectorized_text)  ❸
output = ...                                                   ❸
model = keras.Model(text_input, output)                        ❸

创建一个需要字符串的符号输入。

应用文本矢量化层。

您可以继续在顶部链接新层——只是您的常规功能 API 模型。

两者之间有一个重要区别:如果矢量化步骤是模型的一部分,它将与模型的其余部分同步发生。这意味着在每个训练步骤中,模型的其余部分(放置在 GPU 上)必须等待TextVectorization层的输出(放置在 CPU 上)准备好才能开始工作。同时,将层放入tf.data管道使您能够在 CPU 上对数据进行异步预处理:当 GPU 在一批矢量化数据上运行模型时,CPU 通过矢量化下一批原始字符串来保持忙碌。

因此,如果您在 GPU 或 TPU 上训练模型,您可能希望使用第一个选项以获得最佳性能。这就是我们将在本章所有实际示例中所做的事情。但是,在 CPU 上进行训练时,同步处理很好:无论您使用哪种选项,您都将获得 100% 的内核利用率。

现在,如果您要将我们的模型导出到生产环境,您可能希望发布一个接受原始字符串作为输入的模型,就像上面第二个选项的代码片段一样——否则您将不得不重新实现文本标准化和标记化您的生产环境(可能在 JavaScript 中?),您将面临引入小的预处理差异的风险,这会损害模型的准确性。值得庆幸的是,该TextVectorization层使您能够将文本预处理直接包含到您的模型中,从而使其更易于部署——即使您最初将该层用作tf.data管道的一部分。在侧栏“导出处理原始字符串的模型”中,您将了解如何导出包含文本预处理的仅推理训练模型。

您现在已经了解了有关文本预处理的所有知识——让我们进入建模阶段。

11.3 表示词组的两种方法:集合和序列

机器学习模型应该如何表示单个单词是一个相对没有争议的问题:它们是分类特征(来自预定义集合的值),我们知道如何处理这些。它们应该被编码为特征空间中的维度,或者作为类别向量(在这种情况下为词向量)。然而,一个更成问题的问题是如何对单词编织成句子的方式进行编码:词序。

自然语言中的顺序问题是一个有趣的问题:与时间序列的步骤不同,句子中的单词没有自然的、规范的顺序。不同的语言以非常不同的方式排列相似的词。例如,英语的句子结构与日语有很大的不同。即使在给定的语言中,您通常也可以通过稍微改组单词来以不同的方式说同样的事情。更进一步,如果你完全随机化一个短句中的单词,你仍然可以大致弄清楚它在说什么——尽管在许多情况下似乎会出现明显的歧义。顺序显然很重要,但它与意义的关系并不简单。

如何表示词序是产生不同类型 NLP 架构的关键问题。您可以做的最简单的事情就是丢弃顺序并将文本视为一组无序的单词——这为您提供了词袋模型. 你也可以决定单词应该严格按照它们出现的顺序进行处理,一次一个,就像时间序列中的步骤一样——然后你可以利用上一章的循环模型。最后,一种混合​​方法也是可能的:Transformer 架构在技术上与顺序无关,但它将词位置信息注入到它处理的表示中,这使得它能够同时查看句子的不同部分(与 RNN 不同),同时仍然是订单感知。因为它们考虑了词序,所以 RNN 和 Transformer 都是称为序​​列模型

从历史上看,机器学习在 NLP 中的大多数早期应用只涉及词袋模型。随着循环神经网络的重生,对序列模型的兴趣在 2015 年才开始上升。今天,这两种方法仍然适用。让我们看看它们是如何工作的,以及何时利用它们。

我们将在著名的文本分类基准上演示每种方法:IMDB 电影评论情感分类数据集。在第 4 章和第 5 章中,您使用了 IMDB 数据集的预矢量化版本;现在,让我们处理原始 IMDB 文本数据,就像处理现实世界中的新文本分类问题一样。

11.3.1 准备 IMDB 影评数据

让我们首先从 Andrew Maas 的斯坦福页面下载数据集并解压缩:

!curl -O https:/ /ai.stanford.edu/~amaas/data/sentiment/aclImdb_v1.tar.gz
!tar -xf aclImdb_v1.tar.gz

剩下一个名为 aclImdb 的目录,其结构如下:

aclImdb/
...train/
......pos/
......neg/
...test/
......pos/
......neg/

例如,train/pos/ 目录包含一组 12,500 个文本文件,每个文件都包含用作训练数据的正面情绪电影评论的文本正文。负面情绪评论位于“负面”目录中。总共有 25,000 个文本文件用于训练,另外 25,000 个用于测试。

那里还有一个 train/unsup 子目录,我们不需要。让我们删除它:

!rm -r aclImdb/train/unsup

查看其中一些文本文件的内容。无论您使用的是文本数据还是图像数据,请记住在深入建模之前始终检查数据的外观。它将使您对模型实际在做什么有直觉:

!cat aclImdb/train/pos/4077_10.txt

接下来,让我们通过在新目录 aclImdb/val 中设置 20% 的训练文本文件来准备验证集:

import os, pathlib, shutil, random
  
base_dir = pathlib.Path("aclImdb")
val_dir = base_dir / "val" 
train_dir = base_dir / "train" 
for category in ("neg", "pos"):
    os.makedirs(val_dir / category)
    files = os.listdir(train_dir / category)
    random.Random(1337).shuffle(files)              ❶
    num_val_samples = int(0.2 * len(files))         ❷
    val_files = files[-num_val_samples:]            ❷
    for fname in val_files:                         ❸
        shutil.move(train_dir / category / fname,   ❸
                    val_dir / category / fname)     ❸

使用种子随机排列训练文件列表,以确保我们每次运行代码时都获得相同的验证集。

抽取 20% 的训练文件用于验证。

将文件移动到 aclImdb/val/neg 和 aclImdb/val/pos。

请记住,在第 8 章中,我们如何使用该image_dataset_from_directory实用程序为目录结构创建一批Dataset图像及其标签?您可以使用对文本文件执行完全相同的操作text_dataset_from_directory实用程序。Dataset让我们为训练、验证和测试创建三个对象:

from tensorflow import keras
batch_size = 32 
  
train_ds = keras.utils.text_dataset_from_directory(     ❶
    "aclImdb/train", batch_size=batch_size
)
val_ds = keras.utils.text_dataset_from_directory(
    "aclImdb/val", batch_size=batch_size
)
test_ds = keras.utils.text_dataset_from_directory(
    "aclImdb/test", batch_size=batch_size
)

运行这一行应该输出“Found 20000 files分为2个类”;如果您看到“找到属于 3 个类的 70000 个文件”,则表示您忘记删除 aclImdb/train/unsup 目录。

这些数据集产生的输入是 TensorFlowtf.string张量,目标是int32编码值“0”或“1”的张量。

清单 11.2 显示第一批的形状和数据类型

>>> for inputs, targets in train_ds:
>>>     print("inputs.shape:", inputs.shape)
>>>     print("inputs.dtype:", inputs.dtype)
>>>     print("targets.shape:", targets.shape)
>>>     print("targets.dtype:", targets.dtype)
>>>     print("inputs[0]:", inputs[0])
>>>     print("targets[0]:", targets[0])
>>>     break
inputs.shape: (32,)
inputs.dtype: <dtype: "string">
targets.shape: (32,)
targets.dtype: <dtype: "int32">
inputs[0]: tf.Tensor(b"This string contains the movie review.", shape=(), dtype=string)
targets[0]: tf.Tensor(1, shape=(), dtype=int32)

可以了,好了。现在让我们尝试从这些数据中学习一些东西。

11.3.2 将单词作为一个集合处理:词袋方法

对一段文本进行编码以供机器学习模型处理的最简单方法是丢弃顺序并将其视为一组标记(“袋子”)。您可以查看单个单词(unigrams),也可以尝试通过查看连续标记组(N-grams)来恢复一些本地顺序信息。

具有二进制编码的单个单词(UNIGRAMS)

如果你使用一袋单个单词,句子“the cat sat on the mat”变成

{"cat", "mat", "on", "sat", "the"}

这种编码的主要优点是您可以将整个文本表示为单个向量,其中每个条目都是给定单词的存在指示符。例如,使用二进制编码(multi-hot),您可以将文本编码为具有与词汇表中的单词一样多的维度的向量——几乎所有地方都有 0,而一些 1 用于对文本中存在的单词进行编码的维度。这就是我们在第 4 章和第 5 章中处理文本数据时所做的。让我们在任务中尝试一下。

首先,让我们用一个层处理我们的原始文本数据集,TextVectorization以便它们产生多热编码的二进制词向量。我们的层只会查看单个单词(即unigrams)。

清单 11.3 使用TextVectorization层预处理我们的数据集

text_vectorization = TextVectorization(
    max_tokens=20000,                               ❶
    output_mode="multi_hot",                        ❷
)
text_only_train_ds = train_ds.map(lambda x, y: x)   ❸
text_vectorization.adapt(text_only_train_ds)        ❹
 
binary_1gram_train_ds = train_ds.map(               ❺
    lambda x, y: (text_vectorization(x), y),        ❺
    num_parallel_calls=4)                           ❺
binary_1gram_val_ds = val_ds.map(                   ❺
    lambda x, y: (text_vectorization(x), y),        ❺
    num_parallel_calls=4)                           ❺
binary_1gram_test_ds = test_ds.map(                 ❺
    lambda x, y: (text_vectorization(x), y),        ❺
    num_parallel_calls=4)                           ❺

将词汇限制为 20,000 个最常用的单词。否则,我们将索引训练数据中的每个单词——可能有数以万计的术语只出现一次或两次,因此无法提供信息。一般来说,20,000 是文本分类的正确词汇量。

将输出标记编码为多热二进制向量。

准备一个只产生原始文本输入(无标签)的数据集。

使用该数据集通过 adapt() 方法索引数据集词汇。

准备我们的训练、验证和测试数据集的处理版本。确保指定 num_parallel_calls 以利用多个 CPU 内核。

您可以尝试检查其中一个数据集的输出。

清单 11.4 检查我们的二元一元数据集的输出

>>> for inputs, targets in binary_1gram_train_ds:
>>>     print("inputs.shape:", inputs.shape)
>>>     print("inputs.dtype:", inputs.dtype)
>>>     print("targets.shape:", targets.shape)
>>>     print("targets.dtype:", targets.dtype)
>>>     print("inputs[0]:", inputs[0])
>>>     print("targets[0]:", targets[0])
>>>     break
inputs.shape: (32, 20000)                                                   ❶
inputs.dtype: <dtype: "float32">
targets.shape: (32,)
targets.dtype: <dtype: "int32">
inputs[0]: tf.Tensor([1. 1. 1. ... 0. 0. 0.], shape=(20000,), dtype=float32)❷
targets[0]: tf.Tensor(1, shape=(), dtype=int32)

输入是 20,000 维向量的批次。

这些向量完全由 1 和 0 组成。

接下来,让我们编写一个可重用的模型构建函数,我们将在本节的所有实验中使用它。

清单 11.5 我们的模型构建工具

from tensorflow import keras 
from tensorflow.keras import layers
  
def get_model(max_tokens=20000, hidden_dim=16):
    inputs = keras.Input(shape=(max_tokens,))
    x = layers.Dense(hidden_dim, activation="relu")(inputs)
    x = layers.Dropout(0.5)(x)
    outputs = layers.Dense(1, activation="sigmoid")(x)
    model = keras.Model(inputs, outputs)
    model.compile(optimizer="rmsprop",
                  loss="binary_crossentropy",
                  metrics=["accuracy"])
    return model

最后,让我们训练和测试我们的模型。

清单 11.6 训练和测试二元一元模型

model = get_model()
model.summary()
callbacks = [
    keras.callbacks.ModelCheckpoint("binary_1gram.keras",
                                    save_best_only=True)
]
model.fit(binary_1gram_train_ds.cache(),                   ❶
          validation_data=binary_1gram_val_ds.cache(),     ❶
          epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("binary_1gram.keras") 
print(f"Test acc: {model.evaluate(binary_1gram_test_ds)[1]:.3f}")

我们在数据集上调用 cache() 以将它们缓存在内存中:这样,我们将只在第一个 epoch 期间进行一次预处理,并且我们将在接下来的 epoch 中重用预处理过的文本。只有当数据足够小以适合内存时,才能做到这一点。

这使我们的测试准确率达到 89.2%:还不错!请注意,在这种情况下,由于数据集是一个平衡的二分类数据集(正样本与负样本一样多),我们在不训练实际模型的情况下可以达到的“朴素基线”只有 50%。同时,在不利用外部数据的情况下,在该数据集上可以获得的最佳分数约为 95% 的测试准确率。

二进制编码的二元组

当然,丢弃词序是非常简化的,因为即使是原子概念也可以通过多个词来表达:“美国”一词所传达的概念与“国家”和“联合”两个词的含义截然不同。出于这个原因,您通常最终会通过查看 N-gram 而不是单个单词(最常见的是 bigram),将本地顺序信息重新注入到您的词袋表示中。

使用二元组,我们的句子变成

{"the", "the cat", "cat", "cat sat", "sat",
 "sat on", "on", "on the", "the mat", "mat"}

TextVectorization层可以配置为返回任意 N-gram:bigrams、trigrams 等。只需传递一个ngrams=N参数,如下面的清单所示。

清单 11.7 配置TextVectorization层以返回二元组

text_vectorization = TextVectorization(
    ngrams=2,
    max_tokens=20000,
    output_mode="multi_hot",
)

让我们测试一下我们的模型在训练这种二进制编码的二元组时的表现。

清单 11.8 训练和测试二元二元模型

text_vectorization.adapt(text_only_train_ds)
binary_2gram_train_ds = train_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
binary_2gram_val_ds = val_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
binary_2gram_test_ds = test_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
 
model = get_model()
model.summary()
callbacks = [
    keras.callbacks.ModelCheckpoint("binary_2gram.keras",
                                    save_best_only=True)
]
model.fit(binary_2gram_train_ds.cache(),
          validation_data=binary_2gram_val_ds.cache(),
          epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("binary_2gram.keras")
print(f"Test acc: {model.evaluate(binary_2gram_test_ds)[1]:.3f}")

我们现在获得了 90.4% 的测试准确率,这是一个显着的进步!事实证明,本地订单非常重要。

使用 TF-IDF 编码的 BIGRAMS

您还可以通过计算每个单词或 N-gram 出现的次数来向此表示添加更多信息,也就是说,通过在文本上获取单词的直方图:

{"the": 2, "the cat": 1, "cat": 1, "cat sat": 1, "sat": 1,
 "sat on": 1, "on": 1, "on the": 1, "the mat: 1", "mat": 1}

如果您正在进行文本分类,那么了解一个单词在样本中出现的次数至关重要:任何足够长的电影评论都可能包含“可怕”这个词,而不管情绪如何,但一篇评论包含“可怕”这个词的许多实例很可能是负面的。

TextVectorization以下是您如何计算图层的二元组出现次数。

清单 11.9 配置TextVectorization层以返回令牌计数

text_vectorization = TextVectorization(
    ngrams=2,
    max_tokens=20000,
    output_mode="count"
)

现在,当然,无论文本是关于什么,有些词肯定会比其他词更频繁地出现。“the”、“a”、“is”和“are”这些词总是会支配你的字数直方图,淹没其他词——尽管在分类上下文中它们几乎是无用的特征。我们如何解决这个问题?

你已经猜到了:通过标准化。我们可以通过减去均值并除以方差(在整个训练数据集中计算)来归一化字数。这是有道理的。除了大多数向量化的句子几乎完全由零组成(我们之前的示例具有 12 个非零条目和 19,988 个零条目),这是一个称为“稀疏性”的属性。这是一个很好的属性,因为它极大地减少了计算负载并降低了过度拟合的风险。如果我们从每个特征中减去平均值,我们就会破坏稀疏性。因此,我们使用的任何归一化方案都应该是只除法的。那么,我们应该使用什么作为分母呢?最好的做法是搭配一些东西称为TF-IDF 归一化——TF-IDF 代表“词频,逆文档频率”。

了解 TF-IDF 归一化

给定术语在文档中出现的次数越多,该术语对于理解文档的内容就越重要。同时,术语在数据集中所有文档中出现的频率也很重要:几乎每个文档中出现的术语(如“the”或“a”)并不是特别有用,而仅出现在所有文本的一小部分(如“Herzog”)非常独特,因此很重要。TF-IDF 是融合这两种思想的度量。它通过采用“词频”来加权给定词,即词在当前文档中出现的次数,并将其除以“文档频率”的度量,“文档频率”估计该词在数据集中出现的频率。您将按如下方式计算它:

def tfidf(term, document, dataset):
    term_freq = document.count(term)
    doc_freq = math.log(sum(doc.count(term) for doc in dataset) + 1)
    return term_freq / doc_freq

TF-IDF 非常常见,以至于它内置在TextVectorization层中。开始使用它所需要做的就是将output_mode参数切换为"tf_idf".

清单 11.10 配置TextVectorization返回 TF-IDF 加权输出

text_vectorization = TextVectorization(
    ngrams=2,
    max_tokens=20000,
    output_mode="tf_idf",
)

让我们用这个方案训练一个新模型。

清单 11.11 训练和测试 TF-IDF 二元模型

text_vectorization.adapt(text_only_train_ds)      ❶
 
tfidf_2gram_train_ds = train_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
tfidf_2gram_val_ds = val_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
tfidf_2gram_test_ds = test_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
 
model = get_model()
model.summary()
callbacks = [
    keras.callbacks.ModelCheckpoint("tfidf_2gram.keras",
                                    save_best_only=True)
]
model.fit(tfidf_2gram_train_ds.cache(),
          validation_data=tfidf_2gram_val_ds.cache(),
          epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("tfidf_2gram.keras")
print(f"Test acc: {model.evaluate(tfidf_2gram_test_ds)[1]:.3f}")

 adapt() 调用将学习除词汇表之外的 TF-IDF 权重。

这使我们在 IMDB 分类任务上获得了 89.8% 的测试准确率:在这种情况下似乎不是特别有用。然而,对于许多文本分类数据集,与普通二进制编码相比,使用 TF-IDF 时通常会看到一个百分点的增长。

导出处理原始字符串的模型

在前面的示例中,我们将文本标准化、拆分和索引作为tf.data管道的一部分。但是如果我们想导出一个独立于该管道的独立模型,我们应该确保它包含自己的文本预处理(否则,您必须在生产环境中重新实现,这可能具有挑战性或可能导致两者之间的细微差异训练数据和生产数据)。幸运的是,这很容易。

只需创建一个新模型来重用您的TextVectorization层并将您刚刚训练的模型添加到其中:

inputs = keras.Input(shape=(1,), dtype="string")   ❶
processed_inputs = text_vectorization(inputs)      ❷
outputs = model(processed_inputs)                  ❸
inference_model = keras.Model(inputs, outputs)     ❹

一个输入样本将是一个字符串。

应用文本预处理。

应用之前训练的模型。

实例化端到端模型。

生成的模型可以处理成批的原始字符串:

import tensorflow as tf
raw_text_data = tf.convert_to_tensor([
    ["That was an excellent movie, I loved it."],
])
predictions = inference_model(raw_text_data) 
print(f"{float(predictions[0] * 100):.2f} percent positive")

11.3.3 将单词作为序列处理:序列模型方法

这些过去的几个例子清楚地表明词序很重要:基于顺序的特征的手动工程,例如二元组,可以很好地提高准确性。现在请记住:深度学习的历史是从手动特征工程转向让模型通过仅接触数据来学习自己的特征。如果我们不是手动制作基于顺序的特征,而是将模型暴露于原始单词序列并让它自己找出这些特征,那会怎样?这就是序列模型的意义所在。

要实现序列模型,首先将输入样本表示为整数索引序列(一个整数代表一个单词)。然后,您将每个整数映射到一个向量以获得向量序列。最后,您可以将这些向量序列输入到一组层中,这些层可以交叉关联来自相邻向量的特征,例如 1D 卷积网络、RNN 或 Transformer。

在 2016-2017 年左右的一段时间里,双向 RNN(特别是双向 LSTM)被认为是序列建模的最新技术。由于您已经熟悉此架构,因此我们将在第一个序列模型示例中使用它。然而,现在序列建模几乎普遍使用 Transformer 完成,我们将在稍后介绍。奇怪的是,一维卷积在 NLP 中从来没有很流行,尽管根据我自己的经验,深度可分离的一维卷积的剩余堆栈通常可以在大大降低计算成本的情况下实现与双向 LSTM 相当的性能。

第一个实际例子

让我们在实践中尝试第一个序列模型。首先,让我们准备返回整数序列的数据集。

清单 11.12 准备整数序列数据集

from tensorflow.keras import layers
  
max_length = 600 
max_tokens = 20000 
text_vectorization = layers.TextVectorization(
    max_tokens=max_tokens,
    output_mode="int",
    output_sequence_length=max_length,     ❶
)
text_vectorization.adapt(text_only_train_ds)
 
int_train_ds = train_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
int_val_ds = val_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
int_test_ds = test_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)

为了保持可管理的输入大小,我们将在前 600 个单词之后截断输入。这是一个合理的选择,因为平均评论长度为 233 字,只有 5% 的评论超过 600 字。

接下来,让我们制作一个模型。将整数序列转换为向量序列的最简单方法是对整数进行 one-hot 编码(每个维度将代表词汇表中的一个可能项)。在这些 one-hot 向量之上,我们将添加一个简单的双向 LSTM。

清单 11.13 建立在 one-hot 编码向量序列上的序列模型

import tensorflow as tf
inputs = keras.Input(shape=(None,), dtype="int64")    ❶
embedded = tf.one_hot(inputs, depth=max_tokens)       ❷
x = layers.Bidirectional(layers.LSTM(32))(embedded)   ❸
x = layers.Dropout(0.5)(x) 
outputs = layers.Dense(1, activation="sigmoid")(x)    ❹
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop",
              loss="binary_crossentropy",
              metrics=["accuracy"])
model.summary()

一个输入是一个整数序列。

将整数编码为二进制 20,000 维向量。

添加一个双向 LSTM。

最后,添加一个分类层。

现在,让我们训练我们的模型。

清单 11.14 训练第一个基本序列模型

callbacks = [
    keras.callbacks.ModelCheckpoint("one_hot_bidir_lstm.keras",
                                    save_best_only=True)
]
model.fit(int_train_ds, validation_data=int_val_ds, epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("one_hot_bidir_lstm.keras") 
print(f"Test acc: {model.evaluate(int_test_ds)[1]:.3f}")

第一个观察:这个模型训练非常慢,特别是与上一节的轻量级模型相比。这是因为我们的输入非常大:每个输入样本都被编码为一个大小矩阵(600, 20000)(每个样本 600 个单词,20,000 个可能的单词)。对于单个电影评论来说,这是 12,000,000 次浮动。我们的双向 LSTM 有很多工作要做。其次,该模型只能达到 87% 的测试准确率——它的性能几乎不如我们的(非常快的)二元一元模型。

显然,使用 one-hot 编码将单词转换为向量,这是我们能做的最简单的事情,但并不是一个好主意。有一个更好的方法:词嵌入

理解词嵌入

至关重要的是,当您通过 one-hot 编码对某些内容进行编码时,您正在做出特征工程决策。您正在向模型中注入关于特征空间结构的基本假设。该假设是您正在编码的不同标记都是相互独立的:实际上,单热向量都是相互正交的。就文字而言,这种假设显然是错误的。单词形成了一个结构化的空间:它们彼此共享信息。“movie”和“film”这两个词在大多数句子中是可以互换的,所以表示“movie”的向量不应该与表示“film”的向量正交——它们应该是相同的向量,或者足够接近。

为了更抽象一点,两者之间的几何关系词向量应该反映这些之间的语义关系字。例如,在一个合理的词向量空间中,您会期望同义词嵌入到相似的词向量中,并且通常,您会期望任意两个词向量之间的几何距离(例如余弦距离或 L2 距离)与相关词之间的“语义距离”。表示不同事物的词应该彼此远离,而相关词应该更近。

词嵌入是实现这一点的词的向量表示:它们将人类语言映射到结构化的几何空间中。

而通过 one-hot 编码获得的向量是二进制的、稀疏的(主要由零组成)和非常高维的(与词汇表中的单词数量相同的维度),而词嵌入是低维浮点向量(即密集向量,而不是稀疏向量);见图 11.2。在处理非常大的词汇表时,通常会看到 256 维、512 维或 1,024 维的词嵌入。另一方面,one-hot 编码词通常会导致 20,000 维或更大的向量(在这种情况下捕获 20,000 个标记的词汇表)。因此,词嵌入将更多信息打包到更少的维度中。

图 11.2 从 one-hot 编码或散列获得的词表示是稀疏的、高维的和硬编码的。词嵌入是密集的、相对低维的,并且是从数据中学习的。

除了是密集表示之外,词嵌入也是结构化的表示,它们的结构是从数据中学习的。相似的词嵌入在靠近的位置,而且嵌入空间中的特定方向是有意义的。为了更清楚地说明这一点,让我们看一个具体的例子。

在图 11.3 中,四个单词嵌入在 2D 平面上:catdogwolftiger。使用我们在这里选择的向量表示,这些词之间的一些语义关系可以被编码为几何变换。例如,同一个向量允许我们从老虎,从:这个向量可以解释为“从宠物到野生动物”的向量。同样,另一个向量让我们从,从老虎,可以解释为“从犬到猫”的向量。

图 11.3 一个词嵌入空间的玩具示例

在现实世界的词嵌入空间中,有意义的几何变换的常见示例是“性别”向量和“复数”向量。例如,通过向向量“king”添加“female”向量,我们得到向量“queen”。通过添加“复数”向量,我们得到“国王”。词嵌入空间通常具有数千个这样的可解释和潜在有用的向量。

让我们看看如何在实践中使用这样的嵌入空间。有两种获取词嵌入的方法:

  • 与您关心的主要任务(例如文档分类或情绪预测)一起学习词嵌入。在此设置中,您从随机词向量开始,然后以与学习神经网络权重相同的方式学习词向量。

  • 将使用与您尝试解决的任务不同的机器学习任务预先计算的词嵌入模型加载到模型中。这些是称为预训练词嵌入

让我们回顾一下这些方法。

使用嵌入层学习词嵌入

是否有一些理想的词嵌入空间可以完美地映射人类语言并可以用于任何自然语言处理任务?可能,但我们还没有计算出任何类似的东西。此外,不存在人类语言之类的东西——有许多不同的语言,它们彼此并不同构,因为语言是特定文化和特定背景的反映。但更务实的是,什么是好的词嵌入空间在很大程度上取决于你的任务:英语电影评论情感分析模型的完美词嵌入空间可能看起来与英语法律的完美嵌入空间不同-文档分类模型,因为某些语义关系的重要性因任务而异。

因此,为每个新任务学习一个新的嵌入空间是合理的。幸运的是,反向传播让这一切变得容易,而 Keras 让它变得更容易。这是关于学习权重一层:Embedding一层。

清单 11.15 实例化一个Embedding

embedding_layer = layers.Embedding(input_dim=max_tokens, output_dim=256)   ❶

 Embedding 层至少需要两个参数:可能的标记数和嵌入的维数(此处为 256)。

Embedding层最好理解为将整数索引(代表特定单词)映射到密集向量的字典。它将整数作为输入,在内部字典中查找这些整数,并返回相关的向量。它实际上是一个字典查找(见图 11.4)。

图 11.4Embedding图层

Embedding层将一个 rank-2 整数张量作为输入,形状为(batch_size, sequence_length),其中每个条目是一个整数序列。然后该层返回 shape 的 3D 浮点张量(batch_size, sequence_length, embedding_ dimensionality)

当你实例化一个Embedding层时,它的权重(它的内部令牌向量字典)最初是随机的,就像任何其他层一样。在训练期间,这些词向量通过反向传播逐渐调整,将空间结构化为下游模型可以利用的东西。一旦完全训练,嵌入空间将显示大量结构——一种专门针对您正在训练模型的特定问题的结构。

让我们构建一个包含一个Embedding层的模型并在我们的任务上对其进行基准测试。

清单 11.16 使用Embedding从头开始训练的层的模型

inputs = keras.Input(shape=(None,), dtype="int64")
embedded = layers.Embedding(input_dim=max_tokens, output_dim=256)(inputs)
x = layers.Bidirectional(layers.LSTM(32))(embedded)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop",
              loss="binary_crossentropy",
              metrics=["accuracy"])
model.summary()
  
callbacks = [
    keras.callbacks.ModelCheckpoint("embeddings_bidir_gru.keras",
                                    save_best_only=True)
]
model.fit(int_train_ds, validation_data=int_val_ds, epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("embeddings_bidir_gru.keras") 
print(f"Test acc: {model.evaluate(int_test_ds)[1]:.3f}")

它的训练速度比 one-hot 模型快得多(因为 LSTM 只需要处理 256 维向量而不是 20,000 维),并且其测试准确度相当(87%)。然而,我们离我们的基本二元模型的结果还有一段距离。部分原因仅仅是该模型查看的数据略少:bigram 模型处理完整的评论,而我们的序列模型在 600 个单词后截断序列。

了解填充和遮罩

这里稍微损害模型性能的一件事是我们的输入序列充满了零。这来自于我们在(with equal to 600) 中使用的output_sequence_length=max_ length选项:超过 600 个标记的句子被截断为 600 个标记的长度,小于 600 个标记的句子在末尾用零填充,以便它们可以连接在一起与其他序列形成连续的批次。TextVectorizationmax_length

我们使用双向 RNN:两个 RNN 层并行运行,一个按自然顺序处理令牌,另一个按相反的顺序处理相同的令牌。以自然顺序查看标记的 RNN 将在最后一次迭代中只查看编码填充的向量——如果原始句子很短,可能会进行数百次迭代。存储在 RNN 内部状态中的信息会随着这些无意义的输入而逐渐淡出。

我们需要一些方法来告诉 RNN 它应该跳过这些迭代。有一个 API:masking

Embedding层能够生成与其输入数据相对应的“掩码”。此掩码是形状为 1 和 0(或 True/False 布尔值)的张量(batch_size, sequence_length),其中条目mask[i, t]指示应跳过t样本的时间步长的位置(如果为 0 或 False,i则将跳过时间步长,否则进行处理)。mask[i, t]

默认情况下,这个选项是不活动的——你可以通过传递mask_zero=True给你的Embedding层来打开它。您可以使用compute_mask()方法:

>>> embedding_layer = Embedding(input_dim=10, output_dim=256, mask_zero=True)
>>>  some_input = [
...  [4, 3, 2, 1, 0, 0, 0],
...  [5, 4, 3, 2, 1, 0, 0],
...  [2, 1, 0, 0, 0, 0, 0]]
>>> mask = embedding_layer.compute_mask(some_input)
<tf.Tensor: shape=(3, 7), dtype=bool, numpy=
array([[ True,  True,  True,  True, False, False, False],
       [ True,  True,  True,  True,  True, False, False],
       [ True,  True, False, False, False, False, False]])>

在实践中,您几乎不需要手动管理口罩。相反,Keras 会自动将遮罩传递给能够处理它的每一层(作为附加到它所代表的序列的一段元数据)。RNN 层将使用此掩码来跳过掩码步骤。如果您的模型返回整个序列,则损失函数也将使用掩码来跳过输出序列中的掩码步骤。

让我们尝试在启用掩码的情况下重新训练我们的模型。

清单 11.17 使用Embedding启用了遮罩的图层

inputs = keras.Input(shape=(None,), dtype="int64")
embedded = layers.Embedding(
    input_dim=max_tokens, output_dim=256, mask_zero=True)(inputs)
x = layers.Bidirectional(layers.LSTM(32))(embedded)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop",
              loss="binary_crossentropy",
              metrics=["accuracy"])
model.summary()
callbacks = [
    keras.callbacks.ModelCheckpoint("embeddings_bidir_gru_with_masking.keras",
                                    save_best_only=True)
]
model.fit(int_train_ds, validation_data=int_val_ds, epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("embeddings_bidir_gru_with_masking.keras") 
print(f"Test acc: {model.evaluate(int_test_ds)[1]:.3f}")

这次我们达到了 88% 的测试准确率——这是一个很小但很明显的改进。

使用预训练的词嵌入

有时,可用的训练数据太少,以至于无法单独使用数据来学习词汇表的适当的特定任务嵌入。在这种情况下,您可以从预先计算的嵌入空间中加载嵌入向量,而不是与您要解决的问题一起学习词嵌入,该嵌入空间是高度结构化的并具有有用的属性——捕捉语言结构的一般方面。在自然语言处理中使用预训练的词嵌入的基本原理与在图像分类中使用预训练的卷积网络非常相似:你没有足够的数据来自己学习真正强大的特征,但你希望你需要的特征是相当通用——即常见的视觉特征或语义特征。在这种情况下,

这种词嵌入通常使用词出现统计(关于句子或文档中同时出现的词的观察),使用各种技术来计算,其中一些涉及神经网络,另一些则不涉及。Bengio 等人最初探索了以无监督方式计算的密集、低维的词嵌入空间的想法。在 2000 年代初期,1但它只是在最着名和最成功的词嵌入方案之一发布后才开始在研究和行业应用中起飞:Word2Vec 算法( https://code.google.com/archive/p/word2vec),由 Google 的 Tomas Mikolov 于 2013 年开发。Word2Vec 维度捕获特定的语义属性,例如性别。

1 Yoshua Bengio 等人,“神经概率语言模型” ,机器学习研究杂志(2003 年)。

您可以在 KerasEmbedding层中下载和使用各种预计算的词嵌入数据库。Word2vec 就是其中之一。另一种流行的方法称为词表示的全局向量(GloVe,https ://nlp.stanford.edu/projects/glove ),它是由斯坦福研究人员在 2014 年开发的。这种嵌入技术基于分解词的矩阵发生统计。它的开发人员已经为数百万个英语令牌提供了预计算嵌入,这些令牌来自维基百科数据和 Common Crawl 数据。

让我们看看如何开始在 Keras 模型中使用 GloVe 嵌入。同样的方法适用于 Word2Vec 嵌入或任何其他词嵌入数据库。我们将首先下载 GloVe 文件并解析它们。然后我们将单词向量加载到 KerasEmbedding层中,我们将使用它来构建一个新模型。

首先,让我们下载在 2014 年英语维基百科数据集上预先计算的 GloVe 词嵌入。这是一个 822 MB 的 zip 文件,包含 400,000 个单词(或非单词标记)的 100 维嵌入向量。

!wget http:/ /nlp.stanford.edu/data/glove.6B.zip
!unzip -q glove.6B.zip

让我们解析解压缩的文件(一个 .txt 文件)来构建一个索引,将单词(作为字符串)映射到它们的向量表示。

清单 11.18 解析 GloVe 词嵌入文件

import numpy as np
path_to_glove_file = "glove.6B.100d.txt" 
  
embeddings_index = {} 
with open(path_to_glove_file) as f:
    for line in f:
        word, coefs = line.split(maxsplit=1)
        coefs = np.fromstring(coefs, "f", sep=" ")
        embeddings_index[word] = coefs
  
print(f"Found {len(embeddings_index)} word vectors.")

接下来,让我们构建一个可以加载到Embedding层中的嵌入矩阵。它必须是一个 shape 矩阵(max_words, embedding_dim),其中每个条目i包含参考词索引中索引iembedding_dim的词的维向量(在标记化期间构建)。

清单 11.19 准备 GloVe 词嵌入矩阵

embedding_dim = 100 
  
vocabulary = text_vectorization.get_vocabulary()             ❶
word_index = dict(zip(vocabulary, range(len(vocabulary))))   ❷
 
embedding_matrix = np.zeros((max_tokens, embedding_dim))     ❸
for word, i in word_index.items():
    if i < max_tokens:
        embedding_vector = embeddings_index.get(word)
    if embedding_vector is not None:                         ❹
        embedding_matrix[i] = embedding_vector               ❹

检索由我们之前的 TextVectorization 层索引的词汇表。

使用它来创建从单词到词汇表索引的映射。

准备一个矩阵,我们将用 GloVe 向量填充它。

用索引 i 的词向量填充矩阵中的条目 i。在嵌入索引中找不到的单词将全为零。

最后,我们使用Constant初始化器Embedding在层中加载预训练的嵌入。为了在训练期间不破坏预训练的表示,我们通过以下方式冻结层trainable=False

embedding_layer = layers.Embedding(
    max_tokens,
    embedding_dim,
    embeddings_initializer=keras.initializers.Constant(embedding_matrix),
    trainable=False,
    mask_zero=True,
)

我们现在准备好训练一个新模型——与我们之前的模型相同,但利用 100 维预训练的 GloVe 嵌入而不是 128 维学习嵌入。

清单 11.20 使用预训练嵌入层的模型

inputs = keras.Input(shape=(None,), dtype="int64")
embedded = embedding_layer(inputs)
x = layers.Bidirectional(layers.LSTM(32))(embedded)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop",
              loss="binary_crossentropy",
              metrics=["accuracy"])
model.summary()
  
callbacks = [
    keras.callbacks.ModelCheckpoint("glove_embeddings_sequence_model.keras",
                                    save_best_only=True)
]
model.fit(int_train_ds, validation_data=int_val_ds, epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("glove_embeddings_sequence_model.keras")
print(f"Test acc: {model.evaluate(int_test_ds)[1]:.3f}")

你会发现在这个特定的任务中,预训练的嵌入并不是很有帮助,因为数据集包含足够的样本,可以从头开始学习一个足够专业的嵌入空间。但是,当您使用较小的数据集时,利用预训练嵌入可能非常有用。

11.4 Transformer 架构

从 2017 年开始,一种新的模型架构开始在大多数自然语言处理任务中超越递归神经网络:Transformer。

Vaswani 等人的开创性论文“Attention is all you need”中介绍了变形金刚。2论文的主旨就在标题中:事实证明,一种称为“神经注意力”的简单机制可用于构建没有任何循环层或卷积层的强大序列模型。

2 Ashish Vaswani 等人,“Attention is all you need”(2017 年),https://arxiv.org/abs/1706.03762

这一发现引发了自然语言处理领域的一场革命——甚至更远。神经注意力迅速成为深度学习中最有影响力的思想之一。在本节中,您将深入了解它的工作原理以及为什么它已被证明对序列数据如此有效。然后,我们将利用 self-attention 创建一个 Transformer 编码器,它是 Transformer 架构的基本组件之一,并将其​​应用于 IMDB 电影评论分类任务。

11.4.1 理解自注意力

在阅读本书时,您可能会略读某些部分并专心阅读其他部分,具体取决于您的目标或兴趣。如果您的模型也这样做会怎样?这是一个简单而强大的想法:并非模型看到的所有输入信息对手头的任务都同等重要,因此模型应该“更多地关注”某些特征而“更少关注”其他特征。

这听起来很熟悉吗?你已经在本书中遇到过两次类似的概念:

  • 卷积网络中的最大池化查看空间区域中的特征池,并仅选择一个特征来保留。这是一种“全有或全无”的注意力形式:保留最重要的特征并丢弃其余的。

  • TF-IDF 归一化根据不同标记可能携带的信息量为标记分配重要性分数。重要的代币会得到提升,而无关的代币会淡出。这是一种持续的关注形式。

你可以想象许多不同形式的注意力,但它们都是从计算一组特征的重要性分数开始的,相关性越高的特征得分越高,相关性越低的特征得分越低(见图 11.5)。应该如何计算这些分数,以及你应该如何处理它们,将因方法而异。

图 11.5 深度学习中“注意”的一般概念:输入特征被分配“注意分数”,可用于通知输入的下一个表示。

至关重要的是,这种注意力机制不仅可以用于突出或擦除某些特征。它可用于使功能具有上下文感知. 您刚刚了解了词嵌入——捕捉不同词之间语义关系“形状”的向量空间。在嵌入空间中,单个单词具有固定的位置——与空间中的每个其他单词的一组固定关系。但这并不是语言的工作原理:一个词的含义通常是特定于上下文的。当您标记日期时,您谈论的不是与约会时相同的“日期”,也不是您在市场上购买的那种日期。当你说“我很快再见”时,“看”这个词的含义与“我会看到这个项目结束”或“我明白你的意思”中的“看”有微妙的不同。当然,像“he”、“it”、“in”等代词的含义完全是特定于句子的,甚至可以在一个句子中多次改变。

显然,智能嵌入空间会根据周围的其他单词为单词提供不同的向量表示。这就是self-attention的用武之地。self-attention的目的是通过使用序列中相关标记的表示来调整标记的表示。这会产生上下文感知的令牌表示。考虑一个例句:“火车准时离开车站。” 现在,考虑句子中的一个词:站。我们在谈论什么样的车站?会不会是广播电台?也许是国际空间站?让我们通过自注意力算法来解决这个问题(见图 11.6)。

图 11.6 自注意力:注意力分数是在“站”和序列中的每个其他单词之间计算的,然后它们用于加权成为新的“站”向量的词向量之和。

第 1 步是计算“站”的向量与句子中的每个其他单词之间的相关性分数。这些是我们的“注意力分数”。我们将简单地使用两个词向量之间的点积来衡量它们的关系强度。这是一个计算效率非常高的距离函数,早在 Transformers 之前,它就已经是将两个词嵌入相互关联的标准方法。在实践中,这些分数也会通过一个缩放函数和一个 softmax,但现在,这只是一个实现细节。

第 2 步是计算句子中所有词向量的总和,由我们的相关性分数加权。与“站”密切相关的词对总和的贡献更大(包括“站”这个词本身),而不相关的词几乎没有贡献。结果向量是我们对“站”的新表示:一种包含周围上下文的表示。特别是,它包含了“火车”向量的一部分,表明它实际上是一个“火车站”。

您将对句子中的每个单词重复此过程,从而生成编码句子的新向量序列。让我们用类似 NumPy 的伪代码来看看它:

def self_attention(input_sequence):
    output = np.zeros(shape=input_sequence.shape)
    for i, pivot_vector in enumerate(input_sequence):            ❶
        scores = np.zeros(shape=(len(input_sequence),))
        for j, vector in enumerate(input_sequence):
            scores[j] = np.dot(pivot_vector, vector.T)           ❷
        scores /= np.sqrt(input_sequence.shape[1])               ❸
        scores = softmax(scores)                                 ❸
        new_pivot_representation = np.zeros(shape=pivot_vector.shape)
        for j, vector in enumerate(input_sequence):
            new_pivot_representation += vector * scores[j]       ❹
        output[i] = new_pivot_representation                     ❺
    return output

迭代输入序列中的每个标记。

计算令牌和其他所有令牌之间的点积(注意力分数)。

按归一化因子缩放,并应用 softmax。

取由注意力分数加权的所有标记的总和。

这个总和就是我们的输出。

当然,在实践中,您会使用矢量化实现。Keras 有一个内置层来处理它:MultiHeadAttention图层。以下是您将如何使用它:

num_heads = 4 
embed_dim = 256 
mha_layer = MultiHeadAttention(num_heads=num_heads, key_dim=embed_dim)
outputs = mha_layer(inputs, inputs, inputs)

读到这里,你可能想知道

  • 为什么我们将输入传递给层三次?这似乎是多余的。

  • 我们所指的这些“多头”是什么?这听起来很吓人——如果你剪掉它们,它们还会长出来吗?

这两个问题都有简单的答案。让我们来看看。

广义自注意力:查询-键-值模型

到目前为止,我们只考虑了一个输入序列。但是,Transformer 架构最初是为机器翻译而开发的,您必须在其中处理两个输入序列:您当前正在翻译的源序列(例如“今天天气如何?”),以及您正在转换的目标序列它到(例如“¿Qué tiempo hace hoy?”)。Transformer 是一个序列到序列的模型:它是旨在将一个序列转换为另一个序列。您将在本章后面深入了解序列到序列模型。

现在让我们退后一步。我们介绍的自注意力机制示意性地执行以下操作:

这意味着“对于inputs(A) 中的每个标记,计算该标记与inputs(B) 中的每个标记的相关程度,并使用这些分数来加权inputs(C) 中的标记总和。” 至关重要的是,没有什么要求 A、B 和 C 引用相同的输入序列。在一般情况下,您可以使用三个不同的序列来执行此操作。我们称它们为“查询”、“键”和“值”。该操作变为“对于查询中的每个元素,计算元素与每个键的相关程度,并使用这些分数来加权值的总和”:

这个术语来自搜索引擎和推荐系统(见图 11.7)。想象一下,您正在输入一个查询以从您的收藏中检索一张照片——“海滩上的狗”。在内部,数据库中的每张图片都由一组关键字描述——“猫”、“狗”、“派对”等。我们将这些称为“键”。搜索引擎将首先将您的查询与数据库中的键进行比较。“Dog”产生匹配 1,“cat”产生匹配 0。然后它将根据匹配强度(相关性)对这些键进行排序,并按相关性顺序返回与前N个匹配相关联的图片。

图 11.7 从数据库中检索图像:“查询”与一组“键”进行比较,匹配分数用于对“值”(图像)进行排名。

从概念上讲,这就是 Transformer 风格的注意力正在做的事情。你有一个描述你正在寻找的东西的参考序列:查询。你有一个知识体系,你正试图从中提取信息:价值观。每个值都分配有一个键,该键以可以轻松与查询进行比较的格式描述该值。您只需将查询与键匹配即可。然后你返回一个加权的值总和。

在实践中,键和值通常是相同的顺序。例如,在机器翻译中,查询将是目标序列,而源序列将扮演键和值的角色:对于目标的每个元素(如“tiempo”),你想回到源(“今天天气怎么样?”)并确定与之相关的不同位(“tiempo”和“weather”应该有很强的匹配)。当然,如果您只是在进行序列分类,那么查询、键和值都是相同的:您将序列与自身进行比较,以使用整个序列中的上下文来丰富每个标记。

这就解释了为什么我们需要向我们的层传递inputs3 次。MultiHeadAttention但为什么要“多头”关注?

11.4.2 多头注意力

“多头注意力”是对自注意力机制的额外调整,在“注意力就是你所需要的”中介绍。“多头”绰号是指自注意力层的输出空间被分解为一组独立的子空间,分别学习:初始查询、键和值通过三个独立的密集投影集发送,产生三个独立的向量。每个向量都通过神经注意力进行处理,不同的输出连接在一起形成一个单一的输出序列。每个这样的子空间称为“头”。全图如图 11.8 所示。

图 11.8MultiHeadAttention图层

可学习的密集投影的存在使该层能够实际学习一些东西,而不是纯粹的无状态转换,需要在它之前或之后额外的层才能有用。此外,具有独立的头有助于该层为每个标记学习不同的特征组,其中一组内的特征彼此相关,但大部分独立于不同组中的特征。

这在原理上类似于深度可分离卷积的工作原理:在深度可分离卷积中,卷积的输出空间被分解为许多独立学习的子空间(每个输入通道一个)。“注意力就是你所需要的”论文是在将特征空间分解为独立子空间的想法被证明可以为计算机视觉模型提供巨大好处的时候写的——无论是在深度可分离卷积的情况下,还是在一个密切相关的方法,分组卷积。多头注意力只是将相同的想法应用于自我注意力。

11.4.3 Transformer 编码器

如果添加额外的密集投影如此有用,为什么我们不在注意力机制的输出中也应用一两个呢?实际上,这是一个好主意——让我们这样做。我们的模型开始做很多事情,所以我们可能想要添加残差连接,以确保我们不会在此过程中破坏任何有价值的信息——你在第 9 章中了解到,对于任何足够深的架构来说,它们都是必须的。还有你在第 9 章学到的另一件事:归一化层应该帮助梯度在反向传播期间更好地流动。让我们也添加这些。

这大致就是我当时想象的 Transformer 架构的发明者脑海中展开的思考过程。将输出分解为多个独立空间、添加残差连接、添加归一化层——所有这些都是在任何复杂模型中利用的标准架构模式。这些花里胡哨的东西共同构成了Transformer 编码器——构成 Transformer 架构的两个关键部分之一(见图 11.9)。

图 11.9 将具有密集投影TransformerEncoder的层链接起来,并添加归一化和残差连接。MultiHeadAttention

原始的 Transformer 架构由两部分组成:处理源序列的Transformer 编码器和使用源序列生成翻译版本的Transformer 解码器。您将在一分钟内了解解码器部分。

至关重要的是,编码器部分可用于文本分类——它是一个非常通用的模块,可以提取序列并学习将其转化为更有用的表示。让我们实现一个 Transformer 编码器,并在电影评论情感分类任务上尝试一下。

清单 11.21 作为子类实现的 Transformer 编码器Layer

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
  
class TransformerEncoder(layers.Layer):
    def __init__(self, embed_dim, dense_dim, num_heads, **kwargs):
        super().__init__(**kwargs)
        self.embed_dim = embed_dim                         ❶
        self.dense_dim = dense_dim                         ❷
        self.num_heads = num_heads                         ❸
        self.attention = layers.MultiHeadAttention(
            num_heads=num_heads, key_dim=embed_dim)
        self.dense_proj = keras.Sequential(
            [layers.Dense(dense_dim, activation="relu"),
             layers.Dense(embed_dim),]
        )
        self.layernorm_1 = layers.LayerNormalization()
        self.layernorm_2 = layers.LayerNormalization()
    def call(self, inputs, mask=None):                    ❹
        if mask is not None:                              ❺
            mask = mask[:, tf.newaxis, :]                 ❺
        attention_output = self.attention(
            inputs, inputs, attention_mask=mask)
        proj_input = self.layernorm_1(inputs + attention_output)
        proj_output = self.dense_proj(proj_input)
        return self.layernorm_2(proj_input + proj_output)
  
    def get_config(self):                                 ❻
        config = super().get_config()
        config.update({
            "embed_dim": self.embed_dim,
            "num_heads": self.num_heads,
            "dense_dim": self.dense_dim,
        })
        return config

输入标记向量的大小

内致密层的大小

关注头数

计算在 call() 中进行。

 Embedding 层将生成的掩码将是 2D,但注意力层期望是 3D 或 4D,因此我们扩大了它的排名。

实现序列化,以便我们可以保存模型。

保存自定义图层

编写自定义层时,请确保实现方法:这使get_config层能够从其配置字典中重新实例化,这在模型保存和加载期间很有用。该方法应返回一个 Python dict,其中包含用于创建层的构造函数参数的值。

所有 Keras 层都可以按如下方式进行序列化和反序列化:

config = layer.get_config()
new_layer = layer.__class__.from_config(config)   ❶

配置不包含权重值,因此层中的所有权重都是从头开始初始化的。

例如:

layer = PositionalEmbedding(sequence_length, input_dim, output_dim)
config = layer.get_config()
new_layer = PositionalEmbedding.from_config(config)

保存包含自定义层的模型时,保存文件将包含这些配置字典。从文件加载模型时,您应该向加载过程提供自定义层类,以便它可以理解配置对象:

model = keras.models.load_model(
    filename, custom_objects={"PositionalEmbedding": PositionalEmbedding})

你会注意到我们在这里使用的规范化层不是BatchNormalization我们之前在图像模型中使用过的那些。那是因为BatchNormalization不适用于序列数据。相反,我们使用的是LayerNormalization标准化的层每个序列独立于批次中的其他序列。像这样,在类似 NumPy 的伪代码中:

def layer_normalization(batch_of_sequences):                       ❶
    mean = np.mean(batch_of_sequences, keepdims=True, axis=-1)     ❷
    variance = np.var(batch_of_sequences, keepdims=True, axis=-1)  ❷
    return (batch_of_sequences - mean) / variance

输入形状:(batch_size, sequence_length, embedding_dim)

为了计算均值和方差,我们只在最后一个轴(轴 -1)上汇集数据。

比较BatchNormalization(训练期间):

def batch_normalization(batch_of_images):                              ❶
    mean = np.mean(batch_of_images, keepdims=True, axis=(0, 1, 2))     ❷
    variance = np.var(batch_of_images, keepdims=True, axis=(0, 1, 2))  ❷
    return (batch_of_images - mean) / variance

输入形状:(b​​atch_size、height、width、channels)

在批次轴(轴 0)上汇集数据,这会在批次中的样本之间创建交互。

BatchNormalization从多个样本中收集信息以获得特征均值和方差的准确统计数据的同时,将LayerNormalization每个序列内的数据分开汇集,这更适合序列数据。

现在我们已经实现了我们的TransformerEncoder,我们可以使用它来组装一个类似于您之前看到的基于 GRU 的文本分类模型。

清单 11.22 使用 Transformer 编码器进行文本分类

vocab_size = 20000 
embed_dim = 256 
num_heads = 2 
dense_dim = 32 
  
inputs = keras.Input(shape=(None,), dtype="int64")
x = layers.Embedding(vocab_size, embed_dim)(inputs)
x = TransformerEncoder(embed_dim, dense_dim, num_heads)(x)
x = layers.GlobalMaxPooling1D()(x)                          ❶
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop",
              loss="binary_crossentropy",
              metrics=["accuracy"])
model.summary()

由于 TransformerEncoder 返回完整的序列,我们需要通过全局池化层将每个序列减少为单个向量进行分类。

让我们训练它。它达到了 87.5% 的测试准确率——略低于 GRU 模型。

清单 11.23 训练和评估基于 Transformer 编码器的模型

callbacks = [
    keras.callbacks.ModelCheckpoint("transformer_encoder.keras",
                                    save_best_only=True)
]
model.fit(int_train_ds, validation_data=int_val_ds, epochs=20,
          callbacks=callbacks)
model = keras.models.load_model(
    "transformer_encoder.keras",
    custom_objects={"TransformerEncoder": TransformerEncoder})   ❶
print(f"Test acc: {model.evaluate(int_test_ds)[1]:.3f}")

为模型加载过程提供自定义的 TransformerEncoder 类。

在这一点上,你应该开始感到有点不安。这里有点不对劲。你能说出它是什么吗?

这部分表面上是关于“序列模型”的。我首先强调了词序的重要性。我说 Transformer 是一种序列处理架构,最初是为机器翻译而开发的。但是 。. . 您刚刚看到的 Transformer 编码器根本不是序列模型。你注意到了吗?它由相互独立处理序列标记的密集层和将标记视为一组的注意力层组成. 您可以更改序列中标记的顺序,并且您将获得完全相同的成对注意力分数和完全相同的上下文感知表示。如果你完全打乱每部电影评论中的单词,模型不会注意到,你仍然会得到完全相同的准确性。自注意力是一种集合处理机制,专注于序列元素对之间的关​​系(见图 11.10)——它不知道这些元素是出现在序列的开头、结尾还是中间。那么为什么我们说 Transformer 是一个序列模型呢?如果不看词序,它怎么可能对机器翻译有好处?

图 11.10 不同类型 NLP 模型的特征

我在本章前面提到了解决方案:我顺便提到,Transformer 是一种混合方法,在技术上与订单无关,但它在其处理的表示中手动注入订单信息。这是缺少的成分!这称为位置编码。让我们来看看。

使用位置编码重新注入订单信息

位置编码背后的想法非常简单:为了让模型能够访问词序信息,我们将在每个词嵌入中添加词在句子中的位置。我们的输入词嵌入将有两个组成部分:通常的词向量,它表示独立于任何特定上下文的词,以及一个位置向量,它表示词在当前句子中的位置。希望该模型能够找出如何最好地利用这些附加信息。

你能想出的最简单的方案是将单词的位置连接到它的嵌入向量。您将在向量中添加一个“位置”轴,并将序列中的第一个单词填充为 0,第二个单词填充为 1,依此类推。

然而,这可能并不理想,因为位置可能是非常大的整数,这将破坏嵌入向量中的值范围。如您所知,神经网络不喜欢非常大的输入值或离散的输入分布。

最初的“Attention is all you need”论文使用了一个有趣的技巧来编码单词位置:它向单词嵌入添加了一个向量,该向量包含范围内的值,这些值[-1, 1]根据位置循环变化(它使用余弦函数来实现这一点)。这个技巧提供了一种通过小值向量唯一地表征大范围内的任何整数的方法。它很聪明,但这不是我们要在我们的案例中使用的。我们将做一些更简单、更有效的事情:我们将学习位置嵌入向量,就像我们学习嵌入词索引一样。然后,我们将继续将我们的位置嵌入添加到相应的词嵌入中,以获得位置感知词嵌入。这种技术称为“位置嵌入”。让我们实现它。

清单 11.24 将位置嵌入实现为子类层

class PositionalEmbedding(layers.Layer):
    def __init__(self, sequence_length, input_dim, output_dim, **kwargs):  ❶
        super().__init__(**kwargs)
        self.token_embeddings = layers.Embedding(                          ❷
            input_dim=input_dim, output_dim=output_dim)
        self.position_embeddings = layers.Embedding(
            input_dim=sequence_length, output_dim=output_dim)              ❸
        self.sequence_length = sequence_length
        self.input_dim = input_dim
        self.output_dim = output_dim
  
    def call(self, inputs):
        length = tf.shape(inputs)[-1]
        positions = tf.range(start=0, limit=length, delta=1)
        embedded_tokens = self.token_embeddings(inputs)
        embedded_positions = self.position_embeddings(positions)
        return embedded_tokens + embedded_positions                        ❹
 
    def compute_mask(self, inputs, mask=None):                             ❺
        return tf.math.not_equal(inputs, 0)                                ❺
 
    def get_config(self):                                                  ❻
        config = super().get_config()
        config.update({
            "output_dim": self.output_dim,
            "sequence_length": self.sequence_length,
            "input_dim": self.input_dim,
        })
        return config

位置嵌入的一个缺点是需要提前知道序列长度。

为令牌索引准备一个嵌入层。

另一个用于令牌位置

将两个嵌入向量相加。

与嵌入层一样,该层应该能够生成掩码,因此我们可以忽略输入中的填充 0。框架会自动调用 compute_mask 方法,然后将掩码传播到下一层。

实现序列化,以便我们可以保存模型。

您可以PositionEmbedding像使用常规图层一样使用该Embedding图层。让我们看看它的实际效果!

放在一起:文本分类转换器

开始考虑词序所需要做的就是Embedding用我们的位置感知版本交换旧层.

清单 11.25 将 Transformer 编码器与位置嵌入相结合

vocab_size = 20000 
sequence_length = 600 
embed_dim = 256 
num_heads = 2 
dense_dim = 32 
  
inputs = keras.Input(shape=(None,), dtype="int64")
x = PositionalEmbedding(sequence_length, vocab_size, embed_dim)(inputs)   ❶
x = TransformerEncoder(embed_dim, dense_dim, num_heads)(x)
x = layers.GlobalMaxPooling1D()(x)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop",
              loss="binary_crossentropy",
              metrics=["accuracy"])
model.summary()
  
callbacks = [
    keras.callbacks.ModelCheckpoint("full_transformer_encoder.keras",
                                    save_best_only=True)
] 
model.fit(int_train_ds, validation_data=int_val_ds, epochs=20, callbacks=callbacks)
model = keras.models.load_model(
    "full_transformer_encoder.keras",
    custom_objects={"TransformerEncoder": TransformerEncoder,
                    "PositionalEmbedding": PositionalEmbedding}) 
print(f"Test acc: {model.evaluate(int_test_ds)[1]:.3f}")

看这里!

我们得到了 88.3% 的测试准确率,这是一个坚实的改进,清楚地证明了词序信息对文本分类的价值。这是迄今为止我们最好的序列模型!但是,它仍然比词袋方法低一个档次。

11.4.4 何时使用序列模型而不是词袋模型

您有时可能会听说词袋方法已经过时,并且无论您正在查看什么任务或数据集,基于 Transformer 的序列模型都是可行的方法。绝对不是这种情况:Dense在很多情况下,在 bag-of-bigrams 之上的一小堆层仍然是一种完全有效且相关的方法。事实上,在本章中我们在 IMDB 数据集上尝试过的各种技术中,迄今为止表现最好的是 bag-of-bigrams!

那么,什么时候你应该更喜欢一种方法而不是另一种呢?

2017 年,我和我的团队对许多不同类型的文本数据集的各种文本分类技术的性能进行了系统分析,我们发现了一个非凡且令人惊讶的经验法则,用于决定是否使用词袋模型或序列模型(http://mng.bz/AOzK)——某种黄金常数。

事实证明,在处理新的文本分类任务时,您应该密切注意训练数据中的样本数与每个样本的平均单词数之间的比率(见图 11.11)。如果该比率很小(小于 1,500),那么 bag-of-bigrams 模型的性能会更好(而且作为奖励,它的训练和迭代速度也会快得多)。如果该比率高于 1,500,那么您应该使用序列模型。换句话说,当有大量训练数据可用且每个样本相对较短时,序列模型效果最好。

图 11.11 选择文本分类模型的简单启发式:训练样本数与每个样本的平均单词数之比

因此,如果您要对 1,000 字长的文档进行分类,并且您有 100,000 个文档(比率为 100),那么您应该使用二元模型。如果您要对平均长度为 40 个单词的推文进行分类,并且您有 50,000 个(比率为 1,250),那么您还应该使用二元模型。但是,如果您将数据集大小增加到 500,000 条推文(比率为 12,500),请使用 Transformer 编码器。IMDB 影评分类任务呢?我们有 20,000 个训练样本和 233 个平均字数,因此我们的经验法则指向二元模型,这证实了我们在实践中发现的内容。

这在直觉上是有道理的:序列模型的输入代表了一个更丰富、更复杂的空间,因此需要更多的数据来映射该空间;同时,一组简单的术语是一个非常简单的空间,您可以使用数百或数千个样本在顶部训练逻辑回归。此外,样本越短,模型就越不能丢弃它包含的任何信息——特别是词序变得更加重要,丢弃它会产生歧义。“这部电影是炸弹”和“这部电影是炸弹”这两个句子具有非常接近的一元表示,这可能会混淆词袋模型,但序列模型可以分辨出哪个是负面的,哪个是正面的。使用更长的样本,

现在,请记住,这个启发式规则是专门为文本分类而开发的。它可能不一定适用于其他 NLP 任务——例如,在机器翻译方面,与 RNN 相比,Transformer 尤其适用于非常长的序列。我们的启发式也只是一个经验法则,而不是科学规律,所以期望它在大多数时间都有效,但不一定每次都有效。

11.5 超越文本分类:序列到序列学习

您现在拥有处理大多数自然语言处理任务所需的所有工具。但是,您只看到了这些工具在一个问题上的作用:文本分类。这是一个非常流行的用例,但 NLP 不仅仅是分类。在本节中,您将通过学习序列到序列模型来加深您的专业知识。

序列到序列模型将序列作为输入(通常是句子或段落)并将其转换为不同的序列。这是 NLP 许多最成功应用的核心任务:

  • 机器翻译——转换一个将源语言中的段落转换为目标语言中的等效段落。

  • 文本摘要——转换一个将长文档转换为保留最重要信息的较短版本。

  • 问答——将输入的问题转换为其答案。

  • 聊天机器人——转换一个对话提示转换为对此提示的回复,或将对话历史记录转换为对话中的下一个回复。

  • 文本生成——转换一个将文本提示插入到完成提示的段落中。

  • 等等。

图 11.12 描述了序列到序列模型背后的通用模板。在训练中,

  • 编码器模型转动源序列转换为中间表示。

  • 训练解码i通过查看先前的标记 ( 0to i - 1) 和编码的源序列来预测目标序列中的下一个标记。

图 11.12 序列到序列学习:源序列由编码器处理,然后发送到解码器。解码器查看到目前为止的目标序列,并预测目标序列未来的偏移量。在推理过程中,我们一次生成一个目标标记并将其反馈给解码器。

在推理过程中,我们无法访问目标序列——我们试图从头开始预测它。我们必须一次生成一个令牌:

  1. 我们从编码器获得编码的源序列。

  2. 解码器首先查看编码的源序列以及初始“种子”标记(例如字符串"[start]"),并使用它们来预测序列中的第一个真实标记。

  3. 到目前为止的预测序列被反馈到解码器,解码器生成下一个标记,依此类推,直到它生成一个停止标记(例如字符串"[end]")。

到目前为止,您所学到的一切都可以重新用于构建这种新型模型。让我们潜入水中。

11.5.1 机器翻译示例

我们将演示机器翻译任务的序列到序列建模。机器翻译正是 Transformer 的开发目的!我们将从一个循环序列模型开始,然后我们将跟进完整的 Transformer 架构。

我们将使用 www.manythings.org/anki/ 上提供的英语到西班牙语的翻译数据。让我们下载它:

!wget http:/ /storage.googleapis.com/download.tensorflow.org/data/spa-eng.zip
!unzip -q spa-eng.zip

文本文件每行包含一个示例:一个英语句子,后跟一个制表符,然后是相应的西班牙语句子。让我们解析这个文件。

text_file = "spa-eng/spa.txt" 
with open(text_file) as f:
    lines = f.read().split("\n")[:-1]
text_pairs = [] 
for line in lines:                              ❶
    english, spanish = line.split("\t")         ❷
    spanish = "[start] " + spanish + " [end]"   ❸
    text_pairs.append((english, spanish))

遍历文件中的行。

每行包含一个英文短语及其西班牙文翻译,以制表符分隔。

我们在西班牙语句子前面加上“[start]”和“[end]”,以匹配图 11.12 中的模板。

我们的text_pairs样子是这样的:

>>> import random
>>> print(random.choice(text_pairs))
("Soccer is more popular than tennis.",
 "[start] El fútbol es más popular que el tenis. [end]")

让我们打乱它们并将它们分成通常的训练、验证和测试集:

import random
random.shuffle(text_pairs)
num_val_samples = int(0.15 * len(text_pairs))
num_train_samples = len(text_pairs) - 2 * num_val_samples
train_pairs = text_pairs[:num_train_samples]
val_pairs = text_pairs[num_train_samples:num_train_samples + num_val_samples]
test_pairs = text_pairs[num_train_samples + num_val_samples:]

接下来,让我们准备两个单独的TextVectorization层:一层用于英语,一层用于西班牙语。我们将需要自定义字符串的预处理方式:

  • 我们需要保留我们插入的"[start]"和标记。"[end]"默认情况下,字符[]将被剥离,但我们希望保留它们,以便我们可以区分单词“开始”和开始标记"[start]"

  • 标点符号因语言而异!在西班牙语TextVectorization层中,如果我们要去除标点符号,我们还需要去除字符¿

请注意,对于非玩具翻译模型,我们会将标点字符视为单独的标记而不是剥离它们,因为我们希望能够生成正确的标点符号句子。在我们的例子中,为简单起见,我们将去掉所有标点符号。

清单 11.26 向量化英语和西班牙语文本对

import tensorflow as tf 
import string
import re
  
strip_chars = string.punctuation + "¿"                  ❶
strip_chars = strip_chars.replace("[", "")              ❶
strip_chars = strip_chars.replace("]", "")              ❶
 
def custom_standardization(input_string):               ❶
    lowercase = tf.strings.lower(input_string)          ❶
    return tf.strings.regex_replace(                    ❶
        lowercase, f"[{re.escape(strip_chars)}]", "")   ❶
vocab_size = 15000                                      ❷
sequence_length = 20                                    ❷
 
source_vectorization = layers.TextVectorization(        ❸
    max_tokens=vocab_size,
    output_mode="int",
    output_sequence_length=sequence_length,
)
target_vectorization = layers.TextVectorization(        ❹
    max_tokens=vocab_size,
    output_mode="int",
    output_sequence_length=sequence_length + 1,         ❺
    standardize=custom_standardization,
)
train_english_texts = [pair[0] for pair in train_pairs]
train_spanish_texts = [pair[1] for pair in train_pairs]
source_vectorization.adapt(train_english_texts)         ❻
target_vectorization.adapt(train_spanish_texts)         ❻

为西班牙语 TextVectorization 层准备一个自定义字符串标准化函数:它保留 [ 和 ] 但去除 ¿(以及来自 strings.punctuation 的所有其他字符)。

为简单起见,我们只查看每种语言的前 15,000 个单词,并将句子限制为 20 个单词。

英文层

西班牙层

生成具有一个额外标记的西班牙语句子,因为我们需要在训练期间将句子偏移一步。

学习每种语言的词汇。

最后,我们可以将数据转换为tf.data管道。我们希望它返回一个元组(inputs, target),其中inputs是一个带有两个键的 dict,“encoder_inputs”(英语句子)和“decoder_inputs”(西班牙语句子),并且target是向前偏移了一步的西班牙语句子。

清单 11.27 为翻译任务准备数据集

batch_size = 64 
  
def format_dataset(eng, spa):
    eng = source_vectorization(eng)
    spa = target_vectorization(spa)
    return ({
        "english": eng,
        "spanish": spa[:, :-1],                                ❶
    }, spa[:, 1:])                                             ❷
 
def make_dataset(pairs):
    eng_texts, spa_texts = zip(*pairs)
    eng_texts = list(eng_texts)
    spa_texts = list(spa_texts)
    dataset = tf.data.Dataset.from_tensor_slices((eng_texts, spa_texts))
    dataset = dataset.batch(batch_size)
    dataset = dataset.map(format_dataset, num_parallel_calls=4)
    return dataset.shuffle(2048).prefetch(16).cache()          ❸
 
train_ds = make_dataset(train_pairs)
val_ds = make_dataset(val_pairs)

输入的西班牙语句子不包括最后一个标记以保持输入和目标的长度相同。

目标西班牙语句子领先一步。两者的长度仍然相同(20 个字)。

使用内存缓存来加速预处理。

这是我们的数据集输出的样子:

>>> for inputs, targets in train_ds.take(1):
>>>     print(f"inputs['english'].shape: {inputs['english'].shape}")
>>>     print(f"inputs['spanish'].shape: {inputs['spanish'].shape}")
>>>     print(f"targets.shape: {targets.shape}")
inputs["encoder_inputs"].shape: (64, 20)
inputs["decoder_inputs"].shape: (64, 20)
targets.shape: (64, 20)

数据现已准备就绪——是时候构建一些模型了。我们将从循环序列到序列模型开始,然后再转到 Transformer。

11.5.2 RNN 的序列到序列学习

从 2015 年到 2017 年,循环神经网络主导了序列到序列的学习,然后被 Transformer 超越。它们是许多现实世界机器翻译系统的基础——如第 10 章所述,大约 2017 年的谷歌翻译由七个大型 LSTM 层的堆栈提供支持。今天仍然值得学习这种方法,因为它为理解序列到序列模型提供了一个简单的切入点。

使用 RNN 将一个序列转换为另一个序列的最简单、幼稚的方法是在每个时间步保持 RNN 的输出。在 Keras 中,它看起来像这样:

inputs = keras.Input(shape=(sequence_length,), dtype="int64")
x = layers.Embedding(input_dim=vocab_size, output_dim=128)(inputs)
x = layers.LSTM(32, return_sequences=True)(x)
outputs = layers.Dense(vocab_size, activation="softmax")(x)
model = keras.Model(inputs, outputs)

但是,这种方法存在两个主要问题:

  • 目标序列必须始终与源序列长度相同。在实践中,这种情况很少见。从技术上讲,这并不重要,因为您始终可以填充源序列或目标序列以使其长度匹配。

  • 由于 RNN 的逐步性质,该模型将仅查看源序列中的标记 0... N以预测目标序列中的标记N。此约束使此设置不适用于大多数任务,尤其是翻译。考虑将“今天天气很好”翻译成法语——那就是“Il fait beau aujourd'hui”。您需要能够仅从“The”预测“Il”,仅从“The weather”等预测“Il fait”,这根本不可能。

如果您是人工翻译,您会先阅读整个源句子,然后再开始翻译。如果您要处理的语言具有非常不同的词序,例如英语和日语,这一点尤其重要。这正是标准的序列到序列模型所做的。

在适当的序列到序列设置中(参见图 11.13),您将首先使用 RNN(编码器)将整个源序列转换为单个向量(或向量集)。这可能是 RNN 的最后一个输出,或者是它的最终内部状态向量。然后你会使用这个向量(或向量)作为另一个向量的初始状态RNN(解码器),它将查看目标序列中的元素 0... N,并尝试预测目标序列中的第N +1 步。

图 11.13 序列到序列的 RNN:RNN 编码器用于生成对整个源序列进行编码的向量,该向量用作 RNN 解码器的初始状态。

让我们使用基于 GRU 的编码器和解码器在 Keras 中实现这一点。选择 GRU 而不是 LSTM 会使事情变得更简单,因为 GRU 只有一个状态向量,而 LSTM 有多个。让我们从编码器开始。

清单 11.28 基于 GRU 的编码器

from tensorflow import keras 
from tensorflow.keras import layers
  
embed_dim = 256 
latent_dim = 1024  
 
source = keras.Input(shape=(None,), dtype="int64", name="english")  ❶
x = layers.Embedding(vocab_size, embed_dim, mask_zero=True)(source) ❷
encoded_source = layers.Bidirectional(
    layers.GRU(latent_dim), merge_mode="sum")(x)                    ❸

英文源句放在这里。指定输入的名称使我们能够使用输入字典 fit() 模型。

不要忘记遮罩:在此设置中至关重要。

我们编码的源语句是双向 GRU 的最后输出。

接下来,让我们添加解码器——一个简单的 GRU 层,它将编码的源语句作为其初始状态。在它之上,我们添加一个Dense为每个输出步骤生成西班牙语词汇的概率分布。

清单 11.29 基于 GRU 的解码器和端到端模型

past_target = keras.Input(shape=(None,), dtype="int64", name="spanish")   ❶
x = layers.Embedding(vocab_size, embed_dim, mask_zero=True)(past_target)  ❷
decoder_gru = layers.GRU(latent_dim, return_sequences=True)
x = decoder_gru(x, initial_state=encoded_source)                          ❸
x = layers.Dropout(0.5)(x)
target_next_step = layers.Dense(vocab_size, activation="softmax")(x)      ❹
seq2seq_rnn = keras.Model([source, past_target], target_next_step)        ❺

西班牙语目标句放在这里。

不要忘记掩蔽。

编码后的源语句作为解码器 GRU 的初始状态。

预测下一个令牌

端到端模型:将源句和目标句映射到未来一步的目标句

在训练期间,解码器将整个目标序列作为输入,但由于 RNN 的逐步特性,它只查看输入中的标记 0... N来预测输出中的标记N(对应于序列中的下一个标记,因为输出旨在偏移一个步骤)。这意味着我们只使用过去的信息来预测未来,这是我们应该做的;否则我们会作弊,我们的模型在推理时将无法工作。

让我们开始训练吧。

清单 11.30 训练我们的循环序列到序列模型

seq2seq_rnn.compile(
    optimizer="rmsprop",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"])
seq2seq_rnn.fit(train_ds, epochs=15, validation_data=val_ds)

我们选择准确性作为在训练期间监控验证集性能的粗略方法。我们达到了 64% 的准确率:平均而言,该模型在 64% 的时间内正确地预测了西班牙语句子中的下一个单词。然而,在实践中,next-token 准确度并不是机器翻译模型的一个很好的指标,特别是因为它假设在预测 token N时,从 0 到N的正确目标 token是已知的+1。实际上,在推理过程中,您是从头开始生成目标句子,并且不能依赖先前生成的标记 100% 正确。如果您使用真实世界的机器翻译系统,您可能会使用“BLEU 分数”来评估您的模型——这是一个查看整个生成序列的指标,并且似乎与人类对翻译质量的感知密切相关。

最后,让我们使用我们的模型进行推理。我们将在测试集中挑选几个句子并检查我们的模型如何翻译它们。我们将从种子标记 开始"[start]",并将其与编码的英文源语句一起输入解码器模型。我们将检索下一个标记预测,并将其重复注入解码器,在每次迭代中采样一个新的目标标记,直到"[end]"达到或达到最大句子长度。

清单 11.31 用我们的 RNN 编码器和解码器翻译新句子

import numpy as np
spa_vocab = target_vectorization.get_vocabulary()                          ❶
spa_index_lookup = dict(zip(range(len(spa_vocab)), spa_vocab))             ❶
max_decoded_sentence_length = 20
 
def decode_sequence(input_sentence):
    tokenized_input_sentence = source_vectorization([input_sentence])
    decoded_sentence = "[start]"                                           ❷
    for i in range(max_decoded_sentence_length):
        tokenized_target_sentence = target_vectorization([decoded_sentence])
        next_token_predictions = seq2seq_rnn.predict(                      ❸
            [tokenized_input_sentence, tokenized_target_sentence])         ❸
        sampled_token_index = np.argmax(next_token_predictions[0, i, :])   ❸
        sampled_token = spa_index_lookup[sampled_token_index]              ❹
        decoded_sentence += " " + sampled_token                            ❹
        if sampled_token == "[end]":                                       ❺
            break
    return decoded_sentence
  
test_eng_texts = [pair[0] for pair in test_pairs] 
for _ in range(20):
    input_sentence = random.choice(test_eng_texts)
    print("-")
    print(input_sentence)
    print(decode_sequence(input_sentence))

准备一个字典来将标记索引预测转换为字符串标记。

种子令牌

采样下一个令牌。

将下一个标记预测转换为字符串并将其附加到生成的句子中。

退出条件:达到最大长度或采样停止字符

请注意,这种推理设置虽然非常简单,但效率相当低,因为我们每次采样一个新单词时都会重新处理整个源句子和整个生成的目标句子。在实际应用中,您会将编码器和解码器视为两个独立的模型,并且您的解码器将在每次令牌采样迭代中只运行一个步骤,重用其先前的内部状态。

这是我们的翻译结果。我们的模型非常适合玩具模型,尽管它仍然会犯许多基本错误。

清单 11.32 循环翻译模型的一些示例结果

Who is in this room?
[start] quién está en esta habitación [end]
-
That doesn't sound too dangerous.
[start] eso no es muy difícil [end]
-
No one will stop me.
[start] nadie me va a hacer [end]
-
Tom is friendly.
[start] tom es un buen [UNK] [end]

有很多方法可以改进这个玩具模型:我们可以为编码器和解码器使用深层循环层堆栈(请注意,对于解码器,这会使状态管理更加复杂)。我们可以使用 LSTM 代替 GRU。等等。然而,除了这些调整之外,用于序列到序列学习的 RNN 方法还有一些基本限制:

  • 源序列表示必须完全保存在编码器状态向量中,这极大地限制了您可以翻译的句子的大小和复杂性。这有点像一个人完全从记忆中翻译一个句子,而不是在翻译时看两次源句子。

  • RNN 在处理非常长的序列时遇到了麻烦,因为它们倾向于逐渐忘记过去——当你到达任一序列中的第 100 个标记时,关于序列开始的信息就很少了。这意味着基于 RNN 的模型无法保持长期上下文,这对于翻译长文档至关重要。

这些限制是导致机器学习社区采用 Transformer 架构来解决序列到序列问题的原因。让我们来看看。

11.5.3 使用 Transformer 进行序列到序列学习

序列到序列的学习是 Transformer 真正闪耀的任务。神经注意力使 Transformer 模型能够成功处理比那些 RNN 可以处理的更长和更复杂的序列。

作为将英语翻译成西班牙语的人,您不会一次读一个单词,将其含义保存在记忆中,然后一次生成一个单词的西班牙语句子。这可能适用于五个单词的句子,但不太可能适用于整个段落。相反,您可能希望在源语句和正在进行的翻译之间来回切换,并在写下翻译的不同部分时注意源中的不同单词。

这正是你可以通过神经注意力和变形金刚实现的。您已经熟悉 Transformer 编码器,它使用自注意力来生成输入序列中每个标记的上下文感知表示。在序列到序列的 Transformer 中,Transformer 编码器自然会扮演编码器的角色,它读取源序列并生成它的编码表示。然而,与我们之前的 RNN 编码器不同,Transformer 编码器将编码表示保持为序列格式:它是一系列上下文感知嵌入向量。

模型的后半部分是Transformer 解码器。就像 RNN 解码器一样,它读取目标序列中的令牌 0... N并尝试预测令牌N +1。至关重要的是,在这样做的同时,它使用神经注意力来识别编码源句子中的哪些标记与它当前试图预测的目标标记最密切相关——这可能与人类翻译所做的没有什么不同。回想一下 query-key-value 模型:在 Transformer 解码器中,目标序列充当注意力“查询”,用于更密切地关注源序列的不同部分(源序列同时扮演键和值)。

变压器解码器

图 11.14 显示了完整的序列到序列转换器。查看解码器的内部结构:您会发现它看起来与 Transformer 编码器非常相似,只是在应用于目标序列的自注意力块和出口块的密集层之间插入了一个额外的注意力块。

让我们实现它。与 一样TransformerEncoder,我们将使用一个Layer子类。在我们之前专注于call(), 方法, 其中行动发生,让我们从定义类构造函数开始,包含我们需要的层。

清单 11.33TransformerDecoder

class TransformerDecoder(layers.Layer):
    def __init__(self, embed_dim, dense_dim, num_heads, **kwargs):
        super().__init__(**kwargs)
        self.embed_dim = embed_dim
        self.dense_dim = dense_dim
        self.num_heads = num_heads
        self.attention_1 = layers.MultiHeadAttention(
            num_heads=num_heads, key_dim=embed_dim)
        self.attention_2 = layers.MultiHeadAttention(
            num_heads=num_heads, key_dim=embed_dim)
        self.dense_proj = keras.Sequential(
            [layers.Dense(dense_dim, activation="relu"),
             layers.Dense(embed_dim),]
        )
        self.layernorm_1 = layers.LayerNormalization()
        self.layernorm_2 = layers.LayerNormalization()
        self.layernorm_3 = layers.LayerNormalization()
        self.supports_masking = True                     ❶
  
    def get_config(self):
        config = super().get_config()
        config.update({
            "embed_dim": self.embed_dim,
            "num_heads": self.num_heads,
            "dense_dim": self.dense_dim,
        })
        return config

该属性确保层将其输入掩码传播到其输出;Keras 中的屏蔽是明确选择加入的。如果您将掩码传递给未实现 compute_mask() 且未公开此 supports_masking 属性的层,则会出现错误。

call()方法几乎是图 11.14 中连接图的直接渲染。但是我们需要考虑一个额外的细节:因果填充。因果填充对于成功训练一个序列到序列的 Transformer 是绝对关键的。与 RNN 不同,它一次查看其输入一步,因此只能访问步骤 0... N以生成输出步骤N (即目标序列中的标记NTransformerDecoder +1),它是 order-不可知论:它同时查看整个目标序列。如果允许它使用其整个输入,它将简单地学习将输入步骤N +1 复制到输出中的位置N。因此,该模型将实现完美的训练精度,但当然,在运行推理时,它完全没有用,因为超过N的输入步骤不可用。

图11.14TransformerDecoderTransformerEncoderTransformerEncoder编码器和解码器一起构成了一个端到端的 Transformer。

解决方法很简单:我们将屏蔽成对注意力矩阵的上半部分,以防止模型关注来自未来的信息——在生成目标时,只应使用目标序列中来自标记 0... N的信息令牌N +1。为此,我们将向我们添加一个get_causal_attention_mask(self, inputs)方法来检索我们可以传递给我们的层TransformerDecoder的注意掩码。MultiHeadAttention

清单 11.34TransformerDecoder生成因果掩码的方法

    def get_causal_attention_mask(self, inputs):        
        input_shape = tf.shape(inputs)
        batch_size, sequence_length = input_shape[0], input_shape[1]
        i = tf.range(sequence_length)[:, tf.newaxis]
        j = tf.range(sequence_length)
        mask = tf.cast(i >= j, dtype="int32")                           ❶
        mask = tf.reshape(mask, (1, input_shape[1], input_shape[1]))    ❷
        mult = tf.concat(                                               ❷
            [tf.expand_dims(batch_size, -1),                            ❷
             tf.constant([1, 1], dtype=tf.int32)], axis=0)              ❷
        return tf.tile(mask, mult)          3                           ❷

生成形状矩阵(sequence_length,sequence_length),一半为 1,另一半为 0。

沿batch轴复制得到一个形状为(batch_size, sequence_length, sequence_length)的矩阵

现在我们可以写下call()实现解码器前向传递的完整方法。

清单 11.35 的前向传递TransformerDecoder

    def call(self, inputs, encoder_outputs, mask=None):
        causal_mask = self.get_causal_attention_mask(inputs)       ❶
        if mask is not None:                                       ❷
            padding_mask = tf.cast(                                ❷
                mask[:, tf.newaxis, :], dtype="int32")             ❷
            padding_mask = tf.minimum(padding_mask, causal_mask)   ❸
        attention_output_1 = self.attention_1(
            query=inputs,
            value=inputs,
            key=inputs,
            attention_mask=causal_mask)                            ❹
        attention_output_1 = self.layernorm_1(inputs + attention_output_1)
        attention_output_2 = self.attention_2(
            query=attention_output_1,
            value=encoder_outputs,
            key=encoder_outputs,
            attention_mask=padding_mask,                           ❺
        )
        attention_output_2 = self.layernorm_2(
            attention_output_1 + attention_output_2)
        proj_output = self.dense_proj(attention_output_2)
        return self.layernorm_3(attention_output_2 + proj_output)

检索因果掩码。

准备输入掩码(描述目标序列中的填充位置)。

将两个蒙版合并在一起。

将因果掩码传递给第一个注意层,该层对目标序列执行自我注意。

将组合掩码传递给第二个注意层,它将源序列与目标序列相关联。

放在一起:机器翻译的变压器

端到端 Transformer 是我们将要训练的模型。它将源序列和​​目标序列映射到未来一步的目标序列。它直接结合了到目前为止我们已经构建的部分:PositionalEmbeddinglayers、theTransformerEncoderTransformerDecoder. 请注意,theTransformerEncoder和 theTransformerDecoder都是形状不变的,因此您可以堆叠其中的许多以创建更强大的编码器或解码器。在我们的示例中,我们将坚持每个实例的单个实例。

清单 11.36 端到端转换器

embed_dim = 256 
dense_dim = 2048 
num_heads = 8 
  
encoder_inputs = keras.Input(shape=(None,), dtype="int64", name="english")
x = PositionalEmbedding(sequence_length, vocab_size, embed_dim)(encoder_inputs)
encoder_outputs = TransformerEncoder(embed_dim, dense_dim, num_heads)(x)     ❶
 
decoder_inputs = keras.Input(shape=(None,), dtype="int64", name="spanish")
x = PositionalEmbedding(sequence_length, vocab_size, embed_dim)(decoder_inputs)
x = TransformerDecoder(embed_dim, dense_dim, num_heads)(x, encoder_outputs)  ❷
x = layers.Dropout(0.5)(x)
decoder_outputs = layers.Dense(vocab_size, activation="softmax")(x)        ❸
transformer = keras.Model([encoder_inputs, decoder_inputs], decoder_outputs)

对源语句进行编码。

对目标句进行编码,并与编码后的源句结合。

为每个输出位置预测一个单词。

我们现在准备好训练我们的模型了——我们达到了 67% 的准确率,远远高于基于 GRU 的模型。

清单 11.37 训练序列到序列转换器

transformer.compile(
    optimizer="rmsprop",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"])
transformer.fit(train_ds, epochs=30, validation_data=val_ds)

最后,让我们尝试使用我们的模型从测试集中翻译从未见过的英语句子。该设置与我们用于序列到序列 RNN 模型的设置相同。

清单 11.38 使用我们的 Transformer 模型翻译新句子

import numpy as np
spa_vocab = target_vectorization.get_vocabulary()
spa_index_lookup = dict(zip(range(len(spa_vocab)), spa_vocab))
max_decoded_sentence_length = 20 
  
def decode_sequence(input_sentence):
    tokenized_input_sentence = source_vectorization([input_sentence])
    decoded_sentence = "[start]" 
    for i in range(max_decoded_sentence_length):
        tokenized_target_sentence = target_vectorization(
            [decoded_sentence])[:, :-1]
        predictions = transformer(
            [tokenized_input_sentence, tokenized_target_sentence])  ❶
        sampled_token_index = np.argmax(predictions[0, i, :])       ❶
        sampled_token = spa_index_lookup[sampled_token_index]       ❷
        decoded_sentence += " " + sampled_token                     ❷
        if sampled_token == "[end]":                                ❸
            break                                                   ❸
    return decoded_sentence
  
test_eng_texts = [pair[0] for pair in test_pairs] 
for _ in range(20):
    input_sentence = random.choice(test_eng_texts)
    print("-")
    print(input_sentence)
    print(decode_sequence(input_sentence))

采样下一个令牌。

将下一个标记预测转换为字符串,并将其附加到生成的句子中。

退出条件

主观上,Transformer 的性能似乎明显优于基于 GRU 的翻译模型。它仍然是一个玩具模型,但它是一个更好的玩具模型。

清单 11.39 Transformer 翻译模型的一些示例结果

This is a song I learned when I was a kid.
[start] esta es una canción que aprendí cuando era chico [end]    ❶
-
She can play the piano.
[start] ella puede tocar piano [end]
-
I'm not who you think I am.
[start] no soy la persona que tú creo que soy [end]
-
It may have rained a little last night.
[start] puede que llueve un poco el pasado [end]

虽然源句没有区分性别,但此翻译假定说话者是男性。请记住,翻译模型通常会对其输入数据做出无根据的假设,这会导致算法偏差。在最坏的情况下,模型可能会产生与当前正在处理的数据无关的记忆信息。

关于自然语言处理的这一章到此结束——您刚刚从最基础的部分变成了一个可以从英语翻译成西班牙语的成熟的 Transformer。教机器理解语言是您可以添加到收藏中的最新超级大国。

概括

  • 有两种 NLP 模型:处理词集或 N-gram 而不考虑其顺序的词袋模型,以及处理词序的序列模型。词袋模型由Dense层组成,而序列模型可以是 RNN、1D 卷积网络或 Transformer。

  • 在文本分类方面,训练数据中的样本数与每个样本的平均单词数之间的比率可以帮助您确定应该使用词袋模型还是序列模型。

  • 词嵌入是向量空间,其中词之间的语义关系被建模为表示这些词的向量之间的距离关系。

  • 序列到序列学习是一种通用的、强大的学习框架,可用于解决许多 NLP 问题,包括机器翻译。序列到序列模型由处理源序列的编码器和解码器组成,解码器在编码器处理的源序列的帮助下,通过查看过去的标记来尝试预测目标序列中的未来标记。

  • 神经注意力是一种创建上下文感知词表示的方法。它是 Transformer 架构的基础。

  • 由 a和 a组成的Transformer架构在序列到序列任务上产生了出色的结果。前半部分,也可用于文本分类或任何类型的单输入 NLP 任务。TransformerEncoderTransformerDecoderTransformerEncoder

评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Sonhhxg_柒

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值