Evolution – optimizing with evolutionary algorithms#

Evolution is an interface for device optimization with evolutionary algorithms. They reproduce essential elements of biological evolution. Evolutionary algorithms explore a given range of selected input variables and search for a setup which minimizes a given objective. Evolution is also suitable for multi-objective problems where the trade-off between different objectives is essential.

The search is population-based. The population is a set of candidates, which are evaluated by Evolution at every step of optimization. Every candidate in the population is a given setup of the device. nextnano solvers perfom simulations for the candidates. The objective values are calculated based on the simulation results. The evolutionary algorithm uses these objective values to construct the next population. This process repeats over successive iterations which are called generations.

How the population is constructed#

The first generation of candidates is initialized randomly to explore the design space. Every successive population is chosen based on 3 possible actions:

  • selection: the candidates with better objective values have higher probability to be selected as parents for the next generation.

  • crossover: the input_variables of selected parents are combined to produce new candidates, called offspring.

  • mutation: random changes are introduced to the input_variables of offspring to further explore the design space.

To learn more about the evolutionary algorithms, see the Evolutionary Algorithms page.

Creating the optimization problem#

Assume that the quantum well needs to be optimized to match a given transition energy. The script below shows how to optimize it by using Evolution:

import numpy as np

from nextnano_optimizers.io import IO
from nextnano_optimizers.metric import Metric
from nextnano_optimizers.optimizer import Evolution

io = IO(
    input_file_path="device.nnp",
    variable_names=["well_width", "barrier_width", "alloy_fraction"],
    target_output_paths=[("bias_00000", "Quantum", "energy_spectrum.dat")],
)

TARGET_ENERGY = 0.25  # eV

def distance_to_target(datafiles):
    energies = datafiles[0].variables["Energy"].value
    transition_energy = energies[1] - energies[0]

    return np.array([(transition_energy - TARGET_ENERGY) ** 2])

metric = Metric(
    input_shape=(1,),
    output_shape=(1,),
    extraction_function=distance_to_target,
)

evolution = Evolution(
    nextnanoio=io,
    metric=metric,
    bounds=([2.0, 2.0, 0.15], [12.0, 20.0, 0.45]),
)

bounds of variable_names defines a design space. Candidates for each simulation are always selected inside this space.

Important: The bounds are positional. The order in which you define the bounds must match the order of variable_names in IO. The bounds are defined as two lists of lower and upper bounds:

variable_names = ["well_width", "barrier_width", "alloy_fraction"]

lower bounds   = [2.0,          2.0,             0.15]
upper bounds   = [12.0,         20.0,            0.45]

well_width     -> [2.0,  12.0]   nm
barrier_width  -> [2.0,  20.0]   nm
alloy_fraction -> [0.15,  0.45]

Note

Evolution and LocalOptimizer define the same bounds differently:

# Evolution: one list of lower bounds, one of upper bounds
bounds = ([2.0, 2.0, 0.15], [12.0, 20.0, 0.45])

# LocalOptimizer: one (lower, upper) pair per variable
bounds = [(2.0, 12.0), (2.0, 20.0), (0.15, 0.45)]

Both mean well_width in 2-12 nm, barrier_width in 2-20 nm and alloy_fraction in 0.15-0.45.

Population size and number of generations#

Two parameters determine the budget for the optimization:

  • size is a population size, i.e. the number of candidates in each generation

  • gen defines the number of generations to run.

evolution = Evolution(
    nextnanoio=io,
    metric=metric,
    bounds=([2.0, 2.0, 0.15], [12.0, 20.0, 0.45]),
    size=20,
)

evolution.run(gen=20)

The size of the population also influences how diverse the population is. When the population size is too small, the Evolution can converge prematurely before the design space is explored sufficiently. Typically, more complex problems require a larger population size.

gen defines how many iterations are given for the algorithm to improve and refine the answer.

The total evaluations budget for the optimization is size*gen.

In general, a value between 20 and 100 is a good starting point for both size and gen, but the optimal choice depends on the complexity and dimensionality of the problem. The default value for gen and size is 20.

Running simulations in parallel#

The candidates of one generation can be processed independently. Hence, their respective nextnano simulations can run in parallel. parallel_limit defines the number of parallel simulations and, hence, allows one to speed up the optimization.

evolution = Evolution(
    nextnanoio=io,
    metric=metric,
    bounds=([2.0, 2.0, 0.15], [12.0, 20.0, 0.45]),
    size=20,
    parallel_limit=4,
)

In this example, parallel_limit=4 means that simulations of four candidates are allowed to be executed concurrently. A new simulation starts whenever any previous one finishes. The default is parallel_limit=1, i.e. one simulation at a time.

