class LimeTabularExplainer(object): """Explains predictions on tabular (i.e. matrix) data. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained.""" def __init__(self, training_data, mode="classification", training_labels=None, feature_names=None, categorical_features=None, categorical_names=None, kernel_width=None, kernel=None, verbose=False, class_names=None, feature_selection='auto', discretize_continuous=True, discretizer='quartile', sample_around_instance=False, random_state=None, training_data_stats=None, generator = "Perturb", generator_specs = None, dummies = None, integer_attributes = []): """Init function. Args: training_data: numpy 2d array mode: "classification" or "regression" training_labels: labels for training data. Not required, but may be used by discretizer. feature_names: list of names (strings) corresponding to the columns in the training data. categorical_features: list of indices (ints) corresponding to the categorical columns. Everything else will be considered continuous. Values in these columns MUST be integers. categorical_names: map from int to list of names, where categorical_names[x][y] represents the name of the yth value of column x. kernel_width: kernel width for the exponential kernel. If None, defaults to sqrt (number of columns) * 0.75 kernel: similarity kernel that takes euclidean distances and kernel width as input and outputs weights in (0,1). If None, defaults to an exponential kernel. verbose: if true, print local prediction values from linear model class_names: list of class names, ordered according to whatever the classifier is using. If not present, class names will be '0', '1', ... feature_selection: feature selection method. can be 'forward_selection', 'lasso_path', 'none' or 'auto'. See function 'explain_instance_with_data' in lime_base.py for details on what each of the options does. discretize_continuous: if True, all non-categorical features will be discretized into quartiles. discretizer: only matters if discretize_continuous is True and data is not sparse. Options are 'quartile', 'decile', 'entropy' or a BaseDiscretizer instance. sample_around_instance: if True, will sample continuous features in perturbed samples from a normal centered at the instance being explained. Otherwise, the normal is centered on the mean of the feature data. random_state: an integer or numpy.RandomState that will be used to generate random numbers. If None, the random state will be initialized using the internal numpy seed. training_data_stats: a dict object having the details of training data statistics. If None, training data information will be used, only matters if discretize_continuous is True. Must have the following keys: means", "mins", "maxs", "stds", "feature_values", "feature_frequencies" generator: "Perturb", "VAE", "DropoutVAE", "RBF" or "Forest". Determines, which data generator will be used for generating new samples generator_specs: only matters if generator is not "perturb". Dictionary with values, required by generator dummies: list of lists of categorical feature indices corresponding to dummy variable for the same categorical feature integer_attributes: list of indices of integer attributes """ self.random_state = check_random_state(random_state) self.mode = mode self.categorical_names = categorical_names or {} self.sample_around_instance = sample_around_instance self.training_data_stats = training_data_stats # Check and raise proper error in stats are supplied in non-descritized path if self.training_data_stats: self.validate_training_data_stats(self.training_data_stats) if categorical_features is None: categorical_features = [] if feature_names is None: feature_names = [str(i) for i in range(training_data.shape[1])] self.categorical_features = list(categorical_features) self.feature_names = list(feature_names) self.discretizer = None if discretize_continuous and not sp.sparse.issparse(training_data): # Set the discretizer if training data stats are provided if self.training_data_stats: discretizer = StatsDiscretizer(training_data, self.categorical_features, self.feature_names, labels=training_labels, data_stats=self.training_data_stats, random_state=self.random_state) if discretizer == 'quartile': self.discretizer = QuartileDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels, random_state=self.random_state) elif discretizer == 'decile': self.discretizer = DecileDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels, random_state=self.random_state) elif discretizer == 'entropy': self.discretizer = EntropyDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels, random_state=self.random_state) elif isinstance(discretizer, BaseDiscretizer): self.discretizer = discretizer else: raise ValueError('''Discretizer must be 'quartile',''' + ''' 'decile', 'entropy' or a''' + ''' BaseDiscretizer instance''') self.categorical_features = list(range(training_data.shape[1])) self.dummies = dummies # Get the discretized_training_data when the stats are not provided if(self.training_data_stats is None): discretized_training_data = self.discretizer.discretize( training_data) if kernel_width is None: kernel_width = np.sqrt(training_data.shape[1]) * .75 kernel_width = float(kernel_width) if kernel is None: def kernel(d, kernel_width): return np.sqrt(np.exp(-(d ** 2) / kernel_width ** 2)) kernel_fn = partial(kernel, kernel_width=kernel_width) self.feature_selection = feature_selection self.base = lime_base.LimeBase(kernel_fn, verbose, random_state=self.random_state) self.class_names = class_names # Create the generator (because RBF and Forest are not yet implemented in Python, it is only noted that they are used) if generator == "VAE": self.generator = VAE(original_dim = generator_specs["original_dim"], input_shape = (generator_specs["original_dim"],), intermediate_dim = generator_specs["intermediate_dim"], latent_dim = generator_specs["latent_dim"]) elif generator == "DropoutVAE": self.generator = DropoutVAE(original_dim = generator_specs["original_dim"], input_shape = (generator_specs["original_dim"],), intermediate_dim = generator_specs["intermediate_dim"], dropout = generator_specs["dropout"], latent_dim = generator_specs["latent_dim"]) elif generator in ["RBF", "Forest"]: self.generator = generator self.generator_specs = generator_specs else: self.generator = None # Dodamo integer atribute self.integer_attributes = integer_attributes # Though set has no role to play if training data stats are provided if (generator in ["VAE", "DropoutVAE"]): self.generator_scaler = sklearn.preprocessing.MinMaxScaler() self.generator_scaler.fit(training_data) self.feature_values = {} self.feature_frequencies = {} self.dummies = dummies self.scaler = sklearn.preprocessing.StandardScaler(with_mean=False) self.scaler.fit(training_data) self.feature_values = {} self.feature_frequencies = {} for feature in self.categorical_features: if training_data_stats is None: if self.discretizer is not None: column = discretized_training_data[:, feature] else: column = training_data[:, feature] feature_count = collections.Counter(column) values, frequencies = map(list, zip(*(sorted(feature_count.items())))) else: values = training_data_stats["feature_values"][feature] frequencies = training_data_stats["feature_frequencies"][feature] self.feature_values[feature] = values self.feature_frequencies[feature] = (np.array(frequencies) / float(sum(frequencies))) self.scaler.mean_[feature] = 0 self.scaler.scale_[feature] = 1 # Generator training if isinstance(self.generator, VAE) or isinstance(self.generator, DropoutVAE): scaled_data = self.generator_scaler.transform(training_data) self.generator.fit_unsplit(scaled_data, epochs = generator_specs["epochs"]) @staticmethod def convert_and_round(values): return ['%.2f' % v for v in values] @staticmethod def validate_training_data_stats(training_data_stats): """ Method to validate the structure of training data stats """ stat_keys = list(training_data_stats.keys()) valid_stat_keys = ["means", "mins", "maxs", "stds", "feature_values", "feature_frequencies"] missing_keys = list(set(valid_stat_keys) - set(stat_keys)) if len(missing_keys) > 0: raise Exception("Missing keys in training_data_stats. Details: %s" % (missing_keys)) def explain_instance(self, data_row, predict_fn, labels=(1,), top_labels=None, num_features=10, num_samples=5000, distance_metric='euclidean', model_regressor=None): """Generates explanations for a prediction. First, we generate neighborhood data by randomly perturbing features from the instance (see __data_inverse). We then learn locally weighted linear models on this neighborhood data to explain each of the classes in an interpretable way (see lime_base.py). Args: data_row: 1d numpy array or scipy.sparse matrix, corresponding to a row predict_fn: prediction function. For classifiers, this should be a function that takes a numpy array and outputs prediction probabilities. For regressors, this takes a numpy array and returns the predictions. For ScikitClassifiers, this is `classifier.predict_proba()`. For ScikitRegressors, this is `regressor.predict()`. The prediction function needs to work on multiple feature vectors (the vectors randomly perturbed from the data_row). labels: iterable with labels to be explained. top_labels: if not None, ignore labels and produce explanations for the K labels with highest prediction probabilities, where K is this parameter. num_features: maximum number of features present in explanation num_samples: size of the neighborhood to learn the linear model distance_metric: the distance metric to use for weights. model_regressor: sklearn regressor to use in explanation. Defaults to Ridge regression in LimeBase. Must have model_regressor.coef_ and 'sample_weight' as a parameter to model_regressor.fit() Returns: An Explanation object (see explanation.py) with the corresponding explanations. """ if sp.sparse.issparse(data_row) and not sp.sparse.isspmatrix_csr(data_row): # Preventative code: if sparse, convert to csr format if not in csr format already data_row = data_row.tocsr() data, inverse = self.__data_inverse(data_row, num_samples) if sp.sparse.issparse(data): # Note in sparse case we don't subtract mean since data would become dense scaled_data = data.multiply(self.scaler.scale_) # Multiplying with csr matrix can return a coo sparse matrix if not sp.sparse.isspmatrix_csr(scaled_data): scaled_data = scaled_data.tocsr() else: scaled_data = (data - self.scaler.mean_) / self.scaler.scale_ distances = sklearn.metrics.pairwise_distances( scaled_data, scaled_data[0].reshape(1, -1), metric=distance_metric ).ravel() yss = predict_fn(inverse) # for classification, the model needs to provide a list of tuples - classes # along with prediction probabilities if self.mode == "classification": if len(yss.shape) == 1: raise NotImplementedError("LIME does not currently support " "classifier models without probability " "scores. If this conflicts with your " "use case, please let us know: " "https://github.com/datascienceinc/lime/issues/16") elif len(yss.shape) == 2: if self.class_names is None: self.class_names = [str(x) for x in range(yss[0].shape[0])] else: self.class_names = list(self.class_names) if not np.allclose(yss.sum(axis=1), 1.0): warnings.warn(""" Prediction probabilties do not sum to 1, and thus does not constitute a probability space. Check that you classifier outputs probabilities (Not log probabilities, or actual class predictions). """) else: raise ValueError("Your model outputs " "arrays with {} dimensions".format(len(yss.shape))) # for regression, the output should be a one-dimensional array of predictions else: try: if len(yss.shape) != 1 and len(yss[0].shape) == 1: yss = np.array([v[0] for v in yss]) assert isinstance(yss, np.ndarray) and len(yss.shape) == 1 except AssertionError: raise ValueError("Your model needs to output single-dimensional \ numpyarrays, not arrays of {} dimensions".format(yss.shape)) predicted_value = yss[0] min_y = min(yss) max_y = max(yss) # add a dimension to be compatible with downstream machinery yss = yss[:, np.newaxis] feature_names = copy.deepcopy(self.feature_names) if feature_names is None: feature_names = [str(x) for x in range(data_row.shape[0])] if sp.sparse.issparse(data_row): values = self.convert_and_round(data_row.data) feature_indexes = data_row.indices else: values = self.convert_and_round(data_row) feature_indexes = None for i in self.categorical_features: if self.discretizer is not None and i in self.discretizer.lambdas: continue name = int(data_row[i]) if i in self.categorical_names: name = self.categorical_names[i][name] feature_names[i] = '%s=%s' % (feature_names[i], name) values[i] = 'True' categorical_features = self.categorical_features discretized_feature_names = None if self.discretizer is not None: categorical_features = range(data.shape[1]) discretized_instance = self.discretizer.discretize(data_row) discretized_feature_names = copy.deepcopy(feature_names) for f in self.discretizer.names: discretized_feature_names[f] = self.discretizer.names[f][int( discretized_instance[f])] domain_mapper = TableDomainMapper(feature_names, values, scaled_data[0], categorical_features=categorical_features, discretized_feature_names=discretized_feature_names, feature_indexes=feature_indexes) ret_exp = explanation.Explanation(domain_mapper, mode=self.mode, class_names=self.class_names) ret_exp.scaled_data = scaled_data if self.mode == "classification": ret_exp.predict_proba = yss[0] if top_labels: labels = np.argsort(yss[0])[-top_labels:] ret_exp.top_labels = list(labels) ret_exp.top_labels.reverse() else: ret_exp.predicted_value = predicted_value ret_exp.min_value = min_y ret_exp.max_value = max_y labels = [0] for label in labels: (ret_exp.intercept[label], ret_exp.local_exp[label], ret_exp.score, ret_exp.local_pred) = self.base.explain_instance_with_data( scaled_data, yss, distances, label, num_features, model_regressor=model_regressor, feature_selection=self.feature_selection) if self.mode == "regression": ret_exp.intercept[1] = ret_exp.intercept[0] ret_exp.local_exp[1] = [x for x in ret_exp.local_exp[0]] ret_exp.local_exp[0] = [(i, -1 * j) for i, j in ret_exp.local_exp[1]] return ret_exp def __data_inverse(self, data_row, num_samples): """Generates a neighborhood around a prediction. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained. Args: data_row: 1d numpy array, corresponding to a row num_samples: size of the neighborhood to learn the linear model Returns: A tuple (data, inverse), where: data: dense num_samples * K matrix, where categorical features are encoded with either 0 (not equal to the corresponding value in data_row) or 1. The first row is the original instance. inverse: same as data, except the categorical features are not binary, but categorical (as the original data) """ is_sparse = sp.sparse.issparse(data_row) if is_sparse: num_cols = data_row.shape[1] data = sp.sparse.csr_matrix((num_samples, num_cols), dtype=data_row.dtype) else: num_cols = data_row.shape[0] data = np.zeros((num_samples, num_cols)) categorical_features = range(num_cols) if self.discretizer is None: instance_sample = data_row # If we use perturbations to generate new samples, we have standard scaler and need mean and variance of the data if self.generator is None: scale = self.scaler.scale_ mean = self.scaler.mean_ if is_sparse: # Perturb only the non-zero values non_zero_indexes = data_row.nonzero()[1] num_cols = len(non_zero_indexes) instance_sample = data_row[:, non_zero_indexes] scale = scale[non_zero_indexes] mean = mean[non_zero_indexes] # Generate samples using the given generator if self.generator == "RBF": # With RBF and Forest, we load data which were generated in R if self.generator_specs["experiment"] == "Compas": df = pd.read_csv("..\Data\compas_RBF.csv") elif self.generator_specs["experiment"] == "German": df = pd.read_csv("..\Data\german_RBF.csv") else: df = pd.read_csv("..\Data\cc_RBF.csv") # There are no nominal features in CC dataset if self.generator_specs["experiment"] != "CC": df = pd.get_dummies(df) df = df[self.feature_names] inverse = df.values inverse[0,:] = data_row data = inverse.copy() for feature in categorical_features: data[:, feature] = (inverse[:, feature] == data_row[feature]).astype(int) return data, inverse if self.generator == "Forest": if self.generator_specs["experiment"] == "Compas": df = pd.read_csv("..\Data\compas_forest.csv") elif self.generator_specs["experiment"] == "German": df = pd.read_csv("..\Data\german_forest.csv") else: df = pd.read_csv("..\Data\cc_forest.csv") if self.generator_specs["experiment"] != "CC": df = pd.get_dummies(df) df = df[self.feature_names] inverse = df.values inverse[0,:] = data_row data = inverse.copy() for feature in categorical_features: data[:, feature] = (inverse[:, feature] == data_row[feature]).astype(int) return data, inverse # Perturbations if self.generator is None: data = self.random_state.normal( 0, 1, num_samples * num_cols).reshape( num_samples, num_cols) if self.sample_around_instance: data = data * scale + instance_sample else: data = data * scale + mean # With VAE and DropoutVAE we generate new data in vicinity of data_row elif isinstance(self.generator, VAE): reshaped = data_row.reshape(1, -1) scaled = self.generator_scaler.transform(reshaped) encoded = self.generator.encoder.predict(scaled) encoded = np.asarray(encoded) results = [] latent_gen = [] for _ in range(num_samples): epsilon = np.random.normal(0., 1., encoded.shape[2]) latent_gen.extend([encoded[0, 0, :] + np.exp(encoded[1, 0, :]*0.5)*epsilon]) latent_gen = np.asarray(latent_gen) results.append(self.generator.generate(latent_gen)) results = np.asarray(results) results = np.reshape(results, (-1, len(data_row))) data = self.generator_scaler.inverse_transform(results) elif isinstance(self.generator, DropoutVAE): reshaped = data_row.reshape(1, -1) scaled = self.generator_scaler.transform(reshaped) scaled = np.reshape(scaled, (-1, len(data_row))) encoded = self.generator.mean_predict(scaled, nums = num_samples) results = encoded results = results.reshape(num_samples*results.shape[2], len(data_row)) data = self.generator_scaler.inverse_transform(results) # Round up integer attributes data[:, self.integer_attributes] = (np.around(data[:, self.integer_attributes])).astype(int) if is_sparse: if num_cols == 0: data = sp.sparse.csr_matrix((num_samples, data_row.shape[1]), dtype=data_row.dtype) else: indexes = np.tile(non_zero_indexes, num_samples) indptr = np.array( range(0, len(non_zero_indexes) * (num_samples + 1), len(non_zero_indexes))) data_1d_shape = data.shape[0] * data.shape[1] data_1d = data.reshape(data_1d_shape) data = sp.sparse.csr_matrix( (data_1d, indexes, indptr), shape=(num_samples, data_row.shape[1])) categorical_features = self.categorical_features first_row = data_row else: first_row = self.discretizer.discretize(data_row) data[0] = data_row.copy() inverse = data.copy() if self.generator is None: for column in categorical_features: values = self.feature_values[column] freqs = self.feature_frequencies[column] inverse_column = self.random_state.choice(values, size=num_samples, replace=True, p=freqs) binary_column = (inverse_column == first_row[column]).astype(int) binary_column[0] = 1 inverse_column[0] = data[0, column] data[:, column] = binary_column inverse[:, column] = inverse_column # We assume categorical features are binary encoded else: for feature in self.dummies: column = data[:, feature] binary = np.zeros(column.shape) # We check for binary features with only 2 possible values if len(feature) == 1: binary = (column > 0.5).astype(int) # Put ones in data, where the value of chosen feature is same as in data_row data[:, feature] = (binary == first_row[feature]).astype(int) else: # Delegate 1 to the dummy_variable with the highest value ones = column.argmax(axis = 1) for i, idx in enumerate(ones): binary[i, idx] = 1 # Put ones in data, where the value of chosen feature is same as in data_row for i, idx in enumerate(feature): data[:, idx] = (binary[:, i] == first_row[idx]).astype(int) inverse[:, feature] = binary if self.discretizer is not None: inverse[1:] = self.discretizer.undiscretize(inverse[1:]) inverse[0] = data_row return data, inverse
class LimeTabularExplainer(object): """Explains predictions on tabular (i.e. matrix) data. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained.""" def __init__(self, training_data, mode="classification", training_labels=None, feature_names=None, categorical_features=None, categorical_names=None, kernel_width=None, verbose=False, class_names=None, feature_selection='auto', discretize_continuous=True, discretizer='quartile'): """Init function. Args: training_data: numpy 2d array mode: "classification" or "regression" training_labels: labels for training data. Not required, but may be used by discretizer. feature_names: list of names (strings) corresponding to the columns in the training data. categorical_features: list of indices (ints) corresponding to the categorical columns. Everything else will be considered continuous. Values in these columns MUST be integers. categorical_names: map from int to list of names, where categorical_names[x][y] represents the name of the yth value of column x. kernel_width: kernel width for the exponential kernel. If None, defaults to sqrt(number of columns) * 0.75 verbose: if true, print local prediction values from linear model class_names: list of class names, ordered according to whatever the classifier is using. If not present, class names will be '0', '1', ... feature_selection: feature selection method. can be 'forward_selection', 'lasso_path', 'none' or 'auto'. See function 'explain_instance_with_data' in lime_base.py for details on what each of the options does. discretize_continuous: if True, all non-categorical features will be discretized into quartiles. discretizer: only matters if discretize_continuous is True. Options are 'quartile', 'decile' or 'entropy' """ self.mode = mode self.feature_names = list(feature_names) self.categorical_names = categorical_names self.categorical_features = categorical_features if self.categorical_names is None: self.categorical_names = {} if self.categorical_features is None: self.categorical_features = [] if self.feature_names is None: self.feature_names = [ str(i) for i in range(training_data.shape[1]) ] self.discretizer = None if discretize_continuous: if discretizer == 'quartile': self.discretizer = QuartileDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels) elif discretizer == 'decile': self.discretizer = DecileDiscretizer(training_data, self.categorical_features, self.feature_names, labels=training_labels) elif discretizer == 'entropy': self.discretizer = EntropyDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels) else: raise ValueError('''Discretizer must be 'quartile',''' + ''' 'decile' or 'entropy' ''') self.categorical_features = range(training_data.shape[1]) discretized_training_data = self.discretizer.discretize( training_data) if kernel_width is None: kernel_width = np.sqrt(training_data.shape[1]) * .75 kernel_width = float(kernel_width) def kernel(d): return np.sqrt(np.exp(-(d**2) / kernel_width**2)) self.feature_selection = feature_selection self.base = lime_base.LimeBase(kernel, verbose) self.scaler = None self.class_names = class_names self.scaler = sklearn.preprocessing.StandardScaler(with_mean=False) self.scaler.fit(training_data) self.feature_values = {} self.feature_frequencies = {} for feature in self.categorical_features: feature_count = collections.defaultdict(lambda: 0.0) column = training_data[:, feature] if self.discretizer is not None: column = discretized_training_data[:, feature] feature_count[0] = 0. feature_count[1] = 0. feature_count[2] = 0. feature_count[3] = 0. for value in column: feature_count[value] += 1 values, frequencies = map(list, zip(*(feature_count.items()))) self.feature_values[feature] = values self.feature_frequencies[feature] = (np.array(frequencies) / sum(frequencies)) self.scaler.mean_[feature] = 0 self.scaler.scale_[feature] = 1 @staticmethod def convert_and_round(values): return ['%.2f' % v for v in values] def explain_instance(self, data_row, predict_fn, labels=(1, ), top_labels=None, num_features=10, num_samples=5000, distance_metric='euclidean', model_regressor=None): """Generates explanations for a prediction. First, we generate neighborhood data by randomly perturbing features from the instance (see __data_inverse). We then learn locally weighted linear models on this neighborhood data to explain each of the classes in an interpretable way (see lime_base.py). Args: data_row: 1d numpy array, corresponding to a row predict_fn: prediction function. For classifiers, this should be a function that takes a numpy array and outputs prediction probabilities. For regressors, this takes a numpy array and returns the predictions. For ScikitClassifiers, this is `classifier.predict_proba()`. For ScikitRegressors, this is `regressor.predict()`. labels: iterable with labels to be explained. top_labels: if not None, ignore labels and produce explanations for the K labels with highest prediction probabilities, where K is this parameter. num_features: maximum number of features present in explanation num_samples: size of the neighborhood to learn the linear model distance_metric: the distance metric to use for weights. model_regressor: sklearn regressor to use in explanation. Defaults to Ridge regression in LimeBase. Must have model_regressor.coef_ and 'sample_weight' as a parameter to model_regressor.fit() Returns: An Explanation object (see explanation.py) with the corresponding explanations. """ data, inverse = self.__data_inverse(data_row, num_samples) scaled_data = (data - self.scaler.mean_) / self.scaler.scale_ distances = sklearn.metrics.pairwise_distances( scaled_data, scaled_data[0].reshape(1, -1), metric=distance_metric).ravel() yss = predict_fn(inverse) # for classification, the model needs to provide a list of tuples - classes # along with prediction proabilities if self.mode == "classification": if len(yss.shape) == 1: raise NotImplementedError( "LIME does not currently support " "classifier models without probability " "scores. If this conflicts with your " "use case, please let us know: " "https://github.com/datascienceinc/lime/issues/16") elif len(yss.shape) == 2: if self.class_names is None: self.class_names = [str(x) for x in range(yss[0].shape[0])] else: self.class_names = list(self.class_names) if not np.allclose(yss.sum(axis=1), 1.0): warnings.warn(""" Prediction probabilties do not sum to 1, and thus does not constitute a probability space. Check that you classifier outputs probabilities (Not log probabilities, or actual class predictions). """) else: raise ValueError("Your model outputs " "arrays with {} dimensions".format( len(yss.shape))) # for regression, the output should be a one-dimensional array of predictions else: yss = predict_fn(inverse) try: assert isinstance(yss, np.ndarray) and len(yss.shape) == 1 except AssertionError: raise ValueError( "Your model needs to output single-dimensional \ numpyarrays, not arrays of {} dimensions".format( yss.shape)) predicted_value = yss[0] min_y = min(yss) max_y = max(yss) # add a dimension to be compatible with downstream machinery yss = yss[:, np.newaxis] feature_names = copy.deepcopy(self.feature_names) if feature_names is None: feature_names = [str(x) for x in range(data_row.shape[0])] values = self.convert_and_round(data_row) for i in self.categorical_features: if self.discretizer is not None and i in self.discretizer.lambdas: continue name = int(data_row[i]) if i in self.categorical_names: name = self.categorical_names[i][name] feature_names[i] = '%s=%s' % (feature_names[i], name) values[i] = 'True' categorical_features = self.categorical_features discretized_feature_names = None if self.discretizer is not None: categorical_features = range(data.shape[1]) discretized_instance = self.discretizer.discretize(data_row) discretized_feature_names = copy.deepcopy(feature_names) for f in self.discretizer.names: discretized_feature_names[f] = self.discretizer.names[f][int( discretized_instance[f])] domain_mapper = TableDomainMapper( feature_names, values, scaled_data[0], categorical_features=categorical_features, discretized_feature_names=discretized_feature_names) ret_exp = explanation.Explanation(domain_mapper, mode=self.mode, class_names=self.class_names) if self.mode == "classification": ret_exp.predict_proba = yss[0] if top_labels: labels = np.argsort(yss[0])[-top_labels:] ret_exp.top_labels = list(labels) ret_exp.top_labels.reverse() else: ret_exp.predicted_value = predicted_value ret_exp.min_value = min_y ret_exp.max_value = max_y labels = [0] for label in labels: (ret_exp.intercept[label], ret_exp.local_exp[label], ret_exp.score) = self.base.explain_instance_with_data( scaled_data, yss, distances, label, num_features, model_regressor=model_regressor, feature_selection=self.feature_selection) if self.mode == "regression": ret_exp.intercept[1] = ret_exp.intercept[0] ret_exp.local_exp[1] = [x for x in ret_exp.local_exp[0]] ret_exp.local_exp[0] = [(i, -1 * j) for i, j in ret_exp.local_exp[1]] return ret_exp def __data_inverse(self, data_row, num_samples): """Generates a neighborhood around a prediction. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained. Args: data_row: 1d numpy array, corresponding to a row num_samples: size of the neighborhood to learn the linear model Returns: A tuple (data, inverse), where: data: dense num_samples * K matrix, where categorical features are encoded with either 0 (not equal to the corresponding value in data_row) or 1. The first row is the original instance. inverse: same as data, except the categorical features are not binary, but categorical (as the original data) """ data = np.zeros((num_samples, data_row.shape[0])) categorical_features = range(data_row.shape[0]) if self.discretizer is None: data = np.random.normal(0, 1, num_samples * data_row.shape[0]).reshape( num_samples, data_row.shape[0]) data = data * self.scaler.scale_ + self.scaler.mean_ categorical_features = self.categorical_features first_row = data_row else: first_row = self.discretizer.discretize(data_row) data[0] = data_row.copy() inverse = data.copy() for column in categorical_features: values = self.feature_values[column] freqs = self.feature_frequencies[column] inverse_column = np.random.choice(values, size=num_samples, replace=True, p=freqs) binary_column = np.array( [1 if x == first_row[column] else 0 for x in inverse_column]) binary_column[0] = 1 inverse_column[0] = data[0, column] data[:, column] = binary_column inverse[:, column] = inverse_column if self.discretizer is not None: inverse[1:] = self.discretizer.undiscretize(inverse[1:]) inverse[0] = data_row return data, inverse
class LimeTabularExplainer(object): """Explains predictions on tabular (i.e. matrix) data. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained.""" def __init__(self, training_data, mode="classification", training_labels=None, feature_names=None, categorical_features=None, categorical_names=None, kernel_width=None, kernel=None, verbose=False, class_names=None, feature_selection='auto', discretize_continuous=True, discretizer='quartile', sample_around_instance=False, random_state=None): """Init function. Args: training_data: numpy 2d array mode: "classification" or "regression" training_labels: labels for training data. Not required, but may be used by discretizer. feature_names: list of names (strings) corresponding to the columns in the training data. categorical_features: list of indices (ints) corresponding to the categorical columns. Everything else will be considered continuous. Values in these columns MUST be integers. categorical_names: map from int to list of names, where categorical_names[x][y] represents the name of the yth value of column x. kernel_width: kernel width for the exponential kernel. If None, defaults to sqrt (number of columns) * 0.75 kernel: similarity kernel that takes euclidean distances and kernel width as input and outputs weights in (0,1). If None, defaults to an exponential kernel. verbose: if true, print local prediction values from linear model class_names: list of class names, ordered according to whatever the classifier is using. If not present, class names will be '0', '1', ... feature_selection: feature selection method. can be 'forward_selection', 'lasso_path', 'none' or 'auto'. See function 'explain_instance_with_data' in lime_base.py for details on what each of the options does. discretize_continuous: if True, all non-categorical features will be discretized into quartiles. discretizer: only matters if discretize_continuous is True. Options are 'quartile', 'decile', 'entropy' or a BaseDiscretizer instance. sample_around_instance: if True, will sample continuous features in perturbed samples from a normal centered at the instance being explained. Otherwise, the normal is centered on the mean of the feature data. random_state: an integer or numpy.RandomState that will be used to generate random numbers. If None, the random state will be initialized using the internal numpy seed. """ self.random_state = check_random_state(random_state) self.mode = mode self.categorical_names = categorical_names or {} self.sample_around_instance = sample_around_instance if categorical_features is None: categorical_features = [] if feature_names is None: feature_names = [str(i) for i in range(training_data.shape[1])] self.categorical_features = list(categorical_features) self.feature_names = list(feature_names) self.discretizer = None if discretize_continuous: if discretizer == 'quartile': self.discretizer = QuartileDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels) elif discretizer == 'decile': self.discretizer = DecileDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels) elif discretizer == 'entropy': self.discretizer = EntropyDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels) elif isinstance(discretizer, BaseDiscretizer): self.discretizer = discretizer else: raise ValueError('''Discretizer must be 'quartile',''' + ''' 'decile', 'entropy' or a''' + ''' BaseDiscretizer instance''') self.categorical_features = list(range(training_data.shape[1])) discretized_training_data = self.discretizer.discretize( training_data) if kernel_width is None: kernel_width = np.sqrt(training_data.shape[1]) * .75 kernel_width = float(kernel_width) if kernel is None: def kernel(d, kernel_width): return np.sqrt(np.exp(-(d**2) / kernel_width**2)) kernel_fn = partial(kernel, kernel_width=kernel_width) self.feature_selection = feature_selection self.base = lime_base.LimeBase( kernel_fn, verbose, random_state=self.random_state) self.scaler = None self.class_names = class_names self.scaler = sklearn.preprocessing.StandardScaler(with_mean=False) self.scaler.fit(training_data) self.feature_values = {} self.feature_frequencies = {} for feature in self.categorical_features: if self.discretizer is not None: column = discretized_training_data[:, feature] else: column = training_data[:, feature] feature_count = collections.Counter(column) values, frequencies = map(list, zip(*(feature_count.items()))) self.feature_values[feature] = values self.feature_frequencies[feature] = ( np.array(frequencies) / float(sum(frequencies))) self.scaler.mean_[feature] = 0 self.scaler.scale_[feature] = 1 @staticmethod def convert_and_round(values): return ['%.2f' % v for v in values] def explain_instance(self, data_row, data_quan, predict_fn, labels=(1, ), top_labels=None, num_features=10, num_samples=5000, distance_metric='euclidean', model_regressor=None, train_local_error=None, test_local_error=None, metric="accuracy", retrive_model=False): """Generates explanations for a prediction. First, we generate neighborhood data by randomly perturbing features from the instance (see __data_inverse). We then learn locally weighted linear models on this neighborhood data to explain each of the classes in an interpretable way (see lime_base.py). Args: data_row: 1d numpy array, corresponding to a row predict_fn: prediction function. For classifiers, this should be a function that takes a numpy array and outputs prediction probabilities. For regressors, this takes a numpy array and returns the predictions. For ScikitClassifiers, this is `classifier.predict_proba()`. For ScikitRegressors, this is `regressor.predict()`. The prediction function needs to work on multiple feature vectors (the vectors randomly perturbed from the data_row). labels: iterable with labels to be explained. top_labels: if not None, ignore labels and produce explanations for the K labels with highest prediction probabilities, where K is this parameter. num_features: maximum number of features present in explanation num_samples: size of the neighborhood to learn the linear model distance_metric: the distance metric to use for weights. model_regressor: sklearn regressor to use in explanation. Defaults to Ridge regression in LimeBase. Must have model_regressor.coef_ and 'sample_weight' as a parameter to model_regressor.fit() Returns: An Explanation object (see explanation.py) with the corresponding explanations. """ # Only one measurement method can be used at once. assert (train_local_error is None or test_local_error is None) if data_quan.shape[0] <10: num = data_quan.shape[0]*10 else: num = data.shape[0] if train_local_error is not None: data = np.zeros((num, data_row.shape[0])) if data_quan.shape[0]<10: for i in range(10): for j in range(data_quan.shape[0]) data[j+10*i] = data_quan[j] else: for i in range(num): data[i] = data_quan[i] inverse = np.zeros((num, data_row.shape[0])) if data_quan.shape[0] < 10: for i in range(10): for j in range(data_quan.shape[0]) inverse[j + 10 * i] = data_quan[j] else: for i in range(num): inverse[i] = data_quan[i] else: data = np.zeros((num, data_row.shape[0])) if data_quan.shape[0] < 10: for i in range(10): for j in range(data_quan.shape[0]) data[j + 10 * i] = data_quan[j] else: for i in range(num): data[i] = data_quan[i] inverse = np.zeros((num, data_row.shape[0])) if data_quan.shape[0] < 10: for i in range(10): for j in range(data_quan.shape[0]) inverse[j + 10 * i] = data_quan[j] else: for i in range(num): inverse[i] = data_quan[i] scaled_data = (data - self.scaler.mean_) / self.scaler.scale_ distances = sklearn.metrics.pairwise_distances( scaled_data, scaled_data[0].reshape(1, -1), metric=distance_metric).ravel() yss = predict_fn(inverse,data_quan.shape[0]) # for classification, the model needs to provide a list of tuples - classes # along with prediction probabilities if self.mode == "classification": if len(yss.shape) == 1: raise NotImplementedError( "LIME does not currently support " "classifier models without probability " "scores. If this conflicts with your " "use case, please let us know: " "https://github.com/datascienceinc/lime/issues/16") elif len(yss.shape) == 2: if self.class_names is None: self.class_names = [str(x) for x in range(yss[0].shape[0])] else: self.class_names = list(self.class_names) if not np.allclose(yss.sum(axis=1), 1.0): # warnings.warn(""" # Prediction probabilties do not sum to 1, and # thus does not constitute a probability space. # Check that you classifier outputs probabilities # (Not log probabilities, or actual class predictions). # """) pass else: raise ValueError("Your model outputs " "arrays with {} dimensions".format( len(yss.shape))) # for regression, the output should be a one-dimensional array of predictions else: try: assert isinstance(yss, np.ndarray) and len(yss.shape) == 1 except AssertionError: raise ValueError( "Your model needs to output single-dimensional \ numpyarrays, not arrays of {} dimensions".format( yss.shape)) predicted_value = yss[0] min_y = min(yss) max_y = max(yss) # add a dimension to be compatible with downstream machinery yss = yss[:, np.newaxis] feature_names = copy.deepcopy(self.feature_names) if feature_names is None: feature_names = [str(x) for x in range(data_row.shape[0])] values = self.convert_and_round(data_row) for i in self.categorical_features: if self.discretizer is not None and i in self.discretizer.lambdas: continue name = int(data_row[i]) if i in self.categorical_names: name = self.categorical_names[i][name] feature_names[i] = '%s=%s' % (feature_names[i], name) values[i] = 'True' categorical_features = self.categorical_features discretized_feature_names = None if self.discretizer is not None: categorical_features = range(data.shape[1]) discretized_instance = self.discretizer.discretize(data_row) discretized_feature_names = copy.deepcopy(feature_names) for f in self.discretizer.names: discretized_feature_names[f] = self.discretizer.names[f][int( discretized_instance[f])] domain_mapper = TableDomainMapper( feature_names, values, scaled_data[0], categorical_features=categorical_features, discretized_feature_names=discretized_feature_names) ret_exp = explanation.Explanation( domain_mapper, mode=self.mode, class_names=self.class_names) ret_exp.scaled_data = scaled_data if self.mode == "classification": ret_exp.predict_proba = yss[0] if top_labels: labels = np.argsort(yss[0])[-top_labels:] ret_exp.top_labels = list(labels) ret_exp.top_labels.reverse() else: ret_exp.predicted_value = predicted_value ret_exp.min_value = min_y ret_exp.max_value = max_y labels = [0] full_local_preds = np.zeros((num_samples, len(labels))) local_models = [] for i, label in enumerate(labels): (ret_exp.intercept[label], ret_exp.local_exp[label], ret_exp.score, ret_exp.local_pred, full_local_pred, easy_model) = self.base.explain_instance_with_data( scaled_data, yss, distances, label, num_features, model_regressor=model_regressor, feature_selection=self.feature_selection, full_local=True) if train_local_error is not None: full_local_preds[:, i] = full_local_pred if test_local_error is not None or retrive_model: local_models.append(easy_model) if train_local_error is not None: local_pred_result = np.argmax(full_local_preds, axis=1) pred_result = np.argmax(yss, axis=1) if metric == "accuracy": metric_r = np.sum(np.int32(local_pred_result == pred_result)) elif metric == "distance": metric_r = np.sum(np.abs(local_pred_result - pred_result)) elif metric == "rmse": metric_r = np.sqrt(np.mean(np.square(full_local_preds - yss))) elif metric == "both": metric_r == (np.sum( np.int32(local_pred_result == pred_result)), np.sum(np.abs(local_pred_result - pred_result))) # print(f"show a sample {local_pred_result[0:10]}, and {pred_result[0:10]}") # print (f"derivation {local_error}, sum of error {error_num}" # f", ratio of error {error_num / num_samples}") if test_local_error is not None: if metric != 'both': metric_r = [] else: metric_r = ([], []) for derivation in test_local_error: local_preds = np.zeros((num_samples, len(labels))) test_data, test_inverse = self.__data_inverse( data_row, num_samples, derivation=derivation) test_scaled_data = ( test_data - self.scaler.mean_) / self.scaler.scale_ test_yss = predict_fn(test_inverse) for i in range(len(labels)): local_preds[:, i] = local_models[i].predict( test_scaled_data) test_rl_result = np.argmax(test_yss, axis=1) test_easy_result = np.argmax(local_preds, axis=1) # print(f"size of test_rl_result {test_rl_result.shape}") # print(f"size of test_easy_result {test_easy_result.shape}") # print(f"show a sample {test_rl_result[0:10]}, and {test_easy_result[0:10]}") if metric == "accuracy": metric_r.append( np.sum(np.int32(test_rl_result == test_easy_result))) elif metric == "distance": metric_r.append( np.sum(np.abs(test_rl_result - test_easy_result))) elif metric == "rmse": metric_r.append( np.sqrt(np.mean(np.square(test_yss - local_preds)))) elif metric == "both": metric_r[0].append( np.sum(np.int32(test_rl_result == test_easy_result))) metric_r[1].append( np.sum(np.abs(test_rl_result - test_easy_result))) if self.mode == "regression": ret_exp.intercept[1] = ret_exp.intercept[0] ret_exp.local_exp[1] = [x for x in ret_exp.local_exp[0]] ret_exp.local_exp[0] = [(i, -1 * j) for i, j in ret_exp.local_exp[1]] if retrive_model: return local_models elif train_local_error is not None or test_local_error is not None: return ret_exp, metric_r else: return ret_exp def explain_instance_with_lemna(self, data_row, data_quan, predict_fn, labels=(1, ), top_labels=None, lemna_component=5, num_features=10, num_samples=5000, model_regressor=None, train_local_error=None, test_local_error=None, metric="accuracy", retrive_model=False): """Generates explanations for a prediction. First, we generate neighborhood data by randomly perturbing features from the instance (see __data_inverse). We then learn locally weighted linear models on this neighborhood data to explain each of the classes in an interpretable way (see lime_base.py). we remove regression related code here. Args: data_row: 1d numpy array, corresponding to a row predict_fn: prediction function. For classifiers, this should be a function that takes a numpy array and outputs prediction probabilities. For regressors, this takes a numpy array and returns the predictions. For ScikitClassifiers, this is `classifier.predict_proba()`. For ScikitRegressors, this is `regressor.predict()`. The prediction function needs to work on multiple feature vectors (the vectors randomly perturbed from the data_row). labels: iterable with labels to be explained. top_labels: if not None, ignore labels and produce explanations for the K labels with highest prediction probabilities, where K is this parameter. num_features: maximum number of features present in explanation num_samples: size of the neighborhood to learn the linear model distance_metric: the distance metric to use for weights. model_regressor: sklearn regressor to use in explanation. Defaults to Ridge regression in LimeBase. Must have model_regressor.coef_ and 'sample_weight' as a parameter to model_regressor.fit() Returns: An Explanation object (see explanation.py) with the corresponding explanations. """ # Only one measurement method can be used at once. assert (train_local_error is None or test_local_error is None) if data_quan.shape[0] < 10: num = data_quan.shape[0] * 10 else: num = data.shape[0] if train_local_error is not None: data = np.zeros((num, data_row.shape[0])) if data_quan.shape[0] < 10: for i in range(10): for j in range(data_quan.shape[0]) data[j + 10 * i] = data_quan[j] else: for i in range(num): data[i] = data_quan[i] inverse = np.zeros((num, data_row.shape[0])) if data_quan.shape[0] < 10: for i in range(10): for j in range(data_quan.shape[0]) inverse[j + 10 * i] = data_quan[j] else: for i in range(num): inverse[i] = data_quan[i] else: data = np.zeros((num, data_row.shape[0])) if data_quan.shape[0] < 10: for i in range(10): for j in range(data_quan.shape[0]) data[j + 10 * i] = data_quan[j] else: for i in range(num): data[i] = data_quan[i] inverse = np.zeros((num, data_row.shape[0])) if data_quan.shape[0] < 10: for i in range(10): for j in range(data_quan.shape[0]) inverse[j + 10 * i] = data_quan[j] else: for i in range(num): inverse[i] = data_quan[i] scaled_data = (data - self.scaler.mean_) / self.scaler.scale_ yss = predict_fn(inverse, data_quan.shape[0]) # for classification, the model needs to provide a list of tuples - classes # along with prediction probabilities if self.mode == "classification": if len(yss.shape) == 1: raise NotImplementedError( "LIME does not currently support " "classifier models without probability " "scores. If this conflicts with your " "use case, please let us know: " "https://github.com/datascienceinc/lime/issues/16") elif len(yss.shape) == 2: if self.class_names is None: self.class_names = [str(x) for x in range(yss[0].shape[0])] else: self.class_names = list(self.class_names) if not np.allclose(yss.sum(axis=1), 1.0): warnings.warn(""" Prediction probabilties do not sum to 1, and thus does not constitute a probability space. Check that you classifier outputs probabilities (Not log probabilities, or actual class predictions). """) else: raise ValueError("Your model outputs " "arrays with {} dimensions".format( len(yss.shape))) feature_names = copy.deepcopy(self.feature_names) if feature_names is None: feature_names = [str(x) for x in range(data_row.shape[0])] values = self.convert_and_round(data_row) for i in self.categorical_features: if self.discretizer is not None and i in self.discretizer.lambdas: continue name = int(data_row[i]) if i in self.categorical_names: name = self.categorical_names[i][name] feature_names[i] = '%s=%s' % (feature_names[i], name) values[i] = 'True' categorical_features = self.categorical_features discretized_feature_names = None if self.discretizer is not None: categorical_features = range(data.shape[1]) discretized_instance = self.discretizer.discretize(data_row) discretized_feature_names = copy.deepcopy(feature_names) for f in self.discretizer.names: discretized_feature_names[f] = self.discretizer.names[f][int( discretized_instance[f])] domain_mapper = TableDomainMapper( feature_names, values, scaled_data[0], categorical_features=categorical_features, discretized_feature_names=discretized_feature_names) ret_exp = explanation.Explanation( domain_mapper, mode=self.mode, class_names=self.class_names) ret_exp.scaled_data = scaled_data if self.mode == "classification": ret_exp.predict_proba = yss[0] if top_labels: labels = np.argsort(yss[0])[-top_labels:] ret_exp.top_labels = list(labels) ret_exp.top_labels.reverse() full_local_preds = np.zeros((num_samples, len(labels))) local_models = [] for i, label in enumerate(labels): (full_local_pred, easy_model) = self.base.explain_instance_with_data_limna( scaled_data, yss, label, num_features, component=lemna_component) if train_local_error is not None: full_local_preds[:, i] = full_local_pred if test_local_error is not None or retrive_model: local_models.append(easy_model) if train_local_error is not None: local_pred_result = np.argmax(full_local_preds, axis=1) pred_result = np.argmax(yss, axis=1) if metric == "accuracy": metric_r = np.sum(np.int32(local_pred_result == pred_result)) elif metric == "distance": metric_r = np.sum(np.abs(local_pred_result - pred_result)) elif metric == "rmse": metric_r = np.sqrt(np.mean(np.square(full_local_preds - yss))) elif metric == "both": metric_r = (np.sum(np.int32(local_pred_result == pred_result)), np.sum(np.abs(local_pred_result - pred_result))) # print(f"show a sample {local_pred_result[0:10]}, and {pred_result[0:10]}") # print (f"derivation {local_error}, sum of error {error_num}" # f", ratio of error {error_num / num_samples}") if test_local_error is not None: if metric != "both": metric_r = [] else: metric_r = ([], []) for derivation in test_local_error: local_preds = np.zeros((num_samples, len(labels))) test_data, test_inverse = self.__data_inverse( data_row, num_samples, derivation=derivation) test_scaled_data = ( test_data - self.scaler.mean_) / self.scaler.scale_ test_yss = predict_fn(test_inverse) for i in range(len(labels)): local_preds[:, i] = local_models[i].predict( test_scaled_data) test_rl_result = np.argmax(test_yss, axis=1) test_easy_result = np.argmax(local_preds, axis=1) # print(f"size of test_rl_result {test_rl_result.shape}") # print(f"size of test_easy_result {test_easy_result.shape}") # print(f"show a sample {test_rl_result[0:10]}, and {test_easy_result[0:10]}") if metric == "accuracy": metric_r.append( np.sum(np.int32(test_rl_result == test_easy_result))) elif metric == "distance": metric_r.append( np.sum(np.abs(test_rl_result - test_easy_result))) elif metric == "rmse": metric_r.append( np.sqrt(np.mean(np.square(test_yss - local_preds)))) elif metric == "both": metric_r[0].append( np.sum(np.int32(test_rl_result == test_easy_result))) metric_r[1].append( np.sum(np.abs(test_rl_result - test_easy_result))) if retrive_model: return local_models elif train_local_error is not None or test_local_error is not None: return ret_exp, metric_r else: return ret_exp def __data_inverse(self, data_row, num_samples, derivation=1.0): """Generates a neighborhood around a prediction. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained. Args: data_row: 1d numpy array, corresponding to a row num_samples: size of the neighborhood to learn the linear model Returns: A tuple (data, inverse), where: data: dense num_samples * K matrix, where categorical features are encoded with either 0 (not equal to the corresponding value in data_row) or 1. The first row is the original instance. inverse: same as data, except the categorical features are not binary, but categorical (as the original data) """ data = np.zeros((num_samples, data_row.shape[0])) categorical_features = range(data_row.shape[0]) if self.discretizer is None: data = self.random_state.normal( 0, derivation, num_samples * data_row.shape[0]).reshape( num_samples, data_row.shape[0]) if self.sample_around_instance: data = data * self.scaler.scale_ + data_row else: data = data * self.scaler.scale_ + self.scaler.mean_ categorical_features = self.categorical_features first_row = data_row else: first_row = self.discretizer.discretize(data_row) data[0] = data_row.copy() inverse = data.copy() for column in categorical_features: values = self.feature_values[column] freqs = self.feature_frequencies[column] inverse_column = self.random_state.choice( values, size=num_samples, replace=True, p=freqs) binary_column = np.array( [1 if x == first_row[column] else 0 for x in inverse_column]) binary_column[0] = 1 inverse_column[0] = data[0, column] data[:, column] = binary_column inverse[:, column] = inverse_column if self.discretizer is not None: inverse[1:] = self.discretizer.undiscretize(inverse[1:]) inverse[0] = data_row return data, inverse
class LimeTabularExplainer(object): """Explains predictions on tabular (i.e. matrix) data. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained.""" def __init__(self, training_data, mode="classification", training_labels=None, feature_names=None, categorical_features=None, categorical_names=None, kernel_width=None, kernel=None, verbose=False, class_names=None, feature_selection='auto', discretize_continuous=True, discretizer='quartile', sample_around_instance=False, random_state=None, training_data_stats=None): """Init function. Args: training_data: numpy 2d array mode: "classification" or "regression" training_labels: labels for training data. Not required, but may be used by discretizer. feature_names: list of names (strings) corresponding to the columns in the training data. categorical_features: list of indices (ints) corresponding to the categorical columns. Everything else will be considered continuous. Values in these columns MUST be integers. categorical_names: map from int to list of names, where categorical_names[x][y] represents the name of the yth value of column x. kernel_width: kernel width for the exponential kernel. If None, defaults to sqrt (number of columns) * 0.75 kernel: similarity kernel that takes euclidean distances and kernel width as input and outputs weights in (0,1). If None, defaults to an exponential kernel. verbose: if true, print local prediction values from linear model class_names: list of class names, ordered according to whatever the classifier is using. If not present, class names will be '0', '1', ... feature_selection: feature selection method. can be 'forward_selection', 'lasso_path', 'none' or 'auto'. See function 'explain_instance_with_data' in lime_base.py for details on what each of the options does. discretize_continuous: if True, all non-categorical features will be discretized into quartiles. discretizer: only matters if discretize_continuous is True. Options are 'quartile', 'decile', 'entropy' or a BaseDiscretizer instance. sample_around_instance: if True, will sample continuous features in perturbed samples from a normal centered at the instance being explained. Otherwise, the normal is centered on the mean of the feature data. random_state: an integer or numpy.RandomState that will be used to generate random numbers. If None, the random state will be initialized using the internal numpy seed. training_data_stats: a dict object having the details of training data statistics. If None, training data information will be used, only matters if discretize_continuous is True. Must have the following keys: means", "mins", "maxs", "stds", "feature_values", "feature_frequencies" """ self.random_state = check_random_state(random_state) self.mode = mode self.categorical_names = categorical_names or {} self.sample_around_instance = sample_around_instance self.training_data_stats = training_data_stats # Check and raise proper error in stats are supplied in non-descritized path if self.training_data_stats: self.validate_training_data_stats(self.training_data_stats) if categorical_features is None: categorical_features = [] if feature_names is None: feature_names = [str(i) for i in range(training_data.shape[1])] self.categorical_features = list(categorical_features) self.feature_names = list(feature_names) self.discretizer = None if discretize_continuous: # Set the discretizer if training data stats are provided if self.training_data_stats: discretizer = StatsDiscretizer(training_data, self.categorical_features, self.feature_names, labels=training_labels, data_stats=self.training_data_stats) if discretizer == 'quartile': self.discretizer = QuartileDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels) elif discretizer == 'decile': self.discretizer = DecileDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels) elif discretizer == 'entropy': self.discretizer = EntropyDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels) elif isinstance(discretizer, BaseDiscretizer): self.discretizer = discretizer else: raise ValueError('''Discretizer must be 'quartile',''' + ''' 'decile', 'entropy' or a''' + ''' BaseDiscretizer instance''') self.categorical_features = list(range(training_data.shape[1])) # Get the discretized_training_data when the stats are not provided if(self.training_data_stats is None): discretized_training_data = self.discretizer.discretize( training_data) if kernel_width is None: kernel_width = np.sqrt(training_data.shape[1]) * .75 kernel_width = float(kernel_width) if kernel is None: def kernel(d, kernel_width): return np.sqrt(np.exp(-(d ** 2) / kernel_width ** 2)) kernel_fn = partial(kernel, kernel_width=kernel_width) self.feature_selection = feature_selection self.base = lime_base.LimeBase(kernel_fn, verbose, random_state=self.random_state) self.class_names = class_names # Though set has no role to play if training data stats are provided self.scaler = None self.scaler = sklearn.preprocessing.StandardScaler(with_mean=False) self.scaler.fit(training_data) self.feature_values = {} self.feature_frequencies = {} for feature in self.categorical_features: if training_data_stats is None: if self.discretizer is not None: column = discretized_training_data[:, feature] else: column = training_data[:, feature] feature_count = collections.Counter(column) values, frequencies = map(list, zip(*(sorted(feature_count.items())))) else: values = training_data_stats["feature_values"][feature] frequencies = training_data_stats["feature_frequencies"][feature] self.feature_values[feature] = values self.feature_frequencies[feature] = (np.array(frequencies) / float(sum(frequencies))) self.scaler.mean_[feature] = 0 self.scaler.scale_[feature] = 1 @staticmethod def convert_and_round(values): return ['%.2f' % v for v in values] @staticmethod def validate_training_data_stats(training_data_stats): """ Method to validate the structure of training data stats """ stat_keys = list(training_data_stats.keys()) valid_stat_keys = ["means", "mins", "maxs", "stds", "feature_values", "feature_frequencies"] missing_keys = list(set(valid_stat_keys) - set(stat_keys)) if len(missing_keys) > 0: raise Exception("Missing keys in training_data_stats. Details:" % (missing_keys)) def explain_instance(self, data_row, predict_fn, labels=(1,), top_labels=None, num_features=10, num_samples=5000, distance_metric='euclidean', model_regressor=None): """Generates explanations for a prediction. First, we generate neighborhood data by randomly perturbing features from the instance (see __data_inverse). We then learn locally weighted linear models on this neighborhood data to explain each of the classes in an interpretable way (see lime_base.py). Args: data_row: 1d numpy array, corresponding to a row predict_fn: prediction function. For classifiers, this should be a function that takes a numpy array and outputs prediction probabilities. For regressors, this takes a numpy array and returns the predictions. For ScikitClassifiers, this is `classifier.predict_proba()`. For ScikitRegressors, this is `regressor.predict()`. The prediction function needs to work on multiple feature vectors (the vectors randomly perturbed from the data_row). labels: iterable with labels to be explained. top_labels: if not None, ignore labels and produce explanations for the K labels with highest prediction probabilities, where K is this parameter. num_features: maximum number of features present in explanation num_samples: size of the neighborhood to learn the linear model distance_metric: the distance metric to use for weights. model_regressor: sklearn regressor to use in explanation. Defaults to Ridge regression in LimeBase. Must have model_regressor.coef_ and 'sample_weight' as a parameter to model_regressor.fit() Returns: An Explanation object (see explanation.py) with the corresponding explanations. """ data, inverse = self.__data_inverse(data_row, num_samples) scaled_data = (data - self.scaler.mean_) / self.scaler.scale_ distances = sklearn.metrics.pairwise_distances( scaled_data, scaled_data[0].reshape(1, -1), metric=distance_metric ).ravel() yss = predict_fn(inverse) # for classification, the model needs to provide a list of tuples - classes # along with prediction probabilities if self.mode == "classification": if len(yss.shape) == 1: raise NotImplementedError("LIME does not currently support " "classifier models without probability " "scores. If this conflicts with your " "use case, please let us know: " "https://github.com/datascienceinc/lime/issues/16") elif len(yss.shape) == 2: if self.class_names is None: self.class_names = [str(x) for x in range(yss[0].shape[0])] else: self.class_names = list(self.class_names) if not np.allclose(yss.sum(axis=1), 1.0): warnings.warn(""" Prediction probabilties do not sum to 1, and thus does not constitute a probability space. Check that you classifier outputs probabilities (Not log probabilities, or actual class predictions). """) else: raise ValueError("Your model outputs " "arrays with {} dimensions".format(len(yss.shape))) # for regression, the output should be a one-dimensional array of predictions else: try: assert isinstance(yss, np.ndarray) and len(yss.shape) == 1 except AssertionError: raise ValueError("Your model needs to output single-dimensional \ numpyarrays, not arrays of {} dimensions".format(yss.shape)) predicted_value = yss[0] min_y = min(yss) max_y = max(yss) # add a dimension to be compatible with downstream machinery yss = yss[:, np.newaxis] feature_names = copy.deepcopy(self.feature_names) if feature_names is None: feature_names = [str(x) for x in range(data_row.shape[0])] values = self.convert_and_round(data_row) for i in self.categorical_features: if self.discretizer is not None and i in self.discretizer.lambdas: continue name = int(data_row[i]) if i in self.categorical_names: name = self.categorical_names[i][name] feature_names[i] = '%s=%s' % (feature_names[i], name) values[i] = 'True' categorical_features = self.categorical_features discretized_feature_names = None if self.discretizer is not None: categorical_features = range(data.shape[1]) discretized_instance = self.discretizer.discretize(data_row) discretized_feature_names = copy.deepcopy(feature_names) for f in self.discretizer.names: discretized_feature_names[f] = self.discretizer.names[f][int( discretized_instance[f])] domain_mapper = TableDomainMapper(feature_names, values, scaled_data[0], categorical_features=categorical_features, discretized_feature_names=discretized_feature_names) ret_exp = explanation.Explanation(domain_mapper, mode=self.mode, class_names=self.class_names) ret_exp.scaled_data = scaled_data if self.mode == "classification": ret_exp.predict_proba = yss[0] if top_labels: labels = np.argsort(yss[0])[-top_labels:] ret_exp.top_labels = list(labels) ret_exp.top_labels.reverse() else: ret_exp.predicted_value = predicted_value ret_exp.min_value = min_y ret_exp.max_value = max_y labels = [0] for label in labels: (ret_exp.intercept[label], ret_exp.local_exp[label], ret_exp.score, ret_exp.local_pred) = self.base.explain_instance_with_data( scaled_data, yss, distances, label, num_features, model_regressor=model_regressor, feature_selection=self.feature_selection) if self.mode == "regression": ret_exp.intercept[1] = ret_exp.intercept[0] ret_exp.local_exp[1] = [x for x in ret_exp.local_exp[0]] ret_exp.local_exp[0] = [(i, -1 * j) for i, j in ret_exp.local_exp[1]] return ret_exp def __data_inverse(self, data_row, num_samples): """Generates a neighborhood around a prediction. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained. Args: data_row: 1d numpy array, corresponding to a row num_samples: size of the neighborhood to learn the linear model Returns: A tuple (data, inverse), where: data: dense num_samples * K matrix, where categorical features are encoded with either 0 (not equal to the corresponding value in data_row) or 1. The first row is the original instance. inverse: same as data, except the categorical features are not binary, but categorical (as the original data) """ data = np.zeros((num_samples, data_row.shape[0])) categorical_features = range(data_row.shape[0]) if self.discretizer is None: data = self.random_state.normal( 0, 1, num_samples * data_row.shape[0]).reshape( num_samples, data_row.shape[0]) if self.sample_around_instance: data = data * self.scaler.scale_ + data_row else: data = data * self.scaler.scale_ + self.scaler.mean_ categorical_features = self.categorical_features first_row = data_row else: first_row = self.discretizer.discretize(data_row) data[0] = data_row.copy() inverse = data.copy() for column in categorical_features: values = self.feature_values[column] freqs = self.feature_frequencies[column] inverse_column = self.random_state.choice(values, size=num_samples, replace=True, p=freqs) binary_column = np.array([1 if x == first_row[column] else 0 for x in inverse_column]) binary_column[0] = 1 inverse_column[0] = data[0, column] data[:, column] = binary_column inverse[:, column] = inverse_column if self.discretizer is not None: inverse[1:] = self.discretizer.undiscretize(inverse[1:]) inverse[0] = data_row return data, inverse
class LimeTabularExplainer(object): """Explains predictions on tabular (i.e. matrix) data. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained.""" def __init__( self, training_data, training_labels=None, feature_names=None, categorical_features=None, categorical_names=None, kernel_width=None, verbose=False, class_names=None, feature_selection='auto', discretize_continuous=True, proposal_method="random", # random proposal vs. kde proposal discretizer='quartile'): """Init function. Args: training_data: numpy 2d array training_labels: labels for training data. Not required, but may be used by discretizer. feature_names: list of names (strings) corresponding to the columns in the training data. categorical_features: list of indices (ints) corresponding to the categorical columns. Everything else will be considered continuous. Values in these columns MUST be integers. categorical_names: map from int to list of names, where categorical_names[x][y] represents the name of the yth value of column x. kernel_width: kernel width for the exponential kernel. If None, defaults to sqrt(number of columns) * 0.75 verbose: if true, print local prediction values from linear model class_names: list of class names, ordered according to whatever the classifier is using. If not present, class names will be '0', '1', ... feature_selection: feature selection method. can be 'forward_selection', 'lasso_path', 'none' or 'auto'. See function 'explain_instance_with_data' in lime_base.py for details on what each of the options does. discretize_continuous: if True, all non-categorical features will be discretized into quartiles. discretizer: only matters if discretize_continuous is True. Options are 'quartile', 'decile' or 'entropy' """ #### jiaxuan's addition for kde proposing distribution #### self.proposal_method = proposal_method # standardize data X_train = training_data self.kde_scaler = StandardScaler() X_train = self.kde_scaler.fit_transform(X_train) # learn a kde classifier # use grid search cross-validation to optimize the bandwidth params = {'bandwidth': np.logspace(-1, 1, 20)} grid = GridSearchCV(KernelDensity(), params) grid.fit(X_train) print("best bandwidth: {0}".format(grid.best_estimator_.bandwidth)) # use the best estimator to compute the kernel density estimate self.kde = grid.best_estimator_ #### end jiaxuan's addition ######## self.categorical_names = categorical_names self.categorical_features = categorical_features if self.categorical_names is None: self.categorical_names = {} if self.categorical_features is None: self.categorical_features = [] self.discretizer = None if discretize_continuous: if discretizer == 'quartile': self.discretizer = QuartileDiscretizer( training_data, self.categorical_features, feature_names, labels=training_labels) elif discretizer == 'decile': self.discretizer = DecileDiscretizer(training_data, self.categorical_features, feature_names, labels=training_labels) elif discretizer == 'entropy': self.discretizer = EntropyDiscretizer( training_data, self.categorical_features, feature_names, labels=training_labels) else: raise ValueError('''Discretizer must be 'quartile',''' + ''' 'decile' or 'entropy' ''') self.categorical_features = range( training_data.shape[1]) # so all categorical by the end! discretized_training_data = self.discretizer.discretize( training_data) if kernel_width is None: kernel_width = np.sqrt(training_data.shape[1]) * .75 kernel_width = float(kernel_width) def kernel(d): return np.sqrt(np.exp(-(d**2) / kernel_width**2)) self.feature_selection = feature_selection self.base = lime_base.LimeBase(kernel, verbose) self.scaler = None self.class_names = class_names self.feature_names = feature_names self.scaler = sklearn.preprocessing.StandardScaler(with_mean=False) self.scaler.fit(training_data) self.feature_values = {} self.feature_frequencies = {} for feature in self.categorical_features: feature_count = collections.defaultdict(lambda: 0.0) column = training_data[:, feature] # this is for continuously converted categorical data if self.discretizer is not None: column = discretized_training_data[:, feature] feature_count[0] = 0. # only handles quantile? or useless? feature_count[1] = 0. feature_count[2] = 0. feature_count[3] = 0. for value in column: feature_count[value] += 1 values, frequencies = map(list, zip(*(feature_count.items()))) self.feature_values[feature] = values self.feature_frequencies[feature] = (np.array(frequencies) / sum(frequencies)) self.scaler.mean_[feature] = 0 # not scaled for categorical data self.scaler.scale_[feature] = 1 def explain_instance(self, data_row, classifier_fn, labels=(1, ), top_labels=None, num_features=10, num_samples=5000, distance_metric='euclidean', model_regressor=None, known_features=None): """Generates explanations for a prediction. First, we generate neighborhood data by randomly perturbing features from the instance (see __data_inverse). We then learn locally weighted linear models on this neighborhood data to explain each of the classes in an interpretable way (see lime_base.py). Args: data_row: 1d numpy array, corresponding to a row classifier_fn: classifier prediction probability function, which takes a numpy array and outputs prediction probabilities. For ScikitClassifiers , this is classifier.predict_proba. labels: iterable with labels to be explained. top_labels: if not None, ignore labels and produce explanations for the K labels with highest prediction probabilities, where K is this parameter. num_features: maximum number of features present in explanation num_samples: size of the neighborhood to learn the linear model distance_metric: the distance metric to use for weights. model_regressor: sklearn regressor to use in explanation. Defaults to Ridge regression in LimeBase. Must have model_regressor.coef_ and 'sample_weight' as a parameter to model_regressor.fit() Returns: An Explanation object (see explanation.py) with the corresponding explanations. """ # so the data here is already binary for categorical data # so 1 if in the same category, 0 otherwise # inverse is the undiscretized data matrix # todo: change bandwidth to cross validated bandwidth # was __data_inverse instead of data_inverse2 if self.proposal_method == 'kde': print("using kde proposal, not suitable for categorical data!!!") data, inverse = self.__data_inverse2(data_row, num_samples) else: data, inverse = self.__data_inverse(data_row, num_samples) scaled_data = (data - self.scaler.mean_) / self.scaler.scale_ if self.proposal_method == 'kde': # so the distance is in original space distances = sklearn.metrics.pairwise_distances( inverse, inverse[0].reshape(1, -1), metric='euclidean').ravel() else: # so the distance is on binary features distances = sklearn.metrics.pairwise_distances( scaled_data, scaled_data[0].reshape(1, -1), metric=distance_metric).ravel() yss = classifier_fn(inverse) if self.class_names is None: self.class_names = [str(x) for x in range(yss[0].shape[0])] else: self.class_names = list(self.class_names) feature_names = copy.deepcopy(self.feature_names) if feature_names is None: feature_names = [str(x) for x in range(data_row.shape[0])] if known_features: known_features = self._get_risk_factors(known_features, feature_names) values = ['%.2f' % a for a in data_row] for i in self.categorical_features: if self.discretizer is not None and i in self.discretizer.lambdas: continue # this is for continously converted categories name = int(data_row[i]) if i in self.categorical_names: name = self.categorical_names[i][name] feature_names[i] = '%s=%s' % (feature_names[i], name) values[i] = 'True' categorical_features = self.categorical_features discretized_feature_names = None if self.discretizer is not None: categorical_features = range(data.shape[1]) discretized_instance = self.discretizer.discretize(data_row) discretized_feature_names = copy.deepcopy(feature_names) for f in self.discretizer.names: discretized_feature_names[f] = self.discretizer.names[f][int( discretized_instance[f])] domain_mapper = TableDomainMapper( feature_names, values, scaled_data[0], categorical_features=categorical_features, discretized_feature_names=discretized_feature_names) ret_exp = explanation.Explanation(domain_mapper=domain_mapper, class_names=self.class_names) ret_exp.predict_proba = yss[0] if top_labels: labels = np.argsort(yss[0])[-top_labels:] ret_exp.top_labels = list(labels) ret_exp.top_labels.reverse() for label in labels: (ret_exp.intercept[label], ret_exp.local_exp[label], ret_exp.score) = self.base.explain_instance_with_data( scaled_data, yss, distances, label, num_features, model_regressor=model_regressor, feature_selection=self.feature_selection, risk=known_features) ret_exp.perturbed_original = inverse ret_exp.perturbed_binary = data ret_exp.discretizer = self.discretizer return ret_exp def __data_inverse(self, data_row, num_samples): """Generates a neighborhood around a prediction. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained. Args: data_row: 1d numpy array, corresponding to a row num_samples: size of the neighborhood to learn the linear model Returns: A tuple (data, inverse), where: data: dense num_samples * K matrix, where categorical features are encoded with either 0 (not equal to the corresponding value in data_row) or 1. The first row is the original instance. inverse: same as data, except the categorical features are not binary, but categorical (as the original data) """ # data_row is in original space data = np.zeros((num_samples, data_row.shape[0])) categorical_features = range( data_row.shape[0] ) # oh: so all discretized features becomes categorical features if self.discretizer is None: data = np.random.normal(0, 1, num_samples * data_row.shape[0]).reshape( num_samples, data_row.shape[0]) data = data * self.scaler.scale_ + self.scaler.mean_ categorical_features = self.categorical_features first_row = data_row else: first_row = self.discretizer.discretize(data_row) data[0] = data_row.copy() inverse = data.copy() for column in categorical_features: values = self.feature_values[column] freqs = self.feature_frequencies[column] inverse_column = np.random.choice(values, size=num_samples, replace=True, p=freqs) binary_column = np.array( [1 if x == first_row[column] else 0 for x in inverse_column]) binary_column[0] = 1 # the first row is the original data so 1 inverse_column[0] = data[0, column] data[:, column] = binary_column inverse[:, column] = inverse_column if self.discretizer is not None: inverse[1:] = self.discretizer.undiscretize(inverse[1:]) inverse[0] = data_row return data, inverse def __data_inverse2(self, data_row, num_samples): """Generates a neighborhood around a prediction. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained. Args: data_row: 1d numpy array, corresponding to a row num_samples: size of the neighborhood to learn the linear model Returns: A tuple (data, inverse), where: data: dense num_samples * K matrix, where categorical features are encoded with either 0 (not equal to the corresponding value in data_row) or 1. The first row is the original instance. inverse: same as data, except the categorical features are not binary, but categorical (as the original data) """ ######## jiaxuan's addition ################ new_X = self.kde.sample(num_samples) # todo: use nearest neighbor before kde to sample local points # transform back inverse = self.kde_scaler.inverse_transform(new_X) inverse[0] = data_row # convert inverse to data data = self.discretizer.discretize(inverse) categorical_features = range(data_row.shape[0]) for column in categorical_features: binary_column = np.array( [1 if x == data[0, column] else 0 for x in data[:, column]]) data[:, column] = binary_column ############################################ return data, inverse def _get_risk_factors(self, known_features, feature_names): import torch import torch.nn as nn from torch.autograd import Variable risk = torch.zeros(len(feature_names)) for i, n in enumerate(feature_names): if n in known_features: risk[i] = 1 #print("known factors are ", risk) return Variable(risk, requires_grad=False)
class LimeTabularExplainer(object): """Explains predictions on tabular (i.e. matrix) data. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained.""" def __init__(self, training_data, mode="classification", training_labels=None, feature_names=None, categorical_features=None, categorical_names=None, kernel_width=None, kernel=None, verbose=False, class_names=None, feature_selection='auto', discretize_continuous=True, discretizer='quartile', sample_around_instance=False, random_state=None, training_data_stats=None): """Init function. Args: training_data: numpy 2d array mode: "classification" or "regression" training_labels: labels for training data. Not required, but may be used by discretizer. feature_names: list of names (strings) corresponding to the columns in the training data. categorical_features: list of indices (ints) corresponding to the categorical columns. Everything else will be considered continuous. Values in these columns MUST be integers. categorical_names: map from int to list of names, where categorical_names[x][y] represents the name of the yth value of column x. kernel_width: kernel width for the exponential kernel. If None, defaults to sqrt (number of columns) * 0.75 kernel: similarity kernel that takes euclidean distances and kernel width as input and outputs weights in (0,1). If None, defaults to an exponential kernel. verbose: if true, print local prediction values from linear model class_names: list of class names, ordered according to whatever the classifier is using. If not present, class names will be '0', '1', ... feature_selection: feature selection method. can be 'forward_selection', 'lasso_path', 'none' or 'auto'. See function 'explain_instance_with_data' in lime_base.py for details on what each of the options does. discretize_continuous: if True, all non-categorical features will be discretized into quartiles. discretizer: only matters if discretize_continuous is True and data is not sparse. Options are 'quartile', 'decile', 'entropy' or a BaseDiscretizer instance. sample_around_instance: if True, will sample continuous features in perturbed samples from a normal centered at the instance being explained. Otherwise, the normal is centered on the mean of the feature data. random_state: an integer or numpy.RandomState that will be used to generate random numbers. If None, the random state will be initialized using the internal numpy seed. training_data_stats: a dict object having the details of training data statistics. If None, training data information will be used, only matters if discretize_continuous is True. Must have the following keys: means", "mins", "maxs", "stds", "feature_values", "feature_frequencies" """ self.random_state = check_random_state(random_state) self.mode = mode self.categorical_names = categorical_names or {} self.sample_around_instance = sample_around_instance self.training_data_stats = training_data_stats # Check and raise proper error in stats are supplied in non-descritized path if self.training_data_stats: self.validate_training_data_stats(self.training_data_stats) if categorical_features is None: categorical_features = [] if feature_names is None: feature_names = [str(i) for i in range(training_data.shape[1])] self.categorical_features = list(categorical_features) self.feature_names = list(feature_names) self.discretizer = None if discretize_continuous and not sp.sparse.issparse(training_data): # Set the discretizer if training data stats are provided if self.training_data_stats: discretizer = StatsDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels, data_stats=self.training_data_stats, random_state=self.random_state) if discretizer == 'quartile': self.discretizer = QuartileDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels, random_state=self.random_state) elif discretizer == 'decile': self.discretizer = DecileDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels, random_state=self.random_state) elif discretizer == 'entropy': self.discretizer = EntropyDiscretizer( training_data, self.categorical_features, self.feature_names, labels=training_labels, random_state=self.random_state) elif isinstance(discretizer, BaseDiscretizer): self.discretizer = discretizer else: raise ValueError('''Discretizer must be 'quartile',''' + ''' 'decile', 'entropy' or a''' + ''' BaseDiscretizer instance''') self.categorical_features = list(range(training_data.shape[1])) # Get the discretized_training_data when the stats are not provided if (self.training_data_stats is None): discretized_training_data = self.discretizer.discretize( training_data) if kernel_width is None: kernel_width = np.sqrt(training_data.shape[1]) * .75 kernel_width = float(kernel_width) if kernel is None: def kernel(d, kernel_width): return np.sqrt(np.exp(-(d**2) / kernel_width**2)) kernel_fn = partial(kernel, kernel_width=kernel_width) self.feature_selection = feature_selection self.base = lime_base.LimeBase(kernel_fn, verbose, random_state=self.random_state) self.class_names = class_names # Though set has no role to play if training data stats are provided self.scaler = sklearn.preprocessing.StandardScaler(with_mean=False) self.scaler.fit(training_data) self.feature_values = {} self.feature_frequencies = {} for feature in self.categorical_features: if training_data_stats is None: if self.discretizer is not None: column = discretized_training_data[:, feature] else: column = training_data[:, feature] feature_count = collections.Counter(column) values, frequencies = map( list, zip(*(sorted(feature_count.items())))) else: values = training_data_stats["feature_values"][feature] frequencies = training_data_stats["feature_frequencies"][ feature] self.feature_values[feature] = values self.feature_frequencies[feature] = (np.array(frequencies) / float(sum(frequencies))) self.scaler.mean_[feature] = 0 self.scaler.scale_[feature] = 1 @staticmethod def convert_and_round(values): return ['%.2f' % v for v in values] @staticmethod def validate_training_data_stats(training_data_stats): """ Method to validate the structure of training data stats """ stat_keys = list(training_data_stats.keys()) valid_stat_keys = [ "means", "mins", "maxs", "stds", "feature_values", "feature_frequencies" ] missing_keys = list(set(valid_stat_keys) - set(stat_keys)) if len(missing_keys) > 0: raise Exception( "Missing keys in training_data_stats. Details: %s" % (missing_keys)) def explain_instance(self, data_row, predict_fn, labels=(1, ), top_labels=None, num_features=10, num_samples=5000, distance_metric='euclidean', model_regressor=None): """Generates explanations for a prediction. First, we generate neighborhood data by randomly perturbing features from the instance (see __data_inverse). We then learn locally weighted linear models on this neighborhood data to explain each of the classes in an interpretable way (see lime_base.py). Args: data_row: 1d numpy array or scipy.sparse matrix, corresponding to a row predict_fn: prediction function. For classifiers, this should be a function that takes a numpy array and outputs prediction probabilities. For regressors, this takes a numpy array and returns the predictions. For ScikitClassifiers, this is `classifier.predict_proba()`. For ScikitRegressors, this is `regressor.predict()`. The prediction function needs to work on multiple feature vectors (the vectors randomly perturbed from the data_row). labels: iterable with labels to be explained. top_labels: if not None, ignore labels and produce explanations for the K labels with highest prediction probabilities, where K is this parameter. num_features: maximum number of features present in explanation num_samples: size of the neighborhood to learn the linear model distance_metric: the distance metric to use for weights. model_regressor: sklearn regressor to use in explanation. Defaults to Ridge regression in LimeBase. Must have model_regressor.coef_ and 'sample_weight' as a parameter to model_regressor.fit() Returns: An Explanation object (see explanation.py) with the corresponding explanations. """ if sp.sparse.issparse( data_row) and not sp.sparse.isspmatrix_csr(data_row): # Preventative code: if sparse, convert to csr format if not in csr format already data_row = data_row.tocsr() data, inverse = self.__data_inverse(data_row, num_samples) if sp.sparse.issparse(data): # Note in sparse case we don't subtract mean since data would become dense scaled_data = data.multiply(self.scaler.scale_) # Multiplying with csr matrix can return a coo sparse matrix if not sp.sparse.isspmatrix_csr(scaled_data): scaled_data = scaled_data.tocsr() else: scaled_data = (data - self.scaler.mean_) / self.scaler.scale_ distances = sklearn.metrics.pairwise_distances( scaled_data, scaled_data[0].reshape(1, -1), metric=distance_metric).ravel() yss = predict_fn(inverse) # for classification, the model needs to provide a list of tuples - classes # along with prediction probabilities if self.mode == "classification": if len(yss.shape) == 1: raise NotImplementedError( "LIME does not currently support " "classifier models without probability " "scores. If this conflicts with your " "use case, please let us know: " "https://github.com/datascienceinc/lime/issues/16") elif len(yss.shape) == 2: if self.class_names is None: self.class_names = [str(x) for x in range(yss[0].shape[0])] else: self.class_names = list(self.class_names) if not np.allclose(yss.sum(axis=1), 1.0): warnings.warn(""" Prediction probabilties do not sum to 1, and thus does not constitute a probability space. Check that you classifier outputs probabilities (Not log probabilities, or actual class predictions). """) else: raise ValueError("Your model outputs " "arrays with {} dimensions".format( len(yss.shape))) # for regression, the output should be a one-dimensional array of predictions else: try: if len(yss.shape) != 1 and len(yss[0].shape) == 1: yss = np.array([v[0] for v in yss]) assert isinstance(yss, np.ndarray) and len(yss.shape) == 1 except AssertionError: raise ValueError( "Your model needs to output single-dimensional \ numpyarrays, not arrays of {} dimensions".format( yss.shape)) predicted_value = yss[0] min_y = min(yss) max_y = max(yss) # add a dimension to be compatible with downstream machinery yss = yss[:, np.newaxis] feature_names = copy.deepcopy(self.feature_names) if feature_names is None: feature_names = [str(x) for x in range(data_row.shape[0])] if sp.sparse.issparse(data_row): values = self.convert_and_round(data_row.data) feature_indexes = data_row.indices else: values = self.convert_and_round(data_row) feature_indexes = None for i in self.categorical_features: if self.discretizer is not None and i in self.discretizer.lambdas: continue name = int(data_row[i]) if i in self.categorical_names: name = self.categorical_names[i][name] feature_names[i] = '%s=%s' % (feature_names[i], name) values[i] = 'True' categorical_features = self.categorical_features discretized_feature_names = None if self.discretizer is not None: categorical_features = range(data.shape[1]) discretized_instance = self.discretizer.discretize(data_row) discretized_feature_names = copy.deepcopy(feature_names) for f in self.discretizer.names: discretized_feature_names[f] = self.discretizer.names[f][int( discretized_instance[f])] domain_mapper = TableDomainMapper( feature_names, values, scaled_data[0], categorical_features=categorical_features, discretized_feature_names=discretized_feature_names, feature_indexes=feature_indexes) ret_exp = explanation.Explanation(domain_mapper, mode=self.mode, class_names=self.class_names) if self.mode == "classification": ret_exp.predict_proba = yss[0] if top_labels: labels = np.argsort(yss[0])[-top_labels:] ret_exp.top_labels = list(labels) ret_exp.top_labels.reverse() else: ret_exp.predicted_value = predicted_value ret_exp.min_value = min_y ret_exp.max_value = max_y labels = [0] for label in labels: (ret_exp.intercept[label], ret_exp.local_exp[label], ret_exp.score, ret_exp.local_pred) = self.base.explain_instance_with_data( scaled_data, yss, distances, label, num_features, model_regressor=model_regressor, feature_selection=self.feature_selection) if self.mode == "regression": ret_exp.intercept[1] = ret_exp.intercept[0] ret_exp.local_exp[1] = [x for x in ret_exp.local_exp[0]] ret_exp.local_exp[0] = [(i, -1 * j) for i, j in ret_exp.local_exp[1]] return ret_exp def __data_inverse(self, data_row, num_samples): """Generates a neighborhood around a prediction. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained. Args: data_row: 1d numpy array, corresponding to a row num_samples: size of the neighborhood to learn the linear model Returns: A tuple (data, inverse), where: data: dense num_samples * K matrix, where categorical features are encoded with either 0 (not equal to the corresponding value in data_row) or 1. The first row is the original instance. inverse: same as data, except the categorical features are not binary, but categorical (as the original data) """ is_sparse = sp.sparse.issparse(data_row) if is_sparse: num_cols = data_row.shape[1] data = sp.sparse.csr_matrix((num_samples, num_cols), dtype=data_row.dtype) else: num_cols = data_row.shape[0] data = np.zeros((num_samples, num_cols)) categorical_features = range(num_cols) # kokowo 18ni suru #categorical_features = range(0,19) if self.discretizer is None: instance_sample = data_row scale = self.scaler.scale_ mean = self.scaler.mean_ if is_sparse: # true # Perturb only the non-zero values non_zero_indexes = data_row.nonzero()[1] #8ko num_cols = len(non_zero_indexes) instance_sample = data_row[:, non_zero_indexes] scale = scale[non_zero_indexes] mean = mean[non_zero_indexes] #data = self.random_state.normal(0, 1, num_samples * num_cols).reshape(num_samples, num_cols) data = self.random_state.randint(0, 1, num_samples * num_cols).reshape( num_samples, num_cols) print( "data = self.random_state.normal(0, 1, num_samples * num_cols).reshape(num_samples, num_cols)", data) print("data len ", len(data[0])) if self.sample_around_instance: print("self.sample_around_instance", self.sample_around_instance) data = data * scale + instance_sample else: print("data = data * scale + mean", data) data = data * scale + mean if is_sparse: if num_cols == 0: data = sp.sparse.csr_matrix( (num_samples, data_row.shape[1]), dtype=data_row.dtype) else: print("non_zero_indexes ", non_zero_indexes) indexes = np.tile(non_zero_indexes, num_samples) indptr = np.array( range(0, len(non_zero_indexes) * (num_samples + 1), len(non_zero_indexes))) data_1d_shape = data.shape[0] * data.shape[1] data_1d = data.reshape(data_1d_shape) data = sp.sparse.csr_matrix( (data_1d, indexes, indptr), shape=(num_samples, data_row.shape[1])) categorical_features = self.categorical_features first_row = data_row else: first_row = self.discretizer.discretize(data_row) data[0] = data_row.copy() inverse = data.copy() # print("categorical_features \n", categorical_features) # for column in categorical_features: # print("column ", column) for column in categorical_features: # koko kara no risuto nanode nanimo sinai ppoi values = self.feature_values[column] freqs = self.feature_frequencies[column] inverse_column = self.random_state.choice(values, size=num_samples, replace=True, p=freqs) binary_column = (inverse_column == first_row[column]).astype(int) binary_column[0] = 1 inverse_column[0] = data[0, column] data[:, column] = binary_column inverse[:, column] = inverse_column if self.discretizer is not None: inverse[1:] = self.discretizer.undiscretize(inverse[1:]) inverse[0] = data_row #with open('/root/ml-at-work/chap07/pickle/my_data', 'rb') as f: # my_data = pickle.load(f) # my_inverse = my_data.copy() # data = my_data # inverse = my_inverse with open('/root/ml-at-work/chap07/pickle/big_my_data', 'rb') as f: big_my_data = pickle.load(f) big_my_inverse = big_my_data.copy() data = big_my_data data[0] = data_row.copy() inverse = big_my_inverse #print("inverse type \n",type(inverse)) #print("data[1] len \n",data[1].__len__) #print("data[1] data \n\n",data[1]) #print("inverse type \n",type(inverse)) # print("inverse len ", inverse.__len__) # print("inverse data \n\n",inverse) print("data type \n", type(data)) print("data len \n", data.__len__) print("data data \n\n", data) print("categorical_features ", categorical_features) print("data[1] type \n", type(data[1])) print("data[1] len \n", data[1].__len__) print("data[1] data \n\n", data[1]) print("data todense \n\n", data.todense()) print("num_cols ", num_cols) return data, inverse
class LimeTabularExplainer(object): """Explains predictions on tabular (i.e. matrix) data. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained.""" def __init__(self, training_data, training_labels=None, feature_names=None, categorical_features=None, categorical_names=None, kernel_width=None, verbose=False, class_names=None, feature_selection='auto', discretize_continuous=True, discretizer='quartile'): """Init function. Args: training_data: numpy 2d array training_labels: labels for training data. Not required, but may be used by discretizer. feature_names: list of names (strings) corresponding to the columns in the training data. categorical_features: list of indices (ints) corresponding to the categorical columns. Everything else will be considered continuous. Values in these columns MUST be integers. categorical_names: map from int to list of names, where categorical_names[x][y] represents the name of the yth value of column x. kernel_width: kernel width for the exponential kernel. If None, defaults to sqrt(number of columns) * 0.75 verbose: if true, print local prediction values from linear model class_names: list of class names, ordered according to whatever the classifier is using. If not present, class names will be '0', '1', ... feature_selection: feature selection method. can be 'forward_selection', 'lasso_path', 'none' or 'auto'. See function 'explain_instance_with_data' in lime_base.py for details on what each of the options does. discretize_continuous: if True, all non-categorical features will be discretized into quartiles. discretizer: only matters if discretize_continuous is True. Options are 'quartile', 'decile' or 'entropy' """ self.categorical_names = categorical_names self.categorical_features = categorical_features if self.categorical_names is None: self.categorical_names = {} if self.categorical_features is None: self.categorical_features = [] self.discretizer = None if discretize_continuous: if discretizer == 'quartile': self.discretizer = QuartileDiscretizer( training_data, self.categorical_features, feature_names, labels=training_labels) elif discretizer == 'decile': self.discretizer = DecileDiscretizer( training_data, self.categorical_features, feature_names, labels=training_labels) elif discretizer == 'entropy': self.discretizer = EntropyDiscretizer( training_data, self.categorical_features, feature_names, labels=training_labels) else: raise ('''Discretizer must be 'quartile', 'decile' ''' + '''or 'entropy' ''') self.categorical_features = range(training_data.shape[1]) discretized_training_data = self.discretizer.discretize( training_data) if kernel_width is None: kernel_width = np.sqrt(training_data.shape[1]) * .75 kernel_width = float(kernel_width) def kernel(d): return np.sqrt(np.exp(-(d ** 2) / kernel_width ** 2)) self.feature_selection = feature_selection self.base = lime_base.LimeBase(kernel, verbose) self.scaler = None self.class_names = class_names self.feature_names = feature_names self.scaler = sklearn.preprocessing.StandardScaler(with_mean=False) self.scaler.fit(training_data) self.feature_values = {} self.feature_frequencies = {} for feature in self.categorical_features: feature_count = collections.defaultdict(lambda: 0.0) column = training_data[:, feature] if self.discretizer is not None: column = discretized_training_data[:, feature] feature_count[0] = 0. feature_count[1] = 0. feature_count[2] = 0. feature_count[3] = 0. for value in column: feature_count[value] += 1 values, frequencies = map(list, zip(*(feature_count.items()))) self.feature_values[feature] = values self.feature_frequencies[feature] = (np.array(frequencies) / sum(frequencies)) self.scaler.mean_[feature] = 0 self.scaler.scale_[feature] = 1 def explain_instance(self, data_row, classifier_fn, labels=(1,), top_labels=None, num_features=10, num_samples=5000, distance_metric='euclidean', model_regressor=None): """Generates explanations for a prediction. First, we generate neighborhood data by randomly perturbing features from the instance (see __data_inverse). We then learn locally weighted linear models on this neighborhood data to explain each of the classes in an interpretable way (see lime_base.py). Args: data_row: 1d numpy array, corresponding to a row classifier_fn: classifier prediction probability function, which takes a numpy array and outputs prediction probabilities. For ScikitClassifiers , this is classifier.predict_proba. labels: iterable with labels to be explained. top_labels: if not None, ignore labels and produce explanations for the K labels with highest prediction probabilities, where K is this parameter. num_features: maximum number of features present in explanation num_samples: size of the neighborhood to learn the linear model distance_metric: the distance metric to use for weights. model_regressor: sklearn regressor to use in explanation. Defaults to Ridge regression in LimeBase. Must have model_regressor.coef_ and 'sample_weight' as a parameter to model_regressor.fit() Returns: An Explanation object (see explanation.py) with the corresponding explanations. """ data, inverse = self.__data_inverse(data_row, num_samples) scaled_data = (data - self.scaler.mean_) / self.scaler.scale_ distances = sklearn.metrics.pairwise_distances( scaled_data, scaled_data[0].reshape(1, -1), metric=distance_metric ).ravel() yss = classifier_fn(inverse) if self.class_names is None: self.class_names = [str(x) for x in range(yss[0].shape[0])] else: self.class_names = list(self.class_names) feature_names = copy.deepcopy(self.feature_names) if feature_names is None: feature_names = [str(x) for x in range(data_row.shape[0])] values = ['%.2f' % a for a in data_row] for i in self.categorical_features: if self.discretizer is not None and i in self.discretizer.lambdas: continue name = int(data_row[i]) if i in self.categorical_names: name = self.categorical_names[i][name] feature_names[i] = '%s=%s' % (feature_names[i], name) values[i] = 'True' categorical_features = self.categorical_features discretized_feature_names = None if self.discretizer is not None: categorical_features = range(data.shape[1]) discretized_instance = self.discretizer.discretize(data_row) discretized_feature_names = copy.deepcopy(feature_names) for f in self.discretizer.names: discretized_feature_names[f] = self.discretizer.names[f][int( discretized_instance[f])] domain_mapper = TableDomainMapper( feature_names, values, scaled_data[0], categorical_features=categorical_features, discretized_feature_names=discretized_feature_names) ret_exp = explanation.Explanation(domain_mapper=domain_mapper, class_names=self.class_names) ret_exp.predict_proba = yss[0] if top_labels: labels = np.argsort(yss[0])[-top_labels:] ret_exp.top_labels = list(labels) ret_exp.top_labels.reverse() for label in labels: (ret_exp.intercept[label], ret_exp.local_exp[label], ret_exp.score) = self.base.explain_instance_with_data( scaled_data, yss, distances, label, num_features, model_regressor=model_regressor, feature_selection=self.feature_selection) return ret_exp def __data_inverse(self, data_row, num_samples): """Generates a neighborhood around a prediction. For numerical features, perturb them by sampling from a Normal(0,1) and doing the inverse operation of mean-centering and scaling, according to the means and stds in the training data. For categorical features, perturb by sampling according to the training distribution, and making a binary feature that is 1 when the value is the same as the instance being explained. Args: data_row: 1d numpy array, corresponding to a row num_samples: size of the neighborhood to learn the linear model Returns: A tuple (data, inverse), where: data: dense num_samples * K matrix, where categorical features are encoded with either 0 (not equal to the corresponding value in data_row) or 1. The first row is the original instance. inverse: same as data, except the categorical features are not binary, but categorical (as the original data) """ data = np.zeros((num_samples, data_row.shape[0])) categorical_features = range(data_row.shape[0]) if self.discretizer is None: data = np.random.normal( 0, 1, num_samples * data_row.shape[0]).reshape( num_samples, data_row.shape[0]) data = data * self.scaler.scale_ + self.scaler.mean_ categorical_features = self.categorical_features first_row = data_row else: first_row = self.discretizer.discretize(data_row) data[0] = data_row.copy() inverse = data.copy() for column in categorical_features: values = self.feature_values[column] freqs = self.feature_frequencies[column] inverse_column = np.random.choice(values, size=num_samples, replace=True, p=freqs) binary_column = np.array([1 if x == first_row[column] else 0 for x in inverse_column]) binary_column[0] = 1 inverse_column[0] = data[0, column] data[:, column] = binary_column inverse[:, column] = inverse_column if self.discretizer is not None: inverse[1:] = self.discretizer.undiscretize(inverse[1:]) inverse[0] = data_row return data, inverse