diff --git a/python/interpret-core/interpret/glassbox/_ebm.py b/python/interpret-core/interpret/glassbox/_ebm.py index d1662ee56..3d3e71836 100644 --- a/python/interpret-core/interpret/glassbox/_ebm.py +++ b/python/interpret-core/interpret/glassbox/_ebm.py @@ -221,6 +221,73 @@ def _normalize_callbacks(callbacks): return found.get("examine"), found.get("boost"), found.get("interaction") +def _normalize_smoothing_rounds( + value: float | Sequence[int] | npt.NDArray[Any] | None, + name: str, +) -> int | npt.NDArray[np.int64]: + """Normalize a smoothing_rounds parameter. + + Returns an ``int`` when the caller passed a scalar (or ``None``, which + is treated as zero); returns a 1-D int64 ``ndarray`` when the caller + passed a sequence. Keeping these distinct lets call sites enforce that + a sequence's length match the term count (a length-1 sequence is NOT + silently broadcast). Raises ``ValueError`` on negatives, non-integers, + or wrong dimensionality. + """ + if value is None: + return 0 + + if isinstance(value, (int, np.integer)) and not isinstance(value, bool): + scalar = int(value) + if scalar < 0: + msg = f"{name} cannot be negative" + _log.error(msg) + raise ValueError(msg) + return scalar + + if isinstance(value, float): + if not value.is_integer(): + msg = f"{name} must be an integer or a sequence of integers" + _log.error(msg) + raise ValueError(msg) + scalar = int(value) + if scalar < 0: + msg = f"{name} cannot be negative" + _log.error(msg) + raise ValueError(msg) + return scalar + + if isinstance(value, (list, tuple, np.ndarray)): + arr = np.asarray(value) + if arr.ndim != 1: + msg = f"{name} must be 1-dimensional when passed as a sequence" + _log.error(msg) + raise ValueError(msg) + if arr.size == 0: + msg = f"{name} cannot be an empty sequence" + _log.error(msg) + raise ValueError(msg) + if np.issubdtype(arr.dtype, np.floating): + if not np.all(np.equal(np.mod(arr, 1), 0)): + msg = f"{name} entries must all be integers" + _log.error(msg) + raise ValueError(msg) + elif not np.issubdtype(arr.dtype, np.integer): + msg = f"{name} entries must all be integers" + _log.error(msg) + raise ValueError(msg) + arr = arr.astype(np.int64, copy=False) + if np.any(arr < 0): + msg = f"{name} entries cannot be negative" + _log.error(msg) + raise ValueError(msg) + return arr + + msg = f"{name} must be an integer or a sequence of integers" + _log.error(msg) + raise ValueError(msg) + + class EBMExplanation(FeatureValueExplanation): """Visualizes specifically for EBM.""" @@ -585,29 +652,12 @@ def fit( else: cyclic_progress = float(self.cyclic_progress) - if ( - not isinstance(self.smoothing_rounds, int) - and not self.smoothing_rounds.is_integer() - ): - msg = "smoothing_rounds must be an integer" - _log.error(msg) - raise ValueError(msg) - if self.smoothing_rounds < 0: - msg = "smoothing_rounds cannot be negative" - _log.error(msg) - raise ValueError(msg) - - if ( - not isinstance(self.interaction_smoothing_rounds, int) - and not self.interaction_smoothing_rounds.is_integer() - ): - msg = "interaction_smoothing_rounds must be an integer" - _log.error(msg) - raise ValueError(msg) - if self.interaction_smoothing_rounds < 0: - msg = "interaction_smoothing_rounds cannot be negative" - _log.error(msg) - raise ValueError(msg) + smoothing_rounds_arr = _normalize_smoothing_rounds( + self.smoothing_rounds, "smoothing_rounds" + ) + interaction_smoothing_rounds_arr = _normalize_smoothing_rounds( + self.interaction_smoothing_rounds, "interaction_smoothing_rounds" + ) if ( not isinstance(self.inner_bags, int) @@ -990,8 +1040,9 @@ def fit( inner_bags = 0 greedy_ratio = 0.0 cyclic_progress = 1.0 - smoothing_rounds = 0 - interaction_smoothing_rounds = 0 + # DP-EBM forces no smoothing; ignore any list the user supplied. + smoothing_rounds_arr = 0 + interaction_smoothing_rounds_arr = 0 early_stopping_rounds = 0 early_stopping_tolerance = 0.0 examine_callback = None @@ -1020,8 +1071,9 @@ def fit( term_boost_flags |= Native.TermBoostFlags_Corners inner_bags = self.inner_bags greedy_ratio = self.greedy_ratio - smoothing_rounds = self.smoothing_rounds - interaction_smoothing_rounds = self.interaction_smoothing_rounds + # smoothing_rounds_arr / interaction_smoothing_rounds_arr were + # already parsed and validated above. They are length-1 (scalar + # input) or length-N sequences here. early_stopping_rounds = self.early_stopping_rounds early_stopping_tolerance = self.early_stopping_tolerance examine_callback, boost_callback, interaction_callback = ( @@ -1043,6 +1095,32 @@ def fit( if develop.get_option("full_interaction"): interaction_flags |= Native.CalcInteractionFlags_Full + # Build the smoothing parameter for the mains boost call. + # Scalars (int) are passed through and broadcast inside boost(). + # A user-supplied sequence is validated against n_features_in and + # then gathered down to the post-exclude term_features order. + smoothing_rounds_main: int | npt.NDArray[np.int64] + if isinstance(smoothing_rounds_arr, np.ndarray): + if smoothing_rounds_arr.size != n_features_in: + msg = ( + "smoothing_rounds list length " + f"({smoothing_rounds_arr.size}) must equal the number of " + f"input features ({n_features_in})" + ) + _log.error(msg) + raise ValueError(msg) + if len(term_features) == 0: + # No mains will be boosted (exclude=='mains'); pass 0 as a + # no-op for the boost loop. + smoothing_rounds_main = 0 + else: + smoothing_rounds_main = np.array( + [smoothing_rounds_arr[t[0]] for t in term_features], + dtype=np.int64, + ) + else: + smoothing_rounds_main = smoothing_rounds_arr + exclude_features = set() if monotone_constraints is not None: if len(monotone_constraints) != n_features_in: @@ -1212,7 +1290,7 @@ def fit( else init_score[internal_bags[idx] != 0] ), term_features=term_features, - smoothing_rounds=smoothing_rounds, + smoothing_rounds=smoothing_rounds_main, # if there are no validation samples, turn off early stopping # because the validation metric cannot improve each round early_stopping_rounds=( @@ -1295,6 +1373,12 @@ def fit( shared, ) + # boost_groups_user_idx is only meaningful when the user + # supplied an explicit list of interactions; FAST-discovered + # interactions cannot be aligned to a user-supplied per- + # interaction parameter list. + boost_groups_user_idx: list[int] | None = None + if isinstance(interactions, int): _log.info("Estimating with FAST") @@ -1369,9 +1453,15 @@ def fit( # Check and remove duplicate interaction terms uniquifier = set() boost_groups = [] + # Parallel list mapping each kept group back to its + # index in the user's original interactions list, so + # per-interaction parameters (e.g. a list-form + # interaction_smoothing_rounds) can be gathered after + # dedup/exclude. + boost_groups_user_idx = [] max_dimensions = 0 - for features in interactions: + for user_idx, features in enumerate(interactions): feature_idxs = [] for feature in features: if isinstance(feature, float): @@ -1415,6 +1505,7 @@ def fit( ): uniquifier.add(sorted_tuple) boost_groups.append(feature_idxs) + boost_groups_user_idx.append(user_idx) if max_dimensions > 2: warn( @@ -1427,6 +1518,44 @@ def fit( if stop_flag is not None and stop_flag[0]: break + # Build per-interaction-term smoothing parameter, mirroring + # what we did for the mains boost call. A scalar is passed + # through (broadcast inside boost); a sequence must match + # the number of explicit user interactions. + interaction_smoothing_main: int | npt.NDArray[np.int64] + if isinstance(interaction_smoothing_rounds_arr, np.ndarray): + if boost_groups_user_idx is None: + msg = ( + "interaction_smoothing_rounds can only be a " + "sequence when interactions is an explicit list " + "of feature combinations" + ) + _log.error(msg) + raise ValueError(msg) + if interaction_smoothing_rounds_arr.size != len( + self.interactions + ): + msg = ( + "interaction_smoothing_rounds list length " + f"({interaction_smoothing_rounds_arr.size}) must " + "equal the number of interactions " + f"({len(self.interactions)})" + ) + _log.error(msg) + raise ValueError(msg) + if len(boost_groups) == 0: + interaction_smoothing_main = 0 + else: + interaction_smoothing_main = np.array( + [ + interaction_smoothing_rounds_arr[i] + for i in boost_groups_user_idx + ], + dtype=np.int64, + ) + else: + interaction_smoothing_main = interaction_smoothing_rounds_arr + results = parallel( delayed(booster)( shm_name=shm_name @@ -1448,7 +1577,7 @@ def fit( bag=internal_bags[idx], init_scores=scores_bags[idx], term_features=boost_groups, - smoothing_rounds=interaction_smoothing_rounds, + smoothing_rounds=interaction_smoothing_main, # if there are no validation samples, turn off early stopping # because the validation metric cannot improve each round early_stopping_rounds=( @@ -3449,10 +3578,17 @@ class EBMModel(BaseEBM): it will be used to update internal gain calculations related to how effective each feature is in predicting the target variable. Setting this parameter to a value less than 1.0 can be useful for preventing overfitting. - smoothing_rounds : int, default=100 + smoothing_rounds : int or sequence of int, default=100 Number of initial highly regularized rounds to set the basic shape of the main effect feature graphs. - interaction_smoothing_rounds : int, default=50 + Pass a sequence of length ``n_features_in_`` to set the number of smoothing rounds per + feature; the smoothing phase as a whole runs until every feature has exhausted its budget, + and features that have already hit zero receive normal gain-based updates during the + remaining smoothing iterations. + interaction_smoothing_rounds : int or sequence of int, default=50 Number of initial highly regularized rounds to set the basic shape of the interaction effect feature graphs during fitting. + Pass a sequence to set the rounds per interaction; this is only allowed when ``interactions`` + is an explicit list of feature combinations, and the sequence length must match + ``len(interactions)``. max_rounds : int, default=50000 Total number of boosting rounds with n_terms boosting steps per round. early_stopping_rounds : int, default=100 @@ -3590,8 +3726,8 @@ def __init__( learning_rate: float = 0.02, greedy_ratio: float | None = 10.0, cyclic_progress: bool | float = False, - smoothing_rounds: int | None = 100, - interaction_smoothing_rounds: int | None = 50, + smoothing_rounds: int | Sequence[int] | None = 100, + interaction_smoothing_rounds: int | Sequence[int] | None = 50, max_rounds: int | None = 50000, early_stopping_rounds: int | None = 100, early_stopping_tolerance: float | None = 1e-5, @@ -3709,10 +3845,17 @@ class EBMClassifier(EBMClassifierMixin, EBMModel): it will be used to update internal gain calculations related to how effective each feature is in predicting the target variable. Setting this parameter to a value less than 1.0 can be useful for preventing overfitting. - smoothing_rounds : int, default=75 + smoothing_rounds : int or sequence of int, default=75 Number of initial highly regularized rounds to set the basic shape of the main effect feature graphs. - interaction_smoothing_rounds : int, default=75 + Pass a sequence of length ``n_features_in_`` to set the number of smoothing rounds per + feature; the smoothing phase as a whole runs until every feature has exhausted its budget, + and features that have already hit zero receive normal gain-based updates during the + remaining smoothing iterations. + interaction_smoothing_rounds : int or sequence of int, default=75 Number of initial highly regularized rounds to set the basic shape of the interaction effect feature graphs during fitting. + Pass a sequence to set the rounds per interaction; this is only allowed when ``interactions`` + is an explicit list of feature combinations, and the sequence length must match + ``len(interactions)``. max_rounds : int, default=50000 Total number of boosting rounds with n_terms boosting steps per round. early_stopping_rounds : int, default=100 @@ -3909,8 +4052,8 @@ def __init__( learning_rate: float = 0.015, greedy_ratio: float | None = 10.0, cyclic_progress: bool | float = False, - smoothing_rounds: int | None = 75, - interaction_smoothing_rounds: int | None = 75, + smoothing_rounds: int | Sequence[int] | None = 75, + interaction_smoothing_rounds: int | Sequence[int] | None = 75, max_rounds: int | None = 50000, early_stopping_rounds: int | None = 100, early_stopping_tolerance: float | None = 1e-5, @@ -4032,8 +4175,12 @@ class EBMRegressor(EBMRegressorMixin, EBMModel): to a value less than 1.0 can be useful for preventing overfitting. smoothing_rounds : int, default=500 Number of initial highly regularized rounds to set the basic shape of the main effect feature graphs. + Differentially private EBMs force the smoothing schedule internally, so any sequence value is + validated for type and non-negativity and then ignored at fit time. interaction_smoothing_rounds : int, default=100 Number of initial highly regularized rounds to set the basic shape of the interaction effect feature graphs during fitting. + Differentially private EBMs force the smoothing schedule internally, so any sequence value is + validated for type and non-negativity and then ignored at fit time. max_rounds : int, default=50000 Total number of boosting rounds with n_terms boosting steps per round. early_stopping_rounds : int, default=100 diff --git a/python/interpret-core/interpret/glassbox/_ebm_core/_boost.py b/python/interpret-core/interpret/glassbox/_ebm_core/_boost.py index 6f586dfda..2d2408abb 100644 --- a/python/interpret-core/interpret/glassbox/_ebm_core/_boost.py +++ b/python/interpret-core/interpret/glassbox/_ebm_core/_boost.py @@ -154,6 +154,19 @@ def boost( len(term_features), dtype=np.int64 ) + # Per-term smoothing counter. A scalar input is broadcast + # so the loop is uniform. A term receives random-split + # (smoothing) updates while its own counter is positive; + # the smoothing phase as a whole stays active while any + # counter is positive. + smoothing_counts = np.asarray( + smoothing_rounds, dtype=np.int64 + ).reshape(-1) + if smoothing_counts.size == 1: + smoothing_counts = np.full( + len(term_features), smoothing_counts[0], dtype=np.int64 + ) + while step_idx < max_steps: if state_idx >= 0: # cyclic @@ -181,7 +194,7 @@ def boost( term_idx = random_cyclic_ordering[state_idx] make_progress = False - if cyclic_state >= 1.0 or smoothing_rounds > 0: + if cyclic_state >= 1.0 or smoothing_counts.any(): # if cyclic_state is above 1.0 we make progress make_progress = True else: @@ -219,7 +232,7 @@ def boost( msg = f"Unrecognized missing option '{missing}'. Expected 'low', 'high', 'separate', or 'gain'." raise ValueError(msg) - if smoothing_rounds > 0 and ( + if smoothing_counts[term_idx] > 0 and ( nominal_smoothing or not contains_nominals ): # modify some of our parameters temporarily @@ -388,7 +401,7 @@ def boost( if action is CallbackAction.STOP_CURRENT: break - if len(circular) > 0 and smoothing_rounds <= 0: + if len(circular) > 0 and not smoothing_counts.any(): # during smoothing, do not use early stopping because smoothing # is using random cuts, which means gain is highly variable toss = circular[circular_idx] @@ -407,9 +420,18 @@ def boost( state_idx = state_idx + 1 if len(term_features) <= state_idx: - if smoothing_rounds > 0: + if smoothing_counts.any(): state_idx = 0 # all smoothing rounds are cyclic rounds - smoothing_rounds -= 1 + # Decrement every term that still has rounds left. + # Terms already at 0 stay at 0 (np.maximum clamp), + # so they receive normal gain-based updates while + # other terms finish their smoothing budget. + np.subtract( + smoothing_counts, + 1, + out=smoothing_counts, + where=smoothing_counts > 0, + ) else: state_idx = -greedy_steps if cyclic_state >= 1.0: diff --git a/python/interpret-core/tests/glassbox/ebm/test_smoothing_rounds.py b/python/interpret-core/tests/glassbox/ebm/test_smoothing_rounds.py new file mode 100644 index 000000000..ac570fbb1 --- /dev/null +++ b/python/interpret-core/tests/glassbox/ebm/test_smoothing_rounds.py @@ -0,0 +1,409 @@ +# Copyright (c) 2023 The InterpretML Contributors +# Distributed under the MIT software license + +"""Tests for per-feature ``smoothing_rounds`` (issue #626).""" + +from __future__ import annotations + +import numpy as np +import pytest +from interpret.glassbox import ( + ExplainableBoostingClassifier, + ExplainableBoostingRegressor, +) + + +def _small_dataset(seed: int = 0, n: int = 200): + rng = np.random.default_rng(seed) + X = rng.normal(size=(n, 3)) + y = X[:, 0] * 0.5 + X[:, 1] * 0.2 - X[:, 2] * 0.3 + rng.normal(scale=0.1, size=n) + return X, y + + +def test_scalar_and_broadcast_list_match(): + """A list of identical values must produce the same model as the scalar.""" + X, y = _small_dataset() + + ebm_scalar = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=200, + smoothing_rounds=10, + random_state=42, + n_jobs=1, + ) + ebm_scalar.fit(X, y) + + ebm_list = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=200, + smoothing_rounds=[10, 10, 10], + random_state=42, + n_jobs=1, + ) + ebm_list.fit(X, y) + + pred_scalar = ebm_scalar.predict(X) + pred_list = ebm_list.predict(X) + np.testing.assert_allclose(pred_scalar, pred_list, atol=1e-10) + + +def test_per_feature_zeros_disable_smoothing_for_those_features(): + """Setting ``smoothing_rounds=0`` for some features must still let + other features run their smoothing phase, and the model must train + end-to-end without error. + """ + X, y = _small_dataset() + + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=200, + smoothing_rounds=[50, 0, 0], + random_state=42, + n_jobs=1, + ) + ebm.fit(X, y) + assert ebm.term_features_ == [(0,), (1,), (2,)] + + +def test_zero_everywhere_matches_scalar_zero(): + """An all-zeros list disables smoothing entirely, same as scalar 0.""" + X, y = _small_dataset() + + ebm_zero = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=200, + smoothing_rounds=0, + random_state=42, + n_jobs=1, + ) + ebm_zero.fit(X, y) + + ebm_list_zero = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=200, + smoothing_rounds=[0, 0, 0], + random_state=42, + n_jobs=1, + ) + ebm_list_zero.fit(X, y) + + np.testing.assert_allclose( + ebm_zero.predict(X), ebm_list_zero.predict(X), atol=1e-10 + ) + + +def test_smoothing_rounds_wrong_length_raises(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=50, + smoothing_rounds=[10, 10], # only 2 entries, dataset has 3 features + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="smoothing_rounds list length"): + ebm.fit(X, y) + + +def test_smoothing_rounds_negative_raises(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=50, + smoothing_rounds=[10, -1, 0], + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="cannot be negative"): + ebm.fit(X, y) + + +def test_smoothing_rounds_non_integer_raises(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=50, + smoothing_rounds=[10.5, 5, 0], + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="must all be integers"): + ebm.fit(X, y) + + +def test_smoothing_rounds_empty_sequence_raises(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=50, + smoothing_rounds=[], + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="empty sequence"): + ebm.fit(X, y) + + +def test_smoothing_rounds_scalar_negative_raises(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=50, + smoothing_rounds=-1, + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="cannot be negative"): + ebm.fit(X, y) + + +def test_smoothing_rounds_aligns_with_exclude(): + """When features are excluded, the per-feature list still indexes by + the original feature index. Excluded features' entries are skipped.""" + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=200, + smoothing_rounds=[50, 0, 25], + exclude=[(1,)], + random_state=42, + n_jobs=1, + ) + ebm.fit(X, y) + # Feature 1 was excluded; only mains for features 0 and 2 are present. + assert ebm.term_features_ == [(0,), (2,)] + + +def test_interaction_smoothing_rounds_list_requires_explicit_interactions(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=2, # int -> FAST-discovered, not explicit + outer_bags=1, + max_rounds=200, + smoothing_rounds=5, + interaction_smoothing_rounds=[10, 10], + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="explicit list"): + ebm.fit(X, y) + + +def test_interaction_smoothing_rounds_list_length_must_match(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=[(0, 1), (1, 2)], + outer_bags=1, + max_rounds=200, + smoothing_rounds=5, + interaction_smoothing_rounds=[10], # wrong length + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="interaction_smoothing_rounds list length"): + ebm.fit(X, y) + + +def test_interaction_smoothing_rounds_list_with_explicit_interactions_runs(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=[(0, 1), (1, 2)], + outer_bags=1, + max_rounds=200, + smoothing_rounds=5, + interaction_smoothing_rounds=[10, 0], + random_state=42, + n_jobs=1, + ) + ebm.fit(X, y) + assert (0, 1) in ebm.term_features_ or (1, 0) in ebm.term_features_ + assert (1, 2) in ebm.term_features_ or (2, 1) in ebm.term_features_ + + +def test_classifier_accepts_smoothing_list(): + rng = np.random.default_rng(0) + n_samples, n_features = 200, 4 + X = rng.normal(size=(n_samples, n_features)) + logits = X[:, 0] - 0.5 * X[:, 1] + y = (logits + rng.normal(scale=0.1, size=n_samples) > 0).astype(int) + ebm = ExplainableBoostingClassifier( + interactions=0, + outer_bags=1, + max_rounds=100, + smoothing_rounds=[5] * n_features, + random_state=42, + n_jobs=1, + ) + ebm.fit(X, y) + proba = ebm.predict_proba(X) + assert proba.shape == (n_samples, 2) + np.testing.assert_allclose(proba.sum(axis=1), 1.0, atol=1e-6) + + +# --- Validation-helper coverage tests --------------------------------------- +# These exercise the remaining branches of _normalize_smoothing_rounds and +# the call-site edge cases (no mains, all-interactions-deduped) so the +# helper does not need separate unit tests. + + +def test_smoothing_rounds_none_treated_as_zero(): + """``smoothing_rounds=None`` must be accepted and behave like 0.""" + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=50, + smoothing_rounds=None, + random_state=42, + n_jobs=1, + ) + ebm.fit(X, y) + assert ebm.term_features_ == [(0,), (1,), (2,)] + + +def test_smoothing_rounds_float_scalar_accepted(): + """A whole-numbered float should behave the same as the equivalent int.""" + X, y = _small_dataset() + ebm_int = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=200, + smoothing_rounds=10, + random_state=42, + n_jobs=1, + ) + ebm_int.fit(X, y) + + ebm_float = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=200, + smoothing_rounds=10.0, + random_state=42, + n_jobs=1, + ) + ebm_float.fit(X, y) + np.testing.assert_allclose(ebm_int.predict(X), ebm_float.predict(X), atol=1e-10) + + +def test_smoothing_rounds_float_non_integer_raises(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=50, + smoothing_rounds=10.5, + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="must be an integer or a sequence"): + ebm.fit(X, y) + + +def test_smoothing_rounds_float_negative_raises(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=50, + smoothing_rounds=-3.0, + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="cannot be negative"): + ebm.fit(X, y) + + +def test_smoothing_rounds_2d_array_raises(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=50, + smoothing_rounds=np.array([[10, 5, 0]]), + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="1-dimensional"): + ebm.fit(X, y) + + +def test_smoothing_rounds_object_dtype_array_raises(): + """Arrays with non-int / non-float dtype (e.g. object/string) must be rejected.""" + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=50, + smoothing_rounds=np.array(["10", "5", "0"]), + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="entries must all be integers"): + ebm.fit(X, y) + + +def test_smoothing_rounds_unsupported_type_raises(): + """Types that aren't int/float/list/tuple/ndarray must be rejected.""" + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=50, + smoothing_rounds={10, 5, 0}, # a set + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match="must be an integer or a sequence"): + ebm.fit(X, y) + + +def test_smoothing_rounds_list_with_exclude_mains(): + """Passing a per-feature list together with ``exclude='mains'`` should + not crash; the gather hits the empty-term_features branch.""" + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=[(0, 1)], + outer_bags=1, + max_rounds=100, + smoothing_rounds=[10, 5, 0], + exclude="mains", + random_state=42, + n_jobs=1, + ) + ebm.fit(X, y) + # No mains kept, but the interaction pair must still appear. + main_terms = [t for t in ebm.term_features_ if len(t) == 1] + assert main_terms == [] + + +def test_interaction_smoothing_rounds_list_with_all_interactions_excluded(): + """When every explicit interaction is excluded, boost_groups is empty. + The list-form interaction_smoothing_rounds must still validate against + the user's interactions length without crashing the empty boost call.""" + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=[(0, 1)], + outer_bags=1, + max_rounds=100, + smoothing_rounds=0, + interaction_smoothing_rounds=[10], + exclude=[(0, 1)], + random_state=42, + n_jobs=1, + ) + ebm.fit(X, y) + # The interaction was excluded; only mains remain. + interaction_terms = [t for t in ebm.term_features_ if len(t) > 1] + assert interaction_terms == []