Skip to content

xwhy.explainers.tabular

Tabular explainer implementation.

TabularExplainer

Bases: ExplanationPipeline, BaseExplainer

Explainer for Tabular models utilizing the SMILE algorithm.

This explainer preserves exact Wasserstein LIME mechanics while integrating with the broader framework architectures.

Source code in src/xwhy/explainers/tabular.py
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
class TabularExplainer(ExplanationPipeline, BaseExplainer):
    """Explainer for Tabular models utilizing the SMILE algorithm.

    This explainer preserves exact Wasserstein LIME mechanics while
    integrating with the broader framework architectures.
    """

    def __init__(
        self,
        model: Any,  # noqa: ANN401
        config: TabularConfig | None = None,
        mode: str = "classification",
        num_perturbations: int = 500,
        kernel_width: float = 0.2,
        num_distribution_samples: int = 100,
        local_noise: float = 0.05,
        perturbation_noise: float = 0.4,
        epsilon: float = 1.0,
        distance_type: str | DistanceType = DistanceType.WASSERSTEIN,
        surrogate_type: str | SurrogateType = SurrogateType.LIME,
        use_best_surrogate: bool = True,
        seed: int = 42,
        device: str = "cpu",
        validate_normalization: bool = True,
    ) -> None:
        """Initialize the Tabular explainer.

        Args:
            model: Trained black-box model with a `predict` method.
            config: Optional configuration object.
            mode: Task type ("classification" or "regression").
            num_perturbations: Number of LIME samples generated.
            kernel_width: Kernel width used for weighting.
            num_distribution_samples: Samples per feature distribution.
            local_noise: Noise scale for the local instance neighborhood.
            perturbation_noise: Noise scale for perturbation distributions.
            epsilon: Scaling factor applied to the Wasserstein distance.
            distance_type: Distance metric definition.
            surrogate_type: Default surrogate method name.
            use_best_surrogate: Automatically search for the best surrogate.
            seed: Random seed for reproducibility.
            device: Device type name.
            validate_normalization: Whether to warn if the input appears not
                to be normalized.

        Raises:
            ValueError: If the distance type is not valid.

        """
        distance_type = DistanceType.from_str(distance_type)
        surrogate_type = SurrogateType.from_str(surrogate_type)

        if mode not in ("classification", "regression"):
            raise ValueError("mode must be 'classification' or 'regression'.")

        if config is None:
            config = TabularConfig(
                mode=mode,  # type: ignore[arg-type]
                num_perturbations=num_perturbations,
                kernel_width=kernel_width,
                num_distribution_samples=num_distribution_samples,
                local_noise=local_noise,
                perturbation_noise=perturbation_noise,
                epsilon=epsilon,
                distance_type=distance_type,
                surrogate_type=surrogate_type,
                use_best_surrogate=use_best_surrogate,
                seed=seed,
                device=device,
                validate_normalization=validate_normalization,
            )

        if (
            getattr(config, "use_best_surrogate", True)
            or not config.surrogate_type.is_linear_model  # type: ignore[attr-defined]
        ):
            logger.warning(
                "Using a non-linear surrogate model or enabling 'use_best_surrogate' "
                "can replace a black-box model with another complex model, "
                "sacrificing local interpretability. The scientific community highly "
                "recommends utilizing simple linear models (e.g., LIME, OLS) to "
                "guarantee transparent and additive feature attributions."
            )

        super().__init__(config)
        self.state = TabularState()
        self.state.model = TabularModelAdapter(
            model=model, device=getattr(config, "device", "cpu")
        )

        self._rng = np.random.default_rng(self.config.seed)  # type: ignore[union-attr]

    def _generate_instance_distribution(
        self, instance: np.ndarray, num_features: int, noise: float, samples: int
    ) -> np.ndarray:
        """Create local Gaussian distributions for each feature.

        Args:
            instance: Target instance to explain.
            num_features: Number of features.
            noise: Variance parameter for normal distribution.
            samples: Number of observations per distribution.

        Returns:
            np.ndarray: Matrix of the local distribution.

        """
        distribution = np.zeros((samples, num_features))
        for i in range(num_features):
            distribution[:, i] = instance[i] + self._rng.normal(0, noise, samples)
        return distribution

    def run(
        self,
        instance: np.ndarray | Sequence[Any],
        **kwargs: Any,  # noqa: ANN401
    ) -> TabularXWhyResult:
        """Execute the full explanation pipeline for a tabular instance.

        Args:
            instance: Target instance array of shape [n_features].
            **kwargs: Additional pipeline options passed to the explain method.

        Returns:
            TabularXWhyResult: The structured explanation outcome.

        Raises:
            TypeError: If the instance is a string or not array-like.

        """
        if isinstance(instance, str) or not isinstance(
            instance, (np.ndarray, Sequence)
        ):
            raise TypeError(
                "TabularExplainer requires an array-like instance (e.g., numpy "
                "array or list)."
            )

        return self.explain(instance=instance, **kwargs)

    def explain(
        self,
        instance: np.ndarray | Sequence[Any],
        feature_names: Sequence[str] | None = None,
        fidelity_plot: bool = False,
        **kwargs: Any,  # noqa: ANN401
    ) -> TabularXWhyResult:
        """Generate an explanation using the specified distance algorithm.

        Args:
            instance: Target instance array of shape [n_features].
            feature_names: Optional sequence specifying column names.
            fidelity_plot: Rendering fidelity scatter plot.
            **kwargs: Additional parameters.

        Returns:
            TabularXWhyResult: The structured outcome containing weights,
                distances, and surrogate coefficients.

        Raises:
            ValueError: If the instance contains out-of-scale values, indicating
                a lack of standardization.

        """
        cfg: TabularConfig = self.config  # type: ignore[assignment]
        instance_arr = np.asarray(instance, dtype=np.float64)

        if cfg.validate_normalization and np.abs(np.mean(instance_arr)) > 5.0:
            logger.warning(
                "Instance appears not normalized. Ensure you pass standardized data."
            )

        num_features = len(instance_arr)

        # 1. Generate base perturbation samples
        x_matrix = self._rng.normal(0, 1, size=(cfg.num_perturbations, num_features))

        # 2. Local distribution around original instance
        instance_dist = self._generate_instance_distribution(
            instance=instance_arr,
            num_features=num_features,
            noise=cfg.local_noise,
            samples=cfg.num_distribution_samples,
        )

        y_target = np.zeros((cfg.num_perturbations,))
        distances = np.zeros((cfg.num_perturbations,))

        logger.info(f"Computing distances for {cfg.num_perturbations} perturbations...")

        # 3. Main Loop
        for idx, sample in enumerate(x_matrix):
            sample_dist = self._generate_instance_distribution(
                instance=sample,
                num_features=num_features,
                noise=cfg.perturbation_noise,
                samples=cfg.num_distribution_samples,
            )

            preds = self.state.model.predict(sample_dist)  # type: ignore[union-attr]

            if cfg.mode == "classification":
                y_target[idx] = np.bincount(preds.astype(int)).argmax()
            else:
                y_target[idx] = np.mean(preds)

            # ==============================
            # Compute distance (per feature)
            # ==============================
            dist_total = 0.0
            for j in range(num_features):
                dist = calculate_distance(
                    metric=cfg.distance_type,
                    source=instance_dist[:, j],
                    target=sample_dist[:, j],
                )
                dist_total += dist

            distances[idx] = dist_total

        scaled_distances = distances * cfg.epsilon

        # ---------------------------------------------------------
        # Distance Validation & Imputation setup:
        # Convert distances to numpy array and impute non-finite (inf/NaN) values.
        # ---------------------------------------------------------
        logger.info("Validating perturbation distances...")
        distances_raw = np.array(scaled_distances, dtype=float)

        # Filter out non-finite values to determine the maximum valid distance
        valid_distances = distances_raw[np.isfinite(distances_raw)]

        # Calculate max_penalty: max valid distance + 1000, or default 1000 if
        # all failed
        if len(valid_distances) > 0:
            max_penalty = np.max(valid_distances) + 1000.0
        else:
            max_penalty = 1000.0

        # Impute infinite/NaN values with the dynamically calculated maximum penalty
        scaled_distances = np.where(
            np.isfinite(distances_raw), distances_raw, max_penalty
        )

        # 4. Surrogate Training via Framework
        if cfg.use_best_surrogate:
            logger.info("Searching for optimal surrogate model...")
            method, score = SurrogateTrainer.find_best(
                x=x_matrix,
                y=y_target,
                distances=scaled_distances,
                seed=cfg.seed,
                kernel_width=cfg.kernel_width,
                normalize_distances=False,
            )
            logger.info(
                "Optimization complete. Selected surrogate model:"
                " '%s' (Best Score: %.4f)",
                method.value,
                score,
            )
        else:
            method = cfg.surrogate_type  # type: ignore[assignment]
            logger.info("Skipping surrogate search. Using default: '%s'", method.value)

        weights = SurrogateTrainer.compute_weights(
            method=method,
            distances=scaled_distances,
            kernel_width=cfg.kernel_width,
            normalize_distances=False,
        )

        logger.info(f"Training surrogate model ({method.value})...")
        surrogate = SurrogateFactory.create(method=method, seed=cfg.seed)
        surrogate.fit(x_matrix, y_target, weights)

        coeffs = surrogate.coefficients()
        y_pred = surrogate.predict(x_matrix)

        metrics = RegressionMetrics.calculate(
            y_true=y_target,
            y_pred=y_pred,
            weights=weights,
            num_features=len(coeffs),
        )

        if cfg.mode == "classification":
            y_pred = (y_pred < 0.5).astype(int).flatten()
        else:
            y_pred = y_pred.flatten()

        raw_data = {
            "x_matrix": x_matrix,
            "y_target": y_target,
            "y_pred": y_pred,
            "weights": weights,
            "distances": scaled_distances,
            "surrogate_method": method,
        }

        result = TabularXWhyResult(
            coefficients=coeffs,
            metrics=metrics,
            raw_data=raw_data,
            instance=instance_arr,
            feature_list=feature_names or [],
            base_values=0.0,
        )

        if fidelity_plot:
            logger.info("Rendering fidelity plot as requested...")
            result.plot(show=True)

        return result

