tests/testthat/test_ExplainSupervised.R

# test_ExplainSupervised.R
# ::rtemis::
# 2026- EDG rtemis.org

# `explain()` end to end, per algorithm.
#
# The load-bearing test here is additivity: `sum(phi) + baseline == prediction`
# to numerical tolerance, on the scale the contributions were computed on. It is
# what catches link-function and preprocessing errors, which produce
# plausible-looking but wrong attributions rather than failures. It is asserted
# against the **stored margin**, never against `predict()`, which applies the
# link for classification -- comparing the two is the error being guarded.

set.seed(2026)
.n <- 40L
.explain_dat <- data.frame(
  age = rnorm(.n),
  bmi = rnorm(.n),
  sex = rnorm(.n)
)
.explain_feats <- c("age", "bmi", "sex")
.regression_dat <- within(
  .explain_dat,
  y <- age * 2 - bmi + rnorm(.n, sd = 0.3)
)
.binary_dat <- within(
  .explain_dat,
  y <- factor(ifelse(age + rnorm(.n, sd = 0.5) > 0, "pos", "neg"))
)
.multiclass_dat <- within(
  .explain_dat,
  y <- factor(c("a", "b", "c")[as.integer(cut(age, 3L))])
)

# LightGBM fits set `force_nrounds`, which fixes the number of boosting rounds
# and skips the cross-validated search for it. Every assertion below holds for
# any fitted ensemble. Tests that only read a LightGBM model share one per
# outcome type, fit on first use.
.shared_fit <- function(dat) {
  fit <- NULL
  function() {
    if (is.null(fit)) {
      fit <<- train(
        dat,
        hyperparameters = setup_LightGBM(force_nrounds = 100L),
        verbosity = 0L
      )
    }
    fit
  }
}
.lgb_regression <- .shared_fit(.regression_dat)
.lgb_binary <- .shared_fit(.binary_dat)
.lgb_multiclass <- .shared_fit(.multiclass_dat)

# Every entry of `phi` must reconstruct its own column of `predicted`. The
# baseline is per case, so it is indexed by column rather than by name -- for
# almost every estimator its rows are identical, but not for one that routes
# each case to a different sub-model.
expect_additive <- function(x, tolerance = 1e-10) {
  for (k in seq_along(x@phi)) {
    label <- names(x@phi)[[k]]
    expect_equal(
      as.numeric(rowSums(x@phi[[label]]) + x@baseline[, k]),
      as.numeric(x@predicted[, k]),
      tolerance = tolerance,
      info = label
    )
  }
}


# %% LightGBM: regression ----
test_that("explain() decomposes a LightGBM regression exactly", {
  mod <- .lgb_regression()
  x <- explain(mod, .regression_dat[1:3, .explain_feats], verbosity = 0L)
  expect_s7_class(x, SHAP)
  expect_identical(x@algorithm, "LightGBM")
  expect_identical(x@estimator, "TreeSHAP")
  expect_true(x@exact)
  expect_identical(x@space, "input")
  expect_identical(x@feature_names, .explain_feats)
  # Regression has one output and no class to name it after.
  expect_named(x@phi, "outcome")
  expect_additive(x)
  # For regression the margin *is* the prediction, so the two agree here and
  # only here.
  expect_equal(
    as.numeric(x@predicted[, 1L]),
    as.numeric(predict(mod, .regression_dat[1:3, .explain_feats])),
    tolerance = 1e-10
  )
})


# %% KernelSHAP ----
set.seed(4L)
.kernel_n <- 40L
.kernel_dat <- data.frame(
  a = rnorm(.kernel_n),
  b = rnorm(.kernel_n),
  c = rnorm(.kernel_n)
)
.kernel_feats <- c("a", "b", "c")
.kernel_regression <- within(
  .kernel_dat,
  y <- 2 * a - b + 0.5 * c + rnorm(.kernel_n, sd = 0.3)
)


test_that("KernelSHAP agrees with the exact LinearSHAP on the same model", {
  # The check that validates the exact tier against an implementation we did
  # not write. For a linear model the interventional Shapley value has a closed
  # form, and shapr's `independence` approach computes the same quantity by an
  # entirely different route, so the two must agree.
  skip_if_not_installed("shapr")
  mod <- train(
    .kernel_regression,
    hyperparameters = setup_GLM(),
    verbosity = 0L
  )
  newdata <- .kernel_regression[1:3, .kernel_feats]
  background <- .kernel_regression[, .kernel_feats]
  exact <- explain(mod, newdata, background = background, verbosity = 0L)
  kernel <- explain(
    mod,
    newdata,
    background = background,
    config = setup_SHAP(estimator = "kernel"),
    verbosity = 0L
  )
  expect_identical(exact@estimator, "LinearSHAP")
  expect_identical(kernel@estimator, "KernelSHAP")
  expect_identical(exact@perturbation, kernel@perturbation)
  expect_equal(kernel@phi[[1L]], exact@phi[[1L]], tolerance = 1e-6)
  expect_equal(kernel@baseline, exact@baseline, tolerance = 1e-8)
})


# On CRAN, the file ends here: the tests above explain one tree ensemble
# exactly and one model through shapr.
skip_on_cran()


# %% LightRF: regression ----
test_that("explain() decomposes a LightRF regression on the prediction's scale", {
  # LightGBM sums a random forest's trees in raw scores and contributions
  # while predicting their average; the explanation must use the average.
  mod <- train(
    .regression_dat,
    hyperparameters = setup_LightRF(nrounds = 20L, min_data_in_leaf = 3L),
    verbosity = 0L
  )
  x <- explain(mod, .regression_dat[1:3, .explain_feats], verbosity = 0L)
  expect_additive(x)
  expect_equal(
    as.numeric(x@predicted[, 1L]),
    as.numeric(predict(mod, .regression_dat[1:3, .explain_feats])),
    tolerance = 1e-6
  )
})


# %% LightGBM: binary ----
test_that("a binary explanation is on the margin, not the probability", {
  # Correction 6, as a test: `predict.Supervised` normalizes classification to
  # probabilities via `prob_matrix()`, and contributions live on the margin.
  mod <- .lgb_binary()
  x <- explain(mod, .binary_dat[1:3, .explain_feats], verbosity = 0L)
  expect_identical(x@scale, "margin")
  expect_additive(x)
  probability <- predict(mod, .binary_dat[1:3, .explain_feats], verbosity = 0L)
  reconstructed <- rowSums(x@phi[[1L]]) + x@baseline[1L, 1L]
  # The link relates them...
  expect_equal(
    as.numeric(stats::plogis(reconstructed)),
    as.numeric(probability[, "pos"]),
    tolerance = 1e-10
  )
  # ...and without it they are simply different numbers.
  expect_false(
    isTRUE(all.equal(
      as.numeric(reconstructed),
      as.numeric(probability[, "pos"])
    ))
  )
})


test_that("a binary explanation is named for the positive class", {
  # The same rule `prob_matrix()` uses, so a SHAP's classes and a predicted
  # probability matrix's columns agree. The negative class's contributions are
  # the exact negation, so storing them would store one thing twice.
  mod <- .lgb_binary()
  x <- explain(mod, .binary_dat[1:3, .explain_feats], verbosity = 0L)
  expect_length(x@phi, 1L)
  expect_named(x@phi, levels(mod@y_training)[[mod@binclasspos]])
})


# %% LightGBM: multiclass ----
test_that("a multiclass explanation is one matrix per class, each additive", {
  mod <- .lgb_multiclass()
  x <- explain(mod, .multiclass_dat[1:3, .explain_feats], verbosity = 0L)
  expect_named(x@phi, levels(mod@y_training))
  expect_identical(dim(x@predicted), c(3L, 3L))
  expect_additive(x)
})


test_that("regression, binary and multiclass differ only in length", {
  # The structure promise: a consumer written for multiclass handles the others
  # without special-casing.
  models <- list(
    regression = .lgb_regression(),
    binary = .lgb_binary(),
    multiclass = .lgb_multiclass()
  )
  for (name in names(models)) {
    x <- explain(
      models[[name]],
      .explain_dat[1:3, .explain_feats],
      verbosity = 0L
    )
    expect_true(is.list(x@phi), info = name)
    expect_identical(dim(x@phi[[1L]]), c(3L, 3L), info = name)
    expect_identical(ncol(x@predicted), length(x@phi), info = name)
    expect_identical(colnames(x@baseline), names(x@phi), info = name)
    expect_identical(dim(x@baseline), dim(x@predicted), info = name)
  }
})


