自然语言处理实战第二版(MEAP)(四)

原文:zh.annas-archive.org/md5/fa38013bd1d506f70f24ea5385d06017

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:通过卷积神经网络(CNNs)在文本中找到知识的核心

本章内容包括

  • 理解自然语言处理的神经网络

  • 在序列中找到模式

  • 使用 PyTorch 构建 CNN

  • 训练一个 CNN

  • 训练嵌入

  • 对文本进行分类

在本章中,你将解锁卷积在自然语言处理中被误解的超能力。这将帮助你的机器通过检测单词序列中的模式以及它们与邻居的关系来理解单词。

卷积神经网络(CNNs)在计算机视觉(图像处理)领域大行其道。但很少有企业认识到 CNNs 在自然语言处理中的威力。这为你在自然语言处理学习中以及理解 CNNs 能做什么的企业家创造了机会。例如,2022 年,科尔·霍华德和汉内斯·哈普克(本书第一版的共同作者)利用他们的自然语言处理 CNN 专业知识帮助他们的初创公司自动化业务和会计决策。^([1])并且学术界的深度学习专家,如克里斯托弗·曼宁和杰弗里·辛顿,使用 CNNs 在自然语言处理领域击败竞争对手。你也可以。

那么为什么 CNNs 在行业和大型科技公司中没有引起注意呢?因为它们太好了——太有效率了。CNNs 不需要大量的数据和计算资源,这是大科技公司在人工智能领域的垄断力量的核心。他们对能够"扩展"到海量数据集的模型感兴趣,比如阅读整个互联网。拥有大数据访问权限的研究人员专注于利用他们在数据方面的竞争优势的问题和模型,即"新石油"。^([2])让人们为一个任何人都可以在自己的笔记本电脑上训练和运行的模型付钱是很困难的。

另一个更加平凡的原因是 CNNs 被忽视的原因是,为自然语言处理正确配置和调整的 CNNs 很难找到。我找不到一个在 PyTorch、Keras 或 TensorFlow 中为自然语言处理实现的 CNNs 的单一参考实现。而非官方的实现似乎将用于图像处理的 CNN 通道转置为在嵌入维度上创建卷积,而不是在时间上进行卷积。很快你就会明白为什么这是一个糟糕的想法。但别担心,你很快就会看到别人犯的错误,你将像专业人士一样构建 CNNs。你的 CNNs 将比博客圈中出现的任何东西更有效率,性能更高。

或许你正在问自己,为什么在自然语言处理领域新潮流是transformers时,你还应该学习卷积神经网络(CNNs)。你可能听说过GPT-JGPT-NeoPaLM等等。阅读完本章后,你将能够基于 CNNs 构建更好、更快、更便宜的自然语言处理模型,而其他人还在浪费时间和金钱在千亿参数的 transformers 上。你不需要大型 transformers 所需的昂贵计算资源和训练数据。^([3]) ^([4]) ^([5])

  • PaLM:540B 参数

  • GPT-3:175B 参数

  • T5-11B:11B 参数(FOSS,胜过 GPT-3)

  • GPT-J:6B 参数(FOSS,胜过 GPT-3)

  • CNNs(本章中):少于 200k 参数

是的,在本章中,你将学会如何构建比你在新闻中读到的大型 Transformer 小一百万倍且更快的 CNN 模型。而 CNN 通常是完成任务的最佳工具。

7.1 单词序列中的模式

在以前的章节中,单个单词对你来说效果很好。你可以用单个单词说很多话。你只需要选择正确的单词或在一段文字中找到关键词,那通常就能捕捉到意思。而且在你以前解决的那些问题中,顺序并不是很重要。如果你将“初级工程师”或“数据科学家”等工作标题的所有单词都放入词袋(BOW)向量中,那么搅在一起的 BOW 包含了原始标题的大部分信息内容。这就是为什么本书中以前的所有例子在短语或单个单词上效果最好的原因。这就是为什么关键词通常足以了解一个工作标题的最重要信息或理解一部电影的主要内容。

这也是为什么要选择几个词来总结一本书或一份带有标题的工作是如此困难。对于短语,单词的出现是唯一重要的。当你想要表达一个完整的思想,不仅仅是一个标题时,你必须使用更长的单词序列。而且顺序很重要。

在 NLP 之前,甚至在计算机出现之前,人类使用一种叫做卷积的数学运算来检测序列中的模式。对于 NLP,卷积被用来检测跨越多个单词甚至多个句子的模式。最初的卷积是用鹅毛笔甚至粘土板上的楔形文字手工制作的!一旦计算机被发明出来,研究人员和数学家就会手工制作数学公式来匹配他们想要解决的每个问题。用于图像处理的常见手工制作内核包括拉普拉斯、索贝尔和高斯滤波器。在数字信号处理中,类似于 NLP 中使用的低通和高通卷积滤波器可以根据第一原理进行设计。如果你是一个视觉学习者或对计算机视觉感兴趣,通过查看维基百科上用于这些卷积滤波器的热图绘制,你可能会更容易理解卷积。这些滤波器甚至可能给你关于初始化 CNN 滤波器权重以加快学习并创建更可解释的深度学习语言模型的想法。

但是随着时间的推移,这变得乏味了,我们甚至不再认为手工制作的滤镜在计算机视觉或自然语言处理中很重要。相反,我们使用统计学和神经网络来自动学习在图像和文本中寻找的模式。研究人员开始使用线性全连接网络(多层感知器)。但是这些网络存在一个严重的问题,即泛化能力过强,无法识别当单词模式从句子开头移动到句子末尾时。全连接神经网络不具有尺度不变性和平移不变性。但是后来 David Rumelhart 发明了,Geoffrey Hinton 推广了反向传播方法,帮助 CNN 和深度学习使世界摆脱了长时间的人工智能冬季。[9] [10] 这种方法孕育了第一个实用的计算机视觉、时间序列预测和自然语言处理的 CNN。

想出如何将卷积与神经网络结合起来创建 CNN 只是神经网络所需要的提升。CNN 现在主导着计算机视觉。而对于自然语言处理,CNN 仍然是许多先进的自然语言处理问题中最有效的模型。例如,spaCy 切换到 CNNs 版本 2.0。CNNs 对于命名实体识别(NER)和其他单词标记问题非常有效。[11] 而且你的大脑中的 CNNs 似乎负责识别其他动物无法理解的语言模式。

7.1.1 尺度和平移不变性

CNNs 相对于以前的 NLP 算法的主要优势是,它们可以识别文本中的模式,无论这些模式在文本中出现在哪里(平移不变性)以及它们有多分散(尺度不变性)。TF-IDF 向量没有任何方法可以识别和从文本中的模式进行泛化。而全连接神经网络会从文本中特定位置的特定模式进行过度泛化。

早在 1990 年代,像 Yann LeCun、Yoshua Bengio 和 Geoffrey Hinton 这样的著名研究人员就已经开始将卷积用于计算机视觉和 OCR(光学字符识别)。[12] 他们从我们的大脑中得到了这个想法。神经网络通常被称为“类脑”计算,因为它们模仿或模拟了我们大脑中发生的事情。神经网络在软件中模拟了大脑(生物神经元网络)在生物硬件中的工作。而且因为 CNNs 基于大脑,所以它们可以用于各种“非处方”NLP 应用:语音、音频、文本、天气和时间序列。NLP CNNs 对于任何一系列符号或数值向量(嵌入)都很有用。这种直觉使你能够将你的 NLP CNNs 应用于各种各样的问题,你在工作中会遇到,比如金融时间序列预测和天气预测。

卷积的缩放不变性意味着即使别人将他们的单词模式拉长时间通过说话慢或添加大量废话,你仍可以理解他们。翻译的不变性意味着你可以理解人们的意图,无论他们先说好消息还是坏消息。你可能已经很善于处理来自父母、教师和老板的反馈,无论是真正的建设性批评还是即使“肉”隐藏在“表扬三明治”之内。也许是因为我们使用语言的微妙方式以及语言在文化和记忆中的重要性,卷积被建立在我们的大脑中。我们是唯一有卷积网络内置在大脑中的物种。有些人在处理声音的大脑区域——赫氏回旋部(HG)甚至有着高达三层的卷积层发生。1

你很快就会看到如何将平移和缩放不变的卷积滤波器的威力嵌入你自己的神经网络中。你将使用卷积神经网络对问题和“toots(Mastodon2”帖子进行分类,甚至还可以识别莫尔斯电码中的嘟嗒声和哔哔声。你的机器很快就能判断一个问题是有关人、物、历史日期还是一个一般概念。你甚至可以尝试看看问题分类器是否可以判断别人是否在约你出去。你可能会惊讶地发现,CNN 可以检测出你在网上阅读到的灾难性帖子之间的微妙差异:灾难性的 “birdsite” 帖子与现实中的灾难之间的差异。

7.2 卷积

卷积这个概念并不像听起来那么复杂。它的数学公式几乎和计算相关系数一样简单。相关系数帮助你测量模式和信号之间的协方差或相似性。事实上,它的目的和相关系数相同——模式识别。相关系数可以帮助你检测一系列数字和另一系列数字之间的相似性,这些数字代表你要匹配的模式。

7.2.1 处理自然语言文本的模板

你见过字母模板吗?字母模板是一个有印刷字母轮廓的纸板或塑料片。当你想给某物(例如店铺标志或橱窗展示)上字时,你可以使用模板,使你的标志看起来像印刷文字一样。你可以像使用可移动遮蔽胶带一样使用模板,以防止你涂绘到错误的位置。但在这个例子中,你要反向使用模板。而不是用模板画字,你要使用模板检测字母和单词的模式。你的 NLP 模板是一个带权重(浮点数)的数组,称为滤波器内核

因此,想象一下,你为文本中的九个字母(以及一个空格字符)创建了一份字母模板"are sacred"。想象一下,它恰好是你正在阅读的书中文本的大小和形状。

图 7.1 一个真实的模板

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

现在,在你的脑海中,将模板放在书的顶部,以覆盖页面,你只能看到符合模板切口的单词。你需要将该模板滑过页面,直到该模板与书中的这对单词对齐。在那时,你将能够通过模板或掩膜清晰地看到单词的拼写。文本的黑色字母会填补模板的空洞。而你看到的黑色数量是匹配程度的度量。如果你使用了白色模板,单词"are sacred"将闪耀出来,这将是你唯一能看到的单词。

如果你这样使用模板,将其滑动到文本中,以找到模式和文本之间的最大匹配,你就在使用模板进行卷积!当谈论深度学习和 CNN 时,模板被称为卷积核过滤器。在 CNN 中,卷积核是浮点数数组而不是纸板剪影。卷积核被设计成匹配文本中的一般模式。你的文本也被转换成数字值的数组。卷积是将卷积核滑动过你的文本数字表示,以查看其中的内容。

十年前,在有了 CNN 之前,你不得不手工制作适合你想象的任何模式的卷积核。但是使用 CNN 时,除了决定卷积核的宽度 - 你认为需要多少个字母或单词来捕捉你需要的模式,你不需要编程卷积核。你的 CNN 优化器将填充卷积核中的权重。当你训练模型时,优化器会找到最能预测 NLP 问题目标变量的模式所匹配的最佳权重数组。反向传播算法会逐步调整权重,直到它们与你的数据的正确模式匹配。

为了对 CNN 的工作原理有一个完整的理解,你需要在脑海中增加一些与模板和卷积核相关的步骤,将其融合到一个自然语言处理流程中。CNN 需要执行三项任务来使用卷积核(模板)。

  1. 测量卷积核和文本之间的匹配或相似度

  2. 在文本中滑动卷积核寻找最大匹配值

  3. 使用激活函数将最大值转换为二进制值或概率。

你可以将印刷版的黑暗程度视为印版和文本之间匹配程度的一种度量。因此,卷积神经网络(CNN)的第一步是将核函数中的权重乘以文本中的数值,然后将所有乘积相加,得到总的匹配分数。这仅仅是核函数与该文本窗口之间的点积或相关性。

第二步是在文本上滑动窗口,并再次进行步骤 1 的点积。这个卷积窗口滑动、乘法和求和被称为卷积。卷积将一个数字序列转换为与原始文本序列大小相同的另一个数字序列。根据滑动和乘法(卷积)的细节,您可能得到一个稍微较短或较长的数字序列。但无论如何,卷积操作输出一个数字序列,其中每个可能的核函数位置都有一个数值。

第三步是判断文本中是否存在一个良好的匹配。为此,你的 CNN 将卷积输出的一系列值转换为一个单一的值。结果是一个表示核函数模式可能在文本中某处的概率的单一值。大多数 CNN 设计成将这一系列数值的最大值作为匹配的度量。这种方法被称为“最大池化”,因为它将卷积中的所有值集中到一个最大值中。

注意

如果你要寻找的模式在文本的不同位置上分布开来,那么你可能想尝试一些“均值池化”来处理一些核函数。

你可以看到,卷积使得你的 CNN 能够提取依赖于单词顺序的模式。这使得 CNN 的核函数能够识别自然语言文本意义上的微妙差别,而这些差别如果你只使用词袋(BOW)表示法的话就会丢失。

单词是神圣的。如果你以正确顺序使用正确的单词,你就能微调世界一点点。

—— 汤姆·斯托帕德

真实的事物

在前几章中,你通过学习如何最好地将文本分词为单词,并计算每个单词的向量表示来将单词视为神圣的。现在,你可以将这个技巧与卷积相结合,以便通过你的下一个 Mastodon 聊天机器人“微调世界”。^([16])

7.2.2 再多一点铅字

还记得字母模板的类比吗?反向字母模板对 NLP 来说并不是那么有用,因为硬纸板切割只能匹配单词的“形状”。您想要匹配单词在句子中的使用方式的含义和语法。那么你如何升级你的反向模板概念,使其更像你需要的 NLP?假设你想要你的模板检测(形容词,名词) 2-gram,例如 “right word” 和 “right order” 在汤姆·斯托帕德的引语中。以下是您如何用词性标记部分引用中的单词的方法。

>>> import pandas as pd
>>> import spacy
>>> nlp = spacy.load('en_core_web_md')  # #1

>>> text = 'right ones in the right order you can nudge the world'
>>> doc = nlp(text)
>>> df = pd.DataFrame([
...    {k: getattr(t, k) for k in 'text pos_'.split()}
...    for t in doc])
text  pos_
0   right   ADJ
1    ones  NOUN
2      in   ADP
3     the   DET
4   right   ADJ
5   order  NOUN
6     you  PRON
7     can   AUX
8   nudge  VERB
9     the   DET
10  world  NOUN

就像你在第六章中学到的一样,你希望为每个单词创建一个向量表示,以便文本可以转换为数字,用于 CNN 中。

>>> pd.get_dummies(df, columns=['pos_'], prefix='', prefix_sep='')
text  ADJ  ADP  AUX  DET  NOUN  PRON  VERB
0   right    1    0    0    0     0     0     0
1    ones    0    0    0    0     1     0     0
2      in    0    1    0    0     0     0     0
3     the    0    0    0    1     0     0     0
4   right    1    0    0    0     0     0     0
5   order    0    0    0    0     1     0     0
6     you    0    0    0    0     0     1     0
7     can    0    0    1    0     0     0     0
8   nudge    0    0    0    0     0     0     1
9     the    0    0    0    1     0     0     0
10  world    0    0    0    0     1     0     0

现在你的模板或内核将必须扩展一点以跨越两个 7-D 单热矢量。你将为单热编码向量中的 1 创建想象中的切割,以使孔的模式与您想要匹配的词性序列相匹配。你的形容词-名词模板在第一行和第一列中有形容词在 2-gram 开头的孔。你需要在第二行和第五列中为名词作为 2-gram 中的第二个单词的孔。当你将你的想象模板滑动到每一对词时,它将根据模板是否匹配文本输出布尔值TrueFalse

第一对单词将创建一个匹配:

0, 1   (right, ones)     (ADJ, NOUN)    _True_

将模板移动以覆盖第二个 2 克拉姆,它将输出 False,因为两个克拉姆以名词开头,以失败的方式结束

1, 2   (ones, in)        (NOUN, ADP)    False

继续使用剩余的单词,我们最终得到了这个 10 个词短语的 9 元素图。

跨度匹配?
0, 1(正确的,那些)True (1)
1, 2(那些,在)False (0)
2, 3(在,那个)False (0)
3, 4(正确的,右)False (0)
4, 5(正确的,秩序)True (1)
5, 6(秩序,你)False (0)
6, 7(你,可以)False (0)
7, 8(可以,推动)False (0)
8, 9(推动,那个)False (0)
9, 10(这个,世界)False (0)

恭喜。你刚刚做的是卷积。你将输入文本的较小块,本例中为 2 克拉姆,转换为显示你正在寻找的模式的匹配位置。将填充添加到您的令牌序列通常是有帮助的。并将您的文本剪切到最大长度。这样可以确保您的输出序列始终具有相同的长度,无论您的文本有多长您的内核。

卷积,然后是

  • 一种转换…

  • 可能已被填充的输入…

  • 生成地图…

  • 其中某些条件存在的输入的位置(例如,两个连续的副词)

章节后面,你将使用术语步幅来讨论你的模板以及如何将其滑动到文本上。在这种情况下,你的步幅为一,核大小为二。而对于词性向量,你的核被设计为处理 7 维嵌入向量。如果你使用相同大小的核,但将其以步幅为二滑动到文本上,那么你会得到以下输出:

跨度配对匹配?
0, 1(right, ones)True (1)
2, 3(in, the)False (0)
4, 5(right, order)True (1)
6, 7(you, can)False (0)
8, 9(nudge, the)False (0)

在这种情况下,你的步幅运气很好,因为两个形容词-名词对之间的词数是偶数。所以你的核成功地检测到了模式的两个匹配项。但是在这种配置下,你只有 50%的几率会如此幸运。因此,使用步幅为一和核大小为二或更大的情况更为常见。

7.2.3 相关性与卷积

如果你忘记了,清单 7.1 应该会提醒你 Python 中相关性是什么样子的。(你也可以使用scipy.stats.pearsonr)。

清单 7.1 相关性的 Python 实现
>>> def corr(a, b):
...    """ Compute the Pearson correlation coefficient R """
...    a = a - np.mean(a)
...    b = b - np.mean(b)
...    return sum(a * b) / np.sqrt(sum(a*a) * sum(b*b))
>>> a = np.array([0, 1, 2, 0, 1, 2, 0, 1, 2])
>>> b = np.array([0, 1, 2, 3, 4, 5, 6, 7, 8])
>>> corr(a, b)
0.316...
>>> corr(a, a)
1.0

然而,相关性只在系列长度相同时才有效。而且你肯定希望创建一些能够处理比表示文本的数字序列更短的模式的数学内容。这就是数学家提出卷积概念的方式。他们将较长的序列分成与较短序列相同长度的较小序列,然后对这些序列对的每一个应用相关函数。这样,卷积可以处理任何两个序列的数字,无论它们的长度有多长或多短。所以在自然语言处理中,我们可以将我们的模式(称为)设计得尽可能短。而标记(文本)的序列可以任意长。你可以在文本的滑动窗口上计算相关性,从而创建代表文本含义的相关系数序列。

7.2.4 卷积作为映射函数

CNNs(无论是在我们的大脑中还是在机器中)是 map-reduce 算法中的“映射”部分。它输出一个比原始序列短的新序列,但还不够短。这将在流水线的减少部分后面进行。注意每个卷积层的输出大小。

卷积的数学运算可以让你在文本中无论何处(或何时)都能检测到模式。如果一个自然语言处理算法生成的特征向量无论一个特定词语模式出现在何处(何时)都相同,我们称之为“时不变”。卷积是一个时不变的操作,因此非常适用于文本分类、情感分析和自然语言理解。与你目前使用的其他方法相比,时不变性是卷积的一个巨大优势。你的 CNN 输出向量为你提供了一致的表达方式,表达了文本中的思想,无论该思想在文本中的哪个位置表达出来。与单词嵌入表示不同,卷积将注意力集中在向量的顺序意义上,并不会将它们全部混合成毫无意义的平均值。

卷积的另一个优势是,它输出的文本向量表示大小始终相同,无论你的文本有多长。无论你的文本是一个词名还是一份长达一万字的文档,对该序列的卷积都会输出相同大小的向量来表示该文本的含义。卷积创建的嵌入向量可用于做各种预测,就像你在第六章中使用单词嵌入所做的一样。但现在,这些嵌入将作用于单词序列,而不仅仅是单个单词。你的嵌入,你的含义向量表示,无论你处理的文本是三个词“我爱你”还是更长的文本:“我对你感到深深的爱和欣慰。”爱的感觉或情感会在两个向量中相同的位置结束,尽管单词“爱”出现在文本的不同位置。文本的含义分布在整个向量上,形成所谓的“密集”向量表示。当你使用卷积时,文本向量表示中没有间隙。与之前章节中稀疏的 TF-IDF 向量不同,你的卷积输出向量的维度都是填充的,对你处理的每一小段文本都有意义。

7.2.5 Python 卷积示例

您将从一个纯 Python 实现的卷积开始。这将为您提供卷积的数学模型,更重要的是,为卷积的矩阵和向量形状提供心理模型。这将帮助您理解卷积神经网络中每一层的目的。对于这第一个卷积,您将在卷积核中硬编码权重以计算 2 点移动平均值。如果您想要从 Robinhood 的日常加密货币价格中提取一些机器学习特征,这可能很有用。或者也许更好的想象一下,您正在尝试解决一个可解决的问题,比如对像波特兰(俄勒冈州)这样多雨城市的降雨报告进行一些 2 点平均值的特征工程。或者更好的是,想象您正在尝试构建一个检测自然语言文本中副词部分的词性标签下降的检测器。因为这是一个硬编码的核,所以您现在不必担心训练或拟合您的卷积数据。

您将硬编码此卷积以检测数字序列中的模式,就像您在第二章中硬编码正则表达式来识别字符序列中的标记一样。当您硬编码卷积滤波器时,您必须知道您要寻找的模式,以便将该模式放入您的卷积系数中。这对于易于识别的模式非常有效,比如数值下降或数值短暂上升。这些是本章后面将要寻找的摩尔斯电码“文本”的模式。在本章的第三节中,您将学习如何利用这一技能在 PyTorch 中构建一个卷积神经网络,该网络可以自行学习在您的文本中寻找哪些模式。

在计算机视觉和图像处理中,您需要使用 2-D 卷积滤波器,这样您就可以检测垂直和水平模式,以及中间的所有内容。对于自然语言处理,您只需要 1 维卷积滤波器。您只需在一个维度上进行卷积,即时间维度,在您的标记序列中的位置。您可以将嵌入向量的组件,或者也许是其他词性,存储在卷积的通道中。稍后会详细讨论这一点,等您完成纯 Python 卷积。以下是也许是最简单但有用的 1-D 卷积的 Python 代码。

列表 7.4 显示了如何在纯 Python 中创建一个 1-D 卷积,用于一个硬编码的核([.5, .5]),其中只有两个权重为 .5 的权重。

这个核正在计算数字序列中两个数字的移动平均值。对于自然语言处理,输入序列中的数字表示词汇表中标记的出现(存在或不存在)。而且您的标记可以是任何东西,例如我们在示例中用于标记副词出现(存在性)的词性标签。或者输入可以是每个标记中词嵌入维度的波动数值。

这个移动平均滤波器可以检测到连续出现两个事物的情况,因为 (.5 * 1 + .5 * 1)1。代码会以数字 1 来告诉您它找到了某些东西。卷积对于像这样的其他自然语言处理算法可能会错过的模式非常擅长。与寻找两个词的两个实例不同,您将寻找连续出现的两个意思。而且您刚刚在上一章中了解了不同的意思方面,即单词向量的维度。现在,您只寻找单词的一个方面,即它们的词性。你要找的是两个连续的副词。

合适的词可能具有影响力,但没有一个词能像合适的暂停一样有效。

— 马克·吐温

你能找出两个连续出现的副词吗?我不得不借助 SpaCy 来找到这个例子。类似这样的微妙意义模式对于人类来说很难有意识地注意到。但是对于卷积滤波器来说,测量文本的副词特性只是一门数学问题。卷积将并行处理您可能正在寻找的所有其他意义方面。实际上,一旦您完成了第一个例子,您将对单词的所有方面运行卷积。当您使用前一章节中跟踪单词所有维度的词嵌入时,卷积效果最佳。

卷积将查看单词意思的所有维度以及所有维度的单词意义的模式。卷积神经网络(CNN)会查看您的目标输出(目标变量),以查找影响目标变量的单词嵌入的所有维度中的所有模式。对于这个例子,您将定义一个“副词句”为在句子中连续包含两个副词的句子。这只是为了帮助您看到一个非常简单的问题的数学计算。副词特性只是您需要在机器学习流程中从文本中提取的众多特征之一。CNN 将通过学习适当的副词特性、名词特性、停词特性和其他很多“特性”的组合来自动完成这种工程。现在,您只需手动完成这一个副词特性。目标是了解 CNN 可以学习识别数据中的哪些模式。

图 7.2 展示了如何使用 SpaCy 对引用进行词性标注,然后创建一个二进制系列来表示你正在搜索的单词的一个方面,即副词性。

列表 7.2 用词性标记引用
>>> nlp = spacy.load('en_core_web_md')
>>> quote = "The right word may be effective, but no word was ever" \
...    " as effective as a rightly timed pause."
>>> tagged_words = {
...    t.text: [t.pos_, int(t.pos_ == 'ADV')]  # #1
...    for t in nlp(quote)}
>>> df_quote = pd.DataFrame(tagged_words, index=['POS', 'ADV'])
>>> print(df_quote)
The right  word  may   be  ...    a rightly timed pause      .
POS  DET   ADJ  NOUN  AUX  AUX  ...  DET     ADV  VERB  NOUN  PUNCT
ADV    0     0     0    0    0  ...    0       1     0     0      0

现在你有了你的一串ADV的零和一,所以你可以用卷积来处理它,以匹配你正在寻找的模式。

列表 7.3 为卷积定义输入序列
>>> inpt = list(df_quote.loc['ADV'])
>>> print(inpt)
[0, 0, 0, ... 0, 1, 1, 0, 0...]

哇,这种作弊效果太好了!我们清楚地看到在句子中有两个副词是连续的。让我们使用我们的卷积滤波器来找出确切的位置。

列表 7.4 纯 Python 中的卷积
>>> kernel = [.5, .5]  # #1
>>>
>>> output = []
>>> for i in range(len(inpt) - 1):  # #2
...    z = 0
...    for k, weight in enumerate(kernel):  # #3
...        z = z + weight * inpt[i + k]
...    output.append(z)
>>>
>>> print(f'inpt:\n{inpt}')
>>> print(f'len(inpt): {len(inpt)}')
>>> print(f'output:\n{[int(o) if int(o)==o else o for o in output]}')
>>> print(f'len(output): {len(output)}')
inpt:
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0., 1, 1., 0, 0, 0., 1., 0, 0, 0]
len(inpt): 20
output:
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, .5, 1, .5, 0, 0, .5, .5, 0, 0]
len(output): 19

