import logging
from collections import namedtuple
import numpy as np
from l2l.utils.tools import cartesian_product
from l2l import DictEntryType
from l2l import dict_to_list
from l2l.optimizers.optimizer import Optimizer
logger = logging.getLogger("optimizers.gridsearch")
GridSearchParameters = namedtuple('GridSearchParameters', ['param_grid'])
GridSearchParameters.__doc__ = """
:param dict param_grid: This is the data structure specifying the grid over which to search. This should be a
dictionary as follows::
optimizee_param_grid['param_name'] = (lower_bound, higher_bound, n_steps)
Where the interval `[lower_bound, upper_bound]` is divided into `n_steps` intervals thereby providing
`n_steps + 1` points for the grid.
Note that there must be as many keys as there are in the `Individual-Dict` returned by the function
:meth:`.Optimizee.create_individual`. Also, if any of the parameters of the individuals is an array, then the above
grid specification applies to each element of the array.
"""
[docs]class GridSearchOptimizer(Optimizer):
"""
This class implements a basic grid search optimizer. It runs the optimizee on a given grid of parameter values and
returns the best fitness found. moreover, this can also simply be used to run a grid search and process the results
stored in the traj in any manner desired.
Notes regarding what it does -
1. This algorithm does not do any kind of adaptive searching and thus the concept of generations does not apply
per se. That said, it is currently implemented as a series of runs in a single generation. All of these runs
are declared in the constructor itself. The :meth:`.Optimizer.post_process()` function simply prints the
individual with the maximal fitness.
2. This algorithm doesnt make use of self.eval_pop and :meth:`.Optimizer._expand_trajectory()` simply because the
cartesian product can be used more efficiently directly. (Imagine having to split a dict of 10000 parameter
combinations into 10000 small `Individual-Dict`s and storing into eval_pop only to join them and call
`traj.f_expand()` in :meth:`.Optimizer._expand_trajectory()`)
:param ~l2l.utils.trajectory.Trajectory traj: Use this trajectory to store the parameters of the specific runs.
The parameters should be initialized based on the values in `parameters`
:param optimizee_create_individual: A function which when called returns one instance of parameter (or "individual")
:param optimizee_fitness_weights: The weights which should be multiplied with the fitness returned from the
:class:`~l2l.optimizees.optimizee.Optimizee` -- one for each element of the fitness (fitness can be
multi-dimensional). If some element is negative, the Optimizer minimizes that element of fitness instead of
maximizing. By default, the `Optimizer` maximizes all fitness dimensions.
:param parameters: An instance of :class:`.GridSearchParameters`
"""
def __init__(self, traj,
optimizee_create_individual,
optimizee_fitness_weights,
parameters,
optimizee_bounding_func=None):
super().__init__(traj, optimizee_create_individual=optimizee_create_individual,
optimizee_fitness_weights=optimizee_fitness_weights, parameters=parameters,
optimizee_bounding_func=optimizee_bounding_func)
self.best_individual = None
self.best_fitness = None
sample_individual = self.optimizee_create_individual()
# Generate parameter dictionary based on optimizee_param_grid
self.param_list = {}
_, optimizee_individual_param_spec = dict_to_list(sample_individual, get_dict_spec=True)
self.optimizee_individual_dict_spec = optimizee_individual_param_spec
optimizee_param_grid = parameters.param_grid
# Assert validity of optimizee_param_grid
assert set(sample_individual.keys()) == set(optimizee_param_grid.keys()), \
"The Parameters of optimizee_param_grid don't match those of the optimizee individual"
for param_name, param_type, param_length in optimizee_individual_param_spec:
param_lower_bound, param_upper_bound, param_n_steps = optimizee_param_grid[param_name]
if param_type == DictEntryType.Scalar:
self.param_list[param_name] = np.linspace(param_lower_bound, param_upper_bound, param_n_steps + 1)
elif param_type == DictEntryType.Sequence:
curr_param_list = np.linspace(param_lower_bound, param_upper_bound, param_n_steps + 1)
curr_param_list = np.meshgrid(*([curr_param_list] * param_length), indexing='ij')
curr_param_list = [x.ravel() for x in curr_param_list]
curr_param_list = np.stack(curr_param_list, axis=-1)
self.param_list[param_name] = curr_param_list
self.size = len(self.param_list[param_name])
self.param_list = cartesian_product(self.param_list, tuple(sorted(optimizee_param_grid.keys())))
# Adding the bounds information to the trajectory
traj.f_add_parameter_group('grid_spec')
for param_name, param_grid_spec in optimizee_param_grid.items():
traj.grid_spec.f_add_parameter(param_name + '.lower_bound', param_grid_spec[0])
traj.grid_spec.f_add_parameter(param_name + '.uper_bound', param_grid_spec[1])
traj.f_add_parameter('n_iteration', 1, comment='Grid search does only 1 iteration')
#: The current generation number
self.g = 0
# Expanding the trajectory
grouped_params_dict = {'individual.' + key: value for key, value in self.param_list.items()}
final_params_dict = {'generation': [self.g],
'ind_idx': range(self.size)}
final_params_dict.update(grouped_params_dict)
traj.f_expand(cartesian_product(final_params_dict,
[('ind_idx',) + tuple(grouped_params_dict.keys()), 'generation']))
#: The population (i.e. list of individuals) to be evaluated at the next iteration
self.eval_pop = None
[docs] def post_process(self, traj, fitnesses_results):
"""
In this optimizer, the post_proces function merely returns the best individual out of the grid and
does not expand the trajectory. It also stores any relevant results
"""
logger.info('Finished Simulation')
logger.info('-------------------')
logger.info('')
run_idx_array = np.array([x[0] for x in fitnesses_results])
fitness_array = np.array([x[1] for x in fitnesses_results])
optimizee_fitness_weights = np.reshape(np.array(self.optimizee_fitness_weights), (-1, 1))
weighted_fitness_array = np.matmul(fitness_array, optimizee_fitness_weights).ravel()
max_fitness_indiv_index = np.argmax(weighted_fitness_array)
logger.info('Storing Results')
logger.info('---------------')
for run_idx, run_fitness, run_weighted_fitness in zip(run_idx_array, fitness_array, weighted_fitness_array):
traj.v_idx = run_idx
traj.f_add_result('$set.$.fitness', np.array(run_fitness))
traj.f_add_result('$set.$.weighted_fitness', run_weighted_fitness)
logger.info('Best Individual is:')
logger.info('')
traj.v_idx = run_idx_array[max_fitness_indiv_index]
individual = traj.individual
self.best_individual = {}
for param_name, _, _ in self.optimizee_individual_dict_spec:
logger.info(' %s: %s', param_name, individual[param_name])
self.best_individual[param_name] = individual[param_name]
self.best_fitness = fitness_array[max_fitness_indiv_index]
logger.info(' with fitness: %s', fitness_array[max_fitness_indiv_index])
logger.info(' with weighted fitness: %s', weighted_fitness_array[max_fitness_indiv_index])
self.g += 1
traj.v_idx = -1
def _expand_trajectory(self, traj):
"""
Add as many explored runs as individuals that need to be evaluated. Furthermore, add the individuals as explored
parameters.
:param ~l2l.utils.trajectory.Trajectory traj: The trajectory that contains the parameters and the
individual that we want to simulate. The individual is accessible using `traj.individual` and parameter e.g.
param1 is accessible using `traj.param1`
:return:
"""
grouped_params_dict = get_grouped_dict(self.eval_pop)
grouped_params_dict = {'individual.' + key: val for key, val in grouped_params_dict.items()}
final_params_dict = {'generation': [self.g],
'ind_idx': range(len(self.eval_pop))}
final_params_dict.update(grouped_params_dict)
# We need to convert them to lists or write our own custom IndividualParameter ;-)
# Note the second argument to `cartesian_product`: This is for only having the cartesian product
# between ``generation x (ind_idx AND individual)``, so that every individual has just one
# unique index within a generation.
traj.f_expand(cartesian_product(final_params_dict,
[('ind_idx',) + tuple(grouped_params_dict.keys()), 'generation']))
[docs] def end(self, traj):
"""
Run any code required to clean-up, print final individuals etc.
"""
traj.f_add_result('final_individual', self.best_individual)
traj.f_add_result('final_fitness', self.best_fitness)
traj.f_add_result('n_iteration', self.g)
logger.info('x -------------------------------- x')
logger.info(' Completed SUCCESSFUL Grid Search ')
logger.info('x -------------------------------- x')