From 3be7949310b6d899d9a3fa9c493fb6eb2a733dfe Mon Sep 17 00:00:00 2001 From: ljvmiranda921 Date: Tue, 12 Jun 2018 21:59:14 +0900 Subject: [PATCH 1/4] Add plotters.py module Reference: #130 This commit adds a plotters.py module to replace the environments module. We hope that we can actually decouple the optimization and visualization part, without having the environment to do another rollout of your PSO. Signed-off-by: Lester James V. Miranda --- pyswarms/utils/plotters/__init__.py | 8 + pyswarms/utils/plotters/plotters.py | 373 ++++++++++++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 pyswarms/utils/plotters/__init__.py create mode 100644 pyswarms/utils/plotters/plotters.py diff --git a/pyswarms/utils/plotters/__init__.py b/pyswarms/utils/plotters/__init__.py new file mode 100644 index 00000000..2ad916e9 --- /dev/null +++ b/pyswarms/utils/plotters/__init__.py @@ -0,0 +1,8 @@ +""" +The mod:`pyswarms.utils.plotters` module implements various +visualization capabilities to interact with your swarm. Here, +ou can plot cost history and animate your swarm in both 2D or +3D spaces. +""" + +from .plotters import * \ No newline at end of file diff --git a/pyswarms/utils/plotters/plotters.py b/pyswarms/utils/plotters/plotters.py new file mode 100644 index 00000000..184c085b --- /dev/null +++ b/pyswarms/utils/plotters/plotters.py @@ -0,0 +1,373 @@ +# -*- coding: utf-8 -*- + +r""" +Plotting tool for Optimizer Analysis + +This module is built on top of :code:`matplotlib` to render quick and easy +plots for your optimizer. It can plot the best cost for each iteration, and +show animations of the particles in 2-D and 3-D space. Furthermore, because +it has :code:`matplotlib` running under the hood, the plots are easily +customizable. + +For example, if we want to plot the cost, simply run the optimizer, get the +cost history from the optimizer instance, and pass it to the +:code:`plot_cost_history()` method + +.. code-block:: python + + import pyswarms as ps + from pyswarms.utils.functions.single_obj import sphere_func + from pyswarms.utils.plotters import plot_cost_history + + # Set up optimizer + options = {'c1':0.5, 'c2':0.3, 'w':0.9} + optimizer = ps.single.GlobalBestPSO(n_particles=10, dimensions=2, + options=options) + + # Obtain cost history from optimizer instance + cost_history = optimizer.cost_history + + # Plot! + plot_cost_history(cost_history) + plt.show() + +In case you want to plot the particle movement, it is important that either +one of the :code:`matplotlib` animation :code:`Writers` is installed. These +doesn't come out of the box for :code:`pyswarms`, and must be installed +separately. For example, in a Linux or Windows distribution, you can install +:code:`ffmpeg` as + + >>> conda install -c conda-forge ffmpeg + +Now, if you want to plot your particles in a 2-D environment, simply pass +the position history of your swarm (obtainable from swarm instance): + + +.. code-block:: python + + import pyswarms as ps + from pyswarms.utils.functions.single_obj import sphere_func + from pyswarms.utils.plotters import plot_cost_history + + # Set up optimizer + options = {'c1':0.5, 'c2':0.3, 'w':0.9} + optimizer = ps.single.GlobalBestPSO(n_particles=10, dimensions=2, + options=options) + + # Obtain pos history from optimizer instance + pos_history = optimizer.pos_history + + # Plot! + plot_trajectory2D(pos_history) + +You can also supply various arguments in this method: the indices of the +specific dimensions to be used, the limits of the axes, and the interval/ +speed of animation. +""" + +# Import modules +import logging +from collections import namedtuple + +import matplotlib.pyplot as plt +import numpy as np +from matplotlib import (animation, cm) +from mpl_toolkits.mplot3d import Axes3D + +# Import from package +from .formatters import (Designer, Animator, Mesher) + +# Initialize logger +logger = logging.getLogger(__name__) + +def plot_cost_history(cost_history, ax=None, title='Cost History', + designer=None, **kwargs): + """Creates a simple line plot with the cost in the y-axis and + the iteration at the x-axis + + Parameters + ---------- + cost_history : list or numpy.ndarray + Cost history of shape :code:`(iters, )` or length :code:`iters` where + each element contains the cost for the given iteration. + ax : :class:`matplotlib.axes.Axes` (default is :code:`None`) + The axes where the plot is to be drawn. If :code:`None` is + passed, then the plot will be drawn to a new set of axes. + title : str (default is :code:`'Cost History'`) + The title of the plotted graph. + designer : pyswarms.utils.formatters.Designer (default is :code:`None`) + Designer class for custom attributes + **kwargs : dict + Keyword arguments that are passed as a keyword argument to + :class:`matplotlib.axes.Axes` + + Returns + ------- + :class:`matplotlib.axes._subplots.AxesSubplot` + The axes on which the plot was drawn. + """ + try: + # Infer number of iterations based on the length + # of the passed array + iters = len(cost_history) + + # If no Designer class supplied, use defaults + if designer is None: + designer = Designer() + + # If no ax supplied, create new instance + if ax is None: + _, ax = plt.subplots(1,1, figsize=designer.figsize) + + # Plot with iters in x-axis and the cost in y-axis + ax.plot(np.arange(iters), cost_history, 'k', lw=2, label=designer.label) + + # Customize plot depending on parameters + ax.set_title(title, fontsize=designer.title_fontsize) + ax.legend(fontsize=designer.text_fontsize) + ax.set_xlabel('Iterations', fontsize=designer.text_fontsize) + ax.set_ylabel('Cost', fontsize=designer.text_fontsize) + ax.tick_params(labelsize=designer.text_fontsize) + except TypeError: + raise + else: + return ax + +def plot_contour(pos_history, canvas=None, title='Trajectory', mark=None, + designer=None, mesher=None, animator=None, **kwargs): + """Draws a 2D contour map for particle trajectories + + Here, the space is represented as flat plane. The contours indicate the + elevation with respect to the objective function. This works best with + 2-dimensional swarms with their fitness in z-space. + + Parameters + ---------- + pos_history : numpy.ndarray or list + Position history of the swarm with shape + :code:`(iteration, n_particles, dimensions)` + canvas : tuple of :class:`matplotlib.figure.Figure` and :class:`matplotlib.axes.Axes` (default is :code:`None`) + The (figure, axis) where all the events will be draw. If :code:`None` is + supplied, then plot will be drawn to a fresh set of canvas. + title : str (default is :code:`'Trajectory'`) + The title of the plotted graph. + mark : tuple (default is :code:`None`) + Marks a particular point with a red crossmark. Useful for marking + the optima. + designer : pyswarms.utils.formatters.Designer (default is :code:`None`) + Designer class for custom attributes + mesher : pyswarms.utils.formatters.Mesher (default is :code:`None`) + Mesher class for mesh plots + animator : pyswarms.utils.formatters.Animator (default is :code:`None`) + Animator class for custom animation + **kwargs : dict + Keyword arguments that are passed as a keyword argument to + :class:`matplotlib.axes.Axes` plotting function + + Returns + ------- + :class:`matplotlib.animation.FuncAnimation` + The drawn animation that can be saved to mp4 or other + third-party tools + """ + + try: + # If no Designer class supplied, use defaults + if designer is None: + designer = Designer(limits=[(-1,1), (-1,1)], label=['x-axis', 'y-axis']) + + # If no Animator class supplied, use defaults + if animator is None: + animator = Animator() + + # If ax is default, then create new plot. Set-up the figure, the + # axis, and the plot element that we want to animate + if canvas is None: + fig, ax = plt.subplots(1, 1, figsize=designer.figsize) + else: + fig, ax = canvas + + # Get number of iterations + n_iters = len(pos_history) + + # Customize plot + ax.set_title(title, fontsize=designer.title_fontsize) + ax.set_xlabel(designer.label[0], fontsize=designer.text_fontsize) + ax.set_ylabel(designer.label[1], fontsize=designer.text_fontsize) + ax.set_xlim(designer.limits[0]) + ax.set_ylim(designer.limits[1]) + + # Make a contour map if possible + if mesher is not None: + xx, yy, zz, = _mesh(mesher) + ax.contour(xx, yy, zz, levels=mesher.levels) + + # Mark global best if possible + if mark is not None: + ax.scatter(mark[0], mark[1], color='red', marker='x') + + # Put scatter skeleton + plot = ax.scatter(x=[], y=[], c='black', alpha=0.6, **kwargs) + + # Do animation + anim = animation.FuncAnimation(fig=fig, + func=_animate, + frames=range(n_iters), + fargs=(pos_history, plot), + interval=animator.interval, + repeat=animator.repeat, + repeat_delay=animator.repeat_delay) + except TypeError: + raise + else: + return anim + +def plot_surface(pos_history, canvas=None, title='Trajectory', + designer=None, mesher=None, animator=None, mark=None, **kwargs): + """Plots a swarm's trajectory in 3D + + This is useful for plotting the swarm's 2-dimensional position with + respect to the objective function. The value in the z-axis is the fitness + of the 2D particle when passed to the objective function. When preparing the + position history, make sure that the: + + * first column is the position in the x-axis, + * second column is the position in the y-axis; and + * third column is the fitness of the 2D particle + + The :class:`pyswarms.utils.plotters.formatters.Mesher` class provides a + method that prepares this history given a 2D pos history from any + optimizer. + + .. code-block:: python + + import pyswarms as ps + from pyswarms.utils.functions.single_obj import sphere_func + from pyswarms.utils.plotters import plot_surface + from pyswarms.utils.plotters.formatters import Mesher + + # Run optimizer + options = {'c1':0.5, 'c2':0.3, 'w':0.9} + optimizer = ps.single.GlobalBestPSO(n_particles=10, dimensions=2, options) + + # Prepare position history + m = Mesher(func=sphere_func) + pos_history_3d = m.compute_history_3d(optimizer.pos_history) + + # Plot! + plot_surface(pos_history_3d) + + Parameters + ---------- + pos_history : numpy.ndarray + Position history of the swarm with shape + :code:`(iteration, n_particles, 3)` + objective_func : callable + The objective function that takes a swarm of shape + :code:`(n_particles, 2)` and returns a fitness array + of :code:`(n_particles, )` + canvas : tuple of :class:`matplotlib.figure.Figure` and + :class:`matplotlib.axes.Axes` (default is :code:`None`) + The (figure, axis) where all the events will be draw. If :code:`None` + is supplied, then plot will be drawn to a fresh set of canvas. + title : str (default is :code:`'Trajectory'`) + The title of the plotted graph. + mark : tuple (default is :code:`None`) + Marks a particular point with a red crossmark. Useful for marking the + optima. + designer : pyswarms.utils.formatters.Designer (default is :code:`None`) + Designer class for custom attributes + mesher : pyswarms.utils.formatters.Mesher (default is :code:`None`) + Mesher class for mesh plots + animator : pyswarms.utils.formatters.Animator (default is :code:`None`) + Animator class for custom animation + **kwargs : dict + Keyword arguments that are passed as a keyword argument to + :class:`matplotlib.axes.Axes` plotting function + + Returns + ------- + :class:`matplotlib.animation.FuncAnimation` + The drawn animation that can be saved to mp4 or other + third-party tools + """ + try: + # If no Designer class supplied, use defaults + if designer is None: + designer = Designer(limits=[(-1,1), (-1,1), (-1,1)], + label=['x-axis', 'y-axis', 'z-axis']) + + # If no Animator class supplied, use defaults + if animator is None: + animator = Animator() + + # If ax is default, then create new plot. Set-up the figure, the + # axis, and the plot element that we want to animate + if canvas is None: + fig, ax = plt.subplots(1, 1, figsize=designer.figsize) + else: + fig, ax = canvas + + # Initialize 3D-axis + ax = Axes3D(fig) + + # Get number of iterations + n_iters = len(pos_history) + + # Customize plot + ax.set_title(title, fontsize=designer.title_fontsize) + ax.set_xlabel(designer.label[0], fontsize=designer.text_fontsize) + ax.set_ylabel(designer.label[1], fontsize=designer.text_fontsize) + ax.set_zlabel(designer.label[2], fontsize=designer.text_fontsize) + ax.set_xlim(designer.limits[0]) + ax.set_ylim(designer.limits[1]) + ax.set_zlim(designer.limits[2]) + + # Make a contour map if possible + if mesher is not None: + xx, yy, zz, = _mesh(mesher) + ax.plot_surface(xx, yy, zz, cmap=cm.viridis, alpha=mesher.alpha) + + # Mark global best if possible + if mark is not None: + ax.scatter(mark[0], mark[1], mark[2], color='red', marker='x') + + # Put scatter skeleton + plot = ax.scatter(xs=[], ys=[], zs=[], c='black', alpha=0.6, **kwargs) + + # Do animation + anim = animation.FuncAnimation(fig=fig, + func=_animate, + frames=range(n_iters), + fargs=(pos_history, plot), + interval=animator.interval, + repeat=animator.repeat, + repeat_delay=animator.repeat_delay) + except TypeError: + raise + else: + return anim + +def _animate(i, data, plot): + """Helper animation function that is called sequentially + :class:`matplotlib.animation.FuncAnimation` + """ + current_pos = data[i] + if np.array(current_pos).shape[1] == 2: + plot.set_offsets(current_pos) + else: + plot._offsets3d = current_pos.T + return plot, + +def _mesh(mesher): + """Helper function to make a mesh""" + xlim = mesher.limits[0] + ylim = mesher.limits[1] + x = np.arange(xlim[0], xlim[1], mesher.delta) + y = np.arange(ylim[0], ylim[1], mesher.delta) + xx, yy = np.meshgrid(x, y) + xypairs = np.vstack([xx.reshape(-1), yy.reshape(-1)]).T + # Get z-value + z = mesher.func(xypairs) + zz = z.reshape(xx.shape) + return (xx, yy, zz) \ No newline at end of file From cff5fe927bb387ca37fbcb98ee321b48b014491c Mon Sep 17 00:00:00 2001 From: ljvmiranda921 Date: Wed, 13 Jun 2018 12:19:59 +0900 Subject: [PATCH 2/4] Add formatters module Reference: #130 The problem before is that we tend to have many parameters in our plotting functions. The formatters module changes that. We have three types of formatters: Designer, Animator, and Mesher. There are defaults present, but the user can change and pass them to the ploting functions whenever needed. Signed-off-by: Lester James V. Miranda --- pyswarms/utils/plotters/formatters.py | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 pyswarms/utils/plotters/formatters.py diff --git a/pyswarms/utils/plotters/formatters.py b/pyswarms/utils/plotters/formatters.py new file mode 100644 index 00000000..c6f6e10d --- /dev/null +++ b/pyswarms/utils/plotters/formatters.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- + +""" +Plot Formatters + +This module implements helpful classes to format your plots or create meshes. +""" + +# Import modules +import numpy as np +from attr import (attrs, attrib) +from attr.validators import instance_of + +@attrs +class Designer(object): + """Designer class for specifying a plot's formatting and design""" + # Overall plot design + figsize = attrib(type=tuple, validator=instance_of(tuple), default=(10,8)) + title_fontsize = attrib(validator=instance_of((str, int, float)), + default='large') + text_fontsize = attrib(validator=instance_of((str, int, float)), + default='medium') + label = attrib(validator=instance_of((str, list, tuple)), default='Cost') + limits = attrib(validator=instance_of((list, tuple)), + default=[(-1,1),(-1,1)]) + +@attrs +class Animator(object): + """Animator class for specifying animation behavior""" + interval = attrib(type=int, validator=instance_of(int), default=80) + repeat_delay = attrib(default=None) + repeat = attrib(type=bool, validator=instance_of(bool), default=True) + +@attrs +class Mesher(object): + """Mesher class for plotting contours of objective functions""" + func = attrib() + # For mesh creation + delta = attrib(type=float, default=0.001) + limits = attrib(validator=instance_of((list, tuple)), + default=[(-1,1),(-1,1)]) + levels = attrib(type=list, default=np.arange(-2.0,2.0,0.070)) + # Surface transparency + alpha = attrib(type=float, validator=instance_of(float), default=0.3) + + def compute_history_3d(self, pos_history): + """Computes a 3D position matrix + + The first two columns are the 2D position in the x and y axes + respectively, while the third column is the fitness on that given + position. + + Parameters + ---------- + pos_history : numpy.ndarray + Two-dimensional position matrix history of shape + :code:`(iterations, n_particles, 2)` + + Returns + ------- + numpy.ndarray + 3D position matrix of shape :code:`(iterations, n_particles, 3)` + """ + fitness = np.array(list(map(self.func, pos_history))) + return np.dstack((pos_history, fitness)) \ No newline at end of file From afb3d02a8bcf17e5df4ee54782303353124b480c Mon Sep 17 00:00:00 2001 From: ljvmiranda921 Date: Tue, 12 Jun 2018 21:59:33 +0900 Subject: [PATCH 3/4] Add tests for plotters module --- tests/utils/plotters/__init__.py | 0 tests/utils/plotters/conftest.py | 37 ++++++++++++++++++ tests/utils/plotters/test_plotters.py | 55 +++++++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 tests/utils/plotters/__init__.py create mode 100644 tests/utils/plotters/conftest.py create mode 100644 tests/utils/plotters/test_plotters.py diff --git a/tests/utils/plotters/__init__.py b/tests/utils/plotters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/plotters/conftest.py b/tests/utils/plotters/conftest.py new file mode 100644 index 00000000..d109b893 --- /dev/null +++ b/tests/utils/plotters/conftest.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +"""Fixtures for tests""" + +# Import modules +import os +import pytest +import numpy as np +from mock import Mock +import matplotlib as mpl + +if os.environ.get('DISPLAY','') == '': + mpl.use('Agg') + +# Import from package +from pyswarms.single import GlobalBestPSO +from pyswarms.utils.functions.single_obj import sphere_func +from pyswarms.utils.plotters.formatters import Mesher + +@pytest.fixture +def trained_optimizer(): + """Returns a trained optimizer instance with 100 iterations""" + options = {'c1':0.5, 'c2':0.3, 'w':0.9} + optimizer = GlobalBestPSO(n_particles=10, dimensions=2, options=options) + optimizer.optimize(sphere_func, iters=100) + return optimizer + +@pytest.fixture +def pos_history(): + """Returns a list containing a swarms' position history""" + return np.random.uniform(size=(10, 5, 2)) + +@pytest.fixture +def mesher(): + """A Mesher instance with sphere function and delta=0.1""" + return Mesher(func=sphere_func, delta=0.1) \ No newline at end of file diff --git a/tests/utils/plotters/test_plotters.py b/tests/utils/plotters/test_plotters.py new file mode 100644 index 00000000..3db3cb84 --- /dev/null +++ b/tests/utils/plotters/test_plotters.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Import modules +import os +import pytest +import matplotlib as mpl + +if os.environ.get('DISPLAY','') == '': + mpl.use('Agg') + +from matplotlib.axes._subplots import SubplotBase +from matplotlib.animation import FuncAnimation + +# Import from package +from pyswarms.utils.plotters import (plot_cost_history, + plot_contour, + plot_surface) + +from pyswarms.utils.plotters.plotters import (_mesh, _animate) +from pyswarms.utils.plotters.formatters import Mesher + +@pytest.mark.parametrize('history', ['cost_history', 'mean_neighbor_history', + 'mean_pbest_history']) +def test_plot_cost_history_return_type(trained_optimizer, history): + """Tests if plot_cost_history() returns a SubplotBase instance""" + opt_params = vars(trained_optimizer) + plot = plot_cost_history(opt_params[history]) + assert isinstance(plot, SubplotBase) + +@pytest.mark.parametrize('bad_values', [2, 43.14]) +def test_plot_cost_history_error(bad_values): + """Tests if plot_cost_history() raises an error given bad values""" + with pytest.raises(TypeError): + plot_cost_history(bad_values) + +def test_plot_contour_return_type(pos_history): + """Tests if the animation function returns the expected type""" + assert isinstance(plot_contour(pos_history), FuncAnimation) + +def test_plot_surface_return_type(pos_history): + """Tests if the animation function returns the expected type""" + assert isinstance(plot_surface(pos_history), FuncAnimation) + +def test_mesh_hidden_function_shape(mesher): + """Tests if the hidden _mesh() function returns the expected shape""" + xx, yy, zz = _mesh(mesher) + assert (xx.shape == yy.shape == zz.shape == (20,20)) + +def test_animate_hidden_function_type(pos_history): + """Tests if the hidden _animate() function returns the expected type""" + fig, ax = mpl.pyplot.subplots(1,1) + ax = mpl.pyplot.scatter(x=[], y=[]) + return_plot = _animate(i=1, data=pos_history, plot=ax) + assert isinstance(return_plot, tuple) \ No newline at end of file From 28703bf5b8a3e5247bb6e424a5a44cb928f13b39 Mon Sep 17 00:00:00 2001 From: ljvmiranda921 Date: Wed, 13 Jun 2018 21:46:46 +0900 Subject: [PATCH 4/4] Fix bad behavior in discrete swarm generation Sometimes your swarm can generate a pure 0 or pure 1 vector, we should account that. Signed-off-by: Lester James V. Miranda --- tests/backend/test_generators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/backend/test_generators.py b/tests/backend/test_generators.py index 921cabca..2aea4e93 100644 --- a/tests/backend/test_generators.py +++ b/tests/backend/test_generators.py @@ -53,7 +53,7 @@ def test_generate_discrete_binary_swarm(binary): dims = 3 pos = P.generate_discrete_swarm(n_particles=2, dimensions=dims, binary=binary) if binary: - assert len(np.unique(pos)) == 2 + assert len(np.unique(pos)) <= 2 # Might generate pure 0 or 1 else: assert (np.max(pos, axis=1) == dims - 1).all()