From d8dd4154762705badadceff6effb88c854ef94e3 Mon Sep 17 00:00:00 2001 From: flyingdoubleG <53119724+flyingdoubleG@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:03:51 +0800 Subject: [PATCH] Fix merge_ebms crash when every feature is categorical merge_ebms only set feature_bounds_ on the merged model when some bound was not NaN. With only categorical features every bound is NaN, so the attribute was never set and the later _harmonize_tensor calls raised AttributeError. Fitted EBMs keep NaN bounds for categoricals, so do the same here. Signed-off-by: flyingdoubleG <53119724+flyingdoubleG@users.noreply.github.com> --- .../glassbox/_ebm_core/_merge_ebms.py | 12 ++++----- .../tests/glassbox/ebm/test_merge_ebms.py | 26 +++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/python/interpret-core/interpret/glassbox/_ebm_core/_merge_ebms.py b/python/interpret-core/interpret/glassbox/_ebm_core/_merge_ebms.py index fcab58c5a..ecb43fb0d 100644 --- a/python/interpret-core/interpret/glassbox/_ebm_core/_merge_ebms.py +++ b/python/interpret-core/interpret/glassbox/_ebm_core/_merge_ebms.py @@ -559,14 +559,14 @@ def merge_ebms(models): with warnings.catch_warnings(): warnings.simplefilter("ignore", category=RuntimeWarning) + # nanmin/nanmax give NaN for features that are categorical in every model. + # Keep those rows like a fitted EBM does, so the attribute exists even when + # all features are categorical. min_feature_vals = np.nanmin(min_feature_vals, axis=0) max_feature_vals = np.nanmax(max_feature_vals, axis=0) - if any(not isnan(val) for val in min_feature_vals) or any( - not isnan(val) for val in max_feature_vals - ): - ebm.feature_bounds_ = np.array( - list(zip(min_feature_vals, max_feature_vals)), np.float64 - ) + ebm.feature_bounds_ = np.array( + list(zip(min_feature_vals, max_feature_vals)), np.float64 + ) if not is_dp: if all( diff --git a/python/interpret-core/tests/glassbox/ebm/test_merge_ebms.py b/python/interpret-core/tests/glassbox/ebm/test_merge_ebms.py index 22584a14b..6e3324a5f 100644 --- a/python/interpret-core/tests/glassbox/ebm/test_merge_ebms.py +++ b/python/interpret-core/tests/glassbox/ebm/test_merge_ebms.py @@ -5,6 +5,7 @@ import warnings import numpy as np +import pandas as pd from interpret.glassbox import ( ExplainableBoostingClassifier, ExplainableBoostingRegressor, @@ -467,3 +468,28 @@ def test_merge_ebms_predictions_unchanged(): assert np.isfinite(probabilities).all() assert (probabilities >= 0.0).all() assert (probabilities <= 1.0).all() + + +def test_merge_ebms_all_categorical(): + """Merging EBMs whose features are all categorical keeps ``feature_bounds_``. + + The merged bounds are NaN for categoricals, as in a fitted EBM, and the + merged model predicts the bag-weighted average of the input models. + """ + rng = np.random.default_rng(0) + X = pd.DataFrame( + {"c1": rng.choice(list("abcd"), 400), "c2": rng.choice(list("xyz"), 400)} + ) + y = (X["c1"] == "a") + (X["c2"] == "z") * (X["c1"] == "b") + rng.normal(0, 0.1, 400) + + ebm1 = ExplainableBoostingRegressor(outer_bags=3, random_state=1) + ebm1.fit(X[:200], y[:200]) + ebm2 = ExplainableBoostingRegressor(outer_bags=5, random_state=2) + ebm2.fit(X[200:], y[200:]) + + merged = merge_ebms([ebm1, ebm2]) + + assert np.isnan(merged.feature_bounds_).all() + np.testing.assert_allclose( + merged.predict(X), (3 * ebm1.predict(X) + 5 * ebm2.predict(X)) / 8 + )