DiCE icon indicating copy to clipboard operation
DiCE copied to clipboard

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

Open hadjipantelis opened this issue 3 years ago • 4 comments
trafficstars

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.

hadjipantelis avatar Jan 16 '22 02:01 hadjipantelis

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.

amit-sharma avatar Jan 26 '22 04:01 amit-sharma

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 avatar Jan 26 '22 13:01 hadjipantelis

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

hadjipantelis avatar Jul 19 '22 02:07 hadjipantelis

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

fabiensatalia avatar Mar 06 '24 10:03 fabiensatalia