Repository: pints-team/pints

Issue:
Replace maintainer+email with "devs" and public mailing list
https://github.com/pints-team/pints/blob/master/setup.py#L44-L45

"PINTS team" or "PINTS developers"

and public mailing list pints@maillist.ox.ac.uk

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index db8dc7a6..f1bd4406 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
 ## [Unreleased]
 
 ### Added
+- [#1409](https://github.com/pints-team/pints/pull/1409) The `OptimisationController` now accepts a callback function that will be called at every iteration; this can be used for easier customisation or visualisation of the optimiser trajectory.
 - [#1383](https://github.com/pints-team/pints/pull/1383) Added a method `toy.TwistedGaussianDistribution.untwist` that turns samples from this distribution into samples from a multivariate Gaussian.
 - [#1322](https://github.com/pints-team/pints/pull/1322) Added a method `sample_initial_points` that allows users to generate random points with finite metrics (either log-probabilities or error measures) to use as starting points for sampling or optimisation.
 - [#1243](https://github.com/pints-team/pints/pull/1243) Added testing for Python 3.9.
diff --git a/pints/_optimisers/__init__.py b/pints/_optimisers/__init__.py
index 9358ed24..1f2f87dd 100644
--- a/pints/_optimisers/__init__.py
+++ b/pints/_optimisers/__init__.py
@@ -380,6 +380,9 @@ class OptimisationController(object):
         self._n_workers = 1
         self.set_parallel()
 
+        # User callback
+        self._callback = None
+
         # :meth:`run` can only be called once
         self._has_run = False
 
@@ -452,6 +455,11 @@ class OptimisationController(object):
     def run(self):
         """
         Runs the optimisation, returns a tuple ``(xbest, fbest)``.
+
+        An optional ``callback`` function can be passed in that will be called
+        at the end of every iteration. The callback should take the arguments
+        ``(iteration, optimiser)``, where ``iteration`` is the iteration count
+        (an integer) and ``optimiser`` is the optimiser object.
         """
         # Can only run once for each controller instance
         if self._has_run:
@@ -627,6 +635,9 @@ class OptimisationController(object):
                     running = False
                     halt_message = ('Halting: ' + str(error))
 
+                elif self._callback is not None:
+                    self._callback(iteration - 1, self._optimiser)
+
         except (Exception, SystemExit, KeyboardInterrupt):  # pragma: no cover
             # Unexpected end!
             # Show last result and exit
@@ -670,6 +681,25 @@ class OptimisationController(object):
         # Return best position and score
         return xbest, fbest_user
 
+    def set_callback(self, cb=None):
+        """
+        Allows a "callback" function to be passed in that will be called at the
+        end of every iteration.
+
+        This can be used for e.g. visualising optimiser progress.
+
+        Example::
+
+            def cb(opt):
+                plot(opt.xbest())
+
+            opt.set_callback(cb)
+
+        """
+        if cb is not None and not callable(cb):
+            raise ValueError('The argument cb must be None or a callable.')
+        self._callback = cb
+
     def set_log_interval(self, iters=20, warm_up=3):
         """
         Changes the frequency with which messages are logged.
diff --git a/setup.py b/setup.py
index 3b2d6ba9..f6158260 100644
--- a/setup.py
+++ b/setup.py
@@ -41,8 +41,8 @@ setup(
     # Maintainer information
     # author='',
     # author_email='',
-    maintainer='Michael Clerx',
-    maintainer_email='michael.clerx@cs.ox.ac.uk',
+    maintainer='PINTS Team',
+    maintainer_email='pints@maillist.ox.ac.uk',
     url='https://github.com/pints-team/pints',
 
     # Packages to include


Test patch:
diff --git a/pints/tests/test_opt_optimisation_controller.py b/pints/tests/test_opt_optimisation_controller.py
index 37ec7fde..81c26191 100755
--- a/pints/tests/test_opt_optimisation_controller.py
+++ b/pints/tests/test_opt_optimisation_controller.py
@@ -26,6 +26,57 @@ class TestOptimisationController(unittest.TestCase):
         """ Called before every test """
         np.random.seed(1)
 
+    def test_callback(self):
+        # Tests running with a callback method
+
+        # Define callback that just stores the argument(s) it was called with
+        args = []
+
+        def cb(*arg):
+            args.append(arg)
+
+        # Set up a controller
+        r = pints.toy.TwistedGaussianLogPDF(2, 0.01)
+        x0 = np.array([0, 1.01])
+        s = 0.01
+        opt = pints.OptimisationController(r, x0, s, method=method)
+        opt.set_log_to_screen(False)
+        opt.set_max_unchanged_iterations(None)
+        opt.set_max_iterations(10)
+
+        # Pass in an invalid value
+        self.assertRaisesRegex(
+            ValueError, 'None or a callable', opt.set_callback, 3)
+
+        # Now test using it correctly
+        opt.set_callback(None)
+        opt.set_callback(cb)
+        opt.run()
+
+        # Ensure callback was called at each iteration
+        self.assertEqual(len(args), opt.iterations())
+
+        # Ensure first argument was iteration count
+        a = np.array([arg[0] for arg in args])
+        self.assertTrue(np.all(a == np.arange(opt.iterations())))
+
+        # Ensure second argument was always the optimisation method
+        b = tuple(set([arg[1] for arg in args]))
+        self.assertEqual(len(b), 1)
+        self.assertIs(b[0], opt.optimiser())
+
+        # Check unsetting works
+        args.clear()
+        self.assertEqual(len(args), 0)
+        opt = pints.OptimisationController(r, x0, s, method=method)
+        opt.set_log_to_screen(False)
+        opt.set_max_unchanged_iterations(None)
+        opt.set_max_iterations(10)
+        opt.set_callback(cb)
+        opt.set_callback(None)
+        opt.run()
+        self.assertEqual(len(args), 0)
+
     def test_optimise(self):
         # Tests :meth: `pints.optimise()`.