From 60a69918de4023d1421ad477e2b6a70faca10d04 Mon Sep 17 00:00:00 2001 From: bartzbeielstein <32470350+bartzbeielstein@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:36:46 +0200 Subject: [PATCH] fix(transform): search transformed int dims continuously, repair in natural space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #87. An integer dimension with an active transform was int-cast in TRANSFORMED space: (10, 5000, "log10") became internal bounds (1, 3), so internal rounding collapsed the dimension to the decade exponents {10, 100, 1000} in natural scale — and proposals drawn from the un-cast lower/upper arrays (1.0, 3.699) could round to exponent 4, i.e. 10000, silently EXCEEDING the declared cap (observed in a live spotforecast2 tuning run on 2026-06-05). The sqrt analogue restricted int dims to perfect squares. The integer constraint belongs to the natural scale, not the transformed scale: - transform_bounds keeps float internal bounds for int dims with an active transform (untransformed int and factor dims unchanged). - New SpotOptim.internal_var_type property: per-dimension types for internal-space repair — "float" for transformed int dims. All internal-space repair sites (initial design, user X0, design top-up, acquisition candidates/fallbacks) now use it. - New SpotOptim.repair_natural_X / utils.variables.repair_natural_X: rounds int dims to the nearest integer in NATURAL space and clips transformed ones to the declared bounds; applied in evaluate_function (the objective only ever sees admissible integers) and in the X_ / best_x_ storage paths (serial, restart, and steady-state parallel), so the recorded history matches what the objective saw. Handles full- and reduced-dimension layouts. - SpotOptimProtocol extended with the new members. - tests/test_int_log10_dims.py: 13 regression tests (continuous internal bounds, integer admissible values, no decade collapse, bound respect in history/best across serial and parallel paths, float+log10 and plain int dims unchanged). test_transform_bounds_mixed_var_types updated: it asserted the buggy int-cast; a new companion test pins the unchanged behaviour for untransformed int dims. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/spotoptim_class.qmd | 2 + src/spotoptim/SpotOptim.py | 86 +++++++++- src/spotoptim/core/protocol.py | 2 + src/spotoptim/core/storage.py | 8 +- src/spotoptim/optimizer/acquisition.py | 15 +- src/spotoptim/optimizer/steady_state.py | 3 + src/spotoptim/utils/transform.py | 20 ++- src/spotoptim/utils/variables.py | 84 ++++++++++ tests/test_int_log10_dims.py | 202 ++++++++++++++++++++++++ tests/test_transform_bounds.py | 40 +++-- 10 files changed, 437 insertions(+), 25 deletions(-) create mode 100644 tests/test_int_log10_dims.py diff --git a/docs/spotoptim_class.qmd b/docs/spotoptim_class.qmd index f1f908a1..8164cafa 100644 --- a/docs/spotoptim_class.qmd +++ b/docs/spotoptim_class.qmd @@ -11,6 +11,8 @@ description: "Structure of the Methods" * detect_var_type * modify_bounds_based_on_var_type * repair_non_numeric +* internal_var_type (property) +* repair_natural_X * handle_default_var_trans * process_factor_bounds diff --git a/src/spotoptim/SpotOptim.py b/src/spotoptim/SpotOptim.py index 81b2bdbf..1b510a60 100644 --- a/src/spotoptim/SpotOptim.py +++ b/src/spotoptim/SpotOptim.py @@ -1073,6 +1073,8 @@ def set_seed(self) -> None: # * detect_var_type # * modify_bounds_based_on_var_type # * repair_non_numeric + # * internal_var_type (property) + # * repair_natural_X # * handle_default_var_trans # * process_factor_bounds # ==================== @@ -1157,6 +1159,66 @@ def repair_non_numeric(self, X: np.ndarray, var_type: List[str]) -> np.ndarray: """ return _vars.repair_non_numeric(X, var_type) + @property + def internal_var_type(self) -> List[str]: + """Variable types effective in the internal (transformed) search space. + + Integer dimensions with an active transform are searched continuously + in transformed space and rounded only after the inverse transform, in + natural space (``repair_natural_X``). Rounding their internal + representation would collapse e.g. a ``(10, 5000, "log10")`` dimension + to the decade exponents — four values in natural scale, the largest of + which exceeds the declared upper bound (issue #87). Use this property + instead of ``var_type`` whenever repairing points in internal scale. + + Returns: + list of str: Per-dimension types for internal-space repair — + ``"float"`` for transformed integer dimensions, the declared + type otherwise. + + Examples: + ```{python} + import numpy as np + from spotoptim import SpotOptim + opt = SpotOptim(fun=lambda X: np.sum(X**2, axis=1), + bounds=[(10, 5000), (-5, 5)], + var_type=['int', 'int'], + var_trans=['log10', None]) + print(opt.internal_var_type) + ``` + """ + return _vars.internal_var_type(self) + + def repair_natural_X(self, X: np.ndarray) -> np.ndarray: + """Enforce integrality and declared bounds in natural (original) space. + + Integer dimensions are rounded to the nearest integer; integer + dimensions with an active transform are additionally clipped to their + declared natural bounds, because the inverse transform of a continuous + internal proposal can land marginally outside them (issue #87). Float + and factor dimensions pass through unchanged. + + Args: + X (ndarray): Points in natural scale, shape (n_samples, n_features) + or (n_features,). + + Returns: + ndarray: Repaired copy of ``X`` (same shape as the input). + + Examples: + ```{python} + import numpy as np + from spotoptim import SpotOptim + opt = SpotOptim(fun=lambda X: np.sum(X**2, axis=1), + bounds=[(10, 5000)], + var_type=['int'], + var_trans=['log10']) + X_nat = np.array([[4999.99999], [10.4], [5000.2]]) + print(opt.repair_natural_X(X_nat)) + ``` + """ + return _vars.repair_natural_X(self, X) + def handle_default_var_trans(self) -> None: """Handle default variable transformations. Does not perform any transformations, only sets `var_trans` to a list of `None` values if not specified, or normalizes @@ -1734,7 +1796,8 @@ def get_initial_design(self, X0: Optional[np.ndarray] = None) -> np.ndarray: # If X0 is in full dimensions and we have dimension reduction, reduce it if self.red_dim and X0.shape[1] == len(self.ident): X0 = self.to_red_dim(X0) - X0 = self.repair_non_numeric(X0, self.var_type) + # Internal scale: transformed int dims stay continuous (issue #87). + X0 = self.repair_non_numeric(X0, self.internal_var_type) return X0 @@ -1764,7 +1827,8 @@ def generate_initial_design(self) -> np.ndarray: # Scale to [lower, upper] X0 = self.lower + X0_unit * (self.upper - self.lower) - return self.repair_non_numeric(X0, self.var_type) + # Internal scale: transformed int dims stay continuous (issue #87). + return self.repair_non_numeric(X0, self.internal_var_type) def curate_initial_design(self, X0: np.ndarray) -> np.ndarray: """Remove duplicates and ensure sufficient unique points in initial design. @@ -1839,7 +1903,8 @@ def curate_initial_design(self, X0: np.ndarray) -> np.ndarray: n=n_additional * 2 ) # Generate extras X_extra = self.lower + X_extra_unit * (self.upper - self.lower) - X_extra = self.repair_non_numeric(X_extra, self.var_type) + # Internal scale (issue #87). + X_extra = self.repair_non_numeric(X_extra, self.internal_var_type) # Combine and get unique X_combined = np.vstack([X0_unique, X_extra]) @@ -2708,6 +2773,11 @@ def evaluate_function(self, X: np.ndarray) -> np.ndarray: # Apply inverse transformations to get original scale for function evaluation X_original = self.inverse_transform_X(X) + # Natural-scale repair (issue #87): transformed integer dimensions are + # continuous internally — round them here and clip to the declared + # bounds so the objective only ever sees admissible integer values. + X_original = self.repair_natural_X(X_original) + # Map factor variables to original string values X_for_eval = self.map_to_factor_values(X_original) @@ -3809,9 +3879,13 @@ def _update_best_main_loop( current_best = np.min(y_next) if current_best < self.best_y_: best_idx_in_new = np.argmin(y_next) - # x_next_repeated is in transformed space, convert to original for storage - self.best_x_ = self.inverse_transform_X( - x_next_repeated[best_idx_in_new].reshape(1, -1) + # x_next_repeated is in transformed space, convert to original for + # storage; repair_natural_X rounds transformed int dims and clips + # them to the declared bounds (issue #87). + self.best_x_ = self.repair_natural_X( + self.inverse_transform_X( + x_next_repeated[best_idx_in_new].reshape(1, -1) + ) )[0] self.best_y_ = current_best diff --git a/src/spotoptim/core/protocol.py b/src/spotoptim/core/protocol.py index 6c01b010..0fcfa382 100644 --- a/src/spotoptim/core/protocol.py +++ b/src/spotoptim/core/protocol.py @@ -41,6 +41,7 @@ class SpotOptimProtocol(Protocol): var_type: Optional[list] var_name: Optional[list] var_trans: Optional[list] + internal_var_type: list tolerance_x: Optional[float] max_time: float repeats_initial: int @@ -144,6 +145,7 @@ def suggest_next_infill_point(self) -> np.ndarray: ... def transform_X(self, X: np.ndarray) -> np.ndarray: ... def inverse_transform_X(self, X: np.ndarray) -> np.ndarray: ... def repair_non_numeric(self, X: np.ndarray) -> np.ndarray: ... + def repair_natural_X(self, X: np.ndarray) -> np.ndarray: ... def map_to_factor_values(self, X: np.ndarray) -> np.ndarray: ... def to_all_dim(self, X: np.ndarray) -> np.ndarray: ... diff --git a/src/spotoptim/core/storage.py b/src/spotoptim/core/storage.py index 3676b7f1..dc7aaaea 100644 --- a/src/spotoptim/core/storage.py +++ b/src/spotoptim/core/storage.py @@ -24,7 +24,9 @@ def init_storage(optimizer: SpotOptimProtocol, X0: np.ndarray, y0: np.ndarray) - X0 (ndarray): Initial design points in internal scale, shape (n_samples, n_features). y0 (ndarray): Function values at X0, shape (n_samples,). """ - optimizer.X_ = optimizer.inverse_transform_X(X0.copy()) + # repair_natural_X rounds transformed int dims and clips them to the + # declared bounds (issue #87) so X_ matches what the objective saw. + optimizer.X_ = optimizer.repair_natural_X(optimizer.inverse_transform_X(X0.copy())) optimizer.y_ = y0.copy() optimizer.n_iter_ = 0 @@ -42,7 +44,9 @@ def update_storage( X_new (ndarray): New design points in internal scale, shape (n_new, n_features). y_new (ndarray): Function values at X_new, shape (n_new,). """ - optimizer.X_ = np.vstack([optimizer.X_, optimizer.inverse_transform_X(X_new)]) + # Natural-scale repair mirrors evaluate_function (issue #87). + X_new_natural = optimizer.repair_natural_X(optimizer.inverse_transform_X(X_new)) + optimizer.X_ = np.vstack([optimizer.X_, X_new_natural]) optimizer.y_ = np.append(optimizer.y_, y_new) diff --git a/src/spotoptim/optimizer/acquisition.py b/src/spotoptim/optimizer/acquisition.py index 19da1fda..512c91fc 100644 --- a/src/spotoptim/optimizer/acquisition.py +++ b/src/spotoptim/optimizer/acquisition.py @@ -281,9 +281,10 @@ def try_optimizer_candidates( # Helper to check if a point is valid def is_valid(p, reference_set): - p_rounded = optimizer.repair_non_numeric(p.reshape(1, -1), optimizer.var_type)[ - 0 - ] + # Internal scale: transformed int dims stay continuous (issue #87). + p_rounded = optimizer.repair_non_numeric( + p.reshape(1, -1), optimizer.internal_var_type + )[0] p_2d = p_rounded.reshape(1, -1) x_new, _ = optimizer.select_new( A=p_2d, X=reference_set, tolerance=optimizer.tolerance_x @@ -370,7 +371,10 @@ def handle_acquisition_failure(optimizer: SpotOptimProtocol) -> np.ndarray: x_new_unit = optimizer.lhs_sampler.random(n=1)[0] x_new = optimizer.lower + x_new_unit * (optimizer.upper - optimizer.lower) - return optimizer.repair_non_numeric(x_new.reshape(1, -1), optimizer.var_type)[0] + # Internal scale: transformed int dims stay continuous (issue #87). + return optimizer.repair_non_numeric( + x_new.reshape(1, -1), optimizer.internal_var_type + )[0] def try_fallback_strategy( @@ -403,8 +407,9 @@ def try_fallback_strategy( ) x_next = optimizer._handle_acquisition_failure() + # Internal scale: transformed int dims stay continuous (issue #87). x_next_rounded = optimizer.repair_non_numeric( - x_next.reshape(1, -1), optimizer.var_type + x_next.reshape(1, -1), optimizer.internal_var_type )[0] x_last = x_next_rounded diff --git a/src/spotoptim/optimizer/steady_state.py b/src/spotoptim/optimizer/steady_state.py index 0f179c45..f1131957 100644 --- a/src/spotoptim/optimizer/steady_state.py +++ b/src/spotoptim/optimizer/steady_state.py @@ -45,6 +45,9 @@ def update_storage_steady(optimizer: SpotOptimProtocol, x, y): """ x = np.atleast_2d(x) x = optimizer.inverse_transform_X(x) + # Natural-scale repair mirrors evaluate_function (issue #87): transformed + # int dims are rounded and clipped to the declared bounds before storage. + x = optimizer.repair_natural_X(x) if optimizer.X_ is None: optimizer.X_ = x optimizer.y_ = np.array([y]) diff --git a/src/spotoptim/utils/transform.py b/src/spotoptim/utils/transform.py index 07ab4182..b5c0586f 100644 --- a/src/spotoptim/utils/transform.py +++ b/src/spotoptim/utils/transform.py @@ -217,11 +217,25 @@ def transform_bounds(optimizer: SpotOptimProtocol) -> None: optimizer.lower[i], optimizer.upper[i] = lower_t, upper_t # Update optimizer.bounds to reflect transformed bounds - # Convert numpy types to Python native types (int or float based on var_type) + # Convert numpy types to Python native types (int or float based on var_type). + # + # Integer dimensions with an ACTIVE transform keep float bounds in the + # internal (transformed) space: int-casting e.g. (log10(10), log10(5000)) + # = (1.0, 3.699) to (1, 3) restricts the search to decade exponents + # {10, 100, 1000} in natural scale — and rounding internal proposals drawn + # from the un-cast lower/upper arrays can even reach 10**4 = 10000, + # exceeding the declared cap (issue #87). Such dimensions are searched + # continuously in transformed space; integrality is enforced in natural + # space by ``repair_natural_X`` (round, then clip to the declared bounds). optimizer.bounds = [] for i in range(len(optimizer.lower)): - if i < len(optimizer.var_type) and ( - optimizer.var_type[i] == "int" or optimizer.var_type[i] == "factor" + has_active_trans = ( + i < len(optimizer.var_trans) and optimizer.var_trans[i] is not None + ) + if ( + i < len(optimizer.var_type) + and (optimizer.var_type[i] == "int" or optimizer.var_type[i] == "factor") + and not has_active_trans ): optimizer.bounds.append((int(optimizer.lower[i]), int(optimizer.upper[i]))) else: diff --git a/src/spotoptim/utils/variables.py b/src/spotoptim/utils/variables.py index 9cc7b981..ddd47325 100644 --- a/src/spotoptim/utils/variables.py +++ b/src/spotoptim/utils/variables.py @@ -78,6 +78,90 @@ def repair_non_numeric(X: np.ndarray, var_type: List[str]) -> np.ndarray: return X +def internal_var_type(optimizer: SpotOptimProtocol) -> List[str]: + """Variable types effective in the internal (transformed) search space. + + Integer dimensions with an active transform are searched *continuously* in + transformed space and only rounded after the inverse transform, in natural + space (see ``repair_natural_X``). Rounding their internal representation + would collapse e.g. a ``(10, 5000, "log10")`` dimension to the decade + exponents ``{10, 100, 1000, 10000}`` — four values, the last of which + exceeds the declared upper bound (issue #87). Factor dimensions keep their + integer-index representation regardless of transforms. + + Args: + optimizer: SpotOptim instance. + + Returns: + list of str: Per-dimension types for internal-space repair — + ``"float"`` for transformed integer dimensions, the declared type + otherwise. + """ + return [ + "float" if (vtype == "int" and trans is not None) else vtype + for vtype, trans in zip(optimizer.var_type, optimizer.var_trans) + ] + + +def repair_natural_X(optimizer: SpotOptimProtocol, X: np.ndarray) -> np.ndarray: + """Enforce integrality and declared bounds in natural (original) space. + + Companion to ``repair_non_numeric`` for the *natural* scale: integer + dimensions are rounded to the nearest integer, and integer dimensions with + an active transform are additionally clipped to their declared natural + bounds — the inverse transform of a continuous internal proposal can land + marginally outside them (e.g. ``10 ** 3.69897 = 4999.99...`` rounds to + ``5000``, while floating-point error can also yield ``5000.0000001``). + Float and factor dimensions are left untouched (factor indices are + integral already and are mapped to labels later). + + Handles both full-dimensional input (e.g. ``evaluate_function`` after + ``to_all_dim``) and reduced-dimensional input (the storage paths) by + matching the column count against the optimizer's reduced and full + metadata. Input with an unrecognized width is returned unchanged. + + Args: + optimizer: SpotOptim instance. + X (ndarray): Points in natural scale, shape (n_samples, n_features) + or (n_features,). + + Returns: + ndarray: Repaired copy of ``X`` (same shape as the input). + """ + X_in = np.asarray(X, dtype=float) + one_dim = X_in.ndim == 1 + X_rep = np.atleast_2d(X_in).copy() + n_cols = X_rep.shape[1] + + nat_lower = getattr(optimizer, "_original_lower", None) + nat_upper = getattr(optimizer, "_original_upper", None) + if nat_lower is None or nat_upper is None: + return X_in + + if n_cols == len(optimizer.var_type): + var_type = optimizer.var_type + var_trans = optimizer.var_trans + if getattr(optimizer, "red_dim", False) and len(nat_lower) != n_cols: + ident = optimizer.ident + nat_lower = nat_lower[~ident] + nat_upper = nat_upper[~ident] + elif n_cols == len(getattr(optimizer, "all_var_type", [])): + var_type = optimizer.all_var_type + var_trans = optimizer.all_var_trans + else: + # Unknown column layout: do not guess, return the input unchanged. + return X_in + + for i, (vtype, trans) in enumerate(zip(var_type, var_trans)): + if vtype != "int": + continue + X_rep[:, i] = np.around(X_rep[:, i]) + if trans is not None: + X_rep[:, i] = np.clip(X_rep[:, i], nat_lower[i], nat_upper[i]) + + return X_rep[0] if one_dim else X_rep + + def handle_default_var_trans(optimizer: SpotOptimProtocol) -> None: """Handle default variable transformations. diff --git a/tests/test_int_log10_dims.py b/tests/test_int_log10_dims.py new file mode 100644 index 00000000..77b4a138 --- /dev/null +++ b/tests/test_int_log10_dims.py @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: 2026 bartzbeielstein +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +"""Regression tests for issue #87: int + log10 dimensions. + +Before the fix, an integer dimension with a ``log10`` transform was int-cast +in *transformed* space — ``(10, 5000, "log10")`` became internal bounds +``(1, 3)`` — and internal rounding collapsed the dimension to the decade +exponents ``{10, 100, 1000, 10000}`` in natural scale, with ``10000`` +silently exceeding the declared cap of ``5000`` (observed in a live +spotforecast2 tuning run on 2026-06-05). + +After the fix the dimension is searched continuously in transformed space, +rounded to the nearest integer in natural space, and clipped to the declared +bounds. +""" + +import numpy as np +import pytest + +from spotoptim import SpotOptim +from spotoptim.utils.variables import internal_var_type, repair_natural_X + +LOW, HIGH = 10, 5000 +DECADES = {10, 100, 1000, 10000} + + +def _collecting_fun(seen): + """Objective that records every natural-scale value of dimension 0.""" + + def fun(X, **kwargs): + arr = np.atleast_2d(X) + seen.extend(arr[:, 0].tolist()) + rng = np.random.default_rng(0) + return rng.random(arr.shape[0]) + + return fun + + +def _make_optimizer(seen, **kwargs): + defaults = dict( + fun=_collecting_fun(seen), + bounds=[(LOW, HIGH), (0.0, 1.0)], + var_type=["int", "float"], + var_trans=["log10", None], + max_iter=40, + n_initial=20, + seed=42, + verbose=False, + ) + defaults.update(kwargs) + return SpotOptim(**defaults) + + +class TestInternalBounds: + """Transformed int dims keep continuous internal bounds.""" + + def test_internal_bounds_stay_float(self): + opt = _make_optimizer([]) + lo, hi = opt.bounds[0] + assert isinstance(lo, float) and isinstance(hi, float) + assert lo == pytest.approx(np.log10(LOW)) + assert hi == pytest.approx(np.log10(HIGH)) + + def test_untransformed_int_bounds_still_int_cast(self): + opt = SpotOptim( + fun=lambda X: np.sum(np.atleast_2d(X) ** 2, axis=1), + bounds=[(1.0, 100.0)], + var_type=["int"], + var_trans=[None], + max_iter=1, + n_initial=1, + ) + assert opt.bounds[0] == (1, 100) + assert isinstance(opt.bounds[0][0], int) + assert isinstance(opt.bounds[0][1], int) + + def test_internal_var_type_masks_transformed_int(self): + opt = _make_optimizer([]) + assert internal_var_type(opt) == ["float", "float"] + assert opt.internal_var_type == ["float", "float"] + + def test_internal_var_type_keeps_plain_int_and_factor(self): + opt = SpotOptim( + fun=lambda X: np.sum(np.atleast_2d(X) ** 2, axis=1), + bounds=[(1, 10), (0.0, 1.0)], + var_type=["int", "float"], + var_trans=[None, "sqrt"], + max_iter=1, + n_initial=1, + ) + assert internal_var_type(opt) == ["int", "float"] + + +class TestNaturalValues: + """The objective sees admissible integers, not decade exponents.""" + + def test_values_are_integers_within_bounds(self): + seen = [] + opt = _make_optimizer(seen) + opt.optimize() + values = np.asarray(seen) + assert len(values) > 0 + assert np.all(values >= LOW), f"min {values.min()} < {LOW}" + assert np.all(values <= HIGH), f"max {values.max()} > {HIGH} (issue #87)" + np.testing.assert_array_equal(values, np.around(values)) + + def test_dimension_no_longer_collapses_to_decades(self): + seen = [] + opt = _make_optimizer(seen) + opt.optimize() + distinct = {int(v) for v in seen} + non_decades = distinct - DECADES + assert non_decades, ( + "all evaluated values are decade exponents — the dimension is " + f"still collapsed (issue #87): {sorted(distinct)}" + ) + + def test_history_and_best_respect_bounds(self): + seen = [] + opt = _make_optimizer(seen) + opt.optimize() + col = np.asarray(opt.X_)[:, 0] + assert np.all(col >= LOW) and np.all(col <= HIGH) + np.testing.assert_array_equal(col, np.around(col)) + assert LOW <= opt.best_x_[0] <= HIGH + assert opt.best_x_[0] == round(opt.best_x_[0]) + + def test_parallel_path_respects_bounds(self): + seen = [] + opt = _make_optimizer(seen, n_jobs=2, max_iter=30, n_initial=15) + opt.optimize() + col = np.asarray(opt.X_)[:, 0] + assert np.all(col >= LOW) and np.all(col <= HIGH) + np.testing.assert_array_equal(col, np.around(col)) + + +class TestRepairNaturalX: + """Unit behaviour of the natural-scale repair.""" + + def test_rounds_and_clips_transformed_int(self): + opt = _make_optimizer([]) + X = np.array([[4999.99999, 0.5], [10.4, 0.5], [5000.2, 0.5], [9.6, 0.5]]) + out = opt.repair_natural_X(X) + np.testing.assert_array_equal(out[:, 0], [5000.0, 10.0, 5000.0, 10.0]) + np.testing.assert_array_equal(out[:, 1], X[:, 1]) # float dim untouched + + def test_one_dim_input_keeps_shape(self): + opt = _make_optimizer([]) + out = opt.repair_natural_X(np.array([123.6, 0.25])) + assert out.shape == (2,) + assert out[0] == 124.0 + assert out[1] == 0.25 + + def test_unknown_width_returned_unchanged(self): + opt = _make_optimizer([]) + X = np.array([[1.5, 2.5, 3.5]]) # three columns, optimizer has two dims + out = repair_natural_X(opt, X) + np.testing.assert_array_equal(out, X) + + def test_plain_int_dim_rounded_not_clipped_differently(self): + opt = SpotOptim( + fun=lambda X: np.sum(np.atleast_2d(X) ** 2, axis=1), + bounds=[(1, 10)], + var_type=["int"], + var_trans=[None], + max_iter=1, + n_initial=1, + ) + out = opt.repair_natural_X(np.array([[3.4]])) + assert out[0, 0] == 3.0 + + +class TestFloatLog10Unchanged: + """Float + log10 dimensions keep their existing continuous behaviour.""" + + def test_float_log10_values_in_bounds_and_continuous(self): + seen = [] + + def fun(X, **kwargs): + arr = np.atleast_2d(X) + seen.extend(arr[:, 0].tolist()) + rng = np.random.default_rng(1) + return rng.random(arr.shape[0]) + + opt = SpotOptim( + fun=fun, + bounds=[(0.0001, 0.3)], + var_type=["float"], + var_trans=["log10"], + max_iter=25, + n_initial=12, + seed=7, + verbose=False, + ) + opt.optimize() + values = np.asarray(seen) + assert np.all(values >= 0.0001 - 1e-12) + assert np.all(values <= 0.3 + 1e-12) + # Continuous: values are not all integers + assert not np.allclose(values, np.around(values)) diff --git a/tests/test_transform_bounds.py b/tests/test_transform_bounds.py index da3bad76..2ecc0705 100644 --- a/tests/test_transform_bounds.py +++ b/tests/test_transform_bounds.py @@ -247,7 +247,15 @@ def test_transform_bounds_with_factor_var_type(self): assert isinstance(opt.bounds[1][1], float) def test_transform_bounds_mixed_var_types(self): - """Test transform_bounds() with mixed variable types.""" + """Test transform_bounds() with mixed variable types. + + Integer dimensions with an ACTIVE transform keep float internal + bounds (issue #87): int-casting the transformed bounds restricted + such dimensions to the transform's integer pre-images (perfect + squares for ``sqrt``, decades for ``log10``) and could exceed the + declared natural bounds after internal rounding. Integrality is now + enforced in natural space by ``repair_natural_X``. + """ opt = SpotOptim( fun=lambda X: np.sum(X**2, axis=1), bounds=[(1, 100), (0.1, 10.0), (1, 16)], @@ -257,19 +265,33 @@ def test_transform_bounds_mixed_var_types(self): n_initial=1, ) - # First dimension: int - assert isinstance(opt.bounds[0][0], int) - assert isinstance(opt.bounds[0][1], int) - assert opt.bounds[0] == (1, 10) + # First dimension: int WITH transform -> continuous internal bounds + assert isinstance(opt.bounds[0][0], float) + assert isinstance(opt.bounds[0][1], float) + assert opt.bounds[0] == pytest.approx((1.0, 10.0)) # Second dimension: float assert isinstance(opt.bounds[1][0], float) assert isinstance(opt.bounds[1][1], float) - # Third dimension: int - assert isinstance(opt.bounds[2][0], int) - assert isinstance(opt.bounds[2][1], int) - assert opt.bounds[2] == (1, 4) + # Third dimension: int WITH transform -> continuous internal bounds + assert isinstance(opt.bounds[2][0], float) + assert isinstance(opt.bounds[2][1], float) + assert opt.bounds[2] == pytest.approx((1.0, 4.0)) + + def test_transform_bounds_int_without_transform_stays_int(self): + """Int dimensions WITHOUT a transform keep int-cast internal bounds.""" + opt = SpotOptim( + fun=lambda X: np.sum(X**2, axis=1), + bounds=[(1, 100), (0.1, 10.0)], + var_trans=[None, "log10"], + var_type=["int", "float"], + max_iter=1, + n_initial=1, + ) + assert isinstance(opt.bounds[0][0], int) + assert isinstance(opt.bounds[0][1], int) + assert opt.bounds[0] == (1, 100) class TestTransformBoundsBoundSwapping: