4  Exercise: explaining breast-cancer predictions

4.1 Data and setup

The Breast Cancer Wisconsin Diagnostic dataset contains 569 observations and 30 continuous features computed from digitized images of fine-needle aspirates of breast masses. Ten nuclear properties, including radius, texture, area, smoothness, and concavity, are summarized by their mean, standard error, and a worst value. The outcome is malignant or benign.

The local file contains a fixed training and test split, so R and Python use the same observations. Download breast_cancer_wisconsin.csv and exercise_cases.csv when working elsewhere.

Wolberg, W., Mangasarian, O., Street, N., and Street, W. (1993). Breast Cancer Wisconsin (Diagnostic). UCI Machine Learning Repository. doi:10.24432/C5DW2B. It is distributed under the CC BY 4.0 license.

library(xgboost)
library(lime)
library(ggplot2)

bc <- read.csv("data/breast_cancer_wisconsin.csv")
case_ids <- read.csv("data/exercise_cases.csv")
feature_cols <- setdiff(names(bc), c("sample_id", "malignant", "split"))

train <- bc[bc$split == "train", ]
test <- bc[bc$split == "test", ]
cases <- merge(test, case_ids, by = "sample_id")
cases <- cases[match(case_ids$sample_id, cases$sample_id), ]

cat(nrow(bc), "observations and", length(feature_cols), "features\n")
table(bc$malignant)
569 observations and 30 features

  0   1
357 212
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import xgboost as xgb

from lime.lime_tabular import LimeTabularExplainer
from sklearn.metrics import accuracy_score, confusion_matrix, roc_auc_score

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

train = bc[bc["split"] == "train"].copy()
test = bc[bc["split"] == "test"].copy()
cases = case_ids.merge(test, on="sample_id", how="left")

print(len(bc), "observations and", len(feature_cols), "features")
print(bc["malignant"].value_counts().sort_index())
569 observations and 30 features
malignant
0    357
1    212

4.2 Exercise 1. Inspect the measurements

Summarize the outcome and predictors. Check for missing values and calculate the feature correlation matrix. Report at least five highly correlated pairs. Explain why radius, perimeter, and area measurements might be correlated and why this matters for a sparse LIME explanation.

Do not remove correlated features automatically. The aim is to recognize them before interpreting the model.

TipTip

Use cor() and which(..., arr.ind = TRUE) in R. In Python, use DataFrame.corr(), keep one triangle with np.triu(), then reshape with stack(). Sort pairs by absolute correlation.

stopifnot(!anyNA(bc[, feature_cols]))
cor_mat <- cor(train[, feature_cols])
cor_mat[lower.tri(cor_mat, diag = TRUE)] <- NA
pair_index <- which(!is.na(cor_mat), arr.ind = TRUE)

cor_pairs <- data.frame(
  feature_1 = rownames(cor_mat)[pair_index[, 1]],
  feature_2 = colnames(cor_mat)[pair_index[, 2]],
  correlation = cor_mat[pair_index]
)
cor_pairs <- cor_pairs[order(abs(cor_pairs$correlation), decreasing = TRUE), ]
head(cor_pairs, 10)
assert not bc[feature_cols].isna().any().any()
cor_mat = train[feature_cols].corr()
upper = np.triu(np.ones(cor_mat.shape, dtype=bool), k=1)
cor_pairs = (
    cor_mat.where(upper).stack().rename("correlation").reset_index()
    .rename(columns={"level_0": "feature_1", "level_1": "feature_2"})
)
cor_pairs["absolute_correlation"] = cor_pairs["correlation"].abs()
cor_pairs.sort_values("absolute_correlation", ascending=False).head(10)
Feature 1 Feature 2 Training correlation
mean_radius mean_perimeter 0.998
worst_radius worst_perimeter 0.994
mean_radius mean_area 0.987
mean_perimeter mean_area 0.986
worst_radius worst_area 0.984
A correlation heatmap shows strong blocks among radius, perimeter, area, and related nuclear measurements.
Figure 4.1: Correlations among ten features whose strongest training-set partner has a particularly large absolute correlation.

Radius, perimeter, and area all measure nuclear size. Mean and worst summaries of the same property are also related. Sparse selection may choose one member of such a group in one run and another member in a later run. The selected name should not be treated as uniquely responsible.

4.3 Exercise 2. Fit and evaluate a nonlinear classifier

Fit the supplied XGBoost specification using the fixed training rows. On the test rows, calculate AUC, accuracy, and a confusion matrix. Confirm that probability column 1 in Python and the numeric vector returned in R represent malignancy.

