diff --git a/sharp/metrics/tests/test_metrics.py b/sharp/metrics/tests/test_base.py similarity index 100% rename from sharp/metrics/tests/test_metrics.py rename to sharp/metrics/tests/test_base.py diff --git a/sharp/metrics/tests/test_fidelity.py b/sharp/metrics/tests/test_fidelity.py new file mode 100644 index 0000000..87265f5 --- /dev/null +++ b/sharp/metrics/tests/test_fidelity.py @@ -0,0 +1,58 @@ +import numpy as np +from sharp.metrics import outcome_fidelity + + +def test_outcome_fidelity_no_target_pairs_rank_true(): + contributions = np.array([[0.1, 0.2], [0.3, 0.4]]) + target = np.array([0.5, 0.6]) + avg_target = 0.55 + target_max = 1 + result = outcome_fidelity(contributions, target, avg_target, target_max, rank=True) + expected = ( + 1 + - np.mean(np.abs(target - (avg_target - contributions.sum(axis=1)))) + / target_max + ) + assert np.isclose(result, expected) + + +def test_outcome_fidelity_no_target_pairs_rank_false(): + contributions = np.array([[0.1, 0.2], [0.3, 0.4]]) + target = np.array([0.5, 0.6]) + avg_target = 0.55 + target_max = 1 + result = outcome_fidelity(contributions, target, avg_target, target_max, rank=False) + expected = np.mean( + 1 - np.abs(target - (avg_target + contributions.sum(axis=1))) / target_max + ) + assert np.isclose(result, expected) + + +def test_outcome_fidelity_with_target_pairs_rank_true(): + contributions = np.array([[0.1, 0.2], [0.3, 0.4]]) + target = np.array([0.5, 0.6]) + avg_target = 0.55 + target_max = 1 + target_pairs = np.array([0.4, 0.7]) + result = outcome_fidelity( + contributions, target, avg_target, target_max, target_pairs, rank=True + ) + better_than = target < target_pairs + est_better_than = contributions.sum(axis=1) > 0 + expected = (better_than == est_better_than).mean() + assert np.isclose(result, expected) + + +def test_outcome_fidelity_with_target_pairs_rank_false(): + contributions = np.array([[0.1, 0.2], [0.3, 0.4]]) + target = np.array([0.5, 0.6]) + avg_target = 0.55 + target_max = 1 + target_pairs = np.array([0.4, 0.7]) + result = outcome_fidelity( + contributions, target, avg_target, target_max, target_pairs, rank=False + ) + better_than = target > target_pairs + est_better_than = contributions.sum(axis=1) > 0 + expected = (better_than == est_better_than).mean() + assert np.isclose(result, expected) diff --git a/sharp/utils/tests/test_parallelize.py b/sharp/utils/tests/test_parallelize.py new file mode 100644 index 0000000..baf20a3 --- /dev/null +++ b/sharp/utils/tests/test_parallelize.py @@ -0,0 +1,16 @@ +from sharp.utils._parallelize import parallel_loop + + +def test_parallel_loop(): + def square(x): + return x * x + + iterable = range(10) + + results = parallel_loop(square, iterable, n_jobs=2, progress_bar=False) + assert results == [x * x for x in iterable] + + results = parallel_loop( + square, iterable, n_jobs=2, progress_bar=True, description="Test" + ) + assert results == [x * x for x in iterable] diff --git a/sharp/utils/tests/test_utils.py b/sharp/utils/tests/test_utils.py new file mode 100644 index 0000000..d7c8a7d --- /dev/null +++ b/sharp/utils/tests/test_utils.py @@ -0,0 +1,17 @@ +import pytest +from sharp.utils._utils import _optional_import + + +def test_optional_import_existing_module(): + module = _optional_import("math") + assert module is not None + + +def test_optional_import_non_existing_module(): + with pytest.raises(ImportError): + _optional_import("non_existing_module") + + +def test_optional_import_partial_module(): + module = _optional_import("os.path") + assert module is not None diff --git a/sharp/visualization/tests/test_aggregate.py b/sharp/visualization/tests/test_aggregate.py new file mode 100644 index 0000000..0d671ac --- /dev/null +++ b/sharp/visualization/tests/test_aggregate.py @@ -0,0 +1,45 @@ +import pytest +import numpy as np +import pandas as pd +from sharp.visualization._aggregate import group_boxplot + + +@pytest.fixture +def sample_data(): + X = pd.DataFrame( + { + "feature1": np.random.rand(100), + "feature2": np.random.rand(100), + "group": np.random.choice(["A", "B", "C", "D", "E"], 100), + } + ) + y = np.random.rand(100) + contributions = np.random.rand(100, 2) + feature_names = ["feature1", "feature2"] + return X, y, contributions, feature_names + + +def test_group_boxplot_group_by_bins(sample_data): + X, y, contributions, feature_names = sample_data + ax = group_boxplot( + X, y, contributions, feature_names=feature_names, group=5, show=False + ) + assert ax is not None + assert len(ax.get_xticklabels()) == 5 + + +def test_group_boxplot_group_by_variable(sample_data): + X, y, contributions, feature_names = sample_data + ax = group_boxplot( + X, y, contributions, feature_names=feature_names, group="group", show=False + ) + assert ax is not None + assert len(ax.get_xticklabels()) == len(X["group"].unique()) + + +def test_group_boxplot_show(sample_data): + X, y, contributions, feature_names = sample_data + ax = group_boxplot( + X, y, contributions, feature_names=feature_names, group=5, show=True + ) + assert ax is None diff --git a/sharp/visualization/tests/test_visualization.py b/sharp/visualization/tests/test_visualization.py new file mode 100644 index 0000000..108f892 --- /dev/null +++ b/sharp/visualization/tests/test_visualization.py @@ -0,0 +1,87 @@ +import pytest +import pandas as pd +import numpy as np +from sharp.utils._utils import _optional_import +from sharp.visualization._visualization import ShaRPViz + + +@pytest.fixture +def mock_sharp(): + """ + Fixture to create a mock ShaRP object with dummy feature names. + """ + + class MockSharp: + def __init__(self): + self.feature_names_ = np.array(["Feature1", "Feature2", "Feature3"]) + self.qoi="rank" + self.measure="shapley" + + return MockSharp() + + +def test_bar(mock_sharp): + """ + Test the bar method of ShaRPViz. + """ + sharpviz = ShaRPViz(mock_sharp) + scores = [0.1, 0.5, 0.4] + + plt = _optional_import("matplotlib.pyplot") + fig, ax = plt.subplots(1, 1) + + result_ax = sharpviz.bar(scores, ax=ax) + assert result_ax is not None + assert result_ax.get_ylabel() == "Contribution to QoI" + assert result_ax.get_xlabel() == "Features" + assert len(result_ax.patches) == len(scores) # Number of bars matches scores + + +def test_waterfall(mock_sharp): + """ + Test the waterfall method of ShaRPViz. + """ + sharpviz = ShaRPViz(mock_sharp) + contributions = [0.2, -0.1, 0.3] + feature_values = ["A", "B", "C"] + mean_target_value = 1.0 + + result = sharpviz.waterfall( + contributions=contributions, + feature_values=feature_values, + mean_target_value=mean_target_value, + ) + assert result is not None + + +def test_box(mock_sharp): + """ + Test the box method of ShaRPViz. + """ + sharpviz = ShaRPViz(mock_sharp) + X = pd.DataFrame( + np.random.randn(100, 3), columns=["Feature1", "Feature2", "Feature3"] + ) + y = np.random.choice([0, 1], size=100) + contributions = pd.DataFrame( + np.random.randn(100, 3), columns=["Feature1", "Feature2", "Feature3"] + ) + + plt = _optional_import("matplotlib.pyplot") + fig, ax = plt.subplots(1, 1) + + result = sharpviz.box(X, y, contributions, ax=ax) + assert result is not None + + +def test_bar_without_ax(mock_sharp): + """ + Test the bar method without providing an ax. + """ + sharpviz = ShaRPViz(mock_sharp) + scores = [0.3, 0.5, 0.2] + + result_ax = sharpviz.bar(scores) + assert result_ax is not None + assert result_ax.get_ylabel() == "Contribution to QoI" + assert result_ax.get_xlabel() == "Features" diff --git a/sharp/visualization/tests/test_waterfall.py b/sharp/visualization/tests/test_waterfall.py new file mode 100644 index 0000000..1920f65 --- /dev/null +++ b/sharp/visualization/tests/test_waterfall.py @@ -0,0 +1,57 @@ +import pytest +import pandas as pd +import numpy as np +from sharp.visualization._waterfall import format_value, _waterfall + + +@pytest.mark.parametrize( + "value, format_str, expected", + [ + (123.45000, "%.2f", "123.45"), + (123.00000, "%.2f", "123"), + (-123.45000, "%.2f", "\u2212123.45"), + (-123.00000, "%.2f", "\u2212123"), + (0.00000, "%.2f", "0"), + ("123.45", "%.2f", "123.45"), + ("-123.45", "%.2f", "\u2212123.45"), + ], +) +def test_format_value(value, format_str, expected): + assert format_value(value, format_str) == expected + + +@pytest.fixture +def shap_values(): + return { + "base_values": 0.5, + "features": np.array([1.0, 2.0, 3.0]), + "feature_names": ["feature1", "feature2", "feature3"], + "values": pd.Series([0.1, -0.2, 0.3]), + } + + +def test_waterfall_plot(shap_values): + fig = _waterfall(shap_values, max_display=3, show=False) + assert fig is not None + assert len(fig.axes) > 0 + + +@pytest.mark.parametrize("max_display", [1, 2, 3]) +def test_waterfall_max_display(shap_values, max_display): + fig = _waterfall(shap_values, max_display=max_display, show=False) + assert fig is not None + assert ( + len(fig.axes[0].patches) == max_display * 2 + ) # Each feature has two patches (positive and negative) + + +def test_waterfall_no_features(): + shap_values = { + "base_values": 0.5, + "features": None, + "feature_names": ["feature1", "feature2", "feature3"], + "values": pd.Series([0.1, -0.2, 0.3]), + } + fig = _waterfall(shap_values, max_display=3, show=False) + assert fig is not None + assert len(fig.axes) > 0