spotoptim 1.0.2__tar.gz → 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {spotoptim-1.0.2 → spotoptim-2.0.0}/PKG-INFO +1 -1
  2. {spotoptim-1.0.2 → spotoptim-2.0.0}/pyproject.toml +2 -2
  3. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/SpotOptim.py +122 -20
  4. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/function/__init__.py +4 -0
  5. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/function/so.py +102 -0
  6. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/surrogate/kernels.py +5 -3
  7. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/surrogate/kriging.py +4 -2
  8. {spotoptim-1.0.2 → spotoptim-2.0.0}/README.md +0 -0
  9. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/__init__.py +0 -0
  10. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/core/__init__.py +0 -0
  11. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/core/data.py +0 -0
  12. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/core/experiment.py +0 -0
  13. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/core/protocol.py +0 -0
  14. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/core/storage.py +0 -0
  15. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/data/__init__.py +0 -0
  16. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/data/base.py +0 -0
  17. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/data/diabetes.py +0 -0
  18. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/datasets/__init__.py +0 -0
  19. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/datasets/py.typed +0 -0
  20. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/datasets/test01.csv +0 -0
  21. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/datasets/test02.csv +0 -0
  22. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/datasets/test11.csv +0 -0
  23. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/eda/__init__.py +0 -0
  24. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/eda/plots.py +0 -0
  25. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/factor_analyzer/__init__.py +0 -0
  26. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/factor_analyzer/confirmatory_factor_analyzer.py +0 -0
  27. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/factor_analyzer/factor_analyzer.py +0 -0
  28. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/factor_analyzer/factor_analyzer_rotator.py +0 -0
  29. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/factor_analyzer/factor_analyzer_utils.py +0 -0
  30. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/function/cd_data.csv +0 -0
  31. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/function/forr08a.py +0 -0
  32. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/function/mo.py +0 -0
  33. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/function/remote.py +0 -0
  34. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/function/torch_objective.py +0 -0
  35. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/hyperparameters/__init__.py +0 -0
  36. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/hyperparameters/parameters.py +0 -0
  37. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/hyperparameters/repr_helpers.py +0 -0
  38. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/inspection/__init__.py +0 -0
  39. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/inspection/importance.py +0 -0
  40. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/inspection/predictions.py +0 -0
  41. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/mo/__init__.py +0 -0
  42. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/mo/mo_mm.py +0 -0
  43. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/mo/pareto.py +0 -0
  44. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/nn/__init__.py +0 -0
  45. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/nn/linear_regressor.py +0 -0
  46. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/nn/mlp.py +0 -0
  47. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/optimizer/__init__.py +0 -0
  48. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/optimizer/acquisition.py +0 -0
  49. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/optimizer/schedule_free.py +0 -0
  50. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/optimizer/wrapper.py +0 -0
  51. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/plot/__init__.py +0 -0
  52. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/plot/contour.py +0 -0
  53. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/plot/mo.py +0 -0
  54. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/plot/visualization.py +0 -0
  55. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/py.typed +0 -0
  56. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/reporting/__init__.py +0 -0
  57. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/reporting/analysis.py +0 -0
  58. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/reporting/results.py +0 -0
  59. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/sampling/__init__.py +0 -0
  60. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/sampling/design.py +0 -0
  61. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/sampling/effects.py +0 -0
  62. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/sampling/lhs.py +0 -0
  63. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/sampling/mm.py +0 -0
  64. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/surrogate/__init__.py +0 -0
  65. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/surrogate/mlp_surrogate.py +0 -0
  66. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/surrogate/nystroem.py +0 -0
  67. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/surrogate/pipeline.py +0 -0
  68. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/surrogate/simple_kriging.py +0 -0
  69. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/tricands/__init__.py +0 -0
  70. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/tricands/tricands.py +0 -0
  71. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/__init__.py +0 -0
  72. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/boundaries.py +0 -0
  73. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/convert.py +0 -0
  74. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/dimreduction.py +0 -0
  75. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/eval.py +0 -0
  76. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/file.py +0 -0
  77. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/mapping.py +0 -0
  78. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/ocba.py +0 -0
  79. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/pca.py +0 -0
  80. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/scaler.py +0 -0
  81. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/serialization.py +0 -0
  82. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/stats.py +0 -0
  83. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/tensorboard.py +0 -0
  84. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/transform.py +0 -0
  85. {spotoptim-1.0.2 → spotoptim-2.0.0}/src/spotoptim/utils/variables.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: spotoptim
