TensorFlow 实战(六)(2)https://developer.aliyun.com/article/1522933
最后,我们创建训练数据集,该数据集将保存在创建测试和验证数据集后剩余的所有元素:
train_inds = [i for i in range(rest_x.shape[0]) if i not in valid_inds] train_x, train_y = rest_x[train_inds], rest_y[train_inds]
我们还必须确保训练数据集是平衡的。为了做到这一点,让我们使用比随机选择元素更智能的方式来欠采样数据。我们将在这里使用的欠采样算法称为 near-miss 算法。near-miss 算法会删除与少数类别中的样本太接近的多数类别中的样本。这有助于增加少数类别和多数类别示例之间的距离。在这里,少数类别指的是数据较少的类别,而多数类别指的是数据较多的类别。为了使用 near-miss 算法,它需要能够计算两个样本之间的距离。因此,我们需要将我们的文本转换为一些数值表示。我们将使用 scikit-learn 的 CountVectorizer 来实现这一点:
from sklearn.feature_extraction.text import CountVectorizer countvec = CountVectorizer() train_bow = countvec.fit_transform(train_x.reshape(-1).tolist())
train_bow 将包含我们数据的词袋表示。然后我们可以将其传递给 NearMiss 实例。获取数据的方式与之前相同:
from imblearn.under_sampling import NearMiss oss = NearMiss() x_res, y_res = oss.fit_resample(train_bow, train_y) train_inds = oss.sample_indices_ train_x, train_y = train_x[train_inds], train_y[train_inds]
让我们打印出我们数据集的大小,看看它们是否与我们最初想要的大小相匹配:
Test dataset size 1 100 0 100 dtype: int64 Valid dataset size 1 100 0 100 dtype: int64 Train dataset size 1 547 0 547 dtype: int64
太棒了!我们的数据集都是平衡的,我们准备继续进行工作流的其余部分。
定义模型
准备好数据后,我们将下载模型。我们将使用的 BERT 模型来自 TensorFlow hub (www.tensorflow.org/hub
)。TensorFlow hub 是各种模型训练任务的模型仓库。您可以获得多种任务的模型,包括图像分类、对象检测、语言建模、问答等等。要查看所有可用模型的完整列表,请访问 tfhub.dev/
。
为了成功地利用 BERT 来完成自然语言处理任务,我们需要三个重要的工作:
- 分词器 —— 确定如何将提供的输入序列分割为标记
- 编码器 —— 接受标记,计算数值表示,并最终为每个标记生成隐藏表示以及汇总表示(整个序列的单一表示)
- 分类头 —— 接受汇总表示并为输入生成标签
首先,让我们来看一下分词器。分词器接受单个输入字符串或字符串列表,并将其转换为字符串列表或字符串列表,分别将其拆分为较小的元素。例如,可以通过在空格字符上分割来将句子分割为单词。BERT 中的分词器使用一种称为 WordPiece 算法的算法 (mng.bz/z40B
)。它使用迭代方法找到数据集中常见的子词(即单词的部分)。WordPiece 算法的细节超出了本书的范围。欢迎查阅原始论文以了解更多细节。对于本讨论而言,分词器的最重要特征是它将给定的输入字符串(例如,句子)分解为较小标记的列表(例如,子词)。
像 WordPiece 算法这样的子词方法的优点
像 WordPiece 算法这样的子词方法学习单词的较小且常见的部分,并使用它们来定义词汇表。与将整个单词放入词汇表相比,此方法有两个主要优点。
使用子词通常可以减少词汇量的大小。假设单词为[“walk”, “act”, “walked”, “acted”, “walking”, “acting”]。如果使用单独的单词,每个单词都需要在词汇表中成为一个单一项目。然而,如果使用子词方法,词汇表可以缩减为[“walk”, “act”, “##ed”, “##ing”],只有四个单词。在这里,##表示它需要添加另一个子词作为前缀。
第二,子词方法可以处理词汇表中未出现的单词。这意味着子词方法可以通过组合来自词汇表的两个子词(例如,developed = develop + ##ed)来表示出现在测试数据集中但未出现在训练集中的单词。基于单词的方法将无法这样做,而只能使用特殊标记替换看不见的单词。假设子词表[“walk”, “act”, “##ed”, “##ing”, “develop”]。即使训练数据中没有出现“developed”或“developing”这些单词,词汇表仍然可以通过组合来自词汇表的两个子词来表示这些单词。
要设置标记器,让我们首先导入 tf-models-official 库:
import tensorflow_models as tfm
然后,您可以按以下方式定义标记器:
vocab_file = os.path.join("data", "vocab.txt") do_lower_case = True tokenizer = tfm.nlp.layers.FastWordpieceBertTokenizer( vocab_file=vocab_file, lower_case=do_lower_case )
在这里,你首先需要获取词汇文件的位置,并定义一些配置,比如在对文本进行标记化之前是否应该将其转换为小写。词汇文件是一个文本文件,每行都有一个子词。这个标记器使用了 Fast WordPiece Tokenization(arxiv.org/abs/2012.15524.pdf
),这是原始 WordPiece 算法的高效实现。请注意,vocab_file 和 do_lower_case 是我们将在下一步从 TensorFlow hub 下载的模型工件中找到的设置。但为了方便理解,我们在这里将它们定义为常量。您将在笔记本中找到自动提取它们的代码。接下来,我们可以按照以下方式使用标记器:
tokens = tf.reshape( tokenizer(["She sells seashells by the seashore"]), [-1]) print("Tokens IDs generated by BERT: {}".format(tokens)) ids = [tokenizer._vocab[tid] for tid in tokens] print("Tokens generated by BERT: {}".format(ids))
它返回
Tokens IDs generated by BERT: [ 2016 15187 11915 18223 2015 2011 1996 11915 16892] Tokens generated by BERT: ['she', 'sells', 'seas', '##hell', '##s', 'by', 'the', 'seas', '##hore']
您可以在这里看到 BERT 的标记器如何标记句子。有些单词保持原样,而有些单词则分成子词(例如,seas + ##hell + ##s)。如前所述,##表示它不标记一个单词的开头。换句话说,##表示这个子词需要添加另一个子词作为前缀才能得到一个实际的单词。现在,让我们来看看 BERT 模型使用的特殊标记以及为它们分配的 ID 是什么。这也验证了这些标记存在于标记器中:
special_tokens = ['[CLS]', '[SEP]', '[MASK]', '[PAD]'] ids = [tokenizer._vocab.index(tok) for tok in special_tokens] for t, i in zip(special_tokens, ids): print("Token: {} has ID: {}".format(t, i))
这将返回
Token: [CLS] has ID: 101 Token: [SEP] has ID: 102 Token: [MASK] has ID: 103 Token: [PAD] has ID: 0
在这里,[PAD]是 BERT 用来表示填充令牌(0)的另一个特殊标记。在 NLP 中,常常使用填充来将不同长度的句子填充到相同的长度,填充的句子是用零填充的。在这里,[PAD]标记对应了零。
了解了标记器的基本功能后,我们可以定义一个名为 encode_sentence()的函数,将给定的句子编码为 BERT 模型所理解的输入(请参见下一个清单)。
清单 13.2 使用 BERT 的标记器对给定的输入字符串进行编码
def encode_sentence(s): """ Encode a given sentence by tokenizing it and adding special tokens """ tokens = list( tf.reshape(tokenizer(["CLS" + s + "[SEP]"]), [-1]) ) ❶ return tokens ❷
❶ 将特殊的 [CLS] 和 [SEP] 标记添加到序列中并获取标记 ID。
❷ 返回标记 ID。
在这个函数中,我们返回标记化的输出,首先添加 [CLS] 标记,然后将给定的字符串标记化为子词列表,最后添加 [SEP] 标记来标记句子/序列的结束。例如,句子 “I like ice cream”
encode_sentence("I like ice cream")
将返回
[101, 1045, 2066, 3256, 6949, 102]
如我们所见,标记 ID 101(即,[CLS])在开头,而 102(即,[SEP])在结尾。其余的标记 ID 对应于我们输入的实际字符串。仅仅为 BERT 标记化输入是不够的;我们还必须为模型定义一些额外的输入。例如,句子
"I like ice cream"
应该返回一个如下所示的数据结构:
{ 'input_word_ids': [[ 101, 1045, 2066, 3256, 6949, 102, 0, 0]], 'input_mask': [[1., 1., 1., 1., 1., 1., 0., 0.]], 'input_type_ids': [[0, 0, 0, 0, 0, 0, 0, 0]] }
让我们讨论这个数据结构中的各种元素。BERT 以字典形式接受输入,其中
- 关键 input_ids 表示从之前定义的 encode_sentence 函数中获得的标记 ID
- 关键 input_mask 表示一个与 input_ids 相同大小的掩码,其中 1 表示不应屏蔽的值(例如,输入序列中的实际标记和特殊标记,如 [CLS] 标记 ID 101 和 [SEP] 标记 ID 102),而 0 表示应屏蔽的标记(例如,[PAD] 标记 ID 0)。
- 输入关键 input_type_ids 是一个大小与 input_ids 相同的由 1 和 0 组成的矩阵/向量。这表示每个标记属于哪个句子。请记住,BERT 可以接受两种类型的输入:具有一个序列的输入和具有两个序列 A 和 B 的输入。input_type_ids 矩阵表示每个标记属于哪个序列(A 或 B)。由于我们的输入中只有一个序列,我们简单地创建一个大小与 input_ids 相同的零矩阵。
函数 get_bert_inputs() 将使用一组文档(即,一个字符串列表,其中每个字符串是一个输入;请参见下一个清单)以这种格式生成输入。
列表 13.3 将给定输入格式化为 BERT 接受的格式
def get_bert_inputs(tokenizer, docs,max_seq_len=None): """ Generate inputs for BERT using a set of documents """ packer = tfm.nlp.layers.BertPackInputs( ❶ seq_length=max_seq_length, special_tokens_dict = tokenizer.get_special_tokens_dict() ) packed = packer(tokenizer(docs)) ❷ packed_numpy = dict( [(k, v.numpy()) for k,v in packed.items()] ❸ ) # Final output return packed_numpy
❶ 使用 BertPackInputs 生成标记 ID、掩码和段 ID。
❷ 为 docs 中的所有消息生成输出。
❸ 将 BertPackInputs 的输出转换为一个键为字符串、值为 numpy 数组的字典。
❹ 返回结果。
这里我们使用 BertPackInputs 对象,它接受一个数组,其中每个项目都是包含消息的字符串。然后 BertPackInputs 生成一个包含以下处理过的输出的字典:
- input_word_ids ——带有 [CLS] 和 [SEP] 标记 ID 的标记 ID,会自动添加。
- input_mask ——一个整数数组,其中每个元素表示该位置是真实标记(1)还是填充标记(0)。
- input_type_ids ——一个整数数组,其中每个元素表示每个标记属于哪个段。在这种情况下,它将是一个全零数组。
BertPackInputs 执行了 BERT 模型需要的许多不同的预处理操作。您可以在 www.tensorflow.org/api_docs/python/tfm/nlp/layers/BertPackInputs
上阅读关于此层接受的各种输入的信息。
要为模型生成准备好的训练、验证和测试数据,只需调用 get_bert_inputs() 函数:
train_inputs = get_bert_inputs(train_x, max_seq_len=80) valid_inputs = get_bert_inputs(valid_x, max_seq_len=80) test_inputs = get_bert_inputs(test_x, max_seq_len=80)
完成后,让我们作为预防措施对 train_inputs 中的数据进行洗牌。目前,数据是有序的,即垃圾邮件消息在正常邮件消息之后:
train_inds = np.random.permutation(len(train_inputs["input_word_ids"])) train_inputs = dict( [(k, v[train_inds]) for k, v in train_inputs.items()] ) train_y = train_y[train_inds]
记得对输入和标签都使用相同的洗牌方式来保持它们的关联。我们已经做好了为模型准备输入的一切。现在是揭晓模型的大时刻了。我们需要定义一个具有分类头的 BERT,以便模型可以在我们的分类数据集上进行端到端的训练。我们将分两步来完成这个过程。首先,我们将从 TensorFlow hub 下载 BERT 的编码器部分,然后使用 tensorflow-models-official 库中的 tfm.nlp.models.BertClassifier 对象来生成最终的带有分类器头的 BERT 模型。让我们来看看我们如何完成第一部分:
import tensorflow_hub as hub hub_bert_url = "https://tfhub.dev/tensorflow/bert_en_uncased_L-12_H-768_A-12/4" max_seq_length = 60 # Contains input token ids input_word_ids = tf.keras.layers.Input( shape=(max_seq_length,), dtype=tf.int32, name="input_word_ids" ) # Contains input mask values input_mask = tf.keras.layers.Input( shape=(max_seq_length,), dtype=tf.int32, name="input_mask" ) input_type_ids = tf.keras.layers.Input( shape=(max_seq_length,), dtype=tf.int32, name="input_type_ids" ) # BERT encoder downloaded from TF hub bert_layer = hub.KerasLayer(hub_bert_url, trainable=True) # get the output of the encoder output = bert_layer({ "input_word_ids":input_word_ids, "input_mask": input_mask, "input_type_ids": input_type_ids }) # Define the final encoder as with the Functional API hub_encoder = tf.keras.models.Model( inputs={ "input_word_ids": input_word_ids, "input_mask": input_mask, "input_type_ids": input_type_ids }, outputs={ "sequence_output": output["sequence_output"], "pooled_output": output["pooled_output"] } )
在这里,我们首先定义了三个输入层,每个输入层都映射到 BertPackInputs 的一个输出。例如,input_word_ids 输入层将接收到在 get_bert_inputs() 函数生成的字典中键为 input_word_ids 的输出。接下来,我们通过向 hub.KerasLayer 对象传递一个 URL 来下载预训练的 BERT 编码器。这个层会生成两个输出:sequence_output,其中包含所有时间步长的隐藏表示,和 pooled_output,其中包含与 [CLS] 标记位置对应的隐藏表示。对于这个问题,我们需要后者,将其传递给位于编码器顶部的分类头。最后,我们将使用 Functional API 定义一个 Keras 模型。这个模型需要通过字典定义特定的输入和输出签名,如之前所示。我们将使用这个模型来定义一个基于这个编码器的分类器模型:
# Generating a classifier and the encoder bert_classifier = tfm.nlp.models.BertClassifier( network=hub_encoder, num_classes=2 )
正如您所见,定义分类器非常简单。我们只需将我们的 hub_encoder 传递给 BertClassifier,并声明我们有两个类别,即垃圾邮件和正常邮件(即 num_classes=2)。
获取 BERT 编码器的另一种方式
还有另一种方法可以获得一个 BERT 编码器。然而,它需要手动加载预训练的权重;因此,我们将保留此方法作为另一种选择。首先,你需要使用包含编码器模型各种超参数的配置文件。我已经为你提供了用于该模型的原始配置的 YAML 文件(Ch12/data/bert_en_uncased_base.yaml)。它包含了 BERT 使用的各种超参数(例如,隐藏维度大小,非线性激活等)。请随意查看它们,以了解用于该模型的不同参数。我们将使用 yaml 库将这些配置加载为字典,并将其存储在 config_dict 中。接下来,我们生成 encoder_config,一个使用我们加载的配置初始化的 EncoderConfig 对象。定义了 encoder_config 后,我们将构建一个 BERT 编码器模型,该模型能够生成标记的特征表示,然后使用此编码器作为网络调用 bert.bert_models.classifier_model()。请注意,此方法得到一个随机初始化的 BERT 模型:
import yaml with open(os.path.join("data", "bert_en_uncased_base.yaml"), 'r') as stream: config_dict = yaml.safe_load(stream)['task']['model']['encoder']['bert'] encoder_config = tfm.nlp.encoders.EncoderConfig({ 'type':'bert', 'bert': config_dict }) bert_encoder = tfm.nlp.encoders.build_encoder(encoder_config) bert_classifier = tfm.nlp.models.BertClassifier( network=bert_encoder, num_classes=2 )
如果你想要一个类似这样的预训练版本的 BERT,那么你需要下载 TensorFlow checkpoint。你可以通过转到 bert_url 中的链接,然后点击下载来完成这个过程。最后,你使用以下命令加载权重:
checkpoint = tf.train.Checkpoint(encoder=bert_encoder) checkpoint.read(<path to .ckpt>).assert_consumed()
现在你有了一个预训练的 BERT 编码器。
接下来,让我们讨论如何编译构建好的模型。
编译模型
在这里,我们将定义优化器来训练模型。到目前为止,我们在 TensorFlow/Keras 中提供的默认优化器选项中没有太多变化。这次,让我们使用在 tf-models-official 库中提供的优化器。优化器可以通过调用 nlp.optimization.create_optimizer() 函数进行实例化。这在下一列表中有概述。
列表 13.4 在垃圾邮件分类任务上优化 BERT
epochs = 3 batch_size = 56 eval_batch_size = 56 train_data_size = train_x.shape[0] steps_per_epoch = int(train_data_size / batch_size) num_train_steps = steps_per_epoch * epochs warmup_steps = int(num_train_steps * 0.1) init_lr = 3e-6 end_lr = 0.0 linear_decay = tf.keras.optimizers.schedules.PolynomialDecay( initial_learning_rate=init_lr, end_learning_rate=end_lr, decay_steps=num_train_steps) warmup_schedule = tfm.optimization.lr_schedule.LinearWarmup( warmup_learning_rate = 1e-10, after_warmup_lr_sched = linear_decay, warmup_steps = warmup_steps ) optimizer = tf.keras.optimizers.experimental.Adam( learning_rate = warmup_schedule )
作为默认优化器,带权重衰减的 Adam(arxiv.org/pdf/1711.05101.pdf
)被使用。带权重衰减的 Adam 是原始 Adam 优化器的一个变体,但具有更好的泛化性质。num_warmup_steps 表示学习率预热的持续时间。在预热期间,学习率在 num_warmup_steps 内线性增加,从一个小值线性增加到 init_lr(在 linear_decay 中定义)。之后,在 num_train_steps 期间,学习率使用多项式衰减(mng.bz/06lN
)从 init_lr (在 linear_decay 中定义)衰减到 end_lr。这在图 13.9 中有所描述。
图 13.9 随着训练逐步进行(即迭代步数),学习率的行为
现在我们可以像以前一样编译模型了。我们将定义一个损失(稀疏分类交叉熵损失)和一个指标(使用标签而不是 one-hot 向量计算的准确度),然后将优化器、损失和指标传递给 hub_classifier.compile() 函数:
metrics = [tf.keras.metrics.SparseCategoricalAccuracy('accuracy', ➥ dtype=tf.float32)] loss = tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True) hub_classifier.compile( optimizer=optimizer, loss=loss, metrics=metrics)
训练模型
我们已经走了很长的路,现在剩下的就是训练模型。模型训练非常简单,类似于我们使用 tf.keras.Model.fit() 函数训练模型的方式:
hub_classifier.fit( x=train_inputs, y=train_y, validation_data=(valid_inputs, valid_y), validation_batch_size=eval_batch_size, batch_size=batch_size, epochs=epochs)
我们将使用 get_bert_inputs() 函数准备的 train_inputs 传递给参数 x,将 train_y(即一个由 1 和 0 组成的向量,分别表示输入是垃圾邮件还是正常邮件)传递给 y。类似地,我们将 validation_data 定义为一个包含 valid_inputs 和 valid_y 的元组。我们还传入了 batch_size(训练批量大小)、validation_batch_size 和要训练的 epochs 数量。
评估和解释结果
当你运行训练时,你应该得到接近以下结果。这里你可以看到训练损失和准确率,以及验证损失和准确率:
Epoch 1/3 18/18 [==============================] - 544s 29s/step - loss: 0.7082 - ➥ accuracy: 0.4555 - val_loss: 0.6764 - val_accuracy: 0.5150 Epoch 2/3 18/18 [==============================] - 518s 29s/step - loss: 0.6645 - ➥ accuracy: 0.6589 - val_loss: 0.6480 - val_accuracy: 0.8150 Epoch 3/3 18/18 [==============================] - 518s 29s/step - loss: 0.6414 - ➥ accuracy: 0.7608 - val_loss: 0.6391 - val_accuracy: 0.8550
控制台输出清楚地显示了训练损失稳步下降,而准确率从 45% 上升到了 76%。模型已达到 85% 的验证准确率。这清楚地展示了像 BERT 这样的模型的强大之处。如果你要从头开始训练一个 NLP 模型,在这么短的时间内达到 85% 的验证准确率是不可能的。由于 BERT 具有非常强的语言理解能力,模型可以专注于学习手头的任务。请注意,由于我们的验证和测试集非常小(每个仅有 200 条记录),你可能会得到与此处所示不同水平的准确率。
注意 在一台配有 NVIDIA GeForce RTX 2070 8 GB 的 Intel Core i5 机器上,训练大约需要 40 秒来运行 3 个 epochs。
最后,让我们通过调用 evaluate() 函数在测试数据上测试模型。
hub_classifier.evaluate(test_inputs, test_y)
这将返回
7/7 [==============================] - 22s 3s/step - loss: 0.6432 - accuracy: 0.7950
再次,这是一个很棒的结果。仅经过三个 epochs,没有进行任何繁琐的参数调整,我们在测试数据上达到了 79.5% 的准确率。我们所做的一切就是在 BERT 之上拟合了一个逻辑回归层。
下一节将讨论我们如何定义一个能够从段落中找到答案的模型。为此,我们将使用到目前为止最流行的 Transformer 模型库之一:Hugging Face 的 transformers 库。
练习 2
你有一个包含五个类别的分类问题,并且想要修改 bert_classifier。给定正确格式的数据,你将如何更改所定义的 bert_classifier 对象?
TensorFlow 实战(六)(4)https://developer.aliyun.com/article/1522937