diff --git a/tests/test_plugins/test_design.py b/tests/test_plugins/test_design.py
index 46f4f58ee1..61b72ea865 100644
--- a/tests/test_plugins/test_design.py
+++ b/tests/test_plugins/test_design.py
@@ -13,17 +13,17 @@
SWEEP_METHODS = dict(
grid=tdd.MethodGrid(),
- monte_carlo=tdd.MethodMonteCarlo(num_points=5, rng_seed=1),
- bay_opt=tdd.MethodBayOpt(initial_iter=5, n_iter=2, rng_seed=1),
+ monte_carlo=tdd.MethodMonteCarlo(num_points=5, seed=1),
+ bay_opt=tdd.MethodBayOpt(initial_iter=5, n_iter=2, seed=1),
gen_alg=tdd.MethodGenAlg(
solutions_per_pop=6,
n_generations=2,
n_parents_mating=4,
- rng_seed=1,
+ seed=1,
mutation_prob=0,
keep_parents=0,
),
- part_swarm=tdd.MethodParticleSwarm(n_particles=3, n_iter=2, rng_seed=1),
+ part_swarm=tdd.MethodParticleSwarm(n_particles=3, n_iter=2, seed=1),
)
# Task names that should be produced for the different methods
@@ -245,12 +245,16 @@ def scs_post_list(sim_list):
def scs_pre_dict(radius: float, num_spheres: int, tag: str):
sim = scs_pre(radius, num_spheres, tag)
- return {"test1": sim, "test2": sim, "3": sim}
+ batch = web.Batch(simulations={"a": sim, "b": sim})
+ return {"test1": sim, "test2": sim, "batch1": batch}
def scs_post_dict(sim_dict):
- sim_data = [scs_post(sim) for sim in sim_dict.values()]
- return sum(sim_data)
+ sim_data = [sim_dict["test1"], sim_dict["test2"]]
+ batched_data = [sim for _, sim in sim_dict["batch1"].items()]
+ sim_data.extend(batched_data)
+ post_sim_data = [scs_post(sim) for sim in sim_data]
+ return sum(post_sim_data)
def scs_pre_list_const(radius: float, num_spheres: int, tag: str):
@@ -690,7 +694,7 @@ def test_genalg_early_stop():
n_parents_mating=2,
stop_criteria_type="reach",
stop_criteria_number=1,
- rng_seed=1,
+ seed=1,
)
design_space_pass = init_design_space(gen_alg_pass)
@@ -706,7 +710,7 @@ def test_genalg_early_stop():
n_parents_mating=2,
stop_criteria_type=None,
stop_criteria_number=1,
- rng_seed=1,
+ seed=1,
)
design_space_fail = init_design_space(gen_alg_fail)
@@ -729,7 +733,7 @@ def test_genalg_run_count():
mutation_type="scramble",
crossover_prob=0.6,
crossover_type="uniform",
- rng_seed=1,
+ seed=1,
save_solution=True,
)
design_space = init_design_space(gen_alg)
diff --git a/tidy3d/plugins/design/design.py b/tidy3d/plugins/design/design.py
index e76e2caf71..8716c0f4a7 100644
--- a/tidy3d/plugins/design/design.py
+++ b/tidy3d/plugins/design/design.py
@@ -26,6 +26,27 @@
class DesignSpace(Tidy3dBaseModel):
"""Manages all exploration of a parameter space within specified parameters using a supplied search method.
+ The ``DesignSpace`` forms the basis of the ``Design`` plugin, and receives a ``Method`` and ``Parameter`` list that
+ define the scope of the design space and how it should be searched. ``DesignSpace.run()`` can then be called with
+ a function(s) to generate different solutions from parameters suggested by the ``Method``. The ``Method`` can either
+ sample the design space systematically or randomly, or can optimize for a given problem through an iterative search
+ and evaluate approach.
+
+ Schematic outline of how to use the ``Design`` plugin to explore a design space.
+ .. image:: ../../_static/img/design.png
+ :width: 50%
+ :align: left
+
+ The `Design '_ notebook contains an overview of the
+ ``Design`` plugin and is the best place to learn how to get started.
+
+ Detailed examples using the ``Design`` plugin can be found in the following notebooks:
+ `All-Dielectric Structural Colors '_
+ `Bayesian Optimization of Y-Junction '_
+ `Genetic Algorithm Reflector '_
+ `Particle Swarm Optimizer PBS '_
+ `Particle Swarm Optimizer Bullseye Cavity '_
+
Example
-------
>>> import tidy3d.plugins.design as tdd
@@ -55,9 +76,9 @@ class DesignSpace(Tidy3dBaseModel):
task_name: str = pd.Field(
"",
title="Task Name",
- description="Task name assigned to tasks along with a simulation counter in the form of {task_name}_{counter}. \
- If the pre function outputs a dictionary the key will be included in the task name as {task_name}_{dict_key}_{counter}. \
- Only used when pre-post functions are supplied.",
+ description="Task name assigned to tasks along with a simulation counter in the form of {task_name}_{counter}. "
+ "If the pre function outputs a dictionary the key will be included in the task name as {task_name}_{dict_key}_{counter}. "
+ "Only used when pre-post functions are supplied.",
)
name: str = pd.Field(None, title="Name", description="Optional name for the design space.")
@@ -65,7 +86,7 @@ class DesignSpace(Tidy3dBaseModel):
path_dir: str = pd.Field(
".",
title="Path Directory",
- description="Directory where simulation data files will be locally saved to. Only used when pre-post functions are supplied.",
+ description="Directory where simulation data files will be locally saved to. Only used when pre and post functions are supplied.",
)
folder_name: str = pd.Field(
@@ -132,7 +153,20 @@ def run(self, fn: Callable, fn_post: Callable = None, verbose: bool = True) -> R
If used as a pre function, the output of ``fn`` must be a float, ``Simulation``, ``Batch``, list, or dict. Supplied ``Batch`` objects are
run without modification and are run in series. A list or dict of ``Simulation`` objects is flattened into a single ``Batch`` to enable
- parallel computation on the cloud. The original structure is then restored for output; all `Simulation`` objects are replaced by ``SimulationData`` objects.
+ parallel computation on the cloud. The original structure is then restored for output; all ``Simulation`` objects are replaced by ``SimulationData`` objects.
+ Example pre return formats and associated post inputs can be seen in the table below.
+
+ | fn_pre return | fn_post call |
+ |-------------------------------------------|---------------------------------------------------|
+ | 1.0 | fn_post(1.0) |
+ | [1,2,3] | fn_post(1,2,3) |
+ | {'a': 2, 'b': 'hi'} | fn_post(a=2, b='hi') |
+ | Simulation | fn_post(SimulationData) |
+ | Batch | fn_post(BatchData) |
+ | [Simulation, Simulation] | fn_post(SimulationData, SimulationData) |
+ | [Simulation, 1.0] | fn_post(SimulationData, 1.0) |
+ | [Simulation, Batch] | fn_post(SimulationData, BatchData) |
+ | {'a': Simulation, 'b': Batch, 'c': 2.0} | fn_post(a=SimulationData, b=BatchData, c=2.0) |
The output of ``fn_post`` (or ``fn`` if only one function is supplied) must be a float
or a container where the first element is a ``float`` and second element is a ``list`` / ``dict`` e,g. [float {"aux_1": str}].
@@ -175,7 +209,7 @@ def run(self, fn: Callable, fn_post: Callable = None, verbose: bool = True) -> R
else:
fn_args, fn_values, aux_values, opt_output, sim_names, sim_paths = self.run_pre_post(
- fn_pre=fn, fn_post=fn_post, console=console, verbose=verbose
+ fn_pre=fn, fn_post=fn_post, console=console
)
if len(sim_names) == 0:
@@ -199,12 +233,12 @@ def run_single(self, fn: Callable, console: Console) -> Tuple(list[dict], list,
evaluate_fn = self._get_evaluate_fn_single(fn=fn)
return self.method._run(run_fn=evaluate_fn, parameters=self.parameters, console=console)
- def run_pre_post(
- self, fn_pre: Callable, fn_post: Callable, console: Console, verbose: bool
- ) -> Tuple(list[dict], list[dict], list[Any]):
+ def run_pre_post(self, fn_pre: Callable, fn_post: Callable, console: Console) -> Tuple(
+ list[dict], list[dict], list[Any]
+ ):
"""Run a function with Tidy3D implicitly called in between."""
handler = self._get_evaluate_fn_pre_post(
- fn_pre=fn_pre, fn_post=fn_post, fn_mid=self._fn_mid, verbose=verbose
+ fn_pre=fn_pre, fn_post=fn_post, fn_mid=self._fn_mid, console=console
)
fn_args, fn_values, aux_values, opt_output = self.method._run(
run_fn=handler.fn_combined, parameters=self.parameters, console=console
@@ -223,16 +257,16 @@ def evaluate(args_list: list) -> list[Any]:
return evaluate
def _get_evaluate_fn_pre_post(
- self, fn_pre: Callable, fn_post: Callable, fn_mid: Callable, verbose: bool
+ self, fn_pre: Callable, fn_post: Callable, fn_mid: Callable, console: Console
):
"""Get function that tries to use batch processing on a set of arguments."""
class Pre_Post_Handler:
- def __init__(self, verbose):
+ def __init__(self, console):
self.sim_counter = 0
self.sim_names = []
self.sim_paths = []
- self.verbose = verbose
+ self.console = console
def fn_combined(self, args_list: list[dict[str, Any]]) -> list[Any]:
"""Compute fn_pre and fn_post functions and capture other outputs."""
@@ -247,7 +281,7 @@ def fn_combined(self, args_list: list[dict[str, Any]]) -> list[Any]:
)
data, task_names, task_paths, sim_counter = fn_mid(
- sim_dict, self.sim_counter, self.verbose
+ sim_dict, self.sim_counter, self.console
)
self.sim_names.extend(task_names)
self.sim_paths.extend(task_paths)
@@ -255,12 +289,12 @@ def fn_combined(self, args_list: list[dict[str, Any]]) -> list[Any]:
post_out = [fn_post(val) for val in data.values()]
return post_out
- handler = Pre_Post_Handler(verbose)
+ handler = Pre_Post_Handler(console)
return handler
def _fn_mid(
- self, pre_out: dict[int, Any], sim_counter: int, verbose: bool
+ self, pre_out: dict[int, Any], sim_counter: int, console: Console
) -> Union[dict[int, Any], BatchData]:
"""A function of the output of ``fn_pre`` that gives the input to ``fn_post``."""
@@ -324,12 +358,21 @@ def _find_and_map(
translate_sims[sim_name] = sim_key
sim_counter += 1
+ # Log the simulations and batches for the user
+ if console is not None:
+ # Writen like this to include batches on the same line if present
+ run_statement = f"{len(named_sims)} Simulations"
+ if len(batches) > 0:
+ run_statement = run_statement + f" and {len(batches)} user Batches"
+
+ console.log(f"Running {run_statement}")
+
# Running simulations and batches
sims_out = Batch(
simulations=named_sims,
folder_name=self.folder_name,
simulation_type="tidy3d_design",
- verbose=verbose,
+ verbose=False, # Using a custom output instead of Batch.monitor updates
).run(path_dir=self.path_dir)
batch_results = {}
@@ -337,7 +380,7 @@ def _find_and_map(
batch_out = batch.run(path_dir=self.path_dir)
batch_results[batch_key] = batch_out
- def _return_to_dict(return_dict, key, return_obj):
+ def _return_to_dict(return_dict: dict, key: str, return_obj: Any) -> None:
"""Recursively insert items into a dict by keys split with underscore. Only works for dict or dict of dict inputs."""
split_key = key.split("_", 1)
if len(split_key) > 1:
@@ -356,7 +399,7 @@ def _return_to_dict(return_dict, key, return_obj):
for batch_name, batch in batch_results.items():
_return_to_dict(pre_out, batch_name, batch)
- def _remove_or_replace(search_dict, attr_name):
+ def _remove_or_replace(search_dict: dict, attr_name: str) -> dict:
"""Recursively search through a dict replacing Sims and Batches or ignoring other items thus removing them"""
new_dict = {}
for key, value in search_dict.items():
@@ -398,7 +441,7 @@ def run_batch(
fn_post: Callable[
Union[SimulationData, List[SimulationData], Dict[str, SimulationData]], Any
],
- path_dir: str = None,
+ path_dir: str = ".",
**batch_kwargs,
) -> Result:
"""
@@ -495,7 +538,7 @@ def _estimate_sim_cost(sim):
raise ValueError("Unrecognized output from pre-function, unable to estimate cost.")
# Calculate maximum number of runs for different methods
- run_count = self.method.get_run_count(self.parameters)
+ run_count = self.method._get_run_count(self.parameters)
# For if tidy3d server cannot determine the estimate
if per_run_estimate is None:
@@ -503,7 +546,7 @@ def _estimate_sim_cost(sim):
else:
return round(per_run_estimate * run_count, 3)
- def summarize(self, fn_pre: Callable = None) -> dict[str, Any]:
+ def summarize(self, fn_pre: Callable = None, verbose: bool = True) -> dict[str, Any]:
"""Summarize the setup of the DesignSpace
Prints a summary of the DesignSpace including the method and associated args, the parameters,
@@ -516,6 +559,8 @@ def summarize(self, fn_pre: Callable = None) -> dict[str, Any]:
Function accepting arguments that correspond to the ``name`` fields
of the ``DesignSpace.parameters``. Allows for estimated cost to be included
in the summary.
+ verbose: bool = True
+ Toggle if the summary should be output to log. If False, the dict is returned silently.
Returns
-------
@@ -541,7 +586,7 @@ def summarize(self, fn_pre: Callable = None) -> dict[str, Any]:
else:
param_values.append(f"{param.name}: {param.type} {param.span}\n")
- run_count = self.method.get_run_count(self.parameters)
+ run_count = self.method._get_run_count(self.parameters)
# Compile data into a dict for return
summary_dict = {
@@ -553,22 +598,23 @@ def summarize(self, fn_pre: Callable = None) -> dict[str, Any]:
"max_run_count": run_count,
}
- console.log(
- "\nSummary of DesignSpace\n\n"
- f"Method: {summary_dict['method']}\n"
- f"Method Args\n{summary_dict['method_args']}\n"
- f"No. of Parameters: {summary_dict['param_count']}\n"
- f"Parameters: {summary_dict['param_names']}\n"
- f"{summary_dict['param_vals']}\n"
- f"Maximum Run Count: {summary_dict['max_run_count']}\n"
- )
+ if verbose:
+ console.log(
+ "\nSummary of DesignSpace\n\n"
+ f"Method: {summary_dict['method']}\n"
+ f"Method Args\n{summary_dict['method_args']}\n"
+ f"No. of Parameters: {summary_dict['param_count']}\n"
+ f"Parameters: {summary_dict['param_names']}\n"
+ f"{summary_dict['param_vals']}\n"
+ f"Maximum Run Count: {summary_dict['max_run_count']}\n"
+ )
- if fn_pre is not None:
- cost_estimate = self.estimate_cost(fn_pre)
- summary_dict["cost_estimate"] = cost_estimate
- console.log(f"Estimated Maximum Cost: {cost_estimate} FlexCredits")
+ if fn_pre is not None:
+ cost_estimate = self.estimate_cost(fn_pre)
+ summary_dict["cost_estimate"] = cost_estimate
+ console.log(f"Estimated Maximum Cost: {cost_estimate} FlexCredits")
- # NOTE: Could then add more details regarding the output of fn_pre - confirm batching?
+ # NOTE: Could then add more details regarding the output of fn_pre - confirm batching?
# Include additional notes/warnings
notes = []
@@ -591,7 +637,7 @@ def summarize(self, fn_pre: Callable = None) -> dict[str, Any]:
"Discrete 'int' values are automatically rounded if optimizers generate 'float' predictions.\n"
)
- if len(notes) > 0:
+ if len(notes) > 0 and verbose:
console.log(
"Notes:",
)
diff --git a/tidy3d/plugins/design/method.py b/tidy3d/plugins/design/method.py
index 4535adabc8..81ac9d7636 100644
--- a/tidy3d/plugins/design/method.py
+++ b/tidy3d/plugins/design/method.py
@@ -24,7 +24,7 @@ def _run(self, parameters: Tuple[ParameterType, ...], run_fn: Callable) -> Tuple
"""Defines the search algorithm."""
@abstractmethod
- def get_run_count(self, parameters: list = None) -> int:
+ def _get_run_count(self, parameters: list = None) -> int:
"""Return the maximum number of runs for the method based on current method arguments."""
def _force_int(self, next_point: dict, parameters: list) -> None:
@@ -45,7 +45,7 @@ def _extract_output(output: list, sampler: bool = False) -> Tuple:
if not all(isinstance(val, type(output[0])) for val in output):
raise ValueError(
"Unrecognized output from supplied post function. The type of output varies across the output."
- "Use of multiple return functions in fn_post is discouraged."
+ "Use of multiple return functions in 'fn_post' is discouraged."
"If this is a problem please raise an issue on the Tidy3D Github page."
)
@@ -64,7 +64,7 @@ def _extract_output(output: list, sampler: bool = False) -> Tuple:
for val in output:
if len(val) > 2:
raise ValueError(
- "Unrecognized output from supplied post function. Too many elements in the return object, it should be a float and a list/tuple/dict."
+ "Unrecognized output from supplied post function. Too many elements in the return object, it should be a 'float' and a 'list'/'tuple'/'dict'."
)
float_out.append(val[0])
@@ -75,12 +75,12 @@ def _extract_output(output: list, sampler: bool = False) -> Tuple:
else:
raise ValueError(
- "Unrecognized output from supplied post function. The first element in the iterable object should be a float."
+ "Unrecognized output from supplied post function. The first element in the iterable object should be a 'float'."
)
else:
raise ValueError(
- "Unrecognized output from supplied post function. Output should be a float or an iterable object."
+ "Unrecognized output from supplied post function. Output should be a 'float' or an iterable object."
)
@staticmethod
@@ -126,8 +126,8 @@ class MethodGrid(MethodSample):
"""Select parameters uniformly on a grid.
Size of the grid is specified by the parameter type,
- either as the number of unique discrete values (ParameterInt, ParameterAny)
- or with the num_points argument (ParameterFloat).
+ either as the number of unique discrete values (``ParameterInt``, ``ParameterAny``)
+ or with the num_points argument (``ParameterFloat``).
Example
-------
@@ -135,7 +135,8 @@ class MethodGrid(MethodSample):
>>> method = tdd.MethodGrid()
"""
- def get_run_count(self, parameters: list) -> int:
+ def _get_run_count(self, parameters: list) -> int:
+ """Return the maximum number of runs for the method based on current method arguments."""
return len(self.sample(parameters))
@staticmethod
@@ -161,13 +162,13 @@ class MethodOptimize(Method, ABC):
"""A method for handling design searches that optimize the design"""
# NOTE: We could move this to the Method base class but it's not relevant to MethodGrid
- rng_seed: pd.PositiveInt = pd.Field(
+ seed: pd.PositiveInt = pd.Field(
default=None,
title="Seed for random number generation",
description="Set the seed used by the optimizers to ensure consistant random number generation.",
)
- def any_to_int_param(self, parameter):
+ def any_to_int_param(self, parameter: ParameterAny) -> dict:
"""Convert ParameterAny object to integers and provide a conversion dict to return"""
return dict(enumerate(parameter.allowed_values))
@@ -191,62 +192,64 @@ def _handle_param_convert(self, param_converter: dict, sol_dict_list: list[dict]
class MethodBayOpt(MethodOptimize, ABC):
- """A standard method for performing a bayesian optimization search.
+ """A standard method for performing a Bayesian optimization search.
The fitness function is maximising by default.
"""
initial_iter: pd.PositiveInt = pd.Field(
...,
- title="Number of initial random search iterations",
+ title="Number of Initial Random Search Iterations",
description="The number of search runs to be done initialially with parameter values picked randomly. This provides a starting point for the Gaussian processor to optimize from. These solutions can be computed as a single ``Batch`` if the pre function generates ``Simulation`` objects.",
)
n_iter: pd.PositiveInt = pd.Field(
...,
- title="Number of bayesian optimization iterations",
+ title="Number of Bayesian Optimization Iterations",
description="Following the initial search, this is number of iterations the Gaussian processor should be sequentially called to suggest parameter values and register the results.",
)
acq_func: Literal["ucb", "ei", "poi"] = pd.Field(
default="ucb",
- title="Type of acquisition function",
- description="The type of acquisition function that should be used to suggest parameter values. More detail available `here `_",
+ title="Type of Acquisition Function",
+ description="The type of acquisition function that should be used to suggest parameter values. More detail available `here `_.",
)
kappa: pd.PositiveFloat = pd.Field(
default=2.5,
title="Kappa",
- description="The kappa coefficient used by the ``ucb`` acquisition function. More detail available `here `_",
+ description="The kappa coefficient used by the ``ucb`` acquisition function. More detail available `here `_.",
)
xi: pd.NonNegativeFloat = pd.Field(
default=0.0,
title="Xi",
- description="The Xi coefficient used by the ``ei`` and ``poi`` acquisition functions. More detail available `here `_",
+ description="The Xi coefficient used by the ``ei`` and ``poi`` acquisition functions. More detail available `here `_.",
)
- def get_run_count(self, parameters: list = None) -> int:
+ def _get_run_count(self, parameters: list = None) -> int:
+ """Return the maximum number of runs for the method based on current method arguments."""
return self.initial_iter + self.n_iter
def _run(self, parameters: Tuple[ParameterType, ...], run_fn: Callable, console) -> Tuple[Any]:
- """Defines the bayesian optimization search algorithm for the method.
+ """Defines the Bayesian optimization search algorithm for the method.
Uses the ``bayes_opt`` package to carry out a Bayesian optimization. Utilizes the ``.suggest`` and ``.register`` methods instead of
the ``BayesianOptimization`` helper class as this allows more control over batching and preprocessing.
- More details of the package can be found `here '_
+ More details of the package can be found `here '_.
"""
try:
from bayes_opt import BayesianOptimization, UtilityFunction
except ImportError:
raise ImportError(
- "Cannot run bayesian optimization as 'bayes_opt' module not found. Please check installation or run 'pip install bayesian-optimization'."
+ "Cannot run Bayesian optimization as 'bayes_opt' module not found. Please check installation or run 'pip install bayesian-optimization'."
)
+ # Identify non-numeric params and define boundaries for Bay-opt
param_converter = {}
boundary_dict = {}
for param in parameters:
- if type(param) == ParameterAny:
+ if isinstance(param, ParameterAny):
param_converter[param.name] = self.any_to_int_param(param)
boundary_dict[param.name] = (
0,
@@ -261,13 +264,13 @@ def _run(self, parameters: Tuple[ParameterType, ...], run_fn: Callable, console)
for param_name, param_values in param_converter.items()
}
- # Fn can be defined here to be a combined func of pre, run_batch, post for BO to use
+ # Initialize optimizer and utility function. Carry out optimization manually instead of using helper for more control
utility = UtilityFunction(kind=self.acq_func, kappa=self.kappa, xi=self.xi)
opt = BayesianOptimization(
- f=run_fn, pbounds=boundary_dict, random_state=self.rng_seed, allow_duplicate_points=True
+ f=run_fn, pbounds=boundary_dict, random_state=self.seed, allow_duplicate_points=True
)
- # Run variables
+ # Build the initial starting set of solutions - randomly chosen and batched together
arg_list = []
total_aux_out = []
result = []
@@ -277,19 +280,28 @@ def _run(self, parameters: Tuple[ParameterType, ...], run_fn: Callable, console)
self._handle_param_convert(param_converter, [next_point])
arg_list.append(next_point)
+ # Compute batch
init_output, aux_out = self._extract_output(run_fn(arg_list))
self._flatten_and_append(aux_out, total_aux_out)
+ # Update the sampler with results from random initial solutions
for next_point, next_out in zip(arg_list, init_output):
result.append(next_out)
self._handle_param_convert(invert_param_converter, [next_point])
opt.register(params=next_point, target=next_out)
- # Handle subsequent iterations sequentially as BayOpt package does not allow for batched non-random predictions
- for _ in range(self.n_iter):
+ best_fit = max(init_output)
+
+ if console is not None:
+ console.log(f"Best Fit from Initial Solutions: {round(best_fit, 3)}\n")
+
+ # Handle further iterations sequentially
+ # BayOpt package does not allow for batched non-random predictions
+ for iter_num in range(self.n_iter):
next_point = opt.suggest(utility)
self._force_int(next_point, parameters)
self._handle_param_convert(param_converter, [next_point])
+
next_out, aux_out = self._extract_output(run_fn([next_point]))
result.append(next_out[0])
self._flatten_and_append(aux_out, total_aux_out)
@@ -298,6 +310,11 @@ def _run(self, parameters: Tuple[ParameterType, ...], run_fn: Callable, console)
self._handle_param_convert(invert_param_converter, [next_point])
opt.register(params=next_point, target=next_out[0])
+ if next_out[0] > best_fit:
+ best_fit = next_out[0]
+ if console is not None:
+ console.log(f"Latest Best Fit on Iter {iter_num}: {round(best_fit, 3)}\n")
+
if console is not None:
console.log(
f"Best Result: {opt.max['target']}\n"
@@ -344,7 +361,7 @@ class MethodGenAlg(MethodOptimize, ABC):
description="Define the early stopping criteria. Supported words are 'reach' or 'saturate'. 'reach' stops at a desired fitness, 'saturate' stops when the fitness stops improving. Must set ``stop_criteria_number``. See PyGAD docs https://pygad.readthedocs.io/en/latest/pygad.html for more details.",
)
- stop_criteria_number: pd.PositiveInt = pd.Field(
+ stop_criteria_number: pd.PositiveFloat = pd.Field(
default=None,
title="Early Stopping Criteria Number",
description="Must set ``stop_criteria_type``. If type is 'reach' the number is acceptable fitness value to stop the optimization. If type is 'saturate' the number is the number generations where the fitness doesn't improve before optimization is stopped. See PyGAD docs https://pygad.readthedocs.io/en/latest/pygad.html for more details.",
@@ -404,7 +421,8 @@ class MethodGenAlg(MethodOptimize, ABC):
# TODO: See if anyone is interested in having the full suite of PyGAD options - there's a lot!
- def get_run_count(self, parameters: list = None) -> int:
+ def _get_run_count(self, parameters: list = None) -> int:
+ """Return the maximum number of runs for the method based on current method arguments."""
# +1 to generations as pygad creates an initial population which is effectively "Generation 0"
run_count = self.solutions_per_pop * (self.n_generations + 1)
return run_count
@@ -414,7 +432,7 @@ def _run(self, parameters: Tuple[ParameterType, ...], run_fn: Callable, console)
Uses the ``pygad`` package to carry out a particle search optimization. Additional development has ensured that
previously suggested solutions are not repeatedly computed, and that all computed solutions are captured.
- More details of the package can be found `here '_
+ More details of the package can be found `here '_.
"""
try:
import pygad
@@ -437,10 +455,10 @@ def _run(self, parameters: Tuple[ParameterType, ...], run_fn: Callable, console)
gene_spaces = []
gene_types = []
for param in parameters:
- if type(param) == ParameterFloat:
+ if isinstance(param, ParameterFloat):
gene_spaces.append({"low": param.span[0], "high": param.span[1]})
gene_types.append(float)
- elif type(param) == ParameterInt:
+ elif isinstance(param, ParameterInt):
gene_spaces.append(
range(param.span[0], param.span[1] + 1)
) # +1 included so as to be inclusive of upper range value
@@ -452,7 +470,7 @@ def _run(self, parameters: Tuple[ParameterType, ...], run_fn: Callable, console)
gene_spaces.append(range(0, len(param.allowed_values)))
gene_types.append(int)
- def capture_aux(sol_dict_list):
+ def capture_aux(sol_dict_list: list[dict]) -> None:
"""Store the aux data by pulling from previous_solutions."""
aux_out = []
for sol in sol_dict_list:
@@ -463,7 +481,8 @@ def capture_aux(sol_dict_list):
self._flatten_and_append(aux_out, store_aux)
# Create fitness function combining pre and post fn with the tidy3d call
- def fitness_function(ga_instance, solution, solution_idx):
+ def fitness_function(ga_instance: pygad.GA, solution: np.array, solution_idx) -> dict:
+ """Fitness function for GA. Format of inputs cannot be changed."""
# Break solution down to list of dict
sol_dict_list = self.sol_array_to_dict(solution, param_keys, param_converter)
@@ -505,7 +524,8 @@ def fitness_function(ga_instance, solution, solution_idx):
return sol_out
- def on_generation(ga_instance):
+ def on_generation(ga_instance: pygad.GA) -> None:
+ """Additional code run after every generation. Format of input cannot be changed"""
sol_dict_list = self.sol_array_to_dict(
ga_instance.population.copy(), param_keys, param_converter
)
@@ -521,13 +541,13 @@ def on_generation(ga_instance):
f"Generation {ga_instance.generations_completed} Best Fitness: {best_fitness:.3f}"
)
- # Build stop criteria string
+ # Build stop criteria string - check if both stop criteria fields have been set
if any(val is not None for val in (self.stop_criteria_type, self.stop_criteria_number)):
if all(val is not None for val in (self.stop_criteria_type, self.stop_criteria_number)):
stop_criteria = f"{self.stop_criteria_type}_{self.stop_criteria_number}"
else:
raise ValueError(
- "Both ``stop_criteria_type`` and ``stop_criteria_number`` fields need to be set to define the GA stop criteria."
+ "Both 'stop_criteria_type' and 'stop_criteria_number' fields need to be set to define the GA stop criteria."
)
else:
stop_criteria = None
@@ -538,7 +558,11 @@ def on_generation(ga_instance):
# PyGAD doesn't store the initial population fitness - this captures parameters, fitness and aux data
init_state = []
- def capture_init_pop_fitness(ga_instance, population_fitness):
+ def capture_init_pop_fitness(ga_instance: pygad.GA, population_fitness) -> None:
+ """Store the initial population fitness which PyGAD otherwise ignores
+
+ Has to be run ``on_fitness`` but contains a check so that it only runs on the first pass
+ """
# Have to check len of list instead of bool as can't pass any args into this func, or capture a return
if not len(init_state):
sol_dict_list = self.sol_array_to_dict(
@@ -566,11 +590,12 @@ def capture_init_pop_fitness(ga_instance, population_fitness):
num_genes=num_genes,
fitness_batch_size=self.solutions_per_pop,
on_generation=on_generation,
- random_seed=self.rng_seed,
+ random_seed=self.seed,
gene_space=gene_spaces,
gene_type=gene_types,
stop_criteria=stop_criteria,
save_solutions=self.save_solution,
+ suppress_warnings=True, # Used to prevent delay_on_generation depreciation warning for PyGAD 3.3.0
)
ga_instance.run()
@@ -633,24 +658,25 @@ class MethodParticleSwarm(MethodOptimize, ABC):
ftol_iter: pd.PositiveInt = pd.Field(
default=1,
- title="Number of iterations before acceptable convergence",
+ title="Number of Iterations Before Convergence",
description="Number of iterations over which the relative error in the objective_func is acceptable for convergence.",
)
init_pos: np.ndarray = pd.Field(
default=None,
- title="Initial swarm positions",
+ title="Initial Swarm Positions",
description="Set the initial positions of the swarm using a numpy array of appropriate size.",
)
- def get_run_count(self, parameters: list = None) -> int:
+ def _get_run_count(self, parameters: list = None) -> int:
+ """Return the maximum number of runs for the method based on current method arguments."""
return self.n_particles * self.n_iter
def _run(self, parameters: Tuple[ParameterType, ...], run_fn: Callable, console) -> Tuple[Any]:
"""Defines the particle search optimization algorithm for the method.
Uses the ``pyswarms`` package to carry out a particle search optimization.
- More details of the package can be found `here '_
+ More details of the package can be found `here '_.
"""
try:
from pyswarms.single.global_best import GlobalBestPSO
@@ -660,8 +686,8 @@ def _run(self, parameters: Tuple[ParameterType, ...], run_fn: Callable, console)
)
# Pyswarms doesn't have a seed set outside of numpy std method
- if self.rng_seed is not None:
- np.random.seed(self.rng_seed)
+ if self.seed is not None:
+ np.random.seed(self.seed)
# Variable assignment here so it is available to the fitness function
param_keys = [param.name for param in parameters]
@@ -685,7 +711,8 @@ def _run(self, parameters: Tuple[ParameterType, ...], run_fn: Callable, console)
bounds = (min_bound, max_bound)
- def fitness_function(solution):
+ def fitness_function(solution: np.array) -> np.array:
+ """Fitness function for PSO. Input format cannot be changed"""
# Correct solutions that should be ints
sol_dict_list = self.sol_array_to_dict(solution, param_keys, param_converter)
for arg_dict in sol_dict_list:
@@ -715,11 +742,11 @@ def fitness_function(solution):
# TODO: including oh_strategy would be nice but complicated to specify with pydantic oh_strategy={"w": "exp_decay"},
)
- opt_out = optimizer.optimize(fitness_function, self.n_iter, verbose=False)
+ opt_out = optimizer.optimize(fitness_function, self.n_iter, verbose=True)
if console is not None:
console.log(
- f"Best Result = {opt_out[0]}\n"
+ f"Best Result: {opt_out[0]}\n"
f"Best Parameters: {' '.join([f'{param.name}: {value}' for param, value in zip(parameters, opt_out[1])])}\n"
)
@@ -739,23 +766,24 @@ class AbstractMethodRandom(MethodSample, ABC):
description="The number of points to be generated for sampling.",
)
- rng_seed: pd.PositiveInt = pd.Field(
+ seed: pd.PositiveInt = pd.Field(
default=None,
title="Seed",
description="Sets the seed used by the optimizers to set constant random number generation.",
)
@abstractmethod
- def get_sampler(self, parameters: Tuple[ParameterType, ...]) -> qmc.QMCEngine:
+ def _get_sampler(self, parameters: Tuple[ParameterType, ...]) -> qmc.QMCEngine:
"""Sampler for this ``Method`` class. If ``None``, sets a default."""
- def get_run_count(self, parameters: list = None) -> int:
+ def _get_run_count(self, parameters: list = None) -> int:
+ """Return the maximum number of runs for the method based on current method arguments."""
return self.num_points
def sample(self, parameters: Tuple[ParameterType, ...], **kwargs) -> Dict[str, Any]:
"""Defines how the design parameters are sampled on grid."""
- sampler = self.get_sampler(parameters)
+ sampler = self._get_sampler(parameters)
pts_01 = sampler.random(self.num_points)
# Convert value from 0-1 to fit within the parameter spans
@@ -774,7 +802,6 @@ def sample(self, parameters: Tuple[ParameterType, ...], **kwargs) -> Dict[str, A
class MethodMonteCarlo(AbstractMethodRandom):
"""Select sampling points using Monte Carlo sampling.
-
The sampling is done with the Latin Hypercube method from scipy.
Example
@@ -783,11 +810,11 @@ class MethodMonteCarlo(AbstractMethodRandom):
>>> method = tdd.MethodMonteCarlo(num_points=20)
"""
- def get_sampler(self, parameters: Tuple[ParameterType, ...]) -> qmc.QMCEngine:
+ def _get_sampler(self, parameters: Tuple[ParameterType, ...]) -> qmc.QMCEngine:
"""Sampler for this ``Method`` class."""
d = len(parameters)
- return DEFAULT_MONTE_CARLO_SAMPLER_TYPE(d=d, seed=self.rng_seed)
+ return DEFAULT_MONTE_CARLO_SAMPLER_TYPE(d=d, seed=self.seed)
MethodType = Union[
diff --git a/tidy3d/plugins/design/parameter.py b/tidy3d/plugins/design/parameter.py
index 3bf1bdad4d..77e5cf3c70 100644
--- a/tidy3d/plugins/design/parameter.py
+++ b/tidy3d/plugins/design/parameter.py
@@ -62,7 +62,7 @@ class ParameterNumeric(Parameter, ABC):
span: Tuple[Union[float, int], Union[float, int]] = pd.Field(
...,
title="Span",
- description="(min, max) inclusive range within which are allowed values for the variable.",
+ description="(min, max) range within which are allowed values for the variable. Is inclusive of max value.",
)
@pd.validator("span", always=True)
@@ -83,6 +83,7 @@ def span_size(self):
return span_max - span_min
def sample_first(self) -> tuple:
+ """Output the first allowed sample."""
return self.span[0]
@@ -211,6 +212,7 @@ def select_from_01(self, pts_01: np.ndarray) -> List[Any]:
return np.array(self.allowed_values)[indices].tolist()
def sample_first(self) -> Any:
+ """Output the first allowed sample."""
return self.allowed_values[0]
diff --git a/tidy3d/plugins/design/result.py b/tidy3d/plugins/design/result.py
index 84574fddd2..fac840f442 100644
--- a/tidy3d/plugins/design/result.py
+++ b/tidy3d/plugins/design/result.py
@@ -106,7 +106,7 @@ def _coords_and_dims_shape(cls, val, values):
if len(_val) != num_dims:
raise ValueError(
f"Number of 'coords' at index '{i}' ({len(_val)}) "
- "doesn't match the number of 'dims' ({num_dims})."
+ f"doesn't match the number of 'dims' ({num_dims})."
)
return val