Skip to content

Commit 49df525

Browse files
Merge pull request #18 from mjhough/master
Handle finitely many arbitrary convex constraints with DFO-LS
2 parents 65eebeb + c9709db commit 49df525

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+2320
-1578
lines changed

dfols/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
88
It solves the nonlinear least-squares problem:
99
min_{x} f(x) = r1(x)**2 + ... + rm(x)**2,
10-
subject to the (optional) bounds
11-
lb <= x <= ub,
10+
(optionally) subject to finitely many convex constraints,
1211
where each function ri(x) is differentiable, possibly nonconvex.
1312
Since the derivatives of ri(x) are never required or approximated,
1413
the solver works when the evaluation of ri(x) is noisy.

dfols/controller.py

Lines changed: 130 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
'EXIT_INPUT_ERROR', 'EXIT_TR_INCREASE_ERROR', 'EXIT_LINALG_ERROR', 'EXIT_FALSE_SUCCESS_WARNING',
4444
'EXIT_AUTO_DETECT_RESTART_WARNING']
4545

46+
EXIT_TR_INCREASE_WARNING = 5 # warning, TR increase in proj constrained case - likely due to multiple active constraints
4647
EXIT_AUTO_DETECT_RESTART_WARNING = 4 # warning, auto-detected restart criteria
4748
EXIT_FALSE_SUCCESS_WARNING = 3 # warning, maximum fake successful steps reached
4849
EXIT_SLOW_WARNING = 2 # warning, maximum number of slow (successful) iterations reached
@@ -70,6 +71,8 @@ def message(self, with_stem=True):
7071
return "Warning (slow progress): " + self.msg
7172
elif self.flag == EXIT_MAXFUN_WARNING:
7273
return "Warning (max evals): " + self.msg
74+
elif self.flag == EXIT_TR_INCREASE_WARNING:
75+
return "Warning (trust region increase): " + self.msg
7376
elif self.flag == EXIT_INPUT_ERROR:
7477
return "Error (bad input): " + self.msg
7578
elif self.flag == EXIT_TR_INCREASE_ERROR:
@@ -82,7 +85,7 @@ def message(self, with_stem=True):
8285
return "Unknown exit flag: " + self.msg
8386

8487
def able_to_do_restart(self):
85-
if self.flag in [EXIT_TR_INCREASE_ERROR, EXIT_LINALG_ERROR, EXIT_SLOW_WARNING, EXIT_AUTO_DETECT_RESTART_WARNING]:
88+
if self.flag in [EXIT_TR_INCREASE_ERROR, EXIT_TR_INCREASE_WARNING, EXIT_LINALG_ERROR, EXIT_SLOW_WARNING, EXIT_AUTO_DETECT_RESTART_WARNING]:
8689
return True
8790
elif self.flag in [EXIT_MAXFUN_WARNING, EXIT_INPUT_ERROR]:
8891
return False
@@ -92,13 +95,13 @@ def able_to_do_restart(self):
9295

9396

