LocalOptimizer – optimizing with local search algorithms#
LocalOptimizer performs a local search of the optimal configuration. It starts at an initial guess, x0, and improves it
step by step, guided only by the evaluations made in the region that the optimizer has explored so far. It therefore finds a minimum near the starting point, which is not necessarily the best design in the whole parameter space. A
local search tries to improve one design instead of investigating the entire search space. As a result, if the objective has several local minima, two different starting points can yield two different answers.
Defining the optimization problem#
Assume that one has to optimize transition energies of a quantum-well. The example below shows the full setup of three objects for the case where three variables can be varied:
import numpy as np
from nextnano_optimizers.io import IO
from nextnano_optimizers.metric import Metric
from nextnano_optimizers.optimizer import LocalOptimizer
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,
)
optimizer = LocalOptimizer(nextnanoio=io, metric=metric)
Including three variables in variable_names makes the optimization search space three-dimensional: the optimizer works with vectors of three numbers, and IO substitutes them into the input file.
The function distance_to_target measures a deviation of the transition energy from its targeted value.
input_shape=(1,) corresponds to the single target output path configured in IO, and output_shape=(1,) denotes the single objective value returned by the extraction function.
Importantly, LocalOptimizers support only single-objective problems, so the Metric must have output_shape=(1,) and the extraction function must return a numpy array of length 1.
If Metric accidentally returns several values it is rejected when run() is called.
For multi-objective problems use Evolution instead.
Running from an initial guess#
run() needs the starting design and the algorithm to search with:
result = optimizer.run(
x0=[6.0, 10.0, 0.30],
method="Nelder-Mead",
)
Important: The entries of x0 are positional. Their order must follow the order of variable_names in IO. It is the same order in which values are substituted into the input file:
variable_names = ["well_width", "barrier_width", "alloy_fraction"]
x0 = [ 6.0, 10.0, 0.30]
well_width -> 6.0
barrier_width -> 10.0
alloy_fraction -> 0.30
The initial point x0 can be obtained from either preliminary simulations of the current device or an educated guess.
If x0 has a length that does not match the number of input variables, it is rejected immediately before any simulation runs.
What happens during a run#
run() drives one loop, which is repeated once per candidate suggested by the algorithm:
candidate input values
|
v
nextnano simulation
|
v
objective value from Metric
|
v
next candidate input values
The first candidate is x0. Every subsequent one is chosen by the algorithm
based on the previous objective values. The loop ends when the
algorithm’s convergence criteria are met or its evaluation budget is exhausted.
Choosing the optimization method#
method selects which local algorithm is used for the local optimization, for example:
result = optimizer.run(x0=[6.0, 10.0, 0.30], method="L-BFGS-B")
The choice is important because the various algorithms have different features, in particular:
whether derivatives are needed, and how many simulations one step costs,
whether bounds are supported,
ability to cope with an objective that is noisy, kinked, or flat in places.
The page Local Optimization contains detailed information on the supported and recommended algorithms and guidance on selecting the appropriate one.
Algorithm-specific settings (e.g. tolerances, iteration limits, per-method options) are passed to run() alongside method; LocalOptimizer explains how they are forwarded to run().
Restricting the search with bounds#
Design variables usually have a range within which they are physically
meaningful. This range is passed to run() via bounds:
result = optimizer.run(
x0=[6.0, 10.0, 0.30],
method="L-BFGS-B",
bounds=[
(2.0, 12.0), # well_width (nm)
(2.0, 20.0), # barrier_width (nm)
(0.15, 0.45), # alloy_fraction
],
)
Bounds are defined as one (lower, upper) pair per each input variable, in the same order as in variable_names and x0.
The initial point x0 must lie inside bounds.
Bounds are worth setting even when the starting design is a good one. They keep the search inside the range where the structure is manufacturable and where the input file’s materials and meshing remain valid and meaningful. Bounds stop simulations of candidates that cannot be used, e.g. having a negative layer thickness or an alloy fraction being outside the range covered by the material parameters.
Note that not every local algorithm supports bounds; see the page local optimization methods reference for more details.
Inspecting the result#
run() returns SciPy’s OptimizeResult, which contains the outcome of the
search:
print(result.x) # optimized input values
print(result.fun) # metric value at those values
print(result.success) # whether the algorithm reports it converged
print(result.message) # why it stopped
result.xis the answer, i.e. the optimized values. The order of values in theresult.xvector is the same as that ofvariable_names.result.funis the metric value corresponding to those input values.result.successis a boolean value indicating whether the algorithm converged.result.messageis a string explaining why the algorithm stopped. For example, it can indicate that the maximum number of iterations was reached, or that the change in the objective fell below a defined convergence threshold.result.nfevis an integer that counts the number of performed simulations.
You can also inspect the best candidate found during the run, which is stored in
optimizer.champion_x and optimizer.champion_f:
print(optimizer.champion_x) # input values of the best evaluated candidate
print(optimizer.champion_f) # metric value of that candidate
Both are None until run() has evaluated at least one candidate, and each
call to run() resets them, so they always describe the latest run.
champion_x and champion_f hold the best candidate found during the run.
That is normally the same point reported in result.x, but not always. The
exact behavior depends on the optimization algorithm. For example, an algorithm
may evaluate the best point found during the search, later move away from it, and
eventually converge to a different local minimum.
Plotting the progress and results#
You can track the progress of the optimization with run(live_progress=True) and then plot results
at the end of run with plot_all().
Both are described in Plotting the progress and results.
When to use LocalOptimizer#
Local search is the appropriate tool when:
a meaningful starting design (e.g. a working device, or the best point of a coarse scan) is already known,
the goal is to refine that known design rather than to discover a different one,
the objective is reasonably well behaved near the starting region, namely, a small change in the inputs makes a small change in the metric.
Note that the result of the local optimization may be a local minimum and, therefore, repeating the search from a few different starting points might help.
Repeating run() several times from different x0 values and comparing the
result.fun of different runs is the usual tool to overcome restrictions of the local nature of the search.
Each run is independent and writes its own log.
If the above listed conditions are not met, another optimizer fits better. For example, Evolution and BayesianOptimization should be used if
a broad exploration of the parameter space is needed instead of the local refinement,
a sensible starting point is not known,
the objective is not smooth or there are several objectives.
BayesianOptimization might be the best choice when the simulations are time consuming and the number of evaluations is the bottleneck.
For the most complex and difficult problems, ReinforcementLearning is the most powerful tool, but it requires a lot of training data i.e. a lot of simulations.
Additional information#
See LocalOptimizer for the exact argument types, the
validation rules and the attributes of LocalOptimizer.
See Local Optimization for the available local algorithms, and nextnano_optimizers.plotting module for the full set of plotting options.
Examples can be found in Optimizing well width for specific target transition energy in an infinite quantum well and Optimization of a 2DEG structure.