4  Exercises

4.1 Data and setup

We use the Breast Cancer Wisconsin (Diagnostic) dataset. It contains 569 observations and 30 measurements taken from images of breast-mass samples. The outcome is malignant: 1 means malignant and 0 means benign.

The feature names follow a pattern. Ten properties, such as radius, texture, area, and concavity, are each summarized by their mean, standard error, and a “worst” value (the mean of the three largest measurements). Names ending in _error, such as area_error, refer to a standard error; they are not data-quality warnings. Some columns measure almost the same thing, so correlation will matter when we interpret the model.

Download these files and place them in a data folder beside your script or notebook:

The saved row positions make the examples reproducible in both languages. They also prevent us from accidentally using the first 100 training rows, which are not representative of the full training set. The files use R-style positions starting at 1, which is why the Python code subtracts 1.

import numpy as np
import pandas as pd
import xgboost as xgb
import shap

from sklearn.metrics import accuracy_score, confusion_matrix, roc_auc_score

bc = pd.read_csv("data/breast_cancer.csv")
feature_cols = [c for c in bc.columns if c not in ("malignant", "split")]

train = bc[bc["split"] == "train"].reset_index(drop=True)
test = bc[bc["split"] == "test"].reset_index(drop=True)

print(f"{len(bc)} observations, {len(feature_cols)} features")
print(f"{len(train)} training rows, {len(test)} test rows")
569 observations, 30 features
441 training rows, 128 test rows
library(xgboost)
library(kernelshap)
library(shapviz)

bc <- read.csv("data/breast_cancer.csv")
feature_cols <- setdiff(names(bc), c("malignant", "split"))

train <- bc[bc$split == "train", ]
test <- bc[bc$split == "test", ]

cat(nrow(bc), "observations,", length(feature_cols), "features\n")
cat(nrow(train), "training rows,", nrow(test), "test rows\n")
569 observations, 30 features
441 training rows, 128 test rows

4.2 1. Check the model before explaining it

Fit an XGBoost classifier using all 30 features. On the test set, calculate the AUC, accuracy, and confusion matrix.

The model below uses shallow trees and a modest learning rate. With only 441 training observations, a much more flexible model could fit the training data well without generalizing well.

clf = xgb.XGBClassifier(
    n_estimators=300,
    max_depth=3,
    learning_rate=0.05,
    random_state=0,
    eval_metric="logloss",
)
clf.fit(train[feature_cols], train["malignant"])

proba = clf.predict_proba(test[feature_cols])[:, 1]
pred_label = (proba >= 0.5).astype(int)

print("test AUC:", round(roc_auc_score(test["malignant"], proba), 4))
print("test accuracy:", round(accuracy_score(test["malignant"], pred_label), 4))
print("rows are actual; columns are predicted")
print(confusion_matrix(test["malignant"], pred_label))
test AUC: 0.9898
test accuracy: 0.9453
rows are actual; columns are predicted
[[76  4]
 [ 3 45]]
dtrain <- xgb.DMatrix(
  data = as.matrix(train[, feature_cols]),
  label = train$malignant
)

fit_clf <- xgb.train(
  params = list(
    max_depth = 3,
    eta = 0.05,
    objective = "binary:logistic",
    eval_metric = "logloss"
  ),
  data = dtrain,
  nrounds = 300
)

proba <- predict(fit_clf, as.matrix(test[, feature_cols]))
pred_label <- as.integer(proba >= 0.5)

auc <- function(y, p) {
  ranks <- rank(p)
  n1 <- sum(y == 1)
  n0 <- sum(y == 0)
  (sum(ranks[y == 1]) - n1 * (n1 + 1) / 2) / (n1 * n0)
}

cat("test AUC:", round(auc(test$malignant, proba), 4), "\n")
cat("test accuracy:", round(mean(pred_label == test$malignant), 4), "\n")
print(table(actual = test$malignant, predicted = pred_label))
test AUC: 0.9898
test accuracy: 0.9453
      predicted
actual  0  1
     0 76  4
     1  3 45

The model separates the two classes well on this particular test set: its AUC is 0.99, and it misclassifies 7 of 128 observations. That makes the model worth examining, but it does not make it clinically validated. SHAP will explain this fitted model; it will not provide another test of the model’s quality.

4.3 2. Explain two contrasting predictions

Compute probability-scale SHAP values using the saved general background. First check additivity: the baseline plus one row’s 30 SHAP values should recover its predicted probability. Then compare the test observations with the highest and lowest predicted probabilities.

TipReading the numbers