现在你可以明白为什么你必须在输入序列的末尾停止for循环了。否则,我们的内核中的 2 个权重将会溢出到输入序列的末尾。你可能在其他地方见过这种软件模式称为“map-reduce”。你可以看到如何使用 Python 内置函数map()filter()来实现列表 7.4 中的代码。

如果你把和函数作为你的池化函数,你可以创建一个移动平均卷积,根据我们的 2 个连续副词的定义来计算文本的副词性。如果你想要计算一个无权重的移动平均,你只需要确保你的内核值都是1 / len(kernel),这样它们就会加起来为 1,并且都是相等的。

图 7.5 将创建一条线图,帮助你可视化卷积输出和原始的is_adv输入重叠在一起。

图 7.5 输入(is_adv)和输出(副词性)的折线图
>>> import pandas as pd
>>> from matplotlib import pyplot as plt
>>> plt.rcParams['figure.dpi'] = 120  # #1

>>> import seaborn as sns
>>> sns.set_theme('paper')  # #2

>>> df = pd.DataFrame([inpt, output], index=['inpt', 'output']).T
>>> ax = df.plot(style=['+-', 'o:'], linewidth=3)

你有没有注意到这个大小为 2 的内核的卷积的输出序列产生的输出比输入序列短一个?图 7.2 显示了这个移动平均卷积的输入和输出的线图。当你把两个数字分别乘以.5然后相加时,你得到这两个数字的平均值。所以这个特定的内核([.5, .5])是一个非常小的(两个样本)移动平均滤波器。

图 7.2 is_adv副词性卷积的线图

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

看着图 7.2,你可能会注意到它看起来有点像金融时间序列数据或每日降雨量值的移动平均或平滑滤波器。对于你的 GreenPill 令牌价格的 7 天移动平均,你将使用一个大小为 7 的卷积内核,每周的每一天都为0.142。一个大小为 7 的移动平均卷积将会更加平滑你副词性中的尖峰,从而在你的线图中创建一个更加曲线的信号。但是除非你精心制作了一个包含七个连续副词的声明,否则你永远不会在任何有机引用中获得 1.0 的副词性分数。

你可以将列表 7.6 中的 Python 脚本泛化,以创建一个卷积函数,即使内核大小发生变化也能正常工作。这样你就可以在以后的例子中重复使用它。

清单 7.6 通用卷积函数
>>> def convolve(inpt, kernel):
...    output = []
...    for i in range(len(inpt) - len(kernel) + 1):  # #1
...        output.append(
...            sum(
...                [
...                    inpt[i + k] * kernel[k]
...                    for k in range(len(kernel))  # #2
...                ]
...            )
...        )
...    return output

你在这里创建的 convolve() 函数将输入乘以核权重相加。你也可以使用 Python 的 map() 函数来创建卷积。你使用了 Python 的 sum() 函数来 减少 输出中的数据量。这种组合使卷积算法成为一个你在计算机科学或数据科学课程中可能听说过的 map reduce 操作。

重要

像卷积这样的 map-reduce 操作高度可并行化。数据窗口的每个核乘法可以同时并行进行。这种可并行性是使卷积成为处理自然语言数据的一种强大、高效和成功的方式的原因。

7.2.6 PyTorch 1-D CNN 在 4-D 嵌入向量上

你可以看到 1-D 卷积是如何用于在令牌序列中查找简单模式的。在之前的章节中,你使用正则表达式来查找字符序列中的模式。但是对于涉及单词意义的多个不同方面的语法的更复杂模式呢?为此,你需要使用单词嵌入(来自第六章)结合 卷积神经网络。你想要使用 PyTorch 来处理所有这些线性代数操作的簿记。通过使用 4-D 独热编码向量来表示单词的词性,你将在下一个示例中简化它。稍后,你将学习如何使用 300-D GloVE 向量,这些向量除了保留单词的语法角色外,还跟踪单词的含义。

因为词嵌入或向量捕捉了单词中所有不同的意义组成部分,它们包括了词性。就像之前的广告引用示例一样,你将根据单词的词性匹配一个语法模式。但这次,你的单词将具有表示名词、动词和副词的 3-D 词性向量。你的新 CNN 可以检测到一个非常特定的模式,即一个副词后跟一个动词,然后是一个名词。你的 CNN 正在寻找马克·吐温引用中的“正确的时机”。如果需要帮助创建一个包含“正确时机”的 POS 标签的 DataFrame,请参考清单 7.2。

>>> tags = 'ADV ADJ VERB NOUN'.split()
>>> tagged_words = [
...    [tok.text] + [int(tok.pos_ == tag) for tag in tags]  # #1
...    for tok in nlp(quote)]  # #2
>>>
>>> df = pd.DataFrame(tagged_words, columns=['token'] + tags).T
>>> print(df)
The  right  word  may  be  ...  a  rightly  timed  pause  .
ADV     0      0     0    0   0  ...  0        1      0      0  0
ADJ     0      1     0    0   0  ...  0        0      0      0  0
VERB    0      0     0    0   0  ...  0        0      1      0  0
NOUN    0      0     1    0   0  ...  0        0      0      1  0
图 7.3 带有词性标记的句子

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

为了保持高效,PyTorch 不接受任意的 Pandas 或 numpy 对象。相反,你必须将所有输入数据转换为具有 torch.floattorch.int 数据类型(dtype)对象的 torch.Tensor 容器。

清单 7.7 将 DataFrame 转换为正确大小的张量
>>> import torch
>>> x = torch.tensor(
...     df.iloc[1:].astype(float).values,
...     dtype=torch.float32)  # #1
>>> x = x.unsqueeze(0) # #2

现在你构建了我们想在文本中搜索的模式:副词、动词,然后名词。你需要为你关心的每个词性创建一个单独的过滤器或核。每个核将与其他核对齐,以同时在单词意义的所有方面找到你正在寻找的模式。

在此之前,您只需要担心一个维度,即副词标签。现在,您需要处理这些单词向量的所有 4 个维度,以确保模式正确。您需要协调四个不同的“特征”或数据通道。因此,对于一个 3 个词、4 个通道的核心,我们需要一个 4x3 矩阵。每一行代表一个通道(词性标签),每一列代表序列中的一个单词。单词向量是 4 维列向量。

>>> kernel = pd.DataFrame(
...           [[1, 0, 0.],
...            [0, 0, 0.],
...            [0, 1, 0.],
...            [0, 0, 1.]], index=tags)
>>> print(kernel)

您可以看到,这个 DataFrame 只是您想要在文本样本中匹配的向量序列的精确副本。当然,您之所以能够做到这一点,是因为您在这一个玩具示例中知道您在寻找什么。在真实的神经网络中,深度学习优化器将使用反向传播来学习最有助于预测您的目标变量(标签)的向量序列。

机器如何匹配模式是如何可能的?是什么数学导致核心始终匹配其包含的模式?在图 7.4 中,您可以自己进行一些数据的滤波器跨越几个步骤的数学计算。这将帮助您了解所有这些是如何工作的,以及为什么它既简单又强大。

图 7.4 自己检查卷积模式匹配

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在让 PyTorch 进行数学计算之前,您是否检查了图 7.4 中的数学计算?确保在让 PyTorch 进行数学计算之前进行此操作,以嵌入此数学模式到您的神经网络中,以便将来如果您需要调试 CNN 中的问题,您就可以进行数学计算。

在 PyTorch 或任何其他设计用于同时处理多个样本的深度学习框架中,您必须将核张量进行扩展,以添加一个维度来容纳额外的样本。您扩展的核(权重矩阵)需要与输入数据的批次具有相同的形状。第一个维度用于输入到卷积层的来自训练或测试数据集的样本。通常,这将是嵌入层的输出,并且已经具有适当的大小。但是,由于您正在硬编码所有权重和输入数据以了解 Conv1d 层的工作原理,因此您需要扩展 2-D 张量矩阵以创建 3-D 张量立方体。由于您只有一个引用要通过卷积推进数据集,因此您只需要在第一个维度上具有大小为 1 的尺寸。

列表 7.8 将硬编码的权重加载到 Conv1d 层中
>>> kernel = torch.tensor(kernel.values, dtype=torch.float32)
>>> kernel = kernel.unsqueeze(0)  # #1
>>> conv = torch.nn.Conv1d(in_channels=4,
...                     out_channels=1,
...                     kernel_size=3,
...                     bias=False)
>>> conv.load_state_dict({'weight': kernel})
>>> print(conv.weight)

tensor([[[1., 0., 0.],
         [0., 0., 0.],
         [0., 1., 0.],
         [0., 0., 1.]]])

最后,您准备好看看您手工制作的核心是否可以检测到文本中的副词、动词、名词序列。

列表 7.9 通过卷积层运行单个示例
>>> y = np.array(conv.forward(x).detach()).squeeze()
>>> df.loc['y'] = pd.Series(y)
>>> df
        0      1     2    3    4   ...   15       16     17     18   19
token  The  right  word  may   be  ...    a  rightly  timed  pause    .
ADV      0      0     0    0    0  ...    0        1      0      0    0
ADJ      0      1     0    0    0  ...    0        0      0      0    0
VERB     0      0     0    1    0  ...    0        0      1      0    0
NOUN     0      0     1    0    0  ...    0        0      0      1    0
y      1.0    0.0   1.0  0.0  0.0  ...  0.0      3.0    0.0    NaN  NaN
图 7.5 Conv1d 输出正确预测定时暂停

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

y 值达到最大值 3,其中内核中的所有 3 个值为 1 的部分与句子中的三个 1 完美匹配,形成了相同的词性标签模式。您的内核正确地检测到了句子末尾的副词、动词、名词序列。您卷积输出的值为 3 是正确的,因为在序列中第 16 个单词“rightly” 的位置,存在 3 个与您的模式匹配的单词。这是匹配您的模式的 3 个单词序列的位置,分别位于位置 16、17 和 18。而且,输出值为三是有意义的,因为每个匹配的词性都在您的内核中具有权重为一,总共有三个匹配。

别担心,您永远不必再为卷积神经网络手工制作内核…除非您想提醒自己数学是如何工作的,以便向他人解释。

7.2.7 自然示例

在眼睛和相机的光学世界中,卷积无处不在。当您通过偏振太阳镜向下看海洋或湖泊表面时,镜片会对光进行卷积以滤除噪声。偏振眼镜的镜片有助于渔民滤除散射光,并看穿水面下找到鱼。

至于更疯狂的例子,想象一下一只斑马站在围栏后面。斑马的条纹可以被视为一种视觉自然语言。斑马的条纹向捕食者和潜在伴侣发送关于斑马健康状况的信号。当斑马在草地、竹林或树干之间奔跑时发生的卷积会产生一种闪烁效果,使斑马难以捕捉。

在图 7.6 中,您可以将卡通围栏视为交替数值的内核。而背景中的斑马则像您的数据,其条纹中的光暗区域具有交替的数值。而且卷积是对称的,因为乘法和加法是可交换的操作。因此,如果您愿意,您可以将斑马的条纹视为滤波器,而一长段围栏视为数据。

图 7.6 斑马在围栏后面 ^([18])

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

想象一下图 7.6 中的斑马走在围栏后面,或者围栏在斑马前面滑动。当斑马行走时,围栏中的缝隙将定期与斑马的条纹对齐。这将在我们移动围栏(内核)或斑马时创建光与暗的图案。当斑马的黑色条纹与棕色围栏的缝隙对齐时,这些地方将变暗。当斑马的白色部分与围栏的缝隙对齐时,它们就能透过,因此斑马会显得更亮。因此,如果您想要识别黑色和白色的交替值或交替的数值,您可以在您的内核中使用交替的高(1)和低值(0)。

如果你不经常看到斑马在栅栏后面走动,也许下一个类比会更好理解。如果你在海滩上待一段时间,你可以把浪潮想象成海底的一种自然机械卷积。当波浪经过海底并接近海滩时,它们会上升或下降,这取决于水面下隐藏的东西,比如沙洲、大石头或礁石。沙洲和石头就像你试图用卷积神经网络检测的单词意义的组成部分一样。波浪在沙洲上涨的过程就像卷积乘法操作一样,在你的数据上波浪潮过去。

现在想象一下,你在靠近水边挖了一个洞。当浪潮爬上岸时,取决于波浪的高度,一些浪潮会溢入你的小水池中。你沙堡前的水池或护城河就像卷积中的减少或求和操作一样。事实上,你会看到我们后来使用的一种操作叫做“最大池化”,它在卷积神经网络中的行为非常像这样。最大池化帮助你的卷积测量出特定单词模式的“影响”,就像你的沙堆在海岸上累积了浪潮的影响一样。即使没有别的,这张关于浪潮和沙堡的图像也会帮助你在本章后面看到时记住技术术语最大池化

7.3 莫尔斯电码

在 ASCII 文本和计算机甚至电话出现之前,还有另一种交流自然语言的方式:莫尔斯电码。莫尔斯电码是一种将点和短划替代自然语言字母和单词的文本编码。这些点和短划在电报线上或无线电波上变成长音和短音的蜂鸣声。莫尔斯电码听起来就像一个非常缓慢的拨号上网连接中的蜂鸣声。在本节后面的 Python 示例中播放音频文件,亲自听一下吧。业余无线电操作员通过敲击单个键向世界各地发送消息。你能想象在计算机键盘上输入文本,而键盘上只有一个键,就像图 7.7 中的 Framework 笔记本的空格键一样吗?

图 7.7 单个关键的笔记本键盘

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7.8 显示了一个实际的莫尔斯电码键的样子。就像计算机键盘上的键或游戏控制器上的开火按钮一样,莫尔斯电码键只在按下按钮时关闭电气接触。

图 7.8 一把古董莫尔斯电码键

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

莫尔斯电码是一种设计成只需按下一个键敲出的语言,就像这样。它在电报时代被广泛使用,在电话使得通过电线发送语音和数据成为可能之前。为了在纸上可视化莫尔斯电码,人们用点和线来代表按键的短敲和长敲。按下键时,你短暂地向外发出一个点,而稍微按住键则会发出一个破折号。当你根本不按下该键时则是完全的沉默。所以它和输入文本不太一样。更像是把你的键盘当作游戏手柄上的开火按钮。你可以把莫尔斯电码键想象成视频游戏激光或以按下键的时候才发送能量的任何东西。你甚至可以通过在多人游戏中将武器当作电报机来发送秘密信息。

要在计算机键盘上仅使用一个键进行通信几乎是不可能的,如果不是萨缪尔·莫尔斯创造新的自然语言的工作,就不会有这种可能。莫尔斯在设计莫尔斯电码的语言方面做得非常好,即使像我这样拙笨的业余无线电操作员也可以在紧急情况下使用它。接下来,你将学习这种语言中最重要的两个字母,以便在紧急情况下也能使用它。不用担心,你只需要学习这个语言的两个字母就足够了。这应该足以让你更清楚地理解卷积以及它在自然语言上的工作原理。

图 7.9 莫尔斯电码字典

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

莫尔斯电码至今仍然在无线电波嘈杂的情况下使用,以便使别人能够理解你的语音。当你真的,真的,真的需要传达信息时,它尤其有用。被困在沉没的潜艇或船内的水下空腔的水手使用莫尔斯电码在金属船体上敲出来与营救者进行交流。在地震或矿井事故后被埋在瓦砾下的人们会用金属管道和钢梁敲击来与救援人员进行通信。如果你懂一点莫尔斯电码,你也许可以通过用莫尔斯电码敲出你的话与别人进行双向对话。

这是一个以莫尔斯电码进行广播的秘密消息的音频数据示例。在接下来的部分中,你将使用手工制作的卷积核处理这个数据。现在,你可能只想播放音频轨道,以便听到莫尔斯电码的声音是什么样子。

代码清单 7.10 下载秘密
>>> from nlpia2.init import maybe_download

>>> url = 'https://upload.wikimedia.org/wikipedia/' \
      'commons/7/78/1210secretmorzecode.wav'
>>> filepath = maybe_download(url)  # #1
>>> filepath
'/home/hobs/.nlpia2-data/1210secretmorzecode.wav'

当然,你的.nlpia2-data目录将位于你的$HOME目录下,而不是我的。这里是这些示例中使用的所有数据。现在,你可以加载 wav 文件,以创建一个包含音频信号的数值数组,稍后可以用卷积进行处理。

7.3.1 使用卷积解码莫尔斯电码

如果您了解一点 Python,您可以构建一个能够为您解释摩尔斯电码的机器,这样您就不必记住图 7.9 摩尔斯电码字典中所有的点和划线了。在僵尸启示录或“大事件”(加州地震)期间可能会派上用场。只需确保保留能够运行 Python 的计算机或手机。

第 7.11 列 加载秘密摩尔斯电码 WAV 文件
>>> from scipy.io import wavfile

>>> sample_rate, audio = wavfile.read(filepath)
>>> print(f'sample_rate: {sample_rate}')
>>> print(f'audio:\n{audio}')
sample_rate: 4000
audio:
[255   0 255 ...   0 255   0]

这个 WAV 文件中的音频信号在哔哔声时在 255 和 0 之间振荡(最大和最小的 uint8 值)。因此,您需要使用 abs() 对信号进行矫正,然后将其标准化,使信号在播放音调时为 1,在没有音调时为 0。您还希望将采样数转换为毫秒,并对信号进行降采样,以便更容易地检查单个值并查看发生了什么。第 7.12 列 居中、标准化和降采样音频数据,并提取此音频数据的前两秒。

第 7.12 列 标准化和降采样音频信号
>>> pd.options.display.max_rows = 7

