Skip to content

Commit fcd1f8a

Browse files
committed
Random search
1 parent 31b76ee commit fcd1f8a

12 files changed

+513
-35
lines changed

doc/math.qbk

+2
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,8 @@ and as a CD ISBN 0-9504833-2-X 978-0-9504833-2-0, Classification 519.2-dc22.
726726
[endmathpart] [/mathpart roots Root Finding Algorithms]
727727
[mathpart optimization Optimization]
728728
[include optimization/differential_evolution.qbk]
729+
[include optimization/jso.qbk]
730+
[include optimization/random_search.qbk]
729731
[endmathpart] [/mathpart optimization Optimization]
730732

731733
[mathpart poly Polynomials and Rational Functions]

doc/optimization/random_search.qbk

+91
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
[/
2+
Copyright (c) 2024 Nick Thompson
3+
Use, modification and distribution are subject to the
4+
Boost Software License, Version 1.0. (See accompanying file
5+
LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
6+
]
7+
8+
[section:random_search Random Search]
9+
10+
[heading Synopsis]
11+
12+
``
13+
#include <boost/math/optimization/random_search.hpp>
14+
15+
namespace boost::math::optimization {
16+
17+
template <typename ArgumentContainer> struct random_search_parameters {
18+
using Real = typename ArgumentContainer::value_type;
19+
ArgumentContainer lower_bounds;
20+
ArgumentContainer upper_bounds;
21+
size_t max_function_calls = 0;
22+
ArgumentContainer const * initial_guess = nullptr;
23+
};
24+
25+
template <typename ArgumentContainer, class Func, class URBG>
26+
ArgumentContainer random_search(
27+
const Func cost_function,
28+
random_search_parameters<ArgumentContainer> const &params,
29+
URBG &gen,
30+
std::invoke_result_t<Func, ArgumentContainer> target_value = std::numeric_limits<std::invoke_result_t<Func, ArgumentContainer>>::quiet_NaN(),
31+
std::atomic<bool> *cancellation = nullptr,
32+
std::atomic<std::invoke_result_t<Func, ArgumentContainer>> *current_minimum_cost = nullptr,
33+
std::vector<std::pair<ArgumentContainer, std::invoke_result_t<Func, ArgumentContainer>>> *queries = nullptr);
34+
35+
} // namespaces
36+
``
37+
38+
The `random_search` function searches for a global minimum of a function.
39+
There is no special sauce to this algorithm-it merely blasts function calls over threads.
40+
It's existence is justified by the "No free lunch" theorem in optimization,
41+
which "establishes that for any algorithm, any elevated performance over one class of problems is offset by performance over another class."
42+
In practice, it is not clear that the conditions of the NFL theorem holds,
43+
and on test cases, `random_search` is slower and less accurate than (say) differential evolution and jSO.
44+
However, it is often the case that rapid convergence is not the goal:
45+
For example, we often want to spend some time exploring the cost function surface before moving to a faster converging algorithm.
46+
In addition, random search is embarrassingly parallel, which allows us to avoid Amdahl's law-induced performance problems.
47+
48+
49+
[heading Parameters]
50+
51+
`lower_bounds`: A container representing the lower bounds of the optimization space along each dimension. The `.size()` of the bounds should return the dimension of the problem.
52+
`upper_bounds`: A container representing the upper bounds of the optimization space along each dimension. It should have the same size of `lower_bounds`, and each element should be >= the corresponding element of `lower_bounds`.
53+
`max_function_calls`: Defaults to 10000*threads.
54+
`initial_guess`: An optional guess for where we should start looking for solutions. This is provided for consistency with other optimization functions-it's not particularly useful for this function.
55+
56+
[heading The function]
57+
58+
``
59+
template <typename ArgumentContainer, class Func, class URBG>
60+
ArgumentContainer random_search(const Func cost_function,
61+
random_search_parameters<ArgumentContainer> const &params,
62+
URBG &gen,
63+
std::invoke_result_t<Func, ArgumentContainer> value_to_reach = std::numeric_limits<std::invoke_result_t<Func, ArgumentContainer>>::quiet_NaN(),
64+
std::atomic<bool> *cancellation = nullptr,
65+
std::atomic<std::invoke_result_t<Func, ArgumentContainer>> *current_minimum_cost = nullptr,
66+
std::vector<std::pair<ArgumentContainer, std::invoke_result_t<Func, ArgumentContainer>>> *queries = nullptr)
67+
``
68+
69+
Parameters:
70+
71+
`cost_function`: The cost function to be minimized.
72+
`params`: The parameters to the algorithm as described above.
73+
`gen`: A uniform random bit generator, like `std::mt19937_64`.
74+
`value_to_reach`: An optional value that, if reached, stops the optimization. This is the most robust way to terminate the calculation, but in most cases the optimal value of the cost function is unknown. If it is, use it! Physical considerations can often be used to find optimal values for cost functions.
75+
`cancellation`: An optional atomic boolean to allow the user to stop the computation and gracefully return the best result found up to that point. N.B.: Cancellation is not immediate; the in-progress generation finishes.
76+
`current_minimum_cost`: An optional atomic variable to store the current minimum cost during optimization. This allows developers to (e.g.) plot the progress of the minimization over time and in conjunction with the `cancellation` argument allow halting the computation when the progress stagnates.
77+
`queries`: An optional vector to store intermediate results during optimization. This is useful for debugging and perhaps volume rendering of the objective function after the calculation is complete.
78+
79+
Returns:
80+
81+
The argument vector corresponding to the minimum cost found by the optimization.
82+
83+
[h4:examples Examples]
84+
85+
An example exhibiting graceful cancellation and progress observability can be studied in [@../../example/random_search_example.cpp random_search_example.cpp].
86+
87+
[h4:references References]
88+
89+
* D. H. Wolpert and W. G. Macready, ['No free lunch theorems for optimization.] IEEE Transactions on Evolutionary Computation, vol. 1, no. 1, pp. 67-82, April 1997, doi: 10.1109/4235.585893.
90+
91+
[endsect] [/section:random_search]

example/naive_monte_carlo_example.cpp

+1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ void display_progress(double progress,
5252

5353
int main()
5454
{
55+
using std::abs;
5556
double exact = 1.3932039296856768591842462603255;
5657
double A = 1.0 / boost::math::pow<3>(boost::math::constants::pi<double>());
5758
auto g = [&](std::vector<double> const & x)

example/random_search_example.cpp

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright Nick Thompson, 2024
3+
* Use, modification and distribution are subject to the
4+
* Boost Software License, Version 1.0. (See accompanying file
5+
* LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
6+
*/
7+
#if __APPLE__ || __linux__
8+
#include <signal.h>
9+
#include <stdlib.h>
10+
#include <stdio.h>
11+
#include <future>
12+
#include <chrono>
13+
#include <iostream>
14+
#include <boost/math/constants/constants.hpp>
15+
#include <boost/math/optimization/random_search.hpp>
16+
17+
using boost::math::optimization::random_search_parameters;
18+
using boost::math::optimization::random_search;
19+
using namespace std::chrono_literals;
20+
21+
template <class Real> Real rastrigin(std::vector<Real> const &v) {
22+
using std::cos;
23+
using boost::math::constants::two_pi;
24+
Real A = 10;
25+
Real y = 10 * v.size();
26+
for (auto x : v) {
27+
y += x * x - A * cos(two_pi<Real>() * x);
28+
}
29+
return y;
30+
}
31+
32+
std::atomic<bool> cancel = false;
33+
34+
void ctrl_c_handler(int){
35+
cancel = true;
36+
std::cout << "Cancellation requested-this could take a second . . ." << std::endl;
37+
}
38+
39+
int main() {
40+
std::cout << "Running random search on Rastrigin function (global minimum = (0,0,...,0))\n";
41+
signal(SIGINT, ctrl_c_handler);
42+
using ArgType = std::vector<double>;
43+
auto params = random_search_parameters<ArgType>();
44+
params.lower_bounds.resize(3, -5.12);
45+
params.upper_bounds.resize(3, 5.12);
46+
params.max_function_calls = 100000000;
47+
// Leave one thread available to respond to ctrl-C:
48+
params.threads = std::thread::hardware_concurrency() - 1;
49+
std::random_device rd;
50+
std::mt19937_64 gen(rd());
51+
52+
// By definition, the value of the function which a target value is provided must be <= target_value.
53+
double target_value = 1e-3;
54+
std::atomic<double> current_minimum_cost;
55+
std::cout << "Hit ctrl-C to gracefully terminate the optimization." << std::endl;
56+
auto f = [&]() {
57+
return random_search(rastrigin<double>, params, gen, target_value, &cancel, &current_minimum_cost);
58+
};
59+
auto future = std::async(std::launch::async, f);
60+
std::future_status status = future.wait_for(3ms);
61+
while (!cancel && (status != std::future_status::ready)) {
62+
status = future.wait_for(3ms);
63+
std::cout << "Current cost is " << current_minimum_cost << "\r";
64+
}
65+
66+
auto local_minima = future.get();
67+
std::cout << "Local minimum is {";
68+
for (size_t i = 0; i < local_minima.size() - 1; ++i) {
69+
std::cout << local_minima[i] << ", ";
70+
}
71+
std::cout << local_minima.back() << "}.\n";
72+
std::cout << "Final cost: " << current_minimum_cost << "\n";
73+
}
74+
#else
75+
#warning "Signal handling for the random search example only works on Linux and Mac."
76+
int main() {
77+
return 0;
78+
}
79+
#endif

include/boost/math/optimization/jso.hpp

+10-10
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,10 @@ jso(const Func cost_function, jso_parameters<ArgumentContainer> &jso_params,
173173
// last bullet, which claims this should be set to 0.3. The reference
174174
// implementation also does 0.3:
175175
size_t H = 5;
176-
std::vector<Real> M_F(H, 0.3);
176+
std::vector<Real> M_F(H, static_cast<Real>(0.3));
177177
// Algorithm 1: jSO algorithm, Line 4:
178178
// "Set all values in M_CR to 0.8":
179-
std::vector<Real> M_CR(H, 0.8);
179+
std::vector<Real> M_CR(H, static_cast<Real>(0.8));
180180

181181
std::uniform_real_distribution<Real> unif01(Real(0), Real(1));
182182
bool keep_going = !target_attained;
@@ -203,17 +203,17 @@ jso(const Func cost_function, jso_parameters<ArgumentContainer> &jso_params,
203203
// I confess I find it weird to store the historical memory if we're just
204204
// gonna ignore it, but that's what the paper and the reference
205205
// implementation says!
206-
Real mu_F = 0.9;
207-
Real mu_CR = 0.9;
206+
Real mu_F = static_cast<Real>(0.9);
207+
Real mu_CR = static_cast<Real>(0.9);
208208
if (ri != H - 1) {
209209
mu_F = M_F[ri];
210210
mu_CR = M_CR[ri];
211211
}
212212
// Algorithm 1, jSO, Line 14-18:
213-
Real crossover_probability = 0.0;
213+
Real crossover_probability = static_cast<Real>(0);
214214
if (mu_CR >= 0) {
215215
using std::normal_distribution;
216-
normal_distribution<Real> normal(mu_CR, 0.1);
216+
normal_distribution<Real> normal(mu_CR, static_cast<Real>(0.1));
217217
crossover_probability = normal(gen);
218218
// Clamp comes from L-SHADE description:
219219
crossover_probability = clamp(crossover_probability, Real(0), Real(1));
@@ -233,7 +233,7 @@ jso(const Func cost_function, jso_parameters<ArgumentContainer> &jso_params,
233233
// Algorithm 1, jSO, Line 24-27:
234234
// Note the adjustments to the pseudocode given in the reference
235235
// implementation.
236-
cauchy_distribution<Real> cauchy(mu_F, 0.1);
236+
cauchy_distribution<Real> cauchy(mu_F, static_cast<Real>(0.1));
237237
Real F;
238238
do {
239239
F = cauchy(gen);
@@ -253,13 +253,13 @@ jso(const Func cost_function, jso_parameters<ArgumentContainer> &jso_params,
253253
Real p = Real(0.25) * (1 - static_cast<Real>(function_evaluations) /
254254
(2 * jso_params.max_function_evaluations));
255255
// Equation (4) of the reference:
256-
Real Fw = 1.2 * F;
256+
Real Fw = static_cast<Real>(1.2) * F;
257257
if (10 * function_evaluations < 4 * jso_params.max_function_evaluations) {
258258
if (10 * function_evaluations <
259259
2 * jso_params.max_function_evaluations) {
260-
Fw = 0.7 * F;
260+
Fw = static_cast<Real>(0.7) * F;
261261
} else {
262-
Fw = 0.8 * F;
262+
Fw = static_cast<Real>(0.8) * F;
263263
}
264264
}
265265
// Algorithm 1, jSO, Line 28:
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/*
2+
* Copyright Nick Thompson, 2024
3+
* Use, modification and distribution are subject to the
4+
* Boost Software License, Version 1.0. (See accompanying file
5+
* LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
6+
*/
7+
#ifndef BOOST_MATH_OPTIMIZATION_RANDOM_SEARCH_HPP
8+
#define BOOST_MATH_OPTIMIZATION_RANDOM_SEARCH_HPP
9+
#include <algorithm>
10+
#include <array>
11+
#include <atomic>
12+
#include <cmath>
13+
#include <limits>
14+
#include <list>
15+
#include <mutex>
16+
#include <random>
17+
#include <sstream>
18+
#include <stdexcept>
19+
#include <thread>
20+
#include <type_traits>
21+
#include <utility>
22+
#include <vector>
23+
#include <boost/math/optimization/detail/common.hpp>
24+
25+
namespace boost::math::optimization {
26+
27+
template <typename ArgumentContainer> struct random_search_parameters {
28+
using Real = typename ArgumentContainer::value_type;
29+
ArgumentContainer lower_bounds;
30+
ArgumentContainer upper_bounds;
31+
size_t max_function_calls = 10000*std::thread::hardware_concurrency();
32+
ArgumentContainer const *initial_guess = nullptr;
33+
unsigned threads = std::thread::hardware_concurrency();
34+
};
35+
36+
template <typename ArgumentContainer>
37+
void validate_random_search_parameters(random_search_parameters<ArgumentContainer> const &params) {
38+
using std::isfinite;
39+
using std::isnan;
40+
std::ostringstream oss;
41+
detail::validate_bounds(params.lower_bounds, params.upper_bounds);
42+
if (params.initial_guess) {
43+
detail::validate_initial_guess(*params.initial_guess, params.lower_bounds, params.upper_bounds);
44+
}
45+
if (params.threads == 0) {
46+
oss << __FILE__ << ":" << __LINE__ << ":" << __func__;
47+
oss << ": There must be at least one thread.";
48+
throw std::invalid_argument(oss.str());
49+
}
50+
}
51+
52+
template <typename ArgumentContainer, class Func, class URBG>
53+
ArgumentContainer random_search(
54+
const Func cost_function,
55+
random_search_parameters<ArgumentContainer> const &params,
56+
URBG &gen,
57+
std::invoke_result_t<Func, ArgumentContainer> target_value = std::numeric_limits<std::invoke_result_t<Func, ArgumentContainer>>::quiet_NaN(),
58+
std::atomic<bool> *cancellation = nullptr,
59+
std::atomic<std::invoke_result_t<Func, ArgumentContainer>> *current_minimum_cost = nullptr,
60+
std::vector<std::pair<ArgumentContainer, std::invoke_result_t<Func, ArgumentContainer>>> *queries = nullptr)
61+
{
62+
using Real = typename ArgumentContainer::value_type;
63+
using ResultType = std::invoke_result_t<Func, ArgumentContainer>;
64+
using std::isnan;
65+
using std::uniform_real_distribution;
66+
validate_random_search_parameters(params);
67+
const size_t dimension = params.lower_bounds.size();
68+
std::atomic<bool> target_attained = false;
69+
// Unfortunately, the "minimum_cost" variable can either be passed
70+
// (for observability) or not (if the user doesn't care).
71+
// That makes this a bit awkward . . .
72+
std::atomic<ResultType> lowest_cost = std::numeric_limits<ResultType>::infinity();
73+
74+
ArgumentContainer best_vector;
75+
if constexpr (detail::has_resize_v<ArgumentContainer>) {
76+
best_vector.resize(dimension, std::numeric_limits<Real>::quiet_NaN());
77+
}
78+
if (params.initial_guess) {
79+
auto initial_cost = cost_function(*params.initial_guess);
80+
if (!isnan(initial_cost)) {
81+
lowest_cost = initial_cost;
82+
best_vector = *params.initial_guess;
83+
if (current_minimum_cost) {
84+
*current_minimum_cost = initial_cost;
85+
}
86+
}
87+
}
88+
std::mutex mt;
89+
std::vector<std::thread> thread_pool;
90+
std::atomic<size_t> function_calls = 0;
91+
for (unsigned j = 0; j < params.threads; ++j) {
92+
auto seed = gen();
93+
thread_pool.emplace_back([&, seed]() {
94+
URBG g(seed);
95+
ArgumentContainer trial_vector;
96+
// This vector is empty unless the user requests the queries be stored:
97+
std::vector<std::pair<ArgumentContainer, std::invoke_result_t<Func, ArgumentContainer>>> local_queries;
98+
if constexpr (detail::has_resize_v<ArgumentContainer>) {
99+
trial_vector.resize(dimension, std::numeric_limits<Real>::quiet_NaN());
100+
}
101+
while (function_calls < params.max_function_calls) {
102+
if (cancellation && *cancellation) {
103+
break;
104+
}
105+
if (target_attained) {
106+
break;
107+
}
108+
// Fill trial vector:
109+
uniform_real_distribution<Real> unif01(Real(0), Real(1));
110+
for (size_t i = 0; i < dimension; ++i) {
111+
trial_vector[i] = params.lower_bounds[i] + (params.upper_bounds[i] - params.lower_bounds[i])*unif01(g);
112+
}
113+
ResultType trial_cost = cost_function(trial_vector);
114+
++function_calls;
115+
if (isnan(trial_cost)) {
116+
continue;
117+
}
118+
if (trial_cost < lowest_cost) {
119+
lowest_cost = trial_cost;
120+
if (current_minimum_cost) {
121+
*current_minimum_cost = trial_cost;
122+
}
123+
// We expect to need to acquire this lock with decreasing frequency
124+
// as the computation proceeds:
125+
std::scoped_lock lock(mt);
126+
best_vector = trial_vector;
127+
}
128+
if (queries) {
129+
local_queries.push_back(std::make_pair(trial_vector, trial_cost));
130+
}
131+
if (!isnan(target_value) && trial_cost <= target_value) {
132+
target_attained = true;
133+
}
134+
}
135+
if (queries) {
136+
std::scoped_lock lock(mt);
137+
queries->insert(queries->begin(), local_queries.begin(), local_queries.end());
138+
}
139+
});
140+
}
141+
for (auto &thread : thread_pool) {
142+
thread.join();
143+
}
144+
return best_vector;
145+
}
146+
147+
} // namespace boost::math::optimization
148+
#endif

0 commit comments

Comments
 (0)