深度学习处理文本(2)

建立词表索引

将文本拆分成词元之后,你需要将每个词元编码为数值表示。你可以用无状态的方式来执行此操作,比如将每个词元哈希编码为一个固定的二进制向量,但在实践中,你需要建立训练数据中所有单词(​“词表”​)的索引,并为词表中的每个单词分配唯一整数,如下所示。

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管道中也是如此。