__init__(model, config=None, mode='classification', num_perturbations=500, kernel_width=0.2, num_distribution_samples=100, local_noise=0.05, perturbation_noise=0.4, epsilon=1.0, distance_type=DistanceType.WASSERSTEIN, surrogate_type=SurrogateType.LIME, use_best_surrogate=True, seed=42, device='cpu', validate_normalization=True)

Initialize the Tabular explainer.

Parameters:

Name Type Description Default
model Any

Trained black-box model with a predict method.

required
config TabularConfig | None

Optional configuration object.

None
mode str

Task type ("classification" or "regression").

'classification'
num_perturbations int

Number of LIME samples generated.

500
kernel_width float

Kernel width used for weighting.

0.2
num_distribution_samples int

Samples per feature distribution.

100
local_noise float

Noise scale for the local instance neighborhood.

0.05
perturbation_noise float

Noise scale for perturbation distributions.

0.4
epsilon float

Scaling factor applied to the Wasserstein distance.

1.0
distance_type str | DistanceType

Distance metric definition.

WASSERSTEIN
surrogate_type str | SurrogateType

Default surrogate method name.

LIME
use_best_surrogate bool

Automatically search for the best surrogate.

True
seed int

Random seed for reproducibility.

42
device str

Device type name.

'cpu'
validate_normalization bool

