RASA源码解析与开发:一、nlu模块

nlu模块的主要功能是解析用户输入数据,识别出用户输入的实体、意图等关键信息,同时也可以添加诸如情感分析等自定义模块。

一、输入数据

nlu模块接受Message类型的数据作为输入,与core模块流转的Usermessage数据不同,Message定义在rasa/nlu/training_data/message.py中,默认有三个变量,分别为text、time、data。其中,text中存储的是用户输入的问题,time存储的是时间,data存储的是解析后的数据。

Message中常用的方法是get和set方法,get是从data中取出想要的数据,set是将解析后的intent等信息存入data中。

有的时候,我们可能要传入更多的信息进入nlu模块,例如,在我们的智能客服场景下,需要传入scene_id(场景id),这个时候我们需要修改源码,以支持传入scene__id,需要修改的py文件如下:

rasa/nlu/training_data/message.py

rasa/nlu/model.py

rasa/core/interpreter.py

rasa/core/processor.py

下面将一一解析每一块代码的作用以及需要修改的部分。

1、message.py

message.py中定义的class Message是nlu模块传入的数据,它有set、get等方法,为了传入其他数据进入nlu模块,我们需要在初始化部分初始化要传输的数据。

2、model.py

model.py中定义了Interpreter类,主要用于解析数据,该类在rasa/core/interpreter.py中的RasaNLUInterpreter调用,用于解析text,修改其中的初始parse_data,用于传输额外数据给nlu模块。

3、interpreter.py

interpreter.py中定义了数据的解析方法,在测试时,Rasa采用的是RasaNLUInterpreter。所以要修改class RasaNLUInterpreter中的parse 函数。

下面以RasaNLUInterpreter源码为例,详解interpreter模块如何调用nlu模型并输入到processor模块中。

class RasaNLUInterpreter(NaturalLanguageInterpreter):

    def __init__(

        self,

        model_directory: Text,

        config_file: Optional[Text] = None,

        lazy_init: bool = False,

    ):

        self.model_directory = model_directory

        self.lazy_init = lazy_init

        self.config_file = config_file

        if not lazy_init:

            self._load_interpreter()

        else:

            self.interpreter = None

    async def parse(

        self,

        text: Text,

        message_id: Optional[Text] = None,

        tracker: DialogueStateTracker = None,

    ) -> Dict[Text, Any]:

        """Parse a text message.

        Return a default value if the parsing of the text failed."""

        if self.lazy_init and self.interpreter is None:

            self._load_interpreter()

        result = self.interpreter.parse(text)

        return result

    def _load_interpreter(self) -> None:

        from rasa.nlu.model import Interpreter

        self.interpreter = Interpreter.load(self.model_directory)

在初始化的时候,通过self._load_interpreter()来引入rasa.nlu.model中的Interpreter模块,Interpreter有一个load方法,load传入已经训练好的模型文件路径,加载出nlu模型供interpreter调用。

如果想新建自己的interpreter类,需要注意两点:

如何传入已经训练好的nlu模型文件路径和在rasa中引入自己的interpreter模块,这部分可以在endpoints.yml中引入,详见

额外数据的引入,由于parse方法中传入了tracker,历史信息都可以在tracker中获取。

4、processor.py

processor.py中将信息传入interpreter.py中,这部分在_parse_message中。

在processor中,def parse_message调用interpreter方法,然后在外层封装为def _handle_message_with_tracker如下所示:

async def _handle_message_with_tracker(

        self, message: UserMessage, tracker: DialogueStateTracker

    ) -> None:

        if message.parse_data:

            parse_data = message.parse_data

        else:

            parse_data = await self._parse_message(message, tracker)

        # don't ever directly mutate the tracker

        # - instead pass its events to log

        tracker.update(

            UserUttered(

                message.text,

                parse_data["intent"],

                parse_data["entities"],

                parse_data,

                input_channel=message.input_channel,

                message_id=message.message_id,

                metadata=message.metadata,

            ),

            self.domain,

        )

        if parse_data["entities"]:

            self._log_slots(tracker)

        logger.debug(

            f"Logged UserUtterance - tracker now has {len(tracker.events)} events."

        )

从def _handle_message_with_tracker可以看到,首先检查message.parse_data是否为空(channel.py里面给parse_data传值),如果为空,调用interpreter去识别意图等,最后将结果写入parse_data中。

二、自定义nlu组件

rasa的nlu模块除了自身提供的实体抽取和意图识别模型外,还可以添加自定义模型,如自定义意图识别模块或者情感分析等模块。下面将详细介绍自定义模块的添加。

1、数据结构

传入rasa_nlu的数据存储格式为Message对象,Message对象定义在rasa/nlu/training_data/message.py中。其中message.text中就是输入的文本。

2、处理流程

下图是文本传入rasa_nlu后的标准流程。

2.1 tokenizer

rasa提供了5种分词(Tokenizers)如下:

TokenizerRequiresDescription

WhitespaceTokenizer/为每个以空格分隔的字符序列创建token。

