Skip to content

Commit 19cb9a0

Browse files
authored
Improve bounds processing if no bounds are specified (#618)
1 parent 3bf4f05 commit 19cb9a0

File tree

17 files changed

+199
-93
lines changed

17 files changed

+199
-93
lines changed

src/optimagic/differentiation/generate_steps.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33

44
import numpy as np
55

6+
from optimagic.utilities import fast_numpy_full
7+
68

79
class Steps(NamedTuple):
810
pos: np.ndarray
@@ -90,12 +92,21 @@ def generate_steps(
9092
)
9193
min_steps = base_steps if min_steps is None else min_steps
9294

93-
assert (bounds.upper - bounds.lower >= 2 * min_steps).all(), (
95+
lower_bounds = bounds.lower
96+
upper_bounds = bounds.upper
97+
# None-valued bounds are handled by instantiating them as an -inf and inf array. In
98+
# the future, this should be handled more gracefully.
99+
if lower_bounds is None:
100+
lower_bounds = fast_numpy_full(len(x), fill_value=-np.inf)
101+
if upper_bounds is None:
102+
upper_bounds = fast_numpy_full(len(x), fill_value=np.inf)
103+
104+
assert (upper_bounds - lower_bounds >= 2 * min_steps).all(), (
94105
"min_steps is too large to fit into bounds."
95106
)
96107

97-
upper_step_bounds = bounds.upper - x
98-
lower_step_bounds = bounds.lower - x
108+
upper_step_bounds = upper_bounds - x
109+
lower_step_bounds = lower_bounds - x
99110

100111
pos = step_ratio ** np.arange(n_steps) * base_steps.reshape(-1, 1)
101112
neg = -pos.copy()
@@ -105,7 +116,7 @@ def generate_steps(
105116
x, pos, neg, method, lower_step_bounds, upper_step_bounds
106117
)
107118

108-
if np.isfinite(bounds.lower).any() or np.isfinite(bounds.upper).any():
119+
if np.isfinite(lower_bounds).any() or np.isfinite(upper_bounds).any():
109120
pos, neg = _rescale_to_accomodate_bounds(
110121
base_steps, pos, neg, lower_step_bounds, upper_step_bounds, min_steps
111122
)

src/optimagic/optimization/optimize.py

Lines changed: 23 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -561,38 +561,33 @@ def _optimize(problem: OptimizationProblem) -> OptimizeResult:
561561
# Strict checking if bounds are required and infinite values in bounds
562562
# ==================================================================================
563563
if problem.algorithm.algo_info.supports_bounds:
564-
# we need all_bounds_infinite and some_bounds_infinite to check
565-
# for arrays of np.inf and np.-inf until bounds
566-
# processing is improved
567-
all_bounds_infinite = (
568-
np.isinf(internal_params.lower_bounds).all()
569-
and np.isinf(internal_params.upper_bounds).all()
570-
)
571-
some_bounds_infinite = (
572-
np.isinf(internal_params.lower_bounds).all()
573-
or np.isinf(internal_params.upper_bounds).all()
574-
)
575-
infinite_values_in_bounds = (
576-
np.isinf(internal_params.lower_bounds).any()
577-
or np.isinf(internal_params.upper_bounds).any()
578-
)
579564
bounds_missing = (
580565
internal_params.lower_bounds is None or internal_params.upper_bounds is None
581566
)
582567

583-
if problem.algorithm.algo_info.needs_bounds:
584-
if bounds_missing or some_bounds_infinite:
585-
raise IncompleteBoundsError(
586-
f"Algorithm {problem.algorithm.name} needs finite bounds "
587-
f"for all parameters. "
588-
)
589-
if not problem.algorithm.algo_info.supports_infinite_bounds:
590-
if not all_bounds_infinite:
591-
if infinite_values_in_bounds:
592-
raise IncompleteBoundsError(
593-
f"Algorithm {problem.algorithm.name} does not support "
594-
f"infinite values in bounds for parameters. "
595-
)
568+
# Check for infinite values in bounds arrays (only possible in mixed cases now)
569+
infinite_values_in_bounds = False
570+
if internal_params.lower_bounds is not None:
571+
infinite_values_in_bounds |= np.isinf(internal_params.lower_bounds).any()
572+
if internal_params.upper_bounds is not None:
573+
infinite_values_in_bounds |= np.isinf(internal_params.upper_bounds).any()
574+
575+
# Case 1: Algorithm needs bounds but none provided
576+
if problem.algorithm.algo_info.needs_bounds and bounds_missing:
577+
raise IncompleteBoundsError(
578+
f"Algorithm {problem.algorithm.name} requires bounds for all "
579+
"parameters. Please provide finite lower and upper bounds."
580+
)
581+
582+
# Case 2: Algorithm doesn't support infinite bounds but they are present
583+
if (
584+
not problem.algorithm.algo_info.supports_infinite_bounds
585+
and infinite_values_in_bounds
586+
):
587+
raise IncompleteBoundsError(
588+
f"Algorithm {problem.algorithm.name} does not support infinite bounds. "
589+
"Please provide finite bounds for all parameters."
590+
)
596591

597592
# ==================================================================================
598593
# Do some things that require internal parameters or bounds

src/optimagic/optimizers/fides.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ def fides_internal(
183183

184184
hessian_instance = _create_hessian_updater_from_user_input(hessian_update_strategy)
185185

186+
lower_bounds = np.full(len(x), -np.inf) if lower_bounds is None else lower_bounds
187+
upper_bounds = np.full(len(x), np.inf) if upper_bounds is None else upper_bounds
188+
186189
opt = Optimizer(
187190
fun=fun_and_jac,
188191
lb=lower_bounds,

src/optimagic/optimizers/scipy_optimizers.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -472,12 +472,15 @@ def _solve_internal_problem(
472472
else:
473473
tr_solver_options = self.tr_solver_options
474474

475+
lower_bounds = -np.inf if problem.bounds.lower is None else problem.bounds.lower
476+
upper_bounds = np.inf if problem.bounds.upper is None else problem.bounds.upper
477+
475478
raw_res = scipy.optimize.least_squares(
476479
fun=problem.fun,
477480
x0=x0,
478481
# This optimizer does not work with fun_and_jac
479482
jac=problem.jac,
480-
bounds=(problem.bounds.lower, problem.bounds.upper),
483+
bounds=(lower_bounds, upper_bounds),
481484
method="trf",
482485
max_nfev=self.stopping_maxfun,
483486
ftol=self.convergence_ftol_rel,
@@ -522,12 +525,15 @@ def _solve_internal_problem(
522525
else:
523526
tr_solver_options = self.tr_solver_options
524527

528+
lower_bounds = -np.inf if problem.bounds.lower is None else problem.bounds.lower
529+
upper_bounds = np.inf if problem.bounds.upper is None else problem.bounds.upper
530+
525531
raw_res = scipy.optimize.least_squares(
526532
fun=problem.fun,
527533
x0=x0,
528534
# This optimizer does not work with fun_and_jac
529535
jac=problem.jac,
530-
bounds=(problem.bounds.lower, problem.bounds.upper),
536+
bounds=(lower_bounds, upper_bounds),
531537
method="dogbox",
532538
max_nfev=self.stopping_maxfun,
533539
ftol=self.convergence_ftol_rel,
@@ -1204,8 +1210,13 @@ def _get_workers(n_cores, batch_evaluator):
12041210
return out
12051211

12061212

1207-
def _get_scipy_bounds(bounds: InternalBounds) -> ScipyBounds:
1208-
return ScipyBounds(lb=bounds.lower, ub=bounds.upper)
1213+
def _get_scipy_bounds(bounds: InternalBounds) -> ScipyBounds | None:
1214+
if bounds.lower is None and bounds.upper is None:
1215+
return None
1216+
1217+
lower = bounds.lower if bounds.lower is not None else -np.inf
1218+
upper = bounds.upper if bounds.upper is not None else np.inf
1219+
return ScipyBounds(lb=lower, ub=upper)
12091220

12101221

12111222
def process_scipy_result_old(scipy_results_obj):

src/optimagic/optimizers/tao_optimizers.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,15 @@ def func_tao(tao, x, resid_out): # noqa: ARG001
152152
raise ValueError("The initial trust region radius must be > 0.")
153153
tao.setInitialTrustRegionRadius(trustregion_initial_radius)
154154

155-
# Add bounds.
156-
lower_bounds = _initialise_petsc_array(lower_bounds)
157-
upper_bounds = _initialise_petsc_array(upper_bounds)
158-
tao.setVariableBounds(lower_bounds, upper_bounds)
155+
# Add bounds if provided.
156+
if lower_bounds is not None or upper_bounds is not None:
157+
if lower_bounds is None:
158+
lower_bounds = np.full(len(x), -np.inf)
159+
if upper_bounds is None:
160+
upper_bounds = np.full(len(x), np.inf)
161+
lower_bounds = _initialise_petsc_array(lower_bounds)
162+
upper_bounds = _initialise_petsc_array(upper_bounds)
163+
tao.setVariableBounds(lower_bounds, upper_bounds)
159164

160165
# Put the starting values into the container and pass them to the optimizer.
161166
tao.setInitial(_x)
@@ -197,7 +202,8 @@ def func_tao(tao, x, resid_out): # noqa: ARG001
197202
results = _process_pounders_results(residuals_out, tao)
198203

199204
# Destroy petsc objects for memory reasons.
200-
for obj in [tao, _x, residuals_out, lower_bounds, upper_bounds]:
205+
petsc_bounds = [b for b in (lower_bounds, upper_bounds) if b is not None]
206+
for obj in [tao, _x, residuals_out, *petsc_bounds]:
201207
obj.destroy()
202208

203209
return results

src/optimagic/parameters/bounds.py

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from __future__ import annotations
22

33
from dataclasses import dataclass
4-
from typing import Any, Sequence
4+
from typing import Any, Literal, Sequence
55

66
import numpy as np
77
from numpy.typing import NDArray
@@ -12,6 +12,7 @@
1212
from optimagic.exceptions import InvalidBoundsError
1313
from optimagic.parameters.tree_registry import get_registry
1414
from optimagic.typing import PyTree, PyTreeRegistry
15+
from optimagic.utilities import fast_numpy_full
1516

1617

1718
@dataclass(frozen=True)
@@ -60,8 +61,8 @@ def pre_process_bounds(
6061

6162

6263
def _process_bounds_sequence(bounds: Sequence[tuple[float, float]]) -> Bounds:
63-
lower = np.full(len(bounds), -np.inf)
64-
upper = np.full(len(bounds), np.inf)
64+
lower = fast_numpy_full(len(bounds), fill_value=-np.inf)
65+
upper = fast_numpy_full(len(bounds), fill_value=np.inf)
6566

6667
for i, (lb, ub) in enumerate(bounds):
6768
if lb is not None:
@@ -76,14 +77,14 @@ def get_internal_bounds(
7677
bounds: Bounds | None = None,
7778
registry: PyTreeRegistry | None = None,
7879
add_soft_bounds: bool = False,
79-
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
80+
) -> tuple[NDArray[np.float64] | None, NDArray[np.float64] | None]:
8081
"""Create consolidated and flattened bounds for params.
8182
8283
If params is a DataFrame with value column, the user provided bounds are
8384
extended with bounds from the params DataFrame.
8485
85-
If no bounds are available the entry is set to minus np.inf for the lower bound and
86-
np.inf for the upper bound.
86+
If no bounds are provided, we return None. If some bounds are available the missing
87+
entries are set to -np.inf for the lower bound and np.inf for the upper bound.
8788
8889
The bounds provided in `bounds` override bounds provided in params if both are
8990
specified (in the case where params is a DataFrame with bounds as a column).
@@ -109,10 +110,11 @@ def get_internal_bounds(
109110
add_soft_bounds=add_soft_bounds,
110111
)
111112
if fast_path:
112-
return _get_fast_path_bounds(
113-
params=params,
114-
bounds=bounds,
115-
)
113+
return _get_fast_path_bounds(bounds)
114+
115+
# Handling of None-valued bounds in the slow path needs to be improved. Currently,
116+
# None-valued bounds are replaced with arrays of np.inf and -np.inf, and then
117+
# translated back to None if all entries are non-finite.
116118

117119
registry = get_registry(extended=True) if registry is None else registry
118120
n_params = len(tree_leaves(params, registry=registry))
@@ -149,11 +151,18 @@ def get_internal_bounds(
149151
msg = "Invalid bounds. Some lower bounds are larger than upper bounds."
150152
raise InvalidBoundsError(msg)
151153

154+
if np.isinf(lower_flat).all():
155+
lower_flat = None # type: ignore[assignment]
156+
if np.isinf(upper_flat).all():
157+
upper_flat = None # type: ignore[assignment]
158+
152159
return lower_flat, upper_flat
153160

154161

155162
def _update_bounds_and_flatten(
156-
nan_tree: PyTree, bounds: PyTree, kind: str
163+
nan_tree: PyTree,
164+
bounds: PyTree,
165+
kind: Literal["lower_bound", "upper_bound", "soft_lower_bound", "soft_upper_bound"],
157166
) -> NDArray[np.float64]:
158167
"""Flatten bounds array and update it with bounds from params.
159168
@@ -213,7 +222,7 @@ def _is_fast_path(params: PyTree, bounds: Bounds, add_soft_bounds: bool) -> bool
213222
if not _is_1d_array(params):
214223
out = False
215224

216-
for bound in bounds.lower, bounds.upper:
225+
for bound in (bounds.lower, bounds.upper):
217226
if not (_is_1d_array(bound) or bound is None):
218227
out = False
219228
return out
@@ -224,21 +233,27 @@ def _is_1d_array(candidate: Any) -> bool:
224233

225234

226235
def _get_fast_path_bounds(
227-
params: PyTree, bounds: Bounds
228-
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
236+
bounds: Bounds,
237+
) -> tuple[NDArray[np.float64] | None, NDArray[np.float64] | None]:
229238
if bounds.lower is None:
230-
# faster than np.full
231-
lower_bounds = np.array([-np.inf] * len(params))
239+
lower_bounds = None
232240
else:
233241
lower_bounds = bounds.lower.astype(float)
242+
if np.isinf(lower_bounds).all():
243+
lower_bounds = None
234244

235245
if bounds.upper is None:
236-
# faster than np.full
237-
upper_bounds = np.array([np.inf] * len(params))
246+
upper_bounds = None
238247
else:
239248
upper_bounds = bounds.upper.astype(float)
240-
241-
if (lower_bounds > upper_bounds).any():
249+
if np.isinf(upper_bounds).all():
250+
upper_bounds = None
251+
252+
if (
253+
lower_bounds is not None
254+
and upper_bounds is not None
255+
and (lower_bounds > upper_bounds).any()
256+
):
242257
msg = "Invalid bounds. Some lower bounds are larger than upper bounds."
243258
raise InvalidBoundsError(msg)
244259

src/optimagic/parameters/consolidate_constraints.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
import pandas as pd
1212

1313
from optimagic.exceptions import InvalidConstraintError
14-
from optimagic.utilities import number_of_triangular_elements_to_dimension
14+
from optimagic.utilities import (
15+
fast_numpy_full,
16+
number_of_triangular_elements_to_dimension,
17+
)
1518

1619

1720
def consolidate_constraints(
@@ -25,8 +28,8 @@ def consolidate_constraints(
2528
constraints have been rewritten as linear constraints and
2629
pairwise_equality constraints have been rewritten as equality constraints.
2730
parvec (np.ndarray): 1d numpy array with parameters.
28-
lower_bounds (np.ndarray): 1d numpy array with lower_bounds
29-
upper_bounds (np.ndarray): 1d numpy array wtih upper_bounds
31+
lower_bounds (np.ndarray | None): 1d numpy array with lower_bounds
32+
upper_bounds (np.ndarray | None): 1d numpy array with upper_bounds
3033
param_names (list): Names of parameters. Used for error messages.
3134
3235
Returns:
@@ -37,6 +40,13 @@ def consolidate_constraints(
3740
constraints.
3841
3942
"""
43+
# None-valued bounds are handled by instantiating them as an -inf and inf array. In
44+
# the future, this should be handled more gracefully.
45+
if lower_bounds is None:
46+
lower_bounds = fast_numpy_full(len(parvec), fill_value=-np.inf)
47+
if upper_bounds is None:
48+
upper_bounds = fast_numpy_full(len(parvec), fill_value=np.inf)
49+
4050
raw_eq, other_constraints = _split_constraints(constraints, "equality")
4151
equality_constraints = _consolidate_equality_constraints(raw_eq)
4252

src/optimagic/parameters/conversion.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,12 +179,12 @@ def _fast_derivative_to_internal(
179179
)
180180

181181
if bounds is None or bounds.lower is None:
182-
lower_bounds = np.full(len(params), -np.inf)
182+
lower_bounds = None
183183
else:
184184
lower_bounds = bounds.lower.astype(float)
185185

186186
if bounds is None or bounds.upper is None:
187-
upper_bounds = np.full(len(params), np.inf)
187+
upper_bounds = None
188188
else:
189189
upper_bounds = bounds.upper.astype(float)
190190

src/optimagic/parameters/process_constraints.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@ def process_constraints(
4242
have already been consolidated into an ``"index"`` field that selects
4343
the same parameters from the flattened_parameter vector.
4444
params_vec (np.ndarray): Flattened version of params.
45-
lower_bounds (np.ndarray): Lower bounds for params_vec.
46-
upper_bounds (np.ndarray): Upper bounds for params_vec.
45+
lower_bounds (np.ndarray | None): Lower bounds for params_vec.
46+
upper_bounds (np.ndarray | None): Upper bounds for params_vec.
4747
param_names (list): Names of the flattened parameters. Only used to produce
4848
good error messages.
4949

0 commit comments

Comments
 (0)