4848
4949
5050shap_required_version = (2024 , "P" , 1 )
51+ shap_api_change_version = (2025 , "P" , 0 )
5152shap_supported = daal_check_version (shap_required_version )
53+ shap_api_changed = daal_check_version (shap_api_change_version )
5254shap_not_supported_str = (
5355 f"SHAP value calculation only supported for version { shap_required_version } or later"
5456)
5557shap_unavailable_str = "SHAP Python package not available"
58+ shap_api_change_str = "SHAP calculation requires 2025.0 API"
5659cb_unavailable_str = "CatBoost not available"
5760
5861# CatBoost's SHAP value calculation seems to be buggy
@@ -208,15 +211,15 @@ def test_model_predict_shap_contribs_missing_values(self):
208211 np .testing .assert_allclose (d4p_pred , xgboost_pred , rtol = 5e-6 )
209212
210213
211- # duplicate all tests for bae_score =0.0
214+ # duplicate all tests for base_score =0.0
212215@unittest .skipUnless (shap_supported , reason = shap_not_supported_str )
213216class XGBoostRegressionModelBuilder_base_score0 (XGBoostRegressionModelBuilder ):
214217 @classmethod
215218 def setUpClass (cls ):
216219 XGBoostRegressionModelBuilder .setUpClass (0 )
217220
218221
219- # duplicate all tests for bae_score =100
222+ # duplicate all tests for base_score =100
220223@unittest .skipUnless (shap_supported , reason = shap_not_supported_str )
221224class XGBoostRegressionModelBuilder_base_score100 (XGBoostRegressionModelBuilder ):
222225 @classmethod
@@ -235,7 +238,7 @@ def setUpClass(cls, base_score=0.5, n_classes=2, objective="binary:logistic"):
235238 n_samples = 500 ,
236239 n_classes = n_classes ,
237240 n_features = n_features ,
238- n_informative = 10 ,
241+ n_informative = ( 2 * n_features ) // 3 ,
239242 random_state = 42 ,
240243 )
241244 cls .X_test = X [:2 , :]
@@ -282,25 +285,59 @@ def test_missing_value_support(self):
282285 def test_model_predict_shap_contribs (self ):
283286 booster = self .xgb_model .get_booster ()
284287 m = d4p .mb .convert_model (booster )
285- with self .assertRaises (NotImplementedError ):
286- m .predict (self .X_test , pred_contribs = True )
288+ if not shap_api_changed :
289+ with self .assertRaises (NotImplementedError ):
290+ m .predict (self .X_test , pred_contribs = True )
291+ elif self .n_classes > 2 :
292+ with self .assertRaisesRegex (
293+ RuntimeError , "Multiclass classification SHAP values not supported"
294+ ):
295+ m .predict (self .X_test , pred_contribs = True )
296+ else :
297+ d4p_pred = m .predict (self .X_test , pred_contribs = True )
298+ xgboost_pred = booster .predict (
299+ xgb .DMatrix (self .X_test ),
300+ pred_contribs = True ,
301+ approx_contribs = False ,
302+ validate_features = False ,
303+ )
304+ np .testing .assert_allclose (d4p_pred , xgboost_pred , rtol = 1e-5 )
287305
288306 def test_model_predict_shap_interactions (self ):
289307 booster = self .xgb_model .get_booster ()
290308 m = d4p .mb .convert_model (booster )
291- with self .assertRaises (NotImplementedError ):
292- m .predict (self .X_test , pred_contribs = True )
293-
294-
295- # duplicate all tests for bae_score=0.3
309+ if not shap_api_changed :
310+ with self .assertRaises (NotImplementedError ):
311+ m .predict (self .X_test , pred_contribs = True )
312+ elif self .n_classes > 2 :
313+ with self .assertRaisesRegex (
314+ RuntimeError , "Multiclass classification SHAP values not supported"
315+ ):
316+ m .predict (self .X_test , pred_interactions = True )
317+ else :
318+ d4p_pred = m .predict (self .X_test , pred_interactions = True )
319+ xgboost_pred = booster .predict (
320+ xgb .DMatrix (self .X_test ),
321+ pred_interactions = True ,
322+ approx_contribs = False ,
323+ validate_features = False ,
324+ )
325+ # hitting floating precision limits for classification where class probabilities
326+ # are between 0 and 1
327+ # we need to accept large relative differences, as long as the absolute difference
328+ # remains small (<1e-6)
329+ np .testing .assert_allclose (d4p_pred , xgboost_pred , rtol = 5e-2 , atol = 1e-6 )
330+
331+
332+ # duplicate all tests for base_score=0.3
296333@unittest .skipUnless (shap_supported , reason = shap_not_supported_str )
297334class XGBoostClassificationModelBuilder_base_score03 (XGBoostClassificationModelBuilder ):
298335 @classmethod
299336 def setUpClass (cls ):
300337 XGBoostClassificationModelBuilder .setUpClass (base_score = 0.3 )
301338
302339
303- # duplicate all tests for bae_score =0.7
340+ # duplicate all tests for base_score =0.7
304341@unittest .skipUnless (shap_supported , reason = shap_not_supported_str )
305342class XGBoostClassificationModelBuilder_base_score07 (XGBoostClassificationModelBuilder ):
306343 @classmethod
@@ -328,6 +365,16 @@ def setUpClass(cls):
328365class XGBoostClassificationModelBuilder_objective_logitraw (
329366 XGBoostClassificationModelBuilder
330367):
368+ """
369+ Caveat: logitraw is not per se supported in daal4py because we always
370+
371+ 1. apply the bias
372+ 2. normalize to probabilities ("activation") using sigmoid
373+ (exception: SHAP values, the scores defining phi_ij are the raw class scores)
374+
375+ However, by undoing the activation and bias we can still compare if the original probas and SHAP values are aligned.
376+ """
377+
331378 @classmethod
332379 def setUpClass (cls ):
333380 XGBoostClassificationModelBuilder .setUpClass (
@@ -352,6 +399,42 @@ def test_model_predict_proba(self):
352399 # accept an rtol of 1e-5
353400 np .testing .assert_allclose (d4p_pred , xgboost_pred , rtol = 1e-5 )
354401
402+ @unittest .skipUnless (shap_api_changed , reason = shap_api_change_str )
403+ def test_model_predict_shap_contribs (self ):
404+ booster = self .xgb_model .get_booster ()
405+ with self .assertWarns (UserWarning ):
406+ # expect a warning that logitraw behaves differently and/or
407+ # that base_score is ignored / fixed to 0.5
408+ m = d4p .mb .convert_model (self .xgb_model .get_booster ())
409+ d4p_pred = m .predict (self .X_test , pred_contribs = True )
410+ xgboost_pred = booster .predict (
411+ xgb .DMatrix (self .X_test ),
412+ pred_contribs = True ,
413+ approx_contribs = False ,
414+ validate_features = False ,
415+ )
416+ # undo bias
417+ d4p_pred [:, - 1 ] += 0.5
418+ np .testing .assert_allclose (d4p_pred , xgboost_pred , rtol = 5e-6 )
419+
420+ @unittest .skipUnless (shap_api_changed , reason = shap_api_change_str )
421+ def test_model_predict_shap_interactions (self ):
422+ booster = self .xgb_model .get_booster ()
423+ with self .assertWarns (UserWarning ):
424+ # expect a warning that logitraw behaves differently and/or
425+ # that base_score is ignored / fixed to 0.5
426+ m = d4p .mb .convert_model (self .xgb_model .get_booster ())
427+ d4p_pred = m .predict (self .X_test , pred_interactions = True )
428+ xgboost_pred = booster .predict (
429+ xgb .DMatrix (self .X_test ),
430+ pred_interactions = True ,
431+ approx_contribs = False ,
432+ validate_features = False ,
433+ )
434+ # undo bias
435+ d4p_pred [:, - 1 , - 1 ] += 0.5
436+ np .testing .assert_allclose (d4p_pred , xgboost_pred , rtol = 5e-5 )
437+
355438
356439@unittest .skipUnless (shap_supported , reason = shap_not_supported_str )
357440class LightGBMRegressionModelBuilder (unittest .TestCase ):
0 commit comments