Note, that generations are always computed one after another, since the algorithm needs the objective values of the entire population before it can produce the next one.

Two factors should be taken into account:

  • The machine computation capacity. Each concurrent simulation needs its own CPU cores and memory. It is advisable to choose parallel_limit less than \(n-1\), where \(n\) is a number of physical CPU cores on the machine

  • The population size. Any generation contains only size candidates, so the algorithm can not run more than size simulations in parallel.

Choosing the evolutionary algorithm#

algorithm selects which evolutionary algorithm is employed:

evolution = Evolution(
    nextnanoio=io,
    metric=metric,
    bounds=([2.0, 2.0, 0.15], [12.0, 20.0, 0.45]),
    algorithm="ga",
)

When the algorithm is not specified, the default choice depends on the dimensionality of the objective.

  • For single-objective problems (i.e. the shape of the Metric is (1, )), the default algorithm is a genetic algorithm "ga".

  • For multi-objective problems (i.e. the shape of the Metric is (p, ) where \(p > 1\)), the default algorithm is NSGA-II "nsga-2".

Algorithm-specific settings are passed via algorithm_kwargs. For example, one can define a probability prob that the mutation operation is applied to create a new candidate:

from pymoo.operators.mutation.pm import PM

evolution.run(gen=20, algorithm_kwargs={"mutation": PM(prob=0.5)})

To learn more about the available algorithms and their hyperparameters, see the Evolutionary Algorithms page.

Running the optimization#

A setup of a complete run involves only IO, Metric, and the parts described above:

evolution = Evolution(
    nextnanoio=io,
    metric=metric,
    bounds=([2.0, 2.0, 0.15], [12.0, 20.0, 0.45]),
    size=20,
    parallel_limit=4,
)

evolution.run(gen=20)

run() repeats the loop below one time for each generation:

population of candidate input values
       |
       v
nextnano simulations
       |
       v
objective values from Metric
       |
       v
evolutionary algorithm
       |
       v
next generation of candidates

The first population is sampled randomly within the bounds. Every subsequent one is built by the algorithm from the objective values of the preceeding population. The loop is terminated when gen generations have been computed.

The run’s progress is recorded by all algorithms of nextnano optimizers in the files Simulation_*.log, Simulation_*.json and Simulation_*.dat. During Evolution, these files contain one entry per generation. The entries are used to plot progress figures, see Plotting the progress and results.

Handling failed simulations#

Some points inside the bounds may be not suitable for simulations of the device, e.g., because of absence of convergence. Such candidates raise RuntimeError and end the run.

Alternatively, the failed simulation can be penalized by a deliberately bad objective without terminating the job. The penalty is set by non_execution_penalty:

result = evolution.run(gen=20, non_execution_penalty=1.0)

Interpreting the result#

For a single-objective problem, there is always a best design among those found, i.e. the desigh with the lowest objective value. This design is called champion and can be inspected after the evolution run:

print(evolution.champion_x)  # the input values of the best design
print(evolution.champion_f)  # the objective value of that design

champion_x containes values of the the input variables in the order of variable_names. champion_f is the objective value computed by Metric for champion_x.

For multi-objective problems, there is usually no single design that is the best one for all objectives. For example, the design with the best objective a does not necessarily have the best objective b and vice versa.

In this case, the result is a set of designs corresponding to different trade-offs between the objectives. For each of these designs, improving one objective would require compromising at least one other objective. This set is called Pareto-optimal set. Its corresponding objective values form the Pareto front.

print(evolution.pareto_solutions)  # the input values of the Pareto designs
print(evolution.pareto_fits)       # their objective values

Plotting the progress and results#

run(live_progress=True) visualizes the generations in a live-plotted window; plot_all() plots the recorded history – including the Pareto front of a multi-objective run – once run has finished. Both are described in Plotting the progress and results.

When to use Evolution#

Evolution is the appropriate tool when:

  • the objective may have several local minima, so a local minimization algorithm risks settling in the non-global minima

  • several objectives are optimized at once and the trade-off between them is the result you want.

Evolution is not appropriate when:

  • the simulations are time-consuming and there is not enough computation power available to effectively parallelize them.

Additional information#

See Evolution for the exact argument types, the validation rules and the attributes of Evolution.

See Evolutionary Algorithms for the available algorithms, and nextnano_optimizers.plotting module for the full set of plotting options.

Worked examples: Optimization of Mid-IR Quantum Cascade Laser Gain and Maximizing Envelope Overlaps for a Given Transition Energy.