Skip to content
This repository was archived by the owner on Nov 16, 2023. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
8bba81a
Add calculate_feature_contrinutions method to Pipeline
najeeb-kazmi Jul 12, 2019
788e089
Merge branch 'master' into 91
ganik Jul 12, 2019
41674b6
Merge branch 'master' into 91
ganik Jul 12, 2019
8fa9192
Merge branch 'master' into 91
ganik Jul 13, 2019
ff15cf5
typo
najeeb-kazmi Jul 15, 2019
6639669
rename to get_feature_contributions(), add docs, improve sample
najeeb-kazmi Jul 15, 2019
28dad24
Add list of supported models to sample
najeeb-kazmi Jul 15, 2019
41cfb2c
Some PR feedback
najeeb-kazmi Jul 15, 2019
a0d4f70
Fix feature contributions for regression and ranking
najeeb-kazmi Jul 16, 2019
818c36e
Implement feature contributions in BasePredictor
najeeb-kazmi Jul 16, 2019
5a80929
Add test to check feature contributions with unpickled pipeline
najeeb-kazmi Jul 17, 2019
6337475
Improve doc, simplify entrypoint graph for feature contributions
najeeb-kazmi Jul 17, 2019
11c388e
Add test to check feature contributions with pipeline loaded from zip
najeeb-kazmi Jul 18, 2019
ea3dce4
nit
najeeb-kazmi Jul 18, 2019
93a46fa
PR feedback
najeeb-kazmi Jul 18, 2019
c0500d1
Save the model file when pickling a NimbusML Pipeline. (#189)
pieths Jul 18, 2019
266d27d
Remove stored references to X and y in BasePredictor. (#195)
pieths Jul 18, 2019
d20c398
Add calculate_feature_contrinutions method to Pipeline
najeeb-kazmi Jul 12, 2019
e21f91d
typo
najeeb-kazmi Jul 15, 2019
df3bdc7
rename to get_feature_contributions(), add docs, improve sample
najeeb-kazmi Jul 15, 2019
a09d6ef
Add list of supported models to sample
najeeb-kazmi Jul 15, 2019
c8c851d
Some PR feedback
najeeb-kazmi Jul 15, 2019
80f4655
Fix feature contributions for regression and ranking
najeeb-kazmi Jul 16, 2019
0c208dc
Implement feature contributions in BasePredictor
najeeb-kazmi Jul 16, 2019
042566f
Add test to check feature contributions with unpickled pipeline
najeeb-kazmi Jul 17, 2019
9bd8b4e
Improve doc, simplify entrypoint graph for feature contributions
najeeb-kazmi Jul 17, 2019
b142ab1
Add test to check feature contributions with pipeline loaded from zip
najeeb-kazmi Jul 18, 2019
af7c37c
nit
najeeb-kazmi Jul 18, 2019
e1f12f4
PR feedback
najeeb-kazmi Jul 18, 2019
1a181bd
Adding more tests, implement save_model in BasePipelineItem, and PR f…
najeeb-kazmi Jul 18, 2019
e621047
Fix conflicts
najeeb-kazmi Jul 18, 2019
8546a19
one more conflict
najeeb-kazmi Jul 18, 2019
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/python/nimbusml.pyproj
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@
<Compile Include="nimbusml\examples\PipelineWithGridSearchCV2.py" />
<Compile Include="nimbusml\examples\PipelineWithGridSearchCV1.py" />
<Compile Include="nimbusml\examples\pipeline.py" />
<Compile Include="nimbusml\examples\PipelineWithFeatureContributions.py" />
<Compile Include="nimbusml\examples\Poisson.py" />
<Compile Include="nimbusml\examples\PoissonRegressionRegressor.py" />
<Compile Include="nimbusml\examples\RangeFilter.py" />
Expand Down
4 changes: 4 additions & 0 deletions src/python/nimbusml/base_predictor.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ def _invoke_inference_method(self, method, X, **params):
data = getattr(pipeline, method)(X, **params)
return data

@trace
def get_feature_contributions(self, X, **params):
return self._invoke_inference_method('get_feature_contributions', X, **params)
Comment thread
najeeb-kazmi marked this conversation as resolved.

@trace
def predict(self, X, **params):
"""
Expand Down
85 changes: 85 additions & 0 deletions src/python/nimbusml/examples/PipelineWithFeatureContributions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
###############################################################################
# Pipeline with observation level feature contributions

# Scoring a dataset with a trained model produces a score, or prediction, for
# each example. To understand and explain these predictions it can be useful to
# inspect which features influenced them most significantly. This function
# computes a model-specific list of per-feature contributions to the score for
# each example. These contributions can be positive (they make the score
# higher) or negative (they make the score lower).

from nimbusml import Pipeline, FileDataStream
from nimbusml.datasets import get_dataset
from nimbusml.ensemble import FastTreesBinaryClassifier
from nimbusml.linear_model import LogisticRegressionBinaryClassifier

# data input (as a FileDataStream)
path = get_dataset('uciadult_train').as_filepath()

data = FileDataStream.read_csv(path)
print(data.head())
# label workclass education ... capital-loss hours-per-week
# 0 0 Private 11th ... 0 40
# 1 0 Private HS-grad ... 0 50
# 2 1 Local-gov Assoc-acdm ... 0 40
# 3 1 Private Some-college ... 0 40
# 4 0 ? Some-college ... 0 30

# define the training pipeline with a linear model
lr_pipeline = Pipeline([LogisticRegressionBinaryClassifier(
feature=['age', 'education-num', 'hours-per-week'], label='label')])

# train the model
lr_model = lr_pipeline.fit(data)

# For linear models, the contribution of a given feature is equal to the
# product of feature value times the corresponding weight. Similarly, for
# Generalized Additive Models (GAM), the contribution of a feature is equal to
# the shape function for the given feature evaluated at the feature value.
lr_feature_contributions = lr_model.get_feature_contributions(data)

# Print predictions with feature contributions, which give a relative measure
# of how much each feature impacted the Score.
print("========== Feature Contributions for Linear Model ==========")
print(lr_feature_contributions.head())
# label ... PredictedLabel Score ... FeatureContributions.hours-per-week
# 0 0 ... 0 -2.010687 ... 0.833069
# 1 0 ... 0 -1.216163 ... 0.809928
# 2 1 ... 0 -1.248412 ... 0.485957
# 3 1 ... 0 -1.132419 ... 0.583148
# 4 0 ... 0 -1.969522 ... 0.437361

# define the training pipeline with a tree model
tree_pipeline = Pipeline([FastTreesBinaryClassifier(
feature=['age', 'education-num', 'hours-per-week'], label='label')])

# train the model
tree_model = tree_pipeline.fit(data)

# For tree-based models, the calculation of feature contribution essentially
# consists in determining which splits in the tree have the most impact on the
# final score and assigning the value of the impact to the features determining
# the split. More precisely, the contribution of a feature is equal to the
# change in score produced by exploring the opposite sub-tree every time a
# decision node for the given feature is encountered.
#
# Consider a simple case with a single decision tree that has a decision node
# for the binary feature F1. Given an example that has feature F1 equal to
# true, we can calculate the score it would have obtained if we chose the
# subtree corresponding to the feature F1 being equal to false while keeping
# the other features constant. The contribution of feature F1 for the given
# example is the difference between the original score and the score obtained
# by taking the opposite decision at the node corresponding to feature F1. This
# algorithm extends naturally to models with many decision trees.
tree_feature_contributions = tree_model.get_feature_contributions(data)

# Print predictions with feature contributions, which give a relative measure
# of how much each feature impacted the Score.
print("========== Feature Contributions for Tree Model ==========")
print(tree_feature_contributions.head())
# label ... PredictedLabel Score ... FeatureContributions.hours-per-week
# 0 0 ... 0 -16.717360 ... -0.608664
# 1 0 ... 0 -7.688200 ... -0.541213
# 2 1 ... 1 1.571164 ... 0.032862
# 3 1 ... 1 2.115638 ... 0.537077
# 4 0 ... 0 -23.038410 ... -0.682764
142 changes: 141 additions & 1 deletion src/python/nimbusml/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
transforms_datasetscorer
from .internal.entrypoints.transforms_featurecombiner import \
transforms_featurecombiner
from .internal.entrypoints.transforms_featurecontributioncalculationtransformer import \
transforms_featurecontributioncalculationtransformer
from .internal.entrypoints.transforms_labelcolumnkeybooleanconverter \
import \
transforms_labelcolumnkeybooleanconverter
Expand Down Expand Up @@ -1693,6 +1695,144 @@ def getn(n):
"only fit(X) is allowed or the training becomes "
"ambiguous.")

@trace
def get_feature_contributions(self, X, y=None,
Comment thread
najeeb-kazmi marked this conversation as resolved.
Outdated
evaltype='auto', group_id=None,
Comment thread
najeeb-kazmi marked this conversation as resolved.
Outdated
weight=None,
Comment thread
najeeb-kazmi marked this conversation as resolved.
Outdated
top=10,
bottom=10,
verbose=0,
as_binary_data_stream=False, **params):
"""
Calculates observation level feature contributions. Returns dataframe
with raw data, predictions, and feature contributiuons for each
prediction. Observation level feature contriutions are supported for
the following models:

* Regression:

* OrdinaryLeastSquaresRegressor
* FastLinearRegressor
* OnlineGradientDescentRegressor
* PoissonRegressionRegressor
* GamRegressor
* LightGbmRegressor
* FastTreesRegressor
* FastForestRegressor
* FastTreesTweedieRegressor

* Binary Classification:

* AveragedPerceptronBinaryClassifier
* LinearSvmBinaryClassifier
* LogisticRegressionBinaryClassifier
* FastLinearBinaryClassifier
* SgdBinaryClassifier
* SymSgdBinaryClassifier
* GamBinaryClassifier
* FastForestBinaryClassifier
* FastTreesBinaryClassifier
* LightGbmBinaryClassifier

* Ranking:

* LightGbmRanker

:param X: {array-like [n_samples, n_features],
:py:class:`nimbusml.FileDataStream` }
:param y: {array-like [n_samples]}

:param evaltype: the evaluation type for the problem, can be {
'binary', 'multiclass', 'regression', 'cluster', 'anomaly',
'ranking'}. The default is 'auto'. If model is loaded using the
load_model() method, evaltype cannot be 'auto', and therefore
must be explicitly specified.
:param group_id: the column name for group_id for ranking problem
:param weight: the column name for the weight column for each
sample.
:param top: the number of positive contributions with highest magnitude
to report.
:param bottom: The number of negative contributions with highest
magnitude to report.
:return: dataframe of containing the raw data, predicted label, score,
probabilities, and feature contributions.
"""
self.verbose = verbose

if not self._is_fitted:
Comment thread
najeeb-kazmi marked this conversation as resolved.
raise ValueError(
"Model is not fitted. Train or load a model before test("
").")

#print(self.last_node.type)
Comment thread
najeeb-kazmi marked this conversation as resolved.
Outdated
if y is not None:
if len(self.steps) > 0:
last_node = self.last_node
if last_node.type == 'transform':
raise ValueError(
"Pipeline needs a trainer as last step for test()")
Comment thread
najeeb-kazmi marked this conversation as resolved.
Outdated

X, y_temp, columns_renamed, feature_columns, label_column, \
schema, weights, weight_column = self._preprocess_X_y(
X, y, w=weight
)

if (not isinstance(y, (str, tuple))) or (
isinstance(X, DataFrame) and isinstance(y, (str, tuple))):
y = y_temp

all_nodes = []
inputs = dict([('data', ''), ('predictor_model', self.model)])
if isinstance(X, FileDataStream):
importtext_node = data_customtextloader(
input_file="$file",
data="$data",
custom_schema=schema.to_string(
add_sep=True))
all_nodes = [importtext_node]
inputs = dict([('file', ''), ('predictor_model', self.model)])

score_node = transforms_datasetscorer(
data="$data",
predictor_model="$predictor_model",
scored_data="$scoredvectordata")

fcc_node = transforms_featurecontributioncalculationtransformer(
data="$scoredvectordata",
predictor_model="$predictor_model",
output_data="$output_data",
top=top,
bottom=bottom,
normalize=True)

all_nodes.extend([score_node, fcc_node])

outputs = dict(output_data="")

graph = Graph(
inputs,
outputs,
as_binary_data_stream,
*all_nodes)

class_name = type(self).__name__
method_name = inspect.currentframe().f_code.co_name
telemetry_info = ".".join([class_name, method_name])

try:
(out_model, out_data, out_metrics) = graph.run(
X=X,
y=y,
random_state=self.random_state,
model=self.model,
verbose=verbose,
telemetry_info=telemetry_info,
**params)
except RuntimeError as e:
raise e

return out_data

@trace
def _predict(self, X, y=None,
evaltype='auto', group_id=None,
Expand Down Expand Up @@ -1942,7 +2082,7 @@ def test(
otherwise None
in the returned tuple.
:return: tuple (dataframe of evaluation metrics, dataframe of
scores). Is scores are
scores). If scores are
required, set `output_scores`=True, otherwise None is
returned by default.
"""
Expand Down
49 changes: 49 additions & 0 deletions src/python/nimbusml/tests/pipeline/test_load_save.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
# Licensed under the MIT License.
# --------------------------------------------------------------------------------------------

import os
import pickle
import unittest

Expand Down Expand Up @@ -119,6 +120,54 @@ def test_model_datastream(self):
model_nimbusml_load.sum().sum(),
decimal=2)

def test_unpickled_pipeline_has_feature_contributions(self):
features = ['age', 'education-num', 'hours-per-week']

model_nimbusml = Pipeline(
steps=[FastLinearBinaryClassifier(feature=features)])

model_nimbusml.fit(train, label)

pickle_filename = 'nimbusml_model.p'

# Save with pickle
with open(pickle_filename, 'wb') as f:
pickle.dump(model_nimbusml, f)

with open(pickle_filename, "rb") as f:
model_nimbusml_pickle = pickle.load(f)

os.remove(pickle_filename)

feature_contributions = model_nimbusml_pickle.get_feature_contributions(
test, test_label)

assert ['FeatureContributions.' + feature in feature_contributions.columns
for feature in features]

def test_pipeline_loaded_from_zip_has_feature_contributions(self):
features = ['age', 'education-num', 'hours-per-week']

model_nimbusml = Pipeline(
steps=[FastLinearBinaryClassifier(feature=features)])

model_nimbusml.fit(train, label)

# Save the model to zip
model_filename = 'nimbusml_model.zip'
model_nimbusml.save_model(model_filename)

# Load the model from zip
model_nimbusml_zip = Pipeline()
model_nimbusml_zip.load_model(model_filename)

feature_contributions = model_nimbusml_zip.get_feature_contributions(
Comment thread
najeeb-kazmi marked this conversation as resolved.
Outdated
test, test_label)

os.remove(model_filename)

assert ['FeatureContributions.' + feature in feature_contributions.columns
for feature in features]

if __name__ == '__main__':
unittest.main()