def fi_vals(self, fnames_as_columns=True): """ Computes the sample-wide RFI for each run Returns: pd.DataFrame with index: sample and fsoi as columns """ # self._check_shape() # arr = np.mean(self.scores, axis=(2)) # if return_np: # return arr # else: # runs = range(arr.shape[1]) # index = utils.create_multiindex(['feature', 'run'], # [self.fsoi_names, runs]) # arr = arr.reshape(-1) # df = pd.DataFrame(arr, index=index, columns=['importance']) # return df df = self.scores.mean(level='sample') if fnames_as_columns: return df else: index = utils.create_multiindex([df.index.name, 'feature'], [df.index.values, df.columns]) df2 = pd.DataFrame(df.to_numpy().reshape(-1), index=index, columns=['importance']) return df2
def sample(self, X_test, J, G, num_samples=1): """Sample features of interest using trained resampler. Args: J: Set of features to sample G: relative feature set X_test: DataFrame for which sampling shall be performed num_samples: number of resamples without retraining shall be computed Returns: Resampled data for the features of interest. pd.DataFrame with multiindex ('sample', 'i') and resampled features as columns """ # initialize numpy matrix # sampled_data = np.zeros((X_test.shape[0], num_samples, J.shape[0])) # sample G_key, J_key = Sampler._to_key(G), Sampler._to_key(J) if not self.is_trained(J, G): raise RuntimeError("Sampler not trained on {} | {}".format(J, G)) else: sample_func = self._trained_sampling_funcs[(J_key, G_key)] smpl = sample_func(X_test[Sampler._order_fset(G)].to_numpy(), num_samples=num_samples) snrs = np.arange(num_samples) obs = np.arange(X_test.shape[0]) vss = [snrs, obs] ns = ['sample', 'i'] index = utils.create_multiindex(ns, vss) smpl = np.swapaxes(smpl, 0, 1) smpl = smpl.reshape((-1, smpl.shape[2])) df = pd.DataFrame(smpl, index=index, columns=Sampler._order_fset(J)) return df
def tdi(self, X_eval, y_eval, G, D=None, sampler=None, loss=None, nr_runs=10, return_perturbed=False, train_allowed=True): """Computes Relative Feature importance # TODO(gcsk): allow handing a sample as argument # (without needing sampler) Args: X_eval: data to use for resampling and evaluation. y_eval: labels for evaluation. G: relative feature set D: features, used by the predictive model sampler: choice of sampler. Default None. Will throw an error when sampler is None and self.sampler is None as well. loss: choice of loss. Default None. Will throw an Error when both loss and self.loss are None. nr_runs: how often the experiment shall be run return_perturbed: whether the sampled perturbed versions shall be returned train_allowed: whether the explainer is allowed to train the sampler Returns: result: An explanation object with the RFI computation perturbed_foiss (optional): perturbed features of interest if return_perturbed """ if sampler is None: if self._sampler_specified(): sampler = self.sampler logger.debug("Using class specified sampler.") if loss is None: if self._loss_specified(): loss = self.loss logger.debug("Using class specified loss.") if D is None: D = X_eval.columns # check whether the sampler is trained for each fsoi conditional on G for f in self.fsoi: if not sampler.is_trained([f], G): # train if allowed, otherwise raise error if train_allowed: sampler.train([f], G) logger.info('Training sampler on {}|{}'.format([f], G)) else: raise RuntimeError( 'Sampler is not trained on {}|{}'.format([f], G)) else: txt = '\tCheck passed: Sampler is already trained on' txt = txt + '{}|{}'.format([f], G) logger.debug(txt) # initialize array for the perturbed samples nr_obs = X_eval.shape[0] index = utils.create_multiindex( ['sample', 'i'], [np.arange(nr_runs), np.arange(nr_obs)]) X_fsoi_pert = pd.DataFrame([], index=index) # perturbed_foiss = np.zeros((nr_fsoi, nr_runs, nr_obs)) # sample perturbed versions for foi in self.fsoi: x_foi_pert = sampler.sample(X_eval, [foi], G, num_samples=nr_runs) X_fsoi_pert[foi] = x_foi_pert scores = pd.DataFrame([], index=index) # lss = np.zeros((nr_fsoi, nr_runs, X_eval.shape[0])) # compute observasitonwise loss differences for all runs and fois for foi in self.fsoi: # copy of the data where perturbed variables are copied into for kk in np.arange(0, nr_runs, 1): # replaced with perturbed X_eval_tilde = X_eval.copy() arr = X_fsoi_pert.loc[(kk, slice(None)), foi].to_numpy() X_eval_tilde[foi] = arr # X_eval_one_perturbed[:, self.fsoi[jj]] # = perturbed_foiss[jj, kk, :] # using only seen while training features # make sure model can handle it (selection and ordering) X_eval_tilde_model = X_eval_tilde[D] # X_eval_one_perturbed_model = X_eval_one_perturbed[:, D] X_eval_model = X_eval[D] # compute difference in observationwise loss loss_pert = loss(y_eval, self.model(X_eval_tilde_model)) loss_orig = loss(y_eval, self.model(X_eval_model)) diffs = (loss_pert - loss_orig) scores.loc[(kk, slice(None)), foi] = diffs # lss[jj, kk, :] = loss_pert - loss_orig # return explanation object ex_name = 'RFI^{}'.format(G) result = explanation.Explanation(self.fsoi, scores, ex_name=ex_name) if return_perturbed: logger.debug('Return both explanation and perturbed.') return result, X_fsoi_pert else: logger.debug('Return explanation object only') return result
def sage(self, X_test, y_test, fixed_orderings=None, partial_ordering=None, nr_orderings=None, approx=math.sqrt, type='rfi', save_orderings=True, nr_runs=10, sampler=None, loss=None, train_allowed=True, D=None, return_test_log_lik=False, nr_resample_marginalize=10): """ Compute Shapley Additive Global Importance values. Args: type: either 'rfi' or 'rfa', depending on whether conditional or marginal resampling of the remaining features shall be used X_test: data to use for resampling and evaluation. y_test: labels for evaluation. fixed_orderings: list of ready orderings nr_orderings: number of orderings in which features enter the model nr_runs: how often each value function shall be computed sampler: choice of sampler. Default None. Will throw an error when sampler is None and self.sampler is None as well. loss: choice of loss. Default None. Will throw an Error when both loss and self.loss are None. train_allowed: whether the explainer is allowed to train the sampler return_test_log_lik: return log-likelihood of conditional sampler on X_test Returns: result: an explanation object containing the respective pairwise lossdifferences with shape (nr_fsoi, nr_runs, nr_obs, nr_orderings) orderings (optional): an array containing the respective orderings if return_orderings """ # the method is currently not build for situations # where we are only interested in # a subset of the model's features if X_test.shape[1] != len(self.fsoi): logger.debug('self.fsoi: {}'.format(self.fsoi)) logger.debug('#features in model: {}'.format(X_test.shape[1])) raise RuntimeError('self.fsoi is not identical to all features') if sampler is None: if self._sampler_specified(): sampler = self.sampler logger.debug("Using class specified sampler.") if loss is None: if self._loss_specified(): loss = self.loss logger.debug("Using class specified loss.") if fixed_orderings is not None: fixed_orderings = np.array(fixed_orderings) nr_orderings = len(fixed_orderings) if nr_orderings is None: nr_unique = utils.nr_unique_perm(partial_ordering) if approx is not None: nr_orderings = math.floor(approx(nr_unique)) else: nr_orderings = nr_unique if D is None: D = X_test.columns nr_orderings_saved = 1 if save_orderings: nr_orderings_saved = nr_orderings # create dataframe for computation results index = utils.create_multiindex(['ordering', 'sample', 'id'], [ np.arange(nr_orderings_saved), np.arange(nr_runs), np.arange(X_test.shape[0]) ]) arr = np.zeros( (nr_orderings_saved * nr_runs * X_test.shape[0], len(self.fsoi))) scores = pd.DataFrame(arr, index=index, columns=self.fsoi) test_log_lik = [] for ii in range(nr_orderings): if fixed_orderings is None: ordering = utils.sample_partial(partial_ordering) else: ordering = fixed_orderings[ii] logging.info('Ordering : {}'.format(ordering)) # ordering = np.random.permutation(len(self.fsoi)) # resample multiple times for kk in range(nr_runs): # enter one feature at a time y_hat_base = np.repeat(np.mean(self.model(X_test[D])), X_test.shape[0]) for jj in np.arange(1, len(ordering), 1): # compute change in performance # by entering the respective feature # store the result in the right place # validate training of sampler impute, fixed = ordering[jj:], ordering[:jj] logger.info( 'ordering {}: run {}: split {}: impute, fixed: {} | {}' .format(ii, kk, jj, impute, fixed)) if not sampler.is_trained(impute, fixed): # train if allowed, otherwise raise error if train_allowed: estimator = sampler.train(impute, fixed) # Evaluating test log-likelihood for diagnostics test_inputs = X_test[sampler._order_fset( impute)].to_numpy() test_context = X_test[sampler._order_fset( fixed)].to_numpy() log_lik = estimator.log_prob( inputs=test_inputs, context=test_context).mean() logger.info(f'Test log-likelihood: {log_lik}') test_log_lik.append(log_lik) else: raise RuntimeError('Sampler is not trained on ' '{}|{}'.format(impute, fixed)) X_test_perturbed = X_test.copy() # iterate values nr_samples_marginalize times i_ix = X_test_perturbed.index.values rn_ix = np.arange(nr_resample_marginalize) index = utils.create_multiindex(['sample', 'id'], [rn_ix, i_ix]) tiling = np.tile(np.arange(len(X_test_perturbed)), nr_resample_marginalize) vals = X_test_perturbed.iloc[tiling].to_numpy() cols = X_test_perturbed.columns X_test_perturbed = pd.DataFrame(vals, index=index, columns=cols) imps = sampler.sample(X_test, impute, fixed, num_samples=nr_resample_marginalize) X_test_perturbed[impute] = imps[impute].to_numpy() # sample replacement, create replacement matrix y_hat_new = self.model(X_test_perturbed[D]) # mean over samples df_y_hat_new = pd.DataFrame(y_hat_new, index=index, columns=['y_hat_new']) y_hat_new_marg = df_y_hat_new.mean(level='id') lb = loss(y_test, y_hat_base) ln = loss(y_test, y_hat_new_marg) diff = lb - ln scores.loc[(ii, kk, slice(None)), ordering[jj - 1]] = diff # lss[self.fsoi[ordering[jj - 1]], kk, :, ii] = lb - ln y_hat_base = y_hat_new_marg y_hat_new = self.model(X_test[D]) diff = loss(y_test, y_hat_base) - loss(y_test, y_hat_new) scores.loc[(ii, kk, slice(None)), ordering[-1]] = diff # lss[self.fsoi[ordering[-1]], kk, :, ii] = diff result = explanation.Explanation(self.fsoi, scores, ex_name='SAGE') if return_test_log_lik: return result, test_log_lik return result
def decomposition(self, imp_type, fsoi, partial_ordering, X_eval, y_eval, nr_orderings=None, nr_runs=3, show_pbar=True, approx=math.sqrt, save_orderings=True, **kwargs): """ Given a partial ordering, this code allows to decompose feature importance or feature association for a given set of features into its respective indirect or direct components. Args: imp_type: Either 'rfi' or 'rfa' fois: features, for which the importance scores (of type imp_type) are to be decomposed partial_ordering: partial ordering for the decomposition X_test: test data y_test: test labels nr_orderings: number of total orderings to sample (given the partial) ordering nr_runs: number of runs for each feature importance score computation Returns: means, stds: means and standard deviations for each component and each feature. numpy.array with shape (#components, #fsoi) """ if nr_orderings is None: nr_unique = utils.nr_unique_perm(partial_ordering) if approx is not None: nr_orderings = math.floor(approx(nr_unique)) else: nr_orderings = nr_unique logger.info('#orderings: {}'.format(nr_orderings)) explnr_fnc = None if imp_type == 'tdi': explnr_fnc = self.tdi elif imp_type == 'tai': explnr_fnc = self.tai else: raise ValueError('Importance type ' '{} not implemented'.format(imp_type)) # values (nr_perm, nr_runs, nr_components, nr_fsoi) # components are: (elements of ordering,..., remainder) # elements of ordering are sorted in increasing order component_names = np.unique(utils.flatten(partial_ordering)) component_names = list(component_names) component_names.insert(0, 'remainder') component_names.insert(0, 'total') nr_components = len(component_names) nr_orderings_saved = 1 if save_orderings: nr_orderings_saved = nr_orderings def rescale_fi_vals(fi_vals_old, fi_vals, component, ordering_nr): ''' Necessary to compute the running mean when not saving every ordering. Assuming fi_vals are np.array, ordering_nr run number in range(0, nr_orderings) df the decomposition dataframe ''' fi_vals_old_scaled = fi_vals_old * (ordering - 1) / ordering fi_vals_scaled = fi_vals / ordering fi_vals_new = fi_vals_old_scaled + fi_vals_scaled return fi_vals_new # create dataframe for computation results index = utils.create_multiindex(['component', 'ordering', 'sample'], [ component_names, np.arange(nr_orderings_saved), np.arange(nr_runs) ]) arr = np.zeros( (nr_components * nr_orderings_saved * nr_runs, len(self.fsoi))) decomposition = pd.DataFrame(arr, index=index, columns=self.fsoi) # values = np.zeros((nr_orderings, nr_runs, nr_components, len(fsoi))) if show_pbar: mgr = enlighten.get_manager() pbar = mgr.counter(total=nr_orderings, desc='decomposition', unit='orderings') for kk in np.arange(nr_orderings): if show_pbar: pbar.update() ordering = utils.sample_partial(partial_ordering) logging.info('Ordering : {}'.format(ordering)) # total values expl = explnr_fnc(X_eval, y_eval, [], nr_runs=nr_runs, **kwargs) fi_vals = expl.fi_vals().to_numpy() # store total values if save_orderings: decomposition.loc['total', kk, :] = fi_vals else: fi_old = decomposition.loc['total', 0, :].to_numpy() fi_vals = rescale_fi_vals(fi_old, fi_vals, 'total', kk) decomposition.loc['total', 0, :] = fi_vals previous = fi_vals current = None for jj in np.arange(1, len(ordering) + 1): # get current new variable and respective set current_ix = ordering[jj - 1] G = ordering[:jj] # compute and store feature importance expl = explnr_fnc(X_eval, y_eval, G, nr_runs=nr_runs, **kwargs) current = expl.fi_vals().to_numpy() # compute difference fi_vals = previous - current # store result if save_orderings: decomposition.loc[current_ix, kk, :] = fi_vals else: fi_old = decomposition.loc[current_ix, 0, :].to_numpy() fi_vals = rescale_fi_vals(fi_old, fi_vals, current_ix, kk) decomposition.loc[current_ix, 0, :] = fi_vals previous = current # store remainder if save_orderings: decomposition.loc['remainder', kk, :] = current else: fi_old = decomposition.loc['remainder', 0, :].to_numpy() fi_vals = rescale_fi_vals(fi_old, current, 'remainder', kk) decomposition.loc['remainder', 0, :] = fi_vals ex = decomposition_ex.DecompositionExplanation(self.fsoi, decomposition, ex_name=None) return ex
def tai(self, X_eval, y_eval, K, D=None, sampler=None, decorrelator=None, loss=None, nr_runs=10, return_perturbed=False, train_allowed=True, ex_name=None): """Computes Feature Association Args: X_eval: data to use for resampling and evaluation. y_eval: labels for evaluation. K: features not to be reconstracted D: model features (including their required ordering) sampler: choice of sampler. Default None. Will throw an error when sampler is None and self.sampler is None as well. loss: choice of loss. Default None. Will throw an Error when both loss and self.loss are None. nr_runs: how often the experiment shall be run return_perturbed: whether the sampled perturbed versions shall be returned train_allowed: whether the explainer is allowed to train the sampler Returns: result: An explanation object with the RFI computation perturbed_foiss (optional): perturbed features of interest if return_perturbed """ if sampler is None: if self._sampler_specified(): # may throw an error sampler = self.sampler logger.debug("Using class specified sampler.") if decorrelator is None: if self._decorrelator_specified(): # may throw error decorrelator = self.decorrelator logger.debug("Using class specified decorrelator") if loss is None: if self._loss_specified(): # may throw an error loss = self.loss logger.debug("Using class specified loss.") if D is None: D = X_eval.columns # all_fs = np.arange(X_test.shape[1]) # check whether the sampler is trained for the baseline perturbation if not sampler.is_trained(D, []): # train if allowed, otherwise raise error if train_allowed: sampler.train(D, []) logger.info('Training sampler on {}|{}'.format(D, [])) else: raise RuntimeError('Sampler is not trained on {}|{}'.format( D, [])) else: txt = '\tCheck passed: Sampler is already trained on ' txt = txt + '{}|{}'.format(D, []) logger.debug(txt) # check for each of the features of interest for foi in self.fsoi: if not sampler.is_trained(D, [foi]): # train if allowed, otherwise raise error if train_allowed: sampler.train(D, [foi]) logger.info('Training sampler on {}|{}'.format(D, [foi])) else: raise RuntimeError( 'Sampler is not trained on {}|{}'.format(D, [foi])) else: txt = '\tCheck passed: Sampler is already trained on ' txt = txt + '{}|{}'.format(D, [foi]) logger.debug(txt) # check whether decorrelators have been trained for foi in self.fsoi: if not decorrelator.is_trained(K, [foi], []): if train_allowed: decorrelator.train(K, [foi], []) txt = 'Training decorrelator on ' txt = txt + '{} idp {} | {}'.format(K, [foi], []) logger.info(txt) else: txt = 'Decorrelator is not trained on ' txt = txt + '{} {} | {}'.format(K, [foi], []) raise RuntimeError(txt) else: logger.debug('\tCheck passed: ' 'Decorrelator is already trained on ' '{} {} | {}'.format(K, [foi], [])) # initialize array for the perturbed samples nr_obs = X_eval.shape[0] # initialize pandas dataframes for X_eval_tilde baseline # and X_eval_tilde reconstrcted index_bsln = utils.create_multiindex( ['sample', 'i'], [np.arange(nr_runs), np.arange(nr_obs)]) X_eval_tilde_bsln = pd.DataFrame([], index=index_bsln, columns=D) index_rcnstr = utils.create_multiindex( ['foi', 'sample', 'i'], [self.fsoi, np.arange(nr_runs), np.arange(nr_obs)]) X_eval_tilde_rcnstr = pd.DataFrame([], index=index_rcnstr, columns=D) # sample baseline X^\emptyset X_eval_tilde_bsln = sampler.sample(X_eval, D, [], num_samples=nr_runs) # sample perturbed versions for foi in self.fsoi: # X^foi sample = sampler.sample(X_eval, D, [foi], num_samples=nr_runs) for kk in np.arange(nr_runs): # X^\emptyset,linked sample_decorr = decorrelator.decorrelate( sample.loc[kk, :], K, [foi], []) sd_np = sample_decorr[D].to_numpy() X_eval_tilde_rcnstr.loc[(foi, kk, slice(None)), D] = sd_np # create empty scores data frame index_scores = utils.create_multiindex( ['sample', 'i'], [np.arange(nr_runs), np.arange(nr_obs)]) scores = pd.DataFrame([], index=index_scores) # compute observasitonwise loss differences for all runs and fois for kk in np.arange(nr_runs): X_bl = X_eval_tilde_bsln.loc[(kk, slice(None)), D] l_pb = loss(y_eval, self.model(X_bl)) for foi in self.fsoi: X_rc = X_eval_tilde_rcnstr.loc[(foi, kk, slice(None)), D] l_rc = loss(y_eval, self.model(X_rc)) scores.loc[(kk, slice(None)), foi] = l_pb - l_rc if ex_name is None: ex_name = 'Unknown tai' # return explanation object result = explanation.Explanation(self.fsoi, scores, ex_name=ex_name) if return_perturbed: raise NotImplementedError( 'Returning perturbed not implemented yet.') else: logger.debug('Return explanation object only') return result
def tdi_from(self, K, B, J, X_eval, y_eval, D=None, sampler=None, decorrelator=None, loss=None, nr_runs=10, return_perturbed=False, train_allowed=True, target='Y', marginalize=False): """Computes Relative Feature importance Args: K: features of interest B: baseline features J: "from" conditioning set X_eval: data to use for resampling and evaluation. y_eval: labels for evaluation. D: features, used by the predictive model sampler: choice of sampler. Default None. Will throw an error when sampler is None and self.sampler is None as well. loss: choice of loss. Default None. Will throw an Error when both loss and self.loss are None. nr_runs: how often the experiment shall be run return_perturbed: whether the sampled perturbed versions shall be returned train_allowed: whether the explainer is allowed to train the sampler Returns: result: An explanation object with the RFI computation perturbed_foiss (optional): perturbed features of interest if return_perturbed """ if target not in ['Y', 'Y_hat']: raise ValueError('Y and Y_hat are the only valid targets.') if marginalize: raise NotImplementedError('Marginalization not implemented yet.') if sampler is None: if self._sampler_specified(): sampler = self.sampler logger.debug("Using class specified sampler.") if decorrelator is None: if self._decorrelator_specified(): decorrelator = self.decorrelator logger.debug("Using class specified decorrelator.") if loss is None: if self._loss_specified(): loss = self.loss logger.debug("Using class specified loss.") if D is None: D = X_eval.columns if not set(K).isdisjoint(set(B)): raise ValueError('K and B are not disjoint.') # check whether sampler is trained for baseline dropped features R = list(set(D) - set(B)) if not sampler.is_trained(R, J): # train if allowed, otherwise raise error if train_allowed: sampler.train(R, J) logger.info('Training sampler on {}|{}'.format(R, J)) else: raise RuntimeError('Sampler is not trained on {}|{}'.format( R, J)) else: txt = '\tCheck passed: Sampler is already trained on' txt = txt + '{}|{}'.format(R, J) logger.debug(txt) if not decorrelator.is_trained(R, J, []): # train if allowed, otherwise raise error if train_allowed: decorrelator.train(R, J, []) logger.info('Training decorrelator on {} idp {} |{}'.format( R, J, [])) else: raise RuntimeError( 'Sampler is not trained on {} idp {} |{}'.format(R, J, [])) else: txt = '\tCheck passed: decorrelator is already trained on' txt = txt + '{} idp {}|{}'.format(R, J, []) logger.debug(txt) desc = 'TDI({} | {} <- {})'.format(K, B, J) # initialize array for the perturbed samples nr_obs = X_eval.shape[0] index = utils.create_multiindex( ['sample', 'i'], [np.arange(nr_runs), np.arange(nr_obs)]) # X_fsoi_pert = pd.DataFrame([], index=index) # perturbed_foiss = np.zeros((nr_fsoi, nr_runs, nr_obs)) # sample perturbed versions X_R_J = sampler.sample(X_eval, R, J, num_samples=nr_runs) breakpoint() scores = pd.DataFrame([], index=index) # lss = np.zeros((nr_fsoi, nr_runs, X_eval.shape[0])) for kk in np.arange(0, nr_runs, 1): # replaced with perturbed X_tilde_baseline = X_eval.copy() X_tilde_foreground = X_eval.copy() X_R_empty_linked = decorrelator.decorrelate( X_R_J.loc[kk, :], R, J, []) arr = X_R_empty_linked[R].to_numpy() X_tilde_baseline[R] = arr X_tilde_foreground[R] = arr X_tilde_foreground[K] = X_R_J.loc[(kk, slice(None)), J].to_numpy() # make sure model can handle it (selection and ordering) X_tilde_baseline = X_tilde_baseline[D] X_tilde_foreground = X_tilde_foreground[D] # compute difference in observationwise loss if target == 'Y': loss_baseline = loss(y_eval, self.model(X_tilde_baseline)) loss_foreground = loss(y_eval, self.model(X_tilde_foreground)) diffs = (loss_baseline - loss_foreground) scores.loc[(kk, slice(None)), 'score'] = diffs else: raise NotImplementedError('Y_hat not implemented yet.') # lss[jj, kk, :] = loss_pert - loss_orig # return explanation object ex_name = desc result = explanation.Explanation(self.fsoi, scores, ex_name=ex_name) if return_perturbed: raise NotImplementedError('return_perturbed=True not implemented.') else: logger.debug('Return explanation object only') return result
def decomposition(self, imp_type, fsoi, partial_ordering, X_eval, y_eval, nr_orderings=None, nr_orderings_sage=None, nr_runs=3, show_pbar=True, approx=math.sqrt, save_orderings=True, sage_partial_ordering=None, orderings=None, target='Y', **kwargs): """ Given a partial ordering, this code allows to decompose feature importance or feature association for a given set of features into its respective indirect or direct components. Args: imp_type: Either 'rfi' or 'rfa' fois: features, for which the importance scores (of type imp_type) are to be decomposed partial_ordering: partial ordering for the decomposition X_test: test data y_test: test labels nr_orderings: number of total orderings to sample (given the partial) ordering nr_runs: number of runs for each feature importance score computation Returns: means, stds: means and standard deviations for each component and each feature. numpy.array with shape (#components, #fsoi) """ if orderings is None: if nr_orderings is None: nr_unique = utils.nr_unique_perm(partial_ordering) if approx is not None: nr_orderings = math.floor(approx(nr_unique)) else: nr_orderings = nr_unique else: nr_orderings = orderings.shape[0] logger.info('#orderings: {}'.format(nr_orderings)) if imp_type not in ['tdi', 'tai', 'sage']: raise ValueError('Only tdi, tai and sage ' 'implemented for imp_type.') if imp_type == 'sage' and sage_partial_ordering is None: raise ValueError('Please specify a sage ordering.') if target not in ['Y', 'Y_hat']: raise ValueError('Only Y and Y_hat implemented as target.') if nr_orderings_sage is None: nr_orderings_sage = nr_orderings # values (nr_perm, nr_runs, nr_components, nr_fsoi) # components are: (elements of ordering,..., remainder) # elements of ordering are sorted in increasing order component_names = np.unique(utils.flatten(partial_ordering)) component_names = list(component_names) component_names.insert(0, 'remainder') component_names.insert(0, 'total') nr_components = len(component_names) nr_orderings_saved = 1 if save_orderings: nr_orderings_saved = nr_orderings def rescale_fi_vals(fi_vals_old, fi_vals, component, ordering_nr): ''' Necessary to compute the running mean when not saving every ordering. Assuming fi_vals are np.array, ordering_nr run number in range(0, nr_orderings) df the decomposition dataframe ''' fi_vals_old_scaled = fi_vals_old * (ordering - 1) / ordering fi_vals_scaled = fi_vals / ordering fi_vals_new = fi_vals_old_scaled + fi_vals_scaled return fi_vals_new # create dataframe for computation results index = utils.create_multiindex(['component', 'ordering', 'sample'], [ component_names, np.arange(nr_orderings_saved), np.arange(nr_runs) ]) arr = np.zeros( (nr_components * nr_orderings_saved * nr_runs, len(self.fsoi))) decomposition = pd.DataFrame(arr, index=index, columns=self.fsoi) # values = np.zeros((nr_orderings, nr_runs, nr_components, len(fsoi))) orderings_sampled = pd.DataFrame(index=np.arange(nr_orderings), columns=['ordering']) # in the first sage call an ordering object is returned # that is then fed to sage again # to ensure that always the same orderings are used # for the computation # allow a more reliable approximation sage_orderings = None if show_pbar: mgr = enlighten.get_manager() pbar = mgr.counter(total=nr_orderings, desc='decomposition', unit='orderings') # ordering history helps to avoid duplicate orderings ord_hist = None for kk in np.arange(nr_orderings): if show_pbar: pbar.update() ordering = None if orderings is None: ordering, ord_hist = utils.sample_partial( partial_ordering, ord_hist) logging.info('Ordering : {}'.format(ordering)) orderings_sampled.loc[kk, 'ordering'] = ordering else: ordering = orderings.loc[kk, 'ordering'] # total values expl = None if imp_type == 'tdi': expl = self.tdi(X_eval, y_eval, [], nr_runs=nr_runs, **kwargs) elif imp_type == 'tai': expl = self.tai(X_eval, y_eval, [], nr_runs=nr_runs, **kwargs) elif imp_type == 'sage': tupl = self.sage(X_eval, y_eval, partial_ordering, nr_orderings=nr_orderings_sage, nr_runs=nr_runs, target=target, G=X_eval.columns, orderings=sage_orderings, **kwargs) expl, sage_orderings = tupl fi_vals = expl.fi_vals().to_numpy() # store total values if save_orderings: decomposition.loc['total', kk, :] = fi_vals else: fi_old = decomposition.loc['total', 0, :].to_numpy() fi_vals = rescale_fi_vals(fi_old, fi_vals, 'total', kk) decomposition.loc['total', 0, :] = fi_vals previous = fi_vals current = None for jj in np.arange(1, len(ordering) + 1): # get current new variable and respective set current_ix = ordering[jj - 1] G = ordering[:jj] # compute and store feature importance expl = None if imp_type == 'tdi': expl = self.tdi(X_eval, y_eval, G, nr_runs=nr_runs, **kwargs) elif imp_type == 'tai': expl = self.tai(X_eval, y_eval, G, nr_runs=nr_runs, **kwargs) elif imp_type == 'sage': G_ = list(set(X_eval.columns) - set(G)) tupl = self.sage(X_eval, y_eval, partial_ordering, nr_orderings=nr_orderings_sage, nr_runs=nr_runs, target=target, G=G_, orderings=sage_orderings, **kwargs) expl, sage_orderings = tupl current = expl.fi_vals().to_numpy() fi_vals = None # compute difference fi_vals = previous - current # store result if save_orderings: decomposition.loc[current_ix, kk, :] = fi_vals else: fi_old = decomposition.loc[current_ix, 0, :].to_numpy() fi_vals = rescale_fi_vals(fi_old, fi_vals, current_ix, kk) decomposition.loc[current_ix, 0, :] = fi_vals previous = current # store remainder if save_orderings: decomposition.loc['remainder', kk, :] = current else: fi_old = decomposition.loc['remainder', 0, :].to_numpy() fi_vals = rescale_fi_vals(fi_old, current, 'remainder', kk) decomposition.loc['remainder', 0, :] = fi_vals if orderings is None: orderings = orderings_sampled ex = decomposition_ex.DecompositionExplanation(self.fsoi, decomposition, ex_name=None) return ex, orderings
def viafrom(self, imp_type, fsoi, X_eval, y_eval, target='Y', nr_runs=10, show_pbar=True, components=None, **kwargs): """ Either computs the pfi under only one variable being reconstructed for every feature (ar_via), or the component of the pfi of every feature from single variables (dr_from) """ if imp_type not in ['ar_via', 'dr_from', 'sage']: raise ValueError('Only ar_via, sage and dr_from' 'implemented for imp_type.') if target not in ['Y', 'Y_hat']: raise ValueError('Only Y and Y_hat implemented as target.') # values (nr_perm, nr_runs, nr_components, nr_fsoi) # components are: (elements of ordering,..., remainder) # elements of ordering are sorted in increasing order if components is None: components = X_eval.columns components = list(components) components.append('total') nr_components = len(components) # create dataframe for computation results # orderings is just for compatibility with other decompositions index = utils.create_multiindex( ['component', 'ordering', 'sample'], [components, np.arange(1), np.arange(nr_runs)]) arr = np.zeros((nr_components * nr_runs * 1, len(fsoi))) decomposition = pd.DataFrame(arr, index=index, columns=fsoi) if show_pbar: mgr = enlighten.get_manager() pbar = mgr.counter(total=nr_components * len(fsoi), desc='naive_decomposition', unit='{} runs'.format(imp_type)) # helper funciton to compute the remainder def get_rmd(fs, f): rmd = list(set(fs).difference([f])) return rmd if imp_type == 'sage': expl, ordering = self.sage(X_eval, y_eval, [tuple(fsoi)], **kwargs) fi_vals_total = expl.fi_vals()[fsoi].to_numpy() decomposition.loc[idx['total', 0, :], fsoi] = fi_vals_total for component in get_rmd(components, 'total'): rmd = get_rmd(X_eval.columns, component) expl, ordering = self.sage(X_eval, y_eval, [tuple(fsoi)], G=rmd, **kwargs) fi_vals = expl.fi_vals()[fsoi].to_numpy() diff = fi_vals_total - fi_vals decomposition.loc[idx[component, 0, :], fsoi] = diff ex = decomposition_ex.DecompositionExplanation(self.fsoi, decomposition, ex_name=None) return ex # iterate over features for foi in fsoi: fi_vals_total = None if imp_type == 'ar_via': # compute ar of foi over emptyset expl = self.ar_via([foi], [], X_eval.columns, X_eval, y_eval, nr_runs=nr_runs, target=target, **kwargs) fi_vals_total = expl.fi_vals().to_numpy() elif imp_type == 'dr_from': # compute total PFI (over rmd) rmd = get_rmd(X_eval.columns, foi) expl = self.dr_from([foi], rmd, X_eval.columns, X_eval, y_eval, nr_runs=nr_runs, target=target, **kwargs) fi_vals_total = expl.fi_vals().to_numpy() decomposition.loc[idx['total', 0, :], foi] = fi_vals_total # iterate over components for component in get_rmd(components, 'total'): if show_pbar: pbar.update() fi_vals = None if imp_type == 'ar_via': rmd = get_rmd(X_eval.columns, component) expl = self.ar_via([foi], [], rmd, X_eval, y_eval, nr_runs=nr_runs, target=target, **kwargs) fi_vals = expl.fi_vals().to_numpy() diff = fi_vals_total - fi_vals decomposition.loc[idx[component, 0, :], foi] = diff elif imp_type == 'dr_from': rmd = get_rmd(X_eval.columns, foi) expl = self.dr_from([foi], rmd, [component], X_eval, y_eval, nr_runs=nr_runs, target=target, **kwargs) fi_vals = expl.fi_vals().to_numpy() decomposition.loc[idx[component, 0, :], foi] = fi_vals ex = decomposition_ex.DecompositionExplanation(self.fsoi, decomposition, ex_name=None) return ex
def ar_via(self, J, C, K, X_eval, y_eval, D=None, sampler=None, decorrelator=None, loss=None, nr_runs=10, return_perturbed=False, train_allowed=True, target='Y', marginalize=False, nr_resample_marginalize=5): """Computes Relative Feature importance Args: K: features of interest B: baseline features J: "from" conditioning set X_eval: data to use for resampling and evaluation. y_eval: labels for evaluation. D: features, used by the predictive model sampler: choice of sampler. Default None. Will throw an error when sampler is None and self.sampler is None as well. loss: choice of loss. Default None. Will throw an Error when both loss and self.loss are None. nr_runs: how often the experiment shall be run return_perturbed: whether the sampled perturbed versions shall be returned train_allowed: whether the explainer is allowed to train the sampler Returns: result: An explanation object with the RFI computation perturbed_foiss (optional): perturbed features of interest if return_perturbed """ if target not in ['Y', 'Y_hat']: raise ValueError('Y and Y_hat are the only valid targets.') # if marginalize: # raise NotImplementedError('Marginalization not implemented yet.') if sampler is None: if self._sampler_specified(): sampler = self.sampler logger.debug("Using class specified sampler.") if decorrelator is None: if self._decorrelator_specified(): decorrelator = self.decorrelator logger.debug("Using class specified decorrelator.") if loss is None: if self._loss_specified(): loss = self.loss logger.debug("Using class specified loss.") if D is None: D = X_eval.columns if not marginalize: nr_resample_marginalize = 1 if not set(J).isdisjoint(set(C)): raise ValueError('J and C are not disjoint.') # check whether sampler is trained for baseline dropped features R = list(set(D) - set(C)) R_ = list(set(R) - set(J)) CuJ = list(set(C).union(J)) if not sampler.is_trained(R_, CuJ): # train if allowed, otherwise raise error if train_allowed: sampler.train(R_, CuJ) logger.info('Training sampler on {}|{}'.format(R_, CuJ)) else: raise RuntimeError('Sampler is not trained on {}|{}'.format( R_, CuJ)) else: txt = '\tCheck passed: Sampler is already trained on' txt = txt + '{}|{}'.format(R, J) logger.debug(txt) if not decorrelator.is_trained(K, J, C): # train if allowed, otherwise raise error if train_allowed: decorrelator.train(K, J, C) logger.info('Training decorrelator on {} idp {} |{}'.format( K, J, C)) else: raise RuntimeError( 'Sampler is not trained on {} idp {} |{}'.format(K, J, C)) else: txt = '\tCheck passed: decorrelator is already trained on' txt = txt + '{} idp {}|{}'.format(K, J, C) logger.debug(txt) desc = 'AR({} | {} -> {})'.format(J, C, K) # initialize array for the perturbed samples nr_obs = X_eval.shape[0] index = utils.create_multiindex( ['sample', 'i'], [np.arange(nr_runs), np.arange(nr_obs)]) scores = pd.DataFrame([], index=index) for kk in np.arange(0, nr_runs, 1): # sample perturbed versions X_R_CuJ2 = None X_R_CuJ = sampler.sample(X_eval, R_, CuJ, num_samples=nr_resample_marginalize) index = X_R_CuJ.index df_yh = pd.DataFrame( index=index, columns=['y_hat_baseline', 'y_hat_foreground']) for ll in np.arange(0, nr_resample_marginalize, 1): X_tilde_baseline = X_eval.copy() X_tilde_foreground = X_eval.copy() arr_reconstr = X_R_CuJ.loc[(ll, slice(None)), R_].to_numpy() X_tilde_foreground[R_] = arr_reconstr X_R_decorr = decorrelator.decorrelate(X_tilde_foreground, K, J, C) arr_decorr = X_R_decorr[R].to_numpy() X_tilde_baseline[R] = arr_decorr # make sure model can handle it (selection and ordering) X_tilde_baseline = X_tilde_baseline[D] X_tilde_foreground = X_tilde_foreground[D] y_hat_baseline = self.model(X_tilde_baseline) y_hat_foreground = self.model(X_tilde_foreground) df_yh.loc[(ll, slice(None)), 'y_hat_baseline'] = y_hat_baseline df_yh.loc[(ll, slice(None)), 'y_hat_foreground'] = y_hat_foreground df_yh = df_yh.astype({ 'y_hat_baseline': 'float', 'y_hat_foreground': 'float' }) df_yh = df_yh.mean(level='i') # compute difference in observationwise loss if target == 'Y': loss_baseline = loss(y_eval, df_yh['y_hat_baseline']) loss_foreground = loss(y_eval, df_yh['y_hat_foreground']) diffs = (loss_baseline - loss_foreground) scores.loc[(kk, slice(None)), 'score'] = diffs elif target == 'Y_hat': diffs = loss(df_yh['y_hat_baseline'], df_yh['y_hat_foreground']) scores.loc[(kk, slice(None)), 'score'] = diffs # return explanation object ex_name = desc result = explanation.Explanation(self.fsoi, scores, ex_name=ex_name) if return_perturbed: raise NotImplementedError('return_perturbed=True not implemented.') else: logger.debug('Return explanation object only') return result
def sage(self, X_test, y_test, partial_ordering, nr_orderings=None, approx=math.sqrt, save_orderings=True, nr_runs=10, sampler=None, loss=None, train_allowed=True, D=None, nr_resample_marginalize=10, target='Y', G=None, method='associative', marginalize=True, orderings=None, **kwargs): """ Compute Shapley Additive Global Importance values. Args: type: either 'rfi' or 'rfa', depending on whether conditional or marginal resampling of the remaining features shall be used X_test: data to use for resampling and evaluation. y_test: labels for evaluation. nr_orderings: number of orderings in which features enter the model nr_runs: how often each value function shall be computed sampler: choice of sampler. Default None. Will throw an error when sampler is None and self.sampler is None as well. loss: choice of loss. Default None. Will throw an Error when both loss and self.loss are None. train_allowed: whether the explainer is allowed to train the sampler Returns: result: an explanation object containing the respective pairwise lossdifferences with shape (nr_fsoi, nr_runs, nr_obs, nr_orderings) orderings (optional): an array containing the respective orderings if return_orderings """ if G is None: G = X_test.columns if X_test.shape[1] != len(self.fsoi): logger.debug('self.fsoi: {}'.format(self.fsoi)) logger.debug('#features in model: {}'.format(X_test.shape[1])) raise RuntimeError('self.fsoi is not identical to all features') if method not in ['associative', 'direct']: raise ValueError('only methods associative or direct implemented') if sampler is None: if self._sampler_specified(): sampler = self.sampler logger.debug("Using class specified sampler.") if loss is None: if self._loss_specified(): loss = self.loss logger.debug("Using class specified loss.") if orderings is None: if nr_orderings is None: nr_unique = utils.nr_unique_perm(partial_ordering) if approx is not None: nr_orderings = math.floor(approx(nr_unique)) else: nr_orderings = nr_unique else: nr_orderings = orderings.shape[0] if D is None: D = X_test.columns nr_orderings_saved = 1 if save_orderings: nr_orderings_saved = nr_orderings # create dataframe for computation results index = utils.create_multiindex(['ordering', 'sample', 'id'], [ np.arange(nr_orderings_saved), np.arange(nr_runs), np.arange(X_test.shape[0]) ]) arr = np.zeros( (nr_orderings_saved * nr_runs * X_test.shape[0], len(self.fsoi))) scores = pd.DataFrame(arr, index=index, columns=self.fsoi) orderings_sampled = None if orderings is None: orderings_sampled = pd.DataFrame(index=np.arange(nr_orderings), columns=['ordering']) # lss = np.zeros( # (len(self.fsoi), nr_runs, X_test.shape[0], nr_orderings)) # ord hist helps to avoid duplicate histories ord_hist = None for ii in range(nr_orderings): ordering = None if orderings is None: ordering, ord_hist = utils.sample_partial( partial_ordering, ord_hist) orderings_sampled.loc[ii, 'ordering'] = ordering else: ordering = orderings.loc[ii, 'ordering'] logging.info('Ordering : {}'.format(ordering)) for jj in np.arange(1, len(ordering), 1): # TODO: check if jj in features for which the score shall # TODO: be computed # compute change in performance # by entering the respective feature # store the result in the right place # validate training of sampler J, C = [ordering[jj - 1]], ordering[:jj - 1] if method == 'associative': ex = self.ar_via( J, C, G, X_test, y_test, target=target, marginalize=marginalize, nr_runs=nr_runs, nr_resample_marginalize=nr_resample_marginalize, **kwargs) elif method == 'direct': ex = self.dr_from( J, C, G, X_test, y_test, target=target, marginalize=marginalize, nr_runs=nr_runs, nr_resample_marginalize=nr_resample_marginalize, **kwargs) scores_arr = ex.scores.to_numpy() scores.loc[(ii, slice(None), slice(None)), ordering[jj - 1]] = scores_arr result = explanation.Explanation(self.fsoi, scores, ex_name='SAGE') if orderings is None: orderings = orderings_sampled return result, orderings