>>> audio = audio[:sample_rate * 2]  # #1
>>> audio = np.abs(audio - audio.max() / 2) - .5  # #2
>>> audio = audio / audio.max()  # #3
>>> audio = audio[::sample_rate // 400]  # #4
>>> audio = pd.Series(audio, name='audio')
>>> audio.index = 1000 * audio.index / sample_rate  # #5
>>> audio.index.name = 'time (ms)'
>>> print(f'audio:\n{audio}')

现在,您可以使用 audio.plot() 绘制闪亮的新摩尔斯电码点和划线。

第 7.10 图 方波摩尔斯电码秘密消息

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

您能在图 7.10 中看到点在哪里吗?点是 60 毫秒的静音(信号值为 0),然后是 60 毫秒的音调(信号值为 1),然后再次是 60 秒的静音(信号值为 0)。

要通过卷积检测点,您需要设计一个与低、高、低的模式匹配的核心。唯一的区别是对于低信号,您需要使用负一而不是零,这样数学就会加起来。您希望卷积的输出在检测到点符号时为 1。

第 7.12 列 展示了如何构建点检测核心。

第 7.13 列 点检测核
>>> kernel = [-1] * 24 + [1] * 24 + [-1] * 24  # #1
>>> kernel = pd.Series(kernel, index=2.5 * np.arange(len(kernel)))
>>> kernel.index.name = 'Time (ms)'
>>> ax = kernel.plot(linewidth=3, ylabel='Kernel weight')
第 7.11 图 摩尔斯电码点检测核心

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

您可以通过将其与音频信号进行卷积来尝试您手工制作的核心,以查看它是否能够检测到点。目标是使卷积信号在点符号出现时高、接近于 1,在音频中的短脉冲。您还希望您的点检测卷积在点之前或之后的任何短划线或静音处返回低值(接近于零)。

第 7.14 列 点检测器与秘密消息卷积
>>> kernel = np.array(kernel) / sum(np.abs(kernel))  # #1
>>> pad = [0] * (len(kernel) // 2)  # #2
>>> isdot = convolve(audio.values, kernel)
>>> isdot =  np.array(pad[:-1] + list(isdot) + pad)  # #3
>>> df = pd.DataFrame()
>>> df['audio'] = audio
>>> df['isdot'] = isdot - isdot.min()
>>> ax = df.plot()
第 7.12 图 手工制作的点检测卷积

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

看起来手工制作的核心做得不错!卷积输出仅在点符号的中间接近于 1。

现在您了解了卷积的工作原理,可以随意使用 np.convolve() 函数。它运行更快,并为您提供了更多关于填充处理的 mode 选项。

第 7.15 列 NumPy 卷积
>>> isdot = np.convolve(audio.values, kernel, mode='same')  # #1
>>> df['isdot'] = isdot - isdot.min()
>>> ax = df.plot()
第 7.13 图 NumPy 卷积

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

Numpy 卷积有三种可能的模式可用于进行卷积,按输出长度递增的顺序依次为:

  1. valid: 以纯 Python 为例,只输出len(kernel)-1个卷积值。

  2. same: 通过在数组的开始和结尾之外推算信号,输出与输入长度相同的信号。

  3. full: 输出信号将比输入信号更长。

Numpy 卷积设置为“same”模式似乎在我们的莫尔斯电码音频信号中运作得更好。因此,当在神经网络中进行卷积时,你需要检查你的神经网络库是否使用类似的模式。

建造一个卷积滤波器以在莫尔斯电码音频文件中检测一个单一符号真是一项艰苦的工程。而且这还不是一个自然语言文本的单个字符, 只是 S 字母的三分之一!幸运的是,你辛勤手工制作的日子已经结束了。你可以在神经网络的反向传播中使用它所拥有的强大力量来学习正确的内核以检测解决问题所需的所有不同信号。

7.4 使用 PyTorch 构建 CNN

图 7.14 展示了您如何将文本流入 CNN 网络,然后输出嵌入。与以前的 NLP 流水线一样,需要首先对文本进行标记化。然后您会识别出文本中使用的所有令牌集。您将忽略不想计数的令牌,并为词汇表中的每个单词分配一个整数索引。输入语句有 4 个令牌,因此我们从一个由 4 个整数索引组成的序列开始,每个令牌对应一个索引。

CNN 通常使用单词嵌入来代替单热编码来表示每个单词。您将初始化一个单词嵌入矩阵,该矩阵的行数与词汇表中的单词数量相同,并且如果要使用 300-D 嵌入,则有 300 个列。可以将所有初始单词嵌入设置为零或某些小的随机值。如果要进行知识转移并使用预训练的单词嵌入,则可以在 GloVE、Word2vec、fastText 或任何喜欢的单词嵌入中查找您的令牌。并将这些向量插入到与词汇表索引匹配的行中的嵌入矩阵中。

对于这个四令牌句子,然后可以查找适当的单词嵌入,一旦在单词嵌入矩阵中查找每个嵌入,就会得到一个 4 个嵌入向量的序列。你也会得到额外的填充标记嵌入,它们通常被设置为零,所以它们不会干扰卷积。如果您使用最小的 GloVe 嵌入,那么您的单词嵌入是 50 维的,因此您会得到一个 50 x 4 的数值矩阵,用于这个短句子。

你的卷积层可以使用 1-D 卷积内核处理这 50 个维度中的每一个,稍微挤压一下关于你的句子的这个矩阵的信息。如果你使用了长度为 2 的内核和步幅为 2,你将得到一个大小为 50 x 3 的矩阵来表示四个 50-D 单词向量的序列。

通常使用池化层,通常是最大池化,来进一步减小输出的大小。带有 1-D 内核的最大池化层将把你的三个 50-D 向量的序列压缩成一个单一的 50-D 向量。顾名思义,最大池化将为向量序列中每个通道(维度)的最大和最有影响的输出。最大池化通常相当有效,因为它允许你的卷积为原始文本中每个 n-gram 找到最重要的意义维度。通过多个内核,它们可以分别专门化文本的不同方面,这些方面会影响你的目标变量。

注意

你应该将卷积层的输出称为“编码”,而不是“嵌入”。这两个词都用来描述高维向量,但是“编码”一词暗示着在时间上或序列中的处理。卷积数学在你的单词向量序列中的时间内发生,而“嵌入”向量是单个不变令牌的处理结果。嵌入不编码任何有关单词顺序或序列的信息。编码是对文本含义的更完整的表示,因为它们考虑了单词顺序,就像你的大脑一样。

由 CNN 层输出的编码向量是一个具有你指定的任意大小(长度)的向量。你的编码向量的长度(维度数)与输入文本的长度无关。

图 7.14 CNN 处理层 ^([22])

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

你将需要利用前几章的所有技能来整理文本,以便将其输入到你的神经网络中。图 7.14 中你的管道的前几个阶段是你在前几章中做的标记和大小写转换。你将利用前面示例中的经验来决定忽略哪些单词,比如停用词、标点符号、专有名词或非常罕见的单词。

根据你手工制作的任意停用词列表过滤和忽略单词通常是一个不好的主意,特别是对于像 CNN 这样的神经网络。词形还原和词干提取通常也不是一个好主意。模型将比你用直觉猜测的更了解你的令牌的统计信息。你在 Kaggle、DataCamp 和其他数据科学网站上看到的大多数示例都会鼓励你手工制作管道的这些部分。你现在知道得更清楚了。

你也不会手工制作卷积内核。你会让反向传播的魔力来处理这些事情。神经网络可以学习模型的大部分参数,例如哪些词要忽略,哪些词应该被合并在一起,因为它们具有相似的含义。实际上,在第六章中,您已经学会了用嵌入向量来表示单词的含义,这些嵌入向量精确地捕捉了它们与其他单词的相似程度。只要有足够的数据来创建这些嵌入向量,您就不再需要处理词形还原和词干提取。

7.4.1 裁剪和填充

CNN 模型需要一致长度的输入文本,以便编码中的所有输出值在向量中处于一致的位置。这确保了你的 CNN 输出的编码向量始终具有相同的维度,无论你的文本是多长或多短。你的目标是创建一个字符串和一个整页文本的向量表示。不幸的是,CNN 不能处理可变长度的文本,所以如果你的文本对于 CNN 来说太长,就会将许多单词和字符在字符串末尾进行 “裁剪”。而且你需要插入填充令牌,称为 padding,来填补那些对于您的 CNN 来说太短的字符串中的空白部分。

请记住,卷积操作始终会减少输入序列的长度,无论其长度多长。卷积操作始终会将输入序列的长度减少一个比内核大小少的数。而任何池化操作,如最大池化,也会一致地减少输入序列的长度。因此,如果您没有进行任何填充或裁剪,长句子会产生比短句子更长的编码向量。而这对于需要具有大小不变性的编码是不起作用的。无论输入的大小如何,你希望你的编码向量始终具有相同的长度。

这是向量的基本属性,即它们在整个你正在处理的向量空间中具有相同数量的维度。你希望你的 NLP 流水线能够在相同的位置或向量维度上找到特定的含义,无论这种情感在文本的哪个位置发生。填充和裁剪可以确保你的 CNN 在位置(时间)和大小(持续时间)上是不变的。基本上,只要这些模式在您的 CNN 可处理的最大长度范围内的任何位置,您的 CNN 就可以在文本的含义中找到这些模式,无论这些模式在文本中的位置如何。

你可以选择任何你喜欢的符号来表示填充标记。许多人使用标记 “”,因为它在任何自然语言字典中都不存在。大多数说英语的自然语言处理工程师都能猜到 “” 的含义。而且你的自然语言处理管道会注意到这些标记在许多字符串的末尾重复出现。这将帮助它在嵌入层中创建适当的 “填充” 情感。如果你对填充情感的样子感到好奇,加载你的嵌入向量,比较 “” 的嵌入和 “blah”(如 “blah blah blah”)的嵌入。你只需要确保使用一致的标记,并告诉你的嵌入层你用于填充标记的令牌是什么。通常将其作为你的 id2tokenvocab 序列中的第一个标记,以便它具有索引和 id 值 0

一旦你告诉大家你的填充标记是什么,你现在需要决定一个一致的填充方法。就像在计算机视觉中一样,你可以在你的令牌序列的任意一侧填充,即开头或结尾。你甚至可以拆分填充,将一半放在开头,另一半放在结尾。只是不要把它们插在单词之间。那会干扰卷积计算。并确保你添加的填充标记的总数能够创建正确长度的序列用于你的 CNN。

在清单 7.16 中,您将加载由 Kaggle 贡献者标记了其新闻价值的 “birdsite”(微博)帖子。稍后您将使用您的 CNN 模型来预测 CNN(有线电视新闻网)是否会在 “miasma.” 中的新闻在自己传播之前 “采取”。

重要提示

我们有意使用能引导您朝着亲社会、真实、注意力集中的行为的词语。弥漫在互联网上的黑暗模式已经引导了科技界的创意中坚力量创建了一个替代的、更真实的宇宙,拥有它自己的词汇。

“Birdsite”:“fedies” 称之为 Twitter

“Fedies”:使用保护您健康和隐私的联合社交媒体应用的用户

“Fediverse” 联合社交媒体应用的替代宇宙(Mastodon,PeerTube)

“Nitter” 是 Twitter 的一个不那么操纵的前端。

“Miasma” 是尼尔·斯蒂芬森对一个爱情的互联网的称呼

清单 7.16 加载新闻帖子
>>> df = pd.read_csv(HOME_DATA_DIR / 'news.csv')
>>> df = df[['text', 'target']]  # #1
>>> print(df)
text  target
0     Our Deeds are the Reason of this #earthquake M...       1
1                Forest fire near La Ronge Sask. Canada       1
2     All residents asked to 'shelter in place' are ...       1
...                                                 ...     ...
7610  M1.94 [01:04 UTC]?5km S of Volcano Hawaii. htt...       1
7611  Police investigating after an e-bike collided ...       1
7612  The Latest: More Homes Razed by Northern Calif...       1
[7613 rows x 2 columns]

您可以在上面的例子中看到,一些微博帖子几乎达到了 birdsite 的字符限制。其他则通过较少的词语表达了观点。因此,您需要对这些较短的文本进行填充,以便数据集中的所有示例具有相同数量的令牌。如果您计划在管道的后期过滤掉非常频繁的词或非常罕见的词,您的填充函数也需要填补这些差距。因此,清单 7.17 对这些文本进行了标记化,并过滤掉了其中的一些最常见的标记。

清单 7.17 词汇表中最常见的单词
import re
from collections import Counter
from itertools import chain
HOME_DATA_DIR = Path.home() / '.nlpia2-data'

counts = Counter(chain(*[
    re.findall(r'\w+', t.lower()) for t in df['text']]))  # #1
vocab = [tok for tok, count in counts.most_common(4000)[3:]]  # #2

print(counts.most_common(10))
[('t', 5199), ('co', 4740), ('http', 4309), ('the', 3277), ('a', 2200),
    ('in', 1986)]

你可以看到,令牌 “t” 出现的次数几乎和帖子数(7613)一样多(5199)。这看起来像是由 url 缩短器创建的部分 url,通常用于跟踪这个应用程序上的微博主。如果你希望你的 CNN 专注于人类可能会阅读的内容中的单词的含义,你应该忽略前三个类似 url 的令牌。如果你的目标是构建一个像人类一样阅读和理解语言的 CNN,那么你将创建一个更复杂的分词器和令牌过滤器,以去除人类不关注的任何文本,例如 URL 和地理空间坐标。

一旦你调整好了你的词汇表和分词器,你就可以构建一个填充函数,以便在需要时重复使用。如果你的 pad() 函数足够通用,就像清单 7.18 中一样,你可以将它用于字符串令牌和整数索引。

清单 7.18 多功能填充函数
def pad(sequence, pad_value, seq_len):
    padded = list(sequence)[:seq_len]
    padded = padded + [pad_value] * (seq_len - len(padded))
    return padded

我们还需要为 CNN 的良好工作进行最后一个预处理步骤。你想要包含你在第六章学到的令牌嵌入。

7.4.2 用单词嵌入进行更好的表示

想象一下,你正在将一小段文本通过你的管道运行。图 7.15 展示了在你将单词序列转换为数字(或向量,提示提示)进行卷积操作之前的样子。

图 7.15 卷积步幅

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

现在你已经组装了一个令牌序列,你需要很好地表示它们的含义,以便你的卷积能够压缩和编码所有这些含义。在第 5 和 6 章中我们使用的全连接神经网络中,你可以使用 one-hot 编码。但是 one-hot 编码会创建极其庞大、稀疏的矩阵,而现在你可以做得更好。你在第六章学到了一种非常强大的单词表示方式:单词嵌入。嵌入是你的单词的更加信息丰富和密集的向量表示。当你用嵌入来表示单词时,CNN 和几乎任何其他深度学习或 NLP 模型都会表现得更好。图 7.11 展示了如何做到这一点。

图 7.16 用于卷积的单词嵌入

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 7.16 展示了 PyTorch 中 nn.Embedding 层在幕后执行的操作。为了让你了解 1-D 卷积如何在你的数据上滑动,该图显示了一个两个长度的核在你的数据上移动的 3 个步骤。但是一个 1-D 卷积如何在一个 300-D GloVe 单词嵌入序列上工作呢?你只需要为你想要查找模式的每个维度创建一个卷积核(滤波器)。这意味着你的单词向量的每个维度都是卷积层中的一个通道。

不幸的是,许多博客文章和教程可能会误导您关于卷积层的正确尺寸。 许多 PyTorch 初学者认为 Embedding 层的输出可以直接流入卷积层而不需要任何调整大小。 不幸的是,这将创建一个沿着单词嵌入维度而不是单词序列的 1-D 卷积。 因此,您需要转置您的嵌入层输出,以使通道(单词嵌入维度)与卷积通道对齐。

PyTorch 有一个 nn.Embedding 层,您可以在所有深度学习流水线中使用。 如果您希望模型从头开始学习嵌入,您只需要告诉 PyTorch 您需要多少嵌入,这与您的词汇量大小相同。 嵌入层还需要您告诉它为每个嵌入向量分配多少维度。 可选地,您可以定义填充令牌索引 id 号。

代码清单 7.19 从头开始学习嵌入
from torch import nn

embedding = nn.Embedding(
    num_embeddings=2000,  # #1
    embedding_dim=64,  # #2
    padding_idx=0)

嵌入层将是您的 CNN 中的第一层。这将把您的令牌 ID 转换成它们自己独特的 64-D 单词向量。在训练期间的反向传播将调整每个单词在每个维度上的权重,以匹配单词可用于谈论新闻灾害的 64 种不同方式。这些嵌入不会像第六章中的 FastText 和 GloVe 向量一样代表单词的完整含义。这些嵌入只有一个好处,那就是确定一条 Tweet 是否包含新闻灾害信息。

最后,您可以训练您的 CNN,看看它在像 Kaggle 灾难推文数据集这样的极窄数据集上的表现如何。 那些花费时间打造 CNN 的小时将以极快的训练时间和令人印象深刻的准确性得到回报。

代码清单 7.20 从头开始学习嵌入
from nlpia2.ch07.cnn.train79 import Pipeline  # #1

pipeline = Pipeline(
    vocab_size=2000,
    embeddings=(2000, 64),
    epochs=7,
    torch_random_state=433994,  # #2
    split_random_state=1460940,
)

pipeline = pipeline.train()
Epoch: 1, loss: 0.66147, Train accuracy: 0.61392, Test accuracy: 0.63648
Epoch: 2, loss: 0.64491, Train accuracy: 0.69712, Test accuracy: 0.70735
Epoch: 3, loss: 0.55865, Train accuracy: 0.73391, Test accuracy: 0.74278
Epoch: 4, loss: 0.38538, Train accuracy: 0.76558, Test accuracy: 0.77165
Epoch: 5, loss: 0.27227, Train accuracy: 0.79288, Test accuracy: 0.77690
Epoch: 6, loss: 0.29682, Train accuracy: 0.82119, Test accuracy: 0.78609
Epoch: 7, loss: 0.23429, Train accuracy: 0.82951, Test accuracy: 0.79003

仅仅经过 7 次通过训练数据集,您就在测试集上实现了 79% 的准确率。 在现代笔记本电脑 CPU 上,这应该不到一分钟。 并且通过最小化模型中的总参数,您将过拟合保持到最低。 与嵌入层相比,CNN 使用的参数非常少。

如果您继续训练一段时间会发生什么?

代码清单 7.21 继续训练
pipeline.epochs = 13  # #1
pipeline = pipeline.train()
Epoch: 1, loss: 0.24797, Train accuracy: 0.84528, Test accuracy: 0.78740
Epoch: 2, loss: 0.16067, Train accuracy: 0.86528, Test accuracy: 0.78871
...
Epoch: 12, loss: 0.04796, Train accuracy: 0.93578, Test accuracy: 0.77690
Epoch: 13, loss: 0.13394, Train accuracy: 0.94132, Test accuracy: 0.77690

哦,这看起来很可疑。 过拟合太严重了 - 在训练集上达到了 94%,在测试集上达到了 78%。 训练集准确率不断上升,最终超过了 90%。 到了第 20 个 epoch,模型在训练集上的准确率达到了 94%。 它甚至比专家人类还要好。 自己阅读几个示例,不看标签,你能得到其中的 94% 吗? 这是前四个示例,经过令牌化后,忽略了词汇表外的词汇,并添加了填充。

pipeline.indexes_to_texts(pipeline.x_test[:4])
['getting in the poor girl <PAD> <PAD> ...',
 'Spot Flood Combo Cree LED Work Light Bar Offroad Lamp Full ...',
 'ice the meltdown <PAD> <PAD> <PAD> <PAD> ...',
 'and burn for bush fires in St http t co <PAD> <PAD> ...']

如果你的答案是[“disaster”, “not”, “not”, “disaster”],那你全部答对了。但继续努力吧。你能做到十九对二十吗?这就是你需要在训练集准确率上击败这个卷积神经网络所需要做到的。这不是什么意外,因为机器人一直在推特上发布听起来像是灾难的推文。有时甚至真实的人类也会对世界事件感到讽刺或煽动性。

是什么导致了这种过拟合?是参数太多了吗?神经网络的"容量"太大了吗?以下是一个好的函数,用于显示 PyTorch 神经网络每层的参数。

>>> def describe_model(model):  # #1
...     state = model.state_dict()
...     names = state.keys()
...     weights = state.values()
...     params = model.parameters()
>>>     df = pd.DataFrame()
>>>     df['name'] = list(state.keys())
>>>     df['all'] = p.numel(),
...     df['learned'] = [
...         p.requires_grad  # #2
...         for p in params],  # #3
...     size=p.size(),
...     )
        for name, w, p in zip(names, weights, params)
    ]
    )
    df = df.set_index('name')
    return df

describe_model(pipeline.model)  # #4
learned_params  all_params        size
name
embedding.weight             128064      128064  (2001, 64)  # #1
linear_layer.weight            1856        1856   (1, 1856)
linear_layer.bias                 1           1        (1,)

当你遇到过拟合问题时,你可以在管道中使用预训练模型来改善其泛化能力。

7.4.3 迁移学习

另一个可以帮助你的 CNN 模型的优化方法是使用预训练的词嵌入,如 GloVe。这并不是作弊,因为这些模型是以无监督的方式训练的,没有使用你的灾难推文数据集的任何标签。你可以将这些 GloVe 向量中所包含的所有学习迁移到您训练模型所使用的与灾难相关的词汇集上,通过使用单词的更一般的含义。你只需要调整嵌入层的大小,以容纳你希望用来初始化 CNN 的 GloVe 嵌入的大小。

代码清单 7.22:为 GloVE 嵌入腾出空间
>>> from torch import nn
>>> embedding = nn.Embedding(
...     num_embeddings=2000,  # #1
...     embedding_dim=50,  # #2
...     padding_idx=0)

就是这样。一旦 PyTorch 知道嵌入的数量和它们的维度,它就可以分配内存来保存嵌入矩阵,其中有 num_embedding 行和 embedding_dim 列。这将同时训练你的嵌入和其余的 CNN 部分。你的领域特定的词汇和嵌入将根据你的语料库进行自定义。但是,从头开始训练你的嵌入没有利用到单词在许多领域中共享的含义。

如果你希望你的管道能"跨域适应",你可以使用在其他领域训练的嵌入。这种词嵌入的"跨训练"被称为迁移学习。通过使用在更广泛的文本语料库上训练的预训练词嵌入,这为你的嵌入层提前了解了单词的含义。为此,你需要过滤掉其他领域中使用的所有单词,以便你的 CNN 管道的词汇仅基于你的数据集中的单词。然后,你可以将这些单词的嵌入加载到你的nn.Embedding层中。

代码清单 7.23:加载嵌入并与你的词汇对齐
>>> from nessvec.files import load_vecs_df
>>> glove = load_vecs_df(HOME_DATA_DIR / 'glove.6B.50d.txt')
>>> zeroes = [0.] * 50
>>> embed = []
>>> for tok in vocab:  # #1
...     if tok in glove.index:
...         embed.append(glove.loc[tok])
...     else:
...         embed.append(zeros.copy())  # #2
>>> embed = np.array(embed)
>>> embed.shape
(4000, 50)

现在你已经将 4000 个标记的词汇表转换为一个 4000×5 的嵌入矩阵。embed数组中的每一行都表示一个具有 50 维向量的词汇表标记的含义。如果在你的词汇表中一个标记的 GloVe 嵌入不存在,那么它将有一个全为零的向量。这本质上使得那个标记对于理解你的文档毫无用处,就像一个 OOV(词汇表外)标记一样。

>>> pd.Series(vocab)
0               a
1              in
2              to
          ...
3831         43rd
3832    beginners
3833        lover
Length: 3834, dtype: object

你已经从推文中取出了最常见的 4000 个标记。在这 4000 个词中,最小的 GloVE 词嵌入词汇表中有 3834 个可用的。因此,你用零向量填充了那 166 个缺失词的未知嵌入。当你在神经网络中训练嵌入层时,你的模型会学习这些词的意义并计算它们的嵌入。

现在你有了一种将标记转换为整数的一致方法,你可以将 GloVe 嵌入矩阵加载到你的 nn.Embedding层中。

代码清单 7.24 初始化使用 GloVE 向量的嵌入层
embed = torch.Tensor(embed)  # #1
print(f'embed.size(): {embed.size()}')
embed = nn.Embedding.from_pretrained(embed, freeze=False)  # #2
print(embed)
检测有意义的模式

你说话的方式、单词的顺序,都很重要。你组合单词以创建对你来说具有重要意义的模式,以便将那个意义传达给其他人。

如果你希望你的机器成为一个有意义的自然语言处理器,它需要能够检测到更多不仅仅是特定标记的存在或不存在。你希望你的机器能够检测到隐藏在单词序列中的有意义的模式。^([23])

卷积是过滤器,它可以从单词中提取有意义的模式。最好的部分是,你不再需要将这些模式硬编码到卷积核中。训练过程将搜索最佳的模式匹配卷积,以解决你遇到的问题。每次将标记数据的错误通过网络向后传递时(反向传播),优化器会调整每个过滤器中的权重,使它们在检测意义和分类文本示例方面变得越来越好。

7.4.4 使用丢弃进行卷积神经网络的健壮性增强

大多数神经网络容易受到对抗样本的影响,这些样本会欺骗它们输出错误的分类或文本。有时,神经网络容易受到同义词替换、拼写错误或俚语插入等简单变化的影响。有时候只需要一点“语词沙拉”——无意义的随机词语——就能分散并困惑 NLP 算法。人类知道如何忽略噪音和过滤干扰,但机器有时会遇到麻烦。

*鲁棒化自然语言处理(NLP)*是研究处理来自不同来源的非常规文本的方法和技术。事实上,鲁棒化 NLP 的研究可能会揭示通向人工通用智能的路径。人类能够从极少的例子中学习新词和概念。而我们的泛化能力很好,既不过多也不过少。机器需要一点帮助。如果你能找出我们人类擅长之处的“秘密酱料”,然后将其编码到 NLP 流程中,那你就能够让机器具备类似的能力。

增强神经网络鲁棒性的一种常见技术是随机丢弃法。由于其简便性和有效性,随机丢弃法或简称丢弃法已经变得非常流行。你的神经网络几乎总会从丢弃层中受益。丢弃层会随机隐藏部分神经元的输出,使其不被其他神经元接收。这会导致你人造脑中的某条路径变得静音,并迫使其他神经元在丢弃期间学习当前的特定示例。

这似乎与直觉相悖,但丢弃法有助于使你的神经网络学习更广泛。如果没有丢弃层,你的网络将专注于帮助提高最大准确度的单词、模式和卷积滤波器。但你需要神经元们扩展他们的模式,以便你的网络能够对自然语言文本上的常见变化保持“健壮性”。

在神经网络中安装丢弃层的最佳位置是靠近末尾,就在运行完全连接的线性层之前。这个向量通过线性层传递的权重是来自 CNN 和池化层的输出。每个值代表一系列单词或意义和语法模式。通过隐藏一些模式,迫使你的预测层扩展其“思考”。虽然你的软件并没有真正考虑什么,但如果将其拟人化一点能够帮助你对为什么随机丢弃等技术可以提高模型准确度产生直觉。

7.5 使用 PyTorch CNN 处理灾难推文

