Repository: scipy/scipy

Issue:
differential_evolution: add seed individuals
I think it would be very handy if the user was able to provide one or more 'first guess' individuals for the initial seed population in order to speed up convergence in large (real world) search spaces with heavily varying gradients. Would it be hard to add that feature?


Discussion:
@rgommers @dlax 
I've put something together that allows for a single seed individual by overwriting the first individual of the initialized population. Nothing fancy. Should I PR or could you make some suggestions for that feature?
Initialization of population is currently specified by the init keyword.
The right way of seeding the population by specified individuals is for the
init keyword to also accept an array.

Note that the population members are scaled to a value between 0 and 1.

ZacDiggum a écrit :
> @rgommers <https://github.com/rgommers> @dlax <https://github.com/dlax>
> I've put something together that allows for a single seed individual by
> overwriting the first individual of the initialized population. Nothing
> fancy. Should I PR or could you make some suggestions for that feature?
>

It'd help if you could first show us an example code of this feature. If
we are about to expose the `DifferentialEvolutionSolver` API (#6878),
it's a good time to discuss this as well.


I've added a `p0` kwarg to `DifferentialEvolutionSolver`'s `__init__()` and `differential_evolution()` and put the following lines after the original initialization (after line 418):
```
# after population initialization is done, the first individual is
# replaced by an optional and valid user-provided 'first guess' p0
if p0 is not None:
    p0 = np.array(p0, dtype='float')
    if p0.ndim != 1:
        raise ValueError("Only one first guess is allowed for now.")
    elif len(p0) != np.size(self.population, 1):
    raise ValueError('p0 should be a sequence containing '
                               'real values for each value in x')
    elif np.any(self.limits[0]>p0) or np.any(self.limits[1]<p0):
        raise ValueError('p0 is out of bounds')
    else:
        self.population[0] = self._unscale_parameters(p0)
```
As I said, nothing fancy. I attached a zip file containing the altered `_differentialevolution.py` (including the changes to `callback()`) and a curve fitting script based on `differential_evolution()`.
[curve_fit_de.zip](https://github.com/scipy/scipy/files/681518/curve_fit_de.zip)

There's no need to add an extra p0 keyword. The init keyword can be used
for this.

> The init keyword can be used for this.

Do you think of something like `init=('latinhypercube',p0)`?
Btw, is one seed individual enough or should it be more?
I think this would be doable without any additional development with the API I'm working on for #6878:

```
with DifferentialEvolutionSolver(...) as solver:
   solver.population[0] = p0
   # run optimization
```

the idea is that entering the context would trigger population initialization, so it's quite easy to modify the population later on and before running the optimization loop.

@ZacDiggum would that work for you?
No. Instead of giving a string to init one will give an array.

`init=p0`

Where p0 is an array. In __init__ there is some code which tests which
string was used for the init keyword. This section will also test if an
array-like object was passed in.
If an array like object was provided then initialization will be started
using Latin hyper squares. Then the population array will be partially or
fully overridden by the provided array. This must be done before population
energies are calculated for the first time. The provided array can be 1 or
2d. If 1d then the number of elements must be the same as the problem
dimension. If 2d then the number of columns must be the same as the problem
dimension.

@dlax Sure would! Your new API looks very promising! I'll wait for your first draft.
@andyfaff  You mean init would default to 'latinhypercube' if an array is provided? Is 'random' necessary after all?
Off topic: what happened to parallel evaluation (#5141)? If @dlax's API is final, maybe we could give that one another try...
I chimed in too early. Let's wait for @dlax api.

The parallelisation effort is stalled at the moment. I think it needs
significant refactoring, which is something I can do.

On 3 Jan 2017 11:17 am, "ZacDiggum" <notifications@github.com> wrote:

> @dlax <https://github.com/dlax> Sure would! Your new API looks very
> promising! I'll wait for your first draft.
> @andyfaff <https://github.com/andyfaff> You mean init would default to
> 'latinhypercube' if an array is provided? Is 'random' necessary after all?
> Off topic: what happened to parallel evaluation (#5141
> <https://github.com/scipy/scipy/pull/5141>)? If @dlax
> <https://github.com/dlax>'s API is final, maybe we could give that one
> another try...
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/scipy/scipy/issues/6879#issuecomment-270094002>, or mute
> the thread
> <https://github.com/notifications/unsubscribe-auth/AAq51hlc4n12-9bqAE6jozF1uZ5xNbE5ks5rOi41gaJpZM4LT9Sv>
> .
>


Resolving patch:
diff --git a/scipy/optimize/_differentialevolution.py b/scipy/optimize/_differentialevolution.py
--- a/scipy/optimize/_differentialevolution.py
+++ b/scipy/optimize/_differentialevolution.py
@@ -7,7 +7,7 @@
 from scipy.optimize import OptimizeResult, minimize
 from scipy.optimize.optimize import _status_message
 from scipy._lib._util import check_random_state
-from scipy._lib.six import xrange
+from scipy._lib.six import xrange, string_types
 import warnings
 
 
@@ -67,7 +67,8 @@ def differential_evolution(func, bounds, args=(), strategy='best1bin',
         is: ``(maxiter + 1) * popsize * len(x)``
     popsize : int, optional
         A multiplier for setting the total population size.  The population has
-        ``popsize * len(x)`` individuals.
+        ``popsize * len(x)`` individuals (unless the initial population is
+        supplied via the `init` keyword).
     tol : float, optional
         Relative tolerance for convergence, the solving stops when
         ``np.std(pop) <= atol + tol * np.abs(np.mean(population_energies))``,
@@ -108,17 +109,24 @@ def differential_evolution(func, bounds, args=(), strategy='best1bin',
         If True (default), then `scipy.optimize.minimize` with the `L-BFGS-B`
         method is used to polish the best population member at the end, which
         can improve the minimization slightly.
-    init : string, optional
-        Specify how the population initialization is performed. Should be
+    init : str or array-like, optional
+        Specify which type of population initialization is performed. Should be
         one of:
 
             - 'latinhypercube'
             - 'random'
+            - array specifying the initial population. The array should have
+              shape ``(M, len(x))``, where len(x) is the number of parameters.
+              `init` is clipped to `bounds` before use.
 
         The default is 'latinhypercube'. Latin Hypercube sampling tries to
-        maximize coverage of the available parameter space. 'random' initializes
-        the population randomly - this has the drawback that clustering can
-        occur, preventing the whole of parameter space being covered.
+        maximize coverage of the available parameter space. 'random'
+        initializes the population randomly - this has the drawback that
+        clustering can occur, preventing the whole of parameter space being
+        covered. Use of an array to specify a population subset could be used,
+        for example, to create a tight bunch of initial guesses in an location
+        where the solution is known to exist, thereby reducing time for
+        convergence.
     atol : float, optional
         Absolute tolerance for convergence, the solving stops when
         ``np.std(pop) <= atol + tol * np.abs(np.mean(population_energies))``,
@@ -258,7 +266,8 @@ class DifferentialEvolutionSolver(object):
         is: ``(maxiter + 1) * popsize * len(x)``
     popsize : int, optional
         A multiplier for setting the total population size.  The population has
-        ``popsize * len(x)`` individuals.
+        ``popsize * len(x)`` individuals (unless the initial population is
+        supplied via the `init` keyword).
     tol : float, optional
         Relative tolerance for convergence, the solving stops when
         ``np.std(pop) <= atol + tol * np.abs(np.mean(population_energies))``,
@@ -303,12 +312,24 @@ class DifferentialEvolutionSolver(object):
     maxfun : int, optional
         Set the maximum number of function evaluations. However, it probably
         makes more sense to set `maxiter` instead.
-    init : string, optional
+    init : str or array-like, optional
         Specify which type of population initialization is performed. Should be
         one of:
 
             - 'latinhypercube'
             - 'random'
+            - array specifying the initial population. The array should have
+              shape ``(M, len(x))``, where len(x) is the number of parameters.
+              `init` is clipped to `bounds` before use.
+
+        The default is 'latinhypercube'. Latin Hypercube sampling tries to
+        maximize coverage of the available parameter space. 'random'
+        initializes the population randomly - this has the drawback that
+        clustering can occur, preventing the whole of parameter space being
+        covered. Use of an array to specify a population could be used, for
+        example, to create a tight bunch of initial guesses in an location
+        where the solution is known to exist, thereby reducing time for
+        convergence.
     atol : float, optional
         Absolute tolerance for convergence, the solving stops when
         ``np.std(pop) <= atol + tol * np.abs(np.mean(population_energies))``,
@@ -330,6 +351,10 @@ class DifferentialEvolutionSolver(object):
                     'best2exp': '_best2',
                     'rand2exp': '_rand2'}
 
+    __init_error_msg = ("The population initialization method must be one of "
+                        "'latinhypercube' or 'random', or an array of shape "
+                        "(M, N) where N is the number of parameters and M>5")
+
     def __init__(self, func, bounds, args=(),
                  strategy='best1bin', maxiter=1000, popsize=15,
                  tol=0.01, mutation=(0.5, 1), recombination=0.7, seed=None,
@@ -400,19 +425,23 @@ def __init__(self, func, bounds, args=(),
 
         # default population initialization is a latin hypercube design, but
         # there are other population initializations possible.
-        self.num_population_members = popsize * self.parameter_count
+        # the minimum is 5 because 'best2bin' requires a population that's at
+        # least 5 long
+        self.num_population_members = max(5, popsize * self.parameter_count)
 
         self.population_shape = (self.num_population_members,
                                  self.parameter_count)
 
         self._nfev = 0
-        if init == 'latinhypercube':
-            self.init_population_lhs()
-        elif init == 'random':
-            self.init_population_random()
+        if isinstance(init, string_types):
+            if init == 'latinhypercube':
+                self.init_population_lhs()
+            elif init == 'random':
+                self.init_population_random()
+            else:
+                raise ValueError(self.__init_error_msg)
         else:
-            raise ValueError("The population initialization method must be one"
-                             "of 'latinhypercube' or 'random'")
+            self.init_population_array(init)
 
         self.disp = disp
 
@@ -469,6 +498,41 @@ def init_population_random(self):
         # reset number of function evaluations counter
         self._nfev = 0
 
+    def init_population_array(self, init):
+        """
+        Initialises the population with a user specified population.
+
+        Parameters
+        ----------
+        init : np.ndarray
+            Array specifying subset of the initial population. The array should
+            have shape (M, len(x)), where len(x) is the number of parameters.
+            The population is clipped to the lower and upper `bounds`.
+        """
+        # make sure you're using a float array
+        popn = np.asfarray(init)
+
+        if (np.size(popn, 0) < 5 or
+                popn.shape[1] != self.parameter_count or
+                len(popn.shape) != 2):
+            raise ValueError("The population supplied needs to have shape"
+                             " (M, len(x)), where M > 4.")
+
+        # scale values and clip to bounds, assigning to population
+        self.population = np.clip(self._unscale_parameters(popn), 0, 1)
+
+        self.num_population_members = np.size(self.population, 0)
+
+        self.population_shape = (self.num_population_members,
+                                 self.parameter_count)
+
+        # reset population energies
+        self.population_energies = (np.ones(self.num_population_members) *
+                                    np.inf)
+
+        # reset number of function evaluations counter
+        self._nfev = 0
+
     @property
     def x(self):
         """


Test patch:
diff --git a/scipy/optimize/tests/test__differential_evolution.py b/scipy/optimize/tests/test__differential_evolution.py
--- a/scipy/optimize/tests/test__differential_evolution.py
+++ b/scipy/optimize/tests/test__differential_evolution.py
@@ -375,26 +375,26 @@ def test_gh_4511_regression(self):
         result = differential_evolution(rosen, bounds, popsize=1815, maxiter=1)
 
     def test_calculate_population_energies(self):
-        # if popsize is 2 then the overall generation has size (4,)
-        solver = DifferentialEvolutionSolver(rosen, self.bounds, popsize=2)
+        # if popsize is 3 then the overall generation has size (6,)
+        solver = DifferentialEvolutionSolver(rosen, self.bounds, popsize=3)
         solver._calculate_population_energies()
 
         assert_equal(np.argmin(solver.population_energies), 0)
 
-        # initial calculation of the energies should require 4 nfev.
-        assert_equal(solver._nfev, 4)
+        # initial calculation of the energies should require 6 nfev.
+        assert_equal(solver._nfev, 6)
 
     def test_iteration(self):
         # test that DifferentialEvolutionSolver is iterable
-        # if popsize is 2 then the overall generation has size (4,)
-        solver = DifferentialEvolutionSolver(rosen, self.bounds, popsize=2,
-                                             maxfun=8)
+        # if popsize is 3 then the overall generation has size (6,)
+        solver = DifferentialEvolutionSolver(rosen, self.bounds, popsize=3,
+                                             maxfun=12)
         x, fun = next(solver)
         assert_equal(np.size(x, 0), 2)
 
-        # 4 nfev are required for initial calculation of energies, 4 nfev are
-        # required for the evolution of the 4 population members.
-        assert_equal(solver._nfev, 8)
+        # 6 nfev are required for initial calculation of energies, 6 nfev are
+        # required for the evolution of the 6 population members.
+        assert_equal(solver._nfev, 12)
 
         # the next generation should halt because it exceeds maxfun
         assert_raises(StopIteration, next, solver)
@@ -447,3 +447,31 @@ def test_population_initiation(self):
         assert_equal(solver._nfev, 0)
         assert_(np.all(np.isinf(solver.population_energies)))
 
+        # we should be able to initialise with our own array
+        population = np.linspace(-1, 3, 10).reshape(5, 2)
+        solver = DifferentialEvolutionSolver(rosen, self.bounds,
+                                             init=population,
+                                             strategy='best2bin',
+                                             atol=0.01, seed=1, popsize=5)
+
+        assert_equal(solver._nfev, 0)
+        assert_(np.all(np.isinf(solver.population_energies)))
+        assert_(solver.num_population_members == 5)
+        assert_(solver.population_shape == (5, 2))
+
+        # check that the population was initialised correctly
+        unscaled_population = np.clip(solver._unscale_parameters(population),
+                                      0, 1)
+        assert_almost_equal(solver.population[:5], unscaled_population)
+
+        # population values need to be clipped to bounds
+        assert_almost_equal(np.min(solver.population[:5]), 0)
+        assert_almost_equal(np.max(solver.population[:5]), 1)
+
+        # shouldn't be able to initialise with an array if it's the wrong shape
+        # this would have too many parameters
+        population = np.linspace(-1, 3, 15).reshape(5, 3)
+        assert_raises(ValueError,
+                      DifferentialEvolutionSolver,
+                      *(rosen, self.bounds),
+                      **{'init': population})