Electrostatic potential minimization

The main purpose of iwopy is to be helpful when attacking more complicated optimization tasks than the minimization of simple analytical functions. As an example, we consider the minimization of an inverse distance type potential for a fully coupled system of \(N\) particles in two dimensions. We can imagine such a system to be composed of \(N\) individual point charges, each of them carrying the same electric unit charge. The potential that we want to minimize is then represented by

\[\begin{split}V = \sum_{i\neq j} \frac 1 {|\mathrm r_i - \mathrm r_j|} \ , \quad \text{where} \quad \mathrm r_i = \left( \begin{array}{c} x_i \\ y_i \end{array} \right)\end{split}\]

denotes the two dimensional position vector of charge \(i\) and the sum is over all unequal index pairs. This potential favours large distances between charges, hence we need to constrain them to a certain area for a meaningful solution. We confine them to a circle of radius \(R\) by imposing \(N\) constraints,

\[|\mathrm r_i| \leq R \ , \quad \text{for all} \quad i = 0 \ldots N-1 \ .\]

These are the required imports for this example:

In [1]:
import numpy as np
import matplotlib.pyplot as plt
from iwopy import Problem, Objective, Constraint
from iwopy.interfaces.pymoo import Optimizer_pymoo

We start by creating a specific class that describes the variables of our problem:

In [2]:
class ChargesProblem(Problem):
    def __init__(self, xy_init, radius):
        super().__init__(name="charges_problem")
        self.xy_init = xy_init
        self.n_charges = len(xy_init)
        self.radius = radius

    def var_names_float(self):
        """Defines the variable names"""
        vnames = []
        for i in range(self.n_charges):
            vnames += [f"x{i}", f"y{i}"]
        return vnames

    def initial_values_float(self):
        """Returns inital values, as given to constructor"""
        return self.xy_init.reshape(2 * self.n_charges)

    def min_values_float(self):
        """Minimal values for a square of size 2*radius"""
        return np.full(2 * self.n_charges, -self.radius)

    def max_values_float(self):
        """Maximal values for a square of size 2*radius"""
        return np.full(2 * self.n_charges, self.radius)

    def apply_individual(self, vars_int, vars_float):
        """Returns (x, y) variables for each charge"""
        return vars_float.reshape(self.n_charges, 2)

    def apply_population(self, vars_int, vars_float):
        """Returns (x, y) variables for each charge per individual"""
        n_pop = len(vars_float)
        return vars_float.reshape(n_pop, self.n_charges, 2)

    def get_fig(self, xy):
        """Get a figure showing the charges locations"""
        fig, ax = plt.subplots(figsize=(6, 6))
        ax.scatter(xy[:, 0], xy[:, 1], color="orange")
        ax.add_patch(plt.Circle((0, 0), self.radius, color="darkred", fill=False))
        ax.set_aspect("equal", adjustable="box")
        ax.set_xlabel("x")
        ax.set_ylabel("y")
        ax.set_title(f"N = {self.n_charges}")
        return fig

There are \(2 N\) variables for this problem, and we order them as \((x_0, y_0, \ldots, x_{N-1}, y_{N-1})\). This is convenient, since the numpy reshaping operation vars_float.reshape(n_charges, 2) then represents an array of \((x_i, y_i)\) type arrays, which is handy for calculations.

Notice the two functions apply_individual and apply_population. They are being called during optimization whenever new variables are being set by the optimizer, and their purpose is to update the data in the problem class accordingly (excluding objectives and constraints, which we will deal with shortly).

The difference between the two functions is that apply_individual evaluates a single vector of new problem variables, whereas apply_population handles a full population of such vectors. The implementation of the latter function is not strictly required (alternatively a loop over individuals of the population will be evaluated), but it provides a chance for a vast speed-up by vectorized evaluation. This is particularly beneficial for genetic algorithms or other easily vectorizable approaches (in fact also the gradient calculation via iwopy discretizations makes use of this vectorization). Of course the chosen optimizer must be able to support vectorized evaluation for this to work. Both functions return any kind of evaluation data which will be forwarded to the objective and constraint evaluation.

So far we have only defined the problem variables. Objectives and constraints can be added to the problem via the add_objective and add_constraint functions, respectively. First, we implement the inverse distance potential as our objective:

