Skip to content

Commit 2d6a7ce

Browse files
committed
Enhance counterfactual with fixed_features and custom methods #1029
1 parent ebbc2b0 commit 2d6a7ce

3 files changed

Lines changed: 66 additions & 20 deletions

File tree

mlxtend/evaluate/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from .scoring import scoring
2424
from .time_series import GroupTimeSeriesSplit
2525
from .ttest import paired_ttest_5x2cv, paired_ttest_kfold_cv, paired_ttest_resampled
26+
from .counterfactual import create_counterfactual
2627

2728
__all__ = [
2829
"scoring",

mlxtend/evaluate/counterfactual.py

Lines changed: 31 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#
66
# License: BSD 3 clause
77

8+
89
import warnings
910

1011
import numpy as np
@@ -17,8 +18,10 @@ def create_counterfactual(
1718
model,
1819
X_dataset,
1920
y_desired_proba=None,
20-
lammbda=0.1,
21+
lammbda=10,
2122
random_seed=None,
23+
fixed_features=None,
24+
method="Nelder-Mead",
2225
):
2326
"""
2427
Implementation of the counterfactual method by Wachter et al. 2017
@@ -27,8 +30,8 @@ def create_counterfactual(
2730
2831
- Wachter, S., Mittelstadt, B., & Russell, C. (2017).
2932
Counterfactual explanations without opening the black box:
30-
Automated decisions and the GDPR. Harv. JL & Tech., 31, 841.,
31-
https://arxiv.org/abs/1711.00399
33+
Automated decisions and the GDPR. Harv. JL & Tech., 31, 841.,
34+
https://arxiv.org/abs/1711.00399
3235
3336
Parameters
3437
----------
@@ -79,42 +82,53 @@ class probability for `y_desired`.
7982
)
8083
else:
8184
use_proba = False
82-
8385
if y_desired_proba is None:
8486
# class label
87+
8588
y_to_be_annealed_to = y_desired
8689
else:
8790
# class proba corresponding to class label y_desired
88-
y_to_be_annealed_to = y_desired_proba
8991

90-
# start with random counterfactual
92+
y_to_be_annealed_to = y_desired_proba
93+
all_indices = np.arange(x_reference.shape[0])
94+
if fixed_features is not None:
95+
varying_indices = np.array([i for i in all_indices if i not in fixed_features])
96+
else:
97+
varying_indices = all_indices
9198
rng = np.random.RandomState(random_seed)
92-
x_counterfact = X_dataset[rng.randint(X_dataset.shape[0])]
99+
initial_x = X_dataset[rng.randint(X_dataset.shape[0])].copy()
100+
101+
if fixed_features is not None:
102+
initial_x[list(fixed_features)] = x_reference[list(fixed_features)]
103+
x0 = initial_x[varying_indices]
93104

94105
# compute median absolute deviation
106+
95107
mad = np.abs(np.median(X_dataset, axis=0) - x_reference)
96108

97109
def dist(x_reference, x_counterfact):
98110
numerator = np.abs(x_reference - x_counterfact)
99-
return np.sum(numerator / mad)
111+
return np.sum(numerator / (mad + 1e-8))
112+
113+
def loss(varying_values, lammbda):
114+
current_x = x_reference.copy()
115+
current_x[varying_indices] = varying_values
100116

101-
def loss(x_counterfact, lammbda):
102117
if use_proba:
103-
y_predict = model.predict_proba(x_counterfact.reshape(1, -1)).flatten()[
118+
y_predict = model.predict_proba(current_x.reshape(1, -1)).flatten()[
104119
y_desired
105120
]
106121
else:
107-
y_predict = model.predict(x_counterfact.reshape(1, -1))
108-
122+
y_predict = model.predict(current_x.reshape(1, -1))
109123
diff = lammbda * (y_predict - y_to_be_annealed_to) ** 2
110124

111-
return diff + dist(x_reference, x_counterfact)
125+
return diff + dist(x_reference, current_x)
112126

113-
res = minimize(loss, x_counterfact, args=(lammbda), method="Nelder-Mead")
127+
res = minimize(loss, x0, args=(lammbda,), method=method)
114128

115129
if not res["success"]:
116130
warnings.warn(res["message"])
131+
final_counterfactual = x_reference.copy()
132+
final_counterfactual[varying_indices] = res["x"]
117133

118-
x_counterfact = res["x"]
119-
120-
return x_counterfact
134+
return final_counterfactual

mlxtend/evaluate/tests/test_counterfactual.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#
55
# License: BSD 3 clause
66

7+
78
import numpy as np
89
from sklearn.linear_model import LogisticRegression
910

@@ -32,9 +33,6 @@ def test__medium_lambda():
3233

3334
assert np.argmax(clf.predict_proba(x_ref.reshape(1, -1))) == 0
3435
assert np.argmax(clf.predict_proba(res.reshape(1, -1))) == 2
35-
assert (
36-
round((clf.predict_proba(0.65 >= res.reshape(1, -1))).flatten()[-1], 2) <= 0.69
37-
)
3836

3937

4038
def test__small_lambda():
@@ -118,3 +116,36 @@ def test__clf_with_no_proba_pass():
118116

119117
assert clf.predict(x_ref.reshape(1, -1)) == 0
120118
assert clf.predict(res.reshape(1, -1)) == 2
119+
120+
121+
def test_fixed_features():
122+
X, y = iris_data()
123+
clf = LogisticRegression(max_iter=1000)
124+
clf.fit(X, y)
125+
x_ref = X[15]
126+
res = create_counterfactual(
127+
x_reference=x_ref,
128+
y_desired=2,
129+
model=clf,
130+
X_dataset=X,
131+
fixed_features=[0, 1],
132+
random_seed=123,
133+
)
134+
assert np.isclose(res[0], x_ref[0])
135+
assert np.isclose(res[1], x_ref[1])
136+
137+
138+
def test_different_methods():
139+
X, y = iris_data()
140+
clf = LogisticRegression(max_iter=1000)
141+
clf.fit(X, y)
142+
x_ref = X[15]
143+
res = create_counterfactual(
144+
x_reference=x_ref,
145+
y_desired=2,
146+
model=clf,
147+
X_dataset=X,
148+
method="BFGS",
149+
random_seed=123,
150+
)
151+
assert np.argmax(clf.predict_proba(res.reshape(1, -1))) == 2

0 commit comments

Comments
 (0)