咱们接着这个系列的上一篇文章继续:

政安晨:【深度学习处理实践】(七)—— 文本数据预处理政安晨:【深度学习处理实践】(八)—— 表示单词组的两种方法:集合和序列-LMLPHPhttps://blog.csdn.net/snowdenkeke/article/details/136697057

机器学习模型如何表示单个单词,这是一个相对没有争议的问题


自然语言中的顺序问题很有趣

与时间序列的时间步不同,句子中的单词没有一个自然、标准的顺序。不同语言对单词的排列方式非常不同,比如英语的句子结构与日语就有很大不同。即使在同一门语言中,通常也可以略微重新排列单词来表达同样的含义。更进一步,如果将一个短句中的单词完全随机打乱,你仍然可以大致读懂它的含义——尽管在许多情况下可能会出现明显的歧义。顺序当然很重要,但它与意义之间的关系并不简单。

如何表示词序是一个关键问题,不同类型的NLP架构正是源自于此。最简单的做法是舍弃顺序,将文本看作一组无序的单词,这就是词袋模型(bag-of-words model)。你也可以严格按照单词出现顺序进行处理,一次处理一个,就像处理时间序列的时间步一样,这样你就可以利用咱们以前介绍的循环模型。

最后,你也可以采用混合方法Transformer架构在技术上是不考虑顺序的,但它将单词位置信息注入数据表示中,从而能够同时查看一个句子的不同部分(这与RNN不同),并且仍然是顺序感知的。RNN和Transformer都考虑了词序,所以它们都被称为序列模型(sequence model)。

从历史上看,机器学习在NLP领域的早期应用大多只涉及词袋模型。随着RNN的重生,人们对序列模型的兴趣从2015年开始才逐渐增加。今天,这两种方法仍然都是有价值的。我们来看看二者的工作原理,以及何时使用哪种方法。

我们将在一个著名的文本分类基准上介绍两种方法,这个基准就是IMDB影评情感分类数据集。咱们以前使用了IMDB数据集的预向量化版本,现在我们来处理IMDB的原始文本数据,就如同在现实世界中处理一个新的文本分类问题。

准备IMDB影评数据

首先,我们从斯坦福大学Andrew Maas的页面下载数据集并解压。

你会得到一个名为aclImdb的目录,其结构如下:

政安晨:【深度学习处理实践】(八)—— 表示单词组的两种方法:集合和序列-LMLPHP

例如,train/pos/目录包含12 500个文本文件,每个文件都包含一个正面情绪的影评文本,用作训练数据。负面情绪的影评在neg目录下。共有25 000个文本文件用于训练,另有25 000个用于测试。

还有一个train/unsup子目录,我们不需要它,将其删除。

我们来查看其中几个文本文件的内容。请记住,无论是处理文本数据还是图像数据,在开始建模之前,一定都要查看数据是什么样子。这会让你建立直觉,了解模型在做什么。

!cat aclImdb/train/pos/4077_10.txt

接下来,我们准备一个验证集,将20%的训练文本文件放入一个新目录中,即aclImdb/val目录。

import os, pathlib, shutil, random

base_dir = pathlib.Path("aclImdb")
val_dir = base_dir / "val"
train_dir = base_dir / "train"
for category in ("neg", "pos"):
    os.makedirs(val_dir / category)
    files = os.listdir(train_dir / category)

    # 使用种子随机打乱训练文件列表,以确保每次运行代码都会得到相同的验证集
    random.Random(1337).shuffle(files)

    # (本行及以下1行)将20%的训练文件用于验证
    num_val_samples = int(0.2 * len(files))
    val_files = files[-num_val_samples:]
    for fname in val_files:
        # (本行及以下1行)将文件移动到aclImdb/val/neg目录和aclImdb/val/pos目录
        shutil.move(train_dir / category / fname, 
                    val_dir / category / fname)

咱们以前讲过:

我们使用image_dataset_from_directory()函数根据目录结构创建一个由图像及其标签组成的批量Dataset。你可以使用text_dataset_from_directory()函数对文本文件做相同的操作。我们为训练、验证和测试创建3个Dataset对象。