# %% Factors ----
test_that("a factor predictor gets one contribution, not one per level", {
  set.seed(7L)
  dat <- data.frame(
    age = rnorm(.n),
    dx = factor(sample(c("A", "B", "C"), .n, TRUE))
  )
  dat[["y"]] <- dat[["age"]] * 2 + as.integer(dat[["dx"]]) + rnorm(.n, sd = 0.3)
  mod <- train(
    dat,
    hyperparameters = setup_LightGBM(force_nrounds = 100L),
    verbosity = 0L
  )
  x <- explain(mod, dat[1:3, c("age", "dx")], verbosity = 0L)
  expect_identical(x@feature_names, c("age", "dx"))
  expect_identical(x@space, "input")
  expect_additive(x)
  # LightGBM encodes factors as integers rather than expanding them, so nothing
  # was aggregated and the encoded values would be the same numbers.
  expect_null(x@phi_encoded)
})


# %% Resolution ----
test_that("estimator, value function and scale are resolved and recorded", {
  mod <- .lgb_regression()
  x <- explain(mod, .regression_dat[1:3, .explain_feats], verbosity = 0L)
  # Asked for "auto" and got the concrete estimator; the two vocabularies do
  # not overlap, so the pair is unambiguous.
  expect_identical(x@config@estimator, "auto")
  expect_identical(x@estimator, "TreeSHAP")
  # NULL meant "resolve me", and what it resolved to is on the object rather
  # than left to be inferred from the backend's habits.
  expect_null(x@config@perturbation)
  expect_identical(x@perturbation, "conditional")
  expect_identical(
    x@perturbation,
    explanation_methods("LightGBM")[["perturbation"]]
  )
})


test_that("explain() records what it explained and what it explained against", {
  mod <- .lgb_regression()
  newdata <- .regression_dat[1:3, .explain_feats]
  background <- .regression_dat[, .explain_feats]
  x <- explain(mod, newdata, background = background, verbosity = 0L)
  expect_identical(x@data_fingerprint@hash, data_fingerprint(newdata)@hash)
  expect_identical(
    x@background_fingerprint@hash,
    data_fingerprint(background)@hash
  )
  # No background is a legitimate answer for a path-dependent estimator, which
  # takes its baseline from the model itself.
  expect_null(explain(mod, newdata, verbosity = 0L)@background_fingerprint)
})


# %% Refusals ----
test_that("estimator = 'exact' is refused where there is no exact estimator", {
  # Silently falling back to the kernel estimator would answer a different
  # question than the one asked.
  mod <- train(
    .regression_dat,
    hyperparameters = setup_Ranger(),
    verbosity = 0L
  )
  expect_error(
    explain(
      mod,
      .regression_dat[1:3, .explain_feats],
      config = setup_SHAP(estimator = "exact"),
      verbosity = 0L
    ),
    class = "rtemis_unsupported_error"
  )
})


test_that("an interventional TreeSHAP is refused rather than relabeled", {
  # The booster's contributions are path-dependent. Returning them under an
  # interventional label is exactly the silent mislabeling the perturbation
  # decision exists to prevent.
  mod <- .lgb_regression()
  expect_error(
    explain(
      mod,
      .regression_dat[1:3, .explain_feats],
      config = setup_SHAP(perturbation = "interventional"),
      verbosity = 0L
    ),
    class = "rtemis_unsupported_error"
  )
})


test_that("the probability scale is refused, differently, per outcome type", {
  regression <- .lgb_regression()
  # Not a thing a regression has.
  expect_error(
    explain(
      regression,
      .regression_dat[1:3, .explain_feats],
      config = setup_SHAP(scale = "probability"),
      verbosity = 0L
    ),
    class = "rtemis_value_error"
  )
  classification <- .lgb_binary()
  # A thing a classification has, which is not implemented yet.
  expect_error(
    explain(
      classification,
      .binary_dat[1:3, .explain_feats],
      config = setup_SHAP(scale = "probability"),
      verbosity = 0L
    ),
    class = "rtemis_unsupported_error"
  )
})


test_that("an estimator that is not written yet says so, by algorithm", {
  # Every registered algorithm now has an implemented estimator, so this guards
  # the *next* one rather than a current gap: S7's dispatch error names a
  # backend class the user never chose, and it has to be translated into the
  # algorithm they did. Provoked by swapping in a model class no method covers,
  # which is the only way to reach the branch now.
  mod <- train(.regression_dat, hyperparameters = setup_GLM(), verbosity = 0L)
  mod@model <- structure(list(), class = "not_an_algorithm_rtemis_knows")
  message <- tryCatch(
    explain(
      mod,
      .regression_dat[1:3, .explain_feats],
      background = .regression_dat[, .explain_feats],
      verbosity = 0L
    ),
    error = conditionMessage
  )
  expect_match(message, "GLM")
  expect_match(message, "not implemented")
  # ...and it names the fallback that applies to every algorithm.
  expect_match(message, "kernel")
})


# %% LinearSHAP ----
set.seed(11L)
# Larger than the rest: `cv.glmnet` warns about thin classes when its internal
# folds leave fewer than eight cases of a class, and a multiclass fit here is
# what would trip it.
.linear_n <- 150L
.linear_dat <- data.frame(
  age = rnorm(.linear_n),
  bmi = rnorm(.linear_n),
  dx = factor(sample(c("A", "B", "C"), .linear_n, TRUE))
)
.linear_feats <- c("age", "bmi", "dx")
.linear_regression <- within(
  .linear_dat,
  y <- 2 * age - bmi + as.integer(dx) * 0.5 + rnorm(.linear_n, sd = 0.3)
)
.linear_binary <- within(
  .linear_dat,
  y <- factor(ifelse(age + rnorm(.linear_n, sd = 0.5) > 0, "pos", "neg"))
)
.linear_multiclass <- within(
  .linear_dat,
  y <- factor(c("a", "b", "c")[as.integer(cut(age, 3L))])
)


test_that("LinearSHAP decomposes a GLM exactly, on the link scale", {
  mod <- train(
    .linear_regression,
    hyperparameters = setup_GLM(),
    verbosity = 0L
  )
  x <- explain(
    mod,
    .linear_regression[1:3, .linear_feats],
    background = .linear_regression[, .linear_feats],
    verbosity = 0L
  )
  expect_identical(x@estimator, "LinearSHAP")
  expect_true(x@exact)
  expect_identical(x@perturbation, "interventional")
  expect_additive(x)
})


test_that("a factor gets one contribution, summed over its contrast columns", {
  # `glm()` expands a factor through `model.matrix()` rather than through a
  # Preprocessor, and treatment contrasts give k - 1 columns with the reference
  # level absorbed into the intercept. The `assign` attribute records which
  # column came from which term, so the sum is exact and needs no assumption
  # about the coding.
  mod <- train(
    .linear_regression,
    hyperparameters = setup_GLM(),
    verbosity = 0L
  )
  expect_true(all(c("dxB", "dxC") %in% names(stats::coef(mod@model))))
  x <- explain(
    mod,
    .linear_regression[1:3, .linear_feats],
    background = .linear_regression[, .linear_feats],
    verbosity = 0L
  )
  expect_identical(x@feature_names, .linear_feats)
  expect_identical(x@space, "input")
  expect_additive(x)
})


test_that("LinearSHAP decomposes a binary GLM on the logit, not the probability", {
  mod <- train(.linear_binary, hyperparameters = setup_GLM(), verbosity = 0L)
  x <- explain(
    mod,
    .linear_binary[1:3, .linear_feats],
    background = .linear_binary[, .linear_feats],
    verbosity = 0L
  )
  expect_additive(x)
  probability <- predict(
    mod,
    .linear_binary[1:3, .linear_feats],
    verbosity = 0L
  )
  expect_equal(
    as.numeric(stats::plogis(x@predicted[, 1L])),
    as.numeric(probability[, "pos"]),
    tolerance = 1e-8
  )
})


test_that("LinearSHAP decomposes a glmnet for every outcome type", {
  cases <- list(
    regression = .linear_regression,
    binary = .linear_binary,
    multiclass = .linear_multiclass
  )
  for (name in names(cases)) {
    dat <- cases[[name]]
    mod <- train(dat, hyperparameters = setup_GLMNET(), verbosity = 0L)
    x <- explain(
      mod,
      dat[1:3, .linear_feats],
      background = dat[, .linear_feats],
      verbosity = 0L
    )
    expect_identical(x@estimator, "LinearSHAP", info = name)
    expect_identical(x@feature_names, .linear_feats, info = name)
    expect_additive(x)
  }
})


