如何通过TensorFlow 2.x获得层层的灵活性

252 阅读7分钟

Rasa,我们正在建立对话式人工智能的基础设施,供开发者用来建立基于聊天和语音的助手。Rasa开源,我们的基石产品,为NLU(自然语言理解)和对话管理提供一个框架。在NLU方面,我们提供了使用Tensorflow 2.x构建的模型处理意图分类和实体检测的模型。

在这篇文章中,我们想讨论一下迁移到最新版本的TensorFlow的好处,同时也对Rasa内部的一些工作原理进行了深入了解。

一个典型的Rasa项目设置

当你用Rasa开源构建一个虚拟助手时,你通常会从定义故事开始,这些故事代表用户可能与你的代理进行的对话。这些故事将作为训练数据,你可以把它们配置成yaml文件。如果我们假设我们正在做一个允许你在线购买比萨饼的助手,那么我们的配置中可能有这样的故事。

yaml
version: "2.0"

stories:

- story: happy path
  steps:
  - intent: greet
  - action: utter_greet
  - intent: mood_great
  - action: utter_happy

- story: purchase path
  steps:
  - intent: greet
  - action: utter_greet
  - intent: purchase
    entities: 
product: “pizza”
  - action: confirm_purchase
  - intent: affirm
  - action: confirm_availability

这些故事由意图和行动组成。行动可以是简单的文本回复,也可以触发自定义的Python代码(例如,检查数据库)。为了定义每个意图的训练数据,你为助手提供了用户信息的例子,这些信息可能看起来像。

yaml
version: "2.0"

nlu:
- intent: greet
  examples: |
    - hey
    - hello
    - hi
    - hello there
    - good morning

- intent: purchase
  examples: |
    - id like to buy a [veggie pizza](product) for [tomorrow](date_ref)
    - i want to order a [pizza pepperoni](product)
    - id want to buy a [pizza](product) and a [cola](product)
- 

当你使用Rasa训练一个助手时,你将提供像上面显示的那些配置文件。在你的代理可以处理的对话类型中,你可以有很强的表现力。意图和行动就像乐高积木,可以明确地组合起来,以涵盖许多对话路径。一旦这些文件被定义,它们就会被组合起来,创建一个训练数据集,代理将从中学习。

Rasa允许用户建立自定义的机器学习管道以适应他们的数据集。这意味着,如果你愿意,你可以加入你自己的(预训练的)自然语言理解模型。但Rasa也提供了用TensorFlow编写的模型,专门用于这些任务。

具体的模型要求

你可能已经注意到,我们的例子不仅包括意图,还包括实体。当一个用户有兴趣进行购买时,他们(通常)也会说他们有兴趣购买什么。这些信息需要在用户提供时被检测出来。如果我们需要向用户提供一个表单来检索这些信息,那将是一个糟糕的体验。

如果你退一步想一想,什么样的模型可以在这里很好地工作,你很快就会认识到,这不是一个标准的任务。不仅仅是我们在每个语料中有许多标签;我们还有多种*类型的标签。这意味着我们需要有两个输出的模型。

Rasa开放源码提供了一个可以检测意图和实体的模型,叫做DIET。它使用一个转化器架构,允许系统从意图和实体的互动中学习。因为它需要同时处理这两项任务,所以典型的机器学习模式是行不通的。

model.fit(X, y).predict(X)

你需要一个不同的抽象概念。

抽象

这就是TensorFlow 2.x对Rasa代码库的改进之处。现在定制TensorFlow类要容易得多。特别是,我们在Keras之上做了一个自定义的抽象,以满足我们的需求。这方面的一个例子是Rasa自己的内部`RasaModel.`我们在下面添加了基类的签名。完整的实现可以在这里找到。

class RasaModel(tf.keras.models.Model):

	def __init__(
    	self,
    	random_seed: Optional[int] = None,
    	tensorboard_log_dir: Optional[Text] = None,
    	tensorboard_log_level:Optional[Text] = "epoch",
    	**kwargs,
	) -> None:
    	...

	def fit(
    	self,
    	model_data: RasaModelData,
    	epochs: int,
    	batch_size: Union[List[int], int],
    	evaluate_on_num_examples: int,
    	evaluate_every_num_epochs: int,
    	batch_strategy: Text,
    	silent: bool = False,
    	eager: bool = False,
	) -> None:
		...

这个对象是定制的,允许我们传入我们自己的 `[RasaModelData](https://github.com/RasaHQ/rasa/blob/1.10.x/rasa/utils/tensorflow/model_data.py#L31)` 对象。这样做的好处是,我们可以保留Keras模型对象提供的所有现有功能,同时我们可以覆盖一些特定的方法来满足我们的需求。我们可以用我们喜欢的数据格式运行模型,同时保持对 "急切模式 "的手动控制,这有助于我们进行调试。