from tensorflow import keras
batch_size = 32

# 运行这行代码的输出应该是“Found 20000 files belonging to 2 classes.”(找到属于2个类别的20 000个文件);如果你的输出是“Found 70000 files belonging to 3 classes.”(找到属于3个类别的70 000个文件),那么这说明你忘记删除aclImdb/train/unsup目录

train_ds = keras.utils.text_dataset_from_directory(
    "aclImdb/train", batch_size=batch_size
)

val_ds = keras.utils.text_dataset_from_directory(
    "aclImdb/val", batch_size=batch_size
)

test_ds = keras.utils.text_dataset_from_directory(
    "aclImdb/test", batch_size=batch_size
)

这些数据集生成的输入是TensorFlow tf.string张量,生成的目标是int32格式的张量,取值为0或1,如下代码所示:

显示第一个批量的形状和数据类型

for inputs, targets in train_ds:
    print("inputs.shape:", inputs.shape)
    print("inputs.dtype:", inputs.dtype)
    print("targets.shape:", targets.shape)
    print("targets.dtype:", targets.dtype)
    print("inputs[0]:", inputs[0])
    print("targets[0]:", targets[0])
    break

显示如下:

一切准备就绪,下面我们开始从这些数据中进行学习。

将单词作为集合处理:词袋方法

要对一段文本进行编码,使其可以被机器学习模型所处理,最简单的方法是舍弃顺序,将文本看作一组(一袋)词元。你既可以查看单个单词(一元语法),也可以通过查看连续的一组词元(N元语法)来尝试恢复一些局部顺序信息。

单个单词(一元语法)的二进制编码

如果使用单个单词的词袋,那么“the cat sat on the mat”(猫坐在垫子上)这个句子就会变成{"cat", "mat", "on", "sat", "the"}。

这种编码方式的主要优点是,你可以将整个文本表示为单一向量,其中每个元素表示某个单词是否存在。

首先,我们用TextVectorization层来处理原始文本数据集,生成multi-hot编码的二进制词向量,如下代码所示:该层只会查看单个单词,即一元语法(unigram)

用TextVectorization层预处理数据集

text_vectorization = TextVectorization(

# 将词表限制为前20 000个最常出现的单词。否则,我们需要对训练数据中的每一个单词建立索引——可能会有上万个单词只出现一两次,因此没有信息量。一般来说,20 000是用于文本分类的合适的词表大小
    
max_tokens=20000,
    # 将输出词元编码为multi-hot二进制向量
    output_mode="multi_hot",
)

# 准备一个数据集,只包含原始文本输入(不包含标签)
text_only_train_ds = train_ds.map(lambda x, y: x)

# 利用adapt()方法对数据集词表建立索引
text_vectorization.adapt(text_only_train_ds)

# (本行及以下8行)分别对训练、验证和测试数据集进行处理。一定要指定num_parallel_calls,以便利用多个CPU内核
binary_1gram_train_ds = train_ds.map( 
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
binary_1gram_val_ds = val_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
binary_1gram_test_ds = test_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)

你可以查看其中一个数据集的输出,如下代码所示:

查看一元语法二进制数据集的输出

for inputs, targets in binary_1gram_train_ds:
     print("inputs.shape:", inputs.shape)
     print("inputs.dtype:", inputs.dtype)
     print("targets.shape:", targets.shape)
     print("targets.dtype:", targets.dtype)
     print("inputs[0]:", inputs[0])
     print("targets[0]:", targets[0])
     break

输出如下:

接下来,我们编写一个可复用的模型构建函数,如下代码所示:本节的所有实验都会用到它。

模型构建函数

from tensorflow import keras
from tensorflow.keras import layers

def get_model(max_tokens=20000, hidden_dim=16):
    inputs = keras.Input(shape=(max_tokens,))
    x = layers.Dense(hidden_dim, activation="relu")(inputs)
    x = layers.Dropout(0.5)(x)
    outputs = layers.Dense(1, activation="sigmoid")(x)
    model = keras.Model(inputs, outputs)
    model.compile(optimizer="rmsprop",
                  loss="binary_crossentropy",
                  metrics=["accuracy"])
    return model

