# 3.3 文本数据

深度学习席卷了自然语言处理（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项目](http://www.gutenberg.org/)是一个很棒的资源，它是一项志愿性的工作，它对文化作品进行数字化并存档，并使其以开放格式（包括纯文本文件）免费提供。如果你的目标是大型语料库，那么维基百科语料库就非常出色：它是维基百科文章的完整集合，其中包含19亿个单词和超过440万条文章。你可以在[英语语料库网站](https://www.english-corpora.org/)上找到其他几种语料库。

从Gutenberg项目网站上加载Jane Austen的《傲慢与偏见》[（点击下载）](http://www.gutenberg.org/files/1342/1342-0.txt)。保存文件并读入文件，如下所示。

``` python
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的索引对应该字符在字符集中的位置。

首先，将文本分成若干行，然后选择任意一行：

``` python
lines = text.split('\n')
line = lines[200]
line
```
输出:
```
'“Impossible, Mr. Bennet, impossible, when I am not acquainted with him'
```

创建一个张量，该张量可以容纳整行的独热编码的字符总数：

``` python
letter_tensor = torch.zeros(len(line), 128) # 128是由于ASCII的限制
letter_tensor.shape
```
输出:
```
torch.Size([70, 128])
```

请注意，`letter_tensor`每行将要表示一个独热编码字符。现在在每一行正确位置上设置成1，以使每一行代表正确的字符。设置1的索引对应于编码中字符的索引：

``` python
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”行上调用它时，会得到以下信息：

``` python
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'])
```

接下来，在编码中建立单词到索引的映射：

``` python
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`现在是一个字典，其中单词作为键，而整数作为值。独热编码时，你将使用此词典来有效地找到单词的索引。

现在专注于句子，将其分解为单词并对其进行独热编码（即对每个单词使用一个独热编码向量来填充张量）。先创建一个空向量，然后赋值成句子中的单词的独热编码：

``` python
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（这是字典中单词的数量）的一个句子。


## 3.1.1 文本嵌入

独热编码是一种将类别数据表示成张量的很有用技术。就像你可能预料到的那样，当需要编码的项目数很大（例如语料库中的单词）时，独热编码就开始崩溃了。一本书中有超过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](https://en.wikipedia.org/wiki/Word2vec)）对于我们在此要关注的内容来说有点超出范围，但值得一提的是，嵌入通常是使用神经网络并试图根据句中邻近词（上下文）预测某个词而生成的。在这种情况下，你可以从独热编码的单词开始，使用（通常是相当浅的）神经网络来生成嵌入。当嵌入可用时，你就可以将其用于下游任务。

生成的嵌入的一个有趣的方面是，相似的词不仅会聚在一起，还会与其他词保持一致的空间关系。如果你要使用“苹果”的嵌入向量，并加上和减去其他词的嵌入向量，就可以进行类比，例如`苹果 - 红色 - 甜 + 酸`，最后可能得到一个类似“柠檬”的向量。

我们不会在这里使用文本嵌入，但是当必须用数字向量表示集合中的大量元素时，它们是必不可少的工具。