Repository: evopy/evopy

Issue:
Support setting a target fitness value for early stop

Discussion:
codecov-io: # [Codecov](https://codecov.io/gh/evopy/evopy/pull/27?src=pr&el=h1) Report
> Merging [#27](https://codecov.io/gh/evopy/evopy/pull/27?src=pr&el=desc) into [master](https://codecov.io/gh/evopy/evopy/commit/1ca150c0a4bd76cde3e989aafa114dc476928201?src=pr&el=desc) will **increase** coverage by `0.31%`.
> The diff coverage is `100%`.

[![Impacted file tree graph](https://codecov.io/gh/evopy/evopy/pull/27/graphs/tree.svg?width=650&token=XeKRLzWOmy&height=150&src=pr)](https://codecov.io/gh/evopy/evopy/pull/27?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master      #27      +/-   ##
==========================================
+ Coverage   94.95%   95.27%   +0.31%     
==========================================
  Files           7        7              
  Lines         119      127       +8     
  Branches       26       27       +1     
==========================================
+ Hits          113      121       +8     
  Misses          3        3              
  Partials        3        3
```


| [Impacted Files](https://codecov.io/gh/evopy/evopy/pull/27?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [evopy/evopy.py](https://codecov.io/gh/evopy/evopy/pull/27/diff?src=pr&el=tree#diff-ZXZvcHkvZXZvcHkucHk=) | `96% <100%> (+0.76%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/evopy/evopy/pull/27?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/evopy/evopy/pull/27?src=pr&el=footer). Last update [1ca150c...81e87b4](https://codecov.io/gh/evopy/evopy/pull/27?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/.pylintrc b/.pylintrc
index d826f05..6490d05 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -527,10 +527,10 @@ valid-metaclass-classmethod-first-arg=cls
 [DESIGN]
 
 # Maximum number of arguments for function / method.
-max-args=15
+max-args=20
 
 # Maximum number of attributes for a class (see R0902).
-max-attributes=15
+max-attributes=20
 
 # Maximum number of boolean expressions in an if statement.
 max-bool-expr=5
@@ -539,7 +539,7 @@ max-bool-expr=5
 max-branches=12
 
 # Maximum number of locals for function / method body.
-max-locals=15
+max-locals=20
 
 # Maximum number of parents for a class (see R0901).
 max-parents=7
diff --git a/evopy/evopy.py b/evopy/evopy.py
index 292e875..de3a10d 100644
--- a/evopy/evopy.py
+++ b/evopy/evopy.py
@@ -1,4 +1,6 @@
 """Module used for the execution of the evolutionary algorithm."""
+import time
+
 import numpy as np
 
 from evopy.individual import Individual
@@ -12,7 +14,8 @@ class EvoPy:
 
     def __init__(self, fitness_function, individual_length, warm_start=None, generations=100,
                  population_size=30, num_children=1, mean=0, std=1, maximize=False,
-                 strategy=Strategy.SINGLE_VARIANCE, random_seed=None, reporter=None):
+                 strategy=Strategy.SINGLE_VARIANCE, random_seed=None, reporter=None,
+                 target_fitness_value=None, max_run_time=None):
         """Initializes an EvoPy instance.
 
         :param fitness_function: the fitness function on which the individuals are evaluated
@@ -28,6 +31,8 @@ class EvoPy:
                          information, check the Strategy enum
         :param random_seed: the seed to use for the random number generator
         :param reporter: callback to be invoked at each generation with a ProgressReport as argument
+        :param target_fitness_value: target fitness value for early stopping
+        :param max_run_time: maximum time allowed to run in seconds
         """
         self.fitness_function = fitness_function
         self.individual_length = individual_length
@@ -42,6 +47,21 @@ class EvoPy:
         self.random_seed = random_seed
         self.random = random_with_seed(self.random_seed)
         self.reporter = reporter
+        self.target_fitness_value = target_fitness_value
+        self.max_run_time = max_run_time
+
+    def _check_early_stop(self, start_time, best):
+        """Check whether the algorithm can stop early, based on time and fitness target.
+
+        :param start_time: the starting time to compare against
+        :param best: the current best individual
+        :return: whether the algorithm should be terminated early
+        """
+        return (self.max_run_time is not None
+                and (time.time() - start_time) > self.max_run_time) \
+               or \
+               (self.target_fitness_value is not None
+                and abs(best.fitness - self.target_fitness_value) < np.finfo(float).eps)
 
     def run(self):
         """Run the evolutionary strategy algorithm.
@@ -51,6 +71,8 @@ class EvoPy:
         if self.individual_length == 0:
             return None
 
+        start_time = time.time()
+
         population = self._init_population()
         best = sorted(population, reverse=self.maximize,
                       key=lambda individual: individual.evaluate(self.fitness_function))[0]
@@ -62,9 +84,13 @@ class EvoPy:
                                 key=lambda individual: individual.evaluate(self.fitness_function))
             population = population[:self.population_size]
             best = population[0]
+
             if self.reporter is not None:
                 self.reporter(ProgressReport(generation, best.genotype, best.fitness))
 
+            if self._check_early_stop(start_time, best):
+                break
+
         return best.genotype
 
     def _init_population(self):


Test patch:
diff --git a/test/test_evopy.py b/test/test_evopy.py
index 9ddda48..56fa63c 100644
--- a/test/test_evopy.py
+++ b/test/test_evopy.py
@@ -3,12 +3,14 @@ import numpy as np
 
 from evopy import EvoPy
 
+
 def test_random_consistency():
     """Test whether the random state gives consistent runs when initialized."""
     x_first = EvoPy(lambda x: pow(x, 2), 1, random_seed=42).run()
     x_second = EvoPy(lambda x: pow(x, 2), 1, random_seed=42).run()
     assert x_first == x_second
 
+
 def test_random_consistency_multiple_runs():
     """"Test whether the random state is not re-used in sequential runs"""
     evopy = EvoPy(lambda x: pow(x, 2), 1, random_seed=42)
@@ -16,6 +18,7 @@ def test_random_consistency_multiple_runs():
     x_second = evopy.run()
     assert x_first != x_second
 
+
 def test_simple_use_case():
     """Test whether evopy can successfully run for a simple evaluation function."""
     evopy = EvoPy(lambda x: pow(x, 2), 1)
@@ -25,6 +28,32 @@ def test_simple_use_case():
     assert best_individual.size == 1
 
 
+def test_early_timed_stop():
+    """Test whether evopy can successfully stop early when given a specified time constraint."""
+    count = [0]
+
+    def increment_reporter(report):
+        count[0] = report.generation + 1
+
+    evopy = EvoPy(lambda x: pow(x, 2), 1, max_run_time=0, reporter=increment_reporter)
+    evopy.run()
+
+    assert count[0] == 1
+
+
+def test_early_target_value_stop():
+    """Test whether evopy can successfully stop after achieving a specified target value."""
+    count = [0]
+
+    def increment_reporter(report):
+        count[0] = report.generation + 1
+
+    evopy = EvoPy(lambda x: 0, 1, target_fitness_value=0, reporter=increment_reporter)
+    evopy.run()
+
+    assert count[0] == 1
+
+
 def test_empty_input_array():
     """Test whether evopy can successfully run for a simple evaluation function."""
     evopy = EvoPy(lambda x: 0, 0)