现在进入有趣的部分。你要构造一个真实世界的 CNN,可以区分真实新闻和煽动性报道。你的模型可以帮助你过滤掉与文化战争有关的推文,让你专注于来自真实战区的新闻。

首先,你将看到新的卷积层在管道中的位置。然后你将组装所有组件,来训练一个基于“灾难推文”数据集的 CNN。如果负能量滚动和灾难不是你的菜的话,这个 CNN 也很容易适应任何带标签的推文数据集。你甚至可以选择一个你喜欢的话题作为目标标签,即使推文的作者不知道如何使用标签,你也可以找到与该标签话题相匹配的推文。

7.5.1 网络架构

这里是 CNN NLP 管道的每个阶段的处理步骤和张量的相应形状。构建新 CNN 中最棘手的事情之一是跟踪您张量的形状。您需要确保一个层的输出形状与下一层的输入形状相匹配,对于此示例与以前的示例相同。

  1. 令牌化⇒(N_, )

  2. 填充⇒(N,)

  3. 嵌入⇒(M, N)

  4. 卷积(s)⇒(M, N - K)

  5. 激活⇒(M, N - K)

  6. 池化⇒(M, N - K)

  7. 丢弃(可选)⇒(M, N - K)

  8. 线性组合⇒(L, )

  9. Argmax、softmax 或阈值化⇒(L, )

  • N_是您输入文本中的标记数。

  • N是您填充序列中的标记数。

  • M是您的单词嵌入中的维度数。

  • K是您的核大小。

  • L是您想要预测的类标签或值的数量。

您的 CNN 的 PyTorch 模型比第 5 和第六章中的要多一些超参数。然而,与以前一样,将超参数设置在CNNTextClassifier模型的*init*构造函数中是一个好主意。

列表 7.25 CNN 超参数
class CNNTextClassifier(nn.Module):

    def __init__(self, embeddings):
        super().__init__()

        self.seq_len = 40  # #1
        self.vocab_size = 10000  # #2
        self.embedding_size = 50  # #3
        self.out_channels = 5  # #4
        self.kernel_lengths = [2, 3, 4, 5, 6]  # #5
        self.stride = 1  # #6
        self.dropout = nn.Dropout(0)  # #7
        self.pool_stride = self.stride  # #8
        self.conv_out_seq_len = calc_out_seq_len(  # #9
            seq_len=self.seq_len,
            kernel_lengths=self.kernel_lengths,
            stride=self.stride,
            )

就像本章前面手工制作的卷积一样,每个卷积操作都会减少序列长度。缩短的量取决于内核的大小和步幅。Conv1d层的 PyTorch 文档提供了这个公式和对术语的详细解释。^([25])

def calc_conv_out_seq_len(seq_len, kernel_len,
                          stride=1, dilation=1, padding=0):
    """
    L_out =     (L_in + 2 * padding - dilation * (kernel_size - 1) - 1)
            1 + _______________________________________________________
                                        stride
    """
    return (
        1 + (seq_len +
             2 * padding - dilation * (kernel_len - 1) - 1
            ) //
        stride
        )

您的第一个 CNN 层是一个nn.Embedding层,它将一系列单词 ID 整数转换为一系列嵌入向量。它的行数与词汇表中唯一标记的数量相同(包括新的填充标记)。它的每个嵌入向量的维度都有一列。您可以从 GloVe 或任何其他预训练的嵌入中加载这些嵌入向量。

列表 7.26 初始化 CNN 嵌入
self.embed = nn.Embedding(
    self.vocab_size,  # #1
    self.embedding_size,  # #2
    padding_idx=0)
state = self.embed.state_dict()
state['weight'] = embeddings  # #3
self.embed.load_state_dict(state)

接下来,您想构建卷积和池化层。每个卷积层的输出大小可以用来定义一个池化层,其核占据整个卷积层输出序列。这就是您在 PyTorch 中完成“全局”最大池化的方法,以产生每个卷积滤波器(核)输出的单个最大值。这就是自然语言处理专家如克里斯托弗·曼宁和 Yoon Kim 在他们的研究论文中所做的,这些论文取得了最先进的性能。([26])([27])

列表 7.27 构建卷积和池化层
self.convolvers = []
self.poolers = []
total_out_len = 0
for i, kernel_len in enumerate(self.kernel_lengths):
    self.convolvers.append(
        nn.Conv1d(in_channels=self.embedding_size,
                  out_channels=self.out_channels,
                  kernel_size=kernel_len,
                  stride=self.stride))
    print(f'conv[{i}].weight.shape: {self.convolvers[-1].weight.shape}')
    conv_output_len = calc_conv_out_seq_len(
        seq_len=self.seq_len, kernel_len=kernel_len, stride=self.stride)
    print(f'conv_output_len: {conv_output_len}')
    self.poolers.append(
        nn.MaxPool1d(kernel_size=conv_output_len, stride=self.stride))
    total_out_len += calc_conv_out_seq_len(
        seq_len=conv_output_len, kernel_len=conv_output_len,
        stride=self.stride)
    print(f'total_out_len: {total_out_len}')
    print(f'poolers[{i}]: {self.poolers[-1]}')
print(f'total_out_len: {total_out_len}')
self.linear_layer = nn.Linear(self.out_channels * total_out_len, 1)
print(f'linear_layer: {self.linear_layer}')

与以前的例子不同,你现在要创建多个卷积和池化层。对于这个例子,我们不会像在计算机视觉中经常做的那样将它们一层层叠加。相反,你将连接卷积和池化的输出。这是有效的,因为你通过执行全局最大池化限制了卷积和池化输出的维度,并保持了输出通道的数量远远小于嵌入维度的数量。

你可以使用打印语句来帮助调试 CNN 每一层的矩阵形状不匹配的问题。并且你要确保不会无意间创建太多可训练参数,导致过度拟合超过你的预期:你的池化输出每个包含一个长度为 1 的序列,但它们也包含了在卷积期间组合在一起的 5 个通道的嵌入维度。因此,连接和池化的卷积输出是一个 5x5 张量,产生了一个 25-D 线性层的输出张量,编码了每个文本的含义。

列表 7.28 CNN 层形状
conv[0].weight.shape: torch.Size([5, 50, 2])
conv_output_len: 39
total_pool_out_len: 1
poolers[0]: MaxPool1d(kernel_size=39, stride=1, padding=0, dilation=1,
    ceil_mode=False)
conv[1].weight.shape: torch.Size([5, 50, 3])
conv_output_len: 38
total_pool_out_len: 2
poolers[1]: MaxPool1d(kernel_size=38, stride=1, padding=0, dilation=1,
    ceil_mode=False)
conv[2].weight.shape: torch.Size([5, 50, 4])
conv_output_len: 37
total_pool_out_len: 3
poolers[2]: MaxPool1d(kernel_size=37, stride=1, padding=0, dilation=1,
    ceil_mode=False)
conv[3].weight.shape: torch.Size([5, 50, 5])
conv_output_len: 36
total_pool_out_len: 4
poolers[3]: MaxPool1d(kernel_size=36, stride=1, padding=0, dilation=1,
    ceil_mode=False)
conv[4].weight.shape: torch.Size([5, 50, 6])
conv_output_len: 35
total_pool_out_len: 5
poolers[4]: MaxPool1d(kernel_size=35, stride=1, padding=0, dilation=1,
     ceil_mode=False)
total_out_len: 5
linear_layer: Linear(in_features=25, out_features=1, bias=True)

最终结果是一个迅速过拟合的语言模型和文本分类器。你的模型在第 55 个时期达到了最大的测试准确率 73%,在最后一个时期,第 75 个时期达到了最大的训练集准确率 81%。通过增加卷积层的通道数,你甚至可以实现更多的过拟合。通常,你希望确保你的第一次训练运行能够完成过拟合,以确保所有层都正确配置,并为特定问题或数据集设置一个可实现的准确率的上限。

Epoch:  1, loss: 0.76782, Train accuracy: 0.59028, Test accuracy: 0.64961
Epoch:  2, loss: 0.64052, Train accuracy: 0.65947, Test accuracy: 0.67060
Epoch:  3, loss: 0.51934, Train accuracy: 0.68632, Test accuracy: 0.68766
...
Epoch: 55, loss: 0.04995, Train accuracy: 0.80558, Test accuracy: 0.72966
Epoch: 65, loss: 0.05682, Train accuracy: 0.80835, Test accuracy: 0.72178
Epoch: 75, loss: 0.04491, Train accuracy: 0.81287, Test accuracy: 0.71522

通过将每个嵌入的通道数从 5 减少到 3,你可以将总输出维度从 25 减少到 15。这将限制过度拟合,但会降低收敛速率,除非你增加学习系数:

Epoch:  1, loss: 0.61644, Train accuracy: 0.57773, Test accuracy: 0.58005
Epoch:  2, loss: 0.52941, Train accuracy: 0.63232, Test accuracy: 0.64567
Epoch:  3, loss: 0.45162, Train accuracy: 0.67202, Test accuracy: 0.65486
...
Epoch: 55, loss: 0.21011, Train accuracy: 0.79200, Test accuracy: 0.69816
Epoch: 65, loss: 0.21707, Train accuracy: 0.79434, Test accuracy: 0.69423
Epoch: 75, loss: 0.20077, Train accuracy: 0.79784, Test accuracy: 0.70079

7.5.2 池化

池化将大张量中的数据聚合以将信息压缩为较少的值。在“大数据”领域,这通常被称为“减少”操作,其中 map-reduce 软件模式很常见。卷积和池化非常适合 map-reduce 软件模式,并且可以在 GPU 中自动并行化使用 PyTorch。你甚至可以使用多服务器的 HPC(高性能计算)系统来加速训练。但是 CNN 是如此高效,你可能不太需要这种计算能力。

你习惯计算的所有矩阵数据上的统计量都可以作为 CNN 的池化函数有用:

  • min

  • max

  • std

  • sum

  • mean

最常见和最成功的聚合

7.5.3 线性层

连接编码方法为你提供了关于每条微博的大量信息。编码向量有 1856 个值。你在第六章中使用的最大词向量是 300 维。而对于这个特定的流水线,你真正想要的只是对问题“是否新闻值得报道?”的二进制答案。

你还记得第六章中当你试图让神经网络预测关于特定单词出现或缺失的“是或否”问题时,你是如何做的吗?尽管你并没有真正关注这几千个问题的答案(词汇表中每个词一个问题),但现在你面临的问题是一样的。所以你可以采用相同的方法,一个torch.nn.Linear层将会最优地将来自高维向量的所有信息组合在一起,以回答你提出的任何问题。

因此,你需要添加一个线性层,其中包含与从池化层输出的编码维度数量相同的权重。

清单 7.26 显示了计算线性层大小的代码。