最后,我们对模型进行训练和测试,如下代码所示:

对一元语法二进制模型进行训练和测试

model = get_model()
model.summary()
callbacks = [
    keras.callbacks.ModelCheckpoint("binary_1gram.keras",
                                    save_best_only=True)
]

#  (本行及以下1行)对数据集调用cache(),将其缓存在内存中:利用这种方法,我们只需在第一轮做一次预处理,在后续轮次可以复用预处理的文本。只有在数据足够小、可以装入内存的情况下,才可以这样做
model.fit(binary_1gram_train_ds.cache(), 
          validation_data=binary_1gram_val_ds.cache(),
          epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("binary_1gram.keras")
print(f"Test acc: {model.evaluate(binary_1gram_test_ds)[1]:.3f}")

二元语法的二进制编码

利用二元语法,前面的句子变成如下所示:

你可以设置TextVectorization层返回任意N元语法,如二元语法、三元语法等。只需传入参数ngrams=N,代码如下所示:

设置TextVectorization层返回二元语法

text_vectorization = TextVectorization(
    ngrams=2,
    max_tokens=20000,
    output_mode="multi_hot",
)

我们在这个二进制编码的二元语法袋上训练模型,并测试模型性能,代码如下所示:

对二元语法二进制模型进行训练和测试

text_vectorization.adapt(text_only_train_ds)
binary_2gram_train_ds = train_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
binary_2gram_val_ds = val_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
binary_2gram_test_ds = test_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)

model = get_model()
model.summary()
callbacks = [
    keras.callbacks.ModelCheckpoint("binary_2gram.keras",
                                    save_best_only=True)
]
model.fit(binary_2gram_train_ds.cache(),
          validation_data=binary_2gram_val_ds.cache(),
          epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("binary_2gram.keras")
print(f"Test acc: {model.evaluate(binary_2gram_test_ds)[1]:.3f}")

现在测试精度达到了90.4%,有很大改进!事实证明,局部顺序非常重要。

二元语法的TF-IDF编码

你还可以为这种表示添加更多的信息,方法就是计算每个单词或每个N元语法的出现次数,也就是说,统计文本的词频直方图,如下所示:

如果你做的是文本分类,那么知道一个单词在某个样本中的出现次数是很重要的:任何足够长的影评,不管是哪种情绪,都可能包含“可怕”这个词,但如果一篇影评包含许多个“可怕”,那么它很可能是负面的。

你可以用TextVectorization层来计算二元语法的出现次数,如下代码所示:

设置TextVectorization层返回词元出现次数

text_vectorization = TextVectorization(
    ngrams=2,
    max_tokens=20000,
    output_mode="count"
)

当然,无论文本的内容是什么,有些单词一定比其他单词出现得更频繁。“the”“a”“is”“are”等单词总是会在词频直方图中占据主导地位,远超其他单词,尽管它们对分类而言是没有用处的特征。我们怎么解决这个问题呢?

你可能已经猜到了利用规范化。我们可以将单词计数减去均值并除以方差,对其进行规范化(均值和方差是对整个训练数据集进行计算得到的)。这样做是有道理的。但是,大多数向量化句子几乎完全由0组成(前面的例子包含12个非零元素和19 988个零元素),这种性质叫作稀疏性。这是一种很好的性质,因为它极大降低了计算负荷,还降低了过拟合的风险。如果我们将每个特征都减去均值,那么就会破坏稀疏性。因此,无论使用哪种规范化方法,都应该只用除法。那用什么作分母呢?最佳实践是一种叫作TF-IDF规范化(TF-IDF normalization)的方法。TF-IDF的含义是“词频–逆文档频次”。

TF-IDF非常常用,它内置于TextVectorization层中。要使用TF-IDF,只需将output_mode参数的值切换为"tf_idf",如下代码所示:

设置TextVectorization层返回TF-IDF加权输出

text_vectorization = TextVectorization(
    ngrams=2,
    max_tokens=20000,
    output_mode="tf_idf",
)

理解TF-IDF规范化

某个词在一个文档中出现的次数越多,它对理解文档的内容就越重要。

同时,某个词在数据集所有文档中的出现频次也很重要如果一个词几乎出现在每个文档中(比如“the”或“a”),那么这个词就不是特别有信息量,而仅在一小部分文本中出现的词(比如“Herzog”)则是非常独特的,因此也非常重要。

TF-IDF指标融合了这两种思想。它将某个词的“词频”除以“文档频次”,前者是该词在当前文档中的出现次数,后者是该词在整个数据集中的出现频次。TF-IDF的计算方法如下。

def tfidf(term, document, dataset):
    term_freq = document.count(term)
    doc_freq = math.log(sum(doc.count(term) for doc in dataset) + 1)
    return term_freq / doc_freq

我们用这种设置训练一个新模型,如下代码所示:

对TF-IDF二元语法模型进行训练和测试

text_vectorization.adapt(text_only_train_ds)

tfidf_2gram_train_ds = train_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
tfidf_2gram_val_ds = val_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
tfidf_2gram_test_ds = test_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)

