建立词表索引
将文本拆分成词元之后,你需要将每个词元编码为数值表示。你可以用无状态的方式来执行此操作,比如将每个词元哈希编码为一个固定的二进制向量,但在实践中,你需要建立训练数据中所有单词(“词表”)的索引,并为词表中的每个单词分配唯一整数,如下所示。
vocabulary = {}
for text in dataset:
text = standardize(text)
tokens = tokenize(text)
for token in tokens:
if token not in vocabulary:
vocabulary[token] = len(vocabulary)
然后,你可以将这个整数转换为神经网络能够处理的向量编码,比如one-hot向量。
def one_hot_encode_token(token):
vector = np.zeros((len(vocabulary),))
token_index = vocabulary[token]
vector[token_index] = 1
return vector
请注意,这一步通常会将词表限制为训练数据中前20 000或30 000个最常出现的单词。任何文本数据集中往往都包含大量独特的单词,其中大部分只出现一两次。对这些罕见词建立索引会导致特征空间过大,其中大部分特征几乎没有信息量。在IMDB数据集上训练了第一个深度学习模型,还记得吗?你使用的数据来自keras.datasets.imdb,它已经经过预处理转换为整数序列,其中每个整数代表一个特定单词。当时我们设置num_words=10000,其目的就是将词表限制为训练数据中前10 000个最常出现的单词。
这里有一个不可忽略的重要细节:当我们在词表索引中查找一个新的词元时,它可能不存在。你的训练数据中可能不包含“cherimoya”一词的任何实例(也可能是你将它从词表中去除了,因为它太罕见了),所以运行token_index =vocabulary[“cherimoya”]可能导致KeyError。要处理这种情况,你应该使用“未登录词”(out of vocabulary,缩写为OOV)索引,以涵盖所有不在索引中的词元。OOV的索引通常是1,即设置token_index = vocabulary.get(token, 1)。将整数序列解码为单词时,你需要将1替换为“[UNK]”之类的词(叫作“OOV词元”)。你可能会问:“为什么索引是1而不是0?”这是因为0已经被占用了。有两个特殊词元你会经常用到:OOV词元(索引为1)和掩码词元(mask token,索引为0)。OOV词元表示“这里有我们不认识的一个单词”,掩码词元的含义则是“别理我,我不是一个单词”。你会用掩码词元来填充序列数据:因为数据批量需要是连续的,一批序列数据中的所有序列必须具有相同的长度,所以需要对较短的序列进行填充,使其长度与最长序列相同。如果你想用序列[5, 7, 124, 4, 89]和[8, 34, 21]生成一个数据批量,那么它应该是这个样子:
[[5, 7, 124, 4, 89]
[8, 34, 21, 0, 0]]
使用TextVectorization层
到目前为止的每一个步骤都很容易用纯Python实现。你可以写出如下所示的代码。
import string
class Vectorizer:
def standardize(self, text):
text = text.lower()
return "".join(char for char in text
if char not in string.punctuation)
def tokenize(self, text):
text = self.standardize(text)
return text.split()
def make_vocabulary(self, dataset):
self.vocabulary = {"": 0, "[UNK]": 1}
for text in dataset:
text = self.standardize(text)
tokens = self.tokenize(text)
for token in tokens:
if token not in self.vocabulary:
self.vocabulary[token] = len(self.vocabulary)
self.inverse_vocabulary = dict(
(v, k) for k, v in self.vocabulary.items())
def encode(self, text):
ext = self.standardize(text)
okens = self.tokenize(text)
eturn [self.vocabulary.get(token, 1) for token in tokens]
def decode(self, int_sequence):
return " ".join(
self.inverse_vocabulary.get(i, "[UNK]") for i in int_sequence)
vectorizer = Vectorizer()
dataset = [
"I write, erase, rewrite",
"Erase again, and then",
"A poppy blooms.",
]
vectorizer.make_vocabulary(dataset)
以上代码的效果如下。
>>> test_sentence = "I write, rewrite, and still rewrite again"
>>> encoded_sentence = vectorizer.encode(test_sentence)
>>> print(encoded_sentence)
[2, 3, 5, 7, 1, 5, 6]
>>> decoded_sentence = vectorizer.decode(encoded_sentence)
>>> print(decoded_sentence)
"i write rewrite and [UNK] rewrite again"
但是,这种做法不是很高效。在实践中,我们会使用Keras的TextVectorization层。它快速高效,可直接用于tf.data管道或Keras模型中。TextVectorization层的用法如下所示。
from tensorflow.keras.layers import TextVectorization
text_vectorization = TextVectorization(
output_mode="int", ←----设置该层的返回值是编码为整数索引的单词序列。还有其他几种可用的输出模式,稍后会看到其效果
)
默认情况下,TextVectorization层的文本标准化方法是“转换为小写字母并删除标点符号”,词元化方法是“利用空格进行拆分”。但重要的是,你也可以提供自定义函数来进行标准化和词元化,这表示该层足够灵活,可以处理任何用例。请注意,这种自定义函数的作用对象应该是tf.string张量,而不是普通的Python字符串。例如,该层的默认效果等同于下列代码。
import re
import string
import tensorflow as tf
def custom_standardization_fn(string_tensor):
lowercase_string = tf.strings.lower(string_tensor) ←----将字符串转换为小写字母
return tf.strings.regex_replace( ←----将标点符号替换为空字符串
lowercase_string, f"[{re.escape(string.punctuation)}]", "")
def custom_split_fn(string_tensor):
return tf.strings.split(string_tensor) ←----利用空格对字符串进行拆分
text_vectorization = TextVectorization(
output_mode="int",
standardize=custom_standardization_fn,
split=custom_split_fn,
)
要想对文本语料库的词表建立索引,只需调用该层的adapt()方法,其参数是一个可以生成字符串的Dataset对象或者一个由Python字符串组成的列表。
dataset = [
"I write, erase, rewrite",
"Erase again, and then",
"A poppy blooms.",
]
text_vectorization.adapt(dataset)
请注意,你可以利用get_vocabulary()来获取得到的词表,如代码清单11-1所示。对于编码为整数序列的文本,如果你需要将其转换回单词,那么这种方法很有用。词表的前两个元素是掩码词元(索引为0)和OOV词元(索引为1)。词表中的元素按频率排列,所以对于来自现实世界的数据集,“the”或“a”这样非常常见的单词会排在前面。
代码清单11-1 显示词表
>>> text_vectorization.get_vocabulary()
["", "[UNK]", "erase", "write", ...]
作为演示,我们对一个例句进行编码,然后再解码。
>>> vocabulary = text_vectorization.get_vocabulary()
>>> test_sentence = "I write, rewrite, and still rewrite again"
>>> encoded_sentence = text_vectorization(test_sentence)
>>> print(encoded_sentence)
tf.Tensor([ 7 3 5 9 1 5 10], shape=(7,), dtype=int64)
>>> inverse_vocab = dict(enumerate(vocabulary))
>>> decoded_sentence = " ".join(inverse_vocab[int(i)] for i in encoded_sentence)
>>> print(decoded_sentence)
"i write rewrite and [UNK] rewrite again"
在tf.data管道中使用TextVectorization层或者将TextVectorization层作为模型的一部分重要的是,TextVectorization主要是字典查询操作,所以它不能在GPU或TPU上运行,只能在CPU上运行。因此,如果在GPU上训练模型,那么TextVectorization层将在CPU上运行,然后将出发送至GPU,这会对性能造成很大影响。TextVectorization层有两种用法。第一种用法是将其放在tf.data管道中,如下所示。
int_sequence_dataset = string_dataset.map( ←---- string_dataset是一个能够生成字符串张量的数据集
text_vectorization,
num_parallel_calls=4) ←---- num_parallel_calls参数的作用是在多个CPU内核中并行调用map()
第二种用法是将其作为模型的一部分(毕竟它是一个Keras层),如下所示
text_input = keras.Input(shape=(), dtype="string") ←----创建输入的符号张量,数据类型为字符串
vectorized_text = text_vectorization(text_input) ←----对输入应用文本向量化层
embedded_input = keras.layers.Embedding(...)(vectorized_text) ←---- (本行及以下2行)你可以继续添加新层,就像普通的函数式API模型一样
output = ...
model = keras.Model(text_input, output)
两种用法之间有一个重要区别:如果向量化是模型的一部分,那么它将与模型的其他部分同步进行。这意味着在每个训练步骤中,模型的其余部分(在GPU上运行)必须等待TextVectorization层(在CPU上运行)的出准备好,才能开始工作。与此相对,如果将该层放在tf.data管道中,则可以在CPU上对数据进行异步预处理:模型在GPU上对一批向量化数据进行处理时,CPU可以对下一批原始字符串进行向量化。因此,如果在GPU或TPU上训练模型,你可能会选择第一种用法,以获得最佳性能。本章的所有实例都会使用这种方法。但如果在CPU上训练,那么同步处理也可以:无论选择哪种方法,内核利用率都会达到100%。接下来,如果想将模型导出到生产环境中,你可能希望导出一个接收原始字符串作为入的模型(类似上面第二种用法的代码片段),否则,你需要在生产环境中(可能是JavaScript)重新实现文本标准化和词元化,可能会引入较小的预处理偏差,从而降低模型精度。值得庆幸的是,TextVectorization层可以将文本预处理直接包含在模型中,使其更容易部署,即使一开始将该层用在tf.data管道中也是如此。