3
- Version: 1.0.2
3
+ Version: 2.0.0
4
4
  Summary: Sequential Parameter Optimization Toolbox
5
5
  Author: bartzbeielstein
6
6
  Author-email: bartzbeielstein <32470350+bartzbeielstein@users.noreply.github.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spotoptim"
3
- version = "1.0.2"
3
+ version = "2.0.0"
4
4
  description = "Sequential Parameter Optimization Toolbox"
5
5
  readme = "README.md"
6
6
  license = { text = "AGPL-3.0-or-later" }
@@ -104,7 +104,7 @@ filterwarnings = [
104
104
  ]
105
105
 
106
106
  [build-system]
107
- requires = ["uv_build>=0.9.18,<1"]
107
+ requires = ["uv_build>=0.9.18"]
108
108
  build-backend = "uv_build"
109
109
 
110
110
  [tool.uv]
@@ -29,6 +29,7 @@ from spotoptim.utils import dimreduction as _dimred
29
29
  from spotoptim.optimizer import acquisition as _acq
30
30
  from spotoptim.core import storage as _storage
31
31
  from spotoptim.optimizer.wrapper import gpr_minimize_wrapper
32
+ from spotoptim.surrogate import Kriging
32
33
 
33
34
 
34
35
  @dataclass
@@ -58,7 +59,14 @@ class SpotOptimConfig:
58
59
  verbose (bool): Whether to print verbose output.
59
60
  warnings_filter (Literal["default", "error", "ignore"]): Filter for warnings.
60
61
  n_infill_points (int): Number of infill points.
61
- max_surrogate_points (Optional[Union[int, List[int]]]): Maximum number of surrogate points. Defaults to 30.
62
+ max_surrogate_points (Optional[Union[int, List[int]]]): Maximum number of
63
+ points used to fit the surrogate. Defaults to ``300`` -- large enough
64
+ that typical Bayesian-optimization budgets (a few hundred evaluations)
65
+ are fit on all points (full surrogate quality), while bounding the
66
+ O(n^3) GP-fit cost on very long runs. Once the evaluation count exceeds
67
+ the cap the training set is subsampled via ``selection_method``. Pass
68
+ ``None`` to always fit on all points, or a smaller int to bound cost
69
+ further.
62
70
  selection_method (str): Method for selecting infill points.
63
71
  acquisition_failure_strategy (str): Strategy for handling acquisition function failures.
64
72
  penalty (bool): Whether to use penalty.
@@ -81,6 +89,10 @@ class SpotOptimConfig:
81
89
  acquisition_optimizer_kwargs (Optional[Dict[str, Any]]): Keyword arguments for the acquisition function optimizer.
82
90
  args (Tuple): Arguments for the objective function.
83
91
  kwargs (Optional[Dict[str, Any]]): Keyword arguments for the objective function.
92
+ metric_factorial (str): Distance metric used by a factor-aware Kriging surrogate for
93
+ nominal (categorical) variables. ``"hamming"`` (default) treats all distinct
94
+ factor levels as equidistant, which is correct for unordered factors.
95
+ Any scipy pairwise distance metric string is accepted.
84
96
 
85
97
 
86
98
  Examples:
@@ -116,7 +128,7 @@ class SpotOptimConfig:
116
128
  verbose=False,
117
129
  warnings_filter="ignore",
118
130
  n_infill_points=1,
119
- max_surrogate_points=30,
131
+ max_surrogate_points=300,
120
132
  selection_method="distant",
121
133
  acquisition_failure_strategy="random",
122
134
  penalty=False,
@@ -161,7 +173,7 @@ class SpotOptimConfig:
161
173
  verbose: bool = False
162
174
  warnings_filter: Literal["default", "error", "ignore"] = "ignore"
163
175
  n_infill_points: int = 1
