Stakeholders always ask the same question: “Why did the model make that prediction?” You can email them a static plot, or you can hand them a dashboard where they answer the question themselves. The second option saves everyone time.

We’ll train an XGBoost classifier, compute SHAP values, and wrap everything in a Streamlit app with interactive visualizations. Here’s the full stack working end-to-end in under 100 lines.

Train a Model and Compute SHAP Values

Start with the basics. Train a model on the UCI breast cancer dataset, then generate SHAP values:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import xgboost as xgb
import shap
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split

# Load data
data = load_breast_cancer()
X_train, X_test, y_train, y_test = train_test_split(
    data.data, data.target, test_size=0.2, random_state=42
)

# Train XGBoost
model = xgb.XGBClassifier(
    n_estimators=100,
    max_depth=4,
    learning_rate=0.1,
    use_label_encoder=False,
    eval_metric="logloss",
    random_state=42,
)
model.fit(X_train, y_train)

# Compute SHAP values
explainer = shap.TreeExplainer(model)
shap_values = explainer(X_test)

# Quick summary plot
shap.summary_plot(shap_values, X_test, feature_names=data.feature_names)

TreeExplainer is the right choice for XGBoost, LightGBM, and random forests. It’s exact and fast because it uses the tree structure directly instead of sampling perturbations. For neural networks, you’d reach for DeepExplainer or GradientExplainer instead.

The shap_values object holds everything: base values, SHAP values per feature, and the input data. That single object powers every visualization we’ll build.

Setting Up SHAP With XGBoost

Install the dependencies:

1
pip install shap==0.46.0 xgboost==2.1.3 streamlit==1.41.1 matplotlib==3.9.4 pandas==2.2.3 scikit-learn==1.6.1

Pin your versions. SHAP’s API has changed across releases, and mismatched versions between shap and xgboost cause cryptic segfaults.

One thing worth noting: shap.TreeExplainer returns an Explanation object (not a raw numpy array) when you call explainer(X_test). This object carries .values, .base_values, and .data together. Older tutorials call explainer.shap_values(X_test) which returns a plain array. The newer Explanation object is what the plotting functions expect, so stick with explainer(X_test).

Building the Streamlit Dashboard

Here’s the full dashboard. Save this as app.py:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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
import streamlit as st
import xgboost as xgb
import shap
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_breast_cancer
from sklearn.model_selection import train_test_split

st.set_page_config(page_title="Model Explainability Dashboard", layout="wide")
st.title("Model Explainability Dashboard")


@st.cache_resource
def load_model_and_data():
    data = load_breast_cancer()
    X = pd.DataFrame(data.data, columns=data.feature_names)
    y = data.target
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=42
    )
    model = xgb.XGBClassifier(
        n_estimators=100,
        max_depth=4,
        learning_rate=0.1,
        use_label_encoder=False,
        eval_metric="logloss",
        random_state=42,
    )
    model.fit(X_train, y_train)
    explainer = shap.TreeExplainer(model)
    shap_values = explainer(X_test)
    return model, explainer, X_test, y_test, shap_values, data.feature_names


model, explainer, X_test, y_test, shap_values, feature_names = load_model_and_data()

# Sidebar controls
st.sidebar.header("Controls")
plot_type = st.sidebar.selectbox(
    "Visualization",
    ["Summary Plot", "Bar Plot", "Dependence Plot", "Individual Prediction"],
)

if plot_type == "Summary Plot":
    st.header("SHAP Summary Plot")
    st.write("Each dot is one feature for one sample. Position on X-axis shows impact on prediction.")
    fig, ax = plt.subplots(figsize=(10, 8))
    shap.summary_plot(shap_values, X_test, feature_names=feature_names, show=False)
    st.pyplot(fig)
    plt.clf()

elif plot_type == "Bar Plot":
    st.header("Global Feature Importance")
    st.write("Mean absolute SHAP value per feature, ranked by importance.")
    fig, ax = plt.subplots(figsize=(10, 8))
    shap.plots.bar(shap_values, show=False)
    st.pyplot(fig)
    plt.clf()

elif plot_type == "Dependence Plot":
    st.header("SHAP Dependence Plot")
    feature = st.sidebar.selectbox("Feature", list(feature_names))
    fig, ax = plt.subplots(figsize=(10, 6))
    shap.dependence_plot(
        feature, shap_values.values, X_test,
        feature_names=feature_names, ax=ax, show=False
    )
    st.pyplot(fig)
    plt.clf()