test_that("LinearSHAP decomposes an NNLS, which has no intercept", {
  dat <- data.frame(age = rnorm(.n), bmi = rnorm(.n))
  dat[["y"]] <- 2 * dat[["age"]] + 0.5 * dat[["bmi"]] + rnorm(.n, sd = 0.3)
  feats <- c("age", "bmi")
  mod <- train(dat, hyperparameters = setup_NNLS(), verbosity = 0L)
  x <- explain(mod, dat[1:3, feats], background = dat[, feats], verbosity = 0L)
  expect_additive(x)
  # With no intercept the baseline is just the fit at the background's mean.
  expect_equal(
    x@baseline[1L, 1L],
    sum(mod@model@coefficients * colMeans(dat[, feats])),
    tolerance = 1e-10
  )
})


test_that("LinearSHAP needs a background, and says which argument", {
  # Unlike a path-dependent estimator, this one has no baseline without data:
  # `E[x_j]` is a property of the background, and a fitted model does not carry
  # the data it was trained on.
  mod <- train(
    .linear_regression,
    hyperparameters = setup_GLM(),
    verbosity = 0L
  )
  expect_error(
    explain(mod, .linear_regression[1:3, .linear_feats], verbosity = 0L),
    "background",
    class = "rtemis_missing_error"
  )
})


test_that("a conditional LinearSHAP is refused rather than mislabeled", {
  # `beta_j * (x_j - E[x_j])` uses the marginal mean and never the joint
  # distribution, so it is the interventional answer whatever it is called.
  mod <- train(
    .linear_regression,
    hyperparameters = setup_GLM(),
    verbosity = 0L
  )
  expect_error(
    explain(
      mod,
      .linear_regression[1:3, .linear_feats],
      background = .linear_regression[, .linear_feats],
      config = setup_SHAP(perturbation = "conditional"),
      verbosity = 0L
    ),
    class = "rtemis_unsupported_error"
  )
})


test_that("coefficients that do not describe the model are caught, not used", {
  # The check that makes reading coefficients safe: a regularization path read
  # at the wrong step, or coefficients in a different order than the design,
  # gives contributions that are internally consistent and describe a different
  # model -- which no additivity check downstream could detect.
  design <- matrix(rnorm(40L), ncol = 2L)
  background <- matrix(rnorm(80L), ncol = 2L)
  beta <- matrix(c(1, -2), ncol = 1L)
  honest <- linear_shap(design, background, beta, 0.5)
  expect_equal(
    as.numeric(honest[["predicted"]]),
    as.numeric(design %*% beta + 0.5),
    tolerance = 1e-12
  )
  expect_error(
    linear_shap(
      design,
      background,
      beta,
      0.5,
      margin = design %*% matrix(c(1, 2), ncol = 1L)
    ),
    "do not describe the fitted model"
  )
})


# %% Probed LinearSHAP ----
test_that("probing recovers an affine map exactly", {
  # A difference of an affine function, not a numerical derivative: there is no
  # step size and no approximation error.
  beta <- c(2, -1, 0.5)
  intercept <- 1.25
  margin_fn <- function(x) {
    matrix(as.numeric(as.matrix(x) %*% beta) + intercept, ncol = 1L)
  }
  map <- probe_linear_map(margin_fn, c(a = 10, b = -3, c = 0.1))
  expect_equal(as.numeric(map[["coefficients"]]), beta, tolerance = 1e-12)
  expect_equal(map[["intercept"]], intercept, tolerance = 1e-12)
})


test_that("probing is taken around the given point, not a transposed one", {
  # `base + diag(p)` recycles column-major and would probe `base[i] + e_i`,
  # which is a different point and wrong wherever `base` is not constant.
  beta <- c(3, -2)
  margin_fn <- function(x) {
    matrix(as.numeric(as.matrix(x) %*% beta), ncol = 1L)
  }
  for (base in list(c(a = 0, b = 0), c(a = 100, b = -50))) {
    map <- probe_linear_map(margin_fn, base)
    expect_equal(as.numeric(map[["coefficients"]]), beta, tolerance = 1e-12)
    expect_equal(map[["intercept"]], 0, tolerance = 1e-9)
  }
})


test_that("a model that is not affine is refused, not linearized", {
  # Probing returns the tangent plane at the base point for anything; the
  # reconstruction check is what stops that being reported as an explanation.
  margin_fn <- function(x) {
    matrix(as.numeric(as.matrix(x)[, 1L])^2, ncol = 1L)
  }
  design <- matrix(rnorm(30L), ncol = 2L, dimnames = list(NULL, c("a", "b")))
  expect_error(
    probed_linear_shap(design, design, margin_fn),
    "do not describe the fitted model"
  )
})


# %% SPLS and LinearSVM ----
set.seed(9L)
.probe_n <- 60L
.probe_dat <- data.frame(
  a = rnorm(.probe_n),
  b = rnorm(.probe_n),
  dx = factor(sample(c("A", "B", "C"), .probe_n, TRUE))
)
.probe_feats <- c("a", "b", "dx")
.probe_regression <- within(
  .probe_dat,
  y <- 2 * a - b + as.integer(dx) * 0.4 + rnorm(.probe_n, sd = 0.3)
)
.probe_binary <- within(
  .probe_dat,
  y <- factor(ifelse(a + rnorm(.probe_n, sd = 0.5) > 0, "pos", "neg"))
)
.probe_multiclass <- within(
  .probe_dat,
  y <- factor(c("a", "b", "c")[as.integer(cut(a, 3L))])
)


test_that("SPLS and LinearSVM decompose exactly, through their own one-hot", {
  # These build `setup_Preprocessor(one_hot = TRUE)` at train time, so this is
  # the aggregation path end to end: the backend sees `dx_A`/`dx_B`/`dx_C` and
  # the user gets one `dx`.
  cases <- list(
    SPLS_regression = list(.probe_regression, setup_SPLS(k = 2L)),
    SPLS_binary = list(.probe_binary, setup_SPLS(k = 2L)),
    LinearSVM_regression = list(.probe_regression, setup_LinearSVM()),
    LinearSVM_binary = list(.probe_binary, setup_LinearSVM())
  )
  for (name in names(cases)) {
    dat <- cases[[name]][[1L]]
    mod <- train(dat, hyperparameters = cases[[name]][[2L]], verbosity = 0L)
    x <- explain(
      mod,
      dat[1:3, .probe_feats],
      background = dat[, .probe_feats],
      verbosity = 0L
    )
    expect_identical(x@estimator, "LinearSHAP", info = name)
    expect_identical(x@feature_names, .probe_feats, info = name)
    expect_identical(x@space, "input", info = name)
    expect_additive(x)
  }
})


test_that("a shared backend class routes each algorithm to its own estimator", {
  # Both SVMs are `e1071::svm`. LinearSVM dispatches to the method; RadialSVM
  # never reaches it, because the kernel estimator is model-agnostic and is
  # handled before dispatch.
  skip_if_not_installed("shapr")
  linear <- train(
    .probe_regression,
    hyperparameters = setup_LinearSVM(),
    verbosity = 0L
  )
  radial <- train(
    .probe_regression,
    hyperparameters = setup_RadialSVM(),
    verbosity = 0L
  )
  expect_identical(
    explain(
      linear,
      .probe_regression[1:3, .probe_feats],
      background = .probe_regression[, .probe_feats],
      verbosity = 0L
    )@estimator,
    "LinearSHAP"
  )
  expect_identical(
    explain(
      radial,
      .probe_regression[1:3, .probe_feats],
      background = .probe_regression[, .probe_feats],
      verbosity = 0L
    )@estimator,
    "KernelSHAP"
  )
})


test_that("a binary SVM's margin points at the positive class", {
  # `e1071` names the decision-value column "A/B" with a positive value favoring
  # A, and which class that is follows from the training data's order, not the
  # level order. Taking it as given would invert every contribution for about
  # half of all fitted models.
  mod <- train(
    .probe_binary,
    hyperparameters = setup_LinearSVM(),
    verbosity = 0L
  )
  x <- explain(
    mod,
    .probe_binary[1:5, .probe_feats],
    background = .probe_binary[, .probe_feats],
    verbosity = 0L
  )
  expect_named(x@phi, levels(mod@y_training)[[mod@binclasspos]])
  probability <- predict(
    mod,
    .probe_binary[1:5, .probe_feats],
    verbosity = 0L
  )
  expect_gt(cor(as.numeric(x@predicted[, 1L]), probability[, "pos"]), 0)
})


test_that("SPLS's binary margin is the logit of the reported probability", {
  mod <- train(
    .probe_binary,
    hyperparameters = setup_SPLS(k = 2L),
    verbosity = 0L
  )
  x <- explain(
    mod,
    .probe_binary[1:3, .probe_feats],
    background = .probe_binary[, .probe_feats],
    verbosity = 0L
  )
  probability <- predict(mod, .probe_binary[1:3, .probe_feats], verbosity = 0L)
  expect_equal(
    as.numeric(stats::plogis(x@predicted[, 1L])),
    as.numeric(probability[, "pos"]),
    tolerance = 1e-6
  )
})


