avatar

RASA随笔(一)

早先曾经在项目中接触过一次RASA,近期又遇到了类似的项目,于是又开始使用RASA,本次项目比较有趣,对RASA的使用程度与深度大大高于上一个项目,便想开个不定期更新的随笔专栏,记录一下在使用RASA这个强大多轮对话框架时的一些心得体会。

需要说明一点,笔者个人比较熟悉rasa1.10.x版本,但写这篇博客时,RASA框架版本已经更新到2.8.x版本,为了保持与时代共同进步的步伐,本RASA随笔系列将基于rasa2.8.x版本进行说明。

rasa_version

*. RASA框架简介

首先还是先简单介绍一下RASA。

*.1 消息处理逻辑

当我们与RASA机器人进行通话时,机器人的处理逻辑如下图所示

rasa_architecture

这张图片来自RASA官方文档,具体的步骤说明该文档中已经详细给出,若有疑问的同学可以自行前往阅读。

*.2 架构

下图展示了RASA的技术架构

rasa_architecture_detail

我想对于绝大部分同学来说,RASA中最需要注意的两部分莫过于NLU PipelineDialogue Policies了,前者决定了机器人能够接收信息的数量与精准度,是后者的重要基础;后者决定了机器人的对话策略,直接影响了机器人的“智能”程度。

  • 需要注意的几点:
    1. RASA的深度学习部分是用tensorflow写的,这一点在自定义components的时候可能会存在一些影响;

1. 中文项目下BERT的使用

我相信在2018年之后,对于一般的NLP项目大家肯定都会优先想到我们芝麻街的BERT同学,多轮对话这种较复杂的NLP任务当然也不例外。但比较有意思的是,RASA的官方版本并不支持在中文项目中使用BERT,这是怎么回事呢?我们一起来看一下:

1.1 问题定位

首先,RASA开发团队在RASA中封装了HuggingFace的Transformers,相关代码在rasa.nlu.utils.hugging_face中,我们可以在config.yml中进行如下设置以使用BERT:

config_bert_in_config_yml

接下来就是困扰RASA中文项目的关键点了——Tokenizer,RASA中的HFTransformersNLP中使用的是团队自定义的WhiteSpaceTokenizerRASA里的BERT之所以不支持中文,问题就出在这个WhiteSpaceTokenizer上。先上一个图,直观证明一下这个说法

rasa_bert_do_not_support_chinese

1.2 问题分析

1.2.1 文本缺失问题

最初看源码看到这部分的时候,我的第一想法是——”不就是列了个list吗,我把里边的zh删掉不就好了?“。实际上当然没有那么简单,之所以不支持这三种语言,是与语言的特质有关。从WhiteSpaceTokenizer这个名字,可以猜测这个Tokenizer针对的是那些文字用空格分隔的语言,而中文(zh)、日语(ja)、泰语(th)则是世界常见语言中为数不多的不用空格分隔文字的语言。

那么,WhiteSpaceTokenizer的计算逻辑又是怎么样的呢?关键就在于WhiteSpaceTokenizer.tokenizer()函数,它的源码如下所示

plaintext
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
源码1
1 def tokenize(self, message: Message, attribute: Text) -> List[Token]:
2 text = message.get(attribute)
3
4 words = regex.sub(
5 r"[^\w#@&]+(?=\s|$)|"
6 r"(\s|^)[^\w#@&]+(?=[^0-9\s])|"
7 r"(?<=[^0-9\s])[^\w._~:/?#\[\]()@!$&*+,;=-]+(?=[^0-9\s])",
8 " ",
9 text,
10 ).split()
11
12 words = [self.remove_emoji(w) for w in words]
13 words = [w for w in words if w]
14
15 if not words:
16 words = [text]
17 tokens = self._convert_words_to_tokens(words, text)
18 return self._apply_token_pattern(tokens)

从上述代码中可以看到,对于输入的文本,函数依次执行下列操作:

1.将文本中的特殊字符替换成空格,并对替换后的文本依据空格进行split操作;

2.根据写好的正则模板对分割好的文本中的各片段进行匹配,若匹配成功,则说明这是一个类似emoji的”无效字符“,将其删除;

3.最后,通过_convert_words_to_tokens()函数将words中的元素处理为Token列表。

对于最后这一个步骤,接下来会细讲,这里先不展开。

这个函数对于中英文文本的效果分别如何呢?我们用下面这个例子来测试一下,看看在执行words = [w for w in words if w]这一步之后分别会得到什么结果:

plaintext
1
2
3
4
5
Chinese: 听说你会用rasa,我想学,可以教教我吗?
words = ['听说你会用rasa']

English: I heard that you can use rasa2.8, can you teach me?
words = ['I', 'heard', 'that', 'you', 'can', 'use', 'rasa2.8', 'can', 'you', 'teach', 'me']

