Sklearn、TensorFlow 与 Keras 机器学习实用指南第三版(五)(3)https://developer.aliyun.com/article/1482434
使用 SequenceExample Protobuf 处理列表列表
这是SequenceExample
protobuf 的定义:
message FeatureList { repeated Feature feature = 1; }; message FeatureLists { map<string, FeatureList> feature_list = 1; }; message SequenceExample { Features context = 1; FeatureLists feature_lists = 2; };
SequenceExample
包含一个Features
对象用于上下文数据和一个包含一个或多个命名FeatureList
对象(例如,一个名为"content"
的FeatureList
和另一个名为"comments"
的FeatureList
)的FeatureLists
对象。每个FeatureList
包含一个Feature
对象列表,每个Feature
对象可能是字节字符串列表、64 位整数列表或浮点数列表(在此示例中,每个Feature
可能代表一个句子或评论,可能以单词标识符列表的形式)。构建SequenceExample
、序列化它并解析它类似于构建、序列化和解析Example
,但您必须使用tf.io.parse_single_sequence_example()
来解析单个SequenceExample
或tf.io.parse_sequence_example()
来解析批处理。这两个函数返回一个包含上下文特征(作为字典)和特征列表(也作为字典)的元组。如果特征列表包含不同大小的序列(如前面的示例),您可能希望使用tf.RaggedTensor.from_sparse()
将它们转换为不规则张量(请参阅完整代码的笔记本):
parsed_context, parsed_feature_lists = tf.io.parse_single_sequence_example( serialized_sequence_example, context_feature_descriptions, sequence_feature_descriptions) parsed_content = tf.RaggedTensor.from_sparse(parsed_feature_lists["content"])
现在您已经知道如何使用 tf.data API、TFRecords 和 protobufs 高效存储、加载、解析和预处理数据,是时候将注意力转向 Keras 预处理层了。
Keras 预处理层
为神经网络准备数据通常需要对数值特征进行归一化、对分类特征和文本进行编码、裁剪和调整图像等。有几种选项:
- 预处理可以提前在准备训练数据文件时完成,使用您喜欢的任何工具,如 NumPy、Pandas 或 Scikit-Learn。您需要在生产中应用完全相同的预处理步骤,以确保您的生产模型接收到与训练时相似的预处理输入。
- 或者,您可以在加载数据时使用 tf.data 进行即时预处理,通过使用该数据集的
map()
方法对数据集的每个元素应用预处理函数,就像本章前面所做的那样。同样,您需要在生产中应用相同的预处理步骤。 - 最后一种方法是直接在模型内部包含预处理层,这样它可以在训练期间即时预处理所有输入数据,然后在生产中使用相同的预处理层。本章的其余部分将讨论这种最后一种方法。
Keras 提供了许多预处理层,您可以将其包含在模型中:它们可以应用于数值特征、分类特征、图像和文本。我们将在接下来的部分中讨论数值和分类特征,以及基本文本预处理,我们将在第十四章中涵盖图像预处理,以及在第十六章中涵盖更高级的文本预处理。
归一化层
正如我们在第十章中看到的,Keras 提供了一个Normalization
层,我们可以用来标准化输入特征。我们可以在创建层时指定每个特征的均值和方差,或者更简单地在拟合模型之前将训练集传递给该层的adapt()
方法,以便该层可以在训练之前自行测量特征的均值和方差:
norm_layer = tf.keras.layers.Normalization() model = tf.keras.models.Sequential([ norm_layer, tf.keras.layers.Dense(1) ]) model.compile(loss="mse", optimizer=tf.keras.optimizers.SGD(learning_rate=2e-3)) norm_layer.adapt(X_train) # computes the mean and variance of every feature model.fit(X_train, y_train, validation_data=(X_valid, y_valid), epochs=5)
提示
传递给adapt()
方法的数据样本必须足够大,以代表您的数据集,但不必是完整的训练集:对于Normalization
层,从训练集中随机抽取的几百个实例通常足以获得特征均值和方差的良好估计。
由于我们在模型中包含了Normalization
层,现在我们可以将这个模型部署到生产环境中,而不必再担心归一化的问题:模型会自动处理(参见图 13-4)。太棒了!这种方法完全消除了预处理不匹配的风险,当人们尝试为训练和生产维护不同的预处理代码,但更新其中一个并忘记更新另一个时,就会发生这种情况。生产模型最终会接收到以其不期望的方式预处理的数据。如果他们幸运的话,会得到一个明显的错误。如果不幸的话,模型的准确性会悄悄下降。
图 13-4。在模型中包含预处理层
直接在模型中包含预处理层很简单明了,但会减慢训练速度(在Normalization
层的情况下只会稍微减慢):实际上,由于预处理是在训练过程中实时进行的,每个时期只会发生一次。我们可以通过在训练之前仅对整个训练集进行一次归一化来做得更好。为此,我们可以像使用 Scikit-Learn 的StandardScaler
一样单独使用Normalization
层:
norm_layer = tf.keras.layers.Normalization() norm_layer.adapt(X_train) X_train_scaled = norm_layer(X_train) X_valid_scaled = norm_layer(X_valid)
现在我们可以在经过缩放的数据上训练模型,这次不需要Normalization
层:
model = tf.keras.models.Sequential([tf.keras.layers.Dense(1)]) model.compile(loss="mse", optimizer=tf.keras.optimizers.SGD(learning_rate=2e-3)) model.fit(X_train_scaled, y_train, epochs=5, validation_data=(X_valid_scaled, y_valid))
很好!这应该会加快训练速度。但是现在当我们将模型部署到生产环境时,模型不会对其输入进行预处理。为了解决这个问题,我们只需要创建一个新模型,将适应的Normalization
层和刚刚训练的模型包装在一起。然后我们可以将这个最终模型部署到生产环境中,它将负责对其输入进行预处理和进行预测(参见图 13-5):
final_model = tf.keras.Sequential([norm_layer, model]) X_new = X_test[:3] # pretend we have a few new instances (unscaled) y_pred = final_model(X_new) # preprocesses the data and makes predictions
图 13-5。在训练之前仅对数据进行一次预处理,然后将这些层部署到最终模型中
现在我们拥有了最佳的两种方式:训练很快,因为我们只在训练开始前对数据进行一次预处理,而最终模型可以在运行时对其输入进行预处理,而不会有任何预处理不匹配的风险。
此外,Keras 预处理层与 tf.data API 很好地配合。例如,可以将tf.data.Dataset
传递给预处理层的adapt()
方法。还可以使用数据集的map()
方法将 Keras 预处理层应用于tf.data.Dataset
。例如,以下是如何将适应的Normalization
层应用于数据集中每个批次的输入特征的方法:
dataset = dataset.map(lambda X, y: (norm_layer(X), y))
最后,如果您需要比 Keras 预处理层提供的更多特性,您可以随时编写自己的 Keras 层,就像我们在第十二章中讨论的那样。例如,如果Normalization
层不存在,您可以使用以下自定义层获得类似的结果:
import numpy as np class MyNormalization(tf.keras.layers.Layer): def adapt(self, X): self.mean_ = np.mean(X, axis=0, keepdims=True) self.std_ = np.std(X, axis=0, keepdims=True) def call(self, inputs): eps = tf.keras.backend.epsilon() # a small smoothing term return (inputs - self.mean_) / (self.std_ + eps)
接下来,让我们看看另一个用于数值特征的 Keras 预处理层:Discretization
层。
Discretization 层
Discretization
层的目标是通过将值范围(称为箱)映射到类别,将数值特征转换为分类特征。这对于具有多峰分布的特征或与目标具有高度非线性关系的特征有时是有用的。例如,以下代码将数值age
特征映射到三个类别,小于 18 岁,18 到 50 岁(不包括),50 岁或以上:
>>> age = tf.constant([[10.], [93.], [57.], [18.], [37.], [5.]]) >>> discretize_layer = tf.keras.layers.Discretization(bin_boundaries=[18., 50.]) >>> age_categories = discretize_layer(age) >>> age_categories <tf.Tensor: shape=(6, 1), dtype=int64, numpy=array([[0],[2],[2],[1],[1],[0]])>
在这个例子中,我们提供了期望的分箱边界。如果你愿意,你可以提供你想要的箱数,然后调用层的adapt()
方法,让它根据值的百分位数找到合适的箱边界。例如,如果我们设置num_bins=3
,那么箱边界将位于第 33 和第 66 百分位数之下的值(在这个例子中,值为 10 和 37):
>>> discretize_layer = tf.keras.layers.Discretization(num_bins=3) >>> discretize_layer.adapt(age) >>> age_categories = discretize_layer(age) >>> age_categories <tf.Tensor: shape=(6, 1), dtype=int64, numpy=array([[1],[2],[2],[1],[2],[0]])>
通常不应将诸如此类的类别标识符直接传递给神经网络,因为它们的值无法有意义地进行比较。相反,它们应该被编码,例如使用独热编码。现在让我们看看如何做到这一点。
CategoryEncoding 层
当只有少量类别(例如,少于十几个或二十个)时,独热编码通常是一个不错的选择(如第二章中讨论的)。为此,Keras 提供了CategoryEncoding
层。例如,让我们对刚刚创建的age_categories
特征进行独热编码:
>>> onehot_layer = tf.keras.layers.CategoryEncoding(num_tokens=3) >>> onehot_layer(age_categories) <tf.Tensor: shape=(6, 3), dtype=float32, numpy= array([[0., 1., 0.], [0., 0., 1.], [0., 0., 1.], [0., 1., 0.], [0., 0., 1.], [1., 0., 0.]], dtype=float32)>
如果尝试一次对多个分类特征进行编码(只有当它们都使用相同的类别时才有意义),CategoryEncoding
类将默认执行多热编码:输出张量将包含每个输入特征中存在的每个类别的 1。例如:
>>> two_age_categories = np.array([[1, 0], [2, 2], [2, 0]]) >>> onehot_layer(two_age_categories) <tf.Tensor: shape=(3, 3), dtype=float32, numpy= array([[1., 1., 0.], [0., 0., 1.], [1., 0., 1.]], dtype=float32)>
如果您认为知道每个类别出现的次数是有用的,可以在创建CategoryEncoding
层时设置output_mode="count"
,在这种情况下,输出张量将包含每个类别的出现次数。在前面的示例中,输出将与之前相同,只是第二行将变为[0., 0., 2.]
。
请注意,多热编码和计数编码都会丢失信息,因为无法知道每个活动类别来自哪个特征。例如,[0, 1]
和[1, 0]
都被编码为[1., 1., 0.]
。如果要避免这种情况,那么您需要分别对每个特征进行独热编码,然后连接输出。这样,[0, 1]
将被编码为[1., 0., 0., 0., 1., 0.]
,[1, 0]
将被编码为[0., 1., 0., 1., 0., 0.]
。您可以通过调整类别标识符来获得相同的结果,以便它们不重叠。例如:
>>> onehot_layer = tf.keras.layers.CategoryEncoding(num_tokens=3 + 3) >>> onehot_layer(two_age_categories + [0, 3]) # adds 3 to the second feature <tf.Tensor: shape=(3, 6), dtype=float32, numpy= array([[0., 1., 0., 1., 0., 0.], [0., 0., 1., 0., 0., 1.], [0., 0., 1., 1., 0., 0.]], dtype=float32)>
在此输出中,前三列对应于第一个特征,最后三列对应于第二个特征。这使模型能够区分这两个特征。但是,这也增加了馈送到模型的特征数量,因此需要更多的模型参数。很难事先知道单个多热编码还是每个特征的独热编码哪个效果最好:这取决于任务,您可能需要测试两种选项。
现在您可以使用独热编码或多热编码对分类整数特征进行编码。但是对于分类文本特征呢?为此,您可以使用StringLookup
层。
StringLookup 层
让我们使用 Keras 的StringLookup
层对cities
特征进行独热编码:
>>> cities = ["Auckland", "Paris", "Paris", "San Francisco"] >>> str_lookup_layer = tf.keras.layers.StringLookup() >>> str_lookup_layer.adapt(cities) >>> str_lookup_layer([["Paris"], ["Auckland"], ["Auckland"], ["Montreal"]]) <tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[1], [3], [3], [0]])>
我们首先创建一个StringLookup
层,然后将其适应到数据:它发现有三个不同的类别。然后我们使用该层对一些城市进行编码。默认情况下,它们被编码为整数。未知类别被映射为 0,就像在这个例子中的“Montreal”一样。已知类别从最常见的类别开始编号,从最常见到最不常见。
方便的是,当创建StringLookup
层时设置output_mode="one_hot"
,它将为每个类别输出一个独热向量,而不是一个整数:
>>> str_lookup_layer = tf.keras.layers.StringLookup(output_mode="one_hot") >>> str_lookup_layer.adapt(cities) >>> str_lookup_layer([["Paris"], ["Auckland"], ["Auckland"], ["Montreal"]]) <tf.Tensor: shape=(4, 4), dtype=float32, numpy= array([[0., 1., 0., 0.], [0., 0., 0., 1.], [0., 0., 0., 1.], [1., 0., 0., 0.]], dtype=float32)>
提示
Keras 还包括一个IntegerLookup
层,其功能类似于StringLookup
层,但输入为整数,而不是字符串。
如果训练集非常大,可能会方便地将层适应于训练集的随机子集。在这种情况下,层的adapt()
方法可能会错过一些较少见的类别。默认情况下,它会将它们全部映射到类别 0,使它们在模型中无法区分。为了减少这种风险(同时仅在训练集的子集上调整层),您可以将num_oov_indices
设置为大于 1 的整数。这是要使用的未知词汇(OOV)桶的数量:每个未知类别将使用哈希函数对 OOV 桶的数量取模,伪随机地映射到其中一个 OOV 桶。这将使模型能够区分至少一些罕见的类别。例如:
>>> str_lookup_layer = tf.keras.layers.StringLookup(num_oov_indices=5) >>> str_lookup_layer.adapt(cities) >>> str_lookup_layer([["Paris"], ["Auckland"], ["Foo"], ["Bar"], ["Baz"]]) <tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[5], [7], [4], [3], [4]])>
由于有五个 OOV 桶,第一个已知类别的 ID 现在是 5(“巴黎”)。但是,"Foo"
、"Bar"
和"Baz"
是未知的,因此它们各自被映射到 OOV 桶中的一个。 "Bar"
有自己的专用桶(ID 为 3),但不幸的是,"Foo"
和"Baz"
被映射到相同的桶中(ID 为 4),因此它们在模型中保持不可区分。这被称为哈希碰撞。减少碰撞风险的唯一方法是增加 OOV 桶的数量。但是,这也会增加总类别数,这将需要更多的 RAM 和额外的模型参数,一旦类别被独热编码。因此,不要将该数字增加得太多。
将类别伪随机映射到桶中的这种想法称为哈希技巧。Keras 提供了一个专用的层,就是Hashing
层。
哈希层
对于每个类别,Keras 的Hashing
层计算一个哈希值,取模于桶(或“bin”)的数量。映射完全是伪随机的,但在运行和平台之间是稳定的(即,只要桶的数量不变,相同的类别将始终被映射到相同的整数)。例如,让我们使用Hashing
层来编码一些城市:
>>> hashing_layer = tf.keras.layers.Hashing(num_bins=10) >>> hashing_layer([["Paris"], ["Tokyo"], ["Auckland"], ["Montreal"]]) <tf.Tensor: shape=(4, 1), dtype=int64, numpy=array([[0], [1], [9], [1]])>
这个层的好处是它根本不需要适应,这有时可能很有用,特别是在核外设置中(当数据集太大而无法放入内存时)。然而,我们再次遇到了哈希碰撞:“东京”和“蒙特利尔”被映射到相同的 ID,使它们在模型中无法区分。因此,通常最好坚持使用StringLookup
层。
现在让我们看另一种编码类别的方法:可训练的嵌入。
使用嵌入编码分类特征
嵌入是一种高维数据(例如类别或词汇中的单词)的密集表示。如果有 50,000 个可能的类别,那么独热编码将产生一个 50,000 维的稀疏向量(即,大部分为零)。相比之下,嵌入将是一个相对较小的密集向量;例如,只有 100 个维度。
在深度学习中,嵌入通常是随机初始化的,然后通过梯度下降与其他模型参数一起训练。例如,在加利福尼亚住房数据集中,"NEAR BAY"
类别最初可以由一个随机向量表示,例如[0.131, 0.890]
,而"NEAR OCEAN"
类别可能由另一个随机向量表示,例如[0.631, 0.791]
。在这个例子中,我们使用了 2D 嵌入,但维度的数量是一个可以调整的超参数。
由于这些嵌入是可训练的,它们在训练过程中会逐渐改进;由于它们在这种情况下代表的是相当相似的类别,梯度下降肯定会使它们彼此更接近,同时也会使它们远离"INLAND"
类别的嵌入(参见图 13-6)。实际上,表示得越好,神经网络就越容易做出准确的预测,因此训练倾向于使嵌入成为类别的有用表示。这被称为表示学习(您将在第十七章中看到其他类型的表示学习)。
图 13-6。嵌入将在训练过程中逐渐改进
Keras 提供了一个Embedding
层,它包装了一个嵌入矩阵:这个矩阵每行对应一个类别,每列对应一个嵌入维度。默认情况下,它是随机初始化的。要将类别 ID 转换为嵌入,Embedding
层只需查找并返回对应于该类别的行。就是这样!例如,让我们用五行和 2D 嵌入初始化一个Embedding
层,并用它来编码一些类别:
>>> tf.random.set_seed(42) >>> embedding_layer = tf.keras.layers.Embedding(input_dim=5, output_dim=2) >>> embedding_layer(np.array([2, 4, 2])) <tf.Tensor: shape=(3, 2), dtype=float32, numpy= array([[-0.04663396, 0.01846724], [-0.02736737, -0.02768031], [-0.04663396, 0.01846724]], dtype=float32)>
正如您所看到的,类别 2 被编码(两次)为 2D 向量[-0.04663396, 0.01846724]
,而类别 4 被编码为[-0.02736737, -0.02768031]
。由于该层尚未训练,这些编码只是随机的。
警告
Embedding
层是随机初始化的,因此除非使用预训练权重初始化,否则在模型之外作为独立的预处理层使用它是没有意义的。
如果要嵌入一个分类文本属性,您可以简单地将StringLookup
层和Embedding
层连接起来,就像这样:
>>> tf.random.set_seed(42) >>> ocean_prox = ["<1H OCEAN", "INLAND", "NEAR OCEAN", "NEAR BAY", "ISLAND"] >>> str_lookup_layer = tf.keras.layers.StringLookup() >>> str_lookup_layer.adapt(ocean_prox) >>> lookup_and_embed = tf.keras.Sequential([ ... str_lookup_layer, ... tf.keras.layers.Embedding(input_dim=str_lookup_layer.vocabulary_size(), ... output_dim=2) ... ]) ... >>> lookup_and_embed(np.array([["<1H OCEAN"], ["ISLAND"], ["<1H OCEAN"]])) <tf.Tensor: shape=(3, 2), dtype=float32, numpy= array([[-0.01896119, 0.02223358], [ 0.02401174, 0.03724445], [-0.01896119, 0.02223358]], dtype=float32)>
请注意,嵌入矩阵中的行数需要等于词汇量的大小:这是总类别数,包括已知类别和 OOV 桶(默认只有一个)。StringLookup
类的vocabulary_size()
方法方便地返回这个数字。
提示
在这个例子中,我们使用了 2D 嵌入,但一般来说,嵌入通常有 10 到 300 个维度,取决于任务、词汇量和训练集的大小。您将需要调整这个超参数。
将所有内容放在一起,现在我们可以创建一个 Keras 模型,可以处理分类文本特征以及常规数值特征,并为每个类别(以及每个 OOV 桶)学习一个嵌入:
X_train_num, X_train_cat, y_train = [...] # load the training set X_valid_num, X_valid_cat, y_valid = [...] # and the validation set num_input = tf.keras.layers.Input(shape=[8], name="num") cat_input = tf.keras.layers.Input(shape=[], dtype=tf.string, name="cat") cat_embeddings = lookup_and_embed(cat_input) encoded_inputs = tf.keras.layers.concatenate([num_input, cat_embeddings]) outputs = tf.keras.layers.Dense(1)(encoded_inputs) model = tf.keras.models.Model(inputs=[num_input, cat_input], outputs=[outputs]) model.compile(loss="mse", optimizer="sgd") history = model.fit((X_train_num, X_train_cat), y_train, epochs=5, validation_data=((X_valid_num, X_valid_cat), y_valid))
这个模型有两个输入:num_input
,每个实例包含八个数值特征,以及cat_input
,每个实例包含一个分类文本输入。该模型使用我们之前创建的lookup_and_embed
模型来将每个海洋接近类别编码为相应的可训练嵌入。接下来,它使用concatenate()
函数将数值输入和嵌入连接起来,生成完整的编码输入,准备输入神经网络。在这一点上,我们可以添加任何类型的神经网络,但为了简单起见,我们只添加一个单一的密集输出层,然后我们创建 KerasModel
,使用我们刚刚定义的输入和输出。接下来,我们编译模型并训练它,传递数值和分类输入。
正如您在第十章中看到的,由于Input
层的名称是"num"
和"cat"
,我们也可以将训练数据传递给fit()
方法,使用字典而不是元组:{"num": X_train_num, "cat": X_train_cat}
。或者,我们可以传递一个包含批次的tf.data.Dataset
,每个批次表示为((X_batch_num, X_batch_cat), y_batch)
或者({"num": X_batch_num, "cat": X_batch_cat}, y_batch)
。当然,验证数据也是一样的。
注意
先进行独热编码,然后通过一个没有激活函数和偏置的Dense
层等同于一个Embedding
层。然而,Embedding
层使用的计算量要少得多,因为它避免了许多零乘法——当嵌入矩阵的大小增长时,性能差异变得明显。Dense
层的权重矩阵起到了嵌入矩阵的作用。例如,使用大小为 20 的独热向量和一个具有 10 个单元的Dense
层等同于使用一个input_dim=20
和output_dim=10
的Embedding
层。因此,在Embedding
层后面的层中使用的嵌入维度不应该超过单元数。
好了,现在您已经学会了如何对分类特征进行编码,是时候将注意力转向文本预处理了。
文本预处理
Keras 为基本文本预处理提供了一个TextVectorization
层。与StringLookup
层类似,您必须在创建时传递一个词汇表,或者使用adapt()
方法从一些训练数据中学习词汇表。让我们看一个例子:
>>> train_data = ["To be", "!(to be)", "That's the question", "Be, be, be."] >>> text_vec_layer = tf.keras.layers.TextVectorization() >>> text_vec_layer.adapt(train_data) >>> text_vec_layer(["Be good!", "Question: be or be?"]) <tf.Tensor: shape=(2, 4), dtype=int64, numpy= array([[2, 1, 0, 0], [6, 2, 1, 2]])>
两个句子“Be good!”和“Question: be or be?”分别被编码为[2, 1, 0, 0]
和[6, 2, 1, 2]
。词汇表是从训练数据中的四个句子中学习的:“be” = 2,“to” = 3,等等。为构建词汇表,adapt()
方法首先将训练句子转换为小写并去除标点,这就是为什么“Be”、“be”和“be?”都被编码为“be” = 2。接下来,句子被按空格拆分,生成的单词按降序频率排序,产生最终的词汇表。在编码句子时,未知单词被编码为 1。最后,由于第一个句子比第二个句子短,因此用 0 进行了填充。
提示
TextVectorization
层有许多选项。例如,您可以通过设置standardize=None
来保留大小写和标点,或者您可以将任何标准化函数作为standardize
参数传递。您可以通过设置split=None
来防止拆分,或者您可以传递自己的拆分函数。您可以设置output_sequence_length
参数以确保输出序列都被裁剪或填充到所需的长度,或者您可以设置ragged=True
以获得一个不规则张量而不是常规张量。请查看文档以获取更多选项。
单词 ID 必须进行编码,通常使用Embedding
层:我们将在第十六章中进行这样做。或者,您可以将TextVectorization
层的output_mode
参数设置为"multi_hot"
或"count"
以获得相应的编码。然而,简单地计算单词通常不是理想的:像“to”和“the”这样的单词非常频繁,几乎没有影响,而“basketball”等更稀有的单词则更具信息量。因此,通常最好将output_mode
设置为"tf_idf"
,它代表词频 × 逆文档频率(TF-IDF)。这类似于计数编码,但在训练数据中频繁出现的单词被降权,反之,稀有单词被升权。例如:
>>> text_vec_layer = tf.keras.layers.TextVectorization(output_mode="tf_idf") >>> text_vec_layer.adapt(train_data) >>> text_vec_layer(["Be good!", "Question: be or be?"]) <tf.Tensor: shape=(2, 6), dtype=float32, numpy= array([[0.96725637, 0.6931472 , 0\. , 0\. , 0\. , 0\. ], [0.96725637, 1.3862944 , 0\. , 0\. , 0\. , 1.0986123 ]], dtype=float32)>
TF-IDF 的变体有很多种,但TextVectorization
层实现的方式是将每个单词的计数乘以一个权重,该权重等于 log(1 + d / (f + 1)),其中d是训练数据中的句子总数(也称为文档),f表示这些训练句子中包含给定单词的数量。例如,在这种情况下,训练数据中有d = 4 个句子,单词“be”出现在f = 3 个句子中。由于单词“be”在句子“Question: be or be?”中出现了两次,它被编码为 2 × log(1 + 4 / (1 + 3)) ≈ 1.3862944。单词“question”只出现一次,但由于它是一个不太常见的单词,它的编码几乎一样高:1 × log(1 + 4 / (1 + 1)) ≈ 1.0986123。请注意,对于未知单词,使用平均权重。
这种文本编码方法易于使用,并且对于基本的自然语言处理任务可以得到相当不错的结果,但它有几个重要的局限性:它只适用于用空格分隔单词的语言,它不区分同音异义词(例如“to bear”与“teddy bear”),它不提示您的模型单词“evolution”和“evolutionary”之间的关系等。如果使用多热编码、计数或 TF-IDF 编码,则单词的顺序会丢失。那么还有哪些其他选项呢?
一种选择是使用TensorFlow Text 库,它提供比TextVectorization
层更高级的文本预处理功能。例如,它包括几种子词标记器,能够将文本分割成比单词更小的标记,这使得模型更容易检测到“evolution”和“evolutionary”之间有一些共同之处(有关子词标记化的更多信息,请参阅第十六章)。
另一个选择是使用预训练的语言模型组件。现在让我们来看看这个。
使用预训练语言模型组件
TensorFlow Hub 库使得在您自己的模型中重用预训练模型组件变得容易,用于文本、图像、音频等。这些模型组件称为模块。只需浏览TF Hub 存储库,找到您需要的模块,将代码示例复制到您的项目中,模块将自动下载并捆绑到一个 Keras 层中,您可以直接包含在您的模型中。模块通常包含预处理代码和预训练权重,并且通常不需要额外的训练(但当然,您的模型的其余部分肯定需要训练)。
例如,一些强大的预训练语言模型是可用的。最强大的模型非常庞大(几个千兆字节),因此为了快速示例,让我们使用nnlm-en-dim50
模块,版本 2,这是一个相当基本的模块,它将原始文本作为输入并输出 50 维句子嵌入。我们将导入 TensorFlow Hub 并使用它来加载模块,然后使用该模块将两个句子编码为向量:
>>> import tensorflow_hub as hub >>> hub_layer = hub.KerasLayer("https://tfhub.dev/google/nnlm-en-dim50/2") >>> sentence_embeddings = hub_layer(tf.constant(["To be", "Not to be"])) >>> sentence_embeddings.numpy().round(2) array([[-0.25, 0.28, 0.01, 0.1 , [...] , 0.05, 0.31], [-0.2 , 0.2 , -0.08, 0.02, [...] , -0.04, 0.15]], dtype=float32)
hub.KerasLayer
层从给定的 URL 下载模块。这个特定的模块是一个句子编码器:它将字符串作为输入,并将每个字符串编码为单个向量(在本例中是一个 50 维向量)。在内部,它解析字符串(在空格上拆分单词)并使用在一个巨大的语料库上预训练的嵌入矩阵嵌入每个单词:Google News 7B 语料库(七十亿字长!)。然后计算所有单词嵌入的平均值,结果就是句子嵌入。
您只需要在您的模型中包含这个hub_layer
,然后就可以开始了。请注意,这个特定的语言模型是在英语上训练的,但许多其他语言也可用,以及多语言模型。
最后,由 Hugging Face 提供的优秀开源Transformers 库也使得在您自己的模型中包含强大的语言模型组件变得容易。您可以浏览Hugging Face Hub,选择您想要的模型,并使用提供的代码示例开始。它以前只包含语言模型,但现在已扩展到包括图像模型等。
我们将在第十六章中更深入地讨论自然语言处理。现在让我们看一下 Keras 的图像预处理层。
图像预处理层
Keras 预处理 API 包括三个图像预处理层:
tf.keras.layers.Resizing
将输入图像调整为所需大小。例如,Resizing(height=100, width=200)
将每个图像调整为 100×200,可能会扭曲图像。如果设置crop_to_aspect_ratio=True
,则图像将被裁剪到目标图像比例,以避免扭曲。tf.keras.layers.Rescaling
重新缩放像素值。例如,Rescaling(scale=2/255, offset=-1)
将值从 0 → 255 缩放到-1 → 1。tf.keras.layers.CenterCrop
裁剪图像,保留所需高度和宽度的中心区域。
例如,让我们加载一些示例图像并对它们进行中心裁剪。为此,我们将使用 Scikit-Learn 的load_sample_images()
函数;这将加载两个彩色图像,一个是中国寺庙的图像,另一个是花朵的图像(这需要 Pillow 库,如果您正在使用 Colab 或者按照安装说明进行操作,应该已经安装):
from sklearn.datasets import load_sample_images images = load_sample_images()["images"] crop_image_layer = tf.keras.layers.CenterCrop(height=100, width=100) cropped_images = crop_image_layer(images)
Keras 还包括几个用于数据增强的层,如RandomCrop
、RandomFlip
、RandomTranslation
、RandomRotation
、RandomZoom
、RandomHeight
、RandomWidth
和RandomContrast
。这些层仅在训练期间激活,并随机对输入图像应用一些转换(它们的名称是不言自明的)。数据增强将人为增加训练集的大小,通常会导致性能提升,只要转换后的图像看起来像真实的(非增强的)图像。我们将在下一章更详细地介绍图像处理。
注意
在幕后,Keras 预处理层基于 TensorFlow 的低级 API。例如,Normalization
层使用tf.nn.moments()
来计算均值和方差,Discretization
层使用tf.raw_ops.Bucketize()
,CategoricalEncoding
使用tf.math.bincount()
,IntegerLookup
和StringLookup
使用tf.lookup
包,Hashing
和TextVectorization
使用tf.strings
包中的几个操作,Embedding
使用tf.nn.embedding_lookup()
,图像预处理层使用tf.image
包中的操作。如果 Keras 预处理 API 不满足您的需求,您可能偶尔需要直接使用 TensorFlow 的低级 API。
现在让我们看看在 TensorFlow 中另一种轻松高效地加载数据的方法。
TensorFlow 数据集项目
TensorFlow 数据集(TFDS)项目使加载常见数据集变得非常容易,从小型数据集如 MNIST 或 Fashion MNIST 到像 ImageNet 这样的大型数据集(您将需要相当大的磁盘空间!)。列表包括图像数据集、文本数据集(包括翻译数据集)、音频和视频数据集、时间序列等等。您可以访问https://homl.info/tfds查看完整列表,以及每个数据集的描述。您还可以查看了解您的数据,这是一个用于探索和理解 TFDS 提供的许多数据集的工具。
TFDS 并未与 TensorFlow 捆绑在一起,但如果您在 Colab 上运行或者按照https://homl.info/install的安装说明进行安装,那么它已经安装好了。然后您可以导入tensorflow_datasets
,通常为tfds
,然后调用tfds.load()
函数,它将下载您想要的数据(除非之前已经下载过),并将数据作为数据集字典返回(通常一个用于训练,一个用于测试,但这取决于您选择的数据集)。例如,让我们下载 MNIST:
import tensorflow_datasets as tfds datasets = tfds.load(name="mnist") mnist_train, mnist_test = datasets["train"], datasets["test"]
然后您可以应用任何您想要的转换(通常是洗牌、批处理和预取),然后准备训练您的模型。这里是一个简单的示例:
for batch in mnist_train.shuffle(10_000, seed=42).batch(32).prefetch(1): images = batch["image"] labels = batch["label"] # [...] do something with the images and labels
提示
load()
函数可以对其下载的文件进行洗牌:只需设置shuffle_files=True
。但是这可能不够,最好对训练数据进行更多的洗牌。
请注意,数据集中的每个项目都是一个包含特征和标签的字典。但是 Keras 期望每个项目是一个包含两个元素的元组(再次,特征和标签)。您可以使用map()
方法转换数据集,就像这样:
mnist_train = mnist_train.shuffle(buffer_size=10_000, seed=42).batch(32) mnist_train = mnist_train.map(lambda items: (items["image"], items["label"])) mnist_train = mnist_train.prefetch(1)
但是通过设置as_supervised=True
,让load()
函数为您执行此操作会更简单(显然,这仅适用于带标签的数据集)。
最后,TFDS 提供了一种方便的方法来使用split
参数拆分数据。例如,如果您想要使用训练集的前 90%进行训练,剩余的 10%进行验证,整个测试集进行测试,那么您可以设置split=["train[:90%]", "train[90%:]", "test"]
。load()
函数将返回所有三个集合。这里是一个完整的示例,使用 TFDS 加载和拆分 MNIST 数据集,然后使用这些集合来训练和评估一个简单的 Keras 模型:
train_set, valid_set, test_set = tfds.load( name="mnist", split=["train[:90%]", "train[90%:]", "test"], as_supervised=True ) train_set = train_set.shuffle(buffer_size=10_000, seed=42).batch(32).prefetch(1) valid_set = valid_set.batch(32).cache() test_set = test_set.batch(32).cache() tf.random.set_seed(42) model = tf.keras.Sequential([ tf.keras.layers.Flatten(input_shape=(28, 28)), tf.keras.layers.Dense(10, activation="softmax") ]) model.compile(loss="sparse_categorical_crossentropy", optimizer="nadam", metrics=["accuracy"]) history = model.fit(train_set, validation_data=valid_set, epochs=5) test_loss, test_accuracy = model.evaluate(test_set)
恭喜,您已经到达了这个相当技术性的章节的结尾!您可能会觉得它与神经网络的抽象美有些远,但事实是深度学习通常涉及大量数据,知道如何高效加载、解析和预处理数据是一项至关重要的技能。在下一章中,我们将看一下卷积神经网络,这是图像处理和许多其他应用中最成功的神经网络架构之一。
练习
- 为什么要使用 tf.data API?
- 将大型数据集拆分为多个文件的好处是什么?
- 在训练过程中,如何判断您的输入管道是瓶颈?您可以做些什么来解决它?
- 您可以将任何二进制数据保存到 TFRecord 文件中吗,还是只能序列化协议缓冲区?
- 为什么要费心将所有数据转换为
Example
协议缓冲区格式?为什么不使用自己的协议缓冲区定义? - 在使用 TFRecords 时,何时应该激活压缩?为什么不系统地这样做?
- 数据可以在编写数据文件时直接进行预处理,或者在 tf.data 管道中进行,或者在模型内的预处理层中进行。您能列出每个选项的一些优缺点吗?
- 列举一些常见的编码分类整数特征的方法。文本呢?
- 加载时尚 MNIST 数据集(在第十章中介绍);将其分为训练集、验证集和测试集;对训练集进行洗牌;并将每个数据集保存到多个 TFRecord 文件中。每个记录应该是一个序列化的
Example
协议缓冲区,具有两个特征:序列化图像(使用tf.io.serialize_tensor()
来序列化每个图像),和标签。然后使用 tf.data 为每个集创建一个高效的数据集。最后,使用 Keras 模型来训练这些数据集,包括一个预处理层来标准化每个输入特征。尝试使输入管道尽可能高效,使用 TensorBoard 来可视化分析数据。 - 在这个练习中,您将下载一个数据集,将其拆分,创建一个
tf.data.Dataset
来高效加载和预处理数据,然后构建和训练一个包含Embedding
层的二元分类模型:
- 下载大型电影评论数据集,其中包含来自互联网电影数据库(IMDb)的 50,000 条电影评论。数据组织在两个目录中,train和test,每个目录包含一个pos子目录,其中包含 12,500 条正面评论,以及一个neg子目录,其中包含 12,500 条负面评论。每个评论存储在单独的文本文件中。还有其他文件和文件夹(包括预处理的词袋版本),但在这个练习中我们将忽略它们。
- 将测试集分为验证集(15,000)和测试集(10,000)。
- 使用 tf.data 为每个集创建一个高效的数据集。
- 创建一个二元分类模型,使用
TextVectorization
层来预处理每个评论。 - 添加一个
Embedding
层,并计算每个评论的平均嵌入,乘以单词数量的平方根(参见第十六章)。然后将这个重新缩放的平均嵌入传递给您模型的其余部分。 - 训练模型并查看您获得的准确性。尝试优化您的管道,使训练尽可能快。
- 使用 TFDS 更轻松地加载相同的数据集:
tfds.load("imdb_reviews")
。
这些练习的解决方案可以在本章笔记本的末尾找到,网址为https://homl.info/colab3。
¹ 想象一副排好序的扑克牌在您的左边:假设您只拿出前三张牌并洗牌,然后随机选取一张放在右边,将另外两张留在手中。再从左边拿一张牌,在手中的三张牌中洗牌,随机选取一张放在右边。当您像这样处理完所有的牌后,您的右边将有一副扑克牌:您认为它会被完美洗牌吗?
² 一般来说,只预取一个批次就可以了,但在某些情况下,您可能需要预取更多。或者,您可以通过将tf.data.AUTOTUNE
传递给prefetch()
,让 TensorFlow 自动决定。
³ 但是请查看实验性的tf.data.experimental.prefetch_to_device()
函数,它可以直接将数据预取到 GPU。任何带有experimental
的 TensorFlow 函数或类的名称可能会在未来版本中发生更改而没有警告。如果实验性函数失败,请尝试删除experimental
一词:它可能已经移至核心 API。如果没有,请查看笔记本,我会确保其中包含最新的代码。
⁴ 由于 protobuf 对象旨在被序列化和传输,它们被称为消息。
⁵ 本章包含了您使用 TFRecords 所需了解的最基本知识。要了解更多关于 protobufs 的信息,请访问https://homl.info/protobuf。
⁶ Tomáš Mikolov 等人,“单词和短语的分布式表示及其组合性”,第 26 届国际神经信息处理系统会议论文集 2(2013):3111–3119。
⁷ Malvina Nissim 等人,“公平比耸人听闻更好:男人对医生,女人对医生”,arXiv 预印本 arXiv:1905.09866(2019)。
⁸ TensorFlow Hub 没有与 TensorFlow 捆绑在一起,但如果您在 Colab 上运行或者按照https://homl.info/install的安装说明进行安装,那么它已经安装好了。
⁹ 要精确,句子嵌入等于句子中单词嵌入的平均值乘以句子中单词数的平方根。这是为了弥补随着n增长,n个随机向量的平均值会变短的事实。
¹⁰ 对于大图像,您可以使用tf.io.encode_jpeg()
。这将节省大量空间,但会损失一些图像质量。