In [3]:
class MinPotential(Objective):
    def __init__(self, problem):
        """Define the same variable names and ordering as in the problem"""
        super().__init__(problem, "potential", vnames_float=problem.var_names_float())
        self.n_charges = problem.n_charges

    def n_components(self):
        """The potential is a scalar function, hence one component"""
        return 1

    def maximize(self):
        """Indicates that the single component is to be minimized"""
        return [False]

    def calc_individual(self, vars_int, vars_float, problem_results, components=None):
        """This evaluates the potential. See problem.apply_individual
        for problem results"""
        xy = problem_results
        value = 0.0
        for i in range(1, self.n_charges):
            dist = np.maximum(np.linalg.norm(xy[i - 1, None] - xy[i:], axis=-1), 1e-10)
            value += 2 * np.sum(1 / dist)
        return value

    def calc_population(self, vars_int, vars_float, problem_results, components=None):
        """This evaluates the potential in vectorized manner.
        See problem.apply_population for problem results"""
        xy = problem_results
        n_pop = len(xy)
        value = np.zeros((n_pop, 1))
        for i in range(1, self.n_charges):
            dist = np.maximum(
                np.linalg.norm(xy[:, i - 1, None] - xy[:, i:], axis=-1), 1e-10
            )
            value[:, 0] += 2 * np.sum(1 / dist, axis=1)
        return value

Notice that the functions calc_individual and its (again optional) vectorized correspondent calc_population do not directly make use of the variables vector vars_float, which they could (and it would be perfectly fine, even intended), but instead of the problem_results provided by the problem functions apply_individual and apply_population.

Next, we implement the \(N\) radial constraints:

In [4]:
class MaxRadius(Constraint):
    def __init__(self, problem, tol=1e-2):
        """Define the same variable names and ordering as in the problem"""
        super().__init__(
            problem, "radius", vnames_float=problem.var_names_float(), tol=tol
        )
        self.n_charges = problem.n_charges
        self.radius = problem.radius

    def n_components(self):
        """One constraint per charge"""
        return self.n_charges

    def vardeps_float(self):
        """Boolean array that defines which component depends
        on which variable (optional, default is on all)"""
        deps = np.zeros((self.n_components(), self.n_charges, 2), dtype=bool)
        np.fill_diagonal(deps[..., 0], True)
        np.fill_diagonal(deps[..., 1], True)
        return deps.reshape(self.n_components(), 2 * self.n_charges)

    def calc_individual(self, vars_int, vars_float, problem_results, components=None):
        """This evaluates the constraints, negative values are valid.
        See problem.apply_individual for problem results"""
        components = np.s_[:] if components is None else components
        xy = problem_results[components]
        r = np.linalg.norm(xy, axis=-1)
        return r - self.radius

    def calc_population(self, vars_int, vars_float, problem_results, components=None):
        """This evaluates the constraints in vectorized manner,
        negative values are valid. See problem.apply_population for
        problem results"""
        components = np.s_[:] if components is None else components
        xy = problem_results[:, components]
        r = np.linalg.norm(xy, axis=-1)
        return r - self.radius

Note that by default, negative constraint values represent validity. This can be changed by overloading the function get_bounds of the base class, see API, but is not recommended.

Also note that the function vardeps_float is only relevant for gradient based solvers that support gradient sparsity (e.g. pygmo.ipopt). It is irrelevant for the genetic algorithm that we want to use in this example, but added for the sake of completeness.

Now let’s add the objective and the constraints and initialize the problem:

In [5]:
N = 20
R = 5

# generate random initial coordinates of the N charges:
xy = np.random.uniform(-R / 10.0, R / 10.0, (N, 2))

problem = ChargesProblem(xy, R)
problem.add_objective(MinPotential(problem))
problem.add_constraint(MaxRadius(problem))
problem.initialize()

fig = problem.get_fig(xy)
plt.show()
Problem 'charges_problem' (ChargesProblem): Initializing
--------------------------------------------------------
  n_vars_int  : 0
  n_vars_float: 40
--------------------------------------------------------
  n_objectives: 1
  n_obj_cmptns: 1
--------------------------------------------------------
  n_constraints: 1
  n_con_cmptns: 20
--------------------------------------------------------
../_images/notebooks_electrostatics_10_1.png