test_that("multiclass SPLS and LinearSVM are refused, naming the fallback", {
  # One linear map per model, and a multiclass fit of either has none: SPLS's
  # per-class scores are identified only up to a constant, and a multiclass SVM
  # is one-vs-one voting.
  for (hyperparameters in list(setup_SPLS(k = 2L), setup_LinearSVM())) {
    mod <- train(
      .probe_multiclass,
      hyperparameters = hyperparameters,
      verbosity = 0L
    )
    expect_error(
      explain(
        mod,
        .probe_multiclass[1:3, .probe_feats],
        background = .probe_multiclass[, .probe_feats],
        verbosity = 0L
      ),
      "kernel",
      class = "rtemis_unsupported_error"
    )
  }
})


# %% Additive terms: GAM and MARS ----
set.seed(4L)
.terms_n <- 60L
.terms_dat <- data.frame(
  a = rnorm(.terms_n),
  b = rnorm(.terms_n),
  dx = factor(sample(c("A", "B", "C"), .terms_n, TRUE))
)
.terms_feats <- c("a", "b", "dx")
.terms_regression <- within(
  .terms_dat,
  y <- sin(a) + b * 0.5 + as.integer(dx) * 0.5 + rnorm(.terms_n, sd = 0.2)
)
.terms_binary <- within(
  .terms_dat,
  y <- factor(ifelse(sin(a) + rnorm(.terms_n, sd = 0.5) > 0, "pos", "neg"))
)


test_that("a GAM's own terms are its Shapley values", {
  # `train_GAM()` builds one smooth or parametric term per feature, so the model
  # already is the additive decomposition; nothing needs enumerating.
  for (dat in list(.terms_regression, .terms_binary)) {
    mod <- train(dat, hyperparameters = setup_GAM(), verbosity = 0L)
    x <- explain(
      mod,
      dat[1:3, .terms_feats],
      background = dat[, .terms_feats],
      verbosity = 0L
    )
    expect_identical(x@estimator, "GAMTerms")
    expect_true(x@exact)
    expect_identical(x@feature_names, .terms_feats)
    expect_additive(x)
  }
})


test_that("a multiclass GAM is refused GAMTerms", {
  # One linear predictor per non-reference class has no additive map per
  # class to read.
  dat <- within(
    .terms_dat,
    y <- factor(c("c1", "c2", "c3")[
      as.integer(cut(sin(a) + rnorm(.terms_n, sd = 0.8), 3L))
    ])
  )
  mod <- train(dat, hyperparameters = setup_GAM(), verbosity = 0L)
  expect_error(
    explain(
      mod,
      dat[1:3, .terms_feats],
      background = dat[, .terms_feats],
      verbosity = 0L
    ),
    "multiclass GAM",
    class = "rtemis_unsupported_error"
  )
})


test_that("a GAM baseline is the background's mean prediction", {
  # Re-centered on the supplied background rather than on whatever mgcv
  # centered its smooths on, so a baseline means the same thing here as for
  # every other estimator.
  mod <- train(.terms_regression, hyperparameters = setup_GAM(), verbosity = 0L)
  x <- explain(
    mod,
    .terms_regression[1:3, .terms_feats],
    background = .terms_regression[, .terms_feats],
    verbosity = 0L
  )
  expect_equal(
    x@baseline[1L, 1L],
    mean(predict(mod, .terms_regression[, .terms_feats], verbosity = 0L)),
    tolerance = 1e-8
  )
})


test_that("a GAM captures nonlinearity a linear estimator could not", {
  # The point of using the terms: `sin(a)` is not monotone in `a`, so a
  # coefficient could not express what the smooth does.
  mod <- train(.terms_regression, hyperparameters = setup_GAM(), verbosity = 0L)
  x <- explain(
    mod,
    .terms_regression[, .terms_feats],
    background = .terms_regression[, .terms_feats],
    verbosity = 0L
  )
  contribution <- x@phi[[1L]][, "a"]
  # A linear contribution would be a perfect line in `a`; a smooth of sin() is
  # not, while still being monotone over this range's rank order.
  expect_lt(
    abs(cor(contribution, .terms_regression[["a"]])),
    0.999
  )
})


test_that("an additive MARS decomposes exactly through its own one-hot", {
  mod <- train(
    .terms_regression,
    hyperparameters = setup_MARS(),
    verbosity = 0L
  )
  x <- explain(
    mod,
    .terms_regression[1:3, .terms_feats],
    background = .terms_regression[, .terms_feats],
    verbosity = 0L
  )
  expect_identical(x@estimator, "MARSBasis")
  expect_identical(x@feature_names, .terms_feats)
  expect_identical(x@space, "input")
  expect_additive(x)
  # `earth` drops a one-hot level it did not select, and a feature with no term
  # contributes exactly nothing -- which the encoded view still shows.
  expect_true(all(
    c("dx_A", "dx_B", "dx_C") %in% colnames(x@phi_encoded[[1L]])
  ))
})


test_that("MARS is refused when the fit is not additive, not when asked", {
  # The gate is the fitted model, not `degree`: a degree-2 search that selected
  # no interaction is additive and is explained.
  set.seed(11L)
  n <- 150L
  interacting <- data.frame(a = rnorm(n), b = rnorm(n))
  interacting[["y"]] <- 3 *
    interacting[["a"]] *
    (interacting[["b"]] > 0) -
    2 * interacting[["a"]] * (interacting[["b"]] <= 0) +
    rnorm(n, sd = 0.2)
  feats <- c("a", "b")
  selected <- train(
    interacting,
    hyperparameters = setup_MARS(degree = 2L),
    verbosity = 0L
  )
  dirs <- selected@model[["dirs"]][
    selected@model[["selected.terms"]],
    ,
    drop = FALSE
  ]
  expect_gt(sum(rowSums(dirs != 0) > 1L), 0L)
  expect_error(
    explain(
      selected,
      interacting[1:3, feats],
      background = interacting[, feats],
      verbosity = 0L
    ),
    "not additive",
    class = "rtemis_unsupported_error"
  )
  # The same data fitted additively is explained.
  additive <- train(
    interacting,
    hyperparameters = setup_MARS(degree = 1L),
    verbosity = 0L
  )
  expect_additive(
    explain(
      additive,
      interacting[1:3, feats],
      background = interacting[, feats],
      verbosity = 0L
    )
  )
})


test_that("MARS classification is refused: a GLM sits over the basis", {
  mod <- train(.terms_binary, hyperparameters = setup_MARS(), verbosity = 0L)
  expect_error(
    explain(
      mod,
      .terms_binary[1:3, .terms_feats],
      background = .terms_binary[, .terms_feats],
      verbosity = 0L
    ),
    "GLM over the basis",
    class = "rtemis_unsupported_error"
  )
})


test_that("terms_feature_map refuses a term that reads two features", {
  expect_identical(
    terms_feature_map(c("s(age)", "dx"), c("age", "dx")),
    c("age", "dx")
  )
  # `s(ab)` must not be matched to a feature called `a`.
  expect_error(
    terms_feature_map("s(ab)", c("a", "b")),
    "reads 0 features"
  )
  expect_error(
    terms_feature_map("te(age, bmi)", c("age", "bmi")),
    "reads 2 features"
  )
})


# %% CART TreeSHAP ----
set.seed(2L)
.cart_n <- 60L
.cart_dat <- data.frame(
  a = rnorm(.cart_n),
  b = rnorm(.cart_n),
  dx = factor(sample(c("A", "B", "C"), .cart_n, TRUE))
)
.cart_feats <- c("a", "b", "dx")
.cart_dat[["y"]] <- ifelse(.cart_dat[["a"]] > 0, 2, -1) +
  ifelse(.cart_dat[["b"]] > 0.5, 1, 0) +
  as.integer(.cart_dat[["dx"]]) * 0.4 +
  rnorm(.cart_n, sd = 0.2)


