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
Beispiel #2
0
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