Skip to content

Commit 3492bdb

Browse files
jbbqqfclaude
andcommitted
Fix DiceGenetic.compute_proximity_loss for all-categorical datasets
When the dataset has no continuous features, `continuous_feature_indexes` is empty, so `feature_weights` is an empty np.array and the original implementation hit either: * `proximity_loss / sum(feature_weights)` ⇒ ZeroDivisionError / RuntimeWarning + NaN losses (the symptom @kburchfiel reported in #276 with the original quoted snippet), or * `product.reshape(-1, product.shape[-1])` ⇒ ValueError on a 0-sized array, depending on input shape. Both paths poison `compute_loss` with NaN/exceptions and break the genetic search for legitimate all-categorical use cases. Proximity is conceptually undefined when there are no continuous distances to weigh, so short-circuit with a zero loss vector matching the population shape. The categorical penalty in `compute_loss` already accounts for categorical sparsity, so dropping the proximity contribution is the correct semantic — and it matches what users expect when they explicitly set up a categorical-only `dice_ml.Data`. Adds `TestComputeProximityLossNoContinuousFeatures` covering the all-categorical path. The test fails on `origin/main` with the ValueError reshape variant of this bug. Closes #276. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 8a3aea4 commit 3492bdb

2 files changed

Lines changed: 69 additions & 0 deletions

File tree

dice_ml/explainer_interfaces/dice_genetic.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,14 @@ def compute_proximity_loss(self, x_hat_unnormalized, query_instance_normalized):
370370
x_hat = self.data_interface.normalize_data(x_hat_unnormalized)
371371
feature_weights = np.array(
372372
[self.feature_weights_list[0][i] for i in self.data_interface.continuous_feature_indexes])
373+
# When the dataset has no continuous features, feature_weights is an
374+
# empty array and the original `proximity_loss / sum(feature_weights)`
375+
# divided by zero, raising RuntimeWarning + producing NaN losses that
376+
# poison the genetic search. Proximity is conceptually undefined in
377+
# that case (there are no continuous distances to weigh), so return a
378+
# zero loss vector matching the population shape — see issue #276.
379+
if len(feature_weights) == 0:
380+
return np.zeros(x_hat.shape[0])
373381
product = np.multiply(
374382
(abs(x_hat - query_instance_normalized)[:, [self.data_interface.continuous_feature_indexes]]),
375383
feature_weights)

tests/test_dice_interface/test_dice_genetic.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,3 +261,64 @@ def test_maxiter(self, desired_range, sample_custom_query_2, total_CFs, initiali
261261
for cfs_example in ans.cf_examples_list:
262262
for i in cfs_example.final_cfs_df[self.exp.data_interface.outcome_name].values:
263263
assert desired_range[0] <= i <= desired_range[1]
264+
265+
266+
class TestComputeProximityLossNoContinuousFeatures:
267+
"""Regression for issue #276.
268+
269+
`DiceGenetic.compute_proximity_loss` divides by `sum(feature_weights)`,
270+
where `feature_weights` is restricted to *continuous* feature indexes.
271+
For an all-categorical dataset the array is empty and the original
272+
implementation hit RuntimeWarning + NaN losses (or ZeroDivisionError on
273+
older numpy). The genetic search then propagated NaN through `compute_loss`
274+
and produced unusable counterfactuals.
275+
"""
276+
277+
def _make_explainer_categorical_only(self):
278+
import numpy as np
279+
import pandas as pd
280+
from sklearn.compose import ColumnTransformer
281+
from sklearn.pipeline import Pipeline
282+
from sklearn.preprocessing import OneHotEncoder
283+
from sklearn.linear_model import LogisticRegression
284+
285+
rng = np.random.default_rng(0)
286+
df = pd.DataFrame({
287+
"color": rng.choice(["red", "green", "blue"], size=60),
288+
"shape": rng.choice(["circle", "square"], size=60),
289+
"label": rng.integers(0, 2, size=60),
290+
})
291+
cat = ["color", "shape"]
292+
X = df[cat]
293+
y = df["label"]
294+
clf = Pipeline([
295+
("ohe", ColumnTransformer([("o", OneHotEncoder(), cat)])),
296+
("lr", LogisticRegression(max_iter=200)),
297+
]).fit(X, y)
298+
299+
d = dice_ml.Data(dataframe=df, continuous_features=[],
300+
categorical_features=cat, outcome_name="label")
301+
m = dice_ml.Model(model=clf, backend="sklearn")
302+
return dice_ml.Dice(d, m, method="genetic")
303+
304+
def test_compute_proximity_loss_returns_zero_when_no_continuous_features(self):
305+
import numpy as np
306+
exp = self._make_explainer_categorical_only()
307+
# Mirror the setup the explainer would normally do before any
308+
# call to compute_proximity_loss: set continuous_feature_indexes
309+
# (empty here) and populate feature_weights_list. Driving the full
310+
# generate_counterfactuals path would exercise many other
311+
# categorical-only code paths that aren't this bug.
312+
exp.data_interface.continuous_feature_indexes = []
313+
exp.feature_weights_list = [np.ones(len(exp.data_interface.feature_names))]
314+
n_features = len(exp.data_interface.feature_names)
315+
# normalize_data() consumes a 2-D ndarray for non-DataFrame input.
316+
x_hat = np.zeros((4, n_features), dtype=float)
317+
query = np.zeros((1, n_features), dtype=float)
318+
# Must not raise / warn / produce NaN — origin/main produced
319+
# RuntimeWarning: invalid value encountered in scalar divide
320+
# plus NaN losses (or ZeroDivisionError on older numpy).
321+
loss = exp.compute_proximity_loss(x_hat, query)
322+
assert loss.shape == (4,)
323+
assert np.all(loss == 0.0)
324+
assert not np.any(np.isnan(loss))

0 commit comments

Comments
 (0)