原文:
annas-archive.org/md5/836603e6421cae6c5dff3191c58dc5a0
译者:飞龙
第七章:选择方法和表示数据
本章将涵盖为实施自然语言处理(NLP)应用程序做好准备的下一步。我们从一些基本的考虑事项开始,包括了解应用程序所需的数据量,如何处理专有词汇和语法,并考虑不同类型计算资源的需求。然后,我们讨论 NLP 的第一步——文本表示格式,这些格式将使我们的数据准备好用于 NLP 算法处理。这些格式包括用于表示单词和文档的符号和数值方法。在某种程度上,数据格式和算法可以在应用程序中进行混合搭配,因此,将数据表示与算法的考虑独立开来是很有帮助的。
第一节将回顾选择 NLP 方法的常规考虑事项,这些事项与我们正在处理的应用程序类型和我们将使用的数据有关。
本章将涵盖以下主题:
-
选择 NLP 方法
-
为 NLP 应用表示语言
-
使用向量以数值方式表示语言
-
使用与上下文无关的向量表示单词
-
使用与上下文相关的向量表示单词
选择 NLP 方法
NLP 可以使用多种可能的技术来实现。当你开始进行 NLP 应用时,你有许多选择需要做出,这些选择受到许多因素的影响。最重要的因素之一是应用程序本身的类型,以及系统需要从数据中提取的信息,以执行预定任务。下一节将讨论应用程序如何影响技术的选择。
使方法适应任务
回想一下第一章,NLP 应用程序可以分为互动式和非互动式应用程序。你选择的应用程序类型将在选择应用于任务的技术方面起到重要作用。另一种分类应用程序的方式是根据从文档中提取所需信息所需的详细程度。在最粗略的分析级别(例如,将文档分类为两种不同类别)时,技术可以较不复杂,训练速度较快,计算负担较轻。另一方面,如果任务是训练一个需要从每个发言中提取多个实体和值的聊天机器人或语音助手,分析需要更加敏感和精细。我们将在本章的后续部分看到一些具体的例子。
在下一节中,我们将讨论数据如何影响我们选择技术的方法。
从数据开始
自然语言处理(NLP)应用程序是建立在数据集或目标系统需要处理的数据示例集合上的。为了构建一个成功的应用程序,拥有合适数量的数据是至关重要的。然而,我们不能为每个应用程序指定一个固定的示例数量,因为不同类型的应用程序所需的数据量是不同的。我们不仅需要拥有合适的数据量,还必须拥有合适的数据类型。我们将在接下来的两节中讨论这些考虑因素。
多少数据才足够?
在第五章中,我们讨论了获取数据的多种方法,在阅读完第五章后,你应该对数据的来源有一个清晰的了解。然而,在该章节中,我们并没有涉及如何判断应用程序需要多少数据才能实现其目标这个问题。如果一个任务中有数百或数千种不同的文档分类,那么我们就需要足够多的每一类示例,才能使系统区分它们。显然,如果系统从未见过某一类别的示例,它是无法识别该类别的,但即使是它见过的类别,如果示例很少,也会很难进行区分。
如果某些类别的示例比其他类别多得多,那么我们就有了一个不平衡的数据集。平衡类别的技术将在第十四章中详细讨论,但基本方法包括欠采样(丢弃更多常见类别中的一些项)、过采样(复制稀有类别中的项目)和生成(通过规则生成稀有类别的人工示例)。
系统通常在数据量更多时表现更好,但这些数据还必须代表系统在测试时或作为应用程序部署时将遇到的数据。如果任务中加入了许多新的词汇(例如,如果一个公司的聊天机器人需要处理新的产品名称),则训练数据需要定期更新以获得最佳性能。这与专门词汇和语法的普遍问题有关,因为产品名称是一种专门的词汇。我们将在下一节讨论这一主题。
专门词汇和语法
另一个需要考虑的因素是数据与我们将要处理的其他自然语言的相似性。这一点很重要,因为大多数 NLP 处理都利用了从先前语言示例中派生的模型。数据越与语言其他部分相似,构建成功应用程序就越容易。如果数据充满了专有术语、词汇或语法,那么系统从原始训练数据推广到新数据将变得困难。如果应用中充满了专业词汇和语法,那么需要增加训练数据的量,以包括这些新的词汇和语法。
考虑计算效率
实现特定 NLP 方法所需的计算资源是在选择方法时需要考虑的重要因素。一些在实验室基准测试中能够获得良好结果的方法,在计划部署的应用中可能不切实际。在接下来的章节中,我们将讨论执行这些方法时所需时间的重要性,包括训练时间和推理时间。
训练时间
一些现代神经网络模型计算密集,训练周期非常长。甚至在神经网络训练开始之前,可能需要进行一些探索性工作,旨在找出超参数的最佳值。超参数是无法在训练过程中直接估计的训练参数,必须由开发者设置。当我们在第九章、第十章、第十一章和第十二章中回到机器学习技术时,我们将具体讨论超参数,并谈论如何识别合适的值。
推理时间
另一个重要的考虑因素是推理时间,即经过训练的系统执行任务所需的处理时间。对于聊天机器人等互动应用,推理时间通常不是问题,因为如今的系统足够快,能够跟上用户在互动应用中的节奏。如果一个系统需要一两秒钟来处理用户输入,那是可以接受的。另一方面,如果系统需要处理大量现有的在线文本或音频数据,那么推理时间应尽可能快。例如,Statistica.com(www.statista.com/statistics/259477/hours-of-video-uploaded-to-youtube-every-minute/
)在 2020 年 2 月估算,每分钟就有 500 小时的视频上传到 YouTube。如果一个应用需要处理 YouTube 视频并且需要跟上这种音频的上传速度,那么它必须非常快速。
初步研究
实际应用中的 NLP 需要将工具与问题相匹配。当 NLP 技术有新的进展时,媒体可能会发布关于这些进展意味着什么的热情文章。但如果你正在尝试解决一个实际问题,使用新技术可能适得其反,因为最新的技术可能无法扩展。例如,新的技术可能提供更高的准确性,但代价是非常长的训练时间或非常大的数据量。因此,建议在尝试解决实际问题时,先使用更简单的技术进行初步探索性研究,以查看它们是否能解决问题。只有在简单的技术无法满足问题要求时,才应使用更先进的技术。
在下一部分,我们将讨论设计 NLP 应用时需要做出的一个重要选择——如何表示数据。我们将讨论符号化和数字化表示方法。
为 NLP 应用表示语言
为了让计算机能够处理自然语言,必须将语言表示为它们可以处理的形式。这些表示可以是符号化的,即直接处理文本中的单词,或者是数字化的,即表示为数字的形式。我们将在这里描述这两种方法。尽管数字化方法目前是 NLP 研究和应用中主要使用的方法,但了解符号处理背后的思想也是值得的。
符号化表示
传统上,NLP 一直是基于直接处理文本中的单词。这种方法体现在一种标准的方法中,即将文本分析为一系列步骤,旨在将由未经分析的单词组成的输入转换为意义。在传统的 NLP 管道中,如图 7.1所示,处理的每一步,从输入文本到意义,都会生成一个输出,增加更多的结构,并为下一步处理做好准备。所有这些结果都是符号化的——也就是说,非数字化的。在某些情况下,结果可能包括概率,但实际结果是符号化的:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_07_01.jpg
图 7.1 – 传统的 NLP 符号化管道
尽管我们不会回顾符号化处理方法的所有组成部分,但我们可以在以下的代码示例中看到其中的一些符号化结果,分别展示了词性标注结果和句法分析结果。我们不会在这里讨论语义分析或语用分析,因为这些技术通常只应用于特定的问题:
import nltk
from nltk.tokenize import word_tokenize
from nltk.corpus import movie_reviews
example_sentences = movie_reviews.sents()
example_sentence = example_sentences[0]
nltk.pos_tag(example_sentence)
上述代码片段展示了导入我们之前看到的电影评论数据库的过程,接着是选择第一句并进行词性标注的代码。下一个代码片段展示了词性标注的结果:
[('plot', 'NN'),
(':', ':'),
('two', 'CD'),
('teen', 'NN'),
('couples', 'NNS'),
('go', 'VBP'),
('to', 'TO'),
('a', 'DT'),
('church', 'NN'),
('party', 'NN'),
(',', ','),
('drink', 'NN'),
('and', 'CC'),
('then', 'RB'),
('drive', 'NN'),
('.', '.')]
前述结果中显示的标签(NN
、CD
、NNS
等)是 NLTK 使用的标签,并且在自然语言处理(NLP)中广泛使用。这些标签最初基于宾夕法尼亚树库的标签(构建大型英语注释语料库:宾夕法尼亚树库(Marcus 等,CL 1993))。
另一种重要的符号处理方法是plot: two teen couples go to a church party, drink and then drive
,我们在前面的代码片段中看到过,在以下代码中也有体现:
import spacy
text = "plot: two teen couples go to a church party, drink and then drive."
nlp = spacy.load("en_core_web_sm")
doc = nlp(text)
for token in doc:
print (token.text, token.tag_, token.head.text, token.dep_)
plot NN plot ROOT
: : plot punct
two CD couples nummod
teen NN couples compound
couples NNS go nsubj
go VBP plot acl
to IN go prep
a DT party det
church NN party compound
party NN to pobj
, , go punct
drink VBP go conj
and CC drink cc
then RB drive advmod
drive VB drink conj
. . go punct
上述代码使用 spaCy 解析电影评论语料库中的第一句话。解析后,我们遍历结果文档中的所有标记,并打印出每个标记及其词性标签。接着是该标记的主词或它依赖的词的文本,以及主词与标记之间的依赖关系。例如,单词couples
被标记为复数名词,它依赖于单词go
,其依赖关系是nsubj
,因为couples
是go
的主语(nsubj
)。在第四章中,我们看到了一种依赖解析的可视化示例(图 4.6),该图通过弧线表示项目与其主词之间的依赖关系;然而,在上述代码中,我们看到的是更底层的信息。
在本节中,我们已经看到了一些基于分析单个单词和短语的符号表示语言的例子,包括单个单词的词性和短语的标签。我们也可以使用完全不同的、完全基于向量的数字化方法来表示单词和短语。
使用向量进行语言的数字化表示
在为机器学习做准备时,表示语言的常见数学方法是通过使用向量。文档和单词都可以用向量表示。我们将首先讨论文档向量。
理解用于文档表示的向量
我们已经看到,文本可以表示为单词等符号的序列,这是我们读取文本的方式。然而,出于计算机自然语言处理的目的,通常使用数字方式来表示文本,特别是当我们处理大量文本时。数字表示的另一个优点是,我们可以使用更广泛的数学技术来处理数字表示的文本。
表示文档和单词的一种常见方式是使用向量,向量本质上是一个一维数组。除了单词外,我们还可以使用向量来表示其他语言单位,如词根或词干形式的单词,这些在第五章中有描述。
二元词袋模型
在第三章和第六章中,我们简要讨论了根据单词是否出现在文档中来设置 1
或 0
。向量中的每个位置代表文档的一个特征 —— 即单词是否出现。这是词袋的最简单形式,称为二元词袋。显然,这是一个非常粗糙的表示文档的方式。它只关心单词是否出现在文档中,因此无法捕获许多信息 —— 比如单词的邻近关系、单词在文档中的位置以及单词出现的频率等都没有在二元词袋中体现。此外,它还受到文档长度的影响,因为较长的文档会有更多的单词。
更详细的词袋方法不仅仅是计算单词是否出现在文档中,还要计算它出现的次数。为此,我们将转到下一个技术,计数词袋。
计数词袋
直观上,单词在文档中出现的次数似乎能帮助我们判断两篇文档的相似度。然而,到目前为止,我们还没有利用这些信息。在我们之前看到的文档向量中,值仅为 1 和 0 —— 如果单词出现在文档中则为 1,否则为 0。如果我们让这些值代表单词在文档中出现的次数,那么我们就能获得更多的信息。包含单词频率的词袋就是计数词袋。
我们在第六章的词袋和 k-means 聚类部分(图 6.15)中看到了生成二元词袋的代码。这段代码可以略微修改来计算计数词袋。唯一需要更改的是,当单词在文档中出现多次时,递增该单词的总计数。下面的代码展示了这一点:
def document_features(document):
features = {}
for word in word_features:
features[word] = 0
for doc_word in document:
if word == doc_word:
features[word] += 1
return features
将这段代码与图 6.15中的代码进行对比,我们可以看到,唯一的区别是,当单词在文档中出现时,features[word]
的值会被递增,而不是设置为1
。得到的矩阵,如图 7.2所示,包含了比图 6.16中的矩阵更多不同的单词频率值,后者仅包含零和一:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_07_02.jpg
图 7.2 – 电影评论语料库的词袋计数
在图 7**.2中,我们查看了 10 个随机选取的文档,第一列从 0
到 9
进行编号。更仔细地查看 film
的频率(回想一下 film
是语料库中最常见的非停用词),我们可以看到除了文档 5
和文档 6
外,所有文档至少出现了一次 film
。在二元 BoW 中,它们将被一起归为一类,但在这里它们有不同的值,这使得我们能够在文档之间进行更精细的区分。此时,您可能有兴趣返回第六章中的聚类练习,修改图 6**.22中的代码以使用计数 BoW,并查看生成的聚类结果。
我们可以从图 7**.2中看到,计数 BoW 比二元 BoW 给出了一些关于文档中出现词语的更多信息。然而,通过使用一种称为TF-IDF的技术,我们可以进行更精确的分析,下一节将对此进行描述。
术语频率-逆文档频率
考虑到我们的目标是找到一个准确反映文档之间相似性的表示,我们可以利用一些其他的见解。具体来说,请考虑以下观察:
-
文档中词语的原始频率将根据文档的长度而变化。这意味着具有较少总词数的较短文档可能看起来不像具有更多词语的较长文档那样相似。因此,我们应该考虑文档中词语的比例而不是原始数量。
-
总体上非常频繁出现的词语对于区分文档并不有用,因为每个文档都会包含很多这些词语。显然,在我们讨论的第五章中提到的停用词中,最有问题的词语将是停用词,但其他不严格属于停用词的词语也可能具有这种属性。回想一下我们在第六章中看过的电影评论语料库,诸如
film
和movie
这样的词语在正面和负面评论中都非常常见,因此它们无法帮助我们区分这些类别。最有帮助的词语可能是在不同类别中以不同频率出现的词语。
一种考虑这些问题的流行方法是度量TF-IDF。TF-IDF 包括两个测量值 – 术语频率 (TF) 和 逆文档频率 (IDF)。
TF 是一个术语(或单词)在文档中出现的次数,除以文档中的总词数(这考虑到较长的文档通常包含更多的单词)。我们可以将该值定义为tf(term, document)
。例如,在图 7.2中,我们可以看到术语film
在文档0
中出现了一次,因此tf("film",0)
为文档0
长度的1
。由于第二个文档中film
出现了四次,tf("film",1)
为文档1
长度的4
。术语频率的公式如下:
tf(t, d) = f t,d _ Σ t′ ∈d f t′ ,d
然而,正如我们看到的停用词那样,频繁出现的单词对于区分文档没有太大帮助。即使TF(term, document)
非常大,也可能仅仅是因为term
在每篇文档中都频繁出现。为了解决这个问题,我们引入了 IDF。idf(term, Documents)
的分子是语料库中的总文档数,N,我们将其除以包含术语t的文档数(D)。如果术语在语料库中没有出现,分母将为 0,因此为了防止除以 0,分母加 1。idf(term, documents)
是这个商的对数。idf
的公式如下:
idf(t, D) = log N ____________ |{d ∈ D : t ∈ d}|
然后,文档中给定语料库中术语的TF-IDF值仅仅是其 TF 和 IDF 的乘积:
tfidf(t, d, D) = tf ⋅ idf(t, D)
为了计算电影评论语料库的 TF-IDF 向量,我们将使用另一个非常有用的包——scikit-learn
,因为 NLTK 和 spaCy 没有内置的 TF-IDF 函数。在这些包中计算 TF-IDF 的代码可以通过手动编写标准公式来完成,但如果我们使用scikit-learn
中的函数,特别是在特征提取包中的tfidfVectorizer
,实现起来会更快。计算包含 2,000 篇文档的电影评论语料库的 TF-IDF 向量的代码如图 7.3所示。在这个例子中,我们将只关注前 200 个术语。
分词器在第 9-11 行定义。在这个例子中,我们使用的是标准的 NLTK 分词器,但也可以使用任何其他的文本处理函数。例如,我们可能想尝试使用词干提取或词形还原后的词汇,这些功能可以包含在tokenize()
函数中。为什么词干提取或词形还原文本可能是个好主意?
这种预处理方法可能有用的一个原因是,它可以减少数据中唯一标记的数量。这是因为具有多个不同变体的单词将被合并为它们的根词(例如,walk、walks、walking 和 walked 都会被视为同一个词)。如果我们认为这种变化大多只是数据中的噪声源,那么通过词干提取或词形还原来合并这些变体是一个好主意。然而,如果我们认为这种变化很重要,那么合并变体就不是一个好主意,因为这会导致信息丢失。我们可以通过考虑应用目标所需的信息,提前做出这种决策,也可以将此决策视为一个超参数,探索不同的选项并观察它们如何影响最终结果的准确性。
图 7.3 显示了代码的截图。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_07_03.jpg
图 7.3 – 用于计算电影评论语料库的 TF-IDF 向量的代码
返回到 图 7.3,在定义分词函数后,下一步是定义数据存储路径(第 13 行)并在第 14 行初始化标记字典。然后,代码遍历数据目录并从每个文件中收集标记。在收集标记的过程中,代码会将文本转换为小写并移除标点符号(第 22 行)。将文本转换为小写并移除标点符号的决定由开发者做出,类似于我们之前讨论的是否对标记进行词干提取或词形还原的决定。我们可以通过经验来探索这两步预处理是否提高了处理的准确性;然而,如果我们思考一下大小写和标点符号带来的含义,我们会发现,在许多应用中,大小写和标点符号并没有增加太多意义。在这些应用中,将文本转换为小写并移除标点符号将改善结果。
在收集并统计完所有文件中的标记后,下一步是初始化 tfIdfVectorizer
,这是 scikit-learn 中的一个内置函数。此操作在第 25 至 29 行完成。参数包括输入类型、是否使用 IDF、使用哪个分词器、使用多少特征以及停用词的语言(本示例中为英语)。
第 32 行是 TF-IDF 实际工作的地方,使用 fit_transform
方法,在此方法中,TF-IDF 向量是从文档和标记中构建的。其余的代码(第 37 至 42 行)主要用于帮助显示最终的 TF-IDF 向量。
最终的 TF-IDF 矩阵显示在 图 7.4 中:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_07_04.jpg
图 7.4 – 电影评论语料库中部分文档的 TF-IDF 向量
我们现在已经将电影评论语料库表示为一个矩阵,其中每个文档是一个N维的向量,N是我们要使用的最大词汇量的大小。图 7.4展示了语料库中部分 2,000 个文档(文档 0-4 和 1995-1999)的 TF-IDF 向量,这些文档显示在行中,语料库中的一些词汇按字母顺序排列在顶部。为了展示的方便,文档和词汇都进行了截断。
在图 7.4中,我们可以看到,在不同文档中,相同词汇的 TF-IDF 值有很大差异。例如,acting
在文档0
和文档1
中的得分差异显著。这将在下一步的处理(分类)中变得有用,我们将在第九章中回到这一部分。请注意,我们还没有进行实际的机器学习;到目前为止,目标仅仅是将文档转换为基于其包含的词汇的数值表示。
到目前为止,我们一直在关注文档的表示。但那么,如何表示词汇本身呢?文档向量中的词汇只是代表其频率的数字,要么仅在文档中出现的频率,要么是文档中词汇相对于语料库中词汇频率的频率(TF-IDF)。在我们迄今为止查看的技术中,并没有包含词汇本身的意义。然而,似乎很明显,文档中词汇的意义也应当影响文档与其他文档的相似度。我们将在下一节中探讨如何表示词汇的意义。这种表示方法在自然语言处理(NLP)领域中通常被称为词向量。我们将从一种流行的词向量表示方法——Word2Vec开始,它捕捉了词与词之间的语义相似性。
使用与上下文无关的向量表示词汇
到目前为止,我们已经查看了几种表示文档相似性的方法。然而,发现两个或多个文档彼此相似并不十分具体,尽管它对于某些应用场景(如意图或文档分类)可能有用。在这一节中,我们将讨论如何使用词向量表示词汇的意义。
Word2Vec
Word2Vec 是一个流行的词向量表示库,由谷歌于 2013 年发布(Mikolov, Tomas 等人,2013 年,《Efficient Estimation of Word Representations in Vector Space》)。Word2Vec 的基本思想是,语料库中的每个词汇都由一个单一的向量表示,这个向量是根据词汇出现的所有上下文(周围词汇)计算得出的。这种方法背后的直觉是,具有相似意义的词汇会出现在相似的上下文中。这一直觉在语言学家 J. R. Firth 的名言中得到了总结:“你可以通过一个词的伴随词来了解这个词”(《语言学分析研究》,Wiley-Blackwell)。
让我们通过从将每个单词映射到一个向量的思想开始,逐步构建 Word2Vec。我们用来表示单词的最简单向量是这样的:在向量的某个特定位置上为 1
,其他所有位置为 0
(这种方法被称为 one-hot 编码,因为只有一位是开着的——即 hot)。向量的长度是词汇表的大小。语料库中所有单词的 one-hot 向量集合有点像字典,比如,我们可以说如果单词是 movie
,它将在某个特定位置上用 1
来表示。如果单词是 actor
,它将在另一个位置上用 1
来表示。
目前,我们还没有考虑周围的词语。one-hot 编码的第一步是整数编码,我们为语料库中的每个单词分配一个特定的整数。以下代码使用来自 scikit-learn 的库进行整数编码和 one-hot 编码。我们还从 numpy
库中导入了一些函数,如 array
和 argmax
,我们将在后面的章节中再次使用这些函数:
from numpy import array
from numpy import argmax
from sklearn.preprocessing import LabelEncoder
from sklearn.preprocessing import OneHotEncoder
#import the movie reviews
from nltk.corpus import movie_reviews
# make a list of movie review documents
documents = [(list(movie_reviews.words(fileid)))
for category in movie_reviews.categories()
for fileid in movie_reviews.fileids(category)]
# for this example, we'll just look at the first document, and
# the first 50 words
data = documents[0]
values = array(data)
short_values = (values[:50])
# first encode words as integers
# every word in the vocabulary gets a unique number
label_encoder = LabelEncoder()
integer_encoded = label_encoder.fit_transform(short_values)
# look at the first 50 encodings
print(integer_encoded)
[32 3 40 35 12 19 39 5 10 31 1 15 8 37 16 2 38 17 26 7 6 2 30 29
36 20 14 1 9 24 18 11 39 34 23 25 22 27 1 8 21 28 2 42 0 33 36 13
4 41]
看一下第一部电影评论中前 50 个整数编码,我们看到一个长度为 50
的向量。这个向量可以转换为 one-hot 编码,如下面的代码所示:
# convert the integer encoding to onehot encoding
onehot_encoder = OneHotEncoder(sparse=False)
integer_encoded = integer_encoded.reshape(
len(integer_encoded), 1)
onehot_encoded = onehot_encoder.fit_transform(
integer_encoded)
print(onehot_encoded)
# invert the first vector so that we can see the original word it encodes
inverted = label_encoder.inverse_transform(
[argmax(onehot_encoded[0, :])])
print(inverted)
[[0\. 0\. 0\. ... 0\. 0\. 0.]
[0\. 0\. 0\. ... 0\. 0\. 0.]
[0\. 0\. 0\. ... 1\. 0\. 0.]
...
[0\. 0\. 0\. ... 0\. 0\. 0.]
[0\. 0\. 0\. ... 0\. 0\. 0.]
[0\. 0\. 0\. ... 0\. 1\. 0.]]
['plot']
上面的代码输出首先显示了一个子集的 one-hot 向量。由于它们是 one-hot 向量,因此它们在除一个位置之外的所有位置上都是 0
,而唯一的那个位置值为 1
。显然,这种表示方式是非常稀疏的,因此提供一个更紧凑的表示方式会更好。
倒数第二行展示了我们如何将 one-hot 向量反转以恢复原始单词。稀疏表示占用大量内存,而且不太实际。
Word2Vec 方法使用神经网络来减少嵌入的维度。我们将在 第十章 中回到神经网络的细节,但对于这个例子,我们将使用一个名为 Gensim
的库,它将为我们计算 Word2Vec。
以下代码使用 Gensim 的 Word2Vec
库来创建一个电影评论语料库的模型。通过 Word2Vec 创建的 Gensim model
对象包括许多有趣的用于处理数据的方法。以下代码展示了其中一个方法——most_similar
,该方法给定一个单词后,能够在数据集中找到与该单词最相似的单词。这里,我们可以看到与语料库中 movie
最相似的 25 个单词的列表,以及根据 Word2Vec 分析,表示单词与 movie
相似度的得分:
import gensim
import nltk
from nltk.corpus import movie_reviews
from gensim.models import Word2Vec
# make a list of movie review documents
documents = [(list(movie_reviews.words(fileid)))
for category in movie_reviews.categories()
for fileid in movie_reviews.fileids(category)]
all_words = movie_reviews.words()
model = Word2Vec(documents, min_count=5)
model.wv.most_similar(positive = ['movie'],topn = 25)
[('film', 0.9275647401809692),
('picture', 0.8604983687400818),
('sequel', 0.7637531757354736),
('flick', 0.7089548110961914),
('ending', 0.6734793186187744),
('thing', 0.6730892658233643),
('experience', 0.6683703064918518),
('premise', 0.6510635018348694),
('comedy', 0.6485130786895752),
('genre', 0.6462267637252808),
('case', 0.6455731391906738),
('it', 0.6344209313392639),
('story', 0.6279274821281433),
('mess', 0.6165297627449036),
('plot', 0.6162343621253967),
('message', 0.6131927371025085),
('word', 0.6131172776222229),
('movies', 0.6125075221061707),
('entertainment', 0.6109789609909058),
('trailer', 0.6068858504295349),
('script', 0.6000528335571289),
('audience', 0.5993804931640625),
('idea', 0.5915037989616394),
('watching', 0.5902948379516602),
('review', 0.5817495584487915)]
从上面的代码可以看到,Word2Vec 根据单词出现的上下文,找到的与 movie
最相似的单词正是我们所期待的。排名前两位的单词,film
和 picture
,是 movie
的近义词。在 第十章 中,我们将回到 Word2Vec,并查看这种模型如何用于 NLP 任务。
虽然 Word2Vec 考虑了词语在数据集中出现的上下文,但词汇表中的每个词都由一个单一的向量表示,该向量囊括了词语出现的所有上下文。这忽视了词语在不同上下文中可能具有不同含义的事实。下一节将回顾基于特定上下文来表示词语的方法。
使用上下文相关的向量表示词语
Word2Vec 的词向量是上下文无关的,即一个词在任何上下文中总是具有相同的向量。然而,实际上,词语的含义受到附近词语的强烈影响。例如,film在句子We enjoyed the film和the table was covered with a thin film of dust中的含义是完全不同的。为了捕捉这些上下文中含义的差异,我们希望能够为这些词提供不同的向量表示,以反映由于不同上下文而导致的含义差异。这个研究方向在过去几年中得到了广泛的探索,最初是从BERT(双向编码器表示的变换器)系统开始的(aclanthology.org/N19-1423/
(Devlin 等,NAACL 2019))。
这种方法极大地推动了自然语言处理技术的进步,我们将在后文中深入讨论。因此,我们将在第十一章中详细探讨上下文相关的词语表示,在那一章中我们将对此话题进行深入讲解。
概述
在本章中,我们学习了如何根据可用的数据和其他需求选择不同的自然语言处理方法。此外,我们还学习了如何表示用于自然语言处理应用的数据。我们特别强调了向量表示,包括文档和词语的向量表示。对于文档表示,我们介绍了二进制词袋模型、词袋计数模型和 TF-IDF 方法。对于词语表示,我们回顾了 Word2Vec 方法,并简要介绍了上下文相关的向量,这将在第十一章中更加详细地讨论。
在接下来的四章中,我们将基于本章学习的表示方法,展示如何训练模型,并将其应用于不同的问题,如文档分类和意图识别。我们将从第八章的基于规则的方法开始,讨论在第九章中传统的机器学习技术,讲解第十章中的神经网络,并在第十一章中讨论最现代的方法——变换器和预训练模型。
第八章:基于规则的技术
基于规则的技术是自然语言处理(NLP)中非常重要且有用的工具。规则用于检查文本并决定如何以全有或全无的方式进行分析,这与我们将在后续章节中回顾的统计技术不同。在本章中,我们将讨论如何将基于规则的技术应用于自然语言处理。我们将查看一些示例,如正则表达式、句法分析和语义角色分配。我们将主要使用在前几章中见过的 NLTK 和 spaCy 库。
本章将涵盖以下主题:
-
基于规则的技术
-
为什么使用规则?
-
探索正则表达式
-
句子级别分析——句法分析和语义角色分配
基于规则的技术
自然语言处理中的基于规则的技术顾名思义,依赖于由人工开发者编写的规则,而非从数据中派生的机器学习模型。基于规则的技术曾是多年来自然语言处理的最常见方法,但正如我们在第七章中看到的,基于规则的方法在大多数自然语言处理应用程序的整体设计中,已经被数值型的机器学习方法大多取代。出现这种情况的原因有很多;例如,由于规则是由人类编写的,如果开发者忽视了某些情况,规则可能无法覆盖所有场景。
然而,对于实际应用来说,规则可能非常有用,无论是单独使用,还是与机器学习模型结合使用。
下一节将讨论在自然语言处理应用中使用规则的动机。
为什么使用规则?
规则在以下一种或多种情况下非常有用:
-
您正在开发的应用程序要求分析包含成千上万甚至百万种变体的固定表达式,而提供足够的学习数据来训练机器模型将极为困难。这些固定表达式包括数字、货币金额、日期和地址等。例如,当数据如此多样化时,系统很难学习模型。此外,通常不需要这样做,因为这些表达式的格式非常结构化,编写分析这些表达式的规则并不困难。出于这两个原因,基于规则的方法是识别固定表达式的一个更简单的解决方案。
-
应用程序可用的训练数据非常少,而创建新数据将是昂贵的。例如,注释新的数据可能需要非常专业的专长。尽管现在有一些技术(如少量学习或零-shot 学习)可以使大规模预训练模型适应特定领域,但如果领域特定的数据在语法或词汇上与原始训练数据有很大不同,那么适应过程可能无法顺利进行。医学报告和空中交通管制信息就是这类数据的例子。
-
已经有现成的、经过充分测试的规则和库可以轻松地用于新的应用程序,例如用于识别日期和时间的 Python
datetime
包。 -
目标是通过对语料库的初步标注来启动一个机器学习模型。语料库标注是为了将该语料库作为机器学习模型的训练数据,或者将其作为 NLP 系统评估中的黄金标准所需的准备工作。在这个过程中,数据首先通过应用一些手写规则进行标注。然后,生成的标注语料库通常会被人工标注者审查和修正,因为它可能包含错误。尽管语料库需要审查过程,但通过基于规则的系统进行初步标注会比从零开始进行人工标注节省时间。
-
应用程序需要从一个固定的已知集合中找到命名实体。
-
结果必须非常精确——例如,语法检查、校对、语言学习和作者研究。
-
需要一个快速的原型来测试下游处理,而机器学习所需的数据收集和模型训练阶段则需要太多时间。
我们将从正则表达式开始,它是一种非常常见的技术,用于分析包含已知模式的文本。
探索正则表达式
正则表达式是一种广泛使用的基于规则的技术,通常用于识别固定表达式。我们所说的固定表达式是指根据其自身规则形成的单词和短语,这些规则与语言的正常模式有很大的不同。
一种固定表达式是货币金额。货币金额的格式变化很少——小数点位数、货币类型的符号,以及数字是否用逗号或句点分隔。应用程序可能只需要识别特定的货币,这样可以进一步简化规则。其他常见的固定表达式包括日期、时间、电话号码、地址、电子邮件地址、度量单位和数字。在 NLP 中,正则表达式最常用于对文本进行预处理,然后使用其他技术进一步分析。
不同的编程语言在正则表达式的格式上略有不同。我们将使用 Python 格式,具体格式可以参见docs.python.org/3/library/re.html
以及 Python re
库中的定义。我们这里不会定义正则表达式语法,因为网上有很多资源描述了正则表达式语法,包括 Python 文档,我们不需要重复这些内容。你可能会发现www.h2kinfosys.com/blog/nltk-regular-expressions/
和python.gotrained.com/nltk-regex/
上的信息有助于深入了解 NLTK 中的正则表达式。
我们将首先介绍如何使用正则表达式操作字符串的基础知识,然后提供一些技巧,帮助简化正则表达式的应用和调试。
使用正则表达式识别、解析和替换字符串
正则表达式的最简单用法是仅仅标记是否匹配发生。固定表达式匹配后,我们要做什么取决于应用的目标。在某些应用中,我们只需要识别某个固定表达式是否发生过或没有发生过。例如,在验证网页表单中的用户输入时,这会很有用,这样用户就能纠正无效的地址格式。以下代码展示了如何使用正则表达式识别美国地址:
import re
# process US street address
# the address to match
text = "223 5th Street NW, Plymouth, PA 19001"
print(text)
# first define components of an address
# at the beginning of a string, match at least one digit
street_number_re = "^\d{1,}"
# match street names containing upper and lower case letters and digits, including spaces,
# followed by an optional comma
street_name_re = "[a-zA-Z0-9\s]+,?"
# match city names containing letters, but not spaces, followed by a comma
# note that two word city names (like "New York") won't get matched
# try to modify the regular expression to include two word city names
city_name_re = " [a-zA-Z]+(\,)?"
# to match US state abbreviations, match any two upper case alphabetic characters
# notice that this overgenerates and accepts state names that don't exist because it doesn't check for a valid state name
state_abbrev_re = " [A-Z]{2}"
# match US postal codes consisting of exactly 5 digits. 9 digit codes exist, but this expression doesn't match them
postal_code_re = " [0-9]{5}$"
# put the components together -- define the overall pattern
address_pattern_re = street_number_re + street_name_re + city_name_re + state_abbrev_re + postal_code_re
# is this an address?
is_match = re.match(address_pattern_re,text)
if is_match is not None:
print("matches address_pattern")
else:
print("doesn't match")
在其他情况下,我们可能希望解析表达式并为其组件赋予含义——例如,在日期中,识别月份、日期和年份可能会很有用。在某些情况下,我们可能希望用另一个表达式替换该表达式,删除它,或者规范化它,使得所有该表达式的出现形式都一致。我们甚至可能需要做这些操作的组合。例如,如果应用是分类,那么我们可能只需要知道正则表达式是否出现过;也就是说,我们不需要关心它的内容。在这种情况下,我们可以用class
标记(如DATE
)替换该表达式,这样像我们在 2022 年 8 月 2 日收到了包裹这样的句子就变成了我们在 DATE 收到了包裹。将整个表达式替换为class
标记还可以用于编辑敏感文本,例如社会保障号码。
上述代码展示了如何使用正则表达式匹配文本中的模式,并展示了确认匹配的代码。然而,这个例子只是展示了如何确认匹配是否存在。我们可能还需要做其他事情,比如用类标签替换地址,或者标记字符串中的匹配部分。以下代码展示了如何使用正则表达式的sub
方法替换地址为类标签:
# the address to match
text = "223 5th Street NW, Plymouth, PA 19001"
# replace the whole expression with a class tag -- "ADDRESS"
address_class = re.sub(address_pattern_re,"ADDRESS",text)
print(address_class)
ADDRESS
另一个有用的操作是为整个表达式添加语义标签,例如address
,如下代码所示。这段代码展示了如何为地址添加标签。这样,我们就能够在文本中识别美国地址,并进行诸如计数或提取所有地址之类的任务:
# suppose we need to label a matched portion of the string
# this function will label the matched string as an address
def add_address_label(address_obj):
labeled_address = add_label("address",address_obj)
return(labeled_address)
# this creates the desired format for the labeled output
def add_label(label, match_obj):
labeled_result = "{" + label + ":" + "'" + match_obj.group() + "'" + "}"
return(labeled_result)
# add labels to the string
address_label_result = re.sub(address_pattern_re,add_address_label,text)
print(address_label_result)
运行上述代码的结果如下:
{address:'223 5th Street NW, Plymouth, PA 19001'}
最后,如果我们想要从文本中删除整个匹配项,正则表达式非常有用——例如,删除 HTML 标记。
使用正则表达式的通用技巧
正则表达式很容易变得非常复杂,且难以修改和调试。它们也很容易无法识别一些应该识别的示例,或者错误地识别了不该识别的内容。虽然很诱人去尝试将正则表达式匹配得恰好只识别它应该识别的内容,且不识别其他任何东西,但这样可能会使表达式变得过于复杂,难以理解。有时候,错过一些边缘情况可能比让表达式保持简单更好。
如果我们发现现有的正则表达式未能识别一些我们希望捕获的表达式,或错误地识别了不该捕获的表达式,有时修改现有表达式而不破坏原有的功能可能会很困难。以下是一些能让正则表达式更容易使用的技巧:
-
首先写下你希望正则表达式匹配的内容(例如任何两个连续的大写字母)。这不仅有助于明确你想要做的事情,还能帮助你发现可能忽略的情况。
-
将复杂的表达式拆分为多个组件,并在将它们组合之前独立测试每个组件。除了有助于调试外,这些组件表达式还可以在其他复杂表达式中重复使用。我们在上一节的第一个代码块中看到了这一点,像
street_name_re
这样的组件。 -
在尝试编写自己的正则表达式之前,先使用已有的经过测试的正则表达式来处理常见表达式,例如 Python 的
datetime
包(参见docs.python.org/3/library/datetime.html
)。这些正则表达式经过多年多位开发者的充分测试。
接下来的两节将讨论分析自然语言中两个最重要方面的具体方法:单词和句子。
下一节将通过分析单个单词来开始这个话题。
单词级别的分析
本节将讨论两种分析单词的方法。第一种是词形还原,它通过将单词拆解成组成部分来减少文本中的变化。第二种方法讨论了一些关于如何利用关于单词意义的层次化语义信息(如本体论)进行分析的思路。
词形还原
在我们之前讨论的 第五章 文本预处理部分中,我们讲解了 词形还原(以及与之相关的词干提取)任务,作为一种规范化文本文档的方法,从而减少我们分析文档中的变异性。正如我们所讨论的,词形还原过程将文本中的每个单词转换为其词根,去除像英语中复数形式的 -s 这样的信息。词形还原还需要一个字典,因为字典提供了被还原词汇的词根。当我们在 第五章 中讲解词形还原时,我们使用了普林斯顿大学的 WordNet(wordnet.princeton.edu/
)作为字典。
在下一节中,我们将使用 WordNet 的语义信息,讨论本体及其应用。
本体
entity
。
图 3.2 中的本体是 WordNet 英语及其他语言的一个部分。这些层级关系有时被称为 is a 关系。例如,an airplane is a vehicle(一架飞机是一个交通工具)。在这个例子中,我们说 vehicle 是一个 上位词,airplane 是一个 下位词。WordNet 使用自己的一些术语。在 WordNet 的术语中,hypernym 和上位词是一样的,hyponym 和下位词是一样的。
WordNet 还包含许多其他语义关系,如同义词和 部分—整体 关系。例如,我们可以从 WordNet 中了解到,机翼是飞机的一部分。此外,WordNet 还包括词性信息,你会记得我们在 第五章 中使用了这些词性信息,用于对文本进行词性标注,为词形还原做准备。
除了 WordNet,还有其他本体,你甚至可以使用斯坦福大学的 Protégé 工具(protege.stanford.edu/
)来构建你自己的本体。然而,WordNet 是一个很好的入门方法。
我们如何在自然语言处理(NLP)应用中利用像 WordNet 这样的本体呢?以下是一些想法:
-
开发一个写作工具,帮助作者查找同义词、反义词以及他们想要使用的词语的定义。
-
统计文本中不同类别的词汇出现次数。例如,你可能对查找所有提到“车辆”的地方感兴趣。即使文本中实际写的是 car 或 boat,你仍然可以通过查找与 vehicle 相关的上位词来判断文本中提到了车辆。
-
通过在不同句型中用相同的上位词替换不同的单词,为机器学习生成额外的训练数据。例如,假设我们有一个关于烹饪的聊天机器人,它可能会收到类似How can I tell whether a pepper is ripe? 或Can I freeze tomatoes?的问题。这些问题中,pepper和tomatoes可以被成百上千种不同类型的食物替代。如果要为所有这些类型创建训练示例,将会非常繁琐。为了避免这种情况,你可以从 WordNet 中找到所有不同类型的蔬菜,并通过将它们放入句子模板中生成训练数据,从而创造新的句子。
让我们看一个之前策略的例子。
你可能记得之前提到过 WordNet,它包含在 NLTK 中,因此我们可以导入它并查询vegetable的感知列表(synsets),如下所示:
import nltk
from nltk.corpus import wordnet as wn
wn.synsets('vegetable')
然后,我们将看到vegetable有两个感知,或意思,我们可以使用以下代码查询它们的定义:
[Synset('vegetable.n.01'), Synset('vegetable.n.02')]
print(wn.synset('vegetable.n.01').definition())
print(wn.synset('vegetable.n.02').definition())
感知名称的格式,例如vegetable.n.01
,应该解释为word
和词性
(这里的n表示名词),后面跟着该单词在感知列表中的顺序。我们打印出每个感知的定义,以便查看 WordNet 中的感知含义。结果定义如下:
edible seeds or roots or stems or leaves or bulbs or tubers or nonsweet fruits of any of numerous herbaceous plant
any of various herbaceous plants cultivated for an edible part such as the fruit or the root of the beet or the leaf of spinach or the seeds of bean plants or the flower buds of broccoli or cauliflower
第一个感知指的是我们吃的部分,第二个感知指的是我们吃的植物部分。如果我们对烹饪感兴趣,可能更想要把vegetable的第一个感知作为食物。我们可以使用以下代码获取第一个感知中的所有蔬菜列表:
word_list = wn.synset('vegetable.n.01').hyponyms()
simple_names = []
for word in range (len(word_list)):
simple_name = word_list[word].lemma_names()[0]
simple_names.append(simple_name)
print(simple_names)
['artichoke', 'artichoke_heart', 'asparagus', 'bamboo_shoot', 'cardoon', 'celery', 'cruciferous_vegetable', 'cucumber', 'fennel', 'greens', 'gumbo', 'julienne', 'leek', 'legume', 'mushroom', 'onion', 'pieplant', 'plantain', 'potherb', 'pumpkin', 'raw_vegetable', 'root_vegetable', 'solanaceous_vegetable', 'squash', 'truffle']
代码执行以下步骤:
-
收集所有第一个感知的蔬菜类型(下位词),并将它们存储在
word_list
变量中。 -
遍历单词列表,为每个单词收集其词根,并将词根存储在
simple_names
变量中。 -
打印出单词。
然后,我们可以通过将每个单词填充到文本模板中来生成一些示例数据,如下所示:
text_frame = "can you give me some good recipes for "
for vegetable in range(len(simple_names)):
print(text_frame + simple_names[vegetable])
can you give me some good recipes for artichoke
can you give me some good recipes for artichoke_heart
can you give me some good recipes for asparagus
can you give me some good recipes for bamboo_shoot
上述代码展示了从文本框架和蔬菜列表生成的前几个句子。当然,在实际应用中,我们希望有多个文本框架,以便获得更丰富的句子种类。
在本节开始时,我们列出了几种在自然语言处理应用中应用本体的方法;如果你想到其他不同的方式,利用单词的含义来解决自然语言应用中的问题,你可能还能想出更多方法。
然而,单词并不是孤立出现的;它们与其他单词组合在一起,构成具有更丰富、更复杂含义的句子。下一节将从单词分析转向整个句子的分析,我们将从句法和语义两个方面分析句子。
句子级别分析
句子可以从句法(句子各部分之间的结构关系)或语义(句子各部分的意义关系)方面进行分析。接下来我们将讨论这两种分析类型。识别句法关系对于一些应用非常有用,例如语法检查(句子的主语是否与动词一致?动词的形式是否正确?),而识别语义关系则对于一些应用,如在聊天机器人中查找请求的组成部分,也非常有用。将句法和语义关系一起识别是几乎所有 NLP 应用中统计方法的替代方案。
句法分析
句子和短语的句法可以通过一个叫做parse
包的过程来分析,包中包含了多种解析算法,你可以进一步探索。在本节的例子中,我们将使用nltk.parse.ChartParser
类中的图表解析器,这是一个常见且基础的方法。
上下文无关文法与解析
定义句法分析规则的一个非常常见的方法是上下文无关文法(CFGs)。CFG 可以用于图表解析以及许多其他解析算法。你可能对这种格式比较熟悉,因为它在计算机科学中被广泛应用于定义形式语言,如编程语言。CFG 由一组规则组成。每个规则由左侧(LHS)和右侧(RHS)组成,通常通过一个符号(如箭头)来分隔。规则的解释是,LHS 上的单一符号由 RHS 上的各个成分组成。
例如,句法无关规则S -> NP VP
表明一个句子(S
)由一个名词短语(NP)和一个动词短语(VP)组成。一个 NP 可以由一个限定词(Det),如an、my或the,后面跟着一个或两个名词(Ns),如elephant,可能接着一个介词短语(PP),或者仅仅是一个代词(Pro),等等。每个规则必须通过另一个规则来定义,直到规则最终以单词(或更广泛地说,终结符号)结束,这些符号不会出现在任何规则的左侧(LHS)。
以下展示了创建一些英语规则的 CFG 的代码。这些是成分规则,它们展示了句子各部分之间的关系。还有另一种常用的格式,即依赖关系,它展示了单词之间的关系,但我们在本书中不会探讨这种格式,因为成分规则足以说明句法语法和句法分析的基本概念:
grammar = nltk.CFG.fromstring("""
S -> NP VP
PP -> P NP
NP -> Det N | Det N N |Det N PP | Pro
Pro -> 'I' |'you'|'we'
VP -> V NP | VP PP
Det -> 'an' | 'my' | 'the'
N -> 'elephant' | 'pajamas' | 'movie' |'family' | 'room' |'children'
V -> 'saw'|'watched'
P -> 'in'
""")
这个语法只能解析少数几句句子,如孩子们在家庭房间观看电影。例如,它无法解析句子孩子们睡觉,因为在这个语法中,VP 除了动词外,必须包括宾语或介词短语。完整的英语 CFG 会比前面的代码更大、更复杂。还值得指出的是,NLTK 规则可以附带概率标注,表示 RHS 上每个替代项的可能性。
例如,前面代码中的规则 4(Pro <https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/011.png> 'I' |'you'|'we'
)可能具有I、you 和 we 的相对概率。在实践中,这将导致更准确的解析,但它不影响我们将在本章中展示的例子。表 8.1 总结了 CFG 术语的一些定义:
符号 | 含义 | 示例 |
---|---|---|
S | 句子 | 孩子们观看了电影 |
NP | 名词短语 | 孩子们 |
VP | 动词短语 | 观看电影 |
PP | 介词短语 | 在家庭房间 |
Pro | 代词 | I, we, you, they, he, she, it |
Det | 限定词或冠词 | the, a |
V | 动词 | 看, 观看 |
N | 名词 | 孩子们,电影,象,家庭,房间 |
表 8.1 – CFG 代码块中语法术语的含义
表 8.2 总结了在 NLTK CFG 中使用的一些句法约定:
符号 | 含义 |
---|---|
-> | 分隔左侧和右侧部分 |
| | 分隔扩展左侧的右侧部分(RHS)的替代可能性 |
单引号 | 表示一个单词;即终结符号 |
首字母大写 | 表示一个句法类别;即非终结符号,预计通过其他规则进行定义 |
表 8.2 – CFG 语法
我们可以使用前面代码块中的语法,通过以下代码来解析并可视化句子孩子们在家庭房间观看电影:
# we will need this to tokenize the input
from nltk import word_tokenize
# a package for visualizing parse trees
import svgling
# to use svgling we need to disable NLTK's normal visualization functions
svgling.disable_nltk_png()
# example sentence that can be parsed with the grammar we've defined
sent = nltk.word_tokenize("the children watched the movie in the family room")
# create a chart parser based on the grammar above
parser = nltk.ChartParser(grammar)
# parse the sentence
trees = list(parser.parse(sent))
# print a text-formatted parse tree
print(trees[0])
# print an SVG formatted parse tree
trees[0]
我们可以以不同方式查看解析结果——例如,像下面的代码那样作为括号文本格式:
(S
(NP (Det the) (N children))
(VP
(VP (V watched) (NP (Det the) (N movie)))
(PP (P in) (NP (Det the) (N family) (N room)))))
请注意,解析直接反映了语法:整体结果称为S,因为它来自语法中的第一个规则,S -> NP VP
。同样,NP 和 VP 直接连接到 S,它们的子节点在它们后面以括号形式列出。
上述格式对于后续的处理阶段非常有用,可能需要机器可读;然而,它有点难以阅读。图 8.1 显示了这个解析的常规树形图,更容易查看。与前面的文本解析相似,你可以看到它与语法直接对齐。单词或终结符号都出现在树的底部,或称为叶子:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_08_01.jpg
图 8.1 – “孩子们在家庭房间观看电影”的句法树
你可以尝试用这个语法解析其他句子,还可以尝试修改语法。例如,尝试添加一个语法规则,使得语法能够解析没有跟 NP 或 PP 的动词的句子,比如孩子们睡觉了。
语义分析和槽填充
前面关于正则表达式和句法分析的章节仅仅讨论了句子的结构,而没有涉及它们的意义。像前面章节展示的这种句法语法,可以解析像电影在房间里看孩子这样的无意义句子,只要它们符合语法规则。我们可以在图 8.2中看到这一点:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_08_02.jpg
图 8.2 – “电影在房间里看孩子”句子的句法树
然而,在大多数应用程序中,我们不仅仅想找出句子的句法结构;我们还想提取它们的部分或全部含义。提取含义的过程叫做语义分析。含义的具体意义会根据应用程序的不同而变化。例如,在我们本书中到目前为止讨论的许多应用中,唯一需要从文档中推导出来的含义就是它的总体分类。这在电影评论数据中就是如此——我们希望从文档中获取的唯一含义就是积极或消极的情感。我们在前几章中讨论的统计方法非常擅长进行这种粗粒度的处理。
然而,也有一些应用需要获得句子中项目之间关系的更详细信息。虽然有一些机器学习技术可以获取细粒度的信息(我们将在第九章、第十章和第十一章中讨论它们),但它们在大量数据下表现最佳。如果数据较少,有时基于规则的处理会更加有效。
基本的槽填充
对于接下来的例子,我们将详细讨论一种在互动应用程序中经常使用的技术——槽填充。这是一种常用于语音机器人和聊天机器人的技术,尽管它也用于非互动应用程序,如信息提取。
作为一个例子,考虑一个帮助用户寻找餐馆的聊天机器人应用程序。该应用程序被设计为期待用户提供一些搜索标准,如菜系类型、氛围和位置。这些标准就是应用程序中的槽。例如,用户可能会说,我想找一家离这里近的意大利餐馆。整体用户目标,餐馆搜索,就是意图。在这一章中,我们将重点关注识别槽,但会在后续章节中更详细地讨论意图。
该应用程序的设计如图 8.3所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_08_03.jpg
图 8.3 – 餐厅搜索应用的槽位
在处理用户的发言时,系统必须识别出用户指定了哪些槽位,并提取其值。这就是系统帮助用户找到餐厅所需要的所有信息,因此句子中的其他部分通常会被忽略。如果被忽略的部分实际上是相关的,这可能会导致错误,但在大多数情况下,这种方法是有效的。这也为许多应用程序提供了一种有用的处理策略,即系统只查找与其任务相关的信息。这与我们之前回顾的句法分析过程不同,后者要求系统分析整个句子。
我们可以使用 spaCy 中的基于规则的匹配器来创建一个应用程序,分析用户的发言以找出这些槽位的值。基本方法是为系统定义模式,以找到指定槽位的词,并定义相应的标签来标记这些值及其槽位名称。以下代码展示了如何在句子中找到 图 8.3 中显示的一些槽位(我们不会展示所有槽位的代码,以保持示例简短):
import spacy
from spacy.lang.en import English
nlp = English()
ruler = nlp.add_pipe("entity_ruler")
cuisine_patterns = [
{"label": "CUISINE", "pattern": "italian"},
{"label": "CUISINE", "pattern": "german"},
{"label": "CUISINE", "pattern": "chinese"}]
price_range_patterns = [
{"label": "PRICE_RANGE", "pattern": "inexpensive"},
{"label": "PRICE_RANGE", "pattern": "reasonably priced"},
{"label": "PRICE_RANGE", "pattern": "good value"}]
atmosphere_patterns = [
{"label": "ATMOSPHERE", "pattern": "casual"},
{"label": "ATMOSPHERE", "pattern": "nice"},
{"label": "ATMOSPHERE", "pattern": "cozy"}]
location_patterns = [
{"label": "LOCATION", "pattern": "near here"},
{"label": "LOCATION", "pattern": "walking distance"},
{"label": "LOCATION", "pattern": "close by"},
{"label": "LOCATION", "pattern": "a short drive"}]
ruler.add_patterns(cuisine_patterns)
ruler.add_patterns(price_range_patterns)
ruler.add_patterns(atmosphere_patterns)
ruler.add_patterns(location_patterns)
doc = nlp("can you recommend a casual italian restaurant within walking distance")
print([(ent.text, ent.label_) for ent in doc.ents])
[('casual', 'ATMOSPHERE'), ('italian', 'CUISINE'), ('walking distance', 'LOCATION')]
上述代码首先导入了 spaCy 和我们在英语文本处理中需要的信息。规则处理器被称为 ruler
,并作为 NLP 流程中的一个阶段添加。然后我们定义了三种菜系(实际应用中可能有更多种),并将它们标记为 CUISINE
。类似地,我们定义了用于识别价格范围、氛围和地点的模式。这些规则规定,如果用户的句子包含特定的词或短语,例如 near here
,则应将 LOCATION
槽位填充为该词或短语。
下一步是将模式添加到规则处理器(ruler
)中,然后在一个示例句子上运行 NLP 处理器,你能推荐一个步行可达的休闲意大利餐厅吗?。这个过程将规则应用于文档,结果是得到一组标记的槽位(称为doc.ents
)。通过打印槽位和值,我们可以看到处理器找到了三个槽位,ATMOSPHERE
、CUISINE
和 LOCATION
,对应的值分别是 casual
、Italian
和 walking distance
。通过尝试其他句子,我们可以确认这个槽位填充方法的以下几个重要特性:
-
句子中不匹配模式的部分,如你能推荐吗,会被忽略。这也意味着,句子中不匹配的部分可能是无意义的,或者实际上对含义至关重要,但被忽略后,系统可能会出错。例如,如果用户说你能推荐一个步行可达的非意大利餐馆吗,系统会错误地认为用户想找一个意大利餐馆,依据这些规则。可以编写额外的规则来考虑这类情况,但在许多应用中,我们只希望接受一些不准确性,作为保持应用简洁的代价。这个问题需要根据具体应用来考虑。
-
插槽和对应的值会在句子中出现的任何位置被识别;它们不需要出现在特定的顺序中。
-
如果特定插槽没有出现在句子中,通常不会导致问题。它只会从结果实体列表中被省略。
-
如果一个插槽出现多次,所有出现的实例都会被识别。
插槽标签的名称由开发者自行决定;它们不需要是特定的值。例如,我们可以使用TYPE_OF_FOOD
代替CUISINE
,处理方式是一样的。
我们可以使用 spaCy 的可视化工具displacy
,通过以下代码来获得结果的更清晰可视化:
from spacy import displacy
colors = {"CUISINE": "#ea7e7e",
"PRICE_RANGE": "#baffc9",
"ATMOSPHERE": "#abcdef",
"LOCATION": "#ffffba"}
options = {"ents": ["CUISINE","PRICE_RANGE","ATMOSPHERE","LOCATION"], "colors": colors}
displacy.render(doc, style="ent", options=options,jupyter = True)
我们可以在图 8.4中看到结果,其中文本及其插槽和对应的值已被高亮显示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_08_04.jpg
图 8.4 – 使用 displacy 进行插槽可视化
由于我们的插槽是自定义的(即不是内建在 spaCy 中的),为了显示彩色的插槽,我们必须为不同的插槽(或ents
)定义颜色,并将这些颜色分配给插槽。然后,我们可以使用不同的颜色可视化每个插槽及其对应的值。颜色在前面的代码中的colors
变量中定义。我们可以为插槽分配任何认为有用的颜色。这些颜色不一定要不同,但通常来说,如果它们不同并且具有较高的区分度会更有帮助。此示例中的颜色值是十六进制代码,具有标准的颜色解释。www.color-hex.com/
是一个显示许多颜色的十六进制值的实用网站。
使用 spaCy 的 id 属性
你可能已经注意到,在这个例子中我们定义的一些插槽值是相同的——例如,close by
和 near here
。如果这些插槽和值被传递到后续的处理阶段,比如数据库查询,那么后续阶段必须有处理 close by
和 near here
两者的代码,尽管数据库查询是相同的。这会使应用程序变得复杂,因此我们希望避免这种情况。spaCy 提供了 ent
的另一个属性,ent_id_
,用于这个目的。这个 id
属性可以在查找插槽的模式中分配,并且可以与标签和模式一起使用。通过在模式声明中指定 id
属性来完成这一操作,这是以下代码中位置模式的修改:
location_patterns = [
{"label": "LOCATION", "pattern": "near here", "id":"nearby"},
{"label": "LOCATION", "pattern": "close by","id":"nearby"},
{"label": "LOCATION", "pattern": "near me","id":"nearby"},
{"label": "LOCATION", "pattern": "walking distance", "id":"short_walk"},
{"label": "LOCATION", "pattern": "short walk", "id":"short_walk"},
{"label": "LOCATION", "pattern": "a short drive", "id":"short_drive"}]
如果我们打印出来自 can you recommend a casual italian restaurant close by 的插槽、值和 ID,结果如下:
[('casual', 'ATMOSPHERE', ''), ('italian', 'CUISINE', ''), ('close by', 'LOCATION', 'nearby')]
在这里,我们可以看到 close by
的 ID 是 nearby
,这是基于 close
by
的模式。
在前面的代码中,我们可以看到前三个位置模式,这些模式具有相似的含义,都被分配了 nearby
的 ID。通过这个 ID,处理的下一阶段只需要接收 ent_id_
值,因此只需要处理 nearby
,而不需要为 close by
和 near me
添加额外的情况。
请注意,在这个例子中,CUISINE
和 ATMOSPHERE
插槽的结果没有 ID,因为这些模式中没有定义 CUISINE
和 ATMOSPHERE
。然而,最好为所有模式定义 ID(如果有 ID),以保持结果的一致性。
还请注意,这些模式反映了一些关于哪些短语是同义的设计决策,因此应具有相同的 ID,哪些短语不是同义的,应具有不同的 ID。
在前面的代码中,我们可以看到 short walk
并没有与 near me
具有相同的 ID。例如,在这里做出的设计决策是将 short walk
和 near me
视为具有不同的含义,因此在应用程序的后续阶段需要不同的处理。关于哪些值是同义词,哪些不是同义词的决策将取决于应用程序以及后端应用程序中可用的信息的丰富程度。
我们已经描述了几种有用的基于规则的自然语言处理方法。表 8.3 总结了这些基于规则的技术,列出了规则技术的三个重要特性:
-
规则的格式
-
应用规则到文本的处理类型
-
结果如何表示
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_08_Table_01.jpg
表 8.3 – 基于规则的技术的格式、处理和结果
总结
在本章中,我们学习了几种重要的技能,利用规则处理自然语言。
我们已经学会了如何应用正则表达式来识别固定格式的表达式,如数字、日期和地址。我们还了解了基于规则的 Python 工具的使用,例如 NLTK 语法分析库,用于分析句子的语法结构,并学会了如何应用它们。最后,我们学习了用于语义分析的基于规则的工具,例如 spaCy 的entity_ruler
,用于分析句子的槽-值语义。
下一章,第九章将通过介绍统计技术,如使用朴素贝叶斯的分类方法、词频-逆文档频率(TF-IDF)、支持向量机(SVMs)和条件随机场,开始讨论机器学习。与我们在本章讨论的基于规则的方法不同,统计方法基于从训练数据中学习到的模型,然后应用到新的、未见过的数据上。与全或无的基于规则的系统不同,统计系统是基于概率的。
在探索这些技术时,我们还将考虑如何将它们与本章讨论的基于规则的技术结合起来,以创建更强大和有效的系统。
第九章:机器学习第一部分 – 统计机器学习
在本章中,我们将讨论如何将经典的统计机器学习技术应用于常见的自然语言处理(NLP)任务,如分类(或意图识别)和槽位填充。这些技术包括朴素贝叶斯、词频-逆文档频率(TF-IDF)、支持向量机(SVMs)和条件随机场(CRFs)。
这些经典技巧有两个方面需要我们考虑:表示和模型。表示指的是我们要分析的数据格式。你会记得在第七章中,标准的自然语言处理数据格式并不仅仅是单词列表。像向量这样的数值数据表示格式使得使用广泛可用的数值处理技术成为可能,从而开辟了许多处理的可能性。在第七章中,我们还探索了数据表示方法,如词袋模型(BoW)、TF-IDF 和 Word2Vec。本章中我们将主要使用 TF-IDF。
一旦数据格式化为可供进一步处理的形式,也就是说,一旦数据被向量化,我们就可以用它来训练或构建一个模型,进而分析系统未来可能遇到的相似数据。这就是训练阶段。未来数据可以是测试数据;也就是说,之前未见过的数据,类似于训练数据,用于评估模型。此外,如果模型在实际应用中使用,未来数据可能是用户或客户对运行时系统提出的新查询示例。当系统在训练阶段之后用于处理数据时,这被称为推断。
本章将涵盖以下主题:
-
评估的简要概述
-
使用 TF-IDF 表示文档,并通过朴素贝叶斯进行分类
-
使用 SVM 进行文档分类
-
使用条件随机场进行槽位填充
我们将以一套非常实用且基础的技巧开始本章内容,这些技巧应该是每个人工具箱中的一部分,并且经常成为分类问题的实际解决方案。
评估的简要概述
在我们了解不同统计技术如何工作之前,我们需要有一种方法来衡量它们的性能,并且有几个重要的考虑事项我们应该先回顾一下。第一个考虑因素是我们为系统的处理分配的指标或得分。最常见且简单的指标是准确率,它是正确响应的数量除以总尝试次数。例如,如果我们试图衡量一个电影评论分类器的性能,并且我们尝试将 100 条评论分类为正面或负面,如果系统正确分类了 75 条评论,那么准确率就是 75%。一个紧密相关的指标是错误率,从某种意义上说,它是准确率的对立面,因为它衡量的是系统犯错的频率。在这个例子中,错误率是 25%。
本章我们仅使用准确率,尽管实际上有更精确和信息量更大的指标被更常用,例如精确度、召回率、F1和曲线下面积(AUC)。我们将在第十三章中讨论这些指标。就本章而言,我们只需要一个基本的指标来进行结果对比,准确率已经足够。
我们在评估时需要牢记的第二个重要考虑因素是如何处理我们用于评估的数据。机器学习方法采用标准方法进行训练和评估,这涉及将数据集划分为训练、开发(也常称为验证)数据和测试子集。训练集是用于构建模型的数据,通常占可用数据的 60-80%,尽管具体百分比并不是决定性因素。通常,你会希望尽可能多地使用数据来进行训练,同时保留合理数量的数据用于评估目的。一旦模型构建完成,它可以用开发数据进行测试,通常约占总数据集的 10-20%。通过在开发集上使用模型,通常能发现训练算法的问题。最终的评估是在剩余的数据——测试数据上进行的,测试数据通常占总数据的 10%。
再次强调,训练、开发和测试数据的具体划分并不是关键。你的目标是使用训练数据构建一个好的模型,使得你的系统能够准确地预测新的、以前未见过的数据的解释。为了实现这一目标,你需要尽可能多的训练数据。测试数据的目标是准确衡量模型在新数据上的表现。为了实现这一目标,你需要尽可能多的测试数据。因此,数据划分总是涉及这些目标之间的权衡。
保持训练数据与开发数据,特别是测试数据的分离是非常重要的。训练数据上的表现并不能很好地反映系统在新数据上的实际表现,因此不应将训练数据的表现用于评估。
在简要介绍完评估后,我们现在将进入本章的主要内容。我们将讨论一些最为成熟的机器学习方法,这些方法广泛应用于重要的自然语言处理(NLP)任务,如分类和槽位填充。
使用 TF-IDF 表示文档,并通过朴素贝叶斯进行分类
除了评估之外,机器学习一般范式中还有两个重要话题:表示和处理算法。表示涉及将文本(如文档)转换为一种数值格式,该格式保留了关于文本的相关信息。然后,处理算法会分析这些信息,以执行自然语言处理任务。你已经在第七章中见过常见的表示方法——TF-IDF。在本节中,我们将讨论如何将 TF-IDF 与一种常见的分类方法——朴素贝叶斯结合使用。我们将解释这两种技术,并展示一个示例。
TF-IDF 总结
你可能还记得在第七章中关于 TF-IDF 的讨论。TF-IDF 基于一个直观的目标,即试图在文档中找到特别能代表其分类主题的词汇。在整个语料库中相对较少出现,但在特定文档中相对较常见的词汇,似乎在确定文档类别时非常有帮助。TF-IDF 在第七章的词频-逆文档频率(TF-IDF)部分中给出了定义。此外,我们还在图 7.4中看到了电影评论语料库中一些文档的部分 TF-IDF 向量。在这里,我们将使用这些 TF-IDF 向量,并利用朴素贝叶斯分类方法对文档进行分类。
使用朴素贝叶斯进行文本分类
贝叶斯分类技术已经使用多年,尽管其历史悠久,但至今仍然非常常见并被广泛使用。贝叶斯分类简单且快速,并且在许多应用中能产生令人满意的结果。
贝叶斯分类的公式如以下方程所示。对于每一个可能的类别,对于每一个文档,我们想要计算该文档属于该类别的概率。这一计算基于文档的某种表示;在我们的案例中,表示将是我们之前讨论过的向量之一——如 BoW、TF-IDF 或 Word2Vec。
为了计算这个概率,我们考虑给定类别下向量的概率,乘以类别的概率,再除以文档向量的概率,如下所示的公式:
P(category ∣ documentVector) = P(documentVector ∣ category)P(category) _____________________________ P(documentVector)
训练过程将决定每个类别中文档向量的概率以及各类别的总体概率。
这个公式被称为朴素,因为它假设向量中的特征是独立的。显然,对于文本来说这是不正确的,因为句子中的单词并不是完全独立的。然而,这个假设使得处理过程大大简化,实际应用中通常不会对结果产生重大影响。
贝叶斯分类有二元和多类版本。由于我们只有两类评论,因此我们将使用电影评论语料库进行二元分类。
TF-IDF/贝叶斯分类示例
使用 TF-IDF 和朴素贝叶斯进行电影评论分类时,我们可以从读取评论并将数据拆分为训练集和测试集开始,如下代码所示:
import sklearn
import os
from sklearn.feature_extraction.text import TfidfVectorizer
import nltk
from sklearn.datasets import load_files
path = './movie_reviews/'
# we will consider only the most 1000 common words
max_tokens = 1000
# load files -- there are 2000 files
movie_reviews = load_files(path)
# the names of the categories (the labels) are automatically generated from the names of the folders in path
# 'pos' and 'neg'
labels = movie_reviews.target_names
# Split data into training and test sets
# since this is just an example, we will omit the dev test set
# 'movie_reviews.data' is the movie reviews
# 'movie_reviews.target' is the categories assigned to each review
# 'test_size = .20' is the proportion of the data that should be reserved for testing
# 'random_state = 42' is an integer that controls the randomization of the
# data so that the results are reproducible
from sklearn.model_selection import train_test_split
movies_train, movies_test, sentiment_train, sentiment_test = train_test _split(movie_reviews.data, movie_reviews.target, test_size = 0.20, random_state = 42)
一旦我们有了训练和测试数据,下一步是从评论中创建 TF-IDF 向量,如下代码片段所示。我们将主要使用 scikit-learn 库,虽然在分词时我们会使用 NLTK:
# initialize TfidfVectorizer to create the tfIdf representation of the corpus
# the parameters are: min_df -- the percentage of documents that the word has
# to occur in to be considered, the tokenizer to use, and the maximum
# number of words to consider (max_features)
vectorizer = TfidfVectorizer(min_df = .1,
tokenizer = nltk.word_tokenize,
max_features = max_tokens)
# fit and transform the text into tfidf format, using training text
# here is where we build the tfidf representation of the training data
movies_train_tfidf = vectorizer.fit_transform(movies_train)
前面代码中的主要步骤是创建向量化器,然后使用向量化器将电影评论转换为 TF-IDF 格式。这与我们在第七章中遵循的过程相同。生成的 TF-IDF 向量已经在图 7.4中展示,因此我们在此不再重复。
然后,使用 scikit-learn 中的多项式朴素贝叶斯函数将文档分类为正面和负面评论,这是 scikit-learn 的朴素贝叶斯包之一,适用于处理 TF-IDF 向量数据。你可以访问 scikit-learn 的其他朴素贝叶斯包以获取更多信息,网址为scikit-learn.org/stable/modules/naive_bayes.html#naive-bayes
。
现在我们已经得到了 TF-IDF 向量,可以初始化朴素贝叶斯分类器并在训练数据上进行训练,如下所示:
from sklearn.naive_bayes import MultinomialNB
# Initialize the classifier and train it
classifier = MultinomialNB()
classifier.fit(movies_train_tfidf, sentiment_train)
最后,我们可以通过将测试集(movies_test_tfidf
)向量化,并使用从训练数据中创建的分类器预测测试数据的类别,从而计算分类器的准确性,如下代码所示:
# find accuracy based on test set
movies_test_tfidf = vectorizer.fit_transform(movies_test)
# for each document in the test data, use the classifier to predict whether its sentiment is positive or negative
sentiment_pred = classifier.predict(movies_test_tfidf)
sklearn.metrics.accuracy_score(sentiment_test,
sentiment_pred)
0.64
# View the results as a confusion matrix
from sklearn.metrics import confusion_matrix
conf_matrix = confusion_matrix(sentiment_test,
sentiment_pred,normalize=None)
print(conf_matrix)
[[132 58]
[ 86 124]]
从前面的代码中我们可以看到分类器的准确率为0.64
。也就是说,64%的测试数据评论被分配到了正确的类别(正面或负面)。我们还可以通过查看混淆矩阵来获取更多关于分类效果的信息,混淆矩阵显示在代码的最后两行。
通常,混淆矩阵显示的是哪些类别被误分类为哪些其他类别。我们总共有400
个测试项(这占了预留为测试示例的 2,000 条评论的 20%)。在190
条负面评论中,132
条被正确分类为负面,58
条被错误分类为正面。类似地,在210
条正面评论中,124
条被正确分类为正面,但86
条被误分类为负面。这意味着 69%的负面评论被正确分类,59%的正面评论被正确分类。从中我们可以看到,我们的模型在将负面评论正确分类为负面方面表现稍好。造成这种差异的原因尚不清楚。为了更好地理解这个结果,我们可以更仔细地分析被误分类的评论。我们现在不做这件事,但我们将在第十四章中讨论如何更仔细地分析结果。
接下来,我们将考虑一种更现代且通常更准确的分类方法。
使用支持向量机(SVM)进行文档分类
SVM 是一种流行且强大的文本分类工具,广泛应用于意图识别和聊天机器人等领域。与神经网络不同,我们将在下一章讨论神经网络,SVM 的训练过程通常相对较快,并且通常不需要大量数据。这意味着 SVM 适合需要快速部署的应用程序,可能作为开发更大规模应用程序的初步步骤。
SVM 的基本思想是,如果我们将文档表示为n维向量(例如我们在第七章中讨论的 TF-IDF 向量),我们希望能够识别一个超平面,该超平面提供一个边界,将文档分为两个类别,并且边界(或间隔)尽可能大。
这里展示了使用支持向量机(SVM)对电影评论数据进行分类的示例。我们像往常一样,首先导入数据并进行训练/测试集划分:
import numpy as np
from sklearn.datasets import load_files
from sklearn.svm import SVC
from sklearn.pipeline import Pipeline
# the directory root will be wherever the movie review data is located
directory_root = "./lab/movie_reviews/"
movie_reviews = load_files(directory_root,
encoding='utf-8',decode_error="replace")
# count the number of reviews in each category
labels, counts = np.unique(movie_reviews.target,
return_counts=True)
# convert review_data.target_names to np array
labels_str = np.array(movie_reviews.target_names)[labels]
print(dict(zip(labels_str, counts)))
{'neg': 1000, 'pos': 1000}
from sklearn.model_selection import train_test_split
movies_train, movies_test, sentiment_train, sentiment_test
= train_test_split(movie_reviews.data,
movie_reviews.target, test_size = 0.20,
random_state = 42)
现在我们已经设置好了数据集,并生成了训练/测试集划分,接下来我们将在以下代码中创建 TF-IDF 向量并执行 SVC 分类:
# We will work with a TF_IDF representation, as before
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import classification_report, accuracy_score
# Use the Pipeline function to construct a list of two processes
# to run, one after the other -- the vectorizer and the classifier
svc_tfidf = Pipeline([
("tfidf_vectorizer", TfidfVectorizer(
stop_words = "english", max_features=1000)),
("linear svc", SVC(kernel="linear"))
])
model = svc_tfidf
model.fit(movies_train, sentiment_train)
sentiment_pred = model.predict(movies_test)
accuracy_result = accuracy_score( sentiment_test,
sentiment_pred)
print(accuracy_result)
0.8125
# View the results as a confusion matrix
from sklearn.metrics import confusion_matrix
conf_matrix = confusion_matrix(sentiment_test,
sentiment_pred,normalize=None)
print(conf_matrix)
[[153 37]
[ 38 172]]
这里展示的过程与前一节代码片段中的朴素贝叶斯分类示例非常相似。然而,在这种情况下,我们使用的是支持向量机(SVM)而不是朴素贝叶斯进行分类,尽管我们仍然使用 TF-IDF 对数据进行向量化。在前面的代码中,我们可以看到分类的准确率结果是0.82
,明显优于前一节中展示的贝叶斯准确率。
结果的混淆矩阵也更好,因为190
个负面评论中有153
个被正确分类为负面,37
个被错误分类为正面。类似地,210
个正面评论中有172
个被正确分类为正面,而38
个被误分类为负面。这意味着 80%的负面评论被正确分类,81%的正面评论被正确分类。
支持向量机(SVM)最初是为二分类设计的,就像我们刚才看到的电影评论数据一样,其中只有两个类别(在这种情况下是正面和负面)。然而,它们可以通过将问题拆分为多个二分类问题来扩展到多类问题(包括大多数意图识别的情况)。
考虑一个可能出现在通用个人助手应用中的多类问题。假设该应用包含多个意图,例如:
-
查询天气
-
播放音乐
-
阅读最新头条新闻
-
告诉我我喜欢的球队的最新体育比分
-
查找附近提供特定菜系的餐厅
该应用需要将用户的查询分类到这些意图中的一个,以便处理并回答用户的问题。为了使用 SVM 进行分类,有必要将问题重新表述为一组二分类问题。有两种方法可以做到这一点。
一种方法是为每对类别创建多个模型,并拆分数据,以便每个类别与其他所有类别进行比较。在个人助手的例子中,分类需要决定诸如“这个类别是天气还是体育?”和这个类别是天气还是新闻?这样的问法。这就是一对一的方法,你可以看到,如果意图数量较多,这可能会导致非常多的分类。
另一种方法叫做一对多或一对所有。在这里,问题是询问诸如这个类别是“天气”还是其他类别? 这是更常见的方法,我们将在这里展示。
使用 scikit-learn 的多类 SVM 的方法与之前展示的非常相似。不同之处在于,我们导入了OneVsRestClassifier
并使用它来创建分类模型,代码如下所示:
from sklearn.multiclass import OneVsRestClassifier
model = OneVsRestClassifier(SVC())
分类在许多自然语言应用中得到了广泛应用,包括分类文档(如电影评论)和在聊天机器人等应用中分类用户提问的整体目标(或意图)。然而,应用程序通常除了整体分类之外,还需要从话语或文档中获取更精细的信息。这个过程通常被称为插槽填充。我们在第八章中讨论了插槽填充,并展示了如何编写插槽填充规则。在接下来的部分中,我们将展示另一种基于统计技术的插槽填充方法,特别是条件随机场(CRF)。
使用 CRF 进行插槽填充
在第八章中,我们讨论了插槽填充的常见应用,并使用 spaCy 规则引擎为餐厅搜索应用程序找到插槽,如图 8.9所示。这要求编写规则来查找应用程序中每个插槽的填充项。如果潜在的插槽填充项事先已知,这种方法可以很好地工作,但如果事先不知道,它就无法编写规则。例如,使用图 8.9后面的代码中的规则,如果用户请求一种新的菜系,比如泰国菜,这些规则将无法将泰国菜识别为CUISINE
插槽的填充项,也无法将不远的地方识别为LOCATION
插槽的填充项。我们将在本节中讨论的统计方法可以帮助解决这个问题。
使用统计方法时,系统不使用规则,而是通过在训练数据中寻找可以应用于新示例的模式。统计方法依赖于足够的训练数据,以便系统能够学习到准确的模式,但如果有足够的训练数据,统计方法通常会提供比基于规则的方法更强大的 NLP 问题解决方案。
在本节中,我们将介绍一种可以应用于统计插槽填充的常见方法——条件随机场(CRF)。CRF 是一种在寻找文本跨度标签时,考虑文本项上下文的方法。回想一下我们在第八章中讨论的规则,这些规则并没有考虑任何邻近词汇或其他上下文——它们只关注项本身。相比之下,CRF 试图对特定文本段落的标签概率建模,也就是说,给定一个输入 x,它们建模该输入作为示例类别 *y (P(y|x)) *的概率。CRF 利用单词(或标记)序列来估计在该上下文中插槽标签的条件概率。我们在这里不会回顾 CRF 的数学原理,但你可以在网上找到许多详细的数学描述,例如,arxiv.org/abs/1011.4088
。
为了训练一个槽位标记系统,数据必须进行注释,以便系统能够知道它要寻找哪些槽位。NLP 技术文献中至少有四种不同的格式用于表示槽位标记数据的注释,我们将简要介绍这些格式。这些格式既可以用于训练数据,也可以用于表示处理后的 NLP 结果,后者可以进一步用于数据库检索等处理阶段。
表示带有槽位标记的数据
用于槽位填充应用的训练数据可以有多种格式。让我们看看如何用四种不同的格式表示句子show me science fiction films directed by steven spielberg
,这是 MIT 电影查询语料库中的一个查询(groups.csail.mit.edu/sls/downloads/
)。
一种常用的表示法是JavaScript 对象表示法(JSON)格式,如下所示:
{tokens": "show me science fiction films directed by steven spielberg"
"entities": [
{"entity": {
"tokens": "science fiction films",
"name": "GENRE"
}},
{
"entity": {
"tokens": "steven spielberg",
"name": "DIRECTOR"
}}
]
}
在这里,我们看到输入句子以tokens
形式呈现,后面跟着一列槽位(这里称为entities
)。每个实体与一个名称关联,如GENRE
或DIRECTOR
,以及它适用的 tokens。示例显示了两个槽位,GENRE
和DIRECTOR
,它们分别由science fiction films
和steven
spielberg
填充:
第二种格式使用show me <GENRE>science fiction films</GENRE> directed by <``DIRECTOR>steven Spielberg</DIRECTOR>
。
第三种格式称为Beginning Inside Outside(BIO),这是一种文本格式,用于标记每个槽位填充项在句子中的开始、内部和结束部分,如图 9.1所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_09_01.jpg
图 9.1 – “show me science fiction films directed by steven spielberg”的 BIO 标记
在 BIO 格式中,任何不属于槽位的单词标记为O
(即Outside
),槽位的开始部分(science
和steven
标记为B
),而槽位的内部部分标记为I
。
最后,另一种非常简单的表示标记槽位的格式是Markdown,它是一种简化的文本标记方式,便于渲染。我们之前在 Jupyter 笔记本中见过 Markdown,它用来显示注释块。在图 9.2中,我们可以看到餐馆搜索应用的一些训练数据示例,这与我们在第八章中看到的类似(该内容展示在图 8.9中)。槽位值显示在方括号中,槽位名称显示在括号中:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_09_02.jpg
图 9.2 – 用于餐馆搜索应用的 Markdown 标记
四种格式基本上显示了相同的信息,只是在表示方式上略有不同。对于你自己的项目,如果使用的是公共数据集,可能最方便的是使用数据集已经采用的格式。然而,如果你使用的是自己的数据,可以选择任何最合适或最容易使用的格式。其他条件相同的情况下,XML 和 JSON 格式通常比 BIO 或 Markdown 更具灵活性,因为它们可以表示嵌套槽位,即包含额外值作为槽位填充物的槽位。
对于我们的示例,我们将使用位于github.com/talmago/spacy_crfsuite
的 spaCy CRF 套件库,并使用餐厅搜索作为示例应用程序。此数据集采用 Markdown 格式进行了标注。
以下代码通过导入显示和 Markdown 功能来设置应用程序,然后从examples
目录读取 Markdown 文件。读取 Markdown 文件将重现图 9.2中显示的发话列表。请注意,Markdown 文件中的训练数据对于一个实际的应用程序来说不够大,但在这里作为示例是有效的:
from IPython.display import display, Markdown
with open("examples/restaurant_search.md", "r") as f:
display(Markdown(f.read()))
接下来的步骤如下所示,将导入crfsuite
和spacy
,并将 Markdown 格式的训练数据集转换为 CRF 格式。(GitHub 中的代码显示了一些额外的步骤,这里为了简便省略了):
import sklearn_crfsuite
from spacy_crfsuite import read_file
train_data = read_file("examples/restaurant_search.md")
train_data
In [ ]:
import spacy
from spacy_crfsuite.tokenizer import SpacyTokenizer
from spacy_crfsuite.train import gold_example_to_crf_tokens
nlp = spacy.load("en_core_web_sm", disable=["ner"])
tokenizer = SpacyTokenizer(nlp)
train_dataset = [
gold_example_to_crf_tokens(ex, tokenizer=tokenizer)
for ex in train_data
]
train_dataset[0]
在这一点上,我们可以使用CRFExtractor
对象进行实际的 CRF 训练,如下所示:
from spacy_crfsuite import CRFExtractor
crf_extractor = CRFExtractor(
component_config=component_config)
crf_extractor
rs = crf_extractor.fine_tune(train_dataset, cv=5,
n_iter=50, random_state=42)
print("best_params:", rs.best_params_, ", score:",
rs.best_score_)
crf_extractor.train(train_dataset)
classification_report = crf_extractor.eval(train_dataset)
print(classification_report[1])
分类报告(在倒数第二步中生成,并在下方展示)基于训练数据集(train_dataset
)。由于 CRF 是在此数据集上训练的,因此分类报告会显示每个槽位的完美表现。显然,这不太现实,但这里展示是为了说明分类报告。记住,我们将在第十三章中回到精确度、召回率和 F1 分数的主题。
precision recall f1-score support
U-atmosphere 1.000 1.000 1.000 1
U-cuisine 1.000 1.000 1.000 9
U-location 1.000 1.000 1.000 6
U-meal 1.000 1.000 1.000 2
B-price 1.000 1.000 1.000 1
I-price 1.000 1.000 1.000 1
L-price 1.000 1.000 1.000 1
U-quality 1.000 1.000 1.000 1
micro avg 1.000 1.000 1.000 22
macro avg 1.000 1.000 1.000 22
weighted avg 1.000 1.000 1.000 22
此时,CRF 模型已经训练完成,准备使用新数据进行测试。如果我们用句子show me some good chinese restaurants near me来测试这个模型,我们可以在以下代码中看到 JSON 格式的结果。CRF 模型找到了两个槽位,CUISINE
和QUALITY
,但漏掉了LOCATION
槽位,而near me
应该填充这个槽位。结果还显示了模型在槽位上的置信度,置信度相当高,超过了0.9
。结果还包括输入中字符的零基位置,这些位置标识了槽位值的开始和结束位置(good
的起始位置是10
,结束位置是14
):
example = {"text": "show some good chinese restaurants near me"}
tokenizer.tokenize(example, attribute="text")
crf_extractor.process(example)
[{'start': 10,
'end': 14,
'value': 'good',
'entity': 'quality',
'confidence': 0.9468721304898786},
{'start': 15,
'end': 22,
'value': 'chinese',
'entity': 'cuisine',
'confidence': 0.9591743424660175}]
最后,我们可以通过测试应用程序是否能处理在训练数据中未出现过的菜系来说明这种方法的鲁棒性,Japanese
。让我们看看系统是否能在新的语句中标注Japanese
为菜系。我们可以尝试类似show some good Japanese restaurants near here
的语句,并查看以下 JSON 中的结果:
[{'start': 10,
'end': 14,
'value': 'good',
'entity': 'quality',
'confidence': 0.6853277275481114},
{'start': 15,
'end': 23,
'value': 'japanese',
'entity': 'cuisine',
'confidence': 0.537198793062902}]
系统确实在这个例子中识别出了Japanese
作为菜系,但其置信度比我们在之前的例子中看到的要低得多,这次仅为0.537
,而相同句子中若是已知菜系的置信度则为0.96
。这种相对较低的置信度是训练数据中未出现的槽填充项的典型表现。即使是QUALITY
槽(该槽在训练数据中出现过)的置信度也较低,可能是因为它受到了未知CUISINE
槽填充项低概率的影响。
值得指出的最后一个观察是,虽然我们本可以为此任务开发一个基于规则的槽标签器,正如我们在第八章中看到的那样,但最终的系统甚至无法勉强地将Japanese
识别为槽填充项,除非Japanese
被包含在某条规则中。这是统计方法相较于基于规则的方法的一般性例证,展示了统计方法可以提供一些“非全有或全无”的结果。
摘要
本章探讨了一些基本的、最有用的经典统计技术在自然语言处理(NLP)中的应用。它们尤其对那些起步时没有大量训练数据的小项目和常常先于大规模项目进行的探索性工作非常有价值。
我们从学习一些基本的评估概念开始,特别学习了准确率,同时我们也看了一些混淆矩阵。我们还学习了如何将朴素贝叶斯分类应用于 TF-IDF 格式表示的文本,然后使用更现代的技术——支持向量机(SVMs)来解决相同的分类任务。在比较朴素贝叶斯和 SVMs 的结果后,我们发现 SVMs 的表现更好。接着,我们将注意力转向了与之相关的 NLP 任务——槽填充。我们学习了不同的槽标签数据表示方式,最终通过一个餐厅推荐任务来展示了条件随机场(CRFs)。这些都是标准的方法,尤其适合用在数据有限的应用程序初步探索中,是 NLP 工具箱中的有用工具。
在第十章中,我们将继续讨论机器学习的相关话题,但我们将转向一种非常不同的机器学习类型——神经网络。神经网络有许多种类,但总的来说,神经网络及其变体已成为过去十多年里 NLP 的标准技术。下一章将介绍这一重要话题。
第十章:机器学习第二部分 – 神经网络与深度学习技术
神经网络(NN)直到 2010 年左右才在自然语言理解(NLU)领域变得流行,但此后广泛应用于许多问题。此外,神经网络还被广泛应用于非自然语言处理(NLP)问题,如图像分类。神经网络作为一种可以跨领域应用的通用方法,已经在这些领域之间产生了一些有趣的协同效应。
在本章中,我们将涵盖基于神经网络(NN)的机器学习(ML)技术应用,解决诸如自然语言处理(NLP)分类等问题。我们还将介绍几种常用的神经网络——特别是全连接多层感知器(MLP)、卷积神经网络(CNN)和循环神经网络(RNN)——并展示它们如何应用于分类和信息提取等问题。我们还将讨论一些基本的神经网络概念,如超参数、学习率、激活函数和训练轮数(epochs)。我们将通过使用 TensorFlow/Keras 库的分类示例来说明神经网络的概念。
本章将涵盖以下主题:
-
神经网络基础
-
示例——用于分类的 MLP
-
超参数与调优
-
超越 MLP——循环神经网络(RNN)
-
看另一种方法——卷积神经网络(CNN)
神经网络基础
神经网络的基本概念已经研究了许多年,但直到最近才在大规模自然语言处理(NLP)问题中得到应用。目前,神经网络是解决 NLP 任务的最流行工具之一。神经网络是一个庞大的领域,且研究非常活跃,因此我们无法为你提供关于 NLP 神经网络的全面理解。然而,我们将尽力为你提供一些基本知识,帮助你将神经网络应用到自己的问题中。
神经网络的灵感来自于动物神经系统的某些特性。具体而言,动物神经系统由一系列互联的细胞组成,这些细胞被称为神经元,它们通过网络传递信息,从而在给定输入时,网络产生一个输出,代表了对该输入的决策。
人工神经网络(ANN)旨在从某些方面模拟这一过程。如何反应输入的决定由一系列处理步骤决定,这些步骤从接收输入并在满足正确条件时产生输出(或激活)的单元(神经元)开始。当神经元激活时,它将其输出发送给其他神经元。接下来的神经元从多个其他神经元接收输入,并且当它们收到正确的输入时,它们也会激活。决定是否激活的部分过程涉及神经元的权重。神经网络学习完成任务的方式——也就是训练过程——是调整权重以在训练数据上产生最佳结果的过程。
训练过程由一组周期组成,或者说是训练数据的遍历,每次遍历都会调整权重,试图减少神经网络产生的结果与正确结果之间的差异。
神经网络中的神经元按层排列,最终层——输出层——产生决策。将这些概念应用于自然语言处理(NLP),我们从输入文本开始,该文本被传递到输入层,表示正在处理的输入。处理通过所有层进行,直到到达输出层,输出决策——例如,这篇电影评论是正面还是负面?
图 10.1表示一个包含输入层、两个隐藏层和输出层的神经网络示意图。图 10.1中的神经网络是全连接神经网络(FCNN),因为每个神经元都接收来自前一层的每个神经元的输入,并将输出传递给后一层的每个神经元:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_10_01.jpg
图 10.1 – 一个包含两个隐藏层的全连接神经网络
神经网络领域使用了大量的专业词汇,有时会使得阅读相关文档变得困难。在下面的列表中,我们将简要介绍一些最重要的概念,并根据需要参考图 10.1:
-
激活函数:激活函数是确定神经元何时拥有足够输入以触发并将输出传递给下一层神经元的函数。一些常见的激活函数有 sigmoid 和修正线性单元(ReLU)。
-
反向传播:训练神经网络的过程,其中损失通过网络反馈,训练权重。
-
批次:批次是一组将一起训练的样本。
-
连接:神经元之间的链接,关联着一个权重,代表连接的强度。在图 10.1中的神经元之间的线是连接。
-
收敛:当额外的训练周期不再减少损失或提高准确度时,网络已收敛。
-
Dropout:通过随机删除神经元来防止过拟合的技术。
-
提前停止:在计划的训练周期数之前结束训练,因为训练似乎已经收敛。
-
周期:一次训练数据的遍历,调整权重以最小化损失。
-
误差:神经网络产生的预测结果与参考标签之间的差异。衡量网络对数据分类的预测效果。
-
梯度爆炸:梯度爆炸发生在训练过程中,当梯度变得极大,无法控制时。
-
前向传播:输入通过神经网络的传播过程,从输入层经过隐藏层到输出层。
-
完全连接:FCNN 是一种神经网络,“每一层的每个神经元都与下一层的每个神经元相连接” (
en.wikipedia.org/wiki/Artificial_neural_network
),如图 10.1所示。 -
梯度下降:通过调整权重的方向来优化,以最小化损失。
-
隐藏层:不是输入层或输出层的神经元层。
-
超参数:在训练过程中未学习的参数,通常需要通过手动调整来优化,以使网络产生最佳结果。
-
输入层:神经网络中的层,接收初始数据。这是图 10.1中左侧的层。
-
层:神经网络中的一组神经元,接收来自前一层的信息并将其传递到下一层。图 10.1包括了四个层。
-
学习:在训练过程中为连接分配权重,以最小化损失。
-
学习率/自适应学习率:每个训练周期后对权重的调整量。在某些方法中,学习率可以随着训练的进行而自适应调整;例如,如果学习过程开始变慢,降低学习率可能会有所帮助。
-
损失:提供度量,用于量化当前模型预测与目标值之间的距离的函数。训练过程的目标是最小化损失。
-
MLP:如维基百科所述,“一种完全连接的前馈人工神经网络(ANN)类别。MLP 至少包含三层节点:输入层、隐藏层和输出层” (
en.wikipedia.org/wiki/Multilayer_perceptron
)。图 10.1显示了一个 MLP 的示例。 -
神经元(单元):神经网络中的单元,接收输入并通过应用激活函数计算输出。
-
优化:在训练过程中调整学习率。
-
输出层:神经网络中的最终层,基于输入做出决策。这是图 10.1中右侧的层。
-
过拟合:将网络调得过于紧密地适应训练数据,从而导致它不能很好地泛化到之前未见过的测试或验证数据。
-
欠拟合:欠拟合发生在神经网络无法为训练数据获得良好准确度时。可以通过增加训练周期或增加层数来解决。
-
梯度消失:梯度变得非常小,导致网络无法进行有效的进展。
-
权重:神经元之间连接的特性,表示连接的强度。权重是在训练过程中学习得到的。
在下一节中,我们将通过一个基本的 MLP 文本分类示例,来具体化这些概念。
示例 – 用于分类的 MLP
我们将通过查看多层感知器(MLP)来回顾基本的神经网络概念,它是概念上最直观的神经网络类型之一。我们将使用的例子是电影评论的分类,将评论分为正面和负面情感。由于只有两个可能的类别,这是一个 二分类 问题。我们将使用 情感标注句子数据集(从群体到个体标签的深度特征,Kotzias 等人,KDD 2015 archive.ics.uci.edu/ml/datasets/Sentiment+Labelled+Sentences
),该数据集来自加利福尼亚大学欧文分校。首先下载数据并将其解压到与 Python 脚本相同的目录中。你会看到一个名为 sentiment labeled sentences
的目录,其中包含实际数据文件 imdb_labeled.txt
。你也可以将数据安装到你选择的其他目录,但如果这样做,记得相应修改 filepath_dict
变量。
你可以使用以下 Python 代码查看数据:
import pandas as pd
import os
filepath_dict = {'imdb': 'sentiment labelled sentences/imdb_labelled.txt'}
document_list = []
for source, filepath in filepath_dict.items():
document = pd.read_csv(filepath, names=['sentence', 'label'], sep='\t')
document['source'] = source
document_list.append(document)
document = pd.concat(document_list)
print(document.iloc[0])
最后一个 print
语句的输出将包含语料库中的第一句,其标签(1
或 0
——即正面或负面),以及其来源(互联网电影数据库 IMDB
)。
在这个例子中,我们将使用 scikit-learn 的 CountVectorizer
来对语料库进行向量化,这个方法我们在 第七章 中已经提到过。
以下代码片段展示了向量化过程的开始,我们为向量化器设置了一些参数:
from sklearn.feature_extraction.text import CountVectorizer
# min_df is the minimum proportion of documents that contain the word (excludes words that
# are rarer than this proportion)
# max_df is the maximum proportion of documents that contain the word (excludes words that
# are rarer than this proportion
# max_features is the maximum number of words that will be considered
# the documents will be lowercased
vectorizer = CountVectorizer(min_df = 0, max_df = 1.0, max_features = 1000, lowercase = True)
CountVectorizer
函数有一些有用的参数,能够控制用于构建模型的最大单词数,还可以排除那些被认为过于频繁或过于稀有、不太有用的词语。
下一步是进行训练集和测试集的拆分,如下代码块所示:
# split the data into training and test
from sklearn.model_selection import train_test_split
document_imdb = document[document['source'] == 'imdb']
reviews = document_imdb['sentence'].values
y = document_imdb['label'].values
# since this is just an example, we will omit the dev test set
# 'reviews.data' is the movie reviews
# 'y_train' is the categories assigned to each review in the training data
# 'test_size = .20' is the proportion of the data that should be reserved for testing
# 'random_state = 42' is an integer that controls the randomization of the data so that the results are reproducible
reviews_train, reviews_test, y_train, y_test = train_test_split(
reviews, y, test_size = 0.20, random_state = 42)
上述代码展示了训练数据和测试数据的拆分,保留了总数据的 20% 用于测试。
reviews
变量保存实际文档,而 y
变量保存它们的标签。请注意,X
和 y
在文献中常常用来分别表示机器学习问题中的数据和类别,尽管我们在这里使用 reviews
作为 X
数据:
vectorizer.fit(reviews_train)
vectorizer.fit(reviews_test)
X_train = vectorizer.transform(reviews_train)
X_test = vectorizer.transform(reviews_test)
上述代码展示了数据的向量化过程,即使用先前定义的向量化器将每个文档转换为数值表示。你可以通过回顾 第七章 来复习向量化。
结果是 X_train
,即数据集的词频(BoW)。你可以回忆起在 第七章 中提到的词频模型(BoW)。
下一步是建立神经网络(NN)。我们将使用 Keras 包,它建立在 Google 的 TensorFlow 机器学习包之上。以下是我们需要执行的代码:
from keras.models import Sequential
from keras import layers
from keras import models
# Number of features (words)
# This is based on the data and the parameters that were provided to the vectorizer
# min_df, max_df and max_features
input_dimension = X_train.shape[1]
print(input_dimension)
代码首先打印输入维度,在本例中是每个文档向量中的单词数。了解输入维度非常重要,因为它是从语料库中计算出来的,并且与我们在 CountVectorizer
函数中设置的参数相关。如果输入维度异常地大或小,我们可能需要调整参数,以便使词汇表变大或变小。
以下代码定义了模型:
# a Sequential model is a stack of layers where each layer has one input and one output tensor
# Since this is a binary classification problem, there will be one output (0 or 1)
# depending on whether the review is positive or negative
# so the Sequential model is appropriate
model = Sequential()
model.add(layers.Dense(16, input_dim = input_dimension, activation = 'relu'))
model.add(layers.Dense(16, activation = 'relu'))
model.add(layers.Dense(16, activation = 'relu'))
# output layer
model.add(layers.Dense(1, activation = 'sigmoid'))
在前面的代码中构建的模型包括输入层、两个隐藏层和一个输出层。每次调用 model.add()
方法都会向模型中添加一个新层。所有层都是全连接的,因为在这个全连接的网络中,每个神经元都会接收来自前一层每个神经元的输入,如 图 10.1 所示。两个隐藏层各包含 16 个神经元。为什么指定 16 个神经元?隐藏层神经元的数量没有硬性规定,但一般的方法是从较小的数字开始,因为随着神经元数量的增加,训练时间也会增加。最终的输出层只包含一个神经元,因为我们只需要一个输出,判断评论是正面还是负面。
另一个非常重要的参数是 激活函数。激活函数决定了神经元如何响应其输入。在我们的示例中,除了输出层外,所有层的激活函数都是 ReLU 函数。ReLU 函数可以在 图 10.2 中看到。ReLU 是一种非常常用的激活函数:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_10_02.jpg
图 10.2 – ReLU 函数在输入范围从 -15 到 15 之间的值
ReLU 函数最重要的优点之一是它非常高效。它也证明在实践中通常能提供良好的结果,并且通常是作为激活函数的合理选择。
此神经网络中使用的另一个激活函数是 sigmoid 函数,它用于输出层。我们在这里使用 sigmoid 函数,因为在这个问题中,我们需要预测正面或负面情感的概率,而 sigmoid 函数的值始终介于 0
和 1
之间。sigmoid 函数的公式如下所示:
S(x) = 1 / (1 + e^−x)
Sigmoid 函数的图像显示在 图 10.3 中,很容易看出,无论输入值如何,其输出值始终介于 0
和 1
之间:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_10_03.jpg
图 10.3 – Sigmoid 函数在输入范围从 -10 到 10 之间的值
Sigmoid 和 ReLU 激活函数是常见且实用的激活函数,但它们只是众多可能的神经网络激活函数中的两个示例。如果你希望进一步研究这个话题,以下的 维基百科 文章是一个不错的起点:en.wikipedia.org/wiki/Activation_function
。
一旦模型定义完成,我们就可以编译它,如下所示的代码片段:
model.compile(loss = 'binary_crossentropy',
optimizer = 'adam',
metrics = ['accuracy'])
model.compile()
方法需要 loss
、optimizer
和 metrics
参数,这些参数提供以下信息:
-
loss
参数在这里告诉编译器使用binary_crossentropy
来计算损失。categorical_crossentropy
用于输出中有两个或更多标签类别的问题。例如,如果任务是给评论分配星级评分,我们可能会有五个输出类别,分别对应五个可能的星级评分,在这种情况下,我们会使用类别交叉熵。 -
optimizer
参数在训练过程中调整学习率。我们在这里不讨论adam
的数学细节,但一般来说,我们使用的优化器adam
通常是一个不错的选择。 -
最后,
metrics
参数告诉编译器我们将如何评估模型的质量。我们可以在这个列表中包含多个指标,但现在我们仅包括accuracy
。在实际应用中,你使用的指标将取决于你的问题和数据集,但对于我们这个示例来说,accuracy
是一个不错的选择。在第十三章中,我们将探讨其他指标,以及在特定情况下你可能希望选择它们的原因。
显示模型的摘要也很有帮助,以确保模型按预期的方式构建。model.summary()
方法将生成模型的摘要,如下所示的代码片段:
model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type) Output Shape Param #
=================================================================
dense (Dense) (None, 16) 13952
dense_1 (Dense) (None, 16) 272
dense_2 (Dense) (None, 16) 272
dense_3 (Dense) (None, 1) 17
=================================================================
Total params: 14,513
Trainable params: 14,513
Non-trainable params: 0
在这个输出中,我们可以看到网络由四个全连接层(包括输入层、两个隐藏层和输出层)组成,且结构符合预期。
最后一步是使用以下代码来拟合或训练网络:
history = model.fit(X_train, y_train,
epochs=20,
verbose=True,
validation_data=(X_test, y_test),
batch_size=10)
训练是一个迭代过程,将训练数据通过网络,测量损失,调整权重以减少损失,然后再次将训练数据通过网络。这个步骤可能非常耗时,具体取决于数据集的大小和模型的大小。
每次遍历训练数据的过程叫做一个周期。训练过程中的周期数是一个超参数,意味着它是由开发者根据训练结果调整的。例如,如果网络的表现似乎在经过一定数量的周期后没有改善,则可以减少周期数,因为额外的周期不会改善结果。不幸的是,并没有一个固定的周期数可以决定我们何时停止训练。我们必须观察准确率和损失随周期的变化,来决定系统是否已经充分训练。
设置verbose = True
参数是可选的,但非常有用,因为它会在每个周期后生成结果追踪。如果训练过程很长,追踪信息可以帮助你验证训练是否在进展。批量大小是另一个超参数,它定义了在更新模型之前要处理多少数据样本。当执行以下 Python 代码时,设置verbose
为True
,每个周期结束时,损失、准确率、验证损失和验证准确率都会被计算出来。训练完成后,history
变量将包含训练过程的进展信息,我们可以看到训练进展的图表。
显示准确率和损失在每个周期的变化非常重要,因为这可以帮助我们了解训练收敛需要多少个周期,并且能清楚地看到数据是否过拟合。以下代码展示了如何绘制准确率和损失随周期变化的图表:
import matplotlib.pyplot as plt
plt.style.use('ggplot')
def plot_history(history):
acc = history.history['accuracy']
val_acc = history.history['val_accuracy']
loss = history.history['loss']
val_loss = history.history['val_loss']
x = range(1, len(acc) + 1)
plt.figure(figsize=(12, 5))
plt.subplot(1, 2, 1)
plt.plot(x, acc, 'b', label='Training accuracy')
plt.plot(x, val_acc, 'r', label = 'Validation accuracy')
plt.title('Training and validation accuracy')
plt.legend()
plt.subplot(1, 2, 2)
plt.plot(x, loss, 'b', label='Training loss')
plt.plot(x, val_loss, 'r', label='Validation loss')
plt.title('Training and validation loss')
plt.legend()
plt.show()
plot_history(history)
我们可以通过训练 20 个周期(图 10.4)来看到我们示例的进展结果:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_10_04.jpg
图 10.4 – 训练 20 个周期的准确率和损失
在训练的 20 个周期中,我们可以看到训练准确率接近1.0,训练损失接近0。然而,这个看似好的结果是具有误导性的,因为真正重要的结果是基于验证数据的。由于验证数据没有用来训练网络,验证数据上的表现实际上预测了网络在实际使用中的表现。我们从验证准确率和损失变化的图表中可以看到,在大约第 10 个周期后,继续训练并没有改善模型在验证数据上的表现。事实上,这反而增加了损失,使得模型变得更差。从图 10.4右侧的图表中验证损失的增加可以看出这一点。
提高任务性能将涉及修改其他因素,如超参数和其他调优过程,我们将在下一节中讨论这些内容。
超参数和调优
图 10.4清楚地显示,增加训练周期数并不会提升任务的性能。在 10 个周期后,最佳的验证准确率似乎是大约 80%。然而,80%的准确率并不算很好。我们该如何改进呢?以下是一些想法。虽然没有任何一个方法能保证有效,但值得尝试:
-
如果有更多的训练数据可用,可以增加训练数据的数量。
-
可以研究一些预处理技术,以去除训练数据中的噪音——例如,去除停用词、移除数字和 HTML 标签等非词项、词干提取、词形还原以及小写化等。这些技术的细节在第五章中有介绍。
-
学习率的变化——例如,降低学习率可能改善网络避免局部最小值的能力。
-
减少批量大小。
-
可以尝试改变层数和每层神经元的数量,但层数过多可能会导致过拟合。
-
通过指定一个超参数来添加 dropout,这个超参数定义了层输出被忽略的概率。这有助于提高网络对过拟合的鲁棒性。
-
向量化的改进——例如,使用词频-逆文档频率(TF-IDF)而不是计数型的 BoW。
提高性能的最终策略是尝试一些新型的神经网络方法——特别是 RNN、CNN 和 transformer。
我们将在本章的最后简要回顾 RNN 和 CNN。我们将在第十一章讨论 transformer。
超越多层感知机(MLP)——递归神经网络(RNN)
RNN 是一种能够考虑输入中项次序的神经网络。在之前讨论的 MLP 示例中,表示整个输入(即完整文档)的向量一次性输入神经网络,因此网络无法考虑文档中单词的顺序。然而,在文本数据中,这显然是过于简化的,因为单词的顺序可能对含义非常重要。RNN 通过将早期的输出作为后续层的输入,能够考虑单词的顺序。在某些自然语言处理(NLP)问题中,单词顺序非常重要,例如命名实体识别(NER)、词性标注(POS)或槽标签(slot labeling),RNN 尤为有用。
RNN 单元的示意图如图 10.5所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_10_05.jpg
图 10.5 – 一个 RNN 单元
单元显示在时间 t。时间 t 的输入,x(t),与 MLP 中的情况一样传递给激活函数,但激活函数还会接收到来自时间 t-1 的输出——即 x(t-1)。对于 NLP 来说,早期的输入很可能是前一个词。因此,在这种情况下,输入是当前词和一个前一个词。使用 Keras 中的 RNN 与我们之前看到的 MLP 示例非常相似,只是在层堆栈中添加了一个新的 RNN 层。
然而,随着输入长度的增加,网络往往会忘记早期输入的信息,因为较早的信息对当前状态的影响会越来越小。为克服这一限制,已经设计了各种策略,如门控循环单元(GRU)和长短期记忆(LSTM)。如果输入是完整的文本文档(而非语音),我们不仅可以访问到之前的输入,还能访问未来的输入,这时可以使用双向 RNN。
我们不会在这里详细讲解这些 RNN 的额外变种,但它们确实在某些任务中提高了性能,值得进行研究。尽管关于这个热门话题有大量的资源,以下的Wikipedia文章是一个很好的起点:en.wikipedia.org/wiki/Recurrent_neural_network
。
看看另一种方法——CNN
CNN 在图像识别任务中非常流行,但在 NLP 任务中使用的频率低于 RNN,因为它们没有考虑输入项的时间顺序。然而,它们在文档分类任务中可以很有用。如你从前面的章节中回忆到的,分类中常用的表示方法仅依赖于文档中出现的词汇——例如 BoW 和 TF-IDF——因此,通常可以在不考虑词序的情况下完成有效的分类。
要用 CNN 对文档进行分类,我们可以将文本表示为一个向量数组,其中每个词都映射到由完整词汇表构成的空间中的一个向量。我们可以使用我们在第七章中讨论过的 word2vec 来表示词向量。使用 Keras 训练 CNN 进行文本分类与我们在 MLP 分类中使用的训练过程非常相似。我们像之前一样创建一个顺序模型,但我们添加了新的卷积层和池化层。
我们不会在这里详细讲解 CNN 用于分类的细节,但它们是 NLP 分类的另一种选择。和 RNN 一样,关于这个话题有许多可用的资源,一个很好的起点是Wikipedia(en.wikipedia.org/wiki/Convolutional_neural_network
)。
概述
在本章中,我们探讨了神经网络(NNs)在 NLP 中文档分类中的应用。我们介绍了神经网络的基本概念,回顾了一个简单的多层感知机(MLP),并将其应用于二分类问题。我们还提供了一些通过修改超参数和调整来提高性能的建议。最后,我们讨论了更高级的神经网络类型——循环神经网络(RNNs)和卷积神经网络(CNNs)。
在第十一章中,我们将介绍目前在自然语言处理(NLP)中表现最好的技术——变压器(transformers)和预训练模型(pretrained models)。
第十一章:机器学习第三部分——变换器与大语言模型
在本章中,我们将介绍当前表现最好的技术——自然语言处理(NLP)——变换器和预训练模型。我们将讨论变换器的概念,并提供使用变换器和大语言模型(LLMs)进行文本分类的示例。本章的代码将基于 TensorFlow/Keras Python 库以及 OpenAI 提供的云服务。
本章所讨论的主题非常重要,因为尽管变换器和大语言模型(LLMs)只有几年历史,但它们已经成为许多不同类型 NLP 应用的最前沿技术。事实上,像 ChatGPT 这样的 LLM 系统已被广泛报道,您无疑已经看到过它们的相关信息。您甚至可能已经使用过它们的在线接口。在本章中,您将学习如何使用这些系统背后的技术,这应该是每个 NLP 开发者工具箱的一部分。
在本章中,我们将涵盖以下主题:
-
变换器和大语言模型概述
-
双向编码器表示从变换器(BERT)及其变体
-
使用 BERT——一个分类示例
-
基于云的大语言模型(LLMs)
我们将首先列出本章示例运行所需的技术资源。
技术要求
本章将介绍的代码使用了多个开源软件库和资源。我们在前几章中已经使用了其中的许多,但为了方便起见,我们在这里列出它们:
-
TensorFlow 机器学习库:
hub
、text
和tf-models
-
Python 数值计算包,NumPy
-
Matplotlib 绘图和图形包
-
IMDb 电影评论数据集
-
scikit-learn 的
sklearn.model_selection
用于进行训练、验证和测试数据集划分 -
来自 TensorFlow Hub 的 BERT 模型:我们使用的是这个——
'small_bert/bert_en_uncased_L-4_H-512_A-8'
——但您可以使用任何其他 BERT 模型,只需注意较大的模型可能需要更长的训练时间
请注意,我们在这里使用的模型相对较小,因此不需要特别强大的计算机。 本章中的示例是在一台配备 Intel 3.4 GHz CPU 和 16 GB 内存、没有独立 GPU 的 Windows 10 机器上测试的。当然,更多的计算资源将加速您的训练过程,并使您能够使用更大的模型。
下一节简要介绍了我们将使用的变换器和大语言模型(LLM)技术。
变换器和大语言模型概述
目前,变换器和大型语言模型(LLM)是**自然语言理解(NLU)**领域表现最好的技术。这并不意味着早期章节中介绍的方法已经过时。根据特定 NLP 项目的需求,某些简单的方法可能更实用或更具成本效益。在本章中,你将获得有关这些新方法的信息,帮助你做出决策。
关于这些技术的理论方面的信息在互联网上有大量的资料,但在这里我们将专注于应用,探讨这些技术如何应用于解决实际的自然语言理解(NLU)问题。
正如我们在第十章中看到的,**递归神经网络(RNN)**在自然语言处理(NLP)中是一种非常有效的方法,因为它们不假设输入元素,特别是词语,是独立的,因此能够考虑输入元素的顺序,例如句子中词语的顺序。正如我们所看到的,RNN 通过使用前面的输出作为后续层的输入,保持早期输入的记忆。然而,对于 RNN,随着处理通过序列进行,早期输入对当前输入的影响迅速减弱。
在处理较长文档时,由于自然语言的上下文依赖特性,即使是文本中很远的部分,也可能对当前输入产生强烈的影响。实际上,在某些情况下,远距离的输入可能比较近期的输入更为重要。但是,当数据是一个长序列时,使用递归神经网络(RNN)进行处理意味着较早的信息在处理过程中对后续处理的影响会迅速减弱。为了解决这个问题,最初的一些尝试包括长短期记忆(LSTM),它允许处理器保持状态并包括遗忘门,以及门控循环单元(GRU),这是一种新的且相对较快的 LSTM 类型,但我们在本书中不会讨论它们。相反,我们将重点讨论更近期的方法,如注意力机制和变换器。
介绍注意力机制
注意力机制是一种技术,它使得网络可以学习在哪里关注输入。
最初,注意力机制主要应用于机器翻译。该处理基于编码器-解码器架构,首先将句子编码为向量,然后解码为翻译。在原始的编码器-解码器模型中,每个输入句子被编码为固定长度的向量。结果发现,将句子中的所有信息编码成固定长度的向量是困难的,尤其是长句子。这是因为,固定长度向量无法影响远距离的词语,这些词语超出了固定长度向量的范围。
将句子编码为一组向量,每个词一个,消除了这一限制。
正如早期关于注意力的论文所述,“这种方法与基本的编码器-解码器模型的最重要区别在于,它不试图将整个输入句子编码成一个固定长度的向量。相反,它将输入句子编码成一系列向量,并在解码翻译时自适应地选择这些向量的一个子集。这使得神经翻译模型不必将源句子的所有信息,无论其长度如何,都压缩成一个固定长度的向量。”(Bahdanau, D., Cho, K., & Bengio, Y. (2014). 神经机器翻译通过联合学习对齐和翻译。arXiv 预印本 arXiv:1409.0473.)
对于机器翻译应用,既需要对输入文本进行编码,也需要将结果解码为新语言,以生成翻译文本。在本章中,我们将通过使用一个仅使用注意力架构中的编码部分的分类示例来简化此任务。
最近的一个技术发展是证明了注意力架构的一个组成部分,即 RNNs,并不是获得良好结果的必要条件。这个新的发展被称为变压器,我们将在下一节简要提到,并通过深入的示例来说明。
在变压器中应用注意力
变压器是注意力方法的一个发展,它摒弃了原始注意力系统中的 RNN 部分。变压器在 2017 年的论文《Attention is all you need》(Ashish Vaswani 等,2017 年)中被提出。(Attention is all you need。第 31 届国际神经信息处理系统会议(NIPS’17)论文集,Curran Associates Inc.,纽约红钩,美国,6000-6010)。该论文展示了仅使用注意力就能获得良好的结果。现在几乎所有关于 NLP 学习模型的研究都基于变压器。
最近在自然语言处理(NLP)性能急剧提升的第二个重要技术组成部分是基于大量现有数据进行预训练模型,并将其提供给 NLP 开发者的思想。下一节将讨论这种方法的优势。
利用现有数据——大型语言模型(LLMs)或预训练模型
到目前为止,在本书中,我们已经从训练数据中创建了我们自己的文本表示(向量)。在我们到目前为止的示例中,模型所拥有的关于语言的所有信息都包含在训练数据中,而这些训练数据只是完整语言的一个非常小的样本。但如果模型一开始就具备了语言的通用知识,它们就可以利用大量的训练数据,这些数据对单个项目来说是不可行的。这被称为模型预训练。这些预训练模型可以被多个项目重用,因为它们捕获了关于语言的通用信息。一旦预训练模型可用,就可以通过提供额外的数据对其进行微调,以适应特定的应用。
下一节将介绍一个最著名且最重要的预训练变换器模型——BERT。
BERT 及其变体
作为基于变换器(transformers)的 LLM 技术示例,我们将演示广泛使用的最先进系统 BERT 的使用。BERT 是由谷歌开发的一个开源自然语言处理(NLP)方法,是当今最先进 NLP 系统的基础。BERT 的源代码可以在github.com/google-research/bert
找到。
BERT 的关键技术创新是其训练是双向的,即考虑输入中的前后词语。第二个创新是 BERT 的预训练使用了掩蔽语言模型,系统会在训练数据中掩盖一个词并尝试预测它。
BERT 仅使用编码器-解码器架构中的编码器部分,因为与机器翻译系统不同,它只关注理解,而不生成语言。
BERT 的另一个优势是,与本书中之前讨论的系统不同,它的训练过程是无监督的。也就是说,它训练所用的文本不需要人工标注或赋予任何意义。由于是无监督的,训练过程可以利用网络上大量的文本数据,而无需经过人工审核和判断其含义的昂贵过程。
初始的 BERT 系统于 2018 年发布。从那时起,BERT 背后的理念被探索并扩展为许多不同的变体。这些不同的变体有各种特性,使它们适用于解决不同的需求。这些特性包括更快的训练时间、更小的模型或更高的准确性。表 11.1展示了几个常见 BERT 变体及其特定特性。我们的示例将使用原始 BERT 系统,因为它是所有其他 BERT 版本的基础:
缩写 | 名称 | 日期 | 特性 |
---|---|---|---|
BERT | 基于变换器的双向编码表示 | 2018 | 原始的 BERT 系统。 |
BERT-Base | 原始 BERT 作者发布的多个模型。 | ||
RoBERTa | 强化优化的 BERT 预训练方法 | 2019 | 在该方法中,句子的不同部分在不同的 epoch 中被掩码,这使得它对训练数据中的变化更具鲁棒性。 |
ALBERT | 轻量化 BERT | 2019 | 一种 BERT 版本,通过在层间共享参数来减少模型的大小。 |
DistilBERT | 2020 | 比 BERT 更小更快,且性能良好 | |
TinyBERT | 2019 | 比 BERT-Base 更小更快,且性能良好;适用于资源受限的设备。 |
表 11.1 – BERT 变体
下一节将通过一个 BERT 应用的动手示例进行讲解。
使用 BERT – 一个分类示例
在这个例子中,我们将使用 BERT 进行分类,使用我们在前几章中看到的电影评论数据集。我们将从一个预训练的 BERT 模型开始,并对其进行微调以分类电影评论。如果你想将 BERT 应用于自己的数据,可以按照这个过程进行。
使用 BERT 进行特定应用从 TensorFlow Hub 上提供的预训练模型之一开始(tfhub.dev/tensorflow
),然后通过特定应用的训练数据进行微调。建议从小型 BERT 模型开始,这些模型与 BERT 具有相同的架构,但训练速度更快。通常,小型模型的准确性较低,但如果它们的准确性足以满足应用需求,就不必花费额外的时间和计算资源去使用更大的模型。TensorFlow Hub 上有许多不同大小的模型可以下载。
BERT 模型可以是有大小写处理的(cased)或无大小写处理的(uncased),具体取决于它是否考虑文本的大小写。无大小写处理的模型通常会提供更好的结果,除非应用场景是大小写信息有意义的情况,如命名实体识别(NER),在这种情况下,专有名词很重要。
在这个例子中,我们将使用small_bert/bert_en_uncased_L-4_H-512_A-8/1
模型。它具有以下属性,这些属性已编码在它的名称中:
-
小型 BERT。
-
无大小写处理。
-
4 个隐藏层(L-4)。
-
隐藏层大小为 512。
-
8 个注意力头(A-8)。
这个模型是在维基百科和 BooksCorpus 上训练的。这是一个非常庞大的文本数据集,但也有许多经过更大规模文本训练的预训练模型,我们将在本章后面讨论这些模型。事实上,NLP 领域的一个重要趋势是开发并发布基于越来越大量文本训练的模型。
这里将回顾的例子改编自 TensorFlow 的 BERT 文本分类教程。完整的教程可以在这里找到:
)
我们将开始安装并加载一些基本库。我们将使用 Jupyter Notebook(你可能还记得,Jupyter Notebook 的设置过程在第四章中详细介绍过,必要时可以参考第四章获取更多细节):
!pip install -q -U "tensorflow-text==2.8.*"
!pip install -q tf-models-official==2.7.0
!pip install numpy==1.21
import os
import shutil
import tensorflow as tf
import tensorflow_hub as hub
import tensorflow_text as text
from official.nlp import optimization # to create AdamW optimizer
import matplotlib.pyplot as plt #for plotting results
tf.get_logger().setLevel('ERROR')
我们的 BERT 微调模型将通过以下步骤进行开发:
-
安装数据。
-
将数据拆分为训练集、验证集和测试集。
-
从 TensorFlow Hub 加载 BERT 模型。
-
通过将 BERT 与分类器结合来构建模型。
-
微调 BERT 以创建模型。
-
定义损失函数和评估指标。
-
定义优化器和训练周期数。
-
编译模型。
-
训练模型。
-
绘制训练步骤结果在训练周期中的图表。
-
用测试数据评估模型。
-
保存模型并用其分类文本。
接下来的章节将详细介绍每个步骤。
安装数据
第一步是安装数据。我们将使用在Chapter 10中安装的 NLTK 电影评论数据集。我们将使用tf.keras.utils.text_dataset_from_directory
实用程序从电影评论目录创建一个 TensorFlow 数据集:
batch_size = 32
import matplotlib.pyplot as plt
tf.get_logger().setLevel('ERROR')
AUTOTUNE = tf.data.AUTOTUNE
raw_ds = tf.keras.utils.text_dataset_from_directory(
'./movie_reviews',
class_names = raw_ds.class_names
print(class_names)
数据集中有 2,000 个文件,分为两类,neg
和pos
。我们在最后一步打印类名,以检查类名是否符合预期。这些步骤可用于任何以不同目录包含不同类示例的目录结构的数据集,其中类名作为目录名。
将数据分割为训练、验证和测试集
下一步是将数据集分割为训练、验证和测试集。正如您在前面章节中记得的那样,训练集用于开发模型。验证集与训练集分开,用于查看系统在训练过程中尚未训练的数据上的性能。在我们的示例中,我们将使用常见的 80%训练、10%验证和 10%测试的划分。验证集可以在每个训练周期结束时使用,以查看训练进展情况。测试集仅在最后进行一次使用,作为最终评估:
from sklearn.model_selection import train_test_split
def partition_dataset_tf(dataset, ds_size, train_split=0.8, val_split=0.1, test_split=0.1, shuffle=True, shuffle_size=1000):
assert (train_split + test_split + val_split) == 1
if shuffle:
# Specify seed maintain the same split distribution between runs for reproducibilty
dataset = dataset.shuffle(shuffle_size, seed=42)
train_size = int(train_split * ds_size)
val_size = int(val_split * ds_size)
train_ds = dataset.take(train_size)
val_ds = dataset.skip(train_size).take(val_size)
test_ds = dataset.skip(train_size).skip(val_size)
return train_ds, val_ds, test_ds
train_ds,val_ds,test_ds = partition_dataset_tf(
raw_ds,len(raw_ds))
加载 BERT 模型
下一步是加载我们将在此示例中进行微调的 BERT 模型,如下代码块所示。如前所述,有许多 BERT 模型可供选择,但这个模型是一个很好的起点。
我们还需要提供一个预处理器,将文本输入转换为 BERT 输入之前的数字标记 ID。我们可以使用 TensorFlow 为该模型提供的匹配预处理器:
bert_model_name = 'small_bert/bert_en_uncased_L-4_H-512_A-8'
map_name_to_handle = {
'small_bert/bert_en_uncased_L-4_H-512_A-8':
'https://tfhub.dev/tensorflow/small_bert/bert_en_uncased_L-4_H-512_A-8/1',
}
map_model_to_preprocess = {
'small_bert/bert_en_uncased_L-4_H-512_A-8':
'https://tfhub.dev/tensorflow/bert_en_uncased_preprocess/3',
}
tfhub_handle_encoder = map_name_to_handle[bert_model_name]
tfhub_handle_preprocess = map_model_to_preprocess[
bert_model_name]
bert_preprocess_model = hub.KerasLayer(
tfhub_handle_preprocess)
此处的代码指定我们将使用的模型,并定义了一些方便的变量,以简化对模型、编码器和预处理器的引用。
定义微调模型
以下代码定义了我们将使用的模型。如果需要,可以增加Dropout
层参数的大小,以使模型对训练数据的变化更加稳健:
def build_classifier_model():
text_input = tf.keras.layers.Input(shape=(),
dtype=tf.string, name='text')
preprocessing_layer = hub.KerasLayer(
tfhub_handle_preprocess, name='preprocessing')
encoder_inputs = preprocessing_layer(text_input)
encoder = hub.KerasLayer(tfhub_handle_encoder,
trainable = True, name='BERT_encoder')
outputs = encoder(encoder_inputs)
net = outputs['pooled_output']
net = tf.keras.layers.Dropout(0.1)(net)
net = tf.keras.layers.Dense(1, activation=None,
name='classifier')(net)
return tf.keras.Model(text_input, net)
# plot the model's structure as a check
tf.keras.utils.plot_model(classifier_model)
在Figure 11**.1中,我们可以看到模型的层次结构可视化,包括文本输入层、预处理层、BERT 层、dropout 层和最终分类器层。可视化是由代码块中的最后一行生成的。这个结构对应于我们在前面代码中定义的结构:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_11_01.jpg
图 11.1 – 可视化模型结构
像这样的合理性检查(例如可视化)非常有用,因为对于较大的数据集和模型,训练过程可能非常漫长。如果模型的结构不是预期的,那么花费大量时间训练错误的模型是非常浪费的。
定义损失函数和评估指标
我们将使用交叉熵函数作为损失函数。losses.BinaryCrossEntropy
损失函数:
loss = tf.keras.losses.BinaryCrossentropy(from_logits=True)
metrics = tf.metrics.BinaryAccuracy()
一个具有多个可能结果的分类应用,例如意图识别问题,其中我们必须决定将 10 个意图中的哪一个分配给输入,将使用类别交叉熵。同样,由于这是一个二分类问题,评估指标应为binary accuracy
,而不是简单的accuracy
,后者适用于多类分类问题。
定义优化器和训练轮数
优化器提高了学习过程的效率。我们在这里使用流行的Adam
优化器,并以非常小的学习率(3e-5
)开始,这对于 BERT 是推荐的。优化器将在训练过程中动态调整学习率:
epochs = 15
steps_per_epoch = tf.data.experimental.cardinality(
train_ds).numpy()
print(steps_per_epoch)
num_train_steps = steps_per_epoch * epochs
# a linear warmup phase over the first 10%
num_warmup_steps = int(0.1*num_train_steps)
init_lr = 3e-5
optimizer = optimization.create_optimizer(
init_lr=init_lr, num_train_steps = num_train_steps,
num_warmup_steps=num_warmup_steps,
optimizer_type='adamw')
请注意,我们选择了 15 个训练轮次。在第一次训练时,我们会尽量平衡在足够的轮次上进行训练以获得准确的模型,同时避免浪费时间训练超过所需轮次的模型。一旦得到第一次训练的结果,我们可以调整训练轮次,以平衡这两个目标。
编译模型
使用在def build_classifier_model()
中定义的分类器模型,我们可以使用损失函数、评估指标和优化器编译模型,并查看模型摘要。在开始漫长的训练过程之前,检查模型是否符合预期是一个好主意:
classifier_model.compile(optimizer=optimizer,
loss=loss,
metrics=metrics)
classifier_model.summary()
模型摘要大概会如下所示(我们将只展示几行,因为它相当长):
Model: model
__________________________________________________________________________________________________
Layer (type) Output Shape Param # Connected to
==================================================================================================
text (InputLayer) [(None,)] 0 []
preprocessing (KerasLayer) {'input_mask': (Non 0 ['text[0][0]']
e, 128),
'input_type_ids':
(None, 128),
'input_word_ids':
(None, 128)}
这里的输出只是总结了前两层——输入和预处理。
接下来的步骤是训练模型。
训练模型
在以下代码中,我们通过调用classifier_model.fit(_)
开始训练过程。我们为此方法提供训练数据、验证数据、输出详细程度和训练轮次(我们之前设置的)的参数,如下所示:
print(f'Training model with {tfhub_handle_encoder}')
history = classifier_model.fit(x=train_ds,
validation_data=val_ds,
verbose = 2,
epochs=epochs)
Training model with https://tfhub.dev/tensorflow/small_bert/bert_en_uncased_L-4_H-512_A-8/1
Epoch 1/15
50/50 - 189s - loss: 0.7015 - binary_accuracy: 0.5429 - val_loss: 0.6651 - val_binary_accuracy: 0.5365 - 189s/epoch - 4s/step
请注意,classifier_model.fit()
方法返回一个history
对象,其中包含完整训练过程中进展的信息。我们将使用history
对象来绘制训练过程图。这些图将为我们提供关于训练期间发生情况的深入了解,我们将利用这些信息指导我们的下一步行动。在下一节中,我们将看到这些图。
Transformer 模型的训练时间可能会非常长。所需时间取决于数据集的大小、训练的轮次(epochs)以及模型的大小,但这个例子在现代 CPU 上训练应该不会超过一小时。如果运行这个例子的时间明显超过这个时间,你可以尝试使用更高的详细程度(2 是最大值)进行测试,这样你就可以获得更多关于训练过程中发生了什么的信息。
在这个代码块的最后,我们还看到了处理第一轮训练的结果。我们可以看到第一轮训练用了189
秒。损失为0.7
,准确率为0.54
。经过一轮训练后的损失和准确率并不理想,但随着训练的进行,它们会显著改善。在下一节中,我们将看到如何通过图形化方式显示训练进度。
绘制训练过程
在训练完成后,我们需要查看系统的性能如何随训练轮次变化。我们可以通过以下代码来观察:
import matplotlib.pyplot as plt
!matplotlib inline
history_dict = history.history
print(history_dict.keys())
acc = history_dict['binary_accuracy']
val_acc = history_dict['val_binary_accuracy']
loss = history_dict['loss']
val_loss = history_dict['val_loss']
epochs = range(1, len(acc) + 1)
上述代码定义了一些变量,并从模型的history
对象中获取了相关的指标(用于训练和验证数据的binary_accuracy
和loss
)。现在我们已经准备好绘制训练过程的进展图了。像往常一样,我们将使用 Matplotlib 来创建我们的图表:
fig = plt.figure(figsize=(10, 6))
fig.tight_layout()
plt.subplot(2, 1, 1)
# r is for "solid red line"
plt.plot(epochs, loss, 'r', label='Training loss')
# b is for "solid blue line"
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
# plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.subplot(2, 1, 2)
plt.plot(epochs, acc, 'r', label='Training acc')
plt.plot(epochs, val_acc, 'b', label='Validation acc')
plt.title('Training and validation accuracy')
plt.xlabel('Epochs')
plt.ylabel('Accuracy')
plt.legend(loc='lower right')
plt.show()
dict_keys(['loss', 'binary_accuracy', 'val_loss',
'val_binary_accuracy'])
在图 11.2中,我们看到随着模型训练,损失逐渐减少,准确率逐步增加的图像。虚线代表训练损失和训练准确率,实线代表验证损失和验证准确率:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_11_02.jpg
图 11.2 – 训练过程中的准确率和损失
通常情况下,验证准确率会低于训练准确率,验证损失会大于训练损失,但这并不一定是必然的,具体取决于验证和训练数据子集的划分方式。在这个例子中,验证损失始终低于训练损失,而验证准确率始终高于训练准确率。我们可以从这个图中看到,系统在前十四轮训练后没有变化。实际上,它的表现几乎完美。
因此,很明显在这个点之后没有必要继续训练系统。相比之下,看看在第4
轮附近的图像。我们可以看到,如果在四轮后停止训练并不是一个好主意,因为损失仍在减少,准确率仍在增加。在图 11.2中,我们可以注意到在第7
轮附近,准确率似乎有所下降。如果我们在第7
轮就停止训练,我们就无法知道在第8
轮准确率会再次开始上升。因此,最好继续训练,直到我们看到指标趋于平稳或开始持续变差。
现在我们已经有了一个训练好的模型,我们想看看它在之前未见过的数据上的表现。这些未见过的数据是我们在训练、验证和测试拆分过程中预留出的测试数据。
在测试数据上评估模型
在训练完成后,我们可以看到模型在测试数据上的表现。这可以从以下输出中看到,在这里我们可以看到系统表现得非常好。准确度接近 100%,而损失值接近零:
loss, accuracy = classifier_model.evaluate(test_ds)
print(f'Loss: {loss}')
print(f'Binary Accuracy: {accuracy}')
1/7 [===>..........................] - ETA: 9s - loss: 0.0239 - binary_accuracy: 0.9688
2/7 [=======>......................] - ETA: 5s - loss: 0.0189 - binary_accuracy: 0.9844
3/7 [===========>..................] - ETA: 4s - loss: 0.0163 - binary_accuracy: 0.9896
4/7 [================>.............] - ETA: 3s - loss: 0.0140 - binary_accuracy: 0.9922
5/7 [====================>.........] - ETA: 2s - loss: 0.0135 - binary_accuracy: 0.9937
6/7 [========================>.....] - ETA: 1s - loss: 0.0134 - binary_accuracy: 0.9948
7/7 [==============================] - ETA: 0s - loss: 0.0127 - binary_accuracy: 0.9955
7/7 [==============================] - 8s 1s/step - loss: 0.0127 - binary_accuracy: 0.9955
Loss: 0.012707981280982494
Accuracy: 0.9955357313156128
这与我们在图 11.2中看到的训练过程中系统的表现是一致的。
看起来我们有一个非常准确的模型。如果我们以后想要使用它,可以将其保存下来。
保存模型用于推理
最后的步骤是保存微调后的模型,以便以后使用——例如,如果该模型要在生产系统中使用,或者我们想在进一步的实验中使用它。保存模型的代码如下:
dataset_name = 'movie_reviews'
saved_model_path = './{}_bert'.format(dataset_name.replace('/', '_'))
classifier_model.save(saved_model_path, include_optimizer=False)
reloaded_model = tf.saved_model.load(saved_model_path)
]
在这里的代码中,我们展示了如何保存模型并从保存的位置重新加载它。
正如我们在本节中看到的,BERT 可以通过在相对较小的(2,000 个条目)数据集上进行微调,从而训练出非常好的表现。这使得它成为许多实际问题的一个不错选择。回顾在第十章中使用多层感知器进行分类的示例,我们看到即使经过 20 轮训练,验证数据的准确度(如图 10.4所示)也从未超过大约 80%。显然,BERT 的表现要好得多。
虽然 BERT 是一个非常优秀的系统,但它最近已被非常大的基于云的预训练 LLM 所超越。我们将在下一节中描述这些模型。
基于云的 LLM
最近,出现了一些基于云的预训练大型语言模型,它们因为在大量数据上进行训练而展现出了非常令人印象深刻的表现。与 BERT 相比,它们太大,无法下载并在本地使用。此外,一些模型是封闭和专有的,因而无法下载。这些更新的模型基于与 BERT 相同的原理,并且表现出了非常令人印象深刻的性能。这种令人印象深刻的表现是因为这些模型在比 BERT 更大规模的数据上进行了训练。由于它们无法下载,重要的是要记住,这些模型并不适用于所有应用。特别是,如果数据涉及任何隐私或安全问题,将数据发送到云端进行处理可能并不是一个好主意。部分系统如 GPT-2、GPT-3、GPT-4、ChatGPT 和 OPT-175B,且新的 LLM 模型也在频繁发布。
这些系统所代表的 NLP 领域的戏剧性进展得益于三个相关的技术突破。其一是注意力机制等技术的发展,这些技术比以往的 RNN 等方法更能捕捉文本中单词之间的关系,且比我们在第八章中讨论的基于规则的方法有更好的扩展性。第二个因素是大量训练数据的可用性,主要是来自万维网的文本数据。第三个因素是可用计算资源的巨大增加,这些资源可以用来处理这些数据并训练 LLM。
到目前为止,我们讨论的所有系统中,创建特定应用模型所需的语言知识都来自训练数据。这个过程开始时对语言一无所知。另一方面,LLM(大规模语言模型)带有通过处理大量更为通用的文本进行预训练的模型,因此它们对语言有了基本的知识基础。可以使用额外的训练数据对模型进行微调,以便它能处理特定应用的输入。微调模型以适应特定应用的一个重要方面是尽量减少微调所需的新数据量。这是自然语言处理(NLP)研究中的前沿领域,你可能会看到一些训练方法的相关参考,例如少样本学习,即通过仅几个例子学习识别一个新类别,甚至零样本学习,它使得系统能够在没有见过任何该类别示例的情况下识别该类别。
在下一节中,我们将看看当前最流行的 LLM 之一——ChatGPT。
ChatGPT
ChatGPT (openai.com/blog/chatgpt/
) 是一个能够与用户就通用信息进行非常有效互动的系统。虽然在写作时,定制 ChatGPT 以适应特定应用尚且困难,但它对于除定制自然语言应用之外的其他目的仍然有用。例如,它可以非常容易地用来生成常规应用的训练数据。如果我们想使用本书中前面讨论的一些技术开发一个银行应用程序,我们需要训练数据,以便系统能提供用户可能如何提问的示例。通常,这涉及收集实际的用户输入,而这一过程可能非常耗时。相反,可以使用 ChatGPT 来生成训练数据,只需向它请求示例即可。例如,对于提示给我 10 个用户可能询问支票余额的示例,ChatGPT 给出的回答是图 11.3中的句子:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_11_03.jpg
图 11.3 – 用于银行应用程序的 GPT-3 生成的训练数据
这些查询看起来大多数是关于支票账户的合理问题,但有些听起来并不太自然。出于这个原因,以这种方式生成的数据总是需要进行审查。例如,开发者可能决定不将倒数第二个例子包含在训练集中,因为它听起来生硬,但总体而言,这种技术有潜力为开发者节省大量时间。
应用 GPT-3
另一个知名的 LLM,GPT-3,也可以通过应用特定的数据进行微调,这样应该能带来更好的性能。为了实现这一点,你需要一个 OpenAI 的 API 密钥,因为使用 GPT-3 是收费服务。无论是为了准备模型进行微调,还是在推理时使用微调后的模型处理新数据,都将产生费用。因此,在使用大量数据集进行训练并承担相关费用之前,验证训练过程是否按预期执行非常重要。
OpenAI 推荐以下步骤来微调 GPT-3 模型。
-
在
openai.com/
注册账户并获取 API 密钥。API 密钥将用于追踪你的使用情况并相应地向你的账户收费。 -
使用以下命令安装 OpenAI 命令行界面(CLI):
! pip install --upgrade openai
这个命令可以在类 Unix 系统的终端提示符中使用(一些开发者报告称在 Windows 或 macOS 上有问题)。另外,你也可以安装 GPT-3,在 Jupyter notebook 中使用以下代码:
!pip install --upgrade openai
以下所有示例都假设代码在 Jupyter notebook 中运行:
-
设置你的 API 密钥:
api_key =<your API key>
openai.api_key = api_key
-
下一步是指定你将用来微调 GPT-3 的训练数据。这与训练任何 NLP 系统的过程非常相似;然而,GPT-3 有一个特定的格式,必须按照这个格式提供训练数据。这个格式使用一种叫做 JSONL 的语法,其中每一行都是一个独立的 JSON 表达式。例如,如果我们想要微调 GPT-3 来分类电影评论,几条数据项可能如下所示(为了清晰起见省略了一些文本):
{"prompt":"this film is extraordinarily horrendous and i'm not going to waste any more words on it . ","completion":" negative"}
{"prompt":"9 : its pathetic attempt at \" improving \" on a shakespeare classic . 8 : its just another piece of teen fluff . 7 : kids in high school are not that witty . … ","completion":" negative"}
{"prompt":"claire danes , giovanni ribisi , and omar epps make a likable trio of protagonists , …","completion":" negative"}
每个项目由一个包含两个键的 JSON 字典组成,prompt
和completion
。prompt
是待分类的文本,completion
是正确的分类。所有这三个项目都是负面评论,因此所有的 completion 都标记为negative
。
如果你的数据已经是其他格式,可能不太方便将其转换为这种格式,但 OpenAI 提供了一个有用的工具,可以将其他格式转换为 JSONL。它接受多种输入格式,如 CSV、TSV、XLSX 和 JSON,唯一的要求是输入必须包含两个列,分别为prompt
和completion
。表 11.2展示了一些来自 Excel 电子表格的单元格,其中包含一些电影评论作为示例:
prompt | completion |
---|---|
Kolya 是我近期看到的最丰富的电影之一。Zdenek Sverak 饰演一位确认的老光棍(可能会保持这样的状态),他的生活作为一名捷克大提琴家,越来越受到他照顾的五岁男孩的影响…… | 正面 |
这部三小时的电影以歌手/吉他手/音乐家/作曲家 Frank Zappa 与他的乐队成员排练的画面开场。之后展示的是一系列的片段,主要来自 1979 年万圣节在纽约市的 Palladium 音乐厅的演唱会…… | 正面 |
strange days 讲述了 1999 年最后两天洛杉矶的故事。当当地人准备迎接新千年时,Lenny Nero(拉尔夫·费因斯)继续忙碌着他的工作…… | 正面 |
表 11.2 – 用于微调 GPT-3 的电影评论数据
要将这些替代格式之一转换为 JSONL 格式,你可以使用fine_tunes.prepare_data
工具,假设你的数据包含在movies.csv
文件中,如下所示:
!openai tools fine_tunes.prepare_data -f ./movies.csv -q
fine_tunes.prepare_data
工具将创建一个 JSONL 格式的数据文件,并提供一些诊断信息,这些信息有助于改进数据。它提供的最重要的诊断信息是数据量是否足够。OpenAI 建议使用几百个具有良好表现的示例。其他诊断信息包括各种格式化信息,如提示与完成之间的分隔符。
在数据格式正确后,你可以将其上传到你的 OpenAI 账户,并保存文件名:
file_name = "./movies_prepared.jsonl"
upload_response = openai.File.create(
file=open(file_name, "rb"),
purpose='fine-tune'
)
file_id = upload_response.id
下一步是创建并保存一个微调模型。可以使用多个不同的 OpenAI 模型。我们这里使用的ada
是最快且最便宜的,并且在许多分类任务中表现良好:
openai.FineTune.create(training_file=file_id, model="ada")
fine_tuned_model = fine_tune_response.fine_tuned_model
最后,我们可以用一个新的提示来测试这个模型:
answer = openai.Completion.create(
model = fine_tuned_model,
engine = "ada",
prompt = " I don't like this movie ",
max_tokens = 10, # Change amount of tokens for longer completion
temperature = 0
)
answer['choices'][0]['text']
在这个例子中,由于我们只使用了少量的微调语句,结果不会非常理想。鼓励你尝试使用更多的训练数据进行实验。
总结
本章介绍了目前在自然语言处理(NLP)领域中表现最好的技术——变换器和预训练模型。此外,我们还展示了如何将它们应用于处理你自己特定应用的数据,使用本地预训练模型和基于云的模型。
具体来说,你了解了注意力机制、变换器(transformers)和预训练模型的基本概念,然后将 BERT 预训练变换器系统应用于分类问题。最后,我们探讨了如何使用基于云的 GPT-3 系统生成数据以及处理特定应用的数据。
在第十二章中,我们将转向一个不同的主题——无监督学习。到目前为止,我们的所有模型都是有监督的,你会记得这意味着数据已经被标注了正确的处理结果。接下来,我们将讨论无监督学习的应用。这些应用包括主题建模和聚类。我们还将讨论无监督学习在探索性应用和最大化稀缺数据方面的价值。还将涉及部分监督的类型,包括弱监督和远程监督。
第十二章:应用无监督学习方法
在之前的章节中,如第五章,我们讨论了监督学习需要标注数据的事实,其中人工标注员决定自然语言处理(NLP)系统应如何分析数据——也就是说,数据是由人标注的。例如,在电影评论数据中,人工标注员查看每个评论并决定它是正面还是负面。我们还指出,这一标注过程既昂贵又耗时。
在本章中,我们将研究不需要标注数据的技术,从而节省了数据准备中的这一耗时步骤。虽然无监督学习并不适用于每一个 NLP 问题,但了解这一领域的基本知识非常有用,这样你就能决定如何将其纳入到你的 NLP 项目中。
在更深层次上,我们将讨论无监督学习的应用,如主题建模,包括无监督学习在探索性应用和最大化稀缺数据方面的价值。我们还将讨论无监督分类中的标签生成,并提及一些方法来最大化有限标签数据的利用,结合部分监督的技术。
在本章中,我们将涵盖以下主题:
-
什么是无监督学习?
-
使用聚类技术和标签推导的主题建模
-
最大化部分监督下的数据利用
什么是无监督学习?
在前面的章节中,我们处理的应用都是基于人工标注的数据。例如,我们多次使用的电影评论语料库中的每条评论都由人工标注员阅读,并根据标注员的意见将其分配到正面或负面类别。然后,评论-类别对被用来训练模型,使用我们之前学习的机器学习算法对新评论进行分类。整个过程称为监督学习,因为训练过程实际上是由训练数据进行监督的。由人类标注的数据被称为黄金标准或真实标签。
然而,监督学习方法也存在一些缺点。最明显的缺点是开发真实标签数据的成本,因为需要人工标注员的费用。另一个需要考虑的问题是,不同标注员,甚至同一标注员在不同时间进行的人工标注,可能会存在不一致的情况。如果数据标签本身是主观的或不明确的,也可能导致标注不一致,这使得标注员难以就正确的标注达成一致。
对于许多应用,监督学习方法是唯一的选择,但在本章中,我们将探索其他应用,其中无监督技术是有用的。
这些无监督应用不需要标注的训练数据,因为我们想要从自然语言数据中学习的内容并不需要任何人工判断。相反,这些内容可以通过仅仅检查原始文本来发现,而这一过程可以通过算法来完成。这类应用包括按相似度对文档进行分组和计算文档的相似度。特别地,我们将关注聚类,即将数据,特别是文档,分组到相似的类别中。寻找相似的文档组通常是开发分类应用过程中的第一步,在文档的类别尚未确定之前。一旦聚类被识别出来,还可以使用一些附加技术来帮助找到人类可读的标签,尽管在某些情况下,通过手动检查聚类,能够轻松确定聚类应如何标记。我们将在本章后面介绍找到聚类标签的工具。
除了聚类,另一项重要的无监督学习应用是我们在第十一章中介绍的大语言模型(LLMs)的训练过程。训练 LLMs 不需要任何监督,因为训练过程仅查看单词在其他单词的上下文中的含义。然而,本书中不会涉及 LLM 的训练过程,因为这是一个计算密集型的过程,并且需要昂贵的计算资源,这些资源对绝大多数开发者来说并不具备。此外,大多数实际应用并不需要 LLM 训练,因为现有的已训练 LLM 已被广泛提供。
本章中,我们将详细阐述一个实际的自然语言处理问题,在该问题中,无监督学习非常有用——主题建模。在主题建模中,我们从一组文本项开始,例如文档或聊天机器人应用中的用户输入,但我们没有预先确定的类别集。相反,我们使用文本中的单词本身来发现文本之间的语义相似性,从而将它们分组为不同的类别或主题。
我们将首先回顾一些关于语义上将相似文本分组的基本考虑因素。
使用聚类技术和标签推导的主题建模
我们将通过先讨论一些关于一般性语义上相似文档分组的考虑因素开始探索主题建模,然后我们将看一个具体的例子。
语义上相似文档的分组
和我们迄今为止讨论的大多数机器学习问题一样,整体任务通常可以分解为两个子问题:数据表示和基于表示执行任务。接下来,我们将探讨这两个子问题。
数据表示
我们迄今为止查看的数据表示方法在第七章中有所回顾。这些方法包括简单的词袋(BoW)变体、词频-逆文档频率(TF-IDF)以及较新的方法,包括Word2Vec。Word2Vec 基于词向量,词向量是代表单独单词的向量,不考虑单词出现的上下文。一个较新的表示方法,使用于我们在上一章讨论的BERT系统,考虑了单词在句子或文档中的上下文,以创建数值化的单词表示,或称为嵌入。在本章中,我们将使用 BERT 嵌入来揭示文档之间的相似性。
处理数据表示
本节将讨论处理嵌入的两个方面。首先,我们将讨论将相似文本分组到簇中,然后讨论如何可视化这些簇。
聚类——分组相似项
聚类是我们在本章将讨论的主要自然语言处理(NLP)任务。聚类是指一类旨在根据数据表示中的相似性将数据项分组的各种算法。聚类可以应用于任何数据集,无论数据项是否是基于文本的,只要有数值化的方式表示它们的相似性。在本章中,我们将回顾一组实用的聚类工具,但你应该意识到还有许多其他选择,随着技术的发展,毫无疑问会有更多新的聚类方法。两种常见的聚类方法是 k-means 和 HDBSCAN:
-
k-means:我们在第六章回顾过的 k-means 算法是一种非常常见的聚类方法,其中数据点最初会被随机分配到 k 个簇中,计算簇的均值,并通过迭代过程最小化数据点与簇中心之间的距离。k 的值,即簇的数量,是由开发者选择的超参数。它可以视为该应用中最有用的簇数。k-means 算法之所以常用,是因为它高效且易于实现,但其他聚类算法——尤其是 HDBSCAN——可以产生更好的结果。
-
HDBSCAN:HDBSCAN 是另一种流行的聚类算法,代表基于密度的层次空间聚类与噪声应用。HDBSCAN 考虑了簇内数据点的密度。因此,它能够找到大小不一且形状各异的簇。它还能够检测到离群值或那些不适合某个簇的项,而 k-means 则会强制每个项都被分配到一个簇中。
可视化簇
可视化在无监督学习方法中非常重要,比如聚类,因为它允许我们看到相似数据项的分组方式,并帮助我们判断分组结果是否有用。尽管相似项的聚类可以用任意维度表示,但我们最多只能有效地在三维空间中可视化聚类。因此,在实践中,需要进行降维处理以减少维度的数量。我们将使用一种叫做统一流形近似与投影(UMAP)的工具进行降维。
在下一节中,我们将使用聚类和可视化来说明无监督学习的一个特定应用——主题建模。主题建模可以解决的普遍问题是将文档分类到不同的主题中。这种技术的独特之处在于,与我们在前几章看到的分类示例不同,起初我们并不知道主题是什么。主题建模可以帮助我们识别相似文档的组,即使我们不知道最终的分类会是什么。
在这个示例中,我们将使用 BERT 变换器嵌入来表示文档,并使用 HDBSCAN 进行聚类。具体来说,我们将使用位于maartengr.github.io/BERTopic/index.html
的 BERTopic Python 库。BERTopic 库是可定制的,但在我们的示例中,我们将大部分使用默认设置。
我们将要查看的数据是一个著名的数据集,叫做20 newsgroups
,它是来自 20 个不同互联网新闻组的 20,000 个新闻组文档集合。这是一个常用于文本处理的流行数据集。数据由不同长度的电子邮件消息组成,这些消息发布到新闻组中。以下是该数据集中的一条短消息示例,已去除电子邮件头部:
I searched the U Mich archives fairly thoroughly for 3D graphics packages,
I always thought it to be a mirror of sumex-aim.stanford.edu... I was wrong.
I'll look into GrafSys... it does sound interesting!
Thanks Cheinan.
BobC
20 newsgroups
数据集可以从 scikit-learn 的 datasets 中导入,或者从以下网站下载:qwone.com/~jason/20Newsgroups/
。
数据集引用
Ken Lang,Newsweeder: 学习过滤网新闻,1995 年,第十二届国际机器学习会议论文集,331–339
在接下来的章节中,我们将使用20 newsgroups
数据集和 BERTopic 包详细讲解主题建模。我们将创建嵌入,构建模型,为主题生成建议标签,并可视化生成的聚类。最后一步是展示如何使用我们的模型为新文档找到主题。
将 BERTopic 应用于 20 个新闻组
这个应用的第一步是在 Jupyter 笔记本中安装 BERTopic 并导入必要的库,如下所示:
!pip install bertopic
from sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import CountVectorizer
from sentence_transformers import SentenceTransformer
from bertopic import BERTopic
from umap import UMAP
from hdbscan import HDBSCAN
# install data
docs = fetch_20newsgroups(subset='all', remove=('headers', 'footers', 'quotes'))['data']
嵌入
下一步是准备数据表示或嵌入,如以下代码块所示。由于这是一个较慢的过程,设置show_progress_bar()
为True
是很有用的,这样即使过程很慢,我们也能确保进程正在进行。
# Prepare embeddings
docs = fetch_20newsgroups(subset='all', remove=('headers', 'footers', 'quotes'))['data']
#The model is a Hugging Face transformer model
embedding_model = SentenceTransformer("all-MiniLM-L6-v2")
corpus_embeddings = embedding_model.encode(docs, show_progress_bar = True)
Batches: 100%|########################################################################| 589/589 [21:48<00:00, 2.22s/it]
在本练习中,我们将使用Sentence Bert(SBERT)进行句子嵌入,而不是我们在第十一章中使用的早期 BERT 词嵌入。
SBERT 为每个句子生成一个嵌入。我们将使用一个名为SentenceTransformers
的包,它来自 Hugging Face,且推荐使用all-MiniLM-L6-v2
模型,BERTopic 也推荐使用该模型。不过,许多其他的 transformer 模型也可以使用。例如,en_core_web_trf
的 spaCy 模型或distilbert-base-cased
的 Hugging Face 模型。BERTopic 还提供了一个指南,介绍了您可以使用的其他模型,详细内容请见maartengr.github.io/BERTopic/getting_started/embeddings/embeddings.html
。
我们可以在以下输出中看到实际的嵌入效果:
corpus_embeddings.view()
array([[ 0.002078 , 0.02345043, 0.02480883, ..., 0.00143592,
0.0151075 , 0.05287581],
[ 0.05006033, 0.02698092, -0.00886482, ..., -0.00887168,
-0.06737082, 0.05656359],
[ 0.01640477, 0.08100049, -0.04953594, ..., -0.04184629,
-0.07800221, -0.03130952],
...,
[-0.00509084, 0.01817271, 0.04388074, ..., 0.01331367,
-0.05997065, -0.05430664],
[ 0.03508159, -0.05842971, -0.03385153, ..., -0.02824297,
-0.05223113, 0.03760364],
[-0.06498063, -0.01133722, 0.03949645, ..., -0.03573753,
0.07217913, 0.02192113]], dtype=float32)
使用corpus_embeddings.view()
方法,如下所示,我们可以查看嵌入的摘要,它们是浮动数字的数组的数组。直接查看嵌入本身并不特别有用,但它可以让你对实际数据的样子有个大致的了解。
构建 BERTopic 模型
一旦嵌入计算完成,我们就可以构建 BERTopic 模型。BERTopic 模型可以接受大量参数,因此我们不会展示所有参数。我们将展示一些有用的参数,但还有很多其他的参数,您可以查阅 BERTopic 文档以获取更多的思路。BERTopic 模型可以非常简单地构建,只需要文档和嵌入作为参数,如下所示:
model = BERTopic().fit(docs, corpus_embeddings)
这个简单的模型具有一些默认的参数,通常会产生合理的结果。然而,为了展示 BERTopic 的一些灵活性,我们接下来将展示如何使用更丰富的参数集来构建模型,代码如下所示:
from sklearn.feature_extraction.text import CountVectorizer
vectorizer_model = CountVectorizer(stop_words = "english", max_df = .95, min_df = .01)
# setting parameters for HDBSCAN (clustering) and UMAP (dimensionality reduction)
hdbscan_model = HDBSCAN(min_cluster_size = 30, metric = 'euclidean', prediction_data = True)
umap_model = UMAP(n_neighbors = 15, n_components = 10, metric = 'cosine', low_memory = False)
# Train BERTopic
model = BERTopic(
vectorizer_model = vectorizer_model,
nr_topics = 'auto',
top_n_words = 10,
umap_model = umap_model,
hdbscan_model = hdbscan_model,
min_topic_size = 30,
calculate_probabilities = True).fit(docs, corpus_embeddings)
在前面的代码中,我们首先定义了几个有用的模型。第一个是CountVectorizer
,我们在第七章和第十章中看到过,在那里我们用它将文本文档向量化成如 BoW 这样的格式。在这里,我们将使用该向量化器在降维和聚类后移除停用词,以确保它们不会被包含到主题标签中。
在准备嵌入之前,停用词不应从文档中删除,因为 transformer 模型是基于包含停用词的正常文本进行训练的,缺少停用词会导致模型效果下降。
向量化模型的参数表示该模型是使用英语停用词构建的,并且仅包括在少于 95%的文档中出现且超过 1%的单词。这是为了排除极其常见和极其罕见的单词,这些单词不太可能对区分主题有所帮助。
我们将定义的第二个模型是 HDBSCAN 模型,用于聚类。其一些参数包括以下内容:
-
min_cluster_size
参数是我们希望在聚类中包含的文档的最小数量。在这里,我们选择了30
作为最小聚类大小。此参数可以根据你要解决的问题进行调整,但如果数据集中的文档数量较大,可能需要选择更大的数字。 -
prediction_data
设置为True
,如果我们希望在模型训练后能够预测新文档的主题。
下一个模型是均匀流形近似与投影(UMAP)模型,用于降维。除了更容易可视化多维数据外,UMAP 还使得聚类变得更加容易。它的一些参数包括以下内容:
-
n-neighbors
:此参数限制了 UMAP 在学习数据结构时会观察的区域大小。 -
n-components
:此参数决定了我们将使用的降维空间的维度。 -
low_memory
:此参数决定系统内存管理的行为。如果参数为False
,算法将使用更快速但更占用内存的方法。如果在处理大数据集时内存不足是个问题,可以将low_memory
设置为True
。
在定义了这些初步模型后,我们可以继续定义 BERTopic 模型本身。此示例中设置的参数如下:
-
我们已经定义的三个工具模型——向量化模型、UMAP 模型和 HDBSCAN 模型。
-
nr_topics
:如果我们已经知道想要找到多少个主题,则可以将此参数设置为该数量。在这种情况下,它设置为auto
,HDBSCAN 将根据数据估算出合适的主题数量。 -
top_n_words
:在生成标签时,BERTopic 应仅考虑聚类中出现频率最高的前 n个单词。 -
min_topic_size
:应考虑形成一个主题的文档的最小数量。 -
calculate_probabilities
:此参数计算每个文档所有主题的概率。
运行本节代码将从原始文档中创建聚类。聚类的数量和大小将有所不同,具体取决于生成模型时设置的参数。鼓励你尝试不同的参数设置并比较结果。在思考应用目标时,考虑一些设置是否会产生更多或更少有用的结果。
到目前为止,我们使用的是相似文档的簇,但这些簇并没有被标注上任何类别。如果这些簇有名字会更有用。接下来我们将看看如何为这些簇获得与其内容相关的名称或标签。下一节将回顾标记簇的几种方法。
标记
一旦我们得到了簇,就有多种方法可以为它们标上主题。手动方法,你只需查看文档并思考一个合适的标签是什么,这种方法并不一定要排除。然而,也有一些自动方法可以根据文档中出现的词语建议主题。
BERTopic 使用一种方法来建议主题标签,称为基于类别的tf-idf
,或c-tf-idf
。你应该记得,TF-IDF 在前几章中已作为一种文档向量化方法讨论,它可以识别文档类别中最具诊断性的术语。它通过比较术语在文档中的频率与其在整个文档集合中的频率来实现这一点。那些在某个文档中频繁出现,但在整个数据集中并不常见的术语,很可能表明该文档应该被分配到特定类别。
基于类别的 TF-IDF 通过将每个簇视为一个单独的文档,进一步拓展了这一直觉。然后,它会查看在某个簇中频繁出现的术语,但在整个数据集中不常出现的术语,以识别有助于标记主题的词语。利用这个指标,可以为每个簇生成最具诊断性的词语作为标签。我们可以在表 12.1中看到,为数据集中前 10 个最频繁的主题生成的标签,以及每个主题中的文档数量。
注意表格中的第一个主题,-1,是一个涵盖所有不属于任何其他主题的文档的通用主题。
主题 | 数量 | 名称 | |
---|---|---|---|
0 | -1 | 6,928 | -``1_maxaxaxaxaxaxaxaxaxaxaxaxaxaxax_dont_know_like |
1 | 0 | 1,820 | 0_game_team_games_players |
2 | 1 | 1,569 | 1_space_launch_nasa_orbit |
3 | 2 | 1,313 | 2_car_bike_engine_cars |
4 | 3 | 1,168 | 3_image_jpeg_window_file |
5 | 4 | 990 | 4_armenian_armenians_people_turkish |
6 | 5 | 662 | 5_drive_scsi_drives_ide |
7 | 6 | 636 | 6_key_encryption_clipper_chip |
8 | 7 | 633 | 7_god_atheists_believe_atheism |
9 | 8 | 427 | 8_cheek___ |
10 | 9 | 423 | 9_israel_israeli_jews_arab |
表 12.1 – 前 10 个主题及自动生成的标签
可视化
可视化在无监督学习中极为有用,因为决定如何在开发过程中进行下一步通常依赖于可以通过不同方式查看结果来帮助做出直觉判断。
有许多方法可以可视化主题建模的结果。一种有用的可视化方法是通过 BERTopic 方法model.visualize_barchart()
获得的,它显示了最重要的主题及其关键词,如图 12**.1所示。例如,查看space_launch_nasa_orbit
中的重要词汇:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_12_01.jpg
图 12.1 – 前七个主题及其最重要的词汇
另一种在聚类中常用的重要可视化技术是将每个聚类中的项目表示为一个点,通过点之间的距离来表示它们的相似性,并为不同的聚类分配不同的颜色或标记。这个聚类可以通过 BERTopic 方法visualize_documents()
生成,如以下代码所示,使用最小的docs
和embeddings
参数:
model.visualize_documents(docs, embeddings = corpus_embeddings)
可以设置额外的参数来以各种方式配置聚类图,具体内容可参见 BERTopic 文档。例如,你可以设置显示或隐藏聚类标签,或者仅显示最重要的主题。
20 newsgroups
数据集中的前七个主题的聚类结果可以在图 12**.2中看到。
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_12_02.jpg
图 12.2 – 20 newsgroups
数据集中前七个主题的聚类文档及其生成的标签
图 12*.2中显示了七个主题,对应于图 12**.1中的七个主题,每个主题都有自动生成的标签。此外,图 12*.2中的文档以浅灰色显示,表示这些文档没有被分配到任何聚类中。它们在表 12.1中作为主题-1
显示。
从聚类展示中,我们可以得到一个见解,那就是有许多未分配的文档,这意味着我们可能需要增加要查找的主题数量(通过将nr_topic
参数增加到model()
)。另一个见解是,一些主题——例如,1_space_launch_nasa
——似乎被分配到了多个聚类中。这意味着将它们视为单独的主题可能会更有意义。像未分类文档的情况一样,我们可以通过增加要查找的主题数量来调查这一可能性。
我们展示了两种最有用的 BERTopic 可视化图,但还有更多。建议你参考 BERTopic 文档,探索其他想法。
查找新文档的主题
一旦聚类完成,模型就可以用来找到新文档的主题,类似于分类应用。可以使用model.transform()
方法完成,如下所示:
sentence_model = SentenceTransformer("all-MiniLM-L6-v2")
new_docs = ["I'm looking for a new graphics card","when is the next nasa launch"]
embeddings = sentence_model.encode(new_docs)
topics, probs = model.transform(new_docs,embeddings)
print(topics)
[-1, 3]
new_docs
中的两个文档的预测主题分别是-1
(无主题)和3
,表示3_space_launch_orbit_nasa
。
在聚类和主题标注后
回想一下,聚类最常见的应用是探索数据,通常是为了开发监督分类应用做准备。我们可以查看聚类结果,并决定,例如,某些聚类非常相似并且彼此靠近。在这种情况下,即使经过监督训练,也很难将这些主题中的文档区分开来,因此将这些聚类合并为一个主题可能是个好主意。同样,如果我们发现一些非常小的聚类,通常将这些小聚类与相似的大聚类合并,会得到更可靠的结果。
我们还可以修改标签,使其更加有用或富有信息。例如,表 12.1中的主题1是1_space_launch_nasa_orbit
。你可能会决定使用space
作为一个更简单的标签,它仍然和自动生成的标签一样具有信息量。
在对你认为有帮助的聚类和标签进行调整后,结果将是一个监督数据集,就像我们之前使用的监督数据集,例如电影评论数据集一样。你可以像使用任何监督数据集一样使用它,在 NLP 应用中加以利用。
尽管无监督的主题建模是一种非常有用的技术,但也可以利用部分监督的数据。我们将在下一节总结 NLP 研究社区探索的一些思想,看看如何利用部分标注的数据。
最大化利用弱监督数据
在完全监督和无监督学习之间,有几种部分监督的方法,其中只有 x 数据是受监督的。像无监督方法一样,这些技术的目标是最大化利用监督数据,而监督数据通常很难获取。部分监督相较于无监督方法的一个优势是,无监督结果自动生成的标签并不总是有用的。标签必须通过手动或本章早些时候提到的一些技术来提供。通常情况下,弱监督下的标签是基于已监督数据的子集来提供的。
这是一个活跃的研究领域,我们不会深入讨论。然而,了解弱监督的一般策略是有益的,这样你就能根据可用的标注数据,将这些策略应用到具体任务中。
弱监督的一些策略包括以下内容:
-
不完全监督,其中只有部分数据具有真实标签
-
不精确监督,其中数据有粗粒度的标签
-
不准确监督,其中一些标签可能是错误的
-
半监督学习,其中提供了一些预定义的标签,帮助将模型推向已知类别
在那些完整标注过于昂贵或耗时的应用中,这些方法值得考虑。此外,在那些无监督学习可能会遇到问题的情况下,它们也可以发挥作用,特别是当其他部分的整体应用(例如数据库)需要预定义标签时。
总结
在本章中,你学习了无监督学习的基本概念。我们还通过使用一个基于 BERT 的工具——BERTopic,具体演示了无监督学习的一个应用:主题建模。我们使用 BERTopic 包来识别语义相似文档的聚类,并根据它们包含的词汇为这些聚类提议标签,而无需使用任何监督标注的聚类主题。
在下一章,第十三章,我们将讨论如何使用定量技术衡量我们结果的好坏。定量评估在研究应用中很有用,可以将结果与以往的研究进行比较,并且在实际应用中可以确保所使用的技术符合应用的要求。虽然在前面的章节中简要讨论过评估,第十三章将深入探讨这一主题。内容将包括将数据划分为训练集、验证集和测试集,使用交叉验证进行评估,评估指标(如精确度和召回率)、曲线下面积、消融研究、统计显著性、标注者间一致性和用户测试。
第十三章:它工作得怎么样?——评估
本章将讨论如何量化一个自然语言理解(NLU)系统的效果。在本书的整个过程中,我们假设我们希望开发的 NLU 系统能够在其设计的任务上表现良好。然而,我们并没有详细讨论使我们能够判断系统表现如何的工具——也就是如何评估系统。本章将介绍一些评估技术,帮助你了解系统的表现如何,以及如何在性能上比较不同的系统。我们还将探讨一些避免从评估指标中得出错误结论的方法。
本章我们将讨论的主题如下:
-
为什么要评估自然语言理解系统?
-
评估范式
-
数据分割
-
评估指标
-
用户测试
-
差异的统计显著性
-
比较三种文本分类方法
我们将从为什么评估自然语言理解系统非常重要这个问题开始。
为什么要评估自然语言理解系统?
我们可以提出许多关于自然语言理解系统整体质量的问题,评估它就是回答这些问题的方式。我们如何评估取决于系统开发的目标以及我们想要了解系统的哪些方面,以确保目标得以实现。
不同类型的开发者有不同的目标。例如,考虑以下类型的开发者的目标:
-
我是一个研究员,我想知道我的想法是否推动了自然语言理解(NLU)科学的发展。换句话说,我想知道我的工作与最新技术(SOTA)相比如何——也就是说,与在某个特定任务上任何人报告的最佳结果相比,我的工作如何。
-
我是一个开发者,我想确保我的整体系统性能足够好,适用于某个应用。
-
我是一个开发者,我想看看我的修改如何提高系统的表现。
-
我是一个开发者,我想确保我的修改没有降低系统的性能。
-
我是一个研究员或开发者,我想知道我的系统在不同数据类别上的表现如何。
对所有这些开发者和研究员来说,最重要的问题是,系统在执行其 预期功能 时表现如何?
本章将重点讨论的问题是:如何让不同类型的开发者获取他们所需的信息。然而,还有其他重要的自然语言理解系统属性可以评估,有时这些可能比整体系统性能更为重要。值得简要提及这些,以便你了解它们。例如,其他评估问题的方面包括:
-
支持应用程序的机器学习模型大小:如今的模型可以非常庞大,并且有大量的研究致力于使模型变得更小而不显著降低其性能。如果需要小型模型,则需要权衡模型大小与准确性之间的关系。
-
训练时间:一些算法需要在高度强大的 GPU 处理器上进行几个星期的训练时间,尤其是当它们训练的是大规模数据集时。缩短训练时间可以大大简化实验不同算法和调整超参数的过程。从理论上讲,较大的模型会提供更好的结果,但代价是更多的训练时间,但在实践中,我们需要问的是,这些更大的模型在任何特定任务的表现上有多大差异?
-
训练数据量:如今的大规模语言模型(LLMs)需要大量的训练数据。事实上,在当前的 SOTA(最先进技术)中,数据量对于除最大型组织之外的所有机构来说都是过于庞大的。然而,正如我们在第十一章中看到的那样,LLMs 可以通过特定应用的数据进行微调。另一个关于训练数据的考量是,是否有足够的数据来训练一个能够良好工作的系统。
-
开发者的专业知识:依赖于高度专业的开发者成本很高,因此通常希望有一个可以由较少经验的开发者执行的开发过程。在第八章中讨论的基于规则的系统,通常需要高度专业的开发者,这也是它们往往尽可能避免使用的原因之一。另一方面,实验最先进的深度学习模型可能需要专家数据科学家的知识,而这些专家既昂贵又难以找到。
-
训练成本:对于非常大的模型,训练成本通常在几百万美元的范围内,即使只考虑计算资源的成本。较低的训练成本显然是自然语言理解(NLU)系统的一项理想特性。
-
环境影响:与训练成本密切相关的是其在能源消耗方面的环境影响,这可能非常高。显然,减少这种影响是非常理想的。
-
推理的处理时间:这个问题涉及到训练好的系统处理输入并给出结果所需的时间。对于今天的系统来说,这通常不是问题,特别是在聊天机器人或语音对话系统等互动系统中使用的短输入。几乎任何现代方法都能让它们快速处理到足够的速度,用户不会感到烦恼。然而,对于离线应用(如分析)而言,如果一个应用需要从数小时的音频或数 GB 的文本中提取信息,处理时间较慢将会积累起来。
-
预算:像 GPT-4 这样的付费云端大型语言模型(LLMs)通常能提供非常好的结果,但像 BERT 这样的本地开源模型可能便宜得多,且能为特定应用提供足够好的结果。
即便这些属性在我们决定使用哪种 NLU 方法时可能很重要,但 NLU 系统的实际效果可能是最重要的。作为开发者,我们需要回答一些基础性问题,例如:
-
这个系统的表现是否足够好,能够 满足预期功能并且 有用?
-
随着系统的变化,它是否在不断 变得更好?
-
这个系统的表现与 其他系统的表现相比如何?
这些问题的答案需要评估方法来给系统的性能分配数值。主观的或非定量的评估方法(例如少数人查看系统的表现并决定它是否看起来好)不够精确,无法为这些问题提供可靠的答案。
我们将通过回顾一些整体的评估方法来开始我们的评估讨论,或称为评估范式。
评估范式
在本节中,我们将回顾一些用于量化系统性能并比较系统的主要评估范式。
比较系统在标准指标上的表现
这是最常见的评估范式,可能也是最容易执行的。系统只需处理数据,并根据标准指标对其表现进行定量评估。即将到来的评估指标部分将更详细地探讨这个话题。
评估语言输出
一些自然语言理解(NLU)应用程序生成自然语言输出。这些应用包括翻译或文本摘要等。它们与需要给出明确对错答案的应用不同,如分类和槽填充,因为在这些应用中没有唯一正确的答案——可能有许多好的答案。
评估机器翻译质量的一种方法是让人类查看原文和翻译,并判断其准确性,但这种方法通常过于昂贵,无法广泛使用。因此,已经开发出了可以自动应用的指标,尽管它们不如人工评估令人满意。我们在这里不会详细讨论这些指标,但会简要列出它们,以便你在需要评估语言输出时可以进一步研究。
双语评估替代指标(BLEU)是评估翻译质量的一个公认的指标。这个指标通过将机器翻译结果与人工翻译进行对比,衡量二者之间的差异。由于非常优秀的机器翻译与任何特定的人工翻译可能有很大不同,因此 BLEU 分数不一定能与人工翻译质量评判一致。其他用于评估语言输出应用程序的指标包括带显式顺序的翻译评估指标(METEOR)、摘要评估的召回导向替代指标(ROUGE)和跨语言优化翻译评估指标(COMET)。
在接下来的部分,我们将讨论一种评估方法,该方法涉及去除系统的一部分,以确定它对结果的影响。这是否会使结果变得更好或更差,还是根本不会造成任何变化?
去除系统的一部分——消融
如果一个实验包括多个操作步骤的流水线,通常通过去除流水线中的步骤来比较结果会提供更多信息。这种方法叫做消融,它在两种不同情况下都非常有用。
第一个情况是当实验用于研究论文或包含一些创新技术的学术项目时。在这种情况下,你希望能够量化流水线中每个步骤对最终结果的影响。这将使论文的读者能够评估每个步骤的重要性,特别是如果论文试图展示某个步骤或多个步骤是一个重要的创新。如果去除这些创新后系统仍然表现良好,那么这些创新可能对系统整体性能的贡献并不显著。消融研究将帮助你准确了解每个步骤的贡献。
消融的第二个情况更为实际,发生在你正在开发一个需要在部署时具有计算效率的系统时。通过比较有无特定步骤的系统版本,你可以判断这些步骤所花费的时间是否值得它们对系统性能的提升。
考虑做消融研究的第二个原因的一个例子可能是,了解像停用词去除、词形还原或词干提取这样的预处理步骤是否对系统的性能有影响。
另一种评估方法涉及一个测试,其中多个独立系统处理相同的数据并进行结果比较。这些就是共享任务。
共享任务
自然语言理解(NLU)领域长期以来受益于在共享任务上进行系统比较,其中不同开发人员开发的系统都在一组特定主题的共享数据上进行测试,结果进行比较。此外,参与共享任务的团队通常会发布系统描述,提供关于他们的系统如何达到这些结果的非常有用的见解。
共享任务范式有两个好处:
-
首先,它使得参与共享任务的开发人员能够精确地获得关于他们的系统与其他系统的比较信息,因为数据是完全相同的。
-
其次,参与共享任务的数据可以提供给研究社区,用于开发未来的系统。
共享数据可以长期使用,因为它不受 NLU 技术变化的影响——例如,旅行规划任务中的航空旅行信息系统(ATIS)数据自 1990 年代初以来一直在使用。
NLP-progress 网站 nlpprogress.com/
是一个关于共享任务和共享任务数据的良好信息来源。
在下一节中,我们将探讨如何将数据划分为子集,以为评估做准备。
数据划分
在前面的章节中,我们将数据集划分为用于训练、验证和测试的子集。
提醒一下,训练数据用于开发**自然语言理解(NLU)**模型,该模型用于执行最终的 NLU 应用任务,无论是分类、槽位填充、意图识别还是其他大多数 NLU 任务。
验证数据(有时称为开发测试数据)在训练过程中用于评估模型在未用于训练的数据上的表现。这非常重要,因为如果系统在训练数据上进行测试,它可能只是通过“记住”训练数据来获得良好的结果。这是误导性的,因为那种系统并不十分有用——我们希望系统能够在部署后面对新的数据时,具有良好的泛化能力或表现。验证数据还可以用于帮助调整机器学习应用中的超参数,但这意味着在开发过程中,系统已经暴露了一些验证数据,因此这些数据不如我们希望的那样具有新颖性。
因此,通常会预留另一组全新的数据进行最终测试;这就是测试数据。为了系统开发的准备,完整的数据集会被划分为训练数据、验证数据和测试数据。通常,大约 80%的数据集用于训练,10%用于验证,10%用于测试。
数据划分的三种常见方式:
-
在某些情况下,数据集已经被划分为训练集、验证集和测试集。这在一些普遍可用的数据集中最为常见,例如我们在本书中使用的数据集或共享任务中的数据集。有时,数据只会被划分为训练集和测试集。如果是这样,应该使用训练数据的一个子集作为验证集。Keras 提供了一个有用的工具
text_dataset_from_directory
,它可以从目录中加载数据集,使用子目录名作为目录中文本的监督类别,并划分出一个验证子集。在以下代码中,训练数据是从aclImdb/train
目录加载的,然后将 20% 的数据划分出来作为验证数据:raw_train_ds = tf.keras.utils.text_dataset_from_directory(
'aclImdb/train',
batch_size=batch_size,
validation_split=0.2,
subset='training',
seed=seed)
-
当然,使用先前的划分只有在你使用的是已经划分过的数据集时才有意义。如果你有自己的数据,你将需要自己进行数据划分。我们使用的一些库具有能够在加载数据时自动进行划分的功能。例如,scikit-learn、TensorFlow 和 Keras 都有可以用于此目的的
train_test_split
函数。 -
你也可以手动编写 Python 代码来划分你的数据集。通常,最好使用来自库的经过测试的代码,因此除非你无法找到合适的库,或者你只是对学习更多关于划分过程的内容感兴趣,否则不推荐编写自己的 Python 代码来完成这个任务。
最终的数据划分策略称为k-折交叉验证。该策略涉及将整个数据集划分为 k 个子集,或称 folds,然后将每一个 fold 作为测试数据来评估系统。在 k-折交叉验证中的整体系统得分是所有测试的平均得分。
这种方法的优点是它减少了测试数据和训练数据之间意外差异的可能性,从而避免了模型在对新测试样本进行预测时效果不佳。由于在 k-折交叉验证中,训练数据和测试数据之间没有严格的划分,因此意外差异更难影响结果;每一折的数据轮流作为测试数据。该方法的缺点是它将测试系统所需的时间乘以 k,如果数据集很大,这个时间可能会变得非常长。
数据划分对于我们将在本章讨论的几乎每个评估指标都是必要的,唯一的例外是用户测试。
在下一节中,我们将讨论一些最常见的具体定量指标。在第九章中,我们介绍了最基本和直观的指标——准确率。在这里,我们将回顾其他通常能比准确率提供更好洞察的指标,并解释如何使用它们。
评估指标
在选择用于自然语言处理(NLP)系统评估的指标时,或者更广泛地说,选择任何我们想评估的系统时,有两个重要的概念需要牢记:
-
有效性:第一个是有效性,意味着该指标对应于我们直观上认为的我们想了解的实际属性。例如,我们不希望选择文本长度作为其积极或消极情感的衡量标准,因为文本长度不能作为其情感的有效度量。
-
可靠性:另一个重要的概念是可靠性,意味着如果我们重复测量相同的事物,我们总能得到相同的结果。
在接下来的章节中,我们将讨论一些在自然语言理解(NLU)中最常用的、被认为既有效又可靠的指标。
准确度和错误率
在第九章中,我们将准确度定义为正确系统响应的数量除以总输入数量。类似地,我们将错误率定义为错误响应除以输入数量。请注意,当你阅读语音识别结果报告时,可能会遇到词错误率。由于词错误率是根据一个不同的公式计算的,该公式考虑了不同类型的常见语音识别错误,因此这是一个不同的指标,我们在此不进行详细讨论。
下一节将详细介绍一些更具体的指标,包括精度、召回率和 F1 分数,这些在第九章中已有简要提及。
这些指标通常比准确度提供更多关于系统处理结果的洞察。
精度、召回率和 F1
准确度在某些情况下可能会给出误导性的结果。例如,我们可能有一个包含 100 个项目的数据集,但类别不平衡,绝大多数情况(比如说 90 个)属于一个类别,我们称之为多数类。这在真实数据集中非常常见。在这种情况下,通过自动将每个案例分配给多数类,可以获得 90%的准确度,但属于另一个类别的剩余 10 个实例(假设我们只有两个类别,简化讨论)将始终被分类错误。从直觉上讲,准确度似乎在这种情况下是不可靠且具有误导性的指标。为了提供更有效的指标,引入了一些改进——最重要的是召回率和精度的概念。
召回率意味着一个系统能找到每一个类别的实例且不漏掉任何一个。在我们的例子中,它正确地找到了所有 90 个多数类的实例,但没有找到其他类别的任何实例。
召回率的公式如下所示,其中真正例是被正确识别的类别实例,假负例是被遗漏的该类别实例。在我们的初始例子中,假设数据集中有 100 个项目,系统正确识别了 90 个(多数类),但遗漏了 10 个属于其他类别的例子。因此,主要类别的召回率为 1.0,但另一个类别的召回率为 0:
recall = 真正例 ________________ 真正例 + 假负例
另一方面,精确率意味着所有被识别的项目都是正确的。完美的精确率意味着没有项目被误识别,尽管可能会遗漏一些项目。精确率的公式如下,其中假正例是被误识别的实例。在这个例子中,假正例是 10 个被错误识别为属于多数类的项目。在我们的例子中,少数类的精确率为 1.0,但对于另一个类别,精确率为 0,因为没有真正例。这个例子中的精确率和召回率得分让我们更详细地了解系统犯了哪些错误:
precision = 真正例 ________________ 真正例 + 假正例
最后,还有另一个重要的指标,F1,它结合了精确率和召回率的得分。这个指标非常有用,因为我们通常希望有一个单一的总体指标来描述系统的性能。这可能是自然语言理解(NLU)中最常用的指标。F1 的公式如下:
F1 = 精确率 × 召回率 ____________ 精确率 + 召回率
图 13.1 以图形方式展示了单类的正确和错误分类。椭圆内的项目被识别为类别 1。椭圆内的圆形标记是真正例——被识别为类别 1且实际属于类别 1的项目。椭圆内的方形标记是假正例——实际属于类别 2但错误地被识别为类别 1的项目。椭圆外的圆形标记是假负例——实际属于类别 1但未被识别的项目:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_13_01.jpg
图 13.1 – 单类分类
F
1 度量假设召回率和精确度的得分同等重要。这在许多情况下是正确的,但并非总是如此。例如,考虑一个目标是检测推文中提及公司产品的应用程序。该系统的开发人员可能认为,不错过任何关于其产品的推文非常重要。在这种情况下,他们可能会强调召回率,即使这会以牺牲精确度为代价,这意味着他们可能会得到许多假阳性——系统将某些推文分类为关于其产品的推文,但实际上并非如此。如果你开发的系统中召回率和精确度并不等同重要,可以使用更通用的F
1 度量版本来加权召回率和精确度。
接收操作特征与曲线下面积
测试项属于哪个类别的决策取决于它在该类别的得分。如果该项的得分超过了一个给定的阈值(由开发人员选择),系统就会判断该项确实属于该类别。我们可以看到,真正阳性和假阳性得分会受到这个阈值的影响。如果我们将阈值设置得非常高,很少有项会进入该类别,真正阳性会减少。另一方面,如果阈值设置得非常低,系统会判断许多项属于该类别,系统会接受许多不属于该类别的项,假阳性将会非常高。我们真正想知道的是,在每个阈值下,系统区分不同类别的能力如何。
视觉化虚假阳性(精确度失败)与虚假阴性(召回率失败)之间权衡的一个好方法是使用接收操作特征(ROC)图及其相关度量——曲线下面积(AUC)。ROC 曲线是衡量系统区分不同类别能力的总体表现。理解这一点的最佳方法是查看一个 ROC 曲线示例,正如我们在图 13**.2中所看到的那样。该图基于随机生成的数据:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_13_02.jpg
图 13.2 – 完美分类器与随机分类器的 ROC 曲线
在图 13**.2中,我们可以看到,在点(0, 1)处,系统没有假阳性,所有的真正阳性都被正确检测。如果我们将接受阈值设为1,系统仍然会接受所有真正阳性,且仍然没有假阳性。另一方面,如果分类器完全无法区分不同类别,我们会得到类似随机分类器的线条。无论我们设定什么阈值,系统仍然会犯很多错误,仿佛它在随机地为输入分配类别。
总结系统区分各类别能力的常见方法是 ROC 曲线下的面积,或 AUC。完美分类器的 AUC 得分为1.0,随机分类器的 AUC 得分大约为0.5,如果分类器的表现比随机分类还差,AUC 得分则会低于0.5。注意,我们这里讨论的是二分类问题,因为它更容易解释,但也有技术可以将这些概念应用于多分类问题。我们在此不深入讨论,但有关如何查看多分类数据集 ROC 曲线的技术,可以参考 scikit-learn 文档,地址为scikit-learn.org/stable/auto_examples/model_selection/plot_roc.html
。
混淆矩阵
另一个重要的评估工具是混淆矩阵,它显示每个类别与其他类别混淆的频率。通过一个示例,这个概念会更加清晰,因此我们会推迟讨论混淆矩阵,直到本章最后的示例部分。
用户测试
除了直接的系统度量,用户测试也可以用来评估系统,在这种测试中,代表系统预期用户的测试用户与系统进行交互。
用户测试是一种耗时且昂贵的测试方式,但有时它是唯一能揭示系统性能定性方面的方法——例如,用户在使用系统时完成任务的难易程度,或他们对使用系统的享受程度。显然,用户测试只能在用户能够感知的系统方面进行,比如对话,用户只应被期望评估系统整体表现——也就是说,不能指望用户可靠地区分语音识别和自然语言理解(NLU)组件的表现。
与用户进行有效且可靠的评估实际上是一项心理学实验。这是一个复杂的话题,容易犯错误,导致无法从结果中得出结论。因此,提供完整的用户测试指南超出了本书的范围。不过,你可以通过让一些用户与系统互动并测量他们的体验来进行一些探索性的用户测试。你可以在用户测试中收集的简单测量包括以下内容:
-
让用户填写一个简单的问卷,评估他们的体验。
-
测量用户与系统互动的时间。用户与系统互动的时间可以是正面或负面的度量,具体取决于系统的目的,例如:
-
如果你正在开发一个旨在带来乐趣的社交系统,你会希望用户花更多时间与系统互动。
-
另一方面,如果你正在开发一个任务导向的系统,旨在帮助用户完成某项任务,你通常希望用户与系统的互动时间越少越好。
-
然而,你在进行用户测试时也必须谨慎:
-
用户必须具有代表性,能够代表实际的目标用户群体。如果用户不具代表性,结果可能会非常具有误导性。例如,一个面向客户支持的公司聊天机器人应该在客户身上进行测试,而不是员工,因为员工对公司了解得比客户多,他们提问的问题也会不同。
-
让问卷简洁明了,不要要求用户提供与您要了解的内容无关的信息。如果用户对问卷感到厌烦或不耐烦,他们提供的信息将不具任何参考价值。
-
如果用户测试的结果对项目至关重要,你应该找到一位人因工程师——即有经验设计人类用户实验的人——来设计测试过程,以确保结果有效且可靠。
到目前为止,我们已经探讨了几种不同的衡量系统性能的方法。应用这些技术会得到一个或多个数值,或称为度量指标,用以量化系统的表现。有时,当我们用这些度量指标来比较多个系统,或同一个系统的不同版本时,我们会发现这些度量指标的差异很小。这时,值得问一下,差异是否真的具有意义。这个问题是通过统计显著性来解决的,我们将在下一节进行讲解。
差异的统计显著性
我们在评估中要讨论的最后一个主题是,如何判断我们所做实验的结果之间的差异是否反映了实验条件之间的实际差异,或者这些差异是否由于偶然因素所致。这就是所谓的统计显著性。度量指标值之间的差异是否代表系统之间的实际差异,是我们无法确定的,但我们可以知道的是,这种我们关心的差异是否可能是偶然造成的。假设我们的数据情形如图 13.3所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_13_03.jpg
图 13.3 – 测量值的两个分布——它们是否反映了被测物体之间的实际差异?
图 13.3 显示了两组测量结果,一组平均值为0,在左边,另一组平均值为0.75,在右边。在这里,我们可能在比较两个分类算法在不同数据集上的表现。例如,看起来两个算法之间确实存在差异,但这种差异可能是偶然的?我们通常认为,如果这种差异偶然发生的概率是 20 分之一,那么这个差异就被认为是统计显著的,也就是不太可能是偶然的。当然,这也意味着每 20 个统计显著的结果中,可能有 1 个实际上是偶然发生的。这种概率是通过标准的统计公式来计算的,比如t-统计量或方差分析。
如果你阅读了一篇对其结果进行显著性检验的论文,并且它指出类似p<.05的内容,p表示这个差异是由于偶然发生的概率。这样的统计分析通常用于数据中,其中确定差异是否具有统计显著性非常重要,比如会议上展示的学术论文或用于发表在学术期刊中的论文。我们在这里不讨论统计显著性是如何计算的,因为这个过程可能会变得相当复杂,但如果你需要使用它,你应该知道它的含义。
还需要考虑一个问题,那就是结果可能在统计学上显著,但在现实情况下可能并不具有实际意义。如果一种分类算法的结果仅比另一种稍微好一些,但更好的算法计算起来要复杂得多,那么可能不值得去使用那个更好的算法。这些权衡必须从算法的实际应用角度进行考虑。即便是一个小的,但显著的差异,对于学术论文可能非常重要,但对于已部署的应用程序则可能完全无关紧要。
现在我们已经回顾了几种评估系统的方法,让我们看看如何在实际中应用它们。在接下来的部分中,我们将通过一个案例研究,比较三种不同的文本分类方法在相同数据上的表现。
比较三种文本分类方法
我们使用评估技术可以做的最有用的事情之一是决定在应用程序中使用哪种方法。像词频-逆文档频率(TF-IDF)、支持向量机(SVMs)和条件随机场(CRFs)这样的传统方法是否足够满足我们的任务,还是必须使用深度学习和变换器方法,这些方法虽然训练时间更长,但效果更好?
在本节中,我们将比较三种方法在一个更大的电影评论数据集上的表现,该数据集我们在第九章中讨论过。我们将比较使用小型 BERT 模型、TF-IDF 向量化与朴素贝叶斯分类以及较大的 BERT 模型。
小型变压器系统
我们将首先查看我们在第十一章中开发的 BERT 系统。我们将使用与第十一章相同的 BERT 模型,即small_bert/bert_en_uncased_L-2_H-128_A-2
,它是最小的 BERT 模型之一,具有两层、隐藏层大小为128
,并且有两个注意力头。
我们将进行一些调整,以便更好地评估该模型的表现。
首先,我们将添加新的指标,精确度和召回率,作为我们在第十一章中使用的BinaryAccuracy
指标的补充:
metrics = [tf.metrics.Precision(),tf.metrics.Recall(),tf.metrics.BinaryAccuracy()]
通过这些指标,history
对象将包括在 10 个训练周期内精确度和召回率的变化。我们可以通过以下代码查看这些变化:
history_dict = history.history
print(history_dict.keys())
acc = history_dict['binary_accuracy']
precision = history_dict['precision_1']
val_acc = history_dict['val_binary_accuracy']
loss = history_dict['loss']
val_recall = history_dict['val_recall_1']
recall = history_dict['recall_1']
val_loss = history_dict['val_loss']
val_precision = history_dict['val_precision_1']
precision = history_dict['precision_1']
val_recall = history_dict['val_recall_1']
如前面的代码片段所示,第一步是从history
对象中提取我们感兴趣的结果。接下来的步骤是绘制随着训练周期变化的结果,这将在以下代码中计算:
epochs = range(1, len(acc) + 1)
fig = plt.figure(figsize=(10, 6))
fig.tight_layout()
plt.subplot(4, 1, 1)
# r is for "solid red line"
plt.plot(epochs, loss, 'r', label='Training loss')
# b is for "solid blue line"
plt.plot(epochs, val_loss, 'b', label='Validation loss')
plt.title('Training and validation loss')
# plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
上面的代码展示了训练和验证损失的绘图过程,生成了图 13.4中的顶部图表。图 13.4中的其余图表和图 13.5中的图表是以相同的方式计算的,但由于它们与第一个图表的代码几乎相同,我们这里不再展示完整代码。
图 13.4展示了训练周期内损失的下降和准确率的上升,正如我们之前看到的。我们可以看到,训练数据和验证数据的损失值在0.40到0.45之间趋于平稳。在第7个周期时,看起来额外的训练可能不会改善性能:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_13_04.jpg
图 13.4 – 在 10 个训练周期内的损失和准确率
由于我们添加了精确度和召回率指标,我们还可以看到这些指标在大约0.8的水平上趋于平稳,在大约7个训练周期时达到平衡,如图 13.5所示:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_13_05.jpg
图 13.5 – 在 10 个训练周期内的精确度和召回率
我们还可以查看混淆矩阵和分类报告,以获得更多信息,使用以下代码,调用来自 scikit-learn 的函数:
# Displaying the confusion matrix
%matplotlib inline
from sklearn.metrics import confusion_matrix,ConfusionMatrixDisplay,f1_score,classification_report
import matplotlib.pyplot as plt
plt.rcParams.update({'font.size': 12})
disp = ConfusionMatrixDisplay(confusion_matrix = conf_matrix,
display_labels = class_names)
print(class_names)
disp.plot(xticks_rotation=75,cmap=plt.cm.Blues)
plt.show()
这将绘制出如图 13.6所示的混淆矩阵:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_13_06.jpg
图 13.6 – 小型 BERT 模型的混淆矩阵
图 13.6 中的深色单元格显示了正确分类的数量,实际的负面和正面评论被分配到了正确的类别。我们可以看到,有相当多的错误分类。通过打印以下代码的汇总分类报告,我们可以看到更详细的信息:
print(classification_report(y_test, y_pred, target_names=class_names))
['neg', 'pos']
分类报告显示了两个类别的 召回率
、精确度
和 F1
分数:
precision recall f1-score support
neg 0.82 0.80 0.81 12501
pos 0.80 0.82 0.81 12500
accuracy 0.81 25001
macro avg 0.81 0.81 0.81 25001
weighted avg 0.81 0.81 0.81 25001
从该报告中,我们可以看到系统在识别正面和负面评论方面几乎同样优秀。系统正确分类了许多评论,但仍然犯了很多错误。
现在,我们将比较 BERT 测试与我们之前的一个测试,该测试基于 TF-IDF 向量化和朴素贝叶斯分类。
TF-IDF 评估
在 第九章 中,我们学习了如何使用 TF-IDF 向量化并进行朴素贝叶斯分类。我们通过电影评论语料库展示了这一过程。我们想将这些非常传统的技术与新型的基于变换器的 LLM(如 BERT)进行比较。变换器相比传统方法到底有多好?这些变换器是否值得其更大的体积和更长的训练时间?为了公平地比较 BERT 和 TF-IDF/朴素贝叶斯,我们将使用我们在 第十一章 中使用的较大 aclimdb
电影评论数据集。
我们将使用在 第九章 中使用的相同代码来设置系统并执行 TF-IDF/朴素贝叶斯分类,因此这里不再重复。我们将仅添加最终的代码以显示混淆矩阵并以图形方式展示结果:
# View the results as a confusion matrix
from sklearn.metrics import confusion_matrix
conf_matrix = confusion_matrix(labels_test, labels_pred,normalize=None)
print(conf_matrix)
[[9330 3171]
[3444 9056]]
混淆矩阵的文本版本是数组:
[[``9330 3171]
[``3444 9056]]
然而,这并不容易理解。我们可以通过使用 matplotlib
在以下代码中更清晰地显示它:
# Displaying the confusion matrix
from sklearn.metrics import confusion_matrix,ConfusionMatrixDisplay,f1_score,classification_report
import matplotlib.pyplot as plt
plt.rcParams.update({'font.size': 12})
disp = ConfusionMatrixDisplay(confusion_matrix = conf_matrix, display_labels = class_names)
print(class_names)
disp.plot(xticks_rotation=75,cmap=plt.cm.Blues)
plt.show()
结果显示在 图 13.7 中:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_13_07.jpg
图 13.7 – TF-IDF/朴素贝叶斯分类的混淆矩阵
图 13.7 中的深色单元格显示了正确分类的数量,实际的负面和正面评论被分配到了正确的类别。我们还可以看到,3171 个实际的负面评论被误分类为正面,3444 个实际的正面评论被误分类为负面。
要查看召回率、精确度和 F1 分数,我们可以打印分类报告:
print(classification_report(labels_test, labels_pred, target_names=class_names))
结果分类报告显示了召回率、精确度和 F1 分数,以及准确率、每个类别的项数(支持度
)和其他统计数据:
precision recall f1-score support
neg 0.73 0.75 0.74 12501
pos 0.74 0.72 0.73 12500
accuracy 0.74 25001
macro avg 0.74 0.74 0.74 25001
weighted avg 0.74 0.74 0.74 25001
从这个报告中,我们可以看到该系统在识别负面评论方面稍微有些优势。系统正确分类了许多评论,但仍然存在很多错误。将这些结果与 BERT 系统在图 13.6中的结果进行比较,我们可以看到 BERT 的结果要好得多。BERT 的 F1 得分为0.81,而 TF-IDF/朴素贝叶斯的 F1 得分为0.74。在这项任务中,BERT 系统是更好的选择,但我们能做得更好吗?
在下一节中,我们将提出另一个比较两个系统的问题。这个问题涉及到其他 BERT 变换器模型可能发生的情况。较大的模型几乎总是比较小的模型具有更好的性能,但训练时间会更长。它们在性能方面究竟会好多少呢?
更大的 BERT 模型
到目前为止,我们已经将一个非常小的 BERT 模型与基于 TF-IDF 和朴素贝叶斯的系统进行了比较。通过对比这两个系统的分类报告,我们可以看到 BERT 系统显然比 TF-IDF/朴素贝叶斯系统更好。在这项任务中,BERT 在正确分类方面更具优势。另一方面,BERT 的训练速度要慢得多。
我们能通过另一个变换器系统(比如 BERT 的另一种变体)获得更好的性能吗?我们可以通过将我们的系统结果与其他 BERT 变体的结果进行比较来找出答案。由于 BERT 系统包含更多不同大小和复杂度的模型,因此这很容易实现,这些模型可以在tfhub.dev/google/collections/bert/1
找到。
让我们将另一个模型与我们刚才测试过的 BERT 系统进行比较。我们测试的系统是一个较小的 BERT 系统,small_bert/bert_en_uncased_L-2_H-128_A-2
。该名称编码了它的重要特性——两层、隐藏层大小为128
,并且有两个注意力头。我们可能想知道使用更大的模型会有什么结果。让我们试试一个较大的模型,small_bert/bert_en_uncased_L-4_H-512_A-8
,这个模型仍然不至于太大,能够在 CPU 上训练(而不是 GPU)。这个模型仍然相当小,有四层,隐藏层大小为512
,并且有八个注意力头。尝试不同的模型非常简单,只需对设置 BERT 模型以供使用的代码进行一些小的修改,包含新模型的信息即可:
bert_model_name = 'small_bert/bert_en_uncased_L-4_H-512_A-8'
map_name_to_handle = {
'small_bert/bert_en_uncased_L-4_H-512_A-8' :
'https://tfhub.dev/tensorflow/small_bert/bert_en_uncased_L-4_H-512_A-8/1',
}
map_model_to_preprocess = {
'small_bert/bert_en_uncased_L-4_H-512_A-8':
'https://tfhub.dev/tensorflow/bert_en_uncased_preprocess/3',
}
如代码所示,我们所需要做的就是选择模型的名称,将其映射到存放它的 URL,并将其分配给预处理器。其余的代码将保持不变。图 13.8显示了更大的 BERT 模型的混淆矩阵:
https://github.com/OpenDocCN/freelearn-dl-pt5-zh/raw/master/docs/nlu-py/img/B19005_13_08.jpg
图 13.8 – 更大 BERT 模型的混淆矩阵
如混淆矩阵所示,该模型的性能优于较小的 BERT 模型以及 TF-IDF/朴素贝叶斯模型。分类报告显示在下面的代码片段中,并且也表明该模型在我们本节中查看的三种模型中表现最佳,平均F1
得分为0.85
,相比之下,较小的 BERT 模型为0.81
,TF-IDF/朴素贝叶斯模型为0.74
。
precision recall f1-score support
neg 0.86 0.85 0.85 12501
pos 0.85 0.86 0.86 12500
accuracy 0.85 25001
macro avg 0.85 0.85 0.85 25001
weighted avg 0.85 0.85 0.85 25001
该模型在aclimdb
数据集(包含 25,000 条数据)上的训练时间约为 8 小时,使用的是标准 CPU,这对于大多数应用程序来说可能是可以接受的。这个性能是否足够好?我们应该探索更大的模型吗?显然,仍然有很大的改进空间,我们可以尝试更多其他 BERT 模型。是否接受这些结果由应用程序的开发者决定,取决于获取正确答案和避免错误答案的优先级。每个应用程序的情况可能不同。我们鼓励你尝试一些更大的模型,考虑提高的性能是否值得额外的训练时间。
总结
在本章中,你了解了与评估 NLU 系统相关的一些重要主题。你学习了如何将数据分成不同的子集用于训练和测试,并学习了最常用的 NLU 性能评估指标——准确率、精确率、召回率、F1 值、AUC 和混淆矩阵——以及如何使用这些指标来比较系统。你还学习了相关主题,如通过消融实验进行系统比较、共享任务评估、统计显著性测试和用户测试。
下一章将开始本书的第三部分,我们将在这里讨论系统实践——大规模应用 NLU。我们将从第三部分开始,探讨如果系统无法正常工作该怎么办。如果原始模型不够完善,或者系统模型处理的实际情况发生了变化,那么需要做哪些调整?本章讨论了添加新数据和更改应用程序结构等话题。
第三部分:系统实践——大规模应用自然语言理解
在第三部分,你将学习如何将自然语言理解应用于运行中的应用程序。本部分将涉及将新数据添加到现有应用程序中、处理易变的应用程序、添加和删除类别,并包括总结本书内容以及展望自然语言理解未来的最后一章。
我们专注于将 NLU 系统从实验室带到实际应用中,使其能够解决实际问题。
本部分包括以下几章:
-
第十四章,系统无法正常工作时该怎么办
-
第十五章,总结与未来展望