test_that("CART TreeSHAP is exact for every outcome type", {
  cases <- list(
    regression = .cart_dat,
    binary = within(
      .cart_dat,
      y <- factor(ifelse(a + rnorm(.cart_n, sd = 0.4) > 0, "pos", "neg"))
    ),
    multiclass = within(
      .cart_dat,
      y <- factor(c("p", "q", "r")[as.integer(cut(a, 3L))])
    )
  )
  for (name in names(cases)) {
    mod <- train(cases[[name]], hyperparameters = setup_CART(), verbosity = 0L)
    x <- explain(mod, cases[[name]][1:3, .cart_feats], verbosity = 0L)
    expect_identical(x@estimator, "TreeSHAP", info = name)
    expect_true(x@exact, info = name)
    expect_additive(x)
    # The traversal must reproduce the model's own predictions, which is what
    # makes reading `rpart`'s split table safe.
    expect_equal(
      unname(x@predicted),
      unname(as.matrix(predict(
        mod,
        cases[[name]][1:3, .cart_feats],
        verbosity = 0L
      ))[,
        if (name == "binary") "pos" else seq_len(ncol(x@predicted)),
        drop = FALSE
      ]),
      tolerance = 1e-10,
      info = name
    )
  }
})


test_that("a CART that expanded nothing reports no encoded view", {
  mod <- train(.cart_dat, hyperparameters = setup_CART(), verbosity = 0L)
  x <- explain(mod, .cart_dat[1:3, .cart_feats], verbosity = 0L)
  expect_identical(x@space, "input")
  # `rpart` splits on the factor itself, so nothing was expanded and the encoded
  # values would be the same numbers. This estimator returns them unlabeled,
  # which must not read as an encoding having intervened.
  expect_null(x@phi_encoded)
})


test_that("a CART baseline is the tree's own expected prediction", {
  mod <- train(.cart_dat, hyperparameters = setup_CART(), verbosity = 0L)
  x <- explain(mod, .cart_dat[1:3, .cart_feats], verbosity = 0L)
  # The empty coalition marginalizes every split by training coverage, which is
  # the mean prediction over the training data.
  expect_equal(
    x@baseline[1L, 1L],
    mean(predict(mod, .cart_dat[, .cart_feats], verbosity = 0L)),
    tolerance = 1e-8
  )
})


test_that("a feature the tree never split on gets exactly zero", {
  # The dummy axiom, and the reason the enumeration runs over the tree's own
  # features rather than every column.
  set.seed(5L)
  dat <- data.frame(a = rnorm(200L), noise = rnorm(200L))
  dat[["y"]] <- ifelse(dat[["a"]] > 0, 3, -3) + rnorm(200L, sd = 0.1)
  mod <- train(dat, hyperparameters = setup_CART(), verbosity = 0L)
  x <- explain(mod, dat[1:3, c("a", "noise")], verbosity = 0L)
  expect_true(all(x@phi[[1L]][, "noise"] == 0))
  expect_false(any(x@phi[[1L]][, "a"] == 0))
})


test_that("a duplicate feature the tree ignored gets zero, not half", {
  # "True to the model": a perfect proxy the tree never split on is a dummy
  # player in the path-dependent value function, so symmetry does not apply to
  # it. This is the interventional/conditional distinction showing up in a
  # place people find surprising, and it is correct.
  set.seed(6L)
  dat <- data.frame(a = rnorm(300L))
  dat[["copy"]] <- dat[["a"]]
  dat[["y"]] <- ifelse(dat[["a"]] > 0, 4, -4) + rnorm(300L, sd = 0.05)
  mod <- train(dat, hyperparameters = setup_CART(), verbosity = 0L)
  x <- explain(mod, dat[1:3, c("a", "copy")], verbosity = 0L)
  split_on <- unique(as.character(mod@model[["frame"]][["var"]]))
  expect_length(intersect(c("a", "copy"), split_on), 1L)
  ignored <- setdiff(c("a", "copy"), split_on)
  expect_true(all(x@phi[[1L]][, ignored] == 0))
})


test_that("cart_tree reads split rules aligned to their own nodes", {
  # `primary` holds 0 at every leaf, and indexing `splits` with the whole vector
  # would drop those rather than return NA -- shortening the result so every
  # rule lands against the wrong node. Routing is checked against `rpart`'s own
  # leaf assignment, which is the only way to see that.
  mod <- train(.cart_dat, hyperparameters = setup_CART(), verbosity = 0L)
  tree <- cart_tree(mod@model)
  expect_length(tree[["ncat"]], nrow(mod@model[["frame"]]))
  expect_length(tree[["index"]], nrow(mod@model[["frame"]]))
  expect_true(all(is.na(tree[["ncat"]][tree[["is_leaf"]]])))
  expect_false(any(is.na(tree[["ncat"]][!tree[["is_leaf"]]])))
  # Routing every training case must land where rpart says it does.
  known <- stats::setNames(
    rep(TRUE, length(unique(tree[["feature"]][!tree[["is_leaf"]]]))),
    unique(tree[["feature"]][!tree[["is_leaf"]]])
  )
  routed <- cart_coalition_value(
    tree,
    1L,
    .cart_dat[, .cart_feats],
    known
  )
  expect_equal(
    as.numeric(routed),
    as.numeric(predict(mod, .cart_dat[, .cart_feats], verbosity = 0L)),
    tolerance = 1e-10
  )
})


test_that("a tree splitting on too many features is refused, not left to run", {
  # 2^p traversals: past the limit the kernel estimator is the right tool.
  expect_gt(CART_SHAP_MAX_FEATURES, 8L)
  set.seed(8L)
  wide <- as.data.frame(matrix(rnorm(300L * 20L), ncol = 20L))
  wide[["y"]] <- rowSums(wide[, 1:20]) + rnorm(300L, sd = 0.1)
  mod <- train(
    wide,
    hyperparameters = setup_CART(maxdepth = 30L, minsplit = 2L, cp = 0),
    verbosity = 0L
  )
  used <- length(unique(as.character(mod@model[["frame"]][["var"]]))) - 1L
  skip_if_not(
    used > CART_SHAP_MAX_FEATURES,
    "tree is small enough to enumerate"
  )
  expect_error(
    explain(mod, wide[1:2, paste0("V", 1:20)], verbosity = 0L),
    "coalitions",
    class = "rtemis_unsupported_error"
  )
})


# %% HAL basis walk ----
test_that("HAL's basis walk is exact when every selected basis reads one feature", {
  set.seed(7L)
  n <- 60L
  dat <- data.frame(
    a = rnorm(n),
    b = rnorm(n),
    dx = factor(sample(c("A", "B"), n, TRUE))
  )
  dat[["y"]] <- 2 *
    dat[["a"]] -
    dat[["b"]] +
    as.integer(dat[["dx"]]) * 0.5 +
    rnorm(n, sd = 0.3)
  feats <- c("a", "b", "dx")
  mod <- train(
    dat,
    hyperparameters = setup_HAL(max_degree = 1L),
    verbosity = 0L
  )
  x <- explain(mod, dat[1:3, feats], background = dat[, feats], verbosity = 0L)
  expect_identical(x@estimator, "HALBasis")
  expect_identical(x@feature_names, feats)
  expect_additive(x)
  expect_equal(
    as.numeric(x@predicted[, 1L]),
    as.numeric(predict(mod, dat[1:3, feats], verbosity = 0L)),
    tolerance = 1e-8
  )
})


test_that("HAL's default fit is refused, naming the setting that fixes it", {
  # `setup_HAL()` defaults to `max_degree = 2L`, so a basis reading two features
  # is the common case rather than an edge one, and the message has to be
  # actionable rather than merely correct.
  set.seed(7L)
  n <- 60L
  dat <- data.frame(a = rnorm(n), b = rnorm(n))
  dat[["y"]] <- 2 * dat[["a"]] - dat[["b"]] + rnorm(n, sd = 0.3)
  feats <- c("a", "b")
  expect_identical(formals(setup_HAL)[["max_degree"]], quote(2L))
  mod <- train(dat, hyperparameters = setup_HAL(), verbosity = 0L)
  expect_error(
    explain(mod, dat[1:3, feats], background = dat[, feats], verbosity = 0L),
    "max_degree = 1L",
    class = "rtemis_unsupported_error"
  )
})


# %% KernelSHAP ----
test_that("KernelSHAP answers for the algorithms with no exact estimator", {
  skip_if_not_installed("shapr")
  for (algorithm in c("Ranger", "RadialSVM", "KNN")) {
    mod <- train(
      .kernel_regression,
      hyperparameters = do.call(paste0("setup_", algorithm), list()),
      verbosity = 0L
    )
    x <- explain(
      mod,
      .kernel_regression[1:3, .kernel_feats],
      background = .kernel_regression[, .kernel_feats],
      verbosity = 0L
    )
    expect_identical(x@estimator, "KernelSHAP", info = algorithm)
    # Sampled, so `exact` is FALSE and additivity holds to the sampling error
    # rather than to machine precision.
    expect_false(x@exact, info = algorithm)
    expect_additive(x, tolerance = 1e-6)
  }
})


