interpretml / DiCE

Generate Diverse Counterfactual Explanations for any machine learning model.
https://interpretml.github.io/DiCE/
MIT License
1.37k stars 189 forks source link

Both LGBMClassifier & XGBClassifier classifiers vary features they should not when creating counterfactuals. #260

Open hadjipantelis opened 2 years ago

hadjipantelis commented 2 years ago

DiCE seems awesome. Thank you for your work on it!

I am trying to use DiCE with XGBoost/LightGBM but I am getting some unexpected behaviour. First and foremost, DiCE seems to "partially ignore" the list of features to vary. In the example below, generate_counterfactuals consistently changes a features that is not on the list.

# %%
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split

from lightgbm import LGBMClassifier
from xgboost import XGBClassifier

import dice_ml
from dice_ml.utils import helpers  # helper functions
# %%
np.random.seed(3)

X, y = fetch_california_housing(return_X_y=True, as_frame=True)
y = y > np.quantile(y, 0.80)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.1, random_state=0)

train_dataset = X_train.copy()
test_dataset = X_test.copy()
train_dataset['higher_price']  = y_train.copy()
test_dataset['higher_price']  = y_test.copy()

# %%
## Train the classifiers
# LightGBM
clf_lgm = LGBMClassifier(n_estimators=100)
clf_lgm.fit(X_train,y_train)

# XGBoost
clf_xgb = XGBClassifier(n_estimators=100, use_label_encoder=False, eval_metric='logloss')
clf_xgb.fit(X_train,y_train)

# %%
# DiCE starts here  
d = dice_ml.Data(dataframe=train_dataset, continuous_features=list(train_dataset.columns[::-1][1:]), 
                            outcome_name='higher_price')

m_l = dice_ml.Model(model=clf_lgm, backend="sklearn")
m_x = dice_ml.Model(model=clf_xgb, backend="sklearn") 

exp_l = dice_ml.Dice(d, m_l, method="random")
exp_x = dice_ml.Dice(d, m_x, method="random")

# %%
np.random.seed(3)
e1_l = exp_l.generate_counterfactuals(X_test[110:111], total_CFs=4, 
                                  desired_class="opposite", 
                                  features_to_vary=["HouseAge", "AveRooms", "AveBedrms"],
                                  permitted_range={'AveRooms':[3, 8], 'AveBedrms':[3, 8], 'HouseAge':[1, 51]}
                                  )

np.random.seed(3)
e1_x = exp_x.generate_counterfactuals(X_test[110:111], total_CFs=4, 
                                  desired_class="opposite", 
                                  features_to_vary=["HouseAge", "AveRooms", "AveBedrms"],
                                  permitted_range={'AveRooms':[3, 8], 'AveBedrms':[3, 8], 'HouseAge':[1, 51]}
                                  )
# %%
# Latitude is change despite not being in the list of features to vary
e1_l.visualize_as_dataframe(show_only_changes=True)

# %%
# Latitude is change despite not being in the list of features to vary
e1_x.visualize_as_dataframe(show_only_changes=True)

# %%
from importlib.metadata import version
version('lightgbm'), version('xgboost'), version('dice_ml'), version('scikit-learn') 
# ('3.3.2', '1.5.1', '0.7.2', '1.0.1')

I have the suspicion that DiCE does that because they are no easy counterfactuals to find. Thanks again for your work on DiCE and let me know if further clarifications are required.

P.S.0: In both of the examples above, I also find visualize_as_dataframe to consistently fail if we set method='kdtree' or genetic when we instantiate the DiCE class. I am less bothered by that at the moment as random works "fine". I am mentioning it as something else that also fails and maybe is helpful when debugging.

P.S.1: I have noticed similar behaviour (changing features it shouldn't) with RandomForestClassifer too.

amit-sharma commented 2 years ago

thanks for reporting this, @hadjipantelis Let me have a look and try to reproduce this--the correct behavior is to return no CFs in case features_to_vary cannot lead to a CF.

hadjipantelis commented 2 years ago

Thank you for looking this up. For the record, I tried with scikit-learn 0.24.2 in case that was one of the culprits and I got the same behaviour.

hadjipantelis commented 2 years ago

@amit-sharma Hello Amit, is there an update on this please? I tried it with ver 0.8 and the issue still remains.

fabiensatalia commented 8 months ago

Hi, I have a similar issue with a regressor, here is the MWE:

import os
import random
from urllib.request import urlretrieve

import dice_ml
from lightgbm import LGBMRegressor
import numpy as np
import pandas as pd
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

def diabetes_df():
    url = "https://www4.stat.ncsu.edu/~boos/var.select/diabetes.tab.txt"
    # safety measure for MacOS, see
    # https://docs.python.org/3/library/urllib.request.html#module-urllib.request
    os.environ["no_proxy"] = "*"
    file_name, _ = urlretrieve(url)
    df = pd.read_csv(file_name, sep="\t").astype({"SEX": str}).astype({"SEX": "category"})
    return df.sample(200, random_state=1)

def data_and_model(df, numerical, categorical, target_column):
    np.random.seed(1)
    numeric_transformer = Pipeline(steps=[("scaler", StandardScaler())])
    categorical_transformer = Pipeline(steps=[("onehot", OneHotEncoder(handle_unknown="ignore"))])
    transformations = ColumnTransformer(
        transformers=[
            ("num", numeric_transformer, numerical),
            ("cat", categorical_transformer, categorical),
        ]
    )
    #
    X = df.drop(target_column, axis=1)
    y = df[target_column]
    clf = Pipeline(steps=[("preprocessor", transformations), ("regressor", LGBMRegressor())])
    model = clf.fit(X, y)
    return X, y, model

# Data set
df = diabetes_df()
numerical = ["AGE", "BMI", "BP", "S1", "S2", "S3", "S4", "S5", "S6"]
categorical = ["SEX"]
x_train, y_train, model = data_and_model(df, numerical, categorical, "Y")
factuals = x_train[0:2]

seed = 5
random.seed(seed)
np.random.seed(seed)

# Ask for counterfactual explanations
df_for_dice = pd.concat([x_train, y_train], axis=1)
dice_data = dice_ml.Data(dataframe=df_for_dice, continuous_features=numerical, outcome_name="Y")
dice_model = dice_ml.Model(model=model, backend="sklearn", model_type="regressor")
dice_explainer = dice_ml.Dice(dice_data, dice_model, method="genetic")
features_to_vary = ["BMI", "BP", "S1", "S2", "S3", "S4", "S5", "S6"]
explanations = dice_explainer.generate_counterfactuals(
    factuals,
    total_CFs=5,
    desired_range=[60, 90],
    features_to_vary=features_to_vary,
    posthoc_sparsity_algorithm="binary",
)
for example in explanations.cf_examples_list:
    print("+" * 70)
    print(example.test_instance_df)
    print("-" * 70)
    print(example.final_cfs_df)
    print("-" * 70)

Column AGE is changed in the counterfactual explanations for the second factual, even though it is not in features_to_vary