Finally, we can now setup the genetic algorithm GA from pymoo and solve the problem, here in vectorized form (flag vectorize=True):

In [6]:
solver = Optimizer_pymoo(
    problem,
    problem_pars=dict(
        vectorize=True,
    ),
    algo_pars=dict(
        type="GA",
        pop_size=100,
        seed=1,
    ),
    setup_pars=dict(),
    term_pars=dict(
        type="default",
        n_max_gen=300,
        ftol=1e-6,
        xtol=1e-6,
    ),
)
solver.initialize()
solver.print_info()

results = solver.solve(verbosity=0)
solver.finalize(results)
Loading pymoo
pymoo successfully loaded
Initializing Optimizer_pymoo
Selecting sampling: float_random (FloatRandomSampling)
Selecting algorithm: GA (GA)
Selecting termination: default (DefaultSingleObjectiveTermination)

Problem:
--------
  vectorize: True

Algorithm:
----------
  type: GA
  pop_size: 100
  seed: 1

Termination:
------------
  n_max_gen: 300
  ftol: 1e-06
  xtol: 1e-06



Optimizer_pymoo: Optimization run finished
  Success: True
  Best potential = 78.69922244245727
In [7]:
print(results)

xy = results.problem_results
fig = problem.get_fig(xy)
plt.show()
Results problem 'charges_problem':
-----------------------------------
  Float variables:
    0: x0  = 2.236112e+00
    1: y0  = -4.470885e+00
    2: x1  = 2.082072e+00
    3: y1  = 7.199634e-01
    4: x2  = 4.258628e+00
    5: y2  = 2.615461e+00
    6: x3  = 4.931458e+00
    7: y3  = 8.239395e-01
    8: x4  = -3.453717e+00
    9: y4  = -3.615375e+00
    10: x5  = -2.459065e+00
    11: y5  = 4.351725e+00
    12: x6  = -6.527966e-01
    13: y6  = 2.161302e+00
    14: x7  = 1.670812e+00
    15: y7  = -2.382281e+00
    16: x8  = -1.235636e+00
    17: y8  = -1.913568e+00
    18: x9  = -2.099603e+00
    19: y9  = -4.536876e+00
    20: x10 = -4.701957e+00
    21: y10 = -1.697765e+00
    22: x11 = 1.517358e+00
    23: y11 = 4.762982e+00
    24: x12 = 4.845131e+00
    25: y12 = -1.233574e+00
    26: x13 = 4.140025e+00
    27: y13 = -2.796535e+00
    28: x14 = 2.992287e+00
    29: y14 = 4.004978e+00
    30: x15 = 2.484520e-02
    31: y15 = -4.999799e+00
    32: x16 = -4.887334e+00
    33: y16 = 1.053824e+00
    34: x17 = -3.199172e+00
    35: y17 = 1.417813e-01
    36: x18 = -3.795050e+00
    37: y18 = 3.249910e+00
    38: x19 = -4.806220e-01
    39: y19 = 4.976805e+00
-----------------------------------
  Objectives:
    0: potential = 7.869922e+01
-----------------------------------
  Constraints:
    0: radius_0  = -1.098864e-03
    1: radius_1  = -2.796963e+00
    2: radius_2  = -2.345300e-03
    3: radius_3  = -1.849789e-04
    4: radius_4  = -8.972586e-05
    5: radius_5  = -1.548850e-03
    6: radius_6  = -2.742264e+00
    7: radius_7  = -2.090210e+00
    8: radius_8  = -2.722163e+00
    9: radius_9  = -8.424672e-04
    10: radius_10 = -9.192331e-04
    11: radius_11 = -1.163198e-03
    12: radius_12 = -3.001922e-04
    13: radius_13 = -3.960526e-03
    14: radius_14 = -6.371277e-04
    15: radius_15 = -1.389087e-04
    16: radius_16 = -3.424350e-04
    17: radius_17 = -1.797687e+00
    18: radius_18 = -3.568959e-03
    19: radius_19 = -4.112099e-05
-----------------------------------
  Success: True
-----------------------------------

../_images/notebooks_electrostatics_13_1.png

Note that this problem has many equivalent solutions, and the displayed result is the best solution that the genetic algorithm was able to find within its convergence criteria. With heuristic methods there is generally no guarantee or proof that a global optimum has been found. However, the objective function has clearly been minimized, and therefore the “design” was improved which is often the goal in engineering tasks.

