真实世界的自然语言处理(一)(3)https://developer.aliyun.com/article/1519727
3.7 文档级嵌入
到目前为止,我描述的所有模型都是为单词学习嵌入。如果您只关注词级任务,比如推断词之间的关系,或者将它们与更强大的神经网络模型(如循环神经网络(RNN))结合使用,它们可以是非常有用的工具。然而,如果您希望使用词嵌入和传统机器学习工具(如逻辑回归和支持向量机(SVM))解决与更大语言结构(如句子和文档)相关的 NLP 任务,词级嵌入方法仍然是有限的。您如何用向量表示来表示更大的语言单元,比如句子?您如何使用词嵌入进行情感分析,例如?
一个实现这一目标的方法是简单地使用句子中所有词向量的平均值。您可以通过取第一个元素、第二个元素的平均值等等,然后通过组合这些平均数生成一个新的向量。您可以将这个新向量作为传统机器学习模型的输入。尽管这种方法简单且有效,但它也有很大的局限性。最大的问题是它不能考虑词序。例如,如果您仅仅对句子中的每个单词向量取平均值,那么句子“Mary loves John.”和“John loves Mary.”的向量将完全相同。
NLP 研究人员提出了可以专门解决这个问题的模型和算法。其中最流行的之一是Doc2Vec,最初由 Le 和 Mikolov 在 2014 年提出(cs.stanford.edu/~quocle/paragraph_vector.pdf
)。这个模型,正如其名称所示,学习文档的向量表示。事实上,“文档”在这里只是指任何包含多个单词的可变长度文本。类似的模型还被称为许多类似的名称,比如句子 2Vec、段落 2Vec、段落向量(这是原始论文的作者所用的),但本质上,它们都指的是相同模型的变体。
在本节的其余部分,我将讨论一种称为段落向量分布记忆模型(PV-DM)的 Doc2Vec 模型之一。该模型与我们在本章前面学习的 CBOW 非常相似,但有一个关键的区别——多了一个向量,称为段落向量,作为输入。该模型从一组上下文单词和段落向量预测目标词。每个段落都被分配一个不同的段落向量。图 3.11 展示了 PV-DM 模型的结构。另外,PV-DM 仅使用在目标词之前出现的上下文单词进行预测,但这只是一个微小的差异。
图 3.11 段落向量分布记忆模型
这段向量会对预测任务有什么影响?现在您从段落向量中获得了一些额外信息来预测目标单词。由于模型试图最大化预测准确性,您可以预期段落向量会更新,以便它提供一些在句子中有用的“上下文”信息,这些信息不能被上下文词向量共同捕获。作为副产品,模型学会了反映每个段落的整体含义,以及词向量。
几个开源库和包支持 Doc2Vec 模型,但其中一个最广泛使用的是 Gensim(radimrehurek.com/gensim/
),可以通过运行 pip install gensim 来安装。Gensim 是一个流行的自然语言处理工具包,支持广泛的向量和主题模型,例如 TF-IDF(词频和逆文档频率)、LDA(潜在语义分析)和词嵌入。
要使用 Gensim 训练 Doc2Vec 模型,您首先需要读取数据集并将文档转换为 TaggedDocument。可以使用此处显示的 read_corpus() 方法来完成:
from gensim.utils import simple_preprocess from gensim.models.doc2vec import TaggedDocument def read_corpus(file_path): with open(file_path) as f: for i, line in enumerate(f): yield TaggedDocument(simple_preprocess(line), [i])
我们将使用一个小数据集,其中包含来自 Tatoeba 项目(tatoeba.org/
)的前 200,000 个英文句子。您可以从 mng.bz/7l0y
下载数据集。然后,您可以使用 Gensim 的 Doc2Vec 类来训练 Doc2Vec 模型,并根据训练的段落向量检索相似的文档,如下所示。
列表 3.6 训练 Doc2Vec 模型并检索相似文档
from gensim.models.doc2vec import Doc2Vec train_set = list(read_corpus('data/mt/sentences.eng.200k.txt')) model = Doc2Vec(vector_size=256, min_count=3, epochs=30) model.build_vocab(train_set) model.train(train_set, total_examples=model.corpus_count, epochs=model.epochs) query_vec = model.infer_vector( ['i', 'heard', 'a', 'dog', 'barking', 'in', 'the', 'distance']) sims = model.docvecs.most_similar([query_vec], topn=10) for doc_id, sim in sims: print('{:3.2f} {}'.format(sim, train_set[doc_id].words))
这将显示与输入文档“I heard a dog barking in the distance.”相似的文档列表,如下所示:
0.67 ['she', 'was', 'heard', 'playing', 'the', 'violin'] 0.65 ['heard', 'the', 'front', 'door', 'slam'] 0.61 ['we', 'heard', 'tigers', 'roaring', 'in', 'the', 'distance'] 0.61 ['heard', 'dog', 'barking', 'in', 'the', 'distance'] 0.60 ['heard', 'the', 'door', 'open'] 0.60 ['tom', 'heard', 'the', 'door', 'open'] 0.60 ['she', 'heard', 'dog', 'barking', 'in', 'the', 'distance'] 0.59 ['heard', 'the', 'door', 'close'] 0.59 ['when', 'he', 'heard', 'the', 'whistle', 'he', 'crossed', 'the', 'street'] 0.58 ['heard', 'the', 'telephone', 'ringing']
注意这里检索到的大多数句子与听到声音有关。事实上,列表中有一个相同的句子,因为我一开始就从 Tatoeba 中获取了查询句子!Gensim 的 Doc2Vec 类有许多超参数,您可以使用它们来调整模型。您可以在他们的参考页面上进一步了解该类(radimrehurek.com/gensim/models/doc2vec.html
)。
3.8 可视化嵌入
在本章的最后一节中,我们将把重点转移到可视化词嵌入上。正如我们之前所做的,给定一个查询词检索相似的词是一个快速检查词嵌入是否正确训练的好方法。但是,如果您需要检查多个词以查看词嵌入是否捕获了单词之间的语义关系,这将变得令人疲倦和耗时。
如前所述,词嵌入简单地是 N 维向量,也是 N 维空间中的“点”。我们之所以能够在图 3.1 中以 3-D 空间可视化这些点,是因为 N 是 3。但是在大多数词嵌入中,N 通常是一百多,我们不能简单地将它们绘制在 N 维空间中。
一个解决方案是将维度降低到我们可以看到的东西(二维或三维),同时保持点之间的相对距离。这种技术称为降维。我们有许多降低维度的方法,包括 PCA(主成分分析)和 ICA(独立成分分析),但迄今为止,用于单词嵌入的最广泛使用的可视化技术是称为t-SNE(t-分布随机近邻嵌入,发音为“tee-snee”)的方法。虽然 t-SNE 的细节超出了本书的范围,但该算法试图通过保持原始高维空间中点之间的相对邻近关系来将点映射到较低维度的空间。
使用 t-SNE 的最简单方法是使用 Scikit-Learn (scikit-learn.org/
),这是一个流行的用于机器学习的 Python 库。安装后(通常只需运行 pip install scikit-learn),您可以像下面展示的那样使用它来可视化从文件中读取的 GloVe 向量(我们使用 Matplotlib 来绘制图表)。
清单 3.7 使用 t-SNE 来可视化 GloVe 嵌入
from sklearn.manifold import TSNE import matplotlib.pyplot as plt def read_glove(file_path): with open(file_path) as f: for i, line in enumerate(f): fields = line.rstrip().split(' ') vec = [float(x) for x in fields[1:]] word = fields[0] yield (word, vec) words = [] vectors = [] for word, vec in read_glove('data/glove/glove.42B.300d.txt'): words.append(word) vectors.append(vec) model = TSNE(n_components=2, init='pca', random_state=0) coordinates = model.fit_transform(vectors) plt.figure(figsize=(8, 8)) for word, xy in zip(words, coordinates): plt.scatter(xy[0], xy[1]) plt.annotate(word, xy=(xy[0], xy[1]), xytext=(2, 2), textcoords='offset points') plt.xlim(25, 55) plt.ylim(-15, 15) plt.show()
在清单 3.7 中,我使用 xlim() 和 ylim() 将绘制范围限制在我们感兴趣的一些区域,以放大一些区域。您可能想尝试不同的值来聚焦绘图中的其他区域。
清单 3.7 中的代码生成了图 3.12 中显示的图。这里有很多有趣的东西,但快速浏览时,您会注意到以下词语聚类,它们在语义上相关:
- 底部左侧:与网络相关的词语(posts,article,blog,comments,. . . )。
- 上方左侧:与时间相关的词语(day,week,month,year,. . . )。
- 中间:数字(0,1,2,. . . )。令人惊讶的是,这些数字向底部递增排序。GloVe 仅从大量的文本数据中找出了哪些数字较大。
- 底部右侧:月份(january,february,. . . )和年份(2004,2005,. . . )。同样,年份似乎按照递增顺序排列,几乎与数字(0,1,2,. . . )平行。
图 3.12 由 t-SNE 可视化的 GloVe 嵌入
如果您仔细思考一下,一个纯粹的数学模型能够从大量的文本数据中找出这些词语之间的关系,这实在是一项不可思议的成就。希望现在您知道,如果模型知道“july”和“june”之间的关系密切相连,与从 word_823 和 word_1719 开始逐一解释所有内容相比,这有多么有利。
总结
- 单词嵌入是单词的数字表示,它们有助于将离散单位(单词和句子)转换为连续的数学对象(向量)。
- Skip-gram 模型使用具有线性层和 softmax 的神经网络来学习单词嵌入,作为“假”词语关联任务的副产品。
- GloVe 利用单词共现的全局统计信息有效地训练单词嵌入。
- Doc2Vec 和 fastText 分别用于学习文档级别的嵌入和带有子词信息的词嵌入。
- 你可以使用 t-SNE 来可视化词嵌入。
第四章:句子分类
本章内容包括
- 利用循环神经网络(RNN)处理长度可变的输入
- 处理 RNN 及其变体(LSTM 和 GRU)
- 使用常见的分类问题评估指标
- 使用 AllenNLP 开发和配置训练管道
- 将语言识别器构建为句子分类任务
在本章中,我们将研究句子分类任务,其中 NLP 模型接收一个句子并为其分配一些标签。垃圾邮件过滤器是句子分类的一个应用。它接收一封电子邮件并指定它是否为垃圾邮件。如果你想将新闻文章分类到不同的主题(商业、政治、体育等),也是一个句子分类任务。句子分类是最简单的 NLP 任务之一,具有广泛的应用范围,包括文档分类、垃圾邮件过滤和情感分析。具体而言,我们将重新审视第二章中介绍的情感分类器,并详细讨论其组成部分。在本节结束时,我们将研究句子分类的另一个应用——语言检测。
4.1 循环神经网络(RNN)
句子分类的第一步是使用神经网络(RNN)表示长度可变的句子。在本节中,我将介绍循环神经网络的概念,这是深度 NLP 中最重要的概念之一。许多现代 NLP 模型以某种方式使用 RNN。我将解释它们为什么很重要,它们的作用是什么,并介绍它们的最简单变体。
4.1.1 处理长度可变的输入
上一章中展示的 Skip-gram 网络结构很简单。它接受一个固定大小的词向量,通过线性层运行它,得到所有上下文词之间的分数分布。网络的结构和大小以及输入输出都在训练期间固定。
然而,自然语言处理(NLP)中面临的许多,如果不是大多数情况下,都是长度可变的序列。例如,单词是字符序列,可以短(“a”,“in”)也可以长(“internationalization”)。句子(单词序列)和文档(句子序列)可以是任何长度。即使是字符,如果将它们看作笔画序列,则可以是简单的(如英语中的“O”和“L”)或更复杂的(例如,“鬱”是一个包含 29 个笔画,并表示“抑郁”的中文汉字)。
正如我们在上一章中讨论的那样,神经网络只能处理数字和算术运算。这就是为什么我们需要通过嵌入将单词和文档转换为数字的原因。我们使用线性层将一个固定长度的向量转换为另一个向量。但是,为了处理长度可变的输入,我们需要找到一种处理方法,使得神经网络可以对其进行处理。
一个想法是首先将输入(例如,一系列单词)转换为嵌入,即一系列浮点数向量,然后将它们平均。假设输入句子为 sentence = [“john”, “loves”, “mary”, “.”],并且你已经知道句子中每个单词的单词嵌入 v(“john”)、v(“loves”)等。平均值可以用以下代码获得,并在图 4.1 中说明:
result = (v("john") + v("loves") + v("mary") + v(".")) /
图 4.1 平均嵌入向量
这种方法相当简单,实际上在许多自然语言处理应用中都有使用。然而,它有一个关键问题,即它无法考虑词序。因为输入元素的顺序不影响平均结果,你会得到“Mary loves John”和“John loves Mary”两者相同的向量。尽管它能胜任手头的任务,但很难想象有多少自然语言处理应用会希望这种行为。
如果我们退后一步,思考一下我们人类如何阅读语言,这种“平均”与现实相去甚远。当我们阅读一句话时,我们通常不会孤立地阅读单个单词并首先记住它们,然后再弄清楚句子的含义。我们通常从头开始扫描句子,逐个单词地阅读,同时将“部分”句子在我们的短期记忆中的含义保持住直到你正在阅读的部分。换句话说,你在阅读时保持了一种对句子的心理表征。当你达到句子的末尾时,这种心理表征就是它的含义。
我们是否可以设计一个神经网络结构来模拟这种对输入的逐步阅读?答案是肯定的。这种结构被称为循环神经网络(RNNs),我将在接下来详细解释。
4.1.2 RNN 抽象
如果你分解前面提到的阅读过程,其核心是以下一系列操作的重复:
- 阅读一个词。
- 根据迄今为止所阅读的内容(你的“心理状态”),弄清楚这个词的含义。
- 更新心理状态。
- 继续下一个词。
让我们通过一个具体的例子来看看这是如何工作的。如果输入句子是 sentence = [“john”, “loves”, “mary”, “.”],并且每个单词已经表示为单词嵌入向量。另外,让我们将你的“心理状态”表示为 state,它由 init_state()初始化。然后,阅读过程由以下递增操作表示:
state = init_state() state = update(state, v("john")) state = update(state, v("loves")) state = update(state, v("mary")) state = update(state, v("."))
state 的最终值成为此过程中整个句子的表示。请注意,如果你改变这些单词处理的顺序(例如,交换“John”和“Mary”),state 的最终值也会改变,这意味着 state 也编码了一些有关词序的信息。
如果你可以设计一个网络子结构,可以在更新一些内部状态的同时应用于输入的每个元素,那么你可以实现类似的功能。RNNs 就是完全这样做的神经网络结构。简而言之,RNN 是一个带有循环的神经网络。其核心是在输入中的每个元素上应用的操作。如果你用 Python 伪代码来表示 RNN 做了什么,就会像下面这样:
def rnn(words): state = init_state() for word in words: state = update(state, word) return state
注意这里有一个被初始化并在迭代过程中传递的状态。对于每个输入单词,状态会根据前一个状态和输入使用update
函数进行更新。对应于这个步骤(循环内的代码块)的网络子结构被称为单元。当输入用尽时,这个过程停止,状态的最终值成为该 RNN 的结果。见图 4.2 进行说明。
图 4.2 RNN 抽象
在这里你可以看到并行性。当你阅读一个句子(一串单词)时,每读一个单词后你内部心理对句子的表示,即状态,会随之更新。可以假设最终状态编码了整个句子的表示。
唯一剩下的工作是设计两个函数——init_state()
和 update()
。通常,状态初始化为零(即一个填满零的向量),所以你通常不用担心如何定义前者。更重要的是如何设计 update()
,它决定了 RNN 的特性。
4.1.3 简单 RNNs 和非线性
在第 3.4.3 节中,我解释了如何使用任意数量的输入和输出来实现一个线性层。我们是否可以做类似的事情,并实现update()
,它基本上是一个接受两个输入变量并产生一个输出变量的函数呢?毕竟,一个单元是一个有自己输入和输出的神经网络,对吧?答案是肯定的,它看起来像这样:
def update_simple(state, word): return f(w1 * state + w2 * word + b)
注意这与第 3.4.3 节中的 linear2()
函数非常相似。实际上,如果忽略变量名称的差异,除了 f()
函数之外,它们是完全一样的。由此类型的更新函数定义的 RNN 被称为简单 RNN或Elman RNN,正如其名称所示,它是最简单的 RNN 结构之一。
你可能会想,这里的 f()
函数是做什么的?它是什么样的?我们是否需要它?这个函数被称为激活函数或非线性函数,它接受一个输入(或一个向量)并以非线性方式转换它(或转换向量的每个元素)。存在许多种非线性函数,它们在使神经网络真正强大方面起着不可或缺的作用。它们确切地做什么以及为什么它们重要需要一些数学知识来理解,这超出了本书的范围,但我将尝试用一个简单的例子进行直观解释。
想象一下你正在构建一个识别“语法正确”的英语句子的 RNN。区分语法正确的句子和不正确的句子本身就是一个困难的自然语言处理问题,实际上是一个成熟的研究领域(参见第 1.2.1 节),但在这里,让我们简化它,并考虑主语和动词之间的一致性。让我们进一步简化,并假设这个“语言”中只有四个词——“I”,“you”,“am” 和 “are”。如果句子是“I am” 或 “you are”,那么它就是语法正确的。另外两种组合,“I are” 和 “you am”,是不正确的。你想要构建的是一个 RNN,对于正确的句子输出 1,对于不正确的句子输出 0。你会如何构建这样一个神经网络?
几乎每个现代 NLP 模型的第一步都是用嵌入来表示单词。如前一章所述,它们通常是从大型自然语言文本数据集中学习到的,但在这里,我们只是给它们一些预定义的值,如图 4.3 所示。
图 4.3 使用 RNN 识别语法正确的英语句子
现在,让我们假设没有激活函数。前面的 update_simple() 函数简化为以下形式:
def update_simple_linear(state, word): return w1 * state + w2 * word + b
我们将假设状态的初始值简单地为 [0, 0],因为具体的初始值与此处的讨论无关。RNN 接受第一个单词嵌入 x1,更新状态,接受第二个单词嵌入 x2,然后生成最终状态,即一个二维向量。最后,将这个向量中的两个元素相加并转换为 result。如果 result 接近于 1,则句子是语法正确的。否则,不是。如果你应用 update_simple_linear() 函数两次并稍微简化一下,你会得到以下函数,这就是这个 RNN 的全部功能:
w1 * w2 * x1 + w2 * x2 + w1 * b + b
请记住,w1、w2 和 b 是模型的参数(也称为“魔法常数”),需要进行训练(调整)。在这里,我们不是使用训练数据集调整这些参数,而是将一些任意值赋给它们,然后看看会发生什么。例如,当 w1 = [1, 0],w2 = [0, 1],b = [0, 0] 时,这个 RNN 的输入和输出如图 4.4 所示。
图 4.4 当 w1 = [1, 0],w2 = [0, 1],b = [0, 0] 且没有激活函数时的输入和输出
如果你查看结果的值,这个 RNN 将不合语法的句子(例如,“I are”)与合语法的句子(例如,“you are”)分组在一起,这不是我们期望的行为。那么,我们尝试另一组参数值如何?让我们使用 w1 = [1, 0],w2 = [-1, 0],b = [0, 0],看看会发生什么(参见图 4.5 的结果)。
图 4.5 当 w1 = [1, 0],w2 = [-1, 0],b = [0, 0] 且没有激活函数时的输入和输出
这好多了,因为 RNN 成功地通过将 “I are” 和 “you am” 都赋值为 0 来将不符合语法的句子分组。然而,它也给语法正确的句子(“I am” 和 “you are”)赋予了完全相反的值(2 和 -2)。
我要在这里停下来,但事实证明,无论你如何努力,都不能使用这个神经网络区分语法正确的句子和不正确的句子。尽管你给参数分配了值,但这个 RNN 无法产生足够接近期望值的结果,因此无法根据它们的语法性将句子分组。
让我们退一步思考为什么会出现这种情况。如果你看一下之前的更新函数,它所做的就是将输入乘以一些值然后相加。更具体地说,它只是以线性方式转换输入。当你改变输入的值时,这个神经网络的结果总是会以某个恒定的量变化。但显然这是不可取的——你希望结果只在输入变量是某些特定值时才为 1。换句话说,你不希望这个 RNN 是线性的;你希望它是非线性的。
用类比的方式来说,想象一下,假设你的编程语言只能使用赋值(“=”)、加法(“+”)和乘法(“*”)。你可以在这样受限制的环境中调整输入值以得到结果,但在这样的情况下,你无法编写更复杂的逻辑。
现在让我们把激活函数 f() 加回去,看看会发生什么。我们将使用的具体激活函数称为双曲正切函数,或者更常见的是tanh,它是神经网络中最常用的激活函数之一。在这个讨论中,这个函数的细节并不重要,但简而言之,它的行为如下:当输入接近零时,tanh 对输入的影响不大,例如,0.3 或 -0.2。换句话说,输入几乎不经过函数而保持不变。当输入远离零时,tanh 试图将其压缩在 -1 和 1 之间。例如,当输入很大(比如,10.0)时,输出变得非常接近 1.0,而当输入很小时(比如,-10.0)时,输出几乎为 -1.0。如果将两个或更多变量输入激活函数,这会产生类似于 OR 逻辑门(或 AND 门,取决于权重)的效果。门的输出根据输入变为开启(1)和关闭(-1)。
当 w1 = [-1, 2],w2 = [-1, 2],b = [0, 1],并且使用 tanh 激活函数时,RNN 的结果更接近我们所期望的(见图 4.6)。如果将它们四舍五入为最接近的整数,RNN 成功地通过它们的语法性将句子分组。
图 4.6 当 w1 = [-1, 2],w2 = [-1, 2],b = [0, 1] 且激活函数为时的输入和输出
使用同样的类比,将激活函数应用于你的神经网络就像在你的编程语言中使用 AND、OR 和 IF 以及基本的数学运算,比如加法和乘法一样。通过这种方式,你可以编写复杂的逻辑并模拟输入变量之间的复杂交互,就像本节的例子一样。
注意:本节中我使用的例子是流行的 XOR(或异或)例子的一个略微修改版本,通常在深度学习教材中见到。这是神经网络可以解决但其他线性模型无法解决的最基本和最简单的例子。
关于 RNN 的一些最后说明——它们的训练方式与任何其他神经网络相同。最终的结果与期望结果使用损失函数进行比较,然后两者之间的差异——损失——用于更新“魔术常数”。在这种情况下,魔术常数是 update_simple()函数中的 w1、w2 和 b。请注意,更新函数及其魔术常数在循环中的所有时间步中都是相同的。这意味着 RNN 正在学习的是可以应用于任何情况的一般更新形式。
4.2 长短期记忆单元(LSTMs)和门控循环单元(GRUs)
实际上,我们之前讨论过的简单 RNN 在真实世界的 NLP 应用中很少使用,因为存在一个称为梯度消失问题的问题。在本节中,我将展示与简单 RNN 相关的问题以及更流行的 RNN 架构,即 LSTMs 和 GRUs,如何解决这个特定问题。
4.2.1 梯度消失问题
就像任何编程语言一样,如果你知道输入的长度,你可以在不使用循环的情况下重写一个循环。RNN 也可以在不使用循环的情况下重写,这使它看起来就像一个具有许多层的常规神经网络。例如,如果你知道输入中只有六个单词,那么之前的 rnn()可以重写如下:
def rnn(sentence): word1, word2, word3, word4, word5, word6 = sentence state = init_state() state = update(state, word1) state = update(state, word2) state = update(state, word3) state = update(state, word4) state = update(state, word5) state = update(state, word6) return state
不带循环的表示 RNN 被称为展开。现在我们知道简单 RNN 的 update()是什么样子(update_simple),所以我们可以用其实体替换函数调用,如下所示:
def rnn_simple(sentence): word1, word2, word3, word4, word5, word6 = sentence state = init_state() state = f(w1 * f(w1 * f(w1 * f(w1 * f(w1 * f(w1 * state + w2 * word1 + b) + w2 * word2 + b) + w2 * word3 + b) + w2 * word4 + b) + w2 * word5 + b) + w2 * word6 + b) return state
这变得有点丑陋,但我只是想让你注意到非常深度嵌套的函数调用和乘法。现在,回想一下我们在上一节中想要完成的任务——通过识别主谓一致来对语法正确的英语句子进行分类。假设输入是 sentence = [“The”, “books”, “I”, “read”, “yesterday”, “were”]。在这种情况下,最内层的函数调用处理第一个词“The”,下一个处理第二个词“books”,依此类推,一直到最外层的函数调用,处理“were”。如果我们稍微修改前面的伪代码,如下代码片段所示,你就可以更直观地理解它:
def is_grammatical(sentence): word1, word2, word3, word4, word5, word6 = sentence state = init_state() state = process_main_verb(w1 * process_adverb(w1 * process_relative_clause_verb(w1 * process_relative_clause_subject(w1 * process_main_subject(w1 * process_article(w1 * state + w2 * word1 + b) + w2 * word2 + b) + w2 * word3 + b) + w2 * word4 + b) + w2 * word5 + b) + w2 * word6 + b) return state
为了识别输入确实是一句语法正确的英语句子(或一句句子的前缀),RNN 需要保留有关主语(“书”)的信息在状态中,直到看到动词(“were”)而不会被中间的任何东西(“我昨天读了”)分散注意力。在先前的伪代码中,状态由函数调用的返回值表示,因此关于主题的信息(process_main_subject 的返回值)需要在链中传播到达最外层函数(process_main_verb)。这开始听起来像是一项困难的任务。
当涉及训练该 RNN 时,情况并不好。 RNN 和其他任何神经网络都使用称为反向传播的算法进行训练。反向传播是一种过程,在该过程中,神经网络的组成部分与先前的组成部分通信,以便调整参数以最小化损失。对于这个特定的示例,它是如何工作的。首先,您查看结果,即 is_grammatical 的返回值()并将其与您期望的内容进行比较。这两者之间的差称为损失。最外层函数 is_grammatical()基本上有四种方式来减少损失,使其输出更接近所需内容:1)调整 w1,同时固定嵌套函数 process_adverb()的返回值,2)调整 w2,3)调整 b,或 4)调整 process_adverb()的返回值,同时固定参数。调整参数(w1、w2 和 b)很容易,因为函数知道调整每个参数对其返回值的确切影响。然而,调整上一个函数的返回值是不容易的,因为调用者不知道函数内部的工作原理。因此,调用者告诉上一个函数(被调用方)调整其返回值以最小化损失。请参见图 4.7,了解损失如何向后传播到参数和先前的函数。
图 4.7 损失的反向传播
嵌套的函数调用重复这个过程并玩转电话游戏,直到消息传递到最内层函数。到那个时候,因为消息需要经过许多层,它变得非常微弱和模糊(或者如果有误解则非常强大和扭曲),以至于内部函数很难弄清楚自己做错了什么。
技术上讲,深度学习文献将此称为梯度消失问题。梯度是一个数学术语,对应于每个函数从下一个函数接收到的信息信号,该信号指示它们应该如何改进其过程(如何更改其魔法常数)。反向电话游戏,其中消息从最终函数(=损失函数)向后传递,称为反向传播。我不会涉及这些术语的数学细节,但至少在概念上理解它们是有用的。
由于梯度消失问题,简单的循环神经网络(Simple RNNs)难以训练,在实践中现在很少使用。
4.2.2 长短期记忆(LSTM)
之前提到的嵌套函数处理语法信息的方式似乎太低效了。毕竟,为什么外部函数(is_grammatical)不直接告诉负责的特定函数(例如,process_main_subject)出了什么问题,而不是玩电话游戏呢?它不能这样做,因为每次函数调用后消息都可以完全改变其形状,这是由于 w2 和 f()。最外层函数无法仅从最终输出中告诉哪个函数负责消息的哪个部分。
我们如何解决这个低效性呢?与其每次通过激活函数传递信息并完全改变其形状,不如在每一步中添加和减去与正在处理的句子部分相关的信息?例如,如果 process_main_subject() 可以直接向某种“记忆”中添加有关主语的信息,并且网络可以确保记忆通过中间函数完整地传递,is_grammatical() 就会更容易告诉前面的函数如何调整其输出。
长短期记忆单元(LSTMs)是基于这一观点提出的一种 RNN 单元。LSTM 单元不是传递状态,而是共享“记忆”,每个单元都可以从中删除旧信息并/或添加新信息,有点像制造工厂中的装配线。具体来说,LSTM RNN 使用以下函数来更新状态:
def update_lstm(state, word): cell_state, hidden_state = state cell_state *= forget(hidden_state, word) cell_state += add(hidden_state, word) hidden_state = update_hidden(hidden_state, cell_state, word) return (cell_state, hidden_state)
图 4.8 LSTM 更新函数
尽管与其简单版本相比,这看起来相对复杂,但是如果你将其分解为子组件,就不难理解这里正在发生的事情,如下所述并在图 4.8 中显示:
- LSTM 状态包括两个部分——细胞状态(“记忆”部分)和隐藏状态(“心理表征”部分)。
- 函数 forget() 返回一个介于 0 和 1 之间的值,因此乘以这个数字意味着从 cell_state 中擦除旧的记忆。要擦除多少由 hidden_state 和 word(输入)决定。通过乘以介于 0 和 1 之间的值来控制信息流动称为门控。LSTM 是第一个使用这种门控机制的 RNN 架构。
- 函数 add() 返回添加到记忆中的新值。该值再次是由 hidden_state 和 word 决定的。
- 最后,使用一个函数更新 hidden_state,该函数的值是从前一个隐藏状态、更新后的记忆和输入单词计算得出的。
我通过隐藏一些数学细节在 forget()、add() 和 update_hidden() 函数中抽象了更新函数,这些细节对于这里的讨论不重要。如果你对深入了解 LSTM 感兴趣,我建议你阅读克里斯·奥拉在此主题上撰写的精彩博文(colah.github.io/posts/2015-08-Understanding-LSTMs/
)。
因为 LSTMs 有一个在不同时间步保持不变的单元状态,除非显式修改,它们更容易训练并且相对表现良好。因为你有一个共享的“记忆”,函数正在添加和删除与输入句子的不同部分相关的信息,所以更容易确定哪个函数做了什么以及出了什么问题。来自最外层函数的错误信号可以更直接地到达负责函数。
术语说明:LSTM 是此处提到的一种特定类型的架构,但人们使用 “LSTMs” 来表示带有 LSTM 单元的 RNN。此外,“RNN” 常常用来指代“简单 RNN”,在第 4.1.3 节中介绍。在文献中看到“RNNs”时,你需要注意它们使用的确切架构。
4.2.3 门控循环单元(GRUs)
另一种 RNN 架构称为门控循环单元(GRUs),它使用门控机制。GRUs 的理念与 LSTMs 相似,但 GRUs 仅使用一组状态而不是两组。GRUs 的更新函数如下所示:
def update_gru(state, word): new_state = update_hidden(state, word) switch = get_switch(state, word) state = swtich * new_state + (1 - switch) * state return state
GRUs 不使用擦除或更新内存,而是使用切换机制。单元首先从旧状态和输入计算出新状态。然后计算切换值,一个介于 0 和 1 之间的值。根据切换值选择新状态和旧状态之间的状态。如果它是 0,旧状态保持不变。如果它是 1,它将被新状态覆盖。如果它在两者之间,状态将是两者的混合。请参见图 4.9,了解 GRU 更新函数的示意图。
图 4.9 GRU 更新函数
请注意,与 LSTMs 相比,GRUs 的更新函数要简单得多。实际上,它的参数(魔术常数)比 LSTMs 需要训练的参数少。因此,GRUs 比 LSTMs 更快地训练。
最后,尽管我们介绍了两种不同类型的 RNN 架构,即 LSTM 和 GRU,但在社区中并没有一致的共识,哪种类型的架构对于所有应用最好。你通常需要将它们视为超参数,并尝试不同的配置。幸运的是,只要你使用现代深度学习框架如 PyTorch 和 TensorFlow,就很容易尝试不同类型的 RNN 单元。
4.3 准确率、精确率、召回率和 F-度量
在第 2.7 节,我简要地讨论了一些我们用于评估分类任务性能的指标。在我们继续实际构建一个句子分类器之前,我想进一步讨论我们将要使用的评估指标——它们的含义以及它们实际上衡量的内容。
4.3.1 准确率
准确率可能是我们所讨论的所有评估指标中最简单的。在分类设置中,准确率是你的模型预测正确的实例的比例。例如,如果有 10 封电子邮件,而你的垃圾邮件过滤模型正确地识别了其中的 8 封,那么你的预测准确率就是 0.8,或者 80%(见图 4.10)。
图 4.10 计算准确率
虽然简单,但准确率并不是没有局限性。具体来说,在测试集不平衡时,准确率可能会误导。一个不平衡的数据集包含多个类别标签,它们的数量差异很大。例如,如果一个垃圾邮件过滤数据集不平衡,可能包含 90% 的非垃圾邮件和 10% 的垃圾邮件。在这种情况下,即使一个愚蠢的分类器把一切都标记为非垃圾邮件,也能够达到 90% 的准确率。例如,如果一个“愚蠢”的分类器在图 4.10 中将所有内容都分类为“非垃圾邮件”,它仍然会达到 70% 的准确率(10 个实例中的 7 个)。如果你孤立地看这个数字,你可能会被误导以为分类器的性能实际上很好。当你使用准确率作为指标时,将其与假想的、愚蠢的分类器(多数投票)作为基准进行比较总是一个好主意。
4.3.2 精确率和召回率
剩下的指标——精确率、召回率和 F-度量——是在二元分类设置中使用的。二元分类任务的目标是从另一个类别(称为负类)中识别出一个类别(称为正类)。在垃圾邮件过滤设置中,正类是垃圾邮件,而负类是非垃圾邮件。
图 4.11 中的维恩图包含四个子区域:真正例、假正例、假负例和真负例。真正例(TP)是被预测为正类(= 垃圾邮件)并且确实属于正类的实例。假正例(FP)是被预测为正类(= 垃圾邮件)但实际上不属于正类的实例。这些是预测中的噪音,也就是被误认为垃圾邮件并最终出现在你的电子邮件客户端的垃圾邮件文件夹中的无辜非垃圾邮件。
另一方面,假阴性(FN)是被预测为负类但实际上属于正类的实例。这些是通过垃圾邮件过滤器漏过的垃圾邮件,最终出现在你的收件箱中。最后,真阴性(TN)是被预测为负类并且确实属于负类的实例(即出现在你的收件箱中的非垃圾邮件)。
精确率是模型将正确分类为正例的实例的比例。例如,如果你的垃圾邮件过滤器将三封邮件标记为垃圾邮件,并且其中有两封确实是垃圾邮件,则精确率将为 2/3,约为 66%。
召回率与精确率有些相反。它是你的模型在数据集中被正确识别为正例的正例占比。再以垃圾邮件过滤为例,如果你的数据集中有三封垃圾邮件,而你的模型成功识别了其中两封邮件为垃圾邮件,则召回率将为 2/3,约为 66%。
图 4.11 显示了预测标签和真实标签之间以及召回率和精确率之间的关系。
图 4.11 精确率和召回率
4.3.3 F-测量
你可能已经注意到了精确率和召回率之间的权衡。想象一下有一个非常谨慎的垃圾邮件过滤器。它只有在几千封邮件中输出一封邮件为垃圾邮件,但当它输出时,它总是正确的。这不是一个困难的任务,因为一些垃圾邮件非常明显 - 如果它们的文本中包含“v1@gra”这个词,并且是从垃圾邮件黑名单中的人发送的,将其标记为垃圾邮件应该是相当安全的。这个垃圾邮件过滤器的精确率是多少?100%。同样,还有另一个非常粗心的垃圾邮件过滤器。它将每封电子邮件都分类为垃圾邮件,包括来自同事和朋友的电子邮件。它的召回率是多少?100%。这两个垃圾邮件过滤器中的任何一个有用吗?几乎没有!
正如你所看到的,只关注精确率或召回率而忽视另一个是不好的做法,因为它们之间存在权衡。这就好比你在节食时只关注体重。你减了 10 磅?太棒了!但是如果你身高是 7 英尺呢?并不是很好。你需要同时考虑身高和体重-太多是多少取决于另一个变量。这就是为什么有像 BMI(身体质量指数)这样的衡量标准,它同时考虑了这两个指标。同样,研究人员提出了一种叫做 F-测量的度量标准,它是精确率和召回率的平均值(更准确地说是调和平均值)。通常使用的是一个叫做 F1-测量的特殊案例,它是 F-测量的等权版本。在分类设置中,衡量并尝试最大化 F-测量是一种很好的做法。
真实世界的自然语言处理(一)(5)https://developer.aliyun.com/article/1519730