Skip to content

Commit 2acd3a7

Browse files
committed
Copy pyprima code for fixed constraints to python bindings
Some minor modifications needed to be made. Notably pyprima's Result object uses 'f' for the function value whereas the bindings use 'fun'. Secondly, with the bindings we try to maintain the type that was provided, and so we made to make a minor modification as we changed the x0 that was provided to eliminate the fixed bounds. Ref: #250
1 parent 515be56 commit 2acd3a7

File tree

3 files changed

+151
-1
lines changed

3 files changed

+151
-1
lines changed

python/prima/__init__.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from enum import Enum
1111
import numpy as np
1212
from ._common import _project
13+
from ._common import get_arrays_tol
1314

1415

1516
class ConstraintType(Enum):
@@ -115,6 +116,59 @@ def minimize(fun, x0, args=(), method=None, bounds=None, constraints=(), callbac
115116

116117
lb, ub = process_bounds(bounds, lenx0)
117118

119+
# Check which variables are fixed and eliminate them from the problem.
120+
# Save the indices and values so that we can call the original function with
121+
# an array of the appropriate size, and so that we can add the fixed values to the
122+
# result when COBYLA returns.
123+
tol = get_arrays_tol(lb, ub)
124+
_fixed_idx = (
125+
(lb <= ub)
126+
& (np.abs(lb - ub) < tol)
127+
)
128+
if any(_fixed_idx):
129+
_fixed_values = 0.5 * (
130+
lb[_fixed_idx] + ub[_fixed_idx]
131+
)
132+
_fixed_values = np.clip(
133+
_fixed_values,
134+
lb[_fixed_idx],
135+
ub[_fixed_idx],
136+
)
137+
if isinstance(x0, np.ndarray):
138+
x0 = np.array(x0)[~_fixed_idx]
139+
elif x0_is_scalar:
140+
raise Exception("You have provided a scalar x with fixed bounds, there is" \
141+
"no optimization to be done.")
142+
else:
143+
# In this case x is presumably a list, so we turn it into a numpy array
144+
# for the convenience of indexing and then turn it back into a list
145+
x0 = np.array(x0)[~_fixed_idx].tolist()
146+
lb = lb[~_fixed_idx]
147+
ub = ub[~_fixed_idx]
148+
original_fun = fun
149+
def fixed_fun(x):
150+
newx = np.zeros(lenx0)
151+
newx[_fixed_idx] = _fixed_values
152+
newx[~_fixed_idx] = x
153+
return original_fun(newx, *args)
154+
fun = fixed_fun
155+
# Adjust linear_constraint
156+
if linear_constraint:
157+
new_lb = linear_constraint.lb - linear_constraint.A[:, _fixed_idx] @ _fixed_values
158+
new_ub = linear_constraint.ub - linear_constraint.A[:, _fixed_idx] @ _fixed_values
159+
new_A = linear_constraint.A[:, ~_fixed_idx]
160+
linear_constraint = LinearConstraint(new_A, new_lb, new_ub)
161+
# Adjust nonlinear constraints
162+
if nonlinear_constraint_function:
163+
original_nlc_fun = nonlinear_constraint_function
164+
def fixed_nlc_fun(x):
165+
newx = np.zeros(lenx0)
166+
newx[_fixed_idx] = _fixed_values
167+
newx[~_fixed_idx] = x
168+
return original_nlc_fun(newx, *args)
169+
nonlinear_constraint_function = fixed_nlc_fun
170+
171+
118172
# Project x0 onto the feasible set
119173
if nonlinear_constraint_function is None:
120174
result = _project(x0, lb, ub, {"linear": linear_constraint, "nonlinear": None})
@@ -142,7 +196,7 @@ def minimize(fun, x0, args=(), method=None, bounds=None, constraints=(), callbac
142196
options['nlconstr0'] = nlconstr0
143197
options['m_nlcon'] = len(nlconstr0)
144198

145-
return _minimize(
199+
result = _minimize(
146200
fun,
147201
x0,
148202
args,
@@ -157,3 +211,10 @@ def minimize(fun, x0, args=(), method=None, bounds=None, constraints=(), callbac
157211
callback,
158212
options
159213
)
214+
215+
if any(_fixed_idx):
216+
newx = np.zeros(lenx0)
217+
newx[_fixed_idx] = _fixed_values
218+
newx[~_fixed_idx] = result.x
219+
result.x = newx
220+
return result

python/prima/_common.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,32 @@ def _project(x0, lb, ub, constraints):
165165
return OptimizeResult(x=x0_c)
166166

167167
return OptimizeResult(x=x0_c)
168+
169+
170+
def get_arrays_tol(*arrays):
171+
"""
172+
Get a relative tolerance for a set of arrays. Borrowed from COBYQA
173+
174+
Parameters
175+
----------
176+
*arrays: tuple
177+
Set of `numpy.ndarray` to get the tolerance for.
178+
179+
Returns
180+
-------
181+
float
182+
Relative tolerance for the set of arrays.
183+
184+
Raises
185+
------
186+
ValueError
187+
If no array is provided.
188+
"""
189+
if len(arrays) == 0:
190+
raise ValueError("At least one array must be provided.")
191+
size = max(array.size for array in arrays)
192+
weight = max(
193+
np.max(np.abs(array[np.isfinite(array)]), initial=1.0)
194+
for array in arrays
195+
)
196+
return 10.0 * eps * max(size, 1.0) * weight

python/tests/test_bounds.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from prima import minimize, LinearConstraint as LC, NonlinearConstraint as NLC
2+
import numpy as np
3+
4+
def test_eliminate_fixed_bounds():
5+
# Test the logic for detecting and eliminating fixed bounds
6+
7+
def f(x):
8+
return np.sum(x**2)
9+
10+
lb = [-1, None, 1, None, -0.5]
11+
ub = [-0.5, -0.5, None, None, -0.5]
12+
bounds = [(a, b) for a, b in zip(lb, ub)]
13+
res = minimize(f, x0=np.array([1, 2, 3, 4, 5]), bounds=bounds)
14+
assert np.allclose(res.x, np.array([-0.5, -0.5, 1, 0, -0.5]), atol=1e-3)
15+
assert np.allclose(res.fun, 1.75, atol=1e-3)
16+
17+
18+
def test_eliminate_fixed_bounds_with_linear_constraints():
19+
# Ensure that the logic for fixed bounds also modifies linear constraints
20+
# appropriately
21+
22+
def f(x):
23+
return np.sum(x**2)
24+
25+
lb = [-1, None, None]
26+
ub = [-1, None, None]
27+
bounds = [(a, b) for a, b in zip(lb, ub)]
28+
# Internally, the algorithm should modify lc to be a 1x2 matrix instead of 1x3,
29+
# since it will modify x0 and the objective function to eliminate the first
30+
# variable. If it fails to modify lc, we will get shape mismatch errors.
31+
lc = LC(np.array([1, 1, 1]), lb=9, ub=15)
32+
res = minimize(f, x0=np.array([1, 1, 1]), constraints=lc, bounds=bounds)
33+
assert np.isclose(res.x[0], -1, atol=1e-6, rtol=1e-6)
34+
assert np.isclose(res.x[1], 5, atol=1e-6, rtol=1e-6)
35+
assert np.isclose(res.x[2], 5, atol=1e-6, rtol=1e-6)
36+
assert np.isclose(res.fun, 51, atol=1e-6, rtol=1e-6)
37+
38+
39+
def test_eliminate_fixed_bounds_with_nonlinear_constraints():
40+
# Ensure that the logic for fixed bounds also modifies the nonlinear constraint
41+
# function appropriately
42+
43+
def f(x):
44+
return np.sum(x**2)
45+
46+
lb = [-1, None, None]
47+
ub = [-1, None, None]
48+
bounds = [(a, b) for a, b in zip(lb, ub)]
49+
x0 = np.array([1, 1, 1])
50+
# Have the nonlinear constraint function operate on the last element of x, but be
51+
# explicit about the length of x. This ensures that the test is still valid if the
52+
# fixed bound is removed. If we simply used x[-1] this test would pass but it
53+
# wouldn't actually test if we had properly modified the nonlinear constraint
54+
# function after removing the fixed bounds
55+
nlc = NLC(lambda x: x[len(x0)-1]**2, lb=9, ub=15)
56+
res = minimize(f, x0=x0, constraints=nlc, bounds=bounds)
57+
assert np.isclose(res.x[0], -1, atol=1e-6, rtol=1e-6)
58+
assert np.isclose(res.x[1], 0, atol=1e-6, rtol=1e-6)
59+
assert np.isclose(res.x[2], 3, atol=1e-6, rtol=1e-6)
60+
assert np.isclose(res.fun, 10, atol=1e-6, rtol=1e-6)

0 commit comments

Comments
 (0)