MitieTokenizer需要先配置MitieNLP使用Mitie进行分词,用MITIE tokenizer创建tokens,从而服务于 MITIEFeaturizer。

SpacyTokenizer需要先配置SpacyNLP使用Spacy进行分词,用Spacytokenizer创建tokens,从而服务于SpacyFeaturizer。

ConveRTTokenizer/使用ConveRt进行分词,用ConveRT Tokenizer创建tokens,从而服务于ConveRTFeaturizer。

JiebaTokenizer/使用Jieba作为 Tokenizer,对中文进行分词,用户的自定义字典文件可以通过特定的文件目录路径 dictionary_path自动加载。

分词后的结果会回传进Message对象中,key值为"tokens",详见rasa/nlu/tokenizers/tokenizer.py的process方法。

2.2 featurizer

rasa提供了2类featurizer方法— dense_featurizer和sparse_featurizer。

dense_featurizer:

FeaturizerRequiresDescription

MitieFeaturizer需要先配置MitieNLP使用MITIE featurizer为意图分类创建特征。

SpacyFeaturizer需要先配置SpacyNLP使用spacy featurizer为意图分类创建特征。

ConveRTFeaturizer需要配置ConveRTTokenizer使用ConveRT模型创建用户消息和响应,由于ConveRT模型仅在英语语料上训练,因此只有当训练数据是英语语言时才能使用这个featurizer。

sparse_featurizer:

FeaturizerRequiresDescription

RegexFeaturizer/为实体提取和意图分类创建特征。在训练期间,regex intent featurizer 以训练数据的格式创建一系列正则表达式列表。对于每个正则,都将设置一个特征,标记是否在输入中找到该表达式,然后将其输入到intent classifier / entity extractor 中以简化分类(假设分类器在训练阶段已经学习了该特征集合,该特征集合表示一定的意图)

CountVectorsFeaturizer/创建用户信息和标签(意图和响应)的词袋表征,用作意图分类器的输入,输入的意图特征以词袋表征

特征化后的结果会回传进Message对象中,key值为"text_dense_features"/"text_sparse_features"。

2.3 intent classifier

ClassifierRequiresDescription

MitieIntentClassifiertokenizer 和 featurizer该分类器使用MITIE进行意图分类。底层分类器使用的是具有稀疏线性核的多类线性支持向量机

SklearnIntentClassifierfeaturizer该sklearn意图分类器训练一个线性支持向量机,该支持向量机通过网格搜索得到优化。除了其他分类器,它还提供没有“获胜”的标签的排名。spacy意图分类器需要在管道中的先加入一个featurizer。该featurizer创建用于分类的特征。

EmbeddingIntentClassifierfeaturizer嵌入式意图分类器将用户输入和意图标签嵌入到同一空间中。Supervised embeddings通过最大化它们之间的相似性来训练。该算法基于StarSpace的。但是,在这个实现中,损失函数略有不同,添加了额外的隐藏层和dropout。该算法还提供了未“获胜”标签的相似度排序。在embedding intent classifier之前,需要在管道中加入一个featurizer。该featurizer创建用以embeddings的特征。建议使用CountVectorsFeaturizer,它可选的预处理有SpacyNLP和SpacyTokenizer。

KeywordIntentClassifier/该分类器通过搜索关键字的消息来工作。默认情况下,匹配是大小写敏感的,只精确匹配地搜索用户消息中关键字。

classifier会从Message对象中获取tokenizer 和 featurizer作为分类模型的输入进行意图识别。

3、自定义方法

由2中的处理流程可以看出,rasa提供的意图识别重点是featurizer,rasa本身提供了5种featurizer方法,除了ConveRT模型之外均可用与中文场景,其中MitieFeaturizer和SpacyFeaturizer需要特定的Tokenizer和对应的词向量。

我们也可以自定义自己的中文模型,先通过jieba分词(可以引入用户字典),然后用自己训练的词向量模型来featurize用户输入的text,最后再引入自定义的分类模型识别意图。

以KeywordIntentClassifier意图识别模型为例,解释如何自定义意图识别模型:

