文本是最常用的序列数据之一,可以理解为字符序列或单词序列,但最常见的是单词级处理。深度学习序列处理模型都可以根据文本生成基本形式的自然语言理解,并可用于文档分类、情感分析、作者识别甚至问答(QA,在有限的语境下)等应用。当然,目前我所接触的这些深度学习模型都没有像人类一样真正地理解文本,而只是映射出书面语言的统计结构,但这足以解决许多简单的文本任务。
深度学习用于自然语言处理是将模式识别应用于单词、 句子和段落,这与计算机视觉是将模式识别应用于像素大致相同。 与其他所有神经网络一样,深度学习模型不会接收原始文本作为输入,它只能处理数值张量。 文本向量化是指将文本转换为数值张量的过程。它有多种实现方法。
将文本分解而成的单元、叫作标记(token),将文本分解成标记的过程叫作分词。所有文本向量化过程都是应用某种分词方案,然后将数值向量 与生成的标记相关联。这些向量组合成序列张量,被输入到深度神经网络中。将向 量与标记相关联的方法有很多种。本文将介绍两种主要方法:对标记做one-hot 编码(one-hot encoding)
与标记嵌入[token embedding,通常只用于单词,叫作词嵌入(word embedding)]
。
n-gram 是从一个句子中提取的 N 个(或更少)连续单词的集合。这一概念中的“单词” 也可以替换为“字符”。 下面来看一个简单的例子。
考虑句子“The cat sat on the mat.”(“猫坐在垫子上”)。它 可以被分解为以下二元语法(2-grams)的集合。
{"The", "The cat", "cat", "cat sat", "sat", "sat on", "on", "on the", "the", "the mat", "mat"}
这个句子也可以被分解为以下三元语法(3-grams)的集合。
{"The", "The cat", "cat", "cat sat", "The cat sat", "sat", "sat on", "on", "cat sat on", "on the", "the", "sat on the", "the mat", "mat", "on the mat"}
这样的集合分别叫作二元语法袋(bag-of-2-grams)及三元语法袋(bag-of-3-grams)
。这 里袋(bag)这一术语指的是,我们处理的是标记组成的集合,而不是一个列表或序列,即 标记没有特定的顺序。这一系列分词方法叫作词袋。词袋是一种不保存顺序的分词方法(生成的标记组成一个集合,而不是一个序列,舍 弃了句子的总体结构),因此它往往被用于浅层的语言处理模型,而不是深度学习模型。提取 n-gram 是一种特征工程,深度学习不需要这种死板而又不稳定的方法,并将其替换为分 层特征学习。
一维卷积神经网络和循环神经网络,都能够通过观察连续的 单词序列或字符序列来学习单词组和字符组的数据表示,而无须明确知道这些组的存在。一定要记住,在使用轻量级的浅层文本处理模型时(比 如 logistic 回归和随机森林),n-gram 是一种功能强大、不可或缺的特征工程工具。
one-hot 编码是将标记转换为向量的最常用、最基本的方法。相信许多人已经用过这种方法(都是处理单词)。它将每个单词与一个唯一的整数索引相关联, 然后将这个整数索引i
转换为长度为N
的二进制向量(N 是词表大小),这个向量只有第i
个元 素是 1
,其余元素都为 0
。 当然,也可以进行字符级的one-hot 编码。为了让你完全理解什么是one-hot 编码以及如何 实现 one-hot 编码,下面给出了两个简单示例,一个是单词级的 one-hot 编码,另一个是字符级的 one-hot 编码。
import numpy as np
samples = ['The cat sat on the mat.', 'The dog ate my homework.']
token_index = {}
for sample in samples:
for word in sample.split():
if word not in token_index:
token_index[word] = len(token_index) + 1
max_length = 10
results = np.zeros(shape=(len(samples),
max_length,
max(token_index.values()) + 1))
for i, sample in enumerate(samples):
for j, word in list(enumerate(sample.split()))[:max_length]:
index = token_index.get(word)
results[i, j, index] = 1.
import string
samples = ['The cat sat on the mat.', 'The dog ate my homework.']
characters = string.printable
token_index = dict(zip(range(1, len(characters) + 1), characters))
max_length = 50
results = np.zeros((len(samples), max_length, max(token_index.keys()) + 1))
for i, sample in enumerate(samples):
for j, character in enumerate(sample):
index = token_index.get(character)
results[i, j, index] = 1.
注意,Keras 的内置函数可以对原始文本数据进行单词级或字符级的one-hot 编码。你应该 使用这些函数,因为它们实现了许多重要的特性,比如从字符串中去除特殊字符、只考虑数据 集中前 N 个最常见的单词(这是一种常用的限制,以避免处理非常大的输入向量空间)。
from keras.preprocessing.text import Tokenizer
samples = ['The cat sat on the mat.', 'The dog ate my homework.']
tokenizer = Tokenizer(num_words=1000)
tokenizer.fit_on_texts(samples)
sequences = tokenizer.texts_to_sequences(samples)
one_hot_results = tokenizer.texts_to_matrix(samples, mode='binary')
word_index = tokenizer.word_index
print('Found %s unique tokens.' % len(word_index))
one-hot 编码的一种变体是所谓的one-hot 散列技巧(one-hot hashing trick),如果词表中唯 一标记的数量太大而无法直接处理,就可以使用这种技巧。这种方法没有为每个单词显式分配 一个索引并将这些索引保存在一个字典中,而是将单词散列编码为固定长度的向量,通常用一 个非常简单的散列函数来实现。这种方法的主要优点在于,它避免了维护一个显式的单词索引, 从而节省内存并允许数据的在线编码(在读取完所有数据之前,你就可以立刻生成标记向量)。
这种方法有一个缺点,就是可能会出现散列冲突(hash collision),即两个不同的单词可能具有 相同的散列值,随后任何机器学习模型观察这些散列值,都无法区分它们所对应的单词。如果 散列空间的维度远大于需要散列的唯一标记的个数,散列冲突的可能性会减小。
samples = ['The cat sat on the mat.', 'The dog ate my homework.']
dimensionality = 1000 max_length = 10
results = np.zeros((len(samples), max_length, dimensionality))
for i, sample in enumerate(samples):
for j, word in list(enumerate(sample.split()))[:max_length]:
index = abs(hash(word)) % dimensionality
results[i, j, index] = 1.
将单词与向量相关联还有另一种常用的强大方法,就是使用密集的词向量, 也叫词嵌入。 one-hot 编码得到的向量是二进制的、稀疏的(绝大部分元素都 是 0)、维度很高的(维度大小等于词表中的单词个数),而词嵌入是低维的浮点数向量(即密 集向量,与稀疏向量相对)。与 one-hot 编码得到的词向量不同,词嵌入是从数据中 学习得到的。常见的词向量维度是256、512 或 1024(处理非常大的词表时)。与此相对,onehot 编码的词向量维度通常为 20 000 或更高(对应包含 20 000 个标记的词表)。因此,词向量可 以将更多的信息塞入更低的维度中。
获取词嵌入有两种方法。
要将一个词与一个密集向量相关联,最简单的方法就是随机选择向量。这种方法的问题在于, 得到的嵌入空间没有任何结构。例如,accurate 和 exact 两个词的嵌入可能完全不同,尽管它们 在大多数句子里都是可以互换的 a。深度神经网络很难对这种杂乱的、非结构化的嵌入空间进行 学习。
说得更抽象一点,词向量之间的几何关系应该表示这些词之间的语义关系。词嵌入的作用 应该是将人类的语言映射到几何空间中。例如,在一个合理的嵌入空间中,同义词应该被嵌入 到相似的词向量中,一般来说,任意两个词向量之间的几何距离(比如L2 距离)应该和这两个 词的语义距离有关(表示不同事物的词被嵌入到相隔很远的点,而相关的词则更加靠近)。
除了 距离,你可能还希望嵌入空间中的特定方向也是有意义的。为了更清楚地说明这一点,我们来 看一个具体示例。如下图,四个词被嵌入在二维平面上,这四个词分别是 cat(猫)、dog(狗)、wolf(狼) 和 tiger(虎)。对于我们这里选择的向量表示,这些词之间的某些语义关系可以被编码为几何 变换。例如,从cat 到 tiger 的向量与从dog 到 wolf 的向量相等,这个向量可以被解释为“从宠 物到野生动物”向量。同样,从dog 到 cat 的向量与从wolf 到 tiger 的向量也相等,它可以被解 释为“从犬科到猫科”向量。
在真实的词嵌入空间中,常见的有意义的几何变换的例子包括“性别”向量和“复数”向量。 例如,将 king(国王)向量加上 female(女性)向量,得到的是 queen(女王)向量。将 king(国王) 向量加上 plural(复数)向量,得到的是 kings 向量。词嵌入空间通常具有几千个这种可解释的、 并且可能很有用的向量。
有没有一个理想的词嵌入空间,可以完美地映射人类语言,并可用于所有自然语言处理任 务?
可能有,但我们尚未发现。此外,也不存在人类语言(human language)这种东西。世界上有许多种不同的语言,而且它们不是同构的,因为语言是特定文化和特定环境的反射。但从更 实际的角度来说,一个好的词嵌入空间在很大程度上取决于你的任务。英语电影评论情感分析 模型的完美词嵌入空间,可能不同于英语法律文档分类模型的完美词嵌入空间,因为某些语义 关系的重要性因任务而异。 因此,合理的做法是对每个新任务都学习一个新的嵌入空间。幸运的是,反向传播让这种 学习变得很简单,而Keras 使其变得更简单。我们要做的就是学习一个层的权重,这个层就是 Embedding 层。
from tensorflow.keras.layers import Embedding
embedding_layer = Embedding(1000, 64)
最好将 Embedding 层理解为一个字典,将整数索引(表示特定单词)映射为密集向量。它 接收整数作为输入,并在内部字典中查找这些整数,然后返回相关联的向量。Embedding 层实 际上是一种字典查找。
Embedding 层的输入是一个二维整数张量,其形状为 (samples, sequence_length)
, 每个元素是一个整数序列。它能够嵌入长度可变的序列,例如,对于前一个例子中的 Embedding 层,你可以输入形状为 (32, 10)
(32 个长度为10 的序列组成的批量)或 (64, 15)
(64 个长度为15 的序列组成的批量)的批量。不过一批数据中的所有序列必须具有相同的 长度(因为需要将它们打包成一个张量),所以较短的序列应该用 0
填充,较长的序列应该被截断。
这个Embedding 层返回一个形状为(samples, sequence_length, embedding_ dimensionality)
的三维浮点数张量。然后可以用 RNN 层或一维卷积层来处理这个三维张量 (二者都会在后面介绍)。 将一个 Embedding 层实例化时,它的权重(即标记向量的内部字典)最开始是随机的,与 其他层一样。在训练过程中,利用反向传播来逐渐调节这些词向量,改变空间结构以便下游模 型可以利用。一旦训练完成,嵌入空间将会展示大量结构,这种结构专门针对训练模型所要解 决的问题。
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Embedding, Flatten, Dense
from tensorflow.keras.datasets import imdb
from tensorflow.keras import preprocessing
embedding_layer = Embedding(1000, 64)
max_features = 10000
maxlen = 20
(x_train, y_train), (x_test, y_test) = imdb.load_data(num_words=max_features)
x_train = preprocessing.sequence.pad_sequences(x_train, maxlen=maxlen)
x_test = preprocessing.sequence.pad_sequences(x_test, maxlen=maxlen)
model = Sequential()
model.add(Embedding(10000, 8, input_length=maxlen))
model.add(Flatten())
model.add(Dense(1, activation='sigmoid'))
model.compile(optimizer='rmsprop', loss='binary_crossentropy', metrics=['acc'])
model.summary()
history = model.fit(x_train, y_train,
epochs=10,
batch_size=32,
validation_split=0.2)
有时可用的训练数据很少,以至于只用手头数据无法学习适合特定任务的词嵌入。那么应 该怎么办? 你可以从预计算的嵌入空间中加载嵌入向量(你知道这个嵌入空间是高度结构化的,并且 具有有用的属性,即抓住了语言结构的一般特点),而不是在解决问题的同时学习词嵌入。在自 然语言处理中使用预训练的词嵌入,其背后的原理与在图像分类中使用预训练的卷积神经网络是一样的:没有足够的数据来自己学习真正强大的特征,但你需要的特征应该是非常通用的, 比如常见的视觉特征或语义特征。在这种情况下,重复使用在其他问题上学到的特征,这种做 法是有道理的。
这种词嵌入通常是利用词频统计计算得出的(观察哪些词共同出现在句子或文档中),用到 的技术很多,有些涉及神经网络,有些则不涉及。Bengio 等人在 21 世纪初首先研究了一种思路, 就是用无监督的方法计算一个密集的低维词嵌入空间 a,但直到最有名且最成功的词嵌入方案之 一 word2vec 算法发布之后,这一思路才开始在研究领域和工业应用中取得成功。word2vec 算法 由 Google 的 Tomas Mikolov 于 2013 年开发,其维度抓住了特定的语义属性,比如性别。 有许多预计算的词嵌入数据库,你都可以下载并在Keras 的 Embedding 层中使用。 word2vec 就是其中之一。另一个常用的是 GloVe(global vectors for word representation,词表示 全局向量),由斯坦福大学的研究人员于2014 年开发。这种嵌入方法基于对词共现统计矩阵进 行因式分解。其开发者已经公开了数百万个英文标记的预计算嵌入,它们都是从维基百科数据 和 Common Crawl 数据得到的。