Whether to warn if the input appears not to be normalized.

True

Raises:

Type Description
ValueError

If the distance type is not valid.

Source code in src/xwhy/explainers/tabular.py
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
def __init__(
    self,
    model: Any,  # noqa: ANN401
    config: TabularConfig | None = None,
    mode: str = "classification",
    num_perturbations: int = 500,
    kernel_width: float = 0.2,
    num_distribution_samples: int = 100,
    local_noise: float = 0.05,
    perturbation_noise: float = 0.4,
    epsilon: float = 1.0,
    distance_type: str | DistanceType = DistanceType.WASSERSTEIN,
    surrogate_type: str | SurrogateType = SurrogateType.LIME,
    use_best_surrogate: bool = True,
    seed: int = 42,
    device: str = "cpu",
    validate_normalization: bool = True,
) -> None:
    """Initialize the Tabular explainer.

    Args:
        model: Trained black-box model with a `predict` method.
        config: Optional configuration object.
        mode: Task type ("classification" or "regression").
        num_perturbations: Number of LIME samples generated.
        kernel_width: Kernel width used for weighting.
        num_distribution_samples: Samples per feature distribution.
        local_noise: Noise scale for the local instance neighborhood.
        perturbation_noise: Noise scale for perturbation distributions.
        epsilon: Scaling factor applied to the Wasserstein distance.
        distance_type: Distance metric definition.
        surrogate_type: Default surrogate method name.
        use_best_surrogate: Automatically search for the best surrogate.
        seed: Random seed for reproducibility.
        device: Device type name.
        validate_normalization: Whether to warn if the input appears not
            to be normalized.

    Raises:
        ValueError: If the distance type is not valid.

    """
    distance_type = DistanceType.from_str(distance_type)
    surrogate_type = SurrogateType.from_str(surrogate_type)

    if mode not in ("classification", "regression"):
        raise ValueError("mode must be 'classification' or 'regression'.")

    if config is None:
        config = TabularConfig(
            mode=mode,  # type: ignore[arg-type]
            num_perturbations=num_perturbations,
            kernel_width=kernel_width,
            num_distribution_samples=num_distribution_samples,
            local_noise=local_noise,
            perturbation_noise=perturbation_noise,
            epsilon=epsilon,
            distance_type=distance_type,
            surrogate_type=surrogate_type,
            use_best_surrogate=use_best_surrogate,
            seed=seed,
            device=device,
            validate_normalization=validate_normalization,
        )

    if (
        getattr(config, "use_best_surrogate", True)
        or not config.surrogate_type.is_linear_model  # type: ignore[attr-defined]
    ):
        logger.warning(
            "Using a non-linear surrogate model or enabling 'use_best_surrogate' "
            "can replace a black-box model with another complex model, "
            "sacrificing local interpretability. The scientific community highly "
            "recommends utilizing simple linear models (e.g., LIME, OLS) to "
            "guarantee transparent and additive feature attributions."
        )

    super().__init__(config)
    self.state = TabularState()
    self.state.model = TabularModelAdapter(
        model=model, device=getattr(config, "device", "cpu")
    )

    self._rng = np.random.default_rng(self.config.seed)  # type: ignore[union-attr]

