Source code for machinelens.analyzer.shap_calculator
"""Helper module to compute SHAP values safely and efficiently."""
import logging
from typing import Any, Optional
import numpy as np
import pandas as pd
import shap
from machinelens.core.data_classes.shap import ShapData
logger = logging.getLogger(__name__)
[docs]
def compute_shap_values(
model: Any,
X_train: pd.DataFrame,
X_eval: pd.DataFrame,
is_classification: bool = False,
) -> Optional[ShapData]:
"""Compute SHAP values for a given evaluation set using the training set as background.
Parameters
----------
model : Any
The fitted scikit-learn compatible estimator.
X_train : pd.DataFrame
The training data to use as background.
X_eval : pd.DataFrame
The data to explain.
is_classification : bool
Whether the model is a classifier.
Returns
-------
ShapData or None
The computed SHAP data, or None if computation fails.
"""
try:
# Sample background if it's too large to prevent freezing
background = (
X_train
if len(X_train) <= 100
else shap.sample(X_train, 100, random_state=42)
)
# Cap eval set to max 10,000 for reasonable serialization size
X_eval_capped = X_eval.head(10000)
# Try TreeExplainer first, fallback to Explainer (which uses Permutation or Exact)
try:
explainer = shap.TreeExplainer(model)
# Just test if it works without error
_ = explainer.expected_value
except Exception:
try:
explainer = shap.LinearExplainer(model, background)
except Exception:
predict_fn = (
model.predict_proba
if is_classification and hasattr(model, "predict_proba")
else model.predict
)
explainer = shap.Explainer(predict_fn, background)
shap_values_obj = explainer(X_eval_capped)
base_value = shap_values_obj.base_values
values = shap_values_obj.values
# For multi-class classification, values shape is (n_samples, n_features, n_classes)
# For simplicity in local dashboard plotting, if multi-class, we take the explanation
# for the predicted class or just class 1 for binary.
if is_classification and values.ndim == 3:
# We assume binary classification for now, extracting the positive class (index 1)
# If multi-class, we would need a more complex structure, but let's stick to
# class 1 for binary
if values.shape[2] >= 2:
values = values[:, :, 1]
if isinstance(base_value, np.ndarray) and base_value.ndim > 0:
base_value = (
base_value[:, 1] if base_value.ndim == 2 else base_value[1]
)
elif isinstance(base_value, list):
base_value = base_value[1]
# Ensure base_value is a single scalar or list that can be serialized
if isinstance(base_value, np.ndarray):
base_value = float(np.mean(base_value))
elif isinstance(base_value, list):
base_value = float(base_value[0]) # simplify
if not isinstance(base_value, float):
base_value = float(np.mean(base_value))
mean_abs_shap = np.abs(values).mean(axis=0)
# Store the original index of the eval rows so frontend can map
# original dataset indices → SHAP array positions
eval_index = X_eval_capped.index.tolist()
# Store raw feature values for beeswarm coloring
feature_values = X_eval_capped.values
return ShapData(
feature_names=X_eval_capped.columns.tolist(),
base_value=base_value,
shap_values=values,
mean_abs_shap=mean_abs_shap,
eval_index=eval_index,
feature_values=feature_values,
)
except Exception as exc:
logger.warning("SHAP computation failed: %s", exc)
return None