Spaces:
Build error
Build error
| """ | |
| Functions for explaining text classifiers. | |
| """ | |
| from functools import partial | |
| import itertools | |
| import json | |
| import re | |
| import numpy as np | |
| import scipy as sp | |
| import sklearn | |
| from sklearn.utils import check_random_state | |
| from . import explanation | |
| from . import lime_base | |
| class TextDomainMapper(explanation.DomainMapper): | |
| """Maps feature ids to words or word-positions""" | |
| def __init__(self, indexed_string): | |
| """Initializer. | |
| Args: | |
| indexed_string: lime_text.IndexedString, original string | |
| """ | |
| self.indexed_string = indexed_string | |
| def map_exp_ids(self, exp, positions=False): | |
| """Maps ids to words or word-position strings. | |
| Args: | |
| exp: list of tuples [(id, weight), (id,weight)] | |
| positions: if True, also return word positions | |
| Returns: | |
| list of tuples (word, weight), or (word_positions, weight) if | |
| examples: ('bad', 1) or ('bad_3-6-12', 1) | |
| """ | |
| if positions: | |
| exp = [('%s_%s' % ( | |
| self.indexed_string.word(x[0]), | |
| '-'.join( | |
| map(str, | |
| self.indexed_string.string_position(x[0])))), x[1]) | |
| for x in exp] | |
| else: | |
| exp = [(self.indexed_string.word(x[0]), x[1]) for x in exp] | |
| return exp | |
| def visualize_instance_html(self, exp, label, div_name, exp_object_name, | |
| text=True, opacity=True): | |
| """Adds text with highlighted words to visualization. | |
| Args: | |
| exp: list of tuples [(id, weight), (id,weight)] | |
| label: label id (integer) | |
| div_name: name of div object to be used for rendering(in js) | |
| exp_object_name: name of js explanation object | |
| text: if False, return empty | |
| opacity: if True, fade colors according to weight | |
| """ | |
| if not text: | |
| return u'' | |
| text = (self.indexed_string.raw_string() | |
| .encode('utf-8', 'xmlcharrefreplace').decode('utf-8')) | |
| text = re.sub(r'[<>&]', '|', text) | |
| exp = [(self.indexed_string.word(x[0]), | |
| self.indexed_string.string_position(x[0]), | |
| x[1]) for x in exp] | |
| all_occurrences = list(itertools.chain.from_iterable( | |
| [itertools.product([x[0]], x[1], [x[2]]) for x in exp])) | |
| all_occurrences = [(x[0], int(x[1]), x[2]) for x in all_occurrences] | |
| ret = ''' | |
| %s.show_raw_text(%s, %d, %s, %s, %s); | |
| ''' % (exp_object_name, json.dumps(all_occurrences), label, | |
| json.dumps(text), div_name, json.dumps(opacity)) | |
| return ret | |
| class IndexedString(object): | |
| """String with various indexes.""" | |
| def __init__(self, raw_string, split_expression=r'\W+', bow=True, | |
| mask_string=None): | |
| """Initializer. | |
| Args: | |
| raw_string: string with raw text in it | |
| split_expression: Regex string or callable. If regex string, will be used with re.split. | |
| If callable, the function should return a list of tokens. | |
| bow: if True, a word is the same everywhere in the text - i.e. we | |
| will index multiple occurrences of the same word. If False, | |
| order matters, so that the same word will have different ids | |
| according to position. | |
| mask_string: If not None, replace words with this if bow=False | |
| if None, default value is UNKWORDZ | |
| """ | |
| self.raw = raw_string | |
| self.mask_string = 'UNKWORDZ' if mask_string is None else mask_string | |
| if callable(split_expression): | |
| tokens = split_expression(self.raw) | |
| self.as_list = self._segment_with_tokens(self.raw, tokens) | |
| tokens = set(tokens) | |
| def non_word(string): | |
| return string not in tokens | |
| else: | |
| # with the split_expression as a non-capturing group (?:), we don't need to filter out | |
| # the separator character from the split results. | |
| splitter = re.compile(r'(%s)|$' % split_expression) | |
| self.as_list = [s for s in splitter.split(self.raw) if s] | |
| non_word = splitter.match | |
| self.as_np = np.array(self.as_list) | |
| self.string_start = np.hstack( | |
| ([0], np.cumsum([len(x) for x in self.as_np[:-1]]))) | |
| vocab = {} | |
| self.inverse_vocab = [] | |
| self.positions = [] | |
| self.bow = bow | |
| non_vocab = set() | |
| for i, word in enumerate(self.as_np): | |
| if word in non_vocab: | |
| continue | |
| if non_word(word): | |
| non_vocab.add(word) | |
| continue | |
| if bow: | |
| if word not in vocab: | |
| vocab[word] = len(vocab) | |
| self.inverse_vocab.append(word) | |
| self.positions.append([]) | |
| idx_word = vocab[word] | |
| self.positions[idx_word].append(i) | |
| else: | |
| self.inverse_vocab.append(word) | |
| self.positions.append(i) | |
| if not bow: | |
| self.positions = np.array(self.positions) | |
| def raw_string(self): | |
| """Returns the original raw string""" | |
| return self.raw | |
| def num_words(self): | |
| """Returns the number of tokens in the vocabulary for this document.""" | |
| return len(self.inverse_vocab) | |
| def word(self, id_): | |
| """Returns the word that corresponds to id_ (int)""" | |
| return self.inverse_vocab[id_] | |
| def string_position(self, id_): | |
| """Returns a np array with indices to id_ (int) occurrences""" | |
| if self.bow: | |
| return self.string_start[self.positions[id_]] | |
| else: | |
| return self.string_start[[self.positions[id_]]] | |
| def inverse_removing(self, words_to_remove): | |
| """Returns a string after removing the appropriate words. | |
| If self.bow is false, replaces word with UNKWORDZ instead of removing | |
| it. | |
| Args: | |
| words_to_remove: list of ids (ints) to remove | |
| Returns: | |
| original raw string with appropriate words removed. | |
| """ | |
| mask = np.ones(self.as_np.shape[0], dtype='bool') | |
| mask[self.__get_idxs(words_to_remove)] = False | |
| if not self.bow: | |
| return ''.join( | |
| [self.as_list[i] if mask[i] else self.mask_string | |
| for i in range(mask.shape[0])]) | |
| return ''.join([self.as_list[v] for v in mask.nonzero()[0]]) | |
| def _segment_with_tokens(text, tokens): | |
| """Segment a string around the tokens created by a passed-in tokenizer""" | |
| list_form = [] | |
| text_ptr = 0 | |
| for token in tokens: | |
| inter_token_string = [] | |
| while not text[text_ptr:].startswith(token): | |
| inter_token_string.append(text[text_ptr]) | |
| text_ptr += 1 | |
| if text_ptr >= len(text): | |
| raise ValueError("Tokenization produced tokens that do not belong in string!") | |
| text_ptr += len(token) | |
| if inter_token_string: | |
| list_form.append(''.join(inter_token_string)) | |
| list_form.append(token) | |
| if text_ptr < len(text): | |
| list_form.append(text[text_ptr:]) | |
| return list_form | |
| def __get_idxs(self, words): | |
| """Returns indexes to appropriate words.""" | |
| if self.bow: | |
| return list(itertools.chain.from_iterable( | |
| [self.positions[z] for z in words])) | |
| else: | |
| return self.positions[words] | |
| class IndexedCharacters(object): | |
| """String with various indexes.""" | |
| def __init__(self, raw_string, bow=True, mask_string=None): | |
| """Initializer. | |
| Args: | |
| raw_string: string with raw text in it | |
| bow: if True, a char is the same everywhere in the text - i.e. we | |
| will index multiple occurrences of the same character. If False, | |
| order matters, so that the same word will have different ids | |
| according to position. | |
| mask_string: If not None, replace characters with this if bow=False | |
| if None, default value is chr(0) | |
| """ | |
| self.raw = raw_string | |
| self.as_list = list(self.raw) | |
| self.as_np = np.array(self.as_list) | |
| self.mask_string = chr(0) if mask_string is None else mask_string | |
| self.string_start = np.arange(len(self.raw)) | |
| vocab = {} | |
| self.inverse_vocab = [] | |
| self.positions = [] | |
| self.bow = bow | |
| non_vocab = set() | |
| for i, char in enumerate(self.as_np): | |
| if char in non_vocab: | |
| continue | |
| if bow: | |
| if char not in vocab: | |
| vocab[char] = len(vocab) | |
| self.inverse_vocab.append(char) | |
| self.positions.append([]) | |
| idx_char = vocab[char] | |
| self.positions[idx_char].append(i) | |
| else: | |
| self.inverse_vocab.append(char) | |
| self.positions.append(i) | |
| if not bow: | |
| self.positions = np.array(self.positions) | |
| def raw_string(self): | |
| """Returns the original raw string""" | |
| return self.raw | |
| def num_words(self): | |
| """Returns the number of tokens in the vocabulary for this document.""" | |
| return len(self.inverse_vocab) | |
| def word(self, id_): | |
| """Returns the word that corresponds to id_ (int)""" | |
| return self.inverse_vocab[id_] | |
| def string_position(self, id_): | |
| """Returns a np array with indices to id_ (int) occurrences""" | |
| if self.bow: | |
| return self.string_start[self.positions[id_]] | |
| else: | |
| return self.string_start[[self.positions[id_]]] | |
| def inverse_removing(self, words_to_remove): | |
| """Returns a string after removing the appropriate words. | |
| If self.bow is false, replaces word with UNKWORDZ instead of removing | |
| it. | |
| Args: | |
| words_to_remove: list of ids (ints) to remove | |
| Returns: | |
| original raw string with appropriate words removed. | |
| """ | |
| mask = np.ones(self.as_np.shape[0], dtype='bool') | |
| mask[self.__get_idxs(words_to_remove)] = False | |
| if not self.bow: | |
| return ''.join( | |
| [self.as_list[i] if mask[i] else self.mask_string | |
| for i in range(mask.shape[0])]) | |
| return ''.join([self.as_list[v] for v in mask.nonzero()[0]]) | |
| def __get_idxs(self, words): | |
| """Returns indexes to appropriate words.""" | |
| if self.bow: | |
| return list(itertools.chain.from_iterable( | |
| [self.positions[z] for z in words])) | |
| else: | |
| return self.positions[words] | |
| class LimeTextExplainer(object): | |
| """Explains text classifiers. | |
| Currently, we are using an exponential kernel on cosine distance, and | |
| restricting explanations to words that are present in documents.""" | |
| def __init__(self, | |
| kernel_width=25, | |
| kernel=None, | |
| verbose=False, | |
| class_names=None, | |
| feature_selection='auto', | |
| split_expression=r'\W+', | |
| bow=True, | |
| mask_string=None, | |
| random_state=None, | |
| char_level=False): | |
| """Init function. | |
| Args: | |
| kernel_width: kernel width for the exponential kernel. | |
| 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. | |
| split_expression: Regex string or callable. If regex string, will be used with re.split. | |
| If callable, the function should return a list of tokens. | |
| bow: if True (bag of words), will perturb input data by removing | |
| all occurrences of individual words or characters. | |
| Explanations will be in terms of these words. Otherwise, will | |
| explain in terms of word-positions, so that a word may be | |
| important the first time it appears and unimportant the second. | |
| Only set to false if the classifier uses word order in some way | |
| (bigrams, etc), or if you set char_level=True. | |
| mask_string: String used to mask tokens or characters if bow=False | |
| if None, will be 'UNKWORDZ' if char_level=False, chr(0) | |
| otherwise. | |
| 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. | |
| char_level: an boolean identifying that we treat each character | |
| as an independent occurence in the string | |
| """ | |
| 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.random_state = check_random_state(random_state) | |
| self.base = lime_base.LimeBase(kernel_fn, verbose, | |
| random_state=self.random_state) | |
| self.class_names = class_names | |
| self.vocabulary = None | |
| self.feature_selection = feature_selection | |
| self.bow = bow | |
| self.mask_string = mask_string | |
| self.split_expression = split_expression | |
| self.char_level = char_level | |
| def explain_instance(self, | |
| text_instance, | |
| classifier_fn, | |
| labels=(1,), | |
| top_labels=None, | |
| num_features=10, | |
| num_samples=5000, | |
| distance_metric='cosine', | |
| model_regressor=None): | |
| """Generates explanations for a prediction. | |
| First, we generate neighborhood data by randomly hiding features from | |
| the instance (see __data_labels_distance_mapping). 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: | |
| text_instance: raw text string to be explained. | |
| classifier_fn: classifier prediction probability function, which | |
| takes a list of d strings and outputs a (d, k) numpy array with | |
| prediction probabilities, where k is the number of classes. | |
| 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 sample weighting, | |
| defaults to cosine similarity | |
| 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. | |
| """ | |
| indexed_string = (IndexedCharacters( | |
| text_instance, bow=self.bow, mask_string=self.mask_string) | |
| if self.char_level else | |
| IndexedString(text_instance, bow=self.bow, | |
| split_expression=self.split_expression, | |
| mask_string=self.mask_string)) | |
| domain_mapper = TextDomainMapper(indexed_string) | |
| data, yss, distances = self.__data_labels_distances( | |
| indexed_string, classifier_fn, num_samples, | |
| distance_metric=distance_metric) | |
| if self.class_names is None: | |
| self.class_names = [str(x) for x in range(yss[0].shape[0])] | |
| ret_exp = explanation.Explanation(domain_mapper=domain_mapper, | |
| class_names=self.class_names, | |
| random_state=self.random_state) | |
| 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, ret_exp.local_pred) = self.base.explain_instance_with_data( | |
| data, yss, distances, label, num_features, | |
| model_regressor=model_regressor, | |
| feature_selection=self.feature_selection) | |
| return ret_exp | |
| def __data_labels_distances(self, | |
| indexed_string, | |
| classifier_fn, | |
| num_samples, | |
| distance_metric='cosine'): | |
| """Generates a neighborhood around a prediction. | |
| Generates neighborhood data by randomly removing words from | |
| the instance, and predicting with the classifier. Uses cosine distance | |
| to compute distances between original and perturbed instances. | |
| Args: | |
| indexed_string: document (IndexedString) to be explained, | |
| classifier_fn: classifier prediction probability function, which | |
| takes a string and outputs prediction probabilities. For | |
| ScikitClassifier, this is classifier.predict_proba. | |
| num_samples: size of the neighborhood to learn the linear model | |
| distance_metric: the distance metric to use for sample weighting, | |
| defaults to cosine similarity. | |
| Returns: | |
| A tuple (data, labels, distances), where: | |
| data: dense num_samples * K binary matrix, where K is the | |
| number of tokens in indexed_string. The first row is the | |
| original instance, and thus a row of ones. | |
| labels: num_samples * L matrix, where L is the number of target | |
| labels | |
| distances: cosine distance between the original instance and | |
| each perturbed instance (computed in the binary 'data' | |
| matrix), times 100. | |
| """ | |
| def distance_fn(x): | |
| return sklearn.metrics.pairwise.pairwise_distances( | |
| x, x[0], metric=distance_metric).ravel() * 100 | |
| doc_size = indexed_string.num_words() | |
| sample = self.random_state.randint(1, doc_size + 1, num_samples - 1) | |
| data = np.ones((num_samples, doc_size)) | |
| data[0] = np.ones(doc_size) | |
| features_range = range(doc_size) | |
| inverse_data = [indexed_string.raw_string()] | |
| for i, size in enumerate(sample, start=1): | |
| inactive = self.random_state.choice(features_range, size, | |
| replace=False) | |
| data[i, inactive] = 0 | |
| inverse_data.append(indexed_string.inverse_removing(inactive)) | |
| labels = classifier_fn(inverse_data) | |
| distances = distance_fn(sp.sparse.csr_matrix(data)) | |
| return data, labels, distances | |