public code v1
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
"""Test suite for RuleBasedGroupRecExplainer."""
|
||||
|
||||
import pytest
|
||||
import pandas as pd
|
||||
from unittest.mock import Mock, MagicMock
|
||||
import logging
|
||||
|
||||
from pygrex.explain.groups.rule_based_group_rec_explainer import RuleBasedGroupRecExplainer
|
||||
|
||||
|
||||
"""Test cases for RuleBasedGroupRecExplainer class."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_data_reader():
|
||||
"""Create a mock DataReader."""
|
||||
data_reader = Mock()
|
||||
data_reader.get_new_item_id.side_effect = lambda x: x # Return the same ID
|
||||
return data_reader
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_rules():
|
||||
"""Create sample association rules DataFrame."""
|
||||
rules_data = {
|
||||
"antecedents": [
|
||||
{"item1", "item2"},
|
||||
{"item3"},
|
||||
{"item1"},
|
||||
{"item4", "item5"},
|
||||
],
|
||||
"consequents": [{"rec1"}, {"rec2"}, {"rec1"}, {"rec3"}],
|
||||
"confidence": [0.8, 0.9, 0.7, 0.6],
|
||||
"support": [0.3, 0.4, 0.2, 0.1],
|
||||
}
|
||||
return pd.DataFrame(rules_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_user_history():
|
||||
"""Create sample user history."""
|
||||
return {
|
||||
"user1": {"item1", "item2", "item6"},
|
||||
"user2": {"item3", "item7"},
|
||||
"user3": {"item1", "item4", "item5"},
|
||||
"user4": {"item2", "item8"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def explainer(sample_rules, mock_data_reader, sample_user_history):
|
||||
"""Create a RuleBasedGroupRecExplainer instance."""
|
||||
return RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules,
|
||||
data=mock_data_reader,
|
||||
pool_recommendations=["rec1", "rec2", "rec3"],
|
||||
members=["user1", "user2", "user3"],
|
||||
user_history=sample_user_history,
|
||||
min_members_threshold=2,
|
||||
)
|
||||
|
||||
|
||||
class TestInitialization:
|
||||
"""Test initialization and parameter validation."""
|
||||
|
||||
def test_init_with_valid_parameters(self, sample_rules, mock_data_reader):
|
||||
"""Test successful initialization with valid parameters."""
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules,
|
||||
data=mock_data_reader,
|
||||
pool_recommendations=["rec1", "rec2"],
|
||||
members=["user1", "user2"],
|
||||
user_history={"user1": {"item1"}},
|
||||
min_members_threshold=1,
|
||||
)
|
||||
|
||||
assert explainer.rules is sample_rules
|
||||
assert explainer.data is mock_data_reader
|
||||
assert explainer.pool_recommendations == ["rec1", "rec2"]
|
||||
assert explainer.members == ["user1", "user2"]
|
||||
assert explainer.user_history == {"user1": {"item1"}}
|
||||
assert explainer.min_members_threshold == 1
|
||||
|
||||
def test_init_with_invalid_threshold(self, sample_rules, mock_data_reader):
|
||||
"""Test initialization with invalid min_members_threshold."""
|
||||
with pytest.raises(
|
||||
ValueError, match="min_members_threshold must be at least 1"
|
||||
):
|
||||
RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules, data=mock_data_reader, min_members_threshold=0
|
||||
)
|
||||
|
||||
def test_init_with_defaults(self, sample_rules, mock_data_reader):
|
||||
"""Test initialization with default parameters."""
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules, data=mock_data_reader
|
||||
)
|
||||
|
||||
assert explainer.pool_recommendations == []
|
||||
assert explainer.members == []
|
||||
assert explainer.user_history == {}
|
||||
assert explainer.min_members_threshold == 1
|
||||
|
||||
def test_normalize_recommendations_single_item(
|
||||
self, sample_rules, mock_data_reader
|
||||
):
|
||||
"""Test normalization of single recommendation item."""
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules, data=mock_data_reader, pool_recommendations="rec1"
|
||||
)
|
||||
assert explainer.pool_recommendations == ["rec1"]
|
||||
|
||||
def test_normalize_recommendations_list(self, sample_rules, mock_data_reader):
|
||||
"""Test normalization of recommendation list."""
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules,
|
||||
data=mock_data_reader,
|
||||
pool_recommendations=["rec1", "rec2"],
|
||||
)
|
||||
assert explainer.pool_recommendations == ["rec1", "rec2"]
|
||||
|
||||
def test_normalize_recommendations_none(self, sample_rules, mock_data_reader):
|
||||
"""Test normalization of None recommendations."""
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules, data=mock_data_reader, pool_recommendations=None
|
||||
)
|
||||
assert explainer.pool_recommendations == []
|
||||
|
||||
|
||||
class TestPrivateMethods:
|
||||
"""Test private methods."""
|
||||
|
||||
def test_is_rule_satisfied_by_member_true(self, explainer):
|
||||
"""Test rule satisfaction when member has all antecedent items."""
|
||||
# user1 has item1 and item2
|
||||
result = explainer._is_rule_satisfied_by_member("user1", {"item1", "item2"})
|
||||
assert result is True
|
||||
|
||||
def test_is_rule_satisfied_by_member_false(self, explainer):
|
||||
"""Test rule satisfaction when member doesn't have all antecedent items."""
|
||||
# user2 only has item3, not item1 and item2
|
||||
result = explainer._is_rule_satisfied_by_member("user2", {"item1", "item2"})
|
||||
assert result is False
|
||||
|
||||
def test_is_rule_satisfied_by_member_empty_history(self, explainer):
|
||||
"""Test rule satisfaction for member with empty history."""
|
||||
result = explainer._is_rule_satisfied_by_member("nonexistent_user", {"item1"})
|
||||
assert result is False
|
||||
|
||||
def test_count_satisfied_members(self, explainer):
|
||||
"""Test counting members who satisfy antecedent."""
|
||||
# Only user1 and user3 have item1
|
||||
count = explainer._count_satisfied_members({"item1"})
|
||||
assert count == 2
|
||||
|
||||
def test_count_satisfied_members_complex_antecedent(self, explainer):
|
||||
"""Test counting with complex antecedent."""
|
||||
# Only user1 has both item1 and item2
|
||||
count = explainer._count_satisfied_members({"item1", "item2"})
|
||||
assert count == 1
|
||||
|
||||
def test_find_applicable_rules(self, explainer):
|
||||
"""Test finding rules applicable to an item."""
|
||||
applicable_rules = explainer._find_applicable_rules("rec1")
|
||||
|
||||
# Should find rules where 'rec1' is in consequents
|
||||
assert len(applicable_rules) == 2 # Rules with rec1 as consequent
|
||||
assert all(
|
||||
"rec1" in rule["consequents"] for _, rule in applicable_rules.iterrows()
|
||||
)
|
||||
|
||||
def test_can_explain_item_true(self, explainer):
|
||||
"""Test item explanation when rules are satisfied."""
|
||||
# rec1 should be explainable (rules with item1/item2 and item1 antecedents)
|
||||
result = explainer._can_explain_item("rec1")
|
||||
assert result is True
|
||||
|
||||
def test_can_explain_item_false(self, explainer):
|
||||
"""Test item explanation when no rules are satisfied."""
|
||||
# Create explainer with high threshold
|
||||
explainer.min_members_threshold = 5
|
||||
result = explainer._can_explain_item("rec1")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestAdvancedMethods:
|
||||
"""Test advanced explanation methods."""
|
||||
|
||||
def test_member_has_antecedent_item_true(self, explainer):
|
||||
"""Test when member has at least one antecedent item."""
|
||||
# user1 has item1 (from antecedent {item1, item2})
|
||||
result = explainer._member_has_antecedent_item("user1", {"item1", "item9"})
|
||||
assert result is True
|
||||
|
||||
def test_member_has_antecedent_item_false(self, explainer):
|
||||
"""Test when member has no antecedent items."""
|
||||
# user2 doesn't have item1 or item9
|
||||
result = explainer._member_has_antecedent_item("user2", {"item1", "item9"})
|
||||
assert result is False
|
||||
|
||||
def test_can_explain_item_advanced_both_conditions_true(self, explainer):
|
||||
"""Test advanced explanation when both conditions are met."""
|
||||
members_set = {"user1", "user3"} # Both have item1
|
||||
all_seen_items = {"item1", "item2", "item4", "item5", "item6"}
|
||||
|
||||
# Rule: {item1} -> {rec1}
|
||||
# Condition 1: Both users have item1 ✓
|
||||
# Condition 2: item1 is in all_seen_items ✓
|
||||
result = explainer._can_explain_item_advanced(
|
||||
"rec1", members_set, all_seen_items
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_can_explain_item_advanced_condition1_false(self, explainer):
|
||||
"""Test advanced explanation when condition 1 fails."""
|
||||
members_set = {"user1", "user2"} # user2 doesn't have item1
|
||||
all_seen_items = {"item1", "item2", "item3"}
|
||||
|
||||
# For rule {item1} -> {rec1}: user2 doesn't have item1
|
||||
result = explainer._can_explain_item_advanced(
|
||||
"rec1", members_set, all_seen_items
|
||||
)
|
||||
# This might still be True if other rules apply, so let's check specific case
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_can_explain_item_advanced_condition2_false(self, explainer):
|
||||
"""Test advanced explanation when condition 2 fails."""
|
||||
members_set = {"user1", "user3"}
|
||||
all_seen_items = {"item6", "item7"} # Missing antecedent items
|
||||
|
||||
result = explainer._can_explain_item_advanced(
|
||||
"rec1", members_set, all_seen_items
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestPublicMethods:
|
||||
"""Test public methods."""
|
||||
|
||||
def test_find_explanation_with_recommendations(self, explainer):
|
||||
"""Test explanation finding with recommendations."""
|
||||
fidelity = explainer.find_explanation()
|
||||
|
||||
assert isinstance(fidelity, float)
|
||||
assert 0.0 <= fidelity <= 1.0
|
||||
|
||||
def test_find_explanation_empty_recommendations(
|
||||
self, sample_rules, mock_data_reader
|
||||
):
|
||||
"""Test explanation finding with empty recommendations."""
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules, data=mock_data_reader, pool_recommendations=[]
|
||||
)
|
||||
|
||||
fidelity = explainer.find_explanation()
|
||||
assert fidelity == 0.0
|
||||
|
||||
def test_compute_group_fidelity_advanced_with_data(self, explainer):
|
||||
"""Test advanced group fidelity computation."""
|
||||
fidelity = explainer.compute_group_fidelity_advanced()
|
||||
|
||||
assert isinstance(fidelity, float)
|
||||
assert 0.0 <= fidelity <= 1.0
|
||||
|
||||
def test_compute_group_fidelity_advanced_empty_recommendations(
|
||||
self, sample_rules, mock_data_reader
|
||||
):
|
||||
"""Test advanced fidelity with empty recommendations."""
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules,
|
||||
data=mock_data_reader,
|
||||
pool_recommendations=[],
|
||||
members=["user1", "user2"],
|
||||
)
|
||||
|
||||
fidelity = explainer.compute_group_fidelity_advanced()
|
||||
assert fidelity == 0.0
|
||||
|
||||
def test_compute_group_fidelity_advanced_no_members(
|
||||
self, sample_rules, mock_data_reader
|
||||
):
|
||||
"""Test advanced fidelity with no members."""
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules,
|
||||
data=mock_data_reader,
|
||||
pool_recommendations=["rec1"],
|
||||
members=[],
|
||||
)
|
||||
|
||||
fidelity = explainer.compute_group_fidelity_advanced()
|
||||
assert fidelity == 0.0
|
||||
|
||||
def test_get_explanation_details(self, explainer):
|
||||
"""Test getting detailed explanations."""
|
||||
details = explainer.get_explanation_details()
|
||||
|
||||
assert isinstance(details, dict)
|
||||
assert all(
|
||||
item_id in explainer.pool_recommendations for item_id in details.keys()
|
||||
)
|
||||
|
||||
# Check structure of explanation details
|
||||
for item_id, explanations in details.items():
|
||||
assert isinstance(explanations, list)
|
||||
for explanation in explanations:
|
||||
assert "antecedent" in explanation
|
||||
assert "consequent" in explanation
|
||||
assert "satisfied_members" in explanation
|
||||
assert "confidence" in explanation
|
||||
assert "support" in explanation
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Test edge cases and error conditions."""
|
||||
|
||||
def test_with_string_and_int_ids(self, sample_rules, mock_data_reader):
|
||||
"""Test handling of mixed string and integer IDs."""
|
||||
user_history = {
|
||||
1: {10, 20}, # Integer user ID with integer item IDs
|
||||
"user2": {"item1", "item2"}, # String user ID with string item IDs
|
||||
}
|
||||
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules,
|
||||
data=mock_data_reader,
|
||||
pool_recommendations=[1, "rec1"],
|
||||
members=[1, "user2"],
|
||||
user_history=user_history,
|
||||
)
|
||||
|
||||
# Should not raise exceptions
|
||||
fidelity = explainer.find_explanation()
|
||||
assert isinstance(fidelity, float)
|
||||
|
||||
def test_empty_user_history(self, sample_rules, mock_data_reader):
|
||||
"""Test with completely empty user history."""
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules,
|
||||
data=mock_data_reader,
|
||||
pool_recommendations=["rec1"],
|
||||
members=["user1", "user2"],
|
||||
user_history={},
|
||||
)
|
||||
|
||||
fidelity = explainer.find_explanation()
|
||||
assert fidelity == 0.0
|
||||
|
||||
def test_rules_with_no_matches(self, mock_data_reader):
|
||||
"""Test with rules that don't match any recommendations."""
|
||||
rules_data = {
|
||||
"antecedents": [{"item1"}],
|
||||
"consequents": [{"other_rec"}],
|
||||
"confidence": [0.8],
|
||||
"support": [0.3],
|
||||
}
|
||||
rules = pd.DataFrame(rules_data)
|
||||
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=rules, # type: ignore
|
||||
data=mock_data_reader,
|
||||
pool_recommendations=["rec1"],
|
||||
members=["user1"],
|
||||
user_history={"user1": {"item1"}},
|
||||
)
|
||||
|
||||
fidelity = explainer.find_explanation()
|
||||
assert fidelity == 0.0
|
||||
|
||||
|
||||
class TestLogging:
|
||||
"""Test logging functionality."""
|
||||
|
||||
def test_logging_warnings(self, sample_rules, mock_data_reader, caplog):
|
||||
"""Test that appropriate warnings are logged."""
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules,
|
||||
data=mock_data_reader,
|
||||
pool_recommendations=[],
|
||||
members=[],
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
explainer.find_explanation()
|
||||
|
||||
assert "No recommendations to explain" in caplog.text
|
||||
|
||||
def test_logging_info(self, explainer, caplog):
|
||||
"""Test that info messages are logged."""
|
||||
with caplog.at_level(logging.INFO):
|
||||
explainer.find_explanation()
|
||||
|
||||
assert "Explained" in caplog.text
|
||||
assert "fidelity" in caplog.text
|
||||
|
||||
|
||||
# Integration tests
|
||||
class TestIntegration:
|
||||
"""Integration tests combining multiple components."""
|
||||
|
||||
def test_full_workflow(self, sample_rules, mock_data_reader):
|
||||
"""Test complete workflow from initialization to explanation."""
|
||||
user_history = {
|
||||
"user1": {"item1", "item2"},
|
||||
"user2": {"item3"},
|
||||
"user3": {"item1", "item4", "item5"},
|
||||
}
|
||||
|
||||
explainer = RuleBasedGroupRecExplainer(
|
||||
rules=sample_rules,
|
||||
data=mock_data_reader,
|
||||
pool_recommendations=["rec1", "rec2", "rec3"],
|
||||
members=["user1", "user2", "user3"],
|
||||
user_history=user_history, # type: ignore
|
||||
min_members_threshold=1,
|
||||
)
|
||||
|
||||
# Test basic explanation
|
||||
basic_fidelity = explainer.find_explanation()
|
||||
assert isinstance(basic_fidelity, float)
|
||||
|
||||
# Test advanced explanation
|
||||
advanced_fidelity = explainer.compute_group_fidelity_advanced()
|
||||
assert isinstance(advanced_fidelity, float)
|
||||
|
||||
# Test detailed explanations
|
||||
details = explainer.get_explanation_details()
|
||||
assert isinstance(details, dict)
|
||||
|
||||
# Fidelities should be between 0 and 1
|
||||
assert 0.0 <= basic_fidelity <= 1.0
|
||||
assert 0.0 <= advanced_fidelity <= 1.0
|
||||
@@ -0,0 +1,392 @@
|
||||
import pytest
|
||||
import pandas as pd
|
||||
import numpy as np
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
from typing import List, Union
|
||||
|
||||
from pygrex.config import cfg
|
||||
from pygrex.data_reader.data_reader import DataReader
|
||||
from pygrex.data_reader.group_interaction_handler import GroupInteractionHandler
|
||||
from pygrex.explain.groups.sliding_window_explainer import SlidingWindowExplainer
|
||||
from pygrex.models.recommender_model import RecommenderModel
|
||||
from pygrex.recommender.group_recommender import GroupRecommender
|
||||
|
||||
|
||||
class TestSlidingWindowExplainer:
|
||||
"""Test suite for the SlidingWindowExplainer class."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config(self):
|
||||
"""Create a mock configuration object."""
|
||||
mock_cfg = Mock(spec=cfg)
|
||||
return mock_cfg
|
||||
|
||||
@pytest.fixture
|
||||
def mock_data_reader(self):
|
||||
"""Create a mock DataReader with sample data."""
|
||||
# Create a sample dataset with user-item interactions
|
||||
data = {
|
||||
"userId": [1, 1, 1, 2, 2, 3, 3, 3],
|
||||
"itemId": [101, 102, 103, 101, 104, 102, 103, 105],
|
||||
"rating": [5, 4, 3, 4, 5, 3, 4, 5],
|
||||
"timestamp": [1700000000, 1700100000, 1700200000, 1700300000, 1700400000, 1700500000, 1700600000, 1700700000],
|
||||
}
|
||||
df = pd.DataFrame(data)
|
||||
|
||||
# Create a mock DataReader with the sample dataset
|
||||
mock_reader = Mock(spec=DataReader)
|
||||
mock_reader.dataset = df
|
||||
|
||||
# Mock ID mapping methods
|
||||
mock_reader.get_new_user_id = lambda x: int(
|
||||
x
|
||||
) # Just return the ID as is for testing
|
||||
mock_reader.get_new_item_id = lambda x: int(
|
||||
x
|
||||
) # Just return the ID as is for testing
|
||||
mock_reader.make_consecutive_ids_in_dataset = Mock()
|
||||
mock_reader.binarize = Mock()
|
||||
|
||||
return mock_reader
|
||||
|
||||
@pytest.fixture
|
||||
def mock_group_handler(self):
|
||||
"""Create a mock GroupInteractionHandler."""
|
||||
mock_handler = Mock(spec=GroupInteractionHandler)
|
||||
|
||||
# Mock create_modified_dataset to return modified DataFrame
|
||||
def modified_dataset_func(*args, **kwargs):
|
||||
# Create a slightly modified version of the original dataset
|
||||
modified_df = kwargs.get("original_data").copy()
|
||||
return modified_df
|
||||
|
||||
mock_handler.create_modified_dataset = modified_dataset_func
|
||||
return mock_handler
|
||||
|
||||
@pytest.fixture
|
||||
def mock_recommender_model(self):
|
||||
"""Create a mock recommender model."""
|
||||
mock_model = Mock(spec=RecommenderModel)
|
||||
mock_model.fit = Mock(return_value=None)
|
||||
return mock_model
|
||||
|
||||
@pytest.fixture
|
||||
def mock_sliding_window(self):
|
||||
"""Create a mock sliding window."""
|
||||
mock_window = Mock()
|
||||
|
||||
# Set up the get_next_window method to return windows in sequence
|
||||
mock_window.get_next_window = Mock(
|
||||
side_effect=[
|
||||
[101, 102], # First window
|
||||
[103, 104], # Second window
|
||||
None, # End of windows
|
||||
]
|
||||
)
|
||||
|
||||
return mock_window
|
||||
|
||||
@pytest.fixture
|
||||
def mock_group_recommender(self):
|
||||
"""Create a mock group recommender."""
|
||||
with patch(
|
||||
"pygrex.recommender.group_recommender.GroupRecommender", autospec=True
|
||||
) as mock_gr:
|
||||
# Configure the mock to return predictable recommendations
|
||||
instance = mock_gr.return_value
|
||||
instance.setup_recommendation = Mock()
|
||||
|
||||
# Make get_group_recommendations return different values based on input
|
||||
def get_recommendations(n):
|
||||
# Default recommendation includes target item 200
|
||||
return [200, 201, 202]
|
||||
|
||||
instance.get_group_recommendations = Mock(side_effect=get_recommendations)
|
||||
yield mock_gr
|
||||
|
||||
@pytest.fixture
|
||||
def explainer(
|
||||
self,
|
||||
mock_config,
|
||||
mock_data_reader,
|
||||
mock_group_handler,
|
||||
mock_recommender_model,
|
||||
mock_sliding_window,
|
||||
):
|
||||
"""Create a SlidingWindowExplainer instance with mocked dependencies."""
|
||||
# Create an explainer with test data
|
||||
explainer = SlidingWindowExplainer(
|
||||
config=mock_config,
|
||||
data=mock_data_reader,
|
||||
group_handler=mock_group_handler,
|
||||
members=[1, 2, 3],
|
||||
target_item=200,
|
||||
model=mock_recommender_model,
|
||||
)
|
||||
return explainer
|
||||
|
||||
def test_initialization(self, explainer):
|
||||
"""Test that the explainer initializes with correct attributes."""
|
||||
assert explainer.members == [1, 2, 3]
|
||||
assert explainer.target_item == 200
|
||||
# candidate_items no longer kept on the explainer API
|
||||
assert explainer.calls == 0
|
||||
assert explainer.explanations_found == {}
|
||||
|
||||
def test_set_sliding_window(self, explainer):
|
||||
"""Test setting the sliding window after initialization."""
|
||||
new_window = Mock()
|
||||
explainer.set_sliding_window(new_window)
|
||||
assert explainer.sliding_window == new_window
|
||||
|
||||
def test_find_explanation_no_sliding_window(self, explainer):
|
||||
pytest.skip("find_explanation no longer depends on a preset sliding window")
|
||||
|
||||
@patch.object(SlidingWindowExplainer, "_test_window_removal")
|
||||
@patch.object(SlidingWindowExplainer, "_find_minimal_subset")
|
||||
def test_find_explanation_no_explanations_found(
|
||||
self, mock_find_minimal, mock_test_window, explainer
|
||||
):
|
||||
"""Test behavior when no explanations are found."""
|
||||
# Make _test_window_removal always return False (no effect on recommendations)
|
||||
mock_test_window.return_value = False
|
||||
|
||||
# Prepare minimal valid inputs for new API
|
||||
items_rated_by_group = [101, 102, 103, 104]
|
||||
group_predictions = {1: {101: 4.0}, 2: {102: 3.5}, 3: {103: 4.2}}
|
||||
top_recommendation = 200
|
||||
ranking_weights = {"popularity": 1, "intensity": 1, "rating": 1, "relevance": 1, "trend": 0}
|
||||
result = explainer.find_explanation(items_rated_by_group, group_predictions, top_recommendation, ranking_weights)
|
||||
|
||||
# Check that window was tested but no minimal subset was searched
|
||||
assert mock_test_window.call_count > 0
|
||||
assert mock_find_minimal.call_count == 0
|
||||
assert result == {} # No explanations found
|
||||
|
||||
@patch.object(SlidingWindowExplainer, "_test_window_removal")
|
||||
@patch.object(SlidingWindowExplainer, "_find_minimal_subset")
|
||||
def test_find_explanation_found(
|
||||
self, mock_find_minimal, mock_test_window, explainer
|
||||
):
|
||||
"""Test behavior when an explanation is found."""
|
||||
# Make second window test return True (has effect on recommendations)
|
||||
mock_test_window.side_effect = [False, True]
|
||||
|
||||
items_rated_by_group = [101, 102, 103, 104]
|
||||
group_predictions = {1: {101: 4.0}, 2: {102: 3.5}, 3: {103: 4.2}}
|
||||
top_recommendation = 200
|
||||
ranking_weights = {"popularity": 1, "intensity": 1, "rating": 1, "relevance": 1, "trend": 0}
|
||||
explainer.find_explanation(items_rated_by_group, group_predictions, top_recommendation, ranking_weights)
|
||||
|
||||
# Check that minimal subset was searched for the second window
|
||||
assert mock_test_window.call_count == 2
|
||||
assert mock_find_minimal.call_count == 1
|
||||
# Check that the window passed contains the later items
|
||||
passed_window = mock_find_minimal.call_args[0][0]
|
||||
assert 103 in passed_window and 104 in passed_window
|
||||
|
||||
@patch("pygrex.explain.groups.sliding_window_explainer.GroupRecommender")
|
||||
def test_get_recommendations_after_removal(
|
||||
self,
|
||||
mock_group_recommender_cls,
|
||||
explainer,
|
||||
mock_data_reader,
|
||||
):
|
||||
"""Test getting recommendations after removing items."""
|
||||
|
||||
# Create a mock GroupRecommender instance
|
||||
mock_recommender_instance = Mock()
|
||||
mock_group_recommender_cls.return_value = mock_recommender_instance
|
||||
|
||||
# Mock methods on the GroupRecommender instance
|
||||
mock_recommender_instance.get_group_recommendations.return_value = [
|
||||
201,
|
||||
202,
|
||||
203,
|
||||
]
|
||||
mock_recommender_instance.setup_recommendation = (
|
||||
Mock()
|
||||
) # Mock the method itself
|
||||
|
||||
# Mock the internal methods on the ACTUAL 'explainer' (SlidingWindowExplainer instance)
|
||||
# 'explainer' here is now the fixture instance, as intended.
|
||||
explainer._create_data_reader_and_prepare = Mock(return_value=mock_data_reader)
|
||||
explainer._retrain_model = Mock(
|
||||
return_value=Mock()
|
||||
) # Assuming _retrain_model returns a model mock
|
||||
explainer.group_handler.create_modified_dataset = Mock(
|
||||
return_value=mock_data_reader.dataset
|
||||
)
|
||||
|
||||
# Test the method on the actual 'explainer' instance
|
||||
result = explainer._get_recommendations_after_removal([101, 102])
|
||||
|
||||
# Verify the result
|
||||
# 'result' should now be [201, 202, 203] because it comes from
|
||||
# mock_recommender_instance.get_group_recommendations
|
||||
assert result == [201, 202, 203]
|
||||
|
||||
# Verify that the GroupRecommender CLASS was instantiated with the correct data reader
|
||||
# Inside _get_recommendations_after_removal:
|
||||
# data_retrained = self._create_data_reader_and_prepare(...) # returns mock_data_reader
|
||||
# group_recommender = GroupRecommender(data_retrained) # This is the call we're checking
|
||||
mock_group_recommender_cls.assert_called_once_with(mock_data_reader)
|
||||
|
||||
# Verify that setup_recommendation was called ON THE INSTANCE
|
||||
mock_recommender_instance.setup_recommendation.assert_called_once()
|
||||
|
||||
# Verify that get_group_recommendations was called ON THE INSTANCE with default top_n=10
|
||||
mock_recommender_instance.get_group_recommendations.assert_called_once_with(10)
|
||||
|
||||
def test_test_window_removal_target_removed(self, explainer):
|
||||
"""Test that _test_window_removal returns True when target item is removed from recommendations."""
|
||||
# Mock _get_recommendations_after_removal to return recommendations without target item
|
||||
explainer._get_recommendations_after_removal = Mock(
|
||||
return_value=[201, 202, 203]
|
||||
)
|
||||
|
||||
result = explainer._test_window_removal([101, 102], 200)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_test_window_removal_target_still_present(self, explainer):
|
||||
"""Test that _test_window_removal returns False when target item remains in recommendations."""
|
||||
# Mock _get_recommendations_after_removal to return recommendations with target item
|
||||
explainer._get_recommendations_after_removal = Mock(
|
||||
return_value=[200, 201, 202]
|
||||
)
|
||||
|
||||
result = explainer._test_window_removal([103, 104], 200)
|
||||
|
||||
assert result is False
|
||||
|
||||
@patch.object(SlidingWindowExplainer, "_get_recommendations_after_removal")
|
||||
@patch.object(SlidingWindowExplainer, "_record_explanation")
|
||||
def test_find_minimal_subset_found(self, mock_record, mock_get_recs, explainer):
|
||||
"""Test finding a minimal subset that produces a counterfactual explanation."""
|
||||
|
||||
# Configure mock to make only [101] affect recommendations (not include target 200)
|
||||
def get_recs_side_effect(items, top_n=10):
|
||||
if items == [101]:
|
||||
return [201, 202, 203] # Without target item
|
||||
else:
|
||||
return [200, 201, 202] # With target item
|
||||
|
||||
mock_get_recs.side_effect = get_recs_side_effect
|
||||
|
||||
# Call the private method directly to focus on minimal subset logic
|
||||
explainer._find_minimal_subset([101, 102], 200)
|
||||
|
||||
# Verify _record_explanation was called with the minimal subset [101]
|
||||
assert mock_record.called
|
||||
assert mock_record.call_args[0][0] == [101]
|
||||
|
||||
@patch.object(SlidingWindowExplainer, "_get_recommendations_after_removal")
|
||||
@patch.object(SlidingWindowExplainer, "_record_explanation")
|
||||
def test_find_minimal_subset_not_found(self, mock_record, mock_get_recs, explainer):
|
||||
"""Test behavior when no minimal subset is found."""
|
||||
# Configure mock so no subset affects recommendations
|
||||
mock_get_recs.return_value = [200, 201, 202] # Always includes target item
|
||||
|
||||
# Call the method
|
||||
explainer._find_minimal_subset([101, 102], 200)
|
||||
|
||||
# Verify _record_explanation was not called
|
||||
assert not mock_record.called
|
||||
|
||||
@patch.object(SlidingWindowExplainer, "_calculate_item_intensity")
|
||||
@patch.object(SlidingWindowExplainer, "_calculate_user_intensity")
|
||||
def test_record_explanation(
|
||||
self, mock_user_intensity, mock_item_intensity, explainer, capfd
|
||||
):
|
||||
"""Test recording an explanation."""
|
||||
# Configure mocks
|
||||
mock_item_intensity.return_value = [0.5, 0.7]
|
||||
mock_user_intensity.return_value = [0.3, 0.6, 0.8]
|
||||
|
||||
# Call method
|
||||
explainer._record_explanation([101, 102], 200, 201)
|
||||
|
||||
# Check explanation was stored
|
||||
assert explainer.explanations_found[explainer.calls]["items"] == [101, 102]
|
||||
|
||||
# Check print output
|
||||
out, _ = capfd.readouterr()
|
||||
assert "If the group had not interacted with these items" in out
|
||||
|
||||
def test_calculate_average_item_intensity_score(self, mock_data_reader):
|
||||
"""Test calculation of average item intensity."""
|
||||
# Use static method directly
|
||||
result = SlidingWindowExplainer._calculate_average_item_intensity_score(
|
||||
explanation=[101, 102], members=[1, 2, 3], data=mock_data_reader
|
||||
)
|
||||
|
||||
# Expected:
|
||||
# - Item 101 has interactions with users 1 and 2 (2/3 = 0.67)
|
||||
# - Item 102 has interactions with users 1 and 3 (2/3 = 0.67)
|
||||
assert len(result) == 2
|
||||
assert result[0] == pytest.approx(2 / 3)
|
||||
assert result[1] == pytest.approx(2 / 3)
|
||||
|
||||
def test_calculate_user_intensity_score(self, mock_data_reader):
|
||||
"""Test calculation of user intensity."""
|
||||
# Use static method directly
|
||||
result = SlidingWindowExplainer._calculate_user_intensity_score(
|
||||
explanation_items=[101, 102, 103], members=[1, 2, 3], data=mock_data_reader
|
||||
)
|
||||
|
||||
# Expected:
|
||||
# - User 1 interacted with items 101, 102, 103 (3/3 = 1.0)
|
||||
# - User 2 interacted with item 101 only (1/3 = 0.33)
|
||||
# - User 3 interacted with items 102, 103 (2/3 = 0.67)
|
||||
assert len(result) == 3
|
||||
assert result[0] == pytest.approx(1.0)
|
||||
assert result[1] == pytest.approx(1 / 3)
|
||||
assert result[2] == pytest.approx(2 / 3)
|
||||
|
||||
def test_create_data_reader_and_prepare(self, explainer, mock_data_reader):
|
||||
"""Test creating and preparing a new DataReader with modified data."""
|
||||
with patch(
|
||||
"pygrex.explain.groups.sliding_window_explainer.DataReader"
|
||||
) as mock_reader_class:
|
||||
# Set up mock DataReader class
|
||||
mock_new_reader = Mock(spec=DataReader)
|
||||
mock_reader_class.return_value = mock_new_reader
|
||||
|
||||
# Call method
|
||||
result = explainer._create_data_reader_and_prepare(mock_data_reader.dataset)
|
||||
|
||||
# Check DataReader was created and methods were called
|
||||
assert mock_reader_class.called
|
||||
assert mock_new_reader.make_consecutive_ids_in_dataset.called
|
||||
assert mock_new_reader.binarize.called
|
||||
assert result == mock_new_reader
|
||||
|
||||
def test_retrain_model(self, explainer, mock_data_reader):
|
||||
"""Test retraining the model with modified data."""
|
||||
model = explainer.model
|
||||
result = explainer._retrain_model(mock_data_reader)
|
||||
|
||||
# Check that fit was called and the model was returned
|
||||
assert model.fit.called
|
||||
assert model.fit.call_args[0][0] == mock_data_reader
|
||||
assert result == model
|
||||
|
||||
def test_max_calls_limit(self, explainer):
|
||||
"""Test that find_explanation respects max_calls limit."""
|
||||
# Set a very low max_calls value
|
||||
explainer.max_calls = 1
|
||||
|
||||
# Mock necessary methods to isolate test
|
||||
explainer._test_window_removal = Mock(return_value=False)
|
||||
|
||||
# Call find_explanation with required args
|
||||
items_rated_by_group = [101, 102, 103]
|
||||
group_predictions = {1: {101: 4.0}}
|
||||
top_recommendation = 200
|
||||
ranking_weights = {"popularity": 1, "intensity": 1, "rating": 1, "relevance": 1, "trend": 0}
|
||||
result = explainer.find_explanation(items_rated_by_group, group_predictions, top_recommendation, ranking_weights)
|
||||
|
||||
# Verify only one call was made
|
||||
assert explainer.calls == 1
|
||||
assert result == {}
|
||||
Reference in New Issue
Block a user