test_that("KernelSHAP explains a classification on the probability scale", {
  # It sees only what `predict()` returns. Its contributions are additive *on
  # that*, exactly, because it decomposes the function it was handed rather
  # than transforming a margin decomposition -- so this is honest, and merely
  # not comparable with a margin-scale explanation of the same model.
  skip_if_not_installed("shapr")
  binary <- within(
    .kernel_dat,
    y <- factor(ifelse(a + rnorm(.kernel_n, sd = 0.4) > 0, "pos", "neg"))
  )
  mod <- train(binary, hyperparameters = setup_Ranger(), verbosity = 0L)
  x <- explain(
    mod,
    binary[1:3, .kernel_feats],
    background = binary[, .kernel_feats],
    verbosity = 0L
  )
  expect_identical(x@scale, "probability")
  expect_named(x@phi, "pos")
  expect_additive(x, tolerance = 1e-6)
  expect_equal(
    as.numeric(x@predicted[, 1L]),
    as.numeric(predict(mod, binary[1:3, .kernel_feats], verbosity = 0L)[,
      "pos"
    ]),
    tolerance = 1e-10
  )
})


test_that("KernelSHAP gives multiclass one block per class", {
  skip_if_not_installed("shapr")
  multiclass <- within(
    .kernel_dat,
    y <- factor(c("p", "q", "r")[as.integer(cut(a, 3L))])
  )
  mod <- train(multiclass, hyperparameters = setup_Ranger(), verbosity = 0L)
  x <- explain(
    mod,
    multiclass[1:3, .kernel_feats],
    background = multiclass[, .kernel_feats],
    verbosity = 0L
  )
  expect_named(x@phi, levels(mod@y_training))
  expect_additive(x, tolerance = 1e-6)
})


test_that("the value function is the perturbation, not an approach to guess", {
  # The reason this plan chose shapr: `independence` *is* the interventional
  # value function, so the choice decision 1 says must be explicit is a
  # first-class argument rather than something inferred from a default.
  skip_if_not_installed("shapr")
  mod <- train(
    .kernel_regression,
    hyperparameters = setup_Ranger(),
    verbosity = 0L
  )
  conditional <- explain(
    mod,
    .kernel_regression[1:3, .kernel_feats],
    background = .kernel_regression[, .kernel_feats],
    config = setup_SHAP(
      estimator = "kernel",
      perturbation = "conditional",
      # "gaussian" rather than "ctree": the latter warns its way to a fallback
      # when `party` is absent, and the conditional path is what is under test,
      # not which estimator of it.
      approach = "gaussian"
    ),
    verbosity = 0L
  )
  expect_identical(conditional@perturbation, "conditional")
  expect_additive(conditional, tolerance = 1e-6)
  # `independence` is absent from the approach enum because `perturbation`
  # already says it; the two must not be sayable separately.
  expect_false("independence" %in% SHAP_APPROACHES)
})


test_that("KernelSHAP needs a background and refuses the margin scale", {
  skip_if_not_installed("shapr")
  binary <- within(
    .kernel_dat,
    y <- factor(ifelse(a + rnorm(.kernel_n, sd = 0.4) > 0, "pos", "neg"))
  )
  mod <- train(binary, hyperparameters = setup_Ranger(), verbosity = 0L)
  expect_error(
    explain(mod, binary[1:3, .kernel_feats], verbosity = 0L),
    "background",
    class = "rtemis_missing_error"
  )
  expect_error(
    explain(
      mod,
      binary[1:3, .kernel_feats],
      background = binary[, .kernel_feats],
      config = setup_SHAP(estimator = "kernel", scale = "margin"),
      verbosity = 0L
    ),
    class = "rtemis_unsupported_error"
  )
})


test_that("every refusal's suggested escape hatch actually works", {
  # Several messages name `setup_SHAP(estimator = "kernel")` as the way out.
  # Pointing a user at a door that does not open would be worse than refusing
  # without advice.
  skip_if_not_installed("shapr")
  dat <- .kernel_regression
  feats <- .kernel_feats
  refused <- list(
    # An algorithm with no exact estimator at all.
    Ranger = list(setup_Ranger(), setup_SHAP(estimator = "exact")),
    # A fit whose terms are not additive in its features.
    HAL = list(setup_HAL(), NULL)
  )
  for (name in names(refused)) {
    mod <- train(
      dat,
      hyperparameters = refused[[name]][[1L]],
      verbosity = 0L
    )
    config <- refused[[name]][[2L]]
    if (!is.null(config)) {
      expect_error(
        explain(
          mod,
          dat[1:3, feats],
          background = dat[, feats],
          config = config,
          verbosity = 0L
        ),
        class = "rtemis_unsupported_error",
        info = name
      )
    }
    escape <- explain(
      mod,
      dat[1:3, feats],
      background = dat[, feats],
      config = setup_SHAP(estimator = "kernel"),
      verbosity = 0L
    )
    expect_identical(escape@estimator, "KernelSHAP", info = name)
    expect_additive(escape, tolerance = 1e-6)
  }
})


# %% Shapley properties ----
test_that("a feature the model ignores gets exactly zero, not merely little", {
  # The dummy axiom under an interventional value function, for a linear model:
  # a penalty that zeroes a noise feature's coefficient must zero its
  # attribution, not shrink it. "True to the model" means a feature the model
  # does not use contributes nothing, however predictive it might have been.
  set.seed(21L)
  n <- 120L
  dat <- data.frame(signal = rnorm(n), noise = rnorm(n))
  dat[["y"]] <- 5 * dat[["signal"]] + rnorm(n, sd = 0.2)
  feats <- c("signal", "noise")
  mod <- train(dat, hyperparameters = setup_GLMNET(), verbosity = 0L)
  coefficients <- as.matrix(coef(mod@model))[, 1L]
  skip_if_not(
    coefficients[["noise"]] == 0,
    "the penalty did not zero the noise coefficient"
  )
  x <- explain(mod, dat[1:3, feats], background = dat[, feats], verbosity = 0L)
  expect_true(all(x@phi[[1L]][, "noise"] == 0))
  expect_false(any(x@phi[[1L]][, "signal"] == 0))
})


# %% Plot data ----
.plot_dat <- local({
  set.seed(14L)
  n <- 40L
  dat <- data.frame(
    age = rnorm(n),
    bmi = rnorm(n),
    dx = factor(sample(c("A", "B"), n, TRUE))
  )
  dat[["y"]] <- 2 *
    dat[["age"]] -
    dat[["bmi"]] +
    as.integer(dat[["dx"]]) +
    rnorm(n, sd = 0.3)
  dat
})
.plot_feats <- c("age", "bmi", "dx")

.plot_explanation <- function() {
  mod <- train(.plot_dat, hyperparameters = setup_GLM(), verbosity = 0L)
  explain(
    mod,
    .plot_dat[, .plot_feats],
    background = .plot_dat[, .plot_feats],
    verbosity = 0L
  )
}


test_that("shap_case gives the three things a waterfall needs", {
  # Where the prediction started, what each feature did, where it ended. Shaped
  # here rather than in a renderer, as `session_timeline()` is, so rtemis.draw
  # and rtemislive show the same thing.
  x <- .plot_explanation()
  step <- shap_case(x, .plot_dat[, .plot_feats], case = 3L)
  expect_setequal(step[["steps"]][["feature"]], .plot_feats)
  expect_equal(
    sum(step[["steps"]][["contribution"]]) + step[["baseline"]],
    step[["predicted"]],
    tolerance = 1e-10
  )
  # Ordered by magnitude, which is how a waterfall reads -- whichever way each
  # effect went.
  expect_false(is.unsorted(rev(abs(step[["steps"]][["contribution"]]))))
  # Each row carries the value the feature took, so it reads "age = 2.12, +4.21"
  # rather than only "+4.21".
  expect_identical(
    step[["steps"]][["value"]][step[["steps"]][["feature"]] == "dx"],
    as.character(.plot_dat[["dx"]][[3L]])
  )
})


test_that("shap_long is one row per case per feature", {
  x <- .plot_explanation()
  long <- shap_long(x, .plot_dat[, .plot_feats])
  expect_s3_class(long, "data.table")
  expect_identical(nrow(long), nrow(.plot_dat) * length(.plot_feats))
  expect_setequal(unique(long[["feature"]]), .plot_feats)
  # A color scale needs a number, and a factor has none -- said with NA rather
  # than by coercing a level to its integer code.
  expect_true(all(is.na(long[long[["feature"]] == "dx", ][["numeric_value"]])))
  expect_false(any(is.na(long[long[["feature"]] == "age", ][[
    "numeric_value"
  ]])))
})