Explain why good test performance is necessary before interpretation but does not prove that the model is clinically useful or causally correct.

TipTip

In Python, inspect fit.classes_ before selecting a column from predict_proba(). In R, the binary:logistic objective returns the probability of label 1. Use roc_auc_score() in Python. The solution supplies a small rank-based AUC function for R.

dtrain <- xgb.DMatrix(as.matrix(train[, feature_cols]), label = train$malignant)
fit <- xgb.train(
  params = list(
    objective = "binary:logistic", eval_metric = "logloss",
    max_depth = 3, eta = 0.05, subsample = 0.9,
    colsample_bytree = 0.9, nthread = 1, seed = 2026
  ),
  data = dtrain, nrounds = 300, verbose = 0
)

test_probability <- predict(fit, as.matrix(test[, feature_cols]))
test_class <- as.integer(test_probability >= 0.5)
auc <- function(y, p) {
  r <- rank(p); n1 <- sum(y == 1); n0 <- sum(y == 0)
  (sum(r[y == 1]) - n1 * (n1 + 1) / 2) / (n1 * n0)
}

cat("AUC:", round(auc(test$malignant, test_probability), 4), "\n")
cat("accuracy:", round(mean(test_class == test$malignant), 4), "\n")
table(actual = test$malignant, predicted = test_class)
AUC: 0.9974
accuracy: 0.9609
      predicted
actual  0  1
     0 77  3
     1  2 46
fit = xgb.XGBClassifier(
    n_estimators=300, max_depth=3, learning_rate=0.05,
    subsample=0.9, colsample_bytree=0.9,
    eval_metric="logloss", random_state=2026, n_jobs=1,
)
fit.fit(train[feature_cols], train["malignant"])

print("class order:", fit.classes_)
test_probability = fit.predict_proba(test[feature_cols])[:, 1]
test_class = (test_probability >= 0.5).astype(int)
print("AUC:", round(roc_auc_score(test["malignant"], test_probability), 4))
print("accuracy:", round(accuracy_score(test["malignant"], test_class), 4))
print(confusion_matrix(test["malignant"], test_class))
class order: [0 1]
AUC: 0.9977
accuracy: 0.9688
[[77  3]
 [ 1 47]]

The fitted models discriminate these test rows well. This does not establish transportability, calibration, fairness, clinical utility, or causal validity.

4.4 Exercise 3. Explain three contrasting predictions

The saved IDs identify a low-probability observation, a high-probability observation, and one near the fitted decision boundary. Explain the malignant class with five features and 5,000 perturbations. Record the black-box probability, local prediction, local R-squared, selected features, and weights. Decide how strongly each explanation should be trusted.

TipTip

Use lime() and explain(..., labels = "1") in R. Use LimeTabularExplainer() and explain_instance(..., labels=(1,)) in Python. Compare label_prob with model_prediction in R, or predict_proba[1] with local_pred[0] in Python. Fidelity is model_r2 in R and score in Python.

explainer <- lime(train[, feature_cols], fit)
set.seed(2026)
case_lime <- explain(
  cases[, feature_cols], explainer, labels = "1",
  n_features = 5, n_permutations = 5000
)
diagnostics <- unique(case_lime[, c(
  "case", "label_prob", "model_prediction", "model_r2"
)])
diagnostics$absolute_local_error <- abs(
  diagnostics$label_prob - diagnostics$model_prediction
)
diagnostics
suppressWarnings(plot_features(case_lime))
case  label_prob  model_prediction  model_r2  absolute_local_error
1       0.0001         -0.208         0.195          0.208
2       0.5072          0.335         0.192          0.172
3       0.9996          1.067         0.345          0.068

Three R lime plots show feature weights for contrasting breast cancer predictions.

R lime feature plots for the low, high, and near-boundary observations.
explainer = LimeTabularExplainer(
    train[feature_cols].to_numpy(), mode="classification",
    feature_names=feature_cols, class_names=["benign", "malignant"],
    random_state=2026,
)
case_rows = []
for _, row in cases.iterrows():
    ans = explainer.explain_instance(
        row[feature_cols].to_numpy(dtype=float), fit.predict_proba,
        labels=(1,), num_features=5, num_samples=5000,
    )
    black_box = float(ans.predict_proba[1])
    local = float(ans.local_pred[0])
    case_rows.append({
        "case_type": row["case_type"], "black_box": black_box,
        "local_prediction": local,
        "absolute_local_error": abs(black_box - local),
        "local_r2": float(ans.score),
        "features": ans.as_list(label=1),
    })
