本章内容:
- 展开递归,帮助你理解如何将其应用于自然语言处理(NLP)
- 在PyTorch中实现基于单词和字符的递归神经网络(RNN)
- 确定RNN是最佳选择的应用场景
- 理解时间反向传播
- 使用长短期记忆(LSTM)使RNN更智能
递归神经网络(RNN)为自然语言处理(NLP)带来了革命性的变化。它们催生了许多实际应用和深度学习与人工智能领域的进展,包括移动设备上的实时转录与翻译、高频算法交易和高效的代码生成。RNN通过回收利用之前的token来理解文本,那么为什么要回收和重用你的词语呢?当然是为了建立一个更可持续的NLP管道!递归就是回收的另一个说法。RNN通过递归记住已经读过的token,并将这些理解重新利用以预测目标变量。如果你使用RNN预测下一个词,RNN可以一直生成,直到你告诉它停止为止。这种可持续性或再生能力就是RNN的超级力量。
事实上,如果你的NLP管道能记住它已经读过并理解的内容,它就能更好地预测句子中的下一个token。等一下,难道CNN没有通过带有权重的卷积核或过滤器“记住”附近的token吗?确实是这样!但是,CNN只能记住一个有限的窗口,通常是几个词的长度。而RNN通过在移动到下一个token之前回收机器对每个token的理解,可以记住它所读过的所有token的一部分。这使得你的机器阅读器更加可持续——它可以不断地阅读……直到你决定停止。
但是等一下,递归不危险吗?如果这是你在看到“递归”这个词时的第一反应,那你并不孤单。任何学过算法的同学,可能都因为不正确地使用递归而使一个函数崩溃,导致整个程序不可用,甚至使整个Web服务器宕机。正确而安全地使用递归的关键是确保每次递归时,算法都能减少它必须做的工作量。这意味着在每次调用函数之前,你需要从输入中删除一些内容。在你的自然语言处理(NLP)RNN中,这一点自然而然地得到了体现,因为在将输入再次传入网络之前,你会弹出(移除)栈中的token。
注意:严格来说,递归和递归调用是两个不同的概念。大多数数学家和计算机科学家使用这两个词来解释同一个概念——将部分输出回馈到输入中,重复执行一个操作。但正如所有自然语言中的词汇一样,这些概念是模糊的,理解它们对构建递归神经网络有帮助。正如你将在本章的代码中看到的,RNN并没有像你通常理解的那样,自调用递归函数。.forward(x)方法在RNN外部的for循环中被调用。
RNN具有神经形态学特征。这是一种高大上的说法,意思是研究人员在设计像RNN这样的人工神经网络时,模仿了大脑的工作方式。你可以利用你对自己大脑工作的理解,想出如何用人工神经元处理文本的创意。你的大脑正在递归地处理你现在正在阅读的token,所以递归一定是一个聪明而高效的方式,帮助大脑理解文本。
当你阅读这段文字时,你在回收你已经知道的前面词汇的信息,然后更新你对接下来会发生什么的预测。你会一直预测,直到读到一个句子、段落或你试图理解的任何内容的结束。然后,你可以在文本结束时停下来,处理你刚刚读到的所有内容。就像本章中的RNN一样,你大脑中的RNN也利用结束时的停顿来对文本进行编码、分类并从中获取信息。而且由于RNN总是在预测,你可以利用它们预测你NLP管道应该说出的词。这意味着RNN不仅适用于阅读文本数据,还适用于标记和写作文本。
8.1 RNN的优势
之前你学习的深度学习架构非常适合处理短文本——通常是单个句子。RNN有望突破这一文本长度的障碍,使你的NLP管道能够处理几乎无限长的文本序列。而且,它们不仅可以处理不断增长的文本,还可以生成文本,生成的文本长度可以根据需要无限延长。RNN开启了全新的应用领域,比如生成式对话聊天机器人和文本摘要生成器,后者能够合成你文档中不同部分讨论的概念。表8.1列出了你可以使用RNN在单个张量和多个张量的长序列之间来回转换的三种方式。
表8.1 RNN的多种输入输出方式
| 类型 | 描述 | 应用场景 |
|---|---|---|
| 一对多 | 一个输入张量用于生成一系列输出张量 | 生成聊天消息、回答问题、描述图片 |
| 多对一 | 一系列输入张量汇聚成一个输出张量 | 根据语言、意图或其他特征对文本进行分类或标记 |
| 多对多 | 一系列输入张量用于生成一系列输出张量 | 翻译、标记或匿名化文本中的token,回答问题,参与对话 |
这就是RNN的超能力:它们处理的是token或向量的序列。你不再局限于处理单个固定长度的向量。图8.1展示了RNN的灵活性,能够处理输入和输出,无论是单个token还是几乎无限的多个token序列。这意味着你不再需要截断和填充输入文本,试图将文本强行塞入固定的长度限制中。如果你愿意,RNN甚至可以生成持续不断的文本序列,因此你不必在任意预定的最大长度处截断输出。你的代码可以动态决定何时停止。
你可以使用RNN在许多你已经熟悉的任务上实现最先进的性能,即使你的文本(稍微短一点)并非无限长:
- 翻译
- 摘要生成
- 分类
- 问答系统
RNN是完成一些新NLP任务最高效和准确的方式之一,这些任务你将在本章中学到:
- 生成新文本,例如释义、摘要,甚至是问题的答案
- 标记单个token
- 像在英语课上那样分析句子的语法
- 创建语言模型,预测下一个token
如果你查看Papers With Code排行榜上的RNN,你会发现RNN是许多应用中最有效的方法。
RNN不仅仅是研究人员和学者的工具。让我们实际点说。在现实世界中,人们在多种多样的应用中使用RNN:
- 拼写检查和修正
- 自动完成自然语言或编程语言表达式
- 句子分类用于语法检查或FAQ聊天机器人
- 分类问题或生成问题答案
- 为聊天机器人生成有趣的对话文本
- 命名实体识别(NER)和提取
- 分类、预测或生成个人、婴儿和公司名称
- 分类或预测子域名(用于安全漏洞扫描)
你可能猜得出大部分应用的目的,但你可能对最后一个应用感到好奇。子域名是URL中域名的第一部分——比如www.lesswrong.com中的www,或en.wikipedia.org中的en。为什么有人会想预测或猜测子域名呢?其中一个原因是,一旦黑客或渗透测试人员知道了一个子域名,他们就可以扫描它来寻找服务器的安全漏洞。Dan Meisler在他的播客中深入探讨了子域名猜测器在网络安全工具箱中的关键作用。
一旦你熟练掌握了RNN,你可能会发现自己乐于生成全新的单词、短语、句子、段落,甚至整页文本,甚至不小心开始创建一些能带来商业机会的应用:
- 建议公司、产品或域名名称
- 提供婴儿名字建议
- 标注和标记句子
- 为文本框生成自动补全
- 改写和重组句子
- 发明俚语和短语
8.1.1 RNN序列处理
除了NLP,RNN还适用于任何数值数据序列,例如时间序列;你只需要将序列中的对象表示为数值向量。对于自然语言单词,这通常是词嵌入,但你也可以看到城市政府如何将日常或每小时的电动滑板车租赁、免费公路交通或天气条件表示为向量。而且,通常他们会希望在一个向量中同时预测所有这些内容。
由于RNN可以为序列中的每个元素输出结果,你可以创建一个RNN,它输出“明天”的预测——即你当前已知的元素之后的序列元素。然后,你可以使用该预测来递归地预测接下来的元素。这意味着,一旦你掌握了时间上的反向传播,你将能够使用RNN来预测以下内容:
- 第二天的天气
- 下一分钟的网页流量
- 下一秒的分布式拒绝服务(DDOS)网页请求
- 汽车驾驶员在接下来100毫秒内采取的行动
- 视频片段中一系列帧中的下一帧图像
一旦你有了目标变量的预测,你就可以衡量误差——即模型输出与期望输出之间的差异。通常,这会在你处理的事件序列中的最后一个时间步骤发生。
8.1.2 RNN记住你告诉它的一切
你是否曾不小心触摸到湿漆,然后发现每次触摸到东西时都会“重新使用”那层漆?也许,当你还是孩子的时候,你可能曾自认为是印象派画家,通过用手指在周围的墙壁上涂鸦来与世界分享你的艺术。现在,你将学习如何构建一个更具意识的印象派单词画家。在第7章中,你将字母模板想象为使用CNN处理文本的类比。现在,不是将字模板滑过句子中的单词,而是你将用滚筒涂抹它们……而且是在它们还湿的时候!
想象一下,用慢干的油漆涂写一个句子的字母,并且涂得很厚。假设你正在用一系列彩虹色的油漆来写一个信息,支持LGBTQ骄傲周的自行车道,消息可能看起来像图8.2所示。
现在,拿起一支干净的油漆滚筒,从句子的开头一直滚到结尾。如果你的字母间距与滚筒的周长相匹配,滚筒将从一个字母上拾起油漆并将其回收,再将油漆涂回到接下来的字母上。根据滚筒的大小,一些字母(或字母的部分)会被滚动覆盖到右边的字母上。所有第一个字母之后的字母都会被揉合在一起,形成一个模糊的条纹,最终只模糊地类似于原始句子,如图8.3所示。
这层污迹将之前字母的所有油漆收集成一个紧凑的表示,代表了原始文本。但是,它是一个有用且有意义的表示吗?对于人类读者来说,你所做的只是创造了一团五彩斑斓的混乱,无法向阅读它的人传达太多含义。这就是为什么人类不会使用这种文本意义的表示方式。然而,如果你考虑一下这个字符的污迹,你可能能想象机器是如何解释它的。对于机器来说,它无疑比原始的字符序列更紧凑、更密集。
在NLP中,我们希望创建紧凑、密集的文本向量表示。幸运的是,我们正在寻找的表示隐藏在你的油漆滚筒上!当你用全新的、干净的滚筒涂抹文本中的字母时,它收集了所有滚过的字母的记忆,类似于你在第六章中创建的词嵌入。这种嵌入方法甚至适用于非常长的文本;从理论上讲,你可以无限制地滚动文本,直到你决定停止,将越来越多的文本压缩进紧凑的表示中。
在前几章中,你的令牌主要是单词或单词n-gram。现在,你需要扩展你的令牌概念,包含单个字符。最简单的RNN使用基于字符的令牌化器,这意味着字符而不是单词作为令牌。就像你在前几章中使用词和令牌嵌入一样,你也可以认为字符也具有意义。现在,你是否更容易理解“Wet Paint!”字母最后的污迹如何代表所有字母的嵌入?
最后一步可能有助于你揭示这个思想实验中的隐藏含义。从想象你的油漆滚筒的嵌入开始,然后把它滚到一张足够容纳单个字母的干净纸上。这将输出油漆滚筒对文本的记忆的紧凑表示,直到你决定使用它做某事时,这些信息才会暴露出来。这就是RNN中文本嵌入的工作方式。嵌入被隐藏在你的RNN中,直到你决定输出它们或将它们与其他内容结合使用以便重用。事实上,在许多RNN实现中,这种文本的向量表示存储在一个叫做hidden的变量中。
提示 RNN嵌入与第6章和第7章中学到的词和文档嵌入不同。随着RNN随时间或文本位置收集意义,RNN将意义编码到这个向量中,你可以在处理文本中的后续令牌时重用它。这就像Python中的str.encode()函数,它创建了Unicode文本字符的多字节表示。令牌序列处理的顺序对最终结果——编码向量——至关重要,因此你可能想把RNN嵌入称为编码、编码向量或编码张量——这是Garrett Lander在一个将NLP应用于极其长和复杂文档(如医疗记录或Mueller报告)的项目中推动的词汇变化。这种新的词汇使得他的团队更容易开发共享的NLP管道的思维模型。
在本章稍后留意隐藏层。激活值存储在变量h或hidden中。这个张量中的激活值是你到目前为止在文本中收集的嵌入。每当处理一个新的令牌时,它们会被新的值覆盖,因为你的NLP管道会收集到它已经读取过的令牌的意义。在图8.4中,你可以看到这种在嵌入向量中意义的融合比原始文本更紧凑、更模糊。
你可以从油漆的污迹中解读出一些原始文本的含义,就像在罗夏墨迹测试中一样。罗夏墨迹测试是通过墨迹或油漆在闪卡上制造污迹,用来激发人们的记忆,并测试他们的思维或心理健康。这种由油漆滚筒创造的污迹不仅仅是一团乱糟糟的东西,而恰恰是你试图实现的目标:一个印象主义的、更加紧凑的原始文本表示。你可以清洁你的滚筒,然后在新的文本行上反复执行这个过程,得到不同的污迹,为你的神经网络提供不同的含义。很快,你会看到这些步骤是如何类比于RNN层中的数学操作的。
你的油漆滚筒把句子结尾的许多字母涂抹在一起,感叹号现在几乎完全无法辨认。但正是这个不可理解的部分,恰恰是机器需要理解整个句子的关键,尽管它的表面区域有限。你已经将句子的所有字母涂抹到滚筒的表面,要看到消息,你只需要把它滚到一张干净的纸上。在RNN中,你可以通过在将RNN滚过一些文本的令牌之后输出隐藏层的激活值来完成这一过程。这个编码后的消息对你作为人类可能没有太多意义,但它为你的油漆滚筒(即机器)提供了整个句子含义的线索。
你的油漆滚筒已经将整个句子的字母压缩或编码成了一条短小的、模糊的印象主义油漆条。在RNN中,这个污迹就是一个数字的向量或张量。编码向量中的每个位置或维度就像你油漆污迹中的一个颜色,每个编码维度都包含了RNN被设计来跟踪的一个意义方面。油漆在滚筒上留下的印象(隐藏层的激活)被不断回收,直到你到达文本的结尾,然后你重新使用所有在滚筒上的污迹来创建整个句子的新的印象。
8.1.3 RNN隐藏它们的理解
与CNN相比,RNN的主要进展在于它通过回收每个令牌的意义来维护一个隐藏的嵌入向量,它是逐个读取令牌的。这个隐藏的权重向量包含了RNN在当前阅读的文本点之前所理解的一切。这意味着你不能一次性在整个处理文本上运行网络。在之前的章节中,你的模型学会了一个将一个输入映射到一个输出的函数。但正如你很快会看到的那样,RNN学会了一个程序,它会一直在你的文本上运行,直到完成。RNN需要一次处理一个令牌。
一个普通的前馈神经元只是将输入向量与一组权重相乘以产生输出。不管你的文本有多长,CNN或前馈神经网络都必须进行相同数量的乘法操作来计算输出预测。线性神经网络的神经元共同工作,组成一个新的向量来表示你的文本。你可以在图8.5中看到,一个普通的前馈神经网络接受一个向量输入(x),将其与一个权重矩阵(W)相乘,应用激活函数,然后输出一个转换后的向量(y)。前馈网络层只能将一个向量转换为另一个。
在RNN中,神经元永远无法看到整个文本的向量。相反,RNN必须一次处理一个令牌。为了跟踪它已经读取的令牌,RNN记录一个隐藏向量(h),这个向量可以传递给它的未来状态——即最初生成隐藏向量的相同神经元。在计算机科学领域,这个隐藏向量被称为“状态”。这就是为什么Andrej Karpathy和其他深度学习研究人员对RNN的有效性感到如此兴奋的原因。RNN终于使得机器能够学习图灵完备的程序,而不仅仅是孤立的函数。图8.6中的递归循环使得RNN能够不断运行它从数据中学到的程序。你的程序需要一个退出或停止令牌;否则,它将永远循环下去。
如果你展开RNN,它开始看起来像一条链——实际上是一个马尔可夫链。但这次,你的窗口只有一个令牌宽,而且你在处理下一个令牌之前,会先将当前令牌与前一个令牌的输出结合起来并重用。幸运的是,当你在第7章中滑动CNN窗口或核时,你就做了类似的操作。
幸运的是,在实现有效的Python神经网络递归时,你不需要绕着递归函数调用(这是你可能在一些编程面试中遇到的任务)。你只需要一个变量来单独存储隐藏状态,以及一个单独的权重矩阵来计算隐藏张量。以下代码实现了一个最小的RNN,而没有使用PyTorch的RNNBase类。
>>> 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
#1 vocab_size 和 hidden_size 分配空间用于组合输入。
#2 添加了 W_c2h1, W_c2h2 等 "Linear" 层,以便进行更深的学习。
#3 "x" 是最新令牌的一热编码向量,"hidden" 是最新的编码向量。
#4 将一热令牌向量与最新的隐藏(编码)向量连接。
#5 使用 nn.Linear 点积将组合向量转换为隐藏向量。
#6 使用点积将组合向量转换为 y(类别概率的输出向量)。
#7 注意,输入和输出都包括隐藏编码向量——它在下一个令牌中被重用。
现在,新的RNN神经元产生的不仅仅是一个输出。因为,除了输出或预测之外,RNN还需要输出隐藏状态张量,这个张量将被未来的自我神经元重用。
当然,PyTorch的实现有许多其他功能。PyTorch的RNN甚至可以同时从左到右和从右到左进行训练!当然,像这样的双向语言模型只有在你的问题是非因果性时才有用。在英语NLP中,非因果模型是设计来预测你已经知道的其他单词之前(位于左侧)的单词的模型。非因果模型的常见应用包括推断故意遮掩的报告中的难以辨认的单词,以及纠正由光学字符识别(OCR)错误导致的文本不准确和损坏。如果你对双向RNN感兴趣,所有的PyTorch RNN模型(包括RNN、门控循环单元(GRU)模型、长短时记忆(LSTM)模型,甚至是transformer)都提供了启用双向递归的选项。对于问答模型和其他复杂问题,双向模型通常能比默认的前向(因果)语言模型提高5%到10%的准确率。这种显著的准确率提升可以归因于一个简单的事实:双向语言模型的嵌入提供了比通用模型更好的平衡——它们忘记的部分无论是文本的开始部分还是结束部分,都是相等的。
8.1.4 RNNs 记住你告诉它们的一切
RNNs 保留了所有文档令牌的记忆。为了理解这一点,首先从展开图8.7中的神经元图开始。你需要创建神经元的副本,然后通过 for 循环将它们展示给它的未来自己,该循环遍历你的令牌。你可以将这个过程想象成通过复制和粘贴循环中的代码行来展开一个 for 循环。
图8.7展示了RNN将隐藏状态传递给下一个未来自我神经元,这有点像奥运接力赛跑者传递接力棒。然而,这根接力棒上印上了越来越多的记忆,因为它在RNN中不断被回收。你可以看到输入令牌的张量在RNN看到文本的最后一个令牌之前被修改了许多次。
RNN的另一个优点是,你可以在处理过程中随时访问输出张量。这意味着你可以解决机器翻译、命名实体识别(NER)、文本的匿名化和反匿名化,甚至是政府文件的去红action等挑战。
本质上,这两个特性使RNN独特:
- 你可以在一个文本文档中处理任意数量的令牌。
- 每处理一个令牌后,你可以输出任何你需要的东西。
第一个特性不是什么大问题。正如你在CNN中看到的那样,如果你想处理长文本,你只需要为其在最大输入张量大小中留出空间。事实上,迄今为止最先进的NLP模型——transformers,创建了一个最大长度限制,并像CNN一样对文本进行填充。
然而,RNN的第二个特性是非常重要的。想象一下,使用一个模型给句子中的每个令牌标注标签,你可以做多少事情。语言学家花费大量时间绘制句法图和标注令牌。RNN和深度学习彻底改变了语言学研究的方式。只需看看spaCy能够为一些示例“hello world”文本中的每个单词识别出的语言学特征,如以下清单所示。
清单8.2 使用RNN标注令牌的SpaCy
>>> 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 ! .
这就是你可以随时输出的许多有用信息!但是,对于一段非常长的文本,RNN实际能记住多少信息呢?让我们来看看!
8.2 使用仅有姓氏预测国籍
为了帮助你快速了解如何进行“回收”,你将从最简单的标记开始:低ly字符。你将构建一个模型,根据姓氏中的字母来预测国籍。这个模型可能听起来并不那么有用,你甚至可能担心它可能被用来伤害来自特定文化的个体。
就像你们中的一些人一样,当我们提到训练一个模型来预测名字的族群特征时,作者的LinkedIn关注者们也表示怀疑。不幸的是,企业和政府确实使用类似的模型来识别和定位特定的群体,通常带来有害的后果。但这些模型也可以用于做好事。我们用它们帮助非营利组织和政府客户对其对话AI数据集进行匿名化。志愿者和开源贡献者可以使用这些匿名化的对话数据集训练NLP模型,以识别对用户有用的医疗或教育内容,同时保护用户隐私。
这个多语言数据集将给你一个机会,学习如何处理非英语词汇中常见的变音符号和其他修饰符。为了保持趣味性,你将移除这些字符修饰符和多语言文本中的其他提示信息。这样,你的模型可以学习你真正关心的模式,而不是依赖这些“捷径”来轻松地完成任务。处理这个数据集的第一步是将其转化为纯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'
现在,你有了一个“规范化”字母表的管道,适用于多种语言,你的模型会更好地进行泛化。你的模型将适用于几乎所有使用拉丁字母的文本,甚至是从其他字母表转写到拉丁字母的文本。你可以使用这个模型对任何几乎所有语言的字符串进行分类。你只需要为每个你感兴趣的语言提供一些标注的示例。
现在,让我们看看你是否创建了一个可以解决的问题。一个可解的机器学习问题是指以下每个陈述都为真:
- 你可以想象一个人回答你提出的问题。
- 对于你想要问模型的大多数问题,存在一个正确的答案。
- 你不期望机器比一个经过良好训练的专家表现得更好。
考虑一下这个根据姓氏预测国家或方言的问题。记住,我们已经移除了很多语言的线索,比如那些独特于非英语语言的字符和修饰符。这个问题能解决吗?
从第一个可解问题的测试开始,你能想象一个人仅凭“规范化”后的姓氏来识别一个人的国籍吗?就我个人而言,我(Hobson)经常猜错我学生的国籍,仅凭他们的姓氏。在现实生活中,我永远不会做到100%准确,机器也做不到。所以,只要你能接受模型的不完美,这是一个可解的问题。如果你构建了一个好的管道,并且有大量标注数据,你应该能够创建一个至少与像我这样的人的准确度相当的RNN模型。令人难以置信的是,你的模型甚至可能比经过良好训练的语言学专家还要准确!
思考一下是什么让这个问题变得困难。姓氏与国家之间没有一一对应的关系。尽管姓氏通常会在父母与子女之间代代相传,但人们常常会搬迁,改变国籍、文化和宗教。这些因素都会影响某个国家中常见的姓氏。有时,个人或整个家庭会决定更改姓氏,尤其是移民、外籍人士和间谍。人们有很多原因希望融入其中,这种文化和语言的融合使得人类在共同工作时能够成就伟大事业。RNN为你的国籍预测模型提供了相同的灵活性。如果你恰好想改变自己的姓氏,这个模型可以帮助你创造一个让人(和机器)感知到你希望的国籍的姓氏。
通过查看数据集中的一些随机姓名,看看你是否能发现多个国家之间共用的字符模式。
清单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
#1 Tangible AI’s augmented version of the original PyTorch surname dataset
#2 read_csv can read from URLs or filepaths, but you may need to specify compression='gzip' for some URLs.
在深入分析之前,先快速查看一下数据。鉴于数据集中大量以“Aa”开头的荷兰姓氏,你可能会假设荷兰国籍的人更喜欢让他们的姓氏出现在点名册的开头,频率比其他国家的家庭更高。而且,看起来摩洛哥、荷兰和芬兰的语言文化倾向于在姓氏的开头使用三字母组合“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中,你可以看到收集自多个来源的37个独特国籍和语言类别。这使得问题变得如此困难。就像一个多项选择题,其中有36个错误答案,只有一个正确答案。更具挑战性的是,这些区域或语言类别通常是重叠的——例如,阿尔及利亚语被视为阿拉伯语,而巴西语是葡萄牙语的一种方言。几个名字跨越这些国籍边界,因此模型无法对所有名字给出正确答案;它只能尽可能多地返回正确答案。
这些国籍和数据源的多样性帮助我们进行名字替换,匿名化我们多语言聊天机器人的交流信息。这样,我们可以在开源项目中分享对话设计数据集,就像第12章讨论的聊天机器人一样。RNN模型非常适合用于匿名化任务,例如命名实体识别和生成虚拟名字。它们甚至可以用来生成虚拟但现实的社会保障号码、电话号码和其他个人身份信息(PII)。为了构建这个数据集,我们增强了PyTorch RNN教程数据集,添加了从公共API抓取的数据,这些数据包含来自非洲、南美洲和大洋洲的代表性不足的国家的数据。
当我们构建这个数据集时,在Manning的Twitch频道的每周集体编程活动中,Rochdi Khalid指出他的姓氏是阿拉伯语的。他住在摩洛哥的卡萨布兰卡,阿拉伯语是官方语言之一,与法语和柏柏尔语一起使用。这个数据集是来自多种来源的数据的混合,其中一些基于广泛的语言类别(如阿拉伯语)创建标签,而另一些则使用具体的国籍或方言标签(如摩洛哥人、阿尔及利亚人、巴勒斯坦人和马来西亚人)。
数据集偏见是最难以补偿的偏见之一,除非你能够为你希望提升的群体找到数据。除了公共API,你还可以挖掘内部数据来获取姓名。我们的匿名化脚本从多语言聊天机器人对话中删除姓名。我们将这些姓名添加到此数据集中,以确保它是一个具有代表性的样本,包含与我们的聊天机器人交互的用户类型。你可以在你的项目中使用这个数据集,当你需要一个来自多种文化的全球姓名样本时。
多样性具有挑战性。正如你所想象的那样,这些音译名字的一些拼写在跨国界,甚至跨语言之间被重用。翻译和音译是两个独立的NLP问题,你可以使用RNN来解决这两个问题。例如,尼泊尔语词汇“नमस्कार”可以翻译为英语单词“hello”。但在你的RNN尝试翻译这个尼泊尔词之前,它首先会将其音译为“namaska¯ra”,即只使用拉丁字符集。大多数多语言深度学习管道利用拉丁字符集(罗马字母)来表示所有语言中的单词。
音译是将一个语言字母表中的字符和拼写转换为另一个语言字母表的过程,使得可以使用拉丁字符集表示单词,这个字符集在欧洲和美洲广泛使用。一个简单的例子是去掉或添加法语字符é的尖音符,如“résumé”到“resume”和“raffiné”到“refined”。对于非拉丁字母表来说,音译就更加复杂,如尼泊尔语。
以下是你如何计算每个国籍类别之间的重叠度:
>>> 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教程《NLP从零开始:用字符级RNN分类姓名》。在Arabic.txt文件中的2,000个阿拉伯名字中,只有108个独特的阿拉伯姓氏。
清单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区分国家的模式。如果你打算在真实世界中使用这个模型处理更随机的姓名样本,请记住这一点。
清单8.6 姓名与国籍的重叠
>>> 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 = pd.DataFrame.from_records(overlap).T
>>> 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]
在清单8.6中显示的数据框包含了来自印度和非洲的姓名,有助于你训练一个更加健壮的模型,可以处理多样化的国籍数据。尽管添加了更多的姓名和国籍,这个数据集比原始数据集小得多,因为重复项已经被移除。每当你以这种方式压缩数据集时,你需要统计与每个姓名相关的标签,以保持训练数据集的统计信息和准确性。
这个姓氏国籍数据集扩展了PyTorch RNN教程,使用了来自多语言聊天机器人的匿名数据。如果AI模型在这样的姓氏数据集上训练,可以在实际世界中伦理地应用,例如对聊天日志或其他包含用户姓名的数据集进行匿名化。在对姓名进行匿名化时,如果需要保持这些名字的国籍,你可以创建一个随机映射,将其与匿名化数据进行关联。通过随机化姓名的国籍,可以用来多样化和去偏数据集,这是任何伦理AI管道的重要组成部分。通过可靠地匿名化数据集,你的组织可以成为全球最具伦理的AI组织之一,实行默认开放政策,不仅是对你的软件,而且对你的数据集和模型。
提示: 一个非常好的方法来检查机器学习管道是否有可能解决你的问题,假装你是机器。给自己在训练集中的一些示例做训练,然后尝试在测试集上回答一些问题,看看你是否能做到不看正确标签。你的NLP管道应该能够几乎像你一样解决问题。在某些情况下,你会发现机器比你做得更好,因为它们可以比你能记住的更多的模式平衡。
8.2.1 从头构建RNN
你的RNN类的核心部分如列表8.7所示。像所有Python类一样,PyTorch的Module类有一个__init__()方法,你可以在其中设置一些配置值,这些值控制类的其余部分的工作方式。对于RNN,你可以使用__init__()方法来设置控制隐藏向量中神经元数量以及输入和输出向量大小的超参数。
对于依赖分词器的NLP应用,最好在__init__()方法中包含分词器参数,这样可以更方便地从保存到磁盘的数据中重新实例化。否则,你会发现最终会在磁盘上保存多个不同的模型,每个模型可能使用不同的词汇表或字典来对数据进行分词和向量化。如果这些模型和分词器没有存储在同一个对象中,保持它们的连接将是一个挑战。
NLP管道中的向量化器也是如此。你的管道必须一致地存储词汇表中的每个单词,并且如果输出是类别标签,你还必须对类别的顺序保持一致。如果每次重新使用模型时不精确地定义类别标签的顺序,就容易混淆。如果模型使用的数值没有一致地映射到这些类别的可读名称,输出将变成乱码标签。如果你将向量化器存储在模型类中(参见列表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
#1 将超参数添加到你的__init__()方法中,以便可以比较不同的架构 #2 n_categories等于n_outputs,因为它是独热编码的。 #3 x是输入,一个单一的独热编码字符向量。 #4 RNN返回输出预测和隐藏编码向量,用于处理每个token。
从技术上讲,你的模型不需要完整的char2i词汇表。它只需要知道你计划在训练和推理时输入的独热token向量的大小,以及类别标签的大小。同样,模型实际上只需要知道类别的数量——这些类别的名称对机器来说是无意义的。但通过在模型中包含类别标签,你可以在调试模型内部时,随时将它们打印到控制台。
8.2.2 逐个token训练RNN
在nlpia2项目中,来自35个国家的超过30,000个姓氏的数据集是可以管理的,即使是在一台普通的笔记本电脑上。使用这个数据集,你应该不会遇到在合理时间内训练RNN的问题。如果你的笔记本有四个或更多的CPU核心,并且至少有6GB的内存,训练大约需要30分钟。如果你仅限于使用10个国家和10,000个姓氏,并且在选择学习率时运气好(或者聪明),你可以在仅仅2分钟内训练出一个不错的模型。
与其使用内置的torch.nn.RNN层,不如从头开始构建你的第一个RNN,使用普通的Linear层,如下所示。这样可以帮助你更好地理解RNN的工作原理,以便你能够为几乎任何应用设计自己的RNN。
列表8.8 逐个样本训练
>>> def train_sample(model, category_tensor, char_seq_tens,
... criterion=nn.NLLLoss(), lr=.005):
""" 训练一个epoch(一个示例的姓名和国籍张量对) """
... 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()
#1 在计算第一个token的输出之前,初始化隐藏层为零
#2 每个示例文本的第一个token,RNN从头开始。
#3 PyTorch的Module(模型)对象是可调用的,因为它将`.call()`重定向到`.forward()`方法。
#4 注意,隐藏状态向量既是模型`.forward()`方法的输入也是输出。
nlpia2包包含一个脚本,用来协调训练过程并允许你尝试不同的超参数:
>>> %run classify_name_nationality.py #1
surname nationality
0 Tesfaye Ethiopian
...
[36241 rows x 7 columns]
#1 在IPython控制台(REPL)中,`%run`命令相当于终端中的python命令。
提示:你应该在IPython控制台中使用%run魔法命令,而不是在终端中使用Python解释器运行机器学习脚本。IPython控制台就像一个调试器,允许你在脚本运行结束后检查所有全局变量和函数。如果你取消运行或者错误导致脚本中断,你仍然可以检查全局变量,而不必重新开始。
一旦你启动了classify_name_nationality.py脚本,它会提示你一些关于模型超参数的问题。这是培养对深度学习模型直觉的最佳方法之一。这也是我们选择相对较小的数据集和可以在合理时间内成功训练的小问题的原因。这样,你可以尝试多种不同的超参数组合,既能微调你的模型,也能在微调的过程中培养对NLP的直觉。
列表8.9展示了一些超参数选择,它们会给你相当不错的结果。然而,我们也留给你一些空间,让你自己探索超参数的“超空间”。你能找到一组可以以更高精度识别更广泛国籍的超参数吗?
列表8.9 通过交互式提示实验超参数
你想训练多少个国籍?[10]? 25
model: RNN(
n_hidden=128,
n_categories=25,
categories=[Algerian..Nigerian],
vocab_size=58,
char2i['A']=6
)
你想训练多少个样本?[10000]? 1500
你想使用什么学习率进行训练?[0.005]? 0.010
即使是这个简化版的RNN模型,只有128个神经元和1,500个epoch,仍然需要几分钟才能收敛到一个不错的准确率。这个示例(见列表8.10)是在一台具有四核(八线程)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 == 0.04)。让我们尝试一些在多个国家都常用的常见姓氏:
>>> 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算法中最重要的部分是你用来训练它的度量标准。在列表8.8中,你使用了NLLLoss作为PyTorch优化训练循环中的损失函数,其中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
这意味着模型对于“Rochdi”是阿尔及利亚人的判断信心只有31%。这些概率(可能性)可以用来向你的老板、团队成员,甚至用户解释模型的信心水平。
如果你是“通过打印调试”的爱好者,你可以修改你的模型,打印出任何你关心的、关于模型如何做出预测的数学部分。每当你想记录一些内部运作时,PyTorch模型可以通过print语句进行“注入”。如果你决定使用这种方法,你只需要通过.detach()将张量从GPU或CPU中分离出来,带回到工作内存中进行记录。
RNN的一个不错的特点是,预测是逐步构建的,因为你的forward()方法在每个连续的token上运行。这意味着你甚至不需要在模型类中添加打印语句或其他监控工具。你只需要在输入文本的不同部分进行隐藏状态和输出张量的预测。
你可能想要为你的模型类添加一些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
#1 当进行预测时,禁用梯度计算,避免不必要的计算。
#2 每一行是文本中的字符级token(字母)的张量。
#3 所有继承自nn.Module的对象是可调用的,`self()`等同于`self.forward()`。
这个predict_hidden便捷方法将文本(姓氏)转换为张量,然后遍历独热张量,通过forward方法(或仅通过模型的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
#1 `predict_proba`和`predict_hidden`方法是相同的,唯一不同的是返回的张量。
这个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]
#1 `predict_proba`方法计算输出张量的softmax(),以近似每个类别的概率。
#2 PyTorch的张量有一个`topk`方法,用来找出张量中排名前k的元素。
关键的是要认识到,对于这些方法,你不一定需要输入整个姓氏字符串。只要你每次重置隐藏层,重新评估姓氏文本的第一部分也是完全可以的。
如果你输入扩展的文本窗口,你可以看到预测和隐藏层如何随着对姓氏理解的加深而演化。在与这本书的其他读者进行的集体编程会话中,我们注意到几乎所有的姓名在开始时都被预测为“中文”国籍,直到读到第三或第四个字符。这可能是因为很多中文姓氏包含四个(或更少)字符。
现在,你已经有了辅助函数,可以在RNN逐个字母处理姓名时,记录隐藏状态和类别预测:
>>> text = 'Khalid'
>>> pred_categories = []
>>> pred_hiddens = []
>>> for i in range(1, len(text) + 1):
... input_texts.append(text[:i])
... 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
#1 运行RNN处理文本“K”,然后是“Kh”,“Kha”,“Khal”等
你可以创建一个128 × 6的矩阵,显示六个字母姓名的所有隐藏层值。将PyTorch张量的列表转换为列表的列表,然后再转换为DataFrame,以便更容易地操作和探索:
>>> hiddens = [h[0].tolist() for h in pred_hiddens]
>>> df_hidden = pd.DataFrame(hiddens, index=list(text))
>>> df_hidden = df_hidden.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]
#1 使用`pd.options.display.float_format = '{:.2f}'`来保留内部精度
这堆数字包含了RNN在读取姓名时对姓名的“理解”。
使用Pandas显示选项改善输出
有一些Pandas显示选项可以帮助你在大型DataFrame中感知数字的意义,同时不会提供过多的信息。以下是一些在本书中有助于改善表格输出的设置:
- 要显示浮点值的两位小数精度,可以使用
pd.options.display.float_format = '{:.2f}'。 - 要显示DataFrame中最多12列和7行的数据,可以使用
pd.options.display.max_columns = 12和pd.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的早期,Andrej Karpathy在他的博客文章《RNNs的非理性有效性》中尝试了几种不同的方法来从RNN模型的权重中获取洞察。
8.2.4 多类分类器与多标签标注器
如何处理那些可能有多个正确国籍的姓氏歧义问题?答案是使用多标签分类(multi-label classification),而不是熟悉的多类分类(multiclass classification)。由于多类分类和多标签分类这两个术语听起来非常相似且容易混淆,你可能更希望使用“多标签标注”(multi-label tagging)或简称“标注”(tagging)来代替“多标签分类”。如果你正在寻找适合这种问题的sklearn模型,可以搜索“多输出分类”(multi-output classification)。
多标签标注器正是为了解决歧义问题而设计的。在NLP中,意图分类/标注充满了具有模糊重叠边界的意图标签。当我们提到标注器时,我们并不是指Banksy与Barrio Logan街头艺术家之间的涂鸦战争,而是在谈论一种机器学习模型,它可以为数据集中的对象分配多个离散标签。
多类分类器使用几个不同的类别标签来与对象匹配——每个对象一个标签。分类变量只能属于几种互斥的类或类别之一。例如,如果你想预测与名字(给定名)相关的语言和性别,那就需要一个多类分类器。但如果你想为一个名字标注所有适当的国籍和性别,那么你就需要一个标注模型。
这看起来可能像是在挑剔,但这远不只是语义上的区别。其实是你正在处理的文本的语义(意义)在互联网上那些错误的建议噪声中丧失了。David Fischer,负责组织San Diego Python并在ReadTheDocs.com(RTD)从事广告、安全和隐私工作的人员,在开始学习NLP以构建Python包分类器时,遇到了这些误导性的博客文章。最终,他构建了一个标注器,使RTD广告商能够为他们的广告提供更有效的展示,并为阅读文档的开发人员提供了更相关的广告。
提示 要将任何多类分类器转化为多标签标注器,你必须将激活函数从softmax更改为逐元素的sigmoid函数。softmax会在所有互斥的类别标签上创建一个概率分布。另一方面,sigmoid函数允许每个值在零和一之间取任意值,这样你在多标签标注输出中的每个维度就代表了该标签对该实例应用的独立二进制概率。
8.3 时间反向传播(Backpropagation through time)
对于RNN,反向传播要比CNN复杂得多。训练RNN之所以计算开销大,是因为它必须对每个文本示例执行多次前向和反向计算——每处理一个token就执行一次。然后,它必须为RNN的下一层重新执行这些计算。这个操作序列非常重要,因为一个token的计算依赖于前一个token。你将输出和隐藏状态张量回收并用于计算下一个token。对于CNN和全连接神经网络(fully connected neural networks),前向和反向传播的计算可以对整个层进行一次性处理。文本中每个token的计算不会影响同一文本中相邻token的计算。RNN在时间上进行前向和反向传播,从序列中的一个token到下一个token。
在图8.7中,你可以看到在展开的RNN中,你的训练必须通过所有权重矩阵的乘法进行误差的反向传播。尽管对于数据中的所有token,权重矩阵是相同的或是共享的,但这些矩阵必须作用于你文本中的每个token。所以,你的训练循环需要反向遍历所有token,确保每个步骤的误差都用来调整权重。
初始的误差值是最终输出向量与该文本示例适当标签的“真实”向量之间的距离。一旦你获得了真实向量和预测向量之间的差异,就可以通过时间(token)向后传播这个误差,直到传播到前一个时间步(前一个token)。PyTorch将使用类似于你在代数或微积分课上学过的链式法则来实现这一过程。PyTorch在前向传播过程中计算所需的梯度,然后将这些梯度与每个token的误差相乘,以决定调整权重的程度,并改进预测结果。
一旦你为一层中的所有token调整了权重,就对下一层中的所有token执行相同的操作。从网络的输出一直反向到输入(token),最终你将不得不为每个文本示例多次调整所有的权重。与线性层或CNN层的反向传播不同,RNN的反向传播必须串行进行,一次处理一个token。
RNN只是一个普通的前馈神经网络“卷起”后,对文本中的每个token反复乘以Linear权重。如果你将其展开,就可以看到所有需要调整的权重矩阵,和CNN一样,许多权重矩阵在神经网络计算图的展开视图中在每个token上共享。RNN是一个长核(kernel),它在每个文本文档中重复使用所有的权重,其权重只是一个巨大的卷积核。在每个时间步,它是相同的神经网络,只是处理文本中当前位置的不同输入和输出。
提示 在这些示例中,你传入的是单个训练示例,执行前向传播,然后进行误差反向传播。像所有神经网络一样,网络的前向传播可以在每个训练样本之后进行,或者你也可以将它们批量处理。事实证明,批处理不仅仅带来速度上的好处。但目前,暂时把这些过程理解为单个数据样本、单个句子或文档。
在第7章中,你学会了如何使用CNN一次性处理整个字符串。CNN可以使用代表文本中意义模式的核(权重矩阵)识别文本中的意义模式。CNN和前几章中的技术对于大多数自然语言理解(NLU)任务非常有效,比如文本分类、意图识别,以及创建表示文本意义的向量嵌入。CNN通过权重的重叠窗口来实现这一点,这些窗口可以检测文本中的几乎任何意义模式。
在图8.9的顶部,你可以看到这个句子:“Words are sacred”。在下面,是每个时间步的行数据,如果你有一个包含两个单词的窗口滑过文本并进入文本末尾的<PAD> token。在第7章中,你想象了将核窗口步进到文本上,每次一步。但实际上,机器会并行执行所有的乘法操作——操作的顺序不重要。例如,卷积算法可以先对一对单词进行乘法,然后跳转到窗口的所有其他可能位置。它只需要计算一堆点积,然后将它们全部求和。加法是可交换的,这意味着操作顺序不重要。而且,卷积的点积操作彼此之间没有依赖关系。实际上,在GPU上,这些矩阵乘法(点积)都是并行进行的,几乎同时发生。这就是CNN具有高速的原因。
但RNN是不同的。在RNN中,你将一个token的输出回收并再次用于对下一个token的点积计算。所以,尽管我们提到过RNN可以处理任意长度的文本,为了加快速度,大多数RNN管道会将文本截断并填充到一个固定长度。这会展开RNN矩阵乘法,使你能够按照它们应用到各个token的顺序来检查这些操作。虽然CNN只需要一个矩阵乘法,但RNN需要两个:一个用于隐藏向量,另一个用于输出向量。
如果你做过信号处理或金融建模,你可能在不知情的情况下使用过RNN。CNN中的递归部分在信号处理和定量金融分析领域被称为自回归。自回归移动平均(ARMA)模型就是一个伪装的RNN。
在本章中,你将学习一种新的输入数据结构方式。就像在CNN中一样,每个token都与文本中的时间(t)或位置相关联。变量t只是你token序列中的索引变量的另一种名称。
你甚至会看到一些地方,你使用t的整数值来检索序列中的特定token,表达式如token = tokens[t]。所以当你看到t-1或tokens[t-1]时,你知道这是指前一个时间步或token,同样,t+1和tokens[t+1]是指下一个时间步或token。在前几章中,你可能已经看到我们有时使用i来表示这个索引值。
现在,你将使用几个不同的索引来跟踪输入到网络中的数据以及网络输出的数据:
t或token_num— 当前输入到网络中的tensor的时间步或token位置k或sample_num— 在批处理中的文本示例样本编号b或batch_num— 训练中样本集的批次编号epoch_num— 自训练开始以来已经经过的epoch数量
在图8.10中,你可以看到另一种思考展开RNN的方式。想象一下七个关于马戏团小丑的token被挤进一辆车里,并且想象RNN的隐藏层嵌入在一次性通过七次矩阵乘法的序列中计算出来。
这个文档的二维张量表示类似于第二章中提到的玩家钢琴式的文本表示。只是这次,你使用词嵌入(word embeddings)为每个token创建了一个密集的表示。
对于RNN,你不再需要一次性处理整个文本样本。相反,你将文本逐个token处理。首先,你传入第一个token的词向量并获得网络的输出;然后,你传入第二个token,但同时也传入第一个token的输出;接着,你传入第三个token,并传入第二个token的输出;依此类推。网络有一个前后、因果的概念——某种模糊的时间概念(见8.2.3节)。
8.3.1 初始化RNN中的隐藏层
在每次重新开始训练RNN时,隐藏层会面临一个“鸡和蛋”问题。对于每个你想处理的文本字符串,网络没有“前一个”token或前一个隐藏状态向量可以回收再利用。你没有任何东西来启动泵并开始回收(递归)循环。模型的forward()方法需要一个向量,与输入向量连接,以便它的大小适合与W_c2h和W_c2o相乘。
最明显的方法是将初始隐藏状态设置为全零,并允许偏置和权重在每个样本的训练中迅速调整到最佳值。这对那些跟踪时间的神经元很有用,当前正在(递归地)处理的token序列的位置。但也有一些神经元试图预测你离序列末尾还有多远,而且你的网络有一个定义的极性,0表示关闭,1表示开启。因此,你可能希望网络的隐藏状态向量开始时包含一些零和一的混合。更好的是,你可以使用一些梯度或在0和1之间的值模式,作为你根据类似问题的经验得出的“秘密武器”。
在初始化深度学习网络时,富有创意并保持一致性还有一个额外的好处,即能创建更“可解释”的AI。通常,你会在权重中创建一个可预测的结构。通过每次以相同的方式进行,你将知道在所有层中哪里进行检查。例如,你将知道隐藏状态向量中的哪些位置在跟踪文本中的位置(时间)。
为了充分利用这种初始化值一致性,你还需要确保训练过程中样本的顺序保持一致。你可以像在第7章中对CNN那样,根据文本的长度对其进行排序。但许多文本的长度相同,因此你还需要一种排序算法,能够一致地对长度相同的样本进行排序。按字母顺序排序是一个显而易见的选择,但这往往会把模型困在局部最小值中,因为它试图为数据找到最佳的预测。它会在A名字上表现得很好,但在Z名字上表现不好。因此,在你完全掌握了随机抽样和洗牌方法之前,不要采用这种高级的初始化方法。
只要你在整个训练过程中保持一致,网络将学习到网络需要在这些初始值上叠加的偏置和权重。这可以在神经网络的权重中创建一个可识别的结构。
提示 在某些情况下,将神经网络的初始隐藏状态设为全零以外的值可能会有所帮助。Jonathan Frankle和Michael Carbin发现,有意地重复使用良好的初始化值可能是帮助网络找到针对特定数据集可达到的全局最小损失的关键。他们的方法是初始化所有权重和偏置,使用一个可以在后续训练中重复使用的随机种子。
现在,你的网络开始记住一些东西了!好吧,差不多。有一些问题仍然需要你弄清楚。首先,像这样的结构中,反向传播是如何工作的?
Keras社区中的另一个流行方法是保留来自前一批文档的隐藏层。这个预训练的隐藏层嵌入为你的语言模型提供了关于新文档的上下文信息——即在它之前的文本。然而,只有当你保持批次中文档的顺序,并且跨批次保持顺序时,这才有意义。在大多数情况下,你会在每个epoch中洗牌和重新洗牌训练示例。当你希望你的模型在没有通过阅读相似文档或临近段落进行预热的情况下,也能平等地做出预测时,你会这样做。
因此,除非你在尝试挤压每一分准确度,解决一个非常困难的问题时,否则你应该每次都将其重置为零,开始输入新文档。如果你确实使用这种有状态的训练方法来训练RNN,请确保你能够为每个需要在现实世界(或测试集)中进行预测的预测,预热模型上下文文档。同时,确保你以一致的顺序准备文档,以便对其他文档集进行预测时可以复制这个顺序。
8.4 通过递归网络记忆
RNN能够记住它正在处理的文本中的前一个单词,并且随着处理理论上无限量的文本,它可以不断将更多的模式添加到记忆中。这有助于它理解跨越整个文本的模式,并识别两段视觉上相似但含义截然不同的文本之间的区别:
我为这封长信道歉。我没有时间写一封更短的信。
我为这封短信道歉。我没有时间写一封更长的信。
交换“短”和“长”这两个词,翻转了这句马克·吐温名言的含义。知道马克·吐温那种干练幽默感和对写作的热情,你能猜出哪句是他的吗?
你在第7章中学到的CNN将很难在这两句话中建立“长”与“短”信件之间的联系,而RNN则能轻松做到这一点。这是因为CNN只能在有限的文本窗口内识别模式。要理解整段文字,你必须构建多个CNN层,并且每个层都需要使用重叠的卷积核或窗口来处理文本,而RNN则自然能够做到这一点。它们记住它们所阅读的文档中每个token的信息,直到你告诉它们这个文档结束为止。这使得它们在总结冗长的马克·吐温信件和他那些复杂的幽默时更具优势。
马克·吐温是对的。简洁地表达思想需要技巧、智慧和细致的注意力。在论文《Attention Is All You Need》中,Ashish Vaswani展示了transformers如何通过添加一个注意力矩阵,使RNN能够准确理解更长的文档。在第9章中,你将看到这一注意力机制的实际应用以及其他让transformer方法成为迄今为止最成功和最通用的深度学习架构的技巧。
对冗长文本的总结仍然是NLP中的一个未解决问题。即使是最先进的RNN和transformers,也会犯一些基本的错误。实际上,专注于维基百科符号压缩的Hutter人工智能奖会为你在该网站的压缩(无损总结)中每提高1%给予你5,000欧元。压缩文本的意义,甚至衡量你做得如何,更加困难——但你将开始学会如何去做。
你将不得不开发能够理解常识逻辑的普遍智能机器,这些机器能够组织和操作记忆以及这些记忆的符号表示。这可能看起来是一个无望的任务,但实际上并非如此。你到目前为止构建的RNN可以在一个大型隐藏表示中记住它们对事物的理解。你能想到一种方法来给这些记忆赋予一些结构,使你的机器能够更好地组织它对文本的思考吗?如果你让机器拥有一个单独的方式来同时维护短期记忆和长期记忆呢?这将为它提供一个工作记忆,然后它可以在遇到重要的概念时将其存入长期记忆。
8.4.1 基于词汇的语言模型
你读过的所有最令人印象深刻的语言模型使用的是单词作为token,而不是单独的字符。因此,在深入了解GRU和LSTM之前,你需要重新整理训练数据,使用单词ID序列,而不是字符(字母)ID序列。而且,你将处理比仅仅是姓氏更长的文档,因此你需要将数据集批量化,以提高训练速度。
首先,看看Wikitext2数据集,并思考如何预处理它,以创建一个token ID(整数)序列:
>>> lines = open('data/wikitext-2/train.txt').readlines()
>>> for line in lines[:4]:
... print(line.rstrip()[:70])
= Valkyria Chronicles III =
=======
Senjo¯ no Valkyria 3 : <unk> Chronicles ( Japanese : 戦場のヴァルキュリア3 ,
↪ lit
哇!这个数据集将会很有趣。即使是英文版的Wikipedia也包含了很多其他自然语言的内容,比如这篇文章中的日语。如果你使用之前章节中学习的分词和词汇构建技巧,你应该能够创建一个像下面RNN示例中使用的Corpus类:
>>> from nlpia2.ch08.rnn_word.preprocessing import Corpus
>>> corpus = Corpus('data/wikitext-2')
>>> corpus.train
tensor([ 4, 0, 1, ..., 15, 4, 4])
你还需要确保词汇表包含生成正确单词所需的所有信息,从单词ID序列中恢复原词:
>>> vocab = corpus.vocab
>>> [vocab.idx2word[i] for i in corpus.train[:7]]
['<eos>', '=', 'Valkyria', 'Chronicles', 'III', '=', '<eos>']
现在,在训练过程中,RNN必须逐个token地读取每个token,这可能会比较慢。如果你能同时在多个文本段落上训练该怎么办?你可以通过将文本分成批次,或者批量化数据来实现这一点。这些批次可以成为矩阵中的列或行,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])]
在数据准备好训练之前,还需要执行最后一步。你需要将这个列表中的张量堆叠起来,这样你就会有一个大张量,在训练过程中进行迭代:
>>> 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)
对于短文本,普通的RNN使用每个神经元的单一激活函数通常效果很好。所有神经元需要做的就是回收并重用它们目前为止所读取的文本的隐藏向量表示。但是,普通的RNN具有较短的注意力跨度,这限制了它们理解更长文本的能力。当机器读取文本时,字符串中的第一个token的影响会随着时间的推移而逐渐减弱。这就是门控递归单元(GRU)和长短期记忆(LSTM)神经网络要解决的问题。
你认为怎样才能对抗文本字符串中早期tokens的记忆衰退问题?怎样才能停止一些重要tokens在长文本开始部分的衰退?添加一个if语句来记录或强调文本中的特定词语怎么样?这正是GRU所做的。GRU在RNN神经元中加入了if语句,这些语句被称为逻辑门(或简称门)。
机器学习和反向传播的魔力会帮你处理这些if语句的条件,因此你不必手动调整逻辑门的阈值。RNN中的门通过调整影响信号水平的偏置和权重来学习最佳阈值,这些信号触发0或1的输出(或介于两者之间)。随着时间的推移,反向传播的魔力将训练LSTM的门,允许重要的信号(token含义的方面)通过,并记录在隐藏向量和单元状态向量中。
但是,等等!你可能会想,我们的网络中已经有if语句了。毕竟,每个神经元都有一个非线性激活函数,它会将一些输出压缩为0,将其他输出推向接近1。所以关键不在于LSTM向你的网络添加了门(激活函数),而在于这些新的门位于神经元内部,并以一种方式连接,创建了一个结构,这个结构不会自然地从普通的线性全连接层的神经元中出现。这个结构是有意设计的,反映了研究人员认为有助于RNN神经元解决长期记忆问题的方式。
除了原始的RNN输出门,GRU在递归单元内部添加了两个新的逻辑门或激活函数:
- 重置门(Reset Gate) :哪些隐藏层部分应该被阻止,因为它们不再与当前输出相关?
- 更新门(Update Gate) :哪些隐藏层部分对当前输出(现在,时间步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
#1 重置门
#2 更新门
#3 新的门
当你考虑向神经网络中添加多少单元以解决特定问题时,每个LSTM或GRU单元赋予网络的能力类似于两个“普通”RNN神经元或隐藏向量维度。一个单元只是一个更复杂、容量更大的神经元,你可以通过计算LSTM模型中学习到的参数数量,并将其与等效的RNN进行比较,看到这一点。
备注 你可能会想知道,为什么我们开始使用“单元”(unit)而不是“神经元”(neuron)来描述神经网络中的元素。研究人员使用“单元”或“单元格”(cell)这两个术语来描述LSTM或GRU神经网络的基本构建块,因为它们比普通神经元稍微复杂一些。LSTM或GRU中的每个单元或单元格包含内部门和逻辑。这使得GRU或LSTM单元具有更大的学习和理解文本的能力,因此你可能需要更少的单元来达到与普通RNN相同的性能。
重置门、更新门和新的逻辑门是通过你在第5章中熟悉的全连接线性矩阵乘法和非线性激活函数实现的。新颖之处在于它们是递归地应用于每个token,并且它们在隐藏和输入向量上并行实现。图8.11展示了单个token的输入向量和隐藏向量如何通过逻辑门流动,并输出预测和隐藏状态张量。
如果你已经提高了阅读数据流图(如图8.11)的能力,你可能会发现GRU更新和相关性逻辑门30正在实现以下两个功能: 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 #1 重置 #2 更新 从这两行代码来看,公式的输入是完全相同的。输入和隐藏张量在两个公式中都与权重矩阵相乘。如果你还记得线性代数和矩阵乘法的知识,你也许能稍微简化这些数学表达式。你可能会注意到在块图(图8.11)中,输入和隐藏张量在矩阵乘法之前会被拼接在一起,使用W_reset权重矩阵进行重置。
一旦你将GRU添加到你的RNN模型架构组合中,你会发现它们更加高效。GRU会以更少的学习参数、更少的训练时间和更少的数据,达到更好的准确性。GRU中的门为神经网络提供了结构,创建了更加高效的机制来记住文本中的重要意义。为了衡量效率,你需要一些代码来统计模型中学习(可训练)参数的数量。这是你的模型必须调整的权重值数量,以优化预测。requires_grad属性是检查特定层是否包含可学习参数的一种简单方法:31
>>> def count_parameters(model, learned=True):
... return sum(
... p.numel() for p in model.parameters() #1
... if not learned or p.requires_grad #2
... )
#1 p.numel()等同于p.size().product()。
#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
#1 jsonlines包非常适合逐步保存实验结果。
从这些实验结果中你可以看到,GRU是创建能够理解文本并预测下一个单词的语言模型的最佳选择。令人惊讶的是,GRU不需要像其他RNN架构那样多的层就能实现相同的准确性,而且它们训练所需的时间也比RNN少,能够实现相似的准确性。
8.4.3 长短期记忆(LSTM)
LSTM神经元增加了两个内部门,以试图改善RNN的长时记忆和短时记忆能力。LSTM增加了两个新门:一个用于遗忘,另一个用于控制LSTM的输出,使得内部门的总数达到了四个,每个门都有其特定的功能:
- 遗忘门(f)——决定隐藏层的哪些元素应该被忽略,为未来更重要的符号腾出记忆空间。
- 输入或更新门(i)——决定隐藏层的哪些部分对当前输出(即时刻t的输出)重要。
- 相关性或细胞门(i)——决定隐藏层的哪些部分应当被屏蔽,因为它们对当前输出不再相关。
- 输出门(o)——决定隐藏层的哪些部分应当被输出,作为神经元的激活向量和预测文本中下一个符号的隐藏层向量。
但是图8.12右上角的那个没有标签的tanh激活函数是做什么的呢?那只是原始的输出激活函数,用于从细胞状态生成隐藏状态向量。隐藏状态向量包含关于最近处理的符号的信息,它是LSTM的短期记忆。细胞状态向量则保存了文本长期的意义表示,从文档的开始开始。
在图8.12中,你可以看到这四个逻辑门是如何结合在一起的。每个逻辑门所需的各种权重和偏差被隐藏以简化图示。你可以想象,权重矩阵的乘法运算发生在你在图示中看到的每个激活函数内。另一个需要注意的地方是,隐藏状态不再是唯一的递归输入和输出。你现在有了另一个编码或状态张量,称为细胞状态。和以前一样,你只需要隐藏状态来计算每个时间步的输出。但新的细胞状态张量是过去模式的长短期记忆被编码和存储的地方,以便在下一个符号中重新使用。
在这个图中,你可能只会在最智能的博客文章中看到计算输出张量所需的显式线性权重矩阵。32 甚至连PyTorch文档也对这一细节有所忽略。你需要在你打算基于隐藏状态张量计算预测的层中自行添加这个完全连接的线性层。
你可能会对自己说:“等一下,我以为所有的隐藏状态(编码)都是一样的。为什么我们有了这个新的细胞状态?” 好吧,这就是LSTM的长期记忆部分。细胞状态是单独维护的,这样逻辑门就可以记住某些东西并将它们存储在这里,而不必将它们与隐藏状态张量的短期记忆混合在一起。而且细胞状态的逻辑与隐藏状态的逻辑有些不同。它被设计成有选择性地重新训练,以腾出空间来存储它关于文本学到的信息,远在文本到达字符串结尾之前。
计算LSTM逻辑门和输出的公式与GRU非常相似。主要区别在于添加了三个新函数来计算你所需的所有信号。并且一些信号已经重新路由,创建了一个更复杂的网络,用于存储文本长短期记忆之间的更复杂连接模式。正是隐藏状态和细胞状态之间更复杂的交互创造了更多的容量,或称为内存和计算能力,因为一个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)
#1 LSTM遗忘门(与GRU重置门相同)
#2 LSTM输入相关性门(与GRU更新门相同)
#3 LSTM细胞门——注意冗余的偏置b_i2i和b_h2i
#4 LSTM输出门
#5 细胞状态
8.4.4 给你的RNN进行调优
正如你在第7章中学到的,超参数调优在神经网络变得越来越复杂时变得越来越重要。当模型变得复杂时,你对层次、网络容量和训练时间的直觉会变得越来越模糊。RNN特别直观。为了帮助你启发直觉,我们训练了几十种不同的基本RNN,并尝试了不同的超参数组合,例如每层的层数和隐藏单元的数量。你可以使用nlpia2/ch08中的代码,探索你感兴趣的所有超参数:33
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
探索这样的超参数空间是非常激动人心的事情,并且可以发现构建精确模型的强大技巧。令人惊讶的是,对于在小规模Wikipedia子集上训练的这个RNN语言模型,你可以在不最大化模型大小和容量的情况下获得很好的结果。你可以使用三层RNN来获得比五层RNN更好的准确性。你只需要从一个较大的学习率开始,并保持较低的dropout率——层数越少,模型训练得越快。
提示:经常进行实验,并始终记录你尝试过的内容以及模型的表现。这样的实践工作是培养直觉的最快途径,有助于加速模型构建和学习。你一生的目标是训练你的心理模型,预测哪些超参数值将在任何给定情况下产生最佳结果。
如果你觉得模型在训练数据上过拟合,但又找不到方法简化模型,你可以尝试增加Dropout(丢弃率)。这就像一把大锤,可以减少过拟合,同时允许你的模型根据数据需要的复杂性进行学习。如果你将dropout率设置得高于50%,模型可能会很难学习,学习速度会变慢,验证误差也可能会波动很大。但对于很多RNN和大多数NLP问题来说,20%到50%是一个相对安全的范围。
8.5 预测
你在本章中训练的基于词的RNN语言模型使用了WikiText2语料库。34 使用这个语料库的好处是,它通常被研究人员用来基准测试语言模型的准确性,并且维基百科文章的文本已经为你进行了标记化。此外,文章末尾的无关部分,如参考文献部分,已经被删除。
不幸的是,PyTorch版本的WikiText2包括了<unk>标记,它随机替换或掩蔽了2.7%的标记。这意味着,除非存在某种可预测的模式来确定哪些标记被掩蔽为<unk>,否则你的模型永远无法达到非常高的准确率。但如果你下载没有掩蔽标记的原始原始文本,你可以在上面训练你的语言模型,从而快速提高准确性。35 此外,你还可以将你训练的LSTM和GRU模型的准确性与使用这个基准数据的专家们进行比较。36
以下是被掩蔽的训练数据集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> .
看起来,WikiText2基准语料库中的最后一篇维基百科文章是关于普通椋鸟(一种小型的欧洲鸟类)。根据这篇文章,普通椋鸟似乎很擅长模仿人类的语言,就像你的RNN一样。
那么那些<unk>标记是什么呢?这些标记是为了测试机器学习模型而设计的。语言模型的训练目标是预测被<unk>(未知)标记替换的单词。因为你的大脑里有一个相当好的英语语言模型,所以你可能能够预测出那些被所有这些<unk>标记掩蔽的单词。
但如果你训练的机器学习模型认为这些是正常的英语单词,你可能会把它弄混淆。你在本章中训练的RNN试图辨别没有意义的<unk>标记的含义,这将减少它对语料库中所有其他单词的理解。
提示:如果你想避免这个额外的错误和混淆来源,你可以尝试在WikiText2基准测试的非官方原始文本上训练你的RNN。官方WikiText2语料库的标记和nlpia2仓库中的非官方原始版本之间是逐一对应的。37
那么这个训练集中的<eos>和<unk>标记有多少个呢?
>>> 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%的标记已被替换为没有意义的<unk>标记。<eos>标记表示原始文本中的换行符,通常表示维基百科文章段落的结束。
让我们看看你的RNN模型在写出类似于WikiText2数据集中句子的表现,包括<unk>标记。我们将提示模型从单词“The”开始,看看它“脑海”中有什么:
>>> import torch
>>> from preprocessing import Corpus
>>> from generate import generate_words
>>> from model import RNNModel
>>> corpus = Corpus('data/wikitext-2')
>>> vocab = corpus.vocab
>>> 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)) #3
...
= = Valkyria Valkyria Valkyria Valkyria = = The kakapo is a common starling , and the of the of the ,
...
#1 加载已保存的模型权重和RNNModel类
#2 因为我们在保存检查点后更新了RNNModel类
#3 低温度使得文本不那么随机,更具重复性。
训练集的第一行是“= Valkyria Chronicles III =”,训练语料库中的最后一篇文章标题是“= Common Starling =”。因此,这个GRU能够记住如何生成与它读过的文本开头和结尾相似的文本,并且它似乎确实具备了长短期记忆能力。考虑到我们只训练了一个非常简单的模型,并且数据集非常小,这真是令人兴奋的。但这个GRU似乎还没有足够的能力存储它在200万个标记长的序列中找到的所有英语语言模式。它显然不会很快进行任何有意义的理解。
注:有意义的理解是人们赋予他们共享经验意义的方式。当你试图向自己解释为什么别人做他们正在做的事情时,你是在进行有意义的理解。你不必独自做这件事。社区可以通过社交媒体应用程序甚至对话虚拟助手进行集体讨论来共同完成这项任务。这就是为什么它通常被称为集体有意义的理解。像DAOstack这样的初创公司正在尝试使用聊天机器人从社区中提取最好的想法,并用它们构建知识库和做出决策。38
你现在知道如何训练一个多功能的NLP语言模型,可以在词级或字符级标记上使用这些模型。你可以使用这些模型来分类文本,甚至生成适度有趣的新文本。而且你不需要依赖昂贵的GPU和服务器。
8.6 测试自己
-
有哪些技巧可以提高RNN阅读长文档时的记忆力?
一些技巧包括:使用更长的训练时间来帮助模型记住更多的上下文信息,使用LSTM或GRU这样的改进型RNN结构来帮助捕捉长期依赖,采用合理的超参数调优(如选择适当的学习率和dropout率)来防止过拟合。
-
RNN在现实世界中的一些“异常有效”的应用是什么?
RNN在自然语言处理任务中非常有效,如语言建模、机器翻译、语音识别和文本生成等。此外,它也在金融预测、时间序列分析、情感分析等领域取得了显著应用。
-
AI名称分类器的一些伦理用途是什么?不道德的用途是什么?
伦理用途:AI名称分类器可以用来帮助识别名字背后的文化和地域特征,优化多语言支持,或者在多国业务中提高个性化服务的效果。不道德用途:侵犯个人隐私、进行种族或性别歧视的分类,或者用于对个体进行不正当监控和定位。
-
对于包含数百万个用户名和密码对的数据集,如Mark Burnett的密码数据集,AI有哪些伦理和社会积极用途?
伦理用途:该数据集可以用于研究和提升密码强度分析技术,帮助发现弱密码模式,从而改善网络安全。社会积极用途:它可以帮助开发更安全的身份验证系统,保护用户信息,减少数据泄露的风险。不道德用途:滥用该数据集进行未经授权的密码破解,或用于非法访问系统。
-
在WikiText2数据集的原始未掩蔽文本上训练rnn_word模型。这是否提高了你的词级RNN语言模型的准确性?
如果使用未掩蔽的原始文本,模型可能会获得更好的准确性,因为掩蔽标记(如
<unk>)通常会影响模型的学习,因此直接使用原始文本进行训练可能会提高模型的性能。 -
修改数据集,为每个名字标注一个多热编码张量,表示每个名字的所有国籍。你应该如何衡量准确性?准确性有提高吗?
衡量准确性的标准应包括:正确标注每个名字对应的所有国籍的比例(多标签分类准确度),并检查模型对未见过的名字的泛化能力。通过这种方式,准确性可能会提高,特别是当模型能够学习到更多关于名字和国籍的多重关联时。
总结
- 在自然语言标记序列中,RNN能够记住它读取的所有内容,而不仅仅是一个有限的窗口。
- 沿时间维度(标记)拆分自然语言声明,可以帮助机器加深对自然语言的理解。
- 你可以将误差反向传播到时间上,以及深度学习网络的各层之间。
- 由于RNN是特别深的神经网络,RNN梯度特别容易不稳定,可能会消失或爆炸。
- 直到循环神经网络(RNN)应用于此任务之前,有效建模自然语言字符序列是不可能的。
- 在RNN中,权重是基于给定样本在时间上聚合调整的。
- 你可以使用不同的方法来检查循环神经网络的输出。
- 你可以通过同时在时间上前后传递标记序列,来建模文档中的自然语言序列。