elif plot_type == "Individual Prediction":
    st.header("Individual Prediction Explanation")
    sample_idx = st.sidebar.slider(
        "Sample Index", 0, len(X_test) - 1, 0
    )
    prediction = model.predict(X_test.iloc[[sample_idx]])[0]
    probability = model.predict_proba(X_test.iloc[[sample_idx]])[0]
    actual = y_test.iloc[sample_idx] if hasattr(y_test, "iloc") else y_test[sample_idx]

    col1, col2, col3 = st.columns(3)
    col1.metric("Predicted Class", "Malignant" if prediction == 0 else "Benign")
    col2.metric("Confidence", f"{max(probability):.1%}")
    col3.metric("Actual", "Malignant" if actual == 0 else "Benign")

    # Waterfall plot for this sample
    fig, ax = plt.subplots(figsize=(10, 6))
    shap.plots.waterfall(shap_values[sample_idx], show=False)
    st.pyplot(fig)
    plt.clf()

Run it:

1
streamlit run app.py

The @st.cache_resource decorator is critical. Without it, Streamlit retrains the model and recomputes SHAP values on every interaction. With it, the expensive work happens once and stays in memory.

Why Waterfall Instead of Force Plots

You might have seen shap.force_plot() in older tutorials. It renders as JavaScript/HTML, which doesn’t play well with Streamlit’s rendering pipeline. The waterfall plot gives you the same information (base value, feature contributions, final prediction) in a matplotlib figure that Streamlit handles natively. Use waterfall plots in dashboards. Save force plots for Jupyter notebooks.

Adding Feature Filtering

Stakeholders often care about specific features. Add a multi-select filter:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Add this after the sidebar controls
if plot_type == "Summary Plot":
    selected_features = st.sidebar.multiselect(
        "Filter Features",
        list(feature_names),
        default=list(feature_names[:10]),
    )
    # Filter SHAP values to selected features
    feature_mask = [f in selected_features for f in feature_names]
    filtered_shap = shap_values[:, feature_mask]
    filtered_X = X_test.loc[:, feature_mask]

    fig, ax = plt.subplots(figsize=(10, 8))
    shap.summary_plot(
        filtered_shap, filtered_X,
        feature_names=[f for f in feature_names if f in selected_features],
        show=False,
    )
    st.pyplot(fig)
    plt.clf()

This is the kind of interactivity that makes a dashboard worth building. A static report can’t do this.

Deploying the Dashboard

Streamlit Community Cloud

The fastest path to a shared URL. Create a requirements.txt:

1
2
3
4
5
6
shap==0.46.0
xgboost==2.1.3
streamlit==1.41.1
matplotlib==3.9.4
pandas==2.2.3
scikit-learn==1.6.1

Push to GitHub, connect the repo at share.streamlit.io, pick app.py as the entrypoint, and deploy. Free tier gives you one app.

Docker

For internal deployments where you control the infrastructure:

1
2
3
4
5
6
7
8
9
FROM python:3.11-slim

WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .

EXPOSE 8501
CMD ["streamlit", "run", "app.py", "--server.port=8501", "--server.address=0.0.0.0"]

Build and run:

1
2
docker build -t shap-dashboard .
docker run -p 8501:8501 shap-dashboard

For production, swap out the in-memory model training for loading a serialized model. Save your model with model.save_model("model.json") and load it in the app with model.load_model("model.json"). Same goes for SHAP values: precompute them and store with joblib.dump().

Common Errors and Fixes

TypeError: TreeExplainer does not support model type

You passed a scikit-learn pipeline object instead of the raw estimator. Extract the model first:

1
2
3
4
5
# Wrong
explainer = shap.TreeExplainer(pipeline)

# Right
explainer = shap.TreeExplainer(pipeline.named_steps["classifier"])

AttributeError: 'numpy.ndarray' object has no attribute 'values'

You used explainer.shap_values() (returns numpy array) but passed the result to a plot function that expects an Explanation object. Switch to explainer():

1
2
3
4
5
# Old way (returns numpy array)
shap_values = explainer.shap_values(X_test)

# New way (returns Explanation object)
shap_values = explainer(X_test)

streamlit: Your app is experiencing a memory issue

SHAP values for large datasets eat memory fast. Subsample your test set:

1
2
X_test_sample = X_test.sample(n=500, random_state=42)
shap_values = explainer(X_test_sample)

ValueError: could not broadcast input array from shape...

This usually means your feature names don’t match the data shape. Double-check that X_test has the same columns as what the model was trained on. Pandas DataFrames with named columns prevent this:

1
X_test = pd.DataFrame(X_test_array, columns=feature_names)

Plots not rendering in Streamlit

Always pass show=False to SHAP plot functions. Without it, matplotlib tries to display the plot outside Streamlit’s context and you get a blank space. Also call plt.clf() after st.pyplot() to prevent plot accumulation across reruns.