model = get_model()
model.summary()
callbacks = [
    keras.callbacks.ModelCheckpoint("tfidf_2gram.keras",
                                    save_best_only=True)
]
model.fit(tfidf_2gram_train_ds.cache(),
          validation_data=tfidf_2gram_val_ds.cache(),
          epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("tfidf_2gram.keras")
print(f"Test acc: {model.evaluate(tfidf_2gram_test_ds)[1]:.3f}")

在IMDB分类任务上的测试精度达到了89.8%,这种方法对本例似乎不是特别有用。然而,对于许多文本分类数据集而言,与普通二进制编码相比,使用TF-IDF通常可以将精度提高一个百分点。

导出能够处理原始字符串的模型

在前面的例子中,我们将文本标准化、拆分和建立索引都作为tf.data管道的一部分。但如果想导出一个独立于这个管道的模型,我们应该确保模型包含文本预处理(否则需要在生产环境中重新实现,这可能很困难,或者可能导致训练数据与生产数据之间的微妙差异)。

幸运的是,这很简单。

我们只需创建一个新的模型,复用TextVectorization层,并将其添加到刚刚训练好的模型中。

# 每个输入样本都是一个字符串
inputs = keras.Input(shape=(1,), dtype="string")

# 应用文本预处理
processed_inputs = text_vectorization(inputs)  

# 应用前面训练好的模型
outputs = model(processed_inputs) 

# 将端到端的模型实例化
inference_model = keras.Model(inputs, outputs) 

我们得到的模型可以处理原始字符串组成的批量,如下所示:

import tensorflow as tf
raw_text_data = tf.convert_to_tensor([
    ["That was an excellent movie, I loved it."],
])
predictions = inference_model(raw_text_data)
print(f"{float(predictions[0] * 100):.2f} percent positive")

将单词作为序列处理:序列模型方法

前面几个例子清楚地表明,词序很重要。

基于顺序的手动特征工程(比如二元语法)可以很好地提高精度。现在请记住:深度学习的历史就是逐渐摆脱手动特征工程,让模型仅通过观察数据来自己学习特征。

如果不手动寻找基于顺序的特征,而是让模型直接观察原始单词序列并自己找出这样的特征,那会怎么样呢?这就是序列模型(sequence model)的意义所在。

要实现序列模型,首先需要将输入样本表示为整数索引序列(每个整数代表一个单词)。然后,将每个整数映射为一个向量,得到向量序列。最后,将这些向量序列输入层的堆叠,这些层可以将相邻向量的特征交叉关联,它可以是一维卷积神经网络、RNN或Transformer。

第一个实例

我们来看一下第一个序列模型实例。首先,准备可以返回整数序列的数据集,如下代码所示:

准备整数序列数据集

from tensorflow.keras import layers

max_length = 600
max_tokens = 20000
text_vectorization = layers.TextVectorization(
    max_tokens=max_tokens,
    output_mode="int",

    # 为保持输入大小可控,我们在前600个单词处截断输入。这是一个合理的选择,因为评论的平均长度是233个单词,只有5%的评论超过600个单词
    output_sequence_length=max_length,
)
text_vectorization.adapt(text_only_train_ds)

int_train_ds = train_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
int_val_ds = val_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)
int_test_ds = test_ds.map(
    lambda x, y: (text_vectorization(x), y),
    num_parallel_calls=4)

