Getting Custom Component to Work

Hello! I just wanted to post the custom component py file that I modified from the original blog post that can be found Custom Component Tutorial.

I had a lot of trouble with various exceptions and differences from the code in the tutorial to the documentation.

import typing
from typing import Any, Optional, Text, Dict, List, Type

from rasa.nlu.components import Component
from rasa.nlu.config import RasaNLUModelConfig
from rasa.shared.nlu.constants import TEXT
from rasa.shared.nlu.training_data.training_data import TrainingData
from rasa.shared.nlu.training_data.message import Message
from rasa.nlu.tokenizers import whitespace_tokenizer

if typing.TYPE_CHECKING:
    from rasa.nlu.model import Metadata

from nltk.classify import NaiveBayesClassifier

import pickle
import os

SENTIMENT_MODEL_FILE_NAME = "sentiment_classifier.pkl"

class SentimentAnalyzer(Component):
    """A sentiment analysis component"""

    # Which components are required by this component.
    # Listed components should appear before the component itself in the pipeline.
    @classmethod
    def required_components(cls) -> List[Type[Component]]:
        """Specify which components need to be present in the pipeline."""

        return [whitespace_tokenizer.WhitespaceTokenizer]

    defaults = {}
    supported_language_list = ['en']
    not_supported_language_list = None

    def __init__(self, component_config: Optional[Dict[Text, Any]] = None, clf=None) -> None:
        super(SentimentAnalyzer, self).__init__(component_config=component_config)
        self.clf = clf

    def train(
        self,
        training_data: TrainingData,
        config: Optional[RasaNLUModelConfig] = None,
        **kwargs: Any,
    ) -> None:
        """Train this component."""

        training_data = training_data.training_examples #list of Message objects
        tokens = []
        labels = []
        for example in training_data:
            if 'text_tokens' in example.data:
                labels.append(example.get('metadata')['example']['sentiment'])
                tokens.append([t.text for t in example.data['text_tokens']])

        processed_tokens = [self.preprocessing(t) for t in tokens]
        labeled_data = [(t, x) for t,x in zip(processed_tokens, labels)]
        self.clf = NaiveBayesClassifier.train(labeled_data)

    def preprocessing(self, tokens):
        """Create bag-of-words representation of the training examples."""
        
        return({word: True for word in tokens})

    def convert_to_rasa(self, value, confidence):
        """Convert model output into the Rasa NLU compatible output format."""

        entity = {
            "value" : value,
            "confidence" : confidence,
            "entity" : "sentiment",
            "extractor" : "sentiment_extractor"
        }

        return entity

    def process(self, message: Message, **kwargs: Any) -> None:
        """Process an incoming message."""

        if not self.clf:
            # component is either not trained or didn't receive enough training data
            entity = None
        else:
            if 'text_tokens' in message.data:
                tokens= [t.text for t in message.data['text_tokens']]
                tb = self.preprocessing(tokens=tokens)
                pred = self.clf.prob_classify(tb)
                sentiment = pred.max()
                confidence = pred.prob(sentiment)
                entity = self.convert_to_rasa(value=sentiment, confidence=confidence)
                message.set("entities", [entity], add_to_output=True)
            else:
                entity = None

    def persist(self, file_name: Text, model_dir: Text) -> Optional[Dict[Text, Any]]:
        """Persist this component to disk for future loading."""

        if self.clf:
            model_file_name = os.path.join(model_dir, SENTIMENT_MODEL_FILE_NAME)
            self._write_model(model_file_name, self.clf)
            return {"classifier_model" : SENTIMENT_MODEL_FILE_NAME}

    @classmethod
    def load(
        cls,
        meta: Dict[Text, Any],
        model_dir: Text,
        model_metadata: Optional["Metadata"] = None,
        cached_component: Optional["Component"] = None,
        **kwargs: Any,
    ) -> "Component":
        """Load this component from file."""

        file_name = meta.get('classifier_model')
        classifier_file = os.path.join(model_dir, file_name)

        if os.path.exists(classifier_file):
            classifier_f = open(classifier_file, 'rb')
            clf = pickle.load(classifier_f)
            classifier_f.close()
            return cls(meta, clf)
        else:
            return cls(meta)

    def _write_model(self, model_file, classifier) -> None:
        """Helper to save and load model properly"""

        save_classifier = open(model_file, 'wb')
        pickle.dump(classifier, save_classifier)
        save_classifier.close()

One of the differences in how I labeled my data for the sentiments. In the tutorial it says you can use a .txt file that corresponds with the training data. I used the metadata to hold this information in my nlu.yml.

For example, my intent for greet is in nlu.yml as follows:

