深度学习席卷了自然语言处理(natural language processing, NLP)领域,尤其是通过使用不断消耗输入和模型先前输出相结合的模型。这种模型称为递归神经网络(recurrent neural networks, RNN),它已被成功应用于文本分类、文本生成和自动翻译系统。在这之前的NLP工作的特点是复杂的多阶段处理流程,包括编码语言语法的规则。
- Nadkarni et al., “Natural language processing: an introduction”. JAMIA https://www.ncbi.nlm.nih.gov/pmc/arti-cles/PMC3168328
- Wikipedia entry for natural language processing: https://en.wikipedia.org/wiki/Natural-language_processing
目前,最先进的(state-of-the-art)工作在大型语料库上端到端地从头开始训练网络,让这些规则从数据中浮现出来。在过去的几年中,互联网上最常用的自动翻译系统服务就是基于深度学习的。
在本章中,你的目标是将文本转换成神经网络可以处理的东西,就像前面的例子一样,即数值张量。在处理成数值张量之后,再为你的文本处理工作选择正确的网络结构,然后就可以使用PyTorch进行NLP了。你马上就会看到此功能的强大之处:如果你以正确的形式提出了问题,就可以使用相同的PyTorch工具在不同领域中的任务上达到目前最先进的性能。这项工作的第一部分是重塑数据。
网络在两个级别上对文本进行操作:在字符级别上,一次处理一个字符;而在单词级别上,单词是网络中最细粒度的实体。无论是在字符级别还是在单词级别操作,将文本信息编码为张量形式的技术都是相同的。这种技术没什么神奇的,你之前已经用过了,即独热编码。
我们从字符级示例开始。首先,获取一些文本进行处理。Gutenberg项目是一个很棒的资源,它是一项志愿性的工作,它对文化作品进行数字化并存档,并使其以开放格式(包括纯文本文件)免费提供。如果你的目标是大型语料库,那么维基百科语料库就非常出色:它是维基百科文章的完整集合,其中包含19亿个单词和超过440万条文章。你可以在英语语料库网站上找到其他几种语料库。
从Gutenberg项目网站上加载Jane Austen的《傲慢与偏见》(点击下载)。保存文件并读入文件,如下所示。
with open('../../data/chapter3/1342-0.txt', encoding='utf8') as f:
text = f.read()
在继续之前,你需要先注意一个细节:编码(encoding)。编码是一个宽泛的词,因此我们现在要做的就是实际“触摸”它。每个字符都由一个代码表示,该代码是一系列适当长度的比特(bit)位,它可以唯一地标识每个字符。最简单的这种编码是ASCII(American Standard Code for Information Interchange),其历史可以追溯到1960年代。ASCII使用128个整数对128个字符进行编码。例如,字母“a”对应于二进制1100001
或十进制97;字母“b”对应于二进制1100010
或十进制98,依此类推。该编码刚好8位,这在1965年是一个很大的收获。
注意:显然,128个字符不足以正确表示除英语之外的其他书面文字所需的所有字形、字音、连字等等。为此,其他编码被开发了出来,用更多的比特位代码表示更大范围的字符。更大范围的字符被标准化为Unicode编码,它将所有已知字符映射为数字,这些数字的位表示由特定编码提供。流行的编码包括UTF-8、UTF-16和UTF-32,对应数字分别是8位、16位或32位整数的序列。 Python 3.x中的字符串是Unicode字符串。
你将对字符进行独热编码,以将独热编码限制为对要分析的文本有用的字符集。在本例中,因为你以英文加载了文本,所以使用ASCII这种小型编码是非常安全的。你也可以将所有字符都转换为小写,以减少编码中的字符数。同样,你还可以筛选出与预期的文本类型无关的标点符号、数字和其他字符,这可能会也可能不会对你的神经网络产生实际的影响,具体取决于手头的任务。
此时,你需要解析文本中的字符,并为每个字符进行独热编码。 每个字符将由一个长度等于编码中字符数的向量表示。该向量除了有一个元素是1外其他全为0,这个1的索引对应该字符在字符集中的位置。
首先,将文本分成若干行,然后选择任意一行:
lines = text.split('\n')
line = lines[200]
line
输出:
'“Impossible, Mr. Bennet, impossible, when I am not acquainted with him'
创建一个张量,该张量可以容纳整行的独热编码的字符总数:
letter_tensor = torch.zeros(len(line), 128) # 128是由于ASCII的限制
letter_tensor.shape
输出:
torch.Size([70, 128])
请注意,letter_tensor
每行将要表示一个独热编码字符。现在在每一行正确位置上设置成1,以使每一行代表正确的字符。设置1的索引对应于编码中字符的索引:
for i, letter in enumerate(line.lower().strip()):
# 文本里含有双引号,不是有效的ASCII,因此在此处将其屏蔽
letter_index = ord(letter) if ord(letter) < 128 else 0
letter_tensor[i][letter_index] = 1
你已经将句子独热编码成神经网络可以使用的表示形式。你也可以沿张量的行,通过建立词汇表来在词级别(word-level)对句子(即词序列)进行独热编码。由于词汇表包含许多单词,因此该方法会产生可能不是很实际的很宽的编码向量。在本章的后面,你将看到一种更有效的方式,通过使用嵌入(embedding)来在单词级别表示文本。现在,坚持独热编码,看看会发生什么。
定义clean_words
函数,它接受文本并将其返回小写并删除标点符号。在“Impossible, Mr. Bennet”行上调用它时,会得到以下信息:
def clean_words(input_str):
punctuation = '.,;:"!?”“_-'
word_list = input_str.lower().replace('\n',' ').split()
word_list = [word.strip(punctuation) for word in word_list]
return word_list
words_in_line = clean_words(line)
line, words_in_line
输出:
('“Impossible, Mr. Bennet, impossible, when I am not acquainted with him',
['impossible',
'mr',
'bennet',
'impossible',
'when',
'i',
'am',
'not',
'acquainted',
'with',
'him'])
接下来,在编码中建立单词到索引的映射:
word_list = sorted(set(clean_words(text)))
word2index_dict = {word: i for (i, word) in enumerate(word_list)}
len(word2index_dict), word2index_dict['impossible']
输出:
(7261, 3394)
请注意,word2index_dict
现在是一个字典,其中单词作为键,而整数作为值。独热编码时,你将使用此词典来有效地找到单词的索引。
现在专注于句子,将其分解为单词并对其进行独热编码(即对每个单词使用一个独热编码向量来填充张量)。先创建一个空向量,然后赋值成句子中的单词的独热编码:
word_tensor = torch.zeros(len(words_in_line), len(word2index_dict))
for i, word in enumerate(words_in_line):
word_index = word2index_dict[word]
word_tensor[i][word_index] = 1
print('{:2} {:4} {}'.format(i, word_index, word))
print(word_tensor.shape)
输出:
0 3394 impossible
1 4305 mr
2 813 bennet
3 3394 impossible
4 7078 when
5 3315 i
6 415 am
7 4436 not
8 239 acquainted
9 7148 with
10 3215 him
torch.Size([11, 7261])
此时,word_tensor
表示长度为11编码长度为7261(这是字典中单词的数量)的一个句子。
独热编码是一种将类别数据表示成张量的很有用技术。就像你可能预料到的那样,当需要编码的项目数很大(例如语料库中的单词)时,独热编码就开始崩溃了。一本书中有超过7000个单词!
当然,你可以做一些工作来对单词进行去重、压缩替代拼写、将过去和将来时统一为相同表示,等等。尽管如此,通用的英文编码仍将是巨大的。更糟糕的是,每次遇到一个新单词时,都必须在向量中添加一个新列,这意味着要在模型中添加一组新的权重以解决该新词汇输入问题,从训练角度看这将给你带来很大的痛苦。
如何将编码压缩为更易于管理的大小,并限制大小增长?好吧,可以使用浮点数向量,而不是使用多个0和一个1的向量。举例来说,一个含100个浮点数的向量就可以表示很大量的词汇。关键是找到一种有效的方法,以一种有助于下游学习的方式将单个单词映射到这个100维空间。这种技术称为嵌入(embedding)。
原则上,你可以遍历词汇表并为每个单词生成100个随机浮点数。 这种方法可能是有效的,因为你可以将大量词汇塞入100个数字中,但是它会丢弃掉基于语义或上下文的单词之间的任何距离信息。使用这种词嵌入的模型不得不处理其输入向量中的少量结构。理想的解决方案是以这样的方式生成嵌入:用于同一上下文的单词映射到嵌入空间的邻近区域。
如果要手工设计解决此问题的方法,你有可能决定通过沿轴映射基本名词和形容词来构建嵌入空间。你可以生成一个二维空间,在该空间中,两个坐标轴分别映射到名词“水果”(0.0-0.33)、“花”(0.33-0.66)和“狗”(0.66-1.0),以及形容词“红色”(0.0-0.2)、“橙色”(0.2-0.4)、“黄色”(0.4-0.6)、“白色”(0.6-0.8)和“棕色”(0.8-1.0)。你现在的目标是将水果、花和狗放置在嵌入中。
开始嵌入单词时,可以将“苹果”映射到“水果”和“红色”象限中的某个数。同样,你可以轻松地映射“橘子”、“柠檬”、“荔枝”和“猕猴桃”(五颜六色的水果)。然后,你可以从花开始,分配“玫瑰”、“罂粟”、“水仙花”、“百合”和...好吧,不存在很多棕色的花。好,“太阳花”可以推出“花”、“黄色”和“棕色”,而“雏菊”可以推出“花”、“白色”和“黄色”。也许你应该更新“猕猴桃”以将其映射到“水果”、“棕色”和“绿色”附近。对于狗和颜色,“redbone(译者注:狗的品种)”、“fox”可能是“橙色”、“金毛”和“贵宾犬”可是“白色”的,以及...大多数种类的狗都是“棕色”的。
尽管对于大型语料库而言,手动进行此映射并不可行,但你应注意,尽管嵌入大小仅为2,但你描述了除基数8个之外的15个不同的单词,如果你花一些创造性的时间,可能还会嵌入更多的单词。
你可能已经猜到了,这种工作是可以自动进行的。通过处理大量文本语料库,你可以生成与此类似的嵌入。主要区别在于嵌入向量具有100到1000个元素,并且坐标轴不直接映射到某个词义,但是意思相近的词映射到嵌入空间也是相近的,其轴可能是任意的浮点维(floating-point dimensions)。
尽管实际使用的算法(比如word2vec)对于我们在此要关注的内容来说有点超出范围,但值得一提的是,嵌入通常是使用神经网络并试图根据句中邻近词(上下文)预测某个词而生成的。在这种情况下,你可以从独热编码的单词开始,使用(通常是相当浅的)神经网络来生成嵌入。当嵌入可用时,你就可以将其用于下游任务。
生成的嵌入的一个有趣的方面是,相似的词不仅会聚在一起,还会与其他词保持一致的空间关系。如果你要使用“苹果”的嵌入向量,并加上和减去其他词的嵌入向量,就可以进行类比,例如苹果 - 红色 - 甜 + 酸
,最后可能得到一个类似“柠檬”的向量。
我们不会在这里使用文本嵌入,但是当必须用数字向量表示集合中的大量元素时,它们是必不可少的工具。