Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/spotoptim_class.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
86 changes: 80 additions & 6 deletions src/spotoptim/SpotOptim.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ====================
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions src/spotoptim/core/protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: ...

Expand Down
8 changes: 6 additions & 2 deletions src/spotoptim/core/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)


Expand Down
15 changes: 10 additions & 5 deletions src/spotoptim/optimizer/acquisition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions src/spotoptim/optimizer/steady_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
20 changes: 17 additions & 3 deletions src/spotoptim/utils/transform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
84 changes: 84 additions & 0 deletions src/spotoptim/utils/variables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading
Loading