From 71a612bafee5be3367793681ac7a511651565711 Mon Sep 17 00:00:00 2001 From: Mariam Zakaria <123750992+mariam851@users.noreply.github.com> Date: Mon, 29 Dec 2025 15:57:30 +0200 Subject: [PATCH] Enhance counterfactual with fixed_features and custom methods #1029 --- mlxtend/evaluate/__init__.py | 24 +++++----- mlxtend/evaluate/counterfactual.py | 48 ++++++++++++------- mlxtend/evaluate/tests/test_counterfactual.py | 37 ++++++++++++-- 3 files changed, 77 insertions(+), 32 deletions(-) diff --git a/mlxtend/evaluate/__init__.py b/mlxtend/evaluate/__init__.py index cf177e513..900cce732 100644 --- a/mlxtend/evaluate/__init__.py +++ b/mlxtend/evaluate/__init__.py @@ -26,27 +26,27 @@ __all__ = [ "scoring", + "accuracy_score", "confusion_matrix", + "mcnemar", "mcnemar_table", "mcnemar_tables", - "mcnemar", "lift_score", "bootstrap", - "permutation_test", - "BootstrapOutOfBag", "bootstrap_point632_score", - "cochrans_q", - "paired_ttest_resampled", - "paired_ttest_kfold_cv", + "BootstrapOutOfBag", + "permutation_test", + "combined_ftest_5x2cv", + "ftest", "paired_ttest_5x2cv", + "paired_ttest_kfold_cv", + "paired_ttest_resampled", + "bias_variance_decomp", "feature_importance_permutation", + "cochrans_q", + "GroupTimeSeriesSplit", + "create_counterfactual", "RandomHoldoutSplit", "PredefinedHoldoutSplit", - "ftest", - "combined_ftest_5x2cv", "proportion_difference", - "bias_variance_decomp", - "accuracy_score", - "create_counterfactual", - "GroupTimeSeriesSplit", ] diff --git a/mlxtend/evaluate/counterfactual.py b/mlxtend/evaluate/counterfactual.py index 0e3981e91..7e27b46e4 100644 --- a/mlxtend/evaluate/counterfactual.py +++ b/mlxtend/evaluate/counterfactual.py @@ -5,6 +5,7 @@ # # License: BSD 3 clause + import warnings import numpy as np @@ -17,8 +18,10 @@ def create_counterfactual( model, X_dataset, y_desired_proba=None, - lammbda=0.1, + lammbda=10, random_seed=None, + fixed_features=None, + method="Nelder-Mead", ): """ Implementation of the counterfactual method by Wachter et al. 2017 @@ -27,8 +30,8 @@ def create_counterfactual( - Wachter, S., Mittelstadt, B., & Russell, C. (2017). Counterfactual explanations without opening the black box: - Automated decisions and the GDPR. Harv. JL & Tech., 31, 841., - https://arxiv.org/abs/1711.00399 + Automated decisions and the GDPR. Harv. JL & Tech., 31, 841., + https://arxiv.org/abs/1711.00399 Parameters ---------- @@ -79,42 +82,53 @@ class probability for `y_desired`. ) else: use_proba = False - if y_desired_proba is None: # class label + y_to_be_annealed_to = y_desired else: # class proba corresponding to class label y_desired - y_to_be_annealed_to = y_desired_proba - # start with random counterfactual + y_to_be_annealed_to = y_desired_proba + all_indices = np.arange(x_reference.shape[0]) + if fixed_features is not None: + varying_indices = np.array([i for i in all_indices if i not in fixed_features]) + else: + varying_indices = all_indices rng = np.random.RandomState(random_seed) - x_counterfact = X_dataset[rng.randint(X_dataset.shape[0])] + initial_x = X_dataset[rng.randint(X_dataset.shape[0])].copy() + + if fixed_features is not None: + initial_x[list(fixed_features)] = x_reference[list(fixed_features)] + x0 = initial_x[varying_indices] # compute median absolute deviation + mad = np.abs(np.median(X_dataset, axis=0) - x_reference) def dist(x_reference, x_counterfact): numerator = np.abs(x_reference - x_counterfact) - return np.sum(numerator / mad) + return np.sum(numerator / (mad + 1e-8)) + + def loss(varying_values, lammbda): + current_x = x_reference.copy() + current_x[varying_indices] = varying_values - def loss(x_counterfact, lammbda): if use_proba: - y_predict = model.predict_proba(x_counterfact.reshape(1, -1)).flatten()[ + y_predict = model.predict_proba(current_x.reshape(1, -1)).flatten()[ y_desired ] else: - y_predict = model.predict(x_counterfact.reshape(1, -1)) - + y_predict = model.predict(current_x.reshape(1, -1)) diff = lammbda * (y_predict - y_to_be_annealed_to) ** 2 - return diff + dist(x_reference, x_counterfact) + return diff + dist(x_reference, current_x) - res = minimize(loss, x_counterfact, args=(lammbda), method="Nelder-Mead") + res = minimize(loss, x0, args=(lammbda,), method=method) if not res["success"]: warnings.warn(res["message"]) + final_counterfactual = x_reference.copy() + final_counterfactual[varying_indices] = res["x"] - x_counterfact = res["x"] - - return x_counterfact + return final_counterfactual diff --git a/mlxtend/evaluate/tests/test_counterfactual.py b/mlxtend/evaluate/tests/test_counterfactual.py index 1dec5697a..a90303732 100644 --- a/mlxtend/evaluate/tests/test_counterfactual.py +++ b/mlxtend/evaluate/tests/test_counterfactual.py @@ -4,6 +4,7 @@ # # License: BSD 3 clause + import numpy as np from sklearn.linear_model import LogisticRegression @@ -32,9 +33,6 @@ def test__medium_lambda(): assert np.argmax(clf.predict_proba(x_ref.reshape(1, -1))) == 0 assert np.argmax(clf.predict_proba(res.reshape(1, -1))) == 2 - assert ( - round((clf.predict_proba(0.65 >= res.reshape(1, -1))).flatten()[-1], 2) <= 0.69 - ) def test__small_lambda(): @@ -118,3 +116,36 @@ def test__clf_with_no_proba_pass(): assert clf.predict(x_ref.reshape(1, -1)) == 0 assert clf.predict(res.reshape(1, -1)) == 2 + + +def test_fixed_features(): + X, y = iris_data() + clf = LogisticRegression(max_iter=1000) + clf.fit(X, y) + x_ref = X[15] + res = create_counterfactual( + x_reference=x_ref, + y_desired=2, + model=clf, + X_dataset=X, + fixed_features=[0, 1], + random_seed=123, + ) + assert np.isclose(res[0], x_ref[0]) + assert np.isclose(res[1], x_ref[1]) + + +def test_different_methods(): + X, y = iris_data() + clf = LogisticRegression(max_iter=1000) + clf.fit(X, y) + x_ref = X[15] + res = create_counterfactual( + x_reference=x_ref, + y_desired=2, + model=clf, + X_dataset=X, + method="BFGS", + random_seed=123, + ) + assert np.argmax(clf.predict_proba(res.reshape(1, -1))) == 2