diff --git a/python/interpret-core/interpret/glassbox/_ebm.py b/python/interpret-core/interpret/glassbox/_ebm.py index 488660f48..bb8284324 100644 --- a/python/interpret-core/interpret/glassbox/_ebm.py +++ b/python/interpret-core/interpret/glassbox/_ebm.py @@ -227,65 +227,65 @@ def _normalize_smoothing_rounds( ) -> 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. + Returns an ``int`` for a scalar (``None`` is treated as zero) and a 1-D + int64 ``ndarray`` for any 1-D array-like. Keeping these distinct lets call + sites require that a sequence's length match the term count (a length-1 + sequence is not broadcast). Raises ``ValueError`` on negatives, values + beyond the int64 range, 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(): + arr = np.asarray(value) + # check for whole numbers in the input's own dtype; converting to a Python + # float first could round away a fractional part (e.g. for longdouble) + is_integral = np.issubdtype(arr.dtype, np.integer) + if np.issubdtype(arr.dtype, np.floating): + # inf and nan give a nan remainder, so they fail; silence the warning + with np.errstate(invalid="ignore"): + is_integral = bool(np.all(np.mod(arr, 1) == 0)) + + if arr.ndim == 0: + if not is_integral: msg = f"{name} must be an integer or a sequence of integers" _log.error(msg) raise ValueError(msg) - scalar = int(value) + scalar = int(arr) 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" + if scalar > np.iinfo(np.int64).max: + msg = f"{name} cannot exceed {np.iinfo(np.int64).max}" _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 + return scalar - msg = f"{name} must be an integer or a sequence of integers" - _log.error(msg) - raise ValueError(msg) + 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 not is_integral: + msg = f"{name} entries must all be integers" + _log.error(msg) + raise ValueError(msg) + if np.any(arr < 0): + msg = f"{name} entries cannot be negative" + _log.error(msg) + raise ValueError(msg) + # compare as exact Python ints: NumPy can round when a comparison mixes + # integer and floating-point types, and tolist() leaves longdouble values + # as NumPy scalars + values = [int(v) for v in arr.tolist()] + if max(values) > np.iinfo(np.int64).max: + msg = f"{name} entries cannot exceed {np.iinfo(np.int64).max}" + _log.error(msg) + raise ValueError(msg) + return np.array(values, dtype=np.int64) class EBMExplanation(FeatureValueExplanation): @@ -1040,7 +1040,6 @@ def fit( inner_bags = 0 greedy_ratio = 0.0 cyclic_progress = 1.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 @@ -1072,8 +1071,7 @@ def fit( inner_bags = self.inner_bags greedy_ratio = self.greedy_ratio # smoothing_rounds_arr / interaction_smoothing_rounds_arr were - # already parsed and validated above. They are length-1 (scalar - # input) or length-N sequences here. + # validated above: an int for scalar input, or a 1-D array. early_stopping_rounds = self.early_stopping_rounds early_stopping_tolerance = self.early_stopping_tolerance examine_callback, boost_callback, interaction_callback = ( @@ -1121,6 +1119,30 @@ def fit( else: smoothing_rounds_main = smoothing_rounds_arr + # Check a sequence interaction_smoothing_rounds now so that a bad value + # fails before any boosting. It must line up with an explicit + # interactions list; FAST-selected pairs have no user-defined order. + if isinstance(interaction_smoothing_rounds_arr, np.ndarray): + interactions = clean_interactions(interactions, n_features_in) # type: ignore[assignment] + if isinstance(interactions, int): + if interactions != 0: + msg = ( + "interaction_smoothing_rounds can only be a " + "sequence when interactions is an explicit list " + "of feature combinations" + ) + _log.error(msg) + raise ValueError(msg) + elif interaction_smoothing_rounds_arr.size != len(interactions): + msg = ( + "interaction_smoothing_rounds list length " + f"({interaction_smoothing_rounds_arr.size}) must " + "equal the number of interactions " + f"({len(interactions)})" + ) + _log.error(msg) + raise ValueError(msg) + exclude_features = set() if monotone_constraints is not None: if len(monotone_constraints) != n_features_in: @@ -1518,41 +1540,17 @@ 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. + # A scalar is broadcast inside boost(). A sequence was checked + # against the explicit interactions list before boosting, so + # gather the entries of the pairs that survived dedup/exclude. 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, - ) + assert boost_groups_user_idx is not None + interaction_smoothing_main = ( + interaction_smoothing_rounds_arr[boost_groups_user_idx] + if len(boost_groups_user_idx) != 0 + else 0 + ) else: interaction_smoothing_main = interaction_smoothing_rounds_arr @@ -3580,15 +3578,14 @@ class EBMModel(BaseEBM): to a value less than 1.0 can be useful for preventing overfitting. 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. - 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. + Pass a sequence with one entry per input feature (length ``n_features_in_``) to set the rounds per + feature; entries for excluded features are ignored. Smoothing continues until every feature has used + its rounds, and features that finish early are not boosted until smoothing ends. 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)``. + is an explicit list of feature combinations, and the sequence must have one entry per item in + ``interactions``. Entries for duplicate or excluded interactions are ignored. max_rounds : int, default=50000 Total number of boosting rounds with n_terms boosting steps per round. early_stopping_rounds : int, default=100 @@ -3847,15 +3844,14 @@ class EBMClassifier(EBMClassifierMixin, EBMModel): to a value less than 1.0 can be useful for preventing overfitting. 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. - 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. + Pass a sequence with one entry per input feature (length ``n_features_in_``) to set the rounds per + feature; entries for excluded features are ignored. Smoothing continues until every feature has used + its rounds, and features that finish early are not boosted until smoothing ends. 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)``. + is an explicit list of feature combinations, and the sequence must have one entry per item in + ``interactions``. Entries for duplicate or excluded interactions are ignored. max_rounds : int, default=50000 Total number of boosting rounds with n_terms boosting steps per round. early_stopping_rounds : int, default=100 @@ -4175,14 +4171,14 @@ class EBMRegressor(EBMRegressorMixin, EBMModel): to a value less than 1.0 can be useful for preventing overfitting. smoothing_rounds : int or sequence of int, default=500 Number of initial highly regularized rounds to set the basic shape of the main effect feature graphs. - A sequence (one entry per feature) is accepted for API consistency with the non-private classes, - but differentially private EBMs force the smoothing schedule internally and the value is ignored - at fit time. + Pass a sequence with one entry per input feature (length ``n_features_in_``) to set the rounds per + feature; entries for excluded features are ignored. Smoothing continues until every feature has used + its rounds, and features that finish early are not boosted until smoothing ends. interaction_smoothing_rounds : int or sequence of int, default=100 Number of initial highly regularized rounds to set the basic shape of the interaction effect feature graphs during fitting. - A sequence (one entry per interaction) is accepted for API consistency with the non-private classes, - but differentially private EBMs force the smoothing schedule internally and the value is ignored - at fit time. + 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 must have one entry per item in + ``interactions``. Entries for duplicate or excluded interactions are ignored. 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 a6495e51d..7f48cb03b 100644 --- a/python/interpret-core/interpret/glassbox/_ebm_core/_boost.py +++ b/python/interpret-core/interpret/glassbox/_ebm_core/_boost.py @@ -154,18 +154,18 @@ 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( + # Per-term smoothing counters, decremented in place, so copy + # them: the caller passes the same array to every outer bag. + # A scalar is broadcast to every term. Smoothing stays active + # until every counter reaches zero. + smoothing_counts = np.array( 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 ) + is_smoothing = bool(smoothing_counts.any()) while step_idx < max_steps: if state_idx >= 0: @@ -193,16 +193,9 @@ def boost( term_idx = random_cyclic_ordering[state_idx] - # While the smoothing phase is active, terms that - # have already exhausted their per-term budget sit - # idle: do not compute or apply an update for them. - # Advance state_idx and decrement the per-term - # counters at the end of a cycle, just as if the - # term had been visited normally. - if ( - smoothing_counts.any() - and smoothing_counts[term_idx] == 0 - ): + # While smoothing, terms that have used up their + # rounds sit idle until every term is done. + if is_smoothing and smoothing_counts[term_idx] == 0: state_idx = state_idx + 1 if len(term_features) <= state_idx: state_idx = 0 @@ -212,10 +205,11 @@ def boost( out=smoothing_counts, where=smoothing_counts > 0, ) + is_smoothing = bool(smoothing_counts.any()) continue make_progress = False - if cyclic_state >= 1.0 or smoothing_counts[term_idx] > 0: + if cyclic_state >= 1.0 or is_smoothing: # if cyclic_state is above 1.0 we make progress make_progress = True else: @@ -253,7 +247,9 @@ def boost( msg = f"Unrecognized missing option '{missing}'. Expected 'low', 'high', 'separate', or 'gain'." raise ValueError(msg) - if smoothing_counts[term_idx] > 0 and ( + # idle terms were skipped above, so while smoothing, + # every visited term still has rounds left + if is_smoothing and ( nominal_smoothing or not contains_nominals ): # modify some of our parameters temporarily @@ -422,7 +418,7 @@ def boost( if action is CallbackAction.STOP_CURRENT: break - if len(circular) > 0 and not smoothing_counts.any(): + if len(circular) > 0 and not is_smoothing: # during smoothing, do not use early stopping because smoothing # is using random cuts, which means gain is highly variable toss = circular[circular_idx] @@ -441,18 +437,16 @@ def boost( state_idx = state_idx + 1 if len(term_features) <= state_idx: - if smoothing_counts.any(): + if is_smoothing: state_idx = 0 # all smoothing rounds are cyclic rounds # 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, ) + is_smoothing = bool(smoothing_counts.any()) else: state_idx = -greedy_steps if cyclic_state >= 1.0: diff --git a/python/interpret-core/interpret/glassbox/_ebm_core/_json.py b/python/interpret-core/interpret/glassbox/_ebm_core/_json.py index ee5ead39c..8731d9e92 100644 --- a/python/interpret-core/interpret/glassbox/_ebm_core/_json.py +++ b/python/interpret-core/interpret/glassbox/_ebm_core/_json.py @@ -45,6 +45,13 @@ def jsonify_item(val): return val +def _jsonify_int_param(val): + # integer parameters like smoothing_rounds also accept NumPy scalars and + # 1-D array-likes. tolist() leaves longdouble values as NumPy scalars, so + # cast to int64 first; fit has already checked that the values fit. + return None if val is None else np.asarray(val).astype(np.int64).tolist() + + def _to_json_inner(ebm, detail="all"): """Converts the inner model to a JSONable representation. @@ -168,10 +175,12 @@ def _to_json_inner(ebm, detail="all"): params["greedy_ratio"] = ebm.greedy_ratio if hasattr(ebm, "smoothing_rounds"): - params["smoothing_rounds"] = ebm.smoothing_rounds + params["smoothing_rounds"] = _jsonify_int_param(ebm.smoothing_rounds) if hasattr(ebm, "interaction_smoothing_rounds"): - params["interaction_smoothing_rounds"] = ebm.interaction_smoothing_rounds + params["interaction_smoothing_rounds"] = _jsonify_int_param( + ebm.interaction_smoothing_rounds + ) if hasattr(ebm, "max_rounds"): params["max_rounds"] = ebm.max_rounds diff --git a/python/interpret-core/tests/glassbox/ebm/test_smoothing_rounds.py b/python/interpret-core/tests/glassbox/ebm/test_smoothing_rounds.py index 8ded24ff3..6d12fd747 100644 --- a/python/interpret-core/tests/glassbox/ebm/test_smoothing_rounds.py +++ b/python/interpret-core/tests/glassbox/ebm/test_smoothing_rounds.py @@ -5,7 +5,12 @@ from __future__ import annotations +import io +import json +import warnings + import numpy as np +import pandas as pd import pytest from interpret.glassbox import ( ExplainableBoostingClassifier, @@ -471,3 +476,233 @@ def test_interaction_smoothing_rounds_list_with_all_interactions_excluded(): # The interaction was excluded; only mains remain. interaction_terms = [t for t in ebm.term_features_ if len(t) > 1] assert interaction_terms == [] + + +def test_list_matches_scalar_with_multiple_bags(): + """Each outer bag needs its own smoothing budget, including when the bags + run sequentially in-process (n_jobs=1).""" + X, y = _small_dataset() + kw = { + "interactions": [(0, 1), (1, 2)], + "outer_bags": 3, + "max_rounds": 100, + "random_state": 42, + "n_jobs": 1, + } + scalar = ExplainableBoostingRegressor( + smoothing_rounds=10, interaction_smoothing_rounds=10, **kw + ).fit(X, y) + seq = ExplainableBoostingRegressor( + smoothing_rounds=[10, 10, 10], interaction_smoothing_rounds=[10, 10], **kw + ).fit(X, y) + np.testing.assert_allclose(scalar.predict(X), seq.predict(X), atol=1e-10) + + +def test_exclude_matches_dropping_the_column(): + """Entries follow the original feature index, so excluding a feature must + give the same model as removing its column.""" + X, y = _small_dataset() + kw = { + "interactions": 0, + "outer_bags": 1, + "max_rounds": 100, + "random_state": 42, + "n_jobs": 1, + } + excluded = ExplainableBoostingRegressor( + smoothing_rounds=[6, 0, 9], exclude=[(1,)], **kw + ).fit(X, y) + dropped = ExplainableBoostingRegressor(smoothing_rounds=[6, 9], **kw).fit( + X[:, [0, 2]], y + ) + np.testing.assert_allclose( + excluded.predict(X), dropped.predict(X[:, [0, 2]]), atol=1e-10 + ) + + +def test_interaction_list_skips_duplicate_and_excluded_pairs(): + X, y = _small_dataset() + kw = { + "smoothing_rounds": 0, + "outer_bags": 1, + "max_rounds": 100, + "random_state": 42, + "n_jobs": 1, + } + full = ExplainableBoostingRegressor( + interactions=[(0, 1), (1, 0), (0, 2), (1, 2)], + interaction_smoothing_rounds=[5, 99, 7, 2], + exclude=[(0, 2)], + **kw, + ).fit(X, y) + kept = ExplainableBoostingRegressor( + interactions=[(0, 1), (1, 2)], interaction_smoothing_rounds=[5, 2], **kw + ).fit(X, y) + np.testing.assert_allclose(full.predict(X), kept.predict(X), atol=1e-10) + + +@pytest.mark.parametrize( + ("interactions", "match"), + [([(0, 1), (1, 2)], "list length"), (2, "explicit list")], +) +def test_bad_interaction_smoothing_rounds_fails_before_boosting(interactions, match): + X, y = _small_dataset() + recorder = _StepTermRecorder() + ebm = ExplainableBoostingRegressor( + interactions=interactions, + interaction_smoothing_rounds=[10, 10, 10], + callbacks=recorder, + outer_bags=1, + random_state=42, + n_jobs=1, + ) + with pytest.raises(ValueError, match=match): + ebm.fit(X, y) + assert recorder.records == [] + + +@pytest.mark.parametrize( + "value", + [ + [5, 6, 7], + range(5, 8), + pd.Series([5, 6, 7]), + np.array([5.0, 6.0, 7.0]), + np.array([5, 6, 7], dtype=np.longdouble), + ], +) +def test_smoothing_rounds_array_likes(value): + X, y = _small_dataset() + kw = { + "interactions": 0, + "outer_bags": 1, + "max_rounds": 50, + "random_state": 42, + "n_jobs": 1, + } + expected = ExplainableBoostingRegressor(smoothing_rounds=(5, 6, 7), **kw).fit(X, y) + actual = ExplainableBoostingRegressor(smoothing_rounds=value, **kw).fit(X, y) + np.testing.assert_allclose(actual.predict(X), expected.predict(X), atol=1e-10) + + +@pytest.mark.parametrize( + "value", [np.float32(10), np.int32(10), np.float64(10), np.longdouble(10)] +) +def test_smoothing_rounds_numpy_scalars(value): + X, y = _small_dataset() + kw = { + "interactions": 0, + "outer_bags": 1, + "max_rounds": 50, + "random_state": 42, + "n_jobs": 1, + } + expected = ExplainableBoostingRegressor(smoothing_rounds=10, **kw).fit(X, y) + actual = ExplainableBoostingRegressor(smoothing_rounds=value, **kw).fit(X, y) + np.testing.assert_allclose(actual.predict(X), expected.predict(X), atol=1e-10) + + +def test_smoothing_rounds_bool_raises(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor(smoothing_rounds=True, n_jobs=1) + with pytest.raises(ValueError, match="must be an integer or a sequence"): + ebm.fit(X, y) + + +def test_to_json_with_array_smoothing_rounds(): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=[(0, 1)], + outer_bags=1, + max_rounds=20, + smoothing_rounds=np.array([5, 6, 7]), + interaction_smoothing_rounds=np.array([4]), + random_state=42, + n_jobs=1, + ).fit(X, y) + buf = io.StringIO() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "JSON formats are in beta.*") + ebm.to_json(buf) + params = json.loads(buf.getvalue())["ebm"]["implementation_params"] + assert params["smoothing_rounds"] == [5, 6, 7] + assert params["interaction_smoothing_rounds"] == [4] + + +@pytest.mark.parametrize( + "value", + [ + 2**63, + 1e19, + [2**63, 0, 0], + np.array([2**63, 0, 0], dtype=np.uint64), + np.array([2**63, 0, 0], dtype=np.longdouble), + [1e19, 0, 0], + ], +) +def test_smoothing_rounds_above_int64_raises(value): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor(smoothing_rounds=value, n_jobs=1) + with pytest.raises(ValueError, match="cannot exceed"): + ebm.fit(X, y) + + +@pytest.mark.parametrize("value", [2**63 - 1, [2**63 - 1, 0, 0]]) +def test_smoothing_rounds_int64_max_accepted(value): + X, y = _small_dataset() + ExplainableBoostingRegressor( + interactions=0, + outer_bags=1, + max_rounds=10, + smoothing_rounds=value, + random_state=42, + n_jobs=1, + ).fit(X, y) + + +@pytest.mark.parametrize( + ("value", "match"), + [ + (np.longdouble(1) + np.finfo(np.longdouble).eps, "must be an integer"), + ( + [np.longdouble(1) + np.finfo(np.longdouble).eps, 0, 0], + "must all be integers", + ), + ], +) +def test_smoothing_rounds_longdouble_fraction_raises(value, match): + """Where longdouble is wider than float64, this fraction is lost in a float + conversion, so integrality must be checked in the original dtype.""" + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor(smoothing_rounds=value, n_jobs=1) + with pytest.raises(ValueError, match=match): + ebm.fit(X, y) + + +@pytest.mark.parametrize( + ("smoothing_rounds", "interaction_smoothing_rounds", "expected"), + [ + (np.longdouble(5), np.array([4], dtype=np.longdouble), (5, [4])), + (None, None, (None, None)), + ], +) +def test_to_json_smoothing_rounds_types( + smoothing_rounds, interaction_smoothing_rounds, expected +): + X, y = _small_dataset() + ebm = ExplainableBoostingRegressor( + interactions=[(0, 1)], + outer_bags=1, + max_rounds=20, + smoothing_rounds=smoothing_rounds, + interaction_smoothing_rounds=interaction_smoothing_rounds, + random_state=42, + n_jobs=1, + ).fit(X, y) + buf = io.StringIO() + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", "JSON formats are in beta.*") + ebm.to_json(buf) + params = json.loads(buf.getvalue())["ebm"]["implementation_params"] + actual = (params["smoothing_rounds"], params["interaction_smoothing_rounds"]) + assert actual == expected