For the fun of it, let’s also solve the problem using iwopy’s GG solver (stands for Greedy Gradient), now for 50 charges. This solver is a simple steepest decent algorithm which projects out directions that would violate constraints (and reverts those that already do). It supports iwopy’s vectorization capabilities and hence can run fast for many variables. Of course it is not as advanced as other gradient based solvers, but for this particular problem it does a good job:

In [8]:
from iwopy import LocalFD
from iwopy.optimizers import GG
In [9]:
N = 50
R = 5

xy = np.random.uniform(-R / 10.0, R / 10.0, (N, 2))

problem = ChargesProblem(xy, R)
problem.add_objective(MinPotential(problem))
problem.add_constraint(MaxRadius(problem))

gproblem = LocalFD(problem, deltas=1e-2, fd_order=1)
gproblem.initialize()

solver = GG(
    gproblem,
    step_max=0.1,
    step_min=1e-4,
    vectorized=True,
)
solver.initialize()

results = solver.solve(verbosity=0)
solver.finalize(results)
print(results)
Problem 'charges_problem' (ChargesProblem): Initializing
--------------------------------------------------------
  n_vars_int  : 0
  n_vars_float: 100
--------------------------------------------------------
  n_objectives: 1
  n_obj_cmptns: 1
--------------------------------------------------------
  n_constraints: 1
  n_con_cmptns: 50
--------------------------------------------------------
Problem 'charges_problem_fd' (LocalFD): Initializing
----------------------------------------------------
  n_vars_int  : 0
  n_vars_float: 100
----------------------------------------------------
  n_objectives: 1
  n_obj_cmptns: 1
----------------------------------------------------
  n_constraints: 1
  n_con_cmptns: 50
----------------------------------------------------
GG: Optimization run finished
  Success: True
  Best potential = 585.5754719667092
Results problem 'charges_problem_fd':
--------------------------------------
  Float variables:
    0: x0  = 1.087311e-01
    1: y0  = 3.652947e+00
    2: x1  = -4.606218e+00
    3: y1  = 1.944930e+00
    4: x2  = -1.887607e+00
    5: y2  = -4.630003e+00
    6: x3  = 3.562191e+00
    7: y3  = 4.047046e-01
    8: x4  = 1.485097e+00
    9: y4  = 1.052208e+00
    10: x5  = 3.858055e+00
    11: y5  = 3.180473e+00
    12: x6  = -4.141494e+00
    13: y6  = -2.801432e+00
    14: x7  = -4.122978e+00
    15: y7  = 2.828613e+00
    16: x8  = -8.834537e-01
    17: y8  = -3.429528e+00
    18: x9  = 2.007586e+00
    19: y9  = -4.579257e+00
    20: x10 = -3.463435e+00
    21: y10 = 3.606192e+00
    22: x11 = 1.932276e-01
    23: y11 = -1.712766e+00
    24: x12 = -1.579416e+00
    25: y12 = 3.213613e+00
    26: x13 = 1.743332e+00
    27: y13 = 3.151292e+00
    28: x14 = -1.420385e+00
    29: y14 = -9.827574e-01
    30: x15 = 3.402227e+00
    31: y15 = -1.309271e+00
    32: x16 = -3.366573e+00
    33: y16 = -1.166486e+00
    34: x17 = -4.900849e+00
    35: y17 = -9.907955e-01
    36: x18 = 2.878439e+00
    37: y18 = -4.088348e+00
    38: x19 = 1.042442e+00
    39: y19 = -4.890124e+00
    40: x20 = 3.120221e+00
    41: y20 = 3.906946e+00
    42: x21 = 4.444495e+00
    43: y21 = 2.290517e+00
    44: x22 = 3.645875e+00
    45: y22 = -3.421637e+00
    46: x23 = 2.536083e-01
    47: y23 = 4.993564e+00
    48: x24 = 3.064096e+00
    49: y24 = 2.047608e+00
    50: x25 = 4.740609e-02
    51: y25 = -4.999775e+00
    52: x26 = -3.516980e+00
    53: y26 = -3.553991e+00
    54: x27 = -7.810981e-01
    55: y27 = 4.938612e+00
    56: x28 = -4.607438e+00
    57: y28 = -1.942042e+00
    58: x29 = 1.275371e+00
    59: y29 = 4.834607e+00
    60: x30 = 4.255719e+00
    61: y30 = -2.624664e+00
    62: x31 = -1.127440e-01
    63: y31 = 1.802090e+00
    64: x32 = -2.673215e+00
    65: y32 = 4.225390e+00
    66: x33 = -2.750043e+00
    67: y33 = -4.175796e+00
    68: x34 = 2.240040e+00
    69: y34 = 4.470148e+00
    70: x35 = -2.396197e+00
    71: y35 = -2.639390e+00
    72: x36 = -2.902047e+00
    73: y36 = 2.146349e+00
    74: x37 = 2.344344e+00
    75: y37 = -2.683691e+00
    76: x38 = 4.819316e+00
    77: y38 = 1.331988e+00
    78: x39 = -3.530091e+00
    79: y39 = 5.240002e-01
    80: x40 = 4.990310e+00
    81: y40 = 3.111336e-01
    82: x41 = -9.358615e-01
    83: y41 = -4.911636e+00
    84: x42 = 4.702197e+00
    85: y42 = -1.699806e+00
    86: x43 = 8.549782e-01
    87: y43 = -3.505766e+00
    88: x44 = -4.999989e+00
    89: y44 = -1.061685e-02
    90: x45 = -4.900949e+00
    91: y45 = 9.903010e-01
    92: x46 = 4.949815e+00
    93: y46 = -7.066317e-01
    94: x47 = 1.642586e+00
    95: y47 = -7.069032e-01
    96: x48 = -1.757321e+00
    97: y48 = 4.681007e+00
    98: x49 = -1.552179e+00
    99: y49 = 7.801572e-01