On the probability scale, a SHAP value of +0.10 adds about 0.10 to the model’s predicted probability relative to the background. It is not a statement that the feature increases a patient’s medical risk by 10 percent.

NoteWhy the R and Python code differs

Python uses TreeExplainer, a fast method designed for tree models. Native XGBoost SHAP in R reports values on the raw-margin scale, so the R solution uses the model-agnostic kernelshap() function to stay on the probability scale. It may take a few minutes and, with 30 features, uses an approximation. Small differences between the R and Python values are therefore expected; both solutions use the same background and explain the same output scale.

bg_idx = pd.read_csv("data/bg_general_idx.csv")["idx"].to_numpy() - 1
background = train[feature_cols].iloc[bg_idx]

explainer = shap.TreeExplainer(
    clf,
    data=background,
    model_output="probability",
)
explanation = explainer(test[feature_cols])

reconstructed = explanation.base_values + explanation.values.sum(axis=1)
print("SHAP matrix:", explanation.values.shape)
print("baseline:", round(float(explanation.base_values[0]), 4))
print("largest additivity error:", np.max(np.abs(reconstructed - proba)))

row_high = int(np.argmax(proba))
row_low = int(np.argmin(proba))

for label, row in [("high-probability", row_high), ("low-probability", row_low)]:
    print(f"\n{label} example: probability = {proba[row]:.4f}")
    top = np.argsort(-np.abs(explanation.values[row]))[:5]
    for i in top:
        print(
            f"  {feature_cols[i]}: value={test.loc[row, feature_cols[i]]:.3f}, "
            f"SHAP={explanation.values[row, i]:+.4f}"
        )

shap.plots.waterfall(explanation[row_high])
shap.plots.waterfall(explanation[row_low])
SHAP matrix: (128, 30)
baseline: 0.3984
largest additivity error: 1.0446954856746515e-07

high-probability example: probability = 0.9997
  area_error: value=199.700, SHAP=+0.1097
  worst_area: value=3432.000, SHAP=+0.0765
  worst_concavity: value=0.344, SHAP=+0.0695
  worst_texture: value=47.160, SHAP=+0.0644
  mean_concave_points: value=0.086, SHAP=+0.0642

low-probability example: probability = 0.0002
  area_error: value=22.930, SHAP=-0.0560
  mean_concave_points: value=0.015, SHAP=-0.0546
  worst_concave_points: value=0.055, SHAP=-0.0496
  worst_concavity: value=0.123, SHAP=-0.0476
  worst_area: value=489.800, SHAP=-0.0435
pred_fun_prob <- function(model, X) {
  predict(model, xgboost::xgb.DMatrix(data.matrix(X)))
}

bg_idx <- read.csv("data/bg_general_idx.csv")$idx
background <- train[bg_idx, feature_cols]

set.seed(0)
ks <- kernelshap(
  fit_clf,
  X = test[, feature_cols],
  bg_X = background,
  pred_fun = pred_fun_prob,
  verbose = FALSE
)
shp <- shapviz(ks)

reconstructed <- shp$baseline + rowSums(shp$S)
cat("SHAP matrix:", dim(shp$S), "\n")
cat("baseline:", round(shp$baseline, 4), "\n")
cat("largest additivity error:", max(abs(reconstructed - proba)), "\n")

row_high <- which.max(proba)
row_low <- which.min(proba)

for (label in c("high-probability", "low-probability")) {
  row <- if (label == "high-probability") row_high else row_low
  s <- shp$S[row, ]
  top <- order(abs(s), decreasing = TRUE)[1:5]
  cat(sprintf("\n%s example: probability = %.4f\n", label, proba[row]))
  for (name in names(s)[top]) {
    cat(sprintf(
      "  %s: value=%.3f, SHAP=%+.4f\n",
      name, test[row, name], s[name]
    ))
  }
}

sv_waterfall(shp, row_id = row_high)
sv_waterfall(shp, row_id = row_low)
SHAP matrix: 128 30
baseline: 0.3984
largest additivity error: 2.220446e-16

high-probability example: probability = 0.9997
  area_error: value=199.700, SHAP=+0.1358
  worst_texture: value=47.160, SHAP=+0.0684
  mean_concave_points: value=0.086, SHAP=+0.0663
  worst_area: value=3432.000, SHAP=+0.0654
  worst_concavity: value=0.344, SHAP=+0.0622

low-probability example: probability = 0.0002
  area_error: value=22.930, SHAP=-0.0575
  mean_concave_points: value=0.015, SHAP=-0.0558
  worst_concavity: value=0.123, SHAP=-0.0548
  worst_concave_points: value=0.055, SHAP=-0.0443
  worst_area: value=489.800, SHAP=-0.0429

