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可以传入其他值,如下所示: