From 6059b35db72c7186197cd69df97a16d363da15dd Mon Sep 17 00:00:00 2001 From: Paul Koch Date: Tue, 27 Oct 2026 01:05:46 -0700 Subject: [PATCH 1/3] Refine per-feature smoothing_rounds handling - Copy the per-term smoothing counters in boost() so every outer bag starts with its full budget, including when bags run in-process. - Cache whether smoothing is active instead of scanning all counters on each boosting step. - Validate a sequence interaction_smoothing_rounds against the cleaned interactions list before boosting starts. - Accept any integer-valued scalar or 1-D array-like; reject booleans and strings. - Write array-valued smoothing parameters as lists in to_json. - Describe the sequence forms consistently in the estimator docs. - Add tests for multiple outer bags, exclusion alignment, validation before boosting, array-like inputs, and JSON export. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../interpret-core/interpret/glassbox/_ebm.py | 178 ++++++++---------- .../interpret/glassbox/_ebm_core/_boost.py | 40 ++-- .../interpret/glassbox/_ebm_core/_json.py | 7 +- .../glassbox/ebm/test_smoothing_rounds.py | 148 +++++++++++++++ 4 files changed, 249 insertions(+), 124 deletions(-) diff --git a/python/interpret-core/interpret/glassbox/_ebm.py b/python/interpret-core/interpret/glassbox/_ebm.py index 488660f48..8b93bf289 100644 --- a/python/interpret-core/interpret/glassbox/_ebm.py +++ b/python/interpret-core/interpret/glassbox/_ebm.py @@ -227,65 +227,49 @@ 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, + 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 + arr = np.asarray(value) + is_numeric = np.issubdtype(arr.dtype, np.integer) or np.issubdtype( + arr.dtype, np.floating + ) - if isinstance(value, float): - if not value.is_integer(): + if arr.ndim == 0: + if not is_numeric or not float(arr).is_integer(): 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" - _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) + 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_numeric or not np.all(np.mod(arr, 1) == 0): + 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) + return arr.astype(np.int64) class EBMExplanation(FeatureValueExplanation): @@ -1040,7 +1024,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 +1055,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 +1103,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 +1524,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 +3562,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 +3828,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 +4155,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..ad02d01fa 100644 --- a/python/interpret-core/interpret/glassbox/_ebm_core/_json.py +++ b/python/interpret-core/interpret/glassbox/_ebm_core/_json.py @@ -167,11 +167,14 @@ def _to_json_inner(ebm, detail="all"): if hasattr(ebm, "greedy_ratio"): params["greedy_ratio"] = ebm.greedy_ratio + # these also accept 1-D array-likes, so convert to built-in types if hasattr(ebm, "smoothing_rounds"): - params["smoothing_rounds"] = ebm.smoothing_rounds + params["smoothing_rounds"] = np.asarray(ebm.smoothing_rounds).tolist() if hasattr(ebm, "interaction_smoothing_rounds"): - params["interaction_smoothing_rounds"] = ebm.interaction_smoothing_rounds + params["interaction_smoothing_rounds"] = np.asarray( + ebm.interaction_smoothing_rounds + ).tolist() 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..7de430c21 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,146 @@ 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])], +) +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)]) +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] From fc1a3885383463ee122b29f19be574c2c7c4d365 Mon Sep 17 00:00:00 2001 From: Paul Koch Date: Tue, 27 Oct 2026 02:22:16 -0700 Subject: [PATCH 2/3] Reject smoothing_rounds values beyond the int64 range Values of 2**63 or more, such as a list containing 2**63 or floats like 1e19, now raise a ValueError instead of wrapping to negative counters. Scalars that large raise the same error up front instead of an OverflowError inside boost(). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../interpret-core/interpret/glassbox/_ebm.py | 17 +++++++++-- .../glassbox/ebm/test_smoothing_rounds.py | 30 +++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/python/interpret-core/interpret/glassbox/_ebm.py b/python/interpret-core/interpret/glassbox/_ebm.py index 8b93bf289..fe8e849c3 100644 --- a/python/interpret-core/interpret/glassbox/_ebm.py +++ b/python/interpret-core/interpret/glassbox/_ebm.py @@ -230,8 +230,8 @@ def _normalize_smoothing_rounds( 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, - non-integers, or wrong dimensionality. + sequence is not broadcast). Raises ``ValueError`` on negatives, values + beyond the int64 range, non-integers, or wrong dimensionality. """ if value is None: return 0 @@ -251,6 +251,10 @@ def _normalize_smoothing_rounds( msg = f"{name} cannot be negative" _log.error(msg) raise ValueError(msg) + if scalar > np.iinfo(np.int64).max: + msg = f"{name} cannot exceed {np.iinfo(np.int64).max}" + _log.error(msg) + raise ValueError(msg) return scalar if arr.ndim != 1: @@ -269,7 +273,14 @@ def _normalize_smoothing_rounds( msg = f"{name} entries cannot be negative" _log.error(msg) raise ValueError(msg) - return arr.astype(np.int64) + # compare as Python numbers; NumPy can round when a comparison mixes + # int64, uint64, and float + values = 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): 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 7de430c21..dc3116176 100644 --- a/python/interpret-core/tests/glassbox/ebm/test_smoothing_rounds.py +++ b/python/interpret-core/tests/glassbox/ebm/test_smoothing_rounds.py @@ -619,3 +619,33 @@ def test_to_json_with_array_smoothing_rounds(): 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), + [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) From 49e0b91e3ed7507541e07600087d4a6f77237d1f Mon Sep 17 00:00:00 2001 From: Paul Koch Date: Tue, 27 Oct 2026 02:55:25 -0700 Subject: [PATCH 3/3] Handle longdouble smoothing_rounds values exactly - Check a scalar smoothing_rounds for a fractional part in the input's own dtype, as sequences already were. Converting to float first could drop the fraction of an extended-precision longdouble. - Convert sequence entries to exact Python ints before the int64 range check. Comparing longdouble values with Python ints can round, or raise TypeError or OverflowError, depending on the platform and NumPy version. - Write the smoothing parameters to JSON as built-in ints so longdouble values serialize. None is still written as null. - Skip the RuntimeWarning that inf entries triggered during the check. - Add longdouble cases to the smoothing_rounds tests. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../interpret-core/interpret/glassbox/_ebm.py | 21 ++++--- .../interpret/glassbox/_ebm_core/_json.py | 14 +++-- .../glassbox/ebm/test_smoothing_rounds.py | 61 ++++++++++++++++++- 3 files changed, 82 insertions(+), 14 deletions(-) diff --git a/python/interpret-core/interpret/glassbox/_ebm.py b/python/interpret-core/interpret/glassbox/_ebm.py index fe8e849c3..bb8284324 100644 --- a/python/interpret-core/interpret/glassbox/_ebm.py +++ b/python/interpret-core/interpret/glassbox/_ebm.py @@ -237,12 +237,16 @@ def _normalize_smoothing_rounds( return 0 arr = np.asarray(value) - is_numeric = np.issubdtype(arr.dtype, np.integer) or np.issubdtype( - arr.dtype, np.floating - ) + # 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_numeric or not float(arr).is_integer(): + if not is_integral: msg = f"{name} must be an integer or a sequence of integers" _log.error(msg) raise ValueError(msg) @@ -265,7 +269,7 @@ def _normalize_smoothing_rounds( msg = f"{name} cannot be an empty sequence" _log.error(msg) raise ValueError(msg) - if not is_numeric or not np.all(np.mod(arr, 1) == 0): + if not is_integral: msg = f"{name} entries must all be integers" _log.error(msg) raise ValueError(msg) @@ -273,9 +277,10 @@ def _normalize_smoothing_rounds( msg = f"{name} entries cannot be negative" _log.error(msg) raise ValueError(msg) - # compare as Python numbers; NumPy can round when a comparison mixes - # int64, uint64, and float - values = arr.tolist() + # 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) diff --git a/python/interpret-core/interpret/glassbox/_ebm_core/_json.py b/python/interpret-core/interpret/glassbox/_ebm_core/_json.py index ad02d01fa..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. @@ -167,14 +174,13 @@ def _to_json_inner(ebm, detail="all"): if hasattr(ebm, "greedy_ratio"): params["greedy_ratio"] = ebm.greedy_ratio - # these also accept 1-D array-likes, so convert to built-in types if hasattr(ebm, "smoothing_rounds"): - params["smoothing_rounds"] = np.asarray(ebm.smoothing_rounds).tolist() + params["smoothing_rounds"] = _jsonify_int_param(ebm.smoothing_rounds) if hasattr(ebm, "interaction_smoothing_rounds"): - params["interaction_smoothing_rounds"] = np.asarray( + params["interaction_smoothing_rounds"] = _jsonify_int_param( ebm.interaction_smoothing_rounds - ).tolist() + ) 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 dc3116176..6d12fd747 100644 --- a/python/interpret-core/tests/glassbox/ebm/test_smoothing_rounds.py +++ b/python/interpret-core/tests/glassbox/ebm/test_smoothing_rounds.py @@ -563,7 +563,13 @@ def test_bad_interaction_smoothing_rounds_fails_before_boosting(interactions, ma @pytest.mark.parametrize( "value", - [[5, 6, 7], range(5, 8), pd.Series([5, 6, 7]), np.array([5.0, 6.0, 7.0])], + [ + [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() @@ -579,7 +585,9 @@ def test_smoothing_rounds_array_likes(value): 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)]) +@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 = { @@ -628,6 +636,7 @@ def test_to_json_with_array_smoothing_rounds(): 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], ], ) @@ -649,3 +658,51 @@ def test_smoothing_rounds_int64_max_accepted(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