Skip to content

Commit 7173cab

Browse files
committed
Implemented projection-constrained initial directions
1 parent ea94ca9 commit 7173cab

File tree

3 files changed

+154
-4
lines changed

3 files changed

+154
-4
lines changed

pybobyqa/controller.py

+125-3
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,121 @@ def initialise_random_directions(self, number_of_samples, num_directions, params
268268

269269
return None
270270

271-
def trust_region_step(self):
271+
def initialise_general_constraints(self, number_of_samples, num_directions, params):
272+
# Initialisation when we have general projection constraints, as well as bound constraints
273+
# Basic idea: start with bound-respecting coordinate directions, just like initialise_coordinate_directions
274+
# Then, use geometry-improving procedures to make all these points feasible wrt projection constraints
275+
# Only after this is all done, then evaluate the initial points
276+
# (after the initial coordinate directions given, add points to model with nan objective value)
277+
if self.do_logging:
278+
module_logger.debug("Initialising to satisfy general projection constraints")
279+
# self.model already has x0 evaluated (and x0 feasible), so only need to initialise the other points
280+
# num_directions = params("growing.ndirs_initial")
281+
assert self.model.num_pts <= (self.n() + 1) * (self.n() + 2) // 2, "prelim: must have npt <= (n+1)(n+2)/2"
282+
assert 1 <= num_directions < self.model.num_pts, "Initialisation: must have 1 <= ndirs_initial < npt"
283+
284+
at_lower_boundary = (self.model.sl > -0.01 * self.delta) # sl = xl - x0, should be -ve, actually < -rhobeg
285+
at_upper_boundary = (self.model.su < 0.01 * self.delta) # su = xu - x0, should be +ve, actually > rhobeg
286+
287+
# **** STEP 1 ****
288+
# Build initial coordinate-based directions
289+
xpts_added = np.zeros((num_directions + 1, self.n()))
290+
for k in range(1, num_directions + 1):
291+
# k = 0 --> base point (xpt = 0) [ not here]
292+
# k = 1, ..., 2n --> coordinate directions [1,...,n and n+1,...,2n]
293+
# k = 2n+1, ..., (n+1)(n+2)/2 --> off-diagonal directions
294+
if 1 <= k < self.n() + 1: # first step along coord directions
295+
dirn = k - 1 # direction to move in (0,...,n-1)
296+
stepa = self.delta if not at_upper_boundary[dirn] else -self.delta
297+
stepb = None
298+
xpts_added[k, dirn] = stepa
299+
300+
elif self.n() + 1 <= k < 2 * self.n() + 1: # second step along coord directions
301+
dirn = k - self.n() - 1 # direction to move in (0,...,n-1)
302+
stepa = xpts_added[k - self.n(), dirn]
303+
stepb = -self.delta
304+
if at_lower_boundary[dirn]:
305+
stepb = min(2.0 * self.delta, self.model.su[dirn]) # su = xu - x0, should be +ve
306+
if at_upper_boundary[dirn]:
307+
stepb = max(-2.0 * self.delta, self.model.sl[dirn]) # sl = xl - x0, should be -ve
308+
xpts_added[k, dirn] = stepb
309+
310+
else: # k = 2n+1, ..., (n+1)(n+2)/2
311+
# p = (k - 1) % n + 1 # cycles through (1,...,n), starting at 2n+1 --> 1
312+
# l = (k - 2 * n - 1) / n + 1 # (1,...,1, 2, ..., 2, etc.) where each number appears n times
313+
# q = (p + l if p + l <= n else p + l - n)
314+
stepa = None
315+
stepb = None
316+
itemp = (k - self.n() - 1) // self.n()
317+
q = k - itemp * self.n() - self.n()
318+
p = q + itemp
319+
if p > self.n():
320+
p, q = q, p - self.n() # does swap correctly in Python
321+
322+
xpts_added[k, p - 1] = xpts_added[p, p - 1]
323+
xpts_added[k, q - 1] = xpts_added[q, q - 1]
324+
325+
# Add this new point to the model with a nan function value for now, since it is likely to be moved
326+
# to ensure the projection constraints are respected
327+
x = self.model.as_absolute_coordinates(xpts_added[k, :])
328+
self.model.change_point(k, x - self.model.xbase, np.nan) # expect step, not absolute x
329+
330+
# (no need to switch the k-th and (k-N)-th points, since we can't compare function values at this stage)
331+
332+
# **** STEP 2 ****
333+
# Now go through all interpolation points and check if they are feasible wrt projection constraints
334+
# If not, do a geometry-improving step (again with nan objective value)
335+
for k in range(1, num_directions + 1):
336+
x = self.model.as_absolute_coordinates(xpts_added[k, :])
337+
this_x_feasible = True
338+
for i, proj in enumerate(self.model.projections):
339+
if np.linalg.norm(x - proj(x)) > params("projections.feasible_tol"):
340+
this_x_feasible = False
341+
break # quit loop, only need to find one bad constraint
342+
343+
# Calculate Lagrange polynomial and update this point
344+
if not this_x_feasible:
345+
try:
346+
c, g, H = self.model.lagrange_polynomial(k) # based at xopt
347+
if np.any(np.isinf(g)) or np.any(np.isnan(g)) or np.any(np.isinf(H)) or np.any(np.isnan(H)):
348+
raise ValueError
349+
xnew = ctrsbox_geometry(self.model.xbase, self.model.xopt(), c, g, H, self.model.sl, self.model.su,
350+
self.model.projections, self.delta,
351+
dykstra_max_iters=params("projections.dykstra.max_iters"),
352+
dykstra_tol=params("projections.feasible_tol"),
353+
gtol=params("projections.pgd_tol"))
354+
except LA.LinAlgError:
355+
exit_info = ExitInformation(EXIT_LINALG_ERROR, "Singular matrix encountered in initialization geometry step")
356+
return exit_info # didn't fix geometry - return & quit
357+
except ValueError:
358+
# A ValueError may be raised if gopt or H have nan/inf values (issue #23)
359+
# Ideally this should be picked up earlier in self.model.lagrange_polynomial(...)
360+
exit_info = ExitInformation(EXIT_LINALG_ERROR, "Error when calculating initialization geometry-improving step")
361+
return exit_info # didn't fix geometry - return & quit
362+
363+
# Update point and fill with np.nan again, just for now
364+
self.model.change_point(k, xnew, np.nan) # expect step, not absolute x
365+
366+
# **** STEP 3 ****
367+
# Now we are ready to evaluate our initial points
368+
for k in range(1, num_directions + 1):
369+
x = self.model.xpt(k, abs_coordinates=True)
370+
f_list, num_samples_run, exit_info = self.evaluate_objective(x, number_of_samples, params)
371+
372+
# Handle exit conditions (f < min obj value or maxfun reached)
373+
if exit_info is not None:
374+
if num_samples_run > 0:
375+
self.model.save_point(x, np.mean(f_list[:num_samples_run]), num_samples_run, x_in_abs_coords=True)
376+
return exit_info # didn't fix geometry - return & quit
377+
378+
# Otherwise, add new results
379+
self.model.change_point(k, x - self.model.xbase, f_list[0]) # expect step, not absolute x
380+
for i in range(1, num_samples_run):
381+
self.model.add_new_sample(k, f_extra=f_list[i])
382+
383+
return None # return & continue
384+
385+
def trust_region_step(self, params):
272386
# Build model for full least squares objectives
273387
gopt, H = self.model.build_full_model()
274388
if np.any(np.isinf(gopt)) or np.any(np.isnan(gopt)) or np.any(np.isinf(H)) or np.any(np.isnan(H)):
@@ -281,7 +395,11 @@ def trust_region_step(self):
281395
if self.model.projections is None:
282396
d, gnew, crvmin = trsbox(self.model.xopt(), gopt, H, self.model.sl, self.model.su, self.delta)
283397
else:
284-
d, gnew, crvmin = ctrsbox(self.model.xbase, self.model.xopt(), gopt, H, self.model.sl, self.model.su, self.model.projections, self.delta)
398+
d, gnew, crvmin = ctrsbox(self.model.xbase, self.model.xopt(), gopt, H, self.model.sl, self.model.su,
399+
self.model.projections, self.delta,
400+
dykstra_max_iters=params("projections.dykstra.max_iters"),
401+
dykstra_tol=params("projections.feasible_tol"),
402+
gtol=params("projections.pgd_tol"))
285403
except ValueError:
286404
# A ValueError may be raised if gopt or H have nan/inf values (issue #14)
287405
# Although this should be picked up earlier, in this situation just return a zero
@@ -307,7 +425,11 @@ def geometry_step(self, knew, adelt, number_of_samples, params):
307425
if self.model.projections is None:
308426
xnew = trsbox_geometry(self.model.xopt(), c, g, H, self.model.sl, self.model.su, adelt)
309427
else:
310-
xnew = ctrsbox_geometry(self.model.xbase, self.model.xopt(), c, g, H, self.model.sl, self.model.su, self.model.projections, adelt)
428+
xnew = ctrsbox_geometry(self.model.xbase, self.model.xopt(), c, g, H, self.model.sl, self.model.su,
429+
self.model.projections, adelt,
430+
dykstra_max_iters=params("projections.dykstra.max_iters"),
431+
dykstra_tol=params("projections.feasible_tol"),
432+
gtol=params("projections.pgd_tol"))
311433
except ValueError:
312434
# A ValueError may be raised if gopt or H have nan/inf values (issue #23)
313435
# Ideally this should be picked up earlier in self.model.lagrange_polynomial(...)

pybobyqa/params.py

+14
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ def __init__(self, n, npt, maxfun, objfun_has_noise=False, seek_global_minimum=F
8686
self.params["restarts.auto_detect.min_chg_model_slope"] = 1.5e-2
8787
self.params["restarts.auto_detect.min_correl"] = 0.1
8888

89+
# Projections
90+
self.params["projections.dykstra.d_tol"] = 1e-10
91+
self.params["projections.dykstra.max_iters"] = 100
92+
self.params["projections.feasible_tol"] = 1e-10
93+
self.params["projections.pgd_tol"] = 1e-8
94+
8995
self.params_changed = {}
9096
for p in self.params:
9197
self.params_changed[p] = False
@@ -193,6 +199,14 @@ def param_type(self, key, npt):
193199
type_str, nonetype_ok, lower, upper = 'float', False, 0.0, None
194200
elif key == "restarts.auto_detect.min_correl":
195201
type_str, nonetype_ok, lower, upper = 'float', False, 0.0, 1.0
202+
elif key == "projections.dykstra.d_tol":
203+
type_str, nonetype_ok, lower, upper = 'float', False, 0.0, None
204+
elif key == "projections.dykstra.max_iters":
205+
type_str, nonetype_ok, lower, upper = 'int', False, 1, None
206+
elif key == "projections.feasible_tol":
207+
type_str, nonetype_ok, lower, upper = 'float', False, 0.0, None
208+
elif key == "projections.pgd_tol":
209+
type_str, nonetype_ok, lower, upper = 'float', False, 0.0, None
196210
else:
197211
assert False, "ParameterList.param_type() has unknown key: %s" % key
198212
return type_str, nonetype_ok, lower, upper

pybobyqa/solver.py

+15-1
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ def solve_main(objfun, x0, args, xl, xu, projections, npt, rhobeg, rhoend, maxfu
231231

232232

233233
# Trust region step
234-
d, gopt, H, gnew, crvmin = control.trust_region_step()
234+
d, gopt, H, gnew, crvmin = control.trust_region_step(params)
235235
if do_logging:
236236
module_logger.debug("Trust region step is d = " + str(d))
237237
xnew = control.model.xopt() + d
@@ -813,6 +813,20 @@ def solve(objfun, x0, args=(), bounds=None, projections=None, npt=None, rhobeg=N
813813
warnings.warn("x0 above upper bound, adjusting", RuntimeWarning)
814814
x0[idx] = xu[idx]
815815

816+
# Enforce feasibility of x0 with respect to projection constraints
817+
if projections is not None:
818+
x0_feasible = True
819+
for i, proj in enumerate(projections):
820+
if np.linalg.norm(x0 - proj(x0)) > params("projections.feasible_tol"):
821+
x0_feasible = False
822+
warnings.warn("x0 not feasible with respect to projections[%g], adjusting" % i, RuntimeWarning)
823+
break # quit loop, only need to find one bad constraint
824+
if not x0_feasible:
825+
proj_box = lambda w: pbox(w, xl, xu)
826+
P = list(projections) # make a copy of the projections list
827+
P.append(proj_box)
828+
x0 = dykstra(P, x0)
829+
816830
# Call main solver (first time)
817831
diagnostic_info = DiagnosticInfo()
818832
nruns = 0

0 commit comments

Comments
 (0)