diff --git a/.gitignore b/.gitignore index b22c141..09da353 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ data/users.db # local experimentation local/ +.vscode/ diff --git a/data/explainability/explanation_templates.yaml b/data/explainability/explanation_templates.yaml new file mode 100644 index 0000000..ed60481 --- /dev/null +++ b/data/explainability/explanation_templates.yaml @@ -0,0 +1,24 @@ +genres: + - "You [don't ]like {}." + - "You [don't ]watch {} movies." + - "You [don't ]enjoy {} films." + +actors: + - "You [don't ]like movies with {}." + - "You [don't ]watch films if {} are in them." + - "You [don't ]care for {} in movies." + +directors: + - "You [don't ]like {}' movies." + - "You [don't ]watch films by {}." + - "You [don't ]enjoy movies made by {}." + +keywords: + - "You [don't ]like movies about {}." + - "You [don't ]watch films with {}." + - "You [don't ]care for {} in movies." + +year: + - "You [don't ]like movies from {}." + - "You [don't ]watch films made in {}." + - "You [don't ]enjoy movies from {}." diff --git a/moviebot/explainability/__init__.py b/moviebot/explainability/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/moviebot/explainability/explainable_user_model.py b/moviebot/explainability/explainable_user_model.py new file mode 100644 index 0000000..3fee96a --- /dev/null +++ b/moviebot/explainability/explainable_user_model.py @@ -0,0 +1,30 @@ +"""Abstract class for creating explainable user models.""" + +from abc import ABC, abstractmethod +from typing import Dict + +from dialoguekit.core import AnnotatedUtterance + +UserPreferences = Dict[str, Dict[str, float]] + + +class ExplainableUserModel(ABC): + @abstractmethod + def generate_explanation( + self, user_preferences: UserPreferences + ) -> AnnotatedUtterance: + """Generates an explanation based on the provided input data. + + Args: + input_data: The input data for which an explanation is to be + generated. + + Returns: + A system utterance containing an explanation. + + Raises: + NotImplementedError: This method must be implemented by a subclass. + """ + raise NotImplementedError( + "This method must be implemented by a subclass." + ) diff --git a/moviebot/explainability/explainable_user_model_tag_based.py b/moviebot/explainability/explainable_user_model_tag_based.py new file mode 100644 index 0000000..5638673 --- /dev/null +++ b/moviebot/explainability/explainable_user_model_tag_based.py @@ -0,0 +1,94 @@ +"""Class for creating a tag-based user model explanations. + +The class generates explanations for user preferences in the movie domain. +Currently, the explanations are based on movie tags/attributes that were +explicitly mentioned by the user in the conversation. +Future versions of the class will also support implicit tags/attributes, which +are inferred from the movie recommendation feedback. Explanations are based on +the templates loaded from a YAML file. +""" + +import os +import random +import re + +import yaml +from dialoguekit.core import AnnotatedUtterance +from dialoguekit.participant import DialogueParticipant + +from moviebot.explainability.explainable_user_model import ( + ExplainableUserModel, + UserPreferences, +) + +_DEFAULT_TEMPLATE_FILE = "data/explainability/explanation_templates.yaml" + + +class ExplainableUserModelTagBased(ExplainableUserModel): + def __init__(self, template_file: str = _DEFAULT_TEMPLATE_FILE): + """Initializes the ExplainableUserModelTagBased class. + + Args: + template_file: Path to the YAML file containing explanation + templates. Defaults to _DEFAULT_TEMPLATE_FILE. + + Raises: + FileNotFoundError: The template file could not be found. + """ + if not os.isfile(template_file): + raise FileNotFoundError( + f"Could not find template file {template_file}." + ) + + with open(template_file, "r") as f: + self.templates = yaml.safe_load(f) + + def generate_explanation( + self, user_preferences: UserPreferences + ) -> AnnotatedUtterance: + """Generates an explanation based on the provided user preferences. + + Args: + user_preferences: User preferences. + + Returns: + The generated explanation. + """ + explanation = "" + for category, prefs in user_preferences.items(): + positive_tags = [tag for tag, value in prefs.items() if value == 1] + negative_tags = [tag for tag, value in prefs.items() if value == -1] + + for i, tags in enumerate([positive_tags, negative_tags]): + if len(tags) == 0: + continue + + concatenated_tags = ", ".join(tags) + template = random.choice(self.templates[category]).format( + concatenated_tags + ) + + explanation += self._clean_negative_keyword( + template, remove=i == 0 + ) + + return AnnotatedUtterance(explanation, DialogueParticipant.AGENT) + + def _clean_negative_keyword( + self, template: str, remove: bool = True + ) -> str: + """Removes or keeps negation in template. + + Args: + template: Template containing negative keyword. + remove: If True, remove the negative keyword. Defaults to True. + + Returns: + Template with negative keyword removed or replaced. + """ + if remove: + return re.sub(r"\[.*?\]", "", template) + + chars_to_remove = "[]" + trans = str.maketrans("", "", chars_to_remove) + return template.translate(trans) diff --git a/tests/explainability/test_explainable_user_model_tag_based.py b/tests/explainability/test_explainable_user_model_tag_based.py new file mode 100644 index 0000000..a2a81a1 --- /dev/null +++ b/tests/explainability/test_explainable_user_model_tag_based.py @@ -0,0 +1,43 @@ +import pytest + +from moviebot.explainability.explainable_user_model_tag_based import ( + ExplainableUserModelTagBased, +) + + +@pytest.fixture +def explainable_model() -> ExplainableUserModelTagBased: + return ExplainableUserModelTagBased() + + +def test_generate_explanation_positive(explainable_model): + user_prefs = {"genres": {"action": 1, "comedy": 1}} + explanation = explainable_model.generate_explanation(user_prefs) + assert "action" in explanation.text + assert "comedy" in explanation.text + + +def test_generate_explanation_negative(explainable_model): + user_prefs = {"actors": {"Tom Hanks": -1, "Adam": -1}} + explanation = explainable_model.generate_explanation(user_prefs) + assert "Tom Hanks" in explanation.text + assert "Adam" in explanation.text + + +def test_generate_explanation_mixed(explainable_model): + user_prefs = {"keywords": {"war theme": 1, "comic": -1}} + explanation = explainable_model.generate_explanation(user_prefs) + assert "war theme" in explanation.text + assert "comic" in explanation.text + + +def test_clean_negative_keyword_remove(explainable_model): + template = "You [don't ]like action." + cleaned = explainable_model._clean_negative_keyword(template) + assert cleaned == "You like action." + + +def test_clean_negative_keyword_keep(explainable_model): + template = "You [don't ]like action." + cleaned = explainable_model._clean_negative_keyword(template, remove=False) + assert cleaned == "You don't like action."