From 2150bbbe7da8f068d03edaab563fbaf66147b735 Mon Sep 17 00:00:00 2001 From: IKostric Date: Tue, 26 Sep 2023 11:15:42 +0200 Subject: [PATCH 1/4] Create explainable user model classes --- moviebot/explainability/__init__.py | 0 .../explainability/explainable_user_model.py | 30 +++++++ .../explainable_user_model_tag_based.py | 81 +++++++++++++++++++ .../explainability/explanation_templates.yaml | 24 ++++++ .../test_explainable_user_model_tag_based.py | 45 +++++++++++ 5 files changed, 180 insertions(+) create mode 100644 moviebot/explainability/__init__.py create mode 100644 moviebot/explainability/explainable_user_model.py create mode 100644 moviebot/explainability/explainable_user_model_tag_based.py create mode 100644 moviebot/explainability/explanation_templates.yaml create mode 100644 tests/explainability/test_explainable_user_model_tag_based.py 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..81feb05 --- /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: + """Generate an explanation based on the provided input data. + + Args: + input_data: The input data for which an explanation is to be + generated. + + Returns: + The generated 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..bfbc5b4 --- /dev/null +++ b/moviebot/explainability/explainable_user_model_tag_based.py @@ -0,0 +1,81 @@ +"""This module contains the ExplainableUserModelTagBased class. + +The class generates explanations for user preferences in the movie domain based +on templates loaded from a YAML file. +""" + +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 = "moviebot/explainability/explanation_templates.yaml" + + +class ExplainableUserModelTagBased(ExplainableUserModel): + def __init__(self, template_file: str = _DEFAULT_TEMPLATE_FILE): + """Initialize the ExplainableUserModelTagBased class. + + Args: + template_file: Path to the YAML file containing explanation + templates. Defaults to _DEFAULT_TEMPLATE_FILE. + """ + with open(template_file, "r") as f: + self.templates = yaml.safe_load(f) + + def generate_explanation( + self, user_preferences: UserPreferences + ) -> AnnotatedUtterance: + """Generate an explanation based on the provided user preferences. + + Args: + user_preferences: Nested dictionary of 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. + + 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/moviebot/explainability/explanation_templates.yaml b/moviebot/explainability/explanation_templates.yaml new file mode 100644 index 0000000..ed60481 --- /dev/null +++ b/moviebot/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/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..17003fd --- /dev/null +++ b/tests/explainability/test_explainable_user_model_tag_based.py @@ -0,0 +1,45 @@ +import pytest + +from moviebot.explainability.explainable_user_model_tag_based import ( + ExplainableUserModelTagBased, +) + + +@pytest.fixture +def explainable_model(): + return ExplainableUserModelTagBased( + "moviebot/explainability/explanation_templates.yaml" + ) + + +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." From af729d74682ee3629f5b306acf5a1242cf8a06ed Mon Sep 17 00:00:00 2001 From: IKostric Date: Wed, 18 Oct 2023 01:38:31 +0200 Subject: [PATCH 2/4] Address review comments --- .gitignore | 1 + .../explainability/explanation_templates.yaml | 0 .../explainability/explainable_user_model.py | 4 ++-- .../explainable_user_model_tag_based.py | 23 +++++++++++++------ .../test_explainable_user_model_tag_based.py | 6 ++--- 5 files changed, 21 insertions(+), 13 deletions(-) rename {moviebot => data}/explainability/explanation_templates.yaml (100%) 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/moviebot/explainability/explanation_templates.yaml b/data/explainability/explanation_templates.yaml similarity index 100% rename from moviebot/explainability/explanation_templates.yaml rename to data/explainability/explanation_templates.yaml diff --git a/moviebot/explainability/explainable_user_model.py b/moviebot/explainability/explainable_user_model.py index 81feb05..3fee96a 100644 --- a/moviebot/explainability/explainable_user_model.py +++ b/moviebot/explainability/explainable_user_model.py @@ -13,14 +13,14 @@ class ExplainableUserModel(ABC): def generate_explanation( self, user_preferences: UserPreferences ) -> AnnotatedUtterance: - """Generate an explanation based on the provided input data. + """Generates an explanation based on the provided input data. Args: input_data: The input data for which an explanation is to be generated. Returns: - The generated explanation. + A system utterance containing an explanation. Raises: 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 index bfbc5b4..1e20ea5 100644 --- a/moviebot/explainability/explainable_user_model_tag_based.py +++ b/moviebot/explainability/explainable_user_model_tag_based.py @@ -1,9 +1,10 @@ -"""This module contains the ExplainableUserModelTagBased class. +"""Class for creating a tag-based user model explanations. The class generates explanations for user preferences in the movie domain based on templates loaded from a YAML file. """ +import os import random import re @@ -16,27 +17,35 @@ UserPreferences, ) -_DEFAULT_TEMPLATE_FILE = "moviebot/explainability/explanation_templates.yaml" +_DEFAULT_TEMPLATE_FILE = "data/explainability/explanation_templates.yaml" class ExplainableUserModelTagBased(ExplainableUserModel): def __init__(self, template_file: str = _DEFAULT_TEMPLATE_FILE): - """Initialize the ExplainableUserModelTagBased class. + """Initializes the ExplainableUserModelTagBased class. Args: template_file: Path to the YAML file containing explanation - templates. Defaults to _DEFAULT_TEMPLATE_FILE. + 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: - """Generate an explanation based on the provided user preferences. + """Generates an explanation based on the provided user preferences. Args: - user_preferences: Nested dictionary of user preferences. + user_preferences: User preferences. Returns: The generated explanation. @@ -68,7 +77,7 @@ def _clean_negative_keyword( Args: template: Template containing negative keyword. - remove: If True, remove the negative keyword. + remove: If True, remove the negative keyword. Defaults to True. Returns: Template with negative keyword removed or replaced. diff --git a/tests/explainability/test_explainable_user_model_tag_based.py b/tests/explainability/test_explainable_user_model_tag_based.py index 17003fd..a2a81a1 100644 --- a/tests/explainability/test_explainable_user_model_tag_based.py +++ b/tests/explainability/test_explainable_user_model_tag_based.py @@ -6,10 +6,8 @@ @pytest.fixture -def explainable_model(): - return ExplainableUserModelTagBased( - "moviebot/explainability/explanation_templates.yaml" - ) +def explainable_model() -> ExplainableUserModelTagBased: + return ExplainableUserModelTagBased() def test_generate_explanation_positive(explainable_model): From 2600bd36f0e6d41a72eb68ad02cd6e19c28a1c79 Mon Sep 17 00:00:00 2001 From: IKostric Date: Fri, 27 Oct 2023 10:05:59 +0200 Subject: [PATCH 3/4] Update docstrings --- .../explainability/explainable_user_model_tag_based.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/moviebot/explainability/explainable_user_model_tag_based.py b/moviebot/explainability/explainable_user_model_tag_based.py index 1e20ea5..a90dc49 100644 --- a/moviebot/explainability/explainable_user_model_tag_based.py +++ b/moviebot/explainability/explainable_user_model_tag_based.py @@ -1,7 +1,10 @@ """Class for creating a tag-based user model explanations. -The class generates explanations for user preferences in the movie domain based -on templates loaded from a YAML file. +The class generates explanations for user preferences in the movie domain. +Currently, the explanations are based on the explicit movie tags/attributes. +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 From 8c68b1d216c34e4d2ecc5d061e05a08f2f325733 Mon Sep 17 00:00:00 2001 From: IKostric Date: Fri, 27 Oct 2023 10:51:39 +0200 Subject: [PATCH 4/4] Expand docstrings --- moviebot/explainability/explainable_user_model_tag_based.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/moviebot/explainability/explainable_user_model_tag_based.py b/moviebot/explainability/explainable_user_model_tag_based.py index a90dc49..5638673 100644 --- a/moviebot/explainability/explainable_user_model_tag_based.py +++ b/moviebot/explainability/explainable_user_model_tag_based.py @@ -1,7 +1,8 @@ """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 the explicit movie tags/attributes. +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.