下面来创建模型。要将整数序列转换为向量序列,最简单的方法是对整数进行one-hot编码(每个维度代表词表中的一个单词)。在这些one-hot向量之上,我们再添加一个简单的双向LSTM,如下代码所示:

构建于one-hot编码的向量序列之上的序列模型

import tensorflow as tf

# 每个输入是一个整数序列
inputs = keras.Input(shape=(None,), dtype="int64")

# 将整数编码为20 000维的二进制向量
embedded = tf.one_hot(inputs, depth=max_tokens)  

# 添加一个双向LSTM
x = layers.Bidirectional(layers.LSTM(32))(embedded) 
x = layers.Dropout(0.5)(x)

# 最后添加一个分类层
outputs = layers.Dense(1, activation="sigmoid")(x)  
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop",
              loss="binary_crossentropy",
              metrics=["accuracy"])
model.summary()

下面我们来训练模型,如下代码所示:

训练第一个简单的序列模型

callbacks = [
    keras.callbacks.ModelCheckpoint("one_hot_bidir_lstm.keras",
                                    save_best_only=True)
]
model.fit(int_train_ds, validation_data=int_val_ds, epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("one_hot_bidir_lstm.keras")
print(f"Test acc: {model.evaluate(int_test_ds)[1]:.3f}")

我们得到两个观察结果。

第一,这个模型的训练速度非常慢,尤其是与刚才的轻量级模型相比。

这是因为输入很大:每个输入样本被编码成尺寸为(600,20000)的矩阵(每个样本包含600个单词,共有20 000个可能的单词)。一条影评就有12 000 000个浮点数。双向LSTM需要做很多工作。

第二,这个模型的测试精度只有87%,性能还不如一元语法二进制模型,后者的速度还很快。

显然,使用one-hot编码将单词转换为向量,这是我们能做的最简单的事情,但这并不是一个好主意。

有一种更好的方法词嵌入(word embedding)。

理解词嵌入

重要的是,进行one-hot编码时,你做了一个与特征工程有关的决策。你向模型中注入了有关特征空间结构的基本假设。

这个假设是:你所编码的不同词元之间是相互独立的。

事实上,one-hot向量之间都是相互正交的。对于单词而言,这个假设显然是错误的。单词构成了一个结构化的空间,单词之间共享信息。在大多数句子中,“movie”和“film”这两个词是可以互换的,所以表示“movie”的向量与表示“film”的向量不应该正交,它们应该是同一个向量,或者非常相似。

说得更抽象一点,两个词向量之间的几何关系应该反映这两个单词之间的语义关系。

词嵌入是实现这一想法的词向量表示,它将人类语言映射到结构化几何空间中。

one-hot编码得到的向量是二进制的、稀疏的(大部分元素是0)、高维的(维度大小等于词表中的单词个数),而词嵌入是低维的浮点向量(密集向量,与稀疏向量相对),如下图所示:

one-hot编码或one-hot哈希得到的词表示是稀疏、高维、硬编码的,而词嵌入是密集、相对低维的,而且是从数据中学习得到的

政安晨:【深度学习处理实践】(八)—— 表示单词组的两种方法:集合和序列-LMLPHP

常见的词嵌入是256维、512维或1024维(处理非常大的词表时)。与此相对,one-hot编码的词向量通常是20 000维(词表中包含20000个词元)或更高。因此,词嵌入可以将更多的信息塞入更少的维度中。

在下图中,4个词被嵌入到二维平面中:这4个词分别是Cat(猫)、Dog(狗)、Wolf(狼)和Tiger(虎)。

词嵌入空间的简单示例

政安晨:【深度学习处理实践】(八)—— 表示单词组的两种方法:集合和序列-LMLPHP

利用我们这里选择的向量表示,这些词之间的某些语义关系可以被编码为几何变换。例如,从Cat到Tiger的向量与从Dog到Wolf的向量相同,这个向量可以被解释为“从宠物到野生动物”向量。同样,从Dog到Cat的向量与从Wolf到Tiger的向量也相同,这个向量可以被解释为“从犬科到猫科”向量。

在现实世界的词嵌入空间中,常见的有意义的几何变换示例包括“性别”向量和“复数”向量。例如,将“king”(国王)向量加上“female”(女性)向量,得到的是“queen”(女王)向量。将“king”(国王)向量加上“plural”(复数)向量,得到的是“kings”向量。词嵌入空间通常包含上千个这种可解释的向量,它们可能都很有用。

我们来看一下在实践中如何使用这样的嵌入空间。有以下两种方法可以得到词嵌入。

在完成主任务(比如文档分类或情感预测)的同时学习词嵌入。在这种情况下,一开始是随机的词向量,然后对这些词向量进行学习,学习方式与学习神经网络权重相同。

在不同于待解决问题的机器学习任务上预计算词嵌入,然后将其加载到模型中。这些词嵌入叫作预训练词嵌入(pretrained word embedding)。

我们来分别看一下这两种方法

利用Embedding层学习词嵌入

因此,合理的做法是对每个新任务都学习一个新的嵌入空间。幸运的是,反向传播让这种学习变得简单,Keras则使其变得更简单。我们只需学习Embedding层的权重,如下代码所示:

将Embedding层实例化

# Embedding层至少需要两个参数:词元个数和嵌入维度(这里是256)
embedding_layer = layers.Embedding(input_dim=max_tokens, output_dim=256)

你可以将Embedding层理解为一个字典,它将整数索引(表示某个单词)映射为密集向量。它接收整数作为输入,在内部字典中查找这些整数,然后返回对应的向量。

Embedding层的作用实际上就是字典查询,如下图所示(Embedding层):

政安晨:【深度学习处理实践】(八)—— 表示单词组的两种方法:集合和序列-LMLPHP

Embedding层的输入是形状为(batch_size, sequence_length)的2阶整数张量,其中每个元素都是一个整数序列。

该层返回的是一个形状为(batch_size,sequence_length, embedding_dimensionality)的3阶浮点数张量。

将Embedding层实例化时,它的权重(内部的词向量字典)是随机初始化的,就像其他层一样。

在训练过程中,利用反向传播来逐渐调节这些词向量,改变空间结构,使其可以被下游模型利用。训练完成之后,嵌入空间会充分地显示结构。这种结构专门针对模型训练所要解决的问题。

我们来构建一个包含Embedding层的模型,为我们的任务建立基准,如下代码所示:

从头开始训练一个使用Embedding层的模型

inputs = keras.Input(shape=(None,), dtype="int64")
embedded = layers.Embedding(input_dim=max_tokens, output_dim=256)(inputs)
x = layers.Bidirectional(layers.LSTM(32))(embedded)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop",
              loss="binary_crossentropy",
              metrics=["accuracy"])
