diff --git a/xopt/generators/bayesian/turbo.py b/xopt/generators/bayesian/turbo.py index 72a8c696..63cc99fd 100644 --- a/xopt/generators/bayesian/turbo.py +++ b/xopt/generators/bayesian/turbo.py @@ -256,10 +256,13 @@ def get_trust_region(self, generator: "BayesianGenerator") -> Tensor: if active_indices: lengthscales = lengthscales[active_indices] - # calculate the ratios of lengthscales for each axis + # calculate the ratios of lengthscales for each axis, + # taking the per-element root before multiplying so the + # product cannot overflow/underflow for extreme + # lengthscales (e.g. degenerate fits in high dimensions) active_dim = len(active_variable_names) - weights = lengthscales / torch.prod(lengthscales) ** ( - 1 / active_dim + weights = lengthscales / torch.prod( + lengthscales ** (1 / active_dim) ) # calculate the tr bounding box diff --git a/xopt/tests/generators/bayesian/test_turbo.py b/xopt/tests/generators/bayesian/test_turbo.py index ee0208e3..a4f4d9ef 100644 --- a/xopt/tests/generators/bayesian/test_turbo.py +++ b/xopt/tests/generators/bayesian/test_turbo.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd import pytest +import torch import yaml from gest_api.vocs import VOCS @@ -594,3 +595,53 @@ def clean_up(self): for f in files: if os.path.exists(f): os.remove(f) + + +# Lives outside TestTurbo because pytest cannot parametrize unittest.TestCase +# methods -- parametrization is fixture-driven and TestCase methods take no +# fixtures, so the decorator would leave the argument unfilled at call time. +@pytest.mark.parametrize( + "lengthscale", + [pytest.param(1.0e200, id="overflow"), pytest.param(1.0e-200, id="underflow")], +) +def test_get_trust_region_extreme_lengthscales(lengthscale): + # The geometric-mean normalization must survive lengthscales at both + # extremes. With the naive prod(ls) ** (1 / d) form the raw product is + # unrepresentable in either direction, and the trust region degenerates + # silently -- no warning, no NaN, no exception: + # huge ls -> prod overflows to inf -> weights 0 -> region collapses + # to the center point + # tiny ls -> prod underflows to 0 -> weights inf -> region is clamped + # out to the whole domain + # The underflow case is why "finite, with nonzero width" does not pin this + # on its own: a region spanning the entire domain satisfies both. + test_vocs = deepcopy(TEST_VOCS_BASE) + gen = UpperConfidenceBoundGenerator(vocs=test_vocs) + gen.add_data(TEST_VOCS_DATA) + gen.train_model() + + gen.model.models[0].covar_module.lengthscale = torch.tensor( + [[lengthscale, lengthscale]], dtype=torch.double + ) + + turbo_state = OptimizeTurboController(vocs=gen.vocs) + turbo_state.update_state(gen) + tr = turbo_state.get_trust_region(gen) + + assert torch.all(torch.isfinite(tr)) + + # Equal lengthscales normalize to unit weights, so the region spans at most + # `length` of each dimension's full width. Bounding rather than pinning the + # width keeps this robust to the clamp in get_trust_region, which + # legitimately narrows a side when the incumbent sits near a bound. + bounds = torch.tensor(np.array(test_vocs.bounds).T, dtype=torch.double) + widths = tr[1] - tr[0] + max_widths = turbo_state.length * (bounds[1] - bounds[0]) + + assert torch.all(widths > 0.0), ( + f"region collapsed to a point, widths {widths.tolist()}" + ) + assert torch.all(widths <= max_widths + 1e-12), ( + f"region blown out past the trust region, widths " + f"{widths.tolist()} exceed {max_widths.tolist()}" + )