run(instance, **kwargs)

Execute the full explanation pipeline for a tabular instance.

Parameters:

Name Type Description Default
instance ndarray | Sequence[Any]

Target instance array of shape [n_features].

required
**kwargs Any

Additional pipeline options passed to the explain method.

{}

Returns:

Name Type Description
TabularXWhyResult TabularXWhyResult

The structured explanation outcome.

Raises:

Type Description
TypeError

If the instance is a string or not array-like.

Source code in src/xwhy/explainers/tabular.py
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
def run(
    self,
    instance: np.ndarray | Sequence[Any],
    **kwargs: Any,  # noqa: ANN401
) -> TabularXWhyResult:
    """Execute the full explanation pipeline for a tabular instance.

    Args:
        instance: Target instance array of shape [n_features].
        **kwargs: Additional pipeline options passed to the explain method.

    Returns:
        TabularXWhyResult: The structured explanation outcome.

    Raises:
        TypeError: If the instance is a string or not array-like.

    """
    if isinstance(instance, str) or not isinstance(
        instance, (np.ndarray, Sequence)
    ):
        raise TypeError(
            "TabularExplainer requires an array-like instance (e.g., numpy "
            "array or list)."
        )

    return self.explain(instance=instance, **kwargs)

explain(instance, feature_names=None, fidelity_plot=False, **kwargs)

Generate an explanation using the specified distance algorithm.

Parameters:

Name Type Description Default
instance ndarray | Sequence[Any]

Target instance array of shape [n_features].

required
feature_names Sequence[str] | None

Optional sequence specifying column names.

None
fidelity_plot bool

Rendering fidelity scatter plot.

False
**kwargs Any

Additional parameters.

{}

Returns:

Name Type Description
TabularXWhyResult TabularXWhyResult

The structured outcome containing weights, distances, and surrogate coefficients.

Raises:

Type Description
ValueError

If the instance contains out-of-scale values, indicating a lack of standardization.