model.summary()

callbacks = [
    keras.callbacks.ModelCheckpoint("embeddings_bidir_gru.keras",
                                    save_best_only=True)
]
model.fit(int_train_ds, validation_data=int_val_ds, epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("embeddings_bidir_gru.keras")
print(f"Test acc: {model.evaluate(int_test_ds)[1]:.3f}")

模型训练速度比one-hot模型快得多(因为LSTM只需处理256维向量,而不是20000维),测试精度也差不多(87%)。然而,这个模型与简单的二元语法模型相比仍有一定差距。

部分原因在于,这个模型所查看的数据略少:二元语法模型处理的是完整的评论,而这个序列模型在600个单词之后截断序列。

理解填充和掩码

这里还有一件事会略微降低模型性能,那就是输入序列中包含许多0。

这是因为我们在TextVectorization层中使用了output_sequence_length=max_length选项(max_length为600),也就是说,多于600个词元的句子将被截断为600个词元,而少于600个词元的句子则会在末尾用0填充,使其能够与其他序列连接在一起,形成连续的批量。

我们使用的是双向RNN,即两个RNN层并行运行,一个正序处理词元,另一个逆序处理相同的词元。按正序处理词元的RNN,在最后的迭代中只会看到表示填充的向量。如果原始句子很短,那么这可能包含几百次迭代。

在读取这些无意义的输入时,存储在RNN内部状态中的信息将逐渐消失。

我们需要用某种方式来告诉RNN,它应该跳过这些迭代。有一个API可以实现此功能:掩码(masking)

Embedding层能够生成与输入数据相对应的掩码。

这个掩码是由1和0(或布尔值True/False)组成的张量,形状为(batch_size, sequence_length),其元素mask[i, t]表示第i个样本的第t个时间步是否应该被跳过(如果mask[i, t]为0或False,则跳过该时间步,反之则处理该时间步)。

默认情况下没有启用这个选项,你可以向Embedding层传入mask_zero=True来启用它。你可以用compute_mask()方法来获取掩码,如下所示:

embedding_layer = layers.Embedding(input_dim=10, output_dim=256, mask_zero=True)

some_input = [
... [4, 3, 2, 1, 0, 0, 0],
... [5, 4, 3, 2, 1, 0, 0],
... [2, 1, 0, 0, 0, 0, 0]]

mask = embedding_layer.compute_mask(some_input)
<tf.Tensor: shape=(3, 7), dtype=bool, numpy=
array([[ True,  True,  True,  True, False, False, False],
       [ True,  True,  True,  True,  True, False, False],
       [ True,  True, False, False, False, False, False]])>

在实践中,你几乎不需要手动管理掩码。

相反,Keras会将掩码自动传递给能够处理掩码的每一层(作为元数据附加到所对应的序列中)。RNN层会利用掩码来跳过被掩码的时间步。如果模型返回的是整个序列,那么损失函数也会利用掩码来跳过输出序列中被掩码的时间步。

我们使用掩码重新训练模型,如下代码所示:

使用带有掩码的Embedding层

inputs = keras.Input(shape=(None,), dtype="int64")
embedded = layers.Embedding(
    input_dim=max_tokens, output_dim=256, mask_zero=True)(inputs)
x = layers.Bidirectional(layers.LSTM(32))(embedded)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop",
              loss="binary_crossentropy",
              metrics=["accuracy"])
