Skip to content

[c++] Fix SHAP computation after model.update() - #7501

Open
ron-wettenstein wants to merge 2 commits into
lightgbm-org:mainfrom
ron-wettenstein:bugfix/SHAP_after_model_update
Open

ron-wettenstein wants to merge 2 commits into
lightgbm-org:mainfrom
ron-wettenstein:bugfix/SHAP_after_model_update

Conversation

@ron-wettenstein

Copy link
Copy Markdown

Fixes #7488 .

The fix is simple, we need to mark the new trees for initialization by setting models_initialized_ = false.
I also find similar bug in the case of merging trees, so I also added models_initialized_ = false in the relevant function. The tree merging bug is not something that one can reach using the public R/python API, still we better fix this so it won't hurt us in the future.

Basically all the code here are 3 lines of models_initialized_ = false.
The rest are tests:

  1. I added python tests for the model.update() case and callbacks that compute contributions.
  2. C++ test for the tree merging case.
  3. I added R test that makes sure the computed SHAP values and the bias term sum up to the prediction. I started working on R test as the tree merging case can technically be reproduce in R by using bst$.__enclos_env__$private$handle. Then I notices no simple SHAP test exist there, so I added it. This test is not directly related to this PR, if you prefer I can remove it.

…all (fixes lightgbm-org#7488)

Since lightgbm-org#6778, GBDT::InitPredict() computes the trees' max depth (needed by
TreeSHAP) only on the first pred_contrib call of a Booster, guarded by
models_initialized_. That flag was never reset, so trees added afterwards
kept max_depth_ = -1 and the next pred_contrib call failed
`CHECK_GE(max_depth_, 0)`: e.g. pred_contrib between Booster.update() calls,
or inside a training callback.

Reset models_initialized_ whenever trees are added: in GBDT::TrainOneIter()
(also used by DART and GOSS), RF::TrainOneIter() and GBDT::MergeFrom().

Tests:
* Python: pred_contrib after Booster.update() and inside a callback
* C++: single-row pred_contrib after LGBM_BoosterMerge()
* R: feature contributions sum to the raw score

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mayer79

mayer79 commented Oct 24, 2026

Copy link
Copy Markdown
Collaborator

Will look at it today, thanks a lot!

@mayer79 mayer79 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks very good.

For me it is fine to add a simple SHAP addititivity and dimension test to the R tests in this PR as well, given that the issue solved in this PR is related to SHAP.

@jameslamb jameslamb left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, the explanation and fix make sense to me. Some small suggestions.

}
})

test_that("feature contributions sum to the raw score", {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
test_that("feature contributions sum to the raw score", {
test_that("feature contributions sum to the raw score for binary classification", {

There are already tests covering this:

# check that predictions really look like feature contributions
preds_contrib_s3_keyword <- predict(bst, X, type = "contrib")
num_features <- ncol(X)
shap_base_value <- unname(preds_contrib_s3_keyword[, ncol(preds_contrib_s3_keyword)])
expect_true(is.matrix(preds_contrib_s3_keyword))
expect_equal(dim(preds_contrib_s3_keyword), c(nrow(X), num_features + 1L))
expect_equal(length(unique(shap_base_value)), 1L)
expect_equal(mean(y), shap_base_value[1L])
expect_equal(predict(bst, X), rowSums(preds_contrib_s3_keyword))

It's fine if you want to add this standalone test but please:

  1. expand it to cover the other things you see there (like checking that the column with the predicted value contains correct-looking values)
  2. clarify in the test name that you're only checking binary classification (you yourself just explained in pred_contrib value doesn't add up to prediction in lightgbm regression #3998 (comment) how this is not true for all loss functions)

objective = "binary"
, num_threads = .LGB_MAX_THREADS
)
, nrounds = 20L

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
, nrounds = 20L
, nrounds = 5L

The correctness of this test doesn't depend on the model depth, let's do something even smaller and cheaper please.

@jameslamb jameslamb added the fix label Oct 24, 2026
@jameslamb

Copy link
Copy Markdown
Member

@mayer79 when you review here in the future, you're welcome to choose the PR label you think makes the most sense. That informs how PRs are categorized in the release notes, these categories:

categories:
- title: '💡 New Features'
label: 'feature'
- title: '🔨 Breaking'
label: 'breaking'
- title: '🚀 Efficiency Improvement'
label: 'efficiency'
- title: '🐛 Bug Fixes'
label: 'fix'
- title: '📖 Documentation'
label: 'doc'
- title: '🧰 Maintenance'
label: 'maintenance'

@ron-wettenstein you can ignore the Docker CI failures. Looks like I did not handle PRs from forks correctly in #6638 , sorry. I will get that fixed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[c++] SHAP computation crashes if run after model.update() or in training callback

3 participants