164
- max_surrogate_points: Optional[Union[int, List[int]]] = 30
176
+ max_surrogate_points: Optional[Union[int, List[int]]] = 300
165
177
  selection_method: str = "distant"
166
178
  acquisition_failure_strategy: str = "random"
167
179
  penalty: bool = False
@@ -181,6 +193,7 @@ class SpotOptimConfig:
181
193
  acquisition_optimizer_kwargs: Optional[Dict[str, Any]] = None
182
194
  args: Tuple = ()
183
195
  kwargs: Optional[Dict[str, Any]] = None
196
+ metric_factorial: str = "hamming"
184
197
 
185
198
  def __post_init__(self):
186
199
  if self.kwargs is None:
@@ -351,7 +364,8 @@ class SpotOptim(BaseEstimator):
351
364
  max_surrogate_points (int, optional):
352
365
  Maximum number of points to use for surrogate model fitting.
353
366
  If None, all points are used. If the number of evaluated points exceeds this limit,
354
- a subset is selected using the selection method. Defaults to 30.
367
+ a subset is selected using the selection method. Defaults to 300 (covers
368
+ typical budgets at full quality while bounding GP-fit cost; pass None for all points).
355
369
  selection_method (str, optional):
356
370
  Method for selecting points when max_surrogate_points is exceeded.
357
371
  Options: 'distant' (Select points that are distant from each other via K-means clustering) or
@@ -428,6 +442,13 @@ class SpotOptim(BaseEstimator):
428
442
  * "cosine": Cosine distance.
429
443
  * "correlation": Correlation distance.
430
444
  * "canberra", "braycurtis", "sqeuclidean", etc.
445
+ metric_factorial (str, optional):
446
+ Distance metric used for nominal (factor) variables by a factor-aware
447
+ Kriging surrogate. ``"hamming"`` (default) treats all distinct factor
448
+ levels as equidistant, which is correct for unordered factors; any
449
+ scipy pairwise distance metric string is accepted. Only takes effect
450
+ when factor variables are present and ``surrogate`` is left as ``None``
451
+ (an order-agnostic Kriging is then built automatically).
431
452
 
432
453
  Attributes:
433
454
  X_ (ndarray): All evaluated points, shape (n_samples, n_features).
@@ -733,7 +754,7 @@ class SpotOptim(BaseEstimator):
733
754
  verbose: bool = False,
734
755
  warnings_filter: Literal["default", "error", "ignore"] = "ignore",
735
756
  n_infill_points: int = 1,
736
- max_surrogate_points: Optional[Union[int, List[int]]] = 30,
757
+ max_surrogate_points: Optional[Union[int, List[int]]] = 300,
737
758
  selection_method: str = "distant",
738
759
  acquisition_failure_strategy: str = "random",
739
760
  penalty: bool = False,
@@ -753,6 +774,7 @@ class SpotOptim(BaseEstimator):
753
774
  acquisition_optimizer_kwargs: Optional[Dict[str, Any]] = None,
754
775
  args: Tuple = (),
755
776
  kwargs: Optional[Dict[str, Any]] = None,
777
+ metric_factorial: str = "hamming",
756
778
  ):
757
779
  warnings.filterwarnings(warnings_filter)
758
780
 
@@ -831,6 +853,7 @@ class SpotOptim(BaseEstimator):
831
853
  acquisition_optimizer_kwargs=acquisition_optimizer_kwargs,
832
854
  args=args,
833
855
  kwargs=kwargs,
856
+ metric_factorial=metric_factorial,
834
857
  )
835
858
 
836
859
  # Initialize State
@@ -900,6 +923,8 @@ class SpotOptim(BaseEstimator):
900
923
 
901
924
  # Initialize surrogate model(s)
902
925
  self.init_surrogate()
926
+ # Warn if factor variables are being modelled with a factor-blind surrogate
927
+ self._validate_factor_surrogate_compat()
903
928
 
904
929
  # Design generator (from scipy.stats.qmc)
905
930
  self.lhs_sampler = LatinHypercube(d=self.n_dim, rng=self.seed)
@@ -1637,24 +1662,89 @@ class SpotOptim(BaseEstimator):
1637
1662
  self._max_surrogate_points_list = None