- intent: greet
  examples:
  - text: |
      hi
    metadata:
      sentiment: neutral
  - text: |
      hey
    metadata:
      sentiment: neutral
  - text: |
      hello there
    metadata:
      sentiment: neutral

I had a lot of trouble getting tokens to work with the code from the tutorial. This may just be a mistake on my part, but I was able to make it work with a few changes. I am currently using message.data[‘text_tokens’] to get the tokens.

@fkoerner I just wanted to tag you in case you see something in my solution that is incorrect!

Thanks!

Abhi

I had a lot of trouble with various exceptions and differences from the code in the tutorial to the documentation.

This is probably because the tutorial is outdated. It is only mean to be compatible with Rasa versions < 2.x. Nonetheless, with some modifications it can be made to work.

I used the metadata to hold this information in my nlu.yml.

This is a great solution, provided that labeling the data that way isn’t a pain for you/your workflow. :slight_smile:

I had a lot of trouble getting tokens to work with the code from the tutorial. This may just be a mistake on my part, but I was able to make it work with a few changes. I am currently using message.data[‘text_tokens’] to get the tokens.

This isn’t a mistake, just a matter of tokens being renamed since the tutorial to accommodate different types of tokens (text_tokens,intent_tokens…). Your solution looks good to me! You could use the constant in rasa.nlu.constants.py (TOKENS_NAMES[rasa.shared.nlu.constants.TEXT]). This may be more robust to change than hardcoding the value text_tokens.

All in all, looks good to me! Is is working as expected? I think you had said on another post that you were getting a NoneType error message.

Haha thank you! It was quite tedious, but with the help of a python script I was able to format the yml the correct way and save a lot of time!

That is a great idea! I will make that change today!

Yes, everything is working as of now! :crossed_fingers: I believe that the NoneType error message was occuring because I was trying to get just token instead of a text_token. Once I made the change, the NoneType was resolved.

Thank you for your help!! :grinning:

@fkoerner Hello! I had a follow-up question about this custom component. Just to give more context I can explain what I am trying to do.

I have a chatbot that is able to talk to someone via email or text. During a conversation at any time a person can say ‘I prefer to text’ or ‘please only email me’ along with the rest of their sentence.

For example, a person may say ‘Can you please text me? I would like to make an appointment tomorrow at noon’. In this case, the intent should be ‘create_appointment’, but I would also like to be able to classify that the communication preference for this user is being mentioned.

I thought that possibly labeling this sentence as preference-text in the metadata and using a custom component could be a solution, but the accuracy is not great.

Do you think this is a viable solution, or is there another way this could be accomplished in a better way? I have even considered creating another NLU model, but that did not seem like a seamless option.

Thank you for your help!!

Abhi :slight_smile:

Hi @abhi! Sorry for the slow response :confused:

I think the answer will depend on how you intend to classify/extract the preferred communication channel. In this case, I think you could also handle this as an entity, and could set a preferred_communication slot. I think this could work better than say, treating sentiment as an entity, because there’s a limited number of communication channels, and far fewer ways to say “I want to text” than there are ways to express positive sentiment, for example.

You could also consider multi-intents if you expect there to be a limited number of multi-intents (this can quickly blow out of proportion), it sounds like this might be the case.

@fkoerner No problem at all. Thank you for getting back to me!

That makes sense. I came to the same conclusion with multi-intents as I was drafting it. It had too many different combinations. The entity approach was what I used. I ended up training a custom classifier in the custom component to extract the entity from the text. This is actually where I currently am. I am attempting to use rasa train --cross-validate and I am getting an error that says the following:

File "/opt/venv/lib/python3.8/site-packages/rasa/nlu/test.py", line 1120, in align_entity_predictions entities_by_extractors[p[EXTRACTOR]].append(p)
KeyError: 'communication_preference_extractor'
Error: Process completed with exit code 1.

For some additional information, I have the communication_preference_extractor working in the custom component, and there are no annotated examples in the NLU for communication_preference for the cross validation to occur. I am just not sure how to go about not getting this error or if i’ve missed something somewhere.

Thank you!

Hi!

Did you extend Extractor for your communication_preference_extractor? You are getting prediction results with entities extracted by communication_preference_extractor. When we align entities, we sort the entities into a dict where the key is the extractor and the values are the entities extracted. The dict doesn’t contain the communication_preference_extractor, because it wasn’t recognized as an extractor. isinstance(c, EntityExtractor) needs to evaluate as true for c=communication_preference_extractor.

1 Like

That was the exact issue. Once I changed the extension from component to extractor, it worked. Thank you for all your help!!

1 Like

@abhi you’re too quick, you’ve been figuring these out just fine on your own :rocket: Happy bot building!

1 Like