case_results = pd.DataFrame(case_rows)
print(case_results.drop(columns="features"))
case_type             black_box  local_prediction  absolute_local_error  local_r2
lowest probability      0.0002       0.2160              0.2158           0.2046
highest probability     0.9996       0.9902              0.0094           0.3453
near boundary           0.5095       0.1632              0.3463           0.1786

Three Python LIME bar charts show local weights and fidelity diagnostics for low, high, and near-boundary malignant probabilities.

Python LIME feature plots for the same three observations. Each panel reports the black-box probability, local prediction, and local R-squared.

The high-probability explanation reproduces its target point closely, although its neighborhood-wide R-squared is modest. The near-boundary surrogate misses its black-box probability and has low fidelity. Its coefficients should not be interpreted strongly.

4.5 Exercise 4. Measure sampling stability

Repeat the near-boundary explanation for ten seeds using 1,000 perturbations. Record seed, selected feature, weight, local R-squared, and local prediction. Repeat with 10,000 perturbations and compare selection frequencies.

TipTip

Place the complete explanation call inside the seed loop. In R call set.seed(seed) immediately before explain(). In Python create a new LimeTabularExplainer(random_state=seed) on each iteration. Use table() or pd.crosstab() to count selection.

boundary_case <- cases[cases$case_type == "near boundary", feature_cols, drop = FALSE]
repeat_r_lime <- function(n_permutations) {
  do.call(rbind, lapply(1:10, function(seed) {
    set.seed(seed)
    ans <- explain(
      boundary_case, explainer, labels = "1",
      n_features = 5, n_permutations = n_permutations
    )
    transform(
      ans[, c("feature", "feature_weight", "model_r2", "model_prediction")],
      seed = seed, n_permutations = n_permutations
    )
  }))
}
r_stability <- rbind(repeat_r_lime(1000), repeat_r_lime(10000))
as.data.frame(table(r_stability$n_permutations, r_stability$feature))
boundary_case = cases.loc[cases["case_type"] == "near boundary", feature_cols].iloc[0]
def repeat_python_lime(num_samples):
    records = []
    for seed in range(1, 11):
        local_explainer = LimeTabularExplainer(
            train[feature_cols].to_numpy(), mode="classification",
            feature_names=feature_cols, class_names=["benign", "malignant"],
            random_state=seed,
        )
        ans = local_explainer.explain_instance(
            boundary_case.to_numpy(dtype=float), fit.predict_proba,
            labels=(1,), num_features=5, num_samples=num_samples,
        )
        records.extend({
            "seed": seed, "num_samples": num_samples,
            "feature": feature, "weight": weight,
            "local_r2": ans.score,
            "local_prediction": float(ans.local_pred[0]),
        } for feature, weight in ans.as_list(1))
    return pd.DataFrame(records)

py_stability = pd.concat([
    repeat_python_lime(1000), repeat_python_lime(10000)
], ignore_index=True)
pd.crosstab(py_stability["feature"], py_stability["num_samples"])
Python feature condition 1,000 10,000
area_error > 44.91 10 10
worst_concavity <= 0.11 10 10
worst_concave_points <= 0.06 4 10
worst_smoothness <= 0.12 3 9
83.12 < worst_perimeter <= 97.82 5 7
compactness_error <= 0.01 6 1

The larger sample stabilizes some selections but does not make every feature unique. A fixed seed reproduces one run and conceals this variation.

4.6 Exercise 5. Change complexity and locality

Request 3, 5, and 10 explanatory features for the near-boundary observation. Then change one locality setting. In Python, compare half the default kernel width, the default, and twice the default. In R, compare several gower_pow values.

TipTip

Keep the seed and perturbation count fixed so only one choice changes. Extract one copy of model_r2 and model_prediction from each R result. In Python record score and local_pred[0].

r_complexity <- do.call(rbind, lapply(c(3, 5, 10), function(k) {
  set.seed(2026)
  ans <- explain(
    boundary_case, explainer, labels = "1",
    n_features = k, n_permutations = 5000
  )
  data.frame(
    n_features = k, model_r2 = unique(ans$model_r2),
    local_prediction = unique(ans$model_prediction)
  )
}))
r_complexity

r_locality <- do.call(rbind, lapply(c(0.5, 1, 2), function(power) {
  set.seed(2026)
  ans <- explain(
    boundary_case, explainer, labels = "1",
    n_features = 5, n_permutations = 5000,
    dist_fun = "gower", gower_pow = power
  )
  data.frame(gower_pow = power, model_r2 = unique(ans$model_r2))
}))
r_locality
n_features  model_r2  local_prediction
3             0.140       0.304
5             0.170       0.304
10            0.217       0.342

