https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEg28U92sbbmNuB22urK20gEbgfIjigx8hKJId5MkTx5tkyPQVdGtoq2TLPe90DympPMTo1buPvmejoeT9extktPglEZ0FXNlKPnpn21BR8yhM3C7ZLJgtxT8lVcvQM86kfkkAhXhXD6h0A/s1600/figure1.gif
客座作者 Hannes Hapke,SAP Concur Labs 高级数据科学家。由 Robert Crowe 代表 TFX 团队编辑
Transformer 模型和自然语言处理中的迁移学习概念为情感分析、实体提取和问答问题等任务开辟了新的机会。
BERT 模型允许数据科学家站在巨人的肩膀上。在大型语料库上预先训练后,数据科学家可以使用这些多用途的训练过的 Transformer 模型应用迁移学习,并为其特定领域的问题取得最先进的结果。
在
我们博客文章的第一部分中,我们讨论了为什么 BERT 模型的当前部署感觉过于复杂和繁琐,以及如何通过 TensorFlow 生态系统的库和扩展来简化部署。如果您还没有查看
这篇文章,我们建议您将其作为本文实现讨论的入门读物。
在
SAP Concur Labs,我们着眼于简化我们的 BERT 部署,我们发现 TensorFlow 生态系统提供了实现简单和简洁的 Transformer 部署的完美工具。在这篇博客文章中,我们想带您深入了解我们的实现,以及我们如何使用 TensorFlow 生态系统的组件来实现可扩展、高效和快速的 BERT 部署。
想跳到代码吗?
如果您想跳到完整的示例,
查看 Colab 笔记本。它展示了我们用于生成可部署 BERT 模型的整个 TensorFlow Extended (TFX) 管道,其中预处理步骤作为模型图的一部分。如果您想尝试我们的演示部署,请查看我们的
SAP ConcurLabs 演示页面,它展示了我们的情感分类项目。
为什么要使用 Tensorflow Transform 进行预处理?
在我们回答这个问题之前,让我们快速了解一下 BERT Transformer 的工作原理以及 BERT 目前是如何部署的。
BERT 需要进行哪些预处理?
像 BERT 这样的 Transformer 最初是针对两个主要任务进行训练的:掩码语言模型和下一句预测 (NSP)。这些任务需要超出原始输入文本的输入数据结构。因此,BERT 模型除了需要分词后的输入文本外,还需要一个张量
input_type_ids
来区分不同的句子。第二个张量
input_mask
用于记录
input_word_ids
张量中的相关词元。这是必需的,因为我们将使用填充词元扩展我们的
input_word_ids
张量以达到最大序列长度。这样,所有
input_word_ids
张量将具有相同的长度,但 Transformer 可以区分相关词元(来自我们输入句子的词元)和不相关的填充词元(填充词元)。
|
图 1:BERT 分词 |
目前,在大多数 Transformer 模型部署中,输入文本的分词和转换是在客户端完成,或者作为实际模型预测之外的预处理步骤在服务器端完成。
这带来了一些复杂性:如果预处理在客户端完成,那么如果词元和 ID 之间的映射发生变化(例如,当我们想要添加新词元时),所有客户端都需要更新。大多数使用服务器端预处理的部署使用基于 Flask 的 Web 应用程序来接受模型预测的客户端请求,对输入句子进行分词和转换,然后将数据结构提交到深度学习模型。不得不维护两个“系统”(一个用于预处理,一个用于实际模型推理)不仅繁琐且容易出错,而且难以扩展。
|
图 2:当前的 BERT 部署 |
如果我们能够获得两种解决方案的优点:易于扩展和简单的升级性,那就太好了。使用 TensorFlow Transform (TFT),我们可以通过将预处理步骤构建为图形、将它们与深度学习模型一起导出,并最终只部署一个“系统”(我们结合了深度学习模型和集成的预处理功能)来实现这两个要求。值得注意的是,当我们想要针对特定领域的任务微调 BERT 的 tf.hub 模块时,将 BERT 的所有内容都移动到预处理中并不是一种选择。
|
图 3:带 TFX 的 BERT |
使用 tf.text 处理自然语言
在 2019 年,TensorFlow 团队发布了一种新的张量类型:
RaggedTensors,它允许在张量中存储不同长度的数组。RaggedTensors 的实现对于 NLP 应用程序非常有用,例如,当我们要将 1-D 句子数组分词为具有不同数组长度的 2-D RaggedTensor 时。
分词前
[
“Clara is playing the piano.”
“Maria likes to play soccer.’”
“Hi Tom!”
]
分词后
[
[[b'clara'], [b'is'], [b'playing'], [b'the'], [b'piano'], [b'.']],
[[b'maria'], [b'likes'], [b'to'], [b'play'], [b'soccer'], [b'.']],
[[b'hi'], [b'tom'], [b'!']]
]
正如我们将在后面看到的那样,我们在预处理管道中使用 RaggedTensors。在 2019 年 10 月下旬,TensorFlow 团队发布了对
tf.text 模块的更新,它允许进行
词片分词,这是 BERT 模型输入预处理所必需的。
import tensorflow_text as text
vocab_file_path = bert_layer.resolved_object.vocab_file.asset_path.numpy()
do_lower_case = bert_layer.resolved_object.do_lower_case.numpy()
bert_tokenizer = text.BertTokenizer(
vocab_lookup_table=vocab_file_path,
token_out_type=tf.int64,
lower_case=do_lower_case)
TFText 提供了一个专门用于 BERT 模型所需的词片分词(
BertTokenizer
)的综合分词器。分词器提供字符串形式的分词结果(tf.string)或已转换为词元 ID(tf.int32)的结果。
注意:tf.text 版本需要与导入的 TensorFlow 版本匹配。如果您使用 TensorFlow 2.2.x,则需要安装 TensorFlow Text 版本 2.2.x,而不是 2.1.x 或 2.0.x。
我们如何使用 TensorFlow Transform 预处理文本?
之前,我们讨论过我们需要将任何输入文本转换为 Transformer 模型所需的
input_word_ids、input_mask 和 input_type_ids
数据结构。我们可以使用 TensorFlow Transform 进行转换。让我们仔细看看。
对于我们的示例模型,我们想使用 BERT 模型对
IMDB 评论的情感进行分类。
‘This is the best movie I have ever seen ...’ -> 1
‘Probably the worst movie produced in 2019 ...’ -> 0
‘Tom Hank\’s performance turns this movie into ...’ -> ?
这意味着我们每次预测只输入一个句子。实际上,这意味着所有提交的词元与预测相关(由一个向量表示),并且所有词元都属于句子 A(由一个零向量表示)。在我们进行分类的情况下,我们不会提交任何句子 B。
如果您想将 BERT 模型用于其他任务,例如预测两个句子的相似性、实体提取或问答任务,则需要调整预处理步骤。
由于我们想要将预处理步骤导出为图形,因此我们需要专门对所有预处理步骤使用 TensorFlow 操作。由于此要求,我们无法重用在 CPython 中实现的 Python 标准库的函数。
TFText 提供的 BertTokenizer 处理来自传入的原始文本数据的预处理。无需将字符串转换为小写(如果您使用的是未区分大小写的 BERT 模型)或删除不受支持的字符。TFText 库中的分词器需要一个支持词元的表格作为输入。词元可以作为 TensorFlow LookupTable 提供,或者简单地作为词汇表文件的路径提供。来自 TFHub 的 BERT 模型提供了这样一个文件,我们可以使用以下方法确定文件路径:
import tensorflow_hub as hub
BERT_TFHUB_URL = "https://tfhub.dev/tensorflow/bert_en_uncased_L-12_H-768_A-12/2"
bert_layer = hub.KerasLayer(handle=BERT_TFHUB_URL, trainable=True)
vocab_file_path =
bert_layer.resolved_object.vocab_file.asset_path.numpy()
类似地,我们可以确定加载的 BERT 模型是否区分大小写。
do_lower_case = bert_layer.resolved_object.do_lower_case.numpy()
现在,我们可以将这两个参数传递给我们的 TFText BertTokenizer 并指定词元的类型。由于我们将分词后的字符串传递给 BERT 模型,因此我们需要提供词元作为词元索引(以 int64 整数形式提供)
bert_tokenizer = text.BertTokenizer(
vocab_lookup_table=vocab_file_path,
token_out_type=tf.int64,
lower_case=do_lower_case
)
实例化 BertTokenizer 后,我们可以使用 tokenize 方法进行分词。
tokens = bert_tokenizer.tokenize(text)
对句子进行分词为词元 ID 后,我们需要在开头添加起始词元,并在末尾添加分隔词元。
CLS_ID = tf.constant(101, dtype=tf.int64)
SEP_ID = tf.constant(102, dtype=tf.int64)
start_tokens = tf.fill([tf.shape(text)[0], 1], CLS_ID)
end_tokens = tf.fill([tf.shape(text)[0], 1], SEP_ID)
tokens = tokens[:, :sequence_length - 2]
tokens = tf.concat([start_tokens, tokens, end_tokens], axis=1)
此时,我们的词元张量仍然是长度不同的 ragged 张量。TensorFlow Transform 希望所有张量都具有相同的长度,因此我们将对张量进行填充和截断以达到最大长度(
MAX_SEQ_LEN
),并用定义的填充词元填充较短的张量。
PAD_ID = tf.constant(0, dtype=tf.int64)
tokens = tokens.to_tensor(default_value=PAD_ID)
padding = sequence_length - tf.shape(tokens)[1]
tokens = tf.pad(tokens,
[[0, 0], [0, padding]],
constant_values=PAD_ID)
最后一步为我们提供了固定长度的词元向量,这是主要预处理步骤的最后一步。基于词元向量,我们可以创建两个必需的额外数据结构:input_mask 和 input_type_ids。
在 input_mask 的情况下,我们要记录所有相关词元,基本上是除了填充词元之外的所有词元。由于填充词元的值为零,并且所有 ID 都大于或等于零,因此我们可以使用以下操作定义 input_mask。
input_word_ids = tokenize_text(text)
input_mask = tf.cast(input_word_ids > 0, tf.int64)
input_mask = tf.reshape(input_mask, [-1, MAX_SEQ_LEN])
确定 input_type_ids 在我们的案例中更加简单。由于我们只提交一个句子,因此在我们的分类示例中,类型 ID 全部为零。
input_type_ids = tf.zeros_like(input_mask)
为了完成预处理设置,我们将所有步骤包装在
preprocessing_fn
函数中,这是 TensorFlow Transform 所需的。
def preprocessing_fn(inputs):
def tokenize_text(text, sequence_length=MAX_SEQ_LEN):
...
return tf.reshape(tokens, [-1, sequence_length])
def preprocess_bert_input(text, segment_id=0):
input_word_ids = tokenize_text(text)
...
return (
input_word_ids,
input_mask,
input_type_ids
)
...
input_word_ids, input_mask, input_type_ids = \
preprocess_bert_input(_fill_in_missing(inputs['text']))
return {
'input_word_ids': input_word_ids,
'input_mask': input_mask,
'input_type_ids': input_type_ids,
'label': inputs['label']
}
训练分类模型
TFX 的最新更新允许使用本机 Keras 模型。在下面的示例代码中,我们定义了我们的分类模型。该模型利用了来自 TFHub 的预训练 BERT 模型和
KerasLayer
。为了避免转换步骤和模型训练之间的任何不匹配,我们根据转换步骤提供的特征规范动态创建输入层。
feature_spec = tf_transform_output.transformed_feature_spec()
feature_spec.pop(_LABEL_KEY)
inputs = {
key: tf.keras.layers.Input(
shape=(max_seq_length),
name=key,
dtype=tf.int32)
for key in feature_spec.keys()}
我们需要将变量进行类型转换,因为 TensorFlow Transform 只能将变量输出为以下类型之一:tf.string、tf.int64 或 tf.float32(在本例中为 tf.int64)。但是,上述 Keras 模型中使用的来自 TensorFlow Hub 的 BERT 模型需要 tf.int32 类型的输入。因此,为了使这两个 TensorFlow 组件相协调,我们需要在将输入传递给实例化的 BERT 层之前,在输入函数或模型图中进行类型转换。
input_word_ids = tf.cast(inputs["input_word_ids"], dtype=tf.int32)
input_mask = tf.cast(inputs["input_mask"], dtype=tf.int32)
input_type_ids = tf.cast(inputs["input_type_ids"], dtype=tf.int32)
一旦我们的输入被转换为 tf.int32 数据类型,我们就可以将它们传递给我们的 BERT 层。该层返回两个数据结构:一个池化输出,它表示整个文本的上下文向量;以及一个向量列表,它为每个提交的标记提供特定于上下文的向量表示。由于我们只对整个文本的分类感兴趣,因此可以忽略第二个数据结构。
bert_layer = load_bert_layer()
pooled_output, _ = bert_layer(
[input_word_ids,
input_mask,
input_type_ids
]
)
之后,我们可以使用 tf.keras 组装我们的分类模型。在我们的示例中,我们使用了函数式 Keras API。
x = tf.keras.layers.Dense(256, activation='relu')(pooled_output)
dense = tf.keras.layers.Dense(64, activation='relu')(x)
pred = tf.keras.layers.Dense(1, activation='sigmoid')(dense)
model = tf.keras.Model(
inputs=[inputs['input_word_ids'],
inputs['input_mask'],
inputs['input_type_ids']],
outputs=pred
)
model.compile(loss='binary_crossentropy',
optimizer='adam',
metrics=['accuracy'])
然后,Keras 模型可以被我们的
run_fn
函数使用,该函数由 TFX Trainer 组件调用。随着 TFX 的最新更新,Keras 模型的集成得到了简化。不再需要使用 TensorFlow 的
model_to_estimator
函数进行“绕道”。我们现在可以定义一个通用的
run_fn
函数,该函数执行模型训练并在训练完成后导出模型。
以下是一个使用最新 TFX 版本的
run_fn
函数设置的示例
def run_fn(fn_args: TrainerFnArgs):
tf_transform_output = tft.TFTransformOutput(fn_args.transform_output)
train_dataset = _input_fn(
fn_args.train_files, tf_transform_output, 32)
eval_dataset = _input_fn(
fn_args.eval_files, tf_transform_output, 32)
mirrored_strategy = tf.distribute.MirroredStrategy()
with mirrored_strategy.scope():
model = get_model(tf_transform_output=tf_transform_output)
model.fit(
train_dataset,
steps_per_epoch=fn_args.train_steps,
validation_data=eval_dataset,
validation_steps=fn_args.eval_steps)
signatures = {
'serving_default':
_get_serve_tf_examples_fn(model, tf_transform_output
).get_concrete_function(
tf.TensorSpec(
shape=[None],
dtype=tf.string,
name='examples')),
}
model.save(
fn_args.serving_model_dir,
save_format='tf',
signatures=signatures)
值得特别注意的是 Trainer 函数中的一些行。借助 TFX 的最新版本,我们现在可以在 TFX 训练器组件中利用去年在 Keras 中引入的分布式策略。
mirrored_strategy = tf.distribute.MirroredStrategy()
with mirrored_strategy.scope():
model = get_model(tf_transform_output=tf_transform_output)
在模型训练之前对数据集进行预处理是最有效的,这可以加快训练速度,尤其是在训练器多次遍历同一数据集时。
因此,TensorFlow Transform 将在训练和评估之前执行预处理,并将预处理后的数据存储为 TFRecords。
{'input_mask': array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
'input_type_ids': array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]),
'input_word_ids': array([ 101, 2023, 3319, 3397, 27594, 2545, 2005, 2216, 2040, ..., 2014, 102]),
'label': array([0], dtype=float32)}
这使我们能够生成一个预处理图,该图可以在训练后预测模式中应用。由于我们重用预处理图,因此可以避免训练和预测预处理之间的偏差。
在我们的
run_fn
函数中,我们可以“连接”预处理的训练和评估数据集,而不是在训练期间使用原始数据集。
tf_transform_output = tft.TFTransformOutput(fn_args.transform_output)
train_dataset = _input_fn(fn_args.train_files, tf_transform_output, 32)
eval_dataset = _input_fn(fn_args.eval_files, tf_transform_output, 32)
...
model.fit(
train_dataset,
validation_data=eval_dataset,
...)
训练完成后,我们可以将训练后的模型与其处理步骤一起导出。
将模型与其预处理图一起导出
在
model.fit()
完成模型训练后,我们调用
model.save()
将模型导出为 SavedModel 格式。在模型签名定义中,我们调用函数
_get_serve_tf_examples_fn()
,它解析提交到我们的 TensorFlow Serving 端点(例如,在本例中为要分类的原始文本字符串)的序列化 tf.Example 记录,然后应用保存在 TensorFlow Transform 图中的转换。然后使用转换后的特征(即
model.tft_layer(parsed_features)
调用的输出)执行模型预测。在本例中,这将是 BERT 标记 ID、掩码 ID 和类型 ID。
def _get_serve_tf_examples_fn(model, tf_transform_output):
model.tft_layer = tf_transform_output.transform_features_layer()
@tf.function
def serve_tf_examples_fn(serialized_tf_examples):
feature_spec = tf_transform_output.raw_feature_spec()
feature_spec.pop(_LABEL_KEY)
parsed_features = tf.io.parse_example(serialized_tf_examples, feature_spec)
transformed_features = model.tft_layer(parsed_features)
return model(transformed_features)
return serve_tf_examples_fn
_get_serve_tf_examples_fn()
函数是 TensorFlow Transform 生成的转换图与训练的 tf.Keras 模型之间的重要连接。由于预测输入通过
model.tft_layer()
传递,因此它保证导出的 SavedModel 将包含与训练期间执行的相同的预处理。SavedModel 是一个图,包含预处理图和模型图。
通过 TensorFlow Serving 部署 BERT 分类模型后,我们现在可以将原始字符串提交到我们的模型服务器(作为
tf.Example
记录提交),并在客户端没有任何预处理或复杂的模型部署(包括预处理步骤)的情况下接收预测结果。
未来工作
本文介绍的工作简化了 BERT 模型的部署。
我们的演示项目 中所示的预处理步骤可以轻松扩展以处理更复杂的预处理,例如用于实体提取或问答任务。我们还在研究,如果我们重用预训练的 BERT 模型的量化或蒸馏版本(例如,
Albert),是否可以进一步降低预测延迟。
感谢您阅读我们的两部分博文。如果您有任何问题或建议,请通过
电子邮件 与我们联系。
进一步阅读
如果您想了解我们在本项目中使用的 TensorFlow 库的概述,我们建议您阅读
本博文的第 1 部分。
如果您想尝试我们的演示部署,请查看我们的
SAP ConcurLabs 演示页面,该页面展示了我们的情感分类项目。
如果您想了解 TensorFlow Extended (TFX) 和 TensorFlow Transform 的内部工作机制,请查看即将出版的
O’Reilly 出版物“Building Machine Learning Pipelines with TensorFlow”(在线提供预发布版本)。
有关更多信息
要了解有关 TFX 的更多信息,请查看
TFX 网站,加入
TFX 讨论组,深入了解
TFX 博客 中的其他文章,观看我们的
TFX YouTube 播放列表,并
订阅 TensorFlow 频道。
致谢
如果没有 Catherine Nelson、Richard Puckett、Jessica Park、Robert Reed 和
SAP 的 Concur Labs 团队 的大力支持,这个项目不可能实现。还要感谢 Robert Crowe、Irene Giannoumis、Robby Neale、Konstantinos Katsiapis、Arno Eigenwillig 以及 TensorFlow 团队的其他成员,感谢他们讨论了实现细节并详细审阅了本文。特别感谢 Google TensorFlow 团队的 Varshaa Naganathan、Zohar Yahav 和 Terry Huang,感谢他们提供了对 TensorFlow 库的更新,使这个管道实现成为可能。还要特别感谢 Talenpair 的 Cole Howard,感谢他对自然语言处理的启迪性讨论。