Skip to content

Commit 4bce6be

Browse files
authored
Improve parallelization efficiency (#4)
1 parent f1daa80 commit 4bce6be

8 files changed

Lines changed: 426 additions & 38 deletions

src/tranquilo/acceptance_decision.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import NamedTuple
88

99
import numpy as np
10+
import pandas as pd
1011

1112
from tranquilo.acceptance_sample_size import (
1213
get_acceptance_sample_sizes,
@@ -20,6 +21,7 @@ def get_acceptance_decider(acceptance_decider, acceptance_options):
2021
"classic": _accept_classic,
2122
"naive_noisy": accept_naive_noisy,
2223
"noisy": accept_noisy,
24+
"classic_line_search": accept_classic_line_search,
2325
}
2426

2527
out = get_component(
@@ -85,6 +87,142 @@ def accept_naive_noisy(
8587
return out
8688

8789

90+
def accept_classic_line_search(
91+
subproblem_solution,
92+
state,
93+
history,
94+
*,
95+
wrapped_criterion,
96+
min_improvement,
97+
batch_size,
98+
sample_points,
99+
search_radius_factor,
100+
rng,
101+
):
102+
# ==================================================================================
103+
# Quick return if batch_size is 1
104+
105+
if batch_size == 1:
106+
return _accept_classic(
107+
subproblem_solution=subproblem_solution,
108+
state=state,
109+
history=history,
110+
wrapped_criterion=wrapped_criterion,
111+
min_improvement=min_improvement,
112+
)
113+
114+
# ==================================================================================
115+
# Add candidate to history
116+
117+
candidate_x = subproblem_solution.x
118+
candidate_index = history.add_xs(candidate_x)
119+
120+
eval_info = {candidate_index: 1}
121+
122+
# ==================================================================================
123+
# Determine whether the candidate it sufficiently close to the border of the
124+
# trustregion, in which case we perform a line search
125+
126+
perform_line_search = _is_on_border(state.trustregion, x=candidate_x, rtol=1e-1)
127+
128+
if perform_line_search:
129+
alpha_grid = _generate_alpha_grid(batch_size)
130+
131+
line_search_xs = _sample_on_line(
132+
start_point=state.x, direction_point=candidate_x, alpha_grid=alpha_grid
133+
)
134+
else:
135+
line_search_xs = None
136+
137+
# ==================================================================================
138+
# Check whether there are any unallocated evaluations left, and if yes perform a
139+
# speculative sampling
140+
141+
n_evals_line_search = 0 if line_search_xs is None else len(line_search_xs)
142+
n_unallocated_evals = batch_size - 1 - n_evals_line_search
143+
144+
if n_unallocated_evals > 0:
145+
speculative_xs = _generate_speculative_sample(
146+
new_center=candidate_x,
147+
search_radius_factor=search_radius_factor,
148+
trustregion=state.trustregion,
149+
sample_points=sample_points,
150+
n_points=n_unallocated_evals,
151+
history=history,
152+
rng=rng,
153+
)
154+
else:
155+
speculative_xs = None
156+
157+
# ==================================================================================
158+
# Consolidate newly sampled points
159+
160+
if line_search_xs is not None and speculative_xs is not None:
161+
new_xs = np.vstack([line_search_xs, speculative_xs])
162+
elif line_search_xs is not None:
163+
new_xs = line_search_xs
164+
elif speculative_xs is not None:
165+
new_xs = speculative_xs
166+
167+
# ==================================================================================
168+
# Add new points to history and evaluate criterion
169+
170+
new_indices = history.add_xs(new_xs)
171+
172+
for idx in new_indices:
173+
eval_info[idx] = 1
174+
175+
wrapped_criterion(eval_info)
176+
177+
# ==================================================================================
178+
# Calculate rho
179+
180+
candidate_fval = np.mean(history.get_fvals(candidate_index))
181+
182+
actual_improvement = -(candidate_fval - state.fval)
183+
184+
rho = calculate_rho(
185+
actual_improvement=actual_improvement,
186+
expected_improvement=subproblem_solution.expected_improvement,
187+
)
188+
189+
# ==================================================================================
190+
# Check if there are any better points
191+
192+
new_fvals = history.get_fvals(new_indices)
193+
new_fvals = pd.Series({i: np.mean(fvals) for i, fvals in new_fvals.items()})
194+
new_fval_argmin = new_fvals.idxmin()
195+
196+
found_better_candidate = new_fvals.loc[new_fval_argmin] < candidate_fval
197+
198+
# If a better point was found, update the candidates
199+
if found_better_candidate:
200+
candidate_x = history.get_xs(new_fval_argmin)
201+
candidate_fval = new_fvals.loc[new_fval_argmin]
202+
candidate_index = new_fval_argmin
203+
204+
# ==================================================================================
205+
# Calculate the overall improvement using a potentially updated candidate and draw
206+
# the acceptance conclusions based on that.
207+
208+
overall_improvement = -(candidate_fval - state.fval)
209+
is_accepted = overall_improvement >= min_improvement
210+
211+
# ==================================================================================
212+
# Return results
213+
214+
res = _get_acceptance_result(
215+
candidate_x=candidate_x,
216+
candidate_fval=candidate_fval,
217+
candidate_index=candidate_index,
218+
rho=rho,
219+
is_accepted=is_accepted,
220+
old_state=state,
221+
n_evals=1,
222+
)
223+
return res
224+
225+
88226
def _accept_simple(
89227
subproblem_solution,
90228
state,
@@ -247,3 +385,100 @@ def calculate_rho(actual_improvement, expected_improvement):
247385
else:
248386
rho = actual_improvement / expected_improvement
249387
return rho
388+
389+
390+
# ======================================================================================
391+
# Helper functions for line search
392+
# ======================================================================================
393+
394+
395+
def _generate_speculative_sample(
396+
new_center, trustregion, sample_points, n_points, history, search_radius_factor, rng
397+
):
398+
"""Generative a speculative sample.
399+
400+
Args:
401+
new_center (np.ndarray): New center of the trust region.
402+
trustregion (Region): Current trust region.
403+
sample_points (callable): Function to sample points.
404+
n_points (int): Number of points to sample.
405+
history (History): Tranquilo history.
406+
search_radius_factor (float): Factor to multiply the trust region radius by to
407+
get the search radius.
408+
rng (np.random.Generator): Random number generator.
409+
410+
Returns:
411+
np.ndarray: Speculative sample.
412+
413+
"""
414+
search_region = trustregion._replace(
415+
center=new_center, radius=search_radius_factor * trustregion.radius
416+
)
417+
418+
old_indices = history.get_x_indices_in_region(search_region)
419+
420+
old_xs = history.get_xs(old_indices)
421+
422+
model_xs = old_xs
423+
424+
new_xs = sample_points(
425+
search_region,
426+
n_points=n_points,
427+
existing_xs=model_xs,
428+
rng=rng,
429+
)
430+
return new_xs
431+
432+
433+
def _sample_on_line(start_point, direction_point, alpha_grid):
434+
"""Sample points on a line defined by startind and direction points.
435+
436+
Args:
437+
start_point (np.ndarray): Starting point of the line.
438+
direction_point (np.ndarray): Direction point of the line.
439+
alpha_grid (np.ndarray): Grid of alphas to sample points on the line. 0
440+
corresponds to the starting point and 1 corresponds to the direction point.
441+
Points larger than 1 are beyond the direction point.
442+
443+
Returns:
444+
np.ndarray: Sampled points on the line.
445+
446+
"""
447+
xs = start_point + alpha_grid.reshape(-1, 1) * (direction_point - start_point)
448+
return xs
449+
450+
451+
def _is_on_border(trustregion, x, rtol):
452+
"""Check whether a point is sufficiently close to the border of a trust region.
453+
454+
Args:
455+
trustregion (Region): Trust region.
456+
x (np.ndarray): Point to check.
457+
rtol (float): Relative tolerance.
458+
459+
Returns:
460+
bool: True if the point is sufficiently close to the border of the trust region.
461+
462+
"""
463+
if trustregion.shape == "sphere":
464+
candidate_on_border = _is_on_sphere_border(trustregion, x=x, rtol=rtol)
465+
else:
466+
candidate_on_border = _is_on_cube_border(trustregion, x=x, rtol=rtol)
467+
return candidate_on_border
468+
469+
470+
def _is_on_sphere_border(trustregion, x, rtol):
471+
x_center_dist = np.linalg.norm(x - trustregion.center, ord=2)
472+
return np.isclose(x_center_dist, trustregion.radius, rtol=rtol)
473+
474+
475+
def _is_on_cube_border(trustregion, x, rtol):
476+
cube_bounds = trustregion.cube_bounds
477+
is_on_lower_border = np.isclose(x, cube_bounds.lower, rtol=rtol).any()
478+
is_on_upper_border = np.isclose(x, cube_bounds.upper, rtol=rtol).any()
479+
return is_on_lower_border or is_on_upper_border
480+
481+
482+
def _generate_alpha_grid(batch_size):
483+
n_points = min(batch_size, 4) - 1
484+
return 2 ** np.arange(1, n_points + 1, dtype=float)

src/tranquilo/options.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@
44
import numpy as np
55

66

7+
def get_default_stagnation_options(noisy, batch_size):
8+
if noisy:
9+
out = StagnationOptions(
10+
min_relative_step_keep=0.0,
11+
drop=False,
12+
)
13+
elif batch_size > 1:
14+
out = StagnationOptions(
15+
min_relative_step_keep=0.0,
16+
drop=True,
17+
)
18+
else:
19+
out = StagnationOptions(
20+
min_relative_step_keep=0.125,
21+
drop=True,
22+
)
23+
return out
24+
25+
726
def get_default_radius_options(x):
827
return RadiusOptions(initial_radius=0.1 * np.max(np.abs(x)))
928

@@ -79,20 +98,6 @@ def get_default_n_evals_per_point(noisy, noise_adaptation_options):
7998
return noise_adaptation_options.min_n_evals if noisy else 1
8099

81100

82-
def get_default_stagnation_options(noisy):
83-
if noisy:
84-
out = StagnationOptions(
85-
min_relative_step_keep=0.0,
86-
drop=False,
87-
)
88-
else:
89-
out = StagnationOptions(
90-
min_relative_step_keep=0.125,
91-
drop=True,
92-
)
93-
return out
94-
95-
96101
class StopOptions(NamedTuple):
97102
"""Criteria for stopping without successful convergence."""
98103

src/tranquilo/process_arguments.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
from tranquilo.history import History
1414
from tranquilo.options import (
1515
ConvOptions,
16-
get_default_stagnation_options,
1716
StopOptions,
1817
get_default_acceptance_decider,
1918
get_default_aggregator,
@@ -26,6 +25,7 @@
2625
get_default_radius_options,
2726
get_default_sample_size,
2827
get_default_search_radius_factor,
28+
get_default_stagnation_options,
2929
update_option_bundle,
3030
NoiseAdaptationOptions,
3131
)
@@ -113,10 +113,6 @@ def process_arguments(
113113
x = _process_x(x)
114114
noisy = _process_noisy(noisy)
115115
n_cores = _process_n_cores(n_cores)
116-
stagnation_options = update_option_bundle(
117-
get_default_stagnation_options(noisy),
118-
stagnation_options,
119-
)
120116
sampling_rng = _process_seed(seed)
121117
simulation_rng = _process_seed(seed + 1000)
122118

@@ -142,6 +138,9 @@ def process_arguments(
142138
acceptance_decider = _process_acceptance_decider(acceptance_decider, noisy)
143139

144140
# process options that depend on arguments with dependent defaults
141+
stagnation_options = update_option_bundle(
142+
get_default_stagnation_options(noisy, batch_size=batch_size), stagnation_options
143+
)
145144
target_sample_size = _process_sample_size(
146145
sample_size=sample_size,
147146
model_type=model_type,
@@ -332,6 +331,10 @@ def _process_n_evals_at_start(n_evals, noisy):
332331
return out
333332

334333

334+
def next_multiple(n, base):
335+
return int(np.ceil(n / base) * base)
336+
337+
335338
def _process_n_evals_per_point(n_evals, noisy, noise_adaptation_options):
336339
if n_evals is None:
337340
out = get_default_n_evals_per_point(noisy, noise_adaptation_options)

0 commit comments

Comments
 (0)