gower_pow  model_r2
0.5          0.171
1.0          0.170
2.0          0.170
complexity = []
for k in (3, 5, 10):
    local_explainer = LimeTabularExplainer(
        train[feature_cols].to_numpy(), mode="classification",
        feature_names=feature_cols, class_names=["benign", "malignant"],
        random_state=2026,
    )
    ans = local_explainer.explain_instance(
        boundary_case.to_numpy(dtype=float), fit.predict_proba,
        labels=(1,), num_features=k, num_samples=5000,
    )
    complexity.append({"n_features": k, "local_r2": ans.score,
                       "local_prediction": float(ans.local_pred[0])})

default_width = 0.75 * np.sqrt(len(feature_cols))
locality = []
for width in (default_width / 2, default_width, default_width * 2):
    local_explainer = LimeTabularExplainer(
        train[feature_cols].to_numpy(), mode="classification",
        feature_names=feature_cols, class_names=["benign", "malignant"],
        kernel_width=width, random_state=2026,
    )
    ans = local_explainer.explain_instance(
        boundary_case.to_numpy(dtype=float), fit.predict_proba,
        labels=(1,), num_features=5, num_samples=5000,
    )
    locality.append({"kernel_width": width, "local_r2": ans.score,
                     "local_prediction": float(ans.local_pred[0])})
pd.DataFrame(complexity), pd.DataFrame(locality)
n_features  local_r2  local_prediction
3             0.171       0.304
5             0.202       0.175
10            0.247       0.333

kernel_width  local_r2  local_prediction
2.054           0.215       0.305
4.108           0.202       0.175
8.216           0.198       0.171

Ten features improve R-squared but still leave a poor approximation. The local prediction is sensitive to feature count and kernel width. A longer explanation is not automatically adequate.

4.7 Exercise 6. Correlation, plausibility, and interpretation

For every feature appearing in the repeated explanations, identify its strongest correlated partner. Inspect at least two pairs with scatterplots. Explain how independent marginal perturbation can create combinations away from the observed cloud. Suggest one additional plausibility diagnostic in 30 dimensions.

Finish with two statements: what the LIME analysis supports about this fitted model, and what it does not support about breast-cancer biology or patient care.

TipTip

Drop each feature’s self-correlation and use which.max(abs(values)) in R or abs().idxmax() in Python. One high-dimensional diagnostic compares each artificial row’s nearest-training-row distance with nearest-neighbor distances among real training rows.

selected_features <- unique(r_stability$feature)
strongest_partner <- do.call(rbind, lapply(selected_features, function(feature) {
  values <- cor(train[, feature_cols])[, feature]
  values <- values[names(values) != feature]
  partner <- names(which.max(abs(values)))
  data.frame(feature = feature, partner = partner, correlation = values[partner])
}))
strongest_partner

plot(train$mean_radius, train$mean_area,
     xlab = "mean radius", ylab = "mean area")
plot(train$worst_concavity, train$worst_concave_points,
     xlab = "worst concavity", ylab = "worst concave points")
cor_mat = train[feature_cols].corr()
partner_rows = []
for feature in feature_cols:
    values = cor_mat[feature].drop(feature)
    partner = values.abs().idxmax()
    partner_rows.append({
        "feature": feature, "partner": partner,
        "correlation": values[partner],
    })
partners = pd.DataFrame(partner_rows)

fig, axes = plt.subplots(1, 2, figsize=(10, 4))
axes[0].scatter(train["mean_radius"], train["mean_area"], s=15, alpha=0.4)
axes[0].set(xlabel="mean radius", ylabel="mean area")
axes[1].scatter(
    train["worst_concavity"], train["worst_concave_points"],
    s=15, alpha=0.4,
)
axes[1].set(xlabel="worst concavity", ylabel="worst concave points")
plt.tight_layout()
plt.show()
Selected feature Strongest partner Correlation
area_error radius_error 0.955
worst_perimeter worst_radius 0.994
worst_concavity worst_compactness 0.895
worst_concave_points mean_concave_points 0.909
worst_smoothness mean_smoothness 0.816

Independent marginal sampling can preserve each feature’s range while breaking their joint relationship. Nearest-neighbor distances can detect artificial rows far from every real training row.

A defensible conclusion is: under this LIME representation and neighborhood, the fitted XGBoost model locally relied on the listed size and shape measurements when producing these predictions. It does not follow that changing those measurements would change malignancy, that one selected measurement is uniquely important among correlated partners, or that the model is appropriate for clinical decisions.