model.summary()

callbacks = [
    keras.callbacks.ModelCheckpoint("embeddings_bidir_gru_with_masking.keras",
                                    save_best_only=True)
]
model.fit(int_train_ds, validation_data=int_val_ds, epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("embeddings_bidir_gru_with_masking.keras")
print(f"Test acc: {model.evaluate(int_test_ds)[1]:.3f}")

这次模型的测试精度达到了88%,这是一个很小但仍可观的改进。

使用预训练词嵌入

有时可用的训练数据太少,只用手头数据无法学习特定任务的词嵌入。

在这种情况下,你可以从预计算嵌入空间中加载词嵌入向量(这个嵌入空间是高度结构化的,并且具有有用的性质,捕捉到了语言结构的通用特征),而不是在解决问题的同时学习词嵌入。

在自然语言处理中使用预训练词嵌入,其背后的原理与在图像分类中使用预训练卷积神经网络是一样的:没有足够的数据来自己学习强大的特征,但你需要的特征是非常通用的,即常见的视觉特征或语义特征。在这种情况下,复用在其他问题上学到的特征,这种做法是有意义的。

有许多预计算的词嵌入数据库,你都可以下载并在Keras的Embedding层中使用,Word2Vec是其中之一。

另一个常用的叫作词表示全局向量(Global Vectors for Word Representation,GloVe),由斯坦福大学的研究人员于2014年开发。这种嵌入方法基于对词共现统计矩阵进行因式分解。它的开发者已经公开了数百万个英文词元的预计算嵌入,它们都是从维基百科数据和Common Crawl数据得到的。

我们来看一下如何在Keras模型中使用GloVe嵌入。同样的方法也适用于Word2Vec嵌入或其他词嵌入数据库。我们首先下载GloVe文件并解析。然后,我们将词向量加载到Keras Embedding层中,并利用它来构建一个新模型。

首先,我们下载在2014年英文维基百科数据集上预计算的GloVe词嵌入。它是一个822 MB的压缩文件,里面包含400 000个单词(或非词词元)的100维嵌入向量。

我们对解压后的文件(一个.txt文件)进行解析,构建一个索引将单词(字符串)映射为其向量表示,如下代码所示。

解析GloVe词嵌入文件

import numpy as np
path_to_glove_file = "glove.6B.100d.txt"

embeddings_index = {}
with open(path_to_glove_file) as f:
    for line in f:
        word, coefs = line.split(maxsplit=1)
        coefs = np.fromstring(coefs, "f", sep=" ")
        embeddings_index[word] = coefs

print(f"Found {len(embeddings_index)} word vectors.")

接下来,我们构建一个可以加载到Embedding层中的嵌入矩阵,如下代码所示。

准备GloVe词嵌入矩阵

embedding_dim = 100

# 获取前面TextVectorization层索引的词表
vocabulary = text_vectorization.get_vocabulary() 

# 利用这个词表创建一个从单词到其词表索引的映射
word_index = dict(zip(vocabulary, range(len(vocabulary))))

# 准备一个矩阵,后续将用GloVe向量填充
embedding_matrix = np.zeros((max_tokens, embedding_dim)) 
for word, i in word_index.items():
    if i < max_tokens:
        embedding_vector = embeddings_index.get(word)

    # (本行及以下2行)用索引为i的单词的词向量填充矩阵中的第i个元素。对于嵌入索引中找不到的单词,其嵌入向量全为0
    if embedding_vector is not None:
        embedding_matrix[i] = embedding_vector

它必须是一个形状为(max_words, embedding_dim)的矩阵,对于索引为i的单词(在词元化时建立索引),该矩阵的元素i包含这个单词对应的embedding_dim维向量。

最后,我们使用Constant初始化方法在Embedding层中加载预训练词嵌入。为避免在训练过程中破坏预训练表示,我们使用trainable=False冻结该层,如下所示。

embedding_layer = layers.Embedding(
    max_tokens,
    embedding_dim,
    embeddings_initializer=keras.initializers.Constant(embedding_matrix),
    trainable=False,
    mask_zero=True,
)

现在我们可以训练一个新模型,如下代码所示。新模型与之前的模型相同,但使用的是100维的预训练GloVe嵌入,而不是128维学到的嵌入。

使用预训练Embedding层的模型

inputs = keras.Input(shape=(None,), dtype="int64")
embedded = embedding_layer(inputs)
x = layers.Bidirectional(layers.LSTM(32))(embedded)
x = layers.Dropout(0.5)(x)
outputs = layers.Dense(1, activation="sigmoid")(x)
model = keras.Model(inputs, outputs)
model.compile(optimizer="rmsprop",
              loss="binary_crossentropy",
              metrics=["accuracy"])
model.summary()

callbacks = [
    keras.callbacks.ModelCheckpoint("glove_embeddings_sequence_model.keras",
                                    save_best_only=True)
]
model.fit(int_train_ds, validation_data=int_val_ds, epochs=10,
          callbacks=callbacks)
model = keras.models.load_model("glove_embeddings_sequence_model.keras")
print(f"Test acc: {model.evaluate(int_test_ds)[1]:.3f}")

可以看到,对于这项特定的任务,预训练词嵌入不是很有帮助,因为数据集中已经包含足够多的样本,足以从头开始学习一个足够专业的嵌入空间。

但是在处理较小的数据集时,预训练词嵌入会非常有用。


基于深度学习处理文本,咱们就告一段落了。

03-16 19:14