可以看到经过处理之后,中文文本缺失了一部分,这样的话在训练NLU模型时势必存在语义缺失的问题,从而导致训练失败,这当然不是各位NLPer所希望看到的。

1.2.2 正则匹配问题

我们现在已经知道了上述函数在处理中文时存在文本缺失问题,那么导致这个问题的原因是什么呢?或者可以这样问——为什么有的中文文本会被保留下来,有的却丢失了呢?这与源码1第12行中的remove_emoji()函数有关,让我们看看该函数长什么样:

plaintext
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
源码2
1 def remove_emoji(self, text: Text) -> Text:
2 match = self.emoji_pattern.fullmatch(text)
3 if match is not None:
4 return ""
5 return text

其中
self.emoji_pattern = re.compile(
"["
"\U0001F600-\U0001F64F" # emoticons
"\U0001F300-\U0001F5FF" # symbols & pictographs
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F1E0-\U0001F1FF" # flags (iOS)
"\U00002702-\U000027B0"
"\U000024C2-\U0001F251"
"\u200d" # zero width joiner
"\u200c" # zero width non-joiner
"]+",
flags=re.UNICODE,
)

简单来说,就是self.emoji_pattern这一定义好的正则表达式会匹配到中文等”特殊字符”,而不会匹配到英文。因此,文本中带有英文字母时,由于在该正则表达式下不满足fullmatch()(源码2中第2行),因此会认定为匹配失败,从而remove_emoji()函数会返回原文本;否则,认为匹配成功,返回空字符串,导致“文本缺失”。

1.3 处理方式

怎么解决上述问题呢?这里就要提到我们先前提过的_convert_words_to_tokens()函数了。

plaintext
1
2
3
4
5
6
7
8
9
10
11
12
源码3
1 def _convert_words_to_tokens(words: List[Text], text: Text) -> List[Token]:
2 running_offset = 0
3 tokens = []
4
5 for word in words:
6 word_offset = text.index(word, running_offset)
7 word_len = len(word)
8 running_offset = word_offset + word_len
9 tokens.append(Token(word, word_offset))
10
11 return tokens

简单地说,该函数会遍历words中的每个元素,并为每个元素构造一个对应的Token对象。大家都知道,英文BERT的tokens就是一个个英文单词,而中文BERT的tokens是中文字符,那么这里遍历words中的每个元素,与直接遍历原始文本本质上可以简单地认为是一回事。

因此,要在RASA官方框架下使用中文BERT,我们需要做的事情是:重写WhiteSpaceTokenizer.tokenize()函数(笔者使用的方法是删除无效字符过滤机制,直接定义words = text,然后传入_convert_words_to_tokens()中进行计算)。至于无效字符过滤,可以在该函数中重写过滤逻辑(即self.emoji_pattern),也可以在产品侧的前端部分先对文本进行处理后再将其传入RASA。

2.历史记录导致的性能衰减

本文开头提过笔者在上一家公司的时候便接触过rasa,当时遇到过一个问题:RASA会越用越慢。经过一番查找,发现是RASA的记忆机制拖累了它:

用户在与RASA交互时,除了输入的text之外,还会传给RASA一个sender_id用于标记用户(这就是为什么rasa会有”记忆”)。而当面对一个具有历史信息的老用户时,RASA会首先根据用户的sender_id去数据库中搜索历史记录,当历史会话中的events特别多时,耗时会明显增加,从而导致”RASA越用越慢”的现象。

要解决该问题,笔者当时使用了一个最简陋的方法:

  1. 根据产品端的用户id,为该用户构造一个既允许后期追踪到用户id、又能和该用户历史记录区分开的sender_id,例如:userID_timestamp;
  2. 每次老用户与bot交互时,解析user_id与本次会话时间,若本次会话时间与最近一次会话时间之间的相隔时间超过了事先设定的阈值,则使用本次构造的sender_id进行会话(即开启新会话);否则,沿用最近一次的sender_id进行会话(即调用老会话)。

当时发现该问题后,笔者做过一次实验,对比了更新sender_id与不更新时,平均每个用户在不同轮次的交互时的反应时间。实验结果如下:

2.1 实验1

  • 设置:用户数30,每位用户请求30次,不更新sender_id。
Round times(/s)
0 4
70 34

2.2 实验2

  • 设置:用户数30(其中29位每10轮更新一次sender_id,1位每20轮更新一次sender_id),每位用户请求100次
Round time(/s)
0 4.2
70 4.7
100 5.6

2.3 实验3

  • 设置:用户数30,每10轮更新一次sender_id,每位用户请求100次
Round time(/s)
0 4.3
70 4.0
100 4.7

3.添加额外的NLU信息

未完,待填坑

Author: Qin Yue
Link: https://qinyuenlp.com/article/7a7dec9b64b5/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.

Comment