Source code in src/xwhy/explainers/tabular.py
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
def explain(
    self,
    instance: np.ndarray | Sequence[Any],
    feature_names: Sequence[str] | None = None,
    fidelity_plot: bool = False,
    **kwargs: Any,  # noqa: ANN401
) -> TabularXWhyResult:
    """Generate an explanation using the specified distance algorithm.

    Args:
        instance: Target instance array of shape [n_features].
        feature_names: Optional sequence specifying column names.
        fidelity_plot: Rendering fidelity scatter plot.
        **kwargs: Additional parameters.

    Returns:
        TabularXWhyResult: The structured outcome containing weights,
            distances, and surrogate coefficients.

    Raises:
        ValueError: If the instance contains out-of-scale values, indicating
            a lack of standardization.

    """
    cfg: TabularConfig = self.config  # type: ignore[assignment]
    instance_arr = np.asarray(instance, dtype=np.float64)

    if cfg.validate_normalization and np.abs(np.mean(instance_arr)) > 5.0:
        logger.warning(
            "Instance appears not normalized. Ensure you pass standardized data."
        )

    num_features = len(instance_arr)

    # 1. Generate base perturbation samples
    x_matrix = self._rng.normal(0, 1, size=(cfg.num_perturbations, num_features))

    # 2. Local distribution around original instance
    instance_dist = self._generate_instance_distribution(
        instance=instance_arr,
        num_features=num_features,
        noise=cfg.local_noise,
        samples=cfg.num_distribution_samples,
    )

    y_target = np.zeros((cfg.num_perturbations,))
    distances = np.zeros((cfg.num_perturbations,))

    logger.info(f"Computing distances for {cfg.num_perturbations} perturbations...")

    # 3. Main Loop
    for idx, sample in enumerate(x_matrix):
        sample_dist = self._generate_instance_distribution(
            instance=sample,
            num_features=num_features,
            noise=cfg.perturbation_noise,
            samples=cfg.num_distribution_samples,
        )

        preds = self.state.model.predict(sample_dist)  # type: ignore[union-attr]

        if cfg.mode == "classification":
            y_target[idx] = np.bincount(preds.astype(int)).argmax()
        else:
            y_target[idx] = np.mean(preds)

        # ==============================
        # Compute distance (per feature)
        # ==============================
        dist_total = 0.0
        for j in range(num_features):
            dist = calculate_distance(
                metric=cfg.distance_type,
                source=instance_dist[:, j],
                target=sample_dist[:, j],
            )
            dist_total += dist

        distances[idx] = dist_total

    scaled_distances = distances * cfg.epsilon

    # ---------------------------------------------------------
    # Distance Validation & Imputation setup:
    # Convert distances to numpy array and impute non-finite (inf/NaN) values.
    # ---------------------------------------------------------
    logger.info("Validating perturbation distances...")
    distances_raw = np.array(scaled_distances, dtype=float)

    # Filter out non-finite values to determine the maximum valid distance
    valid_distances = distances_raw[np.isfinite(distances_raw)]

    # Calculate max_penalty: max valid distance + 1000, or default 1000 if
    # all failed
    if len(valid_distances) > 0:
        max_penalty = np.max(valid_distances) + 1000.0
    else:
        max_penalty = 1000.0

    # Impute infinite/NaN values with the dynamically calculated maximum penalty
    scaled_distances = np.where(
        np.isfinite(distances_raw), distances_raw, max_penalty
    )

    # 4. Surrogate Training via Framework
    if cfg.use_best_surrogate:
        logger.info("Searching for optimal surrogate model...")
        method, score = SurrogateTrainer.find_best(
            x=x_matrix,
            y=y_target,
            distances=scaled_distances,
            seed=cfg.seed,
            kernel_width=cfg.kernel_width,
            normalize_distances=False,
        )
        logger.info(
            "Optimization complete. Selected surrogate model:"
            " '%s' (Best Score: %.4f)",
            method.value,
            score,
        )
    else:
        method = cfg.surrogate_type  # type: ignore[assignment]
        logger.info("Skipping surrogate search. Using default: '%s'", method.value)

    weights = SurrogateTrainer.compute_weights(
        method=method,
        distances=scaled_distances,
        kernel_width=cfg.kernel_width,
        normalize_distances=False,
    )

    logger.info(f"Training surrogate model ({method.value})...")
    surrogate = SurrogateFactory.create(method=method, seed=cfg.seed)
    surrogate.fit(x_matrix, y_target, weights)

    coeffs = surrogate.coefficients()
    y_pred = surrogate.predict(x_matrix)

    metrics = RegressionMetrics.calculate(
        y_true=y_target,
        y_pred=y_pred,
        weights=weights,
        num_features=len(coeffs),
    )

    if cfg.mode == "classification":
        y_pred = (y_pred < 0.5).astype(int).flatten()
    else:
        y_pred = y_pred.flatten()

    raw_data = {
        "x_matrix": x_matrix,
        "y_target": y_target,
        "y_pred": y_pred,
        "weights": weights,
        "distances": scaled_distances,
        "surrogate_method": method,
    }

    result = TabularXWhyResult(
        coefficients=coeffs,
        metrics=metrics,
        raw_data=raw_data,
        instance=instance_arr,
        feature_list=feature_names or [],
        base_values=0.0,
    )

    if fidelity_plot:
        logger.info("Rendering fidelity plot as requested...")
        result.plot(show=True)

    return result