9497
class Controller(object):
95-
def __init__(self, objfun, args, x0, r0, r0_nsamples, xl, xu, npt, rhobeg, rhoend, nf, nx, maxfun, params,
98+
def __init__(self, objfun, args, x0, r0, r0_nsamples, xl, xu, projections, npt, rhobeg, rhoend, nf, nx, maxfun, params,
9699
scaling_changes, do_logging):
97100
self.do_logging = do_logging
98101
self.objfun = objfun
99102
self.args = args
100103
self.maxfun = maxfun
101-
self.model = Model(npt, x0, r0, xl, xu, r0_nsamples, precondition=params("interpolation.precondition"),
104+
self.model = Model(npt, x0, r0, xl, xu, projections, r0_nsamples, precondition=params("interpolation.precondition"),
102105
abs_tol = params("model.abs_tol"), rel_tol = params("model.rel_tol"), do_logging=do_logging)
103106
self.nf = nf
104107
self.nx = nx
@@ -137,6 +140,107 @@ def initialise_coordinate_directions(self, number_of_samples, num_directions, pa
137140
assert self.model.num_pts <= (self.n() + 1) * (self.n() + 2) // 2, "prelim: must have npt <= (n+1)(n+2)/2"
138141
assert 1 <= num_directions < self.model.num_pts, "Initialisation: must have 1 <= ndirs_initial < npt"
139142

143+
144+
if self.model.projections:
145+
D = np.zeros((self.n(),self.n()))
146+
k = 0
147+
while k < self.n():
148+
ek = np.zeros(self.n())
149+
ek[k] = 1
150+
p = np.dot(ek,min(1,self.delta))
151+
yk = dykstra(self.model.projections, self.model.xbase + p, max_iter=params("dykstra.max_iters"), tol=params("dykstra.d_tol"))
152+
D[k,:] = yk - self.model.xbase
153+
154+
k += 1 # move on to next point
155+
156+
# Have at least one L.D. vector, try negative direction on bad one first
157+
k = 0
158+
mr_tol = params("matrix_rank.r_tol")
159+
D_rank, diag = qr_rank(D,tol=mr_tol)
160+
while D_rank != num_directions and k < self.n():
161+
if diag[k] < mr_tol:
162+
ek = np.zeros(self.n())
163+
ek[k] = 1
164+
p = -np.dot(ek,min(1,self.delta))
165+
yk = dykstra(self.model.projections, self.model.xbase + p, max_iter=params("dykstra.max_iters"), tol=params("dykstra.d_tol"))
166+
dk = D[k,:].copy()
167+
D[k,:] = yk - self.model.xbase
168+
D_rank2, _diag2 = qr_rank(D,tol=params("matrix_rank.r_tol"))
169+
if D_rank2 <= D_rank:
170+
# Did not improve rank, revert change
171+
D[k,:] = dk
172+
# rank was improved, update D_rank for next comparison
173+
D_rank = D_rank2
174+
k += 1
175+
176+
# Try random combination of negatives...
177+
k = 0
178+
slctr = np.random.randint(0, 1+1, self.n()) # generate rand binary "selector" array
179+
D_rank, diag = qr_rank(D,tol=params("matrix_rank.r_tol"))
180+
while D_rank != num_directions and k < 100*self.n():
181+
if slctr[k%self.n()] == 1: # if selector says make -ve, make -ve
182+
ek = np.zeros(self.n())
183+
ek[k%self.n()] = 1
184+
p = -np.dot(ek,min(1,self.delta))
185+
yk = dykstra(self.model.projections, self.model.xbase + p, max_iter=params("dykstra.max_iters"), tol=params("dykstra.d_tol"))
186+
dk = D[k%self.n(),:].copy()
187+
D[k%self.n(),:] = yk - self.model.xbase
188+
D_rank2, _diag2 = qr_rank(D,tol=params("matrix_rank.r_tol"))
189+
if D_rank2 <= D_rank:
190+
# Did not improve rank, revert change
191+
D[k%self.n(),:] = dk
192+
# rank was improved, update D_rank for next comparison
193+
D_rank = D_rank2
194+
195+
# Go again
196+
slctr = np.random.randint(0, 1+1, self.n())
197+
k += 1
198+
199+
# Set still not L.I? Try random directions
200+
i = 0
201+
D_rank, diag = qr_rank(D,tol=params("matrix_rank.r_tol"))
202+
while D_rank != num_directions and i <= 100*num_directions:
203+
k = 0
204+
while k < self.n():
205+
if diag[k] < mr_tol:
206+
p = np.random.normal(size=self.n())
207+
p = p/np.linalg.norm(p)
208+
p = np.dot(p,min(1,self.delta))
209+
yk = dykstra(self.model.projections, self.model.xbase + p, max_iter=params("dykstra.max_iters"), tol=params("dykstra.d_tol"))
210+
dk = D[k,:].copy()
211+
D[k,:] = yk - self.model.xbase
212+
D_rank2, _diag2 = qr_rank(D,tol=params("matrix_rank.r_tol"))
213+
if D_rank2 <= D_rank:
214+
# Did not improve rank, revert change
215+
D[k,:] = dk
216+
# rank was improved, update D_rank for next comparison
217+
D_rank = D_rank2
218+
k += 1
219+
i += 1
220+
221+
if D_rank != num_directions:
222+
raise RuntimeError("Unable to generate suitable initial directions")
223+
224+
# we have a L.I set of interpolation points
225+
for k in range(0,self.n()):
226+
# Evaluate objective at this new point
227+
x = self.model.as_absolute_coordinates(D[k, :])
228+
rvec_list, f_list, num_samples_run, exit_info = self.evaluate_objective(x, number_of_samples, params)
229+
230+
# Handle exit conditions (f < min obj value or maxfun reached)
231+
if exit_info is not None:
232+
if num_samples_run > 0:
233+
self.model.save_point(x, np.mean(rvec_list[:num_samples_run, :], axis=0), num_samples_run,
234+
x_in_abs_coords=True)
235+
return exit_info # return & quit
236+
237+
# Otherwise, add new results (increments model.npt_so_far)
238+
self.model.change_point(k+1, x - self.model.xbase, rvec_list[0, :]) # expect step, not absolute x
239+
for i in range(1, num_samples_run):
240+
self.model.add_new_sample(k+1, rvec_extra=rvec_list[i, :])
241+
242+
return None # return & continue
243+
140244
at_lower_boundary = (self.model.sl > -0.01 * self.delta) # sl = xl - x0, should be -ve, actually < -rhobeg
141245
at_upper_boundary = (self.model.su < 0.01 * self.delta) # su = xu - x0, should be +ve, actually > rhobeg
142246

@@ -147,17 +251,19 @@ def initialise_coordinate_directions(self, number_of_samples, num_directions, pa
147251
# k = 2n+1, ..., (n+1)(n+2)/2 --> off-diagonal directions
148252
if 1 <= k < self.n() + 1: # first step along coord directions
149253
dirn = k - 1 # direction to move in (0,...,n-1)
150-
stepa = self.delta if not at_upper_boundary[dirn] else -self.delta
254+
stepa = self.delta if not at_upper_boundary[dirn] else -self.delta # take a +delta step if at lower, -delta if at upper
151255
stepb = None
152-
xpts_added[k, dirn] = stepa
256+
xpts_added[k, dirn] = stepa # set new (relative) point to the step since we haven't done any moving, so relative point is all zeros.
153257

154258
elif self.n() + 1 <= k < 2 * self.n() + 1: # second step along coord directions
155259
dirn = k - self.n() - 1 # direction to move in (0,...,n-1)
156-
stepa = xpts_added[k - self.n(), dirn]
157-
stepb = -self.delta
260+
stepa = xpts_added[k - self.n(), dirn] # previous step
261+
stepb = -self.delta # new step
158262
if at_lower_boundary[dirn]:
263+
# if at lower boundary, set the second step to be +ve
159264
stepb = min(2.0 * self.delta, self.model.su[dirn]) # su = xu - x0, should be +ve
160265
if at_upper_boundary[dirn]:
266+
# if at upper boundary, set the second step to be -ve
161267
stepb = max(-2.0 * self.delta, self.model.sl[dirn]) # sl = xl - x0, should be -ve
162268
xpts_added[k, dirn] = stepb
163269

@@ -325,10 +431,13 @@ def get_new_direction_for_growing(self, step_length):
325431

326432
return dirn * (step_length / LA.norm(dirn))
327433

328-
def trust_region_step(self):
434+
def trust_region_step(self, params):
329435
# Build model for full least squares objectives
330436
gopt, H = self.model.build_full_model()
331-
d, gnew, crvmin = trsbox(self.model.xopt(), gopt, H, self.model.sl, self.model.su, self.delta)
437+
if self.model.projections:
438+
d, gnew, crvmin = ctrsbox(self.model.xopt(abs_coordinates=True), gopt, H, self.model.projections, self.delta, d_max_iters=params("dykstra.max_iters"), d_tol=params("dykstra.d_tol"))
439+
else:
440+
d, gnew, crvmin = trsbox(self.model.xopt(), gopt, H, self.model.sl, self.model.su, self.delta)
332441
return d, gopt, H, gnew, crvmin
333442

334443
def geometry_step(self, knew, adelt, number_of_samples, params):
@@ -337,8 +446,13 @@ def geometry_step(self, knew, adelt, number_of_samples, params):
337446
try:
338447
c, g = self.model.lagrange_gradient(knew)
339448
# c = 1.0 if knew == self.model.kopt else 0.0 # based at xopt, just like d
340-
# Solve problem: bounds are sl <= xnew <= su, and ||xnew-xopt|| <= adelt
341-
xnew = trsbox_geometry(self.model.xopt(), c, g, np.minimum(self.model.sl, 0.0), np.maximum(self.model.su, 0.0), adelt)
449+
if self.model.projections:
450+
# Solve problem: use projection onto arbitrary constraints, and ||xnew-xopt|| <= adelt
451+
step = ctrsbox_geometry(self.model.xopt(abs_coordinates=True), c, g, self.model.projections, adelt, d_max_iters=params("dykstra.max_iters"), d_tol=params("dykstra.d_tol"))
452+
xnew = self.model.xopt() + step
453+
else:
454+
# Solve problem: bounds are sl <= xnew <= su, and ||xnew-xopt|| <= adelt
455+
xnew = trsbox_geometry(self.model.xopt(), c, g, np.minimum(self.model.sl, 0.0), np.maximum(self.model.su, 0.0), adelt)
342456
except LA.LinAlgError:
343457
exit_info = ExitInformation(EXIT_LINALG_ERROR, "Singular matrix encountered in geometry step")
344458
return exit_info # didn't fix geometry - return & quit
@@ -499,13 +613,16 @@ def reduce_rho(self, current_iter, params):
499613
def calculate_ratio(self, current_iter, rvec_list, d, gopt, H):
500614
exit_info = None
501615
f = sumsq(np.mean(rvec_list, axis=0)) # estimate actual objective value
502-
pred_reduction = - model_value(gopt, H, d)
616+
pred_reduction = - model_value(gopt, H, d) # negative of m since m(0) = 0
503617
actual_reduction = self.model.fopt() - f
504618
self.diffs = [abs(actual_reduction - pred_reduction), self.diffs[0], self.diffs[1]]
505619
if min(sqrt(sumsq(d)), self.delta) > self.rho: # if ||d|| >= rho, successful!
506620
self.last_successful_iter = current_iter
507621
if pred_reduction < 0.0:
508-
exit_info = ExitInformation(EXIT_TR_INCREASE_ERROR, "Trust region step gave model increase")
622+
if len(self.model.projections) > 1: # if we are using multiple projections, only warn since likely due to constraint intersection
623+
exit_info = ExitInformation(EXIT_TR_INCREASE_WARNING, "Either multiple constraints are active or trust region step gave model increase")
624+
else:
625+
exit_info = ExitInformation(EXIT_TR_INCREASE_ERROR, "Either rust region step gave model increase")
509626

510627
ratio = actual_reduction / pred_reduction
511628
return ratio, exit_info

dfols/model.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@
3636
import scipy.linalg as LA
3737

3838
from .trust_region import trsbox_geometry
39-
from .util import sumsq
39+
from .util import sumsq, dykstra
4040

4141
__all__ = ['Model']
4242

4343
class Model(object):
44-
def __init__(self, npt, x0, r0, xl, xu, r0_nsamples, n=None, m=None, abs_tol=1e-12, rel_tol=1e-20, precondition=True,
44+
def __init__(self, npt, x0, r0, xl, xu, projections, r0_nsamples, n=None, m=None, abs_tol=1e-12, rel_tol=1e-20, precondition=True,
4545
do_logging=True):
4646
if n is None:
4747
n = len(x0)
@@ -63,6 +63,7 @@ def __init__(self, npt, x0, r0, xl, xu, r0_nsamples, n=None, m=None, abs_tol=1e-
6363
self.xbase = x0.copy()
6464
self.sl = xl - self.xbase # lower bound w.r.t. xbase (require xpt >= sl)
6565
self.su = xu - self.xbase # upper bound w.r.t. xbase (require xpt <= su)
66+
self.projections = projections
6667
self.points = np.zeros((npt, n)) # interpolation points w.r.t. xbase
6768

6869
# Function values
@@ -123,6 +124,8 @@ def xpt(self, k, abs_coordinates=False):
123124
return np.minimum(np.maximum(self.sl, self.points[k, :].copy()), self.su)
124125
else:
125126
# Apply bounds and convert back to absolute coordinates
127+
if self.projections:
128+
return dykstra(self.projections, self.xbase + self.points[k,:])
126129
return self.xbase + np.minimum(np.maximum(self.sl, self.points[k, :]), self.su)
127130

128131
def rvec(self, k):
@@ -133,8 +136,10 @@ def fval(self, k):
133136
assert 0 <= k < self.npt(), "Invalid index %g" % k
134137
return self.fval[k]
135138

136-
def as_absolute_coordinates(self, x):
139+
def as_absolute_coordinates(self, x, full_dykstra=False):
137140
# If x were an interpolation point, get the absolute coordinates of x
141+
if self.projections:
142+
return dykstra(self.projections, self.xbase + x)
138143
return self.xbase + np.minimum(np.maximum(self.sl, x), self.su)
139144

140145
def xpt_directions(self, include_kopt=True):

dfols/params.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ def __init__(self, n, npt, maxfun, objfun_has_noise=False):
109109
self.params["growing.full_rank.min_sing_val"] = 1e-6 # absolute floor on singular values
110110
self.params["growing.full_rank.svd_max_jac_cond"] = 1e8 # maximum condition number of Jacobian
111111
self.params["growing.perturb_trust_region_step"] = False # add random direction onto TRS solution?
112+
# Dykstra's algorithm
113+
self.params["dykstra.d_tol"] = 1e-10
114+
self.params["dykstra.max_iters"] = 100
115+
# Matrix rank algorithm
116+
self.params["matrix_rank.r_tol"] = 1e-18
112117

113118
self.params_changed = {}
114119
for p in self.params:
@@ -257,6 +262,12 @@ def param_type(self, key, npt):
257262
type_str, nonetype_ok, lower, upper = 'float', True, 1.0, None
258263
elif key == "growing.perturb_trust_region_step":
259264
type_str, nonetype_ok, lower, upper = 'bool', False, None, None
265+
elif key == "dykstra.d_tol":
266+
type_str, nonetype_ok, lower, upper = 'float', False, 0.0, None
267+
elif key == "dykstra.max_iters":
268+
type_str, nonetype_ok, lower, upper = 'int', False, 0, None
269+
elif key == "matrix_rank.r_tol":
270+
type_str, nonetype_ok, lower, upper = 'float', False, 0.0, None
260271
else:
261272
assert False, "ParameterList.param_type() has unknown key: %s" % key
262273
return type_str, nonetype_ok, lower, upper

0 commit comments

Comments
 (0)