清单 7.29 计算 1D 卷积输出的张量大小
out_pool_total = 0
for kernel_len, stride in zip(kernel_lengths, strides):
    out_conv = (
        (in_seq_len - dilation * (kernel_len - 1) - 1) // stride) + 1
    out_pool = (
        (out_conv - dilation * (kernel_len - 1) - 1) // stride) + 1
    out_pool_total += out_pool

7.5.4 得到拟合

在你训练卷积神经网络之前,你需要告诉它如何根据每一批训练数据来调整权重(参数)。你需要计算两个部分,权重相对于损失函数(梯度)的斜率,以及尝试下降该斜率的距离(学习率)。在前面章节中的单层感知机甚至逻辑回归中,你可以使用一些通用的优化器如“Adam”来实现。你通常可以为卷积神经网络设置一个固定的学习率,并且这些方法对卷积神经网络也适用。然而,如果你想加快训练速度,可以尝试找到一个更聪明的优化器,它可以更好地调整模型的所有参数。Geoffrey Hinton 称这种方法为“rmsprop”,因为他使用了均方根(RMS)公式来计算最近梯度的移动平均值。RMSprop 对每一批数据聚合一个指数衰减的窗口来改善参数梯度(斜率)的估计并加快学习速度。它通常是卷积神经网络在自然语言处理中反向传播的一个不错选择。

7.5.5 超参数调优

探索超参数空间,看看是否可以超过我的性能。Fernando Lopez 和其他人已经使用 1-D 卷积在这个数据集上实现了 80%的验证和测试集准确率。可能还有很大的提升空间。

nlpia2 包含一个命令行脚本,它接受许多你可能想要调整的超参数的参数。试一试,看看是否可以找到超参数空间中更丰富的部分。你可以在清单 7.27 中看到我的最新尝试。

清单 7.30 用于优化超参数的命令行脚本
python train.py --dropout_portion=.35 --epochs=16 --batch_size=8 --win=True
Epoch:  1, loss: 0.44480, Train accuracy: 0.58152, Test accuracy: 0.64829
Epoch:  2, loss: 0.27265, Train accuracy: 0.63640, Test accuracy: 0.69029
...
Epoch: 15, loss: 0.03373, Train accuracy: 0.83871, Test accuracy: 0.79396
Epoch: 16, loss: 0.09545, Train accuracy: 0.84718, Test accuracy: 0.79134

您注意到清单 7.27 中的 win=True 标志了吗?这是我在我的 CNN 流水线中为自己创建的一个彩蛋或秘籍代码。每当我在“彩票假设”游戏中发现一个中奖票时,我就会把它硬编码到我的流水线中。为了使其生效,您必须跟踪您使用的随机种子、精确的数据集和软件。如果您能重现所有这些组件,通常可以重新创建一个特别幸运的“抽签”,以便在后续思考新的架构或参数调整时进行改进。

实际上,这个获胜的随机数序列初始化了模型的权重,以至于测试准确性开始时比训练集准确性更好。训练准确性超过测试集准确性需要 8 个时期。在通过数据集进行 16 次传递(时期)后,模型对训练集的拟合程度比测试集提高了 5%。

如果您想要获得更高的测试集准确性并减少过拟合,您可以尝试添加一些正则化或增加在 Dropout 层中忽略的数据量。对于大多数神经网络来说,30% 到 50% 的丢弃比率通常可以很好地防止过拟合,而不会延迟学习太久。单层 CNN 并不会因为丢弃比率超过 20% 而受益太多。

清单 7.31 CNN 超参数调优
learning  seq  case vocab           training      test
 kernel_sizes    rate  len  sens  size dropout  accuracy  accuracy
          [2]  0.0010   32 False  2000     NaN    0.5790    0.5459
[1 2 3 4 5 6]  0.0010   40 False  2000     NaN    0.7919    0.7100
    [2 3 4 5]  0.0015   40 False  2000     NaN    0.8038    0.7152
[1 2 3 4 5 6]  0.0010   40  True  2000     NaN    0.7685    0.7520
          [2]  0.0010   32  True  2000     0.2    0.8472    0.7533
    [2 3 4 5]  0.0010   32  True  2000     0.2    0.8727    0.7900

您能找到更好的超参数组合来提高此模型的准确性吗?不要期望能够达到比 80% 更好的测试集准确性,因为这是一个困难的问题。即使是人类读者也无法可靠地判断一条推文是否代表了真实的新闻灾难。毕竟,其他人类(和机器人)正在撰写这些推文,试图欺骗读者。这是一个对抗性问题。即使是一个小的单层 CNN 也能做出体面的工作。

图 7.17 我们找到的最佳超参数的学习曲线

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

超参数调优的关键是要认真记录每一个实验,并对下一个实验中进行的超参数调整做出深思熟虑的决定。您可以使用贝叶斯优化器自动化这个决策过程。但在大多数情况下,如果您使用生物神经网络来完成贝叶斯优化,您可以培养自己的直觉并更快地调整超参数。如果您对转置操作对嵌入层的影响感兴趣,您可以尝试两种方法,看看哪种在您的问题上效果最好。但是如果您想在困难问题上获得最先进的结果,您可能想听取专家的意见。不要相信互联网上的一切,特别是涉及 NLP 的 CNN。

7.6 自我测试

  1. 对于长度为 3 的核和长度为 8 的输入数组,输出的长度是多少?

  2. 在本章中使用的秘密消息音频文件中,用于检测“SOS”求救信号(Save Our Souls,或 Save Our Ship)的内核是什么?

  3. 调整超参数后,你能够为新闻价值微博问题达到的最佳训练集准确率是多少?

  4. 你如何扩展模型以容纳额外的类?在 gitlab 上提供的 nlpia2 包中的 news.csv 文件包含了一些著名的引语,可以让你尝试用你的卷积神经网络进行分类。

  5. 编写 3 个内核,分别用于检测点、短划线和停顿。编写一个 计数 这些符号的唯一出现次数的池化函数。奖励:创建一个将秘密消息音频文件转换成符号 ".""-"" " 的函数系统。

  6. 找到一些超参数(不要忘记随机种子),以在灾难推文数据集的测试集上达到超过 80% 的准确率。

  7. 使用 Hugging Face 上的数据集和示例创建一个基于单词的 CNN 的讽刺检测器(huggingface.co)。有几篇发表的论文声称可以从单个推文中,不需要上下文,检测到 91% 的讽刺准确率。^([30]) ^([31])

摘要

  • 卷积是一个窗口滤波器,它在你的单词序列上滑动以将其含义压缩为编码向量。

  • 手工制作的卷积滤波器对可预测的信号(如摩尔斯电码)效果很好,但是你需要为自然语言处理训练自己的卷积神经网络来学习它们自己的滤波器。

  • 神经网络可以提取出一系列单词中的模式,而其他自然语言处理方法可能会错过。

  • 在训练过程中,如果你通过使用一个 dropout 层稍微阻碍你的模型,你可以防止它在训练数据上过度表现(过拟合)。

  • 神经网络的超参数调整给你比传统的机器学习模型更多的发挥空间。

  • 如果你的卷积神经网络将嵌入维度与卷积通道对齐,你可以在 NLP 竞赛中超过 90% 的博主。

  • 传统的 CNN 可能会让你惊讶地发现它们在解决诸如检测新闻推文之类的难题时的效率。

Digits 技术描述( digits.com/technology

《连线》杂志在一篇 2014 年的文章中提出了数据作为新石油的概念( www.wired.com/insights/2014/07/data-new-oil-digital-economy/

谷歌 AI 博客上的路径语言模型,或称为 PaLM,( ai.googleblog.com/2022/04/pathways-language-model-palm-scaling-to.html)

GPT-J 至少需要 48GB 的 RAM( huggingface.co/docs/transformers/model_doc/gptj

由陈秋睿撰写的《T5 - 详细解释》( archive.today/M2EM6

维基百科上的数字图像处理en.wikipedia.org/wiki/Digital_image_processing#Filtering

[7] 维基百科上的"Sobel filter"(en.wikipedia.org/wiki/Sobel_operator

[8] “高斯滤波器”(en.wikipedia.org/wiki/Gaussian_filter

[9] 2015 年 5 月,《自然》杂志,Hinton、LeCunn 和 Benjio 的"深度学习"(www.nature.com/articles/nature14539

[10] Andrey Kurenkov 撰写的"神经网络和深度学习的简要历史"(www.skynettoday.com/overviews/neural-net-history

[11] SpaCy NER 文档(spacy.io/universe/project/video-spacys-ner-model

[12] LeCun, Y 和 Bengio, Y 撰写的"图像、语音和时间序列的卷积网络"(www.iro.umontreal.ca/~lisa/pointeurs/handbook-convo.pdf

[13] 有时称为"反馈三明治"或"sh-t 三明治"。

[14] Michelle Moerel 等人撰写的"人类听觉皮层区域的解剖和功能地形学"(www.ncbi.nlm.nih.gov/pmc/articles/PMC4114190/

[15] Mastodon 是一个由社区拥有的、无广告的社交网络:joinmastodon.org/

[16] Mastodon 是一个类似于 Twitter 的 FOSS 无广告微博平台,具有用于检索 NLP 数据集的开放标准 API(mastodon.social

[17] GreenPill 是一个鼓励加密货币投资者为公共产品做出贡献的再生经济倡议(greenpill.party)。

[18] GDFL(GNU 自由文档许可证)pt.wikipedia.org pt.wikipedia.org/wiki/Zebra#/media/Ficheiro:Zebra_standing_alone_crop.jpg

[19] 维基百科上的"Morse code"文章(en.wikipedia.org/wiki/Morse_code

[20] 维基共享资源中的秘密信息波形文件(upload.wikimedia.org/wikipedia/commons/7/78/1210secretmorzecode.wav

[21] “Ham” 最初是对于笨拙的摩尔斯电码"打字员"的蔑称(en.wikipedia.org/wiki/Amateur_radio#Ham_radio

[22] Ronan Collobert 和 Jason Weston 撰写的"自然语言处理的统一架构"(thetalkingmachines.com/sites/default/files/2018-12/unified_nlp.pdf

[23] 国际促进者协会手册mng.bz/xjEg

[24] Robin Jia 关于 Robust NLP 的论文(robinjia.github.io/assets/pdf/robinjia_thesis.pdf)以及他与 Kai-Wei Chang、He He 和 Sameer Singh 的演讲(robustnlp-tutorial.github.io

[25]pytorch.org/docs/stable/generated/torch.nn.Conv1d.html

[26] “自然语言处理中的卷积神经网络” 由 Christopher Manning 撰写(mng.bz/1Meq

[27] “《CNNs 用于句子分类的敏感性分析》” 由 Ye Zhang 和 Brian Wallace 撰写(arxiv.org/pdf/1510.03820.pdf

[28] 来自 Hinton 的《梯度下降小批量概览》的幻灯片 14 “加速机器学习的四种方法”(www.cs.toronto.edu/~tijmen/csc321/slides/lecture_slides_lec6.pdf

[29] Tijmen Tieleman 的博士论文《优化生成图像的神经网络》(www.cs.toronto.edu/~tijmen/tijmen_thesis.pdf

[30] Ivan Helin 在 Hugging Face 上声称他们的模型达到了 92%的准确率(huggingface.co/helinivan/english-sarcasm-detector

[31] “通过 CNN 深入研究讽刺推文” 由 Soujanya Poria 等人撰写,声称达到了 91%的准确率(arxiv.org/abs/1610.08815

第八章:减少、重用和回收单词(RNN 和 LSTMs)

本章涵盖的内容

  • 卷积递归展开,以便您可以了解如何将其用于 NLP。

  • 在 PyTorch 中实现基于单词和字符的 RNN。

  • 识别 RNN 是您最好的选择的应用程序。

  • 重新设计您的数据集以进行 RNN 训练。

  • 定制和调整您的 RNN 结构以解决 NLP 问题。

  • 理解时间反向传播(backprop)。

  • 将长期和短期记忆机制相结合,使您的 RNN 变得更加智能。

一个 RNN(递归神经网络)会重复使用词汇。为什么要重复和重用您的单词?当然是为了构建更可持续的 NLP 管道!😉 递归 只是另一个词汇,用于循环利用。RNN 使用递归来记住它已经阅读过的标记,并重复利用这种理解来预测目标变量。如果您使用 RNN 来预测下一个单词,RNN 可以生成一直生成,直到你告诉它停止。 RNN 的这种可持续性或再生能力是它们的超级能力。

原来,您的 NLP 管道可以更好地预测句子中的下一个标记,如果它记得它已经阅读并理解了什么。但是,等一下,之前的 CNN 是用一组权重来“记住”附近的标记的吗?是的!但是 CNN 只能 记住 有限的窗口,即几个单词长。通过在转到下一个标记之前循环利用机器对每个标记的理解,RNN 可以记住关于它阅读过的 所有 标记的内容。这使得您的机器阅读器更具可持续性,它可以不停地读下去……您喜欢多久它就能读多久。

但是等一下,递归不危险吗?如果您在读到递归时第一个想到的是危险,那么您并不孤单。任何学过算法的人都可能使用不正确的递归方式,破坏了函数、整个程序,甚至拖垮了整个网络服务器。正确和安全地使用递归的关键是您必须始终确保您的算法在每次输入回收时减少它必须进行的工作量。这意味着您需要在再次使用该输入之前从输入中删除一些内容。对于您的 NLP RNN,这是自然而然的,因为您会在将输入馈送回网络之前,弹出(删除)堆栈(文本字符串)上的一个标记。

从技术上讲,“循环”和“递归”是两个不同的概念。3(#_footnotedef_1) 但是大多数数学家和计算机科学家使用这两个词来解释相同的概念 - 将部分输出循环回输入,以便重复执行序列中的操作。4(#_footnotedef_2) 但是像所有自然语言词汇一样,这些概念是模糊的,当构建 循环 神经网络时理解它们可能会有所帮助。正如你将在本章的代码中看到的那样,RNN 并没有像你通常认为的那样具有调用自身的递归函数。.forward(x) 方法是在 RNN 本身之外的 for 循环中调用的。

RNN 是 类神经形 的。这是一种花哨的说法,意思是研究人员在设计诸如 RNN 这样的人工神经网络时模仿了他们认为大脑如何工作的方式。你可以利用你对自己大脑运作方式的了解来想出如何使用人工神经元处理文本的方法。你的大脑正在循环处理你正在阅读的标记。所以循环必定是一种聪明、高效的利用大脑资源来理解文本的方式。

当你阅读这段文字时,你会利用你已经了解的先前单词的知识来更新你对接下来会发生什么的预测。并且在你达到句子、段落或者你试图理解的任何东西的结尾之前,你不会停止预测。然后你可以在文本的结尾停顿一下,处理你刚刚阅读过的所有内容。就像本章中的 RNN 一样,你大脑中的 RNN 利用这个结尾的停顿来对文本进行编码、分类和 得到一些 信息。由于 RNN 总是在预测,你可以用它们来预测你的 NLP 流水线应该说的单词。所以 RNN 不仅适用于阅读文本数据,还适用于标记和撰写文本。

RNN 对 NLP 是一个颠覆性的改变。它们引发了深度学习和人工智能的实际应用和进步的爆炸性增长。

8.1 RNN 适用于什么?

你已经学过的先前的深度学习架构对处理短小的文本片段非常有效 - 通常是单个句子。 RNN 承诺打破文本长度的限制,并允许你的自然语言处理流水线摄入无限长的文本序列。它们不仅可以处理无穷尽的文本,还可以 生成 你喜欢的文本。RNN 打开了一系列全新的应用,如生成式对话聊天机器人和将来自文档的许多不同地方的概念结合起来的文本摘要器。

类型描述应用
一对多一个输入张量用于生成一系列输出张量生成聊天消息、回答问题、描述图像
多对一被收集成单个输出张量的输入张量序列根据语言、意图或其他特征对文本进行分类或标记
多对多一系列输入张量用于生成一系列输出张量在一系列标记中翻译、标记或匿名化标记,回答问题,参与对话

这就是 RNNs 的超能力,它们处理标记或向量的序列。你不再受限于处理单个、固定长度的向量。因此,你不必截断和填充输入文本,使你的圆形文本形状适合于方形洞。如果愿意,RNN 可以生成永无止境的文本序列。你不必在预先决定的任意最大长度处停止或截断输出。你的代码可以在足够的时候动态决定什么是足够的。

图 8.1 回收标记创建了无尽的选项

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

你可以使用 RNNs 在许多你已经熟悉的任务上取得最先进的性能,即使你的文本比无穷小 ;) 还要短。

  • 翻译

  • 摘要

  • 分类

  • 问答

RNNs 是实现一些新的 NLP 任务最有效和准确的方法之一,你将在本章中了解到:

  • 生成新的文本,如释义、摘要甚至是问题的答案

  • 对单个标记进行标记

  • 绘制句子的语法框图,就像你在英语课上做的那样

  • 创建预测下一个标记的语言模型

如果你阅读过论文榜上的 RNNs,你会发现 RNNs 是许多应用中最有效的方法。

RNNs 不仅仅是给研究人员和学者使用的。让我们来认真对待。在现实世界中,人们正在使用 RNNs 来:

  • 拼写检查和更正

  • 自然语言或编程语言表达的自动补全

  • 对句子进行语法检查或 FAQ 聊天机器人进行分类

  • 对问题进行分类或生成这些问题的答案

  • 为聊天机器人生成有趣的对话文本

  • 命名实体识别(NER)和提取

  • 对人、婴儿和企业进行分类、预测或生成名称

  • 分类或预测子域名(用于安全漏洞扫描)

你可能可以猜到这些应用的大部分内容,但你可能对最后一个应用(子域名预测)感到好奇。子域名是 URL 中域名的第一部分,比如 www.lesswrong.com 中的 wwwen.wikipedia.org 中的 en。为什么有人要预测或猜测子域名?Dan Meisler 在他的网络安全工具箱中讨论了子域名猜测器发挥的关键作用。一旦你知道一个子域名,黑客或渗透测试人员就可以扫描域名,找出服务器安全的漏洞。

一旦你很快就能熟练地使用 RNNs 生成全新的单词、短语、句子、段落,甚至整页的文字。使用 RNNs 玩耍可以非常有趣,你可能会不经意间创造出开启全新业务机会的应用程序。

  • 建议公司、产品或领域名称 ^([5])

  • 建议婴儿姓名

  • 句子标注和标记

  • 文本字段的自动完成

  • 对句子进行释义和改写

  • 发明俚语词汇和短语

8.1.1 RNNs 可以处理任何序列

除了 NLP 之外,RNNs 对于任何数值数据序列都是有用的,比如时间序列。你只需要将序列中的对象表示为数值向量。对于自然语言词汇,这通常是词嵌入。但你也可以看到一个城市政府如何将每日或每小时的电动滑板车租赁、高速公路交通或天气条件表示为向量。而且通常他们希望一次性在一个向量中预测所有这些。

因为 RNNs 可以为序列中的每个元素输出结果,所以你可以创建一个 RNN,用于预测“明天”——当前已知元素之后的下一个序列元素。然后,你可以使用该预测来预测下一个预测,递归地进行。这意味着一旦你掌握了时序反向传播,你就能够使用 RNNs 来预测诸如:

  • 明天的天气

  • 下一分钟的网站流量量

  • 下一秒的分布式拒绝服务(DDOS)网络请求

  • 汽车驾驶员在接下来的 100 毫秒内将采取的动作

  • 视频剪辑序列中的下一帧图像

一旦你对目标变量有了预测,你就可以衡量错误——模型输出与期望输出之间的差异。这通常发生在你正在处理的事件序列中的最后一个时间步骤。

8.1.2 RNNs 会记住你告诉它们的一切

你是否曾经不小心触摸到潮湿的油漆,并发现自己在触碰到东西时“重复使用”那种油漆?小时候,你可能会想象自己是一位印象派画家,通过用手指在周围的墙壁上涂油彩的方式与世界分享你的艺术。你将要学会如何建造一个更加专注的印象派文字画家。在第七章中,你想象了一个字母模板作为用 CNNs 处理文本的类比。现在,与其在句子中滑动一个单词模板,不如在它们还潮湿的时候用油漆辊滚动它们…​!

想象一下,用干得慢的颜料给句子的字母涂上厚厚的一层。让我们在文本中创造出多样化的彩虹颜色。也许你甚至正在支持北公园的 LBGTQ 自豪周,给人行道和自行车道涂上斑马线。

图 8.2 意义的彩虹

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

现在,拿起一个干净的油漆滚筒,将其从句子的开头滚到结尾的字母上。你的滚筒会从一个字母上取下油漆,并将其重新放在先前字母的顶部。根据你的滚筒大小,少量的字母(或字母部分)会被滚到右边的字母上。第一个字母后的所有字母都会被涂抹在一起,形成一个模糊的条纹,只能模糊地反映出原始句子。

图 8.3 彩虹尽头的一锅金币

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

涂抹将先前字母的所有油漆汇集成原始文本的一个紧凑表示。但这是一个有用的、有意义的表示吗?对于人类读者来说,你所做的只是创建了一个多彩的混乱。它对于阅读它的人类来说并不传达多少意义。这就是为什么人类不会为自己使用这种文本含义的表示方式。然而,如果你考虑一下字符的涂抹,也许你能想象出机器是如何解释它的。对于机器来说,它肯定比原始字符序列要密集和紧凑得多。

在自然语言处理中,我们希望创建文本的紧凑、密集的向量表示。幸运的是,我们正在寻找的那种表示隐藏在你的油漆滚筒上!当你的干净的新滚筒被文本的字母涂抹时,它收集了你滚过的所有字母的记忆。这类似于你在第六章创建的词嵌入。但这种嵌入方法可以用于更长的文本片段。如果你愿意,你可以不停地滚动滚筒,不断地将更多的文本压缩成紧凑的表示。

在以前的章节中,你的标记主要是单词或单词 n-gram。你需要扩展你对标记的理解,将个别字符包括在内。最简单的 RNN 使用字符而不是单词作为标记。这被称为基于字符的 RNN。就像你在之前的章节中有单词和标记嵌入一样,你也可以认为字符也有意义。现在,你能理解这个在"Wet Paint!"字母末尾的涂抹如何表示文本所有字母的嵌入吗?

最后一个想象中的步骤可能会帮助你揭示这个思想实验中的隐藏含义。在你的脑海中,检查一下你的油漆滚筒上的嵌入。在你的脑海中,将其在一张干净的纸上滚开。记住,纸和你的滚筒只大到能容纳一个单独的字母。这将 输出 滚筒对文本的记忆的紧凑表示。而这个输出隐藏在你的滚筒里,直到你决定用它做点什么。这就是 RNN 中文本嵌入的工作方式。嵌入被隐藏在你的 RNN 中,直到你决定输出它们或与其他东西结合以重用它们。事实上,在许多 RNN 实现中,文本的这种向量表示存储在名为 hidden 的变量中。

重要

RNN 嵌入与你在第六章和第七章学到的单词和文档嵌入不同。RNN 在时间或文本位置上聚集意义。RNN 将意义编码到这个向量中,以便你可以在文本中重复使用后续的标记。这就像 Python 的 str.encode() 函数,用于创建 Unicode 文本字符的多字节表示。标记序列处理的顺序对最终结果,即编码向量,至关重要。所以你可能想把 RNN 嵌入称为 “编码”、“编码向量” 或 “编码张量”。这种词汇转变是在 Garrett Lander 的一个项目中受到鼓励的,该项目是对非常长且复杂的文档进行自然语言处理,例如患者病历或《穆勒报告》。[6] 这种新的词汇使他的团队更容易发展起自然语言处理管道的共享心理模型。

在本章后面要密切关注隐藏层。激活值存储在变量 hhidden 中。这个张量内的这些激活值是文本中到目前为止的嵌入。每次处理一个新标记时,它都会被新值覆盖,因为你的自然语言处理管道正在汇总它到目前为止已读取的标记的含义。在图 8.4 中,你可以看到这种在嵌入向量中汇集含义的混合要比原始文本更加紧凑和模糊。

图 8.4 汇集含义到一个点中

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

你可以从油漆印迹中读出一些原始文本的含义,就像罗夏克墨点测试一样。罗夏克墨点是指用在纸牌上的墨水或油漆印迹,用于激发人们的记忆并测试他们的思维或心理健康^([7])。你油漆辊上的油漆印迹是原始文本的模糊、印象派式的呈现。这是你要达成的目标,而不仅是制造一团糟。你可以清洁你的辊子,冲洗并重复这个过程,得到不同的油漆印迹,这些印迹代表了你的神经网络的不同含义。很快你就会看到,这些步骤与 RNN 神经元层中的实际数学操作是相似的。

你的油漆辊沾污了句子末尾的许多字母,以至于末尾的感叹号几乎完全无法辨认。但正是这不可理解的部分,使你的机器能够在油漆辊的有限表面积内理解整个句子。你已经把句子的所有字母都涂到油漆辊的表面上了。如果你想看到油漆辊嵌入的信息,只需把它滚到一张干净的纸上即可。

在你的 RNN 中,你可以在将 RNN 滚动文本标记后输出隐藏层激活。对于人类来讲,编码信息可能不会有很多意义,但它给了你的油漆辊,即机器,整个句子的暗示。你的油漆辊收集了整个句子的印象。我们甚至使用“收集”这个词来表达对某人说的话的理解,就像“我从你刚刚说的话中收集到,将湿漆辊辊在湿漆上与 RNN 是相似的。”

你的油漆辊已将整个字母句子压缩或编码成一个短小的、模糊印象派风格的油漆条纹。在 RNN 中,这个印迹是一个由数字组成的向量或张量。编码向量中的每个位置或维度就像你的油漆印迹中的一个颜色。每个编码维度都保留着一个意义方面,你的 RNN 被设计成跟踪这些方面的含义。油漆在辊子上留下的印象(隐藏层激活)被持续回收,直到文本的末尾。接着,将所有这些印迹再次应用在油漆辊的新位置上,创建一个整个句子的新印象。

8.1.3 RNNs 隐藏他们的理解

对于 RNN 来说,一个关键的改变是通过逐个读取令牌来重复使用每个令牌的含义而维护一个隐藏嵌入。这个包含了 RNN 所理解的一切的权重隐藏向量包含在它所读取的文本点中。这意味着你不能一次性运行整个你正在处理的文本的网络。在先前的章节中,你的模型学习了将一个输入映射到一个输出的函数。但是,接下来你将看到,RNN 会学习一个程序,在你的文本上不断运行,直到完成。RNN 需要逐个读取你的文本的令牌。

一个普通的前馈神经元只是将输入向量乘以一堆权重来创建输出。无论你的文本有多长,CNN 或者前馈神经网络都必须执行相同数量的乘法来计算输出预测。线性神经网络的神经元一起工作,组合出一个新的向量来表示你的文本。 在图 8.5 中可以看到,一个普通的前馈神经网络接受一个向量输入(x),将其乘以一组权重矩阵(W),应用激活函数,然后输出一个转换过的向量(y)。前馈网络层只能将一个向量转换为另一个向量。

图 8.5 普通的前馈神经元

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在使用 RNNs 时,你的神经元不会看到整个文本的向量。相反,RNN 必须逐个令牌处理你的文本。为了跟踪已经读取的令牌,它记录一个隐藏向量(h),可以传递给未来自己——产生隐藏向量的完全相同的神经元。在计算机科学术语中,这个隐藏向量被称为 状态。这就是为什么 Andrej Karpathy 和其他深度学习研究人员对 RNNs 的效果如此兴奋的原因。RNNs 使得机器终于能够学习 Turing 完备程序而不只是孤立的函数.5

图 8.6 循环神经元

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如果你展开你的 RNN,它开始看起来像一个链……实际上是一个马尔可夫链。但这一次,你的窗口只有一个标记的宽度,并且您重用了先前标记的输出,结合当前标记,然后向前滚动到文本的下一个标记。庆幸的是,当你在第七章中滑动 CNN 窗口或卷积核时,已经开始做类似的事情。

你如何在 Python 中实现神经网络的递归?幸运的是,你不必像在编程面试中遇到的那样尝试使用递归函数调用。相反,你只需创建一个变量来存储与输入和输出分开的隐藏状态,并且你需要有一个单独的权重矩阵用于计算隐藏张量。列表 8.1 实现了一个最小的 RNN,从头开始,而不使用 PyTorch 的 RNNBase 类。

列表 8.1 PyTorch 中的递归
>>> from torch import nn

>>> class RNN(nn.Module):
...
...     def __init__(self,
...             vocab_size, hidden_size, output_size):  # #1
...         super().__init__()
...         self.W_c2h = nn.Linear(
...             vocab_size + hidden_size, hidden_size)  # #2
...         self.W_c2y = nn.Linear(vocab_size + hidden_size, output_size)
...         self.softmax = nn.LogSoftmax(dim=1)
...
...     def forward(self, x, hidden):  # #3
...         combined = torch.cat((x, hidden), axis=1)  # #4
...         hidden = self.W_c2h(combined)  # #5
...         y = self.W_c2y(combined)  # #6
...         y = self.softmax(y)
...         return y, hidden  # #7

你可以看到这个新的 RNN 神经元现在输出不止一件事。你不仅需要返回输出或预测,而且需要输出隐藏状态张量以供“未来自己”神经元重用。

当然,PyTorch 实现有许多其他特性。PyTorch 中的 RNNs 甚至可以同时从左到右和从右到左训练!这被称为双向语言模型。当然,你的问题需要是“非因果”的,才能使用双向语言模型。在英语 NLP 中,非因果模型意味着你希望语言模型预测你已经知道的其他单词之前(左边)出现的单词。一个常见的非因果应用是预测在 OCR(光学字符识别)期间有意或无意地被屏蔽或损坏的内部单词。如果你对双向 RNNs 感兴趣,所有的 PyTorch RNN 模型(RNNs、GRUs、LSTMs,甚至 Transformers)都包括一个选项来启用双向递归。对于问答模型和其他困难的问题,与默认的向前方向(因果)语言模型相比,双向模型的准确率通常会提高 5-10%。这仅仅是因为双向语言模型的嵌入更加平衡,忘记了文本开头和结尾的内容一样多。

8.1.4 RNNs 记得你告诉它们的一切

要了解 RNNs 如何保留文档中所有标记的记忆,你可以展开图 8.7 中的神经元图。你可以创建神经元的副本,来展示“未来自己”在循环中遍历你的标记。这就像展开一个 for 循环,当你只需复制并粘贴循环内的代码行适当次数时。

图 8.7 展开 RNN 以揭示它的隐藏秘密

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 8.7 显示了一个 RNN 将隐藏状态传递给下一个“未来自己”神经元,有点像奥运接力选手传递接力棒。但是这个接力棒在被 RNN 反复回收利用时印上了越来越多的记忆。你可以看到在 RNN 最终看到文本的最后一个标记之前,输入标记的张量被修改了许多许多次。

RNNs 的另一个好处是你可以在任何位置取出输出张量。这意味着你可以解决像机器翻译、命名实体识别、文本匿名化和去匿名化、甚至政府文件开放化等挑战。^([10])

这两个特点是 RNNs 独有的特点。

  1. 你可以在一个文档中处理任意数量的 token。

  2. 在每个 token 处理完之后,你可以输出任何你需要的内容。

第一个特点其实并不是什么大不了的事情。正如你在 CNN 中看到的那样,如果你想处理长文本,只需要在输入张量的最大尺寸里面留出空间就可以了。事实上,到目前为止最先进的 NLP 模型——transformers,也是创建了最大长度限制并像 CNN 一样填充文本的。

然而,RNNs 的第二大特点真的很重要。想象一下,你可以用一个标记每个句子中每一个词汇的模型做出哪些事情。语言学家花费很多时间对话语进行图解并标记 token。RNNs 和深度学习已经改变了语言学研究的方式。只要看一下 SpaCy 可以在清单 8.2 中识别一些“hello world”文本中每个单词的语言学特征,就可以想象一下。

清单 8.2 SpaCy 用 RNNs 标记 token
>>> import pandas as pd
>>> from nlpia2.spacy_language_model import nlp
>>>
>>> tagged_tokens = list(nlp('Hello world. Goodbye now!'))
>>> interesting_tags = 'text dep_ head lang_ lemma_ pos_ sentiment'
>>> interesting_tags = (interesting_tags +  'shape_ tag_').split()
>>> pd.DataFrame([
...         [getattr(t, a) for a in interesting_tags]
...         for t in tagged_tokens],
...     columns=interesting_tags)
      text    dep_     head lang_   lemma_   pos_  sentiment shape_ tag_
0    Hello    intj    world    en    hello   INTJ        0.0  Xxxxx   UH
1    world    ROOT    world    en    world   NOUN        0.0   xxxx   NN
2        .   punct    world    en        .  PUNCT        0.0      .    .
3  Goodbye    ROOT  Goodbye    en  goodbye   INTJ        0.0  Xxxxx   UH
4      now  advmod  Goodbye    en      now    ADV        0.0    xxx   RB
5        !   punct  Goodbye    en        !  PUNCT        0.0      !    .

拥有所有信息、在你需要的时候输出所有结果都是很好的。你可能很兴奋地想要在真正长的文本上尝试 RNNs,看看它到底能记住多少。

8.2 只使用姓氏预测一个人的国籍

为了快速让你掌握再循环利用,你将从最简单的 token(字母或标点符号)开始。你要建立一个模型,只使用名字中的字母来指导预测,可以预测出一个人的国籍,也叫“姓氏”。这种模型可能对你来说并不那么有用。你可能甚至担心它可能会被用于伤害某些特定文化的人。

就像你一样,作者的 LinkedIn 关注者们也对当我们提到正在训练一个模型来预测姓名的人口学特征时,感到怀疑。不幸的是,企业和政府确实使用这样的模型来识别和定位特定群体的人,这往往会产生有害的后果。但这些模型也可以用于好处。我们使用它们来帮助我们的非营利组织和政府客户将他们的对话 AI 数据集匿名化。然后志愿者和开源贡献者可以从这些经过匿名处理的对话数据库中训练 NLP 模型,根据用户的需求,同时保护用户的隐私,识别出有用的医疗保健或教育内容。

这个多语言数据集将让你有机会学习如何处理非英语单词常见的变音符号和其他装饰。为了保持趣味性,你将删除这些字符装饰和其他泄漏的 Unicode 字符。这样你的模型就可以学习你真正关心的模式,而不是基于这种泄漏而“作弊”。处理这个数据集的第一步是将其ASCII 化 - 将其转换为纯 ASCII 字符。例如,爱尔兰名字“O’Néàl”的 Unicode 表示中,“e”上有一个“重音符号”,在这个名字的“a”上有一个“重音符号”。而“O”和“N”之间的撇号可能是一个特殊的方向撇号,如果你不将其ASCII 化,它可能会不公平地提示你的模型该名字的国籍。你还需要删除经常添加到土耳其语、库尔德语、罗曼语和其他字母表的字母“C”上的西迪拉装饰。

>>> from nlpia2.string_normalizers import Asciifier
>>> asciify = Asciifier()

>>> asciify("O’Néàl")
"O'Neal"
>>> asciify("Çetin")
'Cetin'

现在你有了一个可以为广泛语言规范化字母表的流水线,你的模型会更好地泛化。你的模型几乎可以用于任何拉丁字母文字,甚至是从其他字母表转写为拉丁字母文字的文字。你可以使用完全相同的模型来对几乎任何语言的任何字符串进行分类。你只需要在你感兴趣的每种语言中标记几十个例子来“解决”。

现在让我们看看你是否已经创建了一个可解决的问题。一个可解决的机器学习问题是指:

  1. 你可以想象一个人类回答这些同样的问题

  2. 对于你想问你的模型的绝大多数“问题”,存在一个正确的答案

  3. 你不指望机器的准确度会比训练有素的人类专家高得多

想一想这个预测与姓氏相关的国家或方言的问题。记住,我们已经删除了很多关于语言的线索,比如独特于非英语语言的字符和装饰。这是一个可解决的问题吗?

从上面的第一个问题开始。你能想象一个人类仅从他们的姓氏的 ASCII 化就能确定一个人的国籍吗?就我个人而言,当我试图根据他们的姓氏猜测我的学生来自哪里时,我经常猜错。在现实生活中,我永远不会达到 100%的准确率,机器也不会。所以只要你能接受一个不完美的模型,这就是一个可解决的问题。如果你建立一个良好的管道,有大量标记的数据,你应该能够创建一个至少与你我一样准确的 RNN 模型。当你考虑到这一点时,它甚至可能比训练有素的语言学家更准确,这是相当令人惊讶的。这就是 AI 概念的来源,如果一台机器或算法能够做出智能的事情,我们就称之为 AI。

想想这个问题之所以难的原因。姓氏和国家之间没有一对一的映射。尽管姓氏通常在几代人之间被父母和子女共享,但人们倾向于四处迁移。而且人们可以改变自己的国籍、文化和宗教信仰。所有这些因素都会影响某个特定国家常见的姓名。有时个人或整个家庭决定改姓,尤其是移民、外国人和间谍。人们有很多不同的原因想要融入[¹¹]。文化和语言的融合是使人类在共同努力实现伟大事业方面如此出色的原因,包括人工智能。RNNs 会给你的国籍预测模型带来同样的灵活性。如果你想改名,这个模型可以帮助你设计,使其唤起你想要人(和机器)感知到的国籍。

浏览一些来自这个数据集的随机姓名,看看是否可以找到在多个国家中重复使用的字符模式。

清单 8.3 加载
>>> repo = 'tangibleai/nlpia2'  # #1
>>> filepath = 'src/nlpia2/data/surname-nationality.csv.gz'
>>> url = f"https://gitlab.com/{repo}/-/raw/main/{filepath}"
>>> df = pd.read_csv(url)  # #2
>>> df[['surname', 'nationality']].sort_values('surname').head(9)
        surname nationality
16760   Aalbers       Dutch
16829   Aalders       Dutch
35706  Aalsburg       Dutch
35707     Aalst       Dutch
11070     Aalto     Finnish
11052  Aaltonen     Finnish
10853     Aarab    Moroccan
35708     Aarle       Dutch
11410    Aarnio     Finnish

在深入研究之前先快速查看一下数据。看起来荷兰人喜欢把他们的姓氏(姓氏)放在点名表的开头。一些荷兰姓氏以“Aa”开头。在美国,有很多企业名称以“AAA”开头,原因类似。而且似乎摩洛哥、荷兰和芬兰的语言和文化倾向于鼓励在词语开头使用三字母组“Aar”。所以你可以预料到这些国籍之间会有一些混淆。不要期望分类器达到 90%的准确率。

你还想要统计一下数据集中唯一类别的数量,这样你就知道你的模型将有多少选择。

清单 8.4 数据集中的唯一国籍
>>> df['nationality'].nunique()
37
>>> sorted(df['nationality'].unique())
['Algerian', 'Arabic', 'Brazilian', 'Chilean', 'Chinese', 'Czech', 'Dutch',
 'English', 'Ethiopian', 'Finnish', 'French', 'German', 'Greek',
 'Honduran', 'Indian', 'Irish', 'Italian', 'Japanese', 'Korean',
 'Malaysian', 'Mexican', 'Moroccan', 'Nepalese', 'Nicaraguan', 'Nigerian',
 'Palestinian', 'Papua New Guinean', 'Peruvian', 'Polish', 'Portuguese',
 'Russian', 'Scottish', 'South African', 'Spanish', 'Ukrainian',
 'Venezuelan', 'Vietnamese']

在清单 8.4 中,你可以看到从多个来源收集到的三十七个独特的国籍和语言类别。这就是这个问题的难点所在。这就像是一个多项选择题,有 36 个错误答案,只有一个正确答案。而且这些地区或语言类别经常重叠。例如,阿尔及利亚人被认为是阿拉伯语的一种,巴西人是葡萄牙语的一种方言。有几个姓名跨越了这些国籍边界。所以模型不能为所有姓名都得到正确答案。它只能尽可能地返回正确答案。

各种国籍和数据源的多样性帮助我们进行名称替换,以匿名化我们多语言聊天机器人中交换的消息。这样可以在开源项目中共享会话设计数据集,例如本书第十二章讨论的聊天机器人。递归神经网络模型非常适用于匿名化任务,例如命名实体识别和虚构名称的生成。它们甚至可以用来生成虚构但逼真的社会安全号码、电话号码和其他个人身份信息(PII)。为了构建这个数据集,我们使用了从公共 API 中抓取的包含非洲、南美和中美洲以及大洋洲少数族裔国家数据的 PyTorch RNN 教程数据集。

在我们每周在 Manning 的 Twitch 频道上进行集体编程时,Rochdi Khalid 指出他的姓氏是阿拉伯语。他住在摩洛哥的卡萨布兰卡,在那里阿拉伯语是官方语言,与法语和柏柏尔语并存。这个数据集是从各种来源汇编而成的。[12]) 其中一些基于广泛的语言标签(如"Arabic")创建标签,而其他一些则以特定的国籍或方言为标签,如摩洛哥、阿尔及利亚、巴勒斯坦或马来西亚。

数据集偏见是最难弥补的偏见之一,除非你能找到要提升的群体的数据。除了公共 API,你还可以从内部数据中挖掘名称。我们的匿名化脚本从多语言聊天机器人对话中剥离出名称。我们将这些名称添加到了这个数据集中,以确保它是与我们的聊天机器人互动的用户种类的代表性样本。你可以在需要从各种文化中获得真正全球化的名称片段的自己的项目中使用这个数据集。

多样性也带来了挑战。你可以想象到,这些音译名称的拼写可能跨越国界甚至跨越语言。翻译和音译是两个不同的自然语言处理问题,你可以使用递归神经网络来解决。词语 “नमस्कार” 可以翻译成英语单词 “hello”。但在你的递归神经网络尝试翻译尼泊尔语单词之前,它将会音译尼泊尔语单词 “नमस्कार” 成为使用拉丁字符集的单词 “namaskāra”。大多数多语言深度学习流程都使用拉丁字符集(罗马脚本字母)来表示所有语言中的单词。

注意

音译是将一个语言的字母和拼写翻译成另一种语言的字母,从而可以使用在欧洲和美洲使用的拉丁字符集(罗马脚本字母)表示单词。一个简单的例子是将法语字符 “é” 的重音去除或添加,例如 “resumé”(简历)和 “école”(学校)。对于非拉丁字母表,如尼泊尔语,音译要困难得多。

以下是如何计算每个类别(国籍)内重叠程度的方法。

>>> fraction_unique = {}
>>> for i, g in df.groupby('nationality'):
>>>     fraction_unique[i] = g['surname'].nunique() / len(g)
>>> pd.Series(fraction_unique).sort_values().head(7)
Portuguese           0.860092
Dutch                0.966115
Brazilian            0.988012
Ethiopian            0.993958
Mexican              0.995000
Nepalese             0.995108
Chilean              0.998000

除了跨国家的重叠之外,PyTorch 教程数据集中还包含了许多重复的名称。超过 94% 的阿拉伯语名称是重复的,其中一些在第 8.5 节中显示出来。其他国籍和语言,如英语、韩语和苏格兰语,似乎已经去重了。在你的训练集中重复条目使你的模型更紧密地适应于常见名称而不是不太频繁出现的名称。在数据集中复制条目是一种“平衡”数据集或强制统计短语频率的方法,以帮助准确预测流行名称和人口稠密国家。这种技术有时被称为“过度抽样少数类”,因为它增加了数据集中未被充分代表的类别的频率和准确性。

如果你对原始的姓氏数据感兴趣,请查看 PyTorch 的“RNN 分类教程”。^([13]) 在 Arabic.txt 中的 2000 个阿拉伯示例中,只有 108 个独特的阿拉伯姓氏。^([14])

第 8.5 节 姓氏过度抽样
>>> arabic = [x.strip() for x in open('.nlpia2-data/names/Arabic.txt')]
>>> arabic = pd.Series(sorted(arabic))
0       Abadi
1       Abadi
2       Abadi
        ...
1995    Zogby
1996    Zogby
1997    Zogby
Length: 2000, dtype: object

这意味着即使是一个相对简单的模型(比如 PyTorch 教程中展示的模型),也应该能够正确地将像 Abadi 和 Zogby 这样的流行名称标记为阿拉伯语。通过计算数据集中与每个名称关联的国籍数量,你可以预期模型的混淆矩阵统计数据。

你将使用在第 8.5 节中加载的去重数据集。我们已经计算了重复项,为你提供了这些重复项的统计信息,而不会让你下载一个庞大的数据集。你将使用平衡抽样的国家数据,以鼓励你的模型平等对待所有类别和名称。这意味着你的模型将像准确预测流行国家的流行名称一样准确地预测罕见名称和罕见国家。这个平衡的数据集将鼓励你的 RNN 从它在名称中看到的语言特征中归纳出一般规律。你的模型更有可能识别出许多不同名称中常见的字母模式,尤其是那些帮助 RNN 区分国家的模式。我们在 nlpia2 仓库的 GitLab 上包含了关于如何获取准确的名称使用频率统计信息的信息。^([15]) 如果你打算在更随机的名称样本上在真实世界中使用这个模型,你需要记住这一点。

第 8.6 节 名称国籍重叠
>>> df.groupby('surname')
>>> overlap = {}
... for i, g in df.groupby('surname'):
...     n = g['nationality'].nunique()
...     if n > 1:
...         overlap[i] = {'nunique': n,
 'unique': list(g['nationality'].unique())}
>>> overlap.sort_values('nunique', ascending=False)
         nunique                                             unique
Michel         6  [Spanish, French, German, English, Polish, Dutch]
Abel           5        [Spanish, French, German, English, Russian]
Simon          5            [Irish, French, German, English, Dutch]
Martin         5       [French, German, English, Scottish, Russian]
Adam           5          [Irish, French, German, English, Russian]
...          ...                                                ...
Best           2                                  [German, English]
Katz           2                                  [German, Russian]
Karl           2                                    [German, Dutch]
Kappel         2                                    [German, Dutch]
Zambrano       2                                 [Spanish, Italian]

为了帮助使这个数据集多样化,并使其更具代表性,我们添加了一些来自印度和非洲的姓名。并且通过计算重复项来压缩数据集。由此产生的姓氏数据集将 PyTorch RNN 教程的数据与多语言聊天机器人的匿名化数据结合起来。事实上,我们使用这个姓名分类和生成模型来匿名化我们聊天机器人日志中的姓名。这使我们能够在 NLP 数据集和软件方面“默认开放”。

重要提示

要找出机器学习流水线是否有可能解决您的问题,假装自己是机器。对训练集中的一些示例进行训练。然后尝试回答一些测试集中的“问题”,而不查看正确的标签。你的 NLP 流水线应该能够几乎和你一样好地解决你的问题。在某些情况下,你可能会发现机器比你更好,因为它们可以更准确地在脑海中平衡许多模式。

通过计算数据集中每个名称的最流行国籍,可以创建一个混淆矩阵,使用最常见的国籍作为特定名称的“真实”标签。这可以揭示数据集中的几个怪癖,应该影响模型学习的内容以及其执行此任务的效果如何。对于阿拉伯名字,根本没有混淆,因为阿拉伯名字非常少,而且没有一个被包含在其他国籍中。西班牙、葡萄牙、意大利和英国名字之间存在显著的重叠。有趣的是,在数据集中有 100 个苏格兰名字,其中没有一个最常被标记为苏格兰名字。苏格兰名字更常被标记为英国和爱尔兰名字。这是因为原始的 PyTorch 教程数据集中有成千上万个英国和爱尔兰名字,但只有 100 个苏格兰名字。

图 8.8 在训练之前数据集就产生了混淆

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

我们在原始 PyTorch 数据集中添加了 26 个国籍。这在类标签中创建了更多的歧义或重叠。许多名称在世界多个不同地区都很常见。RNN 可以很好地处理这种歧义,使用字符序列中模式的统计数据来指导其分类决策。

8.2.1 从头开始构建 RNN

这是您的RNN类的核心代码,见列表 8.7。像所有 Python 类一样,PyTorch Module 类有一个*init*()方法,您可以在其中设置一些配置值,以控制类的其余部分的工作方式。对于 RNN,您可以使用*init*()方法设置控制隐藏向量中的神经元数量以及输入和输出向量大小的超参数。

对于依赖于分词器的自然语言处理应用程序,将分词器参数包含在 init 方法中是个好主意,这样可以更容易地从保存到磁盘的数据中再次实例化。否则,你会发现你在磁盘上保存了几个不同的模型。每个模型可能使用不同的词汇表或字典来对你的数据进行分词和向量化。如果它们没有在一个对象中一起存储,那么保持所有这些模型和分词器的连接是一种挑战。

在你的自然语言处理流水线中,向量化器也是如此。你的流水线必须一致地确定每个词汇的存储位置。如果你的输出是一个类别标签,你还必须一致地确定类别的排序。如果在每次重用模型时,你的类别标签的排序不完全一致,你很容易感到困惑。如果你的模型使用的数值值与这些类别的人类可读名称不一致地映射,输出将是一些混乱的无意义标签。如果你将向量化器存储在你的模型类中(见清单 8.7),它将确切地知道要将哪些类别标签应用于你的数据。

清单 8.7 RNN 的核心
>>> class RNN(nn.Module):

>>> def __init__(self, n_hidden=128, categories, char2i):  # #1
...     super().__init__()
...     self.categories = categories
...     self.n_categories = len(self.categories)  # #2
...     print(f'RNN.categories: {self.categories}')
...     print(f'RNN.n_categories: {self.n_categories}')

...     self.char2i = dict(char2i)
...     self.vocab_size = len(self.char2i)

...     self.n_hidden = n_hidden

...     self.W_c2h = nn.Linear(self.vocab_size + self.n_hidden,
 self.n_hidden)
...     self.W_c2y = nn.Linear(self.vocab_size + self.n_hidden,
 self.n_categories)
...     self.softmax = nn.LogSoftmax(dim=1)

>>> def forward(self, x, hidden):  # #3
...     combined = torch.cat((x, hidden), 1)
...     hidden = self.W_c2h(combined)
...     y = self.W_c2y(combined)
...     y = self.softmax(y)
...     return y, hidden  # #4

从技术上讲,你的模型不需要完整的char2i词汇表。它只需要你计划在训练和推断期间输入的一个独热令牌向量的大小。类别标签也是如此。你的模型只需要知道类别的数量。这些类别的名称对机器来说毫无意义。但是通过在你的模型中包含类别标签,你可以在需要调试模型内部时随时将它们打印到控制台。

8.2.2 逐个令牌训练 RNN

nlpia2项目中包含 30000 多个姓氏的数据集,涵盖了 37 个以上的国家,即使在一台普通的笔记本电脑上也是可管理的。因此,你应该能够在合理的时间内使用nlpia2来训练它。如果你的笔记本电脑有 4 个或更多的 CPU 核心和 6GB 或更多的 RAM,训练将花费大约 30 分钟。如果你限制自己只使用 10 个国家、10000 个姓氏,并且在学习率的选择上有一些幸运(或聪明),你可以在两分钟内训练出一个好的模型。

而不是使用内置的torch.nn.RNN层,你可以使用普通的Linear层从头开始构建你的第一个 RNN。这样可以让你的理解更加泛化,这样你就可以为几乎任何应用设计自己的 RNN。

清单 8.8 对单个样本进行训练必须循环遍历字符
>>> def train_sample(model, category_tensor, char_seq_tens,
...                 criterion=nn.NLLLoss(), lr=.005):
    """ Train for one epoch (one example name nationality tensor pair) """
...    hidden = torch.zeros(1, model.n_hidden)  # #1
...    model.zero_grad()  # #2
...    for char_onehot_vector in char_seq_tens:
...        category_predictions, hidden = model(  # #3
...            x=char_onehot_vector, hidden=hidden)  # #4
...    loss = criterion(category_predictions, category_tensor)
...    loss.backward()

...    for p in model.parameters():
...        p.data.add_(p.grad.data, alpha=-lr)

...    return model, category_predictions, loss.item()

nlpia2包包含一个脚本,用于编排训练过程,并允许你尝试不同的超参数。

>>> %run classify_name_nationality.py  # #1
    surname  nationality
0   Tesfaye    Ethiopian
...
[36241 rows x 7 columns]
提示

您应该在 iPython 控制台中使用 %run 魔术命令,而不是在终端中使用 python 解释器运行机器学习脚本。ipython 控制台类似于调试器。它允许您在脚本运行完成后检查所有全局变量和函数。如果取消运行或遇到停止脚本的错误,您仍然能够检查全局变量,而无需从头开始。

一旦您启动 classify_name_nationality.py 脚本,它将提示您关于模型超参数的几个问题。这是培养关于深度学习模型直觉的最佳方式之一。这也是为什么我们选择了一个相对较小的数据集和小问题,可以在合理的时间内成功训练。这使您可以尝试许多不同的超参数组合,并在微调模型时微调您对 NLP 的直觉。

列表 8.9 展示了一些超参数的选择,可以获得很好的结果。但我们给您留了足够的空间来自行探索各种选项的“超空间”。您能否找到一组超参数,以更高的准确率识别更广泛的国籍?

列表 8.9 可交互的提示,以便您可以调整超参数。
How many nationalities would you like to train on? [10]? 25
model: RNN(
    n_hidden=128,
    n_categories=25,
    categories=[Algerian..Nigerian],
    vocab_size=58,
    char2i['A']=6
)

How many samples would you like to train on? [10000]? 1500

What learning rate would you like to train with? [0.005]? 0.010

  2%|| 30/1500 [00:06<05:16,  4.64it/s]000030 2% 00:06 3.0791
   Haddad => Arabic (1)000030 2% 00:06 3.1712 Cai => Moroccan (21) ✗ should be Nepalese (22=22)

即使只有 128 个神经元和 1500 个周期的简化 RNN 模型,也需要几分钟才能收敛到一个合理的精确度。此示例在一台配备 4 核心(8 线程)i7 Intel 处理器和 64GB 内存的笔记本上进行训练。如果您的计算资源更有限,您可以在只有 10 个国籍的简化模型上进行训练,它应该会更快地收敛。请记住,许多名称被分配给多个国籍。有些国籍标签是更常见的语言标签,比如“阿拉伯语”,适用于很多很多国家。因此,您不应期望获得非常高的精确度,特别是当您给模型许多国籍(类别)选择时。

列表 8.10 训练输出日志
001470 98% 06:31 1.7358 Maouche => Algerian (0)001470 98% 06:31 1.8221 Quevedo => Mexican (20)...
001470 98% 06:31 0.7960 Tong => Chinese (4)001470 98% 06:31 1.2560 Nassiri => Moroccan (21) ✓
  mean_train_loss: 2.1883266236980754
  mean_train_acc: 0.5706666666666667
  mean_val_acc: 0.2934249263984298
100%|███████████| 1500/1500 [06:39<00:00,  3.75it/s]

看起来 RNN 在训练集上达到了 57%的准确率,在验证集上达到了 29%的准确率。这是对模型有用性的一种不公平的衡量方式。因为在将数据集拆分成训练和验证集之前,数据集已经去重,每个姓名-国籍组合只有一行数据。这意味着在训练集中与一个国籍相关联的姓名可能在验证集中与不同的国籍相关联。这就是为什么 PyTorch 教程在官方文档中没有创建测试或验证数据集的原因。他们不想让您感到困惑。

现在你了解了数据集中的歧义,你可以看到这个问题有多困难,而且这个 RNN 在字符序列中找到的模式上的泛化能力非常强。它在验证集上的泛化能力比随机猜测要好得多。即使每个名字关联的国籍没有歧义,随机猜测也只能在 25 个类别中获得 4%的准确率(1/25 == .04)。

让我们试试一些在许多国家都使用的常见姓氏。一个叫 Rochdi Khalid 的工程师帮助创建了本章中的一个图表。他生活和工作在摩洛哥的卡萨布兰卡。尽管摩洛哥不是"Khalid"的最高预测,但摩洛哥位居第二!

>>> model.predict_category("Khalid")
'Algerian'
>>> predictions = topk_predictions(model, 'Khalid', topk=4)
>>> predictions
        text  log_loss nationality
rank
0     Khalid     -1.17    Algerian
1     Khalid     -1.35    Moroccan
2     Khalid     -1.80   Malaysian
3     Khalid     -2.40      Arabic

前三个预测都是阿拉伯语国家。我认为没有专家语言学家能够像这个 RNN 模型那样快速或准确地进行这种预测。

现在是时候深入挖掘,检查一些更多的预测,看看你是否能够弄清楚只有 128 个神经元如何能够如此成功地预测某人的国籍。

8.2.3 理解结果

要在现实世界中使用这样的模型,你需要能够向老板解释它是如何工作的。德国、芬兰和荷兰(以及很快在整个欧盟)正在规范 AI 的使用,迫使企业解释他们的 AI 算法,以便用户能够保护自己。企业将无法长时间隐藏他们在算法中的剥削性商业行为。你可以想象政府和企业可能如何利用国籍预测算法进行邪恶用途。一旦你了解了这个 RNN 的工作原理,你就能利用这些知识来欺骗算法做正确的事情,提升而不是歧视历史上处于劣势的群体和文化。

也许 AI 算法中最重要的部分是你用来训练它的指标。你在 PyTorch 优化训练循环中使用了NLLLoss来训练,这在列表 8.8 中已经提到。NLL部分代表“负对数似然”。你应该已经知道如何求反log()这个表达式的部分了。在查看下面的代码片段之前,试着猜测如何求反log()函数的数学函数和 Python 代码是什么。像大多数 ML 算法一样,log表示自然对数,有时写作ln以 e 为底的对数

>>> predictions = topk_predictions(model, 'Khalid', topk=4)
>>> predictions['likelihood'] = np.exp(predictions['log_loss'])
>>> predictions
        text  log_loss nationality  likelihood
rank
0     Khalid     -1.17    Algerian        0.31
1     Khalid     -1.35    Moroccan        0.26
2     Khalid     -1.80   Malaysian        0.17
3     Khalid     -2.40      Arabic        0.09

这意味着模型仅有 31%的信心认为 Rochdi 是阿尔及利亚人。这些概率(可能性)可以用来解释你的模型对老板、队友甚至用户有多自信。

如果你是"通过打印调试"的粉丝,你可以修改你的模型来打印出你对模型使用的数学的任何感兴趣的内容。PyTorch 模型可以在你想要记录一些内部过程时用打印语句进行仪器化。如果你决定使用这种方法,你只需要将张量从它们所在的 GPU 或 CPU 上.detach(),将它们带回你的工作 RAM 中进行记录在你的模型类中。

RNN 的一个很好的特性是,预测是逐步建立的,当你的forward()方法在每个连续的标记上运行时。这意味着你甚至可能不需要添加打印语句或其他仪器到你的模型类中。相反,你可以为输入文本的部分进行隐藏和输出张量的预测。

你可能想要为你的模型类添加一些predict_*便利函数,以便更容易地探索和解释模型的预测。如果你还记得 Scikit-Learn 中的LogisticRegression模型,它有一个predict_proba方法用于预测概率,除了用于预测类别的predict方法。一个 RNN 有一个额外的隐藏状态向量,有时你可能想要检查这个向量,以了解网络是如何进行预测的。因此,你可以创建一个predict_hidden方法来输出 128 维的隐藏张量,以及一个predict_proba来显示每个目标类别(国籍)的预测概率。

>>> def predict_hidden(self, text="Khalid"):
...    text_tensor = self.encode_one_hot_seq(text)
...    with torch.no_grad():  # #1
...    hidden = self.hidden_init
...        for i in range(text_tensor.shape[0]):  # #2
...            y, hidden = self(text_tensor[i], hidden)  # #3
...    return hidden

这个predict_hidden便利方法将文本(姓氏)转换为张量,然后通过一个热编码张量迭代运行前向方法(或者只是模型的self)。

>>> def predict_proba(self, text="Khalid"):
...    text_tensor = self.encode_one_hot_seq(text)
...    with torch.no_grad():
...        hidden = self.hidden_init
...        for i in range(text_tensor.shape[0]):
...            y, hidden = self(text_tensor[i], hidden)
...    return y  # #1

这个predict_hidden方法让你访问模型最有趣的部分,即预测逻辑正在发生的地方。随着每个字符的学习,隐藏层会不断演化,越来越多地了解姓名的国籍。

最后,你可以使用一个predict_category便利方法来运行模型的前向传递预测,以预测一个姓名的国籍。

>>> def predict_category(self, text):
...    tensor = self.encode_one_hot_seq(text)
...    y = self.predict_proba(tensor)  # #1
...    pred_i = y.topk(1)[1][0].item()  # #2
...    return self.categories[pred_i]

要认识到的关键一点是,对于所有这些方法,你不一定需要输入姓氏的整个字符串。重复评估姓氏文本的前部分是完全可以的,只要每次重置隐藏层即可。

如果你输入一个不断扩展的文本窗口,你可以看到预测和隐藏层在对姓氏的理解上是如何演变的。在与本书其他读者的集体编程会议期间,我们注意到几乎所有的名字最初都被预测为"中国",直到第三或第四个字符之后。这可能是因为很多中国姓氏只包含 4 个(或更少)字符。

现在你有了辅助函数,你可以用它们来记录隐藏层和类别预测,当 RNN 在姓名的每个字母上运行时。

>>> text = 'Khalid'
>>> pred_categories = []
>>> pred_hiddens = []

>>> for i in range(1, len(text) + 1):
...    pred_hiddens.append(model.predict_hidden(text[:i]))  # #1
...    pred_categories.append(model.predict_category(text[:i]))

>>> pd.Series(pred_categories, input_texts)
# K English
# Kh Chinese
# Kha Chinese
# Khal Chinese
# Khali Algerian
# Khalid Arabic

而且,您可以创建一个 128 x 6 的矩阵,其中包含 6 个字母名称中的所有隐藏层值。 PyTorch 张量列表可以转换为列表,然后转换为 DataFrame,以便更容易地操作和探索。

>>> hiddens = [h[0].tolist() for h in hiddens]
>>> df_hidden = pd.DataFrame(hidden_lists, index=list(text))
>>> df_hidden = df_hidden.T.round(2)  # #1

>>> df_hidden
    0     1     2     3     4     5   ...  122   123   124   125   126   127
K  0.10 -0.06 -0.06  0.21  0.07  0.04 ... 0.16  0.12  0.03  0.06 -0.11  0.11
h -0.03  0.03  0.02  0.38  0.29  0.27 ...-0.08  0.04  0.12  0.30 -0.11  0.37
a -0.06  0.14  0.15  0.60  0.02  0.16 ...-0.37  0.22  0.30  0.33  0.26  0.63
l -0.04  0.18  0.14  0.24 -0.18  0.02 ... 0.27 -0.04  0.08 -0.02  0.46  0.00
i -0.11  0.12 -0.00  0.23  0.03 -0.19 ...-0.04  0.29 -0.17  0.08  0.14  0.24
d  0.01  0.01 -0.28 -0.32  0.10 -0.18 ... 0.09  0.14 -0.47 -0.02  0.26 -0.11
[6 rows x 128 columns]

这堵数字墙包含了您的 RNN 在阅读名称时的所有“想法”。

提示

有一些 Pandas 显示选项可以帮助您对大型 DataFrame 中的数字有所了解,而不会出现 TMI(“太多信息”)。以下是本书中提高表格打印质量的一些设置。

要仅显示浮点值的 2 个小数位精度,请尝试:pd.options.display.float_format = '{:.2f}'

要从 DataFrame 显示最多 12 列和 7 行的数据:pd.options.display.max_columns = 12pd.options.display.max_rows = 7

这些选项仅影响数据的显示表示,而不是进行加法或乘法时使用的内部值。

正如您可能用其他大量数字的表格所做的那样,通过将其与您感兴趣的其他数字相关联,通常可以找到模式。例如,您可能想发现隐藏权重中是否有任何一个正在跟踪 RNN 在文本中的位置-即它距离文本的开头或结尾有多少个字符。

>>> position = pd.Series(range(len(text)), index=df_hidden.index)
>>> pd.DataFrame(position).T
# K h a l i d
# 0 0 1 2 3 4 5

>>> df_hidden_raw.corrwith(position).sort_values()
# 11 -0.99
# 84 -0.98
# 21 -0.97
# ...
# 6 0.94
# 70 0.96
# 18 0.96

有趣的是,我们的隐藏层在其隐藏内存中有空间来记录许多不同地方的位置。而且最强的相关性似乎是负相关。这些可能有助于模型估计当前字符是名字中最后一个字符的可能性。当我们观察了各种各样的示例名称时,预测似乎只在最后一个或两个字符处收敛到正确的答案。安德烈·卡尔帕西在他的博客文章《RNN 的不合理有效性》中尝试了几种从 RNN 模型的权重中获得见解的方法,这是在发现 RNN 时期的早期。^([22])]

8.2.4 多类别分类器与多标签标记器

怎样应对姓氏的多个不同正确国籍的歧义性?答案是多标签分类或标记,而不是熟悉的多类别分类。因为“多类分类”和“多标签分类”这些术语听起来相似且容易混淆,您可能想使用“多标签标记”或仅使用“标记”而不是“多标签分类”这个术语。如果您正在寻找适用于这种问题的sklearn模型,则要搜索“多输出分类”。

多标签标记器是用于模棱两可的任务的。在 NLP 意图分类和标记中,标签充满了具有模糊重叠边界的意图标签。当我们说“标记器”时,我们不是在谈论 Banksy 和 Bario Logan 街头艺术家之间的涂鸦之争,而是在谈论一种机器学习模型,可以为您数据集中的对象分配多个离散标签。

多类分类器具有多个不同的分类标签,这些标签与对象匹配,每个对象对应一个标签。分类变量只取几个相互排斥的类别中的一个。例如,如果您想要预测名字(给定名字)的语言和性别,那么就需要一个多类分类器。但是,如果您想要为名字标记所有相关的适当国籍和性别,那么您就需要一个标记模型。

这对您来说可能是在纠结细节,但这绝不仅仅是语义。在互联网上错误建议的噪音中,正在丢失您处理的文本的语义(含义)。当 David Fischer 在 ReadTheDocs.com(RTD)和圣地亚哥 Python 组织者开始学习 NLP 以构建 Python 包分类器时,他遇到了这些误导的博客文章。最终,他建立了一个标记器,为 RTD 广告商提供了更有效的广告位置,并为阅读文档的开发人员提供了更相关的广告。

提示

要将任何多类分类器转换为多标签标记器,您必须将激活函数从softmax更改为逐元素的sigmoid函数。Softmax 在所有相互排斥的分类标签上创建一个概率分布。Sigmoid 函数允许每个值取零到一之间的任意值,以便您多标签标记输出中的每个维度表示该特定标签适用于该实例的独立二进制概率。

8.3 通过时间的反向传播

对于 RNN 来说,反向传播比对 CNN 来说要复杂得多。训练 RNN 之所以如此计算密集,是因为它必须为每个文本示例的每个标记执行前向和后向计算多次。然后,它必须再次为 RNN 中的下一层执行所有这些操作。这一系列操作非常重要,因为一个标记的计算取决于前一个标记。您正在将输出和隐藏状态张量循环回到下一个标记的计算中。对于 CNN 和完全连接的神经网络,前向和后向传播计算可以同时在整个层上运行。您文本中每个标记的计算不会影响同一文本中相邻标记的计算。RNN 在时间上进行前向和后向传播,从序列中的一个标记到下一个标记。

但是您可以在图 8.7 中的展开的循环神经网络中看到,您的训练必须将错误通过所有权重矩阵乘法传播回去。即使权重矩阵对于数据中的所有标记都是相同的,或者tied,它们也必须作用于每个文本中的每个标记。因此,您的训练循环将需要向后循环遍历所有标记,以确保每一步的错误都被用来调整权重。

初始误差值是最终输出向量与适用于该文本样本的“真实”向量之间的距离。一旦你得到了真实向量和预测向量之间的差异,你就可以通过时间(标记)向后传播该误差,将该误差传播到上一个时间步(上一个标记)。PyTorch 包将使用与您在代数或微积分课程中使用的链式法则非常相似的东西来实现这一点。PyTorch 在正向传播过程中计算它需要的梯度,然后将这些梯度乘以每个标记的误差,以决定调整权重的量并改善预测。

一旦你为一层中的所有标记调整了权重,你就可以为下一层中的所有标记做同样的事情。从网络的输出一直回到输入(标记),你最终将不得不多次“触及”或调整每个文本示例的所有权重。与通过线性层或 CNN 层的反向传播不同,RNN 上的反向传播必须按顺序进行,一次一个标记。

一个循环神经网络(RNN)只是一个普通的前馈神经网络,被“卷起来”,以便线性权重被为文本中的每个标记再次相乘。如果展开它,你可以看到所有需要调整的权重矩阵。而且像卷积神经网络一样,许多权重矩阵在神经网络计算图的展开视图中对所有标记共享。RNN 是一个长的内核,它重用了每个文本文档中的“所有”权重。RNN 的权重是一个长而巨大的内核。在每个时间步长,它是相同的神经网络,只是在文本中的那个位置处理不同的输入和输出。

提示

在所有这些示例中,你一直在传递一个单一的训练示例,前向传播,然后反向传播错误。与任何神经网络一样,你的网络中的这个前向传播可以在每个训练样本之后发生,或者你可以批处理。而且批处理除了速度之外还有其他好处。但是现在,把这些过程看作单个数据样本、单个句子或文档。

在第七章中,你学会了如何使用 CNN 一次处理一个字符串。CNN 可以使用代表这些模式的内核(权重矩阵)识别文本中的意义模式。CNN 和前几章的技术非常适用于大多数 NLU 任务,如文本分类、意图识别和创建表示文本意义的嵌入向量。CNN 通过可以检测文本中几乎任何意义模式的重叠窗口权重来实现这一点。

图 8. 9. 使用嵌入进行 1D 卷积

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在第七章中,您想象将内核窗口跨越文本,一次一步地进行滑动。 但实际上,机器是在并行进行所有乘法。 操作的顺序并不重要。 例如,卷积算法可以对词对进行乘法,然后跳到窗口的所有其他可能位置。 它只需要计算一堆点积,然后在最后将它们全部相加或汇总在一起。 加法是可交换的(顺序无关紧要)。 实际上,在 GPU 上,这些矩阵乘法(点积)几乎同时并行进行。

但是 RNN 不同。 使用 RNN 时,您将一个标记的输出重新循环到您对下一个标记执行的点积中。 因此,即使我们讨论过 RNN 可以处理任意长度的文本,为了加快速度,大多数 RNN 流水线会将文本截断和填充到固定长度。 这样会展开 RNN 的矩阵乘法,这样和你需要为 RNN 需要两次矩阵乘法,而 CNN 需要一次乘法相比速度更快。 您需要一个用于隐藏向量的权重矩阵和另一个用于输出向量的权重矩阵。

如果您进行过任何信号处理或金融建模,您可能已经使用了 RNN 而不自知。 CNN 中的回归部分在信号处理和定量金融分析领域被称为“自回归”。 自回归移动平均模型是一个伪装的 RNN。^([23])

在本章中,您正在了解一种新的结构化输入数据的方式。 就像在 CNN 中一样,每个标记都与文本中的时间(t)或位置相关联。 变量 t 只是您标记序列中的索引变量的另一个名称。

甚至会看到您使用 t 的整数值来检索序列中的特定标记,例如 token = tokens[t]。 因此,当您看到 t-1tokens[t-1] 时,您知道它是指前一个时间步或标记。 而 t+1tokens[t+1] 则是指下一个时间步或标记。 在过去的章节中,您可能已经看到我们有时将 i 用于此索引值。

现在,您将使用多个不同的索引来跟踪输入到网络中的内容以及网络输出的内容:

  • ttoken_num:当前输入到网络中的张量的时间步或标记位置

  • ksample_num:正在训练的文本示例的批次中的样本号

  • bbatch_num:正在训练的样本集的批次号

  • epoch_num: 训练开始后经过的周期数

图 8.10 输入到循环网络的数据

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

这种文档的二维张量表示类似于第二章中文本的“自动钢琴”表示。只不过这一次,您将使用词嵌入来创建每个标记的密集表示。

对于 RNN,您不再需要一次处理每个文本样本。相反,您逐个标记地处理文本。

在你的循环神经网络中,你传入第一个标记的词向量,并获得网络的输出。然后传入第二个标记,但同时也传入了第一个标记的输出!然后传入第三个标记以及第二个标记的输出。依此类推。网络有一个关于前后、因果关系的概念 - 一些关于时间的模糊概念(见图 8.8)。

8.3.1 初始化 RNN 中的隐藏层

当您在每个新文档上重新启动 RNN 的训练时,隐藏层存在一个鸡生蛋的问题。对于您要处理的每个文本字符串,都没有“先前”的标记或先前的隐藏状态向量可供重新循环回网络中。您没有任何东西可以引导并启动循环(递归)循环。您的模型的 forward() 方法需要一个向量与输入向量连接,以便将其调整为与 W_c2hW_c2o 相乘的正确大小。

最明显的方法是将初始隐藏状态设置为全零,并在训练每个样本时快速将偏差和权重增加到最佳值。这对于任何正在跟踪时间的神经元(当前(递归)正在处理的标记序列中的位置)可能非常有用。但是,还有一些神经元试图预测您离序列末尾有多远。而且您的网络有一个定义明确的极性,0 表示关闭,1 表示打开。因此,您可能希望您的网络以零和一的混合值开始隐藏状态向量。最好的做法是使用一些介于零和 1 之间的梯度或值模式,这是您特定的“秘密配方”,基于您处理类似问题的经验。

在初始化深度学习网络时,变得有创意并保持一致,还有一个额外的好处,那就是创造更多可“解释”的人工智能。您经常会在权重中创建可预测的结构。通过每次都以相同的方式进行,您将知道在所有层中查找的位置。例如,您将知道隐藏状态向量中的哪些位置在跟踪文本中的位置(时间)。

要充分利用初始化值的一致性,您还需要在训练期间使用的样本的排序上保持一致。 您可以按其长度对文本进行排序,就像您在第七章中使用 CNN 时所做的那样。 但是,许多文本将具有相同的长度,因此您还需要一种排序算法,该算法可以一致地对具有相同长度的样本进行排序。 字母顺序是一个明显的选择,但这会倾向于使您的模型陷入局部最小值,因为它试图找到数据的最佳可能预测。 它会在“A”名称上表现得非常好,但在“Z”名称上表现不佳。 因此,在完全掌握已被证明非常有效的随机抽样和洗牌之前,请不要追求这种高级初始化方法。

只要您在整个训练过程中保持一致,您的网络将学习您的网络需要在这些初始值之上叠加的偏差和权重。 这可以在您的神经网络权重中创建一个可识别的结构。

提示

在某些情况下,使用初始隐藏状态而不是全零状态可能有助于启动您的神经网络。 Johnathon Frankle 和 Michael Carbin 发现,有意识地重用良好的初始化值可能是帮助网络找到特定数据集的全局最小损失的关键^(参见脚注 [24]) Their approach is to initialize all weights and biases using a random seed that can be reused in subsequent training.

现在您的网络记住了某些东西! 嗯,有点像。 还有一些事情需要您解决。 首先,这样的结构中反向传播工作是如何进行的?

Keras 社区中另一种流行的方法是保留来自先前批处理的隐藏层。 这种“预训练”的隐藏层嵌入给出了您的语言模型有关新文档上下文的信息 - 即它之前的文本。 但是,只有在训练中保持了文档的顺序才有意义。 在大多数情况下,您会在每个 epoch 中对训练示例进行洗牌和重洗牌。 当您希望您的模型在没有通过阅读类似文档或附近文本段落进行任何引导的情况下同样出色地进行预测时,您会这样做。

所以除非您试图挤出您对一个非常困难的问题的每一点准确性,否则您可能只需在每次将新文档输入模型时将其重置为零即可。 如果您确实使用了这种 stateful 方法来训练 RNN,请确保您能够在真实世界中(或者在您的测试集上)对每个预测需要的上下文文档进行热身,并确保您以一致的顺序准备文档,并且可以为需要对模型进行预测的新文档集合重现此文档排序。

8.4 使用递归网络记忆

循环神经网络记住了它们正在处理的文本中的前面单词,并且可以在处理理论上无限量的文本时不断地向其记忆中添加更多模式。这可以帮助它理解跨越整个文本的模式,并且识别出两个具有截然不同含义的文本之间的区别,这取决于单词出现的位置。

抱歉这封信太长了。我没有时间写一封更短的。

抱歉这封信太短了。我没有时间写一封更长的。

交换“短”和“长”这两个词,会改变这个马克·吐温的引用的含义。了解马克·吐温幽默的干燥的幽默感和对写作的热情,你能分辨出哪个是他的引用吗?是他为长信道歉的那个。他在轻松地谈论编辑和简洁写作是一项艰苦的工作。这是一件即使是最聪明的人类也比最聪明的人工智能做得更好的事情。

您在第七章学到的卷积神经网络会很难在关于长信和短信的这两个句子之间建立联系,而循环神经网络却可以轻松地做到这一点。这是因为卷积神经网络在识别模式时有一个有限的文本窗口。要理解整个段落,您必须构建具有重叠核或文本窗口的 CNN 图层。循环神经网络可以自然地做到这一点。循环神经网络记住了它们读取的文档中的每个标记的一些信息。在您告诉它们您已完成该文档之前,它们会记住您输入到其中的所有内容。这使它们更擅长摘要马克·吐温的长信,并使它们更擅长理解他的长而复杂的笑话。

马克·吐温是对的。简洁地传达事物需要技巧、智慧和对细节的关注。在论文“注意力就是一切”中,阿希什·瓦斯瓦尼揭示了变换器如何添加注意力矩阵,使循环神经网络能够准确理解更长的文档。在第九章中,您将看到这种注意机制的运作,以及使变换器方法成为迄今为止最成功和最灵活的深度学习架构的其他技巧。

长文本的摘要仍然是自然语言处理中未解决的问题。即使是最先进的循环神经网络和变换器也会犯初级错误。事实上,人工智能的赫特奖将为维基百科压缩(无损摘要)每提高一百分之一而奖励您 5000 欧元。赫特奖专注于压缩维基百科中的符号。您将学习如何压缩文本的含义。这甚至更难做到。很难衡量您做得有多好。

你将不得不开发通用智能机器,它们能够理解常识逻辑,并能够组织和操作记忆以及这些记忆的符号表示。这可能看起来无望,但事实并非如此。到目前为止,你构建的 RNN 可以记住其理解的一切,都存储在一个大的隐藏表示中。你能想到一种方法来给这个记忆结构一些结构,让你的机器可以更好地组织关于文本的思维吗?如果你让你的机器有一种单独的方式来维持短期记忆和长期记忆怎么样?这将给它一个工作记忆,当它遇到需要记住的重要概念时,它可以将其存储在长期记忆中。

8.4.1 单词级语言模型

所有你听说过的最令人印象深刻的语言模型都使用单词作为它们的令牌,而不是单个字符。所以,在你跳入 GRU 和 LSTM 之前,你需要重新安排你的训练数据,以包含单词 ID 的序列,而不是字符(字母)ID。而且你将不得不处理比只有姓氏长得多的文档,所以你会想要对你的数据集进行 batchify 以加快速度。

看一看维基文本-2 数据集,并思考如何预处理它以创建一系列令牌 ID(整数)。

>>> lines = open('data/wikitext-2/train.txt').readlines()
>>> for line in lines[:4]:
...     print(line.rstrip()[:70])

 = Valkyria Chronicles III =
 =======

 Senjō no Valkyria 3 : <unk> Chronicles ( Japanese : 戦場のヴァルキュリア3 ,
  lit

哇哦,这将是一个有趣的数据集。即使是英文版的维基百科也包含很多其他的自然语言,比如这篇第一篇文章中的日语。如果你使用前面章节的分词和词汇构建技能,你应该能够创建一个类似于即将出现的 RNN 示例中使用的 Corpus 类。

>>> from nlpia2.ch08.data import Corpus

>>> corpus = Corpus('data/wikitext-2')
>>> corpus.train
tensor([ 4,  0,  1,  ..., 15,  4,  4])

并且你总是希望确保你的词汇量包含了你需要从单词 ID 序列中生成正确单词的所有信息:

>>> vocab = corpus.dictionary
>>> [vocab.idx2word[i] for i in corpus.train[:7]]
['<eos>', '=', 'Valkyria', 'Chronicles', 'III', '=', '<eos>']

现在,在训练过程中,你的 RNN 将不得不逐个读取每个令牌。这可能相当慢。如果你能同时训练它在多个文本段落上呢?你可以通过将文本拆分成批次或 batchifying 你的数据来实现这一点。这些批次可以成为 PyTorch 中可以更有效地执行数学运算的矩阵中的列或行,在 GPU(图形处理单元)内。

nlpia2.ch08.data 模块中,你会找到一些批量化长文本的函数。

>>> def batchify_slow(x, batch_size=8, num_batches=5):
...    batches = []
...    for i in range(int(len(x)/batch_size)):
...        if i > num_batches:
...            break
...        batches.append(x[i*batch_size:i*batch_size + batch_size])
...    return batches
>>> batches = batchify_slow(corpus.train)
>>> batches
[tensor([4, 0, 1, 2, 3, 0, 4, 4]),
 tensor([ 5,  6,  1,  7,  8,  9,  2, 10]),
 tensor([11,  8, 12, 13, 14, 15,  1, 16]),
 tensor([17, 18,  7, 19, 13, 20, 21, 22]),
 tensor([23,  1,  2,  3, 24, 25, 13, 26]),
 tensor([27, 28, 29, 30, 31, 32, 33, 34])]

最后一步,你的数据已经准备好进行训练了。你需要 stack 这个列表中的张量,这样你就可以在训练过程中迭代一个大张量。

>>> torch.stack(batches)
tensor([[4, 0, 1, 2, 3, 0, 4, 4],
        [ 5,  6,  1,  7,  8,  9,  2, 10],
        [11,  8, 12, 13, 14, 15,  1, 16],
        ...

8.4.2 门控循环单元(GRUs)

对于短文本,具有单个激活函数的普通循环神经网络效果很好。所有神经元所需做的就是循环和重复利用它们迄今为止在文本中所读取的隐藏向量表示。但是普通循环神经网络的注意力集中范围有限,限制了它们理解较长文本的能力。随着您的机器阅读越来越多的文本,字符串中第一个令牌的影响会随着时间的推移而减弱。这就是门控循环单元(GRU)和长短期记忆(LSTM)神经网络试图解决的问题。

您认为如何抵消文本字符串中早期令牌的记忆衰减?您如何阻止衰减,但只针对长文本字符串开头的几个重要令牌?在记录或强调文本中的特定单词方面,您怎么想?这就是 GRU 所做的。GRU 添加了称为逻辑门(或只是“门”)的if语句到 RNN 神经元中。

机器学习和反向传播的魔法会替您处理 if 语句条件,因此您不必手动调整逻辑门阈值。RNN 中的门通过调整影响触发零或 1 输出(或介于两者之间的某种输出)的信号水平的偏置和权重来学习最佳阈值。而时间上的反向传播的魔法将训练 LSTM 门让重要信号(令牌含义的方面)通过并记录在隐藏向量和单元状态向量中。

但是等等,您可能认为我们的网络中已经有了 if 语句。毕竟,每个神经元都有一个非线性激活函数,作用是将一些输出压缩到零并将其他输出推向接近 1。因此,关键不是 LSTM 向网络添加门(激活函数)。关键在于新门是神经元内部并以一种连接方式连接的,这种连接方式创建了一个结构,您的神经网络不会自然地从一个正常的线性、全连接的神经元层中出现。这种结构是有意设计的,目的是反映研究人员认为将有助于 RNN 神经元解决这个长期记忆问题的内容。

除了原始 RNN 输出门之外,GRU 还在您的循环单元中添加了两个新的逻辑门或激活函数。

  1. 复位门:应该阻止隐藏层的哪些部分,因为它们对当前输出不再相关。

  2. 更新门:隐藏层的哪些部分应该与当前输出(现在,在时间t)相关。

您已经在 RNN 层的输出上有了一个激活函数。这个输出逻辑门在 GRU 中被称为“新”逻辑门。

>>> r = sigmoid(W_i2r.mm(x) + b_i2r +    W_h2r.mm(h) + b_h2r)  # #1
>>> z = sigmoid(W_i2z.mm(x) + b_i2z +    W_h2z.mm(h) + b_h2z)  # #2

>>> n =    tanh(W_i2n.mm(x) + b_i2n + r∗(W_h2n.mm(h) + b_h2n))  # #3

因此,当你考虑向你的神经网络添加多少单元来解决特定问题时,每个 LSTM 或 GRU 单元都给你的网络一个类似于 2 个 “普通” RNN 神经元或隐藏向量维度的容量。一个单元只是一个更复杂、更高容量的神经元,如果你数一数你的 LSTM 模型中的 “学习参数” 的数量,并将其与等效的 RNN 比较,你会看到这一点。

你可能想知道为什么我们开始使用"单元"这个词而不是"神经元"来描述这个神经网络的元素。研究人员使用"单元"或"细胞"来描述 LSTM 或 GRU 神经网络的基本构建块,因为它们比神经元稍微复杂一些。每个 LSTM 或 GRU 中的单元或细胞都包含内部门和逻辑。这使得你的 GRU 或 LSTM 单元具有更多的学习和理解文本的能力,因此你可能需要比普通 RNN 更少的单元来达到相同的性能。

重置更新 逻辑门是使用你在第五章熟悉的全连接线性矩阵乘法和非线性激活函数实现的。新的地方在于它们在每个标记上是递归实现的,并且它们是在隐藏向量和输入向量上并行实现的。图 8.12 显示了单个标记的输入向量和隐藏向量如何通过逻辑门流过并输出预测和隐藏状态张量。

图 8.11 GRU 通过逻辑门增加容量

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

如果你擅长阅读数据流图,比如图 8.12,你可能会看到 GRU 更新相关性 逻辑门实现了以下两个功能:

r = sigmoid(W_i2r.dot(x) + b_i2r + W_h2r.dot(h) + b_h2r)  # #1
z = sigmoid(W_i2z.dot(x) + b_i2z + W_h2z.dot(h) + b_h2z)  # #2

观察这两行代码,你会发现公式的输入完全相同。在这两个公式中,隐藏张量和输入张量都被权重矩阵相乘。如果你记得线性代数和矩阵乘法操作,你可能能简化这个过程。并且你可能会注意到在方块图(图 8.12)中,输入张量和隐藏张量在被重置权重矩阵 W_reset 进行矩阵乘法之前会被连接在一起。

一旦你将 GRU 添加到你的 RNN 模型架构中,你会发现它们更加高效。GRU 将以更少的学习参数、更少的训练时间和更少的数据获得更好的准确性。GRU 中的门给神经网络带来了结构,创造了更有效的机制来记住文本中的重要含义。为了衡量效率,你需要一些代码来计算模型中的学习(可训练)参数。这是你的模型必须调整以优化预测的权重值的数量。requires_grad 属性是一个简单的方法,用来检查特定层是否包含可学习参数。

>>> def count_parameters(model, learned=True):
...     return sum(
...         p.numel() for p in model.parameters()  # #1
...         if not learned or p.requires_grad  # #2
...     )

权重或学习参数越多,您的模型就能够学习有关数据的更多信息。但是,所有巧妙的想法(例如卷积和递归)的整个目的是创建高效的神经网络。通过选择正确的算法组合、大小和层类型,您可以减少模型必须学习的权重或参数的数量,并同时创建更智能的模型,具有更大的能力做出良好的预测。

如果您使用nlpia2/ch08/rnn_word/hypertune.py脚本尝试各种 GRU 超参数,则可以将所有结果与 RNN 结果聚合在一起,以进行比较。

>>> import jsonlines  # #1

>>> with jsonlines.open('experiments.jsonl') as fin:
...     lines = list(fin)
>>> df = pd.DataFrame(lines)
>>> df.to_csv('experiments.csv')
>>> cols = 'learned_parameters rnn_type epochs lr num_layers'
>>> cols += ' dropout epoch_time test_loss'
>>> cols = cols.split()
>>> df[cols].round(2).sort_values('test_loss', ascending=False)
>>> df
     parameters  rnn_type  epochs   lr  layers  drop  time (s)  loss
3      13746478  RNN_TANH       1  0.5       5   0.0     55.46  6.90
155    14550478       GRU       1  0.5       5   0.2     72.42  6.89
147    14550478       GRU       1  0.5       5   0.0     58.94  6.89
146    14068078       GRU       1  0.5       3   0.0     39.83  6.88
1      13505278  RNN_TANH       1  0.5       2   0.0     32.11  6.84
..          ...       ...     ...  ...     ...   ...       ...   ...
133    13505278  RNN_RELU      32  2.0       2   0.2   1138.91  5.02
134    13585678  RNN_RELU      32  2.0       3   0.2   1475.43  4.99
198    14068078       GRU      32  2.0       3   0.0   1223.56  4.94
196    13585678       GRU      32  2.0       1   0.0    754.08  4.91
197    13826878       GRU      32  2.0       2   0.0    875.17  4.90

从这些实验结果可以看出,GRU 是创建语言模型以足够准确地预测下一个单词的最佳选择。令人惊讶的是,与其他 RNN 架构相比,GRU 不需要使用更多的层数来实现相同的准确性。并且,与 RNN 相比,它们所需的训练时间更少,以实现可比较的准确性。

8.4.3 长短期记忆(LSTM)

LSTM 神经元增加了两个内部门,以试图提高 RNN 的长期和短期记忆容量。 LSTM 保留了更新和相关性门,但添加了新的门来进行遗忘和输出门,共四个内部门,每个具有不同的目的。第一个门只是您熟悉的普通激活函数。

  1. 遗忘门(f):是否完全忽略隐藏层的某个元素,以为将来更重要的令牌腾出空间。

  2. 输入或更新门(i):隐藏层的哪些部分应该对当前输出(现在,在时间t)起作用。

  3. 相关性或细胞门(i):应该阻塞哪些隐藏层的部分,因为它们与当前输出不再相关。

  4. 输出门(o):隐藏层的哪些部分应输出,既输出到神经元输出,也输出到文本中下一个令牌的隐藏层。

那么,图 8.12 右上角的未标注的tanh激活函数是什么?这只是用来从细胞状态创建隐藏状态向量的原始输出激活。隐藏状态向量保存了关于最近处理的令牌的信息;它是 LSTM 的短期记忆。细胞状态向量保存了关于文档自开始以来文本含义的表示,即长期记忆。

在图 8.13 中,你可以看到这四个逻辑门是如何配合使用的。每个逻辑门所需的各种权重和偏差都被隐藏起来,以精简图表。你可以想象在图表中看到的每个激活函数内进行的权重矩阵乘法。 另一个要注意的事情是隐藏状态不是唯一的循环输入和输出。你现在有了另一个编码或状态张量,称为单元状态。与以前一样,你只需要隐藏状态来计算每个时间步的输出。但是,新的单元状态张量是过去模式的长期和短期记忆所编码和存储的地方,以在下一个标记上重复使用。

图 8.12 LSTM 添加了一个遗忘门和一个单元输出

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

在图中,你可能只会在最聪明的博客文章中看到所需的显式线性权重矩阵,用于计算输出张量。^([30])即使是 PyTorch 文档也会忽略这个琐事。你需要在计划根据隐藏状态张量计算预测的哪一层添加这个全连接的线性层。

你可能会想:“等等,我以为所有隐藏状态(编码)都是相同的,为什么我们有这个新的单元状态?”好吧,这就是 LSTM 的长期记忆部分。单元状态是单独维护的,因此逻辑门可以记住事物并将它们存储在那里,而无需将它们混合在隐藏状态张量的较短期记忆中。单元状态逻辑与隐藏状态逻辑有些不同。它的设计是有选择性地保留它所保留的东西,为学习文本长期到达字符串结尾之前的东西保留空间。

计算 LSTM 逻辑门和输出的公式与计算 GRU 的公式非常相似。主要区别在于添加了另外 3 个函数,以计算所需的所有信号。一些信号已被重新路由以创建更复杂的网络,以存储短期和长期记忆之间的更复杂的连接模式。在隐藏状态和单元状态之间的这种更复杂的交互创造了更多的“容量”或内存和计算能力。因为 LSTM 单元包含更多的非线性激活函数和权重,所以它具有更多的信息处理能力。

r = sigmoid(W_i2r.mm(x) + b_i2r +    W_h2r.mm(h) + b_h2r)
z = sigmoid(W_i2z.mm(x) + b_i2z +    W_h2z.mm(h) + b_h2z)
n =    tanh(W_i2n.mm(x) + b_i2n + r∗(W_h2n.mm(h) + b_h2n))

f = sigmoid(W_i2f.mm(x) + b_i2f + W_h2f.mm(h) + b_h2f)  # #1
i = sigmoid(W_i2i.mm(x) + b_i2i + W_h2i.mm(h) + b_h2i)  # #2
g = tanh(W_i2g.mm(x) + b_i2g + W_h2y.mm(h) + b_h2g)  # #3
o = sigmoid(W_i2o.mm(x) + b_i2o + W_h2o.mm(h) + b_h2o)  # #4
c = f*c + i*g  # #5
h = o*tanh(c)

8.4.4 给你的 RNN 进行调谐

正如你在第七章学到的,随着你的神经网络变得越来越复杂,超参数调整变得越来越重要。随着模型变得越来越复杂,你对层次、网络容量和训练时间的直觉也会变得越来越模糊。RNNs 特别直观。为了启动你的直觉,我们训练了几十种不同的基本 RNNs,使用了不同的超参数组合,比如层数和每层的隐藏单元数。你可以使用nlpia2/ch08中的代码来探索你感兴趣的所有超参数。^([31])

import pandas as pd
import jsonlines

with jsonlines.open('experiments.jsonl') as fin:
    lines = list(fin)
df = pd.DataFrame(lines)
df.to_csv('experiments.csv')
cols = 'rnn_type epochs lr num_layers dropout epoch_time test_loss'
cols = cols.split()
df[cols].round(2).sort_values('test_loss').head(10)
epochs   lr  num_layers  dropout  epoch_time  test_loss
37      12  2.0           2      0.2       35.43       5.23
28      12  2.0           1      0.0       22.66       5.23
49      32  0.5           2      0.0       32.35       5.22
57      32  0.5           2      0.2       35.50       5.22
38      12  2.0           3      0.2       46.14       5.21
50      32  0.5           3      0.0       37.36       5.20
52      32  2.0           1      0.0       22.90       5.10
55      32  2.0           5      0.0       56.23       5.09
53      32  2.0           2      0.0       32.49       5.06
54      32  2.0           3      0.0       38.78       5.04

探索这样的选项超空间并发现建立准确模型的令人兴奋的技巧是一件非常令人兴奋的事情。令人惊讶的是,对于这个在维基百科的小数据子集上训练的 RNN 语言模型,你可以在不最大化模型大小和容量的情况下获得很好的结果。你可以通过一个 3 层的 RNN 比一个 5 层的 RNN 获得更好的准确性。你只需要以激进的学习率开始,并将辍学保持在最低水平。而且你的层数越少,模型训练速度就越快。

提示

经常进行实验,并始终记录你尝试过的事情以及模型的工作情况。这种实践提供了加速模型构建和学习的直觉的最快路径。你的终身目标是训练你的心智模型,以预测在任何情况下哪些超参数值会产生最好的结果。

如果你觉得模型过度拟合了训练数据,但找不到简化模型的方法,你可以尝试增加Dropout(百分比)。这是一种减少过度拟合的破坏性方法,同时允许你的模型具有匹配数据所需的复杂性。如果你将辍学百分比设置得高于 50%,模型开始学习变得困难。你的学习速度会变慢,验证误差可能会大幅波动。但是 20%到 50%是很多 RNNs 和大多数 NLP 问题的一个相当安全的范围。

如果你像 Cole 和我一样刚开始学习 NLP,你可能会想知道什么是“单元”。所有先前的深度学习模型都使用“神经元”作为神经网络内的计算基本单元。研究人员使用更一般的术语“单元”来描述 LSTM 或 GRU 中包含内部门和逻辑的元素。因此,当你考虑在神经网络中添加多少个单元来解决特定问题时,每个 LSTM 或 GRU 单元都会给你的网络提供类似于两个“正常”RNN 神经元或隐藏向量维度的容量。一个单元只是一个更复杂、更高容量的神经元,如果你数一下你的 LSTM 模型中的“学习参数”的数量,并将其与等效 RNN 的参数进行比较,你就会明白这一点。

8.5 预测

本章训练的基于单词的循环神经网络语言模型使用了WikiText-2语料库。[³²] 使用这个语料库的好处是,研究人员经常用它来评估他们的语言模型的准确性。而且,维基百科文章的文本已经为你分词了。此外,诸如文章末尾的参考文献之类的无趣部分已经被删除。

不幸的是,WikiText-2 的 PyTorch 版本包含了随机替换或屏蔽了 2.7%的标记的"“标记。这意味着,除非有一些可预测的模式决定了哪些标记被”"标记屏蔽,否则你的模型将永远无法获得很高的准确性。但是如果你下载了原始的无标记文本,你可以在此基础上训练你的语言模型,并快速提高准确性。[³³] 你还可以将你的 LSTM 和 GRU 模型的准确性与使用此基准数据的专家们的准确性进行比较。[³⁴]

这是训练集train.txt末尾的一个示例段落。

>>> from nlpia2.ch08.rnn_word.data import Corpus
>>> corpus = Corpus('data/wikitext-2')
>>> passage = corpus.train.numpy()[-89:-35]
>>> '  '.join([vocab.idx2word[i] for i in passage])
Their ability at mimicry is so great that strangers have looked in vain
for the human they think they have just heard speak . <eos>
Common starlings are trapped for food in some Mediterranean countries .
The meat is tough and of low quality , so it is <unk> or made into <unk> .

看起来 WikiText-2 基准语料库中的最后一篇维基百科文章是关于普通椋鸟(欧洲的一种小鸟)。从文章中可以看出,椋鸟似乎擅长模仿人类的语言,就像你的 RNN 一样。

那些"“标记呢?这些是为了测试机器学习模型而设计的。语言模型的训练目标是预测被”“(未知)标记替换的单词。因为你的大脑中有一个相当好的英语语言模型,你可能可以预测那些被所有那些”"标记屏蔽的标记。

但是,如果你正在训练的机器学习模型认为这些是正常的英文单词,你可能会让它困惑。在本章中训练的循环神经网络正在试图识别无意义的""标记的含义,这将降低其对语料库中所有其他单词的理解。

提示

如果你想避免这种额外的错误和困惑,你可以尝试在wikitext-2基准的非官方原始文本上训练你的 RNN。在 nlpia2 存储库中的非官方原始版本和官方 wikitext-2 语料库的标记之间存在一对一的对应关系。[³⁵]

那么在这个训练集中有多少个"“和”"标记呢?

>>> num_eos = sum([vocab.idx2word[i] == '<eos>' for i in
 corpus.train.numpy()])
>>> num_eos
36718
>>> num_unk = sum([vocab.idx2word[i] == '<unk>' for i in
 corpus.train.numpy()])
>>> num_unk
54625
>>> num_normal = sum([
...     vocab.idx2word[i] not in ('<unk>', '<eos>')
...     for i in corpus.train.numpy()])
>>> num_normal
1997285
>>> num_unk / (num_normal + num_eos + num_unk)
0.0261...

所以 2.6%的标记已经被无意义的"“标记替换了。而”"标记标记了原始文本中的换行符,这通常是维基百科文章段落的结尾。

那么让我们看看它在写类似 WikiText-2 数据集中的新句子时的表现如何,包括"“标记。我们将提示模型以单词"The"开始写作,以找出它的"思路”。

>>> import torch
>>> from preprocessing import Corpus
>>> from generate import generate_words
>>> from model import RNNModel

>>> corpus = Corpus('data/wikitext-2')
>>> vocab = corpus.dictionary
>>> with open('model.pt', 'rb') as f:
...    orig_model = torch.load(f, map_location='cpu')  # #1

>>> model = RNNModel('GRU', vocab=corpus.dictionary, num_layers=1)  # #2
>>> model.load_state_dict(orig_model.state_dict())
>>> words = generate_words(
...    model=model, vocab=vocab, prompt='The', temperature=.1)  # #3
>>> print('  '.join(w for w in words))
...
= =  Valkyria Valkyria Valkyria Valkyria = = The kakapo is a common
 starling , and the of the of the ,
...

训练集中的第一行是“=瓦尔基利亚编年史 III =”,训练语料库中的最后一篇文章标题为“=普通星雀 =”。 因此,这个 GRU 记住了如何生成类似于它所读取的文本段落开头和结尾的文本。 因此,它似乎确实具有长期和短期记忆能力。 考虑到我们只在一个非常小的数据集上训练了一个非常简单的模型,这是令人兴奋的。 但是这个 GRI 似乎还没有能力存储在两百万令牌长序列中找到的所有英语语言模式。 它肯定不会很快进行任何意义上的理解。

注意

意义是人们赋予他们共享的经验的意义的方式。 当您尝试向自己解释其他人为什么做他们正在做的事情时,您正在进行意义上的理解。 你不必独自一人做。 一个社区可以通过社交媒体应用程序甚至对话式虚拟助手进行公共对话来集体进行。 这就是为什么它经常被称为“集体意义制定”。 类似 DAOStack 的初创公司正在尝试使用聊天机器人提炼出社区的最佳想法,并将它们用于构建知识库和做出决策。^([36])

您现在知道如何训练一个多功能的 NLP 语言模型,可以在单词级或字符级标记上使用。 您可以使用这些模型来对文本进行分类,甚至生成适度有趣的新文本。 而且您不必在昂贵的 GPU 和服务器上疯狂。

8.6 测试自己

  • 有哪些技巧可以提高 RNN 阅读长文档的“保留”?

  • 现实世界中一些“不合理有效”的 RNN 应用是什么?

  • 你如何使用一个名字分类器来做好事? 名称分类器的一些不道德用途是什么?

  • 对于类似于 Mark Burnett 的密码数据集的数百万用户名密码对的数据集,一些道德和亲社会的 AI 用途是什么?^([37])

  • 在 WikiText-2 数据集的原始文本、未屏蔽文本上训练 rnn_word 模型,其中令牌的比例为 “”。 这是否提高了您的单词级 RNN 语言模型的准确性?

  • 修改数据集,为每个名称使用多热张量标记所有国籍。([38])([39]) 您应该如何衡量准确性? 您的准确性是否提高了?

8.7 摘要

  • 在自然语言标记序列中,RNN 可以记住它到目前为止读取的所有内容,而不仅仅是有限的窗口。

  • 沿着时间(令牌)维度分割自然语言语句可以帮助您的机器加深对自然语言的理解。

  • 你可以将错误向后传播到过去(标记),也可以将错误向后传播到深度学习网络的层中。

  • 因为 RNN 特别是深度神经网络,RNN 梯度特别暴躁,它们可能会消失或爆炸。

  • 直到循环神经网络应用于任务,才能高效地对自然语言字符序列进行建模。

  • 在给定样本中,RNN 中的权重是在时间上聚合调整的。

  • 你可以使用不同的方法来检查循环神经网络的输出。

  • 你可以通过同时将令牌序列通过 RNN 向前和向后传递来模拟文档中的自然语言序列。

[1] 数学论坛 StackExchange 关于重复和递归的问题(math.stackexchange.com/questions/931035/recurrence-vs-recursive

[2] MIT 开放课程 CS 6.005 “软件构建” 的讲座(ocw.mit.edu/ans7870/6/6.005/s16/classes/10-recursion/

[3] Papers with Code 查询 RNN 应用(proai.org/pwc-rnn

[4] Daniel Miessler 的无监督学习播客第 340 集(mailchi.mp/danielmiessler/unsupervised-learning-no-2676196)以及 RNN 源代码(github.com/JetP1ane/Affinis

[5] Ryan Stout 的(github.com/ryanstout)BustAName 应用程序(bustaname.com/blog_posts

[6] Garrett Lander、Al Kari 和 Chris Thompson 为我们的揭秘 Mueller 报告项目做出了贡献(proai.org/unredact

[7] Rorschach 测试维基百科文章(en.wikipedia.org/wiki/Rorschach_test

[8] “循环神经网络的不合理有效性”(karpathy.github.io/2015/05/21/rnn-effectiveness

[9] PyTorch RNNBase 类源代码(github.com/pytorch/pytorch/blob/75451d3c81c88eebc878fb03aa5fcb89328989d9/torch/nn/modules/rnn.py#L44

[10] 波特兰 Python 用户组关于揭秘 Mueller 报告的演示(proai.org/unredact

[11] Lex Fridman 采访前间谍安德鲁·布斯塔曼特(lexfridman.com/andrew-bustamante

[12] 在 nlpia2 包中有更多信息和数据抓取代码(proai.org/nlpia-ch08-surnames

[13] Sean Robertson 的 PyTorch RNN 教程(pytorch.org/tutorials/intermediate/char_rnn_classification_tutorial.html

[14] 原始的 PyTorch RNN 教程姓氏数据集含有重复项(download.pytorch.org/tutorial/data.zip

[15] 在 GitLab 上的 nlpia2 仓库中的 iPython history 日志,其中包含抓取姓氏数据的示例(proai.org/nlpia-ch08-surnames

[16] PyTorch 基于字符的 RNN 教程(pytorch.org/tutorials/intermediate/char_rnn_classification_tutorial.html

[17] Qary(docs.qary.ai)结合了我们所有多语言聊天机器人的技术和数据(tangibleai.com/our-work

[18] 2020 年在阿姆斯特丹启动的 AI 算法注册表(algoritmeregister.amsterdam.nl/en/ai-register/

[19] 欧盟人工智能法案网站(artificialintelligenceact.eu/

[20] 被接受的“OECD 人工智能理事会”建议(legalinstruments.oecd.org/en/instruments/OECD-LEGAL-0449

[21] 感谢 Tiffany Kho 指出这一点。

[22] 脚注:“RNN 的不合理有效性” 作者是 Andrej Karpathy([karpathy.github.io/2015/05/21/rnn-effectiveness

[23] ARMA 模型解释(en.wikipedia.org/wiki/Autoregressive_model

[24] arxiv.org/pdf/1803.03635.pdf

[25] 《注意力机制就是你所需要的一切》作者是 Ashish Vaswani 等人(arxiv.org/abs/1706.03762

[26] en.wikipedia.org/wiki/Hutter_Prize

[27] 完整的源代码在 nlpia2 包中(gitlab.com/tangibleai/nlpia2/-/blob/main/src/nlpia2/ch08/rnn_word/data.py

[28] PyTorch GRU 层的文档(pytorch.org/docs/stable/generated/torch.nn.GRU.html#torch.nn.GRU

[29] PyTorch 文档讨论关于统计学习参数的问题(discuss.pytorch.org/t/how-do-i-check-the-number-of-parameters-of-a-model/4325/9

[30] 感谢 Rian Dolphin 提供的《LSTM 网络 | 详细解释》(archive.today/8YD7k

[31] nlpia2 Python 包中 ch08/rnn_word 模块中的 hypertune.py 脚本 gitlab.com/tangibleai/nlpia2/-/blob/main/src/nlpia2/ch08/rnn_word/hypertune.py

[32] PyTorch torchtext 数据集(pytorch.org/text/0.8.1/datasets.html#wikitext-2

[33] 包含所有 “unk” 标记的原始、未蒙版的文本的 “answers”(s3.amazonaws.com/research.metamind.io/wikitext/wikitext-2-raw-v1.zip

[34] 人工智能研究者(www.salesforce.com/products/einstein/ai-research/the-wikitext-dependency-language-modeling-dataset/

[35] nlpia2 包含本章节中使用的 rnn_word 模型代码和数据的代码和数据(data.html

[36] DAOStack 去中心化治理平台(deck.html

[37] 亚历山大·费什科夫(Alexander Fishkov)对马克·伯内特(Mark Burnett)的一千万个密码进行的分析(1424-passwords-on-the-internet-publicly-available-dataset.html)- 文章底部有种子磁力链接。

[38] PyTorch 社区多标签(标记)数据格式示例(905.html

[39] torchtext 数据集类多标签文本分类示例(11571.html


  1. 14 ↩︎

  2. 15 ↩︎

  3. [1] ↩︎

  4. [2] ↩︎

  5. 8 ↩︎

  • 22
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值