class KeywordIntentClassifier(Component):

    """Intent classifier using simple keyword matching.

    The classifier takes a list of keywords and associated intents as an input.

    A input sentence is checked for the keywords and the intent is returned.

    """

    provides = [INTENT_ATTRIBUTE]

    defaults = {"case_sensitive": True}

    def __init__(

        self,

        component_config: Optional[Dict[Text, Any]] = None,

        intent_keyword_map: Optional[Dict] = None,

    ):

        super(KeywordIntentClassifier, self).__init__(component_config)

        self.case_sensitive = self.component_config.get("case_sensitive")

        self.intent_keyword_map = intent_keyword_map or {}

    def train(

        self,

        training_data: "TrainingData",

        cfg: Optional["RasaNLUModelConfig"] = None,

        **kwargs: Any,

    ) -> None:

        duplicate_examples = set()

        for ex in training_data.training_examples:

            if (

                ex.text in self.intent_keyword_map.keys()

                and ex.get(INTENT_ATTRIBUTE) != self.intent_keyword_map[ex.text]

            ):

                duplicate_examples.add(ex.text)

                raise_warning(

                    f"Keyword '{ex.text}' is a keyword to trigger intent "

                    f"'{self.intent_keyword_map[ex.text]}' and also "

                    f"intent '{ex.get(INTENT_ATTRIBUTE)}', it will be removed "

                    f"from the list of keywords for both of them. "

                    f"Remove (one of) the duplicates from the training data.",

                    docs=DOCS_URL_COMPONENTS + "#keyword-intent-classifier",

                )

            else:

                self.intent_keyword_map[ex.text] = ex.get(INTENT_ATTRIBUTE)

        for keyword in duplicate_examples:

            self.intent_keyword_map.pop(keyword)

            logger.debug(

                f"Removed '{keyword}' from the list of keywords because it was "

                "a keyword for more than one intent."

            )

        self._validate_keyword_map()

    def _validate_keyword_map(self) -> None:

        re_flag = 0 if self.case_sensitive else re.IGNORECASE

        ambiguous_mappings = []

        for keyword1, intent1 in self.intent_keyword_map.items():

            for keyword2, intent2 in self.intent_keyword_map.items():

                if (

                    re.search(r"\b" + keyword1 + r"\b", keyword2, flags=re_flag)

                    and intent1 != intent2

                ):

                    ambiguous_mappings.append((intent1, keyword1))

                    raise_warning(

                        f"Keyword '{keyword1}' is a keyword of intent '{intent1}', "

                        f"but also a substring of '{keyword2}', which is a "

                        f"keyword of intent '{intent2}."

                        f" '{keyword1}' will be removed from the list of keywords.\n"

                        f"Remove (one of) the conflicting keywords from the"

                        f" training data.",

                        docs=DOCS_URL_COMPONENTS + "#keyword-intent-classifier",

                    )

        for intent, keyword in ambiguous_mappings:

            self.intent_keyword_map.pop(keyword)

            logger.debug(

                f"Removed keyword '{keyword}' from intent '{intent}' because it matched a "

                "keyword of another intent."

            )

    def process(self, message: Message, **kwargs: Any) -> None:

        intent_name = self._map_keyword_to_intent(message.text)

        confidence = 0.0 if intent_name is None else 1.0

        intent = {"name": intent_name, "confidence": confidence}

        if message.get(INTENT_ATTRIBUTE) is None or intent is not None:

            message.set(INTENT_ATTRIBUTE, intent, add_to_output=True)

    def _map_keyword_to_intent(self, text: Text) -> Optional[Text]:

        re_flag = 0 if self.case_sensitive else re.IGNORECASE

        for keyword, intent in self.intent_keyword_map.items():

            if re.search(r"\b" + keyword + r"\b", text, flags=re_flag):

                logger.debug(

                    f"KeywordClassifier matched keyword '{keyword}' to"

                    f" intent '{intent}'."

                )

                return intent

        logger.debug("KeywordClassifier did not find any keywords in the message.")

        return None

    def persist(self, file_name: Text, model_dir: Text) -> Dict[Text, Any]:

        """Persist this model into the passed directory.

        Return the metadata necessary to load the model again.

        """

        file_name = file_name + ".json"

        keyword_file = os.path.join(model_dir, file_name)

        utils.write_json_to_file(keyword_file, self.intent_keyword_map)

        return {"file": file_name}

    @classmethod

    def load(

        cls,

        meta: Dict[Text, Any],

        model_dir: Optional[Text] = None,

        model_metadata: "Metadata" = None,

        cached_component: Optional["KeywordIntentClassifier"] = None,

        **kwargs: Any,

    ) -> "KeywordIntentClassifier":

        if model_dir and meta.get("file"):

            file_name = meta.get("file")

            keyword_file = os.path.join(model_dir, file_name)

            if os.path.exists(keyword_file):

                intent_keyword_map = utils.read_json_file(keyword_file)

            else:

                raise_warning(

                    f"Failed to load key word file for `IntentKeywordClassifier`, "

                    f"maybe {keyword_file} does not exist?",

                )

                intent_keyword_map = None

            return cls(meta, intent_keyword_map)

        else:

            raise Exception(

                f"Failed to load keyword intent classifier model. "

                f"Path {os.path.abspath(meta.get('file'))} doesn't exist."

            )

从上面的代码可以看到,整个KeywordIntentClassifier由4部分组成,train、process、persist和load。其中,train是模型训练部分,process是模型推理部分,persist是模型持久化部分,load是加载训练好的模型。一开始的provide定义的是返回值提供什么字段,default是模型的一些自定义参数,比如模型的epochs和batch_size等,这里定义的是这些参数的默认值,在config.yml中自定义的pipeline可以传入其他值,如下所示:


©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,616评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,020评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,078评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,040评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,154评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,265评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,298评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,072评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,491评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,795评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,970评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,654评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,272评论 3 318
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,985评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,223评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,815评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,852评论 2 351

推荐阅读更多精彩内容