def findClosestObservableSample(observable_samples, dataset_obj, factual_sample, norm_type): closest_observable_sample = {'sample': {}, 'distance': np.infty, 'norm_type': None} # in case no observables are found. for observable_sample_index, observable_sample in observable_samples.items(): # observable_sample['y'] = True if observable_sample['y'] == factual_sample['y']: # make sure the cf flips the prediction continue # Only compare against those observable samples that DO NOT differ with the # factual sample in non-actionable features! violating_actionable_attributes = False for attr_name_kurz in dataset_obj.getInputAttributeNames('kurz'): attr_obj = dataset_obj.attributes_kurz[attr_name_kurz] if attr_obj.actionability == 'none' and factual_sample[attr_name_kurz] != observable_sample[attr_name_kurz]: violating_actionable_attributes = True elif attr_obj.actionability == 'same-or-increase' and factual_sample[attr_name_kurz] > observable_sample[attr_name_kurz]: violating_actionable_attributes = True elif attr_obj.actionability == 'same-or-decrease' and factual_sample[attr_name_kurz] < observable_sample[attr_name_kurz]: violating_actionable_attributes = True observable_distance = normalizedDistance.getDistanceBetweenSamples(factual_sample, observable_sample, norm_type, dataset_obj) if violating_actionable_attributes: continue if observable_distance < closest_observable_sample['distance']: closest_observable_sample = {'sample': observable_sample, 'distance': observable_distance, 'norm_type': norm_type} return closest_observable_sample
def genExp(model_trained, factual_sample, norm_type, dataset_obj): start_time = time.time() # SIMPLE HACK!! # ActionSet() construction demands that all variables have a range to them. In # the case of one-hot ordinal variables (e.g., x2_ord_0, x2_ord_1, x2_ord_2)), # the first sub-category (i.e., x2_ord_0) will always have range(1,1), failing # the requirements of ActionSet(). Therefore, we add a DUMMY ROW to the data- # frame, which is a copy of another row (so not to throw off the range of other # attributes), but setting a 0 value to all _ord_ variables. (might as well do # this for all _cat_ variables as well). tmp_df = dataset_obj.data_frame_kurz sample_row = tmp_df.loc[0].to_dict() for attr_name_kurz in dataset_obj.getOneHotAttributesNames('kurz'): sample_row[attr_name_kurz] = 0 tmp_df = tmp_df.append(pd.Series(sample_row), ignore_index=True) df = tmp_df X = df.loc[:, df.columns != 'y'] # Enforce binary, categorical (including ordinal) variables only take on 2 values custom_bounds = { attr_name_kurz: (0, 100, 'p') for attr_name_kurz in np.union1d( dataset_obj.getOneHotAttributesNames('kurz'), dataset_obj.getBinaryAttributeNames('kurz')) } action_set = ActionSet(X=X, custom_bounds=custom_bounds) # action_set['x1'].mutable = False # x1 = 'Race' # In the current implementation, we only supports any/none actionability for attr_name_kurz in dataset_obj.getInputAttributeNames('kurz'): attr_obj = dataset_obj.attributes_kurz[attr_name_kurz] if attr_obj.actionability == 'none': action_set[attr_name_kurz].mutable = False elif attr_obj.actionability == 'any': continue # do nothing else: raise ValueError( f'Actionable Recourse does not support actionability type {attr_obj.actionability}' ) # Enforce search over integer-based grid for integer-based variables for attr_name_kurz in np.union1d( dataset_obj.getIntegerBasedAttributeNames('kurz'), dataset_obj.getBinaryAttributeNames('kurz'), ): action_set[attr_name_kurz].step_type = "absolute" action_set[attr_name_kurz].step_size = 1 coefficients = model_trained.coef_[0] intercept = model_trained.intercept_[0] if norm_type == 'one_norm': mip_cost_type = 'total' elif norm_type == 'infty_norm': mip_cost_type = 'max' else: raise ValueError( f'Actionable Recourse does not support norm_type {norm_type}') factual_sample_values = list(factual_sample.values()) # p = .8 rb = RecourseBuilder( optimizer="cplex", coefficients=coefficients, intercept=intercept, # - (np.log(p / (1. - p))), action_set=action_set, x=factual_sample_values, mip_cost_type=mip_cost_type) output = rb.fit() counterfactual_sample_values = np.add(factual_sample_values, output['actions']) counterfactual_sample = dict( zip(factual_sample.keys(), counterfactual_sample_values)) # factual_sample['y'] = False # counterfactual_sample['y'] = True counterfactual_sample['y'] = not factual_sample['y'] counterfactual_plausible = True # IMPORTANT: no need to check for integer-based / binary-based plausibility, # because those were set above when we said step_type = absolute! Just round! for attr_name_kurz in np.union1d( dataset_obj.getOneHotAttributesNames('kurz'), dataset_obj.getBinaryAttributeNames('kurz')): try: assert np.isclose(counterfactual_sample[attr_name_kurz], np.round(counterfactual_sample[attr_name_kurz])) counterfactual_sample[attr_name_kurz] = np.round( counterfactual_sample[attr_name_kurz]) except: distance = -1 counterfactual_plausible = False # return counterfactual_sample, distance # Perform plausibility-data-type check! Remember, all ordinal variables # have already been converted to categorical variables. It is important now # to check that 1 (and only 1) sub-category is activated in the resulting # counterfactual sample. already_considered = [] for attr_name_kurz in dataset_obj.getOneHotAttributesNames('kurz'): if attr_name_kurz not in already_considered: siblings_kurz = dataset_obj.getSiblingsFor(attr_name_kurz) activations_for_category = [ counterfactual_sample[attr_name_kurz] for attr_name_kurz in siblings_kurz ] sum_activations_for_category = np.sum(activations_for_category) if 'cat' in dataset_obj.attributes_kurz[attr_name_kurz].attr_type: if sum_activations_for_category == 1: continue else: # print('not plausible, fixing..', end='') # TODO: don't actually return early! Instead see the actual distance, # fingers crossed that we can say that not only is their method giving # counterfactuals are larger distances, but in a lot of cases they are # not data-type plausible # INSTEAD, do below: # Convert to correct categorical/ordinal activations so we can # compute the distance using already written function. # Turns out we need to do nothing, because the distance between # [0,1,0] and anything other than itself, (e.g., [1,1,0] or [1,0,1]) # is always 1 :) # continue distance = -1 counterfactual_plausible = False # return counterfactual_sample, distance elif 'ord' in dataset_obj.attributes_kurz[ attr_name_kurz].attr_type: # TODO: assert activations are in order... # if not, repeat as above... for idx in range(int(sum_activations_for_category)): if activations_for_category[idx] != 1: # Convert to correct categorical/ordinal activations so we can # compute the distance using already written function. # Find the max index of 1 in the array, and set everything before that to 1 # print('not plausible, fixing..', end='') # max_index_of_1 = np.where(np.array(activations_for_category) == 1)[0][-1] # for idx2 in range(max_index_of_1 + 1): # counterfactual_sample[siblings_kurz[idx2]] = 1 # break distance = -1 counterfactual_plausible = False # return counterfactual_sample, distance else: raise Exception( f'{attr_name_kurz} must include either `cat` or `ord`.') already_considered.extend(siblings_kurz) # TODO: convert to correct categorical/ordinal activations so we can compute # distance = output['cost'] # TODO: this must change / be verified!??? distance = normalizedDistance.getDistanceBetweenSamples( factual_sample, counterfactual_sample, norm_type, dataset_obj) # # TODO: post-feasibible needed???? NO # # make plausible by rounding all non-numeric-real attributes to # # nearest value in range # for idx, elem in enumerate(es_instance): # attr_name_kurz = dataset_obj.getInputAttributeNames('kurz')[idx] # attr_obj = dataset_obj.attributes_kurz[attr_name_kurz] # if attr_obj.attr_type != 'numeric-real': # # round() might give a value that is NOT in plausible. # # instead find the nearest plausible value # es_instance[idx] = min( # list(range(int(attr_obj.lower_bound), int(attr_obj.upper_bound) + 1)), # key = lambda x : abs(x - es_instance[idx]) # ) end_time = time.time() return { 'factual_sample': factual_sample, 'cfe_sample': counterfactual_sample, 'cfe_found': True, # TODO? 'cfe_plausible': counterfactual_plausible, 'cfe_distance': distance, 'cfe_time': end_time - start_time, }
def genExp(model_trained, factual_sample, class_labels, epsilon, norm_type, dataset_obj, standard_deviations, perform_while_plausibility): """ This function return the active feature tweaking vector. x: feature vector class_labels: list containing the all class labels counterfactual_label: the label which we want to transform the label of x to """ """ initialize """ start_time = time.time() x = np.array(list(factual_sample.values())) # factual_sample['y'] = False counterfactual_label = not factual_sample['y'] # initialize output in case no solution is found closest_counterfactual_sample = dict( zip(factual_sample.keys(), [-1 for elem in factual_sample.values()])) closest_counterfactual_sample['y'] = counterfactual_label counterfactual_found = False closest_distance = 1000 # initialize cost (if no solution is found, this is returned) # We want to support forest and tree, and we keep in mind that a trained # tree does NOT perform the same as a trained forest with 1 tree! if isinstance(model_trained, DecisionTreeClassifier): # ensemble_classifier will in fact not be an ensemble, but only be a tree estimator = model_trained if estimator.predict(x.reshape(1, -1) != counterfactual_label): paths_info = search_path(estimator, class_labels, counterfactual_label) for key in paths_info: """ generate epsilon-satisfactory instance """ path_info = paths_info[key] es_instance = esatisfactory_instance(x, epsilon, path_info, standard_deviations) if perform_while_plausibility: # make plausible by rounding all non-numeric-real attributes to # nearest value in range for idx, elem in enumerate(es_instance): attr_name_kurz = dataset_obj.getInputAttributeNames( 'kurz')[idx] attr_obj = dataset_obj.attributes_kurz[attr_name_kurz] if attr_obj.attr_type != 'numeric-real': # round() might give a value that is NOT in plausible. # instead find the nearest plausible value es_instance[idx] = min( list( range(int(attr_obj.lower_bound), int(attr_obj.upper_bound) + 1)), key=lambda x: abs(x - es_instance[idx])) if estimator.predict(es_instance.reshape( 1, -1)) == counterfactual_label: counterfactual_sample = dict( zip(factual_sample.keys(), es_instance)) counterfactual_sample['y'] = counterfactual_label distance = normalizedDistance.getDistanceBetweenSamples( factual_sample, counterfactual_sample, norm_type, dataset_obj) if distance < closest_distance: closest_counterfactual_sample = counterfactual_sample counterfactual_found = True closest_distance = distance elif isinstance(model_trained, RandomForestClassifier): ensemble_classifier = model_trained for estimator in ensemble_classifier: if (ensemble_classifier.predict(x.reshape( 1, -1)) == estimator.predict(x.reshape(1, -1)) and estimator.predict( x.reshape(1, -1) != counterfactual_label)): paths_info = search_path(estimator, class_labels, counterfactual_label) for key in paths_info: """ generate epsilon-satisfactory instance """ path_info = paths_info[key] es_instance = esatisfactory_instance( x, epsilon, path_info, standard_deviations) if perform_while_plausibility: # make plausible by rounding all non-numeric-real attributes to # nearest value in range for idx, elem in enumerate(es_instance): attr_name_kurz = dataset_obj.getInputAttributeNames( 'kurz')[idx] attr_obj = dataset_obj.attributes_kurz[ attr_name_kurz] if attr_obj.attr_type != 'numeric-real': # round() might give a value that is NOT in plausible. # instead find the nearest plausible value es_instance[idx] = min( list( range(int(attr_obj.lower_bound), int(attr_obj.upper_bound) + 1)), key=lambda x: abs(x - es_instance[idx])) if ensemble_classifier.predict(es_instance.reshape( 1, -1)) == counterfactual_label: counterfactual_sample = dict( zip(factual_sample.keys(), es_instance)) counterfactual_sample['y'] = counterfactual_label distance = normalizedDistance.getDistanceBetweenSamples( factual_sample, counterfactual_sample, norm_type, dataset_obj) if distance < closest_distance: closest_counterfactual_sample = counterfactual_sample counterfactual_found = True closest_distance = distance # better naming counterfactual_sample = closest_counterfactual_sample distance = closest_distance # Perform plausibility check on the nearest counterfactual found counterfactual_plausible = True for attr_name_kurz in dataset_obj.getInputOutputAttributeNames('kurz'): attr_obj = dataset_obj.attributes_kurz[attr_name_kurz] if attr_obj.attr_type != 'numeric-real': try: assert np.isclose( counterfactual_sample[attr_name_kurz], np.round(counterfactual_sample[attr_name_kurz]) ), 'not satisfying plausibility (data-type)' counterfactual_sample[attr_name_kurz] = np.round( counterfactual_sample[attr_name_kurz]) assert counterfactual_sample[ attr_name_kurz] >= attr_obj.lower_bound, 'not satisfying plausibility (data-range)' assert counterfactual_sample[ attr_name_kurz] <= attr_obj.upper_bound, 'not satisfying plausibility (data-range)' except: counterfactual_plausible = False # distance = 1000 # return factual_sample, counterfactual_sample, distance end_time = time.time() return { 'factual_sample': factual_sample, 'cfe_sample': counterfactual_sample, 'cfe_found': counterfactual_found, 'cfe_plausible': counterfactual_plausible, 'cfe_distance': distance, 'cfe_time': end_time - start_time, }
def findClosestCounterfactualSample(model_trained, model_symbols, dataset_obj, factual_sample, norm_type, approach_string, epsilon, log_file): def getCenterNormThresholdInRange(lower_bound, upper_bound): return (lower_bound + upper_bound) / 2 def assertPrediction(dict_sample, model_trained, dataset_obj): vectorized_sample = [] for attr_name_kurz in dataset_obj.getInputAttributeNames('kurz'): vectorized_sample.append(dict_sample[attr_name_kurz]) sklearn_prediction = int(model_trained.predict([vectorized_sample])[0]) pysmt_prediction = int(dict_sample['y']) factual_prediction = int(factual_sample['y']) if isinstance(model_trained, LogisticRegression): if np.dot(model_trained.coef_, vectorized_sample) + model_trained.intercept_ < 1e-10: return assert sklearn_prediction == pysmt_prediction, 'Pysmt prediction does not match sklearn prediction.' assert sklearn_prediction != factual_prediction, 'Counterfactual and factual samples have the same prediction.' # Convert to pysmt_sample so factual symbols can be used in formulae factual_pysmt_sample = getPySMTSampleFromDictSample( factual_sample, dataset_obj) norm_lower_bound = 0 norm_upper_bound = 1 curr_norm_threshold = getCenterNormThresholdInRange( norm_lower_bound, norm_upper_bound) # Get and merge all constraints print( 'Constructing initial formulas: model, counterfactual, distance, plausibility, diversity\t\t', end='', file=log_file) model_formula = getModelFormula(model_symbols, model_trained) counterfactual_formula = getCounterfactualFormula(model_symbols, factual_pysmt_sample) plausibility_formula = getPlausibilityFormula(model_symbols, dataset_obj, factual_pysmt_sample, approach_string) distance_formula = getDistanceFormula(model_symbols, dataset_obj, factual_pysmt_sample, norm_type, approach_string, curr_norm_threshold) diversity_formula = TRUE( ) # simply initialize and modify later as new counterfactuals come in print('done.', file=log_file) iters = 1 max_iters = 100 counterfactuals = [] # list of tuples (samples, distances) # In case no counterfactuals are found (this could happen for a variety of # reasons, perhaps due to non-plausibility), return a template counterfactual counterfactuals.append({ 'counterfactual_sample': {}, 'counterfactual_distance': np.infty, 'interventional_sample': {}, 'interventional_distance': np.infty, 'time': np.infty, 'norm_type': norm_type }) print( 'Solving (not searching) for closest counterfactual using various distance thresholds...', file=log_file) while iters < max_iters and norm_upper_bound - norm_lower_bound >= epsilon: print( f'\tIteration #{iters:03d}: testing norm threshold {curr_norm_threshold:.6f} in range [{norm_lower_bound:.6f}, {norm_upper_bound:.6f}]...\t', end='', file=log_file) iters = iters + 1 formula = And( # works for both initial iteration and all subsequent iterations model_formula, counterfactual_formula, plausibility_formula, distance_formula, diversity_formula, ) solver_name = "z3" with Solver(name=solver_name) as solver: solver.add_assertion(formula) iteration_start_time = time.time() solved = solver.solve() iteration_end_time = time.time() if solved: # joint formula is satisfiable model = solver.get_model() print('solution exists & found.', file=log_file) counterfactual_pysmt_sample = {} interventional_pysmt_sample = {} for (symbol_key, symbol_value) in model: # symbol_key may be 'x#', {'p0#', 'p1#'}, 'w#', or 'y' tmp = str(symbol_key) if 'counterfactual' in str(symbol_key): tmp = tmp[:-15] if tmp in dataset_obj.getInputOutputAttributeNames( 'kurz'): counterfactual_pysmt_sample[tmp] = symbol_value elif 'interventional' in str(symbol_key): tmp = tmp[:-15] if tmp in dataset_obj.getInputOutputAttributeNames( 'kurz'): interventional_pysmt_sample[tmp] = symbol_value elif tmp in dataset_obj.getInputOutputAttributeNames( 'kurz'): # for y variable counterfactual_pysmt_sample[tmp] = symbol_value interventional_pysmt_sample[tmp] = symbol_value # Convert back from pysmt_sample to dict_sample to compute distance and save counterfactual_sample = getDictSampleFromPySMTSample( counterfactual_pysmt_sample, dataset_obj) interventional_sample = getDictSampleFromPySMTSample( interventional_pysmt_sample, dataset_obj) # Assert samples have correct prediction label according to sklearn model assertPrediction(counterfactual_sample, model_trained, dataset_obj) # of course, there is no need to assertPrediction on the interventional_sample counterfactual_distance = normalizedDistance.getDistanceBetweenSamples( factual_sample, counterfactual_sample, norm_type, dataset_obj) interventional_distance = normalizedDistance.getDistanceBetweenSamples( factual_sample, interventional_sample, norm_type, dataset_obj) counterfactual_time = iteration_end_time - iteration_start_time counterfactuals.append({ 'counterfactual_sample': counterfactual_sample, 'counterfactual_distance': counterfactual_distance, 'interventional_sample': interventional_sample, 'interventional_distance': interventional_distance, 'time': counterfactual_time, 'norm_type': norm_type }) # Update diversity and distance formulas now that we have found a solution # TODO: I think the line below should be removed, because in successive # reductions of delta, we should be able to re-use previous CFs # diversity_formula = And(diversity_formula, getDiversityFormulaUpdate(model)) # IMPORTANT: something odd happens somtimes if use vanilla binary search; # On the first iteration, with [0, 1] bounds, we may see a CF at # d = 0.22. When we update the bounds to [0, 0.5] bounds, we # sometimes surprisingly see a new CF at distance 0.24. We optimize # the binary search to solve this. norm_lower_bound = norm_lower_bound # norm_upper_bound = curr_norm_threshold if 'mace' in approach_string: norm_upper_bound = float(counterfactual_distance + epsilon / 100) # not float64 elif 'mint' in approach_string: norm_upper_bound = float(interventional_distance + epsilon / 100) # not float64 curr_norm_threshold = getCenterNormThresholdInRange( norm_lower_bound, norm_upper_bound) distance_formula = getDistanceFormula( model_symbols, dataset_obj, factual_pysmt_sample, norm_type, approach_string, curr_norm_threshold) else: # no solution found in the assigned norm range --> update range and try again with Solver(name=solver_name) as neg_solver: neg_formula = Not(formula) neg_solver.add_assertion(neg_formula) neg_solved = neg_solver.solve() if neg_solved: print('no solution exists.', file=log_file) norm_lower_bound = curr_norm_threshold norm_upper_bound = norm_upper_bound curr_norm_threshold = getCenterNormThresholdInRange( norm_lower_bound, norm_upper_bound) distance_formula = getDistanceFormula( model_symbols, dataset_obj, factual_pysmt_sample, norm_type, approach_string, curr_norm_threshold) else: print('no solution found (SMT issue).', file=log_file) quit() break # IMPORTANT: there may be many more at this same distance! OR NONE! (what?? 2020.02.19) closest_counterfactual_sample = sorted( counterfactuals, key=lambda x: x['counterfactual_distance'])[0] closest_interventional_sample = sorted( counterfactuals, key=lambda x: x['interventional_distance'])[0] return counterfactuals, closest_counterfactual_sample, closest_interventional_sample