diff --git a/python/interpret-core/interpret/blackbox/_partialdependence.py b/python/interpret-core/interpret/blackbox/_partialdependence.py index 7fdb6b446..0fbf6699f 100644 --- a/python/interpret-core/interpret/blackbox/_partialdependence.py +++ b/python/interpret-core/interpret/blackbox/_partialdependence.py @@ -51,6 +51,11 @@ def _gen_pdp( mean = np.mean(ice_lines, axis=0) std = np.std(ice_lines, axis=0) + # Sample without replacement, so we cannot draw more background lines than + # there are rows in the data. Datasets with fewer than num_ice_samples rows + # used to raise "Cannot take a larger sample than population when + # 'replace=False'", so clamp the count to the available population. + num_ice_samples = min(num_ice_samples, ice_lines.shape[0]) ice_lines = ice_lines[ np.random.choice(ice_lines.shape[0], num_ice_samples, replace=False), : ] diff --git a/python/interpret-core/tests/blackbox/test_partialdependence.py b/python/interpret-core/tests/blackbox/test_partialdependence.py new file mode 100644 index 000000000..e5aa94104 --- /dev/null +++ b/python/interpret-core/tests/blackbox/test_partialdependence.py @@ -0,0 +1,74 @@ +# Copyright (c) 2023 The InterpretML Contributors +# Distributed under the MIT software license + +import numpy as np +import pytest +from interpret.blackbox._partialdependence import PartialDependence, _gen_pdp +from sklearn.linear_model import LinearRegression + + +def _make_regression_data(n_samples): + rng = np.random.default_rng(42) + X = rng.normal(size=(n_samples, 2)) + y = X[:, 0] * 2.0 - X[:, 1] + 0.5 + return X, y + + +@pytest.mark.parametrize("n_samples", [1, 2, 5, 9]) +def test_gen_pdp_fewer_rows_than_ice_samples(n_samples): + """Datasets with fewer rows than the default num_ice_samples must not crash. + + Before the fix, sampling num_ice_samples (10) rows without replacement + raised "ValueError: Cannot take a larger sample than population when + 'replace=False'" for any dataset with fewer than 10 rows. + """ + X, y = _make_regression_data(n_samples) + model = LinearRegression().fit(X, y) + + pdp = _gen_pdp( + X, + model.predict, + col_idx=0, + feature_type="continuous", + num_points=10, + std_coef=1.0, + ) + + # One background line per available row, no more. + assert pdp["background_scores"].shape[0] == n_samples + assert pdp["background_scores"].shape[1] == len(pdp["names"]) + + +def test_partial_dependence_small_dataset_does_not_crash(): + """PartialDependence must work end-to-end on a dataset with < 10 rows.""" + X, y = _make_regression_data(3) + model = LinearRegression().fit(X, y) + + pdp = PartialDependence( + model, X, feature_names=["a", "b"], feature_types=["continuous", "continuous"] + ) + + assert len(pdp.pdps_) == 2 + for pdp_item in pdp.pdps_: + assert pdp_item["background_scores"].shape[0] == 3 + + explanation = pdp.explain_global() + assert explanation.feature_names == ["a", "b"] + + +def test_gen_pdp_enough_rows_still_caps_at_num_ice_samples(): + """With enough rows, exactly num_ice_samples background lines are kept.""" + X, y = _make_regression_data(25) + model = LinearRegression().fit(X, y) + + pdp = _gen_pdp( + X, + model.predict, + col_idx=0, + feature_type="continuous", + num_points=10, + std_coef=1.0, + num_ice_samples=10, + ) + + assert pdp["background_scores"].shape[0] == 10