-
Notifications
You must be signed in to change notification settings - Fork 240
Add support for white-box explainers to alibi-explain runtime #1279
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
adriangonz
merged 20 commits into
SeldonIO:master
from
ascillitoe:feature/white_box_explainers
Jul 6, 2023
Merged
Changes from 11 commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
5b88618
Minor generic refactors
082c54b
White box sklearn api runtime
e29b940
Move _explain_impl up a class, and add test for TreeShap
035de83
Add test for TreePartialDependence
5cfa207
Update tests to use case generator
c4ea56f
Test init_parameters and remove PartialDependenceVariance case
58a5d33
Change scope of sklearn model/data fixtures
e8629f0
Minor changes
016902d
Remove remaining tab changes
f5d5846
Add PartialDependenceVariance (white-box) test back in
4214f39
Correct sklearn model type annotation
ed0a1b9
Rename AlibiExplainSKLearnAPIRuntime to SKLearnRuntime
53ce0b0
Rename test_end_2_end to test_explain
75ab2e4
Remove sk_model helper
5f14616
Move income model and data from conftest.py to test_white_box_sklearn…
f193a8d
Update pyproject.toml and poetry.lock
33b1fd7
Remove loading as lgb.Booster
afae1e6
Add test for _get_inference_model
fdf4ce4
Fix linting
452d02e
Fix mypy errors
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
36 changes: 36 additions & 0 deletions
36
runtimes/alibi-explain/mlserver_alibi_explain/explainers/sklearn_api_runtime.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| from typing import Any | ||
|
|
||
| import joblib | ||
| import lightgbm as lgb | ||
| from xgboost.core import XGBoostError | ||
| from lightgbm.basic import LightGBMError | ||
|
|
||
| from mlserver_xgboost.xgboost import _load_sklearn_interface as load_xgb_model | ||
| from mlserver.errors import InvalidModelURI | ||
| from mlserver_alibi_explain.explainers.white_box_runtime import AlibiExplainWhiteBoxRuntime | ||
|
|
||
|
|
||
| class AlibiExplainSKLearnAPIRuntime(AlibiExplainWhiteBoxRuntime): | ||
|
adriangonz marked this conversation as resolved.
Outdated
|
||
| """ | ||
| Runtime for white-box explainers that require access to a tree-based model matching the SKLearn API, such as | ||
| a sklearn, XGBoost, or LightGBM model. Example explainers include TreeShap and TreePartialDependence. | ||
| """ | ||
| async def _get_inference_model(self) -> Any: | ||
| inference_model_path = self.alibi_explain_settings.infer_uri | ||
| # Attempt to load model in order: XGBoost, LightGBM, sklearn | ||
| # TODO - add support for CatBoost (would require model_type = 'classifier' or 'regressor' in settings) | ||
| try: | ||
|
adriangonz marked this conversation as resolved.
|
||
| # Try to load as sklearn model first | ||
| model = joblib.load(inference_model_path) | ||
| except (IndexError, KeyError, IOError): | ||
| try: | ||
| # Try to load as XGBoost model | ||
| model = load_xgb_model(inference_model_path) | ||
| except XGBoostError: | ||
| try: | ||
| # Try to load as LightGBM model (do this last as raises warning if not successful) | ||
| model = lgb.Booster(model_file=inference_model_path) | ||
|
adriangonz marked this conversation as resolved.
Outdated
|
||
| except LightGBMError: | ||
| raise InvalidModelURI(self.name, inference_model_path) | ||
|
|
||
| return model | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| import os | ||
| from pathlib import Path | ||
| import joblib | ||
|
|
||
| from sklearn.ensemble import GradientBoostingClassifier | ||
|
adriangonz marked this conversation as resolved.
Outdated
|
||
| from alibi.datasets import fetch_adult | ||
|
|
||
| from mlserver import MLModel | ||
| from mlserver.codecs import NumpyCodec | ||
| from mlserver.types import InferenceRequest, InferenceResponse | ||
|
|
||
|
|
||
| _MODEL_PATH = Path(os.path.dirname(__file__)).parent / ".data" / "sk_income" / "model.joblib" | ||
|
adriangonz marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def get_sk_income_model_uri() -> Path: | ||
| if not _MODEL_PATH.exists(): | ||
| _train_sk_income() | ||
| return _MODEL_PATH | ||
|
|
||
|
|
||
| class SKIncomeModel(MLModel): | ||
| async def predict(self, payload: InferenceRequest) -> InferenceResponse: | ||
| np_codec = NumpyCodec | ||
| model_input = payload.inputs[0] | ||
| input_data = np_codec.decode_input(model_input) | ||
| output_data = self._model(input_data) | ||
| return InferenceResponse( | ||
| model_name=self.name, | ||
| outputs=[np_codec.encode_output("predict", output_data)] | ||
| ) | ||
|
|
||
| async def load(self) -> bool: | ||
| self._model = joblib.load(get_sk_income_model_uri()) | ||
| return True | ||
|
adriangonz marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def _train_sk_income() -> None: | ||
| data = get_income_data() | ||
| X_train, Y_train = data['X'], data['Y'] | ||
|
|
||
| model = GradientBoostingClassifier(n_estimators=50) | ||
| model.fit(X_train, Y_train) | ||
|
|
||
| _MODEL_PATH.parent.mkdir(parents=True) | ||
| joblib.dump(model, _MODEL_PATH) | ||
|
|
||
|
|
||
| def get_income_data() -> dict: | ||
| print('Generating adult dataset...') | ||
| adult = fetch_adult() | ||
| X = adult.data | ||
| Y = adult.target | ||
|
|
||
| feature_names = adult.feature_names | ||
| category_map = adult.category_map | ||
|
|
||
| # Package into dictionary | ||
| data_dict = { | ||
| 'X': X, | ||
| 'Y': Y, | ||
| 'feature_names': feature_names, | ||
| 'category_map': category_map, | ||
| 'target_names': adult.target_names, | ||
| } | ||
| return data_dict | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| from typing import Union, Literal, Optional, Tuple | ||
| from pathlib import Path | ||
| import numpy as np | ||
|
|
||
| from alibi.api.interfaces import Explainer | ||
|
|
||
| from mlserver.types import InferenceRequest, Parameters, RequestInput | ||
| from mlserver.codecs import NumpyCodec | ||
| from mlserver.settings import ModelSettings, ModelParameters | ||
| from mlserver_alibi_explain import AlibiExplainRuntime | ||
| from mlserver_alibi_explain.common import AlibiExplainSettings, import_and_get_class | ||
| from mlserver_alibi_explain.alibi_dependency_reference import get_alibi_class_as_str | ||
| from .sk_model import get_sk_income_model_uri | ||
|
|
||
|
|
||
| def train_explainer( | ||
| explainer_tag: str, | ||
| save_dir: Optional[Path], | ||
| fit: Union[np.ndarray, Literal[False, 'no-data']] = False, | ||
| *args, | ||
| **kwargs | ||
| ) -> Explainer: | ||
| """ | ||
| Train and save an explainer. | ||
| """ | ||
| # Instantiate explainer | ||
| klass = import_and_get_class(get_alibi_class_as_str(explainer_tag)) | ||
| explainer = klass(*args, **kwargs) | ||
|
|
||
| # Fit explainer | ||
| if fit: | ||
| explainer.fit() if fit == 'no-data' else explainer.fit(fit) | ||
|
|
||
| # Save explainer | ||
| if save_dir: | ||
| explainer.save(save_dir) | ||
|
|
||
| return explainer | ||
|
|
||
|
|
||
| def build_request(data: np.ndarray, **explain_kwargs) -> InferenceRequest: | ||
| """ | ||
| Build an inference request from a numpy array. | ||
| """ | ||
| inference_request = InferenceRequest( | ||
| parameters=Parameters( | ||
| content_type=NumpyCodec.ContentType, | ||
| explain_parameters=explain_kwargs, | ||
| ), | ||
| inputs=[ | ||
| RequestInput( | ||
| name="predict", | ||
| shape=data.shape, | ||
| data=data.tolist(), | ||
| datatype="FP32", | ||
| ) | ||
| ], | ||
| ) | ||
| return inference_request | ||
|
|
||
|
|
||
| def build_test_case(explainer_type: str, init_kwargs: dict, explain_kwargs: dict, | ||
| fit: Union[np.ndarray, Literal[False, 'no-data']], save_dir: Optional[Path], payload: np.ndarray) \ | ||
| -> Tuple[ModelSettings, Explainer, InferenceRequest, dict]: | ||
| """ | ||
| Function to build a test case for a given explainer type. The function returns a model settings object, an | ||
| explainer object, an inference request object and a dictionary of explain parameters. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| explainer_type | ||
| The type of explainer to build. | ||
| init_kwargs | ||
| Instantiation kwargs for the explainer. | ||
| explain_kwargs | ||
| Explain kwargs for the explainer. | ||
| fit | ||
| Data to fit the explainer on, `False` if no fit is required, or `'no-data'` to fit the explainer without | ||
| data e.g. for `TreeShap` with path-dependent algorithm. | ||
| save_dir | ||
| Directory to save the explainer to, and then pass to `uri` in `ModelParameters`. If `None`, the explainer | ||
| will not be saved to disk, and `init_parameters` will specified in `ModelSettings` instead. | ||
| payload | ||
| The payload to send as request to the explainer. | ||
| """ | ||
| # Build explainer | ||
| explainer = train_explainer( | ||
| explainer_type, | ||
| save_dir, | ||
| fit=fit, | ||
| **init_kwargs | ||
| ) | ||
|
|
||
| # Explainer model settings | ||
| model_params = {} | ||
| alibi_explain_settings = { | ||
| 'explainer_type': explainer_type, | ||
| 'infer_uri': str(get_sk_income_model_uri()), | ||
| } | ||
| if save_dir: | ||
| model_params['uri'] = str(save_dir) | ||
| else: | ||
| init_params = init_kwargs.copy() | ||
| init_params.pop('predictor') # TODO: Will need to add `model`, `predict_fn` here eventually | ||
| alibi_explain_settings['init_parameters'] = init_params | ||
| model_params['extra'] = AlibiExplainSettings(**alibi_explain_settings) | ||
|
|
||
| model_settings = ModelSettings( | ||
| name="foo", | ||
| implementation=AlibiExplainRuntime, | ||
| parameters=ModelParameters(**model_params), | ||
| ) | ||
|
|
||
| # Inference request | ||
| inference_request = build_request(payload, **explain_kwargs) | ||
| return model_settings, explainer, inference_request, explain_kwargs |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.