这些Keras对象现在是TensorFlow 2.x中的一个核心API,这使得我们非常容易集成和定制。

训练循环

为了给代码变得更简单的另一个印象,让我们看一下Rasa模型里面的训练循环。

TensorFlow 1.8的Python伪代码

我们在下面列出了用于旧训练循环的部分代码(完整的实现见这里)。请注意,它是使用`session.run`来计算损失和准确度的。

def train_tf_dataset(
	train_init_op: "tf.Operation",
	eval_init_op: "tf.Operation",
	batch_size_in: "tf.Tensor",
	loss: "tf.Tensor",
	acc: "tf.Tensor",
	train_op: "tf.Tensor",
	session: "tf.Session",
	epochs: int,
	batch_size: Union[List[int], int],
	evaluate_on_num_examples: int,
	evaluate_every_num_epochs: int,
)
	session.run(tf.global_variables_initializer())
	pbar = tqdm(range(epochs),desc="Epochs", disable=is_logging_disabled())

for ep in pbar:
  ep_batch_size=linearly_increasing_batch_size(ep, batch_size, epochs)
   session.run(train_init_op, feed_dict={batch_size_in: ep_batch_size})

    ep_train_loss = 0
    ep_train_acc = 0
    batches_per_epoch = 0
    while True:
    	  try:
        	_, batch_train_loss, batch_train_acc = session.run(
            	[train_op, loss, acc])
        	batches_per_epoch += 1
        	ep_train_loss += batch_train_loss
        	ep_train_acc += batch_train_acc

    	  except tf.errors.OutOfRangeError:
        	break

train_tf_dataset 函数需要大量的张量作为输入。在TensorFlow 1.8中,你需要跟踪这些张量,因为它们包含你打算运行的所有操作。在实践中,这可能会导致繁琐的代码,因为很难分离关注点。

TensorFlow 2.x的Python伪代码

在TensorFlow 2中,由于Keras的抽象性,所有这些都变得更加容易。我们可以从Keras类中继承,使我们能够更好地划分代码。下面是Rasa的DIET分类器的`train`方法(完整的实现见这里)。

def train(
    	self,
    	training_data: TrainingData,
    	config: Optional[RasaNLUModelConfig] = None,
    	**kwargs: Any,
	) -> None:
    	"""Train the embedding intent classifier on a data set."""

    	model_data = self.preprocess_train_data(training_data)

    	self.model = self.model_class()(
        	config=self.component_config,
    	)

    	self.model.fit(
        	model_data,
        	self.component_config[EPOCHS],
        	self.component_config[BATCH_SIZES],
        	self.component_config[EVAL_NUM_EXAMPLES],
        	self.component_config[EVAL_NUM_EPOCHS],
        	self.component_config[BATCH_STRATEGY],
    	)

Keras的面向对象的编程风格使我们可以定制更多的东西。我们能够以这种方式实现我们自己的`self.model.fit` ,我们不需要再担心`session` 。我们甚至不需要跟踪张量,因为Keras的API为你抽象出了一切。

如果你对完整的代码感兴趣,你可以 这里找到旧的循环和新的循环。

额外的功能层

我们不仅仅是在Keras模型中应用这种抽象,我们还使用类似的技术开发了一些神经网络层。

我们自己实现了一些自定义层。例如,我们有一个名为 "DenseWithSparseWeights"的层,它的行为就像一个密集层,但我们事先放弃了许多权重,使其更加稀疏。同样,我们只需要继承正确的类(tf.keras.layer.Dense)来创建它。

我们越来越喜欢自定义,甚至把损失函数作为一个层来实现。这对我们来说很有意义,因为考虑到损失在NLP中会变得很复杂。许多NLP任务会要求你在训练过程中进行采样,这样你就会有负面例子的标签。在这个过程中,你可能还需要屏蔽标记。我们还对记录相似性损失以及标签准确性感兴趣。通过制作我们自己的层,我们正在建立可重复使用的组件,而且它也很容易维护。

学到的经验

发现这个定制的机会对Rasa来说是一个巨大的变化。我们喜欢把我们的算法设计得灵活并适用于许多情况,我们很高兴知道底层技术栈允许我们这样做。对于那些正在进行TensorFlow迁移的人,我们确实有一些建议。

  1. 从思考你的应用程序中需要哪些 "乐高砖 "开始。这个心理设计步骤将使你更容易认识到如何利用现有的Keras/TensorFlow对象来满足你的用例。
  2. 试图通过立即进行深入研究来沉浸其中是很诱人的。相反,从一个工作实例开始,并从那里开始深入研究可能会有帮助。TensorFlow不是一个普通的Python包,其内部结构可能会变得复杂。你与之交互的Python代码需要与C++交互以保持张量操作的性能。一旦代码运行,你就可以更好地开始调整/优化所有新的TensorFlow版本的性能特征。