Skip to content

Commit f413275

Browse files
saitcakmakfacebook-github-bot
authored andcommitted
Add a helper for evaluating feasibility of candidate points (#2733)
Summary: Pull Request resolved: #2733 Pull Request resolved: #2565 Adds a helper for evaluating the feasibility of parameter constraints on a `batch x q x d` tensor of candidates. A follow-up diff will utilize this to detect infeasible candidates produced during optimization and raise an error rather than returning infeasible points to the user. Reviewed By: esantorella Differential Revision: D63909338 fbshipit-source-id: 873ba5036a7076b718ec5e3f054b6f0f3fb999c2
1 parent a43bd4d commit f413275

File tree

3 files changed

+227
-6
lines changed

3 files changed

+227
-6
lines changed

botorch/optim/optimize.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1438,7 +1438,7 @@ def optimize_acqf_discrete_local_search(
14381438
X_avoid = torch.zeros(0, dim, device=device, dtype=dtype)
14391439

14401440
inequality_constraints = inequality_constraints or []
1441-
for i in range(q):
1441+
for _ in range(q):
14421442
# generate some starting points
14431443
X0 = _gen_starting_points_local_search(
14441444
discrete_choices=discrete_choices,

botorch/optim/parameter_constraints.py

Lines changed: 89 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from __future__ import annotations
1212

1313
from collections.abc import Callable
14-
1514
from functools import partial
1615
from typing import Union
1716

@@ -26,7 +25,7 @@
2625
ScipyConstraintDict = dict[
2726
str, Union[str, Callable[[np.ndarray], float], Callable[[np.ndarray], np.ndarray]]
2827
]
29-
NLC_TOL = -1e-6
28+
CONST_TOL = 1e-6
3029

3130

3231
def make_scipy_bounds(
@@ -511,9 +510,12 @@ def f_grad(X):
511510

512511

513512
def nonlinear_constraint_is_feasible(
514-
nonlinear_inequality_constraint: Callable, is_intrapoint: bool, x: Tensor
513+
nonlinear_inequality_constraint: Callable,
514+
is_intrapoint: bool,
515+
x: Tensor,
516+
tolerance: float = CONST_TOL,
515517
) -> Tensor:
516-
"""Checks if a nonlinear inequality constraint is fulfilled.
518+
"""Checks if a nonlinear inequality constraint is fulfilled (within tolerance).
517519
518520
Args:
519521
nonlinear_inequality_constraint: Callable to evaluate the
@@ -523,14 +525,17 @@ def nonlinear_constraint_is_feasible(
523525
constraint has to evaluated over the whole q-batch and is a an
524526
inter-point constraint.
525527
x: Tensor of shape (batch x q x d).
528+
tolerance: Rather than using the exact `const(x) >= 0` constraint, this helper
529+
checks feasibility of `const(x) >= -tolerance`. This avoids marking the
530+
candidates as infeasible due to tiny violations.
526531
527532
Returns:
528533
A boolean tensor of shape (batch) indicating if the constraint is
529534
satified by the corresponding batch of `x`.
530535
"""
531536

532537
def check_x(x: Tensor) -> bool:
533-
return _arrayify(nonlinear_inequality_constraint(x)).item() >= NLC_TOL
538+
return _arrayify(nonlinear_inequality_constraint(x)).item() >= -tolerance
534539

535540
x_flat = x.view(-1, *x.shape[-2:])
536541
is_feasible = torch.ones(x_flat.shape[0], dtype=torch.bool, device=x.device)
@@ -603,3 +608,82 @@ def make_scipy_nonlinear_inequality_constraints(
603608
shapeX=shapeX,
604609
)
605610
return scipy_nonlinear_inequality_constraints
611+
612+
613+
def evaluate_feasibility(
614+
X: Tensor,
615+
inequality_constraints: list[tuple[Tensor, Tensor, float]] | None = None,
616+
equality_constraints: list[tuple[Tensor, Tensor, float]] | None = None,
617+
nonlinear_inequality_constraints: list[tuple[Callable, bool]] | None = None,
618+
tolerance: float = CONST_TOL,
619+
) -> Tensor:
620+
r"""Evaluate feasibility of candidate points (within a tolerance).
621+
622+
Args:
623+
X: The candidate tensor of shape `batch x q x d`.
624+
inequality_constraints: A list of tuples (indices, coefficients, rhs),
625+
with each tuple encoding an inequality constraint of the form
626+
`\sum_i (X[indices[i]] * coefficients[i]) >= rhs`. `indices` and
627+
`coefficients` should be torch tensors. See the docstring of
628+
`make_scipy_linear_constraints` for an example. When q=1, or when
629+
applying the same constraint to each candidate in the batch
630+
(intra-point constraint), `indices` should be a 1-d tensor.
631+
For inter-point constraints, in which the constraint is applied to the
632+
whole batch of candidates, `indices` must be a 2-d tensor, where
633+
in each row `indices[i] =(k_i, l_i)` the first index `k_i` corresponds
634+
to the `k_i`-th element of the `q`-batch and the second index `l_i`
635+
corresponds to the `l_i`-th feature of that element.
636+
equality_constraints: A list of tuples (indices, coefficients, rhs),
637+
with each tuple encoding an equality constraint of the form
638+
`\sum_i (X[indices[i]] * coefficients[i]) = rhs`. See the docstring of
639+
`make_scipy_linear_constraints` for an example.
640+
nonlinear_inequality_constraints: A list of tuples representing the nonlinear
641+
inequality constraints. The first element in the tuple is a callable
642+
representing a constraint of the form `callable(x) >= 0`. In case of an
643+
intra-point constraint, `callable()`takes in an one-dimensional tensor of
644+
shape `d` and returns a scalar. In case of an inter-point constraint,
645+
`callable()` takes a two dimensional tensor of shape `q x d` and again
646+
returns a scalar. The second element is a boolean, indicating if it is an
647+
intra-point or inter-point constraint (`True` for intra-point. `False` for
648+
inter-point). For more information on intra-point vs inter-point
649+
constraints, see the docstring of the `inequality_constraints` argument.
650+
tolerance: The tolerance used to check the feasibility of equality constraints
651+
and non-linear inequality constraints. For equality constraints, we check
652+
if `abs(const(X) - rhs) < tolerance`. For non-linear inequality constraints,
653+
we check if `const(X) >= -tolerance`. This avoids marking the candidates as
654+
infeasible due to tiny violations.
655+
656+
Returns:
657+
A boolean tensor of shape `batch` indicating if the corresponding candidate of
658+
shape `q x d` is feasible.
659+
"""
660+
is_feasible = torch.ones(X.shape[:-2], device=X.device, dtype=torch.bool)
661+
if inequality_constraints is not None:
662+
for idx, coef, rhs in inequality_constraints:
663+
if idx.ndim == 1:
664+
# Intra-point constraints.
665+
is_feasible &= ((X[..., idx] * coef).sum(dim=-1) >= rhs).all(dim=-1)
666+
else:
667+
# Inter-point constraints.
668+
is_feasible &= (X[..., idx[:, 0], idx[:, 1]] * coef).sum(dim=-1) >= rhs
669+
if equality_constraints is not None:
670+
for idx, coef, rhs in equality_constraints:
671+
if idx.ndim == 1:
672+
# Intra-point constraints.
673+
is_feasible &= (
674+
((X[..., idx] * coef).sum(dim=-1) - rhs).abs() < tolerance
675+
).all(dim=-1)
676+
else:
677+
# Inter-point constraints.
678+
is_feasible &= (
679+
(X[..., idx[:, 0], idx[:, 1]] * coef).sum(dim=-1) - rhs
680+
).abs() < tolerance
681+
if nonlinear_inequality_constraints is not None:
682+
for const, intra in nonlinear_inequality_constraints:
683+
is_feasible &= nonlinear_constraint_is_feasible(
684+
nonlinear_inequality_constraint=const,
685+
is_intrapoint=intra,
686+
x=X,
687+
tolerance=tolerance,
688+
)
689+
return is_feasible

test/optim/test_parameter_constraints.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
_make_linear_constraints,
1919
_make_nonlinear_constraints,
2020
eval_lin_constraint,
21+
evaluate_feasibility,
2122
lin_constraint_jac,
2223
make_scipy_bounds,
2324
make_scipy_linear_constraints,
@@ -529,6 +530,142 @@ def test_generate_unfixed_lin_constraints(self):
529530
eq=eq,
530531
)
531532

533+
def test_evaluate_feasibility(self) -> None:
534+
# Check that the feasibility is evaluated correctly.
535+
X = torch.tensor( # 3 x 2 x 3 -> leads to output of shape 3.
536+
[
537+
[[1.0, 1.0, 1.0], [1.0, 1.0, 3.0]],
538+
[[2.0, 2.0, 1.0], [2.0, 2.0, 5.0]],
539+
[[3.0, 3.0, 3.0], [3.0, 3.0, 3.0]],
540+
],
541+
device=self.device,
542+
)
543+
# X[..., 2] * 4 >= 5.
544+
inequality_constraints = [
545+
(
546+
torch.tensor([2], device=self.device),
547+
torch.tensor([4], device=self.device),
548+
5.0,
549+
)
550+
]
551+
# X[..., 0] + X[..., 1] == 4.
552+
equality_constraints = [
553+
(
554+
torch.tensor([0, 1], device=self.device),
555+
torch.ones(2, device=self.device),
556+
4.0,
557+
)
558+
]
559+
560+
# sum(X, dim=-1) < 5.
561+
def nlc1(x):
562+
return 5 - x.sum(dim=-1)
563+
564+
# Only inequality.
565+
self.assertAllClose(
566+
evaluate_feasibility(
567+
X=X,
568+
inequality_constraints=inequality_constraints,
569+
),
570+
torch.tensor([False, False, True], device=self.device),
571+
)
572+
# Only equality.
573+
self.assertAllClose(
574+
evaluate_feasibility(
575+
X=X,
576+
equality_constraints=equality_constraints,
577+
),
578+
torch.tensor([False, True, False], device=self.device),
579+
)
580+
# Both inequality and equality.
581+
self.assertAllClose(
582+
evaluate_feasibility(
583+
X=X,
584+
inequality_constraints=inequality_constraints,
585+
equality_constraints=equality_constraints,
586+
),
587+
torch.tensor([False, False, False], device=self.device),
588+
)
589+
# Nonlinear inequality.
590+
self.assertAllClose(
591+
evaluate_feasibility(
592+
X=X,
593+
nonlinear_inequality_constraints=[(nlc1, True)],
594+
),
595+
torch.tensor([True, False, False], device=self.device),
596+
)
597+
# No constraints.
598+
self.assertAllClose(
599+
evaluate_feasibility(
600+
X=X,
601+
),
602+
torch.ones(3, device=self.device, dtype=torch.bool),
603+
)
604+
605+
def test_evaluate_feasibility_inter_point(self) -> None:
606+
# Check that inter-point constraints evaluate correctly.
607+
X = torch.tensor( # 3 x 2 x 3 -> leads to output of shape 3.
608+
[
609+
[[1.0, 1.0, 1.0], [0.0, 1.0, 3.0]],
610+
[[1.0, 1.0, 1.0], [2.0, 1.0, 3.0]],
611+
[[2.0, 2.0, 1.0], [2.0, 2.0, 5.0]],
612+
],
613+
dtype=torch.double,
614+
device=self.device,
615+
)
616+
linear_inter_cons = ( # X[..., 0, 0] - X[..., 1, 0] >= / == 0.
617+
torch.tensor([[0, 0], [1, 0]], device=self.device),
618+
torch.tensor([1.0, -1.0], device=self.device),
619+
0,
620+
)
621+
# Linear inequality.
622+
self.assertAllClose(
623+
evaluate_feasibility(
624+
X=X,
625+
inequality_constraints=[linear_inter_cons],
626+
),
627+
torch.tensor([True, False, True], device=self.device),
628+
)
629+
# Linear equality.
630+
self.assertAllClose(
631+
evaluate_feasibility(
632+
X=X,
633+
equality_constraints=[linear_inter_cons],
634+
),
635+
torch.tensor([False, False, True], device=self.device),
636+
)
637+
# Linear equality with too high of a tolerance.
638+
self.assertAllClose(
639+
evaluate_feasibility(
640+
X=X,
641+
equality_constraints=[linear_inter_cons],
642+
tolerance=100,
643+
),
644+
torch.tensor([True, True, True], device=self.device),
645+
)
646+
647+
# Nonlinear inequality.
648+
def nlc1(x): # X.sum(over q & d) >= 10.0
649+
return x.sum() - 10.0
650+
651+
self.assertEqual(
652+
evaluate_feasibility(
653+
X=X,
654+
nonlinear_inequality_constraints=[(nlc1, False)],
655+
).tolist(),
656+
[False, False, True],
657+
)
658+
# All together.
659+
self.assertEqual(
660+
evaluate_feasibility(
661+
X=X,
662+
inequality_constraints=[linear_inter_cons],
663+
equality_constraints=[linear_inter_cons],
664+
nonlinear_inequality_constraints=[(nlc1, False)],
665+
).tolist(),
666+
[False, False, True],
667+
)
668+
532669

533670
class TestMakeScipyBounds(BotorchTestCase):
534671
def test_make_scipy_bounds(self):

0 commit comments

Comments
 (0)