From 60db2faf6d2bed5d9852f01d91a44e9918446fab Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:32:50 +0200 Subject: [PATCH 1/2] fix(models): compare array-valued LSS family options by value The continuation check compared family options with options != stored, which raises for NumPy array values such as quantile levels, so every fit(rebuild=False) failed once distributional_kwargs held an array. Compare arrays with np.array_equal; changed options are still rejected. Fixes #563 --- deeptab/models/lss_base.py | 22 +++++++++++++++++++++- tests/integration/test_lss_base.py | 25 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/deeptab/models/lss_base.py b/deeptab/models/lss_base.py index 1b1c7365..78f524c5 100644 --- a/deeptab/models/lss_base.py +++ b/deeptab/models/lss_base.py @@ -28,6 +28,24 @@ from deeptab.training import TaskModel +def _same_family_options(options: dict, stored: dict) -> bool: + """Compare family options, treating NumPy arrays (e.g. quantile levels) by value. + + A plain ``options != stored`` raises for array values, because NumPy cannot + turn an element-wise comparison into a single truth value. + """ + if options.keys() != stored.keys(): + return False + for key, value in options.items(): + other = stored[key] + if isinstance(value, np.ndarray) or isinstance(other, np.ndarray): + if not np.array_equal(value, other): + return False + elif value != other: + return False + return True + + class SklearnBaseLSS(SklearnBase): """Distributional regression base class (LSS variant of SklearnBase). @@ -59,7 +77,9 @@ def _configure_distribution(self, y, family=None, distributional_kwargs=None, *, raise ValueError("Dirichlet num_classes must match the number of target components") options["num_classes"] = count if not allow_change: - if family != getattr(self, "family_name", None) or options != getattr(self, "distributional_kwargs_", {}): + if family != getattr(self, "family_name", None) or not _same_family_options( + options, getattr(self, "distributional_kwargs_", {}) + ): raise ValueError("Changing the distribution family or options requires rebuild=True") return self.family = get_distribution(family, **options) diff --git a/tests/integration/test_lss_base.py b/tests/integration/test_lss_base.py index d9a45eb1..2d0f9f9b 100644 --- a/tests/integration/test_lss_base.py +++ b/tests/integration/test_lss_base.py @@ -532,6 +532,31 @@ def test_reused_build_rejects_changed_options_without_mutating_family(): assert model.family_name == "normal" +def test_reused_build_accepts_numpy_array_options(): + model = _small_lss() + quantiles = np.array([0.1, 0.5, 0.9]) + model.build_model(_X[:24], _Y[:24], family="quantile", distributional_kwargs={"quantiles": quantiles}) + model.fit(_X[:24], _Y[:24], family="quantile", rebuild=False) + model.fit(_X[:24], _Y[:24], family="quantile", distributional_kwargs={"quantiles": [0.1, 0.5, 0.9]}, rebuild=False) + assert model.is_fitted_ + assert model.predict(_X[:4]).shape == (4, 3) + + +def test_reused_build_rejects_different_numpy_array_options(): + model = _small_lss() + model.build_model( + _X[:24], _Y[:24], family="quantile", distributional_kwargs={"quantiles": np.array([0.1, 0.5, 0.9])} + ) + with pytest.raises(ValueError, match="requires rebuild=True"): + model.fit( + _X[:24], + _Y[:24], + family="quantile", + distributional_kwargs={"quantiles": np.array([0.2, 0.5, 0.8])}, + rebuild=False, + ) + + def test_legacy_lss_bundle_loads_without_distributional_kwargs(tmp_path, fitted_mlplss): path = str(tmp_path / "legacy.deeptab") fitted_mlplss.save(path) From 40392beb419feed0eda8075bea62c8af32fca513 Mon Sep 17 00:00:00 2001 From: ChrisW09 <50968720+ChrisW09@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:01:49 +0200 Subject: [PATCH 2/2] fix(models): compare tensor family options by value as well distributional_kwargs={"quantiles": torch.tensor([...])} failed the same way NumPy arrays did when fit(rebuild=False) compared the options. Refs #563 --- deeptab/models/lss_base.py | 15 ++++++++++----- tests/integration/test_lss_base.py | 6 ++++-- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/deeptab/models/lss_base.py b/deeptab/models/lss_base.py index 78f524c5..7ec44b6c 100644 --- a/deeptab/models/lss_base.py +++ b/deeptab/models/lss_base.py @@ -28,18 +28,23 @@ from deeptab.training import TaskModel +def _as_numpy(value): + """Return *value* as a NumPy array; tensors are moved to the CPU first.""" + return value.detach().cpu().numpy() if isinstance(value, torch.Tensor) else np.asarray(value) + + def _same_family_options(options: dict, stored: dict) -> bool: - """Compare family options, treating NumPy arrays (e.g. quantile levels) by value. + """Compare family options, treating arrays and tensors (e.g. quantile levels) by value. - A plain ``options != stored`` raises for array values, because NumPy cannot - turn an element-wise comparison into a single truth value. + A plain ``options != stored`` raises for array values, because NumPy and + torch cannot turn an element-wise comparison into a single truth value. """ if options.keys() != stored.keys(): return False for key, value in options.items(): other = stored[key] - if isinstance(value, np.ndarray) or isinstance(other, np.ndarray): - if not np.array_equal(value, other): + if isinstance(value, np.ndarray | torch.Tensor) or isinstance(other, np.ndarray | torch.Tensor): + if not np.array_equal(_as_numpy(value), _as_numpy(other)): return False elif value != other: return False diff --git a/tests/integration/test_lss_base.py b/tests/integration/test_lss_base.py index 2d0f9f9b..138a6297 100644 --- a/tests/integration/test_lss_base.py +++ b/tests/integration/test_lss_base.py @@ -532,9 +532,11 @@ def test_reused_build_rejects_changed_options_without_mutating_family(): assert model.family_name == "normal" -def test_reused_build_accepts_numpy_array_options(): +@pytest.mark.parametrize( + "quantiles", [np.array([0.1, 0.5, 0.9]), torch.tensor([0.1, 0.5, 0.9], dtype=torch.float64)], ids=["numpy", "torch"] +) +def test_reused_build_accepts_array_options(quantiles): model = _small_lss() - quantiles = np.array([0.1, 0.5, 0.9]) model.build_model(_X[:24], _Y[:24], family="quantile", distributional_kwargs={"quantiles": quantiles}) model.fit(_X[:24], _Y[:24], family="quantile", rebuild=False) model.fit(_X[:24], _Y[:24], family="quantile", distributional_kwargs={"quantiles": [0.1, 0.5, 0.9]}, rebuild=False)