The SHAP matrix has 128 rows, one for each test observation, and 30 columns, one for each feature. Its baseline is 0.398: the model’s average predicted probability over the general background. The tiny residual confirms that the values add up to the prediction. It does not, by itself, tell us whether every estimated contribution is accurate.

The figures below come from the Python solution. The R plots tell the same overall story, although individual bar lengths differ slightly because the explainers use different algorithms.

Waterfall plot starting at a probability baseline of 0.398. Positive contributions from area error, worst area, worst concavity, worst texture, mean concave points, and other features raise the prediction to nearly 1.
Figure 4.1: Waterfall plot for the test observation with the highest predicted probability of malignancy.
Waterfall plot starting at a probability baseline of 0.398. Negative contributions from area error, mean concave points, worst concave points, worst concavity, worst area, and other features lower the prediction to nearly 0.
Figure 4.2: Waterfall plot for the test observation with the lowest predicted probability of malignancy.

A waterfall plot begins at the background prediction, shown at the bottom, and ends at the observation’s prediction, shown at the top. Each bar is one step between those points; its length shows the size of the contribution.

In Figure 4.1, every displayed contribution pushes the probability upward from 0.398 to almost 1. The largest step comes from area_error, whose observed value is 199.7. In Figure 4.2, the same broad group of features pushes in the other direction. Here area_error is 22.93, and its contribution is negative. The model is using similar measurements for both predictions, but their values place the observations on opposite sides of its learned patterns.

4.4 3. Look across the whole test set

Create a beeswarm plot for the test set and a dependence plot for area_error. Use worst_area to color the dependence plot. What do the plots say about direction, spread, and possible thresholds in the fitted model?

mean_abs = np.abs(explanation.values).mean(axis=0)
top = sorted(zip(feature_cols, mean_abs), key=lambda x: -x[1])[:7]
for name, value in top:
    print(f"{name}: {value:.4f}")

shap.plots.beeswarm(explanation)
shap.plots.scatter(
    explanation[:, "area_error"],
    color=explanation[:, "worst_area"],
)
area_error: 0.0863
mean_concave_points: 0.0688
worst_concave_points: 0.0606
worst_concavity: 0.0600
worst_area: 0.0518
worst_texture: 0.0515
worst_perimeter: 0.0452
mean_abs <- colMeans(abs(shp$S))
print(round(sort(mean_abs, decreasing = TRUE)[1:7], 4))

sv_importance(shp, kind = "beeswarm")
sv_dependence(shp, v = "area_error", color_var = "worst_area")
          area_error  mean_concave_points      worst_concavity
              0.0953               0.0681               0.0614
worst_concave_points        worst_texture           worst_area
              0.0591               0.0548               0.0493
     worst_perimeter
              0.0427
Beeswarm plot with features ordered by average absolute contribution. Each dot is one test observation. High feature values are pink, low values are blue, and SHAP values run from negative on the left to positive on the right.
Figure 4.3: Beeswarm plot of probability-scale SHAP values for the test set.

In Figure 4.3, each dot is one test observation. Its horizontal position is the feature’s contribution to the predicted probability: left lowers the prediction, right raises it. Color shows the observed feature value, from low in blue to high in pink. The rows are ordered by average absolute contribution.

The leading rows have a clear color split. High values of area_error, mean_concave_points, and several “worst” measurements tend to push predictions toward malignant; low values tend to push them toward benign. The horizontal spread also matters: area_error can move a prediction much farther than features near the bottom of the plot. This is a summary of model behavior across these 128 observations, not a ranking of biological causes.

The printed value 0.0863 for area_error is its average absolute contribution on the probability scale. It is not an 8.63 percent share of the model’s total importance.

Scatter plot with observed area error on the horizontal axis and its SHAP value on the vertical axis. Contributions are mostly negative at low area error, rise sharply between about 30 and 50, and are positive at higher values. Point color represents worst area.
Figure 4.4: Dependence plot for area_error, colored by worst_area.

In Figure 4.4, each dot is again one observation, but now the axes focus on a single feature. Small area_error values mostly contribute about \(-0.05\) to \(-0.10\). The contribution rises sharply around 30 to 50 and is positive above that range. The shape looks more like a threshold learned by the trees than a straight line.

The pinker points generally appear at larger area_error values because worst_area is related to it. The plot may suggest interaction or shared information, but it does not isolate a biological effect or establish a clinical cutoff.