From 8ce3d0f80d1961009fcbd5ca56c0f186aed1eff4 Mon Sep 17 00:00:00 2001 From: Paul Koch Date: Sat, 17 Oct 2026 23:49:59 -0700 Subject: [PATCH 1/2] Require explicit EBM callback mappings Replace signature inference with event-to-callable mappings and update callback documentation, examples, and regression coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: cddaf74d-b4ad-4361-8299-f2f779330d5a --- docs/benchmarks/ebm-benchmark.ipynb | 11 +- docs/interpret/hyperparameters.md | 50 +++ .../api/ExplainableBoostingClassifier.ipynb | 2 + .../api/ExplainableBoostingRegressor.ipynb | 2 + .../interpret-core/interpret/glassbox/_ebm.py | 248 +++++-------- .../glassbox/_ebm_core/_merge_ebms.py | 4 +- .../tests/glassbox/ebm/test_callback.py | 337 ++++++++++++------ .../tests/glassbox/ebm/test_ebm.py | 6 +- .../tests/glassbox/ebm/test_merge_ebms.py | 2 +- 9 files changed, 376 insertions(+), 286 deletions(-) diff --git a/docs/benchmarks/ebm-benchmark.ipynb b/docs/benchmarks/ebm-benchmark.ipynb index d6c1248e7..39fd5db6f 100644 --- a/docs/benchmarks/ebm-benchmark.ipynb +++ b/docs/benchmarks/ebm-benchmark.ipynb @@ -365,14 +365,15 @@ " def __init__(self, seconds):\n", " self.seconds = seconds\n", "\n", - " def __call__(self, bag_index, step_index, progress, metric):\n", + " def __call__(self, **_kwargs):\n", " import time\n", "\n", " if not hasattr(self, \"end_time\"):\n", " self.end_time = time.monotonic() + self.seconds\n", - " return False\n", - " else:\n", - " return time.monotonic() > self.end_time\n", + " return \"continue\"\n", + " if time.monotonic() > self.end_time:\n", + " return \"stop_all\"\n", + " return \"continue\"\n", "\n", " return Callback(seconds)\n", "\n", @@ -395,7 +396,7 @@ " aplr_params = trial.meta\n", " tabpfn_params = trial.meta\n", "\n", - " # ebm_params[\"callback\"] = callback_generator(60.0 * 60.0) # 1 hour\n", + " # ebm_params[\"callbacks\"] = {\"boost\": callback_generator(60.0 * 60.0)} # 1 hour\n", " ebm_params[\"feature_types\"] = [\n", " \"nominal\" if cat else \"continuous\" for cat in cat_bools\n", " ]\n", diff --git a/docs/interpret/hyperparameters.md b/docs/interpret/hyperparameters.md index 09f15aaaa..f3e8874ff 100644 --- a/docs/interpret/hyperparameters.md +++ b/docs/interpret/hyperparameters.md @@ -172,3 +172,53 @@ guidance: Needs investigation before we offer guidance on tuning. Until then, ex default: 10.0 guidance: Needs investigation before we offer guidance on tuning. Until then, experimentally try [5.0, 10.0, 20.0, 100.0, +inf, NaN] + +## callbacks +default: None + +Use callbacks to observe training or stop it early. Supply a mapping from event +names to callables, such as `callbacks={"boost": record_metric}`. Omitted events +have no callback; `None` or an empty mapping disables all callbacks. + +| Event name | When it runs | Keyword arguments | +| --- | --- | --- | +| `"examine"` | Whenever a term is examined and its gain is calculated | `bag`, `stage`, `step`, `term`, `gain` | +| `"boost"` | After each progressing boosting step | `bag`, `stage`, `step`, `term`, `metric` | +| `"interaction"` | After each interaction strength is computed during interaction detection | `bag`, `term`, `strength` | + +The mapping key selects the event. Callback signatures are not inspected. +A callback must accept all keyword arguments for its event, but it can declare +only the arguments it needs and capture the rest with `**kwargs`: + +```python +from interpret.glassbox import ExplainableBoostingRegressor + +history = [] + +def record_metric(*, metric, **_kwargs): + history.append(metric) + +ebm = ExplainableBoostingRegressor( + callbacks={"boost": record_metric}, + n_jobs=1, +) +ebm.fit(X_train, y_train) +``` + +This example uses `n_jobs=1` so changes to `history` remain in the calling process. +With process-based parallel training, callbacks run in worker processes and +ordinary Python state is not shared with the caller. + +Each callback runs synchronously and can return: + +- `None` or `"continue"` to continue training. +- `"stop_current"` to end the current boosting stage, or interaction detection, + for the current outer bag. Training can continue with later stages. +- `"stop_all"` to stop training across all outer bags. + +The corresponding `interpret.glassbox.CallbackAction` enum members +(`CONTINUE`, `STOP_CURRENT`, and `STOP_ALL`) are also accepted. +Register multiple events in the same mapping, for example +`callbacks={"examine": examine_cb, "boost": boost_cb, "interaction": interaction_cb}`. +The same callable can be registered for multiple events if it accepts each +event's keyword arguments. diff --git a/docs/interpret/python/api/ExplainableBoostingClassifier.ipynb b/docs/interpret/python/api/ExplainableBoostingClassifier.ipynb index b85ccde8e..d2c37c044 100644 --- a/docs/interpret/python/api/ExplainableBoostingClassifier.ipynb +++ b/docs/interpret/python/api/ExplainableBoostingClassifier.ipynb @@ -13,6 +13,8 @@ "\n", "```{eval-rst}\n", ".. autoclass:: interpret.glassbox.ExplainableBoostingClassifier\n", + "\n", + ".. autoclass:: interpret.glassbox.EBMClassifier\n", " :members:\n", " :inherited-members:\n", " :exclude-members: explainer_type, get_params, set_params, get_metadata_routing, set_decision_function_request, set_fit_request, set_predict_proba_request, set_predict_request, set_score_request\n", diff --git a/docs/interpret/python/api/ExplainableBoostingRegressor.ipynb b/docs/interpret/python/api/ExplainableBoostingRegressor.ipynb index 906c4e261..9f5345d65 100644 --- a/docs/interpret/python/api/ExplainableBoostingRegressor.ipynb +++ b/docs/interpret/python/api/ExplainableBoostingRegressor.ipynb @@ -13,6 +13,8 @@ "\n", "```{eval-rst}\n", ".. autoclass:: interpret.glassbox.ExplainableBoostingRegressor\n", + "\n", + ".. autoclass:: interpret.glassbox.EBMRegressor\n", " :members:\n", " :inherited-members:\n", " :exclude-members: explainer_type, get_params, set_params, get_metadata_routing, set_decision_function_request, set_fit_request, set_predict_request, set_score_request\n", diff --git a/python/interpret-core/interpret/glassbox/_ebm.py b/python/interpret-core/interpret/glassbox/_ebm.py index d1662ee56..d2a2266be 100644 --- a/python/interpret-core/interpret/glassbox/_ebm.py +++ b/python/interpret-core/interpret/glassbox/_ebm.py @@ -2,11 +2,10 @@ # Distributed under the MIT software license import heapq -import inspect import json import logging import os -from collections.abc import Callable, Iterable, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from contextlib import nullcontext from copy import deepcopy from functools import partial @@ -100,125 +99,42 @@ ) -_CALLBACK_TYPES = { - "examine": {"bag", "stage", "step", "term", "gain"}, - "boost": {"bag", "stage", "step", "term", "metric"}, - "interaction": {"bag", "term", "strength"}, -} -_CallbackSpec = ( - Callable[..., CallbackAction | str | None] - | Sequence[Callable[..., CallbackAction | str | None]] -) - +_Callback = Callable[..., CallbackAction | str | None] +_CallbackSpec = Mapping[Literal["examine", "boost", "interaction"], _Callback] -def _classify_callback(callback): - if not callable(callback): - msg = ( - "callback must be a callable or an sequence of callables; " - f"got object of type {type(callback).__name__!r}." - ) - _log.error(msg) - raise ValueError(msg) - try: - parameters = inspect.signature(callback).parameters - except (TypeError, ValueError) as exc: - msg = ( - f"callback {getattr(callback, '__qualname__', repr(callback))!r} " - "has no inspectable signature; wrap it in a plain Python function " - "that declares the canonical callback parameters explicitly." - ) - _log.error(msg) - raise ValueError(msg) from exc - - param_names = set(parameters) - matched = [ - name for name, required in _CALLBACK_TYPES.items() if required <= param_names - ] - - if len(matched) == 1: - name = matched[0] - required = _CALLBACK_TYPES[name] - for p in parameters.values(): - if p.name in required: - if p.kind not in ( - inspect.Parameter.POSITIONAL_OR_KEYWORD, - inspect.Parameter.KEYWORD_ONLY, - ): - msg = ( - f"callback {getattr(callback, '__qualname__', repr(callback))!r} " - f"matches the {name!r} callback type but canonical parameter " - f"{p.name!r} is not keyword-callable (e.g. positional-only). All " - "canonical parameters must be declared as POSITIONAL_OR_KEYWORD " - "or KEYWORD_ONLY." - ) - _log.error(msg) - raise ValueError(msg) - elif ( - p.kind - not in ( - inspect.Parameter.VAR_POSITIONAL, - inspect.Parameter.VAR_KEYWORD, - ) - and p.default is inspect.Parameter.empty - ): - msg = ( - f"callback {getattr(callback, '__qualname__', repr(callback))!r} " - f"has additional parameter {p.name!r} without a default value; " - "extra callback parameters must have defaults so the training " - f"engine can invoke the callback with only the canonical {name!r} " - f"kwargs ({sorted(required)})." - ) - _log.error(msg) - raise ValueError(msg) - return name - - if len(matched) == 0: - msg = ( - f"callback {getattr(callback, '__qualname__', repr(callback))!r} " - f"with parameters {sorted(param_names)} does not match any known " - "callback type. The examine callback requires " - "(*, bag, stage, step, term, gain); the boost callback " - "requires (*, bag, stage, step, term, metric); the interaction " - "callback requires (*, bag, term, strength). All canonical " - "parameters must be declared by name - capturing them with a bare " - "'**kwargs' is not allowed. Additional parameters with default " - "values, or a trailing '**kwargs' after the canonical parameters, " - "are permitted." - ) - _log.error(msg) - raise ValueError(msg) - - msg = ( - f"callback {getattr(callback, '__qualname__', repr(callback))!r} " - f"with parameters {sorted(param_names)} matches multiple callback " - f"types {matched}; a callback must include the parameters of " - "exactly one type. The examine callback requires 'gain'; the " - "boost callback requires 'metric'; the interaction callback " - "requires 'strength'. Remove the parameter(s) that do not belong " - "to the intended callback type." - ) - _log.error(msg) - raise ValueError(msg) - - -def _normalize_callbacks(callbacks): +def _normalize_callbacks( + callbacks: _CallbackSpec | None, +) -> tuple[_Callback | None, _Callback | None, _Callback | None]: if callbacks is None: return None, None, None - if callable(callbacks): - callbacks = (callbacks,) + if not isinstance(callbacks, Mapping): + msg = "callbacks must be a mapping from callback names to callables, or None." + _log.error(msg) + raise ValueError(msg) - found: dict[str, Any] = {} - for callback in callbacks: - callback_type = _classify_callback(callback) - if callback_type in found: - msg = f"callbacks cannot contain more than one {callback_type} callback" + for name, callback in callbacks.items(): + if name not in ("examine", "boost", "interaction"): + msg = ( + f"Unknown callback name {name!r}. " + "Expected 'examine', 'boost', or 'interaction'." + ) + _log.error(msg) + raise ValueError(msg) + if not callable(callback): + msg = ( + f"callbacks[{name!r}] must be callable; " + f"got object of type {type(callback).__name__!r}." + ) _log.error(msg) raise ValueError(msg) - found[callback_type] = callback - return found.get("examine"), found.get("boost"), found.get("interaction") + return ( + callbacks.get("examine"), + callbacks.get("boost"), + callbacks.get("interaction"), + ) class EBMExplanation(FeatureValueExplanation): @@ -3474,17 +3390,25 @@ class EBMModel(BaseEBM): tradeoff for the ensemble of models --- not the individual models --- a small amount of overfitting of the individual models can improve the accuracy of the ensemble as a whole. - callbacks : Callable[..., CallbackAction | str | None] | Sequence[Callable[..., CallbackAction | str | None]] | None, default=None - A user-defined callback or sequence of callbacks invoked during training. - An examine callback is invoked whenever a term is examined and its gain is - calculated, and must use keyword-only arguments: - ``def exam_cb(*, bag, stage, step, term, gain)``. - A boost callback is invoked after each progressing boosting step and must use - keyword-only arguments: ``def boost_cb(*, bag, stage, step, term, metric)``. - An interaction callback is invoked after each interaction strength - is computed during interaction detection, and must use - keyword-only arguments: - ``def interaction_cb(*, bag, term, strength)``. + callbacks : Mapping[str, Callable[..., CallbackAction | str | None]] | None, default=None + A mapping from training event names to callbacks, for example + ``{"boost": report}``. Supported keys are ``"examine"``, ``"boost"``, + and ``"interaction"``. Omit a key to disable its callback; ``None`` or + an empty mapping disables all callbacks. + + Callbacks are invoked with keyword arguments: + + - ``"examine"``: ``bag``, ``stage``, ``step``, ``term``, and ``gain``, + whenever a term is examined and its gain is calculated. + - ``"boost"``: ``bag``, ``stage``, ``step``, ``term``, and ``metric``, + after each progressing boosting step. + - ``"interaction"``: ``bag``, ``term``, and ``strength``, + after each interaction strength is computed during interaction detection. + + The mapping key selects the event; callback signatures are not inspected. + Each callback must accept all supplied keyword arguments, either + explicitly or through ``**kwargs``. For example, + ``def report(*, metric, **kwargs)`` can handle ``"boost"`` events. Each callback may return ``None`` (or :attr:`CallbackAction.CONTINUE`) to keep training, :attr:`CallbackAction.STOP_CURRENT` to end the current boosting step (or interaction detection for that bag) for @@ -3493,11 +3417,7 @@ class EBMModel(BaseEBM): and end training. The corresponding string values (``"continue"``, ``"stop_current"``, ``"stop_all"``) are also accepted, so callbacks do not need to import - :class:`~interpret.glassbox.CallbackAction`. The sequence can contain - at most one of each callback type. Callbacks may - declare additional keyword-only parameters beyond the canonical set, - provided they have default values (e.g., for closing over user state - via ``functools.partial`` or default arguments). + :class:`~interpret.glassbox.CallbackAction`. min_samples_leaf : int, default=4 Minimum number of samples allowed in the leaves. min_hessian : float, default=0.0 @@ -3734,17 +3654,25 @@ class EBMClassifier(EBMClassifierMixin, EBMModel): tradeoff for the ensemble of models --- not the individual models --- a small amount of overfitting of the individual models can improve the accuracy of the ensemble as a whole. - callbacks : Callable[..., CallbackAction | str | None] | Sequence[Callable[..., CallbackAction | str | None]] | None, default=None - A user-defined callback or sequence of callbacks invoked during training. - An examine callback is invoked whenever a term is examined and its gain is - calculated, and must use keyword-only arguments: - ``def exam_cb(*, bag, stage, step, term, gain)``. - A boost callback is invoked after each progressing boosting step and must use - keyword-only arguments: ``def boost_cb(*, bag, stage, step, term, metric)``. - An interaction callback is invoked after each interaction strength - is computed during interaction detection, and must use - keyword-only arguments: - ``def interaction_cb(*, bag, term, strength)``. + callbacks : Mapping[str, Callable[..., CallbackAction | str | None]] | None, default=None + A mapping from training event names to callbacks, for example + ``{"boost": report}``. Supported keys are ``"examine"``, ``"boost"``, + and ``"interaction"``. Omit a key to disable its callback; ``None`` or + an empty mapping disables all callbacks. + + Callbacks are invoked with keyword arguments: + + - ``"examine"``: ``bag``, ``stage``, ``step``, ``term``, and ``gain``, + whenever a term is examined and its gain is calculated. + - ``"boost"``: ``bag``, ``stage``, ``step``, ``term``, and ``metric``, + after each progressing boosting step. + - ``"interaction"``: ``bag``, ``term``, and ``strength``, + after each interaction strength is computed during interaction detection. + + The mapping key selects the event; callback signatures are not inspected. + Each callback must accept all supplied keyword arguments, either + explicitly or through ``**kwargs``. For example, + ``def report(*, metric, **kwargs)`` can handle ``"boost"`` events. Each callback may return ``None`` (or :attr:`CallbackAction.CONTINUE`) to keep training, :attr:`CallbackAction.STOP_CURRENT` to end the current boosting step (or interaction detection for that bag) for @@ -3753,11 +3681,7 @@ class EBMClassifier(EBMClassifierMixin, EBMModel): and end training. The corresponding string values (``"continue"``, ``"stop_current"``, ``"stop_all"``) are also accepted, so callbacks do not need to import - :class:`~interpret.glassbox.CallbackAction`. The sequence can contain - at most one of each callback type. Callbacks may - declare additional keyword-only parameters beyond the canonical set, - provided they have default values (e.g., for closing over user state - via ``functools.partial`` or default arguments). + :class:`~interpret.glassbox.CallbackAction`. min_samples_leaf : int, default=4 Minimum number of samples allowed in the leaves. min_hessian : float, default=1e-4 @@ -4055,17 +3979,25 @@ class EBMRegressor(EBMRegressorMixin, EBMModel): tradeoff for the ensemble of models --- not the individual models --- a small amount of overfitting of the individual models can improve the accuracy of the ensemble as a whole. - callbacks : Callable[..., CallbackAction | str | None] | Sequence[Callable[..., CallbackAction | str | None]] | None, default=None - A user-defined callback or sequence of callbacks invoked during training. - An examine callback is invoked whenever a term is examined and its gain is - calculated, and must use keyword-only arguments: - ``def exam_cb(*, bag, stage, step, term, gain)``. - A boost callback is invoked after each progressing boosting step and must use - keyword-only arguments: ``def boost_cb(*, bag, stage, step, term, metric)``. - An interaction callback is invoked after each interaction strength - is computed during interaction detection, and must use - keyword-only arguments: - ``def interaction_cb(*, bag, term, strength)``. + callbacks : Mapping[str, Callable[..., CallbackAction | str | None]] | None, default=None + A mapping from training event names to callbacks, for example + ``{"boost": report}``. Supported keys are ``"examine"``, ``"boost"``, + and ``"interaction"``. Omit a key to disable its callback; ``None`` or + an empty mapping disables all callbacks. + + Callbacks are invoked with keyword arguments: + + - ``"examine"``: ``bag``, ``stage``, ``step``, ``term``, and ``gain``, + whenever a term is examined and its gain is calculated. + - ``"boost"``: ``bag``, ``stage``, ``step``, ``term``, and ``metric``, + after each progressing boosting step. + - ``"interaction"``: ``bag``, ``term``, and ``strength``, + after each interaction strength is computed during interaction detection. + + The mapping key selects the event; callback signatures are not inspected. + Each callback must accept all supplied keyword arguments, either + explicitly or through ``**kwargs``. For example, + ``def report(*, metric, **kwargs)`` can handle ``"boost"`` events. Each callback may return ``None`` (or :attr:`CallbackAction.CONTINUE`) to keep training, :attr:`CallbackAction.STOP_CURRENT` to end the current boosting step (or interaction detection for that bag) for @@ -4074,11 +4006,7 @@ class EBMRegressor(EBMRegressorMixin, EBMModel): and end training. The corresponding string values (``"continue"``, ``"stop_current"``, ``"stop_all"``) are also accepted, so callbacks do not need to import - :class:`~interpret.glassbox.CallbackAction`. The sequence can contain - at most one of each callback type. Callbacks may - declare additional keyword-only parameters beyond the canonical set, - provided they have default values (e.g., for closing over user state - via ``functools.partial`` or default arguments). + :class:`~interpret.glassbox.CallbackAction`. min_samples_leaf : int, default=4 Minimum number of samples allowed in the leaves. min_hessian : float, default=0.0 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..c12e8b45a 100644 --- a/python/interpret-core/interpret/glassbox/_ebm_core/_merge_ebms.py +++ b/python/interpret-core/interpret/glassbox/_ebm_core/_merge_ebms.py @@ -299,9 +299,9 @@ def _initialize_merged_model_params(merged_model, source_models): first_source_model = source_models[0] hyperparameters = first_source_model.get_params(deep=False) - # The callbacks parameter is a per-training-session callable that is + # The callbacks mapping holds per-training-session callables that are # not meaningful for a merged model. Explicitly set it to None to - # avoid retaining a stale reference to the original training callback. + # avoid retaining stale references to the original training callbacks. if "callbacks" in hyperparameters: hyperparameters["callbacks"] = None diff --git a/python/interpret-core/tests/glassbox/ebm/test_callback.py b/python/interpret-core/tests/glassbox/ebm/test_callback.py index 57f56be3e..6ed5d64e0 100644 --- a/python/interpret-core/tests/glassbox/ebm/test_callback.py +++ b/python/interpret-core/tests/glassbox/ebm/test_callback.py @@ -1,16 +1,22 @@ # Copyright (c) 2023 The InterpretML Contributors # Distributed under the MIT software license -"""Regression tests for issue #635: callback API uses keyword-only args.""" +"""Tests for explicit callback registration and keyword-based invocation.""" + +from functools import partial, update_wrapper +from types import MappingProxyType +from unittest.mock import Mock import numpy as np import pytest from interpret.glassbox import ( CallbackAction, + EBMModel, ExplainableBoostingClassifier, ExplainableBoostingRegressor, ) +from interpret.glassbox._ebm import _normalize_callbacks from interpret.utils import make_synthetic @@ -97,7 +103,7 @@ def test_callback_no_repeated_steps_classifier(): outer_bags=1, max_rounds=50, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) ebm.fit(X, y) @@ -129,7 +135,7 @@ def test_callback_no_repeated_steps_regressor(): outer_bags=1, max_rounds=50, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) ebm.fit(X, y) @@ -161,7 +167,7 @@ def test_callback_receives_term_index(): outer_bags=1, max_rounds=50, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) ebm.fit(X, y) @@ -188,7 +194,7 @@ def test_callback_early_termination(): outer_bags=1, max_rounds=5000, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) ebm.fit(X, y) @@ -216,7 +222,7 @@ def test_callback_receives_valid_metrics(): outer_bags=1, max_rounds=50, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) ebm.fit(X, y) @@ -227,11 +233,7 @@ def test_callback_receives_valid_metrics(): def test_callback_keyword_only_signature(): - """Verify the callback is invoked with keyword-only arguments. - - This test ensures that the callback cannot be invoked with positional - arguments, which is the core API change in this PR. - """ + """Verify callbacks are invoked with keywords rather than positional arguments.""" class KeywordOnlyCallback: def __init__(self): @@ -258,15 +260,15 @@ def __call__(self, *, bag, stage, step, term, metric): outer_bags=1, max_rounds=50, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) ebm.fit(X, y) assert cb.called, "Keyword-only callback should have been invoked" -@pytest.mark.parametrize("callback", [None, tuple()]) -def test_fit_without_callback_still_trains(callback): +@pytest.mark.parametrize("callbacks", [None, {}]) +def test_fit_without_callback_still_trains(callbacks): """Verify the no-callback training path still works.""" X, y, names, types = make_synthetic( seed=42, classes=2, output_type="float", n_samples=200 @@ -278,7 +280,7 @@ def test_fit_without_callback_still_trains(callback): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=callback, + callbacks=callbacks, ) ebm.fit(X, y) @@ -300,7 +302,7 @@ def test_exam_callback_receives_valid_gains(): outer_bags=1, max_rounds=50, n_jobs=1, - callbacks=cb, + callbacks={"examine": cb}, ) ebm.fit(X, y) @@ -313,8 +315,8 @@ def test_exam_callback_receives_valid_gains(): assert np.isfinite(gain), f"Gain at step {i} is not finite: {gain}" -def test_callback_tuple_support_calls_both_callbacks(): - """Verify tuple callbacks dispatch both progress and examination hooks.""" +def test_callback_mapping_calls_both_callbacks(): + """Verify mapping keys select both boost and examination hooks.""" progress_cb = RecordingCallback() exam_cb = ExamRecordingCallback() @@ -328,7 +330,7 @@ def test_callback_tuple_support_calls_both_callbacks(): outer_bags=1, max_rounds=50, n_jobs=1, - callbacks=(exam_cb, progress_cb), + callbacks={"examine": exam_cb, "boost": progress_cb}, ) ebm.fit(X, y) @@ -350,7 +352,7 @@ def test_exam_callback_early_termination(): outer_bags=1, max_rounds=5000, n_jobs=1, - callbacks=cb, + callbacks={"examine": cb}, ) ebm.fit(X, y) @@ -363,22 +365,60 @@ def test_exam_callback_early_termination(): assert len(predictions) == len(y) +@pytest.mark.parametrize("mapping_type", [dict, MappingProxyType]) +def test_callback_mapping_preserves_callables(mapping_type): + exam_cb = ExamRecordingCallback() + boost_cb = RecordingCallback() + interaction_cb = InteractionRecordingCallback() + callbacks = mapping_type( + {"interaction": interaction_cb, "boost": boost_cb, "examine": exam_cb} + ) + + assert _normalize_callbacks(callbacks) == (exam_cb, boost_cb, interaction_cb) + assert dict(callbacks) == { + "interaction": interaction_cb, + "boost": boost_cb, + "examine": exam_cb, + } + + @pytest.mark.parametrize( - "callbacks, message", + "callbacks", [ - ((RecordingCallback(), RecordingCallback()), "more than one boost callback"), - ( - (ExamRecordingCallback(), ExamRecordingCallback()), - "more than one examine callback", - ), - ( - (InteractionRecordingCallback(), InteractionRecordingCallback()), - "more than one interaction callback", - ), + RecordingCallback(), + [RecordingCallback()], + (RecordingCallback(),), + [], + (), + 1, + "boost", ], ) -def test_callback_tuple_validation_errors(callbacks, message): - """Verify tuple callback validation errors are raised clearly.""" +def test_callbacks_must_be_a_mapping(callbacks): + with pytest.raises(ValueError, match="callbacks must be a mapping"): + _normalize_callbacks(callbacks) + + +@pytest.mark.parametrize("name", ["progress", "Boost", "", 1]) +def test_callback_names_are_validated(name): + with pytest.raises(ValueError, match="Unknown callback name"): + _normalize_callbacks({name: RecordingCallback()}) + + +@pytest.mark.parametrize("name", ["examine", "boost", "interaction"]) +@pytest.mark.parametrize("callback", [None, 1, [RecordingCallback()]]) +def test_callback_values_must_be_callable(name, callback): + with pytest.raises(ValueError, match="must be callable"): + _normalize_callbacks({name: callback}) + + +def test_callback_missing_keyword_argument_raises_typeerror(): + """Callback signatures must still accept the supplied keyword arguments.""" + + class InvalidCallback: + def __call__(self, *, bag, stage, step, term): + return None + X, y, names, types = make_synthetic( seed=42, classes=2, output_type="float", n_samples=100 ) @@ -389,39 +429,53 @@ def test_callback_tuple_validation_errors(callbacks, message): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=callbacks, + callbacks={"boost": InvalidCallback()}, ) - with pytest.raises(ValueError, match=message): + with pytest.raises(TypeError, match="unexpected keyword argument 'metric'"): ebm.fit(X, y) -def test_callback_signature_requires_metric_or_gain(): - """Verify callbacks are classified by metric/gain keyword names.""" +@pytest.mark.parametrize( + ("model_type", "classes", "objective"), + [ + (EBMModel, None, "rmse"), + (ExplainableBoostingClassifier, 2, "log_loss"), + (ExplainableBoostingRegressor, None, "rmse"), + ], +) +def test_callback_allows_selected_kwargs_boost(model_type, classes, objective): + """A callback can declare only the arguments it needs and capture the rest.""" + invocations = [] - class InvalidCallback: - def __call__(self, *, bag, stage, step, term): - return None + def cb(*, metric, **_kwargs): + invocations.append(metric) X, y, names, types = make_synthetic( - seed=42, classes=2, output_type="float", n_samples=100 + seed=42, classes=classes, output_type="float", n_samples=100 ) - ebm = ExplainableBoostingClassifier( + ebm = model_type( names, types, outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=InvalidCallback(), + callbacks={"boost": cb}, + objective=objective, ) + ebm.fit(X, y) - with pytest.raises(ValueError, match="does not match any known"): - ebm.fit(X, y) + assert len(invocations) > 0 -def test_callback_must_be_callable(): - """Verify non-callable callback values are rejected.""" +def test_callback_allows_selected_kwargs_exam(): + """An examine callback can capture unused keyword arguments.""" + invocations = [] + + def cb(*, gain, **_kwargs): + invocations.append(gain) + X, y, names, types = make_synthetic( seed=42, classes=2, output_type="float", n_samples=100 ) @@ -432,25 +486,21 @@ def test_callback_must_be_callable(): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=1, + callbacks={"examine": cb}, ) + ebm.fit(X, y) - with pytest.raises((TypeError, ValueError)): - ebm.fit(X, y) - + assert len(invocations) > 0 -def test_callback_allows_trailing_kwargs_progress(): - """A progress callback with trailing ``**kwargs`` is accepted. - This lets users opt in to forward-compatible signatures: the library - can add new canonical kwargs in the future without breaking callbacks - that capture them via ``**kwargs``. - """ +def test_callback_registration_ignores_other_event_parameter_names(): + """Parameters from other events do not affect explicit event selection.""" invocations = [] - def cb(*, bag, stage, step, term, metric, **kwargs): + def cb(*, metric, gain=None, strength=None, **_kwargs): + assert gain is None + assert strength is None invocations.append(metric) - return None X, y, names, types = make_synthetic( seed=42, classes=2, output_type="float", n_samples=100 @@ -462,18 +512,18 @@ def cb(*, bag, stage, step, term, metric, **kwargs): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) ebm.fit(X, y) assert len(invocations) > 0 -def test_callback_allows_trailing_kwargs_exam(): - """An exam callback with trailing ``**kwargs`` is accepted.""" +def test_callback_allows_defaulted_extras_and_trailing_kwargs(): + """Defaulted extras and trailing ``**kwargs`` may be combined.""" invocations = [] - def cb(*, bag, stage, step, term, gain, **kwargs): + def cb(*, bag, stage, step, term, gain, foo=None, **kwargs): invocations.append(gain) return None @@ -487,20 +537,27 @@ def cb(*, bag, stage, step, term, gain, **kwargs): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=cb, + callbacks={"examine": cb}, ) ebm.fit(X, y) assert len(invocations) > 0 -def test_callback_allows_defaulted_extra_parameters(): - """Extra parameters are accepted as long as they have default values.""" +@pytest.mark.parametrize( + ("name", "expected_keywords"), + [ + ("examine", {"bag", "stage", "step", "term", "gain"}), + ("boost", {"bag", "stage", "step", "term", "metric"}), + ("interaction", {"bag", "term", "strength"}), + ], +) +def test_callback_accepts_bare_kwargs(name, expected_keywords): + """The mapping key alone selects the event and its keyword arguments.""" invocations = [] - def cb(*, bag, stage, step, term, metric, foo=None, bar=42): - invocations.append(metric) - return None + def cb(**kwargs): + invocations.append(kwargs) X, y, names, types = make_synthetic( seed=42, classes=2, output_type="float", n_samples=100 @@ -512,19 +569,18 @@ def cb(*, bag, stage, step, term, metric, foo=None, bar=42): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=cb, + callbacks={name: cb}, ) ebm.fit(X, y) - assert len(invocations) > 0 + assert invocations + assert all(set(kwargs) == expected_keywords for kwargs in invocations) -def test_callback_allows_defaulted_extras_and_trailing_kwargs(): - """Defaulted extras and trailing ``**kwargs`` may be combined.""" - invocations = [] +def test_callback_missing_extra_argument_raises_typeerror(): + """Unbound required arguments fail when the callback is invoked.""" - def cb(*, bag, stage, step, term, gain, foo=None, **kwargs): - invocations.append(gain) + def cb(*, bag, stage, step, term, metric, extra): return None X, y, names, types = make_synthetic( @@ -537,23 +593,22 @@ def cb(*, bag, stage, step, term, gain, foo=None, **kwargs): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) - ebm.fit(X, y) - - assert len(invocations) > 0 + with pytest.raises(TypeError, match="required keyword-only argument: 'extra'"): + ebm.fit(X, y) -def test_callback_bare_kwargs_shortcut_rejected(): - """A bare ``**kwargs`` callback (no canonical params) is rejected. - The canonical parameters must be declared by name so the user has an - explicit, stable contract for what the callback receives. - """ +def test_callback_signature_is_not_inspected(): + """Callbacks do not need to expose an inspectable signature.""" - def cb(**kwargs): - return None + class UninspectableCallback(RecordingCallback): + @property + def __signature__(self): + pytest.fail("Callback signatures must not be inspected.") + cb = UninspectableCallback() X, y, names, types = make_synthetic( seed=42, classes=2, output_type="float", n_samples=100 ) @@ -564,62 +619,108 @@ def cb(**kwargs): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) + ebm.fit(X, y) - with pytest.raises(ValueError, match="does not match any known"): - ebm.fit(X, y) + assert cb.records -def test_callback_extra_param_without_default_rejected(): - """Extra parameters without defaults remain rejected.""" +@pytest.mark.parametrize("preserve_metadata", [False, True]) +def test_callback_partial_binds_state(preserve_metadata): + invocations = [] - def cb(*, bag, stage, step, term, metric, extra): - return None + def record(records, *, metric, **_kwargs): + records.append(metric) + + cb = partial(record, invocations) + if preserve_metadata: + update_wrapper(cb, record) X, y, names, types = make_synthetic( seed=42, classes=2, output_type="float", n_samples=100 ) - ebm = ExplainableBoostingClassifier( names, types, outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) + ebm.fit(X, y) - with pytest.raises(ValueError, match="without a default value"): - ebm.fit(X, y) + assert invocations -def test_callback_signature_must_be_inspectable(): - """Verify callbacks with uninspectable signatures are rejected.""" +def test_callback_accepts_forwarding_wrapper(): + invocations = [] - class UninspectableCallback: - @property - def __signature__(self): - raise TypeError("uninspectable") + def record(*, metric, **_kwargs): + invocations.append(metric) - def __call__(self, *, bag, stage, step, term, metric): - return None + def cb(*args, **kwargs): + return record(*args, **kwargs) X, y, names, types = make_synthetic( seed=42, classes=2, output_type="float", n_samples=100 ) + ebm = ExplainableBoostingClassifier( + names, + types, + outer_bags=1, + max_rounds=10, + n_jobs=1, + callbacks={"boost": cb}, + ) + ebm.fit(X, y) + assert invocations + + +@pytest.mark.parametrize("use_mock", [False, True]) +def test_callback_accepts_builtin_and_mock(use_mock): + last_event = {} + cb = Mock(wraps=last_event.update) if use_mock else last_event.update + + X, y, names, types = make_synthetic( + seed=42, classes=2, output_type="float", n_samples=100 + ) ebm = ExplainableBoostingClassifier( names, types, outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=UninspectableCallback(), + callbacks={"boost": cb}, ) + ebm.fit(X, y) - with pytest.raises(ValueError, match="inspectable signature"): - ebm.fit(X, y) + assert set(last_event) == {"bag", "stage", "step", "term", "metric"} + + +def test_callback_can_handle_multiple_events(): + invocations = [] + + def cb(**kwargs): + invocations.append(kwargs) + + X, y, names, types = make_synthetic( + seed=42, classes=2, output_type="float", n_samples=100 + ) + ebm = ExplainableBoostingClassifier( + names, + types, + outer_bags=1, + max_rounds=10, + n_jobs=1, + callbacks={"examine": cb, "boost": cb, "interaction": cb}, + ) + ebm.fit(X, y) + + assert any("gain" in kwargs for kwargs in invocations) + assert any("metric" in kwargs for kwargs in invocations) + assert any("strength" in kwargs for kwargs in invocations) # --------------------------------------------------------------------------- @@ -661,7 +762,7 @@ def test_callback_stop_current_only_ends_current_step(): outer_bags=2, max_rounds=50, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) ebm.fit(X, y) @@ -702,7 +803,7 @@ def cb(*, bag, stage, step, term, metric): outer_bags=1, max_rounds=5000, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) ebm.fit(X, y) @@ -725,7 +826,7 @@ def cb(*, bag, stage, step, term, metric): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) with pytest.raises(TypeError, match="callback returned"): @@ -748,7 +849,7 @@ def cb(*, bag, stage, step, term, metric): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=cb, + callbacks={"boost": cb}, ) with pytest.raises(TypeError, match="'halt'"): @@ -774,7 +875,7 @@ def test_interaction_callback_receives_valid_strengths(): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=cb, + callbacks={"interaction": cb}, ) ebm.fit(X, y) @@ -804,7 +905,11 @@ def test_interaction_callback_can_combine_with_others(): outer_bags=1, max_rounds=10, n_jobs=1, - callbacks=(boost_cb, exam_cb, interaction_cb), + callbacks={ + "boost": boost_cb, + "examine": exam_cb, + "interaction": interaction_cb, + }, ) ebm.fit(X, y) @@ -843,7 +948,7 @@ def __call__(self, *, bag, term, strength): outer_bags=2, max_rounds=10, n_jobs=1, - callbacks=cb, + callbacks={"interaction": cb}, ) ebm.fit(X, y) @@ -891,7 +996,7 @@ def __call__(self, *, bag, term, strength): outer_bags=2, max_rounds=10, n_jobs=1, - callbacks=(interaction_cb, boost_cb), + callbacks={"interaction": interaction_cb, "boost": boost_cb}, ) ebm.fit(X, y) diff --git a/python/interpret-core/tests/glassbox/ebm/test_ebm.py b/python/interpret-core/tests/glassbox/ebm/test_ebm.py index 12eabd002..822f78c88 100644 --- a/python/interpret-core/tests/glassbox/ebm/test_ebm.py +++ b/python/interpret-core/tests/glassbox/ebm/test_ebm.py @@ -1350,7 +1350,9 @@ def __call__(self, *, bag, stage, step, term, metric): X, y, names, types = make_synthetic(seed=42, output_type="float", n_samples=10000) # run for half a second - ebm = ExplainableBoostingClassifier(names, types, callbacks=callback_generator(0.5)) + ebm = ExplainableBoostingClassifier( + names, types, callbacks={"boost": callback_generator(0.5)} + ) ebm.fit(X, y) # print(ebm.best_iteration_) @@ -1380,7 +1382,7 @@ def __call__(self, *, bag, stage, step, term, metric): # run for half a second ebm = ExplainableBoostingClassifier( - names, types, callbacks=callback_generator(500000000.0) + names, types, callbacks={"boost": callback_generator(500000000.0)} ) ebm.fit(X, y) 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..920f0e6de 100644 --- a/python/interpret-core/tests/glassbox/ebm/test_merge_ebms.py +++ b/python/interpret-core/tests/glassbox/ebm/test_merge_ebms.py @@ -401,7 +401,7 @@ def training_callback(*, bag, stage, step, term, metric): outer_bags=2, max_rounds=50, random_state=42, - callbacks=training_callback, + callbacks={"boost": training_callback}, ) iris_data = load_iris() classifier_one.fit(iris_data.data, iris_data.target) From 8a19c041082719912a951dd0db3ca2064039ed95 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 06:51:31 +0000 Subject: [PATCH 2/2] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/interpret/hyperparameters.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/interpret/hyperparameters.md b/docs/interpret/hyperparameters.md index f3e8874ff..ca2ee0093 100644 --- a/docs/interpret/hyperparameters.md +++ b/docs/interpret/hyperparameters.md @@ -195,9 +195,11 @@ from interpret.glassbox import ExplainableBoostingRegressor history = [] + def record_metric(*, metric, **_kwargs): history.append(metric) + ebm = ExplainableBoostingRegressor( callbacks={"boost": record_metric}, n_jobs=1,