test_that("the plot readers verify the data and the class they were given", {
  x <- .plot_explanation()
  expect_error(
    shap_case(x, .plot_dat[1:3, .plot_feats]),
    class = "rtemis_value_error"
  )
  expect_error(
    shap_long(x, .plot_dat[, .plot_feats], class = "nope"),
    class = "rtemis_value_error"
  )
  expect_error(
    shap_case(x, .plot_dat[, .plot_feats], case = 9999L),
    "No such case",
    class = "rtemis_value_error"
  )
})


# %% Variable importance ----
set.seed(13L)
.vi_n <- 60L
.vi_dat <- data.frame(
  strong = rnorm(.vi_n),
  weak = rnorm(.vi_n),
  noise = rnorm(.vi_n)
)
.vi_feats <- c("strong", "weak", "noise")
.vi_dat[["y"]] <- 3 *
  .vi_dat[["strong"]] +
  0.4 * .vi_dat[["weak"]] +
  rnorm(.vi_n, sd = 0.3)


test_that("mean |phi| ranks the features by how much they moved predictions", {
  mod <- train(.vi_dat, hyperparameters = setup_GLM(), verbosity = 0L)
  x <- explain(
    mod,
    .vi_dat[, .vi_feats],
    background = .vi_dat[, .vi_feats],
    verbosity = 0L
  )
  importance <- get_varimp(x)
  expect_s7_class(importance, VariableImportance)
  # One row per feature: which *level* of a categorical drives it is
  # `shap_by_level()`'s question, not this one.
  expect_identical(varimp_table(importance)[["variable"]], .vi_feats)
  values <- varimp_table(importance)[["outcome"]]
  expect_gt(values[[1L]], values[[2L]])
  expect_gt(values[[2L]], values[[3L]])
})


test_that("importance is the magnitude, so cancelling contributions still count", {
  # A feature that pushes half the cases up and half down is important; a signed
  # mean would call it irrelevant. For a linear model the contributions are
  # symmetric about zero by construction, which makes the point sharply.
  mod <- train(.vi_dat, hyperparameters = setup_GLM(), verbosity = 0L)
  x <- explain(
    mod,
    .vi_dat[, .vi_feats],
    background = .vi_dat[, .vi_feats],
    verbosity = 0L
  )
  signed <- mean(x@phi[["outcome"]][, "strong"])
  expect_lt(abs(signed), 1e-8)
  expect_gt(varimp_table(get_varimp(x))[["outcome"]][[1L]], 1)
})


test_that("a multiclass explanation gives one importance measure per class", {
  mod <- .lgb_multiclass()
  x <- explain(
    mod,
    .multiclass_dat[1:5, .explain_feats],
    background = .multiclass_dat[, .explain_feats],
    verbosity = 0L
  )
  importance <- get_varimp(x)
  expect_named(
    varimp_table(importance),
    c("variable", levels(mod@y_training))
  )
})


test_that("SHAP gives an importance to an algorithm that has none of its own", {
  skip_if_no_torch()
  # The gap this closes: a torch network has no native measure, so
  # `get_varimp()` on the fitted model is NULL -- and no torch-specific code was
  # written to fix it.
  mod <- train(.vi_dat, hyperparameters = setup_MLP(), verbosity = 0L)
  expect_null(get_varimp(mod))
  x <- explain(
    mod,
    .vi_dat[1:5, .vi_feats],
    background = .vi_dat[1:60, .vi_feats],
    verbosity = 0L
  )
  importance <- get_varimp(x)
  expect_s7_class(importance, VariableImportance)
  expect_identical(varimp_table(importance)[["variable"]], .vi_feats)
  expect_gt(
    varimp_table(importance)[["outcome"]][[1L]],
    varimp_table(importance)[["outcome"]][[3L]]
  )
})


# %% SupervisedRes ----
.resampled <- function(dat, hyperparameters = setup_GLM()) {
  train(
    dat,
    hyperparameters = hyperparameters,
    outer_resampling_config = setup_KFold(n_resamples = 4L),
    verbosity = 0L
  )
}


test_that("the fold average decomposes the fold-averaged prediction exactly", {
  # The claim that makes averaging principled rather than a convenience: since
  # sum(phi_k) + b_k = f_k(x) per fold, averaging both sides gives
  # sum(phi_bar) + b_bar = f_bar(x). It needs one background shared by every
  # fold, which `explain()` enforces by taking a single argument.
  res <- .resampled(.regression_dat)
  newdata <- .regression_dat[1:3, .explain_feats]
  x <- explain(
    res,
    newdata,
    background = .regression_dat[, .explain_feats],
    verbosity = 0L
  )
  expect_s7_class(x, SHAP)
  expect_additive(x)
  expect_equal(
    as.numeric(x@predicted[, 1L]),
    as.numeric(predict(res, newdata, type = "avg")),
    tolerance = 1e-10
  )
})


test_that("type = 'all' returns one explanation per resample", {
  res <- .resampled(.regression_dat)
  newdata <- .regression_dat[1:3, .explain_feats]
  background <- .regression_dat[, .explain_feats]
  each <- explain(
    res,
    newdata,
    background = background,
    type = "all",
    verbosity = 0L
  )
  expect_length(each, length(res@models))
  expect_named(each, names(res@models))
  for (one in each) {
    expect_s7_class(one, SHAP)
    expect_additive(one)
  }
  # ...and the average is exactly their mean, not a re-estimate.
  averaged <- explain(res, newdata, background = background, verbosity = 0L)
  expect_equal(
    averaged@phi[[1L]],
    Reduce(`+`, lapply(each, function(one) one@phi[[1L]])) / length(each),
    tolerance = 1e-12
  )
})


test_that("averaging is refused when the folds do not agree", {
  # A mean of contributions on different scales, or over different features,
  # describes nothing. Models fitted to resamples of one dataset always agree;
  # the check exists because the failure would otherwise be silent.
  res <- .resampled(.regression_dat)
  newdata <- .regression_dat[1:3, .explain_feats]
  background <- .regression_dat[, .explain_feats]
  each <- explain(
    res,
    newdata,
    background = background,
    type = "all",
    verbosity = 0L
  )
  each[[2L]]@scale <- "probability"
  expect_error(average_shap(each), "@scale", class = "rtemis_value_error")
})


test_that("a resampled classification averages margins, not probabilities", {
  # The same link distinction as for a single model: `predict(type = "avg")`
  # averages probabilities, and the contributions decompose the mean margin.
  res <- .resampled(.binary_dat)
  newdata <- .binary_dat[1:3, .explain_feats]
  x <- explain(
    res,
    newdata,
    background = .binary_dat[, .explain_feats],
    verbosity = 0L
  )
  expect_identical(x@scale, "margin")
  expect_additive(x)
  probability <- predict(res, newdata, type = "avg")
  expect_false(
    isTRUE(all.equal(as.numeric(x@predicted[, 1L]), as.numeric(probability)))
  )
})


# %% Isotonic ----
test_that("a one-predictor model gives the whole deviation to its predictor", {
  # One player, so the Shapley value is the entire deviation from the baseline.
  # Additivity here is the definition rather than a check.
  set.seed(11L)
  n <- 60L
  dat <- data.frame(a = runif(n))
  dat[["y"]] <- 2 * dat[["a"]] + rnorm(n, sd = 0.1)
  feats <- "a"
  mod <- train(dat, hyperparameters = setup_Isotonic(), verbosity = 0L)
  x <- explain(
    mod,
    dat[1:3, feats, drop = FALSE],
    background = dat[, feats, drop = FALSE],
    verbosity = 0L
  )
  expect_identical(x@estimator, "Isotonic")
  expect_true(x@exact)
  expect_additive(x)
  expect_equal(
    as.numeric(x@phi[[1L]][, 1L]),
    as.numeric(x@predicted[, 1L] - x@baseline[, 1L]),
    tolerance = 1e-12
  )
})


# %% ConditionalSuperLearner ----
# Two regimes an oracle can separate: the effect of `a` flips with `b`. One
# fit serves both tests below.
set.seed(9L)
.routed_n <- 150L
.routed_dat <- data.frame(a = rnorm(.routed_n), b = rnorm(.routed_n))
.routed_dat[["y"]] <- ifelse(
  .routed_dat[["b"]] > 0,
  3 * .routed_dat[["a"]],
  -3 * .routed_dat[["a"]]
) +
  rnorm(.routed_n, sd = 0.3)
.routed_feats <- c("a", "b")
.routed_mod <- train(
  .routed_dat,
  hyperparameters = setup_ConditionalSuperLearner(
    base_learners = list(setup_GLM(), setup_GLM()),
    inner_resampling_config = setup_KFold(n_resamples = 3L, seed = 2026L)
  ),
  verbosity = 0L
)