1638
1663
  self._active_max_surrogate_points = self.config.max_surrogate_points
1639
1664
 
1640
- kernel = ConstantKernel(1.0, (1e-2, 1e12)) * Matern(
1641
- length_scale=1.0, length_scale_bounds=(1e-4, 1e2), nu=2.5
1642
- )
1665
+ if self._factor_maps:
1666
+ # Factor variables are present: use a nominal (order-agnostic) Kriging
1667
+ # surrogate so the kernel treats factor levels as equidistant.
1668
+ # var_type is already the reduced var_type (after setup_dimension_reduction).
1669
+ self.surrogate = Kriging(
1670
+ var_type=list(self.var_type),
1671
+ metric_factorial=self.config.metric_factorial,
1672
+ seed=self.seed if self.seed is not None else 124,
1673
+ )
1674
+ else:
1675
+ # No factor variables: use the standard GaussianProcessRegressor.
1676
+ kernel = ConstantKernel(1.0, (1e-2, 1e12)) * Matern(
1677
+ length_scale=1.0, length_scale_bounds=(1e-4, 1e2), nu=2.5
1678
+ )
1643
1679
 
1644
- # Determine optimizer for GPR
1645
- optimizer = "fmin_l_bfgs_b" # Default used by sklearn
1646
- if self.config.acquisition_optimizer_kwargs is not None:
1647
- optimizer = partial(
1648
- gpr_minimize_wrapper, **self.config.acquisition_optimizer_kwargs
1680
+ # Determine optimizer for GPR
1681
+ optimizer = "fmin_l_bfgs_b" # Default used by sklearn
1682
+ if self.config.acquisition_optimizer_kwargs is not None:
1683
+ optimizer = partial(
1684
+ gpr_minimize_wrapper, **self.config.acquisition_optimizer_kwargs
1685
+ )
1686
+
1687
+ self.surrogate = GaussianProcessRegressor(
1688
+ kernel=kernel,
1689
+ n_restarts_optimizer=100,
1690
+ normalize_y=True,
1691
+ random_state=self.seed,
1692
+ optimizer=optimizer,
1649
1693
  )
1650
1694
 
1651
- self.surrogate = GaussianProcessRegressor(
1652
- kernel=kernel,
1653
- n_restarts_optimizer=100,
1654
- normalize_y=True,
1655
- random_state=self.seed,
1656
- optimizer=optimizer,
1657
- )
1695
+ def _validate_factor_surrogate_compat(self) -> None:
1696
+ """Warn when factor variables are present but the surrogate is factor-blind.
1697
+
1698
+ Called once after ``init_surrogate`` — at that point ``self._factor_maps``
1699
+ is fully populated and the surrogate is set. Fires only when there is at
1700
+ least one factor dimension. Issues a ``UserWarning`` (not an exception) so
1701
+ that users can still proceed but are informed of the mismatch.
1702
+
1703
+ Warns in two situations:
1704
+
1705
+ * The surrogate is not a :class:`~spotoptim.surrogate.Kriging` at all
1706
+ (e.g. sklearn GPR, ``SimpleKriging``, ``RandomForestRegressor``). Such
1707
+ surrogates treat factor integer codes as ordinal numbers, which is
1708
+ incorrect for unordered categorical variables.
1709
+
1710
+ * The surrogate IS a :class:`~spotoptim.surrogate.Kriging` but its
1711
+ ``metric_factorial`` is ``"canberra"``, which is order-dependent and
1712
+ singles out level index 0.
1713
+
1714
+ Returns:
1715
+ None
1716
+ """
1717
+ if not self._factor_maps:
1718
+ return # No factor variables → nothing to validate
1719
+
1720
+ # Use a nested catch_warnings context to ensure this one-time configuration
1721
+ # warning is always visible to the caller, even when the SpotOptim instance
1722
+ # was created with warnings_filter="ignore" (the default). Users who want to
1723
+ # silence it permanently can use warnings.filterwarnings("ignore", ...) before
1724
+ # calling SpotOptim.
1725
+ with warnings.catch_warnings():
1726
+ warnings.simplefilter("always")
1727
+ if not isinstance(self.surrogate, Kriging):
1728
+ warnings.warn(
1729
+ f"Factor variables detected (original bounds dimensions: "
1730
+ f"{sorted(self._factor_maps.keys())}) "
1731
+ f"but the active surrogate ({type(self.surrogate).__name__}) does not "
1732
+ f"support nominal (order-agnostic) factor metrics. Factor integer codes "
1733
+ f"will be treated as ordinal numbers, which may mislead the surrogate. "
1734
+ f"Consider using a factor-aware Kriging surrogate: "
1735
+ f"Kriging(metric_factorial='hamming').",
1736
+ UserWarning,
1737
+ stacklevel=3,
1738
+ )
1739
+ elif self.surrogate.metric_factorial == "canberra":
1740
+ warnings.warn(
1741
+ "Factor variables detected but the Kriging surrogate uses "
1742
+ "metric_factorial='canberra', which is order-dependent and singles "
1743
+ "out factor level index 0. Use metric_factorial='hamming' for "
1744
+ "unordered (nominal) factor variables.",
1745
+ UserWarning,
1746
+ stacklevel=3,
1747
+ )
1658
1748
 
1659
1749
  def get_initial_design(self, X0: Optional[np.ndarray] = None) -> np.ndarray:
1660
1750
  """Generate or process initial design points. Ensures that design points are in
@@ -2046,6 +2136,18 @@ class SpotOptim(BaseEstimator):
2046
2136
  )
2047
2137
  X_fit, y_fit = self.fit_selection_dispatcher(X, y)
2048
2138
 
2139
+ # B3: Propagate the reduced var_type to the surrogate before fitting.
2140
+ # This activates the factor kernel mask on a user-provided Kriging whose
2141
+ # var_type is still at its constructor default (None → ["float"]).
2142
+ # self.var_type is already the reduced var_type here (dimension reduction
2143
+ # runs in setup_dimension_reduction before fitting, and the fit selection
2144
+ # only drops rows). The length guard is a defensive check against an
2145
+ # externally-supplied X whose column count does not match the active space.
2146
+ if hasattr(self.surrogate, "var_type") and len(self.var_type) == X_fit.shape[1]:
2147
+ surrogate_vt = getattr(self.surrogate, "var_type", None)
2148
+ if surrogate_vt is None or surrogate_vt == ["float"]:
2149
+ self.surrogate.var_type = list(self.var_type)
2150
+
2049
2151
  self.surrogate.fit(X_fit, y_fit)
2050
2152
 
2051
2153
  def fit_scheduler(self) -> None:
@@ -16,6 +16,8 @@ from .so import (
16
16
  lennard_jones,
17
17
  robot_arm_obstacle,
18
18
  wingwt,
19
+ factor_quadratic,
20
+ FACTOR_QUADRATIC_LEVELS,
19
21
  )
20
22
  from .mo import (
21
23
  activity_pred,
@@ -46,6 +48,8 @@ __all__ = [
46
48
  "lennard_jones",
47
49
  "robot_arm_obstacle",
48
50
  "wingwt",
51
+ "factor_quadratic",
52
+ "FACTOR_QUADRATIC_LEVELS",
49
53
  "mo_conv2_min",
50
54
  "fonseca_fleming",
51
55
  "kursawe",
@@ -8,6 +8,8 @@ Analytical single-objective test functions for optimization benchmarking.
8
8
  This module provides well-known analytical test functions commonly used for evaluating and benchmarking optimization algorithms.
9
9
  """
10
10
 
11
+ from functools import lru_cache
12
+
11
13
  import numpy as np
12
14
 
13
15
 
@@ -623,6 +625,106 @@ def robot_arm_obstacle(X: np.ndarray) -> np.ndarray:
623
625
  return dist_sq + penalty
624
626
 
625
627
 
628
+ #: Default factor levels for :func:`factor_quadratic` — 16 unordered nominal levels.
629
+ FACTOR_QUADRATIC_LEVELS = tuple(f"c{i:02d}" for i in range(16))
630
+
631
+
632
+ @lru_cache(maxsize=None)
633
+ def _factor_quadratic_offsets(n_levels: int) -> np.ndarray:
634
+ """Fixed, index-scrambled per-level offsets in ``[0, 1]``.
635
+
636
+ Built from a deterministic permutation (fixed seed) so that adjacency in the
637
+ integer level index carries *no* information about the objective value — the
638
+ defining property that makes an ordinal/continuous encoding of the factor
639
+ misleading. Deterministic across calls and processes; the result is cached
640
+ and returned read-only (callers must not mutate it).
641
+
642
+ Args:
643
+ n_levels (int): Number of factor levels.
644
+
645
+ Returns:
646
+ np.ndarray: Offsets of shape ``(n_levels,)``; exactly one entry is ``0.0``
647
+ (the optimal level) and one is ``1.0`` (the worst).
648
+ """
649
+ perm = np.random.default_rng(0).permutation(n_levels)
650
+ offsets = perm.astype(float) / (n_levels - 1)
651
+ offsets.setflags(write=False) # cached array — prevent accidental mutation
652
+ return offsets
653
+
654
+
655
+ def factor_quadratic(X, levels=FACTOR_QUADRATIC_LEVELS, amp: float = 8.0) -> np.ndarray:
656
+ """Mixed continuous + nominal-factor benchmark with index-scrambled level structure.
657
+
658
+ A deliberately adversarial test function with 1 continuous dimension and 1
659
+ *nominal* (unordered) factor with many levels (16 by default). The continuous
660
+ part is a simple bowl shared by all levels; each level adds a vertical offset
661
+ drawn from a fixed index-scrambled permutation, so that **adjacency in the
662
+ integer level index carries no information about the objective value**.
663
+
664
+ A surrogate that treats the factor as an ordinal/continuous integer (Euclidean
665
+ or Canberra distance on the codes) wrongly assumes neighbouring indices behave
666
+ similarly and is systematically misled — most strongly in the data-sparse
667
+ regime typical of expensive black-box optimization, where most levels are
668
+ observed only a few times. A nominal (Hamming) kernel treats all distinct
669
+ levels as equidistant and learns each level's contribution independently, so it
670
+ builds a more accurate surrogate from the same budget.
671
+
672
+ Mathematical definition (with ``n = len(levels)``)::
673
+
674
+ offset = perm / (n - 1) # perm = fixed permutation of {0, .., n-1}
675
+ f(x, c) = x**2 + amp * offset[idx(c)]
676
+
677
+ where ``idx`` maps a factor label to its integer index.
678
+
679
+ Args:
680
+ X (array-like): Input array of shape ``(n_samples, 2)`` or ``(2,)``.
681
+ Column 0 is the continuous variable ``x ∈ [-3, 3]``.
682
+ Column 1 is the factor column, accepted as either string labels (the
683
+ entries of ``levels``) or numeric indices ``0 .. n-1`` — enabling both
684
+ standalone unit testing and direct use by SpotOptim (which passes the
685
+ factor column as string labels).
686
+ levels (tuple of str, optional): Ordered sequence of factor labels. Its
687
+ length sets the number of levels. Defaults to
688
+ :data:`FACTOR_QUADRATIC_LEVELS` (16 levels ``"c00" .. "c15"``).
689
+ amp (float, optional): Scale of the per-level offset term. Defaults to ``8.0``.
690
+
691
+ Returns:
692
+ np.ndarray: Function values of shape ``(n_samples,)`` with dtype ``float``.
693
+
694
+ Note:
695
+ **Global optimum** (default 16 levels): ``f(x=0, c="c04") = 0.0``. The
696
+ optimal level is the index where the scrambled offset is ``0``.
697
+
698
+ **Why ordinal encoding fails**: with the default levels the optimum
699
+ (index 4) is flanked in index space by a *bad* level (index 3, offset
700
+ ``≈0.67``). An ordinal kernel interpolates between adjacent indices and
701
+ therefore under-ranks the optimal level; a Hamming kernel does not. The
702
+ benefit of the nominal kernel is largest when the number of levels is large
703
+ relative to the evaluation budget — see ``scripts/factor_kernel_benchmark.py``.
704
+
705
+ Examples:
706
+ ```{python}
707
+ import numpy as np
708
+ from spotoptim.function import factor_quadratic
709
+ # global optimum: x=0 at level "c04"
710
+ print(factor_quadratic(np.array([[0.0, "c04"]], dtype=object)))
711
+ # a different level is worse
712
+ print(factor_quadratic(np.array([[0.0, "c15"]], dtype=object)))
713
+ ```
714
+
715
+ """
716
+ X = np.atleast_2d(X)
717
+ x = X[:, 0].astype(float) # continuous column
718
+ col = X[:, 1] # factor column: string labels or numeric indices
719
+ lut = {lab: i for i, lab in enumerate(levels)}
720
+ idx = np.array(
721
+ [lut[v] if isinstance(v, str) else int(round(float(v))) for v in col],
722
+ dtype=int,
723
+ )
724
+ offset = _factor_quadratic_offsets(len(levels))
725
+ return x**2 + amp * offset[idx]
726
+
727
+
626
728
  def robot_arm_hard(X: np.ndarray) -> np.ndarray:
627
729
  """10-Link Robot Arm with Maze-Like Hard Constraints.
628
730
 
@@ -68,7 +68,7 @@ class SpotOptimKernel(Kernel):
68
68
 
69
69
  where:
70
70
  D_ordered = sum_j theta_j * |x_ij - y_lj|^p (for ordered variables)
71
- D_factor = sum_j theta_j * d(x_ij, y_lj) (for factor variables, d is metric like Canberra)
71
+ D_factor = sum_j theta_j * d(x_ij, y_lj) (for factor variables, d is metric like Hamming)
72
72
 
73
73
  Args:
74
74
  theta (np.ndarray): The correlation parameters (weights).
@@ -77,7 +77,9 @@ class SpotOptimKernel(Kernel):
77
77
  var_type (list of str): List of variable types, e.g. ['float', 'int', 'factor'].
78
78
  p_val (float, optional): Power parameter for ordered distance. Defaults to 2.0.
79
79
  metric_factorial (str, optional): Metric for factor distance (passed to cdist/pdist).
80
- Defaults to 'canberra'.
80
+ Defaults to 'hamming'. Hamming is a true nominal (order-agnostic) metric;
81
+ canberra distance on integer level indices is order-dependent and singles out
82
+ index 0. Any scipy distance metric remains selectable via this kwarg.
81
83
  """
82
84
 
83
85
  def __init__(
@@ -85,7 +87,7 @@ class SpotOptimKernel(Kernel):
85
87
  theta,
86
88
  var_type,
87
89
  p_val=2.0,
88
- metric_factorial="canberra",
90
+ metric_factorial="hamming",
89
91
  ):
90
92
  self.theta = np.asanyarray(theta)
91
93
  self.var_type = var_type
@@ -78,7 +78,9 @@ class Kriging(BaseEstimator, RegressorMixin):
78
78
  min_Lambda (float, optional): Minimum log10(Lambda) bound. Defaults to -9.0.
79
79
  max_Lambda (float, optional): Maximum log10(Lambda) bound. Defaults to 0.0.
80
80
  metric_factorial (str, optional): Distance metric for factor variables.
81
- Defaults to "canberra".
81
+ Defaults to ``"hamming"``. Hamming is a true nominal (order-agnostic) metric;
82
+ canberra distance on integer level indices is order-dependent and singles out
83
+ index 0. Any scipy distance metric remains selectable via this kwarg.
82
84
  isotropic (bool, optional): Use single theta for all dimensions. Defaults to False.
83
85
  theta (np.ndarray, optional): Initial theta values (log10 scale). Defaults to None.
84
86
 
@@ -163,7 +165,7 @@ class Kriging(BaseEstimator, RegressorMixin):
163
165
  optim_p: bool = False,
164
166
  min_Lambda: float = -9.0,
165
167
  max_Lambda: float = 0.0,
166
- metric_factorial: str = "canberra",
168
+ metric_factorial: str = "hamming",
167
169
  isotropic: bool = False,
168
170
  theta: Optional[np.ndarray] = None,
169
171
  **kwargs,
File without changes