--------------------------------------
  Objectives:
    0: potential = 5.855755e+02
--------------------------------------
  Constraints:
    0: radius_0  = -1.345435e+00
    1: radius_1  = 1.247216e-07
    2: radius_2  = -6.293392e-07
    3: radius_3  = -1.414893e+00
    4: radius_4  = -3.179930e+00
    5: radius_5  = -6.227472e-10
    6: radius_6  = -3.288465e-07
    7: radius_7  = -4.991725e-08
    8: radius_8  = -1.458509e+00
    9: radius_9  = -1.129005e-07
    10: radius_10 = 1.821135e-08
    11: radius_11 = -3.276369e+00
    12: radius_12 = -1.419237e+00
    13: radius_13 = -1.398633e+00
    14: radius_14 = -3.272775e+00
    15: radius_15 = -1.354546e+00
    16: radius_16 = -1.437065e+00
    17: radius_17 = -5.599746e-07
    18: radius_18 = 4.179612e-08
    19: radius_19 = 2.246986e-07
    20: radius_20 = 1.327298e-09
    21: radius_21 = 4.092386e-09
    22: radius_22 = -2.982356e-08
    23: radius_23 = 2.657750e-09
    24: radius_24 = -1.314707e+00
    25: radius_25 = -4.232622e-07
    26: radius_26 = 1.408324e-07
    27: radius_27 = -9.170680e-09
    28: radius_28 = 7.811203e-07
    29: radius_29 = 5.608240e-09
    30: radius_30 = 7.925003e-09
    31: radius_31 = -3.194387e+00
    32: radius_32 = -2.086939e-08
    33: radius_33 = 6.383923e-07
    34: radius_34 = 4.152908e-09
    35: radius_35 = -1.435152e+00
    36: radius_36 = -1.390472e+00
    37: radius_37 = -1.436554e+00
    38: radius_38 = 7.981631e-09
    39: radius_39 = -1.431231e+00
    40: radius_40 = 6.595215e-09
    41: radius_41 = 6.953370e-07
    42: radius_42 = -5.153245e-09
    43: radius_43 = -1.391485e+00
    44: radius_44 = 4.658783e-07
    45: radius_45 = -2.144952e-07
    46: radius_46 = 9.729153e-09
    47: radius_47 = -3.211760e+00
    48: radius_48 = 7.765166e-10
    49: radius_49 = -3.262789e+00
--------------------------------------
  Success: True
--------------------------------------

In [10]:
xy = results.problem_results
fig = problem.get_fig(xy)
plt.show()
../_images/notebooks_electrostatics_17_0.png