test_that("a routed case is explained by the expert that predicted it", {
  # The oracle sends each case to one expert, which predicts it alone, so the
  # expert's explanation *is* the ensemble's for that case -- exactly, and by
  # the expert's own estimator rather than a pass over the whole ensemble.
  dat <- .routed_dat
  feats <- .routed_feats
  mod <- .routed_mod
  x <- explain(mod, dat[1:3, feats], background = dat[, feats], verbosity = 0L)
  expect_identical(x@estimator, "ExpertSHAP")
  # Both experts are GLMs, so both explanations are exact and so is the whole.
  expect_true(x@exact)
  expect_additive(x)
  expect_equal(
    as.numeric(x@predicted[, 1L]),
    as.numeric(predict(mod, dat[1:3, feats], verbosity = 0L)),
    tolerance = 1e-10
  )
})


test_that("a routed explanation's baseline is its own expert's", {
  # The reason `@baseline` is per case: cases routed to different experts are
  # each exact against a different expected prediction.
  dat <- .routed_dat
  feats <- .routed_feats
  mod <- .routed_mod
  # Enough cases that the oracle routes to more than one expert; three would
  # not reliably reach a second.
  x <- explain(mod, dat[, feats], background = dat[, feats], verbosity = 0L)
  expect_identical(dim(x@baseline), dim(x@predicted))
  # More than one distinct baseline, and no more than there are experts.
  distinct <- length(unique(round(x@baseline[, 1L], 10)))
  expect_gt(distinct, 1L)
  expect_lte(distinct, length(mod@model@experts))
})


test_that("a constant baseline still fills a row per case", {
  # One shape for both kinds of estimator, so no consumer branches on it.
  mod <- .lgb_regression()
  x <- explain(mod, .regression_dat[1:3, .explain_feats], verbosity = 0L)
  expect_identical(dim(x@baseline), c(3L, 1L))
  expect_length(unique(x@baseline[, 1L]), 1L)
})


# %% Categorical detail ----
set.seed(3L)
# Larger than the rest for the same reason as `.linear_n`: a multiclass
# `cv.glmnet` warns when its folds leave a class thin.
.dx_n <- 150L
.dx <- factor(
  sample(c("none", "diabetes", "asthma"), .dx_n, TRUE, prob = c(.6, .25, .15))
)
.dx_dat <- data.frame(age = rnorm(.dx_n), dx = .dx)
.dx_dat[["y"]] <- .dx_dat[["age"]] +
  3 * (.dx == "diabetes") +
  rnorm(.dx_n, sd = 0.3)
.dx_feats <- c("age", "dx")

.dx_explanation <- function(hyperparameters = setup_GLM()) {
  mod <- train(.dx_dat, hyperparameters = hyperparameters, verbosity = 0L)
  explain(
    mod,
    .dx_dat[, .dx_feats],
    background = .dx_dat[, .dx_feats],
    verbosity = 0L
  )
}


test_that("per-level contributions stay reachable, without being reported", {
  # `glm()` expands the factor itself, so the design-matrix values are the
  # per-level view. They are kept rather than discarded, but they are not the
  # answer to "which level matters" -- see below.
  x <- .dx_explanation()
  expect_identical(colnames(x@phi[["outcome"]]), .dx_feats)
  expect_false(is.null(x@phi_encoded))
  expect_true(all(
    c("dxdiabetes", "dxnone") %in% colnames(x@phi_encoded[[1L]])
  ))
  # The reference level has no column at all: a per-level view of a
  # contrast-coded model structurally cannot show it.
  expect_false("dxasthma" %in% colnames(x@phi_encoded[[1L]]))
  # Whatever the split, the total is the same.
  expect_equal(
    rowSums(x@phi_encoded[[1L]]),
    rowSums(x@phi[["outcome"]]),
    tolerance = 1e-10
  )
})


test_that("a GLM explains the data its own predict() accepts", {
  mod <- train(.dx_dat, hyperparameters = setup_GLM(), verbosity = 0L)
  # A subset that no longer carries every level, and whose reference level is
  # the one dropped. `predict.glm()` rebuilds the design from the levels the
  # model was fitted with; the design built here has to agree with it, or the
  # coefficients line up against the wrong columns.
  thin <- droplevels(.dx_dat[.dx_dat[["dx"]] != "asthma", ])
  x <- explain(
    mod,
    thin[1:3, .dx_feats],
    background = thin[, .dx_feats],
    verbosity = 0L
  )
  expect_identical(colnames(x@phi[["outcome"]]), .dx_feats)
  expect_additive(x)
  # The dropped level still has no column of its own -- it is the reference --
  # but the two that remain are the ones the model was fitted with.
  expect_true(all(
    c("dxdiabetes", "dxnone") %in% colnames(x@phi_encoded[[1L]])
  ))
})


test_that("shap_by_level breaks a factor down by the level each case has", {
  # The question a per-feature average hides: "dx matters" is useless next to
  # "diabetes is what moves the prediction".
  x <- .dx_explanation()
  by_level <- shap_by_level(x, .dx_dat[, .dx_feats])
  expect_s3_class(by_level, "data.table")
  expect_identical(
    sort(by_level[["level"]]),
    sort(levels(.dx))
  )
  # Simulated so that only diabetes has an effect, and it is recovered.
  diabetes <- by_level[by_level[["level"]] == "diabetes", ][["mean"]]
  none <- by_level[by_level[["level"]] == "none", ][["mean"]]
  expect_gt(diabetes, 2)
  expect_lt(abs(none), 1)
  # ...and the reference level appears like any other, which is the thing the
  # per-level columns cannot do.
  expect_true("asthma" %in% by_level[["level"]])
  expect_identical(sum(by_level[["n"]]), .dx_n)
})


test_that("shap_by_level is per class, so multiclass is not a special case", {
  mod <- train(
    within(.dx_dat, y <- factor(c("a", "b", "c")[as.integer(cut(age, 3L))])),
    hyperparameters = setup_GLMNET(),
    verbosity = 0L
  )
  x <- explain(
    mod,
    .dx_dat[, .dx_feats],
    background = .dx_dat[, .dx_feats],
    verbosity = 0L
  )
  by_level <- shap_by_level(x, .dx_dat[, .dx_feats])
  expect_setequal(unique(by_level[["class"]]), names(x@phi))
  expect_identical(nrow(by_level), 3L * nlevels(.dx))
})


test_that("shap_by_level refuses data that is not what was explained", {
  # Contributions lined up against the wrong rows would produce a plausible
  # table describing nothing, so the fingerprint is checked rather than assumed.
  x <- .dx_explanation()
  expect_error(
    shap_by_level(x, .dx_dat[1:3, .dx_feats]),
    class = "rtemis_value_error"
  )
  expect_error(
    shap_by_level(x, .dx_dat[rev(seq_len(.dx_n)), .dx_feats]),
    class = "rtemis_value_error"
  )
})


test_that("shap_by_level says so when there is nothing categorical", {
  mod <- train(
    .regression_dat,
    hyperparameters = setup_GLM(),
    verbosity = 0L
  )
  x <- explain(
    mod,
    .regression_dat[, .explain_feats],
    background = .regression_dat[, .explain_feats],
    verbosity = 0L
  )
  expect_error(
    shap_by_level(x, .regression_dat[, .explain_feats]),
    class = "rtemis_value_error"
  )
})


test_that("collapse_by_term sums design columns into their term", {
  phi <- matrix(
    1:12,
    nrow = 3L,
    dimnames = list(NULL, c("age", "dxB", "dxC", "bmi"))
  )
  out <- collapse_by_term(phi, c(1L, 3L, 3L, 2L), c("age", "bmi", "dx"))
  expect_identical(colnames(out), c("age", "bmi", "dx"))
  expect_equal(out[, "dx"], rowSums(phi[, c("dxB", "dxC")]))
  expect_equal(rowSums(out), rowSums(phi))
})


test_that("explain() inherits predict()'s strictness about columns", {
  mod <- .lgb_regression()
  # Predictors only, in training order -- the same contract `predict()` states.
  expect_error(
    explain(mod, .regression_dat[1:3, ], verbosity = 0L),
    class = "rtemis_data_error"
  )
  expect_error(
    explain(mod, .regression_dat[1:3, c("bmi", "age", "sex")], verbosity = 0L),
    class = "rtemis_data_error"
  )
})

Try the rtemis package in your browser

Any scripts or data that you put into this service are public.

rtemis documentation built on Oct. 20, 2026, 1:06 a.m.